Skip to content

Operational rename, slice 1: CHRONICLE_* env names, chronicle.db output, configurable buckets, cutover checklist (#143, mechanism 3) - #226

Open
MaxGhenis wants to merge 105 commits into
mainfrom
ops-rename-slice1
Open

Operational rename, slice 1: CHRONICLE_* env names, chronicle.db output, configurable buckets, cutover checklist (#143, mechanism 3)#226
MaxGhenis wants to merge 105 commits into
mainfrom
ops-rename-slice1

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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, then POLICYENGINE_LEDGER_<X>, then LEDGER_<X>, with a once-per-process FutureWarning (so CLI operators actually see it) naming the preferred variable. Replaces the three ad-hoc _env helpers in db/supabase_client.py, chronicle/source_package.py, and db/pe_source_inventory.py. Names outside the window are read literally.
  • R2 bucket names are configurable (CHRONICLE_R2_RAW_BUCKET, CHRONICLE_R2_DERIVED_BUCKET, plumbed through fetch-artifact, publish-raw, publish-derived, bootstrap-r2 and their --r2-bucket defaults). Defaults stay ledger-raw / ledger-derived; the flip is a follow-up PR once consumers repoint.
  • chronicle.db for new suite outputs; ledger.db still read and inferred.
  • Docs: docs/storage-architecture.md gains 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.py raised ImportError on every db CLI subcommand (a private helper import that pytest never exercised).
  • The consumer-fact boundary guard matched the hard-coded ledger-derived: / r2://ledger-derived/ strings, so the bucket rename would have made it stop firing silently. It now matches on shape (*-derived bucket or derived/ key prefix).

Out of scope, deliberately: the ledger CLI 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 in chronicle-raw (#225 has the integrity findings).

Review fixes

Gate round 1

Both findings are applied on this branch.

[high] fetch-artifact could 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 recorded storage.r2 block while rewriting the entry's sha256/size_bytes. Reproduced against this branch's parent by serving two different bodies from one URL: the entry ends up declaring the fetched bytes' sha256 under 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.

  • Identical — the recorded block is preserved exactly, whichever bucket is configured now.
  • DifferentSourceArtifactRevisionError, raised before the cached artifact or its manifest entry is touched and before anything is uploaded, naming recorded and fetched sha256/size_bytes and the ADR rule that same vintage plus 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 at the URI archived witness records pin.
  • publish-raw applies the same check before treating a recorded block as history: a local file the recorded object does not hold is refused as recorded_r2_identity_mismatch with nothing uploaded.

storage.previous_r2 is a sibling key rather than a new shape for storage.r2, because every reader — inventory-artifacts, publish-raw, source_package._artifact_content, the suite's raw-R2-link acceptance check — reads storage.r2 alone, and publish-raw already spreads the rest of the storage block when it writes back, so a revision survives publication untouched. All 180 tracked manifest entries carrying a storage.r2 block are content-addressed and agree with their declared sha256 and filename, so the check never fires on tracked data.

[low] Env isolation was scoped to one module. The autouse fixture moves to tests/conftest.py and clears all three prefixes for every test. db.supabase_client resolves LEDGER_SCHEMA at import — during collection, before any fixture runs — so tests/test_chronicle_namespace.py re-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-revision records the new key under the configured bucket and retains the previous object; a revised manifest still inventories as one R2-linked artifact; publish-raw refuses 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.py asserts no rename-window variable reaches a test.

Verification on the fix head: uv run pytest -q green; CHRONICLE_R2_RAW_BUCKET=zzz CHRONICLE_SCHEMA=zzz uv run pytest -q green (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_SCHEMA did not configure the primary Supabase mirror writer. chronicle/mirror.py, its harness wrapper and the --schema CLI default all defaulted to the literal ledger, so setting the variable to rehearse a cutover moved the read-side client and left the writer pointed at production. chronicle/env.py now owns default_chronicle_schema() — one home for the CHRONICLE_SCHEMAPOLICYENGINE_LEDGER_SCHEMALEDGER_SCHEMA"ledger" ladder — and the loader resolves through it whenever no schema is supplied. An explicit --schema still wins, and the default is unchanged. Pre-fix: with CHRONICLE_SCHEMA=chronicle_probe, load_supabase_mirror still reports schema='ledger'.

[high] The derived-fact boundary was not rename-safe. chronicle/consumer_contract.py matched the .ledger_derived suffix literally. It now matches the whole final dot-segment against both ledger_derived and chronicle_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-artifact could not address a package's non-default manifest. Seven tracked packages keep a manifest_*_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 wrote manifest.yaml would 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 (default manifest.yaml); the name must be a filename inside --out-dir, not a path. Pre-fix: fetch_source_artifact() rejects manifest_filename as an unexpected keyword, and a fetch into that directory writes manifest.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 declared sha256 and 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 declared sha256 before that. An ordinary fetch of different bytes over either is refused unless --record-revision opts in; a revision over a never-published entry supersedes nothing and gets no empty previous_r2. Pre-fix: re-fetching different bytes over a registered entry rewrote its sha256 with no refusal.

[medium] Recorded-R2 validation read the key or the uri, whichever came first. _validated_recorded_r2 now 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 raises RecordedR2LocatorError at fetch time and refuses as recorded_r2_locator_invalid at publish time, with nothing uploaded. Pre-fix: a block whose key and uri named different objects was preserved verbatim — key sha c63744a4… beside uri sha 1e9b3fdb… — so the entry kept publishing a URI for bytes it no longer described.

[medium] A malformed manifest read as an absent one. _read_manifest now refuses a document that parses as anything but a mapping, and an unparseable one, with MalformedManifestError — raised before the publisher is read at all, so a refusal costs no fetch. inventory-artifacts and publish-raw report 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-valued manifest.yaml was overwritten by the fetch.

[low] Schema resolution happened at import, during collection. db.supabase_client resolved 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()), and tests/conftest.py additionally strips the rename window in pytest_configure, before collection imports anything. Pre-fix: importing under LEDGER_SCHEMA=zzz bound LEDGER_SCHEMA='zzz' and emitted a FutureWarning.

All four refusals share a SourceArtifactManifestError base, so fetch-artifact reports 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 --schema still 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.py and tests/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 on main and none are touched here).

Scope is unchanged: no boundary change, nothing under releases/ or db/data manifests, defaults still ledger-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.db output with ledger.db read-back, the db/cli.py regression), 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-id help text in chronicle/harness.py (merged: accepted epoch prefixes, defaults inferred from reports, chronicle.db, or a legacy ledger.db). Full suite on the rebased head: 955 passed, 1 skipped; ruff check clean; 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 (a SourceArtifactManifestError that is still a ValueError) so the CLI reports a bad --manifest name as error: … / exit 1; a files block 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 --filename that keeps the recorded identity; publish-raw reports an entry whose recorded object in another bucket holds the local bytes as skipped (new skipped_count; sweep exits 0 after the bucket flip); fetching with the default manifest name into a directory that keeps manifest_*.yaml files and no manifest.yaml is 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 --manifest must be a literal supported filename; all manifest reads use the strict duplicate-key loader; a present non-list previous_r2 is refused; LEDGER_SCHEMA/TARGETS_SCHEMA are import-time snapshots of the environment-aware resolvers. Round 3: new registration identities must be one canonical R2 key segment (IdentitySegmentError); matching_directory_entry refuses more than one physical alias; resource_directory is 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 check clean; no tracked db/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 -derived name heuristic. Source parsing and inventory reuse _validated_recorded_r2 before 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-derived walks the build tree with lstat first and refuses symlinks and non-regular entries before any upload. Every new-key publication path (derived, first-time raw) requires canonical identity segments; present manifest source_id/package_id declarations 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 (2024 beside "2024") are refused by one validate_manifest_vintages invoked from load_manifest_document, so every manifest consumer sees them. Discovery's non-regular-manifest refusal is translated to MalformedManifestError at 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 check clean.

Astra gate 6aa29716 round 1 (9681c1c)

  • False shared-file collision (medium, chronicle/artifacts.py): validate_package_directory compared only the declared sha256, 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 recorded sha256 on the selected manifest. It now takes an entry_digest resolver (default unchanged) and the owner-agreement preflight passes _effective_recorded_digest, which resolves the digest the recorded R2 key encodes via _validated_recorded_r2 before falling back to the declared field. Regression test_identical_byte_refetch_keeps_r2_identified_shared_file_valid (both sweeps valid before and after the refetch).
  • Recursive merges (low, chronicle/registration.py): the strict loader tracks the mappings whose merges are being expanded and refuses a source that is already active with ConstructorError("found a recursive merge…"), a yaml.YAMLError the readers handle. Regression test_strict_loader_refuses_recursive_merges_as_yaml_errors (direct, via sequence, via merged mapping; repeated non-cyclic source still merges).
  • Verification on 9681c1c: focused artifacts + source-package + consumer-contract + env + manifest-reading suites 485 passed; ruff check/format clean on the changed files; full suite running (result will be appended).

Astra gate 6aa29716 round 2 (cd14372)

  • Unidentified siblings after selected or partial publication (medium): an entry with no recorded identity (no sha256, no locator) read as an empty digest, so identifying one manifest (selected publish, or a full sweep whose second upload failed) turned its sibling into a false filename_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).
  • Verification on cd14372: focused artifacts + source-package + consumer-contract + env + manifest-reading suites 488 passed; ruff check/format clean; full suite running (result will be appended).

Astra gate 6aa29716 round 3 (7d9fd06)

  • First fetch of a predeclared entry (medium): _manifest_file_owners refused every entry naming the artifact without a recorded identity, including the selected entry on its own first fetch, so files: {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) and test_fetch_still_refuses_an_unidentified_owner_in_another_manifest (retained protection).
  • Verification on 7d9fd06: focused artifacts + source-package + consumer-contract + env + manifest-reading suites 491 passed; ruff check/format clean; full suite running (result will be appended).

Astra gate 6aa29716 round 4 → fresh gate (d544ffe)

  • Selected publication overrides leaked onto sibling manifests (medium): the package-wide publish preflight applied --source-id/--package-id to 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). Regression test_selected_publication_overrides_apply_only_to_the_selected_manifest[source_id|package_id] (red on 7d9fd06).
  • Verification on d544ffe: focused artifacts + source-package + consumer-contract + env + manifest-reading suites 493 passed; ruff check/format clean; full suite running (result will be appended).

Astra gate cc680cc1 round 1 (3eea393)

  • Default sweeps withheld overrides from selected siblings (medium): override eligibility was decided per manifest being processed, so in a default sweep (every manifest selected) a selected sibling met through another manifest's package preflight was treated as unselected and the 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. Regression test_default_sweep_overrides_apply_to_every_selected_sibling[source_id|package_id] (red on d544ffe).
  • Verification on 3eea393: focused artifacts + source-package + consumer-contract + env + manifest-reading suites 495 passed; ruff check/format clean; full suite uv run pytest -q -p no:cacheprovider tests 1281 passed, 1 skipped (20:11). Astra gate cc680cc1 round 2: approve.

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

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):

  1. [high] fetch-artifact can attach the recorded R2 URI to new bytes. When the recorded storage.r2 block is preserved on a repeated fetch, the manifest is rewritten with the newly fetched sha256 and size_bytes but keeps the old content-addressed key and URI. A publisher revision then produces a manifest whose provenance is false. Fix: compare the recorded identity (filename + sha256) with the fetched bytes before preserving anything; identical → preserve the recorded block; different → refuse, or record a new content-addressed key under the configured bucket as a new revision, never the old key. The regression test must repeat with different bytes, not identical ones.
  2. [low] Env isolation is scoped to one test module. Other tests assert default buckets/schemas without clearing CHRONICLE_R2_RAW_BUCKET / CHRONICLE_SCHEMA; move isolation to a shared fixture or clear explicitly where defaults are asserted.

A fix lane is applying both on this branch; re-gate after.

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Gate round 2: changes requested again, all agreed and being fixed on this branch (round-2 lane): CHRONICLE_SCHEMA did not reach the primary Supabase mirror writer (harness/mirror still default to the literal ledger schema); the .ledger_derived suffix check is not rename-safe; fetch-artifact cannot address a package's non-default manifest file (--manifest added), so the IRA revision workflow the docs cite would miss the recorded block; revision protection vanished when an entry lacked storage.r2; recorded-R2 locator fields must be cross-checked, not key-or-URI; malformed manifests must be rejected rather than treated as absent; collection-time schema resolution must be lazy. Re-gate follows.

MaxGhenis added a commit that referenced this pull request Sep 4, 2026
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>
MaxGhenis added a commit that referenced this pull request Sep 4, 2026
MaxGhenis added a commit that referenced this pull request Sep 4, 2026
…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.
MaxGhenis added a commit that referenced this pull request Sep 4, 2026
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>
MaxGhenis added a commit that referenced this pull request Sep 4, 2026
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>
MaxGhenis added a commit that referenced this pull request Sep 4, 2026
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>
MaxGhenis and others added 19 commits September 4, 2026 18:52
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>
MaxGhenis and others added 16 commits September 4, 2026 19:23
…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.
MaxGhenis added a commit that referenced this pull request Sep 5, 2026
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>
MaxGhenis added a commit that referenced this pull request Sep 5, 2026
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>
MaxGhenis added a commit that referenced this pull request Sep 5, 2026
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>
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