# Argus extraction v1 — checkpoint round 2, regression and honesty review

**Reviewer:** independent reviewer 2 of 2, round 2. **Tree:** `D:\fileStorage\repos\GOTT.Apollo`, branch `ops/argus-extraction-v1-red`, head `632dfbb` (committed state only; the uncommitted `src/Sibyla.Web/**` and `tests/Sibyla.Tests.Browser/**` work of another session, and the three files `21097cc` discloses that it carries whole, are out of scope).
**Angle:** what the thirty fixes broke, and whether the numbers they produced are honest. I did not audit round-one closure — that is the other reviewer's assignment — except where the brief put a round-one artefact inside my own remit (D-EX-7).

## Verdict

**REVISE — Critical 0 / High 5 / Medium 14 / Low 10.**

The three published rates are honest. I recomputed all of them from the answers and keys on disk with my own implementation of §5.3 and reproduced them to the cell. The gate is not gamed: the net effect of the fixes on the movements figure was **downward** by a full point. But the record around the number is not yet in the state the ruling says it is, the gate constant is not the constant the spec states, and two production changes landed in the same commit that interact in a way neither one's test covers.

---

## 1. Independent recomputation of the three combined rates

**Method.** I wrote a scorer from §5.3 in Python, without reading `Scorer.cs` first, and ran it over the 40 canonical answers under
`tests/Sibyla.Tests.Argus/evidence/extract-v2/score-20260907-1321-c54e198-a558523/actual/` (35 fiscal) and
`…/score-20260907-1546-ce7d4cf-a558523/actual/` (5 statements), against
`tests/Sibyla.Tests.Argus/golden/extract-v2/answer-keys/*.json`, `answer-key.flags.json` and `answer-key.corrections.csv`.
Movements: N-4 tokenisation and S-11 Jaccard implemented from the spec text; S-2 pairing on `(posting_date ↔ movDate, amount, currency)` per statement, ties by S-11 similarity then `bmCode`; cells per S-7 with `running_balance` only where `printsRunningBalance`; statement cells `movement_count`, `opening_balance`, `closing_balance` against the flags file; both unpaired sides missed on every cell. Header/lines: `Split(...)` of §2.6 reimplemented (step A append, branches 0–3, step C fold, `VAT := VAT ?? 0`), S-3 signs, S-4's `dateDuePrinted` branch, S-5 on the six receipt rows, S-9's conditional cells gated by `printsIssuer/RecipientTaxId`, N-2 for `document_id`, N-3 + prefix-tolerant comparison for `fiscal_no`. Scripts under `D:\fileStorage\tmp\rev2\` (`mov.py`, `hdr.py`, `sens.py`, `pref.py`, `tighten.py`).

| Kind | Published | Mine | Agreement |
|---|---|---|---|
| Header | 445 / 447 = 99.55 % | **445 / 447 = 99.5526 %** | exact — see note |
| Lines | 342 / 347 = 98.56 % | **342 / 347 = 98.5591 %** | exact, cell for cell |
| Movements | 1349 / 1419 = 95.07 % | **1349 / 1419 = 95.0669 %** | exact, cell for cell, same 70 misses, same rows |

**Movements** reproduced with no adjustment at all: my run printed `1349 / 1419 = 95.0669 %` and the identical 70-cell miss set (BM000905, BM000906 and answer `seq=2`, `seq=3` on `BPI-DO-CRF-USD_202604`; BM001390–BM001392 and answer `seq=30`–`seq=32` on `BPI-DO-GOT-EUR_202507`). Per statement: BCP 325/325, BPI-CC 63/63, BPI-CRF 10/38, BPI-GOT 591/633, Revolut 360/360. The denominator decomposes as 187 movements × 7 cells + 10 × 6 (card, `printsRunningBalance: false`) + 15 statement cells + 5 unpaired answer movements × 7 = **1419**.

**Lines** reproduced exactly (109/6/109/6/109/8 = 347 scored) once I corrected my own two errors: I had applied `sign_by_kind` to the `stamp_tax` line of I26030044, where §2.6's printed-sign exception applies. The scorer is right and I was wrong.

**Header:** I recomputed the 318 non-derived cells and got 317/318 — the published `line_count` miss on I26070005 and nothing else (my second discrepancy, I26050010 `date_doc`, is the correction-semantics question at M-8 below, not a rate difference). The remaining **129 cells are derived** — `company` 29, `origin_class` 29, `document_type` 35, `account_period` 24, `service_period` 12 — and I did not reimplement `CompanyMatcher.Gate`, `ExtractionDerivations.PeriodFor` or the DOCTYP mapping. I verified their **denominators** independently from the flags and keys and they are exactly right (`fiscal_no` 32 = 35 − I26050030, I26070005, R26010016; gate cells 29 = 35 − those two − I26030008, I26030044, I26050003, I26050010; `account_period` 24 = 12 R1 + 12 R3; `service_period` 12 = the R1 rows; `date_pay` 6 = the receipt rows). 318 + 129 = 447. What I could not verify is at §5 below, and what it means for the number is at §4.

**Cross-checks that also reproduced:** corrections applied = 24 (26 rows in the CSV, less I26070026 which is a reserve document, less I26070005 `fiscalNo` whose cell is unscored because `printsIssuerTaxId` is false); 24/2213 = 1.0845 % → "1.08 %"; the S-8 fill rates (quantity 45/105, unit price 26/105, VAT rate 85/105, item candidates 105/105, evidence notes 40/40) all recomputed identically; E-4's "48 attempt trace(s), 188 file read(s)" sums exactly over `documentRecords`, with no document at zero.

---

## 2. Findings

### High

| id | sev | file:line | what is wrong | why it matters | fix |
|---|---|---|---|---|---|
| **R2-1** | High | `docs/apollo-argus-extraction-v1-spec.md:476`; change log at `:11` | §5.6 still reads "the reserve is the **third, single sitting** of §5.7 under the frozen tuple at R-EX-3" — the exact sentence D-EX-7 quotes as the Critical finding — and `:11` claims the ruling was applied to "§1.1 D-EX-7, **§5.6**, §5.7, S-10". `git diff main..HEAD -- docs/apollo-argus-extraction-v1-spec.md` shows `ed2616f` touched §1.1, S-10 and the §5.7 Plan and **not** §5.6's body. Seventeen further places still assert the seal or the held-out framing, three of them in files written at HEAD: `tools/extraction-bench/Score-Combined.ps1:23-25` documents the *fitted-key second reserve run* as "the held-out set of section 5.6" with `-Runs score-20260907-2250-e2f02e3-a558523` as the worked example; `Rescore.cs:23` ("`--reserve  also load the held-out keys of section 5.6`"); `ExtractionScoringTests.cs:250` ("The seal is what makes the reserve set evidence that the two measured sittings were not tuned into"). Also `spec:56` (D-EX-6, present tense: "§5.6 **seals** the held-out set"), `spec:72` (Q-EX-9, "opened once … a third, single bench sitting"), `answer-key.flags.json` rulings block, `AnswerKeys.cs:30,48`, `key-appeals:5,95-96`. | The ruling was "fix the wording, no fresh set." The wording is fixed where D-EX-7 is written and unfixed everywhere else, and the change log asserts a correction that was not made. `key-appeals:95-96` still carries the argument that the 100 % "is worth what it says" **because** the set was blind, un-struck. New code authored after the ruling reintroduces the term. | Strike or annotate every listed site; correct the `:11` citation; rename `--reserve`'s help text and the `Score-Combined.ps1` example. |
| **R2-2** | High | `tests/Sibyla.Tests.Argus/evidence/extract-v2/score-20260907-1613-dff90f9-a558523.md:7` and `…-2250-e2f02e3-….md:7` | The blind reserve run's committed report headlines `**Gate (S-10):** PASSED` over `Header — 0 not gated (no field reached 20 cells)`, `Lines — 0 not gated`, `Movements 156/156`, while its own per-field table prints `net_amount \| Lines \| 9 \| 17 \| 52.94%` three times. Under the amended S-10 a zero-cell kind is a gate *failure* and the lines rate is a hard fail; re-issued today the scorer would print FAILED. Neither reserve report carries any status note. | The evidence folder is what a checkpoint reviewer and the owner read. It publishes a PASSED verdict the corrected rule does not support, in the same folder as the report that gates the release. The 1613 run's `actual/` was destroyed, so it cannot be re-scored — which is exactly why the file needs a note rather than a re-run. | Prepend a status header to both `.md` files naming D-EX-7, the corrected rates, and that the printed gate line is superseded. (This overlaps round-one E-3; closure is the other reviewer's call — I report it because the brief put "does anything still present that 100 % as a held-out result" in my remit.) |
| **R2-3** | High | `tools/extraction-bench/Sibyla.Tools.ExtractionBench/Scoring.cs:17,24`; compared at `Scorer.cs:550,554` | `Rate => Scored == 0 ? 0m : Math.Round(Matched / (decimal)Scored, 4)`, and the gate is `k.Rate < GateRate` with `GateRate = 0.95m`. The comparison is against a rate **rounded to four decimals**, so the implemented gate is 94.995 %, not the 95 % S-10 states. At the published denominator, `1348 / 1419 = 94.9965 %` rounds to `0.9500` and prints **PASSED**. The published JSON already carries the rounded value: `"rate": 0.9507`. Same defect on `FieldFloor` (84.995 %). Not introduced by these commits, but it became load-bearing when the margin fell to 0.07 points. | The movements figure clears the stated gate by **one cell** and the implemented gate by **two**. A gate whose threshold is not the number the ruling names is the class of defect this checkpoint exists to find. | Compare the unrounded quotient: `Matched * 100m >= GateRate * 100m * Scored`, or keep an unrounded `Rate` and round only for display. Add an oracle at `Scored = 1419, Matched = 1348`. |
| **R2-4** | High | `ExtractionContractV2.Schema.cs:260` (N-3 now applied by the reader) with `CompanyMatcher.cs:135-142` (C-11) | Two changes in `21097cc` interact. (a) `NormalizeTaxId(node, shape, path)` is now called from `ContractV2Reader.Read`, so a **bare** printed tax id is stamped with the ISO-2 code taken from the model's own `country` member before it ever reaches `ExtractionResult`, the company gate or the register lookup. (b) `SameNormalized` now returns **false** whenever both sides carry a two-letter prefix: `if (HasCountryPrefix(a) && HasCountryPrefix(b)) return false;`. Together, a bare id plus a country that differs from the register's prefix convention now fails to match where it used to match. Certain triggers, not hypothetical: Greece (N-3 says "the ISO-2 country code" → `GR…`; VIES and the FDR use `EL…`) and Northern Ireland (`GB` vs `XI`). A wrong `country` is also demonstrated on this very sample — I26070005's answer gives the recipient `"country": "UM"` for a Fort Lauderdale address and says so in its own notes. `CompanyMatcherTests.cs:153-176` tests hand-written strings only; nothing tests the reader's output through the matcher. | Since D-EX-5 this comparison **is** the register-duplicate test. C-11 was raised to remove a false positive; combined with (a) it manufactures false negatives — a genuine duplicate is not held, and the gate triages a document it used to assign. This is the failure D-EX-5 exists to prevent. Blast radius on the 40 is zero (all 75 stored `tax_id` values already carry a prefix), so no test and no sitting would have shown it. | Either keep the prefix comparison tolerant when one side's prefix was *synthesised* by N-3 (carry a flag), or restrict N-3's prefixing to countries whose fiscal prefix equals their ISO-2 code. Add an oracle: bare printed id + `country: "GR"` against key `EL…`. |
| **R2-5** | High | `src/Sibyla.Platform.Infrastructure/Ingestion/IngestionService.cs:634-651`; test at `tests/Sibyla.Tests.Platform/IngestionServiceHoldTests.cs:188-203` | `ON CONFLICT (owner_id, entry_code, source_filename) DO UPDATE SET fdchdr_id, verdict, clause, evidence, declared_on, declared_in` overwrites a row in `duplicate_classification`. `src/Sibyla.Modules.Argus.Domain/Entities/Declarations.cs:3-6` documents that table's family as "authoritative human input … replayed every run, never overwritten by one"; the sibling `audlog` is protected by `REVOKE UPDATE, DELETE` and a `BEFORE UPDATE OR DELETE` trigger (`20260902031655_AuditLogAppendOnly.cs`). Nothing copies the prior row before replacing it — the audit detail records only the boolean `replacedPriorDeclaration`, not what was replaced. The fix's own test proves the loss: `first` is ruled `isDuplicate: false` ("Verified genuine repeat"), `second` is ruled `true`, and `Assert.Single(await DeclarationsAsync("I26010021"))` then asserts one surviving row with verdict `"Duplicate - archived (clause 1)"`. The conflict key's third column is `intake.OriginalFilename`, a user-supplied name (`scan.pdf`, `fatura.pdf` on the e-mail channel), so a *different* intake by a *different* person collides. The raw SQL carries no `sync_run_id IS NULL` predicate, so an Apollo ruling will also overwrite a legacy-synced row in place while leaving `sync_run_id`/`src_hash` pointing at the source (`SyncEngineWave5.cs:256-276`), after which the next sync reports it unchanged and a later source edit reverts the human ruling. | The fix converts a loud, recoverable failure (23505, rollback, ruling lost, circuit broken) into a silent destructive one on the register of record. The substance of the earlier ruling survives in `audlog`, so it is reconstructible; the register's own declaration — its `evidence` text, the only place the declaring user is named, and its `declared_on` — is not. | `ON CONFLICT … DO NOTHING` plus a superseding row, or a supersession column; at minimum serialise the prior row into the audit detail before replacing it. Scope the conflict to the intake, not the filename. Exclude sync-owned rows. |

### Medium

| id | sev | file:line | what is wrong | why it matters | fix |
|---|---|---|---|---|---|
| **R2-6** | Medium | `Scorer.cs:215-249` (S-6) | `service_period` is scored against `expectedPeriod` = the key's `accountPeriod`, computed as `AccountPeriodService.Decide(dd, stated, null).Period`; `account_period` on the same 12 R1 rows is `ExtractionDerivations.PeriodFor(d, stated, null, null)` against the same expected value. The two are the same measurement counted twice. On the 12 R3 rows `account_period` is `PeriodFor(d, null, null, null)` — a pure function of `date_doc`, which scored 35/35, so those 12 cells cannot miss. | 24 of the 447 header cells carry no information independent of cells already scored, and all 24 matched. They are 5.4 % of the header denominator. See §4 — 18 of them are exactly what the C-2 fix added. | Score `service_period` against the *stated period*, not against `accountPeriod`; or report it under S-8. Amend S-6 accordingly. |
| **R2-7** | Medium | `combined-…a558523.md`, "Per field" | For movements, `amount`, `currency`, `description`, `doc_date`, `period` and `posting_date` all read **192/202 = 95.05 %** because every movement miss is a pairing failure: I confirmed that all 192 paired movements matched every field, and that all 192 paired descriptions are N-4-identical (Jaccard 1.0). The per-field 85 % floor over 202 cells is therefore one test written six times, and the `description` rate says nothing about description accuracy — I re-ran the whole set at similarity thresholds 0.8 and 1.0 and with the prefix rule disabled: `1349/1419` every time. | The report presents six floored fields as independent evidence of movement quality. The underlying measurement is: 5 of 197 key movements were mis-dated by one day, on two statements. A reader is entitled to know the floor table is not six checks. | Say so on the report: state the paired/unpaired split beside the per-field table, and report the exact-match rate S-8 already requires. |
| **R2-8** | Medium | `Similarity.cs:17,69`; test `ExtractionScoringTests.cs:1199-1205`, corroborated at `:869` | `PrefixShare = 0.5m` with `if (shorter.Count < longer.Count * PrefixShare) return false;`. The one real column-slice case the rule exists for is 4 tokens against 8 (`TRF, CRED, NAO, SEPA` vs `… EMITIDA, 07865284, FAT, 20250422904233` — the repo's own `Assert.Equal(0.5m, Overlap(...))` at `:869` confirms the 4/8 split), so `4 < 4.0` is false and it matches **on an exact tie**. `PrefixShare = 0.51`, or `<=` instead of `<`, breaks it. The test's inline comment says "4 of 7 tokens is 57 % of the longer side" — the count and the margin are both wrong — and `Assert.Equal(0.5m, DescriptionSimilarity.PrefixShare)` at `:1205` restates the constant. | The bound is calibrated to the data with zero margin, and is inert on the sample (no paired description needed it). It is presented as a principled bound. | Assert a behavioural band, not the constant; state in S-11 that the real slices sit on the boundary; consider bounding on characters, where the margin is not zero. |
| **R2-9** | Medium | `BenchReport.cs:88-89`; `Rescore.cs` `--out` unvalidated (`RescoreOptions:13`, `:53`) | `Path.Combine(o.EvidenceRoot, o.OutName + ".json")` with no existence check and no sanitisation. `--out score-20260907-1321-c54e198-a558523` silently replaces that sitting's own published report. `--out score-20260907-1321-…/actual/I26010021` writes over a run folder's canonical answer, which is gitignored and unrecoverable — directly contradicting `Rescore.cs:100-103` and `:25-27` ("never touches a run folder's contents"). An absolute `--out` discards `EvidenceRoot` entirely. | This tool writes the release gate's evidence. | Refuse an existing target unless `--force`; assert `Path.GetFileName(OutName) == OutName`. |
| **R2-10** | Medium | `Rescore.cs:134-141`, `:150-154` | `score` refuses a document two runs answered and an answer with no key, but never checks the converse — that every key in the loaded set got an answer. A run folder holding 34 of the 35 fiscal answers plus the 5 statements scores 39 documents, has cells in all three kinds, and can **PASS** the 95 % gate over a subset of the sample §5.1 defines as 40. Neither refusal has a test (`grep BenchRefusedException tests/` hits only an unrelated resume case). | This is the same false-green class C-19 was raised for, left one direction open, in the code written to close it. | Load the key set, compare it to the answered set, and refuse a gap by name unless `--partial`. Add oracles for both refusals. |
| **R2-11** | Medium | `BenchReport.cs:100-101`; `BenchRun.cs:233` | A combined report over runs with different configuration tuples emits `configurationTuples` (plural) and a bold markdown paragraph, but adds **no gate reason** — `Scorer.Report` never sees the tuples, so `passed: true` is possible across two package hashes or two models. Separately, a re-score builds its tuple as `new BenchTuple(o.CliVersion, modelId ?? o.Model, o.Effort, treeHash, …)`: `CliVersion`, `Effort` and `treeHash` come from **today's** options and today's skill-build tree, so re-scoring old answers after a re-pin stamps the new `skillTreeSha256` onto them. | Q-EX-14 hangs the entire gate on the tuple. Both holes let a report assert a tuple its answers were not produced under. | Make a mixed tuple a gate reason; carry the tuple from the prior run's record, as the durations now are. |
| **R2-12** | Medium | `Rescore.cs:155`; `.gitignore` in the evidence folder | `score` parses each `actual/*.json` with `JsonDocument.Parse` and scores it **without re-validating it against the contract**. The answers are not in git (`score-*/` is ignored, and `Rescore.cs:123-124` says so). There is no hash binding the committed report to the files it scored. Combined with R2-9, the gate evidence is mutable, unversioned, and unbound. (Mitigation: the report embeds all 2,213 cells with expected and actual values, which is a real audit trail. And I checked the tightenings retroactively — see §5 — none would refuse any of the 50 stored answers.) A malformed file crashes with an unhandled `JsonException` rather than a named refusal. | The release gate rests on files on one build host that nothing binds to the report. | Re-validate each answer through `ExtractionContractV2.Validate` before scoring and record a SHA-256 per answer file in the report. |
| **R2-13** | Medium | `docs/…-spec.md:432,459,521`; `answer-key.corrections.csv` | §5.5 says "**the 18 defective movement descriptions** — 15 Revolut rows …, 2 Revolut rows otherwise column-sliced …, and 1 BPI-DO #39 row" and "18 of the ≈ 1,380 movement cells". The file holds **16**: 15 Revolut (the fifteen dates §5.5 lists) and 1 BPI. §5.5 also still lists "I24120001 `fiscalNo` (not printed → expected `null`)" as pre-declared, although `answer-key.flags.json` records the ruling that **withdrew** it and the CSV has no such row. §8's R-EX-2 row still reads "(4 + 18 + #31)"; the file is 3 + 16 + 4. Both were named as candidate findings in `owner-guide:207` before the checkpoint and neither is fixed. | §5.5 is the specification of the 5 % guard the same amendment claims to have implemented. Its own account of its inputs is wrong in three places. | Correct §5.5's count to 16 (or add the two missing rows), strike the withdrawn I24120001 entry, correct §8. |
| **R2-14** | Medium | `docs/…-spec.md:238` vs `:375` and `CompanyGate.cs:134` | §2.6 (amended in this round for C-17) still says the gate "looks the register up by company, **issuer fiscal number** and document number". The code uses the counterparty — `var counterparty = flowType == "R" ? key.RecipientTaxId : key.IssuerTaxId;` — and §4.7's amended row says so correctly, pinned by `QueueWorkerGateTests.cs:332`. | Two sections of the same specification now contradict each other on receivables, in a paragraph this round rewrote. | Correct §2.6 to "counterparty fiscal number (the issuer on a payable, the recipient on a receivable)". |
| **R2-15** | Medium | `tests/Sibyla.Tests.Platform/WorkerStartupTests.cs:167-176` | `ThePublishStepRefusesAWorkerReleaseWithoutTheTuple` reads `local/publish-release.ps1` as text and asserts that five key names and `skill-package.manifest.json` appear as substrings. All five names already appear in the comment at `publish-release.ps1:48-51` and in the `throw` message at `:56`, and the golden filename appears in the `throw` at `:64` — so deleting the entire enforcement block at `:57-70` leaves the test green. | It is the only test over the publish-time control that C-20 added, and it tests that six words are in a file. | Invoke the script against a fixture tree missing the section and assert it throws, or move the check into code the suite can call. |
| **R2-16** | Medium | `ExtractionScoringTests.cs:948-960` | `AGateOverNothingIsNotAPass` scores 5 header + 3 line cells. Pre-fix (`git show 632dfbb^:…/Scorer.cs:497`) both fields were under the 20-cell floor, so both kinds tallied 0, `kinds.Values.All(k => k.Scored == 0)` fired and `GatePassed` was already `false` — `Assert.False(report.GatePassed)` passes in both worlds. The only discriminator is the reason string. **No test asserts the shape the finding was actually raised for**: a floored field with many cells in one kind and zero cells in another (a statements-only sitting with ~1,400 movement cells and no header cell) printing PASSED. | The false green C-19 exists to abolish is pinned by nothing. | Add: 100 matched header cells, zero movement cells ⇒ `GatePassed == false`. |
| **R2-17** | Medium | `Scorer.cs:136,259,295`; `AnswerKeys.cs:114-126` | The ruled-flag arm of the 5 % guard attaches `RulingOf(flags)` at exactly two flag applications (`dateDuePrinted == false`, `keyLinesSummarised`) and at no statement cell, although `AnswerKeys` parses `ruling` for the `statements` section. A post-sitting ruling that set `printsIssuerTaxId`, `printsRecipientTaxId`, `placeholders`, `printedOpeningBalance`, `printedClosingBalance`, `printsRunningBalance`, `printOrder` or `hasTransactionDateColumn` would move scored cells and be counted **nowhere** — precisely the defect class C-18 names. The guard counts today's three rulings by construction, not by rule. The report that gates the release reads `"ruledFlagsApplied": 0`, because all three ruled flags (I26070007, I26080031, I26010042) are reserve documents: the whole of C-18 contributes zero cells to the 24/2213 the release is gated on. | The fix is a special case wearing a general name, and its demonstrated coverage on the gating artefact is nil. | Attach the document's ruling to every cell whose expected value came from a flag member, not to two call sites. |
| **R2-18** | Medium | `src/Sibyla.Worker.Documents/Program.cs:5-11`; `owner-guide:264-268`; `publish-release.ps1:40,53-70` | The tuple committed to `appsettings.json` is outranked at runtime by three sources, the highest of which the guide never names: `Program.cs` appends the `SIBYLA_SECRETS_FILE` JSON **last**, so `worker.json` beats `appsettings.Production.json` and every `Worker__*` environment variable. §8.2 tells the owner to strip the five keys from `appsettings.Production.json` only. The publish check reads the **lowest**-priority copy, recomputes no hash from the published `skill/` tree (it string-compares two files in the same commit, so a consistent wrong edit to both passes), and is skipped entirely by `-Hosts web,api` — `$workerFacts` stays `$null` and `release.json` simply omits `workerReleaseFacts`. `WorkerStartupChecks` validates only `CliVersion` and `SkillPackageSha256`; a stale `Model` or `Effort` in a higher-priority file runs unchecked, which is the Q-EX-14 failure the tuple exists to prevent. | The gate-carrying value is verified in the copy least likely to take effect. | Name `worker.json` in §8.2; recompute the tree hash from the published output in `publish-release.ps1`; have `WorkerStartupChecks` log the *effective* Model/Effort and compare them to the release facts. |
| **R2-19** | Medium | `ExtractionContractV2.cs:536-538` | N-3 strips ` `, U+00A0, `.`, `/`, `-`, then splits on every remaining non-alphanumeric character and takes `segments.LastOrDefault(s => s.Any(char.IsAsciiDigit))`. Neither §2.7's N-3 nor the C-24 amendment describes the segmenting, and taking the **last** digit-bearing segment mis-reads a trailing parenthetical: `"12345678 (VAT2)"` → segments `["12345678","VAT2"]` → `"VAT2"`. | The reader now writes this value into the canonical form and everything downstream of it, including the register-duplicate key. | Prefer the **longest** digit-bearing segment, or the first after a known label; and describe the segmenting in N-3. |

### Low

| id | sev | file:line | what is wrong |
|---|---|---|---|
| **R2-20** | Low | `IngestionService.cs:641,649` | The replacing UPDATE sets `declared_on = EXCLUDED.declared_on` while `created_at` is untouched, so a replaced declaration reads as created before the date it claims to have been declared on; and flipping duplicate → genuine writes `clause = EXCLUDED.clause` = `NULL`, erasing the clause-1 archiving justification. That direction has no test. |
| **R2-21** | Low | `src/Sibyla.Platform.Infrastructure/Properties/AssemblyInfo.cs:10` | `InternalsVisibleTo("Sibyla.Worker.Documents")` puts a production assembly behind an unsigned test-visibility hatch (no `SignAssembly` anywhere in the tree) for one method whose siblings `Match`, `Gate` and `Normalize` are already public. It exposes ~59 internals to the worker, including `HarnessRunner.InterruptAllAsync` and `RedactedCommandLine`. Making `SameTaxId` public grants strictly less. |
| **R2-22** | Low | `Scorer.cs:500-502` (`ReadAnswerMovements`) | `if (posting is null) continue;` silently drops a movement from both `movement_count` and the unpaired penalty. The schema makes `posting_date` required (`Schema.cs:138`), but `score` does not re-validate (R2-12), so a hand-edited answer would shrink the denominator undetected. |
| **R2-23** | Low | `Scorer.cs:419` | `movement_count` compares the key's `movementCount` to `answerMovements.Count`, not to the contract's `statement.movements_printed_count`. On `BPI-DO-CRF-USD_202604` it therefore matches (3 == 3) while 2 of the 3 movements failed to pair. The cell measures list length, not the answer's declared count. |
| **R2-24** | Low | commit messages of `21097cc`, `632dfbb` | "Platform extraction suites: 314 passed" and "Argus 218, Platform extraction 331" are not reproducible with the reviewer's filter, which gives **301 passed, 0 failed** at HEAD, and neither number appears in `RED-green.txt` (which records 228 for a different filter). |
| **R2-25** | Low | `Schema.cs:476-510` | The closed ISO 4217 list (181 codes) omits `XXX`, `XTS` and `XBA`–`XBD`, which are on the active alphabetic list. The eight deliberately kept withdrawn codes — the decision the commit message defends at greatest length — are covered by no test: deleting all eight leaves the suite green (`ExtractionContractV2Tests.cs:503` exercises 14 codes that all passed the pre-fix shape check too). |
| **R2-26** | Low | `docs/…-spec.md:339,507,7` | §4.3 and §7 still say `const bool StreamJsonNeedsVerbose` is "`false` at RED"; `ClaudeCli.cs:45` has `= true`, set by `a5480bb`, which is an ancestor of the amendment commit. Stale in a clause the amendment touched. |
| **R2-27** | Low | `docs/…-spec.md:353` (§4.5) | "one of the ten proven forms" — `deny-matrix-20260907-114717-production-dir/MATRIX.md` ran ten forms and proved **eight**; forms A and G (the `//D:/…` shape) did not bind. |
| **R2-28** | Low | `combined-…a558523.md`, "Documents" table | The table is introduced as "Attempts, durations and statuses as each run recorded them", and 35 of the 40 rows read `resumed \| 0 \| 0 s` because an earlier re-score overwrote them. The M-1 damage is disclosed in a paragraph above but not on the rows, and the rows read as measurements. |
| **R2-29** | Low | `ExtractionScoringTests.cs:1177-1188`, `:1328-1351` | `ARuledDueDateFlagIsAnAdjustedCellAndTheHandChecksAreNot` never reads `report.AdjustedCells` — the quantity in its name and the numerator of the 5 % guard; both its `Assert.True(…Matched)` calls held pre-fix. `AReScoreReportSaysWhichRunItReScoresAndWhen` contains `Assert.Contains("35", markdown)` — a bare substring search over a generated page — and supplies the window it claims to prove is preserved, so `BenchRun.cs:227-231` (which actually chooses it) is executed by no test. |

---

## 3. The three tests defended as passing on their first run

I could not find a passage in `RED-green.txt` declaring exactly three such tests. The three places where a first-run green **is** defended are these, and my judgement on each:

1. **`APreAssignedRowGetsNoRegisterLookupInThisSlice_KnownGapUntilThePersistenceGuard`** (`QueueWorkerGateTests.cs:313-330`), defended as "characterisation (not a behaviour change) … it must be deleted, not merely edited, when the gate is extended." **The defence holds.** It asserts a real post-condition (`Assert.Null((await RegisterReferenceOfAsync(intake)).EntryId)`) that would go red the moment the gate is extended, which is exactly what a characterisation test is for, and the deletion instruction is in the doc comment where a future author will read it.
2. **`SeveralSittingsAnswersAreScoredAsOneSet`** (`ExtractionScoringTests.cs:1366`), defended at `RED-green.txt` as "C-19 has no such RED: nothing scored several runs' answers as one set before this, so its test is a new-capability test, not a behaviour change." **The defence holds for the capability and fails for the finding.** C-19 was not only "score several runs as one set"; it was "a single-set sitting printed PASSED over two kinds it could not measure". That half *is* a behaviour change, it *could* have gone red, and it did not — see R2-16. The test itself asserts three `> 0` counts over perfect answers and reads neither a rate nor `GatePassed`.
3. **The whole of `21097cc`.** The commit asserts "Every behaviour change here was observed RED for the reason the reviewer states before its fix", and it touches no file under `tests/Sibyla.Tests.Argus/evidence/`. All 100 appended `RED-green.txt` lines come from `632dfbb` and cover the fifteen bench and scorer tests only. **The defence is not made and the evidence is not there** — which matters more because `RED-green.txt`'s own appended M-3 note says the file exists "so that this file is the whole record rather than most of it, which is what a checkpoint reviewer is entitled to assume it is." I record this as a record gap (Medium in spirit; I have folded it into R2-15/R2-16 rather than counting it separately). The `632dfbb` RED record itself is good, and Batch B's method — reverting the three behaviours in place after the fix and re-running — is disclosed openly and is the right way to demonstrate RED for code that did not previously exist.

---

## 4. Direction — did any scoring change move the number the helpful way?

**Plainly: the fixes moved the gate number down, by a full point, and I found no evidence of gaming. But one change did move it up, it was framed as doing the opposite, and against a 0.07-point margin it is material. I believe that was unconscious.**

I measured every scoring change by re-running my own scorer with each one reverted (`sens.py`):

| Configuration | Movements | Gate |
|---|---|---|
| Published (all fixes) | **1349 / 1419 = 95.0669 %** | PASS by 0.067 pts |
| S-11's new 50 % prefix bound removed | 1349 / 1419 = 95.0669 % | **no change** |
| Similarity threshold at 0.8, or at 1.0 | 1349 / 1419 = 95.0669 % | **no change** |
| C-9 (unpaired-answer symmetry) reverted to 4 cells | 1349 / 1404 = 96.0826 % | +1.02 pts |
| C-2's statement cells excluded, C-9 kept | 1334 / 1404 = 95.0142 % | −0.053 pts |
| Both reverted — the pre-fix figure | 1334 / 1389 = 96.0403 % | +0.97 pts |

**Against the number.** C-9 is the dominant change and it costs 1.02 points. The published 95.07 % clears the gate by 0.07 where the pre-fix 96.04 % cleared it by 1.04. That is the single largest movement any of the thirty fixes produced and it goes the hard way. C-9 is also, arguably, harsher than S-2 requires: because pairing is on `(posting_date, amount, currency)`, one movement whose posting date is one day out is now counted twice — seven cells for the orphaned key row and seven for the orphaned answer row. Five such movements produce all 70 movement misses. Nobody looking to protect the number would have written it.

**Neutral.** S-11's new bound and the sign/correction repairs to S-12 (C-6, C-7) moved nothing on this sample and could not have: all 192 paired descriptions are N-4-identical, so the prefix rule never fires; and `keyLinesSummarised` is set only on I26070007 and I26080031, both reserve documents, so the whole S-12 path is inert on the 40. C-8 (the gate reading `tax_id` only) also moved nothing: exactly one of the 75 stored `tax_id` values diverges from its `tax_id_printed` (R26010016's recipient `BEL999999`, a placeholder that matches no candidate either way), and I26050003 — the case the doc comment at `Scorer.cs:378-383` names — has both members `null`, so the removed fallback had nothing to fall back to. Its RED was demonstrated on a synthetic fixture, correctly, but the sample effect is zero.

**For the number.** C-2 — "the ≥ 20-cell rule belongs to the floor and to nothing else" — added **53 cells across the three kinds and every single one of them matched**: header + 18 (`date_pay` 6/6, `service_period` 12/12), lines + 20 (`quantity` 6/6, `unit_price` 6/6, `vat_rate` 8/8), movements + 15 (the three statement cells × 5). Header went 427/429 → 445/447, lines 322/327 → 342/347, movements 1334/1389 → 1349/1404. On movements it is worth **+0.053 points against a margin of 0.067**: without the statement cells the rate is 95.014 %, still a pass, but by 0.014 points instead of 0.067. Roughly four fifths of the published margin comes from a change presented as making the gate stricter — the amendment names six fields that were "permanently exempted from every gate", and folding them in raised all three rates.

**Why I do not read that as gaming.** Four reasons. (i) The direction was not predictable from the rule: including previously-exempt fields raises a rate only if those fields beat the kind average, and no one could know 53/53 in advance without running it. (ii) The change is compelled by S-1 and Q-EX-7 as written, both of which predate the checkpoint by weeks, and both reviewers found the tally defect independently — it is the checkpoint's own finding, not the author's. (iii) The same change is what turns a statements-only sitting's false PASSED into a FAILED, which is a cost to the author, not a benefit. (iv) The one component that genuinely could not miss — `service_period`, 12 of the 18 header cells added — cannot miss because S-6 scores it with the same computation and the same expected value as `account_period` on the same rows (R2-6). That is a defect in the accepted specification, not in the fix that started counting it.

**What I would put in front of the owner.** The published movements figure is correct and I reproduced it cell for cell. But it is decided by five movements out of 197 whose posting date is one day out on two bank statements; the six "independent" floored movement fields are one measurement written six times; and the gate as implemented compares a rate rounded to four decimals, so `1348/1419 = 94.9965 %` would also have printed PASSED. The margin is one cell under the rule as stated and two under the rule as coded.

---

## 5. What I verified as sound

- **All three published rates.** Recomputed independently; movements and lines to the cell, header to the cell on the 318 non-derived cells with all five derived denominators confirmed.
- **The correction accounting.** 24 applied, 24 adjusted cells, 24/2213 = 1.08 % — reproduced from the CSV and the flags by hand, including why it is 24 and not 25 (I26070005's `fiscalNo` cell is unscored) or 26 (I26070026 is a reserve document).
- **E-4's re-derivation.** 48 attempt traces and 188 file reads sum exactly over `documentRecords`; no document at zero. The S-8 fill rates all reproduce.
- **The tightened validator does not retroactively invalidate the sample.** I checked all 50 stored answers (40 sample + 10 reserve) against the tightened rules: every currency is in the 181-code list, no `vat_rate` carries more than two decimals or leaves 0–100, no answer has two stamp lines, and no stamp line disagrees with `stamp_duty_amount`. Nothing that was accepted at run time would now be refused.
- **The six accepted goldens are untouched.** `git diff --stat 40d5652..HEAD -- tests/Sibyla.Tests.Platform/golden/` shows the fix commits added five rejection fixtures and five lines to `expected.txt` and changed nothing else: the six `.json` and six `.canonical.json` files are byte-identical to their round-one state, and the rejection corpus's expectations were **added to, never loosened** (five insertions, zero deletions). The goldens' `tax_id` values already carry the N-3 shape, so the reader change cannot move their canonical form.
- **No oracle was loosened to admit a fix.** The one expected value moved anywhere in either commit is `ExtractionScoringTests.cs:926`, `Assert.True(nineteen.GatePassed)` → `Assert.False`, which correctly deleted an expectation that encoded the E-2/C-2 defect (109/119 = 91.6 % must fail a 95 % gate) and replaced it with three stricter assertions.
- **The `score` verb touches no model, network or database.** Verified by reading `Rescore.Execute`'s full call graph and by grepping the bench project for `Npgsql|HttpClient|Socket|ConnectionString|IClaudeCli|Process.Start` — the only hits are on the `run` path, which `Program.cs:12-16` dispatches around.
- **The suite is green.** `dotnet test tests/Sibyla.Tests.Platform` with the reviewer's filter, under the shared lock: **301 passed, 0 failed, 18 s**. Lock taken and released.
- **The strongest new oracles.** `TheDeclarationNamesTheFiscalKeyTheMatchWasMadeOnNotTheIssuerBlindly`, `V10ReconcilesTheSumOfEveryStampLineNotOnlyTheFirst`, `AnObjectMemberOfTheWrongKindIsRefusedAndNeverThrown`, `AKindScoresEveryCellOfItsKindAndTheTwentyCellRuleIsTheFloorAlone`, `MoreThanFivePercentOfScoredCellsCorrectedStopsTheRun` (which pins both sides of the boundary, 15/300 pass and 16/300 fail) and the two E-4 bench tests are real, hand-computed, RED-able oracles. The `632dfbb` RED record shows genuine failing values, not compile errors.
- **The gate does not lean on the reserve.** The combined report scores only the two tuning runs; no reserve cell participates; §8's R-EX-3 row asks for the reserve "reported", not gated.

## 6. What I could not verify, and why

- **The 129 derived header cells** (`company` 29, `origin_class` 29, `document_type` 35, `account_period` 24, `service_period` 12) — 128 of them matched. I verified every denominator independently and read the scoring code for each, and I am satisfied that `company`/`origin_class` genuinely run `CompanyMatcher.Gate` on the **answer's** tax ids against candidates built from the keys (so they are a real measurement), and that `account_period`/`service_period` apply the key's rule to the answer's inputs. I did **not** reimplement `CompanyMatcher.Gate`, `ExtractionDerivations.PeriodFor` or `AccountPeriodService.Decide`, so I cannot independently confirm those 128 matches — only that the comparison is not structurally self-referential, and that R2-6's duplication is.
- **The blind reserve run's numbers** (76/80, 27/51 = 52.94 %, 158/158). Its `actual/` folder was destroyed and was never in git, so they cannot be recomputed. I can only confirm that the surviving report `score-20260907-1613-dff90f9-a558523.md` carries the per-field rows those figures are derived from, and that its printed `Gate (S-10): PASSED` is not consistent with them.
- **Anything requiring a run of the CLI, a deployment, or the production database** — out of bounds by the brief. In particular I could not confirm the R2-4 interaction empirically; it is an argument from the code plus the EL/GR and XI/GB conventions, and I state it as such.
- **§4.5's hostile-document run** — known blocked on the owner, out of scope.
- **Whether `worker.json` on the host currently carries the five release facts** — I did not read it (secrets root). R2-18 is stated as a precedence hazard in the code and the guide, not as an observed misconfiguration.
