Skip to content

feat(core): add the ReleaseRole vocabulary and characterise today's claim disposition (#790) - #815

Merged
tobyhede merged 6 commits into
mainfrom
790-claim-disposition-characterisation
Aug 19, 2026
Merged

feat(core): add the ReleaseRole vocabulary and characterise today's claim disposition (#790)#815
tobyhede merged 6 commits into
mainfrom
790-claim-disposition-characterisation

Conversation

@tobyhede

Copy link
Copy Markdown
Owner

Closes #790. Step 1 and step 3 of the #781 implementation order.

Behaviour-neutral. Nothing calls the new vocabulary yet, and the characterisation tests assert today's behaviour unmodified. The point is to make the change that follows visible as a one-line diff.

What lands

The vocabulary, in core, with no callers (packages/core/src/runbook/session-release.ts):

export type ReleaseRole = 'addressed' | 'collateral' | 'discarded';
export type ClaimDisposition = 'retain-as-terminal-evidence' | 'revoke';
export function claimDisposition(role: ReleaseRole): ClaimDisposition;
export function projectRunRelease(session: SessionData, release: RunRelease): boolean;

The session release primitive currently asks each caller for a conclusionretainClaimsAsTerminal, a boolean meaning "should this claim survive?" — which is domain logic performed independently at sixteen call sites. Fifteen agree, on a rule none of them states. The sixteenth omits the option, and omission reads as the destructive direction, which is #781.

The new vocabulary asks for the fact the caller already holds ("I addressed this run") and owns the conclusion itself. That makes the bug class unrepresentable: there is no option left to omit.

Two constraints are load-bearing and are pinned by tests, not just documented:

  • projectRunRelease is synchronous and mutates in place, because six dispositions reach the projection through commitOwnedState({ updateSession }), which accepts a sync in-place callback and nothing else.
  • claimDisposition takes the role alone. A property test pins the invariant that makes that safe — a run's disposition depends only on its own role, never on ordering and never on the other members of a batch — which is what lets it widen to claimDisposition(role, claim) later without touching a caller. The property shuffles up to 5 releases across all orderings rather than testing reversal alone, because cross-talk on an interior ordering survives a plain reversal.

discarded is a distinct arm rather than a synonym for collateral: the destroy paths must never be spelled addressed, which would retain claims over a run about to stop existing. And the retained case is named retain-as-terminal-evidence, not tombstone — the tombstone is the superseded row that revoking produces (runbook-store.ts:1558-1585), and that collision is what the vocabulary exists to remove.

Characterisation tests at the resolution seam (packages/cli/__tests__/integration/claim-disposition-characterisation.test.ts) assert the answer an orchestrator actually gets, not the shape stored in the session projection:

The trigger is a delegation whose child runbook is not discoverable. A plain completion does not reach the entry check at all: the fenced command mutation releases with retention and the loop returns first.

#790's third acceptance item — the multi-process case — is discharged inline in both tests rather than as a third case, and the test file says so: the run is one subprocess and resolveClaim reads the database from another, so both already cross a process boundary. A unit suite mocking the session boundary cannot observe the tombstone crossing that boundary, which is the whole reason the requirement exists.

Acceptance

# item state
1 three characterisation assertions green against unmodified behaviour done — two tests, both cross-process
2 ReleaseRole / ClaimDisposition / claimDisposition / projectRunRelease in core, unit-tested, no production callers done
3 property test pins order-independence done
4 test:mutate:changed reports no survivors in the changed ranges done (3ed3d00af)

Also included

Two Prettier-exclusion chores (132c9d298, 9aeecbd24). They are not incidental: without them pnpm run verify stops at check:md on the untracked vendored skills tree, which is the failure #791 hit and #532 tracks. They belong on the branch that has to be green to merge.

Testing

pnpm run verify — green, exit 0.

What this unblocks

#792, #793 and #794 are blocked in sequence behind #790. Nothing else in the #781 chain can land until this does.

.agents/skills/ holds skills installed from upstream repos and pinned by
computedHash in skills-lock.json. Prettier reflowed all 44 of their markdown
files, which failed check:md and blocked pnpm run verify at step 3 of 11 --
and formatting them would have invalidated every hash in the lock.

.claude/skills/* are symlinks into the same tree; Prettier does not follow
symlinked directories, so the single .agents/skills/** entry covers both.
Pins the answer an orchestrator gets back at the resolution seam, before
anything in the #781 cluster moves. Three cases:

- An already-terminal loop entry resolves `superseded` / `claim-rotated`.
  This is the #781 defect, asserted deliberately: `rundown run` mints a
  run-control claim for every default-stack root, and the terminal loop
  entry releases that root through the stack-pop derivation, which still
  encodes "this run is unclaimed". The assertion flips to `terminal` in the
  ticket that fixes it, so it must be green first for the flip to read as a
  one-line diff.
- A run completing through a fenced command resolves `terminal`. The
  control, which must not move at any point in the cluster.
- Both dispositions survive a process boundary, resolved from a third
  process sharing only the database.

The trigger for the first case is a delegation whose child runbook is not
discoverable, which stops the run during initialization and re-enters the
loop already terminal. A plain completion would not do: the fence releases
with retention and the loop returns before the entry-time terminal check,
so a test built on one passes under both old and new code. The control
resolving `terminal` in the same harness is what proves the two paths are
actually distinct.

Assertions are at the resolution seam rather than the session projection,
because the projection is what the follow-up rewrites and the resolution is
what a caller observes. The precise supersession reason comes from core;
the CLI envelope carries only the code, which `claim-rotated` shares with
`parent-unreadable`, so the message text is what separates them.
`ReleaseRole` (addressed | collateral | discarded), `ClaimDisposition`
(retain-as-terminal-evidence | revoke), `claimDisposition(role)`,
`RunRelease` and `projectRunRelease(session, release)`.

The primitive this will replace asks each caller for a conclusion —
`retainClaimsAsTerminal`, "should this claim survive?" — which is domain
logic, performed independently at sixteen sites. Fifteen agree on a rule
none of them states: the run the caller acted ON keeps its claim as
terminal evidence; a run swept up so the addressed run could close does
not. The sixteenth omits the option, and omission reads as the destructive
direction.

This asks for the fact the caller already holds instead, and owns the
conclusion. There is no option left to omit, so that bug class becomes
unrepresentable. `discarded` is its own arm rather than a synonym for
`collateral`: a destroy path spelled `addressed` would retain claims over a
run about to stop existing.

`claimDisposition` takes the role alone, and a property test pins what makes
that safe — a run's disposition depends only on its own role, never on
ordering, never on the other members of a batch. That is what lets it widen
to `claimDisposition(role, claim)` later without touching a caller.

`projectRunRelease` is synchronous and in-place by requirement, not
preference: several dispositions reach the projection through a session
callback that accepts nothing else. It preserves the replaced primitive's
found/not-found answer exactly, including counting a retained claim as
found, so the migration that moves callers onto it can be behaviour-neutral.

Nothing calls any of it yet. Wiring is the next ticket, deliberately
separate so a regression there stays traceable to the move rather than to
the vocabulary.
`projectRunRelease` reported found/not-found correctly in every existing
case, but never because of the default stack: each of those cases carried a
claim or a stash entry as well, so replacing the stack comparison with
`false` changed no assertion. Adds the case where stack membership is the
only evidence.

The exhaustive `never` arm of `claimDisposition` takes the repo's existing
Stryker suppression for unreachable code, matching
lifecycle-command-service.ts:2344.

`session-release.ts` now detects all 41 of its valid mutants.
.agents/plugins/ comes from the same upstream install as .agents/skills/ and
is pinned by the same lockfile. It ships no Markdown today, so the narrower
pattern passed -- and the first upstream plugin that does would reintroduce
the check:md blocker this exclusion exists to prevent.

Deliberately not extended to cspell: check:spell is `cspell ... .`, whose
traversal does not descend into dot-directories, so .agents is already
unreachable from it. Pointing cspell at the path directly flags pocock,
travelling, lossiness and theorise, none of which are in the dictionary --
yet check:spell reports nothing there. An ignorePaths entry would be dead
config. Prettier needs one only because its **/*.md glob does traverse them.
Review of 132c9d2..HEAD. All four were latent rather than live, and each
would have degraded a gate silently rather than failing it.

session-release.ts: RELEASE_ROLES claimed a compile error would follow from
adding a ReleaseRole arm without listing it. It would not -- `as const
satisfies readonly ReleaseRole[]` checks assignability, not exhaustiveness,
so the constant would have gone silently short and the tests iterating it
silently partial, while only claimDisposition's `never` arm complained. Made
the claim true with an AssertNever-constrained UnlistedReleaseRole; adding a
fourth role now fails the type check at the constant as well.

session-release.test.ts: both order-independence properties were named "under
any permutation" but compared forward against reversed only -- two of up to
120 orderings, blind to cross-talk that depends on an interior ordering. They
now also compare a seed-driven Fisher-Yates permutation. Separately, claimFor
built its key as `key.repeat(32).slice(0, 32)`, which collides for indices
whose hex digits repeat to the same 32 characters (1 and 17); zero-padded
instead, so a larger session cannot quietly assert against fewer claims than
it created.

claim-disposition-characterisation.test.ts: dropped the third test. #790 asks
for a multi-process assertion, and the two remaining tests already carry it --
the run is one subprocess and the `rundown status --claim-id` assertion is
another, sharing only the database. The third re-ran both scenarios to assert
strictly weaker versions, for two extra CLI subprocesses and no coverage, and
duplicated the assertion #793 has to flip where a reader would not look. The
rationale it documented is folded into the file docblock.

Unchanged: 100% mutation score on session-release.ts (39 killed, 2 ignored,
0 survived, 0 no-coverage) and `pnpm run verify` green.
@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your current included review allowance is based on your included PR review attempts over the past 7 days.

Next review available in: 43 minutes

Limit details: You’ve used all 2 included reviews currently available. Your 50 included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 902acae5-344e-4c30-b41a-55083fd90e10

📥 Commits

Reviewing files that changed from the base of the PR and between 7e3fc64 and 463c511.

📒 Files selected for processing (7)
  • .changeset/release-role-vocabulary.md
  • .prettierignore
  • cspell-dictionary.txt
  • packages/cli/__tests__/integration/claim-disposition-characterisation.test.ts
  • packages/core/__tests__/runbook/session-release.test.ts
  • packages/core/src/runbook/index.ts
  • packages/core/src/runbook/session-release.ts

Warning

.coderabbit.yaml has a parsing error

The CodeRabbit configuration file in this repository has a parsing error and default settings were used instead. Please fix the error(s) in the configuration file. You can initialize chat with CodeRabbit to get help with the configuration file.

💥 Parsing errors (1)
Validation error: Too big: expected string to have <=250 characters at "tone_instructions"
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying rundown with  Cloudflare Pages  Cloudflare Pages

Latest commit: 463c511
Status: ✅  Deploy successful!
Preview URL: https://dceb6052.rundown-7hl.pages.dev
Branch Preview URL: https://790-claim-disposition-charac.rundown-7hl.pages.dev

View logs

@github-actions

Copy link
Copy Markdown

🧬 Mutation score (advisory)

Hybrid mutation analysis: source changes use changed-line scopes (dedicated tests by default; mutation:related opts into related tests), while test-only changes use native incremental comparison with the main baseline. This check is advisory and never blocks merge. Trend & full reports: the Stryker Dashboard. See issue #485.

ℹ️ Mutation scope plan

Source test selection: dedicated.

  • ⚠️ core: source and test changes are mixed; the dedicated source tier ran, but test-only regression attribution was not independently evaluated

⚠️ cli — test-only mutation result incomplete

Changed tests: __tests__/integration/claim-disposition-characterisation.test.ts

No valid main-branch dashboard baseline was available. A cold full-package run was intentionally not substituted.

core — changed-scope mutants (floor 70% shown as score context)

File Score Status
src/runbook/index.ts ⏭️ not mutated

core — changed-scope mutants (floor 70% shown as score context)

File Score Status
src/runbook/session-release.ts 100.00%

@tobyhede
tobyhede merged commit 463c511 into main Aug 19, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant