The per-milestone delivery log, moved out of CLAUDE.md on 2026-09-04 when that file passed the
150k-character limit that Claude Code loads into every session. Oldest first, newest at the bottom.
How to maintain it: a new milestone gets a new entry here, and the ## Status snapshot in
CLAUDE.md is rewritten to describe the new current state — never appended to. Each entry records
what shipped, what the review rounds found, and the traps worth not rediscovering. The design itself
lives in docs/, the locked decisions in docs/DECISIONS.md, and claims no test establishes in
docs/UNVERIFIED.md.
-
Phase 0 delivered:
spire-contract(pure domain lib: events, commands, hand-rolled Decider/View/Saga, SPI ports,ReviewLifecycledecider + GWT tests) andspire-orchestrator(single-process pipeline over SmallRye in-memory channels, Postgres event store with optimistic concurrency, live WebSocket timeline dashboard). -
Phase 1 feature delivered (single-process):
spire-diff(unified-diff parser with dual line numbers, token clip, prompt renderer, anchor resolver; PR-Agent studied as prior art, no upstream code used),spire-scm-bitbucket(real Bitbucket CloudScmIngresswith HMAC verify + bot-drop + /command parse,DiffSource,CommentSinkper SCM-MAPPING),spire-llm(LangChain4j OpenAI-compatibleLlmProvider, injection-fenced review prompt, lenient findings parser), orchestrator wiring (/webhooks/bitbucketreturning 202, realReviewWorkerwith comment_idempotency insert-before-post + stale-run pre-check, dev/test stub togglesspire.scm.stub/spire.llm.provider— real SCMs/LLMs are the UI registry, not config). Exit criterion green:BitbucketWebhookE2ETest— signed webhook → real adapters (WireMock Bitbucket) → inline+summary posted exactly once, duplicate delivery posts nothing. -
Phase 1 code-reviewed: 4-agent review (security-officer, code-reviewer, rules-compliance, qa); all 15 findings fixed — recovery-aware comment idempotency (reclaimable NULL claims + id reuse), per-finding post isolation + retryable classification, PR-head re-check before LLM/post, prompt-fence sentinel neutralization, host-pinned redirects, HTML-escaped model output, unordered dispatcher. Semgrep clean. 86 tests green across 17 suites (incl. webhook E2E + idempotency integration). Two LOW items tracked in
techdebt/. -
P1 service split delivered: three deployables over the Kafka protocol —
spire-gateway(webhook -> cs.integration, :34081),spire-orchestrator(deciders/sagas/event store/dashboard, cs.commands + cs.events, :34080),spire-review-worker(cs.commands -> adapters -> cs.results, ownworkerschema for comment_idempotency, :34082). Wire format = polymorphic JSON (type discriminator on the sealed hierarchies); everything keyed by reviewId. The ADR-013 stale-run guard lives in the orchestrator's ResultSaga (it owns the aggregate); the worker keeps the PR-head re-check. Split tests per service run against Testcontainers Kafka + Postgres (gateway webhook->topic, orchestrator choreography incl. stale-drop, worker command->result incl. idempotent redelivery). Redpanda in docker-compose at :34092. -
Split code-reviewed (4 agents) and hardened: poison records never kill consumers (never-throw deserializers) and processing failures go to cs.dlq (never silently dropped, ADR-013); repo/prId derive from the reviewId itself (
ReviewIds.parse— no in-memory registry, nothing lost on restart); ordered per-partition dispatch (no same-review races); the paid LLM call has its own idempotency claim (no duplicate spend on redelivery); gateway awaits broker acks before the 202 (Bitbucket retries on failure) and holds only the webhook secret + bot account id (never the App Password); work queues uselatestoffsets (no side-effect replay for new groups); per-service HTTP port vars. Semgrep clean. -
Encryption at rest delivered: event-store payloads and provider secrets are Tink AES-256-GCM encrypted with AAD binding (stream id / provider id / workspace); the base64 Tink keyset (
SPIRE_ENCRYPTION_KEYSET) is the single fail-fast bootstrap secret. Legacy plaintext rows (key_id='none') still read back. -
Provider registry + multi-SCM delivered: encrypted provider registry with Settings -> Providers CRUD (
ProviderResource; secrets never returned —hasSecretonly), bot identity auto-resolved from the token on save (IdentitySource/ProviderIdentityResolver),spire-scm-githubadapter (client, diff source, comment sink), review modesSPIRE_REVIEW_MODE=active|observe, bounded auto-retry per review (SPIRE_REVIEW_MAX_ATTEMPTS, ADR-016), per-provider PR-author allowlist in the DB. -
spire-uidelivered: React/Vite dashboard (reviews list/detail with live WebSocket updates, Register PR dialog, provider settings) against the orchestrator's REST + WS APIs; vitest +tsc --noEmitin CI-shape, dev server on:34000(UI_PORT). -
Full-project review hardened (2026-07): 4-agent audit, all findings fixed — SSRF guard on provider base URLs (https + public host enforced;
spire.security.allow-insecure-provider-urlsrelaxes only in%dev/%test), provider-neutralScmApiExceptionso GitHub 404/5xx/429 classify like Bitbucket, outbound Kafka publishes await broker acks (failures DLQ or 5xx — never silent),events-inDLQ'd instead of ignored, LLM idempotency marks-before-emit with persisted-result re-emit on redelivery (no duplicate spend, no stall), per-call LLMmaxTokenscap,review_event.seqrace closed (V6 unique constraint + atomic insert), redirect hardening in SCM clients (GET-only, private-IP guard, port-normalized auth pinning), structured JSON logging + reviewId MDC (prod profile), UI URL-scheme guard + fetch-race fixes, npm audit 0 vulnerabilities. -
First ContextProvider — Jira delivered (B6, 2026-07-08): the P1 context stub is now the real aggregator.
spire-context-jira(framework-free, JDK HttpClient + Jackson, SSRF-guarded like the SCM adapters) resolves PR-referenced issue keys (PROJ-123, parsed from title/branch at diff-fetch) intoContextItems via the Jira v2 REST API (baseUrl-driven Cloud + Data Center, basic/bearer).ContextWorkerfans out to the supported providers under a bounded 20s timeout and persists the assembled context encrypted (Tink, AAD=reviewId) to a PostgresBlobStore(worker.context_blob);ReviewWorkerloads it into the untrusted-fenced prompt slot. Credentials live in a new encrypted context-provider registry (Settings → Context,/api/context-providers, global default) brokered per-command like SCM/LLM (ADR-015). Blob deletion is keyed byreview_idat all three sites (delete, re-run, re-assembly) — no orphans. Per-instance project keys (ACME) narrow candidate keys; a live connectivity check (/{id}/check) and a preview/test endpoint (/{id}/preview— resolve a ticket number via the pattern and show the exactContextItema review would inject) back the Settings → Context UI. -
Unified keyed webhook ingress (2026-07-16): all three SCMs now share the gateway's per-repo registry edge
/webhooks/{provider}/{key}(key resolves the encrypted per-repo secret + scope fromwebhook_repo).GitLabIngressadded (X-Gitlab-Tokenconstant-time compare — GitLab does not sign the body — + Merge Request / Note translation;update⇒UPDATED only whenoldrevis present) withGitLabWebhookResource; the sharedRegistryWebhookEdge(resolve→verify→translate→scope→publish) backs the GitHub, GitLab and Bitbucket resources. The legacy single-secret/webhooks/bitbucketedge was removed (WebhookResource+GatewayScmProducer+SPIRE_SCM_BITBUCKET_WEBHOOK_SECRET) — Bitbucket now registers like the others (bitbucket-cloudprovider). Dev webhook exposure: opt-in Cloudflare quick-tunnel service (--profile tunnel) forwarding togateway:39281. -
Re-review reconciliation delivered (ADR-019, 2026-07-18): a follow-up commit now reconciles instead of blindly re-reviewing — the orchestrator command-carries the last posted run's snapshot (
PriorRun, fromreview_status.posted_findings_json, commit-guarded) intoGenerateReview; the worker runs a claim-guarded reconcile call (prior findings + threads + incremental diff viaDiffSource.fetchCompareDiff, full-diff fallback on force-push) for per-finding verdicts, then the review call with an exclusion list.PostCommentsresolves-then-replies closing verdicts (Bitbucket degrades to reply-only), always repliesSTILL_OPEN, updates the summary in place. V19; UI card. -
GitHub integration finalized (2026-07-21..22): a live-use audit's 12 findings are closed — 403/GraphQL
RATE_LIMITEDnow classify as retryable (was 429-only), backed by a throttled, Retry-After-aware inline-posting backoff;/reviewPR comments force a re-run; draft PRs skip untilready_for_review(SPIRE_REVIEW_DRAFT_PRSrestores always-review); OLD-side/multi-line anchors and honest 406/pagination failures; plain PR comments now get conversational answers in the summary thread. GitLab/Bitbucket parity tracked in ROADMAP.md item 13. -
PR-12 review-fix batch (2026-07-22..23): reviews-list rows now show reconciled open-finding counts and cumulative LLM cost instead of overwritten last-run columns;
STILL_OPENdowngrades toUNCHANGEDat hunk (not file) granularity; the Findings card shows an in-progress state instead of a false clean; a transientansweringflag (V21) drives a responding indicator. -
PR-state badge (2026-07-23): a distinct
pr_state(OPEN/MERGED/CLOSED, V22) on the review read-model, set from the open/close webhook events across all three SCMs, shown as its own badge separate from the review status; cancel-on-close is unchanged. -
GitLab + Bitbucket full-flow parity (2026-07-23, ROADMAP item 13): both adapters brought to the finalized GitHub adapter's feature set so the full loop (webhook → review → conversation → reconciliation) can be manually tested on each.
GitLabCommentSink/BitbucketCloudCommentSinknow implementThreadSource(the sharedFollowUpWorker/ConversationSagawere untouched — the conversation loop lights up purely via theinstanceof ThreadSourcegate); GitLab uses a discussion-vs-plain-note 404 fallback for summary-thread reads/replies, Bitbucket rebuilds the comment subtree byparent.id. GitLab ingress now emitsAuthorRepliedfor non-command MR notes (threaded→topLevel=falsekeyed todiscussion_id, individual→topLevel=true); Bitbucket setstopLevel=truefor plain PR comments. Draft/WIP skip now covers all three SCMs (reusesspire.review.draft-prs; GitLab handles the draft→ready flip, Bitbucket the non-draftpullrequest:updated). GitLab/Bitbucket API exceptions now carryretryAfterSeconds(Retry-After, plus GitLabRateLimit-Resetepoch fallback). GitLab posts NEW-side multi-line findings as aposition.line_range. Bitbucket inline stays single-anchor (API constraint). New runbook Mode F (GitLab webhook) + conversation/reconciliation steps for GitLab/Bitbucket. WireMock-tested per adapter; live testing is the operator's runbook pass. (Two claims here were superseded on 2026-07-25 — see the next entry: Bitbucket reconciliation is no longer reply-only, and the compare-direction gate is settled live.) -
Parity verified live on two SCMs + 10 fixes (2026-07-25): the full loop was run end-to-end on a real GitHub PR and a real Bitbucket PR with identical file content — 11/11 scenarios on both (runbook Mode G,
docs/SMOKE-TEST.md). Everything the run exposed is fixed, each with tests:- Cross-provider resolution. A workspace name registered on two SCMs (a GitHub org and a
Bitbucket workspace both
artyomsv) resolved to the oldest provider, cross-wiring SCMs. The conversation saga, the self-loop guard and the thread-refetch endpoint now shareReviewProviderResolver, which disambiguates by the review's storedprovider_type— the way the credential path already did. - Conversation is keyed to its root thread (V24
review_thread.root_ref). Bitbucket threads by immediate parent, so a reply to the bot's own answer carried that answer's id: multi-turn died after one exchange, the turn counter never accumulated (the cap could not fire), later turns were stored under a non-finding ref, andfetchThreadsaw only the last branch. Answers are now linked to their conversation root, and turn counting / ownership / the command'sthreadRef/ event attribution all normalize to it — the single stable id GitHub'sin_reply_to_idalready gave us. - Bitbucket thread resolve (
POST .../comments/{id}/resolve) — a fixed finding now shows Resolved, not just the SCM's auto-Outdated badge. GitHubresolveThreadno longer reportsALREADY_RESOLVEDwhen it matched nothing (a fakeresolved:truethat silently skipped both the resolve and the reply); it degrades honestly to reply-only. - Re-posted findings reconcile against their current thread. Several
review_threadrows can share one anchor across rounds; the loc→thread index kept an arbitrary older id and a stale carried ref won over the live one, so verdicts targeted an already-resolved thread. Newest id per loc wins. - Bitbucket
@{account_id}mentions are recognized (it renders no@loginin raw text), so an @-mention on an unflagged line engages the bot as on GitHub. - Follow-up replies must fence code — the locked FOLLOWUP contract said "no markdown fences", so the model indented code and Bitbucket rendered it as prose.
- UI: general-discussion threads re-fetch their full text (they showed only the ≤160-char
preview) with the opening message always visible and replies behind the toggle; a finding's
conversation no longer also appears under General discussion (both cards now derive from one row
set); a reconciliation reply is reachable from the findings card ("View thread"); the
responding…pill wraps instead of overflowing the reviews table. - Diagnosability:
ConversationSaganow logs every decision with its factors (level/authorAllowed/threadIsOurs/mentioned/priorTurns) — the skips used to reach only the dashboard, which is why several of these bugs were invisible.
- Cross-provider resolution. A workspace name registered on two SCMs (a GitHub org and a
Bitbucket workspace both
-
Provider-neutrality enforced by the build (ADR-020, 2026-07-26): new
spire-archmodule fails the build when a core module (spire-contract,spire-orchestrator,spire-review-worker) names an SCM provider outside an explicit, reasoned allowlist (the composition rootsProviderClients/WorkerScmClients/PrUrlParsers, plusScmTypeand the devStubScm). It scans source text, not bytecode, because the leaks that caused real bugs were string literals; comments are exempt. Guards against a silent pass: the comment stripper is unit-tested, the scan asserts it reached every core module, a stale allowlist entry fails, and the scanned tree is a declared Gradle input (otherwise the check reports a cached pass after the very change it should catch). The three leaks it found are fixed:ManualRegisterResourceclassified only Bitbucket errors, so a GitHub/GitLab 404 escaped as a 500 instead of 404 (real defect, now on the neutralScmApiException);ProviderIdentityResolver's"bitbucket-cloud"branch became the defaulted SPI methodIdentitySource.whoamiOrValidate(workspace), with the account-less-token fallback moved intoBitbucketCloudDiffSource(ProviderClients.assertWorkspaceAccessdeleted);ProviderResource's duplicate type list is nowProviderClients.SUPPORTED_TYPES. -
Semantic leaks swept (2026-07-26): a 4-lens audit hunted the leaks that carry NO provider name, which the build check cannot see. Six fixed:
- A live GitLab defect.
ReviewProjection.newerThreadRefpicked the current thread for a re-posted finding by comparing thread refs asBigInteger("ids are monotonic"). GitLab's thread ref is an opaque discussion id, so every compare threw and fell back to "first seen" — and rows were readORDER BY thread_ref, i.e. the lexicographically smallest. The ADR-019 reconciliation fix was inert on GitLab, able to target an already-resolved thread. Recency is now insertion order (V26 review_thread.seq, newest row per loc wins); no id arithmetic anywhere. DiffRefsdeleted. The(baseSha, startSha, headSha)triple was GitLab'sposition, non-null at exactly one construction site repo-wide.Diff/PullRequest/PullRequestEventReceivedandCommentSink.postInlinenow carry a singleheadCommit;GitLabCommentSinkreadsdiff_refsfrom the MR itself, cached per MR (one extra GET per review, not per finding).- Mention syntax left the core. Each ingress extracts
@-mentions in its own syntax intoAuthorReplied.mentions(Bitbucket's braced@{account_id}included);ConversationSagais now a membership test with no regex. - 406 →
ScmApiException.isDiffTooLarge()(defaulted false, GitHub overrides) — core no longer interprets one provider's status code, and GitLab already reported the same condition as data. - Summary thread ref.
CommentSink.updateCommenttakes aThreadRef, so one opaque ref both locates the conversation and names the comment to rewrite; the worker recordssummary.thread().value()and core stops casting a comment id to aThreadRef(CommentsPosted.summaryThreadRef). The persistedReviewCompleted.summaryCommentIdkeeps its name — renaming it would break event-store replay. spire-gatewayadded to the scanned modules, its shared registry edge no longer holding a provider-name list (WebhookProviders.SUPPORTED_TYPES, composed from each endpoint's own constant). 740 tests green across 97 suites. Allowlist: 9 entries, all composition roots orScmType.
- A live GitLab defect.
-
Context axis brought under the same rule (2026-07-26): the check now also fails on
jira/confluencein core, and the pipeline no longer parses either. New credential-free SPIContextReferenceSource(referencesIn+normalize) — separate fromContextProviderbecause extraction runs at diff-fetch, before context credentials are brokered, so there is no configured provider to ask.JiraReferenceSource/ConfluenceReferenceSourceimplement it; theWorkerContextReferencescomposition root lists them and does the cross-round dedup in each extractor's own normalized form.ticketKeys+linkscollapse to one neutralreferencesset onDiffFetched/GatherContext/ContextRequest, which each provider narrows to what it recognises (JiraContextProviderto key-shaped entries + project keys,ConfluenceContextProviderto page ids on its host).DiffWorkerandContextWorkerare now free of any source's syntax;WorkerContextClients,WorkerContextReferences,ContextProviderResourceandContextKeyValidatorare the context composition roots and are allowlisted. 747 tests green across 98 suites; allowlist 13 entries, every one a composition root orScmType. -
Three-provider parity pass + 3 fixes (2026-07-26, runbook Mode G): S1–S11 run end to end on a real GitHub PR, GitLab MR and Bitbucket PR. 11/11 behaviourally on all three; every reconcile verdict except
ACKNOWLEDGEDexercised (SUPERSEDEDcorrectly never fired), 14 thread resolves across three different resolve mechanisms, a finding born mid-reconciliation closed two rounds later, and a 100%-similarity rename that did not churn finding identity. What the run exposed is fixed, each with tests:- The turn cap was silent. Reaching it recorded a dashboard note and posted nothing, so the bot
just stopped replying — indistinguishable from a lost webhook (a dead tunnel produced the exact
same symptom mid-run). New
NotifyTurnCapcommand → fixed-text notice, no LLM credential, one claim per thread so later replies don't repeat it; result event isTurnCapNotifiednotFollowUpPosted(the latter bumps the turn count — the notice must not consume a turn). An explicit @-mention now overrides the cap, and cap-vs-decline log differently. - GitLab's compare diff parsed to ZERO files.
fetchCompareDiffemitted only---/+++;UnifiedDiffParserkeys ondiff --git. Read as text (the reconcile prompt) it worked, so the notes were right; parsed (changedOldSideRanges) it was empty, sodowngradeUntouchedrewrote everySTILL_OPENtoUNCHANGEDon GitLab alone — an author who partly fixed a finding was told nothing. Now callssynthesizeUnifiedDiff, which existed for exactly this. The old test asserted the text contained---/+++/@@— all true of a string that parses to nothing; it now asserts the diff parses. - Follow-up replies overreached.
FOLLOWUPhad no "already reported" block (theREVIEWprompt always had one) andAnswerFollowUpno field to build it from, so a narrow question got a survey of every defect in the file. The command now carries the findings owned by other threads (reused from the ADR-019 posted-run snapshot; this thread's own filtered out), the prompt lists them as off-limits, an anchored thread sees only its own file, and replies open by naming the asker in plain text (never an @-mention — syntax is per-provider). Both review and reconcile personas now lead with the fix the code's expressed intent points to rather than the smallest edit that compiles; A/B'd against recorded controls on identical input, finding count and severities unchanged. 763 tests green across 97 suites.
- The turn cap was silent. Reaching it recorded a dashboard note and posted nothing, so the bot
just stopped replying — indistinguishable from a lost webhook (a dead tunnel produced the exact
same symptom mid-run). New
-
Split licensing (ADR-021, 2026-07-26): the repo is source-available, not open source, and licensed per module — Apache-2.0 for the plugin SPI, libraries and reference adapters (
spire-contract,spire-diff,spire-encryption,spire-scm-*,spire-context-*,spire-llm,spire-arch), FSL-1.1-ALv2 for the four deployables (spire-gateway,spire-orchestrator,spire-review-worker,spire-ui). Each module carries its ownLICENSE; the map and reasoning are inLICENSING.md. Invariant: no Apache-2.0 module may depend on a service module — permissive flows into restrictive, never the reverse. FSL permits self-hosting, internal commercial use, forking, teaching and consulting; it forbids reselling as a competing product or hosted service, and each version converts to Apache-2.0 two years after release. Versions published before this stay Apache-2.0 (v0.1.0-apachetags the boundary). Contributions require DCO sign-off plus a relicensing grant (CONTRIBUTING.md), without which the split cannot be maintained. The same pass corrected the PR-Agent provenance language across the docs: it was read as prior art, no upstream code was used (the old "ported the IP" / "port ~1,500 lines of prompt templates" wording described a plan that was never executed) — credit now lives inNOTICE. The shipped code was then compared against PR-Agent v0.38.0's source and the result recorded indocs/RESEARCH.md§4: the two share exactly the__new hunk__/__old hunk__prompt markers and the0.9clip safety factor, and differ everywhere else (typedFilePatch/Hunk/DiffLinemodel vs upstream's string-to-string patch rewriting; chars-per-token heuristic vs a real tokenizer; JSON/Jackson vs YAML/try_fix_yaml; own prompts with injection fencing upstream lacks, plusRECONCILE/FOLLOWUPkinds a single-shot reviewer has no counterpart for; and no architectural correspondence at all). Cite §4 rather than re-deriving it. Open item: the name is not trademarked, and no licence stops a fork from using it. -
Operator attention panel (2026-07-27): a topbar bell in
spire-uiwhose every row is a condition true right now, derived on demand — nothing stored, nothing to dismiss, so fixing the cause removes the row. Two same-shape feeds (AttentionViewinspire-contract) merged client-side:GET /api/attention(orchestrator — no usable default LLM provider, no SCM provider, unresolved bot identity, rejected credential, stuck/failed reviews, pending DLQ) andGET /api/webhook-repos/attention(gateway — registrations with no secret or refusing deliveries). No new topic and no non-reviewIdmessage class: most of the catalog is state, not events, so each service answers for its own schema over the HTTP surface it already has. Credential health rides on work already happening, but not identically per registry: the SCM and context registries record a check both on save and on their own Check button, plus (SCM only) from a real review's 401 (new neutralScmApiException.isUnauthorized(), 401-only because one provider overloads 403 for rate limiting; carried byReviewFailed.credentialRejected).spire-llmwraps LangChain4j's untyped runtime exceptions, notScmApiException, so a review's LLM 401 can never mark its provider that way, and its save path validates synchronously (rejects a bad key up front) without persisting a check record — the newllm_providerCheck endpoint is the only path that ever records a verified LLM credential. Gateway rejections are state onwebhook_repo(V2) that a verified delivery clears, so the row self-clears when the secret is rotated. V28 adds three-valuedlast_check_ok(NULL never checked / TRUE passed / FALSE rejected) to all three registries. Deliberately excluded:CREDENTIAL_UNVERIFIEDas a row (wallpaper — it lives inline on the settings pages), per-review facts like the turn cap, and dead-tunnel detection (absence of traffic is indistinguishable from a quiet afternoon;REVIEW_STUCKis the honest proxy). -
GitHub Issues + GitLab Issues context providers (2026-07-30): the ContextProvider SPI's Jira/Confluence precedent proven a second time over — two new Apache-2.0 modules,
spire-context-githubandspire-context-gitlab, resolve a PR/MR's referenced issues, pull/merge requests and (GitLab) epics intoContextItems. Closes ROADMAP item 14. Each platform gets its own reference grammar (GitHubIssueRefs/GitLabIssueRefs, the counterpart toJiraTicketKeys): GitHub's bare#123, qualifiedowner/repo#123, and issue/pull-request URLs; GitLab's three sigils (#123issue,!123merge request,&123epic), its multi-segment qualifiedgroup/subgroup/project#123(namespaces nest, unlike GitHub's flatowner/repo), and their URL forms including a group-scoped epic URL. A bare reference is repository-relative, and that is only safe to resolve with a newScmTypecarried ontoGatherContext/ContextRequest: the sameworkspace/slugroutinely exists on two platforms, so a bare#123on a GitLab MR must not silently resolve against a same-named GitHub repository just because both are registered. The gate is per-reference, not per-provider — a qualified reference or a URL names its own repository and needs no platform match, only a bare one does (both providers' tests assert this distinction directly).ContextItemgained three neutral kinds,ISSUE/PULL_REQUEST/EPIC(GitLab's own term "merge request" stays out of core's vocabulary, matching the house style already set byRULE/CODE_SNIPPET). GitLab epics are a Premium-tier feature; on a free-tier instance the fetch tries the nearest ancestor group outward and a 403/404 skips just that one reference, not the whole contribution — an operator without epic access still gets their issue and MR context. Both providers reuse the registry's genericprojectKeyscolumn as an owner/repo or group/project allow-list (no migration), rejectbasicauth on save (BEARER_ONLY_TYPES— both APIs are bearer-token-only), Check against/userand/api/v4/user, and Preview rejects a bare reference with actionable guidance instead of a silent empty result. Ahead of the two adapters, the pinned-redirect SSRF-guarded HTTP client that Jira and Confluence each carried its own copy of was extracted into a new Apache-2.0 module,spire-http(PinnedJsonClient) — one of three Apache-2.0 modules this branch adds (withspire-context-github/spire-context-gitlab), bringing the total to thirteen perLICENSING.md— so the guard has one home for the context adapters instead of four near-identical copies once these two providers landed; Jira and Confluence were migrated onto it in the same pass. The three SCM clients (Bitbucket/GitHub/GitLab) still carry their own unguarded copy of the redirect-resolve — tracked as tech debt (techdebt/global/), not silently left undocumented.spire-arch's provider-neutrality allowlist needed no new entries — the existing composition-root exemptions already covered the new types. The live-verification runbook's original plan called for opening "the review's LLM call record" to confirm a context item reached the model — no such record exists (review_llm_callstores only token counts/cost; the rendered prompt is never logged or persisted anywhere). The permanent replacement is a worker-level seam test,ReviewWorkerTest.assembledContextReachesThePromptSentToTheModel, which fakes aBlobStoreholding anAssembledContextand asserts the capturedPrompthanded to the LLM client contains the context item's title and body; confirmed to discriminate (fails whencontextRefis null). The gap itself was tracked as tech debt rather than worked around, and has since been closed byPromptLog(opt-in, off by default — the rendered prompt quotes source code and retrieved ticket text, so it is an operator's explicit choice, not a default). 948 Java tests green across 114 suites; 152spire-uivitest tests across 26 files;tsc --noEmitsilent. Runbook: SMOKE-TEST.md Mode I. -
Repo rules — the
.codespirefile (2026-08-01, Phase 2's last unbuilt item): a repository states its own conventions in a.codespirefile at its root, contributed asContextContributed{source=RULES}/ContextItem{kind=RULE}byRulesContextProvider— a credential-free provider, because the rules ride in onDiffFetched.repoRulesrather than being fetched by the aggregator. Read from the PR's target branch, never the reviewed commit: the head is written by the change under review, so rules taken from it would let a PR rewrite the reviewer's instructions in the same PR. Prompt fencing cannot cover that — rules are meant to steer the review, so the defence has to be where they are read from, not how they are quoted. New SPI methodDiffSource.fetchTextFileOnBranchon all three adapters (absent file ⇒ empty, not an error). Format and guidance indocs/REPO-RULES.md. -
Debt-and-guard wave (2026-08-02): ten commits, no roadmap advance — three user-visible defects and four guards, i.e. build checks that fail on a debt's reintroduction, not merely its removal. Defects:
spire-diffsilently parsed a headerless diff to zero files (it keys ondiff --git; now falls back to a---/+++/@@detector and warns when a non-empty diff yields no patches); the Context card never live-updated within a run (its only key was the commit, which does not move mid-run — now also keyed to the Context stage completing, since the assembled context lives in the worker while the socket carries only orchestrator state); the real adapters'apiHost()was covered by fakes alone. Guards:PureModulesAreFrameworkFreeTestenforces the framework-free boundaryspire-contract/spire-diffclaim, withjackson-annotationsas one documented, allowlisted exception (see Conventions);RedirectHandlingHasOneHomeTestfails a fourth hand-rolled redirect loop (the three SCM clients' existing copies stay allowlisted and tracked);ContractSchemaSnapshotTesthad a vacuity hole — it iterated event types andcontinued on an empty list, so zero types read as zero failures — now asserted non-empty. Plus a per-host circuit breaker (ProviderCircuits: 5 failures ⇒ 30s open, CAS-guarded single probe) wrapping the whole SCM retry ladder, keyed by a new no-defaultDiffSource.apiHost()— deliberately not adefaultmethod, since the obvioustype().name()would collapse every instance of a platform onto one key and let one self-hosted GitLab open the circuit for all of them.spire-uion React 19 + react-router 8 (npm audit0). 1027 Java tests across 124 suites; 192spire-uivitest tests across 31 files;tsc --noEmitsilent. -
Debt wave 2 (2026-08-03): three tracked items closed, debt 6 → 4 with nothing above Low.
- The two largest forms and the route shell are covered (
SettingsProviders585 lines,RegisterPrDialog,App) — validation, the secret-blank-on-edit rule (sendingsecret: ''would wipe a stored token), bearer-only coercion, base-URL preservation, and both halves of the cross-providerproviderTypecarry.App.routes.test.tsxasserts each route mounts a screen viamain .content, not just the topbar title: the title is derived from the pathname, so a deleted<Route>would leave it and the nav highlight looking right. A mutation also exposed thatvi.spyOnre-wraps the same module function, so call history leaked between tests in a file andnot.toHaveBeenCalled()was passing on test ordering — fixed centrally withvi.restoreAllMocks()invitest.setup.ts. - The circuit breaker now covers the LLM path (
CircuitBreakingLlmProvider, wrappingWorkerLlmProvider.clientForso review and follow-up are covered by one wrap). Health isLlmFailures.isProviderUnwell— LangChain4j'sRetriableExceptionhierarchy plus I/O and timeouts; a rejected key is an answer and never opens the circuit. Two traps: the provider reports failure as a failed future rather than throwing (a naive wrap records every outage as a success and never opens, while looking installed), andFollowUpWorker.isTransientrecognised neitherCircuitOpenExceptionnor LLM retriables, so an open circuit would have sent every follow-up straight tocs.dlq. The debt entry's own suggestion — reuse the breaker insidespire-llm— is not implementable: that module is Apache-2.0 and the breaker is worker-owned, which ADR-021 forbids. Comment posting stays unguarded, by design (seetechdebt/global/). - The reviews-list findings count is split into new vs carried-over (
OpenCounts.carriedOver→ReviewSummary.carriedOverFindings→findCell), so a total moving 1 → 2 between rounds no longer reads as "the fix made it worse". The halves always sum because the same anchor is counted once, attributed to this run. Rendered only when something is carried over. - 1039 Java tests across 125 suites; 228
spire-uivitest tests across 35 files. Every guard added here was mutation-verified — break the production line, confirm exactly one test fails.
- The two largest forms and the route shell are covered (
-
Operator authentication delivered (D10, ADR-022, 2026-08-03): the dashboard and every REST/WebSocket endpoint now require an operator identity — the gate that blocked any deployment beyond one machine. Hybrid OIDC, not the bearer design
SECURITY.mdoriginally specified: a browser cannot set anAuthorizationheader on a WebSocket handshake, and four live surfaces are sockets, so the browser gets a cookie session whilecurl/CI keep bearer. Each service is its own OIDC client with its own cookie name andcookie-path, and owns one URL prefix — orchestrator/api(sockets moved to/api/ws/*), gateway/gw, worker/wk. The prefixes are the security mechanism, not tidying: cookies scope by host+path, not by backend, so while the gateway sat at/api/webhook-reposthe browser sent the orchestrator's cookie to it; per-service encryption secrets don't help because the encrypted cookie is the credential. Policies are deny-by-default with/webhooks/*(an SCM has only an HMAC signature),/q/health*and/api/meexplicitly public. Two roles decided by three rules: can it spend money (register, re-run, DLQ replay), what's in the payload — whyGET /api/dlqis admin despite changing nothing, since a dead-letter row carries the raw wire record — and is it configuration, which makes every registry admin-only including its reads (SCM/LLM/context providers, models, prompts, webhook registrations, global settings). That third rule replaced an earlier call that the registries were viewer-readable because no secret is in the payload: true, but the wrong test — a listing is an inventory of every repo, endpoint and model the deployment reaches. A viewer sees reviews (list, detail, timeline, threads, context) and the attention panel; the dashboard hides the whole Configure section and bounces its routes, but@RolesAllowedis the control and hiding is only a courtesy. A session is per prefix, and each must be established: every service exposesGET <prefix>/auth/login(both roles, 303 to/) and the dashboard probes the siblings once signed in, navigating to any that refuse (silent SSO,sessionStorage-guarded against looping). Without this the gateway/worker screens were unreachable — neitherfetchnor a WS handshake can follow the cross-origin redirect a missing session produces, so Webhooks reported "failed to fetch", a review's Context card failed alone, and the attention panel called a healthy gateway unreachable. All UI calls go throughapiFetch, which carries the script marker and sends a refusal to the login of the service that refused. The UI grants nothing by default —hasRole(null, …)is false and guarded routes have an explicit unknown state, since defaulting to permitted flashed the full admin surface at a viewer for ~200ms; onlyauthEnabled:false(dev) grants without a role. The UI knows its own session (/api/me), hides what a viewer may not do, and asks why a socket closed before reconnecting: the old blind 1.5s retry hammered the IdP on every routine 5-minute expiry while the attention panel reported it as a gateway outage.%devruns unauthenticated (both gates open together — opening one leaves REST 403ing while sockets still connect) and refuses to start that way outside dev/test. Realm + opt-in Keycloak indocker-compose.idp.yml(a separate file, not a profile: compose interpolates every service's vars regardless of profile, so a required credential would break a plainup). Preceded by a spike that overturned two of the plan's own predictions —tenant-enabled=falsedoes not suffice (build-timeenabled=falsedoes), androles.source=accesstokenis mandatory or login succeeds with zero roles and denies every operator. 1066 Java tests; 243 vitest. Runbook: SMOKE-TEST Mode J. TLS is the operator's edge by design (2026-08-23) — Code Spire terminates none;docs/TLS.mdstates the five requirements a terminator must satisfy. -
CI/CD + packaging delivered (2026-08-05): nine GitHub Actions workflows, four production images on GHCR, and a
deploy/tree covering Compose, Helm and kustomize from one source of truth (chart → kustomize inflation → rendered YAML indeploy/k8s/, drift-checked byrender-manifests.sh --check). The two things a future reader most needs, because both are invisible until they break:- The
spire-uiimage is a reverse proxy, not a static server, and its nginx config (spire-ui/nginx/default.conf.template) is a security control. ADR-022 scopes each service's session cookie to its own URL path, cookies scope by host and path, so the isolation only exists while all four services answer on one origin — which in dev is the Vite proxy and in a packaged run is that file. Three rules in it are load-bearing:/webhooksmust route to the gateway and precede the SPA fallback (missing, every SCM delivery fails with 405 and no review starts);X-Forwarded-Protomust pass an upstream value through rather than derive from$scheme(deriving it breaks login only behind a TLS Ingress, where a plaintext check passes clean); and upstreams resolve at request time via a variable inproxy_pass, because a literal hostname makes nginx refuse to start when a sibling is not up yet. - The chart never generates a secret, and that is a refusal rather than an omission. Helm's
randAlphaNumidiom would rotateSPIRE_ENCRYPTION_KEYSETonhelm upgradeand make every encrypted event payload, provider secret and context blob permanently unreadable. Secret generation is safe for shared state and catastrophic for keys to existing data.
deploy/helm/spire/tests/render.shasserts eight invariants across two sources — rendered manifests for what the chart decides, in-repo config for what is baked into an image — and--self-testproves each catches its own break, because four are negative ("this value must be absent") and those pass trivially when a key is renamed.deploy/e2e.shruns 21 checks against a running stack, covering what dev cannot: WebSocket upgrade through nginx, a token minted for one service refused by another, and the gateway's role denied on theorchestratorschema by Postgres itself. Two gates were weaker than they looked and were measured rather than trusted:helm lintexits 0 with every required value missing (so assertion 8 requireshelm templateto fail instead), and the repo is not Semgrep-clean underp/default + p/secrets— 45 of 54 findings are action-SHA-pinning advice, filed astechdebt/global/4-2, so Semgrep reports rather than blocks. A%prodlesson worth generalising:${VAR}with no default does not enforce presence when the target isOptional—trusted-proxieswas silently empty withproxy-address-forwardingon, so the pairing is now a startup refusal. 1074 Java tests / 130 suites; 265 vitest / 37 files. - The
-
Code-scanning backlog cleared (2026-08-05): the Security tab's 120 open alerts are closed at source, in seven classes. Every action reference is now a commit SHA with the version in a trailing comment — the comment is not decoration, it is what Dependabot's
github-actionsecosystem parses, and without it a pin is a permanently unpatched action. Tags were dereferenced throughrepos/{owner}/{repo}/commits/{tag}, not read off the ref: an annotated tag's ref points at the tag object, and pinning that SHA fails at runtime. Every dependabot entry gained acooldown— the complementary defence, since pinning stops a tag being repointed while cooldown stops a freshly published version being proposed before anyone has looked at it, and it delays nothing that matters because GitHub exempts security updates from it.AttentionQueriesno longer concatenates a table name into its SQL: a table name cannot be a bind parameter, so the old form could only assert its safety in a comment, and three whole-literal queries on a private enum make it checkable instead. All threedetect-insecure-websockethits were the scheme literal in prose, so they are written out rather than suppressed. The Trivy half split by who can fix it. The 39 OS-package alerts (libexpat, p11-kit) are inherited fromeclipse-temurin:25-jre-alpine, which retags more slowly than Alpine's package index moves;apk --no-cache upgradein the runtime stage closes them at build time, trading build-to-build reproducibility that the deployed digest still pins.spire-uineeds none of it — same scan job, clean base. (Both halves of that sentence were later measured wrong — the upgrade layer cached and so ran once, andspire-ui's base was clean only until its next scan. See the 2026-09-04 entry.) The 24 Java-dependency alerts were mostly closed by the platform, 3.37.1 → 3.38.1: netty, PostgreSQL and OpenTelemetry each ship as a stack whose modules must move together, and Quarkus imports its BOM withenforcedPlatform, so aconstraintsblock loses every conflict and hand-forcing means overriding thirty-odd coordinates and hoping the combination was tested — upstream already did that and published it. Only jackson-core/databind (2.22.1) and lz4-java (1.11.1) have no platform release yet; they are forced in the root build, each with the CVE it closes and the warning that a force does not go quiet when it stops being necessary — it starts pinning the version DOWN.techdebt/global/4-2is deleted (debt 9 → 8).semgrep.ymlstill reports rather than blocks, now for the one honest remaining reason:p/defaultresolves from the Semgrep registry at run time, so a blocking gate would let a rule added upstream redden an untouched branch. Blocking wants a pinned ruleset first. -
LLM cost accounting rebuilt as a priced charge-line ledger (ADR-023, 2026-08-07): the fleet cost/abuse caps item (ROADMAP "Explicitly deferred") turned out to need this first — reading what it would build on found four separate, individually-defensible places where unknown became zero (a blank UI field defaulting to
0, REST accepting0as valid, a registrynull → 0Lcoercion, and aSQLExceptionanswering0L), so a spend cap built on the old numbers would install cleanly and never fire for exactly the calls it exists to stop — the same failure shape as the LLM circuit breaker once recording a failed future as a success.llm_charge(migrationV30) replacesreview_llm_calland the fourreview_statusrollup columns entirely: one row per token type per call, priced at the rate in force when the call happened and snapshotted onto the row rather than re-derived from a mutable catalog (a temporal price catalog was considered and rejected — every read becomes an interval join, and it doesn't even solve the case it exists for, since an operator entering a price today still has no recorded price for yesterday).llm_model.pricing_mode(METERED/UNMETERED;UNKNOWNis a ledger-only runtime outcome, never an operator's choice) makes zero a category instead of a number, because no amount of tightening a numeric check distinguishes "this model is free" from "nobody told us the price" when both used to arrive as the same0.spire-llm'sTokenUsageMapperpartitions each vendor's usage onto the neutralTokenType(INPUT/CACHED_INPUT/CACHE_WRITE/OUTPUT/REASONING/TOTAL) and cross-checksΣ(per-type tokens)against the vendor's owntotalTokenCount()— per vendor, not uniformly, because Anthropic's total is derived by LangChain4j asinput + outputand excludes both its cache buckets entirely; a uniform check (an earlier draft's mistake, caught before it shipped) would have made every cached Anthropic call fail reconciliation and degrade to a single unpriceableTOTALline — the cheap calls being the only ones that couldn't be priced.LlmModelPricernever returns a zero for a price it could not find; a lookup fault resolves topricing_mode='UNKNOWN'plus an attention row, not a coerced0. The priceable-model rule is enforced twice, deliberately: atLlmProviderRegistry.create/update(added after a review proved live, via a choreography test that registered a provider through the registry directly, that the rule was enforced only atLlmProviderResource— its one existing REST caller — and not at the invariant's own boundary) and again pre-spend inResultSagaimmediately beforeGenerateReview, because pricing itself is post-hoc and that saga check is the last point an unpriceable review can still be refused rather than merely reported.LlmModelRegistry.updatenow also refuses to rename a catalogued model still referenced by a provider, mirroring the pre-existing delete guard — a rename orphaned every referencing provider identically to a delete and was the one path left that could defeat the config-time guard after it had passed; caught because the conversation path keeps no pre-spend check of its own (a follow-up answers a human already waiting, and the project already learned from the silent turn cap that an unexplained non-response reads as a lost webhook, soAnswerFollowUprecords cost honestly instead of declining to answer), which makes the registry-side guard the only thing standing between a follow-up and an unpriceable call.UNIQUE (call_ref, token_type)closes a real double-charge window: the write this replaced was an unguardedINSERTprotected only byResultSaga.ifCurrentRun's staleness check, so a redelivered result betweenReviewGeneratedandReviewCompleted— still "reviewing" at the same commit — charged the same call twice. One documented gap, not glossed over: the ADR-013 contract-compat snapshot gate stayed green throughModelUsagelosing its cost field and gaining the token-count list, but it did not catch that change and could not have —ContractSchemaSnapshotTestrenders a nested record component asname: TypeNameand never recurses into it, so the golden file never describedModelUsage's own shape in the first place. The break is safe becauseDomainEventcarries no usage field (verified directly) and Kafka retention is short (ADR-014), not because any check approved it — filed astechdebt/spire-contract/3-2-contract-snapshot-does-not-recurse-into-nested-wire-types.md, since the same blind spot covers every other nested wire type. One operator-facing consequence of the migration: a catalog model previously saved with a zero rate cannot be migrated honestly (a rate> 0is the only unambiguous signal that it was operator-entered) and is left without rates, so it must be given real rates or markedUNMETEREDbefore it will run another review — the guard working as specified. SECURITY.md's cost-controls section and ROADMAP's deferred fleet-caps note both now carry the consequence forward: a money-denominated cap will be inert by design on anUNMETEREDdeployment, so the eventual cap needs a token- or call-count axis regardless of pricing mode. 1138 Java tests across 142 suites (testFast497/61 +testServices— gateway 63/9, worker 153/17, orchestrator 425/55); 290spire-uivitest tests across 40 files;tsc --noEmitsilent. -
The cost ledger reviewed on four lenses, every finding closed (2026-08-08): the ADR-023 work above had already had twelve task reviews and a whole-branch pass, all aimed at the cost invariant. A security / code-quality / rules / QA sweep then found two more money-losing defects, both invisible to a green suite and both about the lifetime of a charge's identity rather than the correctness of its arithmetic — which is why the earlier passes could not see them.
- A re-run's charges were silently discarded, keeping only run 1's spend.
CallRefsdocumented its own premise — for a review or reconcile call "the key is a constant, so the commit in the slot position carries the identity by itself" — and that holds only while the worker's idempotency claim is never cleared.ReviewRerunServiceclears it on purpose, so the LLM genuinely runs again;call_refthen reproduced the first run's key andON CONFLICT … DO NOTHINGdropped every line, with no row, no log and no attention row. Re-run ten times, the cost card still showed run 1. Both entry points reached it (the Re-run button and a/reviewPR comment). It was also a regression: thereview_llm_callwrite this replaced used a random UUID primary key, so it recorded every re-run correctly — V30'sUNIQUE (call_ref, token_type)closed a real double-charge on redelivery, but the key it chose could not tell a redelivery from a second genuine call. Fixed withReviewRuns, which countsReviewRequestedevents in the review's own stream. Deliberately notreview_status.attempt, which is the obvious existing column and is wrong twice over: nothing bumps it on a re-run (onlyretryPipeline/scheduleRetrywrite it), so the fix would have been inert; and it does bump on auto-retry, which must share the charge identity becauseonReviewFailedleaves the claims so the worker re-emits its persisted result — reusing it would have charged one paid call two or three times, turning silently-lost money into silently-inflated money, which is strictly worse for the cap this ledger exists to enable. Re-run and auto-retry need opposite treatment of the same key, so no single column can serve both. Derived rather than stored becausedeleteReviewclearsevent_logalongside the ledger, so the count and the charges cannot drift apart. deleteReviewcleared four tables and the worker claims but notllm_charge— and its own comment explained why the claims must go ("a delete-then-re-register is that same key … so delete is a true clean slate").review_idisReviewIds.reviewId(repo, pr), stable per PR, with no FK and so no cascade. Re-registering the PR therefore inherited the deleted run's money and model, and the new run's own charge was then discarded by the collision above. The branch got this "all sites" discipline right forcontext_blob; the ledger did not inherit it./reviewspent money with no author allowlist check. TheManualCommandReceivedbranch gated only on the self-loop guard, while the PR-open path a hundred lines away did check, under the comment "unlisted authors never get touched". So anyone who could comment on a PR could force unlimited paid calls — each of which was then also uncharged, per the first finding. The gate now sits ahead of the command switch, so a future command cannot arrive ungated, and refusal is timeline-only: a reply would confirm to a prober that the command is wired and would cost an API call per probe.- A negative vendor token count permanently dead-lettered a paid review.
remainder()floored and said why, but the vendor's own reported total passed through unchecked andnonEmpty()'s> 0filter never saw it, so a"total_tokens": -1from a buggy OpenAI-compatible proxy violated thetokens >= 0CHECK inside theReviewGeneratedhandler — before thePostCommentsemit. Paid, findings computed, nothing posted, and permanent on every replay. The instructive part: refusing negatives inTokenCount's constructor — the right place — would have relocated the outage rather than removed it, becausezeroIfNullonly handled null, so a negative would then have thrown out ofmap()instead. A guard at the correct boundary is only a fix once the callers can no longer produce the bad value. - An implausibly large rate wrote a negative cost.
(long) tokens * ratehad no overflow check and every bound was one-sided (the validator, thellm_model_rateCHECK and the UI all bounded the rate only below), so an admin typo silently subtracted from both a review's total and the deployment-wide sum.Math.multiplyExactalone would only have converted that into a dead-lettered review after the money was spent, so the fix that matters is the upper bound at save;V31adds thecost_millicents >= 0CHECK behind it. - The two cost attention rows could never be cleared, which would have made them the first
permanently-lit rows in a panel whose whole contract is "fixing the cause removes the row" — and
V30 guarantees that state on any upgraded deployment, since every legacy zero-priced model is left
rateless. They now count from an acknowledgement watermark (
CostAttentionRow). A plain time window was the simpler option and was rejected: it silently forgets a real backlog nobody acted on. - Per-token rates were readable by a viewer on
ReviewDetail, while every registry read is admin-only because rates are configuration (ADR-022's third rule). Field dropped — the UI renders a cost without needing the rate that produced it. - Two follow-up spend paths were unguarded, and the ADR-023 claim that the conversation path is
safe by construction was falsified: V30 creates rateless models directly in SQL, so it reaches
the unpriceable state without passing the registry guard. Both
DECISIONS.mdandROADMAP.mdstated that rationale and both are corrected — the ROADMAP copy was the worse of the two, presenting the absence of a check as a deliberate design decision with a justification. - What the passes could and could not see, worth knowing before commissioning another: the two
Criticals were found by reading paths rather than files, the negative-token and overflow defects by
asking which direction each bound was missing, and two coverage gaps by asking which half of a
two-sided property went unasserted (
ResultSagaRetryTestproved retry does not dispatch twice but never that it does not charge twice — every fake overroderecordChargesto a no-op). One security finding's proposed fix was wrong even though its diagnosis was right, so a review's remedy needs verifying as independently as its claim. 1179 Java tests across 147 suites; 295spire-uivitest tests across 40 files;tsc --noEmitsilent. Two debt entries added (techdebt/spire-ui/4-3-…— the first UI entry;techdebt/spire-orchestrator/3-3-the-charge-ledger-…— the ledger keys on areviewIdcarrying no provider, so one workspace name registered on two SCMs sums two unrelated PRs, which a per-repo spend cap would inherit).
- A re-run's charges were silently discarded, keeping only run 1's spend.
-
Deleting a review now archives it (ADR-024, 2026-08-09): the hard delete destroyed the review's charge ledger, so real paid usage vanished with a row removed for being clutter — the very history ADR-023 snapshotted rates to protect from a price edit stayed erasable by a button whose whole purpose is tidying the list.
review_status.archived_at(V32,NULL= live) marks the review and nothing is deleted: not the scoped timeline, notevent_log, not the worker's claims or context blob, and above all notllm_charge.DELETE /api/reviews/{ws}/{slug}/{pr}becamePOST …/archiveplusPOST …/unarchive, because aDELETEverb that destroys nothing misdescribes the operation to every future reader. This reverses thellm_chargedeletion added by ADR-023's own review round, and safely: that deletion closed a real defect (a re-registered PR inheriting an orphaned run's money and colliding with itscall_ref), but every step of that hazard needs the review row gone so the PR can be registered afresh — archiving keeps the row and retires the PR, so no second review exists to inherit anything. Archival is a third dimension besidestatusandpr_state, never a value in either: overwritingstatuswould destroy whether the run completed or failed, which is the statistic the data is retained for.llm_charge.archived_atexists and ten ledger reads filter it, but archiving never writes it — only a future purge will. Stamping at archive was self-defeating, since the per-review cost reads key onreview_idalone and are the same reads serving the archived review's own detail page, so it would have shown zero cost and no model.- Six paths enforce retirement, because no one choke point sees them all — four integration
events in
IntegrationSaga(AuthorReplied,ManualCommandReceived,PullRequestEventReceived,PullRequestClosed) plusReviewRerunServiceandManualRegisterResource, which are REST and never reach the saga. The re-run's first act isclearWorkerIdempotency, which drops every claim for the review including the once-ever notice's, so an ungated re-run both resurrected the review and re-armed the notice; manual register answered 200 with a reviewId while the saga silently dropped the event. Both now 409. Retirement is a spend boundary, not what makes retention safe — with nothing deleted a resurrected PR's old charges are genuinely its own andReviewRunsstays correct; the real reason is that an author's push must not silently re-bill an operator who archived to be done. - The notice fires on three of the four events, once per review.
NotifyArchived→ArchivedNotifiedposts fixed text with no LLM credential (retiring a PR costs no tokens), in the thread a reply arrived in or else the top-level PR comment.PullRequestClosedgates without spending it: a close is not a human asking a question, and the notice fires once ever, so spending it there leaves whoever later asks a real question with silence.noticeTriggerOfis an allowlist rather than a "not a close" test, so no event added later inherits the notice by default. Three further silences, each with its own reason: the bot's own notice echoes back asAuthorReplied(without the self-loop check it re-emits forever), an author outside the allowlist is refused exactly as/reviewrefuses them, and with no resolvable provider nothing is emitted at all — a credential-less command reaches the worker's stub sink, which would consume the once-ever claim while posting nothing real. Unarchive clearsarchived_atand releases the notice claim, so a later re-archive announces itself again. - The two findings most expensive to rediscover.
ReviewThreadView.rootOfbinds itsThreadRefinto a statement immediately, so a null throws an NPE inside atrywhosecatch (SQLException)cannot see it — andArchivedNotified.threadRefis null for the common case (the/reviewand PR-update paths post top-level), soResultSagamust null-guard before calling it rather than copy theTurnCapNotifiedhandler, whose ref is never null. And the notice is claimed on a constant slot (ArchivedNotice.SLOT, shared inspire-contractbecause the worker takes the claim and the orchestrator releases it) rather than on a thread ref — that constant in the slot position is the entire mechanism making it once-per-review instead of once-per-thread, which is howNotifyTurnCapdeliberately behaves. - Archiving refuses while the review is running (
ResultSaga.ifCurrentRunguards on commit alone, so an in-flight result would write to a row promised frozen and leave a charge no purge stamps), and clearsretry_at(the 5s sweep would resurrect it) andanswering(no permanent responding pill).archiveReviewreturns a four-valuedArchiveOutcome, not a boolean: theUPDATE'sWHEREmatches zero rows for all three failure cases, so 404 / 409-already / 409-still-running are indistinguishable otherwise. Archive broadcasts a removal, not a row update — the row leaves the live list, an archived review is frozen, and the socket'sonOpensnapshot replaces the client list, so pushing archived rows through it would drop them on every routine 5-minute reconnect; Show archived is a plain REST fetch (?includeArchived=true). 1219 Java tests across 157 suites (testFast505/63 +testServices— gateway 63/9, orchestrator 493/67, worker 158/18); 312spire-uivitest tests across 43 files;tsc --noEmitsilent. Runbook: SMOKE-TEST Mode L.
- Six paths enforce retirement, because no one choke point sees them all — four integration
events in
-
Fleet spend caps and the
refusedlifecycle (ADR-025, 2026-08-09): the ledger ADR-023 built so a cap could exist is finally read back. Three gates, no new storage, each where its inputs already are and all speaking one refusal vocabulary (CapRefusal— a reason plus a timelinedetail()and an operatornote(), modelled onDefaultLlmand deliberately not folded intoDefaultLlm.Refusal, which answers a credential question rather than a budget one). Diff size onDiffFetched, becausechangedFiles/sizeBytes/truncatedexist on that event and nowhere afterwards — and because checking later would first run the context fan-out (per-issue API calls, a bounded 20s wait, an encrypted blob write) only to discard it. Pre-spend inResultSagabeside the priceability check, so every reason a paid call was refused reads in one place. Conversation inConversationSaga.planFollowUp— the genuinely unbounded path, and the codebase already said so:CallRefsstates that the turn cap is per thread and "an @-mention removes the cap entirely, so the loss was unbounded", and the comment above theisSpendableguard records that this same path was assumed safe once and was not. The gate therefore sits after the mention override (which must keep bypassing the turn cap without also bypassing the spend cap) and after the freeNotifyTurnCap. Refusing a follow-up records only a timeline line — the review may have completed, and declining one reply must not retract that, so this is the one gate that does not callrefuse(...). A refused review is terminal and archivable. The refusal this copied (skipUnspendable) wrote a note and nothing else, so the review sat inreviewinguntilREVIEW_STUCKfired blaming "a webhook delivery path or a worker", and after ADR-024archiveRowrefuses areviewingrow — it could not even be cleared.refuse(...)mirrorsonReviewFailed's terminal shape (clear retry, timeline, status, note,RecordFailure(retryable=false);setErrordeliberately not called, since there is no infrastructure fault to show). Status isrefused, notfailed, because the archive guard, the attention queries and the reviews-list filters all key on status — the same split that tookpr_stateout ofstatus. Both axes always:SUM(cost_millicents)andCOUNT(DISTINCT call_ref)over a rolling window, since a money cap is inert by design on anUNMETEREDdeployment where every charge is an asserted zero, and anUNKNOWN-priced row's NULL cost is skipped bySUMand caught by the count — the ADR-023 hole exactly. The window rolls rather than bucketing, so the instant capacity returns is computable and theCAP_REACHEDattention row names it — viaSpendWindow.oldestChargeAt(Instant), added beyond the planned interface and put there rather than as a third ad-hocllm_chargequery inAttentionQueries, since one place reading that ledger is the point (and it carries noarchived_atfilter, for the same reasonsincedoes not). The row isBLOCKING, notWARNING: severity describes impact, not fault, and while the cap holds every paid call is refused, which is the same "nothing will run" shape asLLM_DEFAULT_MISSING— the usual nagging objection does not apply because it self-clears. It carries no acknowledgement watermark because it describes current state. OneSpendGateserves both enforcement sites and the attention row — two copies of a money comparison are free to drift, and drift in a money gate is invisible until it fails to fire. Every limit is optional and unset means unlimited (an unparseable stored value too — fail open); the window alone has an effective default of a day. The cap is soft, overshoot bounded by in-flight reviews × per-review cost, because charges land after a call completes — documented rather than papered over. The two findings most expensive to rediscover: the terminal status was being overwritten by an event projection —refuseroutes throughRecordFailure→ReviewFailedTerminally, whichDomainEventSinkprojected asstatus = 'failed'unconditionally, relabelling the refusal one Kafka round trip later while the note stayed correct; the aggregate keeps one terminal-failure event on purpose, sorefusedis a read-model refinement thatprojectTerminalFailuredeclines to coarsen, and no saga-level test can see the break because only a read after the round trip observes it. And the spend read must not filterarchived_atwhile the ten ledger reads beside it must: those answer "what does this review's page show", this one answers "what has already been spent", and a copied filter would make archiving a way to hand budget back. A third, found while writing the runbook: a new backend status is invisible to the UI's type system.ReviewStatusinapi.tsis a compile-time union and the status arrives as runtime JSON, so nothing carriedrefusedacross —STATUS_LABEL(aRecord<ReviewStatus, string>) answeredundefinedfor a blank badge,miniPipelinefell through to the terminal branch and drew a refused review as five green segments under "done", andmatchesChipput it in no chip at all.tschad nothing to check and every suite stayed green — and the default branch it fell into was the success branch, which is what makes this class expensive: a refusal shown as a completed review is worse than silence, because silence at least looks like nothing happened. Fixed with the union member, aRefusedlabel, its ownminiPipelinebranch, a--warnpill (not--crit, which would read as an outage, nor--muted, which would read as nothing to do), and the Needs attention chip. Closed was the first choice and was wrong: the deciding fact is thatCAP_REACHEDcomes fromSpendGate.decide()and so covers the spend and call caps only — a diff-size refusal raises no attention row at all, so under Closed it would have had no surface anywhere. The three places that say "needs attention" (chip filter, chip count, summary tile) now share oneneedsAttentionpredicate, for the reasonSpendGateexists. The class is tracked intechdebt/spire-ui/3-3-…; the two vocabularies already differ, sinceReviewState.Statusholds the aggregate's five values while the read model writes lower-case strings and has grownsuperseded,observedandrefusedon top. 1256 Java tests across 166 suites (testFast505/63 +testServices— gateway 63/9, orchestrator 530/76, worker 158/18); 323spire-uivitest tests across 45 files;tsc --noEmitsilent. Spec B — the per-repo admission rate limit — is deliberately not built (it is the only part needing new storage); what it must carry is recorded in the design. Runbook: SMOKE-TEST Mode M. -
Per-repository prompts and conversation-derived findings delivered (ROADMAP E16/E17, 2026-08-24): the two items the prompt-management shipment deferred, both closed.
- Prompt scope.
prompt_templatere-keyed(scope, kind)(V34, on top of V33's per-customization ancestor tracking);PromptRegistry.effectiveresolves repository → global → built-in default, most-specific-wins with no per-field merge (a repo row replaces bothsystemandbodytogether, never mixes one field from the repo row with the other from global); the orchestrator resolves eachGenerateReview/reconcile/follow-up command's prompt against its own repository before dispatch; the whole/api/promptssurface (including the new/api/prompts/scopes— repositories this deployment has actually reviewed, sourced from the orchestrator's own review rows, not the gateway'swebhook_repo) takes?scope=.PromptViewgainedscopeandinheritedFrom(repo/global/default— which row actually supplied the text, not what scope was requested), and the dashboard finally has a UI for it:PromptScopePicker(a native<select>, not the project's usual custom combobox — its correctness depends on the browser's own display-value semantics) holds the selection in the URL query string on bothPromptsSettingsandPromptDetail, so a reload or a shared link lands back on the same repository. Provenance is unmissable rather than a subtle hint:PromptDetailshows one of Overridden for this repository / Inherited from global / Built-in default (scope-aware — a global-scope customization reads "applies to every repository," never "inherited," since there is nothing above it to inherit from), andPromptsSettingstags every kind's row the same way at the current scope, so a repository showing global's text can never look identical to one with its own override. Also delivered in the run-up to this: sample-review preview (PromptSamplePicker) and the default-drift banner (PromptDriftBanner, V33) — the other two follow-ups the original shipment deferred. - Conversation-derived findings. A
/findingcommand (ConfirmFinding→ConversationFindingRaised— anchor and severity only, no message text, so a quoted snippet never enters the replayable event log per DATA-MODEL.md §5) lets an allowed author file the thread they're in as a first-class finding instead of leaving it as prose the reviewer never revisits. Idempotent on redelivery (raisedFindingComments, same shape asreviewedCommits), refused on an unregistered PR or a disallowed author the same way/reviewis, and confirmed back into the thread it was run in. The finding then behaves exactly like a review-discovered one: it counts toward findings/blocker totals, carries anorigin: 'conversation'tag the UI renders as "from discussion," and survives reconciliation on the next round viaPriorFindinglike any other prior finding. Closestechdebt/global/4-4-conversation-derived-findings.md(deleted)./findinginherits the same gap/reviewalready had — neither checkspolicy.observeOnly()— widened from one path to three rather than fixed here, since whether commands should work at all in observe mode is a product decision; filed astechdebt/global/3-2-slash-finding-bypasses-observe-mode.md. (Closed in M2 and the entry is deleted. The product decision went against the commands: every SCM-originated trigger is refused in observe mode —/command, author reply, and the archived notice, the latter two found by review rather than by the plan. The "an explicit command is an override" reading fails because the author is gated by the per-provider allowlist and not by operator role, so on an empty allowlist — which means "review everyone" — any commenter could force a paid re-review while the operator believed the deployment was only watching.) Runbook: SMOKE-TEST Mode N.
docs/REPO-RULES.mdnow draws the line this raised: a per-repo prompt is an operator-owned change to the reviewer's instructions (structure, persona, which variables even appear), while.codespirestays contributor-owned data that can only add text into the same fenced, untrusted-data slot as a Jira ticket — different owner, different trust level, different power, and the two compose rather than compete.Measured, not estimated: 1399 Java tests across 181 suites (
testFast520/64 +testServices— gateway 68/11, orchestrator 644/86, worker 167/20); 368spire-uivitest tests across 51 files;tsc --noEmitsilent. - Prompt scope.
-
Repository knowledge base rung 1 delivered (ADR-026, 2026-08-26):
spire-context-code(new Apache-2.0 module) resolves the identifiers a diff's changed lines introduce against the changed file's own import block, read at the review commit, and contributes them asContextItem{kind=CODE_SNIPPET}through the existingContextProviderSPI — no crawl, no embeddings, no vector store, noPushReceivedconsumer.LanguageSupportcovers Java and TypeScript at launch; a further language is a bean, not a core edit. Snippets render into their own{{code_context}}prompt slot (its own token budget, mirroringprior_findings) so a chatty ticket in{{context}}can never evict retrieved code by sharing one budget — proven by a seam test (ReviewWorkerTest.aCodeSnippetReachesThePromptSentToTheModel) that fakes the assembled context and asserts the snippet body reaches thePromptobject actually sent to the model, confirmed to discriminate (fails whenReviewPromptBuilderis made to rendercode_contextempty). Rung 2 (worker.code_symbol) was never started, and P3 closed at rung 1 on 2026-08-29 — then reopened and delivered the same day on an operator override; the rung 2 entry near the end of this file is the current state, and what follows is the gate that preceded it, kept because the sequence is the point. P3 closed at rung 1 when the §9 evidence measurement returned a null: 10 findings shared between the arms, 7 only with code context, 8 only without — against a noise floor, measured by running the identical arm twice, of five differing findings on a pull request where nothing changed at all. The toggle moved findings no more than rerunning the same configuration did.The null is corpus-limited, and the distinction is the whole point of recording it. The runs produced 3 code findings against 15 documentation findings — this repository's large pull requests are majority ADRs, runbooks and plans, and code context can only ever change a code finding. There were three, in both arms. So the gate established that this corpus cannot measure the feature, NOT that retrieved definitions fail to help; anyone reopening rung 2 needs a majority-code corpus with cross-file dependencies. The harness is committed at
docs/superpowers/gates/, with the three per-run controls it enforces (both arms are first reviews, the arms genuinely differed, each run returned something parseable) — each added because its absence had already produced a wrong answer. The 2026-08-28 attempt measured a token cap and would have reported it as a null about the feature.Measured, not estimated: 1504 Java tests across 197 suites (
testFast581/75 +testServices— gateway 68/11, orchestrator 669/87, worker 186/24); 375spire-uivitest tests across 51 files;tsc --noEmitsilent. -
Browser login to the packaged stack fixed, and the guard that missed it rewritten (2026-08-27): nobody could sign in to a packaged deployment on any port but 80, and three separate causes had to be found before one login worked. nginx inherits
proxy_set_headerfrom an outer level only when the inner level defines none of its own — so the two WebSocket locations, which setUpgrade/Connection, silently discarded all four forwarded headers from the server block;Hostfell back to$proxy_host, the upstream NAME, and OIDC builtredirect_uriashttp://orchestrator:8080/..., which no realm can match.$hostthen dropped the port, and Quarkus was not honouringX-Forwarded-Hostat all (enable-forwarded-host: true, whichproxy-address-forwardingdoes not imply). The callback that finally reached the service answered a bare 502: an operator session is several largeSet-Cookieheaders and nginx's 4k/8k default is smaller, so the service logged nothing because it had answered correctly — and the same session returns on ONECookie:request header, which neededlarge_client_header_buffersraised with it or the fix held in one direction only.- The template no longer re-states the headers per location; no location sets any header at all.
Connectioncomes from amap $http_upgrade(a literalupgradeon the server block would be sent to every/webhooksand/wkrequest), so all six headers can live on the server block and the inheritance trap cannot fire. Re-stating the list per location also works and is what shipped first — it leaves the trap armed for the next location someone adds. - The invariant that was supposed to catch this passed the regression it was written for.
Neither half was scope-anchored: a file-wide grep for
$http_hostwas satisfied by a location, and an awk block-scanner asked only whether a location mentionedHost, not its value — so deletingX-Forwarded-Protofromlocation /apileft it green, and that regression breaks only behind a TLS-terminating Ingress where a plaintext compose run stays green. It is now two assertions with no brace counting: the server block must carry all six headers with their exact values, and nothing from the firstlocationonwards may carry aproxy_set_headerat all. Both are mutation-verified by--self-testbreaks 4 and 5, whose absence is why the rewritten check shipped unverified by the very mechanismrender.shexists to provide. enable-forwarded-hostmade the client'sHostreachredirect_urifor the first time, and the proxy is the default server, so it forwards anyHostsent. The realm's registered-URI list was the only thing rejecting a spoofed one — load-bearing, exact-match in the shipped realm, and documented nowhere.SPIRE_PUBLIC_HOSTnow pins it. Pinning by rewrite, not by rejection: a 444 on mismatch would take out the kubelet probe (which addresses the pod IP), the container's own health check and any operator reaching the stack by another name. Matching is an exact, case-insensitive map key rather than a regex — a regex needs its dots escaped to pin anything, and the escaped pattern is then what gets forwarded on the mismatch path, which is not a hostname. Empty (the default, and what every deploy artifact renders) forwards the request's ownHost, so the chart's rendered manifests are byte-identical with the value unset.isForwardingSafechecked presence, not width —SPIRE_TRUSTED_PROXIES=0.0.0.0/0started cleanly and re-opened everything the trust gate protects, which matters more now that a forgedHostis one of the things that gate stops. Refused by prefix length, because10.0.0.0/0is just as wide as the two well-known spellings.deploy/e2e.sh's new redirect check prefix-matched the origin, sohttp://localhost:34700.evil.example/...passed — the same "does the expected string appear" shape as the nginx guard it was written alongside. It now compares the whole callback URL, anchors the extraction to[?&]redirect_uri=(post_logout_redirect_uriwas also matching) and decodes lower-case percent escapes.- The proxy buffer sizes are asserted by nothing, deliberately and now on the record —
reproducing them needs a real chunked session from a live IdP, which neither script has;
techdebt/global/4-3-proxy-buffer-sizing-is-unverified-by-any-check.md.
- The template no longer re-states the headers per location; no location sets any header at all.
-
The review output budget, and the ack threshold coupled to it (2026-08-28): an attempt to run ADR-026 §9's evidence measurement — re-review real PRs with and without code context — found that the deployment could not review a real pull request at all, so the gate is still unrun. Four defects, each verified live before being fixed:
- A reasoning model spent its whole output budget thinking and returned nothing.
DEFAULT_MAX_OUTPUT_TOKENSwas 4096 and bounds thinking plus reply together, so on a 17k-input-token diffclaude-opus-5andclaude-sonnet-5each emitted exactly 4096 tokens and produced no parseable review — charged 18.6¢ and 11.4¢ respectively. A 1.9k-token diff used 2106 by comparison, so the new 16384 is what a large diff needs rather than a guess; an operator who wants another number still setsmax_tokenson their provider. - Raising the cap only moved the failure onto a hardcoded 60s timeout (
LangChain4jLlmProvider, three call sites, not configurable). It is nowspire.llm.timeout-seconds, default 180, carried onLlmConfig— the one field there that defaults, because it is an operational bound rather than something only the operator can know. - That timeout equalled SmallRye's ack threshold, so a slow review killed the worker. The
commands-inchannel fails when one record goes unacknowledged for longer thanthrottled.unprocessed-record-max-age.ms, whose default is also 60000: the slow call the LLM budget explicitly permitted was the call that stalled the consumer, and because the record was never acked it was redelivered on every restart and stalled it again — a poison pill that survived restarts and needed a manualrpk group seekto clear, while the worker logged nothing and the review sat inreviewing. The threshold is now 900000ms, andLlmTimeoutBudgetrefuses to start when it does not exceed what one command may spend on its model calls. ADR-019 makes that two calls perGenerateReview(reconcile then review), so a budget sized for one looks generous and still stalls. The check declares the SmallRye default itself, so deleting the line fromapplication.ymlis a refusal rather than a silent regression. - A review that produced nothing was indistinguishable from a clean one. Zero findings is what
both write.
ReviewResult.degraded(set byFindingsParserfor an empty or unparseable response) now rides to the orchestrator, which notes it and persistsreview_status.degraded(V35) for a newREVIEW_DEGRADEDattention row. Written on every outcome, not only when true, so a later good run clears it — the panel's contract is that fixing the cause removes the row, and a flag only ever set would have been its first permanently-lit one.
Two things worth carrying forward. Adding a component to a wire record silently drops it at every rebuild site: both
ReviewWorkersites re-listed components and still compiled, because the shorter convenience constructors stayed valid — hencewithTruncated/withFindingswithers, which enumerate the components once, next to the record. And the contract snapshot did not notice the change at all, exactly astechdebt/spire-contract/3-2-…predicts:ReviewResultis nested insideReviewGenerated, and the golden never described its shape. Safe here (Jackson defaults a missing boolean to false, and Kafka retention is short per ADR-014), but approved by nothing. All six guards mutation-verified — break the production line, confirm exactly one test fails; the first attempt at the reconcile-path guard passed against its own mutation becausedropAnchorCollisionsreturns early when no verdict is still open, so the test never reached the rebuild it was written to protect.A four-lens review round then found five more defects in the fix itself, each reproduced before being changed:
- The ack guard measured a quantity SmallRye does not measure. A record's age is stamped when
it is polled, not when processing starts, and the connector prefetches (
max.poll.records500 over a queue factor of 2) — so a burst ages out however generous the threshold looks. The channel now pins both to 1 (the dispatcher is ordered and blocking, so prefetch only ever built a backlog) and the check reads them rather than assuming them. Its non-LLM allowance also had to rise 120s → 300s: the posting path is already permitted to sleep 180s backing off a rate-limited SCM, so the smaller allowance called a pairing safe that one throttled posting run outran unaided. - The direct "the model hit its cap" signal was being thrown away.
ChatResponse.finishReason()returnsLENGTHfor exactly this condition, and the parser inferred it instead from a total parse failure. A response cut off after some complete findings still parses — so it reported a partial finding set and looked finished. Raising the output cap does not remove that case; it makes it the likely one, because a model with room to start answering is cut off part-way rather than before it begins.Completion.outputCappedcarries it as a neutral boolean, sincespire-contractis framework-free and every provider spells the fact differently. - The degraded note never cleared. The flag was written on every outcome so the attention row could clear; the note was not, so a clean round 2 left round 1's "this run reviewed nothing" on a row whose flag was now false and whose findings were populated — the two halves of one fact disagreeing, and the note is the half an operator reads.
REVIEW_DEGRADEDhad nostatuspredicate, so it fired about a review being re-reviewed right now, doubled up withREVIEW_FAILEDcarrying stale advice, and told arefusedrun — which was never charged — that it had been.- The reviews list still could not tell it from a clean pass, which is the surface the symptom
was observed on: the fix had reached the bell and the detail note only.
ReviewSummary.degradednow drives a no output pill infindCelland joins theneedsAttentionpredicate. Same class as the ADR-025refusedincident, minus its UI-union half — attention rows render generically fromcode: string, so the panel needed nothing.
Two traps worth keeping. The timeout-less
clientForoverload was invisible becauseLlmConfig.DEFAULT_TIMEOUTequals the shippedspire.llm.timeout-secondsdefault: a call site using the wrong one behaves identically on every deployment except the one that raised the timeout, which is the deployment that raised it because it needed to. It is deleted, and bothforCommandpaths are now tested against a budget that matches nothing else. And making the note always write turned an un-overriddensetNoteon a saga test fake into a liveDataSourcecall — the exact shape that fake's ownrecordChargescomment already warned about.Then run against real GitHub before merge, which found three more — every one of them in the UI, and none reachable from a test suite. The headline fix was confirmed first: the same pull request that twice returned nothing now reviews at
out=5201for two real findings, so the old 4096 cap was cutting it off about 1100 tokens short of an answer. The consumer also stayed Stable with lag 0 and an empty DLQ across several calls far longer than the old 60s ceiling — the workload that used to kill the channel. Forcing a real cut-off (a lowmax_tokensagainst a live model) then lit the whole chain:finish_reason→outputCapped→degraded→V35→ note → attention row → list.- The outcome badge still said "Passed", in green, beside "no output".
outcomeBadgekeys onfindings === 0, which is what a clean pass writes too. The most prominent cell on the row asserted a successful outcome for a review that never happened. The findings cell now renders—and the badge says No result, matching howfailed/cancelled/refusedalready split that job between the two columns. - The chip counts stopped adding up —
Completed 32 + Needs attention 2against 33 rows. Each count was its own predicate rather thanmatchesChip, so they drifted the moment a status stopped mapping to one chip. All five now derive from the filter itself. The invariant test written alongside caught a second, subtler one:needsAttentionwas ungated, so a review being re-reviewed right now was claimed by both Reviewing and Needs attention. It is gated oncompleted, mirroring what theREVIEW_DEGRADEDquery already had to do for the same reason. - The detail page said "✓ clean — No issues found in this diff." This is the
refusedincident exactly, recurring for its structural cause:STATUS_EXPLANATIONSis keyed by status, so it cannot see a condition that is not one — and the note explaining the run is rendered ONLY inside the branch that lookup selects, so it was written, stored, sent over the wire and shown nowhere. What made it invisible totscis worth keeping: the dashboard'sReviewDetailtype DERIVES from itsReviewSummarytype, while the Java side is two independent records — so adding the field to only one type-checks on both sides and arrivesundefinedat runtime, defaulting into the reassuring branch. A type system asserting a relationship the wire does not have.
Also worth keeping for the runbook: the dev images BAKE their source, so
up -dwithout--buildsilently runs the old tree — the first startup check passed against code that did not contain the guard being tested. And a hash-route navigation does not reload an SPA, so a screenshot after a redeploy can be reporting the previous bundle.Measured, not estimated: 1608 Java tests across 202 suites (
testFast622/77 +testServices— gateway 73/11, orchestrator 705/89, worker 208/25); 396spire-uivitest tests across 52 files;tsc --noEmitsilent. - A reasoning model spent its whole output budget thinking and returned nothing.
-
Repository knowledge base rung 2 delivered (ADR-026 §7, 2026-08-29):
worker.code_symbol(V5) answers the question rung 1 structurally cannot — what depends on this diff, not only what it needs. Imports point one way, so call-site impact needs memory, and memory means a table. Every file a review reads has its declared and referenced identifiers recorded, so the index grows toward the actively-changing part of the repository and never crawls.Built on operator judgement, with the §9 gate not cleared — recorded in ADR-026 rather than left as a contradiction between doc and code. The reasoning holds better than the gate's framing allowed: rung 1's value needed an operator to judge whether findings improved, which model variance swamped, while rung 2's core claim is a fact.
callersOfeither names files that really reference a symbol or it does not. What stays unproven is the downstream claim that a cited caller makes a review better.Verified deterministically against this repository, no LLM involved. One real review of PR #38 populated 679 rows over 10 files (122 declared symbols, 319 referenced). Asked for the callers of
authEnabled, the index named six files; every one genuinely contains it — precision 6/6, zero false positives. The repository actually holds thirteen, so recall was 46% after a single review, which is the design behaving as specified rather than a defect: the index only knows what reviews have read, and recall grows with traffic. It is also why every citation is framed as "a known caller … others may exist" — seven real callers were genuinely absent, so an item reading as "the callers" would have been a fabrication about completeness.The index is a hint, never an answer, and that is what removes staleness as a category rather than managing it:
callersOfreturns candidates, each is re-fetched at the review commit and the reference confirmed before anything is quoted. There is no invalidation pass, andlast_seen_commitis compared against nothing — a read that filtered on it would have reintroduced exactly what the design avoids. Structure only (identifiers and paths, never a line of source), so ADR-011 needs no carve-out and the table stays unencrypted and therefore queryable — the same splitreview_findingalready makes between its location columns and its message. A null index is rung 1 exactly.Three things live scanning exposed that diff-line scanning never had to face. A caller snippet cannot reuse
SnippetExtractor, which finds a declaration — a caller by definition only uses the symbol, so every caller would have been silently dropped as unconfirmed. Whole files need block comments stripped, or every javadoc sentence enters the index as a reference. And the TypeScript keyword set was too small: a first live run putstring,voidandasamong the most-referenced "symbols", which is expensive noise because a symbol referenced everywhere fills the candidate cap and crowds out the domain names the index exists for.A mutation caught a vacuous test written minutes earlier. "Does not cite a candidate whose reference is gone" passed with the confirmation check deleted, because when the symbol is absent entirely the snippet extractor fails too — two guards covering one case, so neither was proven. The discriminating case is a symbol still present but no longer a reference (mentioned only in a comment), which confirmation rejects and text-matching does not.
Reviewed on four lenses and hardened (2026-08-29). Two defects were live, and both were the same shape — a scan written for diff lines meeting a whole file:
- The index recorded almost no callers.
JavaLanguageSupportexcluded imported names from a file's references, reasoning that an import says what a file could use. To callPricer.chargeFor()a file must importPricer— so the filter removed the name precisely from the files that are callers, andcallersOf("Pricer")returned nothing. The whole feature was inert while every test stayed green, because no test crossed the seam from scanning to retrieving.SymbolIndexSeamTestis that test, and it fails without the fix. - Two regexes were quadratic on a file a pull request author chooses. A reluctant
/\*.*?\*/over a file with no closing delimiter measured 21.6s at 96 KB, and a member-declaration pattern whose reluctant span overlapped its own capture class measured 28.2s at 32 KB — on the four-thread fan-out pool whose 20s timeout does not interrupt, so one pull request stalled the context stage for every review. Replaced bySourceText: index scans, no backtracking, plus a 256 KB skip.
Everything else the review surfaced is a bound that did not exist. Each costs recall only, and the three worth knowing are the ones no obvious reading catches: confirmation fetches (20/review) bound the work between an index read and a citation, which neither the read cap nor the citation cap touches — a common identifier otherwise spent one content GET per candidate against the SCM rate limit every adapter shares; files recorded (100/review) is what makes the per-file row cap mean anything, since a thousand-file pull request wrote that cap a thousand times in one pass; and caller snippets are trimmed against the same
MAX_SNIPPETStotal as definitions, because that number is derived from the{{code_context}}slot's token budget, so appending to a full list overflows the very slot the cap protects — silently, by tail-clipping, dropping the callers just appended. Also: the per-file row budget is now per role, since spending it DEFINES-first starved the only rolecallersOfreads (a file declaring 400 symbols wrote zero reference rows and could never be a candidate — and large files are the likeliest callers); a confirmed caller is re-recorded, because the write phase runs before the caller phase and so could only ever record files fetched before confirmation, leaving the rows a review just proved correct the first the retention sweep deleted; the index key carries the platform (scmType:workspace/slug), the collision this project has already been bitten by twice; andSPIRE_SYMBOL_INDEX_ENABLED=falsedegrades to rung 1 exactly, which a feature shipping on an override with its first persistent store ought to have.Two lessons about the tests themselves, both found by mutation rather than by reading.
symbolsInsubtracts a file's own declarations from its references, so a file can never confirm as a caller of a symbol it declares — which made the self-caller test vacuous in its obvious one-file form: it held with the skip set deleted. Only a second changed file that genuinely calls the symbol exercises it. And the dedup test needed a file that is really both — resolved through an import (rung 1) and named by the index (rung 2) — since a fixture with no imports resolves no definitions, so there was nothing for a caller to duplicate. Every guard added here was mutation-verified: break the production line, confirm exactly the intended test fails.Measured, not estimated: 1658 Java tests across 207 suites (
testFast657/80 +testServices— gateway 73/11, orchestrator 705/89, worker 223/27);spire-uiuntouched. - The index recorded almost no callers.
-
P4 delivered — learned memory and review analytics (ADR-027, 2026-08-29): the roadmap scheduled P4 on the assumption that a corpus of accepted and rejected findings existed to learn from. It did not, in two layers.
DATA-MODEL.mdhad specifiedreview_findingsince the beginning — "persisted for dashboard / analytics / memory" — and no migration ever created it; ADR-026 andV5__code_symbol.sqlboth cited it as an existing precedent for the coordinates-clear/ content-encrypted split. And the findings themselves were being discarded continuously: the persisted domain stream carriesReviewOutcomeRecorded(commit, findingsCount, summaryDigest)— a count and a digest — while the findings ride inline on integration events under ADR-014's short bus retention, andreview_status.posted_findings_jsonholds one overwritten round. That is ADR-011 working exactly as designed; nobody had drawn the consequence. (A third, smaller find:projection_checkpointis declared in V1 and referenced by zero Java, so the read model's "rebuildable" is an intention rather than a mechanism. Recorded, not built — a replay could not have recovered historical findings anyway, since the log never carried them.)review_finding(V36) is the durable record, one row per finding per round, with no backfill — the corpus accrues from here, the same honest shape as the symbol index, and a salvage fromposted_findings_jsonwould have produced exactly one unrepresentative round per review with no verdicts.Findinggains a nullable, closedcategory: closed because free-text categories from a model produce a long tail of near-duplicate labels that group nothing, nullable because a customizedREVIEWtemplate (E16) never asks for it. An unrecognised label parses to null, notOTHER—OTHERis an answer the model gave, an unparseable label is unknown.Three write rules are not the obvious ones, and each obvious version fails silently.
- Verdicts do not judge the previous round.
priorRunis built from the carried-forward OPEN set (V20), spanning every earlier round, so a finding raised in round 1 and fixed in round 4 still has its row at round 1. Around - 1rule updates round 3, matches nothing, and throws nothing — a missedUPDATEaffects zero rows. "Median rounds to resolved" and every dismissal rate would have been quietly, systematically wrong. Matching is the newest not-yet-judged row for the location across all rounds, preferring the verdict's own thread ref. - Thread-ref attachment is not scoped to a round, because a push between generation and posting
appends a new
ReviewRequestedand the current round has already moved on. - Idempotency is delete-then-insert, not a unique key. A redelivered
ReviewGeneratedre-runs the handler inside theisReviewingwindow — the one the V30 double-charge lived in — and a unique key cannot help:categoryis nullable and Postgres treats NULLs as distinct, so it would fail on exactly the uncategorized rows while also dropping two legitimate findings of one category on one line. (Verified against the deployment's own Postgres 18.4 before the design changed.)
Analytics (FR-11) ships with the projection, not after it — reading a projection back is the only way to tell a correct one from a wrong one, which is ADR-023's sequence exactly. A dismissal rate is null rather than 0 until something has been judged: zero asserts "this team dismisses nothing", which is a claim about them. Per-author is self-visible and its authorization is row-level, so it lives in code —
@RolesAllowedcannot express "a viewer may read their own row" — keyed on(provider_type, author_id)because the same id on two SCMs is two unrelated people.operator_identity(V37) is admin-managed and never inferred: matching an OIDC username against an SCM handle would, on a coincidental match, show one person another person's performance data with nothing on screen looking wrong./api/megained asubjectso an admin has a value to link.Learned memory (FR-10) filters after generation and counts what it removed (
learned_preference, V38). Prompt injection was rejected: it might produce better findings rather than merely fewer, but nothing can tell whether the model honoured the instruction, and a finding it silently skipped leaves no trace — the shape of both the circuit breaker recording a failed future as a success and ADR-023's0that meant unknown. The count appears on the pull request and the dashboard, suppressed rows stay inreview_findingnaming the preference that hid them, and revoking restores them next review.PathGlobsis a fixed ladder rather than judgement, because "a rejected proposal is not regenerated" depends on group identity being recognisable tomorrow.Two lessons from the round itself. An adversarial spec review (fable-5) checked seven claims the spec made about the codebase — all seven held — and then found the spec's claims about what the pipeline produces were wrong: three exit criteria asserted the impossible, since observe mode never starts the pipeline, a refused review stops before
GenerateReview, and anchor collisions are dropped in the worker before the event is emitted. And the contract snapshot demonstrated both halves oftechdebt/spire-contract/3-2in one milestone: it caughtPostComments.suppressedCount(a top-level component) and passedFinding.categoryin silence (nested insideReviewResult). The spec had predicted the opposite for the first of those and is corrected.Three tests were found vacuous by mutation and rewritten: a verdict-redelivery case that held with its guard deleted because the newest row was also the unjudged one, and two preference-state cases that could not fail because the upsert never writes
state— the guard actually protects the evidence on a decided row, which is what the replacement asserts.Reviewed on four lenses, and the two worst defects were in the new code (2026-08-29). Both were silent, and both were about ORDER rather than logic — the class this feature was designed to avoid, arriving in its own implementation.
- A hidden finding was hidden forever. The suppression filter ran eleven lines after
recordOpenFindings, so a suppressed finding entered the carry-forward, became the next round'sPriorRun, and the worker turned it into the review prompt's EXCLUSION list — telling the model never to raise it again. Revoking the preference could not restore it, because the filter had nothing left to un-hide. That is exactly the property ADR-027 names as the reason a counted filter beats prompt injection, and four places promised it in prose while the code did the opposite.LearnedMemoryTest.revokingStopsTheHidingOnTheNextReviewpassed throughout, because a unit test of the filter cannot see the seam; the fix is a saga-level test. - A read failure would have deleted history.
ReviewRuns.currentRunanswersFIRST_RUNwhen it cannot read — right for the ledger, where a charge under run 1 is harmless and refusing loses money. Wrong for a round-KEYED write:recordGeneratedreplaces every row for(review_id, round), so a transient fault during round 5 resolved to 1, deleted round 1's real rows, and filed round 5's findings there. Theround <= 0guard was unreachable from production, so the test covering it tested nothing.roundOrUnknownreturns a sentinel instead.
Eight more, each verified before being changed: a preference could hide a SECURITY finding and the evidence for it is manufacturable (an
ACKNOWLEDGEDverdict comes from the model reading the author's own reply, so ten "won't fix" answers on one PR qualified a group — now a never-suppressed floor enforced at both ends, plus a two-review minimum shown on the card); the Memory screen showed "threshold: 0 findings, 0% dismissed" because the thresholds were read as FIELDS off an@ApplicationScopedbean and a CDI proxy delegates methods, not fields; "median rounds to fix" was the median round RAISED (ORDER BY round), so it read 1.0 forever on a healthy repository — V39 recordsverdict_round;markSuppressedstamped every row on the line;/findingfindings never entered the corpus (recordConversationFindinghad zero callers, so theorigin='conversation'V36 documents described rows that could not exist); the PR comment pointed at a page that does not exist; and identity resolution leaned on an internal Quarkus class, whose silent failure would key analytics on a reassignable username.One trap recurred four times in this milestone, which is worth more than any single fix: an un-overridden method on a saga test fake opens a real database connection from a plain unit test.
roundOrUnknown,markSuppressed,recordVerdictsandrecordConversationFindingeach hit it. The lesson was already recorded forsetNoteandrecordCharges; the fakes now override every method the new paths reach, deliberately and with a comment saying why.Every finding was closed in-round, including the ones a first pass had deferred. Two of those were the sharpest remaining edges, and both only reachable on a REDELIVERY — which is why an ordinary run never showed them:
- A verdict could land on the finding its own event had just inserted. The thread rule could not
tell "no such thread" from "that thread is already judged" — an
UPDATEtouching no rows cannot say why — so a redelivered batch fell past a settled thread into the location rule and stamped the current round's fresh finding with an old verdict. A strayACKNOWLEDGEDthen counts as a dismissal in the proposal scan, the number deciding whether the reviewer starts hiding findings. Verdicts are now bounded to earlier rounds, and the thread path probes and reads the verdict. - A redelivered
CommentsPostedstamped the wrong row. That handler has no idempotency guard, and "newest row still awaiting a ref" is not stable across two deliveries: once the posted row is stamped it stops being a candidate, so the second delivery walked down onto an earlier round's never-posted finding — falsifying that fact AND handing the verdict rule a ref pointing at the wrong finding. A row already carrying the ref now wins over the newest unattached one.
Both needed their tests sharpened twice, and the reason generalises: a first version of each passed with the guard deleted. The verdict test used round 2, where the round bound alone already excluded the row, so it proved the bound rather than the probe — round 3 separates them. The thread-ref test had the posted row as the newest one, where
ORDER BY id DESCpicks it anyway; the discriminating case needs the EARLIER round to be the posted one.Also closed rather than carried: a database outage reported itself as "your identity is not linked" (sending an operator to request a mapping they already had — authorization still fails closed, but the READ now reports the fault); the JWT identity branch was untested because
@TestSecurityyields aQuarkusPrincipal, so only the fallback ever ran;/rescanwas an unbounded aggregate; the analytics arithmetic had no test of its own; and the size and parameter rules — verdict logic toFindingVerdicts, suppression toFindingSuppressions, plumbing toFindingRows, two parameter objects, SQL lifted to constants.Checking the shipped code against the spec's own exit criteria then found three gaps — and one of them was a defect that made the feature inert.
PreferenceProposals.scan()was package-private with a javadoc saying "so a test can drive it", and nothing did: everything deciding WHICH groups become proposals lives in its SQL, and none of it was exercised. Driving it end to end showed the distinct-review floor countedcount(DISTINCT review_id)per PATH and then took aMath.maxacross the paths a glob covers. A glob usually covers many paths that each appear in one pull request, so the answer was 1, thereviews >= 2floor never held, and no proposal would ever have been generated on a real corpus. Learned memory would have looked installed and quietly done nothing — the exact failure shape this feature was designed to avoid, one level up. The union of the actual ids is the only number the floor is about.The other two were coverage, not behaviour: "a rejected proposal does not reappear" was asserted against the upsert rather than across two consecutive scans, which is where the guarantee actually rests (it depends on
path_globbeing derived identically each night); and the dashboard's suppression tile was unasserted while the summary comment's count was.Measured, not estimated: 1732 Java tests across 217 suites (
testFast666/81 +testServices— gateway 73/11, orchestrator 767/98, worker 226/27); 415spire-uivitest tests across 55 files;tsc --noEmitsilent. - Verdicts do not judge the previous round.
-
The Mode G runbook is now a job (
spire-e2e, 2026-08-30): the S1–S11 parity script runs unattended against a real containerised GitLab, closing the gap that every automated test below the SCM boundary ran against WireMock — which is our belief about the API, authored by whoever held the wrong model of it. GitLab is the only one of the three providers this is possible for, and the reason is worth recording so nobody retries it: GitHub Enterprise Server is a licensed appliance VM, and the self-hostable Bitbucket is Data Center, whose/rest/api/1.0is a different API family from the Cloud/2.0our adapter targets — self-hosting it would exercise an adapter we do not ship. Gitea/Forgejo are a trap for the same reason one level down: GitHub-shaped, and divergent in exactly the places this project has been bitten. GitHub and Bitbucket stay on the manual runbook. New third CI tiertestE2e(nightly, never the PR path) besidetestFast/testServices; the split is by what a module's tests own, since a service test boots what it talks to while an e2e test is handed a running stack.deploy/compose.e2e.ymladdsgitlab-ce+ a WireMock LLM to the packaged stack under its own compose project, because compose treats a same-named project as the same stack and would otherwise reconfigure a developer's running deployment underneath them.- Everything is on one Docker network, which is what removes the tunnel — GitLab POSTs straight
at
ui:8080/webhooks/gitlab/{key}. Inbound reach to an ephemeral runner is the single reason the other two tiers cannot be automated this way. - The mock is steered by the fixture repository, not by reconfiguration. It tells the three call
kinds apart by
PromptCatalog.lockedSystemSuffix— chosen because it is locked, so a per-repo prompt override (a supported feature) cannot break the suite — and a defect marker counts only on an added line. Two shapes matter and differ: the review prompt renders<lineNumber> +content(DiffRenderer), while the reconcile prompt carries a raw unified diff, becauseReviewWorker.reconcilepasses the incremental compare through unrendered. A pattern written for one matches nothing in the other, and the mock then answers with a fallback that reads exactly like "nothing was fixed". The fallback verdict isUNCHANGED, neverRESOLVED: a/reviewre-run happens on the same commit, and while it said resolved it closed every finding and posted "Fixed in<sha>" against untouched code. - S9b is the load-bearing assertion. Asserting that untouched findings stay
UNCHANGEDproves nothing — when the incremental diff parses to zero files every file reads as untouched, so they still readUNCHANGED. Only a touched-but-unfixed finding surviving asSTILL_OPENcan fail under that regression, which is the one that made ADR-019 inert on GitLab alone. Mutation-verified, as were the added-lines-only rule and the tier guard. - The rename question is settled.
SMOKE-TEST.mdcalled finding-identity churn a known limitation and cited atechdebt/entry that does not exist, whileCLAUDE.mdrecorded a pass where a 100%-similarity rename did not churn.RenameTestdecides it against a real GitLab — findings follow the file, nothing reportsSUPERSEDED, nothing returns as new — and the runbook is corrected in place. - Six defects the existing suite could not see, each fixed: adding a module to
settings.gradle.ktsbroke every production image build (theDockerfilenames each module by hand for its dependency layer; now guarded both ways byImageBuildSeesEveryModuleTest); Java'sHttpClientopens a plaintext origin with an h2c upgrade that nginx does not answer, so every proxied call hung its full 60s while curl returned in 60ms;grafana['enable']was dropped from Omnibus in 16.3 and an unknown key aborts reconfigure, restart-looping the container with no symptom outside its own logs; GitLab's health endpoints are restricted tomonitoring_whitelistso/-/readinessanswers 404 from the host whether it is up or not; GitLab's root seeding did not run and left an instance with zero users that serves every page normally; and a duplicate registry name reaches the client as a bare 500 rather than a 409 (the class already tracked intechdebt/spire-orchestrator/3-3-…). - One scenario is disabled with an UNEXPLAINED failure, and the first explanation was wrong. The
code-context probes fail against the containerised GitLab: the provider runs, extracts identifiers,
and resolves none (
Context resolution for CODE: extracted=17 resolved=0), leavingworker.context_blobempty. Nothing throws, because context providers fail soft — so a failing provider and a pull request with genuinely no context are indistinguishable, which is the operator-facing risk that outlives the specific bug. Filed astechdebt/global/3-3-code-context-resolves-nothing-in-the-e2e-stack.mdwith the reproduction. The first diagnosis — thatPinnedJsonClient's SSRF guard refuses site-local addresses on every request — is false:isPrivateAddressis reachable only fromrequireSafeRedirectTarget, which runs only on a 3xx and exempts same-host targets, and its javadoc says outright that dev/test run against localhost. It reached five documents before anyone read the guard, and a four-lens review did not uniformly catch it (the security lens called the diagnosis accurate; the QA lens read the code and found it unreachable) — agents agreeing is not evidence. Separately, an earlier version of those probes passed for the wrong reason: the definition sat inside the merge request's own diff, so its body reached the model whether or not anything retrieved it. Moving it to the target branch is what made the assertion real.
Measured, not estimated: 1735 Java tests across 218 suites on the PR path (
testFast669/82 +testServices— gateway 73/11, orchestrator 767/98, worker 226/27), plus the new nightly tiertestE2e— 44 tests across 9 suites, 2 skipped, which is the whole S1–S11 chain, the rename scenario, the mock's own contract and the harness's self-tests. Verified on a GitHub runner as well as locally (run 33341378597): identical counts, 16m41s including a cold GitLab boot, so nothing was passing by accident on one machine.spire-uiuntouched. - Everything is on one Docker network, which is what removes the tunnel — GitLab POSTs straight
at
-
Operator SCM sign-in, and the P4 dashboard screens fixed (ADR-028, 2026-08-30): the P4 screens were checked on a live stack and five defects came back, three of them the same shape as failures this project has already paid for.
- Four screens rendered edge-to-edge and unstyled. They used
tiles,tile-value,plain-list,pref-card,row-actions,form-row,tableanderror— an invented vocabularyindex.csshas never defined — so the markup fell back to browser defaults: stat values ran together as0Findings, tables had no borders, a form was raw inputs on one line. Page padding lives on the.contentwrapper every screen supplies for itself, and these four supplied none. Nothing caught it because the UI tests assert text and behaviour, which is normally the right instinct; it left nothing verifying the styles existed at all.styles.contract.test.tscloses that: every class a component asks for must be one the stylesheet defines. It readssrc/withnode:fs, not Vite's?rawimport, which returns an EMPTY string for a stylesheet under vitest — the first version of the guard passed while reading nothing, so its first assertion is now that it read something. It also found three orphans predating P4,pill--warnamong them: the "No result" badge added so a degraded review is not shown as a pass had been rendering as a plain grey pill since it shipped. - The topbar named the wrong screen on all four, from a nested ternary whose last branch was
'Reviews'— an unhandled case defaulting into a branch that names a REAL screen, the ADR-025refusedshape once more. Now a prefix table, and an unclaimed path gets a neutral word. - The rail lit two entries at once. Reviews was styled as "not settings", right while the rail had two sections and silently wrong the moment Analytics arrived. It now tests for what it owns.
- The route test that should have caught the padding already existed and was vacuous twice
over. Its
ROUTESlist was never extended to the four new routes; and for the eight admin routes the assertion was satisfied byRequireRole's own.contentskeleton, because that guard callsuseMe()a SECOND time and the hook holds no cache — so it passed whatever the screen rendered. Both fixed, mutation-verified on all three files. - Operators could not be used without a database. The form asked an admin to TYPE an OIDC subject and a stable provider id — two values the product displays nowhere, while both had already been recorded by ordinary use.
The mapping is now proved, not asserted (ADR-028). ADR-027 refused to INFER the operator↔SCM link because a coincidental username match shows one person another person's performance data with nothing on screen looking wrong. That holds; what it left unexamined is that an admin's assertion cannot be checked either. The bot's API token cannot help — it proves the BOT's identity, and can confirm a handle exists without saying the human at the browser is that handle. So an operator signs into the platform and the platform answers. New credential-free SPI port
OperatorOAuthimplemented by all three SCM adapters;scm_oauth_app+oauth_connect_state(V40),operator_seen(V41);OperatorConnectsis the composition root and the one newspire-archallowlist entry. The shared exchange lives once inspire-http(FormTokenClient), refuses redirects rather than following them, refuses plaintext off the local machine, and never repeats a response body — an OAuth error echoes back what was sent, and on that path one of those values is the client secret. Four details worth keeping: identity comes from each adapter's existingwhoami(), so the stored id is the same id the ingress records as a pull request's author (a link to any other spelling matches no rows and looks exactly like having done nothing); the access token is used once and discarded, so nothing durable here is a credential; the state is consumed, not checked, and carries the subject it was issued to, because the attack is a crafted callback URL that links the sender's account to whoever clicks it; and a self-hosted API base is derived from the sign-in base, never from the hosted default — falling back would identify a self-managed operator against whoever holds their name on the public service, with the sign-in itself having succeeded. A 200 carryingerrorinstead ofaccess_tokenis the failure that would otherwise sign somebody in as nobody; OAuth providers answer a rejected code exactly that way. The admin form stays as the repair path, both ends now picked from recorded values. Runbook: SMOKE-TEST Mode P.Measured, not estimated: 1782 Java tests across 225 suites (
testFast+testServices; the nightlytestE2etier is separate); 457spire-uivitest tests across 59 files;tsc --noEmitsilent. - Four screens rendered edge-to-edge and unstyled. They used
-
Software factory M0 — the walking skeleton — delivered (ADR-029..ADR-039, 2026-09-02, PR #95):
POST /api/runs→cs.run-commands→spire-run-worker→ a three-container run unit on Docker (init clones with the read credential, the agent runs the harness with the model key and no git token, the publisher holds the write token and never the workspace) → bundles on/handoff→ the push gate → a branch on the real remote authored by the machine account; a run touching a CI file is refused at the gate and raisesRUN_PUSH_GATE_REFUSED. Seven new modules (spire-harness,spire-harness-codex,spire-workspace,spire-runtime,spire-runtime-dockerApache;spire-publisher,spire-run-workerFSL), migrations V42–V49 (llm_chargeneutral subject,factory_run,scm_provider.role, the attention acknowledgement), the FACTORY/REVIEWER split on every provider lookup, both credentials Tink-wrapped on the bus with AAD bound to run and slot, the neutrality scan widened to harness and runtime names, and the two images (deploy/agent/codex,spire-publisher/Dockerfile). Both exit criteria are proven byM0WalkingSkeletonTestagainst real containers — the publisher image built from this repository, the reference agent entrypoint with a shell script standing in for the model, and a self-built smart-HTTP git origin — and by runbook Mode Q against a real forge with Codex.docs/factory/ROADMAP.mdrecords what the build taught that the design had wrong; the three most expensive to rediscover: the runtime never fed the prompt on stdin (Codex declares stdin delivery and would have started on an empty prompt — a script-harness test can never notice, so the entrypoint handsSPIRE_PROMPTover from a file outside the tree), the publisher was SIGTERMed the instant the agent exited (docker stop's grace period read as a drain window, so every runtime-driven run reported nothing while the hand-driven unit pushed — exit code 143 on the preserved unit was the tell), and a 12-argument convenience constructor stored every FACTORY registration made through REST as REVIEWER, which handed the review pipeline the push token and made the endpoint answer 409 forever. Each task was four-lens reviewed and mutation-verified; the round-1 findings not closed in-round are filed undertechdebt/spire-run-worker/,techdebt/spire-publisher/andtechdebt/spire-orchestrator/(watchdog, cancel, refusal stopping the agent, run charges — the M1 items by the plan). A second four-lens round over the fix batch found the round's own regressions — aPUTwithout aroledemoted a FACTORY provider back to reviewer (the dashboard's edit form sends none), the wall-clock path SIGKILLed the publisher one line before the drain window the first round had just given it, and aDISPATCH_FAILEDrow could never be corrected by the real result — all closed with discriminating tests, and the review-state file.claude/reviews/global/software-factory.mdrecords every finding's disposition. A forced third round, under the HIGH-or-concrete-bug rule, found the reader "cancel" after a throwing salvage did not cancel —CompletableFuture.canceldocuments its flag as having no effect, so each reader kept blocking on a preserved container's follow stream, a virtual thread and a daemon connection each, until process exit (nowExecutorServicefutures whosecancel(true)interrupts, and a runtime that closes the log callback on interrupt); a never-acknowledged dispatch re-armed with the RETRY's parameters while the first command may be the one running (identical request only, a differing retry is a 409); and the ack budget missing the publisher drain the previous commit had raised 30s → 300s. All closed in-round. Measured, not estimated: 2069 Java tests across 257 suites (testFast857/100 +testServices— gateway 73/11, orchestrator 818/105, review-worker 226/27, run-worker 80/12 incl. the M0 walking skeleton, runtime-docker 15/2);spire-uiuntouched. The two images are not on GHCR andspire-run-workeris not indeploy/yet — packaging follows M1, and the runbook builds both locally. -
Software factory M1 in progress (2026-09-02, PR #96): the lifecycle milestone — a run that meets a hostile world behaves correctly. Plan with per-task test scenarios in
docs/superpowers/plans/2026-09-02-factory-m1-lifecycle.md. Delivered so far:-
Docker-driving test tasks hold a lock, one at a time.
spire-runtime-docker,spire-run-workerandspire-e2eall create and destroy containers on the one daemon, andorg.gradle.parallel=truelet two of them meet there —DockerRunRuntimeITfailed two cases whose symptoms ("no such container", a destroy that removed nothing) impersonate the exact defects that runtime exists to prevent, while passing 15/15 alone. A shared build service withmaxParallelUsages = 1serialises them; the other service modules stay parallel, which is why this is a service and notorg.gradle.parallel=false.DockerTestsAreSerialisedTestderives the module list by scanning test sources rather than trusting a declaration. -
A failed run carries a cause from a closed set (FR-F9).
RunFailureCauseis the wire vocabulary and owns the aliases translating each producer's older words into it — three vocabularies used to reach the wire agreeing only partly, one of them an arbitrary string from the publisher's JSON, landing in an unconstrainedVARCHAR(32). Enforced twice: the application normalises on the way in,V46refuses whatever escaped, and a test binds the enum to that CHECK so the two cannot drift. Parsing is lenient though the set is closed — an unknown value becomesUNCLASSIFIEDrather than throwing, because a classification failure must never become a second failure after the model is paid for. -
Retryability is a property of the cause, not the call site. Every publisher failure was reported retryable, so a run refused for a reason that refuses it identically was retried at the price of another agent run.
RunFailuresis the one collaborator the launcher and the dispatcher both build failures through, which is also what makes the credential scrub unbypassable. -
A run that delivered nothing has its own terminal status (
delivered_nothing, V47) rather thansucceededwith no ref — the same call already made forpush_gate_refused. -
Four migrations' worth of vocabulary lives in
V46/V47; the factory set is now V42–V53. -
The run event stream (
cs.run-events,run_event, V48). The agent's own output reaches an operator live over/api/ws/runs/transcript, encrypted at rest with the run as AAD and swept on a TTL — high-volume by design, so it is bounded rather than durable (ADR-034); a run's outcome lives infactory_runandllm_chargeand outlives it. -
Salvage before teardown (V49). A unit is finalized before it is destroyed, and a failed salvage BLOCKS teardown so the evidence survives. A run that delivered but never finished has its own status rather than being labelled a clean success.
-
A run writes to the charge ledger (V42).
llm_chargegained a neutral subject, so the ADR-025 spend gate — which reads that table with no subject filter — finally sees factory spend. Before this the cap was structurally inert for runs. Unknown usage stays UNKNOWN and is never coerced to a priced zero. -
run_leasewith owner, heartbeat and the sandbox's identity (V45). A restarted worker recovers by discovery, not by memory; the lease is what names the unit a heartbeat belongs to. Taken BEFORE the unit is created, deliberately. -
The orphan watchdog. A sandbox whose lease has gone stale is reclaimed on a timer, because an abandoned one holds the model key and the SCM token until something removes it. It refuses to destroy a unit it could not report on.
-
cs.run-control, and a cancel that cancels. Control rides its OWN topic: the command channel is ordered and blocking for the run's whole duration, so a cancel delivered there would be read only once the run it cancels had finished. Steer is refused where the harness does not declare it, rather than silently dropped. -
Idempotent dispatch (V50, V51). The intent is journalled before dispatch and ambiguity fails CLOSED: an unacknowledged send says nothing about whether the record landed, so the run enters
dispatch_uncertainand an operator resolves it, rather than being retried into a second paid agent. -
The harness credential pool (V52). A run calls the model with the factory's OWN keys, never the reviewer's — one exfiltration must not disable reviews and runs together. Least-rested rotation, and two exhaustion states kept apart because a rate limit is a promise and a rejection is an answer. Both are entered by an operator: nothing in the pipeline reports either yet, guarded by
CredentialRefusalHasNoProducerTestand tracked indocs/UNVERIFIED.md§A1–A2. -
The corporate run-unit environment (FR-F14). A CA bundle and proxy variables injected into every container at run time, and a private-registry credential for the image pull, none of it baked into an image. The bundle and proxy live on
RunUnitSpecrather than on eachContainerSpec, so "every container of the unit" is structural and no arm can apply them to two parts of three; the registry credential goes the other way, onto the RUNTIME, because everything on a spec reaches a container wheredocker inspectprints it and the agent reads its own environment.HostMounthas noreadOnlycomponent at all — a host bind reaches the machine the worker runs on, so a writable one is not expressible rather than merely defaulted. What the first version got wrong is the part worth keeping. It setSSL_CERT_FILE,GIT_SSL_CAINFOandNODE_EXTRA_CA_CERTSand stopped — three names covering OpenSSL, the git binary and Node, which is the AGENT's world. The init clone and the publisher are neither: they are a JVM running JGit, which reads the JDK trust store andProxySelectorand contains zero references to any of those names (measured against the jar). So behind a TLS-inspecting proxy the clone failed at the forge and the push failed at the forge while three documents said the opposite, and the integration test could not see it because itcats the mounted file — proving the bind and saying nothing about trust.CorporateTransportnow builds anSSLContextfrom the PEM and aProxySelector/Authenticatorfrom the proxy, called by both entry points, and the test stands up a real TLS server behind a private CA and asserts the handshake FAILS before and SUCCEEDS after. Four more of the same shape: a relative bundle path passed the startup refusal and then reached the daemon as a VOLUME NAME (an empty volume where the certificate should be); only the FIRST proxy password was scrubbed; the Basic form was built asbase64(scmUser:proxyPassword), a string on no wire; and a bundle holding a PRIVATE KEY — the shape a combinedserver.pemtakes — was mounted into the container running untrusted output. Two guards were also proven for one container out of three, which mutation found and the per-role fixtures now close. Operator guidance indeploy/agent/CORPORATE-ENVIRONMENT.md, runbook Mode R, and the no-baking half is build-enforced over both run-unit Dockerfiles. -
The agent image contract is written down and checkable (FR-F13, M1 half).
docs/factory/AGENT-IMAGE-CONTRACT.mdis the contract; the new Apache-2.0spire-agent-imagemodule checks it. The report has two halves that never mix — VERIFIED clauses the command proved by inspecting and RUNNING the image, and DECLARED clauses the image claims through a label and the command cannot prove (its toolchain needs the repository; its harness needs a paid call). A blended report reads as proof, so an image declaring a toolchain it does not carry would pass with a paid run being the first thing to notice. The split is structural: a declaration has no pass/fail component, and the report refuses a verification carrying a declared clause id. What review found is the part worth keeping. A checker-side setup failure was reported as three image defects — the seed container accepted a BLANK commit when git refused the workspace as dubiously owned, the entrypoint then aborted on its required variable, and three clauses blamed a conforming entrypoint; reproduced on the reference image using the runbook's own commands.USER root:rootpassed the non-root clause, because the check tested the whole string rather than the uid field. The trust-store test readA || B || C && D, which POSIX parses as((A||B)||C) && D— so an image whose store is only/etc/ssl/cert.pemwas told it had none, a false accusation from a checker. A hostile LABEL could forgePASSlines and conceal the verdict line, re-blending in the terminal the two halves the data model separates (Docker stores ESC and CR verbatim — measured); every image-controlled string is stripped of control characters now. And the probe containers ran image-chosen code with the default network and full capabilities while the class javadoc promised neither — no probe needs a network, so they have none. The contract itself was incomplete for its stated purpose: three clauses are only passable by an image honouring fiveSPIRE_*variables the document named nowhere, so a third party building from it alone failed three clauses.ContractAndCheckerAgreeTestholds the document and the checker to each other in both directions, and now also that the document names every variable the entrypoint requires. Runbook Mode S. -
The whole PR reviewed on four lenses after all thirteen tasks had been (2026-09-03). 32 findings, and almost none is a defect inside a task — they are the three shapes a per-task review structurally cannot see. A fix that landed on one of two siblings: an earlier round fixed a swallowed exception on one call and left three, and another removed one of four spellings of an MDC key. A guard whose input can never satisfy it: the transcript socket closed on
-1from aSELECT count(*), which always returns a row, so the close was unreachable while a prior round records it resolved — and the scan protecting the credential pool's documented gap allowlisted the very file that holds the alias map a producer would most likely be written into (measured: adding an alias left the fast tier green). A claim in module A about the behaviour of module B:ChangeKindis carried across the publisher's wire specifically to be reported and dropped at the worker, six documents describe a read-only clone token that does not exist (one token serves clone and push — corrected in all six rather than invented, since a read scope is forge-specific and a product decision), and five places pointed at aSECURITY.mdfactory section that was never written.The worst was a class doing the opposite of its own javadoc:
RunCommandDeserializerpromised it never throws on a poison record and overrode nothing, so the base implementation threw and the messaging layer failed the channel — andfailure-strategydoes not apply to a deserialization fault. It serves BOTH worker channels, so one malformed record oncs.run-controlwas a worker that could not be cancelled: the outage that topic exists to remove. Its four siblings all override; this one was a copy that dropped the only line that mattered.Nothing asserted any sandbox control. ADR-039 makes the container the security boundary and six settings implement it; a repo-wide grep for
withCapDrop,no-new-privileges,withPidsLimit,withMemoryandwithNetworkModeacross every test source returned nothing. The daemon-driving IT asserts behaviours and never inspects aHostConfig, so it is blind to these by construction.Also the seventh fake-coverage trap, and the first SILENT one — every previous instance failed loudly with an NPE from a real
DataSource, while this one sits under the sweep's owncatch (RuntimeException), so a plausible call would have left all 27 tests green with the feature inert. And neither Keycloak realm defined the run worker's OIDC client while four documents told an operator it did; adding it caught a second defect on the way in, since the copied client inherited the review worker's/wkprefix and port where the run worker owns/rwon 34083.Eight findings are filed rather than fixed (
techdebt/), the sharpest being that a cancel for a run that has not started is accepted and dropped:registerruns only aftercreatereturns andcreateblocks on the clone, so a queued, cloning or dispatch-uncertain run takes a 202 and then runs anyway. Two task reviews each closed the half they could see and the gap is before either. Dispositions in.claude/reviews/global/factory-m1.md; the unproven claims indocs/UNVERIFIED.md.
Measured, not estimated: 2549 Java tests across 299 suites (
testFast+testServices; the nightlytestE2etier is separate).spire-uiuntouched by M1. -
-
The base-image upgrade was cached, so it never ran (2026-09-04): all 102 open code-scanning alerts were one defect with one cause. Every alert was Trivy on the three service images, and all of them named two Alpine packages the base ships and this repository does not use — openssl (
3.5.7-r0, aslibcrypto3/libssl3/openssl) and libexpat (2.8.3-r0).Dockerfilehas runapk --no-cache upgradesince 2026-08-05 for exactly this, and three documents said it worked. It had run once. The layer has no input that changes, so withcache-from: type=gharestoring it BuildKit re-used it on every build — its only cache key being the base image, which means the mitigation refreshed precisely wheneclipse-temurinretagged, the wait it exists to skip. The same shape as the LLM circuit breaker recording a failed future as a success: installed, described, inert.-
Measured at every step, because each half is separately deniable. Workflow run 33810550375 logged
#48 [stage-1 2/10] RUN apk --no-cache upgradefollowed by#48 CACHEDfor all three services. A throwaway two-line Dockerfile reproduced it in isolation — the sameRUNre-built with nothing changed isCACHED, and with a referenced build arg it re-executes. One freshapk --no-cache upgradeon the very same base installs openssl3.5.8-r0and libexpat2.8.4-r0, i.e. the fixed versions every alert names. The realspire-publisherimage built from the changed Dockerfile scans 0 OS vulnerabilities where its base scores 34 on the digest current that day — the counterfactual matters, since a clean report means nothing without proof the scanner sees the dirty one. And the merge build settled it in production: all four images loggedapk upgrade for build 33847927972and the three service images upgraded libcrypto3, libssl3, libexpat and openssl to the fixed versions, where the previous run had loggedCACHED. -
APK_UPGRADE_BUSTis echoed inside theRUN, not merely declared. BuildKit keys aRUNon the build args it actually references, so anARGthe command never mentions changes no cache key — a fix that looks identical to the defect.docker.ymlpassesgithub.run_id, unique per build; a constant would restore the original behaviour with more ceremony. -
spire-uiupgrades too, and the reasoning first given for it was wrong. The claim was that its base carried 24 OS vulnerabilities with four HIGH. It did not: that was measured against a six-week-old local copy ofnginxinc/nginx-unprivileged:1.30-alpine, becausedocker runanddocker buildresolve a floating tag from the local store before the registry. Re-measured after an explicitdocker pull,1.30-alpineand1.31-alpineboth score 0, and the first CI build with the new line upgraded nothing butapk-tools.eclipse-temurin:25-jre-alpine, by contrast, is genuinely stale on its current digest — 34 OS vulnerabilities, openssl 3.5.7-r0 and libexpat 2.8.3-r0, exactly what CI upgraded away on the merge build. The line stays, on the honest argument rather than the wrong one: the two nginx retags visible here are six weeks apart and the older carried openssl 3.5.7-r0, so this closes a window, not a present backlog, for about a second of build time.USER root, upgrade, straight back to101, because that base runs unprivileged by design. Measured on the built image: still uid 101, nginx still 1.30.4,/healthzand/both 200, no errors in the log. -
Dependabot #97 (
nginx-unprivileged1.30-alpine → 1.31-alpine) is declined, and on one reason rather than the two first given. The CVE argument was the stale-image mistake above and is withdrawn — both tags score 0. What stands is checked and independent:stable-alpineresolves to 1.30.4 andmainline-alpineto 1.31.5, so the bump is a stable→mainline branch switch, not a patch, and Dependabot cannot see the difference. With no CVE difference between them there is nothing on the other side of that trade. Worth keeping: the nginx config is a security control (ADR-022 cookie-path scoping depends on all four services answering on one origin),deploy/e2e.shis the only thing that exercises it, and it runs nightly — never on the PR path (techdebt/global/3-3-nginx-proxy-behaviour-is-unchecked-on-the-pr-path.md). Anginx -tagainst the real template passes on both versions, which proves the config parses and nothing about the behaviour. -
The guard derives its file list by walking the tree for
Dockerfile*and joining backslash continuations (spire-publisherchains its upgrade into anadduser), so an image added later inherits the rule instead of escaping it, and it refuses to pass on an empty scan. Five mutations verified — drop the arg reference, freeze the workflow value, remove a service's flag, send the flag to an image that cannot consume it, and removespire-ui's once it could — each failing exactly one test.spire-publisher/Dockerfileis wired the same way although nothing passes it a value yet: it is not indocker.yml, and joining that matrix should be a one-line change rather than a rediscovery.
The nine open Dependabot pull requests were triaged in the same pass; eight are merged and one is declined. Routine and covered by the tiers that ran on them:
docker-java3.5.1→3.7.1 (#101; #98 carried the byte-identical change set and was closed as superseded),@types/node,@types/react-dom,lucide-react, andjunit-bom5.11.4→6.1.3 (#104 — a major by Dependabot's classification and by the repo's own auto-merge rule, but in fact the completion of a migration already done: sixteen declarations were on 6.1.3 and nine on 5.11.4, all nine in modules the fast and service tiers cover). #97 is declined for the reason recorded above.#99 and #100 are a matched pair, and merging either alone regresses the publisher.
jgit7.7.1 moves toslf4j-api2.0.18 whileslf4j-nopstays at 1.7.36, and a 1.7 binding does not satisfy a 2.x api.spire-publisherpinsslf4j-nopfor one reason, recorded next to the dependency: JGit's three-line "no binding" warning on every run's stderr, which the run worker's log stream then carries for every run. Measured by runningLoggerFactory.getLoggeragainst each pair rather than inferred — 7.3.0+1.7.36 silent, 7.7.1+1.7.36 six warning lines, 7.3.0+2.0.18 three, 7.7.1+2.0.18 silent. No test asserts the publisher's stderr, so CI was green for both alone. Merged back to back (different files, no rebase), verified together first:testFastgreen and:spire-run-worker:testgreen includingM0WalkingSkeletonTest(5 tests, 0 skipped) — a real clone and push through jgit 7.7.1.Measured, not estimated: spire-arch 46 tests across 14 suites (43/13 before);
testFastgreen, and:spire-run-worker:testgreen on the paired dependency bump. -
-
Software factory M2 delivered (2026-09-04, PR #119) — the review closes its own findings, except that the full loop has never been run end to end in one place. That qualifier belongs in the same sentence as the claim, because everything below is true and the thing M2 exists to do has been proved only in halves.
/fixon a finding dispatches a sandboxed run that pushes onto the pull request's own source branch (ADR-040), the factory can open a pull request on all four forges, and runs have a screen and a cost. Twelve tasks, T1–T12./fix(T1–T5b). A comment resolves to the finding its thread belongs to, and the run's task is built from the finding alone — nobody types a prompt, because a commenter authoring instructions for an agent holding a clone and a push token is the threat model, not a feature. Two caps bound it (FR-F32): per finding, and per review, the second because each hop of a runaway raises a new finding, so a per-finding counter sees one run each and never fires. The dispatch is linear on purpose — claim, spend cap, plan, configuration, machine account, spec, credential, command, row, launch — with the claim first (the only gate that answers "this already happened") and the credential last (selecting one is a write).PullRequestSink(T6–T7). The port nothing in this codebase had: the reviewer only ever commented on pull requests other people opened, so a factory run ended at a pushed branch. Three adapters, each find-first, because a Kafka record is redelivered on every consumer restart and by then the push has happened — GitHub refuses a duplicate with 422, GitLab and Bitbucket do not, so find-first is the only guard on two of three.NothingToProposenormalises "the agent changed nothing", which all four forges report as a 4xx that reads like a failure. No column ofSCM-MAPPING.md§8 has been measured against a live API; the tests drive a stub this repository wrote, which establishes what the adapter does and nothing about what the forge does.- The run↔review join, and a screen (T8–T9).
factory_runhad carriedreview_idandfinding_refsince V54 with no query reading them beside anything, so neither the caps' evidence nor "what did this cost" could be shown to a person — and there was no list endpoint at all.GET /api/runsrefuses an unrecognised filter value rather than ignoring it: silently dropping a mistypedstatus=faieldreturns every run, which reads as "nothing is stuck", the most dangerous possible answer to the question that page is opened to ask. Cost is a type, not along—RunCostmakes unknown unrepresentable as zero, becauseSUMskips NULL and a run with one unpriced line otherwise reports the priced remainder as a total (ADR-023).Runs.tsxlists all nine statuses and every reader defaults an unlisted one to unknown, never to green and never to busy; the recorded trap is thatrefusedonce rendered as five green segments. - Packaged, behind a profile it must be opted into (T10). A Docker socket is root-equivalent on
the host and the run worker is the one service that executes untrusted model output, so the
factoryprofile is a security decision rather than a convenience: 7 services by default, 8 with--profile factory. Kubernetes deliberately does not get it — a K8s deployment would mount the node's socket into a pod, precisely whatSECURITY.mdpromises that arm removes. - What is NOT proved (T11).
Adr040ExistingBranchTestruns the real thing — real containers, a real smart-HTTP remote, the real publisher image — and reads the pushed content back from the remote. What it cannot reach is the loop: finding → fix run → push → reconciliation is covered by three tests and joined by none, because a run unit lands on the default bridge and cannot resolve the e2e stack'sgitlabservice.RunUnitSpechas no network field. Rebinding GitLab off loopback would undo a deliberate security control, so it is not the answer, and the gap is filed rather than worked around. - Two of my own tests were wrong before any production code was. One asserted the publisher
refuses a run whose branch equals its destination — it cannot, because
ExecuteRun's constructor refuses that outright and the command never exists. The other is the instructive one: the trunk case asserted the publisher's floor refusesmain, and deletinglooksLikeATrunkleft it green. A control probe (refuse every branch) reddened the permitted-push cases, so mutations do reach the container and the survival was real; measuring it showed the run dies asinit container failed with exit 1before the publisher is consulted, becauseWorkspaceClonecallssetCreateBranch(true)and a clone has already materialised the default branch. Two independent guards with the outer firing first — defence in depth working, and a claim no container test establishes. It is inUNVERIFIED.md. - The whole-PR round (T12) found one Critical and it was on the arm with no user.
/fixthrew an NPE out of the saga when the FACTORY account had no resolved login.RunResourcehad guarded exactly that and said why; the/fixpath re-derived the same lookup and dropped the guard. On the REST arm a throw is a 500 the caller reads — on a Kafka consumer it escapes, so the record is redelivered forever and the author who typed/fixis told nothing. The guard moved into the one method that resolves the factory's push identity, because two callers each remembering the same check is the shape this repository keeps paying for.- A comment id is the forge's, not the world's. The fix claim was keyed on a bare comment id,
which every ingress passes straight through from the forge. Two providers, or two self-hosted
GitLabs whose note ids both start at 1, collide — and unscoped that refuses a legitimate
/fixwhile writing another workspace's run id into this review's durable history. Keyed on(review_id, comment_id)now. - A username in the allowlist authorises a review, not a push. The shared author gate accepts
a handle or a stable id, which is right for a command costing one model call.
/fixpushes as the machine account and a forge handle can be released and re-registered, so it matches onproviderUserIdalone — CLAUDE.md's own rule, applied where it had not been. - Two broker outages retired a finding forever. Both caps counted rows whose dispatch was never acknowledged: runs that never executed and never spent, which the projection already treats as re-armable. The filter names the CAUSE, not the status — a run that executed and then died still counts, because the cap is about money already gone.
- The prompt fence was closable from inside it. Writing inside the fence buys nothing the surrounding text does not account for; writing the END marker closes it, and what follows reads as the orchestrator's own voice. Both markers are neutered in any value now, and the three headers above the fence are bounded to one line each.
- A mutation harness produced three false survivals in one run, which is worth more than the
findings. It restored with
git checkout, so it reverted the fixes under test and left the next mutant uncompilable — and a compile failure looks exactly like "no test failed" to a grep. Its perl patterns then used a bare\nagainst CRLF files, so three mutations never applied at all and were scored as survivals. A mutant that does not compile or does not apply measures nothing, and the harness now says INVALID rather than SURVIVED for both.
- A comment id is the forge's, not the world's. The fix claim was keyed on a bare comment id,
which every ingress passes straight through from the forge. Two providers, or two self-hosted
GitLabs whose note ids both start at 1, collide — and unscoped that refuses a legitimate
- Nine mutations across the round, each killing exactly its intended test.
-
Still pending from P1 scope: nothing. Call-level resilience shipped as a hand-rolled retry ladder + circuit breaker, not SmallRye Fault Tolerance — ADR-016 rejected per-call
@Retryfor the review budget, and the same reasoning held for the call level. Model pricing is delivered and deliberately operator-entered (ADR-018): a hardcoded cost table would silently mis-price every review as prices drift, which is the no-fabricated-data rule applied to money. -
Accounts and roles (2026-09-07, PR #120) — the screens say what the code does. The Settings screen labelled Repositories was the SCM account registry, and the one labelled Webhooks was the screen that lists repositories;
POST /api/runssent operators to "Settings -> Providers", which no nav had, to set a role the form could not set. Renamed: Accounts (tabs Machine accounts and People; the Operators screen became the People tab) and Repositories; old routes redirect and keep?edit=. The account form gained a Role field that is fixed after registration — aPUTthat changes it is refused with 409, because a role-lessPUTonce demoted a Factory row to reviewer and handed the review path the push token.GET /api/providers/servinganswers which accounts serve a forge + workspace in five states, using the pipeline's own resolvers, and the Repositories screen shows both roles per row with a per-account Verify. Tracker accounts are listed read-only on Accounts; Context shows Used by. The allowlist field asks for the stable user id, which is what/fixaccepts. No new tables. Design:docs/superpowers/specs/2026-09-07-accounts-and-roles-design.md; out of scope, with reasons, in its §11. -
Accounts normalization (2026-09-12, #148; ADR-041). Machine credentials now live on accounts; context sources hold references plus their source-specific URL, keys and path allowlists. V59 preserves legacy ciphertext while a startup reconciler re-encrypts under the account AAD, atomically per source. Identical credentials at the same origin/authentication identity share a migrated account; failures remain recoverable and do not stop other rows. Six forge accounts and five legacy source types were exercised in a PostgreSQL/Flyway upgrade fixture. Disabling an account stops its sources, rotation is shared, and deletion names the referring sources. Atlassian joins the account registry with CONTEXT role and no workspace. The account screen manages every kind, shows derived usage and advisory scopes, and the source form selects a compatible account. Code-reader platform travels explicitly in the encrypted wire contract.
-
REVIEWER/FACTORY separation is unchanged (ADR-038); gateway and both role resolvers stay as they were. M3 retains workspace remodeling, per-repository push checks and handle resolution.
-
Seven mutations were caught by their intended assertions: unconditional credential collapse, removing the migration transaction, ignoring disabled accounts, restoring host guessing, provider literals outside the composition root, refusing unknown scopes, and an unfiltered account picker. Each mutation was restored before the final gate.
-
Local verification exposed two environment requirements: Quarkus packaging needs the Gradle JVM itself on JDK 25, and publisher tests need Git's shell on Windows PATH. The account-table browser rendering also exposed excess metadata width; compact ellipsis cells retain complete values on hover. 2952 Java tests / 335 suites (one skip), 611 UI tests / 75 files, and TypeScript validation establish the automated coverage. The container deadline test waits for its real publisher drain; the nightly E2E tier is separate.
-
Live scope-family claims and scoped Atlassian gateway limits are in UNVERIFIED. The GitHub header observation used OAuth, not a classic PAT; synthetic fixtures are not presented as live credential evidence. No production token migration or deployment was performed. The software-factory analyst submitted a formal review with twelve inline findings.
-
Review round 1: two high findings were confirmed and fixed. A queued code credential with no platform now skips only code context, preserving other sources and repository rules. A failed scope probe retains its last observed report and timestamp, so an outage cannot erase advice. Both regression tests were mutation-verified by removing their respective guards. Unrecognized legacy hosts now remain recoverable instead of being persisted as GitHub, and remaining rows have named Attention entries. A Java/TypeScript compatibility check guards picker drift; the delete dialog shows usage, missing usage is explicitly unknown, and Preview explains disabled accounts. Dead save-time ping code and its redundant tests were removed.
-
The proposed GitHub scope-policy change was declined with the vendor contract: public_repo includes writes to public repositories, so the account-level rule cannot call it read-only. Per-repository private access remains outside this epic. The redundant conditional was removed and the public-repository case now has an explicit assertion. Migration docs now explicitly say retained columns permit retry of failed rows, not downgrade after successful re-encryption.
-
Final review-fix verification: testFast, testServices and build passed; 2954 Java tests across 336 suites, zero failures and one skip; 613 UI tests and TypeScript passed. Nine intentional mutations were caught across the implementation and review fixes.
-
Review round 2: the analyst's follow-up review confirmed both high findings resolved and accepted the public_repo disposition. Its two non-blocking notes were addressed: the renamed scope-probe fixture now returns an asserted marker instead of accidentally reaching the network, and the delete dialog explains source references conditionally without inferring source names from role labels. The obsolete scope-query wrapper and unconditional scope-write overload were removed so future call sites cannot silently choose the old path. Disabled migration rows intentionally remain in Attention because their legacy credential columns must be retired too; that intent is now commented.
-
-
Factory M3 slice 1 (2026-09-13, PR #153; ADR-042) — repository registration before resolver cutover. Repositories own explicit forge origin, workspace and slug plus reviewer/factory account bindings. The settings view and admin API expose those choices, disabled or missing accounts, identity conflicts and stale edits. Existing review/run callers retain their legacy resolution until slice 2; the account workspace and its constraints remain intact.
- V60 expands the orchestrator schema without changing account ids, ciphertext or context references. Gateway V3 queues versioned metadata snapshots in a transactional outbox; broker acknowledgement precedes completion, and failed snapshots have a registry DLQ replay route. Replays preserve operator rebindings. Automatic migration requires explicit registration origin or stored PR URL evidence; unknown/mismatched origins produce named attention rows and explicit mapping repair. Historical review and run coordinates are linked together.
- The reviewed backup command now targets persistent, git-ignored
.handoff/in the worktree. The existing 182-object, 492,480-byte archive was reused; no second dump was taken for the review correction. A read-only encrypted continuity probe matched 9 real credential/reference entries. This remains pre-upgrade evidence: no dev deployment or live migration was performed. - M2's live proof is recorded for
artyomsv/spire-test#31, runs3987682681:1and3987682176:1, resolved threads and persisted verdicts. The automated GitLab networking gap remains separate. Slice 8b retains the standalone live/fixpublication regression proof. - Review evidence and the per-guard mutation ledger are in
.claude/reviews/global/factory-m3-slice1.md. - Final sequential forced gates: 3005 Java tests across 348 suites, zero failures and one existing Windows symlink privilege skip; 620 UI tests across 76 files and the UI build passed. Forty distinct mutations each failed one targeted test and passed after scratch restoration. Docker-driving tests passed with the live run workers stopped; none was started for this slice.
- Slice 1 review correction: reject blank origins in the wire record while preserving null;
constrain the gateway and repository columns; normalize legacy blank payloads to unknown at
the bridge boundary. The broker test requires both the durable
registration_origin_unknownmapping/revision and a committed consumer offset. Removing the blank-safe check failed the mapping assertion after the offset committed, so DLQ delivery cannot stand in for repair. Four additional isolated mutations bring the total to 44. Final forced gates passed 3009 Java tests across 348 suites, zero failures and the same Windows symlink skip. The archived-review retry fixture now uses a future test clock to prevent its background scheduler stealing the live-row precondition; production timing is unchanged. Dev Services startup timeouts on a parallel retry were cleared by the final invocation's--no-parallel --max-workers=2. - CI correction: replace the private history-link table-name concatenation with two complete literal SQL constants, preserving the four bound values without suppressing Semgrep. The existing history-bridge suite passed all 11 tests. The failed full scan and separate OSS check each named the same single finding.
- Reviewed bridge rollout: orchestrator V60 and gateway V3 from
a956532reached dev before the new CI hold. All 37 reviews and 14 runs mapped to six repositories with eight bindings; all three existing gateway snapshots were acknowledged and remain origin-unknown repair rows. The real encrypted baseline comparison passed all 9 entries after V60. Runtime account resolution remains on the legacy path; slice 2's later cutover comparison is still required.
-
M3 slice 2 — repository cutover (PR #153, 2026-09-13; awaiting review): all active SCM dispatch uses explicit repository/role bindings. Account workspace leaves DTOs, forms and runtime SQL; V61 drops its old constraints while retaining populated rollback evidence. Gateway V4 preserves hook keys/secrets/rejection history and adds one hook per kind. Verified provenance uses a new topic, FACTORY activity is separate, and unknown repositories produce Attention instead of auto-enrollment. The repository screen supports optional hooks, missing roles, partial-save retry and legacy repair at the gateway owner. Nested GitLab paths preserve old review IDs and transport AADs. Criterion 7 has its exact named tests and production-schema mutation; 62 distinct mutations are recorded. 3048 Java tests across 357 suites, zero failures and 1 existing Windows symlink privilege skip; 630 UI tests and packaging passed. The real gateway-first V4/V61 rollout preserved 6/37/85/14/3 rows, all retained workspaces and hook rejection metadata; 9 account/context and 12 webhook entries matched after decryption. The three missing origins remain explicit repairs, not inferred mappings. No live run worker, new spend, synthetic live row or second dump was used. Later M3 criteria remain pending.
-
Factory M3 slice 3 — resolved people and editable overrides (PR #153, 2026-09-13). Account person entry resolves with the selected credential, repeats lookup and stable-ID verification on save, and renders cached handles after reload. Raw-author writes through the older account endpoint are refused; ordinary credential edits retain policy and observations. GitHub/GitLab exact matching and Bitbucket/Jira explicit selection have separate capability notes. Identity redirects cannot cross origin. V62 adds display observations, durable stale flags, optimistic revisions and one ALLOW/DENY row per repository actor. Its legacy-grant filters and constraints are measured against fresh V61 schemas. The account/workspace write guard now covers INSERT and UPDATE; the startup reconciler omits the retained column. The HTTP/DB and fresh-reader-JVM proof plus UI round trip establish automated criterion 6; work-source screen wiring follows in slice 5/6 and effective push authorization in slice 4. Tests caught a refresh failure that vanished on reload, an ambiguity fixture that could pass on a 404 and an old account form overwriting a concurrent policy during token validation. The locked registry write now owns the compatibility check and rolls back the stale edit. 3118 Java tests, 636 UI tests, packaging and pinned Semgrep passed; 92 production mutations have selected failures and scratch-restored passes. See the slice 3 review notes for boundaries and the exact ledger. Live V62 preserves the approved row and credential baselines; the existing GitHub person resolves by stable ID and keeps its observed handle across a full service restart.
-
Factory M3 slice 4 — effective push authorization (PR #153, 2026-09-13). /fix applies repository DENY, ALLOW, then a fresh effective permission read through the selected reviewer. Unknown refuses with a named capability error. GitHub effective collaborator roles, GitLab inherited active membership and Bitbucket effective paginated rights have fixture proofs; live token limits remain explicit. The total read budget is 20 seconds and cancels unfinished work. Repository/account locks prevent a credential edit or rebind from splitting a decision. Self-loop, observe/archive, finding, target, spending and both fix-cap guards remain in force. Account review policy now matches only stable IDs: a numeric username cannot impersonate another actor's stored ID. 64 compiling production mutations have exactly one selected assertion failure each and byte-identical scratch restoration with a passing baseline. A malformed-user fixture initially masked its guard with a second missing field; the repaired fixture preserves valid repository metadata and kills exactly the intended identity mutant. 3186 Java tests, packaging and pinned Semgrep passed. Criteria 6 and 7 were independently accepted; criterion 5 has automated proof. No production migration, live credential elevation or second dump was needed.
-
Factory M3 slice 5 — first durable tracker ticket (PR #153, 2026-09-13). The GitHub work-source arm shares the existing pinned read transport and authentication, with writes on a separate facade. The Apache-2.0 SPI remains framework-free. Explicit source and repository bindings, source-owned resolved actor IDs, immutable profiles and a repository ceiling govern signed issue intake and scans. Current-label authority requires complete audit evidence; an allowed actor hint with UNATTRIBUTED origin still grants nothing. Membership, attribution and genuinely missing identity have separate service cases. Deleting membership or attribution fails only its named test across the complete intake class. Events, projection, dedupe and encrypted outbox commit together. The rollback mutant commits an orphan event on a separate transaction, which the test detects after an injected projection failure. Real Kafka tests require committed consumer offsets before cleanup, including the duplicate delivery; merely seeing the first row had allowed cleanup to race redelivery. Work events have their own topics and cannot become review history. Work items displays durable workflow, effective/admitted modes and label evidence; current title/body/status comes from a separate tracker fetch. Removing a restrictive label cannot widen the combined policy retained at admission. Missing specification/approval execution stays visibly unavailable. Round 7 independently accepted criterion 5 and found that a 20-second permission read held repository/account locks. Short revision snapshots now bracket the bounded remote call; rotation/rebinding completes while the forge is still waiting and invalidates its answer. The carry has seven mutation proofs, including deleting the re-read. No shared dev database, live token privileges, backup or run worker was changed. Live tracker/token behavior, parity, uncertain remote writes and bounded recovery of slow scan pages remain explicit later work. Validation: 3371 Java tests / 393 suites / 28 modules, zero failures and 1 existing Windows skip; 650 full UI tests plus 12 final targeted tests; packaging and pinned Semgrep passed. 154 mutation checks cover 153 distinct production changes, including Java, schema and UI guards. Scope validation also accepts valid dot-prefixed GitHub names while refusing dot path segments. Criterion 3 awaits independent review.
-
M3 round 8 — route smoke-test correction (2026-09-13). Criterion 3 was independently accepted, bringing the count to four of seven. Dashboard CI exposed a route fixture missing required policy fields; the test could pass against its initial wrapper before the payload rendered because it awaited the shell loading text rather than the detail responses. The same exception was reproduced in isolation, so a single isolated pass did not establish mock leakage. The fixture now satisfies the API type and both response headings must render before the unchanged
.contentassertion. Each case gets fresh session/storage/globals and unmounts before global teardown. The complete file passed 35/35 in normal order and with shuffle seeds 42, 99 and 5191448392. Removing the production wrapper failed exactly the retained assertion; scratch-byte restoration passed. Final full UI: 650/650, no unhandled errors, production build passed. The prior full UI run predated the last display additions; targeted tests had missed this fixture. Evidence:.claude/reviews/global/factory-m3-round8.md. -
Factory M3 slice 6 — source parity and recovery (PR #153, 2026-09-13). Round 9 independently accepted the pending-effects teardown correction on d426501c, with 650 UI tests and the retained mutation-verified route wrapper assertion. Criteria 3, 5, 6 and 7 remain four of seven. GitLab and Jira now share the work-source contract through the existing pinned context transports and separate writers. GitLab has authenticated issue hooks; Jira polls, binds actual Cloud accountIds and refuses unsupported Data Center attribution. Both new arms separately prove unlisted and unattributed labels grant nothing, with the unattributed hint deliberately passing membership. Source settings expose explicit mapping, resolved actor entry, enablement, rescans and measured capabilities. V65 stages coordinates and commits each reconciliation with its checkpoint. Actual packaged JVMs are killed between pages and mid-page; restart admits each item exactly once without rereading completed coordinates. V66 encrypts tracker effects separately from Kafka outbox events. It commits uncertainty before HTTP, checks current policy/local revisions and never blindly resends. A successful remote POST followed by client timeout recovers through its marker with exactly one POST. A second connection observes the durable claim during HTTP. Mutation work exposed a Jira offset fixture whose second inconsistent completion field masked its guard; every other completion fact now agrees. An explicit Java-test Semgrep scan exposed a computed ProcessBuilder executable; literal java plus the Gradle-selected toolchain PATH removed the pattern without a suppression. 3545 Java tests, 675 UI tests, packaging and pinned Semgrep passed. 142 checks cover 140 distinct production mutations. No dev database, backup, live tracker write or run worker was used. Per-arm live forge/token limits and the exact TEST cleanup remain explicit in UNVERIFIED and the slice review notes.
-
Factory M3 slice 7 — visible policy bounds and durable approvals (PR #153, 2026-09-14). Round 10 accepted slice 6. GitHub now inherits the shared parity cases; an architecture test derives adapter modules and fails when Jira loses inheritance. Profiles bound every mode and numeric cap by all eligible labels, admission and the current ceiling, with cumulative protected paths. ADR-045 states the invariant and resolves the FR-F22/F25 classification conflict. V67 persists visible clamp milestones. Separate meet, silent-write and empty-message mutants fail the named criterion 2 tests. A running SPEC result under a newly disabled PLAN persists plan_off without another attempt or PR; a gate policy revision change requires a new decision even when every mode and cap remains identical. Both criterion 4 mutants are discriminating. V68 binds versioned gates and attempts, encrypted notes, usage and reservations. Dashboard answers and expiry serialize on the aggregate; late/conflicting answers refuse, winning retries are idempotent, and expiry releases the reservation and invalidates pending effects. One child JVM itself opens the gate, is killed while OPEN, and a second expires it. Approvals, policy settings, detail and current attention conditions expose the durable results. 3649 Java tests, 701 UI tests, packaging and pinned Semgrep passed. 123 mutation checks cover 120 distinct production changes and the authorized inheritance check. Criteria 2 and 4 are ready for independent review; 3, 5, 6 and 7 remain verified. Artifact/build handoff remains slice 8. No dev data, backup, live tracker write or live run worker was used.
-
Factory M3 slice 8a — prepared tasks and distinct build journeys (PR #153, 2026-09-14). Round 11 accepted slice 7 with no findings and independently verified criteria 2 and 4, bringing the count to six of seven. Humans register actual tracker specification and single-step plan versions. The dashboard reads the specification digest before the plan exists and supplies its JSON format; all three tracker arms resolve stable scoped identities through their selected source. Artifact bodies remain transient. The existing phase policy accepts manual evidence, binds approvals to artifact versions and build coordinates, and keeps its real approval branch. Suggest stops before build with zero runs; assisted opens a durable plan gate with zero runs, then records its human answer and admits one build; autonomous admits one build without a gate. The UI proof receives no profile names and exposes actual phases, gates, decisions and runs. V69 joins a phase attempt to its M2 run and commits uncertainty before broker dispatch. Dispatch compares current authority with the original attempt decision, so intake redelivery cannot authorize a pending build after a lowered ceiling. Encrypted terminal-result inboxes recover across the completion/acknowledgement boundary; duplicate old results cannot change a new phase attempt. Usage survives readmission, unknown potential spend blocks, and M2's narrow pre-agent failure classification permits repair without inventing a purchased call. Associated runs cannot enter standalone automatic PR creation. Verification caught an overflowing JSON schema version, the pending-dispatch policy race, absent-charge accounting that would block a proven pre-agent failure, and a late deployment validation error that could escape instead of refusing a pending build durably. Production item execution remains unavailable until the slice 8b publication hold; the policy proof explicitly supplies a test transport. No M4 verifier, real-container item build, draft delivery or live-forge journey is claimed by 8a. 3739 Java tests, 711 UI tests, packaging and pinned Semgrep passed. 92 checks cover 92 distinct production mutations. Criterion 1 is ready for independent review. Evidence is in
.claude/reviews/global/factory-m3-slice8a.md. No dev data, second backup or live worker was used. -
Factory M3 slice 8b — held publication and observed delivery (PR #153, 2026-09-14). Round 12 independently verified criterion 1 on d9861bc3; all seven acceptance criteria are proved. The review accepted five independent journey axes and the assertions that history persists artifact references while manual acceptance cannot invent executor completion. Item execution now uses a distinct held command and publisher entry point. The worker persists encrypted execution, topology and independent readiness/terminal acknowledgements. It checkpoints the real head without pushing, stops active compute and retains its workspace through restart and orphan recovery. A current short-lived delivery permit resumes only the trusted publisher with fresh SCM credentials, exact head and both original/current protected-path floors. Production VERIFY and LAND remain unavailable. Native draft delivery is requested and observed across all three adapters; REVIEW consumes the existing review at the exact reviewed and posted head. An ambiguous PR creation recovers only by reading; a stale publisher reader cannot rewind its durable proposal claim. Late paid usage survives stop/readmission without completing a newer attempt. Separate worker results share the existing M2 charge identity. Real local-origin execution and three worker JVMs prove recovery after readiness, after a durable publication claim before IO, and after a real push before terminal commit, with one build. Verification exposed missing late accounting, checkpoint loss after an early terminal result, a stale proposal race, rotated-credential failure redaction and two unmapped publisher causes. The producer-derived cause inventory caught the last pair during the full fast suite; both now retain paid-build accounting and refuse retry of the same publication permit. Forced fast/service suites and packaging passed sequentially: 3970 Java tests, 714 UI tests, 248 checks covering 245 distinct production mutations, and zero pinned Semgrep findings across 98 files. The live orchestrator was refreshed from the tested source after Docker tests exited; its repository/account inventory is unchanged. Live ingress required an explicit repair of the GitHub webhook's missing origin. Two older PRs refused missing historical finding/fork metadata without dispatch or cap changes. Fresh TEST PR #32 then completed the standalone
/fixchain: run4003204361:1automatically pushed264ff858b538a3c779cf94161d3bc601bcfcf69a, the next review completed on that head, and both GitHub and the persisted finding recorded resolution. The user started the worker after the service tier exited and was notified when the proof ended. Cleanup closed the TEST PR and deleted its exact source branch, retaining the review/run audit. Evidence:.claude/reviews/global/factory-m3-slice8b.md. No second backup was taken. -
Factory M3 final implementation and handoff (PR #153, 2026-09-14). All seven acceptance criteria were independently verified through round 12; round 16 accepted slice 9 with no findings. The consolidated acceptance record maps each criterion to its slice, exact witnesses and mutation ledger. Shared table/form/empty-state styling was accepted after operator inspection, with route-derived guards and save lock-out preserved. External gate answers share ResolveGate while retaining distinct channel authority. Ordinary approving prose cannot approve. Current named native reviews bind the linked head, human ID and measured push permission; dismissed/stale approvals refuse. Recorded bot IDs survive renames and rotation, while a human wearing the bot's display name still takes over. Takeover supersedes gates, invalidates unstarted effects and durably holds publication. A real worker JVM is killed after revocation commits but before compute stops; a fresh permit and watchdog cannot publish without an M1 cancel claim masking the proof. Concurrent in-flight PR recovery records one observed outcome and keeps the item suspended. Operator resume requires current evidence and an authenticated, versioned action with a note. Retired items cannot resume. Slice 10 ran AccountWorkspaceIsUnusedTest before the drop and independently re-killed its read, INSERT and UPDATE arms. V72 explicitly removes only scm_provider.workspace. A populated private V71→V72 migration preserves every other account field, ciphertext/AAD, bindings, context references and immutable legacy mapping rows; replacing DROP with SELECT 1 fails its assertion. The fresh .handoff backup was verified at 2026-09-14T14:10:32.6500742+00:00 before dev migration. Dev's full inventory remains 6 accounts / 38 reviews / 93 findings / 15 runs / 3 hooks. The accepted PR #32 audit explains +1 review, findings 152–159 and run 4003204361:1; excluding only those recorded proof rows, the original 6/37/85/14/3 baseline still matches. All 9 encrypted credential/reference entries and 12 webhook entries remain identical after decryption. Final forced fast/service tiers and packaging passed sequentially: 4076 Java tests in 452 suites and 30 modules, zero failures/errors and 1 existing Windows symlink skip. The full UI passed 730 tests in 92 files, TypeScript and production build. Slice 9 records 78 distinct production mutations; slice 10 records four. Pinned Semgrep reports zero findings across 5 changed code files. Earlier per-slice counts remain in docs/factory/M3-ACCEPTANCE.md without an inflated cross-slice distinct total. Production VERIFY and LAND remain unavailable. M4 owns the verifier; M3 does not ship one. No live item-build proof: the accepted TEST PR #32 run proves standalone /fix only. The automated GitLab run-unit gap remains open: RunUnitSpec has no network field and cannot reach the e2e stack's GitLab; a live GitHub run does not close it. Both factory images remain absent from GHCR. Separate per-forge identity/permission UNVERIFIED entries remain unchanged. No warm GitLab e2e stack was available for this handoff. No new live canary or dev run worker was started. PR #153 remains draft for final operator review; no merge is claimed. Evidence: .claude/reviews/global/factory-m3-slice10.md.
-
Factory M3 operator setup usability (PR #153, round 18, 2026-09-14). Work policy/source pages now open on lists with Add actions, and saved records appear with confirmation. Source repository choices explain account prerequisites and origin mismatches; configured-account pickers reuse accountOptionLabel. Field help, required/optional/automatic markers and the policy nav icon complete the operator setup fixes while preserving save locks. WIDGETS.md inventories shared controls. Route-derived guards now preserve quoted wildcard paths; a previously surviving profile-table mutation exposed that comment-parser gap and now fails. Measured 742 UI tests in 93 files, shuffled setup/routes, TypeScript/build, 28 production mutations and clean Semgrep. Eight browser screenshots use intercepted TEST-only data. All seven accepted criteria and the separate production/live-proof limits remain unchanged. Evidence: .claude/reviews/global/factory-m3-round18.md.
-
Factory operator experience, and M3 on master (2026-09-16). The operator's first live item test stopped item #36 at
verify / awaiting_input / run_usage_unknownand produced ten findings. The operator-experience specification records each one, the operator's decisions (1B 2A 3A 4B 5A) and the chosen mockups (list A, detail B, approvals C). Shipped: named refusal details for every artifact rule; harness and model selects; a branch-head read with honest failures; progress words and locks on every factory action; the Approvals icon; "Billed to" on run detail; label appliers shown by handle. Work items became one triage list with filter counts, polling without overlap and a decision panel beside it. Approve is offered only when the texts on screen were read against the binding the gate stores, and a decision whose preparation is gone cannot be approved. The detail page became eight journey steps with generation-scoped proof;/approvalsredirects to the Needs-you filter. Six developer review rounds ran; each verified finding got a test and a mutation that fails it. Measured after merging master's dependency updates: 847 UI tests in 98 files, TypeScript and build; spire-orchestrator 1764 tests in 202 suites; testFast green. The other testServices modules and packaging were not re-run. Still open and moved to M3.5: spec and plan tickets and the plan JSON (part C), incomplete pricing (part D), Codex subscription sign-in (part F). By the operator's decision the branch was pushed to master directly; PR #153 was not merged through GitHub.