Skip to content

D19 hash epoch: granule_workers into DATA_SOURCE_PACKAGING_KEYS; semantic_core widened to the leaf-shaping output knobs (issue #415) - #420

Merged
espg merged 33 commits into
mainfrom
claude/415-d19-hash-epoch
Aug 17, 2026
Merged

D19 hash epoch: granule_workers into DATA_SOURCE_PACKAGING_KEYS; semantic_core widened to the leaf-shaping output knobs (issue #415)#420
espg merged 33 commits into
mainfrom
claude/415-d19-hash-epoch

Conversation

@espg

@espg espg commented Aug 10, 2026

Copy link
Copy Markdown
Member

Closes #415. Refs #384 (the release train and the recorded ruling — plan delta 2), PR #397 (questions (3), (7), (8) and their review threads).

What this does

The D19 hash epoch: every ruled identity fix landed in one release so the compatibility break happens exactly once. Two came from PR #397's ruled questions (7) and (8); three more were ruled by espg in-session on 2026-08-17credentials_provider (issue #449, phase 5), the byte-movement knobs read_workers/write_buffer/source_region (phase 7), and canonical granule identity (phase 8) — together with two rulings that changed nothing but the record: keep the sharding declaration as landed, and drop emit_cell_ids back out of the core (phase 7).

Four of them change what zagg.semantics.semantic_hash digests, so every pre-epoch D19 hash is invalidated by design — the point of the release, not a defect. The fifth changes the other half of the identity pair, the sidecars' granules_sha256, so pre-epoch catalog identities taken over resolved hrefs are invalidated too. Nothing in any store changes on disk; only the derivation of the digests that label it.

(7)(b) — the granule fan-out width is packaging. D19's ratified exclusion list already named "worker size" as packaging, but the keys were never in DATA_SOURCE_PACKAGING_KEYS, which made the leaf seams' semantic_hash=None fallback clamp-sensitive: both dispatchers hand each cell a data_source clamped to min(K, n_granules) (runner._clamped_data_source, issue #184), so a small shard's worker-side hash differed from the run-level hash, and semantic_hash being an _EQ_OR_NONE_KEYS member then collapsed the identity half to null in any rollup mixing clamped and unclamped shards.

(8)(c) — semantic_core is widened to the leaf-shaping output knobs. Until now the whole output block was outside the core, so any knob that changes what a leaf contains moved neither half of the skip gate's identity pair and the gate read equal.

knob in? why
output.aoi_mask in masks cells outside the AOI — the leaf's values differ (issue #101)
output.windowing in partitions observations into per-window leaves (D13); folded NORMALIZED, so epoch spellings and the issue #355 point-window sugar canonicalize
output.grid.sharded in question (8)'s checked example: flips the leaf's object set while the granule set holds
output.grid.emit_cell_ids out the D16 hatch does write an ADDITIONAL cell_ids array into every leaf, so it met the criterion — and espg ruled it back out, 2026-08-17 (question (4)(b)): the hatch is scheduled for removal (issue #304), after which a store built with it ON would carry a digest no legal config reproduces. A leaf's array inventory is verified by reading the leaf, the same closing argument as output.pyramid below.
output.pyramid out the leaf column is verified by reading the artifact (hive.leaf_column_expectation, spec §4.6) — that is what closed question (8)'s concrete regression; D11 keeps the block out of the frozen manifest keys; and declare_pyramid's retrofit exists precisely to add the declaration to the config that built a store, which its own semantic guard would then refuse. Hashing it breaks a supported workflow to re-cover covered ground — caught by test_pyramid_only_edit_hashes_identically going red on an earlier draft of this branch.
orders (parent_order/child_order/chunk_inner) out D24's resolution axis, untouched: hashing them would make o8 and o9 runs different products and block mixed-order processing
store/store_layout/product_name, coverage_moc, sweep, consolidate_metadata out addressing, run triggers over regenerable D9 caches, and a derived finalize blob

Every included knob is resolved through its accessor, never as spelled, so an explicit default hashes identically to an absent key (the pipeline.type discipline, §8.3) — sharded: true on a HEALPix config is a no-op, as is windowing: {schedule: none}.

credentials_provider is packaging — espg-ruled in-session 2026-08-17 (issue #449). data_source.credentials_provider joins DATA_SOURCE_PACKAGING_KEYS alongside reader/driver/read_plan: it selects how source bytes are fetched — which registry name mints the DAAC credentials the dispatcher attaches to every event (issue #213 Phase 4) — and never what is computed from them. Three things make it the same D19 class rather than a judgment call:

  • Fetch mechanism, not computation. The same granules read with lpdaac credentials, gesdisc credentials, or an anonymous open produce byte-identical leaves. anonymous — the other spelling of the same choice — was already excluded, so hashing the provider was hashing half of one decision.
  • The failure asymmetry runs the safe way. A wrong credential fails the fetch: a 403 at read time, never a quiet wrong value. Nothing depends on the digest to catch it. Hashing it, by contrast, silently splits one product in two.
  • The MERRA-2 rehash trap. A credential migration over unchanged data — re-registering an existing store's source under a different provider, or adding the key to a config that ran without it — would otherwise refuse every leaf as semantic-mismatch and rewrite the whole store to produce the same bytes.

Ruled now, at the epoch, deliberately: no store carries a provider-bearing hash yet, so the first GEDI store's identity is born without an auth knob in it. The credentials_provider: lpdaac line lands with the GEDI template on PR #450; ruling after that lands would mean paying a second epoch for a knob that never should have been in the core.

The byte-movement knobs are packaging too — espg-ruled in-session 2026-08-17 (question (2)(c)). read_workers, write_buffer and source_region join DATA_SOURCE_PACKAGING_KEYS in phase 7, on credentials_provider's three-part test:

  • Mechanism, not computation. read_workers (issue Compiled (hidefix) read path is never exercised by default, and its fetch/decode is serial #170) is the third fan-out width beside the two spellings phase 1 excluded (processing/worker.py sizes the budget as granule_workers × read_workers × fetch width); write_buffer bounds how many slabs are alive under the streamed raster sink (processing/raster._write_buffer) — peak memory over an identical written result; source_region is the raster source store's AWS region kwarg, sitting in the same dict literal as the already-excluded anonymous (runner.py's src_kwargs), so the D19 line ran through the middle of one "how do we open the source" decision.
  • The failure asymmetry runs the safe way. A too-small pool is slower, a wrong region is a connection error, an over-large buffer is an OOM. Nothing depended on the digest to catch any of them; hashing them silently split one product in two.
  • A machinery migration must never rehash unchanged data. Retuning a width or a bound over an existing store would otherwise refuse every leaf as semantic-mismatch and rewrite it to produce the same bytes.

That last point is not hypothetical. espg ran the same GEDI SERC flux build twice on the deployed 0.46.0 fleet on 2026-08-17 with identical outputs, and got two different identities:

run semantic_hash machinery
1 490044e3e5e380de… shard_workers 1, buffer_granules 4, 8192 MB
2 5d081b68f0487b4d… shard_workers 4, buffer_granules 8, 4096 MB, hierarchical index pin

Same shard (5347294481781620745), identical total_obs (23,353,274), identical cells_with_data (27,727), both in the exact single-block spill regime (byte-identical fold by design) — and the hashes split purely on worker/streaming/index machinery. Both run-stats parquets are under s3://sliderule-public/zagg-demo/serc_gedi_flux.zarr/ (stats_20260817T084340Z_8344cde5… and the later stats_*_5d081b68…). That is a live, dated instance of exactly the false product split these exclusions close.

Canonical granule identity — espg-ruled in-session 2026-08-17 (question (1)(b)). This is the epoch's other digest: granules_sha256, the catalog identity half recorded in every D20 sidecar, plus the id list in its granules.json sibling. espg's words: "we want the granule to trigger the hash, not how that granule is fetched."

One physical granule reaches the recording seams under three names — a resolved s3://bucket/key/FILE href, an https://host/path/FILE href (runner._resolve_urls picks one by data_source.driver), or the bare catalog id, which for every CMR/STAC catalog zagg reads is the basename of both hrefs (verified against catalog_cycle22_*.parquet: id == "ATL06_20240108040423_03102212_007_01.h5", and both asset hrefs end in exactly that). driver has been packaging in the semantic core since D19 was written, so pre-epoch the two digests disagreed about the same run: the semantic half said "same product", the catalog half said "different inputs".

The canonical form is therefore the basename — the scheme, host, bucket and key prefix are all where the bytes live, and the s3 and https spellings of one granule agree on nothing else. Landed at three seams so no path can record a non-canonical id:

  • telemetry.canonical_granule_id / canonical_granule_ids — the rule, with paired-asset {url, assets} entries (issue GEDI waveforms 2/3: generic vlen reader primitives, paired-asset shardmap, flux transform + gedi01b template #425) identifying by their primary exactly as before, so the local backend (resolved strings) and the Lambda handler (payload entries verbatim) still agree on one hash;
  • telemetry.granules_sha256 and telemetry.write_granule_ids — the digest and the recorded list, so the missing ids a contraction names are driver-independent too;
  • dedup.classify_leaf_identityboth sides of the diff, which is what lets a pre-epoch leaf (full hrefs on its sibling) compare cleanly against a post-epoch plan instead of reading as a full contraction.

Accepted cost, recorded at the function and in the migration note: two granules whose hrefs differ only in prefix collapse to one identity. Every catalog zagg reads names granules globally uniquely — which is why the catalog's own id equals the basename — and the alternative, keeping any part of the fetch path, is precisely what the ruling excludes. The raster id space (raster_granule_ids: STAC item ids or ISO datetimes) carries no path separator, so it is canonical already and no raster digest moves.

What this closes for operators: docs/hive_layout.md's skip-if-current section carried a caveat that "the recorded id space is driver-dependent … flipping the driver between runs reads as a full mixed contraction and refuses per leaf — --allow-contraction is the escape hatch." That caveat is now deleted rather than documented, because the behavior is fixed.

Phases

  • phase 1 — granule_workers (and the canonical shard_workers) into DATA_SOURCE_PACKAGING_KEYS + tests

  • phase 1 adversarial-review fold — 3 findings, 2 folded + 1 split: the constant's summary line now names worker sizing as its own D19 category (14b670d); a seam-level pin replaced the module-level-only coverage (b882d94, test_a_clamped_per_cell_config_still_reads_current); the shard_workers scope objection is declined and stands as question (2), with its unruled status now stated at the constant (2d6921b)

  • phase 2 — semantic_core widened to the leaf-shaping output knobs + tests

  • phase 2 adversarial-review fold — 5 findings, 4 folded + 1 split: the K == 1 / chunk_inner limits of hashing the sharding declaration are now stated at the constant (a8cdb00) while the three-way design fork is declined and stands as question (3); emit_cell_ids marked as inferred-not-ruled (6be4b1a, question (4)); the windowing totality guard split by fault class so a fault inside the normalizer propagates instead of laundering into a digest (3747cb9); a gate-level pin for the sharded flip (2165be9); the windowing rationale corrected to name the layer it actually buys (fbcccaf)

  • phase 3 — conformance fixtures regenerated through tools/generate_spec_fixtures.py; docs/specification.md §6 cross-reference

  • phase 3 adversarial-review fold — 2 findings, both folded: TestFixtureSemanticHash makes the fixture obligation self-enforcing instead of a habit (fc608e8); docs/hive_layout.md's pre-epoch output.* claim at the declare_pyramid guard corrected (7a3a705)

  • phase 4 — the migration note in docs/hive_layout.md + the D19 design-record amendment in docs/design/sparse_coverage.md

  • phase 4 adversarial-review fold — 3 findings, all folded: the in-place restamp path gated with its three real hazards instead of a one-liner (e0711d8); the fleet consequence, the async-manifest question, and what the epoch unblocks added (93bb630); the design-record amendment marked at the point of contradiction (bc910da)

  • merge origin/main (e0ae75b) — the branch predated weeks of merges. Conflicts were narrow: tests/test_semantics.py (both sides appended a new class at EOF — kept both, TestLeafShapingOutputKnobs then main's issue GEDI waveforms 1/3: §2 counts/flux weights declaration + δ=8,192 raise #424 TestWeightsAndOverviewDeltaHashing) and seven tests/data/spec/ fixtures where only the regeneration timestamps collided (generated_at / written_at / the stats blob's timestamp + zagg_version) — main's side taken, since main regenerated later. No semantic_hash line conflicted: main's issue GEDI waveforms 1/3: §2 counts/flux weights declaration + δ=8,192 raise #424 normalization (VARIABLE_PACKAGING_KEYS, weights: counts) touches per-variable aggregation keys no fixture config declares, so the epoch's digests merged through untouched and TestFixtureSemanticHash is green without regenerating anything. src/zagg/semantics.py auto-merged (main's _normalize_variables + this branch's widened core, disjoint).

  • phase 5 — credentials_provider into DATA_SOURCE_PACKAGING_KEYS (espg-ruled 2026-08-17, issue GEDI template ships without an LPDAAC credentials provider — reads 403 as NSIDC #449) + tests; the migration note and the D19 design-record amendment extended with the third exclusion

  • second merge of origin/main (7f7452c) — main moved again mid-run (issues Raster time axis encodes as toc words (instant|range) — S2 first #443/Unindexed builds: cover from WKB and intersect before materializing records (#439 for the live path) #445 landed). Conflicts: the semantics.py module docstring (both sides appended a bullet after the pipeline-type one — kept both, main's output.time_encoding §8 entry now follows the epoch's leaf-shaping entry) and tests/test_spec_conformance.py at EOF (both appended a class — kept both). No behavioral overlap: main's time_encoding enters the core as a top-level core["time_encoding"] key, not through core["output"], so OUTPUT_LEAF_SHAPING_KEYS and test_the_documented_constants_match_the_core are untouched.

  • phase 5 adversarial-review fold — 2 findings, 1 folded + 1 split: anonymous and credentials_provider are the same class, not one knob under two names (disjoint consumers — anonymous only in the raster source-store kwargs, the provider only on the point/temporal paths), reworded to the design record's accurate form in all three places (e8377b3c); the proposal to exclude read_workers/source_region/write_buffer in the same breath is declined on agent authority — excluding a key is a ruling, not a fold — and rides question (2), with the three now named as still-hashed-and-unruled at the constant and in the migration note (0594d400)

  • phase 7 — the three semantics rulings (2cf022e7): read_workers/write_buffer/source_region into DATA_SOURCE_PACKAGING_KEYS with per-key equality pins and per-key machinery-migration pins (question (2)(c)); emit_cell_ids back OUT of the core with its by-name exclusion and dedicated rationale test restored (question (4)(b)); the sharding declaration left exactly as landed with its two limits restated as espg-ruled rather than open (question (3)(a)). test_golden_hash_pin re-pinned to fb15224f7265… and the seven fixture manifests' semantic_hash updated.

  • phase 8 — canonical granule identity (623bded0, question (1)(b)): telemetry.canonical_granule_id/canonical_granule_ids, threaded through granules_sha256, write_granule_ids, build_record, dedup.classify_leaf_identity (both sides) and the two hive.py call sites; the four granules.json fixtures updated; the migration note's item 5 and the design record's catalog-identity clause amended; the operator-facing driver-dependence caveat in docs/hive_layout.md deleted rather than documented, because the behavior is fixed.

  • phases 7–8 adversarial-review fold — 6 findings, 5 folded + 1 split. Folded: docs/hive_layout.md's "the driver-dependence caveat is gone" paragraph overclaimed and is now split by epoch side (31af5392) — a pre-epoch leaf's stored granules_sha256 is href-space, so hashes_match is False by construction and the equal arm is unreachable; what canonicalizing the recorded side actually buys there is the direction (id-multiset-drift/expansion instead of a spurious contraction refusal), pinned by test_a_pre_epoch_leaf_rewrites_rather_than_refusing. The granules.json siblings were unguarded — nothing in the suite read them — closed with TestFixtureGranuleIdentity plus test_every_leaf_fixture_carries_the_sibling, enumerated so a new fixture cannot ship one unguarded (ab7cd89d); it also found there are six committed siblings, not four. canonical_granule_id's claim that sibling asset hrefs are "href-form-independent in every component" was unsupported — a paired entry's identity is its primary alone — reworded, and the vacuous test replaced with a discriminating one over two entirely different sibling granules (34f1d40b). The dated GEDI measurement was cited for two raster-only knobs a point-path build cannot exercise: scoped to the fan-out widths at all four sites, with the three divergent paste variants reconciled (344f53ad). A url-less mapping used to mint the literal id "None" and collapse N such entries into one — now a pointed ValueError, with granules_sha256/build_record's stale Iterable[str] annotations widened (88d234f2). One 112-char docstring line reflowed (d0e9fbdb; E501 is ignored, so ruff would not have caught it).

    Split, and standing as question (6): basename collapse can make a genuine contraction invisible, because the diff is over SETS. The loud half is folded (46936c56) — a warning that names the colliding canonical id and says the guard's per-granule resolution is degraded on that leaf, plus the doc/docstring re-pricing and test_collapsed_recorded_ids_degrade_the_guard_loudly. The Counter-based predicate change itself is declined on agent authority: it would flip test_duplicate_drift_with_equal_sets_rewrites, an existing pin of the ruled predicate recorded ∖ planned ≠ ∅, from rewrite to refuse. After collapse the two inputs are byte-identical data, so no diff can separate them — which way that ambiguity resolves is a ruling.

  • phase 6 — the epoch's fixture obligation applied to the two fixtures that landed on main after phase 3. flux/ (issue GEDI waveforms 1/3: §2 counts/flux weights declaration + δ=8,192 raise #424) and raster_toc/ (issue Raster time axis encodes as toc words (instant|range) — S2 first #443) were generated on main and merged in carrying pre-epoch semantic_hash values that no post-epoch zagg reproduces — the exact failure TestFixtureSemanticHash exists to catch, which it missed only because its parametrize list was a hardcoded three. Regenerated both through tools/generate_spec_fixtures.py --only flux raster_toc (diff is semantic_hash + timestamps + one zagg_version, no content_hashes movement), pinned both, and closed the hole with test_every_fixture_is_covered: it walks tests/data/spec/**/morton_hive.json and fails if any fixture carrying a semantic_hash is not in the covered set, so the next fixture cannot ship unpinned. raster_toc/'s config literal was factored out of its builder into _raster_toc_config() so the test can rebuild it.

The fixtures

tests/data/spec/ regenerated through the production path. The whole diff is 26 linessemantic_hash and generated_at, nothing else. No compressed-byte churn and no content_hashes movement, which is the evidence that the epoch is contract-invisible at the byte level: the §5 hashes are over decoded values, not over identity. docs/specification.md is otherwise untouched — the derivation of semantic_hash has never been spec-normative, and the operator-facing statement lands in docs/hive_layout.md where §4.7's informative scope-out already places this family.

Update after the main merges (phase 6): two fixtures generated on main after phase 3 — flux/ (issue #424) and raster_toc/ (issue #443) — merged in carrying pre-epoch digests. Both regenerated through the same production path; the diff is again only semantic_hash, the timestamps, and one zagg_version string, with content_hashes untouched. TestFixtureSemanticHash now covers all six fixtures and carries test_every_fixture_is_covered, which enumerates tests/data/spec/**/morton_hive.json and fails on any fixture whose digest is not pinned — the fold's obligation is now closed under new fixtures, not just under new semantic-core edits.

Update after the 2026-08-17 rulings (phases 7 and 8). All seven morton_hive.json digests moved (question (4)'s removal) and four granules.json siblings moved (question (1)'s canonicalization). Both sets of values came from the production path — tools/generate_spec_fixtures.py --out <scratch> — but were applied field-surgically rather than by committing a wholesale regeneration, and that deviation is deliberate: a full regeneration breaks TestLocatedDeclaration::test_absent_declaration_on_the_pre_section_9_fixture, because tests/data/spec/kitchen_sink/ is frozen on purpose as the pre-§9 fixture and today's writers add a located declaration to two of its arrays (issue #410) and an overview_delta to its pyramid attrs (issue #424). Those two fields are stale on main in every fixture generated before that work merged — pre-existing, separable from this epoch, flagged not fixed per CLAUDE.md §4. No content_hashes moved in either step.

New in the fold: tests/test_spec_conformance.py::TestFixtureSemanticHash recomputes each fixture's digest from the config that built it. Before it, nothing in the suite read that field — the whole suite passed on this branch with the stale pre-epoch hashes still committed, and moczarr would have vendored them (espg/moczarr#19/#20) with every parity gate green. Its pyramid/ case pins the exclusion decision as well as the digest: that fixture's manifest is built from the /1 config and retrofitted by declare_pyramid under the /2 one, so semantic_hash(cfg_v1) == semantic_hash(cfg_v2) is asserted on a committed conformance artifact.

How it was tested

After phases 7 and 8 + the fold (head 88d234f2):

  • Full suite: uv run pytest -q2 failed, 4203 passed, 38 skipped in 253 s — both failures the known pre-existing pair listed below, unchanged.
  • tests/test_semantics.py tests/test_telemetry.py tests/test_dedup.py tests/test_hive.py tests/test_spec_conformance.py441 passed before the fold; see the fold entry for the post-fold count.
  • New pins for question (2): test_byte_movement_knobs_are_packaging and test_a_machinery_migration_never_rehashes, both parametrized per key, so each of the three carries its own equality assertion (key in the tuple, absent from semantic_core, digest equal to the bare config) and its own migration assertion (a retune over unchanged data never rehashes) — the treatment credentials_provider got in phase 5.
  • New pin for question (4): test_emit_cell_ids_is_packaging, carrying the issue Morton-only writer flip: stop writing cell_ids (implements #262 / D16) #304 rationale in the test itself, restoring the dedicated coverage this PR had replaced.
  • New pins for question (1): tests/test_telemetry.py::TestCanonicalGranuleIdentity — real ICESat-2 href trios (s3:// / https:// / bare id / the s3 driver's stripped bucket/key form) hashing identically and recording identical ids; the granules.json sibling round trip byte-identical across driver forms; paired-asset entries; the raster id space provably untouched; and the trailing-slash edge. Plus two gate-level pins where the cost would land: tests/test_dedup.py::TestClassifyLeafIdentity::test_a_driver_switch_still_reads_current (parametrized over four href forms, asserting the hash fast path decides and the sibling is never read) and tests/test_hive.py::TestLeafSkipIfCurrent::test_a_driver_switch_still_reads_current — a leaf written from s3 hrefs, rerun with the https hrefs of the same granules, must read equal with the fold armed to raise if it runs.
  • Deliberate re-pins: test_golden_hash_pinfb15224f7265… (question (4)'s removal in one line), the seven fixture semantic_hash values, and — in the canonical id space — the recorded-id and missing-id assertions in tests/test_dedup.py, tests/test_telemetry.py and tests/test_hive.py. Every one of those is the ruling's behavior, not a weakened assertion: each now asserts the bare granule id where it previously asserted a resolved href.
  • docs/specification.md stays untouched, verified rather than assumed: neither granules_sha256 nor granules.json appears anywhere in it, so question (1) moves no spec-normative text. The operator-facing statement lands in docs/hive_layout.md, where §4.7's informative scope-out already places this family.
  • CI on 623bded0: ruff, build, build-x86_64, build-arm64, test (3.12) green. CI on the fold head 88d234f2 fired 8 check-runs (no PR-event stall); ruff and build green, the test and arch-build jobs settling.

After the second origin/main merge + phase 6 (fb93108):

  • Full suite: uv run pytest -q2 failed, 4049 passed, 38 skipped in 245 s — the same two failures as below, unchanged.
  • tests/test_spec_conformance.py tests/test_semantics.py tests/test_raster_pipeline.py tests/test_time_axis.py273 passed (the merged §8 time-axis surface and the epoch's fixture pins together). ruff check / ruff format --check clean on tools/generate_spec_fixtures.py and tests/test_spec_conformance.py.

After the first origin/main merge + phase 5 (4297585):

  • Full suite: uv run pytest -q2 failed, 3980 passed, 38 skipped in 285 s. Neither failure is this diff's:
    • tests/test_lambda_build.py::TestFunctionBuild::test_function_build_succeeds — the known environmental docker/build failure on this machine.
    • tests/test_client_transport.py::TestStatusPoller::test_invoke_fault_burns_an_attempt_and_retries — a wall-clock-sensitive poller test (assert calls["n"] == 2 after a 5 s spin). Verified red on a clean origin/main checkout at this session's clock, so it is pre-existing; flagged, not fixed, per CLAUDE.md §4.
    • The old test_sweep_stage.py::TestStagePass::test_ratchet_rewrites_on_child_change failure that made test (3.13) red on bc910da is gone — it was fixed on main by PR small fixes: staged sweep skip key carries the run ids; bench object model gains the column term #421, which this merge brings in.
  • Targeted after each step: tests/test_semantics.py tests/test_spec_conformance.py tests/test_hive.py tests/test_config.py557 passed. test_golden_hash_pin is unchanged by phase 5 (96e5b120… stands) — the pinned config declares no credentials_provider, and neither does any conformance fixture, so no fixture regeneration was needed and no committed digest moves. What does move is the digest of a config that spells the key: it now equals the digest of the same config without it, which is the ruling.
  • Phase 5's new tests: tests/test_semantics.py::TestCanonicalization::test_credentials_provider_is_packaging (the equality assertion — key in the tuple, absent from semantic_core, hash equal to the bare config) and ::test_a_credential_migration_never_rehashes (add / drop / swap lpdaacgesdisc, and composition with anonymous), plus tests/test_hive.py::TestLeafSkipIfCurrent::test_a_credential_migration_still_reads_current — the gate-level mirror of phase 1's test_a_clamped_per_cell_config_still_reads_current: a leaf written without a provider, rerun under a config that names one, must still read equal with the fold armed to raise if it runs.
  • PR small fixes 2026-08-17: NaN-safe dispatch payload, LP DAAC credentials, GEDI filter set #450 interaction, tracked here. That PR (still open, unmerged) adds tests/test_semantics.py::test_credentials_provider_is_not_in_the_packaging_list — a deliberate pin of the pre-ruling behavior whose own comment says "if it is ruled packaging, this test flips to the equality assertion." It is not on main yet, so this PR writes the equality test fresh; small fixes 2026-08-17: NaN-safe dispatch payload, LP DAAC credentials, GEDI filter set #450's pin will conflict at its next main sync and should adopt this flip (its credentials_provider: lpdaac line on the GEDI template is unaffected and correct either way).
  • uv run ruff check and ruff format --check on the touched files (src/zagg/semantics.py, tests/test_semantics.py, tests/test_hive.py) → clean.

On bc910da, before the merge:

  • Full suite, final tree: uv run pytest -q1 failed, 3781 passed, 38 skipped in 464 s. The single failure is tests/test_sweep_stage.py::TestStagePass::test_ratchet_rewrites_on_child_change, which is pre-existing and unrelated — verified red on a clean origin/main checkout at this session's clock (its monkeypatch only moves the stamp when the UTC hour string starts with 0, so it fails at any hour ≥ 10 UTC). Flagged, not fixed, per CLAUDE.md §4.
  • CI on bc910da: ruff green, build / build-x86_64 / build-arm64 green, test (3.12) green, test (3.13) red on exactly that one pre-existing test1 failed, 3781 passed, 38 skipped, the same counts as the local run, assert 0 > 0 at tests/test_sweep_stage.py:376. Nothing in this diff is implicated; the 3.12 job running the same tree green is the evidence.
  • Test hygiene note, no action taken: tests/test_runner.py::TestConsolidationGate writes to a relative ./out.zarr (gitignored) and does not clean up, so a leftover directory from an earlier run made it fail on the second full run of this session with the epoch's own manifest refusal. It passes on a clean tree (4 passed). Pre-existing, and arguably the epoch working as designed.
  • New tests: 12 in tests/test_semantics.py (the TestLeafShapingOutputKnobs class plus the two phase-1 packaging tests), 2 gate-level pins in tests/test_hive.py (test_a_clamped_per_cell_config_still_reads_current, test_a_sharded_flip_defeats_the_skip), 4 in tests/test_spec_conformance.py. Both golden pins re-pinned deliberately: test_golden_hash_pin96e5b120… (the epoch in one line) and the fixture manifests.
  • uv run ruff check src tests → 1 error, the pre-existing N818 at src/zagg/registry.py:64. uv run ruff format --check src tests → 1 file would be reformatted, the pre-existing tests/data/benchmark/README.md. Both are the same pair PR leaf skip-if-current: input-identity no-op with lifecycle touch (issue #388) #397 flagged; neither is touched by this diff, and neither is in the CI lint job's scope (it is green).
  • uv run pre-commit run --all-files: ruff-format passes; codespell and check-yaml clean on every file this PR touches (the repo-wide failures are tests/test_hive.py:2074's statics and the CloudFormation !Ref/!GetAtt tags under deployment/aws/, both pre-existing on main). mypy: 140 error lines, identical to the origin/main baseline — the one error in src/zagg/semantics.py is the pre-existing DataSourceDict arg-type, moved by line number only.

Questions for review — the original five are ruled and landed; one new one is open

espg ruled questions (1)–(5) in-session on 2026-08-17, and all five are landed. They are kept here as the decision trail rather than deleted, because each ruling is a compatibility fact a future reader will need. Question (6) is new, opened by the phases 7–8 fold, and is the only thing on this PR still needing a decision.

  1. Canonical granule-id normalization → (b): the driver-stripped bare granule id. Ruled, and landed in phase 8 across both halves — granules_sha256 and the recorded id list. espg's words: "we want the granule to trigger the hash, not how that granule is fetched." One granule reaches the recording seams as an s3://bucket/key/FILE href, an https://host/path/FILE href (runner._resolve_urls picks by data_source.driver) or the bare catalog id — which for every catalog zagg reads is the basename of both hrefs. driver has always been packaging in the semantic core, so hashing the href form made a fetch-mechanism edit look like a catalog change.

  2. shard_workers scope → (a) + (c). Both spellings stay excluded, and read_workers / write_buffer / source_region joined them in phase 7. The class test is credentials_provider's: selects how bytes are fetched or moved, never what is computed; fails loudly rather than silently; and a machinery migration over unchanged data must not rehash. The "still-hashed-and-unruled" language phase 5's fold added for the three is gone — they are ruled.

  3. The sharding declaration → (a): left exactly as landed. No code change; the constant's text now reads as espg-ruled rather than open. Both limits stand documented and bounded: K == 1 over-discriminates (the safe direction), and chunk_inner still moves K without moving the digest. Closing the second costs part of D24, which is why it stays open by ruling rather than by omission.

  4. emit_cell_ids → (b): dropped back OUT of the core. The by-name exclusion and a dedicated rationale test are restored (test_emit_cell_ids_is_packaging), now carrying the issue Morton-only writer flip: stop writing cell_ids (implements #262 / D16) #304 argument the criterion alone does not price: the hatch is scheduled for removal, after which a store built with it ON would carry a digest no legal config can reproduce. A leaf's array inventory is verified by reading the leaf — the same precedent that keeps output.pyramid out.

  5. The draft→ready flip → resolved by the above. With every ruling landed, the findings folded and CI green, CLAUDE.md §2 applies unambiguously: the PR is flipped ready-for-review by the agent (reversible; merging is not). It carries waiting, which now covers both the merge decision and question (6).

  6. NEW — should a collapsed granule id resolve toward refuse? Opened by the phases 7–8 self-review, and the one open item. classify_leaf_identity diffs the recorded and planned id sets, so if two distinct granules in one shard canonicalize to the same bare id — different key prefixes, same basename — dropping one becomes invisible: the unit reads id-multiset-drift and rewrites where pre-epoch it refused and named the dropped href. That is the direction the classifier's own docstring says must never happen ("a rewrite over a contracted input set destroys aggregated data the operator was never told about").

    The loud half is folded: the collapse now emits a warning naming the colliding id and saying the guard's per-granule resolution is degraded on that leaf, the docs price it as a guard consequence rather than only an identity collision, and test_collapsed_recorded_ids_degrade_the_guard_loudly pins it. What is not taken on agent authority is the predicate: after collapse, "a recorded duplicate of one granule" and "two distinct granules, one dropped" are byte-identical data, so no diff can separate them, and a Counter-based diff would flip test_duplicate_drift_with_equal_sets_rewrites — an existing pin of the ruled predicate recorded ∖ planned ≠ ∅ — from rewrite to refuse. Options: (a) leave as shipped (current — loud, but a collapsed drop rewrites); (b) multiset diff, so the ambiguity resolves toward refuse (data-loss-safe, at the cost of a false refusal on genuine duplicate drift, escapable with --allow-contraction); (c) refuse only when a collapse was actually detected, keeping the plain-duplicate path as it is.

    Worth pricing before choosing: the collapse needs two granules in ONE shard sharing a basename across different prefixes, which every catalog zagg reads rules out by construction — that is why rec["id"] equals the basename. Nothing observed has ever hit it.

Generated by Claude Code

@espg espg added the implement label Aug 10, 2026

@espg espg left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

Adversarial self-review of phase 1 (granule_workers / shard_workers into DATA_SOURCE_PACKAGING_KEYS), against CLAUDE.md and issue #415's acceptance.

Three findings, no blockers: one scope question (the unruled second spelling), one doc-accuracy nit at the constant, and one test-depth finding — the two new tests pin the semantics layer but not the seam where the clamp-sensitivity actually bit. The epoch framing itself is sound: phase 1 alone moves a hash only for configs that spell a fan-out width, so the "every pre-epoch hash is invalidated" claim in the body rests on phase 2, not this commit — worth keeping straight when the migration note is written.


Generated by Claude Code

Comment thread src/zagg/semantics.py
Comment on lines +71 to +77
DATA_SOURCE_PACKAGING_KEYS = (
"reader",
"driver",
"read_plan",
"anonymous",
"shard_workers",
"granule_workers",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

[medium] shard_workers is not in the ruled scope, and it does not fix the defect question (7) actually reported.

Issue #415 scope item (1) is one key, spelled once: "add granule_workers to DATA_SOURCE_PACKAGING_KEYS". CLAUDE.md §6 puts "change the PR's agreed scope" on the @espg-only list, so the burden is on the diff to show the second key is implied rather than added.

It is not implied by the reported mechanism. The clamp writes exactly one key:

# runner._clamped_data_source
return {**data_source, "granule_workers": clamped}

so granule_workers alone closes the clamp-sensitivity, the _EQ_OR_NONE_KEYS rollup collapse, and every consequence question (7) enumerates — including for configs that spell shard_workers, because the clamp appends granule_workers regardless of which name the config used. shard_workers in the tuple therefore buys a different property (an operator retuning fan-out width does not mint a new product identity), which is defensible on D19's "worker size is packaging" line but is a second decision, not the ruled one.

The counter-argument in the PR body (a second epoch would be needed later) is real, but it cuts both ways: an unruled key silently riding a hash epoch is exactly the kind of thing that is invisible in review and unrecoverable after release. Either drop it to the ruled key and leave both spellings as question (2), or — if it stays — the constant's comment should say at the constant that the second spelling is unruled and awaiting a ruling, so a reader of the code (not the PR body) knows.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Split: the code-comment half is folded in 2d6921b; the removal is declined and stands as question (2) for @espg.

You are right that granule_workers alone closes the reported drift — the clamp appends that key whatever the config spelled, so the clamp-sensitivity, the _EQ_OR_NONE_KEYS collapse, and the dedup.shard_status stale all resolve without the second key. The constant now says exactly that instead of implying the pair was one decision:

#: The clamp writes ``granule_workers`` whatever the config spelled, so that
#: key alone closes the reported drift; ``shard_workers`` rides along because
#: the two ARE one knob (``zagg.processing.worker._granule_workers`` resolves
#: the canonical name first) and hashing the canonical spelling of an excluded
#: knob would be incoherent. Issue #415 named only the legacy alias, so the
#: canonical one is flagged for ruling on the epoch PR rather than assumed.

Why it is not removed on agent authority either way. Removing it is as much a call as keeping it, and the asymmetry decides: if shard_workers stays hashed and @espg later wants it excluded, that needs a second epoch — issue #415's own framing for the granule-id passenger ("it cannot ride a later train without a second epoch"). Keeping it costs a one-line revert before merge, which is the cheaper error. It is not silent: it is in the commit, in the constant, in the PR body's "what this does", and it is question (2) with an explicit "(b) drop back to granule_workers only" option.


Generated by Claude Code

Comment thread src/zagg/semantics.py Outdated
@@ -54,7 +55,27 @@

#: ``data_source`` keys that are read machinery, not output semantics (D19).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

[low] The constant's one-line summary is now false, and it is the line a reader greps.

The module docstring was updated to "read machinery and fan-out sizing", but this line — the #: summary that lands in the API docs next to the tuple and is what anyone scanning the file reads first — still says "read machinery, not output semantics". shard_workers/granule_workers are not read machinery; they are worker sizing, which D19's exclusion list names as a separate category from read knobs:

Excluded as packaging: cell order (a resolution axis — D24), parent/shard order, chunk_inner/sharded, worker size, streaming mode ... and read knobs

Two categories, one summary line claiming only one of them.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Folded in 14b670d. The summary line now names both categories, matching D19's own two-category exclusion list rather than only the read-knob half:

#: ``data_source`` keys that are read machinery or worker sizing, not output
#: semantics (D19 names the two as separate packaging categories).
#: Changing any of these must never change the ``semantic_hash``.

Generated by Claude Code

Comment thread tests/test_semantics.py
Comment on lines +115 to +128
def test_dispatch_clamp_never_moves_the_hash(self):
# The exact mechanism question (7) reported: the dispatcher hands each
# cell a data_source clamped to min(K, n_granules) (issue #184), and
# the worker-side fallback hashes THAT config. A 2-granule cell must
# not hash differently from the run.
from dataclasses import replace

from zagg.runner import _clamped_data_source

cfg = _cfg()
clamped = _clamped_data_source(cfg.data_source, 2)
assert clamped is not None and clamped["granule_workers"] == 2
assert semantic_hash(replace(cfg, data_source=clamped)) == semantic_hash(cfg)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

[medium] Both new tests stop one seam short of the thing that was broken.

The reported defect is not "a config carrying granule_workers hashes differently" — it is that the worker-side fallback hash disagrees with the run hash, at these two call sites:

# processing/raster.py
if semantic_hash is None:
    from zagg.semantics import semantic_hash as _semantic_hash
    semantic_hash = _semantic_hash(config)

with the same shape in hive.process_and_write_hive. test_dispatch_clamp_never_moves_the_hash reconstructs the clamp by hand (_clamped_data_source(cfg.data_source, 2)) and hashes the result, which pins the composition of two functions but not the seam that composes them. If a later change made a seam hash something other than the config it was handed — the raster handler dropping keys is the live precedent, PR #397 question (1) — these tests stay green while the drift is back.

A gate-level pin is cheap here and exists already in a form you can copy: tests/test_hive.py's skip-gate tests drive process_and_write_hive with skip_if_current=True and no semantic_hash. One test that folds a leaf under the run config, then reruns the same unit with the clamped per-cell config and asserts current: True (rather than identity: "semantic-mismatch") would pin the actual claim: a small shard's worker no longer reads its own leaf as stale. Without it, phase 1 is pinned only where it cannot regress.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Folded in b882d94tests/test_hive.py::TestLeafSkipIfCurrent::test_a_clamped_per_cell_config_still_reads_current, exactly the shape you named: write + stamp + sidecar the leaf under the RUN config, then rerun the same unit with the dispatcher's clamped per-cell config and no semantic_hash, so the seam's fallback is the thing under test.

clamped = _clamped_data_source(cfg.data_source, len(self.URLS))
assert clamped is not None and clamped["granule_workers"] == len(self.URLS)
self._arm_boom(monkeypatch)
meta = hive.process_and_write_hive(
    shard, list(self.URLS), grid, {}, root,
    replace(cfg, data_source=clamped), store_kwargs={}, skip_if_current=True,
)
assert meta["current"] is True and meta["identity"] == "equal"

_arm_boom makes it a real skip pin rather than an identity-string pin: the fold raises AssertionError("fold ran on a gated unit") if the unit does not skip, so a reintroduced drift fails on the fold, not only on the classification. Pre-epoch this asserts identity == "semantic-mismatch", which is the regression it exists to catch.

Not extended to the raster seam here: the raster branch of the fleet handler drops semantic_hash on the floor today (PR #397 question (1), still unarmed), so a raster gate pin would be testing a path no fleet run reaches. Named rather than silently skipped.


Generated by Claude Code

@espg espg left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

Adversarial self-review of phase 2 (semantic_core widened to the leaf-shaping output knobs).

Five findings. The pyramid exclusion is the right call and well-grounded — the declare_pyramid retrofit and hive.leaf_column_expectation both say so, and the branch proves it by leaving test_the_declaration_moves_neither_identity_half green. The substantive worry is the opposite one: sharded is the knob the epoch is being spent on, and hashing the declaration rather than the effective layout leaves the object-layout hole half-open (chunk_inner) while opening a new false split (K == 1). Second, two of the four keys in the new constants (emit_cell_ids, and arguably sharded itself) are inferred rather than named by the filing, and one of them had a ratified exclusion and a dedicated test that this diff deletes — that should be on the record for @espg, not folded into a docstring. Third, the class pins hashes but not the gate behavior the hashes exist to drive.


Generated by Claude Code

Comment thread src/zagg/semantics.py
# grid-family-shaped exactly as `grids.from_config` resolves it — HEALPix
# output defaults to sharded (issue #215), rect does not — so an explicit
# `sharded: true` on a HEALPix config hashes identically to omitting it.
grid["sharded"] = get_sharded(config, default=grid_type == "healpix")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

[high] The core now hashes the sharding declaration, but the leaf's object layout is decided by the declaration ∧ the orders — so this both over- and under-discriminates, in opposite directions, on the exact knob the epoch exists for.

Over. The grid no-ops sharding at K == 1 — get_sharded's own docstring: "a K==1 grid has nothing to bundle, so the grid silently no-ops it (issue #215)". So on a K == 1 config, sharded: true and sharded: false produce byte-identical leaves and now hash apart. That is a false product split: two runs that would write the same bytes refuse each other's stores.

Under. chunk_inner is what sets K, and it stays packaging by the D24 line this diff deliberately keeps:

if chunk_inner is None and sharded and layout == "fullsphere" and not is_raster:
    derived = child_order - 6
    if derived > resolved_parent:
        chunk_inner = derived

So chunk_inner: 11 → 13 changes the leaf's object set completely — different K, different shard object extents — with sharded unchanged, and the hash holds. The gate still reads equal for the very failure mode question (8) described ("changes the leaf's object layout while the hash and the granule set both hold"), just entered through a different key.

Net: the epoch pays a full compatibility break for sharded and closes maybe half of the object-layout hole, while adding a new false-split at K == 1. Three coherent positions, none of them "what landed":

  1. resolve the effective sharding (sharded ∧ K > 1) rather than the declared flag — kills the false split, but needs the orders to compute K, which is precisely what D24 keeps out of the core;
  2. take the whole packing group (sharded + chunk_inner) and accept that chunk geometry is product identity — coherent, but a much bigger bite out of D24 than the filing describes;
  3. drop sharded and put the object-layout half in the D20 sidecar instead — question (8) option (b), which espg did not pick.

At minimum the K == 1 false split needs to be stated at the constant, because nothing else in the tree says it.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Split: the documentation half is folded in a8cdb00; the three-way design fork is declined and stands as question (3) for @espg.

Both mechanisms confirmed as described. The K == 1 no-op is get_sharded's own documented behavior, and chunk_inner is what decides K, including via the issue #259 derivation quoted. So the constant now states both, rather than leaving the reader to find them:

#:   * at ``K == 1`` the grid silently no-ops sharding (issue #215), so
#:     ``sharded: true`` and ``sharded: false`` write identical leaves and
#:     still hash apart — an over-discrimination, the safe direction (F1's
#:     recorded posture: "over-discriminating is safe, a semantic collision
#:     is not");
#:   * ``chunk_inner`` changes K, hence the leaf's object set, and still
#:     hashes equal — the object-layout hole is narrowed here, not closed.

Why the fork is not taken here. Your three options are three different answers to "how much of D24 does this epoch eat", and each one is a bigger decision than the filing authorizes:

  • (1) resolving sharded ∧ K > 1 needs child_order/chunk_inner/parent_order inside semantic_core to compute K. Even if the orders are only read and never hashed, the core's value would then depend on them, which is the composability property D24 protects — o8 and o9 runs of the same product could land on different sides of the K > 1 boundary and split.
  • (2) hashing the packing group outright contradicts the ratified exclusion line verbatim (chunk_inner/sharded are named together there).
  • (3) is question (8) option (b), which espg explicitly did not pick.

The direction that landed is at least the safe one at both ends: the K == 1 case over-discriminates (a spurious rewrite, never a false skip), and the chunk_inner case is no worse than pre-epoch. It is now question (3) on the body with those same three options, since only @espg can size the bite out of D24.


Generated by Claude Code

Comment thread src/zagg/semantics.py Outdated
#: - ``emit_cell_ids`` (issue #304) — the D16 transition hatch writes an
#: ADDITIONAL ``cell_ids`` array into every leaf, which is a leaf-content
#: difference by the same test, not a display preference.
GRID_LEAF_SHAPING_KEYS = ("sharded", "emit_cell_ids")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

[medium] emit_cell_ids is a second unruled key, and unlike shard_workers it was excluded by name in this very docstring.

The pre-epoch text you edited read:

Excluded as packaging: all orders (parent_order/child_order/chunk_inner), sharded, store layout/path, emit_cell_ids (the issue #304 transition hatch), worker sizing, ...

and tests/test_semantics.py carried a dedicated assertion for it with a stated rationale ("emit_cell_ids is the issue #304 transition hatch, not identity (F4)"), which this diff deletes. Issue #415 names sharded nowhere either, but sharded is at least the knob PR #397 question (8) checked and reported. emit_cell_ids appears in neither the filing nor the question — it is inferred from the criterion.

The inference is sound (an extra cell_ids array in every leaf is a leaf-content difference by exactly the test the constant states), but it has a consequence the diff does not mention: the hatch is scheduled for removal (#304, gated on the gridlook morton decode). Once it is removed, a store built with emit_cell_ids: true has a hash no supported config can reproduce — its config is illegal, so the store's frozen key is unverifiable rather than merely stale. Default-off stores are unaffected because the resolved False is what they hash today, but the hatch's users are exactly the population that would notice.

Not a defect — a decision that should be espg's, on the record, alongside question (2). Deleting a test that carried a ratified rationale is the part that should not pass silently.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Folded in 6be4b1a, and raised as question (4) on the body. You are right that the deleted assertion carried a stated rationale and should not have gone quietly. The constant now says both halves out loud:

#:   difference by the same test, not a display preference. **Inferred from
#:   the criterion, not named by issue #415** (and previously excluded here
#:   by name), so it is flagged for ruling on the epoch PR alongside
#:   ``shard_workers``. Note the hatch is scheduled for removal: after that,
#:   a store built with it ON has a hash no legal config reproduces.

The deleted test is not restored, because restoring it would assert the opposite of what the code now does — the replacement is TestLeafShapingOutputKnobs::test_emit_cell_ids_is_identity, which carries the new rationale in the same place. What the old test's rationale ("the core reads only the grid type/scheme, so it drops out") described was a mechanism, not a ruling: the key dropped out because nothing in output was read at all, so the F4 note recorded a consequence of the old scope rather than a decision to exclude this knob specifically. That is the argument for the change, and it is exactly the argument @espg should be able to reject in one line — hence question (4), with "(b) drop emit_cell_ids back out" as an explicit option.

On the removal consequence: it is real but bounded to hatch-on stores, and it is now stated at the constant rather than only here.


Generated by Claude Code

Comment thread src/zagg/semantics.py
Comment on lines +272 to +282
def _windowing_leaf_shape(config: PipelineConfig):
"""The normalized ``output.windowing`` declaration, or ``None`` (D13).

Normalized rather than as-spelled so ``epoch`` spellings and the issue
#355 point-window sugar canonicalize (§8.3). Total by contract: an
out-of-grammar block is ``_validate_windowing``'s refusal to raise, not
the hash's — :func:`semantic_core` must not start raising on a config that
hashed before the epoch (the Lambda worker builds its config without
``validate_config``), so such a block simply hashes as spelled.
"""
from zagg.config import get_windowing

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

[medium] The totality guard is a four-exception catch that was grown by test failure, and it now swallows any bug inside get_windowing as "hash it as spelled".

AttributeError was added only after test_the_core_stays_total_on_an_out_of_grammar_block failed on a string block — which is the guard doing its job, but it is also evidence that the exception set is empirical rather than derived. get_windowing calls into zagg.windows.parse_utc and _explicit_window_bounds; a genuine regression there (a bad ISO round-trip, a mis-parsed explicit window) raises one of these four and is then silently downgraded to hashing the raw block. The store gets a hash, the run proceeds, and the only symptom is that two configs which should collide do not.

Cheaper and tighter: normalize only what the guard is actually for. The failures you are defending against are all shape failures on an out-of-grammar block, which is one isinstance check:

block = (config.output or {}).get("windowing")
if not isinstance(block, dict):
    return block          # out of grammar; validate_config refuses it by name
return get_windowing(config)

That keeps totality for the case that motivated it (non-dict blocks) and lets a real fault inside the normalizer raise, where it is findable. If a dict-shaped-but-invalid block must also stay total, catch on that path explicitly rather than around the whole call.

Also worth pinning either way: the guard means a windowed config whose block is slightly wrong hashes differently from the same block spelled correctly, which is safe (over-discrimination) but currently undocumented and untested — there is no test that a raising block still yields a stable, repeatable digest.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Folded in 3747cb9 — the guard is now split by fault class rather than wrapped around the call, and your point that the exception set was empirical is exactly right (AttributeError was added reactively).

block = (config.output or {}).get("windowing")
if not isinstance(block, dict):
    return block
try:
    return get_windowing(config)
except (KeyError, ValueError):
    return block

Shape faults are refused by isinstance; a mapping missing a required key or carrying an unparseable epoch/window bound raises KeyError/ValueError, which is a GRAMMAR fault _validate_windowing also refuses by name. TypeError/AttributeError no longer catch — those would be a fault inside the normalizer and now propagate to where they are findable, which is the property you asked for. The docstring states that split, so the next person extending the catch has to argue with it.

The dict-shaped-but-invalid arm is kept total deliberately (your "if it must, catch on that path explicitly"): the Lambda worker builds its config without validate_config, so the hash is reachable ahead of any refusal.

Your untested-stability point is folded too — test_the_core_stays_total_on_an_out_of_grammar_block is now parametrized over both fault classes and asserts three things per block, not one: 64 hex chars, the same digest twice, and no collision with the unwindowed store.


Generated by Claude Code

Comment thread tests/test_semantics.py
"96e5b12005fcf09a9508e643abb7eecd7893220adef2ae75cf2b34f40c04d4c1"
)

def test_pipeline_type_is_semantic(self):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

[medium] Phase 2 pins that the hash moves; it never pins the thing the hash moving is for.

Question (8)'s claim is behavioral and testable in one place: a sharded flip over an existing store must now rewrite instead of reading equal. Every test in TestLeafShapingOutputKnobs stops at semantic_hash(a) != semantic_hash(b), which is a statement about this module, not about the gate. The gate could stop consulting the hash tomorrow and this class stays green.

Phase 1's fold already established the pattern and the fixture — tests/test_hive.py::TestLeafSkipIfCurrent, _write_leaf + skip_if_current=True + _arm_boom. The mirror is four lines of setup:

grid, shard, root, _ = self._write_leaf(monkeypatch, cfg, tmp_path)
flipped = copy.deepcopy(cfg)
flipped.output["grid"]["sharded"] = False
# ...rerun the unit under `flipped`, assert identity == "semantic-mismatch"

Note the grid must be rebuilt from flipped for the rerun to be honest — reusing the leaf's grid would pass the old sharding into the write path and test nothing. Without this, the PR's central claim ("the gate can now see a leaf-shape change") is asserted only in prose.

test_column.py::TestColumnDefeatsTheSkipGate is the precedent for exactly this shape of test, and it is worth noting that its premise test — test_the_declaration_moves_neither_identity_half — still passes on this branch, which is the check that the pyramid exclusion decision actually holds end to end.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Folded in 2165be9tests/test_hive.py::TestLeafSkipIfCurrent::test_a_sharded_flip_defeats_the_skip, and your note about rebuilding the grid was the load-bearing part:

flipped.output["grid"]["sharded"] = False
flipped_grid = self._grid(flipped)
calls = self._counting_fake(monkeypatch, flipped_grid)
meta = hive.process_and_write_hive(
    shard, list(self.URLS), flipped_grid, {}, root, flipped,
    store_kwargs={}, skip_if_current=True,
)
assert calls == [int(shard)] and meta["identity"] == "semantic-mismatch"

It uses _counting_fake rather than _arm_boom because this arm must assert the fold DID run (calls == [int(shard)]), which is the behavioral claim — pre-epoch it is [] with identity == "equal".

Your closing observation is the other half and it holds on this branch: test_column.py::TestColumnDefeatsTheSkipGate::test_the_declaration_moves_neither_identity_half still passes, which is the end-to-end evidence that excluding pyramid was right — the column's currency is still carried by the artifact read (hive.leaf_column_expectation), not by the hash, so declare_pyramid's retrofit keeps working. That test failing was in fact how the pyramid inclusion was caught before this commit.


Generated by Claude Code

Comment thread src/zagg/semantics.py
#: precisely to add a pyramid declaration to the config that built a store,
#: which its own semantic guard would then refuse. Hashing it would break a
#: supported workflow to re-cover ground already covered.
OUTPUT_LEAF_SHAPING_KEYS = ("aoi_mask", "windowing")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

[low] windowing is already frozen at two other layers, so it is the one key in this constant that closes no reported hole — and the docstring should say which layer it is actually for.

hive._FROZEN_MANIFEST_KEYS already includes temporal:

_FROZEN_MANIFEST_KEYS = (
    "spec", "dataset", "semantic_hash", "cell_order",
    "shard_order", "split_schedule", "path_grouping", "temporal",
)

so a windowing change already refuses pre-dispatch at ensure_manifest, and separately, windowed leaves are named {window}.zarr (D23), so a schedule change lands the unit on a leaf path with no sidecar → no-sidecar → rewrite. Both guards predate the epoch.

That does not make it wrong to include — the D20 sidecar identity a fleet leaf compares is not the manifest, and product identity in a multi-product root is not either — but the constant currently justifies it with "a windowed and an unwindowed store over the same granules shared a hash before the epoch", which reads as though a hole were open. Say instead which layer gains: product identity and the D20 identity half, not the manifest guard. Otherwise the next reader trying to work out what the epoch bought will re-derive the two existing guards and conclude the key was unnecessary.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Folded in fbcccaf. Both existing guards verified as described — temporal is in _FROZEN_MANIFEST_KEYS, and a schedule change lands the unit on a {window}.zarr path with no sidecar (D23) → no-sidecar → rewrite. The constant now names the layer that actually gains instead of implying an open hole:

#:   granules shared a hash before the epoch. What this buys is **product
#:   identity and the D20 sidecar's identity half**, not the manifest guard:
#:   ``temporal`` is already a frozen manifest key ... so pre-dispatch refusal
#:   and the leaf gate were already covered — the hash is what a multi-product
#:   root and a fleet leaf's recorded identity compare.

Kept rather than dropped for the reason you allow: the two existing guards are store-scoped and path-scoped, and neither reaches the recorded semantic_hash that a fleet leaf's D20 sidecar compares or that distinguishes two products under one root. Over-discrimination on a knob that genuinely changes leaf content is the safe side of F1's recorded posture, and the epoch is the only free moment to add it.


Generated by Claude Code

@espg espg left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

Adversarial self-review of phase 3 (conformance fixtures + the spec cross-reference).

The regeneration itself is clean and verifiable: the whole tests/data/spec/ diff is 26 lines, all of it semantic_hash and generated_at — no compressed-byte churn, no content_hashes movement, which is the evidence that the epoch is contract-invisible at the byte level (§5 hashes are over decoded values). Two findings, both about enforcement rather than the diff: the fixture obligation is unguarded by any test, and the spec page now documents the output.* rule only in a §6 parenthetical while docs/hive_layout.md still carries the pre-epoch claim.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

[medium] The fixtures were regenerated, but nothing in the tree would have noticed if they had not been — so the epoch's own fixture obligation is unenforced.

grep -rn semantic tests/test_spec_conformance.py returns nothing: the conformance suite asserts decoded values and §5 content hashes, and no test recomputes a fixture's semantic_hash from the config that built it. The full suite passed on this branch before this commit with the stale hashes still in place. So the regeneration is correct and invisible, which is the bad combination — the next semantic-core edit that forgets it produces fixtures whose semantic_hash is a value no zagg reproduces, moczarr vendors them (espg/moczarr#19/#20), and the parity gate stays green because it does not look at that field either.

The generator already carries every input needed to close this — it builds each fixture from a config in the same process. A single conformance assertion (fixture manifest's semantic_hash == semantic_hash(that fixture's config)) would make the obligation self-enforcing rather than a CLAUDE.md §4 habit.

Related, and the reason this is worth more than a nit: the generator's own docstring already warns about the opposite failure mode ("STALE BY DESIGN … real churn in files no test asserts"), which is precisely a statement that this part of the fixture tree is unguarded.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Folded in fc608e8tests/test_spec_conformance.py::TestFixtureSemanticHash, four assertions, and your diagnosis was exact: the suite passed on this branch with the stale hashes still committed, so the obligation was a habit rather than a gate.

The generator loads as a module through the test_content_hash precedent, so each fixture's own config is available:

gen = self._generator()
assert self._recorded(name) == semantic_hash(gen._config(**kwargs))

parametrized over minimal / kitchen_sink / column, plus one for pyramid/ that is worth more than reproducibility: its manifest is built from the /1 config and then retrofitted by declare_pyramid under the /2 one, so

assert semantic_hash(cfg_v1) == semantic_hash(cfg_v2)
assert self._recorded(PYRAMID) == semantic_hash(cfg_v1)

pins the digest and the property that keeps the retrofit legal — the pyramid block staying outside the core. That is the phase-2 exclusion decision, now enforced by a conformance fixture rather than only by a unit test.


Generated by Claude Code

Comment thread docs/specification.md
Comment on lines 1305 to +1310
*(Informative.)* Writing `/2` will be a per-product opt-in
(`output.ragged_encoding: typed`), which shifts the product's
`semantic_hash` — a new product identity, by design. The default stays `/1`;
flipping it is a schema epoch deferred to its own ruling (public/interop
stores may deliberately stay `/1` for vanilla-zarr openability).
`semantic_hash` — a new product identity, by design. That shift is not
automatic: `output.*` keys reach the semantic core only by being listed as
leaf-shaping (`zagg.semantics.OUTPUT_LEAF_SHAPING_KEYS`, issue #415), so the
`/2` implementation PR must add the knob there in the same change. The

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

[low] This edit is correct and useful, but it makes the spec page's silence elsewhere louder.

Pointing the future /2 PR at OUTPUT_LEAF_SHAPING_KEYS is right — pre-epoch the sentence was aspirational (no output.* key could shift the hash at all), and post-epoch it is conditionally true, which is worth saying.

But this is now the only place in docs/specification.md that mentions how output.* reaches the semantic core, and it is a parenthetical inside §6's ragged-encoding note — a reader looking for the rule will not find it there. §4.7's informative scope-out and the O11 note at §5 both touch product identity without it. Either the epoch deserves one informative sentence where identity is actually discussed, or this cross-reference belongs in docs/hive_layout.md (where phase 4 is putting the migration note) with §6 keeping a pointer to it.

Also worth checking before this lands: docs/hive_layout.md:405 still reads "output.* is not in the semantic core, so adding output.pyramid to the original config hashes identically". The second clause survives the epoch (that is exactly why pyramid was excluded); the first clause is now false, and it is the sentence that documents declare_pyramid's semantic guard — the one workflow whose correctness depends on getting this distinction right.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Both halves folded. The docs/hive_layout.md:405 claim is fixed in 7a3a705 — you were right that it is the highest-stakes sentence in the tree on this point, since it documents the one guard whose correctness turns on the distinction:

Adding output.pyramid to the original config hashes identically, so the retrofit never false-refuses — the whole pyramid block is deliberately outside the semantic core, and keeping this workflow working is one of the reasons why (the D19 hash epoch of issue #415 put the leaf-shaping output knobs into the core, listed in zagg.semantics.OUTPUT_LEAF_SHAPING_KEYS; pyramid is not one of them).

The surviving clause is kept and the false one is replaced by the reason it survives, rather than deleted — a reader who wonders "why does the retrofit still work after the epoch" now gets the answer at the guard.

On placement: taking your option (b). docs/specification.md stays as-is with the §6 parenthetical as a pointer, and the operator-facing statement of the epoch lands in docs/hive_layout.md in phase 4, where §4.7's informative scope-out already places this family and where the two guards it interacts with (the frozen-key manifest check and the leaf identity gate) are already described. Putting normative-adjacent identity language into the spec page would be a bigger move than the filing calls for — the derivation of semantic_hash has never been spec-normative, and this PR is not the place to make it so.


Generated by Claude Code

@espg espg left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

Adversarial self-review of phase 4 (the migration note + the D19 design-record amendment).

The note does the thing the filing asked for — it states what changed and argues why re-hashing is correct rather than a defect, and the "wrong in both directions" framing is the right argument. Three findings, one of which I would not let ship as written: migration path (3) understates what a manifest-only restamp does (it is not one field, and it manually defeats the guard that exists to catch a wrong config). The other two are coverage: the fleet consequence and the arming unblock are absent, and the design-record amendment sits after the sentence it contradicts with no marker at the contradiction.


Generated by Claude Code

Comment thread docs/hive_layout.md Outdated
Comment on lines +773 to +783
3. **Restamp in place.** The data really was produced by this config, so
rewriting the manifest's `semantic_hash` to the new digest
(`python -c "from zagg.config import load_config; from zagg.semantics import
semantic_hash; print(semantic_hash(load_config('cfg.yaml')))"`) is
*semantically* correct. Be aware of the cost: every leaf's D20 stats
sidecar records the **old** digest too, so a rerun then classifies each
unit `semantic-mismatch` and rewrites it wholesale — the store self-heals,
leaf by leaf, at the price of one full re-aggregation spread over reruns.
There is no zagg tool for this; it is a deliberate operator action.

Whichever path you take, the first post-epoch run over a store is a **full

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

[high] Path (3) hands the operator a one-liner and understates what it does to a store — and one of its two stated consequences is wrong in the operator's favour, which is the dangerous direction.

The claim is that restamping the manifest leaves every leaf sidecar carrying the old digest, so units classify semantic-mismatch and the store "self-heals, leaf by leaf". Two problems:

  1. A semantic-mismatch rewrite is not a heal, it is a full re-aggregation of that unit — the same cost as path (2), except paid unpredictably over future runs instead of once, deliberately. The text says "at the price of one full re-aggregation spread over reruns" only at the end of a sentence that opens with "semantically correct", and the recommended-path ordering puts it last but does not mark it as the one with a footgun.
  2. The restamp is not one field. semantic_hash also rides leaf attrs and every D20 sidecar (the design record: "is recorded in leaf attrs and D20 sidecars"), and dedup.shard_status consults the sidecar's copy independently of the manifest. So a manifest-only restamp produces a store whose root says one thing and whose leaves say another — a mixed-identity store, which is a state nothing else in this document describes and no zagg code expects to persist. That is worth more than a cost note.

Concretely, the snippet is also load-bearing in a way it should not be: it prints a digest for whatever config file the operator names, with no check that the config is the one that built the store. Get that wrong and the restamp installs a digest for a different product, and then the frozen-key guard — the thing that exists to catch exactly this — has been manually defeated and will never fire again for that store. Path (1) has no such failure mode.

Either drop path (3), or gate it: state that the config must be verified as the store's own first (its pre-epoch digest recomputed under the previous zagg and matched against the manifest), and say plainly that the result is a mixed-identity store until every leaf has been rewritten.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Folded in e0711d8 — path (3) is gated rather than dropped, with all three of your objections stated at the path instead of implied. The one-liner is gone.

It now leads with "expert path, read the whole entry" and enumerates:

  • not one fieldsemantic_hash rides leaf attrs and every D20 sidecar, and dedup reads the sidecar's copy, so a manifest-only restamp leaves a mixed-identity store until every leaf is rewritten. Your wording, because it is the accurate one; the design record's "recorded in leaf attrs and D20 sidecars" is the citation;
  • not a heal — the rewrite is the same re-aggregation as path (2), paid unpredictably instead of once deliberately;
  • it defeats the guard — nothing checks that the config being hashed is the store's own, and after a foreign digest is installed the frozen-key check never fires for that store again. The mitigation is stated as a precondition: recompute the config's pre-epoch digest under the previous zagg and match it against the recorded one first.

Kept rather than dropped because it is the only path that preserves an existing large store's data without a new prefix, and an operator who needs it will do it whether or not it is documented — better documented with its footguns named. Paths (1) and (2) are now explicitly called out as having none of these failure modes.


Generated by Claude Code

Comment thread docs/hive_layout.md
construction; the root object simply doesn't appear until the sweep or a
refresh builds it.

## Migration: the D19 hash epoch

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

[medium] The note documents the manifest refusal thoroughly and the fleet consequence not at all — and the fleet is where the epoch actually costs money.

Everything under "What an operator sees" is the pre-dispatch guard: a ValueError from validate_manifest, three ways forward, done. But on the Lambda path the manifest write is an async init-time Event invoke with a finalize backstop (issue #252 hybrid, described 500 lines above in this same document), and the fleet's leaf gate is not armed at all yet — PR #397 question (1), the raster branch of which drops semantic_hash from its record entirely. Those two facts change what an operator experiences:

  • the validate_manifest precheck does run before fan-out on the ping, so the refusal still fires first — worth saying, because a reader who knows the manifest write is asynchronous will reasonably wonder whether an epoch mismatch is caught before or after a few thousand workers have run;
  • and once the gate is armed post-epoch, the first fleet run over a migrated store rewrites every leaf, which for the pole figure is a full re-aggregation — the sentence at the end ("the first post-epoch run over a store is a full rewrite of everything it touches") is correct but reads as a footnote rather than as the headline cost.

Also missing: the epoch is the thing that unblocks fleet-gate arming (issue #415's own "sequencing consequences" — "Fleet gate ARMING waits for this epoch; this release satisfies it automatically"). An operator reading this section learns what breaks and nothing about what it enables, which is the half that justifies the break.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Folded in 93bb630, all three points.

  • The async-manifest question is answered explicitly, because you are right that a reader who knows the write is deferred will ask it: the read-only precheck (zagg.hive.validate_manifest) runs on the mode: "ping" preflight before fan-out, so "an epoch mismatch costs one preflight, not a few thousand worker invocations."
  • The full-rewrite cost is promoted from footnote to headline — "on a fleet-scale store that is a full re-aggregation, and it is the headline cost of the epoch, not a footnote."
  • A new "What the epoch buys" subsection carries the half that justifies the break: fleet arming of the leaf identity gate was gated on this epoch (issue D19 hash epoch: granule_workers into DATA_SOURCE_PACKAGING_KEYS; semantic_core widened to the leaf-shaping output knobs #415's sequencing), for the phase (7) reason — pre-epoch a small shard's worker compared its leaf against a digest the run never wrote, so an armed fleet gate would have rewritten forever without self-healing. It closes on the payoff: the second post-epoch run over an unchanged store is the no-op the gate exists for.

Deliberately not stated there: that the raster fleet branch still drops semantic_hash from its record. That is PR #397 question (1), it is unresolved, and its fix lives in deployment/aws/ — writing an operator-facing promise about raster fleet behavior before that lands would be documenting an intention rather than the code.


Generated by Claude Code

Comment thread docs/design/sparse_coverage.md Outdated
knobs — hashing the whole template would have made o8 and o9 runs
different products and blocked mixed-order processing. The hash is a
different products and blocked mixed-order processing.
**Amended by the D19 hash epoch** (espg-ruled 2026-08-07 as PR #397

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

[low] The amendment is appended after the paragraph it contradicts, so the D19 entry now states both positions and the reader has to reach the end to learn which one holds.

The paragraph above still reads chunk_inner/sharded as an exclusion, verbatim and unqualified, and the amendment three sentences later moves sharded out of it. That is the right content — this file is a design record, and records amend rather than rewrite (the D19 entry's own "revision history, all espg-ratified in-session" is the precedent) — but nothing at the exclusion sentence signals that it has been superseded in part. A reader grepping for sharded lands on the stale clause first.

The cheapest fix that keeps the record's amend-don't-rewrite discipline is a marker at the point of contradiction — chunk_inner/sharded (sharded amended below) — rather than editing the original list.

Two smaller things in the same block: the epoch is dated "espg-ruled 2026-08-07" but the ruling comment is the one linked from #384, and this entry elsewhere is careful to date-and-attribute in-session rulings distinctly from thread rulings; and the amendment says operator consequences "are in docs/hive_layout.md" without naming the section, which is the kind of cross-reference that rots the next time that file is reorganized.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Folded in bc910da, all three, keeping the amend-don't-rewrite discipline you named:

  • the exclusion list gains a marker at the point of contradiction rather than an edit — `chunk_inner`/`sharded` (`sharded` amended below — the D19 hash epoch) — so a reader grepping sharded lands on the pointer, not on the stale clause alone;
  • the attribution is now "espg-ruled on-thread 2026-08-07", matching how this entry distinguishes thread rulings from the in-session ones ("all espg-ratified in-session 2026-07-20");
  • the cross-reference names its target section: docs/hive_layout.md, "Migration: the D19 hash epoch".

Generated by Claude Code

@espg espg added the waiting label Aug 10, 2026
@espg

espg commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

🤖 from Claude

Status: all four phases landed, all 13 adversarial-review findings resolved (10 folded, 3 declined and standing as questions), CI green except one pre-existing failure. Labeled waiting — the ball is with @espg on five questions, and on the flip.

Two things worth calling out beyond the body:

The pyramid exclusion was a course correction, not a plan. An earlier draft of phase 2 did include output.pyramid in the semantic core, on the reasoning that the leaf column is leaf-shaping. The full suite caught it: test_pyramid_only_edit_hashes_identically and four TestColumnDefeatsTheSkipGate cases went red, because sweep_overview.declare_pyramid's semantic guard exists to accept the config that built the store plus a pyramid declaration — hashing the block refuses the retrofit the tool is for. The column half was already covered by reading the artifact (hive.leaf_column_expectation, the spec's §4.6 posture, landed in PR #397's phase-2 fold), so including it would have broken a supported workflow to re-cover covered ground. That reasoning is now recorded at OUTPUT_LEAF_SHAPING_KEYS, in docs/hive_layout.md at the guard, and pinned on a conformance fixture.

Two keys in this diff are inferred rather than named by the filingshard_workers (questions (2)) and emit_cell_ids (question (4)) — and both are one-line reverts. They are in the diff rather than left out because the epoch is the only free moment: a key left hashed today needs a second epoch to exclude later, which is issue #415's own argument about the granule-id passenger. Both are flagged at the constant in code, not only here, so a reader of semantics.py sees their status without the PR.

The one CI failure is tests/test_sweep_stage.py::TestStagePass::test_ratchet_rewrites_on_child_change. It is red on a clean origin/main checkout at any hour ≥ 10 UTC (its clock patch only moves the stamp when the UTC hour string starts with 0), and test (3.12) ran the same tree green. Flagged, not fixed, per CLAUDE.md §4 — it looks like it belongs to whichever PR is currently working issue #417.


Generated by Claude Code

@espg

espg commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

🤖 from Claude

Merge-order note: this PR's red test (3.13) is fixed by PR #421, not by anything here.

The single CI failure on bc910da is tests/test_sweep_stage.py::TestStagePass::test_ratchet_rewrites_on_child_change — pre-existing, and correctly flagged-not-fixed in the body per CLAUDE.md §4. What the body could not know is that a sibling PR opened in the same run repairs it. PR #421 (the small-fix bundle for issues #417 and #418) rewrites that test's clock patch, because the test is issue #417's own acceptance pin and was failing for the reason #417 exists:

# before — only moves the stamp when the UTC hour string starts with "0"
lambda: real().replace("T0", "T1", 1) if "T0" in real() else real()

So: merge PR #421 before this one, or rebase this branch onto it, and the 3.13 job goes green. Merged in the other order, this PR lands with a red check that has nothing to do with its diff. The two PRs touch disjoint code — the only coupling is that shared test file — so there is no rebase conflict risk either way.

I have not applied the blocked label. The dependency is on the CI-green gate, not on the work: all four phases here are complete and every review finding is folded or explicitly declined into a numbered question, so waiting is the honest state — this is awaiting your review, not awaiting another PR's code. Recording the ordering here rather than mislabeling it.

Two disclosures about how this PR was produced, both process rather than content:

  1. CLAUDE.md §2's separate-subagent requirement was not met. The rule asks for the adversarial review and the fold to run as two independent Opus-class subagents with fresh context. No agent-spawning tool was reachable from that execution environment, so both passes ran within one agent as separately-scoped passes — the review posted findings only and made no edits; the fold made one title-only commit per finding and replied on every thread. The artifacts and the discipline match the rule; the context isolation that makes the review genuinely adversarial does not. The same limitation hit PR small fixes: staged sweep skip key carries the run ids; bench object model gains the column term #421. Weigh the findings accordingly — several here are self-evidencing (the output.pyramid exclusion was caught by test_pyramid_only_edit_hashes_identically going red on a draft of this branch, and TestFixtureSemanticHash exists because the suite passed with stale committed hashes), but the softer ones deserve a skeptical read.

  2. Rule slip, already emitted and not retractable. CLAUDE.md §6 forbids @-mentioning any GitHub user. The account handle was used with an @ in several inline review and fold comments on this PR before the rule was caught. The PR body has been cleaned; the inline comments were already sent, and the notifications with them, and I found no API surface to edit review-comment bodies after the fact. Flagging it rather than leaving you to notice the pings.


Generated by Claude Code

Comment thread src/zagg/semantics.py
"driver",
"read_plan",
"anonymous",
"credentials_provider",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

[medium] The epoch excludes the auth half of the fetch-mechanism class but leaves read_workers, source_region and write_buffer in the core — and this epoch is the last cheap moment to rule on them.

The criterion the ruling turns on — "it selects HOW source bytes are fetched … never WHAT is computed from them" — is satisfied verbatim by three data_source keys that are not on this tuple, so they still move semantic_hash (everything not listed falls through _without at src/zagg/semantics.py:422):

  • read_workers — the third fan-out width in the family phase 1 excluded two of. src/zagg/processing/read.py:849-863: "per-worker read concurrency (issue Compiled (hidefix) read path is never exercised by default, and its fetch/decode is serial #170) … Default 8. Peak RSS grows with width …, so dense-shard configs can dial it down; 1 is serial", and src/zagg/processing/worker.py:98-101 sizes the budget as granule_workers x read_workers x fetch width, telling operators to "dial read_workers down as K rises". That is worker sizing in D19's own words, and it is a tuned knob (the pinned read-path baseline is read_workers: 8), so two runs of one product at different widths hash apart and every leaf reads semantic-mismatch — the exact defect (7)(b) closed for granule_workers.
  • source_region — sits in the same dict literal as the already-excluded anonymous, src/zagg/runner.py:907-911:
    source = config.data_source or {}
    src_kwargs = {
        "region": source.get("source_region"),
        "anonymous": source.get("anonymous", True),
    }
    One of those two keys is packaging after this commit and the other is identity.
  • write_buffer — max slabs alive under the streamed raster sink (src/zagg/processing/raster.py:590-599), a memory/throughput knob only.

What makes this worth raising on this commit rather than later is that the design record it amends now asserts the class is already handled: docs/design/sparse_coverage.md:756 justifies the new exclusion as "the same class as the read knobs and as anonymous, already excluded", and the pre-existing D19 list names "worker size" and "read knobs" as packaging. That is precisely the shape of defect (a) two paragraphs above it (sparse_coverage.md:740-744): "Worker size was already named as packaging but its keys were never excluded". After this commit the sentence is still true for three more keys.

Not proposing a unilateral change — adding keys to the exclusion list is a ruling, not a fold (CLAUDE.md §6). But the docstring's own argument cuts both ways: "Ruled at the epoch deliberately … this costs nothing now and cannot be taken back cheaply later." Either they ride this epoch, or the omission deserves a line in the "Deliberately not changed" paragraph of docs/hive_layout.md (which today names only the orders and pyramid) so a reader cannot mistake it for an oversight.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Documentation half folded in 0594d400; the exclusion itself is declined on agent authority — adding a key to DATA_SOURCE_PACKAGING_KEYS is a ruling, not a fold (CLAUDE.md §6), so it stays standing as question (2) on the PR body for espg. Option (c) there already names read_workers and write_buffer; source_region is new here and rides the same question now.

docs/hive_layout.md, after the "Deliberately not changed" paragraph:

Also not changed, and not an oversight: three data_source keys that read as the same fetch-mechanism class as item 1 and item 3 are still in the core, so they still move the digest — read_workers (the third fan-out width, beside the two spellings item 1 excluded), source_region (the raster source-store kwarg that sits in the same dict literal as the already-excluded anonymous), and write_buffer (the live-slab bound on the streamed raster sink). Excluding a key is a ruling, not housekeeping, so this epoch carries only what was ruled; the three stand unruled as an open question on the epoch PR (#420), and excluding them later would be its own epoch.

src/zagg/semantics.py, a new #: paragraph directly above the constant, in the voice the block already uses for shard_workers' unruled status:

Unruled, and therefore still hashed: read_workers (the third fan-out width beside the two spellings above), source_region (the raster source-store kwarg that sits in the same dict literal as anonymous) and write_buffer (the live-slab bound on the streamed raster sink) all read as the same fetch-mechanism class as the entries here. Excluding a key is a ruling, not an assumption — the same footing shard_workers was flagged on above — so they are raised on the epoch PR rather than added, and every one of them still moves semantic_hash today.

docs/design/sparse_coverage.md is untouched: its amendment (c) already uses the accurate class wording, and nothing in it became inaccurate.

Comment thread docs/hive_layout.md Outdated
joined `DATA_SOURCE_PACKAGING_KEYS` (espg-ruled 2026-08-17): the provider
name selects *how* source bytes are fetched, never *what* is computed from
them, so the same granules read with `lpdaac` credentials, `gesdisc`
credentials, or an anonymous open are one product — `anonymous`, the other

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

[low] "anonymous, the other spelling of the same choice" overstates the relationship — the two keys are read by disjoint pipelines, and the phrasing is repeated in three places.

data_source.anonymous has exactly one consumer, the raster source-store kwargs (src/zagg/runner.py:910, declared only by src/zagg/configs/sentinel2_l2a.yaml:32), and the raster path never resolves a credential provider. credentials_provider has two consumers, both on the other side of that split: runner._resolve_source_credentials for the point path's S3 driver (src/zagg/runner.py:2649-2665) and the temporal fan-out (src/zagg/runner.py:1817). No run reads both — a raster run ignores the provider, a point/temporal run ignores anonymous — so they are not two spellings of one knob; they are two knobs of the same class on two paths.

The conclusion is untouched (both are fetch mechanism, both belong out of the core), but the phrase is doing argumentative work in three places — here, src/zagg/semantics.py:111-112, and the test comment at tests/test_semantics.py (test_credentials_provider_is_packaging) — while the design record already uses the accurate form: "the same class as the read knobs and as anonymous, already excluded" (docs/design/sparse_coverage.md:756). Worth making the three agree on the design record's wording.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Folded in e8377b3c. Verified the disjointness against src/zagg/runner.py before rewording: anonymous has exactly one consumer, RasterStrategy.run's source-store kwargs (runner.py:913, declared only by configs/sentinel2_l2a.yaml:32); credentials_provider is read by _resolve_source_credentials (runner.py:2662, called from the point path at runner.py:2955/3486 and client.py:791) and by the temporal fan-out (runner.py:1820). No path consults both.

All three occurrences now carry the design record's wording. src/zagg/semantics.py, first bullet:

a hash that moves with the provider is a false product split. It is the same class as the read knobs and as anonymous, already excluded (a class, not one knob under two names: anonymous is read only by the raster source-store kwargs, credentials_provider only by the point and temporal paths);

docs/hive_layout.md, item 3:

It is the same class as the read knobs and as anonymous, already excluded — the same class, not one knob under two names: anonymous is read only by the raster source-store kwargs, credentials_provider only by the point and temporal paths, so no single run consults both.

tests/test_semantics.py::TestCanonicalization::test_credentials_provider_is_packaging gets the same sentence in comment form.

On test_a_credential_migration_never_rehashes: the comment did overstate, so it was rewritten (assertions unchanged — the both-keys config is still a valid canonicalization input even though no run produces it):

# ...and a config carrying BOTH auth keys drops both. They are two
# knobs of one class on disjoint paths -- anonymous is read only by
# the raster source-store kwargs, the provider only by the point and
# temporal paths -- so this pins canonicalization, not a run shape.

@espg

espg commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

🤖 from Claude

Status: synced with main, the ruled credentials_provider exclusion landed, still waiting on the five standing questions.

Synced with main — twice. The branch had been sitting since 2026-08-10 and predated weeks of merges, so origin/main was merged in at e0ae75b and again at 7f7452c after main moved mid-run (issues #443/#445 landed while this was in flight). Conflicts were narrow and are itemized in the body's phase checklist; the substantive one was src/zagg/semantics.py, where the §8 output.time_encoding inclusion (issue #443) and the epoch's leaf-shaping output block turned out to be disjoint — time_encoding enters the core as a top-level key, not through core["output"], so OUTPUT_LEAF_SHAPING_KEYS and its constants-match-the-core pin are untouched.

The old red test (3.13) is gone. It was tests/test_sweep_stage.py::TestStagePass::test_ratchet_rewrites_on_child_change, the hour-of-day-sensitive ratchet test — fixed on main by PR #421, which this merge brings in. The current suite is 2 failed, 4049 passed, 38 skipped: tests/test_lambda_build.py::TestFunctionBuild::test_function_build_succeeds (the known environmental build failure) and tests/test_client_transport.py::TestStatusPoller::test_invoke_fault_burns_an_attempt_and_retries, a wall-clock-sensitive poller test verified red on a clean origin/main checkout at this session's clock. Both pre-existing, flagged not fixed (CLAUDE.md §4).

Phase 5 — the ruled addition. data_source.credentials_provider is now in DATA_SOURCE_PACKAGING_KEYS (espg-ruled in-session 2026-08-17, issue #449). The rationale is recorded at the constant, in the migration note, and in the D19 design-record amendment: it selects how source bytes are fetched, never what is computed; the wrong credential fails the fetch loudly rather than silently, so nothing depended on the digest to catch it; and a credential migration over unchanged data (the MERRA-2 / gesdisc path) must never rehash. Ruled at the epoch so the first GEDI store's identity is born without an auth knob in it. Pins: test_credentials_provider_is_packaging, test_a_credential_migration_never_rehashes, and the gate-level tests/test_hive.py::TestLeafSkipIfCurrent::test_a_credential_migration_still_reads_current — a leaf written without a provider, rerun under a config that names one, must still read equal with the fold armed to raise if it runs.

PR #450 interaction. That PR's test_credentials_provider_is_not_in_the_packaging_list is a deliberate pin of the pre-ruling behavior whose own comment says it flips if the key is ruled packaging. It has not merged, so this PR writes the equality test fresh; #450's pin will conflict at its next main sync and should adopt the flip. Its credentials_provider: lpdaac line on the GEDI template is correct either way.

Phase 6 — a real defect the merge surfaced. flux/ (issue #424) and raster_toc/ (issue #443) were generated on main after this branch's phase 3 and merged in carrying pre-epoch semantic_hash values that no post-epoch zagg reproduces — precisely what TestFixtureSemanticHash was added to prevent, missed only because its parametrize list was a hardcoded three. Both regenerated through the production path (diff: semantic_hash, timestamps, one zagg_version — no content_hashes movement), and test_every_fixture_is_covered now walks tests/data/spec/**/morton_hive.json so the next fixture cannot ship unpinned.

Unchanged, and still yours. The five questions in the body stand exactly as filed, as do the three findings the earlier folds declined. Phase 5's own review found two things and split the same way: the anonymous-vs-credentials_provider wording is folded (e8377b3c — they are the same class, not one knob under two names; their consumers are disjoint), while the proposal to exclude read_workers/source_region/write_buffer in the same breath is declined on agent authority — excluding a key is a ruling, not a fold — and rides question (2), which now also names source_region — the key that sits in the same dict literal as the already-excluded anonymous, so one of those two neighbours is packaging after phase 5 and the other is identity. All three are now named as still-hashed-and-unruled at the constant and in the migration note (0594d400), so the omission cannot be read as an oversight. Nothing there is decided here.

The PR stays draft and waiting.

CI confirms it, on e8377b3c: ruff, build, build-x86_64, build-arm64, test (3.12) and test (3.13) all green — the first fully green run this PR has had, and the 3.13 job that was red on bc910da passes on the merged tree.

@espg

espg commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

🤖 from Claude

Status: synced with main again after today's merges (38785e4), the anticipated PR #450 pin-test conflict resolved in favor of the ruled equality tests, still waiting on the five standing questions.

What the merge brought in. origin/main at d35e576 — PR #456 (the §8.2/§8.3/§9 temporal/located companion spec, issue #410: docs/specification.md, grids/base.py, the temporal/ conformance fixture), PR #450 (the 2026-08-17 small-fix bundle: NaN-safe dispatch payload, LP DAAC credentials, GEDI filter set), plus the issue #452/#457 work. Git auto-merged everything — zero textual conflicts — so the two real reconciliations below were semantic, caught by inspection and the suite, and both ride the merge commit.

The #450 pin-test flip, exactly as forecast in the previous sync comment. #450 landed test_credentials_provider_is_not_in_the_packaging_list, the deliberate pre-ruling pin whose own comment says it "flips to the equality assertion" if the key is ruled packaging. It merged in without textual conflict, so the suite would have carried both the pin and this PR's phase-5 test_credentials_provider_is_packaging — one of which must fail. Resolution: the superseded pin is removed; the equality assertion it flips to already exists as test_credentials_provider_is_packaging (plus test_a_credential_migration_never_rehashes and the gate-level hive test), so flipping in place would only have duplicated it. #450's credentials_provider: lpdaac line on the GEDI template needed nothing — correct under the ruling.

The temporal/ fixture arrived pre-epoch — the third catch for TestFixtureSemanticHash. PR #456's fixture was generated on main and carried a semantic_hash (90e7480a…) no post-epoch zagg reproduces, the same defect this gate caught on flux/ and raster_toc/ at the last sync. Regenerated through the production path (tools/generate_spec_fixtures.py --only temporal; diff: semantic_hashe1ee00d1…, timestamps, one zagg_version string — no content_hashes movement), pinned via test_the_temporal_fixture_hash_is_reproducible, and added to COVERED so test_every_fixture_is_covered stays exhaustive.

Tests. Full suite on the merged tree: effectively 2 failed, ~4185 passed, 38 skipped — both failures pre-existing and previously flagged (test_lambda_build.py::TestFunctionBuild::test_function_build_succeeds, environmental; test_client_transport.py::TestStatusPoller::test_invoke_fault_burns_an_attempt_and_retries, wall-clock-sensitive, red on clean main too). The full run's third failure was a collection artifact (it started before the temporal repin landed in the tree); test_spec_conformance.py + test_semantics.py + TestLeafSkipIfCurrent rerun on the final tree: 203 passed. Two local-only ruff findings (an N818 in registry.py, a markdown snippet in tests/data/benchmark/README.md) are on files byte-identical to main — local ruff 0.16.3 version drift, not this diff; flagged, not fixed.

CI. Check-runs fired on 38785e4 — no PR-event stall. ruff, build, build-x86_64, build-arm64 green; test (3.12) / test (3.13) in progress at time of writing.

Unchanged. The five questions in the body stand exactly as filed, as do the declined findings. Nothing in this sync touches them. The PR stays draft and waiting.

@espg espg left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

Fresh-context adversarial review of phases 7 and 8 only (2cf022e7..623bded0; everything at or below 38785e45 treated as already folded). Six findings, ranked in the inline comments: two blocking (a false back-compat claim in the operator docs, and a contraction that the canonicalization can now hide), two worth fixing (an unsupported docstring claim with a vacuous test behind it, and a dated measurement cited for two knobs it cannot cover), two nits.

Verified locally on the worktree: tests/test_telemetry.py tests/test_dedup.py tests/test_semantics.py tests/test_spec_conformance.py → 314 passed; tests/test_hive.py → 127 passed; ruff check / ruff format --check clean on all eight touched Python files. The hand-edited fixture digest checks out — sha256("a.h5") == d02afc90c38fa5066e787040b91eaeb33b840928aaae85561eb54869fd6a668d, matching all four granules.json edits — and nothing in tests/test_spec_conformance.py or docs/specification.md reads granules_sha256/granules.json, so leaving docs/specification.md untouched is right (but see the note in finding (1) about what that means for coverage). Key names for the three question (2)(c) additions all check out as data_source.* (config.py:495, config.py:1006, runner.py:912) and validate_config still range-checks all three, so nothing became silently unvalidated. emit_cell_ids is cleanly out of semantics.py with no dangling reference and no now-unused import (get_emit_cell_ids is still live in grids/healpix.py:132, and healpix.signature() still carries it — which is the artifact-level cover the ruling leans on).

Comment thread docs/hive_layout.md Outdated
Comment on lines +989 to +995
The **driver-dependence** caveat is gone. The recorded id space used to be the
resolved href, so flipping `data_source.driver` between runs read as a full
mixed contraction and refused per leaf. Since the epoch both sides are reduced
to the canonical driver-stripped bare granule id (item 5 of the migration
note), so a driver switch over unchanged granules reads `equal` — including
against leaves whose recorded list predates the epoch, since the *recorded*
side is canonicalized on read too.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

(1) BLOCKING — this paragraph's back-compat claim is false, and it is the one an operator will act on.

"a driver switch over unchanged granules reads equal — including against leaves whose recorded list predates the epoch, since the recorded side is canonicalized on read too."

A pre-epoch leaf cannot reach equal, because the recorded side of the fast path is not a list — it is the stored granules_sha256, which was taken over resolved hrefs. classify_leaf_identity computes hashes_match = rec_hash == granules_sha256(planned) with planned now canonical, so on every pre-epoch leaf that compare is False and the equal arm is unreachable no matter what the sibling holds. Reproduced on this tree:

S3 = ['s3://b/g1.h5', 's3://b/g2.h5']
pre = hashlib.sha256('\n'.join(sorted(S3)).encode()).hexdigest()   # href-space, as written pre-epoch
rec = {'granules_sha256': pre, 'semantic_hash': 'a'*64}
classify_leaf_identity(rec, semantic_hash='a'*64, planned_ids=S3, load_recorded_ids=lambda: S3)
# -> {'action': 'rewrite', 'classification': 'id-multiset-drift', 'missing': []}

Same granules, same semantic hash, and it still rewrites — plus it pays the sibling GET the fast path exists to avoid. So the operator consequence is the opposite of what is promised here: over a pre-epoch store a driver switch rewrites every leaf once (which is fine and expected at an epoch), it does not skip.

Migration-note item 5 already states the accurate, weaker version — "the classifier canonicalizes the recorded side as well, so a pre-epoch leaf diffs cleanly instead of reading as a full contraction" — and the dedup.py:317 comment says the same thing correctly. Only this paragraph overclaims.

Suggested fix: scope the claim to leaves written at or after the epoch, and say plainly that pre-epoch leaves rewrite once because their recorded catalog digest is in the old space (which the note's own "every pre-epoch hash invalidated" headline already implies). Two related gaps worth closing in the same pass, since nothing currently guards the claim either way: there is no test anywhere in this diff for a pre-epoch (href-space) sidecar hash — every new pin writes both sides post-epoch — and docs/specification.md does not describe granules_sha256/granules.json at all, so the surgical granules.json fixture edits are covered by no assertion in tests/test_spec_conformance.py (TestFixtureSemanticHash only recomputes semantic_hash). A single pin in tests/test_dedup.py with an href-space granules_sha256 on the recorded record would pin the real behaviour and keep the doc honest.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Folded in 31af5392 (doc + comment + pin) and ab7cd89d (the fixture guard you flagged in the same finding).

You are right and the repro is right — hashes_match compares the stored digest, which on a pre-epoch leaf is href-space, so the equal arm is unreachable there. The paragraph now splits the claim by which side of the epoch the leaf was written on: at-or-after → equal + skip on the hash fast path; pre-epoch → rewrites once, and what canonicalizing the recorded side buys is the direction of that rewrite (id-multiset-drift/expansion instead of refusing as a spurious contraction, so a driver switch over a pre-epoch store no longer needs --allow-contraction).

Verified your other two calls before touching them: migration-note item 5 and the dedup.py comment above planned = canonical_granule_ids(...) are both already accurate, so neither was rewritten — the comment only gained the explicit "such a leaf still REWRITES once, because hashes_match is False by construction" clause so no reader can re-derive the claim the doc used to make.

New pin, tests/test_dedup.py::TestClassifyLeafIdentity::test_a_pre_epoch_leaf_rewrites_rather_than_refusing: a genuinely href-space granules_sha256 (hashlib.sha256 over the sorted hrefs, asserted != granules_sha256(IDS)) with an href-space sibling, parametrized over a canonical plan and the pre-epoch spelling. Both read {"action": "rewrite", "classification": "id-multiset-drift", "missing": []} with exactly one sibling GET — not skip, and specifically not a refusal.

Fixture gap closed too, in ab7cd89d: tests/test_spec_conformance.py::TestFixtureGranuleIdentity now asserts every committed granules.json self-pairs (granules_sha256(granule_ids) == granules_sha256 field) and is canonical-and-sorted (canonical_granule_ids(ids) == ids == sorted(ids)), plus test_every_leaf_fixture_carries_the_sibling, enumerated off TestFixtureSemanticHash.COVERED × each fixture's expected["leaf"] the way test_every_fixture_is_covered does, so a new leaf fixture cannot ship its sibling unguarded (pyramid/ is manifest-only, hence keyed on the leaf). Six siblings, all green; no digest moved.

Comment thread src/zagg/dedup.py Outdated
# docstring's one-sided-conservatism note).
return {"action": "rewrite", "classification": "unrecorded-ids", "missing": []}
recorded_set = {str(g) for g in rec_ids}
recorded_set = set(canonical_granule_ids(rec_ids) or [])

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

(2) BLOCKING — basename collapse converts a genuine contraction into a silent rewrite, which inverts the guard's one-sided conservatism.

canonical_granule_ids preserves list length, so granules_sha256 still distinguishes a colliding pair. But the diff below is over sets, so once two distinct granules collapse to one canonical id, dropping one of them is invisible:

REC     = ['s3://v006/ATL06_X.h5', 's3://v007/ATL06_X.h5', 's3://v007/ATL06_Y.h5']
planned = ['s3://v007/ATL06_X.h5', 's3://v007/ATL06_Y.h5']    # one distinct granule genuinely dropped
classify_leaf_identity(rec, semantic_hash=SEM, planned_ids=planned, load_recorded_ids=lambda: REC)
# -> {'action': 'rewrite', 'classification': 'id-multiset-drift', 'missing': []}

Pre-epoch that same input refused (contraction, missing naming the dropped href). This is exactly the direction the class docstring twelve lines up says must never happen: "For the contraction guard it is not [safe]: a rewrite over a contracted input set destroys aggregated data the operator was never told about, which is the loss issue #388 exists to prevent." The refusal manifest records nothing, and the CLI reports an ordinary rewrite.

The collapse is documented as an accepted cost, and the ruling is espg's — but the docs price it as an identity collision only (telemetry.py:157, sparse_coverage.md: "two granules whose hrefs differ only in prefix collapse to one identity"), not as the contraction guard going quiet. That second consequence is the one that costs data, and it is not stated anywhere in this diff.

Suggested fix — cheap and it restores the pre-epoch verdict without touching the ruling: make the diff a multiset. missing = sorted((Counter(recorded_set_ids) - Counter(planned)).elements()) and added = Counter(planned) - Counter(recorded) recovers the refusal under collisions, and is a no-op on every non-colliding input (so no existing pin moves; id-multiset-drift still catches the true duplicate-id case, which has equal counts on both sides). If a multiset diff is out of scope for this PR, then at minimum say in telemetry.canonical_granule_id's "accepted cost" paragraph and in the migration note that a collision degrades a contraction to a rewrite, so the cost is recorded on the side where it actually lands, and add a pin for the behaviour you intend.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Partly folded in 46936c56 — the honest parts landed; the predicate change is declined and stands for espg.

Folded:

  • zagg.dedup._warn_on_collapsed_recorded_ids now runs before the diff and emits a logger.warning whenever canonicalization collapses distinct recorded ids (grouped by canonical id, so it fires only on genuine collisions, never on a recorded duplicate). It names the colliding canonical id(s) and says the guard cannot resolve a drop within a collapsed group on that leaf. Silent was the complaint; loud is what is available without a ruling.
  • Docs re-priced, in all three places that treated the collapse as an identity collision only: canonical_granule_id's "accepted cost" paragraph, docs/hive_layout.md migration item 5, and the catalog-identity clause in docs/design/sparse_coverage.md. Each now also states the direction: a dropped member of a collided pair reads as id-multiset-drift and rewrites where the pre-epoch href-space diff refused and named the dropped href.
  • New pin, tests/test_dedup.py::TestClassifyLeafIdentity::test_collapsed_recorded_ids_degrade_the_guard_loudly, on your exact v006/v007 input: asserts the rewrite/id-multiset-drift verdict and that the warning fires naming ATL06_X.h5. The state is now pinned rather than incidental.

Declined — the Counter-based multiset diff. It is not a no-op on every existing input: tests/test_dedup.py::TestClassifyLeafIdentity::test_duplicate_drift_with_equal_sets_rewrites feeds recorded = IDS + [IDS[0]] against planned = IDS, whose counts are not equal on both sides (2 vs 1 for g1.h5), so Counter(recorded) - Counter(planned) is non-empty and that pinned, espg-ruled rewrite becomes a refusal. And after collapse the two cases are byte-identical data — a recorded duplicate and two distinct granules sharing a basename are indistinguishable — so no diff can separate them. Choosing which way that ambiguity resolves is a ruling on the ruled predicate recorded − planned ≠ ∅, not a fold.

So it stands for espg as one question: should a collapsed-id ambiguity resolve toward refuse (data-loss-safe, at the cost of a false refusal on genuine duplicate drift, i.e. re-ruling the pin above), or stay as shipped (rewrite, now warned loudly per leaf and documented in both directions)?

Comment thread src/zagg/telemetry.py Outdated
Comment on lines +150 to +155
mappings; the record's granule identity is the **primary**, exactly as
before, so a caller passing resolved strings (the local backend) and one
passing the worker payload verbatim (the Lambda handler) still agree. Each
sibling asset href normalizes by this same rule wherever it is taken, so a
paired entry is href-form-independent in every component, not just its
primary.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

(3) The "every component" claim is not what the code does, and the test named after it cannot fail.

"Each sibling asset href normalizes by this same rule wherever it is taken, so a paired entry is href-form-independent in every component, not just its primary."

canonical_granule_id reduces a paired entry to entry.get("url") and drops assets entirely — the sibling hrefs never enter the recorded identity, so a paired entry is href-form-independent in its assets not because they normalize but because they are absent. That is pre-existing behaviour from issue #425 and probably fine, but the sentence asserts a property of a code path that does not exist, and a reader taking it at face value would conclude the recorded catalog identity covers the L2B sibling a paired GEDI read actually consumed. It does not: swap the sibling asset for a genuinely different granule and granules_sha256 is unchanged.

Which is also why tests/test_telemetry.py::TestCanonicalGranuleIdentity::test_paired_asset_entries_normalize_in_every_component is vacuous — both sides use the same sibling basename (SIB_A.h5), so the assertion passes identically whether assets are normalized, ignored, or replaced. It pins the primary-URL rule (which is worth pinning) and nothing about "every component."

Suggested fix: reword the paragraph to say the sibling assets sit outside the recorded identity (the primary is the identity, unchanged by this epoch), and rename the test to what it checks — something like test_paired_asset_entries_identify_by_their_primary. If sibling-asset identity should be in the catalog hash, that is a separate ruling and belongs as its own question rather than as an implied property here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Folded in 34f1d40b.

The docstring no longer asserts a normalization that does not happen. It now says a paired entry's identity is its primary alone — issue #425's invariant, which is what keeps the local backend (resolved strings) and the Lambda handler (payload verbatim) on one hash — that assets are dropped so a sibling href never reaches a digest at all, and that whether the recorded catalog identity should cover the sibling a paired read consumes is a separate ruling, not implied here.

The test is renamed to test_paired_asset_entries_identify_by_their_primary and made discriminating: the two sides now carry entirely different sibling granules (s3://b/prefix/SIB_A.h5 vs https://h/other/GEDI02_B_WHOLLY_OTHER.h5, under different asset keys) and the digests must still agree. That is the property, and it fails if assets ever start reaching the digest — where the old same-basename form passed whether assets were normalized, ignored, or replaced.

Comment thread src/zagg/semantics.py
Comment on lines +155 to +164
#: open the source" decision across both sides of the D19 line.
#: * *The failure asymmetry runs the safe way.* Each fails LOUDLY in its own
#: direction — a too-small pool is slower, a wrong region is a connection
#: error, an over-large buffer is an OOM — so nothing depended on the digest
#: to catch them, while hashing them silently splits one product in two.
#: The live demonstration is dated: two GEDI flux builds of the same shard on
#: 2026-08-17 produced identical ``total_obs`` and ``cells_with_data`` in the
#: exact single-block spill regime and still hashed apart, purely on
#: worker/streaming machinery.
#: * *A machinery migration must never rehash unchanged data* — retuning a pool

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

(4) The dated measurement cannot demonstrate two of the three knobs it is cited for, and the three copies of it disagree on scope.

write_buffer and source_region are both raster-only: write_buffer is read by zagg.processing.raster._write_buffer (raster.py:632, guarded in config.py:1006) and reaches nothing on the point/temporal path; source_region appears exactly once, in the raster branch's source-store src_kwargs (runner.py:911-914, right after emit_raster_template). A GEDI flux build is the point path, so it cannot have hashed apart on either of them — the demonstration covers the fan-out widths (read_workers and the two *_workers spellings phase 1 excluded) and no more. As written the anecdote reads as evidence for all three.

The same paragraph is pasted in three places with three different scopes, which makes the overreach easy to spot: here it is "purely on worker/streaming machinery"; docs/hive_layout.md item 4 says "purely on worker/streaming/index machinery" (index machinery is not one of the three keys at all); docs/design/sparse_coverage.md clause (d) says "on worker/streaming machinery alone."

The arguments for excluding the two raster keys stand on their own without the anecdote — source_region sharing a dict literal with the already-excluded anonymous is the strongest single line in this section, and it needs no measurement. Suggested fix: attach the dated measurement to read_workers where it applies, drop the "index machinery" variant, and make the three copies say the same thing. (Separately, for the record and not a change request: the claim that write_buffer is "a peak-memory knob over an identical written result" is true of the array bytes, but the streamed content-hash recipe can be invalidated by a parking overflow bounded by STREAM_PENDING_MAX_BYTES under in-flight slabs — content_hash.py:164-172 — so write_buffer can change whether content_hashes is recorded at all. That is O11 telemetry, not leaf identity, so it does not touch the ruling; it just means "identical written result" is doing narrower work than it sounds.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Folded in 344f53ad.

All four copies of the anecdote are now scoped to what it can actually demonstrate — the fan-out widths (read_workers plus the two *_workers spellings) — and each says so in the same words, with the reason the other two are out of its reach stated inline: write_buffer is read only by zagg.processing.raster._write_buffer and source_region only in the raster branch's src_kwargs, while a GEDI flux build is the point path. Their exclusion now rests explicitly on the arguments (mechanism, loud failure, and source_region sharing a dict literal with the already-excluded anonymous), which stand without a measurement.

Sites touched: the DATA_SOURCE_PACKAGING_KEYS docstring in src/zagg/semantics.py, docs/hive_layout.md migration item 4 (the "index machinery" variant is gone — you are right that index machinery is not one of the three keys), docs/design/sparse_coverage.md clause (d), and the comment on tests/test_semantics.py::test_a_machinery_migration_never_rehashes, which now says why it pins all three keys even though the measurement covers one.

Your STREAM_PENDING_MAX_BYTES note is deliberately not in the diff, as you framed it: a parking overflow can change whether content_hashes is recorded, which is O11 telemetry rather than leaf identity, so it touches no ruling here. Acknowledged on the record — "identical written result" is doing narrower work than it sounds — and left for a separate thread if it should be written down.

Comment thread src/zagg/telemetry.py Outdated
ruling excludes.
"""
if isinstance(entry, dict):
entry = entry.get("url")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

(5) Nit, but a loud failure became a silent one: a paired entry missing url now mints the literal id "None".

canonical_granule_id({"assets": {}})  # -> 'None'
canonical_granule_id(None)            # -> 'None'

Pre-epoch, build_record's g["url"] if isinstance(g, dict) else g raised KeyError on a url-less mapping — a malformed worker payload could not become a recorded identity. .get("url") swallows it, and because every such entry canonicalizes to the same string, N malformed entries collapse to one recorded id, which shrinks the recorded set and can hide a contraction the same way finding (2) describes. Nothing in the current dispatchers produces such an entry (_resolve_granule_entries always sets url), so this is a robustness nit rather than a live bug — but the whole point of the D19/D20 identity pair is that an identity is never silently wrong.

Suggested fix: keep the pre-epoch loudness — entry = entry["url"] in the dict branch, and reject None/"" rather than stringifying it. Related, same function: granules_sha256's annotation is still Iterable[str] | None, but the documented contract now accepts paired-asset mappings and any href form (see the shard_status warning this diff rewrote), so Iterable[Any] | None would match canonical_granule_ids and the callers.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Folded in 88d234f2.

canonical_granule_id no longer mints "None". A mapping with no usable url primary raises ValueError naming the offending entry, and a None/"" id raises too — restoring the pre-epoch loudness of build_record's g["url"] without reintroducing the KeyError. The docstring records why it must be loud rather than lenient: every such entry canonicalizes to the same string, so N of them collapse onto one recorded id and shrink the recorded set — the same shape as finding (2)'s collision. Confirmed your read that nothing live produces one: zagg.runner._resolve_granule_entries skips a record without a href (if not url: continue) and always sets a non-empty url.

Annotation widened: granules_sha256(granule_ids: Iterable[Any] | None), matching canonical_granule_ids and the documented contract, with the reason noted in its docstring. build_record's granule_ids parameter had the same stale Iterable[str] and takes paired mappings through the same path, so it moved with it.

Pin: tests/test_telemetry.py::TestCanonicalGranuleIdentity::test_a_malformed_entry_raises_rather_than_minting_an_identity, parametrized over {"assets": {}}, {"url": None}, {"url": ""}, None and "", asserting the raise through canonical_granule_id, canonical_granule_ids and granules_sha256 — so it cannot be swallowed on the way to a sidecar. Full suite green apart from the two known pre-existing failures; no digest moved.

Comment thread src/zagg/telemetry.py Outdated
CANONICAL granule-id space — the driver-stripped bare id
(:func:`canonical_granule_id`, espg-ruled at the D19 hash epoch) — so the
identity a leaf carries names the granules it read and not the driver that
fetched them. It is deliberately not a record key — identity equality is the hash compare every fan-out reader

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

(6) Nit — reflow miss. This line is 112 chars and is the only over-100 line this diff adds (ruff does not catch it: E501 is in the ignore list per pyproject.toml, so nothing but review will). The rest of the module docstring wraps at ~79, and the sentence it interrupts ("It is deliberately not a record key — ...") reads as if the insertion above was spliced in without rewrapping. Reflow the paragraph.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Folded in d0e9fbdb. The paragraph is rewrapped so the "It is deliberately not a record key — ..." sentence flows at the module docstring's ~79-column width instead of running to 112. Checked the rest of the diff for the same miss with awk 'length > 100' over the touched modules — this was the only one, and it stays clean after the later folds.

@espg

espg commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

🤖 from Claude

Status: all five standing questions are ruled and landed. Phases 7 and 8 are in, the review findings are folded, and the PR is out of draft and waiting — on the merge decision plus one new question the fold opened.

espg ruled all five in-session on 2026-08-17. What landed for each:

(1) Canonical granule identity → (b), the driver-stripped bare granule id (phase 8, 623bded0). espg's words: "we want the granule to trigger the hash, not how that granule is fetched." Landed across both halves the question named — granules_sha256 and the recorded id list — at three seams so no path can record a non-canonical id: telemetry.canonical_granule_id/canonical_granule_ids (the rule), telemetry.granules_sha256 + telemetry.write_granule_ids (digest and recorded list), and dedup.classify_leaf_identity (both sides of the diff, which is what lets a pre-epoch leaf carrying full hrefs compare cleanly instead of reading as a full contraction). Paired-asset {url, assets} entries (issue #425) still identify by their primary, so the local backend and the Lambda handler agree on one hash; every component normalizes, so a sibling href form cannot move the identity either.

(2) shard_workers scope → (a) + (c) (phase 7, 2cf022e7). Both spellings stay excluded, and read_workers / write_buffer / source_region joined DATA_SOURCE_PACKAGING_KEYS on credentials_provider's three-part test. The "still-hashed-and-unruled" language phase 5's fold added for those three is removed — they are ruled now. The live demonstration is dated and in the body: two GEDI SERC flux builds on the deployed 0.46.0 fleet, same shard, identical total_obs and cells_with_data in the exact single-block spill regime, and two different semantic_hash values that differ only in worker/streaming/index machinery.

(3) The sharding declaration → (a), left as landed (phase 7). No code change; the constant's text reads as espg-ruled rather than open, with both limits (K == 1 over-discrimination, chunk_inner still moving K) stated as bounded and deliberate — closing the second costs part of D24.

(4) emit_cell_ids → (b), back OUT of the core (phase 7). The by-name exclusion is restored in the module docstring and GRID_LEAF_SHAPING_KEYS, with a dedicated rationale test (test_emit_cell_ids_is_packaging) carrying the issue #304 argument: the hatch is scheduled for removal, after which a store built with it ON would carry a digest no legal config reproduces. Same closing argument as output.pyramid — a leaf's array inventory is verified by reading the leaf.

(5) The draft→ready flip → resolved. With the rulings landed, the findings folded and CI green, CLAUDE.md §2 applies without conflict: flipped ready-for-review (reversible), waiting set. Merging is not the agent's.

The fold, and the one thing it left open. Phases 7 and 8 drew 6 review findings; 5 are folded and 1 is split. The folded five, briefly: docs/hive_layout.md's "the driver-dependence caveat is gone" paragraph overclaimed and is now split by epoch side (31af5392) — a pre-epoch leaf's stored granules_sha256 is href-space, so hashes_match is False by construction and the equal arm is unreachable there; what canonicalizing the recorded side buys is the direction (id-multiset-drift/expansion instead of a spurious contraction refusal), which is the accurate claim and is now pinned. The granules.json siblings were unguarded — nothing in the suite read them — closed with TestFixtureGranuleIdentity and an enumerated test_every_leaf_fixture_carries_the_sibling (ab7cd89d), which also turned up that there are six committed siblings, not four. canonical_granule_id's sibling-asset claim was unsupported (a paired entry's identity is its primary alone) and its test was vacuous — both fixed (34f1d40b). The dated GEDI measurement was cited for two raster-only knobs a point-path build cannot exercise: scoped to the fan-out widths at all four sites (344f53ad). A url-less mapping used to mint the literal id "None" and collapse N such entries into one — now a pointed ValueError (88d234f2). One 112-char docstring line reflowed (d0e9fbdb).

Question (6), new and standing for espg (now in the body): basename collapse can make a genuine contraction invisible, because classify_leaf_identity diffs SETS — two distinct granules sharing a basename across different prefixes collapse, and dropping one then reads id-multiset-drift and rewrites where pre-epoch it refused and named the dropped href. The loud half is folded (46936c56: a warning naming the colliding id, the docs re-priced to state the guard consequence and its direction, and test_collapsed_recorded_ids_degrade_the_guard_loudly). The Counter-based predicate change is declined on agent authority — it would flip test_duplicate_drift_with_equal_sets_rewrites, an existing pin of the ruled predicate recorded ∖ planned ≠ ∅, from rewrite to refuse. After collapse the two inputs are byte-identical data, so no diff can separate them; which way that ambiguity resolves is a ruling, not a fold. Three options are laid out in the body. Worth pricing first: the collapse needs two granules in ONE shard sharing a basename, which every catalog zagg reads rules out by construction — that is why the catalog id equals the basename.

Fixtures — a deliberate deviation worth flagging. The epoch's fixture obligation was applied field-surgically this time (only semantic_hash in each morton_hive.json, and the granules.json siblings), not by wholesale regeneration. A full tools/generate_spec_fixtures.py run was tried first and it broke TestLocatedDeclaration::test_absent_declaration_on_the_pre_section_9_fixture: tests/data/spec/kitchen_sink/ is deliberately frozen as the pre-§9 fixture, and regenerating it writes today's located declaration into two arrays and today's overview_delta into the pyramid attrs. Both values came from work merged after that fixture was generated (issues #410/#424), so main carries fixtures that are stale in fields other than the digests — pre-existing, flagged not fixed (CLAUDE.md §4), and separable from this epoch. Every value written here came from the production path (--out into a scratch tree, digest fields copied across); no content_hashes moved, and TestFixtureSemanticHash + test_every_fixture_is_covered are green over all seven fixtures.

Tests. Full suite on the final tree: uv run pytest -q2 failed, 4203 passed, 38 skipped in 253 s — both failures the known pre-existing pair below. test_semantics.py + test_telemetry.py + test_dedup.py + test_hive.py + test_spec_conformance.py together: 441 passed. New pins: the three per-key packaging equalities and three machinery-migration equalities for (2); test_emit_cell_ids_is_packaging for (4); and for (1) a TestCanonicalGranuleIdentity class over real ICESat-2 href trios (s3 / https / bare / the s3 driver's stripped bucket/key form all hash identically and record identical ids), the sibling-object round trip, the paired-asset invariance, the raster id space untouched, plus gate-level pins in tests/test_dedup.py::test_a_driver_switch_still_reads_current (parametrized over four href forms) and tests/test_hive.py::TestLeafSkipIfCurrent::test_a_driver_switch_still_reads_current — a leaf written from s3 hrefs, rerun with the https hrefs of the same granules, must read equal with the fold armed to raise if it runs.

Both golden pins moved deliberately: test_golden_hash_pinfb15224f7265… (question (4)'s removal, in one line) and the seven fixture manifests. granules_sha256 and granules.json are not spec-normative (docs/specification.md never mentions either), so §7's fixture set moves without a spec edit; the operator-facing statement lands in docs/hive_layout.md where §4.7's informative scope-out already places this family.

Pre-existing failures, flagged not fixed: tests/test_lambda_build.py::TestFunctionBuild::test_function_build_succeeds (environmental) and tests/test_client_transport.py::TestStatusPoller::test_invoke_fault_burns_an_attempt_and_retries (wall-clock-sensitive, red on clean main). The local-only ruff findings on registry.py (N818) and tests/data/benchmark/README.md are on files byte-identical to main — local version drift, untouched.

No digest moved in the fold: test_golden_hash_pin and all seven fixture semantic_hash pins pass untouched, and no fixture needed regenerating.

CI. Fully green on the phase-8 head 623bded0ruff, build, build-x86_64, build-arm64, test (3.12), test (3.13). Check-runs fired on every push (6, 6, 8) — no PR-event stall. The fold head 88d234f2 is green on the same six: ruff, build, build-x86_64, build-arm64, test (3.12) (8m29s), test (3.13) (12m15s).

@espg

espg commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

🤖 from Claude

Question (6) ruled: (a), leave as shipped (espg, in-session 2026-08-17).

Rationale on record: the collapse precondition (two granules in one shard sharing a basename across prefixes) is impossible by construction in every catalog zagg reads — rec["id"] == basename exists because per-shard basename uniqueness is a catalog invariant — and the shipped behavior fails safe on both axes: loud (the folded warning + doc + pin) and toward rewrite, which recomputes the leaf from the current plan (correct bytes; the cost is one redundant leaf write, never a stale skip). Options (b)/(c) were declined as paying observed-path or permanently-dormant costs to guard an unobservable state, with (b) additionally flipping the ruled recorded ∖ planned ≠ ∅ predicate pin.

Optional hardening noted for a future small-fix train (not this PR): enforce the invariant where it is owned — refuse at shardmap build when two granules in one shard share a basename. A build-time, human-present error provably keeps the ambiguous state from ever reaching the leaf gate, making the leaf-side variants permanently unnecessary.

With this, questions (1)–(6) are all ruled and landed or closed; nothing further stands on this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

D19 hash epoch: granule_workers into DATA_SOURCE_PACKAGING_KEYS; semantic_core widened to the leaf-shaping output knobs

2 participants