# Argus extraction v1 — checkpoint review, CONTRACT AND CORRECTNESS (2026-09-08)

Reviewer: independent reviewer 2 (contract and correctness angle). Branch `ops/argus-extraction-v1-red`, head `8c2fdda`, 66 commits from `main`, reviewed in its **committed** state (`git show HEAD:<path>` throughout; the working tree's `src/Sibyla.Web/**`, `tests/Sibyla.Tests.Browser/**` and the commits `c54e198`, `6b12c43`, `c576fdd`, `b984afa`, `e0f40bb` are another session's and out of scope). Authority: `docs/apollo-argus-extraction-v1-spec.md` at revision 9 plus its four amendments (R-EX-3 caps + V-17, D-EX-5, D-EX-6).

## Verdict

**REVISE — Critical 0 / High 3 / Medium 14 / Low 9.**

Two of the three High findings are reproduced here with commands and output, not inferred. The third is a schema constraint the code violates on the exact scenario D-EX-5 was written for.

---

## Findings

| id | sev | clause / file:line | what is wrong | why it matters | fix |
|---|---|---|---|---|---|
| **C-1** | **High** | §2.8 V-1/V-5; `src/Sibyla.Platform.Infrastructure/Ingestion/ExtractionContractV2.Schema.cs:215-230` (pass 1) and `:326-327` (`case Kind.Obj: return Read(value, …)`) | Whenever any object-typed member (`header`, `statement`, `issuer`, `recipient`, `payment_proof`, `service_period`, `sub_issuer`, `evidence`) is a **scalar or an array** instead of an object or null, `Validate` throws an unhandled `InvalidOperationException` instead of returning a contract failure. Pass 1 only recurses `if (field.Kind == Obj && member.Value.ValueKind == Object)`, so a wrong kind is skipped; pass 2's `Read` then calls `element.GetProperty(name)` on a non-object. **Reproduced** against the built `Sibyla.Platform.Infrastructure.dll` on six mutations of the accepted golden `I26030044.json`: `"header":5`, `"header":[]`, `"issuer":"…"`, `"service_period":3`, `"lines[0].service_period":[]`, `"lines[0].sub_issuer":[]` — all six print `*** UNCAUGHT System.InvalidOperationException: The requested operation requires an element of type 'Object', but the target element has type 'Number'/'Array'/'String'` | This is the trust boundary. `ExtractionRunner.cs:177` calls `Validate` with no `try`; the exception unwinds to `QueueWorker.cs:149-155`, which records `JobOutcome.Retry($"Worker exception: {ex.Message}")` **with no `EvidenceJson`**. So a malformed answer of this class (a) burns all five attempts at up to 900 s each, (b) writes **no evidence element for the attempt**, contradicting §2.8's "retry, then dead-letter at `MaxAttempts` = 5, **evidence kept**", and (c) dead-letters with `Worker exception: The requested operation requires an element of type 'Object'…`, naming no V-rule. Emitting `[]` where a nullable object belongs is one of the commonest LLM shape errors, so this is not a contrived input. §4.5's hostile-answer corpus has no scalar/array-for-object case, so nothing in the suite fails | In `CheckMembers`, fail V-1 when an `Obj` field's value is neither `Object` nor `Null` and when an `ObjArray` field's value is neither `Array` nor `Null`; add three reject fixtures (`header` scalar, nested object scalar, nullable object as `[]`) to `reject/expected.txt` |
| **C-2** | **High** | §5.3 S-1 ("each = matched ÷ scored"), §5.3 S-10, Q-EX-7; `tools/extraction-bench/Sibyla.Tools.ExtractionBench/Scorer.cs:477-483` | The **kind** score is computed over only the cells of fields with ≥ 20 cells (`gated = fields.Where(f => f.FloorApplies)`; then `cells.Where(c => c.Kind == kind && gated.Contains(…))`). S-1 defines the kind score as matched ÷ scored over the kind's cells; the ≥ 20-cell rule belongs to the **per-field floor** only (S-10, Q-EX-7: "a per-field floor of 85 % applies only to fields with ≥ 20 scored cells"). Consequence in the committed evidence, `evidence/extract-v2/score-20260907-1613-dff90f9-a558523.md`: Header `— 0 not gated`, Lines `— 0 not gated`, Movements 156/156, and the headline **"Gate (S-10): PASSED"** — on a reserve sitting whose fiscal cells were `net_amount` Lines **9/17 = 52.94 %**, `total_amount` 9/17, `vat_amount` 9/17, `line_count` **6/8 = 75 %**, `document_id` 7/8, `date_due` 7/8. Under S-1's plain reading that reserve run is Header 76/80 = 95.00 % and **Lines 27/51 = 52.94 % — a gate failure**. The statements sitting (`score-20260907-1546-ce7d4cf`) likewise reports PASSED with Header and Lines at 0, and drops the 15 statement cells (`movement_count`, `opening_balance`, `closing_balance`) out of the movements tally although S-1 puts them **in** it verbatim | This is the gate that R-EX-3 uses to open the contract to gateway traffic, and the reserve is the held-out set whose whole purpose is to catch what tuning hid. A "PASSED" headline over 52.94 % line cells is a false green in the report the checkpoint reads. `date_pay` (6), `service_period` (12), `quantity` (6), `unit_price` (6), `vat_rate` (8), `movement_count` (5), `opening_balance` (5), `closing_balance` (5) are permanently exempt from every gate although S-9 lists them as scored columns. The existing guard `AGateOverNothingIsNotAPass` covers only the case where **all three** kinds are empty | Compute `KindScore` over all cells of the kind (S-1); keep `FloorApplies` for the 85 % per-field floor only (S-10); and refuse `PASSED` for any kind that has zero cells while the set contains documents of that kind. Re-run the three sittings' scoring from the committed `actual/` answers and publish one combined report over the 40 |
| **C-3** | **High** | §4.7 register rows; `src/Sibyla.Platform.Infrastructure/Ingestion/IngestionService.cs:587-596` vs `src/Sibyla.Modules.Argus.Infrastructure/Persistence/ArgusDbContext.cs:594` | `duplicate_classification` carries `HasIndex(x => new { x.OwnerId, x.EntryCode, x.SourceFilename }).IsUnique()`. `RuleOnDuplicateAsync` inserts with `source_filename = intake.OriginalFilename` via raw SQL with **no `ON CONFLICT`** and no `catch`. The second ruling on the same filename against the same register entry raises `23505` | This is precisely the D-EX-5 scenario: the owner re-uploaded `Gott_Invoice_BICS_202601_01.pdf`, which the register already held as `I26010021`. A third upload of that file matches the same entry, is ruled, and the insert violates the index; the exception escapes `RuleOnDuplicateAsync`, and the page handler (`Documents.razor:589-602`) is `try/finally` with no `catch`, so it kills the Blazor circuit instead of returning one of the method's error strings. The transaction rolls back, so the ruling is lost as well. No test: both ruling tests seed one intake per entry | `ON CONFLICT (owner_id, entry_code, source_filename) DO UPDATE` (or `DO NOTHING` plus an audit note), and a regression test that rules twice on the same (entry, filename) |
| C-4 | Medium | §2.7 N-1 vs §2.8 V-4; `ExtractionContractV2.Schema.cs:372-374` | `ReadString` enforces `raw.Length > max` **before** N-1's trim/whitespace-collapse (`:380`). Reproduced: `vat_exemption_text` of 200 `A`s + one trailing space → `V-4: header.vat_exemption_text exceeds 200 characters (201)`; 99 `B`s + ten spaces + 100 `C`s (normalises to exactly 200) → `V-4 … (209)` | §2.1 defines `s(n)` as the value's length and the canonical form stores the **normalised** string, so the cap is being applied to the wire text, not the value. This is the same class of failure the R-EX-3 amendment spent five exhausted documents fixing; PDF text layers routinely emit runs of spaces, and `doc_type_printed` s(60) — the one cap deliberately left short — is the most exposed | Normalise first (N-1), then apply V-4 to the normalised value |
| C-5 | Medium | §2.8 V-17 ("resolves and **is `null`**"); `ExtractionContractV2.cs:387-394` `CarriesNothing` | V-17 is satisfied not only by `null` but by an empty list and by **any object all of whose members carry nothing**. Reproduced: `not_printed: ["header.service_period"]` with `service_period = {start:null,end:null,text:null}` → accepted. Introduced by commit `2acf0b4` ("…an object of nulls"), i.e. during the bench shakedown | The R-EX-3 amendment authorises exactly two relaxations — the index base and the `lines[]` wildcard. It says nothing about "is null" meaning "carries nothing". The spec's four amendments are each recorded at the top of the file; this one is not. It is defensible on the merits, but it is an unrecorded widening of a validation rule after the spec was accepted | Either amend §2.8 V-17 to state the "carries nothing" reading, or restore the literal `null` test |
| C-6 | Medium | §5.3 S-12 ("after `Split` **and S-3 signs**"), §2.6 signs; `Scorer.cs:259` vs `:272` | On the summarised path the printed-sign exception is hard-coded: `SignByKind(answerDocType, l.Kind, v, **false**)`. On the ordinary per-line path it is computed: `exception = actualLine is not null && answerDocType != "credit_note" && (Kind == stamp_tax || Net < 0m)`. So on a flagged **invoice**, a printed discount line of −5.00 contributes +5.00 to the answer's column sum while the key holds −5.00 (a 10.00 divergence), and a #6-shaped stamp line of −0.03 contributes +0.03 against the key's −0.03 | S-3's signs include §2.6's printed-sign exceptions; S-12 says the sums are taken "after `Split` and S-3 signs". Neither currently-flagged document (I26070007, I26080031) has a negative line, so the suite is green — but the flag is settable by owner ruling on any document (§5.5), and the failure is silent and in the wrong direction: it makes a **right** answer miss | Pass the same `exception` computation the per-line branch uses |
| C-7 | Medium | §5.5 ("A correction row replaces the key value for its cell **wherever the cell is scored**"); `Scorer.cs:253-262` | The summarised branch reads the key with bare `Dec(l, keyName)`. It never calls `CorrectedDecimal`, so a §5.5 line correction on a flagged document is silently ignored — and, because the corrections count is derived from cell notes (`Scorer.cs:502-503`), the report would not even show it as applied | Today's only line corrections are #31's, on an unflagged document, so there is no live miscount — but the rule is stated without exception and the failure is silent in both the score and the count | Apply `CorrectedDecimal` to the key lines before summing |
| C-8 | Medium | §5.3 S-9 / §5.4 vs §4.7; `Scorer.cs:342-343` vs `src/Sibyla.Platform.Infrastructure/Ingestion/ExtractionResult.cs:101` | The scorer's gate input is `Str(p,"tax_id") ?? Str(p,"tax_id_printed")`. The production gate's is `tax_id` only (`ExtractionResult.PartyTaxId`, `:101`), as §4.7 states literally. The two therefore read different inputs | The bench's `company` / `origin_class` cells (29 + 29 in sitting 1, both 100 %) do **not** measure the gate the worker ships. An answer that correctly leaves `tax_id` null while recording `tax_id_printed` is scored as assigning and triages in production. It also breaks the D-EX-6 A-3 shape (`I26050003` `fiscalNo` → null): an answer that rightly declines `tax_id` but records the printed registration number is scored a **miss** on `fiscal_no` | Use `tax_id` only, through the one seam |
| C-9 | Medium | §5.3 S-2 ("Unpaired extraction movements and unpaired key movements are misses on **every cell**"); `Scorer.cs:429-437` | An unpaired **answer** movement emits 4 cells (`posting_date`, `amount`, `currency`, `description`); an unpaired **key** movement emits 6–7 (adds `doc_date`, `period`, and `running_balance` where printed). Visible in `score-20260907-1546-…md`: `BM000905` yields seven miss rows, `seq=2` four | A hallucinated or mis-dated movement is penalised ~40 % less than the key row it failed to pair with. On the statements sitting that is 55 miss cells out of 1389 — the gate cleared at 96.04 %, near enough for the asymmetry to matter | Emit the same cell set on both unpaired sides |
| C-10 | Medium | §4.7 register row ("whose entry's `entmst.fiscal_no` is **`SameTaxId`** with the answer's counterparty fiscal number"); `src/Sibyla.Worker.Documents/CompanyGate.cs:156`, doc-comment `:122` | The lookup compares with `CompanyMatcher.Match(counterparty, [Candidate(fdchdrId, fiscalNo)])`, i.e. `Normalize` + `SameNormalized`, **not** `SameTaxId`. The two differ: `SameTaxId` additionally runs `Printed()` (`CompanyMatcher.cs:109-125`), which drops a label before a colon and a ≥ 3-letter label glued to the digits. `SameTaxId("NIPC: 500 940 231","PT500940231")` is true; `Match` normalises the same input to `NIPC500940231`, bares it to `PC500940231`, and does not match. `entmst.fiscal_no` is stored as captured | A labelled fiscal number in the register produces exactly the silent miss D-EX-5 exists to prevent. The code's own doc-comment claims `SameTaxId`. Root cause: `SameTaxId` is `internal` to `Sibyla.Platform.Infrastructure` and `Sibyla.Worker.Documents` has no `InternalsVisibleTo` — §4.3 lists the test and bench assemblies but not the worker. No fixture uses a labelled id, so nothing fails | Make `SameTaxId` reachable from the worker and call it |
| C-11 | Medium | §2.6 (S-9 "a two-letter country prefix tolerated on **either side**"); `CompanyMatcher.cs:127-135` | `SameNormalized` returns true when `Bare(a) == Bare(b)` and **either** carries a prefix, and `Bare` strips the first two characters whenever both are letters. So `"PT500940231"` and `"ES500940231"` compare **equal** | "Tolerated on either side" means one side may carry the prefix the other omits, not that two different country prefixes are interchangeable. Since D-EX-5 the comparison is also the register-duplicate test, where a false positive holds a genuine document as a duplicate of another company's entry. The behaviour is inherited from v1 (the function was renamed from `SameTaxId` in this slice) but is newly load-bearing | Strip the prefix from at most one side: compare in full when both carry one |
| C-12 | Medium | §2.6 branch-(2) guard; `src/Sibyla.Platform.Infrastructure/Ingestion/StampDutySplit.cs:78` | The guard is `!Within(sumAllLines, printedNet, **0m**)` — exact equality — while every neighbouring test uses ± 0.02. One cent of line-level rounding drift on a document that prints net, VAT and a stamp line inside the net defeats the guard: branch (2) then runs `VAT −= duty`, moving a duty that was already inside the net out of the header VAT. The code matches §2.6's literal "Σ all lines … ≠ net", so the brittleness is the spec's | §2.6 says these shapes are "production cases, exercised by the synthetic fixtures only" — none of the 40 prints net, VAT and a stamp line together, so the first real one will be in production. V-9 passes (± 0.02 absorbs the cent), so the wrong header VAT is silent | Give the guard the same ± 0.02 tolerance in §2.6 and in the code |
| C-13 | Medium | §2.8 V-15 vs §7 golden #40; `ExtractionContractV2.cs:298-323` | V-15 is written one-directional (`running_balance[i] = running_balance[i−1] + amount[i]`). Revolut #40 prints **newest-first**, so its running balances chain the other way; the accepted golden therefore fails V-15 as specified. The code silently reads the chain forward **or** backward over the whole list, documented only in a test class comment (`ExtractionContractV2Tests.cs`, "Two readings the goldens fix") | The spec's V-15 as accepted is contradicted by a golden the spec itself mandates, and the fix was made in code without an amendment while the R-EX-3 and D-EX-6 amendments were being written for smaller changes. The relaxation is mathematically right; the record is wrong | Amend §2.8 V-15 to state the chronological reading |
| C-14 | Medium | §4.7 register row ("`hold_reason` kept on the row and **echoed in the audit detail** (`document.company.inferred` carries `held: <reason>`)"); `CompanyGate.cs:109`, SELECT at `:28` | `held = status == 9 ? "held for a person at the time of the gate" : null` — a constant. `hold_reason` is never read (the SELECT fetches only `company_id, sha256, processing_status`). The audit records **that** the row was held, never **why** | The reason (`timeouts:2` vs `movements_truncated:87/60`) is exactly what the transition table says must survive into the audit trail, and it is the only place a held row's provenance is reconstructable after a ruling clears it. The oracle cannot fail: `QueueWorkerGateTests.cs:234` asserts `Contains("held", "held for a person at the time of the gate")` | Read `hold_reason` in the SELECT and put it in the detail |
| C-15 | Medium | §4.7 register row ("evidence = **the matched fiscal key** and the person"); `IngestionService.cs:585-586` | Both branches build the evidence string as `… counterparty fiscal number {Normalize(key.IssuerTaxId) ?? Normalize(key.RecipientTaxId) ?? "?"}` — flow-blind, issuer-first. On an `R` row the gate matched on the **recipient** (`CompanyGate.cs:130`), and on an intercompany row both ids are present | The permanent declaration written into the register names a fiscal number that is not the key the match was made on. It is the evidence a person or an auditor reads back. The only test asserts the payable shape, where issuer-first happens to be right | Persist the matched key (or the flow) with the reference and quote it |
| C-16 | Medium | §4.7 ("`PossibleDuplicate` of a register entry … a person rules 'not a duplicate' → … then `ReleaseHeldProcessing` as for a checksum duplicate"); `IngestionService.cs:612` | `intake.DuplicateOfId = null;` runs on the register branch too. `CompanyGate.cs:89` deliberately preserves a coexisting checksum reference (`duplicate_of_id = COALESCE($3, duplicate_of_id)`); one ruling about the **register** question then silently discards the byte-identical-copy question unruled and unaudited | The transition table licenses releasing the hold, not clearing a reference the gate was told to keep. Untested in both directions — every fixture seeds a distinct `sha` | Clear `DuplicateOfId` only when the row was a checksum duplicate |
| C-17 | Medium | D-EX-5 (2) "immediately after the assignment"; `CompanyGate.cs:37` | `if (currentCompany is not null) return;` — the whole gate, and with it the entire register lookup, is skipped whenever the intake already carries a company. An upload made **into** a company therefore gets no register check at all in this slice | The reading is faithful to the words ("any row the gate has just assigned"), and D-EX-5 (3) puts the second guard in the persistence slice — but the effect is that the commonest upload shape is unprotected until that slice lands, while the owner's motivating case (an upload with no company) is protected. The checkpoint should see that explicitly rather than infer it | Run the lookup for a pre-assigned row too, or state the gap in §4.7 and in the owner guide |
| C-18 | Medium | §5.5 ("More than 5 % of scored cells corrected stops the run for the owner's look"); `answer-key.flags.json` I26070007 / I26080031 / I26010042 | Three of D-EX-6's seven appeals were applied as **flags** (`keyLinesSummarised` ×2, `dateDuePrinted` ×1), not as correction rows. Flags change the score exactly as corrections do — comparing the two reserve reports, `line_count` went 6/8 → 8/8, the fiscal line cells 27/51 → 30/30, `date_due` 7/8 → 8/8 — yet they are neither counted in `CorrectionsApplied` (which reads 1 in `score-20260907-2250-…md`) nor subject to §5.5's 5 % stop | §5.5's cap is the only quantitative guard on moving the goalposts after reading answers, and the largest single movement on the held-out set went round it. The flag file's own definition says "Set only by an owner ruling under section 5.5" — so it is meant to be inside that control | Count flag changes ruled after a sitting alongside corrections in the 5 % guard and on the report |
| C-19 | Medium | §5.7, §5.1, D-EX-3 ("field-level exact match on header and lines ≥ 95 % **before the contract opens**") | There is no committed report that scores the 40-document sample as one set. Sitting 1 (fiscal) reports Movements "not gated"; the statements sitting reports Header and Lines "not gated"; both print "Gate (S-10): PASSED". No artefact shows all three kinds ≥ 95 % over the sample at once | R-EX-3's gate is over the sample, not over each sitting. With C-2 unfixed, "PASSED" on a partial set is not evidence of the gate | Produce one combined report from the committed `actual/` answers |
| C-20 | Medium | §4.6 ("`WorkerOptions` keys … in the release's `appsettings.json` — … are release facts"), Q-EX-14; `src/Sibyla.Worker.Documents/appsettings.json` | The committed `appsettings.json` and `appsettings.Development.json` contain only `Logging`. `SkillPackageSha256`, `SkillCommit`, `CliVersion`, `Model`, `Effort` have no defaults (`WorkerOptions.cs:24-32`) and appear in no committed config or deployment script; `WorkerStartupChecks.cs:41` and `:56` throw on a null. The only place the values exist is a hand-written `appsettings.Production.json` described in the owner guide (`docs/apollo-argus-extraction-v1-owner-guide-260907.md:202-210`) | Q-EX-14 makes the tuple the thing that gates production, and §4.6 makes a release and its package roll back together — but the tuple is not carried by the release, is not in the clean-export fingerprint, and nothing verifies it. A rollback restores a tree; the configuration that pins the tree is outside it. `WorkerStartupTests` supplies its options in code, so no test notices | Commit the release facts (they are not secret) or have `publish-release.ps1` write and verify them, and fail the publish when they are absent |
| C-21 | Low | §2.8 V-13; `ExtractionContractV2.Schema.cs:405-406` | `IsCurrency` checks three upper-case letters only. `"ZZZ"` is accepted (reproduced) | A hallucinated code reaches persistence; V-13 says "ISO 4217" | Check against the ISO list, or reword V-13 as a shape rule |
| C-22 | Low | §2.3 (`vat_rate` `d(≤ 2 dp)`), §2.8 V-5; `ExtractionContractV2.Schema.cs:301-308` | `vat_rate` is `Kind.Small`, so `ReadDecimal(value, **6**, path)`. `vat_rate: 23.123456` is accepted (reproduced). V-5's 6-dp exemption names only quantity and unit price | Minor laxity against a declared type | Give `vat_rate` its own 2-dp check |
| C-23 | Low | §2.8 V-10; `ExtractionContractV2.cs:158, 200-207` | `stampLine = lines.FirstOrDefault(…)`; with two printed `stamp_tax` lines only the first is reconciled against `stamp_duty_amount` | V-10 says "when both a `stamp_tax` line and `stamp_duty_amount` are present they agree ± 0.01" | Compare the sum |
| C-24 | Low | §2.7 N-3; `ExtractionContractV2.cs:518-535` | `NormalizeTaxId` is **called from nowhere** in `src/` or `tools/` (grep). N-3 is therefore unenforced by the contract; the model is expected to emit N-3 form and the gate's tolerant comparison hides the difference | Dead code that reads as a control | Either apply it in the reader or delete it and say in §2.2 that N-3 is the model's |
| C-25 | Low | §4.3 ("Before hashing, the whole document path is replaced by the `<document>` token…"); `src/Sibyla.Worker.Documents/ExtractionRunner.cs:62-63, 119, 135` | `PromptHash()` takes no argument: it hashes `PromptFor(DocumentToken, PackageToken)` and never the prompt actually built at `:119`. The evidence field is a compile-time constant | The `.pdf`/`.png` parity the §7 oracle asserts (`ProcessingEvidenceTests.cs:240`, `ClaudeCliPermissionTests.cs:93`) is trivially true and cannot fail; a divergence between the two call sites would be invisible | Tokenise and hash the real prompt |
| C-26 | Low | §5.3 S-11; `Similarity.cs:48-61` | The prefix rule has no minimum length: a one-token answer description matches any key description beginning with that token (`"TRF"` ↔ `"TRF CR SEPA+ 0003654 DE ANAPTYXIS…"`). `description` is a floored field (202 cells) | The rule as specified is very lax; only the exact-match rate is reported (S-8), so a systematically truncated description reads as 95 % | Require the prefix to cover a minimum share of the longer list |
| C-27 | Low | §2.6 header-equals-Σ-lines check; `StampDutySplit.cs:127-130` | In branches (0)–(2) a non-zero difference produces only an evidence *note*, never a finding. §2.6 states the equality as an invariant of those branches and says nothing about a breach | Only the scorer calls `Split` in this slice, so impact is nil today; it becomes a silent hole in the persistence slice | Raise a DPRCHK finding, or say in §2.6 what a breach means |
| C-28 | Low | §2.4 / §4.7; `src/Sibyla.Platform.Infrastructure/Persistence/…` migration `20260907105936` vs `ArgusDbContext.cs:250-270` | `docint.register_entry_code` is `varchar(20)`; `Fdchdr.EntryCode` has no `HasMaxLength` and is `text`. An entry code over 20 characters raises 22001 inside `CompanyGate` and fails the whole job completion | Latent (codes are 9 characters today) | Match the widths or cap on write |
| C-29 | Low | §2.8 V-17 index base (R-EX-3 amendment) | Verified working as ruled and recorded here only so the reading is on the record: `not_printed: ["lines[1].quantity"]` is accepted when the **second** line prints no quantity even though the line `line_no` 1 prints one (reproduced, both directions), and refused when both print (reproduced: `V-17: evidence.not_printed names lines[1].quantity, which is not null (counting from 0)`). This is exactly what the amendment says; the residual is that one line index per path can carry a false "not printed" claim | — | none; noted |

