Skip to content

Entitlement Grant Hash Index & Entitlement Grant Digest Tree#999

Open
mj-palanker wants to merge 10 commits into
mainfrom
mjp/grant-digest-v2
Open

Entitlement Grant Hash Index & Entitlement Grant Digest Tree#999
mj-palanker wants to merge 10 commits into
mainfrom
mjp/grant-digest-v2

Conversation

@mj-palanker

@mj-palanker mj-palanker commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Adds a new "hash index" which stores a descriptive hash of the content of a grant on a new index which is shaped exactly like the normal grant key but with a new 2 bytes of the hash of the principal keys. This distributes all grants for an entitlement randomly across the keyspace when considering only the hash. A "bucket" is just a prefix scan of those 2 bytes, meaning you can chop the grant space into as many as 2^16 even buckets.

Also introduces the concept of a grant digest which is the XOR of all grant hashes for a given bucket (plus the # of grants in said bucket). By default we always create a root digest storing info on all the entitlement's grants, plus a set of "leaf nodes" for some desired "depth" into the tree.

This desired depth is calculates such that (if distributed evenly) we have no more than 512 grants per bucket.
16 bits lets this hold until we hit 33.5 Million Grants on a single entitlement.

since this desired depth calculated value isn't assumed ever, we could change this in the sdk without breaking anything

Any stored depth can be rolled up into any less granular depth simply by XOR-ing the hashes of nodes, and adding the counts, meaning you can trivially compare grant digests stored at different resolutions. We may want to leverage this to store a fixed max resolution in c1.

Derived from #977 via robot onto main

perf tests show we doubled the End Sync time, but that's from ~25ms to ~50ms

goos: darwin
goarch: arm64
pkg: github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble
cpu: Apple M4
BenchmarkRegisteredPebbleWritePack/grants=10000000-10                  1        2549355708 ns/op                41.00 ms/EndSync              2345 ms/PutGrants 5171366088 B/op    46525 allocs/op
BenchmarkRegisteredPebbleWritePack/grants=10000000-10                  1        2218060458 ns/op                54.00 ms/EndSync              2027 ms/PutGrants 5170921376 B/op    42772 allocs/op
BenchmarkRegisteredPebbleWritePack/grants=10000000-10                  1        2210106875 ns/op                68.00 ms/EndSync              2005 ms/PutGrants 5170957704 B/op    42766 allocs/op
BenchmarkRegisteredPebbleWritePack/grants=10000000-10                  1        2272597000 ns/op                48.00 ms/EndSync              2084 ms/PutGrants 5170922776 B/op    42777 allocs/op
BenchmarkRegisteredPebbleWritePack/grants=10000000-10                  1        2172931042 ns/op                50.00 ms/EndSync              1986 ms/PutGrants 5170920936 B/op    42764 allocs/op
BenchmarkRegisteredPebbleWritePack/grants=10000000-10                  1        2144806791 ns/op                46.00 ms/EndSync              1958 ms/PutGrants 5170960672 B/op    42774 allocs/op
PASS
ok      github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble      41.450s
goos: darwin
goarch: arm64
pkg: github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble
cpu: Apple M4
BenchmarkRegisteredPebbleWritePack_NoDigestIndex/grants=10000000-10                    1        3560330167 ns/op                28.00 ms/EndSync              3365 ms/PutGrants 5031893760 B/op    45705 allocs/op
BenchmarkRegisteredPebbleWritePack_NoDigestIndex/grants=10000000-10                    1        2149813833 ns/op                27.00 ms/EndSync              1985 ms/PutGrants 5031500976 B/op    42041 allocs/op
BenchmarkRegisteredPebbleWritePack_NoDigestIndex/grants=10000000-10                    1        2136727709 ns/op                27.00 ms/EndSync              1977 ms/PutGrants 5031444792 B/op    42030 allocs/op
BenchmarkRegisteredPebbleWritePack_NoDigestIndex/grants=10000000-10                    1        2142267459 ns/op                26.00 ms/EndSync              1979 ms/PutGrants 5031456872 B/op    42053 allocs/op
BenchmarkRegisteredPebbleWritePack_NoDigestIndex/grants=10000000-10                    1        2156140416 ns/op                26.00 ms/EndSync              1992 ms/PutGrants 5031494040 B/op    42037 allocs/op
BenchmarkRegisteredPebbleWritePack_NoDigestIndex/grants=10000000-10                    1        2119374666 ns/op                26.00 ms/EndSync              1956 ms/PutGrants 5031441216 B/op    42027 allocs/op
PASS
ok      github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble      41.383s

@mj-palanker mj-palanker requested review from a team, ggreer and kans July 8, 2026 03:25
return &Options{
durability: DurabilitySync,
slowQueryThreshold: 5 * time.Second,
grantDigestIndex: true,

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: Defaulting grantDigestIndex to true means every Pebble seal now runs an extra full O(grants) scan + spill-sort + ingest + digest fold — including the common PutGrantRecords path that previously ran no deferred pass at all (endSyncFinalize now calls BuildGrantDigests on that branch). This adds seal-time latency and disk I/O for all existing downstream callers, most of which never grant-diff. The repo's own compat guidance prefers new behavior to default to the old behavior and be opt-in. It's mitigated by WithGrantDigestIndex(false) and is documented, so this is non-blocking — but consider defaulting off (or at least calling out the added seal cost in the PR/rollout notes). (medium confidence)

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

General PR Review: Entitlement Grant Hash Index & Entitlement Grant Digest Tree

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

Review Summary

Full PR diff scanned for security and correctness. This adds the by_entitlement_principal_hash index and per-entitlement grant digests (XOR set-hash tree) plus a whole-file grant_digest_root manifest field. The proto change is additive (new field 43 + new GrantDigestRoot message) and the checked-in generated pb/ output matches; go.mod/go.sum are unchanged. The digest/index keyspaces are derived only at seal time, invalidated on post-seal mutation, and crash-guarded by a durable build-pending marker with drop-on-open recovery — the "present-means-exact" contract holds across the write, repair, fold-compaction, and read paths I traced. The prior bot finding about the grantDigestIndex default remains open (carried over); the earlier grants.go error-swallow finding is addressed in the current code (the identity error is now returned).

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/dotc1z/engine/pebble/grant_digest_repair.go:294 — after a fold, targeted repair skips orphan partitions (grants with no entitlement record), so the recomputed whole-file grant_digest_root excludes a touched orphan while a seal-time build includes it; the header-only whole-file comparison becomes orphan-lossy after a fold (low confidence — orphans are an acknowledged edge case).
  • pkg/dotc1z/engine/pebble/options.go:165grantDigestIndex defaults to true, so every Pebble seal runs the extra digest build (~2x EndSync). Escape hatch WithGrantDigestIndex(false) exists; confirm the default is acceptable for all consumers. (Carried over from prior review; unchanged by this commit.)
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 294 (findMissingGrantDigestPartitionsLocked) and line 477
    (recomputeGrantDigestGlobalRootLocked): the fold-compaction repair path
    (InvalidateGrantDigestPartitions + RepairMissingGrantDigests) can invalidate an
    orphan partition (grants whose entitlement has no entitlement RECORD). The
    missing-partition scan only walks entitlement records, so a touched orphan is
    never rebuilt, and the recomputed whole-file global root
    (globalGrantDigestNodeKey) then omits it — whereas a seal-time grantDigestFold
    includes orphan grants in the global root. This makes the manifest
    grant_digest_root inconsistent between seal-derived and fold-derived files and
    can weaken the header-only "did anything change?" check. Either also rebuild
    invalidated partitions that still hold grants regardless of an entitlement
    record, or explicitly document that the whole-file root is orphan-lossy after a
    targeted repair.

In `pkg/dotc1z/engine/pebble/options.go`:

  • Around line 165: grantDigestIndex defaults to true, so every Pebble seal now
    runs an extra full O(grants) scan + spill/merge to build the hash index and
    digests (~2x EndSync CPU, plus roughly duplicated grant on-disk footprint).
    Confirm this cost is acceptable as a default for all downstream consumers;
    WithGrantDigestIndex(false) is the opt-out.
    ```

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

@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 force-pushed the mjp/grant-digest-v2 branch from 37b586c to 9a0012a Compare July 8, 2026 22:45

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

Comment thread pkg/dotc1z/engine/pebble/grants.go Outdated
}
}
if e.grantDigestsPresent.Load() {
if id, err := grantIdentityFromRecord(r); err == nil {

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 (low confidence): this swallows the error from grantIdentityFromRecord(r) — on the err != nil branch the touched entitlement's digest is silently not invalidated, which would leave a stale-but-present digest and violate the present-means-exact contract. In practice a record that reaches writeGrantIndexes already had its identity derived to build the primary key, so this is likely unreachable; but the sibling paths (writeGrantIndexesForIdentityScratch, deleteGrantIndexesScratch) can't fail-open here. Consider propagating the error instead of dropping it.

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

@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 Jul 10, 2026

Copy link
Copy Markdown
Contributor

Final brief:

@mj-palanker do we really want to exclude sources from grant hashes? Thats user facing data, and maybe goes into ticket processing?

@alan-lee-12 you possibly know best ^

Brief: grant-digest review fixes (mjp/grant-digest-v2)

Findings from a correctness/error-recovery review of the branch. One
must-fix soundness hole, a few small hardening items, one decision to
sign off. No changes to the digest core design or the hash ABI.

Fix 1 (must-fix): standalone build crash window breaks "root present ⇒ index present"

buildGrantDigestsFromSpill commits digest nodes during the merge
(closePartition flushes batches every 4 MiB) and again at
fold.finish() — including the global root — before the hash-index
IngestAndExcise. The fused seal pass is protected by the durable
deferredIdxPending marker (a crash reruns the full build); the
standalone BuildGrantDigests path has no equivalent.

Failure sequence: inline-only sync (deferred marker never armed) →
EndSync → BuildGrantDigests → process killed mid-merge or between
fold.finish() and the ingest. Committed WAL writes survive a process
crash, so partial (or all) digest roots — possibly the global root —
are durable while the index ingest never ran. On resume,
RepairMissingGrantDigests either fast-paths on the surviving global
root or repairs only partitions missing a root, trusting every
crashed-build root that committed. The file seals with correct-looking
digests over an empty hash index: ScanEntitlementGrantBucket
yields zero grants for dirty buckets and
GetEntitlementGrantDigestNodes at a finer-than-stored level scans the
empty index and reports "no grants". That is the silent
skip-entitlements-at-uplift failure mode.

Fix options (pick one):

  • Preferred — durable build-pending marker, mirroring
    deferredIdxPending: arm (fsync'd) before the standalone build's
    first node commit, clear after writeMissingEntitlementDigestRoots
    completes. Open and RepairMissingGrantDigests seeing the marker →
    dropAllGrantDigestState first. Converts every crash into "digests
    absent", which the design already treats as safe.
  • Alternative — reorder publication: stream digest nodes to their own
    SST (they are emitted in ascending key order) and ingest it after
    the index SST, so a crash can only leave index-without-digests
    (reads as missing → safe).

Verification: a test that kills the build between the digest-node
commit and the index ingest (inject a failpoint or split the function),
reopens, reruns EndSync, and asserts either no digest state at all or
digest state whose hash index matches a from-scratch build byte-for-
byte. Cover both the mid-merge and post-finish() windows.

Fix 2: writeGrantIndexes swallows identity errors — DONE

Fixed on origin/mjp/grant-digest-v2 ("fixup for error swallow"):
writeGrantIndexes now propagates the grantIdentityFromRecord error
instead of silently skipping invalidation. Nothing further to do once
that commit is in your local stack.

Fix 3: repair path drops malformed grant keys silently

repairOneGrantDigestPartitionLocked skips keys that fail
splitGrantPrimaryKey with a bare continue. The build paths count and
Error-log the same condition ("rows NOT represented in the digests").
Add the same counter + log to the repair path.

Fix 4: doc contradiction on the ComputeEntitlementBucketDigest fallback

GetEntitlementDigestRoot / getPartitionDigestRoot doc comments
invite falling back to ComputeEntitlementBucketDigest when no root
exists — but the index only exists alongside built digests, so on a
never-built file that fold returns {0, 0} ("zero grants"): the exact
trap dirtyPartitionBuckets' own comment warns against. No in-repo
caller misuses it; fix the comments so the exported API doesn't
recommend the unsafe pattern.

Fix 5 (scale, not correctness): bound repair memory

repairOneGrantDigestPartitionLocked buffers one partition's full row
set in memory, one allocation per row. A fold repair touching an
"everyone"-style entitlement (millions of grants) is multi-GB. Add a
row-count/byte threshold above which the repair falls back to the
spill-sorter machinery (or degrade to BuildGrantDigests).

Decision (sign-off, no code): content-hash field exclusions

grantContentHash64 deliberately excludes grant annotations,
source-record VALUES, external_id, and expansion flags. Any change in
only those fields fingerprints as "no change" and gets skipped by a
digest-driven diff. Confirm with the uplift consumer that nothing it
needs rides on grant annotations (e.g. immutability/system markers).
If something does, that is an ABI change — bump
grantDigestABIVersion — and far cheaper to decide now than after
files ship.

Noted, no action required

  • Bucket-plan Compactor.Compact (synccompactor/pebble) replaces
    grants and digest buckets in separate ingests; a crash mid-loop
    leaves new grants under old, present digests. Recovery contract is
    "re-run Compact" and there is no in-repo production caller — but if
    the API is live in c1, consider dropping the digest/hash buckets
    first and replacing them last.
  • Orphan partitions (grants without an entitlement record) invalidated
    post-seal are never repaired and are excluded from the recomputed
    global root. All consequences are conservative (false-dirty, never
    false-clean); documented in RepairMissingGrantDigests.

mj-palanker and others added 8 commits July 10, 2026 11:13
Compacted outputs previously shipped with no grant-digest state in
every merge mode (fold/k-way/overlay), defeating the digest feature
for exactly the files downstream diff consumers ingest most. After
each mode's merge finishes writing the final grant keyspace, the
compactor now calls the existing standalone Engine.BuildGrantDigests
(the same lean build EndSync runs when the deferred index pass didn't
fire), giving compacted files digests indistinguishable from a
seal-time build without re-deriving by_principal/the primary grant
keyspace, and with the same fail-safe (drop-and-continue) semantics.

Also adds a whole-file grant digest root to the v3 envelope manifest
(GrantDigestRoot: xor_digest/count/abi_version) so a consumer can
answer "did anything change?" from a header read. It's a running
(xor, count) accumulator threaded through the same build that writes
per-entitlement digest nodes, and is dropped by the same invalidation
paths whenever any partition's digest goes stale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@mj-palanker mj-palanker force-pushed the mjp/grant-digest-v2 branch from 51bf06c to 278309f Compare July 10, 2026 19:14

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

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

// partition turns out to be missing. Orphan grant-bearing entitlements
// without a record are not covered — see RepairMissingGrantDigests's
// doc comment.
func (e *Engine) findMissingGrantDigestPartitionsLocked(ctx context.Context) ([]string, error) {

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 (low confidence): findMissingGrantDigestPartitionsLocked walks only entitlement records, so an orphan partition (grants with no entitlement record) that was invalidated by InvalidateGrantDigestPartitions during a fold is never rediscovered or rebuilt here. recomputeGrantDigestGlobalRootLocked then folds only the roots that remain stored, so the whole-file grant_digest_root written after a fold excludes a touched orphan — whereas a seal-time grantDigestFold includes orphan grants in the global root. The manifest whole-file root can therefore represent the same grant set differently depending on whether it came from a seal vs. a fold+repair, which weakens (and in contrived symmetric cases could falsely-match) the header-only "did anything change?" check. Orphans are an acknowledged edge case, but consider either rebuilding invalidated partitions that still hold grants regardless of an entitlement record, or documenting that the global root is orphan-lossy after a targeted repair.

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

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