Skip to content

Stamp grant digest state with its hash ABI version and enforce it at Open - #1121

Open
mj-palanker wants to merge 2 commits into
mainfrom
mjp/grant-digest-abi-stamp
Open

Stamp grant digest state with its hash ABI version and enforce it at Open#1121
mj-palanker wants to merge 2 commits into
mainfrom
mjp/grant-digest-abi-stamp

Conversation

@mj-palanker

Copy link
Copy Markdown
Contributor

Summary

GrantDigestABIVersion names the framing of the grant content hash and the principal bucket hash, but nothing on disk recorded which version a file's digest nodes and hash-index rows were computed under. A future bump would have had no way to tell current state from state built by older hash code, short of an index migration, which older binaries can re-pollute after it has run once. This PR adds the durable bookkeeping needed to make such a bump safe on its own, and closes two gaps in how its consequences reach consumers. The constant stays at 1; no hash changes here.

Changes

ABI stamp (pkg/dotc1z/engine/pebble)

  • A durable stamp key (rawdb.GrantDigestABIStampKey, uint32 BE) lives inside the digest keyspace under a reserved meta index id. Every wholesale destroyer of digest state, including those in already-shipped SDKs, erases it without knowing it exists. The presence probe now spans only the node keyspace, so a leftover stamp over an empty file arms nothing.
  • Every global-root write site writes the stamp first, so a durable root is never uncertified.
  • Open reads the stamp after the presence probe. A writable open drops all digest state whose stamp does not name the current version, and the next EndSync rebuilds from scratch through the existing digests-absent path. A read-only open cannot drop, so it sets grantDigestAbiStale and the digest root getters report "never built", the same fail-safe shape as the interrupted-build marker.
  • A missing stamp over present digest nodes reads as version 1. Every SDK build that predates the stamp hashed at that version, so introducing the stamp costs existing files no rebuild. An unstamped file stays unstamped until its first global-root rewrite, which is fine: absence keeps reading as 1.

Consumers of the untrusted-state flags

  • The stale flag and the pre-existing build-pending flag are folded into one grantDigestStateUntrusted helper, consulted by both root getters and by ComputeEntitlementBucketDigest, which previously folded hash-index rows with no check and would have returned digests derived from another hash scheme's content on a read-only open of a stale file.
  • The bucket scan is deliberately left ungated. Bucket membership is the principal bucket hash over the encoded principal identity, frozen with the v3 key encoding and unchanged by any ABI bump, so a stale file's bucket placement is still exact.

Fold compaction (pkg/synccompactor)

  • A fold that writes no grants used to leave the base's copied digest state alone unconditionally. That is right when the state is present, but the dest's writable Open may have just dropped it, or the base may never have had any (digest index disabled at seal, or an earlier build failure). Such a fold shipped an output with no digest state and nothing downstream to repair it. When the digest index is enabled and no state is present, the fold now runs the repair, which delegates to the full build: a one-time O(base) cost the first time a digest-less base is folded, after which the output is stamped and later folds pay nothing extra.

Rollout behavior

  • Existing files open with no drop and no rebuild, since a missing stamp reads as the current version 1.
  • Files get stamped on their next global-root rewrite (any seal that repairs or rebuilds digests).
  • A later bump of the constant is then sufficient by itself: stamped-at-1 and unstamped files alike are dropped and rebuilt at the new version on their next writable use, and a binary rolled back across the bump re-stamps at its own version on its next repair, so the newer binary detects the mixed state instead of trusting it.

Docs

Three older comments that said a hash framing change "requires an index-migration bump" now point at GrantDigestABIVersion; index_migrations.go explains why digest ABI bumps do not belong there.

Tests

  • TestGrantDigestABI* in grant_digest_abi_test.go: stamp written by seal and excluded from the presence probe; stale stamp dropped on writable open and rebuilt at the current version; missing stamp reads as version 1 and is kept, then stamped on the first root rewrite; read-only open sets the stale flag and root getters report not-built; orphan stamp over an empty keyspace is ignored; stale stamp plus interrupted-build marker together; bucket digest gated while bucket scan still yields every grant; plus an oracle test proving the hash-index verifier can detect tampering.
  • TestCompactPebbleFoldRebuildsDigestsWhenBaseHasNone: a digest-less base folded with a partial that writes no grants ends with a present global root byte-equal to both the original base's root and a from-scratch oracle build.

Reviewer note

The fold change is a compactor behavior change: the O(base) build now fires on the first fold of any digest-less base, not only after an ABI bump, including a base sealed with the digest index disabled and then folded by an engine with it enabled.

🤖 Generated with Claude Code

mj-palanker and others added 2 commits September 2, 2026 12:43
…Open

GrantDigestABIVersion names the framing of the grant content hash and
principal bucket hash, but nothing on disk recorded which version a
file's digest nodes and hash-index rows were computed under. A future
bump would have had no way to tell current state from state built by
older hash code, short of an index migration, which old binaries can
re-pollute after it runs.

Add a durable ABI stamp (rawdb.GrantDigestABIStampKey, uint32 BE)
inside the digest keyspace under a reserved meta index id, so every
wholesale destroyer of digest state, including those in already
shipped SDKs, erases it without knowing it exists. Every global-root
write site writes the stamp first. Open reads it after the presence
probe: a writable open drops all digest state whose stamp does not
name the current version, and the next EndSync rebuilds from scratch;
a read-only open cannot drop, so it sets grantDigestAbiStale and the
digest root getters report "never built".

A missing stamp over present digest nodes reads as version 1: every
SDK build that predates the stamp hashed at that version, so
introducing the stamp costs existing files no rebuild while the
constant stays at 1. The presence probe now spans only the node
keyspace, so a leftover stamp over an empty file arms nothing.

The constant stays at 1 in this change. It exists to make a later
bump safe on its own, and to let a rollback across that bump re-stamp
at the older version on its next repair, so the newer binary detects
the mixed state instead of trusting it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s a base lacks

Two gaps in how the digest ABI stamp's consequences reach consumers.

The read-only stale flag (and the pre-existing build-pending flag)
guarded the two digest root getters but not ComputeEntitlementBucketDigest,
which folds hash-index rows directly. On a read-only open of a file
stamped with a different ABI it would have returned digests derived
from another hash scheme's content hashes. Fold both flags into one
grantDigestStateUntrusted helper and consult it at all three entry
points. The bucket scan stays deliberately ungated: bucket membership
is the principal bucket hash over the encoded principal identity,
frozen with the v3 key encoding and unchanged by any ABI bump, so a
stale file's bucket placement is still exact.

The fold compactor left a base's copied digest state alone whenever
the fold wrote no grants. That is right when the state is present,
but the dest's writable Open may have just dropped it (a stale stamp),
or the base may never have had any (digest index disabled at seal, or
an earlier build failure). Such a fold shipped an output with no
digest state and nothing downstream to repair it. When the digest
index is enabled and no state is present, run the repair, which
delegates to the full build: a one-time O(base) cost the first time a
digest-less base is folded, after which the output is present and
stamped and later folds pay nothing extra.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment on lines +598 to +607
// Deliberately NOT gated by grantDigestStateUntrusted, unlike
// getPartitionDigestRoot / GetGrantDigestGlobalRoot /
// ComputeEntitlementBucketDigest: a bucket's MEMBERSHIP is the
// principal bucket hash over the encoded principal identity, frozen by
// the v3 key encoding and untouched by any GrantDigestABIVersion bump
// (only the stored CONTENT hashes and digest nodes are ABI-dependent —
// see grantContentHash64 vs grantPrincipalBucketHash64). So a stale
// file's bucket placement is still exact, and this keeps yielding the
// grants a caller already knows to be dirty even on a read-only open
// over a stale ABI stamp.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: this rationale contradicts GrantDigestABIVersion's own definition. That constant is documented as "the version of the content-hash / bucket-hash definitions below (grantContentHash64, grantPrincipalBucketHash64)" and PrincipalBucketHash's comment (edited in this PR, line ~258) says its "input framing changes only under a GrantDigestABIVersion bump" — so a bump can change bucket placement, and then the stored index keys' bucket bytes are from the old scheme while bucketBounds derives the range from the caller's new-scheme bucket. On a stale read-only open this yields a silently wrong grant subset with no error. Either narrow the ungating to "bumps that only change the content hash" (and say a bucket-hash bump must gate this too), or gate on grantDigestAbiStale and rely on the root getters reporting not-built. Medium confidence; no impact while the constant is 1.

Comment on lines +61 to +67
// Enforcement on the stored state itself is the durable ABI stamp
// (rawdb.GrantDigestABIStampKey), written alongside every global-root
// write and checked once per Open (verifyGrantDigestABI): digest state
// whose stamp does not name this constant is dropped (writable open)
// or reported "never built" (read-only open), so a bump here is
// sufficient by itself to force every previously-sealed file's digest
// state to be rebuilt in full at the new ABI on its next writable use.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: "a bump here is sufficient by itself" holds only against stamp-aware binaries. Already-shipped pre-stamp SDKs never write GrantDigestABIStampKey, and the paths they use to mutate digest state (stageGrantDigestInvalidation / InvalidateGrantDigestPartitions + recomputeGrantDigestGlobalRootLocked) delete only the touched partitions and the global root — the stamp key survives. So after a bump to 2: a v2 binary stamps a file at 2, an older pre-stamp SDK opens it writable and rewrites some partitions at ABI 1 while leaving stamp == 2, and the next v2 binary trusts mixed-ABI state. That is the same "old binaries re-pollute after the fact" failure the PR uses to argue against an index migration. Worth stating the residual limitation here (and in index_migrations.go), or encoding the ABI into the digest index/key prefix so old and new state cannot share a keyspace. Medium confidence; no impact today.

Comment on lines +710 to +714
case destEng.GrantDigestIndexEnabled() && !destEng.GrantDigestsPresent():
if err := destEng.RepairMissingGrantDigests(ctx); err != nil {
return "", fmt.Errorf("compactPebbleFold: build grant digests for a base with none: %w", err)
}
l.Info("compactPebbleFold: no grant writes, but the base carried no grant digest state; built it")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: the "one-time O(base) cost" claim depends on the build succeeding. RepairMissingGrantDigests downgrades any non-cancellation failure to a log + dropAllGrantDigestStateLocked and returns nil, so a base whose digest build keeps failing leaves GrantDigestsPresent() false and pays the full O(base) grant scan + spill sort + SST ingest on every subsequent fold, silently. Consider narrowing the wording, and — per docs/BUG_CATCHING.md §2's cost-contract rule for compaction paths — adding a benchmark that pins the new digest-less-base fold cost curve; this PR states the delta but nothing enforces it.

// itself: it must pass right after a clean seal, and it must detect a
// deliberately tampered hash-index row (proving it is a real oracle,
// not a tautology) — the same helper TestGrantDigestABIStaleStampDroppedAtWritableOpen
// and TestGrantDigestABIMissingStampTreatedAsStale rely on to certify a

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: TestGrantDigestABIMissingStampTreatedAsStale does not exist — the test in this file is TestGrantDigestABIMissingStampReadsAsVersion1. Update the reference so the doc chain stays navigable.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

General PR Review: Stamp grant digest state with its hash ABI version and enforce it at Open

Blocking Issues: 0 | Suggestions: 4 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 01296f45de4c.
Review mode: full
View review run

Review Summary

Scanned the full PR diff for security and correctness across all 12 changed files, and traced the new durable ABI stamp through every digest write/drop/probe/copy site in pkg/dotc1z/engine/pebble, pkg/synccompactor, and the K-way/fold bucket plans. The core mechanism holds up: the stamp lives at the reserved DigestMetaIndexID (0xFF) sub-range so it is excluded from DigestNodeKeyspaceBounds (the presence probe) and from isGrantDigestRootKey / recomputeGrantDigestGlobalRootLocked's fold, while remaining inside DigestLowerBound/UpperBound so every wholesale drop, ResetForNewSync excise, fold DeleteRange, and the compactor's digest bucket plan carries or erases it; all three global-root write sites now stamp first, verifyGrantDigestABI runs after the pending-marker drop so its presence read is post-recovery, and the new compactPebbleFold branch reaches an already-proven BuildGrantDigests call site. No blocking issues found; the four suggestions below are forward-looking design/documentation gaps and a missing cost benchmark, none of which change behavior while GrantDigestABIVersion is 1.

Risk triage per docs/BUG_CATCHING.md §2: silence yes (a wrong digest is a well-formed false-clean, not a crash); durability yes (new key in the c1z format, plus digest state read by SDK versions that do not exist yet); uncontrolled dimensions yes (explicit version-pair dependence); consumer distance high (c1 platform, downstream connectors, future SDKs); consequence rung 3-4. Verdict: HIGH, and any version-pair dependence is HIGH on its own. The PR does carry real instruments — an Open-state permutation set (stale / missing / orphan-over-empty / stale + pending marker / read-only), a positive-evidence oracle with a tamper self-test (TestGrantDigestABIOracle), and a byte-equal cross-build fold oracle — which is why this is advisory rather than a request for the full pass-set. What is absent is a benchmark on the compaction cost contract and any coverage of the pre-stamp-writer rollback shape (finding 2).

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/dotc1z/engine/pebble/grant_digest.go:598-607IterateGrantsByEntitlementBucket's "untouched by any GrantDigestABIVersion bump" rationale contradicts the constant's own definition (which covers grantPrincipalBucketHash64's framing); under a bucket-hash bump the ungated scan silently returns the wrong grant subset.
  • pkg/dotc1z/engine/pebble/grant_digest.go:61-67 — "a bump here is sufficient by itself" holds only against stamp-aware binaries; already-shipped pre-stamp SDKs rewrite partitions without touching the stamp, so a rollback across a bump can leave mixed-ABI state under a current-looking stamp.
  • pkg/synccompactor/compactor_pebble.go:710-714 — the "one-time O(base)" claim does not hold when RepairMissingGrantDigests keeps swallowing build failures (every fold re-pays the full scan), and this compaction cost delta has no enforcing benchmark.
  • pkg/dotc1z/engine/pebble/grant_digest_abi_test.go:470 — doc reference to a non-existent TestGrantDigestABIMissingStampTreatedAsStale.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/dotc1z/engine/pebble/grant_digest.go`:
- Around lines 598-607: The doc comment on IterateGrantsByEntitlementBucket justifies
  leaving the bucket scan ungated by claiming bucket membership is "untouched by any
  GrantDigestABIVersion bump". That contradicts GrantDigestABIVersion's own doc at
  lines 54-59 ("the version of the content-hash / bucket-hash definitions below
  (grantContentHash64, grantPrincipalBucketHash64)") and PrincipalBucketHash's comment
  around line 258 ("the input framing changes only under a GrantDigestABIVersion
  bump"). If a future bump changes grantPrincipalBucketHash64's framing, the stored
  by_entitlement_principal_hash keys hold old-scheme bucket bytes while
  grantDigestSpec.bucketBounds derives the scan range from a new-scheme bucket, so the
  iteration silently yields a wrong subset of grants with no error. Fix by either (a)
  narrowing the claim: state that the ungating is sound only for bumps that change the
  CONTENT hash alone, and that a bucket-hash framing bump must additionally gate this
  method on grantDigestStateUntrusted; or (b) gating the method on
  grantDigestStateUntrusted now and relying on the already-gated root getters to make
  consumers recalculate.
- Around lines 61-67: The claim that "a bump here is sufficient by itself to force
  every previously-sealed file's digest state to be rebuilt" is only true against
  binaries that know about the stamp. Already-shipped pre-stamp SDKs never write
  rawdb.GrantDigestABIStampKey, and the mutation paths they use
  (stageGrantDigestInvalidation, InvalidateGrantDigestPartitions followed by
  recomputeGrantDigestGlobalRootLocked) delete only the touched partition prefixes and
  the global root, never the whole digest range, so the stamp key survives their
  writes. Concretely, after a bump to version 2: a v2 binary drops, rebuilds and stamps
  a file at 2; an older pre-stamp SDK then opens it writable, invalidates and rebuilds
  some partitions using ABI-1 hashing, and leaves stamp == 2 intact; a later v2 binary
  reads stamp == 2, considers it current, and trusts mixed-ABI state. Document this
  residual limitation here and in the index_migrations.go note (which currently argues
  the stamp is strictly better than a migration precisely because migrations can be
  re-polluted by old binaries), or eliminate it by encoding the ABI version into the
  digest index discriminator / key prefix so state written under different ABIs cannot
  occupy the same keyspace.

In `pkg/synccompactor/compactor_pebble.go`:
- Around lines 710-714: The new "no grant writes but the base carried no digest state"
  branch's comment says the O(base) build is "a one-time cost the first time a
  digest-less base is folded, after which the output is stamped and later folds pay
  nothing extra". That only holds when the build succeeds.
  RepairMissingGrantDigests downgrades any non-context-cancellation failure to a log
  plus dropAllGrantDigestStateLocked and returns nil, leaving GrantDigestsPresent()
  false, so a base whose digest build persistently fails re-enters this branch and
  pays a full O(base) grant scan, spill sort and SST ingest on every subsequent fold,
  with no error surfaced to the caller. Reword the comment to say the cost is one-time
  only on success. Separately, per the repo's cost-contract rule for compaction paths,
  add a benchmark that pins the cost curve of folding a digest-less base (grants /
  entitlements scaling), since the PR states the delta but nothing enforces it.

In `pkg/dotc1z/engine/pebble/grant_digest_abi_test.go`:
- Around line 470: The doc comment on TestGrantDigestABIOracle references
  TestGrantDigestABIMissingStampTreatedAsStale, which does not exist. The test in this
  file is named TestGrantDigestABIMissingStampReadsAsVersion1. Update the reference.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking issues found.

@mj-palanker
mj-palanker marked this pull request as ready for review September 3, 2026 01:13
@mj-palanker
mj-palanker requested review from kans and a lite review from Copilot September 3, 2026 01:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

It changes persistent on-disk digest state semantics and Open-time enforcement in a core storage engine path, warranting final human review despite strong test coverage.

Pull request overview

Adds durable, on-disk bookkeeping for the grant-digest hash ABI version so future GrantDigestABIVersion bumps can be enforced safely at engine Open, and ensures downstream consumers/compactors treat stale or half-built digest state as untrusted.

Changes:

  • Introduces a durable digest-ABI stamp key in the digest keyspace, excludes it from the “digests present” probe, and enforces it at Open (drop on writable open; “never built” behavior on read-only open).
  • Unifies “untrusted digest state” handling (ABI-stale + build-pending) so root getters and bucket digest folding won’t trust stale/half-built hash-index content.
  • Updates Pebble fold compaction to rebuild digests when the destination wants digests but the byte-copied base carries none; adds targeted tests covering the new behavior and ABI-stamp enforcement.
File summaries
File Description
pkg/synccompactor/compactor_pebble.go Ensures fold compaction rebuilds digests when the destination has digest index enabled but no digest state is present.
pkg/synccompactor/compactor_grant_digest_test.go Adds a regression test for “no-grant-write fold” rebuilding digests when the base carries none.
pkg/dotc1z/engine/pebble/internal/rawdb/rawdb.go Updates digest presence probe to scan only digest node keys (excluding metadata like the ABI stamp).
pkg/dotc1z/engine/pebble/internal/rawdb/keyspace.go Adds reserved digest metadata index ID, ABI stamp key, and node-only digest keyspace bounds helper.
pkg/dotc1z/engine/pebble/index_migrations.go Documents why digest ABI bumps are enforced via stamps (not one-time index migrations).
pkg/dotc1z/engine/pebble/grant_digest.go Implements ABI stamp read/write helpers, Open-time ABI verification, and gates digest getters/folding on untrusted-state flags.
pkg/dotc1z/engine/pebble/grant_digest_repair.go Writes the ABI stamp alongside global-root writes during repair, ensuring “root implies certified ABI”.
pkg/dotc1z/engine/pebble/grant_digest_build.go Writes the ABI stamp in full/zero-grant build completion paths and documents durability ordering.
pkg/dotc1z/engine/pebble/grant_digest_abi_test.go Adds comprehensive tests covering stamping, enforcement, stale read-only behavior, and an oracle verifier.
pkg/dotc1z/engine/pebble/engine.go Calls ABI verification during Open, exports GrantDigestsPresent, and centralizes “digest state untrusted” logic.
pkg/dotc1z/engine/pebble/digest.go Switches root trust gating to the unified grantDigestStateUntrusted() helper.
pkg/dotc1z/engine/pebble/digest_test.go Adjusts digest node counting to use node-only bounds (excluding ABI stamp structurally).
Review details
  • Files reviewed: 12/12 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants