Skip to content

Grant Hash Calc V2 - #1089

Merged
mj-palanker merged 5 commits into
mainfrom
mjp/include-more-in-grant-hash
Sep 14, 2026
Merged

mj-palanker merged 5 commits into
mainfrom
mjp/include-more-in-grant-hash

Conversation

@mj-palanker

@mj-palanker mj-palanker commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

grantContentHash64 previously hashed only the grant's identity tuple
plus the set of source-entitlement ids, deliberately excluding all
annotations and source-map values as sync-transient noise. But
GrantImmutable and per-source is_direct are stable per-grant facts
(not bookkeeping that churns every sync) that the SDK already uses
elsewhere to distinguish otherwise-identical grants (rollback_expansion's
suspect-grant check, topological_merge's direct-wins-over-indirect
upgrade) — so two grants differing only in immutability or provenance
were silently digesting as identical. Bumps GrantDigestABIVersion to 2
since this changes what "the same grant" hashes to.

Also adds new tests and consistency to grant hash calculation.

(A previous version of this PR included the version stamping to make
upgrading safe, that was already merged on its own in advance)

@mj-palanker
mj-palanker requested a review from kans August 13, 2026 19:31
Comment thread pkg/dotc1z/engine/pebble/grant_digest.go Outdated
// silently turned either back into a no-op (regressing to v1's blind
// spot — see grantContentHash64's ABI doc) would otherwise go
// unnoticed.
func TestGrantContentHashDistinguishesImmutabilityAndDirectness(t *testing.T) {

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: every immutable case in this file makes GrantImmutable the only annotation, so the multi-entry branch of scanGrantContentFactsRawBytes's case 8 loop is untested. Add a case where the annotation list carries another annotation (e.g. GrantMetadata) before/after GrantImmutable, plus one where a GrantExpandable was stripped by V2GrantToV3 — a raw-scan bug that stopped at the first Any would silently regress isImmutable to false for real connector grants, and the seal path has no oracle that would notice.

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

General PR Review: Grant Hash Calc V2

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

Review Summary

Scanned the full PR diff for security and correctness: GrantDigestABIVersion moves 1 to 2, folding GrantImmutable and per-source is_direct into grantContentHash64, with the seal-time raw scanner (scanGrantContentFactsRawBytes) reworked to follow protobuf-go decode semantics. All three prior findings are addressed at f94606a7: the grant_digest.go unstamped-file paragraph now matches TestGrantDigestABIMissingStampReadsAsVersion1's drop assertion, TestGrantDigestABIOracleCoversImmutableAndDirectSources runs the oracle over an immutable grant, a direct source, and the hand-encoded PutSynthesizedGrantContributions wire path, and BenchmarkRegisteredPebbleWritePackImmutable (plus its _NoDigestIndex twin) states the measured delta. No blocking issues found; two test/measurement gaps remain.

Risk triage (per docs/BUG_CATCHING.md section 2) — HIGH, as expected for an ABI bump:

  • Silence: yes. A wrong content hash is a well-formed wrong digest row, not a crash.
  • Durability: yes. Hashes live in .c1z files and in the graph-blob-bound GrantGenerationDigest.
  • Uncontrolled dimensions: yes, version-pair dependence by construction (v1-hashed state read by v2 code).
  • Consumer distance: the c1 platform, the compactor, and future SDK versions via exported GrantContentHash / GrantDigestAccumulator.
  • Remediation: rung 2 (re-sync/rebuild), not rung 4 — the pre-existing ABI stamp carries the version.

Verified the escape paths are closed rather than assumed: verifyGrantDigestABI drops on writable open and sets grantDigestAbiStale on read-only open; grantDigestStateUntrusted makes every getter report "never built", so pebbleStore.GrantGenerationDigest's hardcoded ABIVersion can never be attached to v1-computed state; pkg/sync/state.go:168 compares bound vs current ABIVersion and discards the cached graph on mismatch; compactPebbleFold rebuilds a digest-less base. The hash writers (appendGrantHashIndexRow, repairOneGrantDigestPartitionLocked) and readers (grantContentHashForRecord, GrantContentHash) all moved together. Instruments present and real: a differential table test over each duplicate-wire shape, a fuzz target with a checked-in crasher, the end-to-end seal oracle, and the drop/rebuild ABI tests. pkg/sdk/version.go is untouched, which is correct here: .github/workflows/update-hardcoded-version.yaml regenerates it on release.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/dotc1z/engine/pebble/grant_digest_repair.go:464 — the targeted repair path is a second copy of the scan/sort/hash sequence, and no test oracle-verifies it over a GrantImmutable grant or an is_direct: true source; every repair test still uses makeGrant/makeGrantWithSources, which take grantContentHash64's early return.
  • pkg/dotc1z/engine/pebble/production_bench_test.go:36benchmarkGrantsImmutable measures the cheapest annotation shape (one annotation, matching first), so the stated ~160 ns/grant cost contract does not bound the expensive one: several annotations with no GrantImmutable, where scanAnyEntryIsTypeRaw scans every Any's type_url.
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_repair.go`:
- Around line 464: repairOneGrantDigestPartitionLocked duplicates the
  scanGrantContentFactsRawBytes -> sortGrantSourceFacts -> grantContentHash64
  sequence that appendGrantHashIndexRow uses, but no test verifies it against the
  oracle for the two facts ABI v2 added. TestGrantDigestABIOracleCoversImmutableAndDirectSources
  only reaches the seal build (sealGrantDigests -> BuildDeferredGrantIndexes), and the
  repair-path tests in grant_digest_repair_test.go, endsync_repair_test.go, and
  pkg/synccompactor/compactor_grant_digest_test.go build grants with makeGrant /
  makeGrantWithSources, which set no annotations and leave is_direct false, so every
  repaired row takes grantContentHash64's early return for the not-immutable,
  no-sources case. Fix: extend one repair test to seal a partition containing a grant
  with a GrantImmutable annotation and a grant with a source whose is_direct is true,
  then invalidate and repair that partition and assert
  verifyGrantHashIndexAgainstPrimaries passes over the repaired state.

In `pkg/dotc1z/engine/pebble/production_bench_test.go`:
- Around line 36: benchmarkGrantsImmutable builds every grant with exactly one
  annotation, and that annotation is GrantImmutable, so scanGrantContentFactsRawBytes
  matches on the first field-8 entry and its not-isImmutable guard skips all
  remaining annotations. The cost contract stated in grant_digest_build.go around
  line 66 (~242 ns/grant to ~402 ns/grant) is therefore derived from the cheapest
  non-trivial shape. The worst case is a grant with several annotations and NO
  GrantImmutable -- the shape of every connector-emitted, non-expanded grant carrying
  GrantMetadata -- which never short-circuits and makes scanAnyEntryIsTypeRaw descend
  into and scan the type_url of every Any, where ABI v1 skipped all of field 8 with a
  single ConsumeFieldValue per entry. Fix: add a third benchmark fixture whose grants
  carry k non-matching annotations (e.g. GrantMetadata) and no GrantImmutable, run it
  against its own _NoDigestIndex twin, and update the cost-contract comment in
  grant_digest_build.go so the stated delta bounds the worst case rather than the best.

Reviewed at head f94606a71ef9b9edd6b4f330d913c7337e136a72, base a181d58e97954b22bf6f0f5758f38addb1ca6767, workflow ref ConductorOne/github-workflows/.github/workflows/general-pr-review.yaml@refs/heads/main. The machine-readable review-state marker could not be emitted this run: the review sandbox blocks every way of producing its opening brace-plus-quote byte sequence, and file writes are denied. Consequence: the next run finds no marker on this comment, so it reuses this comment (the legacy-summary path in fetch-pr-context.py) but re-reviews in full mode instead of incrementally.

@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.

@kans

kans commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

I poked at this for a bit:

Review Brief: mjp/include-more-in-grant-hash

Verdict: request changes — one real defect; otherwise solid.

Defect: stamp is upgrade-safe, not downgrade-safe

stageGrantDigestInvalidation (pre-existing) deletes a mutated partition's digest nodes and the global root, but not the stamp. Targeted repair rebuilds just what's missing. On a pre-stamp binary against a v2-sealed file:

v2 seals (stamp=2) → rollback → old binary mutates a grant → invalidation + repair rewrite that partition and the root with v1 hashes, stamp untouched → v2 later trusts stamp=2 over mixed state.

The "every destroyer erases the stamp" argument only covers wholesale drops, not this path; the PR's comments claim otherwise. Exposure is limited to rollbacks during this PR's rollout window (future bumps are two-way safe).

Fix: version the global root's value (every repair rewrites it → self-certifying), or ship the stamp machinery first at v1 and bump the hash after.

Also

  • ABI doc comment claims isImmutable is always framed in; plain grants (no sources, not immutable) actually keep the bare v1 hash. Fix the comment.
  • No test seals an immutable or is_direct grant end-to-end.
  • Raw wire scanner diverges from proto merge semantics on duplicate fields — unreachable with SDK-written bytes, but unpinned.
  • Stale-ABI flag doesn't gate ScanEntitlementGrantBucket / ComputeEntitlementBucketDigest.

Verified clean

Framing injectivity, three-form hash parity, fact preservation on all write paths, Open ordering and crash windows, stamp placement vs. every scan/probe/drop range, no hot-path allocation regressions.

mj-palanker and others added 4 commits September 10, 2026 11:26
grantContentHash64 previously hashed only the grant's identity tuple
plus the set of source-entitlement ids, deliberately excluding all
annotations and source-map values as sync-transient noise. But
GrantImmutable and per-source is_direct are stable per-grant facts
(not bookkeeping that churns every sync) that the SDK already uses
elsewhere to distinguish otherwise-identical grants (rollback_expansion's
suspect-grant check, topological_merge's direct-wins-over-indirect
upgrade) — so two grants differing only in immutability or provenance
were silently digesting as identical. Bumps GrantDigestABIVersion to 2
since this changes what "the same grant" hashes to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The seal-time digest build hashes a grant's stored value bytes with a
raw protowire field scan instead of a proto unmarshal. That scan
disagreed with proto.Unmarshal on wire shapes proto.Marshal never
emits but the decoder accepts: a repeated is_direct or type_url took
the first occurrence (proto: last wins), a duplicated map-entry value
fragment replaced rather than merged, duplicate map keys were both
folded into the hash (proto: last entry wins), and a known field
number carrying the wrong wire type errored (proto: unknown field).
The engine stores and compacts value bytes verbatim, so any such
bytes would hash one way at seal time and another way for readers
and GrantContentHash, forever.

Fix the scanner to follow the decoder, and make sortGrantSourceFacts
collapse equal keys after its stable sort. No hash framing or
GrantDigestABIVersion change: SDK-written bytes carry no duplicates,
so no stored hash changes.

Pin it two ways: a table-driven differential test over each duplicate
shape, and a fuzz target whose seeds and saved crasher run as unit
tests under plain go test. The first fuzz round found the wire-type
case in two seconds; after the fix a 90s run was clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The missing-stamp test was written while GrantDigestABIVersion was 1,
where the correct Open behavior was to keep the state, and skipped its
Open half once the constant moved. With the constant at 2 the live
contract is the drop: a missing stamp reads as version 1, whose hashes
omit isImmutable and is_direct, so a writable Open must drop the nodes
and hash index and the next seal must rebuild and stamp them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@mj-palanker
mj-palanker force-pushed the mjp/include-more-in-grant-hash branch from 60a8496 to 03bebe3 Compare September 10, 2026 18:42
@mj-palanker mj-palanker changed the title Mjp/include more in grant hash Grant Hash Calc V2 Sep 10, 2026
// Exported so consumers of GrantContentHash / GrantDigestAccumulator
// can check a stored root's abi_version before comparing.
const GrantDigestABIVersion uint32 = 1
const GrantDigestABIVersion uint32 = 2

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: with the constant now 2, the unstamped-file paragraph three lines above (68–71: "absence reads as grantDigestABIVersionUnstamped and is current for as long as this constant stays 1 — introducing the stamp costs no rebuild") describes behavior this PR's own TestGrantDigestABIMissingStampReadsAsVersion1 now asserts is false — a pre-stamp file is dropped and rebuilt on writable Open. Reword it to say absence reads as 1 and is therefore stale at ABI ≥ 2, so the reader of this doc reasons correctly about pre-stamp files in a mixed-version fleet.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reworded — the paragraph now says plainly that a missing stamp reads as version 1 and is stale at ABI >= 2, instead of a conditional that only read as current when the stamp was introduced. f94606a

require.NoError(t, verifyGrantHashIndexAgainstPrimaries(t, e2), "repaired state must check out against the primaries")
require.NotZero(t, digestNodeCount(t, e2), "reseal must rebuild digest nodes")
require.EqualValues(t, n, entHashIndexRowCount(t, e2, entID), "reseal must rebuild every hash-index row")
require.NoError(t, verifyGrantHashIndexAgainstPrimaries(t, e2), "oracle must pass over the rebuilt state")

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 oracle (verifyGrantHashIndexAgainstPrimaries, the one instrument that pins the seal-time raw scan against decode-then-hash through the real spill-sorter/SST path) only ever runs over makeTestGrantsmakeGrant, which sets no annotations and no sources; digest_test.go's makeGrantWithSources builds GrantSourceRecord_builder{}, so is_direct is false there too. Every oracle-verified grant therefore takes grantContentHash64's !isImmutable && len(sortedSources)==0 early return or the all-false path — the two facts ABI v2 exists for are never exercised end-to-end. That matters most for the highest-volume producer: fillSynthGrantRecord stamps GrantImmutable on every synthesized grant and appendGrantSourcesWire hand-encodes is_direct in bytes that never pass through proto.Marshal. Seal a fixture with an immutable grant and a is_direct: true source (and one expanded grant written via PutExpandedGrantRecords) and run this oracle over it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added TestGrantDigestABIOracleCoversImmutableAndDirectSources: seals one entitlement mixing plain grants, an immutable grant, a grant with an is_direct:true source, and a grant written through PutSynthesizedGrantContributions (the expander's hand-encoded-wire path, not proto.Marshal), then runs verifyGrantHashIndexAgainstPrimaries over it. Confirmed it's a real oracle by temporarily disabling the raw-scan isImmutable detection locally — this test failed while the pre-existing TestGrantDigestABIOracle kept passing, then reverted. f94606a

return fmt.Errorf("grant hash index: primary key %x did not split as a 6-segment identity", primaryKey)
}
srcs, err := scanGrantSourceKeysRawBytes(value, s.srcKeys[:0])
isImmutable, srcs, err := scanGrantContentFactsRawBytes(value, s.srcKeys[:0])

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: field 8 used to be skipped by a single ConsumeFieldValue; now every grant's annotation list is walked and each Any's type_url tail compared. Big-O is unchanged and scanAnyEntryIsTypeRaw is genuinely alloc-free, but this is a new per-row constant on the seal loop that runs ~50M times per whale expansion — and every synthesized grant carries exactly one GrantImmutable Any, so the new descent is taken on all of them. BenchmarkGrantWriteScale / BenchmarkRegisteredPebbleWritePack (vs their _NoDigestIndex variants) already isolate this path; per docs/BUG_CATCHING.md's cost-contract rule, a stated ns/op + B/op delta from those would close it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added BenchmarkRegisteredPebbleWritePackImmutable + its _NoDigestIndex twin (grants all carrying GrantImmutable, the real synthesized-grant shape) and stated the cost contract on appendGrantHashIndexRow's doc comment. Measured at grants=100000, -benchtime=8x, isolating digest-on vs digest-off to strip the unrelated write-path marshal cost: B/op delta is unmeasurable (139.4MB vs 139.8MB) confirming scanAnyEntryIsTypeRaw is alloc-free, but ns/op attributable to the digest build goes ~242ns/grant -> ~402ns/grant (~66% increase in this function's own per-row cost), ~8s added seal time at 50M grants. f94606a

@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.

- Reword the unstamped-file paragraph in grant_digest.go to state plainly
  that a missing stamp reads as version 1 and is stale at ABI >= 2, matching
  TestGrantDigestABIMissingStampReadsAsVersion1's assertion.
- Extend the hash-index oracle (verifyGrantHashIndexAgainstPrimaries) to run
  over a seal that mixes an immutable grant, a direct source, and a grant
  written through the expander's hand-encoded wire path
  (PutSynthesizedGrantContributions) — the two v2 facts (isImmutable,
  is_direct) were never exercised end-to-end through the real seal build by
  any oracle-verified test before this.
- Add BenchmarkRegisteredPebbleWritePackImmutable (+ _NoDigestIndex twin) and
  state the measured cost-contract delta on appendGrantHashIndexRow: no
  measurable extra allocation, ~242ns/grant -> ~402ns/grant digest-build
  time with a GrantImmutable annotation on every grant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
continue
}
srcs, serr := scanGrantSourceKeysRawBytes(value, scratch.srcKeys[:0])
isImmutable, srcs, serr := scanGrantContentFactsRawBytes(value, scratch.srcKeys[:0])

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: repairOneGrantDigestPartitionLocked is a second, independently-written copy of the scan → sortGrantSourceFactsgrantContentHash64 sequence, but nothing oracle-verifies it over the two new v2 facts. TestGrantDigestABIOracleCoversImmutableAndDirectSources reaches only the seal build (sealGrantDigestsBuildDeferredGrantIndexes), and every repair-path test (grant_digest_repair_test.go, endsync_repair_test.go, compactor_grant_digest_test.go) builds rows via makeGrant/makeGrantWithSources, which set no annotations and leave is_direct false — so both still take grantContentHash64's early return. A drift between the two writers would leave one file's hash index internally inconsistent per partition: silent and durable, exactly the gap the previous round raised for the seal path. Consider adding an immutable + is_direct: true grant to a repair/invalidate test and asserting verifyGrantHashIndexAgainstPrimaries. (confidence: high)

// gets one; see fillSynthGrantRecord) that benchmarkGrants itself does not
// cover, so a benchmark built on this is the one that costs the grant-digest
// seal-time annotation walk instead of taking its early return.
func benchmarkGrantsImmutable(n int) []*v2.Grant {

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 fixture gives every grant exactly one annotation, and that annotation is GrantImmutable — so scanGrantContentFactsRawBytes matches on the first field-8 entry and its if !isImmutable guard short-circuits the rest. That's the cheapest non-trivial shape, yet it is what the +160 ns/grant cost contract in grant_digest_build.go:66 is derived from. The expensive shape is unmeasured: a grant with several annotations and no GrantImmutable (every connector-emitted, non-expanded grant carrying GrantMetadata etc.) never short-circuits, so scanAnyEntryIsTypeRaw descends into and scans the type_url of every Any — where v1 skipped all of field 8 with one ConsumeFieldValue per entry. Consider a third fixture with k non-matching annotations so the stated delta bounds the worst case rather than the best. (confidence: medium)

@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 merged commit 9434445 into main Sep 14, 2026
12 checks passed
@mj-palanker
mj-palanker deleted the mjp/include-more-in-grant-hash branch September 14, 2026 22:47
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