# Argus extraction v1 — checkpoint round 3, release and operations review (2026-09-08)

Reviewer: independent, third angle. Scope: is this safe to release, and what happens when it goes wrong
in production. Reviewed the **committed state at `81ab4fc`** on `ops/argus-extraction-v1-red`. The working
tree carries another session's uncommitted work under `src/Sibyla.Web/**` and `tests/Sibyla.Tests.Browser/**`;
every web citation below is from `git show 81ab4fc:<path>`, not from the working tree. I did not re-audit the
two earlier rounds' closure and did not re-derive the scores. I ran no deployment, started and stopped no
service, applied no migration, queried no database, called no CLI, and opened no secrets path.

## Verdict

**REVISE — Critical 0 / High 4 / Medium 9 / Low 7.**

## The plain answer

**Do not release it as it stands — but the code is not the problem. The runbook is, and one configuration
line is.**

The engineering in this slice is, on the operational axis, better than it needed to be. The migration
ordering claim in §4.6 is not merely true, it is under-sold: the module runs `Precondition.Schema` in the
**Install** phase before any write (`DeploymentPlanCompilerV2.cs:81`), so an unmigrated Main fails the
deployment *before a single file is copied*, and health check 9 catches it again after activation. The
rollback claim survives inspection: all three migrations are additive and nullable, the previous release's
code never names the new columns, and a health failure triggers an automatic, phase-reversed compensation
of the whole run (`DeploymentEngineV2.cs:288-300`). Three of the four override sources the start-up refusal
warns about cannot even exist on this host, because the module pins the service's environment block to
exactly two names and fails Install if it differs (`ValueObjectsV2.cs:352-374`). The start-up refusal itself
fails closed at deploy time — a refusing worker never reaches `Running`, `Process.Start` fails inside the
Health phase, and the release rolls back.

What is not ready is the paperwork and one pin:

1. **The owner guide's release section, §7, tells the operator to do the thing §8.2 of the same document
   says will stop the worker.** §7 was written before the fix and never corrected. It is the section an
   operator will actually follow.
2. **§7 contains no runnable command at all** — it points at a run record, whose procedure needs a
   preparation script that is not in the repository.
3. **`ClaudeCliPath` is not pinned and is not a release fact.** The whole gate rests on running the binary
   the bench ran; production resolves it at runtime through a fallback chain whose first candidate is a
   per-user npm install. The bench pins it explicitly and §5.7 says so in as many words; production does not.
4. **There is no way for anyone to find out that the worker refused to start.** After three SCM restarts
   (~6 minutes) the service stays Stopped for 24 hours. Nothing watches it; the module's health checks only
   run during a deployment; the Uploads page shows the documents sitting at `Queued`. The guide never says
   how to see it or how to get back.

### The shortest path to yes

All four are documentation plus one configuration key. None needs a code change beyond adding a key to a
list, and none needs the sittings re-run (`ClaudeCliPath` is not part of the §4.6 tuple).

1. Rewrite guide §7: the five facts now travel in the release's own `appsettings.json`; the operator's job
   is to make sure `appsettings.Production.json` and `worker.json` do **not** carry them. Delete the JSON
   block, or relabel it as the content of the committed `src/Sibyla.Worker.Documents/appsettings.json`.
2. Put the actual release commands in §7: merge, `publish-release.ps1`, the staging root, the launcher and
   its arguments, and where the preparation step lives. Move `Prepare-Deploy.ps1` into the repository beside
   `local/deploy/tests/Sibyla.Deployment.Tests/probes/e2-launch-v2.ps1`.
3. Add `"ClaudeCliPath": "C:\\Apps\\Sibyla\\tools\\claude\\claude.exe"` to the worker's `appsettings.json`
   and `"ClaudeCliPath"` to `ReleaseFacts.Keys` (and to the `publish-release.ps1` key list).
4. Add a short §8.5 to the guide: what a refusal looks like (Application event log, `Get-Service`), the four
   causes, and the recovery for each — including that `Worker:StartupCheckSeconds` may be raised in
   `worker.json` without tripping the refusal, because it is not one of the five.

That is a morning's work on the guide and a two-line code change. With those four done I would accept.

## Findings

| id | sev | file:line / step | what is wrong | why it matters in production | fix |
|---|---|---|---|---|---|
| R3-1 | **High** | `docs/apollo-argus-extraction-v1-owner-guide-260907.md:237-239` vs `:281-305` | §7 ("The release") instructs: *"Before publishing, the worker's `appsettings.Production.json` gains the release facts"*, and gives the JSON block. §8.2 of the same document says any of the five facts in `C:\Apps\Sibyla\worker\<release>\appsettings.Production.json` **will stop the worker**. §7 was never corrected after the R2-18 fix. | §7 is the release runbook. An operator following it creates the file the worker now refuses over. If any value drifts by one character — and §7 itself warns that the guide previously carried a *different* hash, `b85d0995…` — the worker will not start and the pipeline is down. §7's block also carries `ClaudeTimeoutSeconds`, which is not one of the five and is not checked, so a partial follow leaves a half-configured host with no signal. | Rewrite §7. State that the tuple ships inside the release, that `appsettings.Production.json` must not carry it, and cross-reference §8.2 rather than contradicting it. |
| R3-2 | **High** | guide `:230` ("as run record §7p does today"); `docs/apollo-deployment-run-260903.md:564-597` | §7 gives **no command**. Its only pointer is run record §7p, which describes `D:\fileStorage\tmp\apollo-deploy\Prepare-Deploy.ps1` → `e2-launch-v2.ps1 -Step Execute` → `e2-execute-v2.ps1`. The launchers are in the repo (`local/deploy/tests/Sibyla.Deployment.Tests/probes/e2-launch-v2.ps1`, `e2-execute-v2.ps1`), but `Prepare-Deploy.ps1` exists **only** under `D:\fileStorage\tmp\apollo-deploy\` and is untracked. `e2-execute-v2.ps1:2-14` has ten mandatory parameters, four of which (`ExpectedCandidateCommit`, `ExpectedCandidateFingerprint`, `ExpectedC2Commit`, `StagingRoot`) are exactly what the missing preparation step computes. | A competent operator who has not read this conversation cannot get from "both reviewers accept" to a deployed release. A wiped temp folder — an ordinary thing to happen to `D:\fileStorage\tmp` — leaves no documented path to production at all. | Move `Prepare-Deploy.ps1` into the repository. Write the sequence out in §7 with its arguments. |
| R3-3 | **High** | `src/Sibyla.Worker.Documents/WorkerOptions.cs:7`; `ClaudeCli.cs:193-203`; spec §5.7 | `ClaudeCliPath` defaults to the bare string `"claude"` and is **not** set anywhere in production configuration. `ResolveCliExecutable` then prefers `%APPDATA%\npm\node_modules\@anthropic-ai\claude-code\bin\claude.exe` for the service account and otherwise falls back to a PATH lookup of `claude`. The gated binary is the pinned self-contained copy at `C:\Apps\Sibyla\tools\claude\claude.exe` (`docs/apollo-deployment-run-260903.md:89-91`). The bench pins it explicitly and §5.7 says "**never** the npm fallback of `ResolveCliExecutable`" — production is left on exactly that fallback. `ClaudeCliPath` is also not one of the five release facts, so nothing stops a host file from changing it. | The entire Q-EX-14 gate is "production runs the tuple the bench ran". Nothing in the release binds production to the binary the bench ran; only its self-reported version is compared. Anything that puts a `claude` earlier on the machine PATH, or installs claude-code under the `SibylaWorker` profile, silently changes which binary runs — and if it happens to report `2.1.259` the check passes. If it reports anything else, the worker refuses and the pipeline stops (R3-4). Neither outcome is acceptable and neither is documented. | Add `Worker:ClaudeCliPath` to the release's `appsettings.json` with the absolute pinned path, and add it to `ReleaseFacts.Keys` and to `publish-release.ps1`'s key list, so it is pinned, published, rolled back and refused-on like the other five. |
| R3-4 | **High** | `local/provision-production.ps1:220`; `WorkerStartupChecks.cs:20-36`; guide §8 | A start-up refusal is invisible. `sc failure … actions= restart/10000/restart/60000/restart/300000` gives three restarts over about six minutes, after which the SCM takes no further action and the service stays Stopped until the 86400 s reset. Nothing watches the service: the module's twelve health checks run only inside a deployment, `Documents.razor` shows affected rows as `Queued` with no worker-state indicator, and no `ILogger` line is written (the exception is thrown out of `StartAsync`, so it surfaces only as a CLR unhandled-exception event in the Windows Application log). The guide says nothing about detection or recovery. | This is a new failure mode this slice introduces: v1 had no start-up checks, so a CLI drift merely showed up in the evidence. Now it stops the document pipeline. Client invoices queue silently. The plausible innocent triggers are real — a replaced or upgraded CLI binary, and a cold start where `claude --version` plus the package hash do not both finish inside the 10 s shared budget (`WorkerOptions.StartupCheckSeconds = 10`), which on a reboot with on-access antivirus scanning a freshly copied release tree is not far-fetched. | Add a §8.5 to the guide: the four causes, `Get-Service` / Application-log diagnosis, and the recovery for each. Note explicitly that `Worker:StartupCheckSeconds` can be raised in `worker.json` **without** tripping the refusal, because it is not one of the five — that is the one escape hatch and it is currently undocumented. Consider raising the default from 10 s. |
| R3-5 | Medium | `ExtractionRunner.cs:125-135`; `ClaudeDocumentProcessor.cs:93` | Evidence now lives in a per-job folder `<StagingRoot>\evidence\<jobId>\` and **nothing ever deletes it**. v1 wrote `<StagingRoot>\evidence\attempt-N-*` and overwrote, so it held roughly one job's worth. There is no purge, no retention rule (§4.6's retention rule covers only the CLI transcripts) and no free-space guard. | Unbounded growth on `D:\`, the same volume that holds every staged client document. When it fills, `File.WriteAllTextAsync(rawPath, …)` at `:157` throws, the exception unwinds to `QueueWorker`'s catch-all (`QueueWorker.cs:149-155`), the job is recorded as a retry, and the document burns all five attempts at up to 900 s of real model time each before dead-lettering — with the CLI cost paid every time. | State a retention rule for `<StagingRoot>\evidence` in §4.6 and give the owner a purge in §8, as §5 already does for the CLI transcripts. A free-space precondition in `SweepSandboxes` would be cheap. |
| R3-6 | Medium | `local/publish-release.ps1:44-45` vs `:48-52` | The publish step checks that each manifest **destination path exists** under `<target>\skill\`. It never hashes the published tree. The comment at `:51-52` claims "a release … whose package hash disagrees with the pinned golden is not published"; the code compares only the *string* in `appsettings.json` to the golden, never bytes on disk. There is also no extra-file check, so a `skill\` tree with an added file passes. | The one defect this check exists to catch — a published package that is not the gated package — is detected only by the worker refusing to start, i.e. as a stopped pipeline at the end of a deployment rather than an error at publish. `SkillPackageTests` covers the *committed source* package, not the published output. | Recompute `SkillPackage.TreeHash` over `<target>\skill\` in `publish-release.ps1` and compare it to `$golden.treeSha256`. The worker already has the code; the publish step needs the same twelve lines. |
| R3-7 | Medium | `Documents.razor:205-228` (committed); spec §4.7 C-16 amendment | §4.7 says that when a row is flagged both ways, ruling the register question leaves the checksum reference "unruled, for the Uploads page to put to a person as it always did". It does not. The duplicate buttons render only inside `case IntakeProcessingStatus.PossibleDuplicate:`; `RuleOnDuplicateAsync(false)` on a register duplicate calls `ReleaseHeldProcessing`, which moves the row to `Processed` or `HeldForPerson` (`IngestionService.cs:452-466`), and the page never asks again. | A byte-identical duplicate within the company is processed with the question silently dropped. The row keeps `duplicate_of_id`, so the fact is recorded, but no person is ever asked and no audit row says the question was deferred. The spec asserts a behaviour the code does not have. | Either render the checksum question for a `Processed`/`HeldForPerson` row that still carries a `duplicate_of_id` and no ruling, or correct §4.7 to say the reference survives as data only, and say who is expected to look at it. |
| R3-8 | Medium | `Documents.razor:249-250` with `:545-546` | After a person confirms a register duplicate, the row renders `L.F("Confirmed duplicate of {0}. …", OriginalName(d))`, and `OriginalName` resolves `DuplicateOfId` — which is null for a register duplicate — so it falls back to the literal `"previous document"`. The register entry code the intake was archived against is never shown. | This is the Document Archiving Policy clause 1 action. The page tells the operator the intake was archived against "previous document" and never names the entry. The information is in `register_entry_code` on the row and in the audit detail, one field away. | Render the entry code in the `Duplicate` arm when `RegisterEntryCode` is non-null, as the `PossibleDuplicate` arm at `:208-215` already does. |
| R3-9 | Medium | `CompanyGate.cs:146` vs `IngestionService.cs:767-786` | The gate matches only live entries (`h.src_deleted_at IS NULL`). The ruling does not re-check. `MatchedFiscalKeyAsync` reads `fdchdr JOIN entmst` with no `src_deleted_at` filter, and `docint.register_entry_id` has no foreign key (migration `20260907105936_ExtractionV2RegisterReference.cs:21-25` adds a bare nullable uuid). The sync upserts `fdchdr` by `entry_code` and preserves the id, and tombstones rather than deletes — but a source-side **rename** of an `EntryCode` tombstones the old row and inserts a new one with a new GUID, leaving the stored pointer on the tombstone. | Between the hold and the ruling, days can pass and the sync runs continuously. The person is shown an entry code, rules "duplicate", and the intake is archived pointed at a register entry the source has since deleted or renamed — with a `duplicate_classification` row written against a dead `entry_code`. Nothing tells the person. | Re-read the entry inside `RuleOnDuplicateAsync` and refuse (with a message, not an exception) when `src_deleted_at` is set or the row is gone. Cheap: `MatchedFiscalKeyAsync` already queries `fdchdr` by id. |
| R3-10 | Medium | spec §4.6 "Rollback" vs `IngestionService.cs:452-466`, `:600-736` at the previous release | The **schema** claim is correct — I verified all three migrations are additive and nullable, and the previous release's code names none of the new columns. But "nothing is lost" overstates what a rollback does to the v2 rows already written. The previous release's `ReleaseHeldProcessing` has no `HoldReason` arm, so releasing a held-and-duplicate row lands it in `Processed` — presenting a V-19-truncated statement as a completed extraction. Its `RuleOnDuplicateAsync` knows nothing of `register_entry_id`, so a register duplicate is ruled as a bare override with no `duplicate_classification` declaration — precisely what D-EX-5 exists to prevent. | A rollback is taken under pressure. The operator will read §4.6, see "needs no schema rollback … nothing is lost", and not know that held and register-flagged rows are silently mishandled by the code they are rolling back to. | Amend §4.6: no schema rollback is needed, **and** rows in status 9 or carrying a `register_entry_id` must be left alone until roll-forward. Give the operator the two SQL predicates that list them. |
| R3-11 | Medium | `tests/Sibyla.Tests.Argus/evidence/extract-v2/score-20260907-2250-…/*/attempt-1-result.json` (`modelUsage`) | Every recorded attempt's `modelUsage` block carries **two** models: `claude-opus-5` (the pinned one) and `claude-haiku-4-5-20251001`, ~981 input / 14 output tokens per run. Q-EX-14 says "production runs only that tuple". A second model is invoked on every job, is not in the tuple, is not named in the score report, and no probe establishes what it is given. | Not a security claim — `webSearchRequests` is 0 throughout and the token count is far too small for a document — but the tuple is the entire gate, and a second model that nobody has characterised runs inside it on every client invoice. It is also outside the Q-EX-14 change discipline: an upgrade to that model would change production behaviour with no re-run obligation. | Name it in §4.6 and in the score report's configuration tuple line, and record in R-EX-2 what the call is given. One `stream-json` trace already on disk probably answers it. |
| R3-12 | Medium | `Documents.razor:589-601`, `:625-637`; no `<ErrorBoundary>` anywhere in `81ab4fc:src/Sibyla.Web` | `RuleOnDuplicateAsync` and `ReExtractAsync` are `try`/`finally` with no `catch`, and the app has no error boundary. Any exception from `IngestionService.RuleOnDuplicateAsync` — which now does two raw-SQL round trips against `duplicate_classification` and `fdchdr`/`entmst` (`IngestionService.cs:639-682`, `:769-785`) — tears down the Blazor circuit. Related and unverifiable from here: the `INSERT INTO duplicate_classification` is a **new write path for the web role**; `sibyla_app` gets it from the blanket grant in `local/setup-db.ps1:212-219`, and `PreviewParityTests` compares grants for `sibyla_api` and `apollo_ai_reader` only, never `sibyla_app`. | If the grant on Main is not what Preview has, the first person to click "Confirm duplicate" on a register duplicate gets a dead page, not a message. The intake is not lost, but the operator has no idea why and no way forward. Even with the grant in place, any 22001/23505/serialization failure has the same shape. | Catch `Exception` in the two handlers and surface `_duplicateError` / `_actionError`, as the methods' `string?` return contract already intends. Add a one-line grant check to the release runbook: `SELECT has_table_privilege('sibyla_app','duplicate_classification','INSERT');` on Main. |
| R3-13 | Medium | spec §4.5 "Hostile-document run"; `tests/Sibyla.Tests.Argus/evidence/extract-v2/` | `hostile-documents-run.md` does not exist. The corpus (`tests/Sibyla.Tests.Argus/golden/extract-v2/hostile`) and the runner (`tools/skill-package/Invoke-HostileDocumentRun.ps1`) do. §4.5 requires the run "recorded once". | My judgement: **releasing without it is defensible, on a condition.** The controls that matter are structural, and they were proved on the pinned CLI on 2026-09-07 — the tool set is `Read` alone, there is no Bash, no WebFetch and no MCP, so a document that says "fetch this URL" or "open the secrets file" has no instrument, and probes (a), (a′) and (b) plus the two deny matrices proved confinement and rule binding, including finding the one rule that did not bind. What the missing run would test is *behavioural*: an injected instruction changing the answer. That risk is not removed by any control here, and in this slice its blast radius is bounded — no answer reaches the register (§6 defers persistence); the worst an injected answer does is assign a wrong company or raise a wrong duplicate hold, both reversible by a person and both audited. | Release. But make the run a hard precondition of the **persistence** slice, not an open item — the moment an answer writes to the register, the behavioural risk stops being reversible. Say so in §6. |
| R3-14 | Low | `WorkerStartupChecks.cs:31-35` with `ClaudeCli.cs:75-78` | The budget-expiry path is unreachable for the CLI-version leg: `VersionAsync` catches `OperationCanceledException` and returns the string `"unavailable (OperationCanceledException)"`, so a budget expiry surfaces as a *version mismatch* naming that string, not as the "did not complete within StartupCheckSeconds" message the code at `:33` was written to give. | Misdiagnosis of the most likely innocent refusal (R3-4). | Let the cancellation propagate from `VersionAsync` when the caller's token is the one that fired, or test for the `"unavailable ("` prefix and report the budget. |
| R3-15 | Low | `ReleaseFacts.cs:109` vs `WorkerStartupChecks.cs:95` | `Differences` compares with `StringComparison.Ordinal`; `CheckPackageAsync` compares the same hash with `OrdinalIgnoreCase`. A hex hash written in uppercase in `worker.json` refuses the start; the same value would pass the package check. | A case-only difference is an innocent trigger for a stopped pipeline. | Compare the two hash-shaped facts case-insensitively in `Differences`, or normalise on read. |
| R3-16 | Low | guide `:21-35` vs `:267-280` | §1 says "The v2 schema adds **one** nullable column" and its "Expect" line predicts `ExtractionV2HoldReason` alone; §8.1 says there are now three. §1 was never updated. | An operator working top-down sees an expectation that will not match. Harmless because `-Target All` applies whatever is pending, but it undermines trust in the guide's other expectations. | Update §1 to name all three, or make it point forward to §8.1. |
| R3-17 | Low | `CompanyGate.cs:167` | `matches.FirstOrDefault()` — several register entries matching the same document number and counterparty are silently reduced to the lowest `entry_code`. The audit detail names only that one. | A genuinely ambiguous register state is presented to the person as a single confident match. Rare, but the ruling archives a document against it. | Record the count in the audit detail, and say "N entries match" on the page when N > 1. |
| R3-18 | Low | `CompanyGate.cs:147`; `ArgusDbContext.cs:254-256` | The lookup predicate is `regexp_replace(upper(h.document_id), '[^A-Z0-9]', '', 'g') = $3`. `fdchdr` is indexed on `(owner_id, entry_code)`, `(owner_id, company_id, period)` and `(owner_id, entmst_id)` — nothing supports it, so every gated document scans that company's register rows computing a regex per row, inside the job-completion transaction. | Fine at today's volume; it grows with the register, and it holds a transaction that also writes `docint`, `jobque` and `audlog`. | An expression index on `(owner_id, company_id, regexp_replace(upper(document_id), '[^A-Z0-9]', '', 'g'))`. |
| R3-19 | Low | `local/provision-production.ps1:216-222` | `sc.exe failure … restart/10000/restart/60000/restart/300000` is inside the `if (-not (Get-CimInstance Win32_Service …))` branch, so re-running provisioning against an existing service never re-applies or repairs the recovery actions. | Mitigated in practice — the module writes the identical `FailureActions` bytes on every deploy (`ScmWindowsAdapterV2.cs:65`, `:190-196`) — so this only bites a host provisioned but never deployed by the module. | Move line 220 out of the creation branch. |
| R3-20 | Low | `20260908014913_ExtractionV2RegisterEntryCodeWidth.cs:30-41` | The `Down` migration alters `register_entry_code` from `text` back to `character varying(20)`; PostgreSQL raises 22001 if any stored value exceeds 20 characters. It is the one non-safely-reversible step of the three. | Irrelevant to §4.6's rollback (which needs no schema rollback), but it is a trap for anyone who ever does run `dotnet ef database update` backwards. | Note it in the migration's summary, which already explains the forward direction well. |

## Failure modes traced

I traced each of these from the code. Where I say "the operator sees", that is what the code writes, not
something I observed running.

**The worker refuses to start.** Causes, in order of likelihood: (a) the CLI binary at the resolved path
does not report `2.1.259` — replaced, upgraded, or a different binary reached first (R3-3); (b) a release
fact set in `appsettings.Production.json` or in the `worker.json` named by `SIBYLA_SECRETS_FILE` disagrees
with the release's own copy; (c) the 10 s shared budget expires; (d) the published `skill\` tree does not
hash to `083cbd79…`. A `Worker__*` **service** environment variable is impossible: the module pins the
service's `Environment` value to exactly `SIBYLA_SECRETS_FILE` and `CLAUDE_CONFIG_DIR` and fails the Install
phase if it differs (`ValueObjectsV2.cs:352-374`, `ScmWindowsAdapterV2.cs:114-122`). A *machine-level*
`Worker__*` variable would still be read by `Host.CreateApplicationBuilder`'s unprefixed
`AddEnvironmentVariables()`, and the module does not look at those — so the worker's own check is the only
guard there, and it is a good one.

*What the operator sees.* Inside a deployment: the Health phase's `Process.Start` fails, the whole
transaction compensates in reverse, and the surface is `SIBYLA-S2-ROLLED-BACK`. The real reason — the
refusal message, which names the key, both values and the three override sources — is **not** in the
deployment result; it is only in the Windows Application log as a CLR unhandled-exception record, because
the exception leaves `StartAsync` before any `ILogger` line is written. Outside a deployment: nothing.
Three SCM restarts over six minutes, then a Stopped service, and documents accumulating at `Queued` on a
page that never mentions the worker.

*Is there a way back?* Yes for every cause, and none needs a redeploy except (d): remove the offending
override and `Start-Service`; restore the pinned binary and `Start-Service`; raise
`Worker:StartupCheckSeconds` in `worker.json` — it is not one of the five, so raising it does not itself
trip the refusal. That last lever is the important one and it is written down nowhere. Cause (d) needs a
redeploy or a rollback, correctly.

**A usage limit or a CLI error.** `ExtractionRunner.Envelope` (`:296-302`) reads `is_error` and short-circuits
before the validator, so "Not logged in" is never retried five times as a bad extraction — a good fix.
`IsQuotaSignal` (`ClaudeDocumentProcessor.cs:366-369`) matches "rate limit", "usage limit", "overloaded" and
pauses the whole `claude` lane for `LanePauseMinutes` rather than burning attempts. Documents wait; nothing
is lost. Sound. The residual is that the match is a substring test against text the CLI is free to reword,
and a reworded limit message would burn five attempts instead. Not a finding in this slice — it is
pre-existing — but it is the sort of thing a CLI upgrade changes.

**Two timeouts.** 900 s, then 1350 s, then `Hold("timeouts:2")` with `jobque.state = 2` so the job is never
re-claimed. The count is read from the row's own evidence by `JobId` and `ExitCode == -1`
(`ExtractionRunner.PriorTimeouts`), which correctly survives a re-extract. The person's way back is
"Re-extract" or "Enter manually" on the Uploads page (`Documents.razor:187-195`, `:260-263`) — except for a
bank statement, which by design has no completion path until §6.3 and says so. Correct, and the transition
table matches the code.

**Two consecutive timeouts across slots.** `TimeoutLaneMonitor` is per process and pauses the lane. Note it
is fed by `TimedOut(result)` in `QueueWorker.cs:139`, which parses the outcome's evidence element — so a
timeout whose completion produced no evidence element does not count. Consistent with the spec's wording.

**A killed process mid-job.** The CLI child is killed with `entireProcessTree: true` only on the timeout
path. On a service stop, `stoppingToken` cancels the linked token, the same catch fires, the process tree is
killed, and `RunAsync` returns `(-1, …)` — but nothing is then persisted, because every subsequent database
call in `QueueWorker` uses the cancelled token and lands in `catch (OperationCanceledException) when
(stoppingToken.IsCancellationRequested)` at `:145`. So a graceful stop does **not** record a spurious
timeout. The lease is held for up to `LeaseSeconds` (300 s) after the process dies and the job is then
re-claimed at `attempt_count + 1`. A deployment therefore costs one attempt out of five per in-flight
document. Acceptable; worth the operator knowing.

**A full disk.** No guard anywhere. See R3-5. This is the failure mode with the worst cost/signal ratio in
the slice: five real model runs at up to 900 s each, then a dead letter, per document, with no message that
mentions disk.

**An answer that fails validation five times.** `ExtractionRunner:208-231` — the validator is wrapped, so a
thrown validator is a retry *with* an evidence element rather than the evidence-less catch-all path C-1
found. At the cap, `JobCompletion` converts the retry to `Dead("Attempt cap reached: …")` and the page shows
the last evidence and the "Enter manually" form. There is a way back for a person. Sound.

**A person rules twice, or two people rule at once.** `LockIntakeAsync` (`IngestionService.cs:522-528`) is a
`SELECT … FOR UPDATE` and the status guard at `:610-613` refuses anything not `PossibleDuplicate`. The second
ruler blocks on the row lock and then gets a message. The `duplicate_classification` insert is `ON CONFLICT
… DO NOTHING` with a per-intake fallback key and a five-attempt loop, and it never updates an existing
declaration — the R2-5 fix is real and the reasoning in the comment at `:658-668` is right. This is the
best-handled concurrency in the slice.

**Migrations applied out of order.** Stronger than §4.6 claims. `Precondition.Schema` runs in the **Install**
phase before any write (`DeploymentPlanCompilerV2.cs:81`), so deploying before migrating fails the release
with `SIBYLA-S2-PRECONDITION-SCHEMA` having touched nothing. Post-activation, health check 9 asserts status
200, role exactly `sibyla_api`, and that every id in `release.json`'s `requiredMigrations` appears in
`/health/schema`'s array, and a failure rolls the whole run back. Migrating *before* deploying is also safe:
the three columns are nullable and the currently deployed v1 code names none of them.

## What I verified as sound

- **The migration ordering claim and the rollback-needs-no-schema-rollback claim.** Both correct. All three
  migrations are `AddColumn`/`AlterColumn`, nullable, with real `Down` methods. The gate is doubled
  (pre-write precondition plus health check 9) and a health failure compensates the entire run in reverse
  phase order, not just the failing phase.
- **Health check 9's 4096-byte body limit is not close.** I was looking for a trap here. `/health/schema`
  (`src/Sibyla.Api/ChannelIntake/ChannelIntakeEndpoints.cs:36-50`) reads `__EFMigrationsHistory`, and the
  Argus context uses a *separate* history table (`ArgusDesignTimeFactory.cs:9`,
  `__EFMigrationsHistory_Sibyla`), so the response carries only the 24 platform migrations — about 950 bytes
  of JSON against a 4096-byte cap. Roughly 70 migrations of headroom. Not an issue for this release; worth
  someone's attention in a year.
- **Web before worker inside one release.** True, and by construction rather than by convention: the site
  repoint and pool recycle for `Sibyla.Web` are emitted at `DeploymentPlanCompilerV2.cs:190-191`, the worker
  is stopped at `:202` and repointed at `:203`, and it only starts in the Health phase at `:260`. A
  worker-only or web-only release cannot form — `Precondition.Artifact` enumerates all three trees.
- **The environment-variable override surface is closed on this host.** See above. The refusal message names
  three sources, one of which the module makes impossible; that makes the refusal *narrower* than the message
  suggests, which is the right direction.
- **The model id is what Q-EX-15 asks for.** I checked whether `claude-opus-5` is an alias. It is the exact
  key the CLI itself writes in `modelUsage`, with `canonicalModel` equal to it — unlike the haiku entry in
  the same block, which is dated. So it satisfies "the full model id read from the first bench run's
  `modelUsage`". Residual, unfixable here: an undated id gives the tuple no protection against a server-side
  re-point, and nothing in the pipeline would notice.
- **The deny file's location and form.** `permissions.json` is written under
  `<StagingRoot>\evidence\<jobId>\`, which is both outside every `--add-dir` root and itself denied by the
  `D:\ApolloData\staging\**` rule — belt and braces, correctly. The `Read(D:/…)` form is the one the
  2026-09-07 matrix proved, and the `**\local\**` amendment (M-5) is honest about what it does and does not
  hold.
- **The register ruling's declaration semantics.** Never overwrites a prior declaration, records what it
  supersedes whole, scopes its key to the intake when the filename is taken, and keeps `duplicate_of_id` when
  a row is flagged both ways (C-16). The code matches the ruling.
- **The company gate uses `SameTaxId`, not `Match`.** The C-10 fix is present at `CompanyGate.cs:162` and the
  comment explains exactly why, correctly.
- **`fdchdr` ids are stable.** The sync upserts by `entry_code` preserving the GUID and tombstones rather
  than deleting, so `register_entry_id` does not dangle on ordinary sync runs. Only a source-side code
  rename breaks it (R3-9).

## What I could not verify, and why

- **Anything requiring a running system.** I did not deploy, did not start or stop a service, did not apply a
  migration, did not call the CLI. Every claim above about deployment behaviour is read from
  `local/deploy/src/**` and the run records, not observed.
- **The live state of `gott_sibyla`.** Out of bounds. So R3-12's grant question — whether `sibyla_app` really
  holds INSERT on `duplicate_classification` on Main — is reasoned from `local/setup-db.ps1:212-219` and the
  RLS policy in `20260828011744_SibylaWave5Declarations.cs:583-601`, both of which say it should. The
  mitigating argument is strong (the same blanket grant covers the SELECT on `fdchdr` that the Argus pages
  already do in production), which is why it is Medium and not High. It is one query to settle.
- **The contents of `worker.json` and `appsettings.Production.json` on the host.** Off-limits by §8, as the
  guide itself says at `:303-305`. Whether R3-1's contradiction has *already* been acted on — that is,
  whether an `appsettings.Production.json` carrying the facts already exists in a release folder — I cannot
  see. That is precisely why §7 must be corrected before, not after, the release.
- **Whether `C:\Apps\Sibyla\tools\claude` is on the machine PATH.** R3-3 assumes production currently resolves
  the pinned binary through a PATH lookup, because `provision-production.ps1:224` sets only two environment
  values and the npm path is unlikely to exist for `SibylaWorker`. The worker demonstrably works today, so
  something resolves it; nothing in the repository says what. That undocumented dependency is part of the
  finding.
- **The behavioural half of §4.5.** The hostile-document run has not happened (R3-13). I judged the structural
  controls sufficient for this slice and not for the next one.
- **I did not run the test suites.** The other reviewer is on closure and regression, and `PreviewParityTests`
  is expected red until Main is migrated. Nothing in my findings turns on a test result.