---

## What I verified and found correct

**Validator / canonical form.** V-1's exact member set at every level including duplicate members; V-2; V-3's four presence rules; V-4's cardinalities (`lines` 200, `movements` 150, `item_candidates` 3, `related_document_ids` 5, `notes` 20, `not_printed`/`uncertain` 40); the three raised caps land exactly where the R-EX-3 amendment puts them (`vat_exemption_text` header and line 200, `payment_terms_text` 300, `evidence.notes` item 500) and `doc_type_printed` is correctly left at 60; V-5's control-character, N-6 date, finite-decimal and 2-dp checks (`1e400` and `"123.45"` rejected, `NaN` rejected at parse as V-1 and the corpus row says so); V-6 both lists; V-7; V-8's two shapes exactly as amended by C-X-2; V-9's four clauses including the branch-(2) `Σ lines.vat + |duty|` added by C-X-1; V-10 both sides; V-11 and V-18 as findings, not failures; V-12's note/no-note split; V-14; V-16 under both account kinds with the zero-amount and unknown-kind guards; V-19's three arms. The canonical writer walks the same schema table in the same order, writes amounts at exactly two decimals with `-0 → 0.00`, quantities with trailing zeros stripped, relaxed escaping, and no trailing newline; the six goldens canonicalise byte-for-byte and round-trip (`ExtractionContractV2Tests`, 105 assertions in the 174-test Platform filter, all green).

**Derivations.** `StampDutySplit.Apply` reproduces every §2.6 trace I re-derived by hand: #6 (branch 0, stamp line 3 = −0.03, Net 767.02, VAT 0.00), Lari #16, #25 both ways (branch 1 → Net 411.54, item 392.39, stamp 19.15), #31 both ways (item 143.02, stamp 12.38, Σ = header 155.40), the four synthetic fixtures, and the guard fixture (branch 3, zero difference, **no** finding). Step A appends at `n + 1` with the printed sign, `vat_rate` 0 and VAT 0 in every branch including branch 0; step C folds `other` into the single or largest item line in every branch, which is what the #25/#31 "same lines" traces require; `VAT := VAT ?? 0` holds everywhere; `Total` is never changed. `CompanyMatcher.Gate` implements Q-EX-23 exactly — ambiguous side never assigns whatever the other side says, both-match owned by the issuer as R/Internal with both candidate lists carried. `ExtractionDerivations.DateDocFor` and `PeriodFor` follow the ladder as §7's `AccountPeriodTests` row states it.

**Scorer.** S-3's sign-by-kind with the printed-sign exceptions on the per-line path; S-4's three branches; S-5 on the six receipt rows only; S-6's R1/R3 split through `AccountPeriodService.Decide` with the §2.6 `DateDoc` fallback feeding it; S-7's balances against the flags file and never `bnkchk` (Revolut 40.06 → 164.94, card 270.03 → 619.22 both in the reports); S-11's 0.9 threshold and prefix rule; S-2's natural key per statement, `bm_code`-ascending tie-break, and the rule that description is never a pairing precondition; the corrections file applied on the key side with a note per cell; the 85 % floor at exactly 20 cells. S-12 does what it says on the two flagged documents and, importantly, still fails a merge (`AnAnswerThatMergesLinesStillFailsUnderTheSummarisedRule` reduces the fixture to one line and asserts all three sums miss) — and the unflagged Regus merge is still scored line by line. The flag reaches only I26070007 and I26080031, both reserve rows, both carrying `"ruling": "D-EX-6 / A-4"` / `A-5`.

**Register branch (D-EX-5).** Counterparty by the gate's **own** flow decision (issuer on I, recipient on R, recipient on the intercompany R row) — right, and tested; document number normalised on **both** sides (`NormalizeDocumentId` in C#, `regexp_replace(upper(…), '[^A-Z0-9]', '', 'g')` in SQL); the lookup scoped by `owner_id` **and** `company_id` under the `app.owner_id` GUC with `src_deleted_at IS NULL` for liveness; fully parameterised; NULL document id or NULL counterparty short-circuits before the query and no NULL-vs-NULL false positive is reachable on either side; `hold_reason` untouched by all three UPDATEs; a coexisting checksum reference preserved by `COALESCE`; the two verdict strings byte-exact (`Duplicate - archived (clause 1)`, `Verified genuine repeat`); the declaration written to the **entry** and never for a checksum duplicate; the whole ruling in one transaction; `ReadRegisterKey` never throws on v0/v1, on a statement, or on unreadable input. Both migrations agree with the mapping in name, type, nullability and length, and `hold_reason` varchar(200) is ample — the longest reason `movements_truncated:<printed>/<returned>` is bounded at 35 characters by `Kind.Int` and V-4's 150-movement cap.

**Worker and page (spot-checked against §4.3/§4.7).** The §4.3 argument list is exact and in order with `-p` first, no `--fallback-model`, and `--verbose` gated on `outputFormat == "stream-json" && StreamJsonNeedsVerbose` so the production `json` list is unchanged for either value of the constant; the 900/1350 ladder with `PriorTimeouts` scoped to `JobId == job.Id` and no third run (`jobque.state = 2` is never re-claimed, and `TimeoutFor` throws rather than invent a third budget); `JobCompletion` writes status 9 / `hold_reason` / `jobque.state = 2` / `last_error` / `job.hold` and runs the gate for Success **and** Hold, keeping an existing `result_json`; `WorkerStartupChecks` runs in a real `StartAsync` override before `base.StartAsync`, compares the first whitespace token, and shares one budget; `SkillPackage.TreeHash` matches §4.4 byte for byte with an **ordinal** sort and byte-level CRLF→LF that leaves a lone CR alone; the permissions file allows exactly the two roots, denies all eleven enumerated classes including every sibling release, and is written outside every working directory; `ReleaseHeldProcessing` returns a row with a `hold_reason` to `HeldForPerson` and never to `Processed`; `SubmitReviewAsync` accepts `Processed`/`DeadLetter`/`HeldForPerson`, refuses a held **statement** decided by `result_json.doc_type`, and stamps the correction with the **source** contract; the v2 page carries `data-contract="sibyla.extract.v2"`, the superseded-correction block with its own stamp, `sib-hold-reason`, `sib-register-duplicate` with the entry code beside the two ruling buttons, and the re-extract action.

**Suites.** `Sibyla.Tests.Argus` 204/204 green. `Sibyla.Tests.Platform` filtered to `Extraction|ClaudeCli|SkillPackage|CliProcessRunner` 174/174 green.

---

## Test gaps (behaviour with no oracle, or an oracle that cannot fail)

1. **No test can fail on C-1**: the rejection corpus has no scalar- or array-for-object case at any level.
2. **`TheGateIsPerKindAtNinetyFivePercentAndTheFloorNeedsTwentyCells`** (`ExtractionScoringTests.cs:901`) asserts that 19 `vat_rate` cells at 9/19 = 47 % leave `GatePassed` true — it *enshrines* C-2's reading rather than testing S-1's. No test exercises a kind with zero gated cells while another kind has some, which is the shape both the reserve and the statements reports actually took.
3. **S-12**: no test for a flagged document with a negative line (C-6), for a correction row on a flagged document (C-7), or for an answer returning far more lines than printed with correct sums — `line_count` has no upper bound at all under the flag.
4. **C-10**: every gate fixture uses clean tax ids (`BE0866977981`, `IE3559150RH`, `"BE 0866.977.981"`), all of which both `Match` and `SameTaxId` accept, so the substitution is invisible.
5. **C-14**: `QueueWorkerGateTests.cs:234` asserts `Contains("held", …)` against a string that begins with "held" — green for any literal.
6. **C-3, C-16**: no fixture ever seeds a row that is both a checksum duplicate and a register match, so `COALESCE($3, duplicate_of_id)` is never executed with a non-null `$3` and the reference-clearing is untested; no fixture rules twice on the same (entry, filename).
7. **C-25**: `Assert.Equal(first.Evidence.PromptSha256, second.Evidence.PromptSha256)` and `Assert.Equal(hash, ExtractionRunner.PromptHash())` compare a parameterless constant with itself.
8. **The Hold-with-result branch** (`JobCompletion.cs:79`) and the worker-side `movements_truncated:<printed>/<returned>` string are never *produced* by a test — `IngestionServiceHoldTests` and `DocumentsReviewV2Tests` seed the literal into the row. The formatter could swap printed and returned and the suite stays green.
9. **`WorkerStartupTests` "a CLI that never answers exhausts the shared budget"** passes only because its fake propagates the cancellation; the shipped `ClaudeCliProcess.VersionAsync` catches `OperationCanceledException` and caches `"unavailable (…)"`, so the budget branch is unreachable in production and the throw comes out as a version mismatch instead.
10. Every gate test drives `CompanyGate.ApplyAsync` on a BYPASSRLS migrator connection with no `app.owner_id` GUC; the RLS-bound path the worker actually runs is never exercised.

---

## What I could not verify, and why

- **§4.5's hostile-document run against the real CLI.** `tests/Sibyla.Tests.Argus/evidence/extract-v2/hostile-documents-run.md` does not exist. Known and being closed in parallel; recorded here only for the checkpoint's completeness. R-EX-3's gate row requires it before the contract opens to gateway traffic.
- **Whether the bench's answers are faithful to the documents.** I did not open the client PDFs, and the `actual/` folders are `.gitignore`d (`evidence/extract-v2/.gitignore`), so only the reports are in the history. Every claim I make about the sittings is read off the committed `score-*.md`/`.json`.
- **Anything requiring the production database.** `PreviewParityTests` is red until the owner migrates Main (known, not my finding), and I did not query `gott_sibyla`, so I could not confirm that `entmst.fiscal_no` in the live register actually carries labelled values — C-10's severity rests on the column being "as captured" per `Documents.cs:54` and on the corpus of printed forms the spec quotes.
- **The published worker tree.** `SkillPackageTests`' publish-tree assertion is `publish-release.ps1`'s, outside `dotnet test`; I ran no publish.
- **My reproductions of C-1, C-4, C-5, C-21, C-22, C-29** loaded `tests/Sibyla.Tests.Argus/bin/Debug/net10.0/Sibyla.Platform.Infrastructure.dll` by reflection from a scratch harness under `D:\tmp` (nothing in the repository was written). `ExtractionContractV2.cs` and `.Schema.cs` are not among the working tree's modified files, so that binary is HEAD's for the class under test; a reader who wants belt and braces should re-run the six mutations from a clean checkout.
