Operational rename, slice 1: CHRONICLE_* env names, chronicle.db output, configurable buckets, cutover checklist (#143, mechanism 3) - #226
Conversation
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>
|
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. |
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>
|
Gate round 2: changes requested again, all agreed and being fixed on this branch (round-2 lane): |
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>
The message named key and bucket while the check also requires provider, so a hand-written block missing it read as a contradiction between the error and the rule. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The flag's help still described the recorded R2 object alone, which is no longer the only identity a fetch is refused against. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
They run before the publisher is read, and again before the manifest is rewritten; the base class claimed the second one also predated the cache write. 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