Skip to content

fix(runtime-host): separate the persisted grant record from the authority it derives - #4427

Merged
Astro-Han merged 3 commits into
mainfrom
feat/persistence-forward-roll-guard
Sep 1, 2026
Merged

fix(runtime-host): separate the persisted grant record from the authority it derives#4427
Astro-Han merged 3 commits into
mainfrom
feat/persistence-forward-roll-guard

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

A credential's grant list served two masters. As a record it should hold what some earlier release wrote; as an authority it may only name operations this build defines and this principal's policy allows. Decoding reconciled the two in place, so every vocabulary change had to be patched into the same array — three hand-written constants, then a fourth for the SessionTodo cutover that stranded workspaces holding a credential issued before it (#4420).

This separates them, and guards the seam that let it ship.

  • The record is StoredAccessCredential.grants, kept as the file states it. effectiveOperationGrants derives the authority on every decode and never writes back, so a grant this build cannot serve is absent from the authority and still present in the record. An unrelated later mutation no longer erases it — neither a newer build's key read by an older one, nor a key whose migration entry was forgotten. PERSISTED_GRANT_MIGRATIONS is the only thing that rewrites the record, and a replacement naming no successor fails to compile.
  • The fixture seeds a released-build credential and golden capture spans the control namespace, where that file actually lives. Inspection asserts the reading build can account for every stored grant, so a rename shipping without its entry fails on the candidate.
  • The trigger includes the operation vocabulary. protocol/operations.ts is what feat(runtime): cut over SessionTodo to Runtime Host authority #4351 changed; it touched no decoder, so a decoder-only trigger would have stayed green on the exact change this guard exists to catch.

Deriving per principal also puts a Client Capability provider under its own policy instead of the remote owner's — decoding never applied it — and retires the Session Guest special case, which is now just another derivation rule.

Refs #4420

Where this check runs

It runs on the heavy lane, against the published predecessor downloaded and integrity-checked from the registry, read by the workspace that lane already built. The alternative was the CLI packaging lane, which supplies a built artifact but spends nine minutes of packaging that proves nothing this check needs.

wall clock runner time
CLI packaging lane +0s (parallel, hidden) +31 min
heavy lane (this PR) +76s +1.5 min

Packaging costs nothing in wall clock only because it hides behind a slower lane — a margin nobody promised to keep. Runner time is the scarcer resource here, which is why this lane is one job of serial steps sharing a single checkout, install and build to begin with. Both fire on roughly 1 commit in 10.

Measured: plan 25s and heavy 1150s on main; the qualification step 76s; the packaging lane's jobs 1863s in total.

Verification

  • Runtime Host: 1550 pass, 0 fail. New grant-migration suite: 8 pass, including that an unaccountable grant survives a rewrite under the published JSON key, and that policy contraction is not reported as a missing migration
  • Scripts: 72 pass across the planner, qualification and workflow-policy suites
  • Replaying the real 85-file change list of feat(runtime): cut over SessionTodo to Runtime Host authority #4351 through the planner now yields stateRootCompat = true with full = false — precise selection, not a full-suite fallback. It yielded false before this change
  • Reverse-checked: removing operations.ts from the trigger fails the new planner test
  • biome check clean; ASF header check passes

Known limits

  • The alarm catches a retired key with no migration entry. It cannot catch a key that stays valid while its meaning widens — session.turns.query gaining a separate landmark query, say. No set comparison derives product semantics; that stays a human declaration in the registry.
  • Preservation is only as durable as the oldest writer. A build predating this change still rewrites the file by its own rules.
  • schemaVersion above the known maximum stays fail-closed. Structure the build cannot interpret is categorically different from one vocabulary item it does not own, and relaxing it is a separate change.
  • The forward-roll baseline is whichever Nightly the registry currently tags, with no check that it is an ancestor of the commit under test. Pre-existing; tracked in Forward-roll baseline is not fenced to an ancestor of the change under test #4447.

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code (Opus 5) investigated the failure, wrote the change and its tests, and ran the verification above. OpenAI Codex and two other models were consulted independently on the design; the record/authority separation replaced an earlier fail-open decode after Codex identified that the earlier shape let a later write erase what decoding had dropped.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

@github-actions github-actions Bot added the effort/M Under 500 readable lines label Sep 1, 2026
@Astro-Han
Astro-Han force-pushed the feat/persistence-forward-roll-guard branch from a8ec928 to 9e575f1 Compare September 1, 2026 05:50
@M4n5ter

M4n5ter commented Sep 1, 2026

Copy link
Copy Markdown
Member

I re-reviewed the latest head (9e575f1) against current main (8bc4846), combining the correctness and simplification audits. Two merge-blocking findings remain:

  1. An unregistered persisted grant is still indistinguishable from an explicit release. In migrateStoredOperationGrants, both an explicit { kind: 'release' } entry and an absent, non-current key become []. If a rename removes an operation and its migration entry is forgotten, the Host silently opens with reduced authority; a later access mutation then rewrites that reduced file and makes the loss durable. The released fixture cannot catch this: it intentionally records only credential identity and checks only host.status (fixture), so the exact forgotten-migration case this PR says it guards remains green. When resolving the current main conflict, preserve fail-closed validation for an unknown grant and make the registry the sole exception: explicit replacement or explicit release. That is also the simpler model; target startup then makes the forward roll catch an omitted entry without a second semantic-policy framework. The test that blesses an arbitrary unregistered release, and the guest exception built on the same fallback, should not remain.

  2. The new lane does not run when the operation vocabulary—the actual retirement point—changes. stateRootCompat is selected from the partial file set in DURABLE_STATE_DECODER_FILES plus SQLite schema names (selection). The TaskLedger cutover that caused this incident changed packages/runtime-host/src/protocol/operations.ts and did not touch either listed access decoder, so this guard would not have run on the exact historical change shape. Include the operation-vocabulary ownership surface—at minimum protocol/operations.ts, preferably the relevant protocol boundary—and lock that path into the planner test.

The control-namespace capture and released credential fixture are valuable and should stay. I would not add generic per-grant semantic probes: once unknown grants fail closed, the real forward roll catches omissions, while focused migration tests remain the right place to verify each explicitly chosen successor.

@Astro-Han
Astro-Han force-pushed the feat/persistence-forward-roll-guard branch from 9e575f1 to 01d119f Compare September 1, 2026 06:14
@Astro-Han
Astro-Han marked this pull request as ready for review September 1, 2026 06:15
@github-actions github-actions Bot added effort/L Under 1000 readable lines and removed effort/M Under 500 readable lines labels Sep 1, 2026
…rity it derives

A credential's grant list served two masters. As a record it should hold what
some earlier release wrote; as an authority it may only name operations this
build defines and this principal's policy allows. Decoding reconciled them in
place, so every vocabulary change had to be patched into the same array —
first three hand-written constants, then a fourth for the SessionTodo cutover
that stranded workspaces holding a credential issued before it (#4420).

The record is now `StoredAccessCredential.grants`, kept as the file states it,
and `effectiveOperationGrants` derives the authority on every decode without
writing back. A grant this build cannot serve is absent from the authority and
present in the record, so an unrelated later mutation no longer erases it —
neither a newer build's key seen by an older one, nor a key whose migration
entry was forgotten. `PERSISTED_GRANT_MIGRATIONS` is the only thing that
rewrites the record, and a replacement naming no successor fails to compile.

Deriving per principal also puts a Client Capability provider under its own
policy rather than the remote owner's, which decoding never applied, and
retires the Session Guest special case: its record was never authoritative, so
it is simply one more derivation rule.

The published JSON keeps `operationGrants` as its key. An explicit encoder
states the on-disk shape once, so a field added to the runtime type cannot
reach the file by accident.

Refs #4420

Generated-by: Claude Code (claude-opus-5)
The forward roll restored and compared the State Root alone, but the record
that decides whether a Host starts at all is the access credential file, and
it lives in the account-local control namespace rather than inside the Root.
The harness was structurally blind to it: no seeded credential, no golden
capture of that directory, so a release could strand every existing workspace
and still qualify.

The fixture now asks the released build to issue a credential with everything
it is able to grant, rather than naming operations here — a fixture that
hard-codes today's keys stops covering the next rename the moment it lands.
Golden capture and restore span both durable locations.

Inspection also asserts that the reading build can account for every stored
grant. That question is asked of whichever build is reading rather than
compared between them, so a rename shipping without its migration entry fails
on the candidate while the released build, which predates the check, skips it.

Generated-by: Claude Code (claude-opus-5)
The forward-roll job existed and already ran, but nothing woke it when the
code that decodes durable state changed. The trigger now selects it from the
decoders and, decisively, from the operation vocabulary they decode against:
the SessionTodo cutover that caused #4420 changed `protocol/operations.ts` and
no decoder, so a decoder-only trigger stays green on the exact change shape
this guard exists to catch. The planner test pins that path.

It runs on the heavy lane rather than the CLI packaging lane. Packaging is
nine minutes that prove nothing this check needs; the baseline is instead the
published predecessor, downloaded and integrity-checked against the registry's
own digest, and read by the workspace already built on that lane. That costs
about a minute of wall clock on roughly one commit in ten, against a full
cross-platform matrix of runner time — and runner time is the scarcer resource
here, which is why this lane is one job of serial steps to begin with.

Generated-by: Claude Code (claude-opus-5)
@Astro-Han
Astro-Han force-pushed the feat/persistence-forward-roll-guard branch from 01d119f to eb7a3a1 Compare September 1, 2026 08:32
@Astro-Han Astro-Han changed the title fix: guard durable state across version transitions fix(runtime-host): separate the persisted grant record from the authority it derives Sep 1, 2026
@Astro-Han

Copy link
Copy Markdown
Contributor Author

Both findings were right. Rebased onto current main and rebuilt. Finding 2 is fixed as prescribed; finding 1 is fixed differently.

Finding 2. Verified first: replaying the real 85-file change list of #4351 through the planner gave stateRootCompat = false. protocol/operations.ts is now in the trigger, pinned by a planner test that fails when it is removed. Same input now gives true, with full = false.

Finding 1. Your diagnosis holds on every point, including the sharpest one: fail-open decode plus a host.status-only assertion means the alarm was structurally incapable of firing.

I did not restore fail-closed, because it prices in a cost the prescription does not name. This file sits at resolveRootControlNamespace()/<rootId>/runtime-host-access.json — no version segment. A stable app, a Nightly CLI and a rolled-back build read the same file, and vocabulary evolution never bumps schemaVersion (#4351 did not). Under fail-closed, any release adding an operation key bricks every older build installed beside it. That is two release channels coexisting, not an edge path.

Both fail-closed and my fail-open were symptoms of one defect your comment made visible: the array was simultaneously the record and the authority.

They are separate now. StoredAccessCredential.grants is the record; effectiveOperationGrants derives the authority per decode and never writes back. A grant this build cannot serve is excluded from the authority and kept in the record.

Against your specific objections:

  • Durable loss — gone. The key stays on file, so adding a forgotten entry later restores the grant on next start. Qualification now asserts the reading build can account for every stored grant, so a retired key with no entry fails the candidate.
  • Registry as sole exception — kept. replace and release are the only rewrites, and a replace naming no successor now fails to compile.
  • The test blessing an arbitrary unregistered release — gone; the suite asserts the opposite.
  • The guest exception — gone, by restructuring rather than removal: a guest's record was never authoritative, so it is one derivation rule among three. The same change put a capability provider under its own policy, which decoding had been filtering through the remote-owner allowlist.

No per-grant semantic probes were added. The assertion is one question asked of the reading build; the fixture does not know the registry exists.

One limit, since my earlier description overreached: this catches a retired key with no entry, not a key that stays valid while its meaning widens. That stays a human declaration, as you said.

Separately, found while verifying: the forward-roll baseline is whichever Nightly the registry currently tags, unfenced against the commit under test. The quiet failure is the bad one — if that Nightly already contains the migration under test, both sides speak the same vocabulary and it passes without crossing a version boundary. Pre-existing, exposure widened here, tracked in #4447.

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

Approved. Separating the persisted grant record from the current effective authority preserves recoverability across operation vocabulary changes, while explicit replacements and releases keep migration intent auditable. The released-state qualification and planner coverage make this compatibility boundary materially easier to maintain.

@Astro-Han
Astro-Han merged commit 0d3265e into main Sep 1, 2026
20 of 22 checks passed
@Astro-Han
Astro-Han deleted the feat/persistence-forward-roll-guard branch September 1, 2026 11:13

@M4n5ter M4n5ter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

English

Re-review at eb7a3a15: the previous grant-record/effective-authority issue is resolved. One important issue remains.

[Important] Include the leaf operation-spec modules in the State Root compatibility trigger

ci-test-plan.mjs treats protocol/operations.ts as the owner of the operation vocabulary, but that file only aggregates the spec maps. The operation keys are declared across 40 leaf protocol modules, for example session-transcript.ts.

I verified the planner directly:

protocol/operations.ts          stateRootCompat=true
protocol/session-transcript.ts  stateRootCompat=false
protocol/session-turns.ts       stateRootCompat=false

A normal rename or removal in a leaf module can therefore still skip the released State Root qualification and leave an omitted grant migration undetected. Please cover the modules that contribute to HOST_OPERATION_SPECS and add a planner regression test using a leaf protocol file.

Verification: build passed; planner/qualification tests 69/69; grant migration tests 11/11; Runtime Host suite 1550 passed, 12 skipped, 0 failed.

中文

eb7a3a15 上重新审查:之前 grant record 与 effective authority 的问题已经解决。目前还剩一个重要问题。

[Important] State Root 兼容性触发器需要覆盖实际声明 operation spec 的子模块

ci-test-plan.mjsprotocol/operations.ts 视为 operation vocabulary 的所有者,但该文件实际上只负责聚合;operation key 分散声明在 40 个协议子模块中,例如 session-transcript.ts

我直接验证了 planner:

protocol/operations.ts          stateRootCompat=true
protocol/session-transcript.ts  stateRootCompat=false
protocol/session-turns.ts       stateRootCompat=false

因此,直接在子模块中重命名或删除 operation key,仍可能跳过 released State Root qualification,使遗漏的 grant migration 无法被发现。请覆盖所有参与组成 HOST_OPERATION_SPECS 的模块,并使用一个协议子模块补充 planner 回归测试。

验证结果:构建通过;planner/qualification tests 69/69;grant migration tests 11/11;Runtime Host suite 1550 passed、12 skipped、0 failed。

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

Labels

effort/L Under 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants