Register raw microdata releases as source artifacts: licence/access fields, hash-only registration, first UK registrations (#221) - #227
Register raw microdata releases as source artifacts: licence/access fields, hash-only registration, first UK registrations (#221)#227MaxGhenis wants to merge 214 commits into
Conversation
|
Review (Fable, main): approving, with the Sol gate running. Cross-checked every registered hash against Microcosm's Sequencing: #222 merges first (its gate is running; merge-on-agreement), then this. No file overlap between the two. Notes, none blocking:
|
|
Gate round 1: changes requested. The 15 registrations match the consumer pins and add no bytes or fact surfaces, but Sol found real escapes around the boundary, all of which stand: filename aliases ( Two additions from the ADR review (#222 round 2) fold into the same fix: byte custody requires an affirmative, evidence-backed redistribution signal, not merely Fix lane queues behind #226's round-2 fixes because both rewrite |
|
Cross-PR convention ruling (chronicle#227 ↔ microcosm#853), so both sides land on one shape:
Chronicle's #227 fix round and a small #853 follow-up apply this. |
3cbfff4 to
baf9449
Compare
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>
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>
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.
…equires PR 227 requires an explicit kind on every non-grandfathered manifest with entries; the six manifests built by PR 226's newest artifact tests declared none. Add kind: publisher_table to each so the imported tests exercise the merged contract as written (the lane's reviewed six-declaration patch).
…R 227 Conflicts: chronicle/artifacts.py (kept this branch's locked/staged publish structure, whose package preflight already decides override eligibility from the whole selected set) and tests/test_chronicle_artifacts.py (kept both default-sweep regressions; PR 226's new fixture declares kind as the merged contract requires).
2864708 to
cb4c7ef
Compare
Summary
Implements chronicle#221: Chronicle registers every raw microdata release its
consumers build from, and stores the content of none of them.
Depends on #222 (the raw-microdata-identity ADR and the governance,
architecture,
AGENTS.md, and non-goal amendments). This PR deliberately makesnone of those edits, so the two do not conflict — but until #222 merges, the
governance doc still forbids what this branch does, and the ADR file that four
files cite does not exist on
main. Merge #222 first.Contract is shared verbatim with microcosm#848.
Schema and validation
Manifest file entries gain two fields:
licence— the publisher's terms, as an identifier or URL.access— a closed set:public|licensed|restricted.publicis inferred where absent;fetch-artifactnow writes the classexplicitly onto every entry it touches. Both fields are required on a
kind: microdata_releasemanifest, the new manifest-level carve-out.Only
publicbytes enter a Chronicle store, so the R2 keyraw/{source_id}/{package_id}/{year}/{sha256}/{filename}exists only for thatclass. A
licensedorrestrictedrelease is registered hash-only: themanifest carries the checksum, size, vintage, licence, access route, and
verification timestamp, and no bytes are fetched, written, or uploaded.
A registration is identified by
{source_id, package_id, year, sha256, filename}— the tuple consumers reference underchronicle_artifact.New command
chronicle register-artifactregisters a licensed or restricted release fromCLI arguments alone. It refuses
--access public, a missing or malformedchecksum, a missing licence, vintage, access route, or verification timestamp,
bytes sitting beside the manifest, a
publisher_tablemanifest, a foreignsource_id, and an unrequested reissue. Public releases keep the existingfetch-artifactpath, which gains--access,--licence, and--kind.First registrations
15 hash-only registrations, every value read verbatim from Microcosm's reviewed
pins and cross-checked field by field:
access: licensed, UK DataService End User Licence, study SN 9367, DOI
10.5255/UKDA-SN-9367-2.Microcosm carries 19
licensed_microdatareferences across five stages;they resolve to 14 distinct files (identical SHA-256 where they repeat).
put2223uk.tab,access: restricted, study SN 9422, DOI10.5255/UKDA-SN-9422-1.chronicle inventory-artifacts --root db/datais valid with 195 artifacts: 180with R2 links, 15 hash-only with no local bytes.
BE-SILC 2023 is not registered. Microcosm pins it with no
sha256, nosize_bytes, and no per-file filename — the locator names four file roles.No hash is invented; the blocker and its unblock steps are recorded in
docs/data-sources/be-silc-2023-registration-blocker.md.Tooling
scripts/register_microdata_releases.pyresolves a declarative catalogueagainst a read-only microcosm checkout.
emitwrites the hash-only manifestsbyte-stably;
planprints the exactfetch-artifact --upload-r2commands torun from a networked machine for the public US releases, copying publisher URLs
verbatim and printing
TODO_PUBLISHER_URLwhere Microcosm records none (CPSbasic monthly 2024, SIPP 2023). The catalogue is the only authored content —
every checksum, size, filename, and vintage is read from Microcosm.
Audit fixes
An adversarial pass over the first implementation found the byte boundary was
escapable. All six are fixed here with regression tests:
only inspected
files[year]for the requested year and only entries thatsurvived a kind-gated list expansion, while the write target and R2 key are
directory-scoped. A release registered under one vintage could be fetched
under another, and a list under a manifest with an absent or misspelled
kindwas invisible to the guard. Both were reproduced writing licensedbytes into the tree. The guard now walks every vintage and expands lists
unconditionally.
files[year]value. It now replaces only the entry for its own filename.emitstopped being re-runnable after a reissue — registration raised onthe first filename match with a different checksum before reaching the exact
match. Now two passes.
--skip-hash-onlyreported a contract violation as valid, disagreeingwith
inventory-artifactsabout the same tree. Hash-only entries are nowalways validated; the flag turns off the refusal, not the check.
kind: microdata_release, though the ADR puts redistributable public-use files inscope.
fetch-artifactgains--kind, and a release manifest keeps the listshape so several files share one vintage (ACS household + person).
planemitted--source-url, whichfetch-artifactdoes not accept. Fixed to--url, anda test now parses each generated command against the real CLI parser.
Also hardened
_year_mapping, which returned a list and let the caller raise anuncaught
TypeErrorthatvalidate-packagedoes not catch.No content, anywhere
No microdata row, cell, or fact is added. No source package parses a release:
validate-packagefails withmicrodata_release_not_parseable, and the FRS andSPI packages hold nothing but
manifest.yaml. Bucket names stay configurable(
ledger-rawdefault, unchanged).Chronicle Governance
For any source package, consumer contract, schema, or source-data
boundary change:
Approved Chronicle agent role:
ledger-source-ingestor— thedb/data/**microdata-release manifestsledger-contract-maintainer— the manifest schema (licence,access,kind), its validation, and the registration commandNote both roles'
allowed_pathsin.github/chronicle-agents.ymlpredatethis work and cover none of
chronicle/registration.py,chronicle/artifacts.py,chronicle/harness.py,chronicle/cli.py,db/data/**,scripts/**,docs/**, orREADME.md. Flagging rather thanquietly widening the manifest; reviewers may want a follow-up that extends
the globs.
Deterministic checks run:
uv run pytest -q— full suiteuv run ruff check .— passesuv run ruff format --check .— 14 pre-existing failures, all in files thisbranch does not touch (verified unchanged against
origin/main); thebranch's own files pass
uv run chronicle inventory-artifacts --root db/data— valid, 195artifacts, 15 hash-only
scripts/register_microdata_releases.py emit— re-run is byte-stableLLM judge verdicts:
ledger-source-fidelity: PASS — no fact is added. Every registeredvalue (sha256, size, filename, vintage) is transcribed verbatim from the
consumer's reviewed pin, machine-verified against Microcosm, and each entry
records
hash_sourceplus a note that Chronicle did not recompute thechecksum. BE-SILC is refused rather than guessed.
ledger-contract: PASS — the change adds two manifest fields and onemanifest kind, preserves provenance, and moves no Microcosm responsibility
into Chronicle. Registration is identity only; selection, reconciliation,
aging, and imputation are untouched.
ledger-boundary: PASS — no microdata row, cell, or fact entersChronicle; no source package parses a release; no licensed or restricted
bytes enter any Chronicle store, and the escapes that previously allowed
that are closed and regression-tested.
Tests
tests/test_chronicle_microdata_registration.py— 69 tests covering the accessand kind vocabularies, every manifest-entry validation code, each command's
refusals, the six audit regressions, public microdata releases, the generated
fetch plan, and the committed FRS/SPI registrations as identity-only.
Two existing assertions in
tests/test_chronicle_artifacts.pygain the newhash_only_countandhash_only_refused_countreport keys.🤖 Generated with Claude Code
Gate round 2 (9/4): rebased onto #226 and #228; all nine round-1 findings closed
Rebased onto
ops-rename-slice1(PR #226 at fb1bc1d, which carries main at 9da0243 = #228). Conflicts inchronicle/artifacts.pywere resolved so #226's stray-manifest refusal, files-block check, rename refusal, skipped publish entries and explicit-nullfiles:handling all survive beside this PR's pre-fetch preflight._read_source_artifact_contentrefuses a hash-only entry before the package tree, the content-addressed cache, or a fetch is touched;SourceArtifactSpec.assert_parseabledecides both carve-outs from the manifest alone;validate-packagereportshash_only_artifact_not_parseableandmanifest_kind_missing;build-suitevalidates before creating or replacing its output directory.--kindthat conflicts with the stored kind, a kindless manifest with entries, and every inventory-level manifest error are refused before the publisher is read.--filenameis omitted, so a gated artifact is never downloaded to be refused._upsert_manifest— replaces the matched entry in place under Operational rename, slice 1: CHRONICLE_* env names, chronicle.db output, configurable buckets, cutover checklist (#143, mechanism 3) #226's recorded-identity guard, carries forward fields a fetch does not own, never turns a publisher-table mapping into a list, and always writeskind.consumer_pin(attested_by: PolicyEngine/microcosm,pinned_frompath and commit, noverified_at);planemits--publisher,--vintage,--filename,--expected-sha256/--expected-size-byteswhen Microcosm's pin is of the publisher bytes, the allowlisted licence and its evidence; anything unknown is a TODO the CLI refuses. The 15 committed FRS and SPI rows are re-emitted that way with byte-identical SHA-256/size pins (verified against the microcosm blobs at 2fb2e2f8 and de7451bd).tests/fixtures/microcosmholds synthetic consumer manifests in Microcosm's shapes with the reviewed pins verbatim;emitreproduces both committed manifests byte for byte,planmatches a golden command list, every planned command parses against the real CLI, and the committed pins are a golden mapping. The commit-reading tests run against a copy of the fixture in a throwaway consumer repository.Also implemented from the #222 ADR (which this PR still does not edit): the explicit
kindrule with its frozen grandfathered-manifest list (chronicle/grandfathered_manifests.py), the redistributable-licence allowlist with per-term evidence (chronicle/licences.py), artifact-boundlicence_evidence,hash_source/attested_by/pinned_from/attestation_evidence, staging outside the repository for public bytes, and the no-tracked-microdata-bytes guard. Docs:docs/agent-source-package-harness.md"Hash-Only Registrations", README quick-start, storage note.Verification on 7f9bfe6: full suite 1219 passed, 1 skipped;
ruff checkclean.Merge order unchanged: #222 first.
Follow-on hardening (9/4, after the round-2 fixes)
--manifest(Operational rename, slice 1: CHRONICLE_* env names, chronicle.db output, configurable buckets, cutover checklist (#143, mechanism 3) #226) lets a fetch record into one of several manifests in a publisher directory; the hash-only guard read only that one, so a release registered licensed inmanifest.yamlcould be fetched, published, parsed, or reclassified through a siblingmanifest_<package>.yaml.fetch-artifactnow reads every manifest the directory keeps before the publisher is touched and refuses a name or digest any of them registers hash-only, refuses to overwrite a file a sibling records as other bytes, refuses a manifest whosesource_id/package_iddiffer from the fetch's, and refuses an artifact named like a manifest;publish-rawandinventory-artifactsreportfilename_collision_across_manifests/sha256_collision_across_manifests;register-artifacttakes--manifestwith the same refusals; the source-package byte reader refuses a file a sibling registers hash-only. 18 tests intests/test_chronicle_package_directory.py.validate_file_entryreports nameless or malformed entries, misspelled fields, a storage block orchronicle_fetchattestation on a hash-only entry, a consumer attestation signed by Chronicle, and evidence URLs without a host;register-artifactvalidates every existing entry before recording.tests/test_chronicle_manifest_reading.py.Gate round 3 (9/4, Sol): twelve findings closed
Entry keys use a closed schema (typos such as
acessare refused, never public-by-default); the source-package reader and the fetch validate the selected manifest and every sibling strictly before any boundary decision or byte read; normalized-duplicate manifest names are refused; every no-local-bytes check scans real directory entries by normalized identity (shared helper across fetch, publish, inventory, registration and the public-release tree guards); inventory countsstorage.r2only for a validated content-addressed R2 locator (public microdata releases must also bind source, package and vintage); consumer pins are checked against the recorded commit's blob withgit cat-filebefore emission; registration refuses symlinked output paths and persists under an exclusive lock by atomic same-directory replacement (two-thread regression); default sweeps discover every supported manifest name; only YAML null reads as an empty manifest; blank identity fields are replaced or refused before persistence; FRS selectors compare all stage references. Adversarial follow-ups closed in the same lane, including publish's dry preflight opening a valid artifact before honoring a malformed-sibling error. Full suite on this head: 1,268 passed, 1 skipped;ruff checkclean; UK pins and #222-owned files unchanged.Gate round 4 (9/4): eleven findings closed
Public entries without a sha256 are hashed and collision-checked against gated siblings before any upload, cache, or parse; inventory short-circuits on manifest, sibling, or entry errors before any byte read; source parsing validates
storage.r2locators and package-wide collisions before content access; the cross-bucket key rule is reconciled with #226's historical-route compatibility (a legacy recorded route whose checksum tail matches is preserved history; a key contradicting the entry's own package/year that is not a recorded legacy shape is refused); consumer pins record the repository, commit, and path of the blob actually checked; fetch, publish, and registration share one per-package lock keyed on physical file identity; required textual fields must be non-empty strings; the stray-default guard covers every supported manifest spelling; FRS cross-stage checks compare stable byte identity. Full suite on this head: 1,295 passed, 1 skipped;ruff checkclean; UK pins and #222-owned files unchanged.Integration with PR #226 (cb4c7ef)
kind: publisher_tableon the six manifests Operational rename, slice 1: CHRONICLE_* env names, chronicle.db output, configurable buckets, cutover checklist (#143, mechanism 3) #226's newest artifact tests build, as this branch's registration contract requires.uv run pytest -q -p no:cacheprovider1661 passed, 1 skipped (20:55); ruff check/format clean.Astra gate bbd833a9 round 1 (46560bc)
4f7aea5(6 cases), fixadb030c._validated_microdata_staging_destination(every component, realpath, no symlinked component, outside the repository and every package) before any byte is read or uploaded. Reda164a2b(13 cases incl. staging beneathdb/dataand a symlinked ancestor), fix63c6bb9.2040886(5 cases incl. revalidation under lock), fixb820b18.Astra gate bbd833a9 round 2 (89737a1)
_assert_package_identities_parseableclassifies every sibling's current and archived filename and checksum and refuses before any read, fetch, cache write, or row parse when any sibling registers the selected entry's identity asmicrodata_release; selected declared and effective digests are checked before I/O. Red9c8738e(32 cases), controle543cea, fixdfd3b99._prepare_registration_payloadcalls validate every current and archived R2 locator explicitly (locator errors are refusals), enforce filename/checksum agreement, and run the same owner-agreement validator publication and inventory use (effective digests, owners across vintages within one manifest), before lock creation and again under the lock before replacement, metadata-only. Reproductions:test_registration_refuses_invalid_recorded_sibling_before_mutation(5 cases),test_registration_rechecks_recorded_sibling_under_lock_before_replacement(5 cases), positive control, and a no-artifact-read control; fix651571b.