Skip to content

#24 docs: split the content.fetch issuer contract to the per-doc budget - #26

Merged
gregoryfoster merged 5 commits into
mainfrom
24-split-issuer-contract
Aug 6, 2026
Merged

gregoryfoster merged 5 commits into
mainfrom
24-split-issuer-contract

Conversation

@gregoryfoster

Copy link
Copy Markdown
Contributor

Closes #24.

docs/contracts/content-fetch-issuer-contract.md was 14,772 tokens against the 10,000 per-doc budget — the last live doc over it after #22, and the one most likely to be read by an agent working in a sibling repo, which is where an over-budget file does the most damage.

Two commits

1. Promote the fact schemas out of the request-options subsection. There was no heading between ### Request options and ## What the issuer MUST do, so both wire schemas — BlobAvailableEvent and FetchFailedEvent — were structurally nested under a subsection about headers and timeout_seconds. The issue's split table read that heading tree faithfully; following it would have relocated the schemas into a lookup annex. Fixed first, verbatim, on its own.

That also corrects the issue's arithmetic: ### Request options proper is 49 lines / ~1,562 tokens (10.6%), not the 168 lines / 29% the heading span suggested.

2. The split. Measured per-section, the core as the issue scoped it came to 10,911 — over budget with request options, the taxonomy, provenance and the Settled section all removed. So the cut goes further.

File Tokens Contents
content-fetch-issuer-contract.md (path unchanged) 14,772 → 9,966 The frame, the payload shapes, the seven MUSTs, the guarantee / non-guarantee pair
content-fetch-issuer-reference.md (new) 7,349 Refusal list, enriched-field reasoning + the #17 conditional-GET trap, failure taxonomy, the four silent conditions, reading the DLQ, provenance and trust, version history, envelope-key rationale, pacing at deployed defaults
docs/plans/2026-07-31-fetch-failed-fact-settled.md (new) — The resolved silence-as-failure question. Archival, so outside the measured surface

Both contract files are normative. A rule did not become advisory by moving.

The linked path stays the core, not a stub. A stub would cost every issuer-side agent a second read to reach the MUSTs, which is the cost the split exists to remove.

Verification

Cross-repo

CannObserv/watcher#241 notified, per the contract's own announcement rule — the failure taxonomy moved, so the rule fired. CannObserv/watcher#241 (comment)

Refs updated

AGENTS.md (Detail Docs + the Bus Conventions signpost), README.md, docs/ARCHITECTURE.md, docs/COMMANDS.md, docs/contracts/replicator-boundaries.md, src/core/errors.py — its docstring names the file for the taxonomy specifically, which is now the reference.

🤖 Generated with Claude Code

gregoryfoster and others added 5 commits August 6, 2026 14:38
`### Request options` had no sibling heading after it, so both fact schemas —
`BlobAvailableEvent` and `FetchFailedEvent` — were structurally nested under a
subsection about `headers` and `timeout_seconds`. A split that reads the heading
tree would have relocated the wire schemas into a lookup annex.

Adds `### The command`, `### The success fact`, `### The failure fact` under
`## The payload`, and moves the `occurred_at` tz blockquote up to the command it
constrains. Content verbatim; prove-no-loss reports 0 unaccounted lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The contract was 14,772 tokens against the 10,000 per-doc budget, and it is the
doc most likely to be read by an agent working in a sibling repo — the case where
an over-budget file does the most damage, since there is no cheaper local surface
to fall back on.

The path sibling repos link stays the normative core, not a stub: a stub would
cost every issuer-side agent a second read to reach the MUSTs, which is the cost
the split exists to remove.

Core keeps the frame, the payload shapes, the seven MUSTs, and the
guarantee/non-guarantee pair — 14,772 -> 9,966.

content-fetch-issuer-reference.md (7,349, new, equally normative) takes what an
issuer looks up rather than reads through: the request-options refusal list, the
reasoning behind the enriched blob_available fields and the #17 conditional-GET
trap, the failure taxonomy, the four silent conditions, reading the DLQ, the
provenance and trust posture, the co-core version history, what the envelope key
is operationally for, and pacing at the deployed defaults.

docs/plans/2026-07-31-fetch-failed-fact-settled.md takes the resolved
silence-as-failure question. Archival, so out of the measured surface.

Moves are verbatim. prove-no-loss --base d4a5e02 accounts for 471 of 475 lines;
the four it does not are the intra-document pointers whose referent moved, so
"see the taxonomy below" and "the refusal table above" were disproven by the move
and are now cross-file links.

Every live doc is now under budget; AGENTS.md is 5,896 of 6,000. No dead links,
no orphans, every anchor resolves. Manifest updated and re-embedded in the same
change, per the #19 CR #17 rule.

Refs updated: AGENTS.md (Detail Docs + the Bus Conventions signpost), README.md,
docs/ARCHITECTURE.md, docs/COMMANDS.md, docs/contracts/replicator-boundaries.md,
src/core/errors.py (its docstring names the file for the taxonomy specifically,
which is now the reference).

Closes #24

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1  fix    Restored the "No ordering" non-guarantee to the contract. A slice-based
          cut took one line too many and swept it into the reference's pacing
          section, where it dangled under an unrelated heading and read as a claim
          about pacing. prove-no-loss passed because it verifies a line exists
          somewhere, not that it landed in the right place — the one failure mode
          the check is structurally blind to.
2  update MUST-6's "FetchFailedEvent note above" now links the reference section
          the note moved to.
3  update The moved silent-conditions bullet's "see **The frame**" now links
          across to the contract instead of pointing nowhere.
4  update De-indented the moved pacing paragraph, which kept its list-continuation
          indent under a heading with no list.
5  update Reordered the reference into reading order: request options, enriched
          fields, taxonomy, reading the failure fact, the four silent conditions,
          reading the DLQ, provenance and trust, then the three short appendices.
          Accretion order had provenance splitting the taxonomy from its own
          expansions.
6  update Contract 10,017 -> 9,481, margin 519. Restoring finding 1 had pushed it
          back over. Moved to the reference: MUST-4's why-a-duplicate-failure-fact-
          differs mechanism, MUST-7's TTL clock mechanism, MUST-6's swallowed-
          publish bullet, MUST-2's map-loss consequence, MUST-5's per-occasion
          consequence (now adjacent to the blockquote making the same argument from
          the other side), and the re-hash note. Each rule stays; each mechanism
          moves. Tail index compressed.
7  update "Three behaviours", not "two tokens' worth" — the section covers three
          things and one of them is a token.
8  update The relocated Settled doc's "MUST-6 above" now links to MUST-6.
9  drop   Dropped its duplicate H2.
10 rewrap README:33.
11 update Linked the bare MUST-6 reference at what-the-envelope-key-is-for.

prove-no-loss --base d4a5e02: 447 of 475 lines verbatim in place, 28 unaccounted —
every one a pointer the move itself disproved ("see the taxonomy below", "the
refusal table above", "see **The frame**"), now a cross-file link, or the dropped
duplicate heading. All under approved directives.

Every live doc under budget; AGENTS.md 5,896/6,000. All links and anchors resolve
under GitHub's slug rule (the first-round checker collapsed whitespace runs and
would have passed two broken anchors). 444 passed, ruff clean, manifest re-embedded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second-round review finding 13. The header described four things; after finding
6 moved five mechanism blocks in, the file also carries the mechanisms behind
several MUSTs — each rule stated in the contract, each explanation here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gregoryfoster
gregoryfoster merged commit afb1f97 into main Aug 6, 2026
0 of 2 checks passed
@gregoryfoster
gregoryfoster deleted the 24-split-issuer-contract branch August 6, 2026 15:50
gregoryfoster added a commit that referenced this pull request Aug 14, 2026
Two of these were bugs in the wiring rather than the guards, and both were
verified by probe before being written down.

**Writers are keyed by alias, not by provider (CR #26).** AsyncGcsDriver takes a
bucket in its constructor and never sees another, so a driver *is* a bucket and
the key has to be whatever selects one. Keyed by provider, two gcs bindings
collapsed onto a single dict entry: a command naming `public` reached the driver
holding `co-gcs-internal` and wrote outside the root its own binding declared —
validate_destination guards the prefix half of T3, and nothing downstream
re-checks the bucket because by then it is baked into the driver. The loser was
also dropped with its HTTP session open, since shutdown iterates that dict.
Invisible today only because no host provisions two aliases.

**Provider failures classify by HTTP status (CR #27).** Everything used to become
TransientReplicateError, which loop.py exempts from the delivery ceiling — so a
403 on a misprovisioned bucket retried forever and published *no fact at all*,
leaving the issuer waiting on a command that could never succeed. Reachable from
the wire, too: object_options is an opaque dict, so a bad storage_class is a 400
any bus writer could use to park a command in the PEL indefinitely. Now 4xx
closes (400 is the issuer's — invalid_destination; the rest are the host's —
provider_disabled) while 5xx, 408, 429 and statusless failures stay open, which
T4 makes safe. Read as a number rather than caught as google.api_core classes:
that package is an undeclared transitive, ConditionalWriter is provider-agnostic,
and 408 has no named class there at all. ValueError is left to propagate — it is
the driver's own seam guard, a defect on our side, and the ceiling should see it.

**"The first non-terminal fact" was never emitted (CR #28, #34).** Claimed in
five places, including the previous commit message and what I reported. A
transient outcome returns RETRY and publishes nothing; build_replicate_reporter
still stamps terminal=True on every fact. The decision not to close an
INDETERMINATE stands; the description of how it reaches the issuer was wrong.
Whether that silence should become a terminal=false fact is a contract change,
and is not made here.

Also: a driver that cannot be built is skipped and logged rather than fatal, so
an expired key file no longer stops the fetch path — load_alias_table promises
exactly that one line earlier (#29). The shutdown close loop is guarded per
writer; unguarded it ran before client.aclose() and one raising driver leaked the
Redis client (#30). A blob swept between locate_blob and the open is blob_expired
instead of a ceiling-burning handler_error (#33). The write timeout is a setting
rather than the SDK's unchosen 120s (#38). Stale prose in replicate.py, the
reporter, ARCHITECTURE.md, AGENTS.md and README corrected (#31, #36); read_blob
deleted as dead code (#32); the object_options isinstance hoisted out of its
comprehension (#35); _TERMINAL_OUTCOMES inlined (#37).

Two found while fixing the above. ty caught that ReplicationCompleteEvent
.public_url is a required str while GcsCreateResult types it str | None — a
success carrying no URL raised ValidationError inside a publisher that re-raises,
so the command retried forever; now refused unclassified before the publish.
And build_completion_publisher had no test at all: every write-path test
substitutes its own complete seam, so the envelope, the topic and the three
echoed correlators were exercised nowhere.

683 tests, coverage 99.83%, ruff clean. Not re-run against the live bucket: the
GCS interaction is unchanged apart from timeout_seconds, which a unit test
asserts, and a verification run leaves an object objectCreator cannot delete (#38).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gregoryfoster added a commit that referenced this pull request Aug 14, 2026
**`AliasBinding.alias` is gone (CR #39).** I introduced the problem while fixing
CR #26 and it is the same mistake one layer in: one key, two derivations.
`build_writers` keyed its drivers by the alias table's key while the handler
looked them up by `binding.alias` — equal by construction through
`load_alias_table` and free to disagree through any other constructor, which
silently disabled a binding whose driver had built fine. Now a binding says
*where* and the table says *which*, one owner each, and the handler uses the same
string that resolved the binding. Still only ever a key: two lookups, no branch.

**The contract's `provider_disabled` row was narrower than the code (CR #40).**
It read "the provider is not enabled on this host (T5)", but a provider 4xx now
closes the command and 403/404 land there — the host's credential lacking create
permission, or a bound bucket that no longer exists. Both are "this host cannot
write there" and the remedy is the same operator act, so the row widens rather
than the reason splitting. Also records that 400 is `invalid_destination` and
that 5xx/429/408/statusless leave the command open and publish nothing.

Also: the `isinstance(status, bool)` guard deleted, since `True` is `1` and the
`400 <= status` test already excluded it (CR #42); the write timeout is one
constant in `config.py` that `replicate.py` imports, rather than the same 120 in
two files (CR #43); `_write`'s docstring no longer says `ValueError` is "not
caught" of a clause that catches and re-raises it (CR #44); three unused fixtures
dropped from a test that calls `build_writers` directly (CR #41).

683 tests, coverage 99.83%, ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.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.

docs: split and compress content-fetch-issuer-contract.md — 14,772 tokens, over the 10,000 per-doc budget

1 participant