Skip to content

fix(api): key cross-ledger governance by the model ledger's head commit - #2052

Open
bplatz wants to merge 2 commits into
mainfrom
fix/governance-cache-commit-id
Open

bplatz wants to merge 2 commits into
mainfrom
fix/governance-cache-commit-id

Conversation

@bplatz

@bplatz bplatz commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

Problem

A model ledger dropped and created again under its name kept enforcing its predecessor's policies and shapes on every data ledger it governs, until the server restarted. No error: a new model whose policies deny what the old ones allowed kept allowing it.

The governance cache keys resolved artifacts by (kind, model ledger, graph, t). A recreated ledger restarts at t 1, so after the same number of commits it lands on a key the dropped ledger already filled. Dropping a ledger already clears its cached ledger state; the governance cache was the only stale layer.

The compiled-SHACL cache on the data ledger has the same blind spot. It is keyed by the shapes wire's origin (model, graph, t) and checked before translation, so even with a fresh wire, a recreated model at the same t reused the shapes compiled from the dropped one.

Change

  • ModelHead { t, commit_id } replaces the bare t in the resolution key. The commit id comes from the nameservice record the resolver already reads, so there is no extra I/O.
  • The per-request pin holds both. ResolveCtx::resolved_heads (and GraphDb::cross_ledger_resolved_heads, carried from wrap_policy to query) pins t and commit together, so a later stage opening the model at the pinned t keys under the same commit rather than a newer one.
  • WireOrigin carries the commit id (the API and policy crates' copies), and the compiled-SHACL cache keys on it.
  • The cycle error still reports (kind, ledger, graph, t); its public type is unchanged.

Cost is negligible: a ContentId is 96 bytes inline, no allocation; one extra hash per resolution, and the 4,096-entry cache grows by at most ~384 KB. Hit rate is unchanged in steady state, since t and head commit move together on every commit.

Tests

  • recreated_model_ledger_policies_replace_the_dropped_ones (it_policy_cross_ledger): end to end through db_with_policy. M denies users; M is dropped and recreated allowing them at the same t; the query must return them.
  • recreated_model_ledger_is_not_served_the_dropped_ones_artifact (it_cross_ledger_resolver): the resolver returns a fresh artifact, not the dropped M's cached Arc.
  • recreated_model_ledger_shapes_replace_the_cached_compile (it_shapes_cross_ledger): a write rejected by M's shape (compiled and cached), then M recreated at the same t with the shape deactivated; the same write must pass.

Mutation checks:

  • Capturing no commit id in the resolver fails all three.
  • Leaving the commit id out of the compiled-SHACL cache key only fails the SHACL test.

Related

Partially addresses #1962 (caches keyed by ledger id and t that collide on drop/recreate). This one was not on its list.

#1980 keys the same cache by the model ledger's instance. When it lands, the commit id here makes the instance in the key redundant, and its recreate test still applies.

Also keys correctly a branch whose head returns to an earlier t with other commits, as a Skip rebase can produce. That case isn't pinned here: today such a rebase never moves the branch's durable head, because publish_commit ignores a head that doesn't advance t. That is a separate bug, to be filed on its own.

The governance cache keyed resolved policies, shapes, rules and schema by
(kind, model ledger, graph, t), relying on new commits to produce new keys.
`t` is not a model ledger's identity: one dropped and created again under
its name restarts at t 1, so after the same number of commits it landed on
a key the dropped ledger had filled, and every data ledger it governs was
served the dropped ledger's artifacts until the server restarted. A model
whose new policies deny what the old ones allowed kept allowing it.

The key now carries the model's head commit id beside its t. The per-request
pin (`ResolveCtx::resolved_heads`, carried across `wrap_policy` and `query`
on the view) holds the two together, so a later stage that opens the model
at the pinned t keys under the same commit rather than a newer one.

The compiled-SHACL cache on the data ledger had the same blind spot: it is
keyed by the shapes wire's origin and checked before translation, so a
recreated model at the same t reused the shapes compiled from the dropped
one. `WireOrigin` (both the API and policy crates' copies) now carries the
commit id, and the compile cache keys on it.
@bplatz bplatz added bug Something isn't working as expected area:query Query execution, planning, fast paths, overlay, result formatting labels Oct 10, 2026
@bplatz
bplatz requested a review from a team October 10, 2026 17:41

This branch has not been deployed

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

Labels

area:query Query execution, planning, fast paths, overlay, result formatting bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant