Skip to content

Supersession: dated source blocks, a supersede primitive, and the reports that say when the writer was overruled - #51

Open
lucasmaan wants to merge 81 commits into
mainfrom
docs/supersession-a2-spec
Open

lucasmaan wants to merge 81 commits into
mainfrom
docs/supersession-a2-spec

Conversation

@lucasmaan

Copy link
Copy Markdown
Contributor

What this delivers

Two increments of the supersession feature — teaching the writer to retract a claim instead of stacking a newer one beside it — plus the date ordering that makes retraction decidable.

Ordering and dates (A1's groundwork)

  • Documents are dated on the submit route, and the writer receives one dated source block per source, newest first, so a merge knows which claim is newer rather than guessing from position.
  • The write budget is allocated newest source first; the prompt is told which way the blocks run.
  • Version chains the merge cannot reconcile are reported instead of being silently merged, and the ordering claim between same-day sources is withdrawn rather than asserted.

The supersede primitive (A2 steps 1–4)

  • A merge can now replace an anchored claim and leave a [Superseded ...] trail where it stood, on both write routes (merge-diff and merge-rewrite).
  • The rewrite path is guarded: a rewrite that drops a pre-existing trail costs the merge rather than the history (D9 — losing a merge beats losing what the article recorded).
  • Merge findings — a refused supersede, an abandoned merge, an article that shrank — travel in a caller-passed sink and surface in the compile report and the worker route's item result. An abandoned merge is its own status, so nothing reads as merged when the sources never reached the article.

Evaluation scaffolding

  • The fixture case selector, per-version staging, the arm driver, and an article/frontmatter reachability audit, all under test.
  • The gateway is refused when it does not serve the arm's write model, so an arm cannot silently measure the wrong writer.

Documentsdocs/features/supersession/: the spec for both increments, design options, the labelled fixture, the test set with its scoring rules, and A1's verdict (verdict-fx7.md), which is what decided A2 was required rather than optional.

What is not in this PR

  • A2 step 6, the arm and its verdict (verdict-a2.md). That step spends real model budget to measure the writer this branch changed; it is measurement, not code, and it is the only open item in the spec's sequencing.
  • A known pre-existing hole, recorded rather than fixed (spec-a2.md, step 3's notes): _merge_diff section-truncates an article past 70% of the prompt budget and patches the truncated text, so section bodies — trail blocks among them — can be dropped before any action runs. Measured on an 86,731-character article: 51,966 characters written back. It predates this work and contradicts A1's G4 as squarely as it does A2's D9, so the fix is its own change. It is named in the spec so the shrink report is not misread as the guard failing.

Verification

  • 2025 offline tests pass (cd py && uv run pytest tests/ -m "not slow"). Coverage: core/merge.py 100%, commands/pipeline/_phase_write.py 100%, commands/compile.py 99%.
  • One environment-dependent failure outside the offline set: tests/test_distill.py::test_distill_end_to_end_produces_article needs a gateway serving gpt-4o-mini. It fails the same way on the branch point.
  • Each increment was reviewed by an independent reviewer before landing, and the prompt rules and guards were checked by mutation: a rule deleted or a guard reverted has to turn a test red. The last pass verified each of the four finding-drain sites individually — reverting any one alone turns exactly one test red.

Notes for reviewers

  • The write prompts (py/src/kb_ai/prompts/defaults/merge-{diff,rewrite}.md) are the behavioural change; write_prompt_version moves with them, so articles written before this branch are marked as written by an older writer rather than silently treated as current.
  • docs/features/supersession/spec-a2.md carries the decisions and the deviations from the rule text as written, including the ones that could have gone another way. Reading its ## Implementation sequencing first is the fastest route into the diff.

🤖 Generated with Claude Code

lucasmaan and others added 30 commits August 20, 2026 00:04
Records the design before the code, and splits build path A in two on the
strength of what the write path actually does. `compile.py` has three write
paths, not two: `merge→create` composes an article from scratch, while
`merge` and `merge-batch` run against existing article text through prompts
whose only actions are `append_to_section` and `new_section`. Carrying the
document date and one block per source lets the create path state the newer
version, and cannot help the merge paths at all — there is no action that
retracts. So the ordering signal and the replace primitive fix different
paths, and only the second can destroy correct content.

A1 is the signal half: an optional `date` on submit written as frontmatter,
the date read from raw frontmatter at write time, per-source blocks instead
of one flattened bag, and newest-first budget allocation so truncation drops
the oldest source rather than the newest. A2 adds the replace primitive and
is bought only if A1 fails the fixture positives. A1 therefore does not
satisfy D1 — its best output is latest-wins, with no superseded trail — and
the spec says so, so a clean A1 score is not mistaken for the trail landing.

Settles what design-options.md left open. The trigger is explicit
contradiction only: under A1 the writer sees extractions rather than raw
text, so a claim absent from a later source is indistinguishable from one
the summarizer dropped, and triggering on absence would turn every lossy
extraction into a false supersession. Dropped claims escalate to a report
instead — already shipped for revised documents, new for two documents whose
titles differ only by a version marker. D3 exposes the date on submit rather
than inferring it, because ingest time is not authorship time and backfilling
an older version would otherwise invert the order. D4 reads raw frontmatter
at write time, a deliberate exception to the extraction layer's write-phase
boundary, taken because a provenance field costs a `schema_version` bump and
that refuses every existing file. D5 keeps `write_prompt_version` reported
and never gated.

Also flags that the fixture cannot yet score any of this: 38 documents in one
compile routes each version chain into a single `merge→create` call, so it
measures the path A1 can fix and never exercises the merge paths. It needs one
compile per version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…depend on

Re-reading the spec against the code turned up four citations that do not
match what is there, three of which change what A1 has to touch.

The Background enumerated three write paths in compile.py and missed the
create-ops loop at :492/:495, which calls create_new_article while the
article file is absent and merge_into_article once it exists -- exactly
what a version chain hits when its members arrive as separate create ops.
The single-source merge pointer named :553, the branch head, rather than
:558, the call.

WP4 counted four call sites. Both writers take
(extraction: ExtractionResult, source_path: str), so changing that
signature reaches the three single-source callers too: compile.py:492,
:495 and :558. Seven sites move, not four.

WP1 named merge.py:95 as the only place the "- Source:" prefix is
rendered. _estimate_full_extraction_size at merge.py:72 spells it out a
second time, and BG3's truncation notice fires on the two disagreeing, so
both sites gain the "- Date:" line together. The single renderer does
serve both writers (merge.py:320, :596), so no write path is missed there.

RT6 cited design-options.md:57-60 for the shared-helper argument; the
passage is at :60-63.
A1 step 1 of the supersession spec: the write phase needs to know when each
source was written, and no ingest route recorded it. The submit handler now
writes a `date` into the document's YAML frontmatter, and the reader that
parses it back is shared rather than duplicated.

The two ends share no definition of the format, so the Go writer's rules are
pinned against the real PyYAML reader from both sides. The scan is textual and
answers "may this already be dated" rather than "is it": it errs towards yes,
so a block it cannot read is left alone instead of having the ingest clock
stamped over a date the document authored -- which is the corpus defect A1
exists to fix. Unreadable means flow syntax, a sequence, tab indentation, a
colon without the space YAML needs, a top-level value the reader refuses, a
`date` whose value is on the lines below it, or a character the reader breaks a
line on. An explicit caller date outranks the document's own, so it is written
even there, as a stacked block: inserting into frontmatter that does not parse
would cost the document every label it had.

`read_document_frontmatter` moves out of storage.index into _frontmatter so the
write phase can share it (RT6), and widens two guards a raw document can
actually trip: a date no calendar accepts, which took the whole document
catalog down for every document beside it, and a leading BOM, which made a
document read as having no frontmatter at all.

`ContentHash` and `FileTitle` are still computed over the content as submitted,
before anything is prepended -- hashing the written bytes would put the stamp
time inside the hash and leave the 409 duplicate path unreachable.

Verified by a round-trip differential over 68 shapes through both
implementations: the clock never replaces a date the reader can see, every
stamp reads back as a date, an explicit date always survives, and no input
line is lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A1 step 2 of the supersession spec. The write phase received one flattened
extraction and one comma-joined source string, so two things were unrecoverable
from its payload: which document made a given claim, and which document is the
later one. An article composed from a plan and its revision could state both.

`create_new_article` and `merge_into_article` now take `Sequence[SourceBlock]`,
one block per source, each carrying the day the document dates itself. All seven
call sites move together -- the create-op pair, merge->create, single merge and
merge-batch in compile.py, and both branches of the pipeline's write phase, which
build the payload once for whichever runs. `_combine_extractions` had no callers
left and is deleted with its test file; the enumeration guarantee it carried
(#41) is re-pinned as a block-rendering test, where per-source blocks keep it
structurally rather than by field priority alone.

Blocks come back oldest to newest, undated last, both orders path-derived so the
payload is reproducible across a 16-worker run whose raw-scan order is not.
Duplicates collapse on checksum (WP7), the survivor being first in path order;
the collapsed path is then absent from the article's `sources:`, which costs the
derived KB no content -- the bytes are identical to the survivor's -- only the
second path's name. The ops bookkeeping still reads every merge's rel rather than
the surviving blocks, or a collapsed duplicate would stay uncompiled and be
retried forever.

The date is read from raw frontmatter at write time (D4), a deliberate exception
to the extraction layer's write-phase boundary: the alternative costs a
`schema_version` bump, which refuses every existing extraction file. A document
that cannot be read degrades to undated rather than failing the write. Stamps are
narrowed to their day, because `datetime` is a subclass of `date` that refuses to
be compared with one, and the corpus holds both.

Budget allocation across blocks is interim and says so: every block is rendered
whole when they all fit, and only otherwise is the budget split evenly with the
unused share carried forward. BG1's newest-first priority replaces it in step 3.
The first version split unconditionally and truncated an 11-member enumeration
out of a payload with 10,560 characters of budget unspent.

Two of the three write paths already guaranteed in code that every source reaches
the article's `sources:` key. The full rewrite did not, and a source missing from
that key is a document `derive` refuses to copy, so its user message now renders
the same list. No `write_prompt_version` churn: user messages are outside that
hash by design, and the prompt file's singular wording is step 4's.

Verified by 26 mutations of the new rules. Three survivors were adjudicated, not
waved through: two are equivalent given the path pre-sort and a stable sort, and
the third does not overrun the budget because the separators are deducted before
any share is computed. Three real survivors were closed with tests -- both
bookkeeping paths, and the two single-source sites building blocks without
reading the date at all. Plus a both-routes parity test (VF6) comparing whole
blocks from the CLI and the worker in one process, over documents dated against
their path order.

Rebased onto #45, which landed the grounding constraint on the same three write
prompts. Its three new prompt-parity tests called the flattened signatures this
commit replaces, so they now pass one block each; the constraint they assert is
untouched. And `_sources` in the new grounding checker documented its
comma-splitting as covering the `", ".join(rels)` that `create_new_article` used
to be handed, which no longer happens -- the splitting stays, because the rewrite
path's frontmatter is model-authored and articles compiled before this change
carry that joined string, but the docstring now says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A1 step 3 of the supersession spec. Step 2 split the block budget evenly between
sources with the unused share carried forward, which is the wrong policy for the
thing this increment exists to fix: whether a claim has been superseded is a
question about what the latest document says, and an even split cuts the newest
source to fit the oldest.

Budget is now claimed in `_budget_priority`'s order -- dated blocks newest first,
then undated ones in path order -- while blocks still render oldest to newest
(WP5). A block is kept only if it fits whole in what is left, separator included,
and the first one that does not ends the walk, so what is dropped is whole blocks
from the bottom of the queue (BG2). A block cut to its header and a halved list is
worse than an absent one: the writer cannot tell a thin source from a truncated
one, and a partial enumeration is what it turns into a confident wrong list (#41,
#42). A budget too small for even the first block is spent truncating that one by
field priority rather than sending the article and no new information (BG4), and
every source that contributed nothing is named on stderr (BG3).

The queue is deliberately not the render order reversed. `build_source_blocks`
emits dated blocks then undated ones, so reversing hands the highest priority to
the sources that make no recency claim: measured on two 3,050-char blocks against
a 3,123 budget, the undated block survived and the 2021 document was dropped --
BG1 inverted on a guess, on a shape the spec provisions in three places (RT12
leaves the files route undated, WP8 makes an unparseable date undated, and a raw
file that cannot be read degrades to undated). The spec said it too, so BG1 now
states the queue, BG2 stops calling the dropped blocks "trailing" -- true only
while every block is dated -- and BG4 names the first block in that order rather
than "the newest". Sources sharing a day break to path order, the same direction
the undated ones take, and both keys are read off the blocks rather than inherited
from the order they arrive in: 160 of the reference KB's 395 multi-source articles
have two sources dated the same day, so that tiebreak decides real drops.

`_fit_block_to_budget` rejected a budget of exactly its header, which this step's
own arithmetic is what made reachable: an extraction whose eight priority fields
are all empty estimates at its header, so a block measured as fitting whole came
back without its `- Date:` line, silently, with the rest of the budget unspent.
WP6 reads an absent date as "makes no ordering claim", which is the opposite of
what that document says about itself. One character, and a test on the boundary.

A dropped source is still named under the article's `sources:`, now pinned rather
than only described. `derive` reads that key to decide which raw documents reach a
derived KB, so dropping the name would cost the document its only route into one.
The price is an article naming a source the writer was shown nothing from, and on
the reference corpus that is not rare: modelling a full compile through the real
budget arithmetic, 152 of 577 measurable articles (46.6% of the multi-source ones)
drop at least one whole block and 29 hit BG4. That is BG2 working as specified --
the relief is fewer sources per write op or a larger prompt budget, neither of
which is A1 -- but the figures belong on the record before FX4 scores anything.

Verified by 19 mutations of the new rules, 18 caught. The survivor -- clamping a
whole-fitting block to the budget minus its separator -- is equivalent because
that line is only reached when the block already fits, which is a claim the
header-sized-budget fix above is what makes true: 4,000 random block shapes at six
budgets from the estimate upward, 0 that did not render whole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Step 2 gave the payload one dated block per source, oldest to newest with the
undated last, and step 3 made the newest one survive a tight budget. Nothing said
so to the model. A sequence whose meaning is never stated is one it may read
either way, and the undated tail is the trap: those blocks sort last for
reproducibility, not recency, so an unexplained order invites reading the sources
that make no recency claim as the newest material (spec WP6, S5, Q2).

_SOURCE_ORDER states the direction, what a `- Date:` line dates, and that an
undated block's position carries no ordering claim. In the system prompt because
that is where an instruction is applied reliably, and a code constant appended to
all three write-stage prompts for the reasons _GROUNDING already carries: one of
the three is built in code, and a fourth prompt file would fail an operator's
existing KAAS_PROMPTS_DIR on the first write after upgrade.

It states facts and asks for nothing. A1 carries the ordering signal and adds no
action: the merge paths cannot retract, and the rewrite path must not start, since
it returns a whole article and is the one place A1 could destroy correct content
(NG1, G4). What to *do* about a contradiction is A2's replace primitive, and
saying it here would also spend the fixture arm that exists to decide whether A2
is needed at all (FX7).

Two approximations in what the statement asserts are recorded rather than closed:
BG2 drops whole blocks the `Sources:` list still names, and the bare-source-line
branch can emit a dated block's header without its date -- which now reads as a
claim rather than as missing text.

Editing every write-stage system prompt moves write_prompt_version, so the first
report after this names every article (PV1). It gates nothing per D5, and the
argument for keeping it ungated is unchanged: an article that exists is
re-composed through the merge paths, which are still the additive ones. Both
copies of that reasoning are updated -- write_prompt_version's docstring (PV2) and
storage/lag.py's, which ended on the same pre-A1 sentence.

Also corrects the singular-source wording both writers inherited from the
flattened payload. merge-rewrite.md asked for "source" where the payload now lists
N of them, and the create prompt's frontmatter template shows one `sources:` item;
neither path repairs that list in code, so a source missing from it is a document
derive will not copy into a derived KB.

py 1661 passed, core/merge.py 100%.
Shape A is already reported: a re-fetched document is marked revised and its
articles named. Shape B -- v1 and v2 ingested as two documents, which no `id`
connects and only the title betrays -- was not reported at all, so an article
holding both versions looked like an article holding one document twice.

The rule is the one test-set.md validated on 996 documents: same title after a
trailing version marker is stripped, same `source`, different `id`. Its two
exclusions come from that corpus rather than from taste. Cross-source collisions
are excluded by making `source` part of the grouping key, so a design document and
the recording of the meeting about it are not paired while two recordings of one
meeting still are. A title that is a person's name groups with nothing, because a
recurring one-to-one is titled that way; the names come from the KB's person pages
and from the configured allowlist, since the pages are stubs a later phase writes
and a first compile has none.

RP3's stated trigger is dropped, and the spec now says why. "The earlier member
asserts something the later one does not" selects nothing: over the corpus's stored
extractions a claim-text comparison fires on 35 of the 35 checkable pairs at every
threshold from 0.55 up, and the similarity distributions do not separate real
version pairs from recurring meeting series (median 0.47 against 0.36). Claims get
restated in new words between versions, so absence is the normal case. The cheap
proxy fails the other way: "the later member asserts fewer claims" holds for 14 of
35 and excludes P6, the one adjudicated success, whose claim count grows. An LLM
judge would settle it and RP5 forbids one, so the report names the group and leaves
the reading to a human.

A recurring series satisfies any title rule -- 37 of the 41 (article, group) pairs
on the corpus are one, against 4 real chains -- so the version marker is reported
as a triage key and marked groups are listed first. It is not a filter: P7, P8 and
P9 are positives whose two versions share a title verbatim.

Nothing here reaches a prompt (RP5), and that is structural rather than reviewed:
the rule lives in storage/, core/merge.py is asserted over its imports not to reach
it, the writer's entry points take source blocks and nothing else, and the report
is built after the last write op. "Share an article" spans this run's ops and the
paths already under the article's `sources:`, because the staged fixture compiles
version N into the wiki version N-1 produced, and a report built from one run would
be silent on exactly the merge paths A1 is measured on.

Moves the date narrowing out of core/merge.py into the shared frontmatter reader.
The report orders a group's members by the same dates the writer's blocks use, and
two narrowings that disagreed would order them opposite ways.

Also corrects what the corpus actually says. Reconstructing the rule showed the
throwaway script that measured it missed a three-part `v1.0.0` marker and compared
titles case-sensitively, so shape B has 42 matching titles rather than 40, leaving
38 groups after the cross-source rule and 37 after the person rule. And the
migrated extractions are schema_version 1 where the loader now reads 2, so FX3's
label drafting needs them re-serialized first -- free, but not free of work.

py 1701 passed, storage/lineage.py 100%, commands/compile.py 99%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rsion

FX1 asked for the /tmp/supersession/ scripts with tests. Two of the three were
one-shot migrations that already ran against a gitignored KB with absolute paths
baked in, and have since been deleted; porting them would mean owning tests for
code that cannot run again. What lands is the two that do run again: select_cases.py
finds the lineage chains and stratifies them, stage_fixture.py builds FX2's stages.
test-set.md's Regenerating section says the conversion is not reproducible instead of
pointing at /tmp paths that will vanish.

The selector had to be rebuilt rather than moved. The script that measured the
corpus is gone and only cases.json survived, so each rule was reconstructed against
that file: all 79 non-identical chains' diffstats and all 134 strata now reproduce
exactly. Getting there recovered four things the numbers in test-set.md depended on
and did not state. The diff runs over the body, never the frontmatter -- every
version differs in date, id and checksum, which is three changed lines between two
files whose prose is identical, enough to move a group out of the noise stratum. The
line counts include the break that ended the closing delimiter, because the original
split on the delimiter rather than on lines. The append-only test runs before the
similarity test, since a large enough append drives similarity below 0.55 on its own
and testing similarity first put two negative controls in the positive set. And
`sources:` entries holding a comma-joined batch are split apart, which is what the
shared-article column had been undercounting: 43 duplicate-stratum groups against
the 34 recorded, 6 append-only against 4, on a corpus where 46 articles carry such
an entry.

Shape B goes through storage.lineage, exclusions included, so the selector reports
131 chains where the old file has 134: it drops three cross-source pairs and a set
of one-to-ones, and adds the v1.0.0 chain the original's marker missed. Corrects the
strata table, and 101 of 675 articles at 15% becomes 123 of 682 at 18%.

Staging is what puts the merge paths under test. One version per stage, materialised
into the KB the previous stage wrote, so from stage 2 onward every write is a merge
into an article an earlier version composed -- on the fixture that plans four stages
of 18, 18, 1 and 1 documents, the last two being P4's four-version chain. The script
prints the compiles rather than running them: a pass costs about 10 USD and each arm
pays it again, so spending stays the operator's decision. FX4's baseline arm needs
the pre-A1 code, which is no longer checked out, and both the script and the spec now
say it runs from a worktree at bd8252e.

Verified without spending: the selector finds exactly 18 chains in the fixture, which
is its 10 positives, 4 negative controls and 4 duplicate controls; two staged
materialisations then pass `kb-ai check` with 36 match, 0 missing, 0 mismatched.

py 1740 passed, scripts/ 99% and 98%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ontradiction and drop

The test set's single `superseded` list conflated two failures that A1 is not
equally responsible for. Q1 scopes A1's trigger to explicit contradiction and
sends dropped claims to the RP1-RP3 report, so a single list would have judged A1
against work it does not contain.

Split it into `superseded-contradiction`, which gates FX7, and
`superseded-drop`, which is measured on the same runs and gates nothing. Draft
the labels for P2-P5 and P7-P10 in labels.md: 58 contradictions, 36 drops, 48
controls, of which 38 contradictions are still stated as current today.

Two consequences are recorded rather than buried. P1, the one adjudicated
failure, is a drop case and no longer gates the work it motivated. And the
fixture needs no re-serialization: the schema check lives inside `parse`, which
only `extraction.load` calls.
Both blocking rulings from FX3's label drafting are settled, so the labels are
down to item-by-item confirmation.

P2 is withdrawn from the positives and kept as the documented wrong-date
counter-case. Its file dated 2026-04-14 is a rolling document carrying a
`# 2026-05-04` body section and an `extracted_at` three weeks after the file it
supposedly precedes, so the chain runs backwards relative to its content. Scoring
it would credit A1 for asserting an order that is wrong -- the direction that
flatters the work. The drafted label is retained verbatim, in the fixture's
direction, along with the inversion to apply if the case is revived, because P2 is
the corpus's only inverted chain.

P7 keeps its 8 contradictions: the measurement-time reading is accepted, decided
on the article's own behaviour rather than on a preference between two defensible
readings, since the article flattens the weekly table into undated current-state
prose and that is exactly where v1's figures become wrong.

The scoring set is therefore seven drafted cases plus P6, carrying 50
contradictions, 31 drops and 42 controls, with 31 contradictions stated as current
today.

Records the design gap P2 exposed as an A2 question rather than leaving it in the
label file: a frontmatter date can be wrong and not merely missing, which is a case
Q2 and D3 did not provision for, and A1 has no way to notice because the writer is
told a date and never the text it came from.
Subtracting P2 left the drop count at 31 where the seven remaining cases carry 30
(5+4+5+0+6+5+5). The contradiction, control and staleness totals were right.
…cation

Objective corrections only; the disputes those audits raised are recorded
separately once every case has reported.

P3: D5's quote is at v1 L1113, not L1114, which is a closing tag. K3's v2 cite
moves to L97/L109, because L28 asserts source-to-structured-docs without the AI
authorship the control is about. D1 is narrowed to the repository count -- v2
restates the multi-team half at L20, so the original compound row was half not a
drop -- and its stray fifth cell is removed so the row renders.

P8: the relocated-list cites for MCP and AI Coding adoption are v2 L291 and L311,
eight lines off as drafted. The intro said four rows move to `架构` when two move
to `架构 / api 团队` and `架构 / 效能`, so it now says they replace named
individuals with team names. And the drop table claimed all six drops are stated as
current when D3's headcount is absent from the article -- only its 25%/two-month
clause survives.
K2's v2 range is L53-62; L54-63 as drafted started one line late and omitted the
`Fee Conversion` label cell. C5's replacement said "5 capital-protected Earn
products" when v2's own table at L333-384 spans Earn, Margin Staked SOL and Spot X,
so it overstated the narrowing -- v1's spot leg is partly retained and only RWA and
the horizontal-comparison framing are genuinely gone. Neither change touches a
classification or an article verdict.
P7's verification found that the article's fifth source, the 04-14 infra biweekly
that is also P2's v1, asserts v1's entire 0430 column verbatim. So 7 of P7's 8
staleness observations have a second possible cause, and its headline was wrong on
both halves: C4's and C6's replacements are in the article, so 5 of 8 corrections
were lost rather than 8 of 8.

Generalised into a note, because P10's judgement call 5 had already caught the same
shape for one case. Only P3 and P8 compile from their chain alone; P5's article
merges 16 sources, which is the strongest reason not to read its 0-of-5 pass as
evidence about the pipeline. The bound matters as much as the finding: FX4 already
re-runs the compile over the staged fixture rather than trusting the existing wiki,
and only P5 and P7 carry a co-source into that run. So the 62% figure is motivation,
not measurement, and FX5 needs staleness defined against the newest source in the
compile set.

Also corrects C2's article evidence (L57 sits under a `Completed (Apr 30)` header,
so the row rests on L480 alone), C3's (v1's 13.3% is absent, not present -- the
article recomputes ~27-34%), and C6's (L192 is explicitly `as of April 30` and
carries the replacement scope in the same sentence).
…ruling queue

All 128 drafted items across the seven scoring cases were re-derived from the
sources in fresh contexts, one per case, with no access to the drafting reasoning.
Result: 111 verified, 4 line-corrected, 13 disputed, 0 unverifiable. The quoting
held up well -- only four cites had drifted and no quote was invented -- and the
findings concentrate in classification and in the `Article today` column, which is
the one that scores.

labels.md gains a verification section with two tables: 15 objective corrections
already applied in place, and a queue of 17 items needing a Captain ruling, ordered
by how much each moves the measurement and each carrying its own location, finding,
recommendation and consequence. Three would move the totals -- P8's four
contradictions collapsing as de-specifications rather than contradictions, P9-C1
being preserved by v2 rather than superseded, and P10-C6 comparing a mean against a
median -- taking the set to 44 contradictions with 27 stale and six gating cases
instead of seven.

test-set.md and spec.md now point at that queue instead of implying 122 unchecked
rows, and FX3 records that FX5 needs staleness defined against the newest source in
the compile set.
系統户 for 系统户; the traditional form appears nowhere in the fixture.
The P10 verifier's full report flagged that C5's delta is largely a basis artifact,
and I confirmed it by comparing all 24 monthly values: v2 L378 declares its median
series >120-filtered, but its twelve monthly medians (L51-62) are identical in all
twelve months to the full report's UNFILTERED column (L89-100), not its filtered one.
So C5 compares v1's <=90-filtered 13.8/17.8 against v2's unfiltered 15.3/19.8.

The row survives -- v2 declares the same span and statistic, so it contradicts on its
face -- but it must stop being cited as evidence of real slowdown, and it strengthens
the case that FX5 has to state measurement basis. Queue is now 18 items, 13 of them
disputes; the rest are corrections and one spec change. Also records A16, a quote
boundary in P10-C4.
…t finding

The P4 verifier's full report surfaced one thing I had not captured: v1 asserts both
the monthly fund-flow table and its replacement, two lines apart (L1761
uta_liq_trans_log_202605, L1763 translog_realtime). Confirmed independently -- the
measured token is clean, 1 hit in v1 and 0 in v2-v4, but translog_realtime already
has 2 hits in v1. L1763 is also the line control K4 scores on. Queued as V18.

That makes four instances of one shape, so it is now a note rather than a per-case
remark: P4-C3 in v1, P4-C8 and P4-C7 in v4, and P2's rolling file asserting both
100% and 90%. Only C7's was documented when drafted.

Every contradiction row is phrased 'the earlier version asserts X, the later asserts
incompatible Y', and that is stronger than these documents support. The practical
consequence is for FX5: an extractor reading the later version alone can legitimately
emit the older claim, because the later version still makes it. P4-C8 is the sharp
case -- grading the article stale would fail a pipeline for repeating what v4 itself
says in five places. So a stale verdict needs the later version to be unambiguous,
not merely to contain the correction somewhere.
…ass did not cover

The 128-item pass covered only the seven drafted positives. The controls and the
adjudicated cases were never checked, and the controls are what protects A1 from being
wrongly FAILED: if a chain labelled purely additive actually contains a contradiction,
A1 reporting it correctly scores as a false positive. All eight hold.

N1's only non-carried body line is its own H1 (a retitle, not a contradicted claim), N2
is an append-at-top rolling log whose shared 0428 section is unedited apart from
extraction artifacts, N3 and N4 differ by one blank line. U1-U4 all confirmed: bodies
byte-identical after stripping frontmatter and checksum fields matching exactly, with
only date differing; U1 spans a six-week gap.

P1 verified exactly as written -- v2 has 0 hits for both 现有情况 and 140, and the
article has precisely four occurrences at L39, L44, L241 and L702 with both quoted
strings verbatim. Notably P1 is NOT confounded: none of its nine co-sources asserts the
~140 figure, the only other hit being a substring of a numeric id. So the motivating
case's evidence is cleaner on that axis than the drafted positives'.

Two new findings. N2 carries P2's wrong-date pathology -- a file dated 04-17 holding
0422 and 0428 sections -- making it a second named fixture instance, though it does not
break the control because the pair still orders correctly. And corpus-wide, 91 sources
entries comma-pack multiple paths into one YAML item while 22 articles duplicate a
source path; recorded against FX4 as a check for both arms, since G2 is the claim that a
source is an attributable unit. The seven scoring cases contain none, so the co-source
table stands.
P6 is the only adjudicated success in the set, and its explanation is load-bearing for the
build decision: if the failures cluster on documents lacking an internal version marker,
path A is the whole fix and paths B and C are over-buying. It holds as written. Both
framing quotes are where the doc says, the article leads with the v1.7 framing and names
v1.5-v1.7, and the superseded framing is absent in both languages -- 0 hits for 重量级 and
0 for `heavyweight`, which matters because grepping Chinese against an English article
would otherwise have passed vacuously.

Two precision fixes: the v2 quote drops the source's own bold markers, and the scope
widening has a cleaner second location the adjudication never cited (the 定位 line, where
v2 adds 整合 bgw / bgwg / bgwtp / LiteLLM). Also checked the one thing that could have read
as stale -- the article repeats BGW's problem list, but v1.7 keeps that six-row table under
a new per-gateway subheading, so v1.7 subordinates v1.5's framing rather than retracting
its detail.

P6 is not confounded, like P1: three sources rather than the chain's two, and the third
asserts none of the framing and carries no version or date marker at all. It is not staged
in the fixture, so FX4's re-compile sees two of three sources and cannot reproduce §14 --
score P6 on the framing, not on completeness.

The finding lands on the design lesson rather than on the case. P6's v1 contradicts itself
about its own version: title and H1 say v1.5, the body marker says v1.6. The accidental
ordering signal the success is credited to is present in both documents but self-inconsistent
in the earlier one, so a reader taking the title sees v1.5->v1.7 where a reader taking the
marker sees v1.6->v1.7. The case stands, and the finding argues for path A rather than
against it. Recorded as a fifth instance of within-version ambiguity, the first about a
version rather than a claim.

Verification coverage is now complete: every case, control and adjudicated case has been
re-derived from the sources. Also fixed a dead anchor left when the section was renamed to
three adjudicated cases.
…rulings

V16 asked whether the compile read a source P5's article does not declare, or
whether the article's source list is incomplete. No artifact answers it: the
compile state records no target article, no classify cache survives, and the
list itself is model-written on the rewrite path. Resolved on content instead.
None of the 68 lines the hardening plan alone asserts reaches the article, and
two residues at issue are single corpus hits in v1 -- including D5's own dropped
clause -- so D2-D5 score as lost drops and the fixture has no provenance gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rontmatter

Re-measuring the source-list defect for V16 found a worse one beside it: seven of
the 682 articles do not open with a frontmatter delimiter, because the writer's
preamble prose stands above it, so no key reaches any reader -- no title, no date
for WP2's signal, and no sources for derive to copy, which fails G2 outright
rather than mis-shaping it. Nothing catches it today: check counts documents
against extractions and skipped all 682 on the schema-version gate.

Also withdraws the 22 duplicated-path articles figure, which reproduces under no
reading tried, for 21 literal and 30 after splitting comma-packed items, with the
definitions attached. The 91 comma-packed entries reproduces exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ions

Captain accepted V1, so P8 contributes no contradictions and leaves the gating
set: six drafted cases plus P6, totals 46/30/42 with 29 stale. Two arguments the
queue row did not have decided it. v2 asserts the mapping the rows read as a
substitution -- it writes Lucas (架构) and hangs @lucas.wan on the whole
infrastructure heading -- and the article writes both sides anyway, so under
either staleness rule it is not asserting a superseded value. v2 also keeps
person names in its own team column and still names Victor / Lucas elsewhere.

The rows stay as a re-attribution list with their evidence and their IDs retired,
because they are the set's clearest instance of a change that reads like
supersession and is not one. P8 keeps its 6 drops and 6 controls, which is worth
having: it is the one case with no accidental ordering cue at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Captain accepted V2. v2 keeps both halves of v1's two-part end state, at L175-178
and L184, and L184 is already the line control K1 is scored on -- so the label set
had one passage both preserved and superseded. Checked at ruling time that the
stale verdict was wrong on the facts too: the article's two-component framing is
what v2 asserts as well.

The substance goes into K1's evidence rather than a new control, since K1 already
covers the second half, and the three-pillar restructure joins call 7's additive
list as a taxonomy on another axis. Totals 45/30/42 with 28 stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…diction

C6 paired v1's mean development duration against v2's median 研发周期, which v2
defines as development plus test: different statistic, different span. Two checks
at ruling time closed the alternatives. v2 does carry a same-scope development
duration as a median (1-3 to 4-7 days), compatible with v1's mean and predicted by
v2's own note that the mean runs high on a long tail. And v1 reports that duration
only as a mean, so there is no same-basis pair to re-cut the row onto.

Recorded as drop D6 and as a lost drop, since the article still carries the mean
framing. V12 lands in the same item: v2 does report means elsewhere, so that claim
is now restricted to development duration. Totals 44/31/42 with 27 stale, which is
the arithmetic verification predicted for V1 through V3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five of the nineteen items are settled -- V1, V2, V3 and V12 ruled, V16 resolved by
investigation -- so the queue heading and preamble now say 14 open rather than 18,
and name which five of those still move a scored number. Also corrects the
staleness rate to 61%, which is what 27 of 44 reads after V3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…urces

V19 asked for staleness to be restated against "the newest source in the
compile set". Ruled accepted, tightened to the newest source *that speaks to
the item*: read per document the criterion clears an item whenever the latest
source is merely silent on it, which is the common case.

Ruling it established two things the item did not claim.

Its own rationale does not hold. V19 invokes V4, but the wording only clears an
observation where a co-source is newer than the chain head, and in the fixture
none is except P5's -- P7's is 2026-04-14 against a chain head of 2026-05-14,
P10's 2026-03-05 against 2026-03-06. So it answers V4 in no case, and the
co-source note no longer implies a definition can repair that confound.

P5 cannot be scored on the column at all yet, which is now V20. Both of its
versions carry date: 2026-03-13; build_source_blocks breaks the tie on the path
while _SOURCE_ORDER states "blocks run oldest to newest" caveated for undated
blocks only, and since "-" sorts before "." the payload renders the v3 file
first and thereby asserts that v1 is the newest source. The article carries
v3's figures, so against the stated order P5 reads 5 of 5 stale rather than
0 of 5. It is held out of the gating count until V20 is ruled, and V20 is
recorded as WP9 because the fix belongs in the payload, not in the scoring.

Scope of V20, measured: 159 of 397 multi-source articles in the reference KB
carry two or more same-day sources (411 pairs), of which only 8 have a
version-suffix shape -- so the tie-break is mostly uninformative rather than
inverted. Three cases render a source after their own chain head, but only
P5's is a chain member; the same-day co-sources P7 and P10 render last assert
none of the superseded values.

No total moved: 44 contradictions, 31 drops, 42 controls, 27 stale.
… the score

V4 found that P7's article merges the 04-14 infra biweekly (P2's v1), which
asserts v1's entire 0430 column, and recommended scoring only C8. The finding is
accepted and verified exactly; the scoring restriction is rejected.

Premise verified with fixed-string counts and the lines read in context: L898
40.95/24.57, L904 13.3, L911 3000C, L916 合同已提交, L919 117.3, L921 3300/83.1,
L925 7197, L928 17054 -- one hit each except 7197 with two.

The restriction was rejected because it attributes staleness to a document pair,
while supersession is a property of the compile set. The set holds 04-14
asserting the old value and v2 asserting otherwise, so a writer that emits it
undated mishandled ordering whichever document supplied it -- and 04-14 is itself
superseded inside the fixture by P2's v2, so A1 orders it too. Under V19's ruled
criterion every stale row holds, the newest source speaking to each being v2. For
FX7 the confound cancels, the co-source sitting in both arms. So P7 keeps all 8
rows at 6 of 8 stale, and only the causal claim narrows to C8.

Two repairs came out of ruling it. C8's test is now the full phrase
识别—分析—跟踪—复盘, which is 0 hits in all three non-chain sources, because the bare
token 闭环 has 7 hits in the 双周会 alone on a different loop -- the same
string-matching hazard already recorded on C2. And C6 gains a residual: the newest
source in the set restates 3000C at 05-20 L374, six days after v2 reported that
gap closed to 7C, with the scope unstated. That is the one row where the newest
source speaking may assert the superseded value.

Also recorded: 3,864 carries a comma in the 05-14 meeting, so a scorer searching
3864 misses it, and the corrections absent from every co-source are C1's 95.18,
C2's 44.4/41.13, C3's 41.5/8.3, C5's 25046, C6's 7C and C7's ES outcome.

No total moved: 44 contradictions, 31 drops, 42 controls, 27 stale.
…rong

V5 found that v4 still names 系统户 as the settling counterparty in five places,
none in a 待决策 block, and offered either an open call or dropping the row. The
residual is recorded and the row stands.

It survives because the replacement is the cleanest in the chain: v2 L5447-5453
and v3 L5697-5703 are the same table row replaced cell for cell -- subject
交易系统户 to 交易系统内的差额账户, role 作为事故用户的对手方 in both, and detail 1
from 资金都和系统户结算,允许透支 to 资金都和差额账户结算,允许透支.

The five residual mentions are weaker than C7's rather than stronger, because they
describe a different level than the counterparty role the row scores: three are
transType accounting directions in §3.3 engine (which ledger account moves per
transType), one is hedged with 可以考虑, and one is a worked example that three
lines later says 差额公司出, sitting under an open question highlighted
light-yellow. Scored on the table row, the basis call 2 already uses for C7, and
written up as call 8.

Two line-level corrections came out of ruling it.

The cited pair is v2 to v3, not v2 to v4. Term counts across the chain are
0/0 at v1, 7/0 at v2, 5/2 at v3 and 5/2 at v4 for 系统户 and 差额账户, and v4 only
shifts the two lines by one. This confirms V15's "rewrite X1 as v2->v3" from a
second direction, so V15's row and X1's cites are updated too.

And 允许透支 is not part of the change: it already stands in v2's cell, so the row
wrongly implied overdraft arrived with the replacement. X1's entry had this right,
which is internal corroboration.

No total moved: P4 stays 9 of 10 stale, and the set is 44 contradictions, 31
drops, 42 controls, 27 stale.
V20 asked what to do about two source blocks that share a date: WP5 breaks the tie
on the path and WP6 then tells the writer that blocks run oldest to newest without
qualification, so the pair gets a positive ordering claim resting on a filename.
The fix is to withdraw the claim rather than state a better one. WP6's undated
caveat now covers same-day blocks, they keep rendering in path order for
reproducibility, and BG1 restates its own basis as newest known day first with ties
broken on path for stability.

Both candidate tie-breakers were rejected on measurement. Of 384 same-day pairs on
the reference KB a filename version marker appears in 9, names a document revision
in 2, and survives inspection in 1 -- the other of the two, the testnet report
marked v100, carries the tested skill's version 1.0.0 against a sibling reporting
3.1.0. A body-stated date is present in both members of 9 pairs, unambiguous and
differing in 5, a revision pair in 1. A rule in the system prompt applies to every
payload, so a signal worth 1 pair in 384 cannot be it. Reading body dates is also
A2's deferred question and the corpus does not settle it: of the 10 same-day
sources whose body date contradicts their frontmatter, 7 point earlier and 2 later.

Ruling it reversed the item's own consequence. P5 does not become gate-scoreable,
because withdrawing the claim leaves its chain unordered rather than ordered, so
its 0 of 5 would be luck instead of evidence. P5 stays out of the gating column for
good, and the reason underneath is new: v3's body dates itself 2026-03-19 against a
frontmatter date of 2026-03-13, six days apart, so the shared date is an error in
the metadata rather than a property of the corpus. That is raised as V21.

Two numbers were corrected on the way. The population figure recorded for this item
counted byte-identical duplicates as two blocks, which WP7 collapses into one, so
the basis is 156 articles and 384 pairs rather than 159 and 411. And BG1 needs no
change of tie-break direction: it already breaks path-ascending exactly as the
render does, so the conflict was never between two orders, only between WP6 calling
a block older while BG1 called it newer.

P5's five contradictions stay in the set totals while P5 is not judged on the
staleness column, so the stale rate now has two readings -- 27 of 44 (61%) over all
cases carrying contradictions, 27 of 39 (69%) over the five that gate. V21 picks
the published one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lucasmaan and others added 28 commits August 20, 2026 00:06
…atus cell is not the status

Seventh case through the confirm pass, and the one that could be measured end to
end instead of argued. Parsing both files into endpoint rows gives 278 rows in v1
and 299 in v3, 273 shared, with name and method identical in every one -- the
case's central claim turned into a count. Two measurements under it were wrong:
the status cell differs on 22 of the 273, always by the removal glyph v3 drops, so
a field-equality test reads 22 changed rows where a decision test reads none; and
the warning-glyph rise is +6, not +8, eight added in the new modules against two
lost with the leverage tokens. The 280-against-278 basis reconciles on two
untabled WebSocket endpoints both documents count.

The case gains a notation finding. The removal glyph is 24 rows in v1 and 0 in v3,
v1 is the only one of the article's 16 declared sources that marks removals with
it, and it survives into the article -- while in the other direction v3's own
rewritten P2P wording reaches two article lines. So the compiled output took its
notation from the superseded version and all five figure pairs from v3. The glyph
asserts no proposition, so call 4 still keeps it out of the score.

Row-level: four of the five contradictions had their article side cited one line
short, two of the six controls sit on cells v3 abridged so a byte test reads
over-deletion while the control holds, both of those dropped clauses reach the
article on the control's own line, K1 holds on content rather than bytes, and D6's
two arms cannot be cited at one grain. Five of the 17 abridgements are not the
trailing-clause deletions the blurb claimed, one of them a rewrite. No verdict
moved: P5 holds at 5C / 6D / 6K, 0 of 5 stale, totals unchanged at 44/32/42 with
27 of 39 stale. All five calls answered, so P5 leaves nothing with Captain.

106 of 118 rows settled; P8's 12 are what is left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ference set is fully labelled

Last case through the confirm pass, and the only one it could close completely.
71 of 88 table rows are byte-identical, and the 17 that are not decompose into two
table headers, the four owner rows V1 struck plus one scope expansion, the four
rows of the deleted resource-constraint table, the three deleted milestone rows,
and three items the calls had already set aside. Nothing in this pair is
unlabelled -- the sweep that found five unrestated propositions in P9 and six in
P10 finds none here -- so P8's drop arm is complete at 6 rather than capped at 6.

The 212-line figure is reproducible once its basis is named: diff reports 155
changed lines on v1's side and 65 on v2's, 8 of them frontmatter. Two tighter
figures sit beside it, because 212 counts reserialisation: 40 of v1's 1,665
non-blank body lines have no counterpart in v2, 22 of v2's are new, and all eight
surviving table tags were rewritten with header-row and column widths.

Two evidentiary points are stronger than drafted. The article declares only the
two chain versions, so with P3 this is one of the only two cases free of a
co-source confound. And v2 deletes the only two in-body dates later than v1's own
frontmatter -- they are D4 and D5 -- so the newer body is dated less than the
older one, which pushes a body-reading ordering heuristic the wrong way.

Call 3 is escalated and left with Captain: V1 ruled a de-specification is not a
contradiction and said nothing about drops, and V8's test, the one that promoted
P5-D6, is satisfied by v1's week-level MCP commitment becoming a bare quarter. On
that reading it is a seventh drop with three article residues and the published
drop total moves 32 to 33. An undeclared same-day sibling document asserts the
same schedule, which is the V16 shape that kept P5's residues with the chain.

Row-level: K3's cite was one cell of three and the whole summary table turns out
to be a control, though the article never states its category split; K4 holds on
content rather than bytes; D1's residue is three lines and loses its scope clause;
D2 shares one of them; D6's second residue was uncited; and one absence test had
to be re-based off a string with a hit in v2. Calls 1 and 5 are corrected on their
own evidence. No verdict moved: P8 holds at 0C / 6D / 6K, totals unchanged at
44/32/42 with 27 of 39 stale.

All 118 scoring rows are now settled. What "done" still needs is test-set.md's
Status block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…h defect was never one

The confirm pass finished on the rows in the previous commit; this is the last of
the three conditions "done" required. test-set.md's Status block now reads
confirmed rather than awaiting-confirmation, its Label column carries each case's
pass result and its open calls, labels.md records all three conditions met, and
spec.md's FX3 criterion carries the closing line -- with the historical blocker-2
paragraph annotated rather than rewritten, since it is quoted there as the
reasoning A1's criteria were written from.

One deferred item dissolves on inspection. The note carried since P7's pass said
test-set.md's article paths use plural wiki directories where the corpus is
singular, wrong on all ten rows. The section's own preamble already says the paths
are given as they stand in ~/.knowledge and that corpus copies sit under the
singular names, and both trees check out: ~/.knowledge/wiki is plural, the corpus
is singular, and all fourteen paths resolve as written. The caveat now records the
re-check instead of the table being rewritten.

Aggregate, cross-checked by counting status cells rather than by adding up the
commit messages: 118 scoring rows, 64 confirmed and 54 amended, none rejected,
plus P2's 18 counter-case rows. No pass moved a total, so the set still publishes
44 contradictions, 32 drops and 42 controls with 27 of 39 stale. Five judgement
calls stay open with Captain -- P3 call 3, P8 call 3, P9 calls 3 and 9, P10 call 6
-- each a promotion whose other branch moves one of those numbers up.

FX5 can now score against these labels, which unblocks the FX4 baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FX4 asks both arms to check two defects nothing in the pipeline catches, and a
note in the spec is not a check. This is the script.

Comma-packed and duplicated `sources` entries are the first: G2's claim is that a
source is an attributable unit, and an entry holding `raw/a.md, raw/b.md` is not
one, while a path listed twice is the double-count the U1-U4 controls exist to
catch. Frontmatter that does not start at byte 0 is the sharper one, because for
those articles every key is invisible to every reader -- `split_frontmatter`
returns None for content that does not open with a delimiter line, so there is no
title for the catalog, no date for WP2 and no sources for `derive` to copy.

The walk goes over the filesystem rather than `existing_articles()` on purpose:
the index is built from the very frontmatter the second defect hides, so an
article with an unreachable block is invisible to every reader that goes through
it. A BOM is reported apart from those articles rather than with them, since every
reader here strips it and no key is lost.

On `data/kb-knowledge` it reproduces the spec's figures exactly -- 682 articles,
91 packed entries in 46, 47 duplicated paths in 30, 7 unreachable -- which is what
qualifies it to be believed on a fresh run. It adds one datum the spec did not
have: 8 articles carry readable frontmatter with no `sources` key at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The confirm pass settled 118 rows and moved no total, leaving five judgement
calls open because each had one branch that moves a published figure. All five
are ruled, and ten rows arrive with them: one contradiction and nine drops.

V23 promotes P3's knowledge-repo naming row to C9 -- both sides carry exclusive
strings, which is what P4-R3 was found to lack, and only one of its three article
residues falls under the one-defect-counted-once precedent. It is the only ruling
that touches the gate: 44C to 45C, and the headline 27 of 39 to 28 of 40 (70%).

V24 promotes P8's `Q2 W6` to `Q2` de-specification to drop D7, not to a
contradiction, which V1 already closed. V9's exclusion does not fire because the
sibling carrying the same string is undeclared -- the V16 shape, which leaves the
residue with the chain.

V25 keeps P9-C6 and re-grounds it: read on the tier table being exhaustive it
collides with P3 call 4, so it is ruled on v2's own re-terming of the same
capability instead. Nothing moves; what changes is what the row is tested on.

V26 takes three of P9's five unrestated propositions as D6-D8, and V27 five of
P10's six as D7-D11. Both hold back the item that would gate on an absence -- P9's
answer-card componentisation, which v2 answers with A2UI, and P10's ToB
volatility, declined on V3's and V17's different-statistics ground -- and both
leave a dimension as a note rather than a row.

Two principles decided the branches, and they are recorded as such: V8's standard
is applied uniformly, so "the diff did not show it" is no longer a reason a
proposition stays unlabelled; and the gate may only grow on affirmative evidence,
never on an absence.

Also here, from starting the FX4 baseline arm: the two article-shape checks are a
script now, both arms re-extract because the cached extractions predate the schema
the code reads, and the cost estimate's "one merge op per document" is not an upper
bound -- 18 documents fan out into 26 articles, so an arm lands near 14-15 USD.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the prediction was wrong

FX4 predicted that comma-packed and unreachable-frontmatter `sources` were old-writer
residue a fresh run would not reproduce. The finished pre-A1 baseline arm reproduces
both, at rates no better than the corpus they were found in: 2/27 (7.4%) comma-packed
against 46/682 (6.7%), and 1/27 (3.7%) unreachable against 7/682 (1.0%). Small
denominators, so the sign is the finding.

The mechanism implicates both arms. `_apply_diff` appends one list item per path and
cannot pack; the full-rewrite path has the model re-emit the article's frontmatter, and
every packed entry sits in an article that took a merge-batch write. Step 2's rendered
`sources:` list tells the model what to emit without constraining it. So the A1 arm gets
audited on the same checks rather than assumed clean, and serializing `sources` in code
is a fix outside A1.

FX5 gains three scoring rules the arm exposed:

- Cases resolve to an arm's article by its `sources` set, not by the name test-set.md
  cites. N2 landed as zero-trust-security-initiative.md, not the platform slug in the
  table; classification picks the slug, so the arms can diverge from each other too.
- infra-team-h1-2026-decisions.md is excluded by name: it wrote in stage 1 and then lost
  every later merge to a write timeout, so it carries v1 of both its chains and would
  read as a supersession failure. Exposure is near nil (P2 scores nothing under V10, N2's
  scoring article wrote with both versions).
- That exclusion is not assumed symmetric. The timeout constant is 300.0 in both arms,
  but three other groups timed out and then landed, and this one failed 21% under
  MAX_PROMPT_CHARS. If the A1 arm writes it, the exclusion is one-armed.
…solves to two articles

FX5 is measured against the finished FX4 baseline arm (27 articles, 17.99 USD,
code at bd8252e). The gating column -- Staleness over superseded-contradiction --
reads 24 of 40 (60%) against the 28 of 40 (70%) the label pass published for the
historical articles, so the arm is better than the artifact it replaces. Band
21-25 on the four queued rulings that move the column.

The reason nothing on this branch predicted: the pre-A1 writer already performs
supersession unprompted on 18 of 45 rows, in four distinct styles -- explicit
wording, version-labelled parallel presentation, as-of snapshot dating, and
coequal presentation that names the conflict and refuses to order it, which the
D1 option list has no name for. It is unreliable rather than absent: P9's article
compared one of three phase deadlines stated on a single v2 legend line and kept
v1 for the other two.

Two findings changed the criterion before a row was scored. A case resolves to
two articles rather than one (10 of 18 chains split across decision/ and
project/, both carrying the whole chain), so scoring is the union over a case's
articles -- arm-independent, unlike designating a primary, and on P3 the two
articles are stale on almost disjoint rows, 5 of 9 each and 8 of 9 unioned. And
one article carrying a whole chain is invisible to source-based resolution
because FX4's frontmatter defect hides its sources; it is the article carrying
P10's trails, so it was resolved by hand.

Three spec clauses were checked rather than assumed and did not hold: the frozen
article needs no by-name exclusion, V9's co-source exclusion changed no verdict
anywhere on this fixture, and Size is unmeasurable on both arms as scripted.
test-set.md's Trail and Size rows now say what is open and what is unavailable,
NG3 no longer expects Trail 0 on P4, and labels.md's closed queue points at V31,
which questions P3-C7's own scope.

Seven rulings are open with Captain (V28-V34); the drafted position on each is
what these numbers use, so none of them blocks the A1 arm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
V28 is the only one of the seven that moves a number, and it is ruled
against its drafted position: a Trail requires a directional statement
about which value is dead. Version- or basis-labelled parallel
presentation and as-of date stamps do not count, because P5 reads
(Staleness 0 of 5, Trail 4 of 5) -- forging the current-plus-trail
signature while deciding nothing -- and P7 reads (5 of 8, 6 of 8), a
pair no D1 option produces. The three tiers decompose as 7 directional
+ 4 basis-labelled + 7 as-of = the 18 the rubric counted, and the two
excluded tiers stay on the record so FX7 cannot claim the baseline
performs no supersession.

V31 and V34 are ruled at the draft on evidence that still exists.
V29, V30, V32 and V33 close permanently at the draft, with a dissent
recorded on V33: they are reads of lines in baseline-arm articles, and
/tmp was cleared, so no baseline-arm article survives. scoring.md is
now the arm's only record, which is also why FX7 compares against a
written baseline rather than a readable tree.

The gating column keeps 24 of 40 (60%) and the 21-25 band closes onto
the point estimate. D1's option list gains coequal presentation, the
shape the baseline produces most often and the one it never named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The baseline arm ran from four shell scripts under /tmp that were never
committed. /tmp was cleared on 2026-08-19 and took them, the 27 scored
articles and the logs, so the arm can only be re-quoted from scoring.md.
This is their replacement, versioned and under test.

It carries their policy, all of it paid for by 2026-08-18's two outages:
wait for the endpoint before every compile (one outage lasted ten
minutes and the driver that gave up into it scored the wrong thing), one
retry per stage taken in place (staging the next version first would
compile a leftover against an article that version had already moved),
and a residual recorded rather than raised (stage 2 finished 16 of 18
and the arm is still the measurement).

Plus the two things their absence cost. --out may not be under /tmp, and
wiki/ is copied per stage, which is what Size was never measurable
without.

cases.json is rebuilt rather than shipped: select_cases against the
fixture emits the curated 18 chains over all 38 documents -- the 131
candidates on record is its count against data/kb-knowledge, a different
KB -- so the driver regenerates it and proves the coverage every run.

35 tests, 99% coverage, 10 of 10 mutations of the new rules caught.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sioned

The 2026-08-19 entry recorded cases.json as unrecoverable on the ground
that select_cases.py emits 131 candidates rather than the curated 18.
That count is against data/kb-knowledge. Against the fixture the same
script emits exactly 18 chains over all 38 documents, no orphan and no
missing member, and the stage plan comes back 18/18/1/1 -- the split
stage_fixture.py and the baseline arm both used. test-set.md L764
already published that count on this branch, so the claim contradicted a
document beside it.

So the file is derived, deterministic and free, and run_fx4_arm.py
rebuilds it per run with the coverage proved by a test against the real
fixture. FX1 now covers the thing that spends the money too, not only
the things that prepare it, and Size is recorded per stage on the A1 arm
while staying unavailable on the comparison -- the baseline is
unrecoverable, not merely expensive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found in review. The completion test read data["compiled"] from the
compile response, but that count is KB-wide: gate 2 (compile.py:312-317)
re-queues every document whose state carries no compiled_at, so a later
stage's compile also recomposes an earlier stage's residual. Stages 3 and
4 stage one document each and the baseline left two residual in stage 2,
so stage 3 would have reported compiled=3 against staged=1 and been
declared done even when its own document failed -- no retry, nothing in
the report, and the chain collapsed onto the single-run merge->create
path FX2 exists to avoid. An 18 USD arm mis-measured silently.

Stage completion is now by name: stage_shortfall requires compiled_at for
every member of that stage, read fresh from .compile-state.json after
each attempt, and the residual names the documents. A per-document
compile error is deliberately not a retry trigger -- those errors can be
entirely about documents this stage never staged, while an error on a
member of this stage leaves it missing, which is the trigger.

Also from the round: --attempts, defaulting to 3 rather than 2 because
the baseline got three passes at stage 2 by hand and an arm with fewer
attempts than the arm it is compared against confounds FX7; a config
block in the report (repo SHA, models, workers, extract strategy) plus
the per-document errors, since the baseline's write history had to be
rebuilt by hand from log lines; the report checkpointed after every stage
instead of once at the end; --out refused when it *resolves* into a
volatile directory, the relative spelling having passed the prefix check;
--out refused when non-empty unless --resume, staging being additive; and
a real --execute integration test, without which an argument swapped
between fixture and kb would have copied an empty KB over the fixture
with every test still green.

Docs: scoring.md's Size section still said "unmeasurable on both arms"
and cited a deleted /tmp script; spec.md FX1 said "only the two" and
named four; test-set.md's Regenerating block showed only the
corpus-scoped select_cases invocation, which is the reading that produced
the retracted claim. The fixture is now the last unversioned
load-bearing artifact and is recorded as such.

55 tests, 99% coverage, 13 of 13 mutations caught, full suite 1817 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he articles

Two findings from re-review, both measured before accepting.

A typo'd scheme cost fifteen minutes and lied about why. The exception
classes do not separate a config error from an outage: gw.example/v1
raises ValueError, htp://gw.example raises URLError -- an OSError, so
wait_for_endpoint read a typo as an outage, burned the whole 900 s
deadline and aborted with "endpoint never came back" -- and
http://gw .example raises http.client.InvalidURL, which is neither and
crashed the arm mid-run. unusable_base_url now checks the shape once in
main, before any hook is built, and the probe is left pure.

And the report's model record was false. run_compile hardcodes all three
write-path models to claude-sonnet-4-6 (compile.py:806-808) and reads
LLM_MODEL only for the summarize fallback, so recording that env var
published a model which wrote nothing -- inside the block whose whole
purpose is FX7 self-description. The driver now sends the three models
explicitly, at the same default the baseline ran, and records what it
sent.

Smaller, each a way to lose or misread an arm: --attempts 0 would have
snapshotted every stage, called nothing and written a full-residual
report at 0.00 USD, an arm that never ran looking finished; --workers 0
silently took compile's own 16. A stage blocked between attempts now
snapshots what its earlier attempts wrote, Size being why snapshots
exist. Errors carry an attempt index, so one document failing twice no
longer serialises as two documents failing. KB_WORKERS is recorded,
chunk-level extraction reading it independently of --workers.

Two rules the last commit advertised were pinned by nothing: the
per-stage checkpoint (its test asserted a stub calling its own callback)
and the $TMPDIR refusal entry (disabled suite-wide by the fixture that
makes tmp_path usable). Both now have real tests, and the tautological
attempts assert reads 3 rather than the constant it was comparing
against itself.

scoring.md still published "one retry per stage" as this driver's policy
while the default is 3 attempts -- in the one document FX7 reads its
baseline from, understating the A1 arm's retry budget on the exact
variable that choice was made to protect.

72 tests, 99% coverage, 18 of 18 mutations caught, full suite 1834 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e shifted

The A1 arm ran attended at 14.3873 USD over 185 calls, four stages each finishing
on its first attempt with an empty residual. Both FX4 article-shape defects are
absent, so no case needed hand resolution and 14 of 18 chains resolve to one
article against the baseline's 8.

Every failure column improved and the one positive column got worse: gating
Staleness 24 -> 19 of 40, drops 37 -> 29 of 41, corrections 34 -> 38 of 45,
collateral 40 -> 41 of 42, Trail 7 -> 5 of 45.

The reason is one behaviour. The arm consumes the ordering signal but attributes
each value to its source version instead of asserting which one is dead, so its
best cases read (Staleness 0, Trail 0) -- coequal presentation, the shape D1
rejects in terms. Where it stops labelling it fails in the same three places:
Key Decisions sections, Action Items tables, and DDL cells copied from an older
version into a table headed by a newer one.

Two regressions belong to the classifier rather than the writer. P4 and P9 split
by version, so 16 of the 45 contradiction rows sit in chains no single article can
trail, and they carry 14 of the 19 stale gating rows.

Four rulings are drafted and open (V35-V38). V36 -- how a chain the arm
distributes should resolve -- moves P4 between 9 and 3 stale rows and the total
between 19 and 13 of 40, so the FX7 verdict waits on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A1 does not clear the positives, so D2's condition fires and A2 moves from
optional to required. The gate is test-set.md's -- path A ships first if and
only if it clears the positives without tripping the negatives. A1 holds the
negatives at 0 of 4 and 0 of 4 and leaves 19 of 40 contradictions stated as
current.

The four rulings the A1 scoring opened are all taken at their drafted
positions, so no published figure moves: V35 name residue is recorded and not
scored, V36 scores a distributed chain on the union with the newest-half figure
reported beside it, V37 an accurate as-of stamp does not make a row stale when
nothing newer is offered, V38 a tense-marked dated pair is not a Trail. Unlike
the baseline's queue none closes for want of evidence, the arm's articles
having survived.

The verdict does not depend on any of them. The band across every drafted and
alternative reading is 8-22 of 40 against the baseline's 24, so A1 is better on
every reading and clean on none.

Two findings carry into A2 rather than into the gate. Split by whether A1's
articles saw the whole chain, staleness went 13 to 5 of 24 on P3, P7 and P10
and 11 to 14 of 16 on P4 and P9, which A1 split by version -- so 8 of the 19
stale rows are in an article that had both values in front of it and 11 exist
only because a second article carries the older half of a chain. Version
splitting is NG6's territory, upstream of this feature, and is now the column's
largest single contributor with no owner on this branch. Second, A1's five-row
advantage rests on basis-labelled coequal presentation scoring clean; strictly
read, both arms land near 34-36 of 40 and it disappears. That cannot be settled
-- the criterion can be applied to this arm's surviving articles and never to
the baseline's -- so it is recorded as a bound, and it is why test-set.md's
Scoring table should be settled before A2's arm is bought.

One recorded counterfactual was wrong in four documents and is corrected: V36's
alternative totals 8 of 40, not 13, which had applied the newest-half rule to
P4 and not to P9 though P9 is split the same way and its v2-only article reads
0 of 6. The ruled figure never moved. The content-iteration diagram also
credited the A1 arm with the baseline's Trail count; it is 5 of 45 against 7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…clean

The FX7 verdict named one thing to fix before A2's arm is bought: Staleness and
Trail together certified basis-labelled coequal presentation -- the shape D1
rejects in terms -- as a clean latest-wins result, so A1's three best cases read
(Staleness 0, Trail 0) and an A2 shipping a real trail would not have been
distinguishable from them.

V39 adds a third gating column, In force: a row fails when both its values are
stated and nothing resolves which one holds. It is a new column rather than a
stricter reading of Staleness, deliberately -- redefining the gating column would
have replaced this branch's one measured comparison with a one-sided one, the
baseline's articles being gone. So 24 against 19 of 40 stands untouched.

Measured row by row on the surviving articles in ~/kaas-arms/a1, not derived from
the scoring records: In force is 28 of 40 on the A1 arm, 18 inside a single
article and 10 across a chain the classifier split, so the gate reads 18 and the
rest sits with NG6. Presence was checked on the files -- 11,362 and 25,046 appear
in comma form in P7's article, the only 7C match in it is inside 7197C, P4's
nanoseconds appear in both articles with no millisecond form in either.

Three consequences worth naming. P10 and P5 go from the cleanest results in the
set to 7 of 7 and 5 of 5 while their Staleness stays 0. A trail no longer
guarantees a pass: P3-C1 and P3-C5 each carry a correct directional statement and
assert the superseded value as current elsewhere, which makes intra-article
self-contradiction countable for the first time. And the strict reading this
branch carried as "roughly 36 of 40" is now measured at 32 on the A1 side, with
the baseline's 34 staying an estimate for good.

The verdict does not move: the second gating column agrees with the first that A1
does not clear the positives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…odel

An arm's write model is sent explicitly, but nothing checked that the resolved
endpoint serves it. KaaS reads LLM_BASE_URL/LLM_API_KEY and falls back to
OPENAI_BASE_URL/OPENAI_API_KEY, which on the laptop that ran both arms points at
MiniMax -- eight MiniMax models, no Claude model. An arm launched there 400s on
every call and "completes" with a full residual at 0.00 USD, which reads like a
catastrophic writer failure rather than one unset variable. That cost an hour once.

So main now reads the gateway's /models list before anything is written and
refuses when the write model is absent, naming both what it needs and what the
gateway serves. Same class as the URL-shape check, and placed after the --out
refusal so a local mistake costs no round trip.

The two readings of that payload are kept apart, because only one is evidence: an
empty data list is a gateway saying it serves nothing and the arm refuses, while
HTML, a bare string or entries without an id is a gateway that was asked something
else -- that warns and runs, since a gateway not exposing /models would otherwise
be unusable for an arm whose every write would have succeeded. Unlike the endpoint
probe, no failure propagates: wait_for_endpoint owns outages.

arm-report.json records models_verified, for the same reason it records the models.
An arm that produced a residual has to be readable as a writer failure or as a
gateway that was never confirmed.

16 new tests, 88 in the file, 99% coverage (the one miss is the __main__ guard),
11/11 mutations caught, full suite 1851 passed. Also verified the file opens no
real socket, with socket.connect stubbed to raise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eways

Review round on the pre-flight check: 3 Important + 6 Minor, every one fixed,
adjudicated or recorded. The reviewer proved four surviving mutations rather than
asserting gaps, and two of them were rules the commit message itself claimed.

The two claimed-but-untested rules now have tests. Checking _models(_MODEL)
instead of _models(args.model) passed the whole suite, so --model was checked
against nothing: an override the gateway serves would have been refused, and one
it does not serve would have been accepted and then 400 every write. And moving
the check above the used---out refusal also passed, which mattered twice -- the
ordering claim was decorative, and in that state the one --execute test that does
not stub _model_catalog performed a real DNS lookup for gw.example. Both are
pinned, the second by making that test fail if the gateway is asked anything.

The guard's premise is now measured instead of assumed, which is this file's own
standard for a claim about how a call fails. LiteLLM answers /models with 200, 30
ids, no wildcard entry and claude-sonnet-4-6 spelled exactly, so no arm can be
falsely refused there. The MiniMax endpoint the OPENAI_* fallback points at
answers 200 in 0.26 s with 8 ids and no Claude model at all -- so the recorded
incident is refused rather than degraded to a warning, which was the one way this
guard could have been decorative.

http.client.HTTPException joins the cutset: InvalidURL, IncompleteRead,
BadStatusLine and LineTooLong are neither OSError nor ValueError, and two are
reachable through a URL unusable_base_url accepts (a nonnumeric port, and a
control character in the path, which the netloc check does not inspect). The
docstring claimed every failure reads as None and did not.

An autouse fixture now deletes the four gateway variables for this file. There was
no such isolation, and on this laptop OPENAI_BASE_URL is the MiniMax endpoint with
a live key -- so the incident configuration was ambient during a bare pytest run,
and suite hermeticity rested on check ordering rather than on isolation.

Adjudicated, not implemented, and both recorded in main's comment: the summarize
model is not checked, because compile.py:228 makes it mandatory only for a
non-chunked strategy and checking it unconditionally would refuse an arm over a
model no chunked run calls, while checking it conditionally restates compile.py's
rule where it would drift; and a 401 on /models stays in the warn branch, because
a completion-only key that cannot list models is a real configuration. Both would
be answered by probing with a 1-token completion, which is noted as the cheaper
fix if either premise stops holding.

93 tests, 99% coverage (the miss is the __main__ guard), 12/12 mutations caught
including the four the review found surviving and the block move, full suite 1856
passed, and the file still opens no real socket with connect/create_connection/
getaddrinfo all stubbed to raise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
V39 made this caption stale and the V39 commit did not update it. The diagram's
second shape -- coequal presentation, the one the A1 arm produces most -- was
captioned as clearing Staleness and earning no Trail, which under the two-column
rubric left it looking like an unremarkable latest-wins result. It now names the
third column and what that column reads: nothing says which value holds.

Rendered and checked rather than eyeballed as XML: the line is 126 characters and
sits inside its box with margin on both sides at font-size 10.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…under

CI's first run of this branch failed 17 tests with `SystemExit: 2` and the reason
is a platform assumption in the autouse fixture, not in the driver. The driver
refuses an `--out` inside a volatile directory, so the suite cannot use `tmp_path`
unless the rule is narrowed for it -- and the fixture narrowed it by writing the
answer down: `("/tmp", "/private/tmp")`. On macOS that works, because `tmp_path`
lives under `$TMPDIR` (`/var/folders/...`) and dropping that one entry is enough.
On Linux `tempfile.gettempdir()` is `/tmp`, so every scratch path is still refused.

Reproduced locally before fixing, with `TMPDIR=/tmp uv run pytest` -- the same 17
failures as CI -- and the fixture now computes the entries to drop from where
`tmp_path` actually resolves, so it is correct wherever pytest puts its files.

The two tests that exercise the rule itself now install their own list instead of
inheriting the narrowed one. Without that, `/tmp` being dropped on Linux would
leave `test_the_out_directory_may_not_be_under_tmp` unable to refuse anything and
`..._merely_starts_with_the_same_letters_is_fine` passing for the wrong reason --
a green suite that had stopped testing the lesson that cost 18 USD.

Verified in both shapes: 93 passed under the macOS default and under `TMPDIR=/tmp`,
and disabling the production refusal fails 6 tests in each, so the rule is pinned
in both environments rather than only in one. Full suite 1917 passed in both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er reaches

The FX7 verdict decided A2 is required rather than optional, so this is A2's spec.
Five decisions carry it, each taken against the option it beat (D6-D10).

The one that shapes everything else is D6. A1's gating column is 19 of 40, and 11 of
those rows exist only because the classifier split a chain across two articles: the
one asserting the older value never received the newer one, so no write prompt can
retract it. test-set.md's gate counts them on the Staleness half while V39 already
scoped In force to the same-article count, which means a writer-side increment fails
that gate by construction. A2 stays writer-side and V40 restates the Staleness half
the same way V39 was written -- a scope added, no published figure re-read. The
baseline's 24 of 40 and A1's 19 of 40 stand, with A1's writer-owned 8 of 40 and
same-article In force 18 of 40 stated beside them, and the split rows keep being
published and attributed to classification.

D8 sets the primitive's grain from the failure map rather than from taste: every
stale row in P10 is in a Key Decisions section or an Action Items table, and P4's
three writer-owned survivors are DDL cells copied under a v3 heading, so a
section-level replace would reach almost none of the residue while risking every
neighbouring cell on a Collateral column that already reads 41 of 42. The action
names exact text, matches once or not at all, and the trail is rendered by code so
D1's four format rules are mechanical instead of hoped for.

D9 replaces A1's G4 now that a path can delete. A trail is append-only -- structural
on the diff path, one retry then an abandoned merge on the rewrite path -- and
shrinkage is reported with no threshold, because there is no measured distribution of
legitimate shrinkage to set one from and a guessed number would kill correct
rewrites. D7 keeps the trigger at explicit contradiction and D10 accumulates chained
trails newest first, which answers A1's NG3 by reasoning rather than by evidence,
V15 having measured that the fixture holds no nested instance.

Anchors and figures checked against the tree rather than quoted from memory: every
cited merge.py line resolves, the six sites asserting the merge paths cannot retract
are enumerated for PV5, and the arm's comparison side is 20 articles in
~/kaas-arms/a1/kb/wiki -- which survives because run_fx4_arm.py refuses a volatile
--out, not because anyone remembered to copy it.

A1's spec and the FX7 verdict gain forward pointers, and A1's two questions carried
to A2 are marked decided there instead of still open here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tood

Step 1 of the A2 increment (docs/features/supersession/spec-a2.md): the diff
path gains a `supersede` action that replaces exactly-matched article text and
renders a `[Superseded <date> by <path>: <was>]` block in its place. No prompt
file changes, so no writer can emit the action yet -- write_prompt_version stays
at 5a66a8ed04ea and behaviour does not move. The code lands under test before it
can be exercised, which is what the spec's sequencing asks for.

Covers RA1-RA5 and TR1-TR5. The guards worth naming: an anchor must match once
in the body, with no normalization and with existing trail blocks excluded, so a
later supersession edits claims and never history; `by` must name a payload
source whose date is strictly newer than every other dated block, which puts WP9
in code; supersede applies before the additive actions, since anchors were
chosen against the article as it entered the prompt.

_apply_diff now takes the payload's SourceBlocks rather than their paths, because
the order guard reads their dates and two representations of one payload could
disagree about which document is newest. It returns the refusals alongside the
content; _merge_diff names them on stderr until the compile report carries them
in step 4.

Three spec amendments came out of implementing it, each a gap the criteria left
rather than a change of direction:

- RA3 produces a failure SG3 did not name -- `by` dated but beaten by a single
  newer block. Reported as its own reason, because folding it into "no
  strictly-newest block" would report the writer naming the wrong document as
  the payload having no order.
- RA1 fixes which fields are required. An empty `replacement` withdraws a claim
  and the trail makes that recoverable, so it is allowed; an empty `was` deletes
  the record instead of the claim, so it is refused. G7 is a property of the
  record, so the record is the field that cannot be omitted.
- TR5 protected only `was`, leaving the same table corruption reachable through
  the other half of the same edit. It now covers `replacement` too, and refuses
  an anchor spanning a row boundary -- that shape merges two rows and leaves one
  `was` standing as the record for every claim they held, so TR5's column count
  and G7's record fail together.

VA5 is listed under step 1 but verifies SG2, which step 4 owns and whose byte
deltas are only visible at the compile layer. Left to step 4 as a sequencing
slip rather than implemented here.

Verification: 1956 tests pass; kb_ai.core.merge at 100% statement coverage with
every partial branch in pre-existing code. Assertion strength checked by
mutation on a scratch copy -- 14 mutations across RA1-RA5, TR3 and TR5, zero
survivors, reverse control green. One mutation first read as surviving because
reordering the two patch passes leaves the file's size unchanged and CPython
validates a cached .pyc on (mtime, size); with bytecode disabled it is killed by
exactly the RA4 test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ter reaches

Step 5 of A2's sequencing: GT1 lands in test-set.md as V40, with GT2-GT4.
Both gating columns now read the same-article count -- V39 had already
scoped In force that way, and Staleness follows for the same reason. 11 of
A1's 19 stale rows and 10 of its 28 In force rows sit in an article whose
own sources never contradict the value it states, so a gate counting them
measures the classifier with a writer-side increment in the denominator.

No published figure moves. The baseline's 24 of 40 and A1's 19 of 40 stand
undecomposed; A1's writer-owned 8 of 40 and same-article In force 18 of 40
are stated beside them, and A1 fails both columns on the narrower count
too, so FX7's verdict is untouched. The baseline's own split is
unrecoverable and stays that way -- what V40 fixes is what the next arm's
gate reads.

GT2 states the gate as four figures, GT4 measures the same-article
denominator on the arm being scored rather than inheriting A1's routing,
and GT3 publishes a D1 conformance reading beside the gate, since four
clean figures with Trail near 0 is latest-wins shipped rather than D1.

Step 2 is untouched: write_prompt_version has not moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…claim goes

A2 sequencing step 2, the step that changes what the writer does. merge-diff.md
gains a `supersede` action with RA1's four fields; merge-rewrite.md states the
same rules in prose, since the rewrite path has no action vocabulary. TR6's
validator reads the rewrite path's own trail text back and reports what is
malformed without rejecting the write. write_prompt_version moves 5a66a8ed04ea
-> 3c88b88358d8, and the six sites claiming the merge paths cannot retract are
now false, so they are rewritten -- including the two operator-facing report
strings, which say "may still carry ... look for [Superseded ...]" (PV6).

Three deviations from the spec text, each recorded in spec-a2.md rather than
left for a reader to find. TR6 read literally reports a false defect on every
correct chain, because S7's preserved v2 entry names a document the v3 payload
does not carry -- so it validates only the blocks new in the output, compared
against the pre-write article. RA6's "D1's example verbatim" contradicts TR1:
D1 writes the block wrapped over two lines and TR1 makes it single-line, so the
prompt states it unwrapped, pinned by a test that runs the prompt's own example
through the validator. And RA7's anchor-uniqueness rule is diff-only, being
vacuous on a path that returns a whole article.

Two things a mutation sweep caught that reading the code did not. The candidate
scan was line-greedy, so TR4's chains -- which sit adjacent on one line -- had
every entry after the first swallowed and never validated; it now runs per
opener. And `by` required a run of non-space, which reported as malformed every
trail _render_trail itself emits for a source path holding a space, reachable
because distill._raw_rel joins the file's path parts verbatim.

D9's append-only rule lands in merge-rewrite.md a step early on purpose: SG1
retries "with the constraint restated", which presumes the prompt carried it.

One bound left open and pinned by a test rather than closed quietly: an empty
`was` passes TR6, where the diff path refuses it on G7's argument. A fourth
check is a change to TR6, not a fix to it.

Tests: 1974 passed, core/merge.py at 100%. Prompt rules are pinned by a
parametrized test over (criterion, wording) pairs, because a rule deleted from
a prompt is invisible until an arm is bought -- four of them, including the one
sentence standing against coequal presentation, could be deleted silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e merge

SG1 with VA4: every [Superseded ...] block the pre-write article carried has
to come back verbatim from a full rewrite. A missing one buys a single retry
with the append-only constraint restated; still missing, the merge is dropped
and the article keeps every byte it had. D9's price, paid on purpose -- this
is the one loss no reader can recover from the article itself.

The retry states the constraint as a requirement rather than as feedback on
the rejected draft, because the draft is not sent back: it would cost a whole
article of budget to say what the list of missing blocks already says. Its own
text is bounded by what the first send left of the prompt budget, so a merge
SG1 means to retry cannot raise PromptTooLargeError instead and lose the
history to a crash; the report names every missing block either way.

TR6's malformed-trail report now runs only when a rewrite lands, since the
format of prose in an abandoned write points an operator at text no file holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…truncation range

Annotates SG1 as landed and records what implementing it measured: the diff
path's append-only guarantee is not structural for every article. _merge_diff
section-truncates an article past 70% of the prompt budget and then patches the
truncated text, which is what gets written back, so section bodies -- trail
blocks among them -- are dropped before any action runs. Measured on an 86 731
character article: 51 966 written back, the trail in a low-relevance section
gone with its heading kept.

Recorded rather than fixed. It predates A2 and contradicts A1's G4 as squarely
as it does D9, so the fix is its own change; naming it now means SG2's shrink
report will not be read as the guard failing.

SG1 also picks up the 80-character report bound it shares with SG3, which the
implementation adopted and the rule did not state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ndoned merge stops reading as merged

SG2, SG3 and SG4 with VA8. Every merge op now takes an optional sink, and the
findings it used to print from inside the merge -- refused supersedes (SG3),
abandoned merges (SG1), and the byte delta of an article that shrank (SG2) --
reach the layer that knows which article was written. Collected instead of
printed: the sink's owner logs the report, so emitting at both layers would tell
an operator the same refusal twice and make a count of report lines wrong. A
caller that passes no sink keeps the stderr behaviour it had.

SG2 is measured inside the merge op rather than by its two callers, though both
hold the pre- and post-write text: one delta must not depend on which route
wrote the article. Bytes, not characters -- a rewrite can drop three CJK
characters for one ASCII word and grow in characters while shrinking on disk.

Both routes stop calling an abandoned merge `merged`. The compile log says
merge-abandoned and the worker route reports `abandoned` in its item result,
because `merged` tells a client the sources reached the article and SG1 dropped
the write so that they did not. The ops still count as completed, per D9.

SG4 is asserted the way RP5 already was, with a sentinel anchor: the write entry
points take source blocks and nothing else, and the findings are built after the
last write op, so a report cannot steer the writer that produced it. VA8 extends
VF6 from the payload to the article -- one supersede, both routes, same bytes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…left open

Annotates the sequencing entry and records what the implementation had to
decide: findings travel in a caller-passed sink and are collected instead of
printed, SG2's delta is measured inside the merge op and carries a one-byte
floor from the rewrite route's strip, and an abandoned merge is its own status
on both routes while its ops still count as completed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…indings before the write

An abandoned merge is what EV_MERGE_ABANDONED names; the reason string already
says the trail is missing. The value stays "abandoned", so nothing an external
consumer reads moves.

All four drains now run above store.write_article. A drain below it loses every
finding of the run most worth reporting to the exception, and the findings
describe what the merge produced rather than what reached disk -- so a failed
write reports SG2's delta beside the error it also files. Each site is pinned by
its own test: reverting any one of the four alone turns exactly one test red.
The single-merge branch had none, because the shared fixture ingests two raw
files into one article and can only reach the batch branch.

The compile report is formatted from the events and serialised from the same
order, rather than rebuilding a MergeEvent from a dict just to print it. SG4's
wording narrows to what holds on both routes: no finding is read back by the run
that produced it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tybot02 pushed a commit that referenced this pull request Aug 22, 2026
#53)

GitHub's code scanning default setup never triggers on a pull request from a
fork. Pull request #50, from wangdahoo/kaas, therefore sat permanently blocked:
the "main" ruleset requires a CodeQL result, and no CodeQL run was ever created
for its head or its merge commit. Every same-repo pull request from #32 to #51
got one; both fork pull requests got none.

A workflow file is part of the pull request's merge commit, so it also runs for
fork pull requests, subject to the usual approval for outside contributors. The
matrix reproduces the four analyses and build modes default setup ran, so the
languages covered do not change.

Default setup has to be turned off for this to work: while both are active the
upload fails with "CodeQL analyses from advanced configurations cannot be
processed when the default setup is enabled".

Co-authored-by: lucasmaan <305449091+lucasmaan@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant