Backend
Overview
Section titled “Overview”The Logrite Backend is a Spring Boot 3 REST API that serves as the central API gateway, handles authentication, manages workspaces and compliance scans, and integrates with OpenSearch.
| Setting | Value |
|---|---|
| Technology | Java 17 + Spring Boot 3 + Maven |
| Port | 8080 in the container, published on host 9800 (leapfrogapi.deployd.io / dev-leapfrogapi.logrite.ai) |
| Repo | Deployd-io/Logrite-AI |
| Runs as | The backend compose service on each VM |
API Endpoints
Section titled “API Endpoints”⚠️ Corrected 2026-08-27 against the frozen contract (
src/test/resources/contract/api-surface.txt). Five rows in this table named endpoints that have never existed:/api/v1/scans(all four verbs) and/api/v1/workspaces. The real resources are singular or hyphenated —/api/v1/scan/initiate,/api/v1/scan-results/...,/api/v1/scan-runs/...,/api/v1/auth/workspace— and there is no delete-scan endpoint at all./api/v1/auth/registerand/api/v1/auth/verify-emailwere wrong the same way. The same mistake is recorded inai-agents/agents-flow.mdx(v1f), where a chat tool 404’d against/api/v1/scans/{id}for exactly this reason. Check this file againstapi-surface.txtbefore trusting a row.
| Method | Path | Description |
|---|---|---|
POST | /api/v1/auth/login | Authenticate user, return JWT |
POST | /api/v1/auth/signup | Create the user and its workspace and return a session immediately (data.token) — no email, no verification step (AuthSignupFlows.register). The app uses it only to finish signup after verification (create-workspace step). The method is register, which is where the long-standing /auth/register mistake in this table came from. |
POST | /api/v1/auth/pre-register | The app’s sign-up form: {email, password, firstName, lastName?} stores an Inactive account and mails a 4-digit code (10-minute expiry) plus a magic link |
POST | /api/v1/auth/verify-otp | {email, otp} → verifies the account and returns a session. (/auth/verify-magic {token} does the same for the emailed link.) |
POST | /api/v1/auth/refresh | Issue a fresh JWT from a valid or recently-expired token (5-minute grace) |
POST | /api/v1/invite | Invite team members — { workSpaceName, userEmail: [{userEmail, role}] }. Maintainer or above, and only for roles strictly below the caller’s own; 400 on unknown workspace or invalid role. |
GET | /api/v1/workspace/{id}/members | List members + pending invitations (each row has status = ACTIVE/INVITED and, for invites, invitationId) |
PUT | /api/v1/workspace/{id}/members/{userId} | Change a member’s role — Owner or Admin only ("Only an Owner or Admin can change member roles"), new role ∈ Admin/Maintainer/Developer/Viewer and strictly below the caller’s; the Owner’s row cannot be changed |
GET | /api/v1/project/{projectId}/templates | The project’s framework templates (templateId, name, key, logType, ruleCount) and addable (framework keys with global rules the project lacks). JWT or API key; project access required. See Project templates API |
POST | /api/v1/project/{projectId}/templates | {"frameworks": [...]} (1–20 names, each ≤64 chars) → {added, existing, unknown, addable}; never fails part-way. Needs the policy-code-write role floor |
DELETE | /api/v1/project/{projectId}/templates/{templateId} | 204, or 404 template_not_found when the template is not on that project. Needs the policy-code-write role floor |
DELETE | /api/v1/workspace/{id}/members/{userId} | Remove a member (Maintainer or above; cannot remove self or the Owner) |
DELETE | /api/v1/workspace/{id}/invitations/{invitationId} | Revoke a pending invitation (Maintainer or above) |
GET | /api/v1/workspace/role?workspaceId=… | Caller’s effective role in the workspace |
POST | /api/v1/codeFixes | Generate a code fix PR for a prior scan’s findings |
POST | /api/v1/codeFixes/auto | Autonomous fix in one PR — build-verify repair loop + exception-handling hardening (PR only if it compiles) |
POST | /api/v1/code-analysis | Static full-source exception analysis → findings with class/method |
GET | /api/v1/config/log-types | Every template format (General, SOC 2, HIPAA, ISO 27001, …) with its id — the logTypeId that /config/get-analyzerrules takes. |
GET | /api/v1/config/get-analyzerrules?logTypeId=… | The rules for one format. Omitting logTypeId returns the General set. |
GET | /api/v1/config/templates | Every format together with its rules, in one call — equivalent to /log-types followed by a /get-analyzerrules per format. |
POST | /api/v1/config/analyzerrules | Removed 2026-10-15. A project’s per-framework rules are a scan_profile policy under /api/v1/policies (POST /api/v1/policies/scan-profiles/ensure, PUT /api/v1/policies/{id}); a new project is created first with POST /api/v1/agents-flow/ensure-project. See Rules & Policies. |
POST | /api/v1/scan/initiate | Create and trigger a scan (orchestrator entry point; derives the checkout path itself and takes no path field). Without a PR number it records the checkout’s real 7-character commit, not the literal HEAD. An ownership refusal keeps its own status — 401 / 403 / 404 — instead of being wrapped in a 500 |
POST | /api/v1/analyzer | The UI’s scan entry point. checkoutFolder in the body is ignored — see “The checkout directory is derived, not chosen”. userId is optional and ignored: the caller’s id comes from the credential |
POST | /api/v1/generator | Instrument a scanned project and open a PR. scanId is required — {} answers 400 naming it ({"scanId": "must not be null"}), not a 500. userId is optional and ignored. See services/log-generator |
GET | /api/v1/scan-results/workspace/{workspaceId} | List a workspace’s scans |
GET | /api/v1/scan-results/{id} | Get scan results (plus /summary and /details/{severity}) |
GET | /api/v1/scan-runs/latest | State of the most recent run (/scan-runs/{id} for one) |
GET | /api/v1/auth/workspace | List the caller’s workspaces (POST creates one, POST /switch changes the active one) |
GET | /api/v1/project/workspace/{workspaceId} | List the caller’s projects in a workspace. Identity (email) comes from the JWT — not a path segment — so a client that lost the email from its state can’t produce /project/{ws}/ (404) or a mismatched list. Owner-scoped; 401 without a token. Preferred over /{id}/{emailId}. |
GET | /api/v1/project/{id}/{emailId} | (Legacy, back-compat) List projects for a workspace + email; the path email must equal the caller’s JWT email (else 403). |
GET | /api/v1/project/{id}/index-binding | Resolve a project’s authoritative serviceName + log/trace indexes (effective values) |
PUT | /api/v1/project/{id}/index-binding | Set/clear a project’s serviceName + dedicated log/trace indexes |
GET | /api/v1/project/{id} | One project the caller can see (JWT or API key; a key stays in its own workspace). 404 for missing or forbidden. See services/api-sandbox → “Project routes for machine callers” |
POST | /api/v1/project/enrol | Create-or-return a project for a GitHub repo via the workspace’s App installation on the owner (JWT or API key). An already-enrolled repo — matched by stored full name or repository URL — returns that project with created: false before the installation is checked. 409 + installUrl when no installation covers the owner |
POST | /api/v1/ide/check | Instrument one snippet as a file of a project the caller can reach. Body {projectName, fileName, code, projectUrl?, projectId?} — see “IDE check” below |
GET | /api/v1/user/settings | Current user’s profile + capabilities (hasPassword) + working defaults (JWT subject) |
PUT | /api/v1/user/profile | Update the authenticated user’s first/last name |
POST | /api/v1/user/change-password | Verify current password and set a new one — enforces the password policy + no reuse of the last two passwords (400 for OAuth-only accounts, wrong current password, weak, or reused) |
PUT | /api/v1/user/settings | Persist per-user working defaults (defaultBranch, defaultTemplateType, showWardenGuide) |
POST | /api/v1/user/account/delete | Permanently delete the caller’s account + all its data. Body { confirmation, password } — confirmation must equal the email or provider username; password required for password accounts. Irreversible |
DELETE | /api/v1/project/{id} | Permanently delete a project + its scans/generator-results/rule-configs. Workspace-member-gated |
DELETE | /api/v1/workspace/{id} | Permanently delete a workspace + everything scoped to it. Maintainer or above (Owner included); body { confirmation } must equal the workspace name |
GET | /health | Health check |
GET | /swagger-ui.html | Swagger UI |
The checkout directory is derived, not chosen
Section titled “The checkout directory is derived, not chosen”Shipped: 2026-08-27 — this replaces “Checkout directory is created automatically” (2026-05-28).
The Initiate-Scan / Generate-Logs modal used to carry a Checkout Directory Path field. It is
gone, along with GET /api/v1/path/exists, POST /api/v1/path/ensure and
GET /api/v1/projects/{id}/scan-path/canonical, which existed only to serve it.
It was never a question the user could answer. The path is a directory inside the backend
container, while the helper text called it “the local directory path” and the folder icon
opened the browser’s own picker — which cannot report an absolute path, so it yielded a bare
folder name. The suggested value was a placeholder, never submitted, so leaving the field alone
blocked the scan on “Please select or enter a valid path!”. And /path/ensure kept only the
last segment of whatever was typed, so the placeholder /root/logrite/scans/2/153 resolved to
/root/logrite/scans/153 — a different directory from the one displayed. Users typed their own
names to get past it; prod still holds /root/logrite/scans/rahil/p2 and
/root/logrite/scans/jayant/p102.
AnalyzerService now calls GitPathRules.canonicalScanPath(project) — the derivation
RealOrchestratorRun has always used — so /api/v1/analyzer and /api/v1/scan/initiate agree on
one location per project, /root/logrite/scans/<workspaceId>/<projectId>, and a clone made by one
is reusable by the other. GitUtil.checkoutRepository creates it before cloning, as it always did.
AnalyzerRequestDTO.checkoutFolder still exists but is ignored, unvalidated, kept for one
release so a browser tab loaded before the change gets a 202 rather than a 400. Honouring it was
never safe: the only check was a regex rejecting the literal string /root, and the value reached
RepositoryCheckout.cloneFresh’s recursive deleteDirectory, so a project member posting
checkoutFolder:"/etc" had /etc/p<id> deleted and recreated.
Checkouts are reused, and swept — they are not ephemeral
Section titled “Checkouts are reused, and swept — they are not ephemeral”Shipped: 2026-08-27
A checkout persists between scans on purpose: ensureCleanCheckoutOrReclone fetches and
hard-resets an existing clone rather than re-downloading history, which is the difference between
seconds and minutes on a large repository. Nothing deleted them and nothing bounded them, so the
tree grew without limit — 16 GB on dev, about 3 GB of it directories for projects that no longer
existed.
ScanCheckoutSweeper (@Scheduled, logwrite.scan.checkout.sweep-cron, 04:20 daily) makes three
passes over /root/logrite/scans:
| Pass | What goes | Property |
|---|---|---|
| Orphans | <workspaceId>/<projectId> whose project id is no longer in that workspace | Unreachable whatever the disk budget says |
| Budget | Oldest-first until the tree fits logwrite.scan.checkout.budget-bytes (default 20 GiB) | Evicting a live project costs one re-clone, never correctness |
| Scan logs | .logrite-logs bundles older than logwrite.scan.checkout.log-retention-days (default 30) | index.log is kept whatever its age |
Two things it never touches. Dot-prefixed entries are ours, not checkouts — .logrite-logs,
.dotnet-sdks, .msbuild-legacy, .logrite-baseline — and the toolchain caches cost far more to
rebuild than to keep. Non-numeric directories are pre-2026-08-27 checkouts named after whoever
typed a path into the old modal; they are logged, not deleted, because the job cannot tell one from
a directory someone put there deliberately. Remove those by hand.
Every candidate is taken through ScanWorkspaceLockRegistry.tryAcquire, so a scan running right
now keeps its checkout — the sweeper skips what it cannot lock rather than waiting. release(String)
is called after the delete, which is the cleanup-daemon hook that registry’s comment anticipated.
Set logwrite.scan.checkout.sweep-enabled=false to disable it entirely.
IDE check — POST /api/v1/ide/check
Section titled “IDE check — POST /api/v1/ide/check”-
Project resolution is the caller’s. With
projectIdthe project must be visible to the caller (404 otherwise). Without it,projectNamematches the project name or repo full name (case-insensitive) among the caller’s projects — a key’s own workspace only — narrowed byprojectUrlwhen that names a repo; several matches → the most recently scanned. It used to look the scan up by a hard-codedhttps://github.com/Deployd-io/<name>/tree/masterlink that no project scoped, so it failed for every other org or branch and could match another workspace’s scan. -
The scan is the project’s latest with a checkout, on any branch (
findTopByProject_IdAndCheckoutFolderPathIsNotNullOrderByRunDateDesc). A missing checkout directory is a 404 asking for a re-scan, not a 500. -
fileNamemust be a bare name —/,\,..,:, NUL / control characters or an absolute path → 400. The snippet is written to a fresh.logrite-ide-<uuid>/directory inside the checkout (never over a repo file of the same name, which the old code overwrote and then deleted) and the directory is removed afterwards, even on failure. -
The handler no longer
printlns the request (the user’s whole source); it logs ids and the code length at debug. -
A skipped file says so (2026-10-01). The response gains three additive fields.
skippedistruewhen the file was deliberately not instrumented —recommendedCodeis then the input unchanged — andskipReason/skipMessagesay why. Reasons, in the order the filters run:skipReasonmeaning emptythe file is empty (or unreadable) unsupported-typethe file type holds no method bodies to instrument dtodata/DTO file — fields and accessors only entry-pointthe file contains mainskipReasonandskipMessageare omitted whenskippedisfalse. Before this, an unchanged file meant either “nothing to add” or “never looked at” and a caller could not tell them apart. -
Imports land at the import point. A logger’s imports are inserted together after the file’s existing imports (the same fix applies to repo-scan generation); they used to be able to land inside the class body, which does not compile.
Settings page — profile, password & per-user working defaults
Section titled “Settings page — profile, password & per-user working defaults”Shipped: 2026-06-24
UserController (/api/v1/user) gained four JWT-gated endpoints backing the Settings → General page. The caller is always resolved from the JWT subject (jwtService.extractUsername), never from a request param, so a user can only edit their own record.
GET /api/v1/user/settings→UserSettingsResponse { firstName, lastName, email, loginType, hasPassword, defaultBranch, defaultTemplateType, showWardenGuide }.hasPasswordlets the UI hide the change-password section for OAuth-only (GitHub/Bitbucket) accounts.PUT /api/v1/user/profile— updates first/last name; the frontend then patches Redux directly so the sidebar name refreshes without re-login.POST /api/v1/user/change-password— verifiescurrentPasswordagainst the stored hash and setsnewPassword. The new password must satisfy the shared password policy (see below) and must differ from the user’s last two passwords. Uses theBCryptPasswordEncoderbean (same encoder login/signup use) for both verify and re-hash, so the new password round-trips through the login path. Rejects OAuth-only accounts and a wrong current password with400.PUT /api/v1/user/settings— persistsdefaultBranch,defaultTemplateType("" | general | soc2 | hipaa), andshowWardenGuide.
New per-user columns live on the users row (no extra table): default_branch, default_template_type, show_warden_guide. Hibernate ddl-auto:update adds them on boot; mirrored in resources/migrations/2026-user-settings.sql.
Password policy — complexity + reuse prevention
Section titled “Password policy — complexity + reuse prevention”Shipped: 2026-07-01
Every flow that sets a password — signup (POST /api/v1/auth/pre-register), reset (POST /api/v1/auth/reset-password), change (POST /api/v1/user/change-password), and set (POST /api/v1/user/set-password) — now enforces one shared policy.
- Complexity is centralized in
com.logwrite.util.PasswordPolicy.validate(String), which returns the first violation message (ornullwhen valid). A password must be at least 8 characters and contain at least one uppercase letter, one number, and one special character ([!_@#$%^&*(),.?":{}|<>]). The UI mirrors these exact rules insrc/utils/validatePassword.js. - Reuse prevention (
PasswordHistoryService): a new password must differ from the user’s last two passwords (their current password plus the one before it). Every password change records the new BCrypt hash in the newpassword_historytable (id,user_id,password_hash,created_at); rows beyond a small retention window (5) are pruned on each set. Existing accounts predate history tracking, so the check also compares against the current stored hash directly. Reuse returns400— “New password must be different from your last two passwords.” - Hibernate
ddl-auto:updatecreatespassword_historyon boot; mirrored inresources/migrations/2026-password-history.sql. Complexity rules need no schema. - This replaces the old reset-only “cannot be the same as your current password” single-password check and the per-flow inline
< 8length checks.
Consumed at scan time: the frontend mirrors these into the theme slice. useInitiateScanLogic pre-selects defaultBranch (when it exists on the project) and, in the compliance flow, the defaultTemplateType framework. The AI Warden guide auto-show is gated on showWardenGuide (server-authoritative; the logrite_warden_guide_seen localStorage key remains a per-browser fast-path).
Email verification — OTP, magic link & resend
Section titled “Email verification — OTP, magic link & resend”Shipped: 2026-08-07
Signup is a two-step flow. POST /api/v1/auth/pre-register stores the account as status = "Inactive" with a UserVerificationLink row holding a 4-digit code and a 10-minute expiry, then mails it. The account cannot sign in until it verifies: AuthenticationService.authenticate throws BadCredentialsException("EMAIL_NOT_VERIFIED") for any Inactive user whose link is still enabled = false. Accounts created before this flow (status = "Active", enabled = false) are deliberately unaffected.
Three ways to clear it, all landing on the frontend /verifyEmail page:
POST /api/v1/auth/verify-otp{ email, otp }— manual entry. Flipsenabled, clears the code and expiry.POST /api/v1/auth/verify-magic{ token }— the Verify Email button in the mail. The link carries a signed, short-lived, single-purpose JWT (jwtService.generateEmailVerifyToken), never the OTP itself, and is exchanged for a full session — one click both verifies and signs in.POST /api/v1/auth/resend-otp{ email }— mints a fresh code and re-sends.
Resend answers 200 with one neutral message for every outcome — unknown address, already-verified account, cooldown, or a real send. It previously 404’d “User not found”, which made it an account-existence oracle, and it re-sent codes to accounts that had already verified, i.e. a way to mail any registered address on demand. The cooldown (one minute, derived from otpExpiresAt.minusMinutes(9) against the 10-minute life) is silent for the same reason; the Verify Email page runs its own 60s countdown, which starts when the response lands and so can only expire after the server window, meaning a legitimate resend is never silently dropped.
Frontend (components/Login/VerifyEmail.jsx). All three calls use a bare axios.create() instance, not the default one. The global request interceptor in utils/constant.js attaches whatever Bearer token is still in storage to any API-prefixed URL; on these anonymous endpoints the JWT filter rejects a stale token with 401, and the global response interceptor then runs performLogout() — “Your session has expired.” in the middle of verifying. A bare instance carries no interceptors.
The page also sends a code on arrival when it was reached from login. Only /signup mails one; the EMAIL_NOT_VERIFIED redirect does not, so the page claimed “We sent a 4-digit OTP” when the only real code was the signup one, long expired, and the Resend link was the sole way forward. Router state carrying userData marks the signup path, which already sent one and is skipped.
Account, project & workspace deletion (GitHub-style)
Section titled “Account, project & workspace deletion (GitHub-style)”Shipped: 2026-07-10
Three irreversible, typed-confirmation delete flows, all cascaded in a single transaction by AccountDeletionService (raw JDBC via EntityManager, FK-safe ordering; the schema has a projects → code_repository → user_credentials → projects cycle that is broken by nulling the nullable edges first). Every statement is scoped strictly by the target id, so no unrelated rows are touched.
POST /api/v1/user/account/delete— caller resolved from the JWT subject. Body{ confirmation, password }:confirmationmust equal the account email or provider username;password(verified withBCryptPasswordEncoder) is required only for accounts with a real password (OAuth-only accounts gate on the typed confirmation alone). Purges the user and everything tied to it — projects, scans, generator results, repositories, credentials, sessions, emails, VCS tokens, passkeys, trusted devices, password history/reset tokens, assistant conversations, and workspace membership/roles. Workspaces are not deleted (there is no owner column — membership is theworkspace_userjoin), only the user’s membership; the homeworkspace_idis nulled.DELETE /api/v1/project/{id}— workspace-member-gated (OwnershipService.requireProjectAccess). Deletes the project + its scans, generator results and per-project rule configs. Shared, user-owned repositories/credentials are kept (only their link to the project is nulled).DELETE /api/v1/workspace/{id}— Maintainer-only (requireMaintainer) + body{ confirmation }must equal the workspace name. Cascades the workspace’s projects (and their scans/results/rules), collector agents (+ agent children), memberships, roles, invitations and org-install mapping. Each member whose home workspace was this one is moved to another workspace they belong to (or left with none) before the membership rows are dropped.
Deletion notification emails. After a successful delete, TransactionalEmailService sends a confirmation + security notice (best-effort, off the response path — email failures never affect the result) via the same authenticated Resend path as the welcome/OTP mail: account-deleted-email-template.ftl, project-deleted-email-template.ftl, and workspace-deleted-email-template.ftl (the last sent to every former member). Each carries the requesting device, IP address (first X-Forwarded-For hop) and UTC time — built by com.logwrite.util.RequestContextUtil — so the recipient can spot an unauthorized deletion. Not gated by notification preferences (critical/transactional). Identity + request context are captured before the cascade runs, since the user’s own rows are gone afterwards.
Frontend: GitHub-style danger zones with typed confirmation — account on Settings → Security, project on Settings → Projects, workspace on Settings → Workspace (Maintainer-only). After a workspace delete the UI removes it from Redux and switches to another workspace (or routes to create-workspace).
Multi-template appliedTemplates field
Section titled “Multi-template appliedTemplates field”Shipped: 2026-05-21
LogScanResultDTO, LogScanResultLiteDTO, and LogGeneratorResultDTO carry appliedTemplates: List<String> — every template name applied to the scan. Populated from LogScanResult.run_details.appliedTemplates (written by ScanOrchestratorService only when matchedIds.size() > 1). Single-template scans fall back to [logType.name], so consumers can always treat it as a non-empty list (length 1 = single-template, length > 1 = merged).
Zero extra DB queries: the field is a pure passthrough from the same jsonb column Hibernate already hydrates with the row. Surfaced on GET /api/v1/scan-results/{id}, GET /api/v1/scan-results/user/{userId} → allScanResults[], and GET /api/v1/log-generator-results/project/{projectId} → element rows. The singular logType field stays untouched (primary template only) for back-compat.
Ownership guard fix (
keyFindings).GET /api/v1/log-generator-results/keyFindings/{scanId}takes a scanId (aLogScanResultid), so it now guards withownershipService.requireScanAccess(scanId, req)— matching the siblingGET /api/v1/scan-results/keyFindings/{scanId}. It previously calledrequireLogGeneratorResultAccess(scanId), which looked the id up in theLogGeneratorResultkey space and returned 404 “scan not found” for valid scans whose generator-result id differed from the scan id.
Instrumentation integrity — timers, returns, placement and where loggers go
Section titled “Instrumentation integrity — timers, returns, placement and where loggers go”These rules come from BankJava PRs #187/#188 (run 1569: mvn failed with
cannot find symbol: variable start at nine sites) and JobSearch PR #677 (run 1567).
A duration log always has its timer in scope (Java, C#)
Section titled “A duration log always has its timer in scope (Java, C#)”BankJava’s failing methods open with their query. The orphan-timer sweep searched down for a reader
and stopped at the first >>> Entering. That line was the same method’s own entry log, so the sweep
deleted long start and the duration log that reads it survived.
-
The sweep is method-scoped.
OrphanTimerPruner.removeOrphanedTimerDeclarationsbounds “does anything read it?” by the timer’s own method (TimerReaderSearch). In a brace language that is the brace-delimited body; elsewhere it is the lifecycle markers. -
TimerDeclarationRepair.ensureTimerInScope(C#:ensureTimerInScopeCs) applies these rules per method inTimerScopeRepair, telling this run’s lines from the customer’s byThisRunLines:- The customer’s original method already declares the timer name (
start, or__logStartin C#) in another form: a local, a loop variable or a parameter. Every timer declaration and reader this run added to that method is removed and counted asTIMER_NAME_COLLIDES. - A second declaration this run added inside the scope of an earlier one is re-armed as
start = …, because Java forbids declaring a local twice. - A reader with no declaration in view gets this run’s first declaration, hoisted to the first body
statement: after
super(…)orthis(…), and after the entry log when that comes first. If this run declared none, one is inserted there. If a declaration that predates this run exists in the method, the unreachable reader is removed instead.
The repair is idempotent. It runs in
RepairChainand again after the orphan sweep as a backstop. - The customer’s original method already declares the timer name (
-
LocalDeclarationIndexrecognises a declaration by an assignment pattern, soaccount == nullno longer counts as one.AnchorWritedefers only timer declarations, in FIFO order. -
ChainGuards.skipDbLogginglooks for the method’s timer from the method’s declaration (LocalDeclarationIndex.methodDeclarationIndex), not from its>>> Enteringline. A timer keyed above its own entry log therefore still counts, so its duration log is not dropped as timer-less (BankJavasaveToken, integration commitdb9b456).
On the BankJava replay under real javac with Lombok, the nine variable start errors went to 0.
Pass-through returns are captured into a local (Java)
Section titled “Pass-through returns are captured into a local (Java)”The return-site emitter put the exit log directly above the final return. When that return is
the work, as in return employerService.findById(id);, the log said <<< Exiting findById before
findById ran. Every JobSearch #677 controller method has that shape. ReturnValueCapture runs in
RepairChain after the Elixir hoist and before the orphan sweep, and rewrites the return:
EmployerDTO result = employerService.findById(id);log.trace("<<< Exiting findById(id={}) -> {}", id, result);return result;- A site is an exit log this run added, followed (past nothing but this run’s logs) by a
returnwhose expression does work: a call,new,++/--or an assignment. The expression may span lines. - The type is the method’s declared return type. The local is
result, orlogriteResultwhenresultis taken. - The value is appended (
-> {},result) only under three conditions:- there is evidence the logger takes
(String, Object...): an SLF4J or Log4j2 import or annotation, or arguments the log already passes; - the placeholders already match the arguments;
- nothing suggests a secret. The method name, return type and returned identifiers are checked for words starting with token, password, passwd, secret, otp, pin, key, credential, jwt, ssn or card.
- there is evidence the logger takes
EXIT_UNSAFE_TO_CAPTURE. Where a capture is not safe, the exit log this run added is removed and counted, because a log that runs before the work is worse than none. The verdicts areUNRECOVERABLE_TYPE,VOID,CONSTRUCTOR,WILDCARD,LAMBDA,ANONYMOUS_CLASS,SWITCH(a switch arm oryield) andC_STYLE_ARRAY.- A
returnwhose expression contains a block (an anonymous-class body, a block lambda or a switch expression) is left as it is. Lines the file already had are never touched. return result;is never captured again.ReturnValueCapture.undoreverses a capture exactly; it is how the Java build gate undoes one (CAPTURE_UNDONE).- The write-time dedup is seeded with every captured exit log in its planned form
(
plannedExitLogs), so a re-run does not add a second one.
Java only. Other languages keep the old behaviour.
Placement: no duration above its statement, no exit inside a guard
Section titled “Placement: no duration above its statement, no exit inside a guard”-
A duration log is not chained above the statement it times.
ChainGuards.chainablerefuses to pull a timer reader (DurationLogChaining.readsTimer) forward when the pull would jump a statement in its own block. BankJavaupdatePinshippedfind query executed in {} msabove the find.- When the jumped lines leave the block (
},End If,catch, a dedent), or include areturnorthrow, the chain stays. That keeps the reader inside its timer’s scope. - C# Stopwatch pairs and Vue files are exempt.
- A reader keyed below the terminator of a guard it would jump is re-keyed to just after that
guard’s
}when the key is free (movedPastJumpedGuard; BankJavasaveToken). AnchorWrite’stimerStartedAtThisAnchorstays Python-only. Enabling it for.javalost three timer pairs in the goldens.
- When the jumped lines leave the block (
-
An exit log is not hoisted into a guard clause. For
<<< Exitingonly,TerminalHoister.indexOfLastTerminator(lines, stopAtGuard)no longer steps over a closing brace whose block can be skipped (ReachableAfterBlock). Those blocks are anifwith no finalelse, a Swiftguard … else, and a loop that can end. BankJavavalidatePin,validatePasswordandvalidateAmounthad their exit log insideif (…) { throw … }, so it fired only on failure.synchronized,do,switch,try/catch/finally,for (;;),while (true), lambdas and type bodies keep the old step-over (JLS 14.22). -
Dedup ignores the level.
LogIdentitykeys a logger call with its level token replaced, solog.trace(…)andlog.debug(…)with the same receiver, message and arguments are one log.WriteGuards(sieveSharedKey,isDuplicate) andTerminalHoister.hoistAboveTerminaluse it, so a TRACE re-run over a DEBUG-instrumented file refuses the twin asALREADY_IN_SOURCE. JobSearch run 1567 wrote 11 such twins. The receiver still matters (log.debugis notlogger.debug), and a line that is not a logger call keeps exact-text comparison. -
Post-placement relocations move only this run’s lines.
StrayLogRehomer.rehome,StatementOrderRepair.hoistTrailingLogsAboveFinalTerminalandrelocateVarTrackBeforeFirstAssignmenttake the original file fromRepairChain.repair/LineOwnershipcarriesThisRunLines’ marks through each move, so:- a previous run’s log is neither moved nor deleted as a “duplicate”;
- a mixed tail group moves only this run’s lines;
- an all-old group stays put.
Without an original file the steps behave as before. JobSearch run 1567 had moved EmployerService’s existing
emp → {}debug line (integration commit8193d09,RepairChainPreExistingLogsTest).- Still unrestricted: the offside tail-hoist, the Java/C# class-body and unreachable-line relocations, and the Elixir/C# repairs.
- Weak spot: the up-front re-indent also touches old log-shaped lines, which would make them count as this run’s.
Where a logger may be injected
Section titled “Where a logger may be injected”| Guard | Rule | Switch |
|---|---|---|
| Build tooling | .mvn/, gradle/wrapper/ and buildSrc/ above any src/ are not collected (SourceFileFilters.isBuildScriptPath). JobSearch #677 had put @Slf4j into .mvn/wrapper/MavenWrapperDownloader.java, which the wrapper compiles without Lombok | build-tooling-exclusion |
| Lombok presence | A Lombok logger annotation is inherited only by a file under its module’s source root whose module declares Lombok (org.projectlombok:lombok, or the io.freefair.lombok Gradle plugin) for compilation. The module’s declarations are its pom and local parent chain, or its Gradle build and root build. Otherwise the SLF4J field is used when the module declares SLF4J, Logback or a spring-boot-starter. Otherwise the file is skipped before analysis as NO_LOGGER_ON_CLASSPATH (JvmLoggerClasspath, ScanLoggerScope) | lombok-classpath-check |
| Scaffolding-only files | After all gates, a tracked file that only gained blank lines and logger scaffolding (a logger import, a Lombok logger annotation, a logger field) and no logger call is checked out from HEAD (ScaffoldingRestore, called from GeneratorRun) | scaffolding-restore |
| Classic browser scripts | A .js file with no import, export, require(, module.exports or exports.x = outside block comments gets var logger = (typeof logger !== 'undefined' && logger) || console; instead of const logger = console;. Two classic scripts on one page with const logger threw SyntaxError: Identifier 'logger' has already been declared (JobSearch jobs.js + candidates.js) | classic-script-logger |
| Block comments | A log keyed inside a /* … */ comment is refused before the write and counted as INSIDE_BLOCK_COMMENT (BlockCommentSpans, string-aware per language). Applies to Java, JS/TS, C#, Go, PHP, C/C++/Objective-C, Groovy, Kotlin, Scala, Swift, Dart and Rust; comments nest in the last five. Logger scaffolding is exempt | block-comment-veto |
For the Lombok check, a module whose dependency list is partly out of sight keeps the old inherit
behaviour: a remote parent pom other than Spring Boot, Apache or Sonatype OSS, Gradle buildSrc,
includeBuild or apply from. So does a file with no Maven or Gradle build file above it.
test- and runtime-scoped Maven dependencies and Gradle test* configurations do not count.
The switches. Every guard is on by default. Disable one with the system property
-Dlogrite.injection.<name>=false, or the environment variable LOGRITE_INJECTION_<NAME>=false
(upper case, - becomes _). The property wins when both are set. false, 0, off and no
disable; any other value enables. The value is read on every call and never cached.
| Name | Environment variable |
|---|---|
build-tooling-exclusion | LOGRITE_INJECTION_BUILD_TOOLING_EXCLUSION |
lombok-classpath-check | LOGRITE_INJECTION_LOMBOK_CLASSPATH_CHECK |
scaffolding-restore | LOGRITE_INJECTION_SCAFFOLDING_RESTORE |
classic-script-logger | LOGRITE_INJECTION_CLASSIC_SCRIPT_LOGGER |
block-comment-veto | LOGRITE_INJECTION_BLOCK_COMMENT_VETO |
Known gaps:
- Only the scan path skips a
NO_LOGGER_ON_CLASSPATHfile. The IDE and gatekeeper paths give it an empty logger declaration but still analyse it. - A file restored by the scaffolding sweep is reported in the PR body’s unaccounted note rather than
under “recommended, none placed” (
CoverageSkipClassifier.applyRecommendedNonePlaced).
New drop and skip reasons
Section titled “New drop and skip reasons”| Reason | Kind | Means |
|---|---|---|
INSIDE_BLOCK_COMMENT | drop | The keyed position is inside a /* … */ comment: commented-out code the analyzer read as live. A log there would be counted as placed and could never run |
EXIT_UNSAFE_TO_CAPTURE | drop | An exit log above a pass-through return that could not be captured into a local (see above), so it was removed rather than left to run before the work |
TIMER_NAME_COLLIDES | drop | The customer’s method already declares the timer’s name, so this run’s timer and its readers in that method were removed |
NO_LOGGER_ON_CLASSPATH | skipped file | A Java file whose module compiles with no logger LogRite can declare. It is not analysed and is listed in the coverage modal’s skipped files |
The PR body’s “where recommendations are lost” breakdown (CoverageTables) words each reason through
DropReasonLabels. The first two have wording there. TIMER_NAME_COLLIDES has none yet, so it
prints as its key.
Log-generation pipeline — syntax gate & build verification
Section titled “Log-generation pipeline — syntax gate & build verification”After the analyzer instruments a checkout, the backend runs two independent safety steps before a PR is opened. They produce different milestone messages and mean different things.
Post-injection syntax gate (JsSyntaxGate) — per-log prune (esbuild, JSX/TS-aware)
Section titled “Post-injection syntax gate (JsSyntaxGate) — per-log prune (esbuild, JSX/TS-aware)”JsSyntaxGate.pruneFailingInjectedLogs parse-checks every changed
.js/.jsx/.ts/.tsx/.cjs/.mjs file with esbuild — the same parser Vite uses, so “passes
the gate ⇒ the build parses.” When a file fails, the gate removes only the injected log
line(s) that break it, keeping every valid log:
Skipped 2 log(s) that failed syntax validation after instrumentation: Reserve.jsx: console.debug(`<<< Exiting handler(...)`); … . Remaining changes are syntactically valid.—messageType: success
That message is the gate working as intended — the bad logs were dropped, every valid log and every other file shipped, and nothing un-loadable reached the PR. Not an error.
How a line is chosen for removal (never touches user code): a line is removable only if it
is both (a) a LogRite injection signature (>>> Entering / <<< Exiting / a var-track
→ / _start = Date.now() timing / const logger = console) and (b) added this run
(git diff -U0 HEAD). The breaking log is usually the reported error line itself (e.g. an exit
log spliced into an object literal — handler: function(){} → the sibling notes: {}), or the
line just above the symptom (an exit log between } and else if, where esbuild points at
else); a small upward window handles the latter.
A gate no longer throws the whole file away. When an injected log breaks the checker, the
gate escalates rather than reverting, stopping at the first subset that parses
(util/gate/GateEscalation):
- the blamed log;
- the logs of the enclosing function or block (
util/gate/EnclosingBlock); - every log this run injected.
Only a file that still fails while holding none of our logs reaches EXHAUSTED — at which
point the failure is the customer’s own breakage and the file may be handed back. So the loss is
bounded by the failing function instead of by the file. Measured on a Python fixture carrying
25 logs across 8 handlers with one bad log: the previous path reverted all 25, the escalation
drops at most 4 and keeps at least 21. Equivalent Python, JavaScript and PHP fixtures were each
shown to revert wholesale on the previous code and to survive here.
The Java build gate escalates the same way (util/javagate/JavaEscalation) — “a revert only
when there is nothing of ours left to remove”. Note that on a type-checking row a
resolution-only diagnostic is not attributable to us (!checker.parseOnly() in
DiagnosticText.newlyBlamedLines, deciding with ResolutionDiagnostics.isResolutionDiagnostic),
because a single-file javac/rustc/clang has no classpath the real build would have given it.
esbuild runs as a local subprocess (baked into the
backend image via npm i -g esbuild; resolved on PATH or $ESBUILD_BIN) — not the
analyzer HTTP /syntax-check, which caused the 524 + logout and was reverted.
Fallback / fail-open: if esbuild is unavailable, the gate falls back to the legacy
node --check whole-file revert for .js/.cjs/.mjs (JSX/TS blind). If neither runs, it
reverts/prunes nothing rather than blocking the pipeline (the build remains the backstop).
Why per-log, not per-file (Hotel-Management Reserve.jsx, PR #13): mis-placed exit logs
break across several JS/JSX structures — inside an object literal, splitting an if/else-if
chain, overshooting a callback’s close — and node --check was blind to all of them in
.jsx. The per-log prune keeps the dozens of valid logs while dropping just the broken ones,
robust to every structural pattern. (Placement is also being hardened upstream — analyzer
findLastStatementEndLine, MethodLastLineIndexFinder object-property support — to reduce how
often the gate fires.)
Java syntax gate (LanguageSyntaxGate) — only this run’s lines can be pruned
Section titled “Java syntax gate (LanguageSyntaxGate) — only this run’s lines can be pruned”The per-file Java row compiles one file with javac -proc:none and no classpath. Lombok never
generates log, so every injected log.x() used to be a brand-new cannot find symbol. The gate then
blamed the last N log lines in the file and deleted them, including earlier LogRite runs’ logs.
Measured: BankJava run 1569 had PRUNED_BY_GATE 184 of 494 placed logs, and JobSearch run 1567 had 68.
-proc:none stays, so no customer annotation processor runs inside the backend.
- Ownership is a diff.
ThisRunLines.mark(original, current)runs a JGit histogram diff against the file as it stood before the run, ignoring trailing whitespace. A previous run’s log looks injected, but it is the customer’s code. Pruning inpruneOneFile,GateRescue,LastChancePruneandDotNetLogPrunertargets only lines this run added. - Blame (
BlameAttribution):- The pristine file’s diagnostics are subtracted against the customer’s lines first, so leftovers land on this run’s lines.
- On a this-run line, a resolution error is blamed only when its
symbol:names one of two things:- the LogRite timer
variable starton a line that reads or arms it.GateTimerRepairrepairs that (ensureTimerInScope) and the file is checked again before anything is pruned; - a
variablethe original file declares, meaning a local referenced out of scope.
- the LogRite timer
variable log,class Logger,package lombok…,class val, a static-import constant or a superclass field blames nothing. That is the empty classpath talking, and the project build is the judge of it.
-Xmaxerrs 1000(was 100,resources/gate/checkers.yaml). A Spring service with no classpath already has about 100 unresolved-type errors of its own: BankJavaAccountServiceImplhas 111 pristine and 185 instrumented. A cap of 100 hid everything below the cut, including avariable start.- Scaffolding-only restore. After the gate prunes a file,
GateScaffolding.restoreIfOnlyScaffoldingchecks the file out again when this run’s surviving lines are all logger scaffolding and it gained no logger call. JobSearch #677’sCandidateDTO,JobContactViewDTOandJobDTOshipped with onlyimport lombok.extern.slf4j.Slf4j;and@Slf4jafter their logs were pruned.ScaffoldingRestorerepeats the rule once after every gate, including other languages’ scaffolding (see “Where a logger may be injected”).
Replays: JobSearch #677 went from 25 of the previous run’s logs pruned to 0. BankJava’s four shipped files went from 9 real-javac errors after the gate to 0, with 0 pruned.
Not done: the Python and JavaScript last-chance prune (PySyntaxGate, JsSyntaxGate) is not yet
ownership-restricted.
Build verification (“Testing Changes” milestone)
Section titled “Build verification (“Testing Changes” milestone)”For JS/TS repos the step runs npm install && npm run build (Vite/tsc for a real app; a
no-op when the repo has no build script). With the gate now esbuild/JSX-aware, the build is
the backstop for what a syntax parse can’t catch — type errors, bundler/resolution issues,
and any .vue/.svelte SFCs the gate skips. One bad file still fails the whole build:
Build Summary: 169 passed, 1 failed out of 170 files…failed Build: <file>…END Build Failed. … errorCode: build_failed
The offending file is named in failedFiles; the full build output is logged server-side so
the exact compiler error (file + line) is recoverable, while the client gets the capped
summary.
Most common cause: a log placed in a position the .js gate can’t detect — e.g. a
logger.debug(...) statement inside a JSX expression container (between a ternary’s
condition and its ?/: branches). Braces stay balanced, so even running the gate on
.jsx wouldn’t catch it, but the bundler rejects it (Unexpected token, expected "}").
This is the Logrite-UI PR #370 failure (Generator.jsx, 2026-05-31). See the analyzer’s
JSX/TSX injection placement rules.
Java build gate (JavaBuildGate) — repair before publish
Section titled “Java build gate (JavaBuildGate) — repair before publish”When the post-instrumentation build fails, GeneratorBuildPhase.rebuild hands it to
JavaBuildGate.ensureBuilds before anything reports on it, so coverage, the draft flag and the
banner describe the tree that is actually pushed. It replaces the three-round unreachable-statement
relocator, which was the only Java repair and could not see cannot find symbol.
- At most 4 passes (
MAX_PASSES).- Pass 1 acts on the diagnostics the full build already printed.
JavacDiagnosticsparses Maven (in-process and forked), Gradle and raw javac output, with itssymbol:line. - Each pass repairs every failing file, then runs a narrow verify compile under the
verifydeadline. Maven runsmvn -B -e -o compile -Dmaven.compiler.fork=true -fae; Gradle runsgradle compileJava --continue --offline. Either is retried online when offline dependency resolution fails. - The gate stops on a clean compile, a pass that changed nothing, a timeout, or the budget. If anything changed, the original full build runs once more, and its result is the one reported.
- Pass 1 acts on the diagnostics the full build already printed.
- Never touched: files git does not list as changed, and files that already failed before instrumentation. Within a file, only this run’s LogRite lines are touched.
Per diagnostic, the first action that applies (JavaFileRepair):
| # | Diagnostic | Action | action |
|---|---|---|---|
| 1 | cannot find symbol naming a timer, on or ±1 from a this-run line | Declare the timer in scope. If that changes nothing, prune the reader | TIMER_REPAIRED / PRUNED |
| 2 | unreachable statement on a this-run line | Relocate the log (BuildOutputRelocator) | RELOCATED |
| 3 | A return-value capture on or ±1 from a this-run line, whose undo removes none of the file’s original lines | Undo the capture | CAPTURE_UNDONE |
| 4 | A this-run LogRite line at or ±1 from the reported line | Prune it together with its method-scoped timer group | PRUNED |
| 5 | Any other line of an instrumented file that compiled at baseline: our logger scaffolding or the customer’s own line | Revert that one file to HEAD. Deferred while other edits were made, except on the last pass | REVERTED / DEFERRED |
| 6 | A line that does not exist, a path outside the checkout, or a file this run did not instrument | Leave it | UNVERIFIED |
| – | A file that already failed before instrumentation | Leave it | PRE_EXISTING |
What still fails after the final build is recorded as STILL_FAILING. Each finding also records whose
line it was (attribution): TIMER_READER, INJECTED_LINE, OUR_SCAFFOLDING, CUSTOMER_LINE,
BASELINE_FAILED, NOT_INSTRUMENTED, OUTSIDE_CHECKOUT or NO_SUCH_LINE.
The capture undo in row 3 needs JavaBuildGate.setCaptureUndoer(ReturnValueCapture::undo). At
b5c5de1 nothing calls it yet, so the undoer is still CaptureUndoer.NONE. Wiring it is part of the
backend integration.
Every build has a deadline.
| Property | Environment variable | Default | Bounds |
|---|---|---|---|
logrite.build.timeout-seconds.full | LOGRITE_BUILD_TIMEOUT_FULL_SECONDS | 1800 | Every full build the generator runs (mvn clean install, dotnet build, …) |
logrite.build.timeout-seconds.verify | LOGRITE_BUILD_TIMEOUT_VERIFY_SECONDS | 600 | Each Java-gate verify compile |
logrite.build.evidence-dir | LOGRITE_BUILD_EVIDENCE_DIR | empty: <checkout>.build-evidence/ | Where each pass’s raw compiler output is kept |
A value of 0 or less falls back to the default. On expiry the whole process tree is killed
(BoundedProcess).
NOT VERIFIED means “not a pass”. A build result carries timedOut and unverifiedReasons
alongside success. ModuleVerdict used to rescue a module that failed without naming a file. Two
cases are no longer rescued:
- a timed-out module fails with
… timed out — NOT VERIFIED; - a Maven or Gradle module whose javac errors name no source file fails with
… NOT VERIFIED, and not counted as a pass.
A baseline build that timed out is not cached and is treated as clean. The reasons surface as run
caveats (Build not verified — …, Java build gate — …) and in buildVerify.unverifiedReasons.
Evidence. All three records come from the same gate result, so the PR and the row cannot disagree:
| Where | What |
|---|---|
run_details.buildVerify | ran, passes, verified, timedOut, actions (counts per action, never truncated), prunedLogs, revertedFiles, unverifiedReasons, up to 50 diagnostics (still-failing first, each with file, line, col, message, symbol, attribution, action and pass), diagnosticsTotal, evidencePath. Absent when the gate had nothing to act on |
| Evidence files | One raw-output file per pass, tail-capped at 5 MB, never inside the checkout (which is committed and pushed). Newest 5 runs per checkout |
log_generator_result.error_details | BuildFailureDetail appends up to 8 javac lines such as File.java:61 cannot find symbol (variable start), plus the build TIMED OUT and was killed, so this result is NOT VERIFIED. Within the 1200-character bound, the file list gives way before the javac lines |
| PR banner | BuildFailureBanner: how many repair passes ran, whether a build timed out, then Repaired by LogRite, Still failing and Not verified, up to 10 items each |
Real Maven, end to end on a BankJava copy:
- With timer repair: 9 ×
cannot find symbol (variable start)→TIMER_REPAIRED×9 → BUILD SUCCESS,verified=true. - With timer repair off:
PRUNED×9 (exactly the nine readers) → BUILD SUCCESS.
A repaired long start can land directly above a reader that itself sits above the query. That
compiles and measures about 0 ms: a placement defect, not a gate defect.
Module-aware build (monorepo fix, 2026-06-02)
Section titled “Module-aware build (monorepo fix, 2026-06-02)”Build verification builds every module in its own directory, not once in the repo root.
BuildUtil.detectBuildTool/getSourceFiles were always module-aware (they recurse up to
4 levels for build markers — pom.xml, package.json, …), but the build step itself used to
run npm install && npm run build only in the checkout root. For a monorepo whose
markers live in subfolders (e.g. Hotel-Management: api/ + client/ with no root
package.json), npm install in the root aborted with:
npm error enoent Could not read package.json: … open '…/jayant/package.json'
…so a healthy repo was mislabelled “Build Failed” (buildTool=npm, 0 per-file errors) even
though detection had correctly found the submodules.
LogGeneratorService now calls BuildUtil.buildAllModules(checkoutPath), which:
- Discovers all build modules, then filters out lock-only npm/yarn false positives — a
directory with a
package-lock.jsonbut nopackage.json(exactly the stub root lockfile a prior failed root build left behind, which itself fed the ENOENT loop). selectBuildRootskeeps only the top-most module per build tool, so a Maven parent reactor (or an npm/yarn workspaces root) builds its children once, while independent siblings (api/+client/, or abackend/Maven +frontend/npm split) are each built in place with their own tool. Uses path-component-awarestartsWithsoapp/≠app-web/.- Applies the per-module iterative failed-file discovery (npm/yarn capped at 3 iterations,
other tools at 50) and the non-compile-exit rescue (a non-zero exit with no flagged source
file = pass) that previously lived in
LogGeneratorService. Maven and Gradle are built once, with-fae/--continueso every reactor module or task reports, and are never narrowed by renaming failed files to.logrite_skip. On BankJava that rename loop manufactured a failure inTestConfig.java, which the run never touched. A timed-out module, or a Maven/Gradle module whose javac errors name no file, is not rescued. See NOT VERIFIED under the Java build gate. - Aggregates: the build passes only if every module passes;
failedFilesis the union.
Side benefit: because npm never runs in the root anymore, the stub root package-lock.json
is no longer created/committed into the PR (the old GitUtil.pushCode dir-based artifact guard
matched directories like node_modules/, not a stray root lockfile).
dotnet build-tool detection by extension (C# support, 2026-06-29)
Section titled “dotnet build-tool detection by extension (C# support, 2026-06-29)”BuildUtil originally matched build tools by literal filename (pom.xml, package.json,
build.gradle, go.mod, …). .NET projects have no fixed marker name — the project file is
AnyStore.csproj, the solution AnyStore.sln — so a C# repo detected no tool and the scan
failed with Unsupported build tool: unknown (the original Deployd-io/anystore_CSharp symptom).
detectBuildToolAt now also scans the directory listing for files ending in the
BUILD_TOOL_EXTENSION_MARKERS suffixes — .csproj, .sln, .fsproj, .vbproj → dotnet
(global.json stays a literal marker). Suffix matching is isolated to this new map, so the
exact-filename tools (maven/npm/gradle/go) are untouched — proven by BuildUtilDiscoveryTest
(detectsDotnetFromSlnAndCsproj plus exactFilenameToolsUnaffectedByExtensionMatching).
The matching C# log-injection placement guards in FileUtil/MethodLastLineIndexFinder
(namespace-aware class-body relocation, Stopwatch-start injection, overload/delegate
disambiguation, brace-free conditional and before-else fixes) are documented under
C# (.NET) injection — placement rules. Verified end-to-end against
anystore_CSharp: Build succeeded. 0 Error(s) after 1125 injected logs.
.NET failed-file parsing — Linux + VB/F# regex fix (2026-07-19)
Section titled “.NET failed-file parsing — Linux + VB/F# regex fix (2026-07-19)”Once a dotnet build fails, BuildUtil extracts the offending source files via
DotNetParser (BuildToolParser.java) so the log-fix pass can re-target only those files
(LogGeneratorUtil matches them with failedFiles.contains(absFileName)). The original
pattern — ([A-Za-z]:[/\\].*?\.cs)\(\d+,\d+\) — hard-required a Windows drive prefix
(C:\) and only .cs. On the Linux build host dotnet build emits absolute POSIX paths
(/checkout/App/Program.cs(12,5): error CS1002: …), so the regex matched nothing, and
.vb/.fs compiler errors were never parsed at all. With an empty failedFiles list the
targeting silently degraded to “reprocess every file”.
The pattern now anchors on a real path-start ((?:[A-Za-z]:[\\/]|[\\/]|\.[\\/]) → Windows
drive, POSIX /, or relative ./), captures lazily to a .NET source extension
(cs|vb|fsx|fsi|fs), and requires the trailing (line,col). It returns the full absolute
path, excludes an MSBuild parallel-node prefix (1>/checkout/…), and takes the source file
rather than the […App.csproj] suffix (which ends in .csproj], not .cs(). Covered by
DotNetParserTest (7 cases: Linux C#/VB/F#/.fsx, 1> prefix stripping, Windows regression,
.csproj exclusion). Windows-drive behaviour is unchanged.
ASP.NET Web Forms “Web Site” projects — Web.config detection (2026-06-30)
Section titled “ASP.NET Web Forms “Web Site” projects — Web.config detection (2026-06-30)”The extension markers above cover .NET projects that ship a project/solution file. ASP.NET
Web Forms “Web Site” projects do not — they are loose .aspx + .aspx.cs code-behind +
Web.config, with no .csproj/.sln (historically compiled by IIS at runtime). Such a
repo detected no marker, so detectBuildTool returned unknown and the scan failed at the
build-verification gate with Unsupported build tool: unknown — even though the .cs
code-behind was detected and instrumented correctly (the failing case was
Rahil-Shah-3105/cSharp PR #1: 5 files processed, branch + PR created, then build_failed).
Two changes in BuildUtil fix this without disturbing real .NET projects:
detectBuildTool— after the root/marker/discovery passes find nothing, a newisAspNetWebFormsSitecheck (aWeb.config, case-insensitive, or any.aspx/.ascx/.asax/.ashx/.masterfile in the tree) makes detection returndotnetinstead ofunknown, so thebuildToolis labelled correctly.buildProject— when the tool isdotnetbut the build dir has no.csproj/.slnto target (hasDotnetProjectFileis false), build-verification passes through with aSkipped build verification: ASP.NET Web Forms Web Site project …result instead of runningdotnet build(which would failMSB1003; Web Forms is .NET Framework only and unbuildable by the dotnet CLI in the Linux build container). The injected logs are still applied — the gate simply has nothing to compile.
Because Web.config is not added to the per-directory marker maps, it never creates a
module that could shadow a real .csproj sibling: hybrid repos (a .csproj/.sln anywhere)
still build their real project normally; the pass-through only triggers for a pure Web Site
project. The pass-through lives in buildProject, so every caller benefits
(buildAllModules, GatekeeperService, CodeFixService). Verified against the live
Rahil-Shah-3105/cSharp checkout (detect → dotnet, build → pass-through success) plus
synthetic Web-App / lowercase-web.config / .aspx-only / plain-Java / empty / unsupported
fixtures.
Workspace invitations & RBAC (end-to-end, 2026-06-30)
Section titled “Workspace invitations & RBAC (end-to-end, 2026-06-30)”Roles were Maintainer (admin — manage members/workspace), Developer (run scans), Viewer
(read-only), stored per member in workspace_member_roles. Membership = a row in workspace_user (access) + a row in
workspace_member_roles (role). There is no Spring method-security; /api/v1/** is
permitAll at the filter and each privileged endpoint enforces the role manually via
requireMaintainer (WorkspaceMemberController) / MemberRoleService.effectiveRole.
Pending invitations (new user_invitations table)
Section titled “Pending invitations (new user_invitations table)”The invite flow is now end-to-end for unregistered invitees: the inviter’s chosen role is persisted and applied when the invitee signs up, instead of silently defaulting to Developer.
- Entity
UserInvitation→ tableuser_invitations(id, email, workspace_id, role, status, token, invited_by, created_at, accepted_at). Status lifecycle:PENDING→ACCEPTED(invitee signed up / was added) orREVOKED(Maintainer cancelled). One PENDING row per (email, workspace); re-inviting updates the role in place. Created by Hibernateddl-auto: updateon boot. InvitationServiceowns the lifecycle:upsertPending,consumeForSignup(marks ACCEPTED + returns the role),markAccepted,listPending,revoke.VALID_ROLESlives here as the single source of truth.
Invite endpoint hardening (POST /api/v1/invite)
Section titled “Invite endpoint hardening (POST /api/v1/invite)”- Resolves the workspace case-insensitively (
findByWorkspaceNameIgnoreCase); an unknown name is now a 400, not a silent “email everyone anyway”. - Maintainer enforcement runs always (it used to sit inside
Optional.ifPresent, so a missing/mismatched name skipped the 403 entirely). - Every invitee role is validated (400 on anything outside Maintainer/Developer/Viewer); the invite path previously accepted arbitrary free-text.
- Already-registered invitees are added immediately with the chosen role (and any pending
invitation is marked accepted); unregistered emails get a PENDING
user_invitationsrow and the invite email.
Role-preserving signup
Section titled “Role-preserving signup”AuthenticationService.signup, when an invitee joins an existing workspace, now calls
invitationService.consumeForSignup(email, workspaceId) and grants the invited role (was
hard-coded Developer). Workspace creators still become Maintainer. This is the single
chokepoint for both the legacy /signup and the OTP pre-register→/signup paths.
Member list + revoke
Section titled “Member list + revoke”GET /workspace/{id}/members appends pending invitations as status: "INVITED" rows (with
invitationId, no userId). DELETE /workspace/{id}/invitations/{invitationId} revokes a
pending invite (Maintainer-only, 404 if absent).
Frontend
Section titled “Frontend”Settings → Workspace → Team Members: invited rows show an Invited badge; Edit/Remove are
gated to Maintainers (canManage), pending invites can be revoked but not edited;
EditMemberModal role options corrected to Maintainer/Developer/Viewer (were the non-existent
Admin/Moderator); default invite role lowered from Maintainer to Developer.
Tested (JUnit/Mockito, 21 cases): role validation, pending upsert/idempotency, consume-on-signup, revoke, plus invite-endpoint RBAC (400 unknown-workspace, 403 non-Maintainer, 400 invalid-role/empty, happy path) and member-list merge / revoke gating + 404.
Verified end-to-end (2026-06-30): 40/40 live API checks driven through the full HTTP →
controller → service → JPA stack against a throwaway PostgreSQL copy of leapfrog with
ddl-auto: update — which confirmed Hibernate auto-creates user_invitations on boot (no
migration needed). Covered: login (Maintainer + Developer JWTs), existing-user immediate-add,
unregistered-email PENDING row (+ outbound email captured by a local SMTP sink), member-list
ACTIVE/INVITED merge, idempotent re-invite, case-insensitive workspace resolve, the full
validation/RBAC negative set (400/401/403), revoke (200/404/403 → status REVOKED), and
new-user signup consuming the pending invite with the chosen role (Developer default when none).
Known follow-ups (not in this change)
Section titled “Known follow-ups (not in this change)”- Deactivate/reactivate a member (reversible alternative to hard delete) — not implemented.
- Joining an existing workspace by name still does not require an invitation (anyone who knows the name joins as Developer) — invitation now sets the role but is not yet a hard gate.
- RBAC remains per-endpoint manual checks rather than centralized method security.
Generator PR link for email/password logins
Section titled “Generator PR link for email/password logins”Shipped: 2026-07-10
The generator opens the PR with a VCS token. That token was only ever populated in GitUtil’s in-memory userTokenMap during GitHub OAuth2 SSO login, so email/password users (who connect GitHub by fetching repos with a token) had no entry — LogGeneratorController did String.valueOf(gitUtil.getUserToken(userId)), which for a cache miss produced the literal string "null", and createPR failed with a silent 401 → the WebSocket END message carried no PR link.
Fix: the frontend now sends the connected token in the /generator payload (token: user.gitToken || user.bitToken); LogGeneratorRequestDTO.setToken’s guard keeps that value over the map lookup. LogGeneratorController null-guards the lookup so the literal "null" is never passed to the GitHub API. getUserToken also falls back to a persisted token (user_git_token) so the link survives a backend restart.
Follow-up (2026-07-14): the same String.valueOf(gitUtil.getUserToken(...)) anti-pattern was null-guarded in GatekeeperController, AnalyzerController and UserService.getRepos — there the literal "null" defeated a Strings.isEmpty fallback / was handed to the GitHub API. On the frontend, a session-matched identity helper (getActiveEmail(user) in utils/identity.js) now builds every /api/v1/project/{workspaceId}/{email} URL from the email that matches the active loginType (i.e. the JWT identity). A provider (GitHub/Bitbucket) session that still held a stale email-login address was sending it in the path and getting a 403 from the ownership check; the helper is fully dynamic per-user and returns the same value in every previously-working case. New setGitData/setBitData reducers let an already-logged-in user connect a provider without resetting the session (the old dashboard path dispatched ssoLogin, which called clearAppStorage() and never actually set the token).
Follow-up (2026-07-14) — push_auth_failed vs the misleading no_changes, and the “run Generate twice” race. When a repo has no usable push credential — an email/password user with no OAuth token whose connected repo’s owner isn’t covered by any GitHub App installation (the owner is fully dynamic: any personal account or org, e.g. Rahil-Shah-3105/cSharp, since the App installed on the Deployd-io org cannot push to a different account’s repo) — the generator instrumented the code, built, then git push was rejected. GitUtil.pushCode returned a single false for both “nothing to commit” and “commit produced but push rejected”, so the auth failure surfaced as the terminal errorCode:"no_changes" (“no committable source edits”), which is wrong and unactionable. Note: GET /api/v1/user/repos consumes a VCS token and never returns/persists one — “fetch repos again” cannot restore push; only OAuth-connect or an App install that covers the repo owner mints a push credential.
Fix: GitUtil.pushCodeDetailed returns a PushOutcome enum (PUSHED / NO_CHANGES / NO_TOKEN / PUSH_REJECTED / ERROR); pushCode delegates (== PUSHED) so GatekeeperService/CodeFixService are unchanged. A blank token after a real commit → NO_TOKEN; GitHub 403 at push → JGit TransportException → PUSH_REJECTED. LogGeneratorService now emits errorCode:"no_changes" only for a genuinely empty diff, and errorCode:"push_auth_failed" (owner-agnostic message: “We generated the logs but couldn’t push them to <owner>/<repo> — LogRite doesn’t have write access… Connect GitHub with write access (or install the LogRite GitHub App on the account that owns it), then run Generate again.”) otherwise. The frontend (ScanProgressContext.jsx) branches on push_auth_failed/no_git_token → terminal inline error, no logout.
Same change fixes having to click Generate twice: the frontend fires the /generator POST from the WebSocket onopen callback, so it can beat StreamingWebSocketHandler.afterConnectionEstablished’s sessionRegistry.register(...); generateLogs used to bail immediately (log.error("No open WebSocket session")) with zero WS messages, so the first click silently no-op’d. It now waits up to 3s (awaitSession, polling every 100 ms) for the socket to register before giving up.
API error responses — global error envelope
Section titled “API error responses — global error envelope”Shipped: 2026-07-14
Every REST endpoint now returns a consistent, descriptive error body instead of Spring’s bare default ({"timestamp","status","error":"Forbidden","path"}, which drops the reason and leaves the UI showing only the HTTP phrase). A single @RestControllerAdvice — GlobalApiExceptionHandler, ordered LOWEST_PRECEDENCE — maps every otherwise-unhandled exception to an ApiError:
{ "timestamp": "2026-07-14T06:33:52.148+00:00", "status": 403, "error": "Forbidden", "message": "You are signed in as '[email protected]' and cannot view projects for '[email protected]'. Sign in with the account that owns this workspace, or reconnect the correct provider, then try again.", "errorCode": "forbidden",}- Keeps the default top-level keys (
timestamp/status/error/path) for backward compatibility and addsmessage(human, user-facing),errorCode(machine slug the FE can branch on), and optionaldetails(per-field validation errors, or the exception type for a 5xx).detailsand any null field are omitted. LOWEST_PRECEDENCEmeans the pre-existingAnalyzeExceptionHandlerand all controller-local@ExceptionHandlermethods keep precedence — no success path or business logic changes, only error responses are enriched.- Coverage:
ResponseStatusException(surfaces its reason — the biggest win, sinceOwnershipServicethrows 401/403/404 across the app), Spring Security 401/403, bean/param validation 400, missing/mistyped params, malformed JSON, 404/405/406/413/415/503, and a catch-all 500 (generic user message; the real cause is logged server-side and its type returned indetails, never leaked intomessage). - The frontend already reads
data.message(utils/constant.js), so the toast shows the real reason with no frontend change.
The API filter chain’s own 401 carries the same body (2026-10-01). A request refused before
it reaches a controller — no bearer, an invalid or expired JWT, an unknown lgp_live_ key — used
to get a bare 401 with an empty body. It now gets an ApiError with errorCode: "invalid_token"
(or unauthorized when no credential was sent). An API key’s message is one sentence for
unknown, revoked and expired alike — “The API key is unknown, revoked or expired. Create or
rotate a key in the Sandbox (API keys).” — because saying which would make the 401 a key oracle.
The status is unchanged.
errorCode values: bad_request, unauthorized, forbidden, not_found, endpoint_not_found, method_not_allowed, not_acceptable, conflict, validation_failed, missing_fields, missing_parameter, type_mismatch, malformed_request, unsupported_media_type, payload_too_large, request_timeout, internal_error.
missing_fields (added 2026-10-06) is the one a caller sees most: a request that omitted
mandatory fields, with message naming every one of them in a single sentence and
details carrying them as a map. See API Validation for
the wording contract, which the analyzer service implements identically.
Build memory — heap ceiling + container cap (the Vite-OOM → 524 fix)
Section titled “Build memory — heap ceiling + container cap (the Vite-OOM → 524 fix)”The build step runs inside the backend container, so a memory-hungry frontend build competes with the Spring JVM for the container’s cgroup memory. A large React app (e.g. Logrite-UI, ~200 components) makes Vite/Rollup load the whole module graph into a single V8 heap. Node auto-sizes its old-space heap from the cgroup limit, so under the old 1.5 GiB container cap V8 capped out at ~768 MB and aborted:
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
The build then failed, the npm/yarn retry loop (capped at 3 iterations) re-ran it, and
/generator ran for minutes — past Cloudflare’s 100 s origin limit → the client saw a
524. (Java/Maven/Gradle repos were unaffected: mvn forks javac into its own process
and never holds a giant in-memory graph. This is why JobSearch/banking generated fine but
Logrite-UI 524’d.)
Fix (two interdependent levers — neither alone is enough):
NODE_OPTIONS=--max-old-space-size=3072on the npm build command (BuildUtil). Raises V8’s heap ceiling above the auto-tuned ~768 MB.- Backend container memory cap 1.5 GiB → 4 GiB (Coolify
applications.limits_memory;docker update --memoryto apply live without a redeploy). The flag only grants V8 permission to use more heap — the cgroup must have the physical room, or the kernel just OOM-kills the build instead of V8 aborting.
Measured build peak is ~3.12 GiB (JVM + node), so a 3 GiB cap is too tight (would be
kernel-OOM-killed); 4 GiB leaves ~0.9 GiB headroom. Verified live on Logrite-UI: the Vite
build went from OOM-fail (/generator ~145 s, build-failed PR) to Build Passed 170/170 in
~39 s (/generator ~94 s, build-passed PR). The synchronous-endpoint 524 for builds that
legitimately run > 100 s remains a separate, larger fix (make /analyzer + /generator
async over the existing WebSocket).
Analyzer scan throughput — parallel per-file calls
Section titled “Analyzer scan throughput — parallel per-file calls”The scan step (LogWriteAIService.processSourceFiles) makes one blocking HTTP call to
analyzer.logrite.ai/analyze per source file (aiAdapterService.getLogDetails, ~0.7 s
each). Run sequentially over a ~200-file React app (Logrite-UI) that’s ~150 s — past
Cloudflare’s 100 s edge, so POST /api/v1/analyzer surfaced as a 524 even though the
scan finished and persisted server-side.
These calls are now run on a bounded thread pool (logwrite.analyzer.concurrency, default
6 — tunable via env, no redeploy). getLogDetails is stateless (fresh RestTemplate per
call) so it parallelizes safely; concurrency is capped so we don’t overwhelm/rate-limit the
analyzer service. Crucially the work is two-phase: Phase 1 fetches in parallel; Phase 2
aggregates single-threaded, so all shared-state mutation (the severity counters in
Util.convertToLevelTwoDtos, the four severity lists) stays race-free and the output is
identical to the old sequential path. A single file whose call fails is now logged and
skipped instead of NPE-ing the whole scan.
Verified live on Logrite-UI (198 files): the analysis phase dropped from ~140 s to ~30–44 s;
POST /api/v1/analyzer through Cloudflare returned 200 in 72.9 s (was a 524 at ~123 s+).
No frontend change — same endpoint, same WS progress frames (Analyzing <file> … END Scan ID:<n>), same response. The only visible difference is that per-file Analyzing …
frames now arrive interleaved rather than in strict order. This widens the margin under the
100 s edge but does not remove the ceiling — the async refactor above is still the durable
fix for very large repos.
Concurrent scans of the same repo — CORRECTED 2026-08-27. The text that stood here described
code that does not exist. It credited Logrite-AI d617f08 with a per-scan ephemeral checkout:
GitUtil.uniqueScanPath(project) = canonicalScanPath + "/" + UUID, a LogWriteAIService.runAnalyzer
that overrode checkoutFolder before cloning, GitUtil.deleteCheckoutQuietly in a finally on
every flow, and a GitUtil.sweepCheckoutsOnStartup (3755cb3) wiping SCAN_ROOT on boot. A
repo-wide grep finds none of those symbols — no uniqueScanPath, no deleteCheckoutQuietly,
no sweepCheckoutsOnStartup, no LogWriteAIService. Nothing was ephemeral and nothing was swept,
which is why the scans tree reached 16 GB on dev unnoticed: this section said it could not.
What is actually true: the checkout is the canonical per-project directory
/root/logrite/scans/<workspaceId>/<projectId>, it is reused across scans deliberately, and it
is reclaimed on a schedule by ScanCheckoutSweeper (see “Checkouts are reused, and swept” above).
Concurrency is serialised by ScanWorkspaceLockRegistry, keyed on the checkout path — a
per-project mutex, not a per-scan one, so two scans of the same project queue rather than race.
UI complement (Logrite-UI 67ed632e): because a scan always persists server-side even
when the live channel dies, the frontend (useAnalyzerLogic.recoverViaPolling) no longer shows
the error screen on a 524 / early WebSocket close. It polls GET /api/v1/scan-results/{id}
(5 tries, backoff) and renders the completed scan; fresh scans (whose id only arrives on the WS
END Scan ID frame) are recovered via GET /api/v1/scan-results/user/{userId} matched on
project+branch+commit. Only a confirmed-missing result or a result:"Analysis Failed" row shows
the error screen. This is a safety net under the 100 s edge — the async refactor remains the
durable fix.
Generator instrumentation throughput — parallel per-file apply (2026-07-22)
Section titled “Generator instrumentation throughput — parallel per-file apply (2026-07-22)”The generator’s instrumentation step (LogGeneratorUtil.addLogsBasedOnSeverityLevel) applies
the precomputed log statements to every flagged file — pure local CPU + file I/O, no AI
call — but ran sequentially, one file at a time, and each file costs ~0.3–1 s
(FileUtil.addMultipleLinesToFile re-scans the whole file per method for insertion-point /
overload / scope validation). Measured on scanId 4302: 3,225 files ≈ 30 min for this phase
alone; a 15k-file enterprise repo would take hours. The throttled Instrumenting X/N files...
WS frames arrive every N/50 files (that’s where the “64 at a time” impression on a
3,225-file project came from — 3225/50 = 64 is the progress-message step, not a batch size).
The per-file work now fans out over a bounded worker pool: INSTRUMENT_WORKERS env
override, default = all available cores (container-aware), cap 16. This is safe because the
DTO list is merged one-entry-per-file (mergeByAbsFileName — no two tasks ever write the same
file), FileUtil/ImportFixer/extractor helpers are stateless (final Patterns only), WS
progress sends were already serialized through the single-thread wsSender executor, and the
LogAddDetail counters are now synchronized. Progress frames only emit counts strictly
higher than the last sent (CAS guard) so parallel completion can’t stream a backwards count.
A file that throws no longer aborts every file after it (the old sequential behavior) — it’s
logged and skipped. Regression test:
LogGeneratorUtilParallelInstrumentationTest (40-file fan-out parity + bad-file isolation).
Follow-up (same day) — heartbeat, slow-file WARNs, cores-2 (877486a). A 3,226-file
run died at “3200/3226”: a backend redeploy killed the container mid-run (auto-deploy on
push is active — a deploy restarts the container and aborts any in-flight scan), and
separately the throttled progress goes silent when the tail is a few giant files —
per-file cost is O(lines × methods); measured worst-case 102 s for one 5k-line test file,
17 files >30 s on Dnn.platform. Changes: a 15 s heartbeat re-emits the current
“Instrumenting X/N” count while the phase is in flight (feeds the frontend’s 5-min
watchdog and gives tail visibility beyond the N/50 multiples); files taking >30 s get a
named WARN: slow instrumentation; worker default is cores−2 at MIN_PRIORITY
(all-cores saturated the shared host and every co-hosted service lagged). Verified by a
full headless E2E on Dnn.platform (5,170 analyzed / 3,226 instrumented, C#):
analyzer 3m27s → instrumentation 3226/3226 in ~13.5 min (24 heartbeat repeats observed)
→ build verify 3225/3226 → push → PR #12. Note: a WS drop mid-run is survivable — the
run continues async and WebSocketSessionRegistry re-resolves the live session on
reconnect; the app’s WS auto-reconnects (3 s backoff).
UI complement (Logrite-UI): the generator progress bar used to freeze at ~38.9 % for
the whole instrumentation phase — every “Generating Logs” frame asymptoted toward the 39 %
ceiling, so three setup frames pushed the bar to ~33 and the per-file count frames could never
beat the running max; meanwhile the ETA (elapsed × (100−p)/p) grew as elapsed rose
(~3 min → ~4 min → …). Now (useGeneratorWebSocket.js + ScanProgressContext.jsx): setup
messages asymptote only toward a low pre-ceiling (8 %) and Instrumenting X/Y frames
ratio-map across 8→39 %, advancing a decimal step per frame like the analyzer. The ETA is
count-based during instrumentation (files-left × measured per-file rate + 120 s allowance for
build/PR), falls back to percent extrapolation only after 30 s elapsed, and the displayed
value is monotonically non-increasing with a 60 s floor (“~1 min remaining” near the
end) — cleared at ≥99 % / terminal frames.
Note: the {"type":"ping"} frames every 25 s in WS captures are the client-side keepalive
(webSocketHelpers.js) — Cloudflare idle-closes a silent socket at ~125 s and scan/generator
traffic is server→client only, so the client must send something to keep the tunnel open
during long quiet stretches (e.g. LLM/build phases). They are intentional and harmless.
Operating it
Section titled “Operating it”The cli/*-cli.cjs scripts and logrite-manager.js drove PM2 on the host destroyed on
2026-08-03; they manage nothing now. The service is the backend compose service on each VM
(ssh dev / ssh prod):
C=$(docker ps -q --filter label=com.docker.compose.project=logrite --filter label=com.docker.compose.service=backend)docker logs -f "$C" # logsdocker inspect -f '{{.State.Status}}' "$C" # statusDeploy by pushing to main (the deploy watcher builds and deploys it on dev), or redeploy
from the console at https://management.logrite.ai — or, from the dev VM:
curl -sX POST http://127.0.0.1:8090/api/deploy -H 'content-type: application/json' \ -d '{"service":"backend","host":"dev","ref":"main"}'Prod moves only when promoted from the console. See Deployment platform.
Build Process
Section titled “Build Process”The deploy pipeline builds the jar: on dev the backend prebuild runs the Maven build and gates
before the image is built. To build locally (JDK 17; the pom is not at the repo root):
cd services/backend/dev/Logrite-AImvn -f logwrite-api/pom.xml clean package -DskipTestsAuthentication
Section titled “Authentication”JWT-based authentication:
- Tokens include
userId,workspaceId,role - All endpoints (except
/health,/auth/*) requireAuthorization: Bearer <token> - Workspace isolation is enforced at the repository layer
- Default TTL is 4 hours (
SECURITY_JWT_EXPIRATION_TIME=14400000). Override via the env var on thebackendcompose service.
JWT issuance across all three login methods
Section titled “JWT issuance across all three login methods”Every login method ends with a Logrite JWT signed by SECURITY_JWT_SECRET_KEY so JWT-gated services (notably the Cortex chat at logrite-ai-service) accept the user uniformly.
| Login method | Where the JWT is minted | Frontend storage |
|---|---|---|
| Email/password | AuthenticationController.authenticate → jwtService.generateToken(appUser) returns it in the /auth/login response | state.user.token (Redux); reaches bearerToken.js chain via persist:user.token |
| GitHub SSO | OAuth2AuthenticationSuccessHandler.onAuthenticationSuccess — after the user record is upserted, jwtService.generateToken(extraClaims, appUser) produces the JWT (claims include loginType=github, userId). It is URL-encoded and appended to the callback redirect as ?logriteJwt=... | SelectWorkSpace.jsx extracts the query param and passes it to the ssoLogin reducer, which writes it to state.user.token. The GitHub provider OAuth token is preserved separately in state.user.gitToken for repo/branch API calls. |
| Bitbucket SSO | Same path as GitHub; oAuth2User.getClientType() == "bitbucket" branch in the same handler | Same as GitHub but provider token lives in state.user.bitToken |
The Logrite JWT and the provider OAuth token are distinct. Provider tokens are kept for calls to GitHub/Bitbucket REST APIs (cloning repos, listing branches, opening PRs). The Logrite JWT is what logrite-ai-service and any future JWT-gated services verify. Both must be present after SSO; do not conflate them.
Welcome email (once, on first login of any kind)
Section titled “Welcome email (once, on first login of any kind)”All three login methods — plus signup and MFA-completed logins — funnel through the single session-mint chokepoint UserSessionService.record(...). That is the one place WelcomeEmailService.maybeSendWelcome(userId) fires, so the professional welcome email is sent from every login path without duplicating the hook. It sends exactly once per account, guarded by the AppUser.welcome_email_sent flag (ddl-auto:update adds the column; mirror resources/migrations/2026-user-welcome-email.sql). The flag is set only after a successful send — so a transient render/SMTP failure retries on the next login instead of permanently suppressing the welcome — and an in-flight guard (a per-instance Set of user ids) de-dupes concurrent first-logins so mark-after-success can’t double-send. The send runs off the request thread (CompletableFuture.runAsync) and every failure is swallowed + logged, so mail trouble can never add latency to — or break — authentication. SSO accounts whose email is still a username placeholder are skipped (flag untouched) so they get it once the provider email syncs. Recipient is AppUser.email (the real GitHub/Bitbucket verified address after EmailAccountService.syncProviderEmails). Rendered from templates/welcome-email-template.ftl and sent as multipart/alternative (plain-text + HTML, with Reply-To) via NotificationService.sendHtmlWithText — same authenticated sender ([email protected] over the Resend SMTP relay — spring.mail.host defaults to smtp.resend.com; the office365 values that used to be named here are dead placeholders and authenticate with 535) as the proven OTP/password-reset mail, so SPF/DKIM alignment (and inbox placement) is identical. Templates load via the classpath loader (ApiConfig sets preferFileSystemAccess=false) so a new .ftl resolves deterministically in the packaged jar.
Ops/test trigger. POST /internal/email/welcome (guarded by X-Warden-Internal-Token, like the Warden internal API) re-sends the welcome email to an explicit { "email", "userId"? } — bypassing the once-per-user flag — for deliverability smoke-tests or a manual re-send.
Email design system — one look, twelve templates
Section titled “Email design system — one look, twelve templates”Every .ftl under logwrite-api/src/main/resources/templates/ renders on the app’s own tokens, not a separate email palette: #E64100 accent, the #F3F4F6 / #FFFFFF / #F8F9FA / #E5E7EB light ramp and the #0c0e12 / #13161B / #1a1d24 / #2A2A2A dark ramp all come from Logrite-UI/src/styles/themes.js, and the face is the same system stack index.css sets. Values that are identifiers rather than prose — OTP codes, IP addresses — take the app’s mono stack.
Shared structure across all twelve: a 3px accent rule at the card head, a 32px logomark + wordmark lockup, a 26px headline, body at 15px/1.65, detail facts in a hairline-separated labelled panel (not a bulleted list), an 8px-radius button sized to its label, and a hairline footer on the card itself. Dark mode is a real second theme via prefers-color-scheme and the .darkbg / .darkcard / .darkpanel / .darktext / .darkmute / .darkbody / .darkline / .darkrule classes, so a client that reports a dark preference never gets a partial inversion. MSO conditionals, the VML button fallback and the hidden preheader survive in every template.
The email is sent in the theme the account chose in the app. AppUser.theme ("light" / "dark" / null; resources/migrations/2026-user-email-theme.sql mirrors the column) is written by the settings API and read by TransactionalEmailService.send(...), which puts a theme_media key into every model. Each template hangs its dark block off it — @media ${theme_media!'(prefers-color-scheme:dark)'} — so:
| Account’s stored theme | Media query sent | Result |
|---|---|---|
dark | all | the dark rules always apply |
light | not all | valid CSS that can never match, so the message stays light |
| null (never chose) | (prefers-color-scheme:dark) | follows the reader’s own mail client |
It is still one message and one send — light and dark were never two emails. The dark rules travel inside the single MIME body and this decides only what their @media block asks. Null is the correct value for every pre-existing row and needs no backfill: with no preference on record, following the reader’s client is the only honest answer. The inline ! default matters because EmailTemplateFieldValuesTest renders with an empty model; a template that hard-coded the query instead would silently ignore the account’s choice, which EmailThemeSelectionTest sweeps the whole directory to prevent.
What this cannot do is stop a client inverting on top of us: Gmail on Android and Outlook.com apply their own dark transform to a light message whatever it asks for. Forcing light is a statement of intent, not a guarantee about pixels — which is also why the designed dark ramp is worth keeping rather than letting those clients invent one.
On the frontend, utils/themeSync.js mirrors the choice onto the account whenever the theme changes (contexts/ThemeContext). It is best-effort and silent, skipped when signed out — an anonymous PUT would 401, and the global response interceptor turns a 401 into performLogout(), so toggling the theme on the login page would announce a session expiry — and it records what it last sent in logrite_theme_synced so a page reload does not re-PUT a value the account already holds. An account that has never touched the toggle since this shipped keeps the adaptive behaviour, by design.
Two gates hold it. EmailTemplateResolutionTest asserts every template resolves through the real ApiConfig loader — a hand-rolled Configuration would resolve through a loader production never uses and pass while every send failed. EmailTemplateFieldValuesTest sweeps the directory (so a template added tomorrow is covered without anyone remembering) and renders each one twice: once with every placeholder set to a sentinel, to tell a labelled field apart from a heading that also ends in a colon, and once with an empty model, which is what a caller that forgets a key produces. A label that prints with nothing after it fails the build. Its reader treats a table row as one line and a cell as not — set a fact as label-cell + value-cell and a cell-per-line reader sees “Device:” alone, which is indistinguishable from the blank field the sweep exists to catch.
New-device sign-in alert
Section titled “New-device sign-in alert”Fires from the same chokepoint as the welcome email — UserSessionService.record(...) — so every login path (password, SSO, MFA-completed) is covered by one hook. It is sent when a returning account signs in from a device it has never used. A true first-ever login is the welcome email’s job and deliberately does not also raise this alert; the check keys off AppUser.welcome_email_sent rather than the presence of session rows, so the first login after the feature shipped — when user_sessions is still empty — correctly alerts instead of being trusted.
A device is recognised by signature, not by its User-Agent string, and never by its IP. ClientFingerprint.deviceSignature(ua) reduces a User-Agent to browser family, OS family + major version, and device type — Chrome | Windows 10/11 | Desktop. The full User-Agent is still stored on the session row for forensics; it is not what recognition compares. Two things made the earlier rule alert on essentially every login:
- The browser version. Comparing the whole User-Agent means
Chrome/141.0.0.0andChrome/151.0.0.0are different devices. Chrome ships a major every few weeks, so a routine auto-update re-introduced the same laptop as a stranger. Dropping the browser version and keeping only the OS major is what makes a device stay recognised between updates. - The IP address. Detection used to be
deviceUnseen || networkUnseen, where “network” meant the IPv4/24and, for anything that did not parse as four dotted octets, string equality — which is every IPv6 address. Consumer IPv6 has no stable address to compare: the interface identifier rotates on a timer (RFC 8981 privacy addresses) and the delegated prefix moves with the lease. Three sign-ins from one laptop on a mobile carrier produced2401:4900:8834:d456:…,2401:4900:8834:2c5d:…and2401:4900:8835:53ea:…— the/64differs in all three and even the/48moved, so no prefix length short of the carrier’s own allocation would have matched. The address was never seen before because it never can be, so theORwas permanently true. Widening the prefix would not have fixed this; an unfamiliar IP is simply not evidence of a new device on a mobile network, so it no longer decides. The IP is still recorded on the session row and still printed in the email, where it is context for a human rather than a trigger.
Session de-duplication deliberately does NOT use the signature. supersedePriorSessions retires a browser’s earlier live rows on a fresh login, and it matches on the exact User-Agent. Superseding revokes, and JwtAuthenticationFilter rejects a revoked jti — so a false “same device” there does not tidy a list, it signs the other device out. The signature is forgiving by design: two different laptops both running Chrome on Windows share one, and matching on it would end the first laptop’s session the moment the second signed in. Recognition can afford to be forgiving because being wrong there only withholds an email; this cannot. The cost is a duplicate row after a browser update, which is cosmetic and self-healing — list(...) already hides any row not seen since sessionActivity.idleCutoff().
The alert honours notifyNewDevice. Settings renders the switch and UserSettingsResponse documents it as a preference; the send previously ran “regardless of any notification preference”, so the one alert a user is most likely to want to turn down was the one they could not. Default is on — AppUser.prefEnabled treats null as enabled. The always-on security mail is the OTP / password-reset / MFA-code set, which has no preference to read.
Rendered from templates/new-device-login-email-template.ftl, sent async and best-effort off the login path. Model keys: first_name, device, device_type, browser, operating_system, ip_address, login_time, review_url.
Sliding refresh (active users never expire)
Section titled “Sliding refresh (active users never expire)”Two complementary refresh paths keep actively-working users from being force-logged out:
X-Refresh-Tokenresponse header (piggyback, no extra request). Any authenticated request made in the last 25% of the token’s lifetime causesJwtAuthenticationFilterOriginalto attach a freshly-issued JWT in theX-Refresh-Tokenresponse header. The frontend axios response interceptor inutils/constant.jsreads this header and dispatchesupdateTokenso subsequent requests use the new token — no separate network round-trip. CORS exposes this header viaCorsConfig(exposedHeaders("X-Refresh-Token")).POST /api/v1/auth/refresh(polling fallback). The frontendSessionWatcher(inApp.jsx) polls every 30s and explicitly calls this endpoint ~5 minutes before expiry. The endpoint allows a 5-minute grace window after the token has expired so race conditions never force a logout. This is the safety net for backgrounded tabs that don’t trigger the piggyback path.
Idle users are still logged out by the frontend’s INACTIVITY_LIMIT (4h, matches the JWT TTL), measured from the last successful API response.
Code-fix flow
Section titled “Code-fix flow”POST /api/v1/codeFixes drives the “Fix it” action from the AI chat panel:
- Looks up the most recent
LogScanResultfor the project on any branch (findTopByBranchLinkStartingWithIgnoreCaseOrderByRunDateDesc, matchinghttps://github.com/Deployd-io/<project>/). Previously this was pinned to/tree/master, so a project whose errors were scanned on a feature branch never matched and the fix was then applied to the unrelated configuredcodeFix.baseBranch— the build-failure root cause. - Checks out the branch that scan was actually run on (
logScanResult.branchName, falling back tocodeFix.baseBranchonly when the scan didn’t record a branch), creates a fix branch, and dispatches each target file to the analyzer’s/code-fixesendpoint. The PR base is the same resolved branch. - Sanitizes the analyzer’s LLM output before writing it to disk — strips a wrapping markdown
```lang … ```fence and rejects empty / truncated / obviously-non-code output, keeping the original (compilable) file rather than corrupting it. This was the most common cause ofmvn“Compilation failure” with no PR. Then builds to verify, pushes the branch, and opens a PR via the user’s stored OAuth token. Returns{ success, branch, fixedFiles, prLink }.
Failure modes surface as JSON — the controller is wrapped in try/catch so a missing scan row yields {"error":"IllegalStateException: No prior scan found for project '<name>'..."} instead of a blank 500. A null prLink with success:true means the fix succeeded locally but the push/PR-creation step hit a 401 (usually a stale OAuth token; the user needs to re-auth GitHub). On a build failure the full maven output is logged server-side (=== FULL MAVEN OUTPUT (codeFix <branch>) ===) so the real compiler error — offending file + line — is recoverable; the client still receives only the capped summary.
Fix-it categories
Section titled “Fix-it categories”CodeFixRequest accepts an optional category — bug (default), performance, or compliance — forwarded as-is to the analyzer’s /code-fixes endpoint (AnalyzerCodeFixRequest.category). The analyzer branches its LLM prompt on it:
category | Analyzer fix mode |
|---|---|
bug (default / null) | Minimal exception/bug fix (null/bounds checks, error handling). Unchanged legacy behavior. |
performance | Optimize the described hot path without changing behavior — eliminate N+1 queries, batch, paginate, cache. |
compliance | Redact leaked credentials/PII/PHI/PCI in a log statement while preserving the log line (mask / opaque-id / last-4). |
The field is optional and backward compatible — existing callers that omit it get bug mode. It is the shared first step of the Cortex unified Fix-it work (exceptions live; performance + compliance sweeps to follow).
Static analysis — POST /api/v1/code-analysis
Section titled “Static analysis — POST /api/v1/code-analysis”Reuses the project’s latest-scan checkout (prepareCheckout, shared with the code-fix flow), enumerates
its source files via BuildUtil.getSourceFiles (bounded by maxFiles, default 15, favouring
service/controller/dao classes), and runs the analyzer’s /analyze-exceptions per file. Returns
{ success, projectName, filesAnalyzed, findings: [{ className, methodName, exceptionType, line, description, severity, file }] }.
className is the source-files map key, so it round-trips straight into a code_fix_action’s levels.
This is what Cortex’s “analyze the code / add exception handling” intent calls.
Autonomous Fix-it — POST /api/v1/codeFixes/auto
Section titled “Autonomous Fix-it — POST /api/v1/codeFixes/auto”One self-verifying fix that opens one PR only if the project compiles — the “without causing any
errors” guarantee. After prepareCheckout + a fix branch:
- Build-verify repair loop (≤5 rounds):
BuildUtil.buildProject→ for each failing file,BuildUtil.resolveFailedFilePathlocates the source and the exact compiler error is sent to the analyzer (/code-fixeswithcompilerError); the sanitized rewrite is written (rejected by the sameisUnsafeFixguard if blank/truncated/fenced), then rebuild. Stops when green or when a round produces no safe fix. - Static hardening pass (once green): for the target classes (explicit
levels, else bounded discovery),/analyze-exceptionsfinds gaps and/code-fixeswithmode:"preventive"adds the missing try-catch / null / auth guards. - Final gate: a last
buildProjectMUST pass — otherwise no PR is opened and the error summary is returned. On success it reusesGitUtil.pushCode(node_modules tripwire) +createPR/createBitBucketPR, PR base = the resolved scan branch.
Verified locally on real Maven: deleting a } in JobSearch’s EmployerService.java → build failed →
analyzer fixed it → build green on the 2nd iteration.
Project → index binding
Section titled “Project → index binding”Shipped: 2026-05-29
Each Project now carries an optional, authoritative OpenSearch binding so Cortex/Analytics resolve a project’s data deterministically instead of guessing serviceName = lower(projectName) — which breaks whenever the OTEL agent’s service.name differs from the project name (e.g. project BankJava_TestRepo but serviceName=bankjava).
- New nullable columns on
projects:service_name,os_log_index,os_trace_index(added byddl-auto; null = legacy behavior, so existing projects are unchanged with no migration). Project.effectiveServiceName()/effectiveLogIndex()/effectiveTraceIndex()return the explicit binding when set, elselower(projectName)+ the sharedlog-analytics-logrite/trace-analytics-raw-logriteindexes.GET /api/v1/project/{id}/index-binding→{ projectId, projectName, serviceName, osLogIndex, osTraceIndex, explicitlyBound }(effective values).PUTthe same path to set/clear them (a null field is left unchanged; a blank string clears it).- The ai-service Cortex tools call the GET endpoint (cached) and filter by the bound
serviceName— see AI Service.
Phase 1 keeps the physical index shared (log-analytics-logrite) and only corrects the serviceName filter; per-project physical indexes are a later phase.
Compliance rule levels — seeding, and the backfill (2026-09-17)
Section titled “Compliance rule levels — seeding, and the backfill (2026-09-17)”analyzer_rules_config is the rule catalogue every template is built from. It is seed-only:
its three writers are ComplianceAnalyzerRulesSeeder, LogLeakRulesSeeder and
CoreAnalyzerRulesSeeder, the repository exposes no update path, and nothing in the API writes it.
A user’s own choices live in the project’s scan-profile policies (policy kind scan_profile, one catalogue item per rule; until 2026-10-15 project_analyzer_rules_config.template_rules).
logging_level on a NEW row
Section titled “logging_level on a NEW row”ComplianceAnalyzerRulesSeeder seeds it from the analyzer’s suggestion
(RuleSuggestionService.suggestedLevelOrDefault → GET /compliance/rule-suggestions), falling
back to RuleLogLevel.DEFAULT ("INFO") when the analyzer is unreachable or the rule has no
suggestion. It never touches a row that already exists — an operator may have re-prioritised it,
and reverting that on every restart would make the table impossible to administer.
ComplianceRuleLevelBackfill — the EXISTING rows
Section titled “ComplianceRuleLevelBackfill — the EXISTING rows”That “never touch an existing row” rule is correct and has a consequence: the improvement reaches a new deployment and no existing one. Measured against the dev catalogue on 2026-09-17, by joining the 187 served suggestions against the 203 catalogue rows:
| rows | |
|---|---|
| have a suggestion | 181 |
| differ from it | 94 |
— at the blanket INFO (70 → WARN, 21 → ERROR) | 91 |
— hand-seeded WARN, suggestion ERROR | 3 |
| no suggestion at all (core, hand-written, M-26-14) | 22 |
So 94 rules kept handing the generator a level chosen by a constant rather than by the obligation.
The backfill runs at ApplicationReadyEvent, @Order(110) (after the seeder), and moves a row
only when its stored level is the blanket default — null, blank, or INFO — and a suggestion
exists and differs. It will not touch:
- a row that is not on the default. The 3 hand-seeded
WARNrows keepWARN. Telling a consideredWARNfrom an accidental one is not something it can do, so it does not try. - a row with no suggestion. Inventing a level is the defect being fixed, pointed the other way.
- any saved template. A template holds its own copy of the rules from the day it was saved; rewriting those would change what an already-completed scan says it ran with.
Idempotent — a second boot finds every row equal to its suggestion and writes nothing. An
unreachable analyzer makes it a no-op rather than a catalogue full of INFO, because
RuleSuggestionService answers “no suggestion” when it cannot reach one. Fail-soft throughout: it
runs on the boot thread, and no failure in a convenience backfill may cost a startup. Each moved
row is logged by name at INFO, so the boot that changed what the generator emits is reconstructible
from its own log.
Switch: compliance.rule-level.backfill.enabled=false.
Default general levels — and CoreRuleLevelBackfill (2026-10-01)
Section titled “Default general levels — and CoreRuleLevelBackfill (2026-10-01)”The five general rules in compliance/core-rules.yaml used to carry the dev dump’s levels:
method entry and every local-variable change at ERROR, method exit at WARN, so a project on
the default template paged on-call for every method call. The defaults are now:
| canonical rule | was | now |
|---|---|---|
method_entry_logging | ERROR | DEBUG |
method_exit_logging | WARN | DEBUG |
method_variable_tracking | ERROR | DEBUG |
database_logging | ERROR | INFO |
external_service_calls_response_times | ERROR | INFO |
Exceptions stay at ERROR. Timing rules are INFO so the response-time dashboards still receive
them at a production log level.
CoreAnalyzerRulesSeeder never touches an existing row, so a boot-time CoreRuleLevelBackfill
(ApplicationReadyEvent, @Order(35)) moves those five catalogue rows — only under the
general log type, and only while both category and logging_level still hold the old
level (a blank logging_level counts as old). A row somebody re-levelled is held. Saved project
templates are never touched — they are user data and the record of what a scan ran with.
Idempotent and fail-soft. Switch: core.rules.level-backfill.enabled=false.
Why the frontend stops showing a suggestion on a built-in framework
Section titled “Why the frontend stops showing a suggestion on a built-in framework”Once the catalogue’s stored level is the suggested level, the “Suggested level X” line under every description on SOC 2 / HIPAA / GDPR (and the other 26) repeats a value the control beside it already shows. The frontend scopes that line to a user’s own templates — see Frontend.
Generation options travel on the scan profile (2026-09-30)
Section titled “Generation options travel on the scan profile (2026-09-30)”The analyzer’s three generation options — skip_trivial_methods, prompt_hashing,
structured_fields, see analyzer
— are configured per rule set and stored under generationOptions: since 2026-10-15 in the scan
profile policy’s settings.generationOptions (before that, in
project_analyzer_rules_config.template_rules beside templateRules).
⚠️ The backend does not know the defaults, and must not learn them. It forwards what the
scan profile says and stays silent about the rest — Boolean, not boolean, with
@JsonInclude(NON_NULL) on the DTOs, so an unset option reaches the analyzer as an absent
field and the analyzer’s own OPTION_DEFAULTS answers. Two of the three are off by default and
structured_fields is on; a copy of that table here would be a second authority, and when the
two drifted both services would keep answering, just differently.
GenerationOptions.from(map) is the only reader and never guesses. A missing key, a value that
is not a map, or a component that is not a real Boolean is all “not configured” — "false" is
a non-empty String and every truthiness rule in reach would read it as ON.
Three things that would each have been silent
Section titled “Three things that would each have been silent”An extra JSONB key nearly failed every scan. ScanFileAnalysis converts that column with a
DEFAULT ObjectMapper, and a default mapper throws on a property it does not know. So the cost
of adding a key to it, done wrong, is not a missing feature — it is UnrecognizedPropertyException
on every scan of that project, before a single file is analysed, over a value the scan does not
read there. TemplateRulesWrapper is @JsonIgnoreProperties(ignoreUnknown = true) now: a JSONB
blob that is extended over time has to be read leniently or it is not extensible at all.
Null is not “all off”. saveAnalyzerRules builds template_rules FRESH on every save, so
anything the request does not carry is gone. That is right for the rule list — the client sends
the whole list every time — and wrong for a preference: the old POST /api/v1/config/analyzerrules (removed 2026-10-15) was
sent from two places and by clients that predate this field. Taken literally, any such save
would revoke a customer’s setting, and the only visible consequence would be different generated
code on the next scan. So absent means “not stated” and the stored value is carried forward;
false is a decision and is honoured.
Both analyzer call paths get the same value. ScanBatchCall reads the options once per
batch and passes them to the batched files[] call and to analyzePerFile alike. The per-file
path is the fallback taken whenever a batch 502s or times out, so a scan that meant one thing
until Cloudflare hiccupped and another afterwards would be indistinguishable from a flaky
analyzer.
On the wire the DTOs are @JsonInclude(NON_NULL) with Boolean fields, so an unconfigured rule
set sends the request it has always sent — byte for byte. GenerationOptionsWireTest asserts
that, and asserts the JSONB read survives the new key.
The UI is Configure Rules (Generator and Compliance both render it): a Generation options panel above the rule list, saved with the template by both write paths, shown disabled on the read-only “Configured Rules” view — “the rules that scan ran with” has to include the options it ran with.
A project is never stored without its slug (2026-10-08)
Section titled “A project is never stored without its slug (2026-10-08)”projectFullName is the owner/repo path every branch lookup keys on. It had no absence
constraint of any kind: @NotNull passes "", and maxLength() ignores a blank value, so a
body carrying "projectFullName": "" answered 200 and created a project whose branch list
could never be populated. The failure surfaces later and somewhere else — a project that exists,
opens, and simply has no branches.
Blank is not refused, it is resolved. Rejecting it was not available: both call sites in
Logrite-UI send projectToUse?.projectFullName (Generator/hooks/useTemplateSaving.js,
useRulesHandlers/handleProceed.js), and on a first save projectToUse is a repository stub
that has no slug to send. A check.text() there would have turned a silent data bug into a
Configure page that cannot save at all. The server already resolves the CodeRepository on that
path, and the slug is sitting in its repo_url.
com.logwrite.util.ProjectFullNames is the one place that decides:
| Body | Stored |
|---|---|
"learnerjay/ErpNet.C-" | kept, trimmed |
"", whitespace, or absent | derived from repo_url → learnerjay/ErpNet.C- |
"ErpNet.C-" (no owner) | 400 naming projectFullName |
| blank, and the repository has no usable URL | 400 — nothing is written |
The whole URL path is kept, so a GitLab subgroup (group/subgroup/repo) survives; truncating to
two segments would produce an address that resolves to nothing. A single segment is a repository
name, not a slug, and inventing an owner for it would store a value that looks right and
addresses nothing — so that case derives to null and the save is refused rather than guessed.
Existing rows repair themselves. backfill fills a blank project_full_name from the
repository on that project’s next save and never overwrites a populated one, so no migration was
needed. Measured on dev before the change: 0 of 202 projects carried a blank slug — the defect
was reachable on the create path but had not yet been hit, so this is a guard, not a cleanup.
Edge validation stays deliberately permissive about absence and strict about shape:
TemplateRuleChecks enforces the owner/repo pattern only when a value is present, because the
service is what supplies it when it is not.
API keys and the OpenSearch proxy (2026-10-01)
Section titled “API keys and the OpenSearch proxy (2026-10-01)”A key’s scope names its estate. Every key used to be minted with scope: "production",
including on dev. The scope is now derived from baseDevApplicationPath — the frontend origin
this backend already sends users to — so a dev backend (https://dev-app.logrite.ai/) mints
development and prod (https://app.logrite.ai/) mints production (staging for a
staging- host). Anything unrecognised, unset or unparseable reads as production: the label
never under-states what a credential reaches. A stored production row on a non-production
backend is reported with that backend’s scope, since a key lives in exactly one estate’s
database. No setting or environment variable changed.
Rotation works at the 25-key ceiling. POST /api/v1/api-keys/{id}/rotate revokes the old key
and mints the new one in one transaction, leaving the key being replaced out of the ceiling
count. It used to count it, so a workspace at 25 got 409 “revoke one first” for a rotation that
never changes the live count. A failed mint rolls the revoke back.
The OpenSearch proxy refuses the bare compliance-logs index. That name is the pre-tenancy
shared index holding every workspace’s rows; it carries no workspace suffix, so it fell through
the authoriser’s “no tenant suffix → allow” rule. The per-workspace form is
compliance-logs-<ws>. semantic-api
refuses it the same way.
Production URLs
Section titled “Production URLs”| Branch | URL |
|---|---|
| Main | https://leapfrogapi.deployd.io |
| Dev | https://dev-leapfrogapi.deployd.io |
| Swagger (Main) | http://localhost:8080/swagger-ui.html |
Generator push-readiness (2026-07)
Section titled “Generator push-readiness (2026-07)”GET /api/v1/project/{id}/push-status → { canPush, method(oauth|app|none), platform, ownerLogin, ownerIsOrg, reason, connectOptions }. Resolves — before a scan is
initiated — whether LogRite can push to the connected repo, mirroring the generator’s
exact push-credential resolution (GitUtil.getUserToken → GithubAppTokenService. installationIdForRepo). The UI uses it to disable the “Initiate Log Scan” button up
front (with a reason + Connect CTA) instead of failing at the final git push with
push_auth_failed.
Client-held token (X-Vcs-Token, 2026-07-23): when no server-side credential can
push, the endpoint also honors an optional X-Vcs-Token request header carrying the
browser’s gitToken (email-login users hold their PAT client-side only). The token is
verified against the repo itself (GET /repos/{owner}/{repo} → permissions.push for the
authenticated caller); write access ⇒ canPush: true, method: "client-token". An
invalid/expired/read-only token fails soft into the existing connect-options response.
The generator’s push already accepted exactly this forwarded token, so without this branch
the gate was stricter than the real push. The UI (usePushStatus + the pre-scan gate in
useInitiateScanLogic) forwards the header automatically. CORS already allows it
(allowedHeaders: *).
Terminal scan-error frames (messageType: "error", 2026-07-23): ProgressEmitter
gained an emitError(message) default method; WebSocketProgressEmitter overrides it to
stamp messageType: "error" on the frame. LogWriteAIService’s scan-failure path
(ERROR: <stage>: <message>) now uses it, so the frontend can reliably switch to the
error screen. (Previously every frame — including ERROR: checkout: … — went out as
messageType: "success"; the UI additionally matches the ERROR: text prefix for
backward compatibility with not-yet-redeployed backends.)
GithubAppTokenService.installationIdForRepo(owner, repo) resolves the App installation
that actually covers a repo (via GET /repos/{owner}/{repo}/installation), so a push to a
personal-account repo uses an installation scoped to that account rather than the
workspace’s org installation.
Whole-project scan + .NET correctness fixes (2026-07-27)
Section titled “Whole-project scan + .NET correctness fixes (2026-07-27)”Root-caused from the Deployd-io/nopCommerce scan (PR #2), which instrumented only
.cs (1,438 files) and skipped the entire frontend (0 of 403 .js, 1 of 925 .cshtml),
while every injected log referenced an undeclared logger and CRLF→LF rewrites produced
~74k phantom deletions. Four fixes:
1. Dynamic whole-project, multi-language collection.
BuildUtil.getAllSupportedSourceFiles(projectPath) walks the WHOLE repo and collects every
file whose extension is in SUPPORTED_SOURCE_EXTENSIONS (the union of all language globs +
the .NET markup family), instead of only the single dominant build tool’s files. This is
why a polyglot repo — e.g. an ASP.NET solution whose wwwroot JS has no package.json, so
the dotnet globs never saw it — now gets C#, Razor and JavaScript in one scan.
Exclusions: EXCLUDED_SEGMENTS, TEST_SEGMENTS, DISCOVERY_SKIP_DIRS, plus the new
VENDOR_ASSET_SEGMENTS and a minified/bundled guard (*.min.js, wwwroot/lib, …) so
vendored jQuery/bootstrap is never instrumented. Gated by the new
AnalyzerServiceRequestDTO.wholeProjectScan flag (defaults to true when omitted;
LogWriteAIService falls back to the legacy build-tool-scoped getSourceFiles when false).
Coverage is now reported loudly (Instrumenting N of M collected files (K skipped …)).
2. Markup/script files no longer dropped. The old eligibility gate
(SourceFileClassifier.detectType(...) == CLASS) was C#-centric and discarded every Razor
view (no literal class keyword) — i.e. the whole server-rendered frontend. Replaced by
SourceFileClassifier.isInstrumentable(path): markup (.cshtml/.razor/.aspx/…) and
script/functional files are always eligible; only class-based languages keep the class-body
requirement (so pure interface/enum/DTO files aren’t needlessly instrumented).
3. Line-ending / BOM preservation. All instrumentation write-backs now go through
com.util.util.FileUtil.writePreservingEol(path, lines), which detects the source’s
original CRLF vs LF (and trailing-newline) and rejoins with it, instead of
Files.write(path, lines) (which used System.lineSeparator() = LF on the Linux box and
rewrote every line of Windows-authored .NET files). Applied at FileUtil (main write +
build-relocate pass), JsSyntaxGate, and PySyntaxGate; the syntax-gate whole-file revert
already uses git checkout (exact bytes).
4. .NET logger-declaration fallback. LoggerDetector.DEFAULT_LOGGER_BY_LANGUAGE now
covers c#, vb and fsharp (previously only Java/Kotlin/JS/TS/Python/Go), so a .NET
class with no logger gets a synthesized _logger field + using Microsoft.Extensions.Logging;
instead of an empty declaration (the undeclared-logger compile failure). The fallback is
compile-safe by construction: the emitted LogDebug/LogInformation extension methods live in
Microsoft.Extensions.Logging.Abstractions, the same package that defines
NullLogger.Instance used by the fallback — so if the log calls compile, the field does too.
Classes that already declare/inject a real logger are detected and reused unchanged. (The
NullLogger fallback is inert at runtime; functional output for logger-less classes is a
follow-up — DI-inject ILogger<T> — and a real dotnet build verify gate should be added to
catch any residual placement issues, e.g. duplicate fields across partial classes.)
Regression tests: LoggerFallbackTest (C#/VB fallback), verified via
mvn -Dtest=LoggerFallbackTest test.
Observability export — pushing LogRite’s own records to a customer’s SIEM (2026-08-11)
Section titled “Observability export — pushing LogRite’s own records to a customer’s SIEM (2026-08-11)”Everything LogRite’s own components produce — Warden verdicts, compliance findings, coverage gaps, M-26-14 posture, policy changes, access logs, ten record kinds in all — is created inside the platform. The customer’s application never sees it, so no line of their code writes it down and their forwarder has nothing to pick up. Four routes carry it into their Splunk; two of them are ours to build:
| Route | Direction | Where it lives |
|---|---|---|
| A their forwarder carries our generated log lines | their app → forwarder → SIEM | nothing to build |
| B we push | LogRite → vendor | ObservabilityEgressService |
| C their SIEM pulls on a schedule | vendor → LogRite | ObservabilityFeedController + the Splunk add-on |
| D notification webhook → HEC | monitor → webhook → HEC | configuration, not code |
Five vendors are supported both ways: Splunk, Datadog, Dynatrace, Elastic, New Relic.
Push and pull credentials share one AES-256-GCM envelope per connection under distinct
keys (hecToken/ingestToken/licenseKey for export, token/apiKey for pull), so the
hot path decrypts once. The workspace always comes from the verified JWT, never a request
parameter.
The schema law. Every exported field is a LOCATOR (where), a CLASSIFICATION (what
kind) or a MEASUREMENT (how much/when) — never PAYLOAD. ExportRecord.assertToken is the
single choke point and fails closed: anything longer than the limit, containing a line
break, or reading as prose (>4 words) throws rather than being truncated into something
that merely looks safe. The message/content field that Datadog, Dynatrace and New
Relic require is composed from the record’s own classification tokens, so it cannot become
a back door for matched text. Verified by planting a live-looking card number and an SSN in
every free-text source column: both return zero hits in Splunk, while findings keep file,
line, rule, severity and confidence.
Vendor contracts that fail quietly
Section titled “Vendor contracts that fail quietly”These are the reason the sinks are not interchangeable, and each was measured rather than
assumed. All are covered by ObservabilityEgressVendorContractTest.
- Splunk HEC takes epoch seconds as a plain decimal. Jackson writes
timeMs / 1000.0as1.786E9; HEC cannot parse an exponent and rejects the whole batch with400 {"code":15,"text":"Error in handling indexed fields"}— a message that names indexed fields and never mentions time. UseBigDecimal. - Splunk indexer acknowledgment. A token with ack enabled — the default in most
enterprise estates — rejects a channel-less request with
400 {"text":"Data channel is missing","code":10}.X-Splunk-Request-Channelis sent on every push (measured safe on non-ack tokens), derived per workspace so tenants never share an ack queue. - A missing Splunk index is silent data loss. HEC answers
200 Successfor an index that does not exist and discards the events: one typo gives a green card, a rising accepted count and nothing in Splunk. The connection test checks the index over the management API and returns awarning. Indexer ack does not help — a push to a missing index still ackstrue, because the ack covers queueing, not indexing. - Dynatrace
200means partial success, not success — “only a part of input events were ingested”. Only204is clean. Itstimestampmust be ISO-8601 or a Unix time as a string; a bare millisecond number is ambiguous. - Datadog drops anything older than 18h and still returns
202. With the 24h default backfill that silently loses the oldest six hours of the very first export. The window is clamped per vendor (Datadog 18h, New Relic 48h); no cap is invented for vendors that publish none. - New Relic caps a POST at 1 MB. Chunking is bounded by bytes as well as by the 200-record count, since a wide batch crosses it and the whole request is refused.
- Elastic
@timestampmust be a string. Sent as epoch millis, Elasticsearch dynamic-maps it aslong, and the range query Kibana issues for every time filter then fails withfailed to create query: For input string: "now-30d"— the exported index cannot be used as a time-series index pattern at all. ECS requires a date regardless. Upgrading: an index written by an earlier build must be deleted or rolled over; the bulk error now says so. insecureTlsneedsX509ExtendedTrustManager. A plainX509TrustManageris wrapped by the JDK, which then performs the hostname check itself, so a stock self-signed cert (Splunk’sSplunkServerDefaultCert, Elastic’s container-id CN) is still rejected.SSLParameters.setEndpointIdentificationAlgorithmis ignored byjava.net.http.HttpClient, andjdk.internal.httpclient.disableHostnameVerificationis process-global — both were measured and rejected. Shared client:VendorHttpClients.
Failure accounting. A partial batch reports exactly how many records the vendor accepted, and the cursor advances by that many — so a mid-batch outage re-sends the tail rather than losing it. A read failure never advances the cursor. Vendor errors are sanitised before they reach the connection card, so a token can never be echoed back.
Verification status
Section titled “Verification status”Splunk Enterprise 10.4.2 and Elasticsearch 8.15 (TLS on) are verified live, both
directions, all ten record kinds. Datadog, Dynatrace and New Relic are SaaS-only and are
verified against a strict simulator of their published contract — including the silent
behaviours — which is not the same as a live tenant, and cannot catch anything the docs
get wrong. Closing that needs a trial tenant for each. Splunk Cloud (as distinct from
Enterprise) is also unverified: same wire format, different HEC hostname
(http-inputs-<stack>.splunkcloud.com), and HEC must be enabled for the stack.
The Discord channel carries the whole product, not just Warden (2026-10-01)
Section titled “The Discord channel carries the whole product, not just Warden (2026-10-01)”The workspace Discord channel shipped as an AI Warden surface (verdicts in your own Discord channel). The backend now sends to the same channel for the rest of the product, so a team gets one feed rather than one integration per subsystem.
| Lives in | service/discord/ — DiscordEvents (wording), DiscordDispatcher (sending), DiscordMessage (shape), DiscordCategory (the vocabulary) |
| Configure | Settings → Notifications → Discord (moved from Integrations on 2026-10-07) (Maintainer or Owner) — the same row, same webhook |
| Which events | discord_notifications.categories, a CSV of category keys |
The catalogue
Section titled “The catalogue”| Category | Events |
|---|---|
analyzer | a scan finished — coverage, and the critical / medium / low counts |
generator | log generation succeeded (with the logs added) or failed |
gatekeeper | a CI verdict — allowed or blocked, coverage against the threshold, the commit |
agents | a collector agent stopped checking in, recovered, or is failing to ship |
security | a CI key was minted or revoked, and project deletion |
warden | verdicts, still sent by the relay |
Every message is # category then ## event
Section titled “Every message is # category then ## event”DiscordCategory owns the heading text and DiscordMessage owns the shape, so a new event
cannot invent its own layout. The embed deliberately carries no title — a title next to the
heading renders as two notifications for one event. Colour carries severity, never category:
a reader scanning a channel is asking “how bad is this”, and the category is already the heading.
Which categories are on: null, empty and the ALLOW exception
Section titled “Which categories are on: null, empty and the ALLOW exception”categories has no database default, and that is load-bearing. null means “never
configured” and falls back to the defaults; an empty string means the customer unticked
every box. A column default would erase the distinction and make unticking everything turn every
notification on — the one action whose intent is unambiguous. The same three states run up
through the API: an absent categories key in the PUT body leaves the setting alone, exactly
as the boolean toggles do.
ALLOW is not a category. It stays on notify_allowed because its safe default is false,
the opposite of every category’s. Folding it in would have given it the wrong default.
Agent health: transitions, and one UPDATE that is both the state change and the permission
Section titled “Agent health: transitions, and one UPDATE that is both the state change and the permission”AgentHealthNotifier sweeps the collector agents on a schedule. Four properties, each of which
a plainer implementation gets wrong:
- There is no ShedLock here, so every
@Scheduledmethod runs on every replica. A Discord message is not idempotent, soAgentRepository.claimHealthStateis a conditionalUPDATEthat is both the state change and the permission to speak — whichever replica’s write lands is the one that posts. A separate check-then-send would post once per replica. - Notifications are about transitions, not states. Keyed on the state alone, a dead host posts once a minute for ever, which is how a channel teaches people to mute it.
- An agent that has never checked in is PENDING, not offline. It has no
last_seen_at, and reading that as down would alert on every registration. - The stale threshold is three of the agent’s own check-in cycles, clamped to 1h–24h
(
logrite.discord.agent-stale-min-ms/-max-ms) — not a constant. A 5-minute agent and a 12-hour agent are not late at the same age.
Where the scan notification is wired, and why not next to the email
Section titled “Where the scan notification is wired, and why not next to the email”scanCompleted is called outside the CompletableFuture that sends the report email. That
block is gated on the user’s email preferences, while a Discord channel is a workspace
setting — inside it, a workspace would stop receiving notifications because one member
unsubscribed. For the same reason ProjectController captures the workspace id before
deleting the project: afterwards there is nothing left to resolve it from.
What is deliberately not sent
Section titled “What is deliberately not sent”| Not sent | Why |
|---|---|
| Agent CPU / memory alerts | There is no telemetry to threshold — AgentHostReader exposes a core count, nothing sampled. |
| New-device login | AppUser.workspaces is many-to-many, so there is no single right channel; fanning it out would publish one engineer’s device to every team they belong to. |
| A separate critical-issues message | Two messages per scan is how a channel trains people to ignore the second. The counts are in the scan message. |
Gatekeeper: the server owns the threshold now (2026-10-01)
Section titled “Gatekeeper: the server owns the threshold now (2026-10-01)”POST /api/v1/gatekeeper/check used to require logCoverageThreshold (@NotBlank). A pipeline
that had not created the GitHub Actions variable LOG_COVERAGE_THRESHOLD therefore got a 400
from a field validator, which reads as “LogRite is broken” rather than as “you have not set a
variable in your own repository”. It was one of three things a customer had to configure before a
single check could run, and the server already knew a sensible value.
The field is now optional. GatekeeperThreshold.resolve() is the one place that decides what a
check is measured against:
| The caller sends | Applied | thresholdSource |
|---|---|---|
| nothing (field absent) | gatekeeper.default-coverage-threshold | server |
"" — an unset Actions variable | the same default | server |
"90" | 90 | request |
"0" | 0 — “report, never block” | request |
"eighty" | nothing: 400, exactly as before | — |
Workspace threshold (2026-10-15). Between the request and the server property sits the
workspace setting gatekeeper.coverageThreshold (0–100, catalogue default 80). Precedence:
a value in the request → a stored workspace row (only a row actually saved; the catalogue
default does not count) → gatekeeper.default-coverage-threshold. The workspace is the one the
prior scan was resolved in (see below);
the stored value reports thresholdSource: "workspace" (Source.WORKSPACE).
Admins set it in Rules & Policies → Where rules apply (“Pull-request gate”). In the table above,
“nothing” and "" therefore apply the workspace row when one exists.
The response carries the numbers
Section titled “The response carries the numbers”Three fields were added, and they are not decoration. The pipeline no longer holds the threshold, so a bare “coverage is below the threshold” would be a verdict the customer cannot check, act on, or tell apart from a misconfiguration — strictly worse CI output than what it replaced.
| Field | Meaning |
|---|---|
coveragePercent | what the scan measured |
threshold | what was actually applied, not necessarily what was sent |
thresholdSource | request or server — answers “why 80?” without a support conversation |
They are strings, because a shell script prints them verbatim and what the server compared is
more useful than a float round-trip’s 79.99999999999999.
A missing measurement is not 0%
Section titled “A missing measurement is not 0%”check compared getStartLogCoveragePercent() without a null guard, so a scan that measured
nothing threw an NPE. The outcome was accidentally right — the caller saw a 5xx and reported
“could not be checked” — but it was unreadable in the log, and the other reading is the dangerous
one: no measurement is not zero coverage, and treating it as a block would open an auto-fix
pull request against a scan that never looked at anything. It is now an explicit 500 naming the
commit.
Configuration: gatekeeper.default-coverage-threshold, env
GATEKEEPER_DEFAULT_COVERAGE_THRESHOLD, default 80.
Gatekeeper finds the prior scan in the caller’s workspace (2026-10-16)
Section titled “Gatekeeper finds the prior scan in the caller’s workspace (2026-10-16)”From 2025-11-27 (e9731877) until this change, POST /api/v1/gatekeeper/check answered 404 for
every repository on both estates. It looked the prior scan up with an exact, case-sensitive
branch_link = repoUrl, but every writer stores https://host/owner/repo/tree/branch
(Bitbucket: /branch/) and the request DTO only accepts a bare https://host/owner/repo(.git)?/?.
The two sets never intersect, so every pull request using the served workflow went red with
“Coverage was NOT checked”.
The lookup now happens once, in GatekeeperAuth.authorize, and returns a GatekeeperTarget
(scan, workspace id, repo owner, repo name) that the threshold and the scan both use:
- The URL is normalised: trimmed, every trailing
/removed, one trailing.gitremoved. Case is kept for the stored link, the match itself is case-insensitive. - It is a prefix match on
normalised + "/", so a scan of any branch of the repository counts. The request’sbranch(in the served workflow, the PR’s source branch, which has usually never been scanned) does not have to have a scan of its own. - It is scoped to the caller’s workspace. A CI key looks only in the key’s workspace. A JWT caller looks in each of their workspaces and gets the newest scan across them.
| Request | Before | Now |
|---|---|---|
| Repository never scanned | 404 | 404 |
| Scanned only by another workspace | 403 “this CI key is not bound to the workspace that owns this repository” (or 404) | 404 — no prior scan in your workspace |
| Scanned by the caller’s workspace, any branch | 404 | proceeds |
…/app.git/ vs …/App | 404 | same repository |
The 404 text is unchanged: “No prior scan found for repository ’…’. Run an initial scan on this repository before calling the gatekeeper.”
The scan the check persists now carries the real repoOwner / repoName from the request URL.
Before, runLogScan never set them, so a successful check would have stored
https://github.com/null/null/tree/<branch> (four such rows from an older writer exist on prod,
2026-09-02). findTopByBranchLinkOrderByRunDateDesc and GatekeeperService.workspaceOf are gone.
The block outcome still pushes a branch and opens a pull request on the customer repository, as
designed. To exercise the route without that, send "logCoverageThreshold": "0" — a request
threshold of 0 can never block.
The JobSearch demo repository runs it end to end (2026-10-19)
Section titled “The JobSearch demo repository runs it end to end (2026-10-19)”Deployd-io/JobSearch carries its own copy of the Action (.github/workflows/logrite-gatekeeper.yml
plus the Go plugin in logrite-plugin/), unchanged from 2025-11-27 and refused by the live route in
two ways until 2026-10-19 (JobSearch 509e46a, a109444):
- it sent no bearer. It now sends
Authorization: Bearer $LOGRITE_CI_KEY, read from theLOGRITE_API_KEYrepository secret — the name the key-mint response tells users to use — and ends with “no verdict” (exit 2) rather than a coverage failure when the key is missing or refused; - it sent
repoUrlashttps://github.com/<owner>/<repo>/tree/master, which the request DTO refuses with 400. It now sends the bare repository URL; the branch travels inbranch.
Its API_URL secret must be the full check URL (…/api/v1/gatekeeper/check): the plugin posts to
it as given, and the bare host answers 302. The secret holds a dev CI key minted for ws 3103
(“JobSearch GitHub Action”). Verified on PR #680: run 39237851488 ended STATUS: allow, exit 0.
Close-out changes (2026-10-09)
Section titled “Close-out changes (2026-10-09)”- Core-rule priorities are graded. SOC2: security monitoring Critical; authentication,
authorization, data protection High; database integrity, change management Medium; system
operations Low. HIPAA: incident response, access control Critical; audit logging,
authentication, transmission security High; data encryption, data integrity Medium.
CoreRulePriorityBackfillmoves catalogue rows still at the old value; saved project templates are untouched. The hard-coded BankJava statement filters inLevelTwoDtoBuilderwere removed. - Units.
ScanDetailsPageDTOaddsstatementCount(recommended log statements in all groups of the severity) andoutstandingStatementCount(those not yet added);totalItemscounts finding groups (one per file × logging rule). - No server paths over the API. Summary and details drop
checkoutFolderPathandabsFileNameand returnfilePathrelative to the checkout. The fullGET /scan-results/{id}used by the app is unchanged. - Refused before running.
run_compliance_scananswers FAILED withbranch_not_foundfor an unknown branch andscan_in_progresswhen a run is active for the project, before anything is marked RUNNING. - Templates. A requested template the project lacks is seeded from the global defaults (also
for a project with no rules); an unknown name with
strictTemplates(default true) fails withtemplate_not_configuredand scans nothing; PR events run non-strict and reportdroppedTemplates.appliedTemplatesis always filled; the label is the joined names (SOC 2+HIPAA). - No user GitHub token needed. When neither the request nor the owner has a GitHub token, the scan uses the GitHub App installation token for that repository (this scan only).
- An unbound project reports
log-analytics-<ws>-*/trace-analytics-raw-<ws>as its index binding.
Gates and auth
Section titled “Gates and auth”- Scripts refused for API keys and connected clients. On query actions through the OpenSearch
proxy, a body containing
script,script_score,scripted_metric,runtime_mappings,script_fieldsorderivedfrom an API key or delegated (MCP) JWT answers 400{"error":"bad_request","errorCode":"invalid_fields","message":"Scripts are not accepted from API keys or connected clients: remove '<key>' at <path>"}. Session JWTs (Discover derived fields, monitors) are unaffected. - Revoked OAuth grants stop at once. A delegated JWT whose grant is revoked answers 401
{"error":"grant_revoked"}(grant status cached 5 s, evicted on revoke). Consent views carryclientRegisteredAt. - GitHub.
AgentsFlowControllermaps GitHub 401/403 → 401, 404 → 404, no answer → 502 with a reference, never 500./bindkeeps the installation’s own account login and refuses a mismatchingorgLogin(400). Installation lists carryrepoNamePrefix. Listing and binding installations work with the stored GitHub token whenX-OAuth-Tokenis absent; with neither, 428github_sign_in_required. - Salesforce case responses carry
orgConnected; collector log/flag reads carryrevealRequestedandrevealed.
Warden and redaction
Section titled “Warden and redaction”RedactionPatterns gains mrn, phone, dob
(kinds); privacy settings, tool-order rules,
GET /api/v1/warden/judge, rule displayName, latency.wardenMs and the rule-set delete guard are
described on AI Warden.
Close-out fix wave 2 (2026-10-09)
Section titled “Close-out fix wave 2 (2026-10-09)”- Templates on project DTOs (K13). Project responses carry
templates: [name, logType, key]objects.TemplateNamesresolves spellings (PCI DSS,pci_compliance,pci,PCIDSS) to one template. - Generator-scan details. The approval POST stamps
logAdded: truemeaning “approved”, not “in the code”. Scan details now take outstanding counts per severity from the generator’s ledger and reportdetailsScope(backend 33f3a7f0). Open observation: the UI’s “Implemented” chips still readlogAdded. ScanRun.prNumberrecords the pull request a PR-event scan ran for.avgTotalMsis null when unmeasured.WardenStatsResponseno longer turns a missing average into0.outcomekeyword mapping in thewarden-requeststemplate (new indexes).
Audit changes (2026-10-10)
Section titled “Audit changes (2026-10-10)”Documented from backend (Logrite-AI) main at 730433e7; check /api/drift for the deployed
commit.
Project templates API (2026-10-10)
Section titled “Project templates API (2026-10-10)”A project’s framework templates can be listed, added and removed directly (the MCP
project_admin list_templates / add_templates / remove_template actions call these — see
MCP Server → Templates from MCP).
| Route | Answer |
|---|---|
GET /api/v1/project/{projectId}/templates | {"projectId", "templates": [{"templateId", "name", "key", "logType", "ruleCount"}], "addable": [{"key", "name", "logType", "ruleCount"}]}. addable is every log type with at least one global rule whose key the project does not already have, sorted by key (general included when missing) |
POST /api/v1/project/{projectId}/templates | Body {"frameworks": ["hipaa", "PCI DSS"]}: 1–20 non-blank names of at most 64 characters, else 400 invalid_fields naming frameworks. Answers {"projectId", "added": [{"templateId", "name", "key", "ruleCount"}], "existing": [...], "unknown": ["foo"], "addable": [...]}. Known names are added even when others are unknown |
DELETE /api/v1/project/{projectId}/templates/{templateId} | 204; 404 {"error": "template_not_found"} when the row does not exist or belongs to another project. Removing the last template is allowed — the next scan seeds general |
- Auth. A session JWT or an API key, plus access to the project.
POSTandDELETEalso need thepolicy-code-writerole floor (Developer by default; a workspace can raise it like the other floors), so a Viewer gets 403. Until 2026-10-21 this was a separatetemplate-writefloor over the same rows; it was removed (backend7db0d5a1). - Audit. An add with a non-empty
addedwritesproject_templates_added({projectId, added}); a removal writesproject_template_removed({projectId, templateId, name}) to the governance audit log. - The same floor on the older writers.
POST /api/v1/monitoring/enablerequirespolicy-code-writetoo. (POST /api/v1/config/analyzerruleshad the same floor until it was removed on 2026-10-15; scan profiles are written through/api/v1/policieswith the Developer floor.)
Scans add templates only after the request is known to be good
Section titled “Scans add templates only after the request is known to be good”POST /api/v1/scan/initiate plans template seeds before writing anything. If any requested name
resolves to no framework with global rules (strict requests), the scan is refused with
template_not_configured and nothing is saved. The refusal names the project’s templates, the
framework keys that can be added, and tells the caller to pass one in frameworks or add it with
project_admin add_templates. Two spellings of one framework (pci, pcidss) seed one row; the
same de-duplication applies to POST /api/v1/monitoring/enable.
InitiateScanResponse gains seededTemplates: the template names this request created (an empty
list when none, and always empty on a refusal).
Read-only API keys are enforced on every chain
Section titled “Read-only API keys are enforced on every chain”A key minted with access: "read" is refused writes with 403 insufficient_scope whichever
security chain is active. The check moved out of ApiKeyAuthenticationFilter (which exists only
when security.api-chain.enabled=true; prod runs with it unset) into ApiKeyAccessFilter, a plain
servlet filter ahead of the idempotency replay and of either chain. It judges only keys that verify;
an unknown, revoked or expired key passes through and the controller answers 401 as before.
Idempotency stores only final answers
Section titled “Idempotency stores only final answers”With an Idempotency-Key header the first answer is stored for 24 h and replayed to a retry with
the same key and body (Idempotent-Replayed: true). Now:
- only 2xx answers and 4xx answers a retry would repeat are stored — never a 5xx, a
408/409/423/425/429, or any answer carrying
Retry-After; - a stored answer keeps its
LocationandRetry-Afterheaders; - routes that mint a secret (API-key create/rotate, coding tokens, gatekeeper keys, Warden relay keys) are not covered, so a replay can never hand a secret out twice;
- a replay is given only while the caller’s credential still verifies; a revoked key or expired session gets the normal answer (e.g. 401) instead of the stored one.
Default branch and fork pull requests
Section titled “Default branch and fork pull requests”code_repository.default_branchstores GitHub’sdefault_branchat import (and fills it on an existing row that has none). It is exposed asrepository.defaultBranchonGET /api/v1/project/{id}and the workspace project list.- A scan with no ref uses the stored default branch (else asks GitHub), and the answer says
Scan started on the repository's default branch '<branch>'. - A scan of a pull request from a fork checks out
refs/pull/<n>/headat the PR’s head sha (fetched as+refs/pull/<n>/head:refs/remotes/origin/pr-<n>, detached); a same-repository PR still uses its branch name.
Smaller changes
Section titled “Smaller changes”POST /api/v1/project/enrolreportscreatedfrom the importer (an adopted or existing project iscreated: false) and no longer echoesbranch.- The MCP OAuth code exchange refuses a grant revoked between approval and exchange
(
invalid_grant, “the connection was revoked”). - Scan detail items carry
approvedandplacedseparately fromlogAdded; the scan log labels readPLACED,APPROVED,PRESENT(already in the code) orPENDING. Placement is not yet recorded per statement, soplacedis not written and the app shows “Approved”, never “Implemented”, for an approved method. - MCP connection rows carry
canUpgrade(the caller owns the grant). - Custom rule patterns (
CustomRulePatterns) are refused when a repeated group contains any repeat with an upper bound above 1, or when there are more than 3 unbounded repeats in total — the same rule as the analyzer (AS-14). /internal/warden/capture/verdict: a BLOCK setsverdict,outcome=BLOCKandreview.final=block; a NOTIFY setsoutcome=NOTIFYandreview.final=notifyand leavesverdictunchanged (ED-05).- PR events: a top-level
deliveryId(forwarded by the webhook fromX-GitHub-Delivery) is remembered for 24 h, up to 2,000 ids per JVM; a repeat answers 200IGNORED_DUPLICATE_DELIVERYand runs nothing. A request without adeliveryIdis never deduplicated (ED-12).