Repository navigation
fix(runtime): the run-lifecycle refusal names only a remedy the tenancy posture honours (#19874) - #20074
Conversation
…re honours The cancel / restore-suspension refusal told a denied caller to obtain the unscoped admin_full_access grant. Under a walled posture that grant no longer confers platform-admin standing, so the sentence named a remedy that fails silently. The standing sentence is now read off the same inputs the platform-admin derivation reads: walled postures name only the declared administrator list (OS_PLATFORM_OWNER_EMAIL); single names the unscoped grant or the declared list; an unreadable posture names only the list. Who is admitted, the code and the status are unchanged. The gate docblocks in automation.ts and activation-gate.ts that said the rung derives from the unscoped grant and nothing else now state the per-posture anchors. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn
…olver per posture For single, group and isolated, a caller whose only route to platform standing is one candidate remedy is resolved through the runtime's own request-path resolver and handed to the real dispatcher. The refusal names a remedy if and only if the resolver honours it on that posture; a named remedy is admitted by the gate, an unhonoured one is refused with the same sentence. An unreadable requested posture keeps PERMISSION_DENIED + 403 and names only the declared administrator list. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn
…osture remedy Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 083aab5d2ac42d69d478a1422cfa27c10f728b41 && git checkout 083aab5d2ac42d69d478a1422cfa27c10f728b41
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56 40bf21c984a4713185c5c922b5426665e34880a2 && git checkout -B drift-repro b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56 && git merge --no-ff 40bf21c984a4713185c5c922b5426665e34880a2
node scripts/docs-audit/affected-docs.mjs --json b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56
|
Fixes #19874
Clause-②: no
What changed
POST /automation/:name/runs/:runId/canceland.../restore-suspensionadmit only the ADR-0095PLATFORM_ADMINrung. Their refusal told every refused caller to obtain "the unscopedadmin_full_accessgrant, ADR-0068 D2". Under a walled posture (group/isolated) the platform-admin derivation no longer reads that grant as an anchor. The resolver setslegacyGrantAnchorRetired = postureEnforcesWall(resolveTenancyPosture())and gates the grant route on it. So on a walled deployment the refusal named a remedy that fails silently.The middle sentence of the refusal is now built from the inputs the derivation reads, using the same expression,
postureEnforcesWall(resolveTenancyPosture()):group/isolatedOS_PLATFORM_OWNER_EMAILsingleadmin_full_accessgrant (ADR-0068 D2), or the declared administrators inOS_PLATFORM_OWNER_EMAILresolveTenancyPosturethrows)OS_PLATFORM_OWNER_EMAIL(honoured on every posture), without echoing the environment valueresumedoor) are unchanged.ec.posture === 'PLATFORM_ADMIN'plus theisSystembypass, still403 PERMISSION_DENIED, and admits the same callers on every arm. The posture read sits inside a guard, so it can change the wording but never the answer.OS_PLATFORM_OWNER_EMAILis quoted throughPLATFORM_OWNER_EMAIL_ENV(@objectstack/types). That is the constant the boot-side walled lines inplugin-securityalready quote, and the namecontent/docs/deployment/environment-variables.mdxdocuments. The set name is quoted throughADMIN_FULL_ACCESS(@objectstack/spec/identity), the constant the resolver compares against.singleis described as it ships today and nothing more. The runtime string carries no tracker id and does not predict thesingledisposition. platform-admin re-anchor follow-up (Choice 4B): config-anchor thesingleposture — first-user promotion becomes development-only fallback #11979 is not addressed here.Docblocks corrected (comment-only)
packages/runtime/src/domains/automation.ts: the run-lifecycle gate docblock said the rung "is derived from the unscopedadmin_full_accessevidence and nothing else". That is false. The config anchor confers on every posture, and walled postures retire the grant anchor. It now states the per-posture anchors, and notes that only the refusal sentence reads the posture while admission does not.packages/runtime/src/domains/activation-gate.ts:56: the same sentence, word for word ("derived from the unscoped-grant evidence and nothing else"), corrected the same way. This file is outside the claim's declared file surface. It was fixed under os-dev's bounded in-place-fix exemption:automation.tsfix.The claim's file surface needs this path added.
activation-gate.ts:51was left alone: it quotes the old premise and already says it "stopped holding", which is true.Sweep of
packages/runtime/src(non-test)Spellings searched:
unscoped `admin_full_access`,admin_full_access` grant,admin_full_access grant,unscoped grant,unscoped-grant,ADR-0068 D2,unscoped admin_full_access. Every non-comment string line naming a platform operator or admin was also read.automation.ts:656on3557f85f).activation-gate.ts:134and:237say "platform operator" and name no mechanism, which is true on every posture.dispatcher-error-vocabulary.ts:521is ledger data about a vendor code, not a remedy.route-ledger.tsnotes name the rung, not a remedy.Tests
New file:
packages/runtime/src/domains/automation-run-lifecycle-deny-remedy.test.ts(8 tests). It does not pin wording. For each ofsingle,groupandisolatedit pins one equivalence: the refusal names a remedy if and only if the resolver honours it. For each candidate remedy, it builds a caller whose only route to standing is that remedy and resolves it through the runtime's ownresolveExecutionContext, with the real core resolver underneath and the requested posture read from the environment as in production. It then hands that caller to the real dispatcher:PERMISSION_DENIED+ 403 with the same sentence, which does not name that remedy. This is the mirror.PLATFORM_ADMINundersingle. So a walledfalsemeans the anchor is retired, not that the fixture is broken.PERMISSION_DENIED+ 403.Reverse verification ran on
b4b3ed07(fix and test committed first). Each mutation was applied withscripts/ablation-replace.mjs(anchor hit x1 to x0, blob changed) and ran under a trap. Each restore was proven: blob equal to the HEAD blobdaa4fa8562bf,git diff HEADempty,git statusclean. The subject is imported through relative source paths, and runtime's vitest config aliases@objectstack/core,@objectstack/typesand@objectstack/spectosrc, so nodist/rebuild applies. Every run turned red as predicted:groupandisolatedequivalence rows, "grant: named ⇔ honoured: expected true to be false"singlearm stops naming the grantsinglerow, "expected false to be true"Local verification (at
40bf21c9)pnpm --filter @objectstack/runtime exec vitest run --project local(thetestscript's project): 277 files passed, 3901 tests passed, 1 skipped.pnpm --filter @objectstack/runtime typecheck: exit 0. This coverstsc --noEmitandcheck:test-typecheck, which held the ledger at 27 files, 191 errors and 69 pinned signatures. The new test is insidetsconfig.test.json's program (--listFilesOnly: 1 hit).pnpm lint(fulleslint . --no-inline-configunion): exit 0.dispatch-gates --commandson the real diff derived the same 60 commands as the PM's list. Result: 58 exit 0, 2 NOT MEASURED.--ranreconciliation: "60 derived famil(ies) accounted for — 58 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)". This includespnpm check:doc-authoring: "sibling-package prose ids hold the baseline — 816 pinned site(s) across 231 file(s) ... no growth, no burn-down unrecorded".scripts/doc-authoring-prose-id.baseline.jsonis unchanged, andautomation.tsnever had an entry in it.check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity,check:route-ledger-census), andcheck-issue-citations --base 3557f85f("every citation this change adds resolves").check:dual-build-cjs-loads. Reason: exit 3, PREREQUISITE NOT MET. It is a whole-tree gate, and 39 packages have nodist/. A narrowed substitute ran for runtime only: after the build,require('@objectstack/runtime')in a real node process resolveddist/index.cjs(exit 0, 260 exports).dist/index.cjscarries the new sentence (1 hit) and not the old one (0 hits). This is a substitute, not the gate.check:type-check-debt. Reason: exit 3, PREREQUISITE NOT MET (@objectstack/driver-tursohas no built type entry). Runtime's own typecheck above is the per-package half.test:repoproject, and the families that take workflow values.Acceptance notes
check:doc-authoring: the card said this message sits "under the shrink-onlycheck:doc-authoringbaseline". Measured, that is not the case. That gate's runtime leg is a (file, tracker-id) ratchet over string literals. The old message carried no tracker id, andautomation.tshas no baseline entry. Editing the message owes the baseline nothing as long as no#NNNNenters a string. The new strings carry ADR ids only.packages/core/src/security/resolve-authz-context.ts: the §6d comment (about lines 1042-1045) and thehasPlatformAdminStandingdocblock (about lines 1085-1098) still sayPLATFORM_ADMINderives from the unscoped-grant evidence "and nothing else". That file is read-only in this lane.packages/qa/dogfood/test/authz-conformance.matrix.ts:178says "derives only from an unscoped admin_full_access grant".packages/runtime/src/domains/automation-run-lifecycle-door.test.ts:117: theOPERATOR_CTXdoc comment says "derived from the unscopedadmin_full_accessgrant".singlequalifier. That sentence would still name the grant on a walled deployment. The qualifier "until platform-admin re-anchor follow-up (Choice 4B): config-anchor thesingleposture — first-user promotion becomes development-only fallback #11979 decides otherwise" would also put a tracker id into a runtime string and predict that card's outcome. A config-only sentence would be true everywhere. But it would describesingleas config-only, which is the reading thesingledisposition ruling reversed. So the sentence is chosen per posture, using the derivation's own expression.Generated by Claude Code