Operational rename, slice 1: CHRONICLE_* env names, chronicle.db output, configurable buckets, cutover checklist (#143, mechanism 3) - #226
Operational rename, slice 1: CHRONICLE_* env names, chronicle.db output, configurable buckets, cutover checklist (#143, mechanism 3)#226MaxGhenis wants to merge 105 commits into
Conversation
|
Fable+Sol gate: changes requested, and the high finding is exactly the failure mode today's backfill hit in the wild (IRS re-published the 2022 IRA tables with different bytes under the same URL, #225):
A fix lane is applying both on this branch; re-gate after. |
|
Gate round 2: changes requested again, all agreed and being fixed on this branch (round-2 lane): |
ea67f0a to
aa768dd
Compare
fetch-artifact resolved the destination name only inside the read, compared filenames as raw strings, trusted an explicit --kind over the stored one, looked up only the integer year, and replaced list entries wholesale. Each of those let bytes past the hash-only boundary or lost a recorded identity. Now, before the publisher is read: the filename is a bare name (inferred from the URL without I/O when --filename is omitted), every alias of a hash-only registration is refused, a --kind that conflicts with the manifest is refused, a kindless manifest with content is refused, and the whole manifest is validated with the same codes inventory reports. The vintage entry is selected by key spelling and bare filename, so PR #226's recorded identity guard covers list-shaped release vintages, and _upsert_manifest replaces that entry in place, carries forward the fields a fetch does not own, never turns a publisher-table mapping into a list, and always writes kind. A public microdata release is archived only with --expected-sha256 and licence evidence bound to it: the fetched bytes are checked against the pin before anything is written or uploaded, --record-revision does not override that, and the bytes are staged outside the package tree and uploaded from there. The entry records hash_source chronicle_fetch, attested_by chronicle and the fetch date. publish-raw and inventory-artifacts report manifest-level defects, skip a manifest they cannot classify, and read release bytes from the staging directory only. The CLI gains the matching flags and prints refusals as errors instead of tracebacks. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…it tests hermetic After the rebase onto #226, a manifest whose `files:` block is an explicit null (which #226 reads as absent) was refused by this branch's kind rule as a kindless manifest with content. `declares_file_entries` now decides what counts as content: absent, null and empty `files` are no entries, so the command writing the first entry declares the kind; anything else, malformed blocks included, is content the rule applies to. The hash-only registrar normalizes the null to a mapping before recording, as #226's fetch does. The emit tests that exercise commit reading pointed at the fixture inside this repository, which is now committed, so `git log` always found a commit. They run against copies of the fixture in throwaway consumer repositories: one that records no commit for the manifests, one that is no repository, and one whose HEAD is the commit every registration must record.
fetch-artifact resolved the destination name only inside the read, compared filenames as raw strings, trusted an explicit --kind over the stored one, looked up only the integer year, and replaced list entries wholesale. Each of those let bytes past the hash-only boundary or lost a recorded identity. Now, before the publisher is read: the filename is a bare name (inferred from the URL without I/O when --filename is omitted), every alias of a hash-only registration is refused, a --kind that conflicts with the manifest is refused, a kindless manifest with content is refused, and the whole manifest is validated with the same codes inventory reports. The vintage entry is selected by key spelling and bare filename, so PR #226's recorded identity guard covers list-shaped release vintages, and _upsert_manifest replaces that entry in place, carries forward the fields a fetch does not own, never turns a publisher-table mapping into a list, and always writes kind. A public microdata release is archived only with --expected-sha256 and licence evidence bound to it: the fetched bytes are checked against the pin before anything is written or uploaded, --record-revision does not override that, and the bytes are staged outside the package tree and uploaded from there. The entry records hash_source chronicle_fetch, attested_by chronicle and the fetch date. publish-raw and inventory-artifacts report manifest-level defects, skip a manifest they cannot classify, and read release bytes from the staging directory only. The CLI gains the matching flags and prints refusals as errors instead of tracebacks. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
PR #226's gate round taught fetch-artifact to refuse a stray default manifest, a non-mapping files block and a same-bytes rename, to record into a fresh mapping when files: is an explicit null, and to report an object a preserved bucket already holds as skipped. Rebasing this branch over it left three seams, each reproduced failing-first on the rebased tree: - RawArtifactPublishEntry.to_dict listed "skipped" twice (ruff F601). The hash-only skip now shares #226's skipped/uploaded vocabulary behind HASH_ONLY_SKIP_PREFIX, so publish-raw counts both skipped_count and hash_only_refused_count without conflating them. - #226's explicit-null files: cases were refused by the explicit-kind rule: a kindless manifest holding source_id and a bare files: line has no entry that could be read as a publisher table. has_file_entries() says so, and manifest_kind treats an entry-less manifest like an absent one: the command writing its first entry declares the kind. A declared kind stays fixed (register-artifact and fetch-artifact --kind still refuse to reclassify one), and register-artifact records into a fresh mapping on an explicit null just as fetch does. - test_emit_needs_a_commit_it_can_read_or_be_told read a Chronicle commit once the fixture was committed here; it now copies the fixture outside any repository and into one where the manifests are untracked, and refuses in both. _upsert_manifest's repeated revision guard now calls the same _assert_recorded_identity_holds_these_bytes as the preflight, so #226's rename rule (identical bytes under another name, --record-revision does not apply) holds at the write as well; a direct call is pinned by test_the_manifest_write_refuses_a_same_bytes_rename_by_itself. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
PR #226's --manifest lets a fetch record into one of several manifests in a publisher directory. The hash-only guard read only that manifest, so a release registered licensed in manifest.yaml could be fetched, published through a sibling manifest_<package>.yaml, parsed by a source package that reads through the sibling, or reclassified by register-artifact, all under the very name the registration says must never hold bytes. Reproduced failing-first in tests/test_chronicle_package_directory.py (18 tests). The boundary is now the file in the directory, not the manifest naming it: - fetch-artifact reads every manifest the directory keeps before the publisher is touched and refuses a name or a digest any of them registers hash-only (the reviewed pin's digest before the read, the served bytes' digest before anything is written), refuses to overwrite a file a sibling records as other bytes, and refuses a manifest whose source_id/package_id differ from the fetch's. An artifact may not be named like a manifest, and --manifest must name manifest.yaml or manifest_<package>.yaml. - publish-raw and inventory-artifacts report filename_collision_across_ manifests / sha256_collision_across_manifests (a name or digest public in one manifest and hash-only in another, or public under two digests) and publish nothing under such a directory; the tracked shape of one public file recorded by two manifests as the same bytes is accepted. validate_manifest_files gains sha256_collision within one manifest. - register-artifact takes --manifest with the same stray-default refusal as fetch, refuses a manifest-named filename, and refuses an identity (by name or digest) any manifest in the directory holds public or archived. - The source-package byte reader refuses a file a sibling manifest registers hash-only, by name or digest, before any store is consulted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Chronicle's operational stores migrate by dual-run (chronicle#143, mechanism 3). Every env read was ledger-first with no chronicle-named alternative, and each of the three modules that read configuration had grown its own helper. Add chronicle/env.py: env_value/env_flag expand a name into CHRONICLE_<X>, LEDGER_<X>, POLICYENGINE_LEDGER_<X> and return the first set value, warning once per process with ChronicleEnvDeprecationWarning when a ledger-era name supplied it. Names outside those three prefixes, such as POLICYENGINE_SUPABASE_URL, are read literally so the helper renames the ledger-era surface only. The warning subclasses FutureWarning, not DeprecationWarning, so operators running the CLI actually see it. The Supabase schema name stays "ledger"; only the env var that overrides it moves to CHRONICLE_SCHEMA. Renaming the schema is a later slice. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Bucket names: add CHRONICLE_R2_RAW_BUCKET / CHRONICLE_R2_DERIVED_BUCKET, resolved in the function body rather than bound as keyword defaults so the setting reaches long-lived processes. Defaults are unchanged at ledger-raw and ledger-derived; only the follow-up cutover PR flips them. Plumbed through fetch-artifact, publish-raw, publish-derived and bootstrap-r2, whose --r2-bucket/--raw-bucket/--derived-bucket now default to the resolver. Because the bucket can now vary, two manifest write paths could restate recorded storage.r2 blocks. Archived witness records pin raw R2 URLs by hash, so both now preserve history: publish-raw reports recorded_r2_bucket_is_preserved_history instead of uploading, and fetch-artifact keeps an already-recorded r2 block rather than overwriting it with a different bucket. The consumer-fact boundary guard matched the literal strings ledger-derived: and r2://ledger-derived/, so a renamed bucket would have made it silently stop firing. It now matches on shape (bucket ends in -derived, or key starts with derived/) and parses the r2:// URI, which also closes the uri-only hole where a fact carrying no bucket or key slipped past. Database artifact: new suite outputs write chronicle.db. infer_build_id reads chronicle.db then ledger.db (both branches were byte-identical, so the existing fallback was a no-op), and _derived_artifact_kind classifies both names (its set literal held one element twice). The sidecar resource list moves with the write site because _resource_descriptor stats every listed path. Fix db/cli.py, which imported the private env reader that the shared helper replaced. That ImportError broke every db CLI subcommand, including the three the CI job runs, while pytest stayed green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
tests/test_chronicle_env.py covers the shared helper end to end: the lookup ladder, CHRONICLE_* winning over both ledger-era spellings without a warning, each legacy spelling still working with a once-per-process warning attributed to the caller, empty values counting as unset, and a migrated operator being able to turn a flag off without unsetting the stale legacy name. It then exercises the real call sites -- source-package artifact cache and fetch flag, the db CLI's --pe-us-root default, the Supabase schema override, and R2 bucket resolution -- so a helper regression cannot pass by only testing the helper. An autouse fixture strips every rename-window variable from the ambient environment, so the file is hermetic under any shell. The artifacts tests cover the chronicle.db write and ledger.db read fallback, both database names classifying as sqlite_database, publish-derived following the configured bucket, and the two manifest-preservation paths: publish-raw refusing to restate a recorded bucket and fetch-artifact keeping the recorded r2 block while still uploading the backfill copy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs/storage-architecture.md stated the fallback direction backwards: it claimed
the CHRONICLE_-prefixed variables were the old names kept as migration
fallbacks, when they are the names to migrate to. Replaced with an "Environment
Variable Rename Window" section that gives the actual lookup order, the
once-per-process warning, and a table of all seven variables. Two behaviors get
stated outright because both invert a naive fallback: the CHRONICLE_ name wins
even when it reads false, so a migrated operator can turn a flag off without
hunting down a stale legacy export; and an empty value counts as unset. The
section also records what is deliberately NOT aliased -- POLICYENGINE_SUPABASE_*
and POLICYENGINE_TARGETS_SCHEMA carry no ledger prefix and are read literally --
and that CHRONICLE_SCHEMA renames the override variable, not the schema value,
which moves in a later slice.
Added a "Bucket Cutover" section covering the six steps: create the buckets on
the account wrangler.toml pins, enumerate, backfill-copy, verify, flip the
defaults in a follow-up, and leave the ledger-era buckets read-only forever.
The enumeration and verification are given as commands rather than prose. Every
raw key ends {sha256}/{filename}, so the key is its own checksum witness and
verification needs no manifest lookup. Counted rather than assumed: 186 distinct
ledger-raw objects across 154 tracked manifest files, and the doc says to
recount rather than trust that number, since source packages land continuously.
The derived bucket needs no backfill -- derived artifacts are reproducible and
already keyed by {build_id}, so a rebuild republishes them wherever configured.
README and the harness doc follow: bucket references become archive roles with
the default named, ledger.db becomes chronicle.db with the legacy name noted as
still readable, and bootstrap-r2's example drops the hardcoded bucket flags now
that they default to the resolver.
Left alone: LEDGER_EXPLORER_DATA_DIRS in the README. It configures the explorer
app, which is not in this repository, so renaming it here would document a name
no shipped code honors -- the same defect this commit fixes in the other
direction.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The consumer-fact boundary guard now matches derived artifacts on shape, but nothing tested the cases that motivated the change. Both of these pass every check the old literal matching applied, so they would have slipped through silently once the buckets are renamed: - a fact carrying no bucket and no key, only a `r2://chronicle-derived/derived/source/fact.json` URI. The old code compared the URI against two hardcoded prefixes, `r2://ledger-derived/` and `r2://ledger-raw/derived/`, and matched neither. - a `source_file` prefixed `chronicle-derived:`. The old code matched the literal prefix `ledger-derived:`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The env-isolation fixture lived in tests/test_chronicle_env.py, so only that
module ran with CHRONICLE_/POLICYENGINE_LEDGER_/LEDGER_ variables stripped.
Tests elsewhere assert the defaults those variables override — the raw and
derived bucket names, the Supabase schema — and failed when an operator's
shell had them set:
CHRONICLE_R2_RAW_BUCKET=zzz CHRONICLE_SCHEMA=zzz uv run pytest -q
FAILED tests/test_chronicle_artifacts.py::test_publish_source_artifacts_uploads_manifest_entries
The fixture moves to tests/conftest.py as a suite-wide autouse fixture, and
tests/test_chronicle_env.py gains an assertion that no rename-window variable
reaches a test.
db.supabase_client resolves LEDGER_SCHEMA and TARGETS_SCHEMA at import, which
happens during collection — before any fixture runs — so the namespace test
re-imports the module under the cleared environment instead of asserting on the
constant it bound at collection time.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A raw R2 key is content-addressed, so a recorded storage.r2 block is a claim about specific bytes. fetch-artifact preserved that block whenever one existed and the fetch did not re-upload into the same bucket, then rewrote the entry's sha256 and size_bytes from the newly fetched bytes. When a publisher re-publishes under the same URL and vintage — the IRS did exactly this to the 2022 IRA tables today (#225) — the manifest ended up describing the new bytes while pointing at the old object's key and URI. Verified against this branch's parent: a second fetch of different bytes leaves sha256=109dcf49… in an entry whose storage.r2 key is addressed by c63744a4…, both when the fetch only registers the bytes and when the bucket default has moved. Identity now decides, not the bucket: - the recorded key's last two segments (sha256, filename) are compared with the fetched bytes. Identical: the recorded block is preserved exactly, whichever bucket is configured now. Different: SourceArtifactRevisionError, raised before the cached artifact or its manifest entry is touched, naming recorded and fetched sha256/size_bytes and the ADR rule that the same vintage with new bytes is a new release revision. - --record-revision opts in: the fetched bytes get their own content-addressed key under the configured bucket, never the old key, and the superseded block moves to storage.previous_r2 with its sha256, size_bytes and fetched_at so the earlier bytes stay addressable. - publish-raw applies the same check before treating a recorded block as history, so a local file the recorded object does not hold is refused rather than uploaded. storage.previous_r2 is a sibling of storage.r2: every reader (inventory-artifacts, publish-raw, source_package._artifact_content and the suite's raw-R2-link acceptance check) reads storage.r2 alone, and publish-raw already spreads the existing storage block when it rewrites, so a revision survives publication untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The rename-window text said a recorded storage.r2 block is preserved because the bucket may have moved. That is the weaker half of the rule: the block is preserved because it addresses the bytes in hand. Adds a Publisher Revisions section covering the identity check, the refusal, --record-revision, the storage.previous_r2 shape and why every existing storage.r2 reader is unaffected, and points the bucket-cutover, publish-flow and harness passages at it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A storage.r2 block written by Chronicle always carries both key and uri, but a
hand-written one need not. The identity read now falls back to the uri, whose
last two segments are the same {sha256}/{filename}, so a block that records
only where the object lives is still recognized as history for the bytes it
holds instead of being refused as a revision.
Also covers registering a revision without an upload: the entry is left with no
storage.r2 at all rather than a pointer to bytes R2 does not hold, the
superseded object stays in storage.previous_r2, and publish-raw completes the
registration.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Dict equality would pass on a block that was rebuilt with the same values in a different order, which is not what "preserved" means for a manifest that gets dumped back to YAML. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The hashes named the fix lane's two-body fixture, not IRS bytes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The seven findings, in the order the fixes depend on each other, plus what a scan of every tracked manifest says about how strict the locator check can be: 187 entries, all content-addressed, no contradictory field. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The consumer boundary matched the `.ledger_derived` suffix literally, so a producer that renamed its derived rows with everything else would have walked a downstream target fact straight through the guard. Match the whole final dot-segment against both spellings instead. Without the fix the two new cases fail: the chronicle-spelled record id validates clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The hosted mirror loader is the primary writer into the schema, and both it and its CLI defaulted to the literal "ledger": setting CHRONICLE_SCHEMA to rehearse a cutover moved the read-side client and left the writer pointed at production. The default now resolves through the shared helper -- one home for the CHRONICLE_SCHEMA -> POLICYENGINE_LEDGER_SCHEMA -> LEDGER_SCHEMA -> "ledger" ladder -- whenever no explicit --schema is supplied. Defaults are unchanged. Resolution is a function rather than a module constant, so db.supabase_client no longer binds the schema while being imported. That import happens at collection, before any fixture, which is why the namespace test needed a reload dance to assert the defaults; it now just calls the resolver. A pytest_configure hook clears the rename window before collection too, so no module can read (or warn from) an operator's shell on the way in. Six tests fail against the previous code: the loader and its CLI ignore the variable, and the namespace assertions see the collection-time constant. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Four defects in the state fetch-artifact reads before it writes: - It always targeted manifest.yaml. Seven tracked packages keep a manifest_*_source_package.yaml instead, and three publisher directories keep two of them, so the IRA revision workflow the docs cite would have written a third manifest beside the real ones and never seen the recorded block. A --manifest filename now selects it; the name has to stay inside the package. - Revision protection vanished when the entry had no storage.r2 -- a fetch that only registered bytes, or one whose upload failed, which is the state #225 landed in. A manifest entry identifies its bytes by its declared sha256 whether or not it has been published, and a fetch of different bytes over either identity is refused unless --record-revision opts in. - Recorded R2 validation read the key or the uri, whichever came first. A block whose key and uri named different objects was preserved verbatim, so the entry kept publishing a URI for bytes it no longer described. Every supplied locator field is now cross-checked against every other and against the content-addressed key shape; a contradiction is an error at fetch time and a refusal at publish time, never a silent preserve. - A manifest that parsed as anything but a mapping was treated as absent, so the fetch would replace it with a single entry. It is now refused before the publisher is read at all, and inventory-artifacts and publish-raw report it rather than crashing on it. The refusals share a SourceArtifactManifestError base, so the CLI reports all of them as an exit-1 message with nothing written. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
What a manifest entry identifies (declared sha256 from registration, the content-addressed key once published), which manifest a fetch addresses in a package that keeps more than one, what a recorded storage.r2 block has to say for itself, and that load-supabase-mirror takes its schema default from CHRONICLE_SCHEMA like every other reader. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
_validated_recorded_storage sits next to _validated_recorded_r2 and above its only caller, instead of a _recorded_storage_block that read like a variant of the lenient accessor. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…unts Main gained packages (#239) between the round-4 fixes and the rebase, so the exact 161/194 counts no longer hold; the sweep's contract is that every tracked artifact is a preserved-bucket skip with an R2 link, nothing uploads or fails, and the command exits 0. Floors keep the test from passing on an empty tree.
fa98993 to
ba8147a
Compare
…ML-aliased owners skipped on revision
…y revision owners by manifest and vintage - is_derived_r2_route treats any bucket ending in "-derived" (case-folded) as derived alongside the configured bucket/prefix set, so configuring a route can extend the consumer boundary but never narrow it; archived facts citing publisher-derived buckets keep failing derived_fact_provenance. - The selected revision owner is identified by (manifest path, vintage key) at both the preflight and the write, not by dict identity: a YAML anchor can make two vintages share one object, which skipped every owner and left the aliased vintage on the superseded checksum.
The strict loader flattened `<<` merges first, so an entry that overrode a merged default with its own explicit key was refused as a duplicate. Explicit keys are now checked among themselves before merge expansion; merges then apply with YAML precedence (the explicit key wins), while two explicit spellings of one key are still refused.
…apping PyYAML's flatten_mapping rewrites a node in place and construction is lazy, so a later `<<: *entry` merge turned an anchored entry's inherited keys into apparent explicit duplicates, and an inline merge mapping's own duplicate keys were consumed without any check. The strict loader now splits each mapping into merge sources and explicit pairs (refusing explicit duplicates), expands merge sources recursively into a fresh pair list with the same check applied to every mapping reached through a merge, and builds the result with YAML precedence, leaving every node untouched.
Failing-first regression for Astra gate round 4: a <<: [first, second] merge must select first's filename/sha256 (YAML merge-key precedence, matching yaml.safe_load), with explicit keys still overriding every merged source, including nested merge sequences.
…ader The non-mutating merge expansion appended a <<: [a, b] sequence's mappings in document order and assigned last-wins, so the LAST mapping won; YAML gives precedence to the FIRST. Contribute later mappings first so the first mapping's pairs are assigned last. Validation of every sequence entry still happens before any pair is contributed.
Failing-first regressions for Astra gate 6aa29716 round 1: - a mapping that merges itself (directly, through a merge sequence, or through a merged mapping's own merge) must be refused as a yaml.YAMLError, not escape as RecursionError; - two manifests identifying one package-local file through identical content-addressed R2 locators without a declared sha256 must stay valid for every sweep after an identical-byte refetch records sha256 on the selected manifest only.
…ifests
registration: the strict loader tracks the mappings whose merges are being
expanded and refuses a source that is already active with a
ConstructorError ("found a recursive merge"), so self-merging manifests
fail through the readers' existing yaml.YAMLError handling instead of a
RecursionError traceback. Repeated (non-cyclic) sources still merge.
artifacts: validate_package_directory now accepts an entry_digest resolver
and the owner-agreement preflight passes _effective_recorded_digest, which
resolves the digest an entry's recorded content-addressed R2 key encodes
(the identity _recorded_identity uses) before falling back to the declared
sha256 field. A sibling that only carries the locator no longer reads as an
empty digest after an identical-byte refetch, so inventory and publication
stop reporting a false filename_collision_across_manifests.
…ublication Failing-first regressions for Astra gate 6aa29716 round 2: two manifests naming one package-local file before either records an identity must stay valid for every sweep after publishing only one of them, and after a full sweep whose second upload fails; the sibling's later publication records the same identity. Identifying a sibling whose package-local bytes no longer match the identified owner must be refused before any upload.
…es before upload validate_package_directory no longer counts an entry that records no identity (no sha256, no locator) against identified owners: it has nothing to contradict, so a selected publication or a partially failed sweep that identifies one manifest cannot strand its siblings behind a false filename_collision_across_manifests. The owner-agreement preflight now also hashes the package-local bytes of each unidentified entry that shares a filename with identified owners and refuses, before any upload or manifest rewrite, when they differ from the recorded identity: identifying that entry would give one file two identities.
Failing-first regression for Astra gate 6aa29716 round 3: a manifest entry predeclared with only filename and source_url must acquire its initial bytes on first fetch (default and --record-revision), while another manifest's unidentified entry naming the same file keeps refusing the fetch before any byte is written.
… identifies
_manifest_file_owners refused every entry naming the artifact that records
no identity, including the selected entry on its first fetch, so a manifest
predeclaring files: {2024: {filename, source_url}} could never acquire its
bytes (MalformedManifestError before publisher I/O; --record-revision too).
The fetch preflight, the sibling-bytes check, and the manifest writer now
name the (manifest, vintage) entry they are initializing; that entry is not
an owner yet and is skipped, while every other unidentified entry naming
the file keeps the existing refusal.
…anifests Failing-first regression for Astra gate 6aa29716 round 4: publishing one manifest with --source-id/--package-id supplying its missing identifier must not preflight an unselected sibling that declares a different identifier against the override; the selected publication succeeds and the sibling is left untouched.
…fest only The package-wide publish preflight applied --source-id/--package-id to every sibling manifest in the directory, so a selected publication that supplied the selected manifest's missing identifier was refused before upload when a sibling declared a different one. Unselected siblings are now preflighted with their own identifiers; the override completes or confirms only the selected manifest.
… sibling Failing-first regression for Astra gate cc680cc1 round 1: a default sweep selects every manifest in the directory, so --source-id/--package-id must complete the manifest lacking the identifier and confirm the sibling that declares the same value regardless of processing order.
…lection The package preflight applied --source-id/--package-id only to the manifest currently being processed, so in a default sweep (every manifest selected) a selected sibling met through another manifest's package preflight was treated as unselected, preflighted without the override, and the whole publication aborted with r2_identity_invalid. The selected set is now computed up front from the root sweep; selected siblings take the overrides, unselected siblings keep their own identifiers.
fetch-artifact resolved the destination name only inside the read, compared filenames as raw strings, trusted an explicit --kind over the stored one, looked up only the integer year, and replaced list entries wholesale. Each of those let bytes past the hash-only boundary or lost a recorded identity. Now, before the publisher is read: the filename is a bare name (inferred from the URL without I/O when --filename is omitted), every alias of a hash-only registration is refused, a --kind that conflicts with the manifest is refused, a kindless manifest with content is refused, and the whole manifest is validated with the same codes inventory reports. The vintage entry is selected by key spelling and bare filename, so PR #226's recorded identity guard covers list-shaped release vintages, and _upsert_manifest replaces that entry in place, carries forward the fields a fetch does not own, never turns a publisher-table mapping into a list, and always writes kind. A public microdata release is archived only with --expected-sha256 and licence evidence bound to it: the fetched bytes are checked against the pin before anything is written or uploaded, --record-revision does not override that, and the bytes are staged outside the package tree and uploaded from there. The entry records hash_source chronicle_fetch, attested_by chronicle and the fetch date. publish-raw and inventory-artifacts report manifest-level defects, skip a manifest they cannot classify, and read release bytes from the staging directory only. The CLI gains the matching flags and prints refusals as errors instead of tracebacks. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
PR #226's gate round taught fetch-artifact to refuse a stray default manifest, a non-mapping files block and a same-bytes rename, to record into a fresh mapping when files: is an explicit null, and to report an object a preserved bucket already holds as skipped. Rebasing this branch over it left three seams, each reproduced failing-first on the rebased tree: - RawArtifactPublishEntry.to_dict listed "skipped" twice (ruff F601). The hash-only skip now shares #226's skipped/uploaded vocabulary behind HASH_ONLY_SKIP_PREFIX, so publish-raw counts both skipped_count and hash_only_refused_count without conflating them. - #226's explicit-null files: cases were refused by the explicit-kind rule: a kindless manifest holding source_id and a bare files: line has no entry that could be read as a publisher table. has_file_entries() says so, and manifest_kind treats an entry-less manifest like an absent one: the command writing its first entry declares the kind. A declared kind stays fixed (register-artifact and fetch-artifact --kind still refuse to reclassify one), and register-artifact records into a fresh mapping on an explicit null just as fetch does. - test_emit_needs_a_commit_it_can_read_or_be_told read a Chronicle commit once the fixture was committed here; it now copies the fixture outside any repository and into one where the manifests are untracked, and refuses in both. _upsert_manifest's repeated revision guard now calls the same _assert_recorded_identity_holds_these_bytes as the preflight, so #226's rename rule (identical bytes under another name, --record-revision does not apply) holds at the write as well; a direct call is pinned by test_the_manifest_write_refuses_a_same_bytes_rename_by_itself. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
PR #226's --manifest lets a fetch record into one of several manifests in a publisher directory. The hash-only guard read only that manifest, so a release registered licensed in manifest.yaml could be fetched, published through a sibling manifest_<package>.yaml, parsed by a source package that reads through the sibling, or reclassified by register-artifact, all under the very name the registration says must never hold bytes. Reproduced failing-first in tests/test_chronicle_package_directory.py (18 tests). The boundary is now the file in the directory, not the manifest naming it: - fetch-artifact reads every manifest the directory keeps before the publisher is touched and refuses a name or a digest any of them registers hash-only (the reviewed pin's digest before the read, the served bytes' digest before anything is written), refuses to overwrite a file a sibling records as other bytes, and refuses a manifest whose source_id/package_id differ from the fetch's. An artifact may not be named like a manifest, and --manifest must name manifest.yaml or manifest_<package>.yaml. - publish-raw and inventory-artifacts report filename_collision_across_ manifests / sha256_collision_across_manifests (a name or digest public in one manifest and hash-only in another, or public under two digests) and publish nothing under such a directory; the tracked shape of one public file recorded by two manifests as the same bytes is accepted. validate_manifest_files gains sha256_collision within one manifest. - register-artifact takes --manifest with the same stray-default refusal as fetch, refuses a manifest-named filename, and refuses an identity (by name or digest) any manifest in the directory holds public or archived. - The source-package byte reader refuses a file a sibling manifest registers hash-only, by name or digest, before any store is consulted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Summary
First slice of #143 mechanism 3 (operational stores migrate by dual-run). Code and docs only; no infrastructure changes and no defaults flipped.
chronicle/env.py— one shared reader for the rename window:CHRONICLE_<X>is read first, thenPOLICYENGINE_LEDGER_<X>, thenLEDGER_<X>, with a once-per-processFutureWarning(so CLI operators actually see it) naming the preferred variable. Replaces the three ad-hoc_envhelpers indb/supabase_client.py,chronicle/source_package.py, anddb/pe_source_inventory.py. Names outside the window are read literally.CHRONICLE_R2_RAW_BUCKET,CHRONICLE_R2_DERIVED_BUCKET, plumbed throughfetch-artifact,publish-raw,publish-derived,bootstrap-r2and their--r2-bucketdefaults). Defaults stayledger-raw/ledger-derived; the flip is a follow-up PR once consumers repoint.chronicle.dbfor new suite outputs;ledger.dbstill read and inferred.docs/storage-architecture.mdgains an "Environment Variable Rename Window" section (the previous sentence had the fallback direction backwards) and a "Bucket Cutover" checklist; README and the harness doc swept.Two defects found and fixed on the way, each with a regression test that passes on
main's behavior and fails on the bug:db/cli.pyraisedImportErroron every db CLI subcommand (a private helper import that pytest never exercised).ledger-derived:/r2://ledger-derived/strings, so the bucket rename would have made it stop firing silently. It now matches on shape (*-derivedbucket orderived/key prefix).Out of scope, deliberately: the
ledgerCLI alias, Supabase schema and mirror table names, governance role ids and concept authorities (hashed → mechanism 1), hash domains and schema ids (the epoch PR).Note for the cutover checklist: this branch's count of manifest-declared raw objects differs from the one on #143 (the checklist tells the operator to recount rather than trust a number). The backfill performed today used the tracked manifests on
main: 157 distinct keys, all now present inchronicle-raw(#225 has the integrity findings).Review fixes
Gate round 1
Both findings are applied on this branch.
[high]
fetch-artifactcould attach a recorded R2 URI to new bytes. The preserve rule keyed on the bucket, so a repeated fetch that did not re-upload into the same bucket kept the recordedstorage.r2block while rewriting the entry'ssha256/size_bytes. Reproduced against this branch's parent by serving two different bodies from one URL: the entry ends up declaring the fetched bytes'sha256under a key addressed by the superseded bytes' one, both when the fetch only registers the bytes and when the bucket default has moved — the shape #225 hit when the IRS re-published the 2022 IRA tables.The rule now keys on identity: the recorded key's
{sha256}/{filename}tail against the fetched bytes.SourceArtifactRevisionError, raised before the cached artifact or its manifest entry is touched and before anything is uploaded, naming recorded and fetchedsha256/size_bytesand the ADR rule that same vintage plus new bytes is a newrelease_revision.--record-revisionopts in: the fetched bytes get their own content-addressed key under the configured bucket — never the old key — and the superseded block moves tostorage.previous_r2with itssha256,size_bytesandfetched_at, so the earlier bytes stay addressable at the URI archived witness records pin.publish-rawapplies the same check before treating a recorded block as history: a local file the recorded object does not hold is refused asrecorded_r2_identity_mismatchwith nothing uploaded.storage.previous_r2is a sibling key rather than a new shape forstorage.r2, because every reader —inventory-artifacts,publish-raw,source_package._artifact_content, the suite's raw-R2-link acceptance check — readsstorage.r2alone, andpublish-rawalready spreads the rest of thestorageblock when it writes back, so a revision survives publication untouched. All 180 tracked manifest entries carrying astorage.r2block are content-addressed and agree with their declaredsha256andfilename, so the check never fires on tracked data.[low] Env isolation was scoped to one module. The autouse fixture moves to
tests/conftest.pyand clears all three prefixes for every test.db.supabase_clientresolvesLEDGER_SCHEMAat import — during collection, before any fixture runs — sotests/test_chronicle_namespace.pyre-imports it under the cleared environment instead of asserting the constant it bound at collection time.New tests in
tests/test_chronicle_artifacts.py(fake downloader, no network): a repeated fetch of identical bytes preserves the block; a repeated fetch of different bytes is refused on all three routes (re-uploaded, registered without an upload, after the bucket rename) with the cache, manifest and upload log untouched;--record-revisionrecords the new key under the configured bucket and retains the previous object; a revised manifest still inventories as one R2-linked artifact;publish-rawrefuses a mismatched local file and publishes a registered revision; and the CLI exits 1 with the message, then 0 with the flag.tests/test_chronicle_env.pyasserts no rename-window variable reaches a test.Verification on the fix head:
uv run pytest -qgreen;CHRONICLE_R2_RAW_BUCKET=zzz CHRONICLE_SCHEMA=zzz uv run pytest -qgreen (five tests failed this way before the shared fixture);uv run ruff check .clean.Gate round 2
Seven findings, all applied on this branch. Each has a regression test, and each was first reproduced against the round-1 head (
34d1d0f) — the pre-fix behavior quoted under each item is that run's output, not an inference.[high]
CHRONICLE_SCHEMAdid not configure the primary Supabase mirror writer.chronicle/mirror.py, its harness wrapper and the--schemaCLI default all defaulted to the literalledger, so setting the variable to rehearse a cutover moved the read-side client and left the writer pointed at production.chronicle/env.pynow ownsdefault_chronicle_schema()— one home for theCHRONICLE_SCHEMA→POLICYENGINE_LEDGER_SCHEMA→LEDGER_SCHEMA→"ledger"ladder — and the loader resolves through it whenever no schema is supplied. An explicit--schemastill wins, and the default is unchanged. Pre-fix: withCHRONICLE_SCHEMA=chronicle_probe,load_supabase_mirrorstill reportsschema='ledger'.[high] The derived-fact boundary was not rename-safe.
chronicle/consumer_contract.pymatched the.ledger_derivedsuffix literally. It now matches the whole final dot-segment against bothledger_derivedandchronicle_derived, so a producer that renames its derived rows cannot walk a downstream target fact through the guard. Pre-fix:'.chronicle_derived'.endswith('.ledger_derived')is False — the guard never fired.[high]
fetch-artifactcould not address a package's non-default manifest. Seven tracked packages keep amanifest_*_source_package.yaml, and three publisher directories keep two of them:db/data/irs_soi/ira_contributions/holds the traditional manifest beside the Roth one. A fetch that always wrotemanifest.yamlwould write a third manifest neither package reads, so the IRA revision workflow the docs cite would never see the recorded block.--manifest <filename>selects it (defaultmanifest.yaml); the name must be a filename inside--out-dir, not a path. Pre-fix:fetch_source_artifact()rejectsmanifest_filenameas an unexpected keyword, and a fetch into that directory writesmanifest.yaml.[high] Revision protection vanished when the entry had no
storage.r2. A fetch that only registered bytes, or one whose upload failed — the state #225 landed in — left an entry with a declaredsha256and no recorded block, and the guard skipped it entirely. A manifest entry identifies its bytes from the moment it is registered, so the comparison is now against the entry's recorded identity: the recorded key's{sha256}/{filename}once published, the declaredsha256before that. An ordinary fetch of different bytes over either is refused unless--record-revisionopts in; a revision over a never-published entry supersedes nothing and gets no emptyprevious_r2. Pre-fix: re-fetching different bytes over a registered entry rewrote itssha256with no refusal.[medium] Recorded-R2 validation read the key or the uri, whichever came first.
_validated_recorded_r2now cross-checks every supplied locator field against every other — key against the URI's path, bucket against its authority, provider against its scheme — and the resulting key against the content-addressed{sha256}/{filename}shape. A contradiction raisesRecordedR2LocatorErrorat fetch time and refuses asrecorded_r2_locator_invalidat publish time, with nothing uploaded. Pre-fix: a block whose key and uri named different objects was preserved verbatim — key shac63744a4…beside uri sha1e9b3fdb…— so the entry kept publishing a URI for bytes it no longer described.[medium] A malformed manifest read as an absent one.
_read_manifestnow refuses a document that parses as anything but a mapping, and an unparseable one, withMalformedManifestError— raised before the publisher is read at all, so a refusal costs no fetch.inventory-artifactsandpublish-rawreport it as a manifest error instead of crashing on.get. An absent or empty document still reads as an empty mapping. Pre-fix: a list-valuedmanifest.yamlwas overwritten by the fetch.[low] Schema resolution happened at import, during collection.
db.supabase_clientresolved both schemas into module constants when it was first imported — at collection, before any fixture — so a legacy variable could warn and seed state that no fixture could take back. Both are now functions (chronicle_schema(),targets_schema()), andtests/conftest.pyadditionally strips the rename window inpytest_configure, before collection imports anything. Pre-fix: importing underLEDGER_SCHEMA=zzzboundLEDGER_SCHEMA='zzz'and emitted aFutureWarning.All four refusals share a
SourceArtifactManifestErrorbase, sofetch-artifactreports every one as exit 1 with nothing written.New round-2 tests:
tests/test_chronicle_mirror.py(the loader and its CLI write to the configured schema, under each name in the window, with an explicit--schemastill winning);tests/test_chronicle_artifacts.py(a two-manifest package addressed by name and its revision refused in the right manifest, on the direct and CLI paths; a manifest name that tries to leave the package; revision protection over a registered entry and over a failed upload; every locator contradiction, at fetch and at publish time; malformed manifests refused before the fetch and reported by both sweeps);tests/test_chronicle_consumer_contract.py(the chronicle spelling rejected identically, and the marker matched as a whole segment);tests/test_chronicle_namespace.pyandtests/test_chronicle_env.py(schema read per call, no constant frozen at import).Verification on the round-2 head (
ea67f0a):uv run pytest -q— 839 passed, 1 skipped;LEDGER_SCHEMA=zzz uv run pytest -q -W error::FutureWarning tests/test_chronicle_namespace.py— 6 passed;CHRONICLE_R2_RAW_BUCKET=zzz CHRONICLE_SCHEMA=zzz uv run pytest -q— 839 passed, 1 skipped;uv run ruff check .clean;uv run ruff format --check .clean for every file this branch touches (the 13 unformatted files are pre-existing onmainand none are touched here).Scope is unchanged: no boundary change, nothing under
releases/ordb/datamanifests, defaults stillledger-raw/ledger-derived/ledger, and the shared functions #227 will rebase onto keep their signatures with the new arguments defaulted.Chronicle Governance
This PR does not touch the source-data boundary: no source packages, facts, profiles, or schemas change. Storage configuration, CLI defaults, and docs only.
Tests
tests/test_chronicle_env.py(new, hermetic: chronicle-first precedence, legacy fallback with one warning per process, out-of-window names read literally),tests/test_chronicle_artifacts.py(bucket configuration,chronicle.dboutput withledger.dbread-back, thedb/cli.pyregression),tests/test_chronicle_consumer_contract.py(boundary guard by shape). CI runs the full suite on this PR; the lane that authored the branch ran out of budget before pasting its local run, so treat CI here as the verification of record.🤖 Generated with Claude Code
Rebased onto #228 (9/4)
Rebased onto main at 9da0243 (epoch dual-domain acceptance). Five conflicts, all mechanical: import lines in
chronicle/artifacts.py,chronicle/source_package.py,chronicle/suite.py,tests/test_chronicle_artifacts.py(both sides kept), and the--build-idhelp text inchronicle/harness.py(merged: accepted epoch prefixes, defaults inferred from reports,chronicle.db, or a legacyledger.db). Full suite on the rebased head: 955 passed, 1 skipped;ruff checkclean; the one file the rebase left unformatted is formatted in the last commit.Gate round 3 (9/4)
Five low-severity findings, all applied:
ManifestNameError(aSourceArtifactManifestErrorthat is still aValueError) so the CLI reports a bad--manifestname aserror: …/ exit 1; afilesblock that is not a mapping is refused before the publisher is read; identical bytes under a different filename are refused as a rename (with and without--record-revision), naming the--filenamethat keeps the recorded identity;publish-rawreports an entry whose recorded object in another bucket holds the local bytes asskipped(newskipped_count; sweep exits 0 after the bucket flip); fetching with the default manifest name into a directory that keepsmanifest_*.yamlfiles and nomanifest.yamlis refused (AmbiguousManifestError, closing the #225 path without the flag). Full suite on this head: 961 passed, 1 skipped.Sol gate rounds 2–3 (9/4)
Twelve further findings closed across two Sol rounds. The documented bucket-cutover sweep now exits 0 over a copy of the tracked registry (161 manifests, 194 artifacts skipped or linked, none uploaded or failed) because recorded content-addressed keys are accepted as preserved history when their checksum/filename tail matches the bytes, while contradictions still refuse. Shared physical archives across manifest pairs (USDA, CMS, SSA) are recognised: fetch refuses changes by default and rewrites every owner consistently under
--record-revision. Manifest-declared artifact paths that are absolute, parent-traversing, symlinked, or manifest-named are refused before any read; the ambiguity guard covers every supported manifest spelling and refuses creating any manifest beside an existing registry; sweep--manifestmust be a literal supported filename; all manifest reads use the strict duplicate-key loader; a present non-listprevious_r2is refused;LEDGER_SCHEMA/TARGETS_SCHEMAare import-time snapshots of the environment-aware resolvers. Round 3: new registration identities must be one canonical R2 key segment (IdentitySegmentError);matching_directory_entryrefuses more than one physical alias;resource_directoryis contained inside the resource package (no absolute, parent, dot, or symlinked-ancestor components); manifest-named entries that are not regular files fail sweeps and discovery loudly. Full suite on this head: 1,069 passed, 1 skipped;ruff checkclean; no trackeddb/data/**manifest changed.Peer round 4 (9/4, Astra): nine findings closed, then rebased onto current main
The derived-provenance boundary now derives from the configured derived bucket and prefix (
is_derived_r2_route, shared by publication and the consumer guard; case-insensitive scheme, archived routes retained) instead of a-derivedname heuristic. Source parsing and inventory reuse_validated_recorded_r2before any read, so contradictory, incomplete, or non-R2 locators are refused rather than emitted as provenance, and local or fetched bytes are checked against the recorded object's digest even without a separately declared checksum.publish-derivedwalks the build tree withlstatfirst and refuses symlinks and non-regular entries before any upload. Every new-key publication path (derived, first-time raw) requires canonical identity segments; present manifestsource_id/package_iddeclarations must be canonical strings equal to the arguments (no strip or stringify). A single case- or Unicode-normalized filename alias whose physical spelling differs from the manifest is an entry error in publish and inventory, never a read. Logical vintage duplicates (2024beside"2024") are refused by onevalidate_manifest_vintagesinvoked fromload_manifest_document, so every manifest consumer sees them. Discovery's non-regular-manifest refusal is translated toMalformedManifestErrorat the publish and inventory sweeps and their CLI wrappers. The source-package resolver requires regular files for both manifest and artifact entries. The branch is rebased onto current main (the OTS proof "deletions" a reviewer saw were the stack's stale base, not changes in this PR).Full suite on the rebased head: 1,238 passed, 7 skipped (Astra lane) and, after the rebase, every test green with the cutover sweep pinned by invariants;
ruff checkclean.Astra gate 6aa29716 round 1 (9681c1c)
chronicle/artifacts.py):validate_package_directorycompared only the declaredsha256, so a sibling manifest identifying the same file solely through its content-addressed R2 locator read as an empty digest after an identical-byte refetch recordedsha256on the selected manifest. It now takes anentry_digestresolver (default unchanged) and the owner-agreement preflight passes_effective_recorded_digest, which resolves the digest the recorded R2 key encodes via_validated_recorded_r2before falling back to the declared field. Regressiontest_identical_byte_refetch_keeps_r2_identified_shared_file_valid(both sweeps valid before and after the refetch).chronicle/registration.py): the strict loader tracks the mappings whose merges are being expanded and refuses a source that is already active withConstructorError("found a recursive merge…"), ayaml.YAMLErrorthe readers handle. Regressiontest_strict_loader_refuses_recursive_merges_as_yaml_errors(direct, via sequence, via merged mapping; repeated non-cyclic source still merges).Astra gate 6aa29716 round 2 (cd14372)
sha256, no locator) read as an empty digest, so identifying one manifest (selectedpublish, or a full sweep whose second upload failed) turned its sibling into a falsefilename_collision_across_manifests. Such entries are now skipped by the directory-level comparison (nothing to contradict), and the owner-agreement preflight hashes their package-local bytes against the identified owners and refuses before any upload when they differ. Regressions:test_selected_publication_of_a_shared_unidentified_file_keeps_siblings_valid,test_partial_upload_failure_of_a_shared_file_stays_retryable,test_sweeps_refuse_identifying_a_shared_file_whose_bytes_changed(all red on 9681c1c).Astra gate 6aa29716 round 3 (7d9fd06)
_manifest_file_ownersrefused every entry naming the artifact without a recorded identity, including the selected entry on its own first fetch, sofiles: {2024: {filename, source_url}}could never acquire its bytes. The fetch preflight, the sibling-bytes check, and the manifest writer now name the(manifest, vintage)entry they are initializing; it is skipped as not-yet-an-owner, while every other unidentified entry naming the file keeps the refusal. Regressions:test_first_fetch_initializes_a_predeclared_entry_without_identity[False|True](red on cd14372) andtest_fetch_still_refuses_an_unidentified_owner_in_another_manifest(retained protection).Astra gate 6aa29716 round 4 → fresh gate (d544ffe)
--source-id/--package-idto every sibling, so publishing one manifest with its missing identifier supplied was refused when a sibling declared a different one. The override now completes or confirms only the selected manifest; unselected siblings are preflighted with their own identifiers (inventory takes no overrides). Regressiontest_selected_publication_overrides_apply_only_to_the_selected_manifest[source_id|package_id](red on 7d9fd06).Astra gate cc680cc1 round 1 (3eea393)
r2_identity_invalid. The selected set is now computed up front from the root sweep; selected siblings take the overrides, unselected siblings keep their own identifiers. Regressiontest_default_sweep_overrides_apply_to_every_selected_sibling[source_id|package_id](red on d544ffe).uv run pytest -q -p no:cacheprovider tests1281 passed, 1 skipped (20:11). Astra gate cc680cc1 round 2: approve.