Skip to content

Backend

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.

SettingValue
TechnologyJava 17 + Spring Boot 3 + Maven
Port8080 in the container, published on host 9800 (leapfrogapi.deployd.io / dev-leapfrogapi.logrite.ai)
RepoDeployd-io/Logrite-AI
Runs asThe backend compose service on each VM

⚠️ 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/register and /api/v1/auth/verify-email were wrong the same way. The same mistake is recorded in ai-agents/agents-flow.mdx (v1f), where a chat tool 404’d against /api/v1/scans/{id} for exactly this reason. Check this file against api-surface.txt before trusting a row.

MethodPathDescription
POST/api/v1/auth/loginAuthenticate user, return JWT
POST/api/v1/auth/signupCreate 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-registerThe 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/refreshIssue a fresh JWT from a valid or recently-expired token (5-minute grace)
POST/api/v1/inviteInvite 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}/membersList 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}/templatesThe 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/codeFixesGenerate a code fix PR for a prior scan’s findings
POST/api/v1/codeFixes/autoAutonomous fix in one PR — build-verify repair loop + exception-handling hardening (PR only if it compiles)
POST/api/v1/code-analysisStatic full-source exception analysis → findings with class/method
GET/api/v1/config/log-typesEvery 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/templatesEvery format together with its rules, in one call — equivalent to /log-types followed by a /get-analyzerrules per format.
POST/api/v1/config/analyzerrulesRemoved 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/initiateCreate 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/analyzerThe 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/generatorInstrument 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/latestState of the most recent run (/scan-runs/{id} for one)
GET/api/v1/auth/workspaceList 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-bindingResolve a project’s authoritative serviceName + log/trace indexes (effective values)
PUT/api/v1/project/{id}/index-bindingSet/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/enrolCreate-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/checkInstrument 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/settingsCurrent user’s profile + capabilities (hasPassword) + working defaults (JWT subject)
PUT/api/v1/user/profileUpdate the authenticated user’s first/last name
POST/api/v1/user/change-passwordVerify 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/settingsPersist per-user working defaults (defaultBranch, defaultTemplateType, showWardenGuide)
POST/api/v1/user/account/deletePermanently 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/healthHealth check
GET/swagger-ui.htmlSwagger 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:

PassWhat goesProperty
Orphans<workspaceId>/<projectId> whose project id is no longer in that workspaceUnreachable whatever the disk budget says
BudgetOldest-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.

  • Project resolution is the caller’s. With projectId the project must be visible to the caller (404 otherwise). Without it, projectName matches the project name or repo full name (case-insensitive) among the caller’s projects — a key’s own workspace only — narrowed by projectUrl when that names a repo; several matches → the most recently scanned. It used to look the scan up by a hard-coded https://github.com/Deployd-io/<name>/tree/master link 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.

  • fileName must 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. skipped is true when the file was deliberately not instrumented — recommendedCode is then the input unchanged — and skipReason / skipMessage say 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 main

    skipReason and skipMessage are omitted when skipped is false. 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 }. hasPassword lets 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 — verifies currentPassword against the stored hash and sets newPassword. The new password must satisfy the shared password policy (see below) and must differ from the user’s last two passwords. Uses the BCryptPasswordEncoder bean (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 with 400.
  • PUT /api/v1/user/settings — persists defaultBranch, defaultTemplateType ("" | general | soc2 | hipaa), and showWardenGuide.

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 (or null when 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 in src/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 new password_history table (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 returns 400 — “New password must be different from your last two passwords.”
  • Hibernate ddl-auto:update creates password_history on boot; mirrored in resources/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 < 8 length 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).

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. Flips enabled, 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 }: confirmation must equal the account email or provider username; password (verified with BCryptPasswordEncoder) 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 the workspace_user join), only the user’s membership; the home workspace_id is 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).

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 (a LogScanResult id), so it now guards with ownershipService.requireScanAccess(scanId, req) — matching the sibling GET /api/v1/scan-results/keyFindings/{scanId}. It previously called requireLogGeneratorResultAccess(scanId), which looked the id up in the LogGeneratorResult key 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.removeOrphanedTimerDeclarations bounds “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 in TimerScopeRepair, telling this run’s lines from the customer’s by ThisRunLines:

    1. The customer’s original method already declares the timer name (start, or __logStart in 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 as TIMER_NAME_COLLIDES.
    2. 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.
    3. A reader with no declaration in view gets this run’s first declaration, hoisted to the first body statement: after super(…) or this(…), 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 RepairChain and again after the orphan sweep as a backstop.

  • LocalDeclarationIndex recognises a declaration by an assignment pattern, so account == null no longer counts as one. AnchorWrite defers only timer declarations, in FIFO order.

  • ChainGuards.skipDbLogging looks for the method’s timer from the method’s declaration (LocalDeclarationIndex.methodDeclarationIndex), not from its >>> Entering line. A timer keyed above its own entry log therefore still counts, so its duration log is not dropped as timer-less (BankJava saveToken, integration commit db9b456).

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 return whose 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, or logriteResult when result is 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.
  • 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 are UNRECOVERABLE_TYPE, VOID, CONSTRUCTOR, WILDCARD, LAMBDA, ANONYMOUS_CLASS, SWITCH (a switch arm or yield) and C_STYLE_ARRAY.
  • A return whose 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.undo reverses 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.chainable refuses to pull a timer reader (DurationLogChaining.readsTimer) forward when the pull would jump a statement in its own block. BankJava updatePin shipped find query executed in {} ms above the find.

    • When the jumped lines leave the block (}, End If, catch, a dedent), or include a return or throw, 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; BankJava saveToken).
    • AnchorWrite’s timerStartedAtThisAnchor stays Python-only. Enabling it for .java lost three timer pairs in the goldens.
  • An exit log is not hoisted into a guard clause. For <<< Exiting only, TerminalHoister.indexOfLastTerminator(lines, stopAtGuard) no longer steps over a closing brace whose block can be skipped (ReachableAfterBlock). Those blocks are an if with no final else, a Swift guard … else, and a loop that can end. BankJava validatePin, validatePassword and validateAmount had their exit log inside if (…) { 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. LogIdentity keys a logger call with its level token replaced, so log.trace(…) and log.debug(…) with the same receiver, message and arguments are one log. WriteGuards (sieveSharedKey, isDuplicate) and TerminalHoister.hoistAboveTerminal use it, so a TRACE re-run over a DEBUG-instrumented file refuses the twin as ALREADY_IN_SOURCE. JobSearch run 1567 wrote 11 such twins. The receiver still matters (log.debug is not logger.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.hoistTrailingLogsAboveFinalTerminal and relocateVarTrackBeforeFirstAssignment take the original file from RepairChain. repair/LineOwnership carries ThisRunLines’ 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 commit 8193d09, 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.
GuardRuleSwitch
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 Lombokbuild-tooling-exclusion
Lombok presenceA 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 filesAfter 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 scriptsA .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 commentsA 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 exemptblock-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.

NameEnvironment variable
build-tooling-exclusionLOGRITE_INJECTION_BUILD_TOOLING_EXCLUSION
lombok-classpath-checkLOGRITE_INJECTION_LOMBOK_CLASSPATH_CHECK
scaffolding-restoreLOGRITE_INJECTION_SCAFFOLDING_RESTORE
classic-script-loggerLOGRITE_INJECTION_CLASSIC_SCRIPT_LOGGER
block-comment-vetoLOGRITE_INJECTION_BLOCK_COMMENT_VETO

Known gaps:

  • Only the scan path skips a NO_LOGGER_ON_CLASSPATH file. 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).
ReasonKindMeans
INSIDE_BLOCK_COMMENTdropThe 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_CAPTUREdropAn 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_COLLIDESdropThe 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_CLASSPATHskipped fileA 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):

  1. the blamed log;
  2. the logs of the enclosing function or block (util/gate/EnclosingBlock);
  3. 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 in pruneOneFile, GateRescue, LastChancePrune and DotNetLogPruner targets 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 start on a line that reads or arms it. GateTimerRepair repairs that (ensureTimerInScope) and the file is checked again before anything is pruned;
      • a variable the original file declares, meaning a local referenced out of scope.
    • 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: BankJava AccountServiceImpl has 111 pristine and 185 instrumented. A cap of 100 hid everything below the cut, including a variable start.
  • Scaffolding-only restore. After the gate prunes a file, GateScaffolding.restoreIfOnlyScaffolding checks the file out again when this run’s surviving lines are all logger scaffolding and it gained no logger call. JobSearch #677’s CandidateDTO, JobContactViewDTO and JobDTO shipped with only import lombok.extern.slf4j.Slf4j; and @Slf4j after their logs were pruned. ScaffoldingRestore repeats 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. JavacDiagnostics parses Maven (in-process and forked), Gradle and raw javac output, with its symbol: line.
    • Each pass repairs every failing file, then runs a narrow verify compile under the verify deadline. Maven runs mvn -B -e -o compile -Dmaven.compiler.fork=true -fae; Gradle runs gradle 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.
  • 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):

#DiagnosticActionaction
1cannot find symbol naming a timer, on or ±1 from a this-run lineDeclare the timer in scope. If that changes nothing, prune the readerTIMER_REPAIRED / PRUNED
2unreachable statement on a this-run lineRelocate the log (BuildOutputRelocator)RELOCATED
3A return-value capture on or ±1 from a this-run line, whose undo removes none of the file’s original linesUndo the captureCAPTURE_UNDONE
4A this-run LogRite line at or ±1 from the reported linePrune it together with its method-scoped timer groupPRUNED
5Any other line of an instrumented file that compiled at baseline: our logger scaffolding or the customer’s own lineRevert that one file to HEAD. Deferred while other edits were made, except on the last passREVERTED / DEFERRED
6A line that does not exist, a path outside the checkout, or a file this run did not instrumentLeave itUNVERIFIED
–A file that already failed before instrumentationLeave itPRE_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.

PropertyEnvironment variableDefaultBounds
logrite.build.timeout-seconds.fullLOGRITE_BUILD_TIMEOUT_FULL_SECONDS1800Every full build the generator runs (mvn clean install, dotnet build, …)
logrite.build.timeout-seconds.verifyLOGRITE_BUILD_TIMEOUT_VERIFY_SECONDS600Each Java-gate verify compile
logrite.build.evidence-dirLOGRITE_BUILD_EVIDENCE_DIRempty: <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:

WhereWhat
run_details.buildVerifyran, 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 filesOne 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_detailsBuildFailureDetail 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 bannerBuildFailureBanner: 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:

  1. Discovers all build modules, then filters out lock-only npm/yarn false positives — a directory with a package-lock.json but no package.json (exactly the stub root lockfile a prior failed root build left behind, which itself fed the ENOENT loop).
  2. selectBuildRoots keeps 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 a backend/ Maven + frontend/ npm split) are each built in place with their own tool. Uses path-component-aware startsWith so app/ ≠ app-web/.
  3. 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 / --continue so 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 in TestConfig.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.
  4. Aggregates: the build passes only if every module passes; failedFiles is 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:

  1. detectBuildTool — after the root/marker/discovery passes find nothing, a new isAspNetWebFormsSite check (a Web.config, case-insensitive, or any .aspx/.ascx/.asax/.ashx/.master file in the tree) makes detection return dotnet instead of unknown, so the buildTool is labelled correctly.
  2. buildProject — when the tool is dotnet but the build dir has no .csproj/.sln to target (hasDotnetProjectFile is false), build-verification passes through with a Skipped build verification: ASP.NET Web Forms Web Site project … result instead of running dotnet build (which would fail MSB1003; 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 → table user_invitations (id, email, workspace_id, role, status, token, invited_by, created_at, accepted_at). Status lifecycle: PENDING → ACCEPTED (invitee signed up / was added) or REVOKED (Maintainer cancelled). One PENDING row per (email, workspace); re-inviting updates the role in place. Created by Hibernate ddl-auto: update on boot.
  • InvitationService owns the lifecycle: upsertPending, consumeForSignup (marks ACCEPTED + returns the role), markAccepted, listPending, revoke. VALID_ROLES lives 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_invitations row and the invite email.

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.

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).

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).

  • 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.
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",
"path": "/api/v1/project/3052/[email protected]"
}
  • Keeps the default top-level keys (timestamp/status/error/path) for backward compatibility and adds message (human, user-facing), errorCode (machine slug the FE can branch on), and optional details (per-field validation errors, or the exception type for a 5xx). details and any null field are omitted.
  • LOWEST_PRECEDENCE means the pre-existing AnalyzeExceptionHandler and all controller-local @ExceptionHandler methods keep precedence — no success path or business logic changes, only error responses are enriched.
  • Coverage: ResponseStatusException (surfaces its reason — the biggest win, since OwnershipService throws 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 in details, never leaked into message).
  • 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):

  1. NODE_OPTIONS=--max-old-space-size=3072 on the npm build command (BuildUtil). Raises V8’s heap ceiling above the auto-tuned ~768 MB.
  2. Backend container memory cap 1.5 GiB → 4 GiB (Coolify applications.limits_memory; docker update --memory to 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:&lt;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.

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):

Terminal window
C=$(docker ps -q --filter label=com.docker.compose.project=logrite --filter label=com.docker.compose.service=backend)
docker logs -f "$C" # logs
docker inspect -f '{{.State.Status}}' "$C" # status

Deploy 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:

Terminal window
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.

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):

Terminal window
cd services/backend/dev/Logrite-AI
mvn -f logwrite-api/pom.xml clean package -DskipTests

JWT-based authentication:

  • Tokens include userId, workspaceId, role
  • All endpoints (except /health, /auth/*) require Authorization: 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 the backend compose 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 methodWhere the JWT is mintedFrontend storage
Email/passwordAuthenticationController.authenticate → jwtService.generateToken(appUser) returns it in the /auth/login responsestate.user.token (Redux); reaches bearerToken.js chain via persist:user.token
GitHub SSOOAuth2AuthenticationSuccessHandler.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 SSOSame path as GitHub; oAuth2User.getClientType() == "bitbucket" branch in the same handlerSame 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 themeMedia query sentResult
darkallthe dark rules always apply
lightnot allvalid 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.

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.0 and Chrome/151.0.0.0 are 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 /24 and, 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 produced 2401:4900:8834:d456:…, 2401:4900:8834:2c5d:… and 2401:4900:8835:53ea:… — the /64 differs in all three and even the /48 moved, 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 the OR was 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:

  1. X-Refresh-Token response header (piggyback, no extra request). Any authenticated request made in the last 25% of the token’s lifetime causes JwtAuthenticationFilterOriginal to attach a freshly-issued JWT in the X-Refresh-Token response header. The frontend axios response interceptor in utils/constant.js reads this header and dispatches updateToken so subsequent requests use the new token — no separate network round-trip. CORS exposes this header via CorsConfig (exposedHeaders("X-Refresh-Token")).
  2. POST /api/v1/auth/refresh (polling fallback). The frontend SessionWatcher (in App.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.

POST /api/v1/codeFixes drives the “Fix it” action from the AI chat panel:

  1. Looks up the most recent LogScanResult for the project on any branch (findTopByBranchLinkStartingWithIgnoreCaseOrderByRunDateDesc, matching https://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 configured codeFix.baseBranch — the build-failure root cause.
  2. Checks out the branch that scan was actually run on (logScanResult.branchName, falling back to codeFix.baseBranch only when the scan didn’t record a branch), creates a fix branch, and dispatches each target file to the analyzer’s /code-fixes endpoint. The PR base is the same resolved branch.
  3. 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 of mvn “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.

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:

categoryAnalyzer fix mode
bug (default / null)Minimal exception/bug fix (null/bounds checks, error handling). Unchanged legacy behavior.
performanceOptimize the described hot path without changing behavior — eliminate N+1 queries, batch, paginate, cache.
complianceRedact 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:

  1. Build-verify repair loop (≤5 rounds): BuildUtil.buildProject → for each failing file, BuildUtil.resolveFailedFilePath locates the source and the exact compiler error is sent to the analyzer (/code-fixes with compilerError); the sanitized rewrite is written (rejected by the same isUnsafeFix guard if blank/truncated/fenced), then rebuild. Stops when green or when a round produces no safe fix.
  2. Static hardening pass (once green): for the target classes (explicit levels, else bounded discovery), /analyze-exceptions finds gaps and /code-fixes with mode:"preventive" adds the missing try-catch / null / auth guards.
  3. Final gate: a last buildProject MUST pass — otherwise no PR is opened and the error summary is returned. On success it reuses GitUtil.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.

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 by ddl-auto; null = legacy behavior, so existing projects are unchanged with no migration).
  • Project.effectiveServiceName() / effectiveLogIndex() / effectiveTraceIndex() return the explicit binding when set, else lower(projectName) + the shared log-analytics-logrite / trace-analytics-raw-logrite indexes.
  • GET /api/v1/project/{id}/index-binding → { projectId, projectName, serviceName, osLogIndex, osTraceIndex, explicitlyBound } (effective values). PUT the 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).

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 suggestion181
differ from it94
  — at the blanket INFO (70 → WARN, 21 → ERROR)91
  — hand-seeded WARN, suggestion ERROR3
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 WARN rows keep WARN. Telling a considered WARN from 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 rulewasnow
method_entry_loggingERRORDEBUG
method_exit_loggingWARNDEBUG
method_variable_trackingERRORDEBUG
database_loggingERRORINFO
external_service_calls_response_timesERRORINFO

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:

BodyStored
"learnerjay/ErpNet.C-"kept, trimmed
"", whitespace, or absentderived from repo_url → learnerjay/ErpNet.C-
"ErpNet.C-" (no owner)400 naming projectFullName
blank, and the repository has no usable URL400 — 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.

GET /api/v1/project/{id}/push-status → { canPush, method(oauth|app|none), platform, ownerLogin, ownerIsOrg, reason, connectOptions &#125;. 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:

RouteDirectionWhere it lives
A their forwarder carries our generated log linestheir app → forwarder → SIEMnothing to build
B we pushLogRite → vendorObservabilityEgressService
C their SIEM pulls on a schedulevendor → LogRiteObservabilityFeedController + the Splunk add-on
D notification webhook → HECmonitor → webhook → HECconfiguration, 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.

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.0 as 1.786E9; HEC cannot parse an exponent and rejects the whole batch with 400 {"code":15,"text":"Error in handling indexed fields"} — a message that names indexed fields and never mentions time. Use BigDecimal.
  • 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-Channel is 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 Success for 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 a warning. Indexer ack does not help — a push to a missing index still acks true, because the ack covers queueing, not indexing.
  • Dynatrace 200 means partial success, not success — “only a part of input events were ingested”. Only 204 is clean. Its timestamp must 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 @timestamp must be a string. Sent as epoch millis, Elasticsearch dynamic-maps it as long, and the range query Kibana issues for every time filter then fails with failed 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.
  • insecureTls needs X509ExtendedTrustManager. A plain X509TrustManager is wrapped by the JDK, which then performs the hostname check itself, so a stock self-signed cert (Splunk’s SplunkServerDefaultCert, Elastic’s container-id CN) is still rejected. SSLParameters.setEndpointIdentificationAlgorithm is ignored by java.net.http.HttpClient, and jdk.internal.httpclient.disableHostnameVerification is 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.

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 inservice/discord/ — DiscordEvents (wording), DiscordDispatcher (sending), DiscordMessage (shape), DiscordCategory (the vocabulary)
ConfigureSettings → Notifications → Discord (moved from Integrations on 2026-10-07) (Maintainer or Owner) — the same row, same webhook
Which eventsdiscord_notifications.categories, a CSV of category keys
CategoryEvents
analyzera scan finished — coverage, and the critical / medium / low counts
generatorlog generation succeeded (with the logs added) or failed
gatekeepera CI verdict — allowed or blocked, coverage against the threshold, the commit
agentsa collector agent stopped checking in, recovered, or is failing to ship
securitya CI key was minted or revoked, and project deletion
wardenverdicts, still sent by the relay

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 @Scheduled method runs on every replica. A Discord message is not idempotent, so AgentRepository.claimHealthState is a conditional UPDATE that 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.

Not sentWhy
Agent CPU / memory alertsThere is no telemetry to threshold — AgentHostReader exposes a core count, nothing sampled.
New-device loginAppUser.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 messageTwo 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 sendsAppliedthresholdSource
nothing (field absent)gatekeeper.default-coverage-thresholdserver
"" — an unset Actions variablethe same defaultserver
"90"90request
"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.

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.

FieldMeaning
coveragePercentwhat the scan measured
thresholdwhat was actually applied, not necessarily what was sent
thresholdSourcerequest 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.

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 .git removed. 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’s branch (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.
RequestBeforeNow
Repository never scanned404404
Scanned only by another workspace403 “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 branch404proceeds
…/app.git/ vs …/App404same 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 the LOGRITE_API_KEY repository 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 repoUrl as https://github.com/<owner>/<repo>/tree/master, which the request DTO refuses with 400. It now sends the bare repository URL; the branch travels in branch.

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.

  • 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. CoreRulePriorityBackfill moves catalogue rows still at the old value; saved project templates are untouched. The hard-coded BankJava statement filters in LevelTwoDtoBuilder were removed.
  • Units. ScanDetailsPageDTO adds statementCount (recommended log statements in all groups of the severity) and outstandingStatementCount (those not yet added); totalItems counts finding groups (one per file × logging rule).
  • No server paths over the API. Summary and details drop checkoutFolderPath and absFileName and return filePath relative to the checkout. The full GET /scan-results/{id} used by the app is unchanged.
  • Refused before running. run_compliance_scan answers FAILED with branch_not_found for an unknown branch and scan_in_progress when 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 with template_not_configured and scans nothing; PR events run non-strict and report droppedTemplates. appliedTemplates is 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.
  • 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_fields or derived from 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 carry clientRegisteredAt.
  • GitHub. AgentsFlowController maps GitHub 401/403 → 401, 404 → 404, no answer → 502 with a reference, never 500. /bind keeps the installation’s own account login and refuses a mismatching orgLogin (400). Installation lists carry repoNamePrefix. Listing and binding installations work with the stored GitHub token when X-OAuth-Token is absent; with neither, 428 github_sign_in_required.
  • Salesforce case responses carry orgConnected; collector log/flag reads carry revealRequested and revealed.

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.

  • Templates on project DTOs (K13). Project responses carry templates: [name, logType, key] objects. TemplateNames resolves spellings (PCI DSS, pci_compliance, pci, PCIDSS) to one template.
  • Generator-scan details. The approval POST stamps logAdded: true meaning “approved”, not “in the code”. Scan details now take outstanding counts per severity from the generator’s ledger and report detailsScope (backend 33f3a7f0). Open observation: the UI’s “Implemented” chips still read logAdded.
  • ScanRun.prNumber records the pull request a PR-event scan ran for.
  • avgTotalMs is null when unmeasured. WardenStatsResponse no longer turns a missing average into 0.
  • outcome keyword mapping in the warden-requests template (new indexes).

Documented from backend (Logrite-AI) main at 730433e7; check /api/drift for the deployed commit.

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).

RouteAnswer
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}/templatesBody {"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. POST and DELETE also need the policy-code-write role 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 separate template-write floor over the same rows; it was removed (backend 7db0d5a1).
  • Audit. An add with a non-empty added writes project_templates_added ({projectId, added}); a removal writes project_template_removed ({projectId, templateId, name}) to the governance audit log.
  • The same floor on the older writers. POST /api/v1/monitoring/enable requires policy-code-write too. (POST /api/v1/config/analyzerrules had the same floor until it was removed on 2026-10-15; scan profiles are written through /api/v1/policies with 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.

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 Location and Retry-After headers;
  • 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.
  • code_repository.default_branch stores GitHub’s default_branch at import (and fills it on an existing row that has none). It is exposed as repository.defaultBranch on GET /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>/head at 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.
  • POST /api/v1/project/enrol reports created from the importer (an adopted or existing project is created: false) and no longer echoes branch.
  • The MCP OAuth code exchange refuses a grant revoked between approval and exchange (invalid_grant, “the connection was revoked”).
  • Scan detail items carry approved and placed separately from logAdded; the scan log labels read PLACED, APPROVED, PRESENT (already in the code) or PENDING. Placement is not yet recorded per statement, so placed is 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 sets verdict, outcome=BLOCK and review.final=block; a NOTIFY sets outcome=NOTIFY and review.final=notify and leaves verdict unchanged (ED-05).
  • PR events: a top-level deliveryId (forwarded by the webhook from X-GitHub-Delivery) is remembered for 24 h, up to 2,000 ids per JVM; a repeat answers 200 IGNORED_DUPLICATE_DELIVERY and runs nothing. A request without a deliveryId is never deduplicated (ED-12).