Skip to content

feat(runtime): give the two operator run-lifecycle verbs a door (cancel, restore-suspension) - #16755

Merged
os-zhuang merged 6 commits into
mainfrom
claude/issue-13953-run-lifecycle-operator-door
Sep 8, 2026
Merged

feat(runtime): give the two operator run-lifecycle verbs a door (cancel, restore-suspension)#16755
os-zhuang merged 6 commits into
mainfrom
claude/issue-13953-run-lifecycle-operator-door

Conversation

@os-trump

@os-trump os-trump commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Fixes #13953

AutomationEngine's two operator run-lifecycle verbs — cancelRun (ADR-0044) and restoreConsumedSuspension (#13909) — get a door. Until PR #16563 declared them on IAutomationService neither was reachable by an operator at all: no REST route, no CLI command, not on the contract. This is the services half of the 2026-09-05 maintainer ruling (option A, comment 5548737008).

POST /api/v1/automation/:name/runs/:runId/cancel               body { reason? }
POST /api/v1/automation/:name/runs/:runId/restore-suspension   body { reason? }

The permission model, and how it compares to resume's

The gate is the ADR-0095 D2/D3 posture rung — ec.posture === 'PLATFORM_ADMIN' — required unconditionally, with the isSystem bypass every gate in this family carries.

  • Not the positions[] name. The ruling says "the existing platform_admin position"; Four server-side readers derive platform authority from the NAME in ExecutionContext.positions — same species as #15948's blocked escalation, and already reachable on main #15981 established that reading that array is not the authority, because sys_user_position is apiEnabled with unconstrained position values, so a tenant can mint a row spelling the built-in and resolveUserAuthzGrants §4 pushes it onto the array. The rung is derived from the unscoped admin_full_access evidence and nothing else — byte-for-byte what hasPlatformAdminStanding returns, and what the ruling meant. The correction is made at birth here rather than after a measurement; activation-gate.ts carries the same one.
  • Not posture-conditional. refuseUngrantedActivationWrite requires the operator only under group/isolated and falls open under single — correctly, because a manage_metadata capability tier still gates it there. This door has no tier in front of it, so the same conditionality would leave both verbs open to any authenticated caller on every single-organization deployment.

Against resume, the card's floor. resume has no route-level gate: it is fail-closed in the engine on the suspended node's declared resumeAuthority (#3801 / #5561, since when a node declaring no resumeAuthority is refused too), so an ordinary authenticated caller reaches the service and the engine decides. These doors refuse that same caller before the service is resolved. Pinned as a side-by-side in one test: the caller resume forwards to the service gets 403 at both lifecycle doors.

Nothing existing was relaxed. Two new routes, one new predicate, one new gate; no existing predicate, refusal, capability or posture rule changed. The hard stop in the dispatch did not fire.

Where the gate sits. With the three gates already there and ahead of the service probe, for their documented reasons read one tier up: which standing a route requires must not vary with which automation service a deployment mounts, an unentitled caller must not learn from a 501-vs-403 whether automation is mounted here, and nothing may be cancelled or re-armed before the refusal — "cancel first, refuse second" is the worst shape a run-lifecycle door can have.

One predicate, read three times. isRunLifecycleWrite is read by the gate and by both route arms, so a gate narrower than its route (a bypass) or wider than its route (an over-block) is not expressible. It excludes parts[0] === 'trigger' exactly as the toggle (#10243) and clone (#12156) arms do, so POST /automation/trigger/runs/:id/cancel — the legacy execution door for a flow literally named runs — is neither over-blocked nor dispatched as a cancel. Pinned.

The absent-member fail-closed pin

Both members are optional on IAutomationService (13 of its 15 are). The contract states the consequence and hands this door the job: "a door MUST probe for presence and refuse fail-closed when it is absent — never answer success for a verb it could not dispatch".

A service not declaring the verb answers 501, NOT_IMPLEMENTED, naming the member — the shape resume already uses one arm up. Three things are pinned, not one:

Unknown refusal codes

IAutomationService types the restore refusal as refusal?: string — a covariant widening of the engine's closed eight-member SuspensionRestoreRefusal union (#16495 route (i)). Any refusal-code → HTTP-status mapping is therefore a non-exhaustive string switch by construction.

The eight known codes map onto the statuses this same door already answers those conditions with on resume, so one deployment cannot answer RUN_NOT_FOUND two ways depending on which verb asked:

refusal status
RUN_NOT_FOUND 404 no record of the run at all
STORE_UNAVAILABLE 503 existence unknown; the same call is expected to work once it recovers
RESUME_IN_PROGRESS · RESTORE_IN_PROGRESS · RUN_SUSPENDED · RUN_COMPLETED · RUN_CANCELLED · NO_CONSUMED_SUSPENSION 409 the run's own state refuses the repair; retrying unchanged answers the same

Anything else — an unrecognised code, or a restored: false carrying none — answers 500. Deliberately not one of the 409s: a 409 would claim a diagnosis this door did not make ("the run's state refuses this, retrying will not help"), and a caller or an agent reading that would stop, believing the platform had answered them. 500 says the true thing — the implementation refused, this door does not know what it refused with, and nothing about the run's state has been established. Never a 200 either way.

The vocabulary is neither narrowed nor extended at the call site. Closing it is a packages/spec card, as the contract's own docblock rules. The engine's code rides details.refusal, never details.code — that key is promoted into error.code, which ADR-0112 closes to StandardErrorCode ∪ the registered ledger, and none of the engine's eight are members. error.code derives from the status instead. Pinned.

No once-only side effect keys off cancelRun's return

Per ruling ① on this card, no cancel-side compare-and-set is added, and the consequence is absorbed in the door instead. The engine's cancelRun does loadSuspendedRunStrict then an unconditional delete-by-id; only resume passes the claimAdvance compare-and-set through forgetSuspendedRun. So two overlapping cancels each read the row, each delete, each recordLog('cancelled'), and each return true.

The proof, in three parts:

  1. The arm's body is the whole surface. It probes the member, validates the body, calls the verb once, and returns. No logger call, no deps.announceKernelEvent, no store write, no event — the cancelled ? … : … expression selects a response string and nothing else.
  2. Behavioural pin. Two consecutive cancels both answering true produce two byte-identical 200s, and the domain's side-effect channel (deps.announceKernelEvent, which reaches kernel.context.trigger) is asserted never called. Nothing accumulates; nothing is "already done".
  3. The wire says so. The true answer carries a notice stating that true is not exclusive and must not be used as an idempotency token — because this door is not the last consumer, and a caller reading a bare cancelled: true as "I, uniquely, ended this run" would build the forbidden side effect one tier up.

The stop condition did not fire: the door does not require a once-only side effect, so the CAS did not become necessary.

⛔ Never a success that hides the condition

cancelled: false is a 200 — the contract calls it idempotent success — but never a bare one. Two conditions land on that same false and nothing above the engine can tell them apart: "no suspended run under this id" and "the durable store could not be READ, so the run may still be parked and resumable" (the engine reports the second at error for exactly that reason). The response carries a notice naming both readings and telling the operator to confirm the run's state. The two arms carry different notices, pinned, so the condition can never be collapsed.

Who asked, and why

requestedBy is filled from the authenticated caller, never from the body, and the closed { reason? } envelope refuses the key by name — a wire-settable requestedBy would let one operator write another's name into the trace that records who re-armed a terminally-failed run, which is the one field that record exists for. A caller who spells it gets a loud refusal instead of the silent impression that it took.

cancel's reason is relayed verbatim — the contract calls it "why, in the operator's words", so the door does not decorate it. ⚠️ Stated rather than hidden: cancelRun has no requestedBy slot (only the repair verb does), so what the terminal record carries about who cancelled is whatever the operator wrote. Inventing a slot here would be a second name for a contract parameter that does not exist.

Zone 2 — who inside the engine calls these two verbs today

Re-measured on this tree, confirming the contract review's reading (comment 5565076877):

  • cancelRun has an in-process caller, but not through IAutomationServiceplugin-approvals carries its own duck-typed ApprovalResumeSurface for the revise-window recall. That caller is isSystem and never speaks HTTP, so this door's gate does not touch it.
  • restoreConsumedSuspension has zero non-test callers in the repo.
  • The automation slot holds the concrete engine, so any getService('automation') holder could already call both at runtime — they were invisible in the type system, not unreachable. ⇒ This PR turns a runtime-reachable, type-invisible capability into a contract-visible one with a gate in front of it.

What this deliberately does not ship

  • No lister (ruling ②). A lister backed by the engine's in-memory journal answers zero in any process that did not itself strand the run, and a confident zero is the failure the parent card is about. A correct one is a query over sys_automation_run terminal rows and is its own card.
  • No CLI command — the ruling declines one for want of pull. Both ledger rows are server-only and say so as scope, not as a closed question: this card declares no client method and implies none; adding one reclassifies the row to sdk. (gap was not available: the ledger's gap ratchet is <= 0 and raising it "demands an explicit, reviewed decision".)
  • No cancel-side compare-and-set (ruling ①), and no change to the contract sentence feat(spec): declare the operator run-lifecycle verbs cancelRun and restoreConsumedSuspension on IAutomationService (contract half of the #13953 ruling) #16563 landed.
  • No relaxation of any existing boundary.

Route shape — a note for review

The ruling wrote POST /automation/runs/:id/cancel. The routes here are flow-scoped (/:name/runs/:runId/...), matching the existing run family (resume, screen, getRun) — the same class of shorthand as the ruling's cancelRun(runId), which comment 5570713718 already corrected to the engine's real signature. A flat /automation/runs/... shape would also have to be matched as parts[0] === 'runs', which shadows a flow literally named runs — the exact route-order hazard this file's header warns about for /actions, /connectors and /_status.

验收备注

  • ⭐ SUPERSEDED by the CI repair round: check:type-check-debt and check:dual-build-cjs-loads were PREREQUISITE NOT MET (exit 3) at c9c1c6d2b and are measured green at 34feecba7 — the second only after building the seven packages its refusal names, the first without the OOM once the closure build existed. Both are inside the 86/86 reconciliation below; no PREREQUISITE NOT MET result is reported here as a measurement.
  • Dogfood run, recorded. @objectstack/dogfood is run exactly as CI shards it, at 34feecba7: --shard=1/3 45 files / 345 tests passed · --shard=2/3 45 files / 299 passed, 1 skipped · --shard=3/3 43 files passed and 1 skipped file / 405 passed, 2 skipped — 134 files, 1052 tests, 0 failures, the whole suite. No test was skipped, disabled, .skip-ed or quarantined to get there.
  • Noted, not filed — already tracked as dogfood: the enterprise-organizations declared-unresolvable CONTROL flips red once packages/plugins/organizations is BUILT — its premise died when the package moved to open core #16539: packages/qa/dogfood/test/enterprise-organizations.test.ts's declared-unresolvable CONTROL goes red on any checkout where packages/plugins/organizations has been BUILT. Measured here in both legs at this head, on a file this PR does not touch: with that dist present the file reads Tests 1 failed | 8 passed (9), with it parked aside Tests 9 passed (9). Mechanism, measured inside vitest: vitest exports NODE_PATH containing node_modules/.pnpm/node_modules, so createRequire from the /tmp "declared but not installed" fixture host resolves the package anyway and the probe answers available: true. CI's dogfood job is green only because it builds dogfood's dependency closure, which excludes that package — so the dogfood numbers above were taken with that dist absent, i.e. in the state the dogfood CI job actually runs in. It surfaced here only because check:dual-build-cjs-loads demanded a fuller build.
  • Noted, not filed: cancelRun's signature has no requestedBy slot while restoreConsumedSuspension's does, so the cancel door cannot record who asked except through the operator's own reason text. An asymmetry in the engine's shape, not a defect in either verb.
  • Noted, not filed: the ADR-0112 refusal-code ledger holds none of the engine's eight restore codes, which is why they ride details.refusal here. Registering them is the spec card that would close the vocabulary.

Tests

  • packages/runtime/src/domains/automation-run-lifecycle-door.test.ts38 new tests, driving the real HttpDispatcher (real router, real gate stack, real envelope helpers).
  • pnpm --filter @objectstack/runtime test241 files / 3378 tests, all pass.
  • pnpm --filter @objectstack/runtime typecheck — green; the new test file is proven inside the type-check program (tsc -p tsconfig.test.json --listFiles: 1 hit, positive control 1, negative control 0).
  • Cross-package ledger consumers (source-relative imports of ROUTE_LEDGER from packages/client) — 3 files / 8 tests, pass.
  • eslint . --no-inline-config over the whole repo — exit 0, 6347 files, 0 errors, 0 warnings (measured at c9c1c6d2b; exit code captured before any pipe).
  • dispatch-gates --ran reconciliation: 86 derived families, 86 run, 0 unrun.

CI repair round — re-measured at 34feecba7

The first push of this branch was red on Dogfood Regression Gate (aggregate plus shards 1/3 and 3/3) for two independent reasons, only one of which was a pinned expectation. Both are repaired in 0765b1436, and origin/main was merged in as a merge commit (never a rebase; no force-push, no amend) before the final re-derivation.

what was red what it actually was the fix
route-ledger-live-mount-parity.dogfood.test.ts (shard 1/3) A real defect, not a stale pin. Both rows were LEDGERED BUT NOT MOUNTED: registerAutomationRoutes in dispatcher-plugin.ts mounts one literal registration per automation route and gained neither, so each URL answered Hono's notFound at runtime while every ledger-reading guard passed it — the exact class #7526's gate exists to catch. two literal server.post registrations beside the resume arm. They carry no authority logic: the gate stays the single isRunLifecycleWrite predicate in domains/automation.ts.
authz-probe-blind-spot.test.ts (shard 3/3) The census pins the runtime ledger's population and read expected 82 to be 80 — the two new rows. the census row moved to the newly measured 82 / 82; blindSpot stays 0 and keys stays 21 because both rows carry domain: '/automation', an already-classified key. The three co-located prose claims of the same number were refreshed.

The ruled shape did not drift. The repair touches dispatcher-plugin.ts and two dogfood measurement files only — packages/runtime/src/domains/automation.ts is byte-identical to the reviewed head — so the door is still gated on the ADR-0095 PLATFORM_ADMIN posture rung, unconditionally, isSystem-bypass only, never a positions[] entry, never posture-conditional; no lister, no CLI command, no cancel-side compare-and-set, and nothing keys a once-only side effect off cancelRun's return.

Re-measured at 34feecba7, after the merge:

  • pnpm --filter @objectstack/dogfood whole suite, sharded as CI shards it — 134 files / 1052 tests, 0 failures (the per-shard split is in 验收备注 above).
  • pnpm --filter @objectstack/runtime test243 files / 3405 tests, all pass.
  • pnpm --filter @objectstack/runtime --filter @objectstack/dogfood typecheck — exit 0.
  • eslint . --no-inline-config --format json over the whole repo — exit 0, 6357 files, 0 errors, 0 warnings, not narrowed.
  • dispatch-gates --commands re-derived after the merge (the first derivation printed STALE TREE, 41 commits behind, which is why the merge happened): 86 families, all 86 run, all exit 0, reconciled — 86 derived famil(ies) accounted for — 86 run, 0 NOT-MEASURED.
  • Every heavy run went through scripts/pm/os-verify-lock.sh; the verdict quoted is its VERDICT command-exit line, never a bare $?.

Ablation — every pin proved able to fail

Three mutations, each with an on-disk landing proof (grep -c on both the removed anchor and the injected text, plus a git hash-object differing from the baseline blob), a trap … EXIT INT TERM restore verified by git diff HEAD being empty and the blob hash returning to baseline, and a green control on the unmutated tree.

mutation result
gate also admits a minted positions[] name beside the rung 3 failed / 35 passed
absent cancelRun answers 200 instead of 501 1 failed / 37 passed
unknown refusal code absorbed into 409 instead of 500 3 failed / 35 passed
control (unmutated) 38 passed

Negative claims, each with a firing positive control

  • "No CLI command for either verb": git grep -E "cancelRun|restoreConsumedSuspension|restore-suspension" origin/main -- packages/cli0 files. Positive control, same matcher form on symbols the CLI provably uses (listFlows|registerFlow|getFlow) → 4 files, fires.
  • "No REST route existed": git grep -E "'(cancel|restore-suspension)'" origin/main -- packages/runtime/src packages/rest/src0. Positive control, same form on '(resume|screen)' in the same file → 2, fires.

Changeset

.changeset/automation-run-lifecycle-operator-door.md, @objectstack/runtime minor. Per AGENTS.md:1029 read verbatim: skip-changeset is for a diff that publishes nothing from any released package, and adding two externally reachable doors to a released package publishes something. Not major — purely additive, no existing route, refusal, capability or contract member changes behaviour. No ADR-0087 marker: check:adr-0087-registration requires one only on a declared-breaking changeset, and this one is minor (the gate ran green).

Clause-②: yes

The deliverable adds externally reachable entry points on a published surface, so it widens the accept set by definition. needs:contract-review is hung on this PR as well as on the issue. ⚠️ The contract-review tier is quota-exhausted, so this stays in draft until it returns.


🤖 Generated with Claude Code

https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37

`AutomationEngine`'s `cancelRun` (ADR-0044) and `restoreConsumedSuspension`
(#13909) had no operator door: no REST route, no CLI command, and until
`IAutomationService` declared them, no way for a contract-holding host to call
them either. Add `POST /automation/:name/runs/:runId/cancel` and
`POST /automation/:name/runs/:runId/restore-suspension`, behind the ADR-0095
`PLATFORM_ADMIN` rung, required unconditionally.

Fail-closed on every axis the contract cannot close on its own: an absent
optional member answers 501 rather than a 200, an unrecognised restore refusal
code answers 500 rather than a 409 that would claim a diagnosis, and the cancel
door's `false` names both readings instead of reading as a clean no-op. No
once-only side effect keys off `cancelRun`'s non-exclusive `true`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…evation read

`check:doc-authoring` refuses tracker ids inside runtime prose strings — a route
ledger `note` reaches operators and generated surfaces, none of whom can resolve
`#NNNN`. The ids move to adjacent `//` comments, where the reader who can resolve
them already is.

`check:check-system-context-census` requires every `isSystem` read to carry an
anchor on the census page. The new gate's `isSystem` bypass is anchored on the
automation row and the page's seven census-derived counts move with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 41 documentable anchor(s).

32 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json ce8bfc9d651676c80f3c5438bc2db0e86d0560cb.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ce8bfc9d651676c80f3c5438bc2db0e86d0560cbpackageMentionDocs.

Which tree this was computed on

This run read content/docs from a89f26ce9c5de3f76a8a0e9c432e3408a6f84255 — the merge of head 34feecba75aea132ba43345dac3539e007200059 into base ce8bfc9d651676c80f3c5438bc2db0e86d0560cb, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a89f26ce9c5de3f76a8a0e9c432e3408a6f84255 && git checkout a89f26ce9c5de3f76a8a0e9c432e3408a6f84255
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ce8bfc9d651676c80f3c5438bc2db0e86d0560cb 34feecba75aea132ba43345dac3539e007200059 && git checkout -B drift-repro ce8bfc9d651676c80f3c5438bc2db0e86d0560cb && git merge --no-ff 34feecba75aea132ba43345dac3539e007200059

node scripts/docs-audit/affected-docs.mjs --json ce8bfc9d651676c80f3c5438bc2db0e86d0560cb

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs ce8bfc9d651676c80f3c5438bc2db0e86d0560cb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Contributor

Contract review (CONTRACT_REVIEW_TIER, isolated seat) — PR #16755 @ c9c1c6d2b

Verdict: CHANGES REQUIRED

Ruling implemented: partially — the gate, the fail-closed arms, the refusal mapping and the scope exclusions match the ruling; but the two doors are not mounted on the live HTTP server (F1), so on the wire the ruling's "REST doors" do not yet exist. The head is also red on the Dogfood Regression Gate for two reasons this diff introduces (F1, F2), which the PR body does not report (F3).

Everything below was measured independently: PR head fetched into a throwaway ref, diffed against merge-base(head, origin/main) = 7c12e475e; PR body claims were not trusted.

1. The ruling, and whose it is

No comment on #13953 is titled ## Ruling recorded verbatim. The operative ruling is comment 5548737008 (os-warren, "Director seat, summon #14", 2026-09-05), titled and quoted verbatim:

Maintainer ruling recorded — A, with the minimal permission model: cancelRun and restoreConsumedSuspension are declared on IAutomationService and get REST doors; both are platform-operator verbs gated on the existing platform_admin position (no new permission type, no per-run ownership — a run belongs to the environment, not a user); listing and acting go through sys_automation_run (the persistent face), never engine memory

[…] Provenance: maintainer, live PM chat, decision batch #42 (item 3, presented with the recommendation A and the permission model above), verbatim reply 「13753 我让别人处理了,其他同意」. […]

Execution, contract-first (per the seat's own (b)): (1) domain:spec — declare cancelRun(runId) and restoreConsumedSuspension(runId) on IAutomationService in packages/spec/src/contracts/automation-service.ts, with the persistent-face statement in their docblocks (Clause-②: yes, @objectstack/spec minor); (2) domain:services — implementations + REST POST /automation/runs/:id/cancel and POST /automation/runs/:id/restore-suspension behind the platform_admin check, refusals in the ADR-0112 envelope (minor, needs:contract-review). ⛔ No CLI command in this card (no pull).

Whose: a seat-recorded maintainer ruling — the director seat recorded the maintainer's batch-#42 reply; the maintainer's verbatim text is a blanket 「其他同意」 to the seat's recommendation, so the permission-model wording is the seat's, approved by the maintainer. It is not a seat's own ruling. Step (1) landed as PR #16563 (card #16495, merged 2026-09-07); this PR is step (2). The dispatch comment 5578166523 (os-trump, services PM seat) added two seat-level constraints: ① no cancel-side compare-and-set, ② no lister — both honoured.

Does the PR implement exactly it? Semantically yes, with two stated, justified deviations: (a) route shape is flow-scoped /automation/:name/runs/:runId/{cancel,restore-suspension} (matching resume/screen/getRun), not the ruling's flat /automation/runs/:id/… — the flat form would be matched as parts[0]==='runs' and shadow a flow named runs; (b) "the existing platform_admin position" is read as the ADR-0095 D2/D3 posture rung ec.posture === 'PLATFORM_ADMIN' rather than positions.includes('platform_admin') (the #15981 correction, same as activation-gate.ts:228). Both are the ruling's intent. What is not delivered is wire reachability (F1).

2. Files in the diff vs merge-base (5 files, +1216/−10)

file kind
.changeset/automation-run-lifecycle-operator-door.md new
content/docs/permissions/system-context.mdx census counts 106→107 for the new isSystem read
packages/runtime/src/domains/automation-run-lifecycle-door.test.ts new, 38 tests
packages/runtime/src/domains/automation.ts +602: predicate, gate, body validator, refusal table, two route arms
packages/runtime/src/route-ledger.ts +2 rows, server-only

Governed paths touched: NO (none of docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/**). ⇒ no maintainer-only-merge trigger from the governed surface; the "Governed Surface Queue Guard" check is green.

3. The two doors (Clause-② widening)

axis cancel restore-suspension
route POST /automation/:name/runs/:runId/cancel POST /automation/:name/runs/:runId/restore-suspension
domain file packages/runtime/src/domains/automation.ts (arm at ~L2325) same (~L2385)
ledger row route-ledger.ts:396, disposition: 'server-only', note present, no responseSchema :402, same
spec contract IAutomationService.cancelRun?(runId, reason?) — declared on origin/main (automation-service.ts:739) restoreConsumedSuspension?(runId, { requestedBy?, reason? }) (:823) — declared
gate refuseUngrantedRunLifecycleWrite: isSystem → pass; posture === 'PLATFORM_ADMIN' → pass; everything else 403 (absent context, absent posture, TENANT_ADMIN, minted positions[] entry) same predicate isRunLifecycleWrite, same gate
ADR-0112 403 PERMISSION_DENIED via details.code promotion; 501 NOT_IMPLEMENTED (status-derived, ledger row error-code-ledger.zod.ts:1120) + 404/503/409/500 by refusal code; engine code rides details.refusal, never details.code; pinned that error.code for a 409 is RESOURCE_CONFLICT, not RUN_COMPLETED
tenancy verb takes runId only, no org argument — identical to the existing resume arm (automationService.resume(parts[2], signal)) same
  • 「以协议为基准」: both verbs are spec-declared members (PR feat(spec): declare the operator run-lifecycle verbs cancelRun and restoreConsumedSuspension on IAutomationService (contract half of the #13953 ruling) #16563) — the door binds to the contract, not to the concrete engine. The wire payloads ({ runId, cancelled, notice }, { runId, restored, reason }) have no packages/spec/src/api schema; the ledger's own rule says an absent responseSchema is "undeclared, not a defect" and forbids filling it ahead of conformance coverage, so this is recorded as an observation (F8), not a block.
  • ADR-0066 D4 / capability: the gate is a posture rung, not a named capability, exactly as the ruling asked ("no new permission type"); ADR-0095 D3 derives PLATFORM_ADMIN from the unscoped admin_full_access grant (hasPlatformAdminStandinggrants.posture === 'PLATFORM_ADMIN'), so the check is capability-derived. Verified present and unconditional; it sits ahead of deps.getService(...) and ahead of body validation, so nothing is cancelled before a refusal and a 501-vs-403 does not fingerprint the deployment.
  • ADR-0105 tenancy: the run lookup is not organization-scoped in the engine (loadSuspendedRunStrict(runId)store.load(runId)), and the door adds no scoping. Under this ruling that is by design — "no per-run ownership, a run belongs to the environment" — and the gate refuses every rung below PLATFORM_ADMIN (the rung that crosses the tenant wall per ADR-0095 D1/D2), so no tenant-scoped principal reaches the verb at all. Pinned: TENANT_ADMIN → 403, minted positions:['platform_admin'] + TENANT_ADMIN → 403, no-posture → 403, verb never called. Accepted as the ADR-0105-consistent shape for an environment-level verb; noted as F6 for the record.
  • parts[0] === 'trigger' exclusion matches the toggle/clone arms; pinned (trigger/runs/run_7/cancel executes the flow, no gate, no cancel).
  • requestedBy is filled from executionContext.userId and refused by name in the body; pinned.

4. Route-envelope gate, ledger conformance, SDK

  • No new module under packages/runtime/src/domains/; automation.ts is already in DISPATCHER_DOMAINS (scripts/check-route-envelope.mjs:442, handBuilt: 0). check:route-envelope runs inside "Lint & Repo Gates" (lint.yml:2567) — green on head.
  • route-ledger.conformance.test.ts hygiene: non-sdk rows must carry a note — both do; gap ratchet <= 0 untouched. Client-side ledger consumers (packages/client/src/*route-ledger*.test.ts) run in Test Core — green.
  • Live-mount parity is red — see F1. The ledger says the routes exist; the router does not serve them.
  • Authz blind-spot census is red — see F2.
  • SDK client method: not added, not deferred with a card. Rows are server-only with prose saying "adding one reclassifies this row to sdk". The ruling declines a CLI command; it is silent on the JS SDK. See F4.

5. Changeset

.changeset/automation-run-lifecycle-operator-door.md"@objectstack/runtime": minor. Correct level: two new externally reachable doors on a released package = additive widening ⇒ at least minor (Clause-② yes ⇒ patch forbidden by the #16055 level axis; major forbidden by the launch-window guard). "Check Changeset" green on head. Fixed-group lockstep: FROM 17.3.0 TO 17.4.0 for every package in the fixed group. No ADR-0087 marker needed (not a declared-breaking changeset). Body of the changeset is accurate to the diff.

6. Tests

automation-run-lifecycle-door.test.ts — 38 tests, driving HttpDispatcher.handleAutomation directly with a stub automation service. Per verb:

pin cancel restore
happy path (operator, 200, verb called once) ✅ (and runId echoed is the path's, not the service's)
unauthorised caller refused (user / tenant admin / minted position / no posture) ✅ 403, verb not called
cross-tenant run id refused ✅ by construction — every non-PLATFORM_ADMIN rung is refused before the id is read; no per-org run fixture exists because the model has no per-run ownership ✅ same
wrong-state transition refused cancelled:false → 200 + two-reading notice (the contract's idempotent-success shape); the true/false notices differ — pinned ✅ 8 refusal codes → 404/503/409, unknown code → 500, restored:false without code → 500, non-object result → 500
absent member → 501, handled:true, never 200, only after the gate
closed body envelope, requestedBy refused by name, bodyless accepted
no side effect keyed off cancelRun's return (announceKernelEvent never fired)

No .skip / .only / .todo in the new file (grepped). Typecheck coverage: "Type Check · workspace/source/consumer gates" all green on head. ⚠️ The suite proves the dispatcher answers; it cannot prove the server mounts the route — which is exactly the gap F1 names.

7. CI on head c9c1c6d2b (38 check runs)

  • 30 success · 3 failure · 5 skipped (Packed-tarball ×2, Auto Label, Check PR Size, Console Pin Gate — all expected skips).
  • ⛔ Failures: Dogfood Regression Gate (1/3)route-ledger-live-mount-parity.dogfood.test.ts (F1); Dogfood Regression Gate (3/3)authz-probe-blind-spot.test.ts, 2 tests (F2); and the rollup "Dogfood Regression Gate".
  • mergeable_state: unknown on two reads (GitHub has not computed it; draft PR).
  • Distance: merge-base 7c12e475e is 24 commits behind origin/main (78bc4ad58 at review time; the PR's recorded base was 8ccf7a1df). Not a conflict indicator, but a re-merge is due when F1/F2 land.

Findings

F1 — ⛔ BLOCKING — the two routes are ledgered but not mounted; they 404 on the live server.
packages/runtime/src/dispatcher-plugin.ts registers the automation run family per route (:1527 GET …/runs, :1536 GET …/runs/:runId, :1547 POST …/runs/:runId/resume, :1556 GET …/runs/:runId/screen); there is no wildcard /automation/* mount and this diff adds no registration for …/cancel or …/restore-suspension. The dogfood live-mount parity gate says so verbatim: "POST /automation/:name/runs/:runId/cancel — LEDGERED BUT NOT MOUNTED. The live router answers nothing … this URL 404s at runtime while every ledger-reading guard passes it" (same for restore-suspension). The 38 unit tests are green because they call dispatcher.handleAutomation(...) directly and never touch the server. As shipped, an operator holding only HTTP — the ruling's whole premise — still cannot reach either verb.
Expectation: register both POST mounts in dispatcher-plugin.ts beside the resume registration, forwarding req.body / req.query / { request: req } identically; re-run pnpm --filter @objectstack/dogfood test and show the parity test green; state the mount site in the PR body.

F2 — ⛔ BLOCKING — the authz blind-spot census pins the ledger at 80 rows; it is now 82.
packages/qa/dogfood/test/authz-probe-blind-spot.census.ts:294-302 (file: 'packages/runtime/src/route-ledger.ts', population: 80, controls: { "route: '": 80, "domain: '": 80, … }) and the second row at :317 (population: 80) fail authz-probe-blind-spot.test.ts ("population, reach and blind spot are unchanged" and "every positive control is still present"). This census exists to force a classification of every new route's authz reach (#13260), not a number bump.
Expectation: re-derive the census per that file's own discipline (population 82; classify both rows — gated, PLATFORM_ADMIN-only, isSystem bypass — into reachable/blind-spot honestly), update the controls, and refresh the "80 rows / 21 domains" prose in authz-conformance.matrix.ts:28, authz-ledger-population.baseline.ts:62, authz-probe-blind-spot.census.ts:109.

F3 — PR body accuracy. The body states "dispatch-gates --ran reconciliation: 86 derived families, 86 run, 0 unrun" and names only two NOT-MEASURED gates; the Dogfood Regression Gate is red on head for causes this diff introduces. Either the dogfood suite is not in the derived family set for a ledger + dispatcher-domain change (a gate-derivation gap worth a card), or it was not run. Expectation: the 验收备注 records the dogfood run result on the fixed head, and says which.

F4 — SDK deferral has no card. Both rows are server-only; the ruling declines a CLI command and says nothing about the JS SDK client. The note's "adding one reclassifies this row to sdk" is scope prose, not a tracked decision. Expectation: file a follow-up card for client.automation.cancelRun / restoreSuspension (or record a maintainer decision that these stay server-only) and cite it from both ledger notes. Non-blocking.

F5 — the :name segment is not validated against the run. Both arms call the verb with parts[2] and never check that the run belongs to flow parts[0]; any flow name reaches any run id. This is the existing convention of resume/screen/getRun, so it is consistent, but the flow-scoped shape implies a scoping the door does not enforce. Expectation: say so in the ledger notes (one clause), or leave as-is with a stated reason. Non-blocking.

F6 — tenancy scoping is by rung, not by lookup (recorded). See §3. Consistent with the ruling's "no per-run ownership". No change requested; if ADR-0105 ever moves run rows behind an organization wall for platform admins, this door inherits whatever the engine does. Non-blocking.

F7 — docs route tables not updated (non-governed). content/docs/automation/flows.mdx:1731-1732 and packages/services/service-automation/README.md:296-297 list the run family (resume, screen) without the two new doors; the drift bot flagged 30 pages. Expectation: add the two rows to those tables in this PR (not content/docs/releases/**, which stays untouched). Non-blocking.

F8 — wire payloads undeclared in spec (observation). { runId, cancelled, notice } and { runId, restored, reason } have no @objectstack/spec/api schema and the ledger rows carry no responseSchema. Permitted by the ledger's own rule ("absent means undeclared … do not fill ahead of conformance coverage"); noting it so the #3877 Stage-D ratchet has a row to pick up. Non-blocking.

Verified as claimed (no finding)

Spec declares both verbs on origin/main; gate is unconditional and ahead of the service probe; isSystem bypass matches the family (system-context.mdx row 57 and census 106→107 updated); absent-member → 501 never 200 never handled:false; refusal codes never promoted into error.code; requestedBy never wire-settable; no once-only side effect; no lister; no CLI; no CAS; no existing predicate/refusal/capability relaxed (diff is purely additive on automation.ts); changeset level and package correct; no governed path touched; no .skip/.only/.todo.

Merge disposition: not mergeable as-is (F1, F2 → CI red, and F1 means the ruling's deliverable is not on the wire). Re-review on the fixed head is short: the parity and census tests are the acceptance for F1/F2.


Generated by Claude Code

…lready declares

`POST /automation/:name/runs/:runId/cancel` and `.../restore-suspension` were
declared in `route-ledger.ts` and answered by the `/automation` domain handler,
but nothing registered them on the HTTP router: `registerAutomationRoutes` in
`dispatcher-plugin.ts` mounts one literal registration per automation route and
gained neither. Both URLs answered Hono's `notFound` at runtime while every
ledger-reading guard passed them — the class the route-ledger to live-mount
parity gate (#7526) exists to catch, and the class it caught this in.

Two literal `server!.post` registrations, mirroring the `resume` arm beside
them. They carry no authority logic: the gate stays the single
`isRunLifecycleWrite` predicate in `domains/automation.ts` on the ADR-0095
posture rung, required unconditionally.

The dogfood authz probe blind-spot census moves with the population it
measures: the runtime ledger reads 82 rows instead of 80. Both new rows carry
`domain: '/automation'`, an already-classified key, so `reachable` moves with
`population`, `blindSpot` stays 0 and the key count stays 21.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
`authz-ledger-population.baseline.ts`'s header still stated the runtime route
ledger at 80 rows. The number moved to 82 with the two operator run-lifecycle
rows; the date the measurement was taken is kept, because the key arithmetic
the sentence goes on to state (40 minted, 6 classified, 34 baselined) is the
part that did not move — both new rows sit under the already-classified
`/automation` domain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37

Copy link
Copy Markdown
Contributor

Contract review (claude-fable-5-1, isolated seat) — PR #16755 @ 34feecba7

Verdict: PASS

Re-review of a moved head. Prior review: 5580584918 at c9c1c6d2b, CHANGES REQUIRED on F1/F2. Everything below was re-measured on a throwaway ref (refs/review/16755 = 34feecba7, merge-base with origin/main = e08892dac); nothing in the PR body was trusted.

Ruling implemented: yes. The operative ruling is 5548737008 (os-warren, director seat, 2026-09-05; provenance: maintainer batch #42, verbatim reply 「13753 我让别人处理了,其他同意」). Its execution step (2), quoted verbatim once:

(2) domain:services — implementations + REST POST /automation/runs/:id/cancel and POST /automation/runs/:id/restore-suspension behind the platform_admin check, refusals in the ADR-0112 envelope (minor, needs:contract-review). ⛔ No CLI command in this card (no pull).

The two justified deviations recorded last time (flow-scoped /:name/runs/:runId/… shape; platform_admin read as the ADR-0095 PLATFORM_ADMIN posture rung, never positions[]) are unchanged. What was missing at c9c1c6d2b — wire reachability — is now delivered.

The repair round, in full

git diff c9c1c6d2b..refs/review/16755 --stat is 179 files, because eb7f89b33 merges origin/main in (a merge commit, not a rebase). Restricted to the PR's own file set, the PR-attributable delta is 5 files (+59/−12): packages/runtime/src/dispatcher-plugin.ts (+33), authz-probe-blind-spot.census.ts, authz-conformance.matrix.ts, authz-ledger-population.baseline.ts, and content/docs/permissions/system-context.mdx row 54 — the last one is origin/main's #14503 edit arriving through the merge (identical to origin/main), not this PR's. The two PR commits after c9c1c6d2b are 0765b1436 (mount + census + matrix prose) and 34feecba7 (baseline prose). packages/runtime/src/domains/automation.ts: git diff c9c1c6d2b refs/review/16755 -- <path> is 0 bytes. The ruled shape did not drift. Nothing unexplained.

Prior findings

# status evidence
F1 (blocking) — routes ledgered but not mounted closed dispatcher-plugin.ts:1571 server!.post(\${base}/automation/:name/runs/:runId/cancel`…)and:1580 …/restore-suspension, beside resumeat:1547; each forwards req.body, req.query, { request: req }byte-for-byte as theresumearm does and contains no authority logic (a comment says so; the code agrees — try/dispatch/sendResult/errorResponse only). The gate remains the singleisRunLifecycleWrite predicate (domains/automation.ts:594, read at :1590, :2326, :2381). automation.tsbyte-identical toc9c1c6d. Dogfood 1/3 on head (job 102038734987): Test Files 45 passed (45) · Tests 345 passed (345), shard attested green; aggregate Dogfood Regression Gate` success.
F2 (blocking) — census pinned at 80 closed authz-probe-blind-spot.census.ts:311-316 now population: 82, reachable: 82, blindSpot: 0, keys: 21, controls 82/82/2. Re-counted on head: 82 route: ', 82 domain: ', 21 distinct domains; origin/main and the merge-base both read 80, so the +2 is entirely this PR's and the pin survives the merge. Prose refreshed at census.ts:109, matrix.ts:28, baseline.ts:62-64. Dogfood 3/3 on head (job 102038735165): 43 passed | 1 skipped (44) · 405 passed | 2 skipped (407). Honesty of the classification: by the census's own populationRule ("reachable = rows carrying a domain"), the two rows count as reachable because /automation is an already-classified key (matrix row anonymous-deny-automation, covers: dispatcher-domain:route-ledger.ts:/automation) — reach is domain-level, and the file says so ("the per-route rows being documentation"). No dogfood test drives either new route (git grep over packages/qa/dogfood: only the census comment names them); the per-route 403/501/409 behaviour is pinned only by the 38 runtime unit tests. That is honest within the census's stated contract, so no finding — see observation N3.
F3 — body accuracy (dogfood not reported) closed, one clause short 验收备注 now records the run per shard; the numbers match the CI job logs exactly (1/3: 45/345; 3/3: 43+1/405+2). It does not say which of the two causes: the answer is a gate-derivation gap — dispatch-gates.mjs derives no family for the dogfood workflow job on a packages/runtime/** change (86/86 derived green at c9c1c6d2b while dogfood was red) — and that gap is already carded as #16285 (open, pm:queue, "any package whose CI coverage is a workflow job rather than a gate script is invisible to it"). No new card needed; a cite is owed (N1).
F4 — SDK deferral has no card open, non-blocking No card found (search: only #13953/#16495/#15222). Ledger notes carry scope prose only ("this card declares no client method and implies none; adding one reclassifies this row to sdk"); no maintainer decision recorded either. Residual, carried as N4.
F5 — :name not validated against the run open, non-blocking Both arms still call the verb with parts[2] (:2333, :2389) and never read parts[0] against the run; neither ledger note carries the one clause asked for. Consistent with resume/screen/getRun. Residual, carried as N5.
F6 — tenancy by rung, not lookup accepted-as-recorded Unchanged; gate refuses every rung below PLATFORM_ADMIN before the id is read.
F7 — docs route tables not updated open → finding again (N2) content/docs/automation/flows.mdx:1730-1732 and packages/services/service-automation/README.md:295-297 still list resume / screen / detail only; neither file is in the diff. Non-governed docs.
F8 — wire payloads undeclared in spec observation, unchanged No responseSchema on either row; permitted by the ledger's own rule.

Governed paths, changeset, CI, distance

Governed paths touched: NO — the full diff vs merge-base (9 files) contains none of docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/**; "Governed Surface Queue Guard" green.

Changeset: .changeset/automation-run-lifecycle-operator-door.md"@objectstack/runtime": minor, no BREAKING, no ADR-0087 marker — correct for two additive doors on a released package. "Check Changeset" green (twice, incl. the 14:39 re-run).

CI on head (41 check runs): 36 success · 0 failure · 5 skipped (Packed-tarball ×2, Console Pin Gate, and the 14:39 label-event re-run's Auto Label / Check PR Size — all expected). All three dogfood shards plus the rollup green; Lint & Repo Gates (carries check:route-envelope, check:partof-closing-keyword) green. mergeable_state: clean.

Commit trailers: none of the 4 non-merge commits (c67f66f93, c9c1c6d2b, 0765b1436, 34feecba7) carries Fixes/Refs/Part of/Closescheck:partof-closing-keyword RULE 2 ("no commit may carry a card-relation trailer at all") is clean; the body's Fixes #13953 is the only carrier (RULE 1/3 clean). Advisory: the squash message must equal the PR body at merge.

Distance: merge-base e08892dac is 38 commits behind origin/main (21aabbc7b). Not a conflict indicator; route-ledger.ts on origin/main still reads 80 rows / 21 domains, so the census pin does not go stale on merge. No re-merge needed.

Clause-②: yes (two new externally reachable doors on the published HTTP surface).

New findings (repair round)

N1 — low, body accuracy. The CI-repair section says the repair "touches dispatcher-plugin.ts and two dogfood measurement files only"; it is three (census.ts, matrix.ts, baseline.ts — the os-dev report on the card already says three). Same section: the dogfood-vs-dispatch-gates divergence is a derivation gap, not a run omission, and is tracked as #16285. Expectation: one-line fix to "three", and cite #16285 in 验收备注 so the next reader does not re-derive it. Can be done in the body alone; no code change.

N2 — low, non-governed docs (F7 re-raised). flows.mdx:1730-1732 and service-automation/README.md:295-297 route tables lack the two doors; an operator reading either page still cannot discover the verbs the ruling exists to expose. Expectation: add the two rows to both tables — either in this PR or as an immediately-following docs-only PR; content/docs/releases/** stays untouched either way. Non-blocking.

N3 — observation, no change requested. Authz reach for the two doors is proven at domain level (census) and at unit level (38 runtime tests driving handleAutomation), but no dogfood probe hits the live URLs with a non-PLATFORM_ADMIN principal. Given F1's history (all 38 unit tests green while the URLs 404'd), a single live-server probe asserting 401 anonymous / 403 tenant-admin / 200-or-501 platform-admin at each door would close the last seam between "the handler refuses" and "the server refuses". A candidate row for whoever picks up #16285 or the authz-probe follow-ups.

N4 — carried from F4, non-blocking. SDK deferral still has no card and no recorded decision. Expectation: file client.automation.cancelRun / restoreSuspension as a follow-up card (or record "stays server-only" from the maintainer) and cite it from both ledger notes.

N5 — carried from F5, non-blocking. Expectation: one clause in each ledger note stating that :name is not checked against the run (matches resume/screen/getRun), so the flow-scoped shape is not read as a scoping the door enforces.

N6 — housekeeping. The PR is still draft. The director seat must undraft before the merge queue will take it.

Verified as claimed (no finding)

Mounts forward identically to resume; no authority logic in the plugin; automation.ts byte-identical; census 82/82/0/21 re-counted; the three prose sites refreshed; per-shard dogfood numbers equal the CI logs; check-test-completeness and shard attestation green on 1/3 and 3/3; no .skip/.only/.todo added; no governed path; changeset level and package correct; no commit trailers; nothing outside dispatcher-plugin.ts + three dogfood files in the PR-side repair delta.

Merge disposition: F1 and F2 are closed with independent evidence; the remaining items (N1, N2, N4, N5) are non-blocking and non-governed. Mergeable once undrafted; the docs rows (N2) and the two body clauses (N1) are cheap to fold in before the squash if the director prefers a single landing.

Maintainer-only merge: no — non-governed, additive, minor, no !; the director seat can land it.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 8, 2026 15:02
@os-zhuang
os-zhuang enabled auto-merge September 8, 2026 15:02
@os-zhuang
os-zhuang disabled auto-merge September 8, 2026 15:02
@os-zhuang
os-zhuang enabled auto-merge September 8, 2026 15:03
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit 44c849c Sep 8, 2026
46 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-13953-run-lifecycle-operator-door branch September 8, 2026 15:27
os-trump pushed a commit that referenced this pull request Sep 8, 2026
…alytics-native-sql-authz

#16755 landed on main and reached the one file both diffs touch,
content/docs/permissions/system-context.mdx, exactly as the cross-PR pile-up
patrol predicted on this PR. The merge driver declined to defer it (MIXED — a
generated half plus hand-written prose) and text-merged it into a conflict.

Resolved by hand in the only direction that keeps both sides' prose: main's
enriched automation row is adopted whole, including its fourth anchor
`#refuseUngrantedRunLifecycleWrite` and the operator run-lifecycle sentence,
carried onto this branch's row number (this branch inserted a row upstream in
the same table, so every row below it is offset by one). The census counts are
re-derived from the merged tree in the follow-up commit, per the driver's own
instruction and the regen ordering rule — never regenerated while in MERGE
state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
os-trump pushed a commit that referenced this pull request Sep 8, 2026
…tree

The merge of #16755 is the case the census gate exists to catch and the merge
driver cannot: both sides had independently bumped the SAME declared counts
from 106 to 107, so every one of them text-merged cleanly to 107 while the
merged tree now holds 108 elevation reads — main's new
`#refuseUngrantedRunLifecycleWrite` plus this branch's `canReadObject`. Two
correct edits, one wrong sum, and no conflict marker anywhere near it.

Re-derived from the merged tree, after the merge was committed and never
during MERGE state. Seven declared counts move by one; the gate names each and
states there is no mechanical repair, so each was corrected by hand:

  check-system-context-census: OK — 108 elevation read sites in 20 packages
  across 45 files, living in 91 symbol(s); the page cites 105 symbol(s)
  against 105 required, over 129 anchors and 8 file-level citation(s)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

3 participants