Repository navigation
#24 docs: split the content.fetch issuer contract to the per-doc budget - #26
Merged
Merged
Conversation
`### 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>
This was referenced Aug 6, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #24.
docs/contracts/content-fetch-issuer-contract.mdwas 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 optionsand## What the issuer MUST do, so both wire schemas —BlobAvailableEventandFetchFailedEvent— were structurally nested under a subsection aboutheadersandtimeout_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 optionsproper 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.
content-fetch-issuer-contract.md(path unchanged)content-fetch-issuer-reference.md(new)docs/plans/2026-07-31-fetch-failed-fact-settled.md(new)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
prove-no-loss.sh --base d4a5e02— 471 of 475 lines verbatim. The four exceptions are the intra-document pointers whose referent moved (see the taxonomy below,the refusal table above); the move itself disproved them and they are now cross-file links.AGENTS.md5,896 of 6,000.uv run pytest444 passed ·uv run ruff check .clean.codebase_context_indexre-run in the same change (Consume content.fetch-policy — replace the single env default with Watcher's per-host numbers (cannobserv#285) #19 CR A body-less 304 dead-letters — make "not modified" a first-class outcome before conditional GET is usable #17).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