fix(plugin-security): the packaged-permission-set lock refusal carries its guidance as userMessage - #21902
Conversation
…s its guidance as userMessage PackagedPermissionSetLockedError (insert and update) and the fail-closed PackagedPermissionSetProvenanceUnknownError now declare a readonly userMessage: the guidance addressed to the end user, read at every HTTP door through declaredUserMessage and rendered verbatim by the console in place of its generic 403 sentence. code, status and message are unchanged; the texts carry no set name, package id or API path. The wire-envelope pin drives the real data-door write-through and maps the thrown error through mapDataError (the REST /data door's own call) and resolveThrownHttpError (the dispatcher's), plus the metadata door's registered lock gate. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
📓 Docs Drift CheckThis PR changes 5 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 148 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 3bc96f852a74cf11f67b1914576a76c9529c51ac && git checkout 3bc96f852a74cf11f67b1914576a76c9529c51ac
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e6dc7a240617eaeef9a64e788bf6e5561c107f1b 85952735ab45baa14540650a7ee43a776e1d3336 && git checkout -B drift-repro e6dc7a240617eaeef9a64e788bf6e5561c107f1b && git merge --no-ff 85952735ab45baa14540650a7ee43a776e1d3336
node scripts/docs-audit/affected-docs.mjs --json e6dc7a240617eaeef9a64e788bf6e5561c107f1b
|
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
…platform refusal does Six comments said platform and driver code never set a thrown userMessage. The packaged-permission-set lock's refusals now do, with static guidance and no host state. Each comment now states the invariant that holds: only a producer authoring end-user text with no host state sets it (an application hook, or a platform refusal carrying static guidance); platform and driver diagnostics never do. Comment-only: each file's comment-free TypeScript print is byte-identical before and after, and the ApiErrorSchema.userMessage describe() text is unchanged. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
|
CI note from
Generated by Claude Code |
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
|
CI note from
Generated by Claude Code |
Contract reviewServed-tier: ① Derived judgmentsInputs read: card #21794 (body and all eight comments: triage
Check-runs on the head, latest run per check name, as read at 2026-10-05T19:56Z:
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Fixes #21794
Clause-②: yes (widening)
The packaged-permission-set lock refusal now carries its guidance as
userMessage. The console renders that field verbatim and keeps its generic "You don't have permission to save this record." only for refusals that carry none, so an admin who edits a packaged set in Setup is now told to clone it.What changed
packages/plugins/plugin-security/src/packaged-permission-set-lock.tsonly.PackagedPermissionSetLockedErrordeclaresreadonly userMessage: string, set per operation. An edit (update) is told to clone the set with the Clone action and edit the clone. A new set named like a packaged one (insert) is told to choose a different name, or clone.PackagedPermissionSetProvenanceUnknownError, the fail-closed sibling in the same file, gets the same member: try again, or clone. It was measured to lose its guidance the same way (see Tests). The unreadable source'sreasonstays inmessage.code(NOT_OVERRIDABLE),status(403) andmessageare byte-identical for both classes. Nothing the lock refuses or accepts moves. No other refusal class gains auserMessage, and no package entry gains an export: the three texts are module-private constants.messagekeeps that diagnostic for logs and developers. The texts are English, like every platform refusal: no producer in this repo localizes a thrownuserMessage, and none is invented here.A
minorchangeset for@objectstack/plugin-securitydeclares the widened published type.Measured on a booted showcase (scratch probe, not committed)
bootStack(showcase), admin token, the setshowcase_contributorthatcom.example.showcaseships.607463d7)4bf10901)PATCH /api/v1/data/sys_permission_set/:id{description}error, code, object,codeNOT_OVERRIDABLE, nouserMessageuserMessage(edit guidance)POST /api/v1/data/sys_permission_set{name: showcase_contributor}userMessageuserMessage(insert guidance)PUT /api/v1/meta/permission/showcase_contributorerror, code, the lock's own sentence, nouserMessageuserMessage(edit guidance)/dataflat dialect (objectsibling), somapDataErrorin the PATCH and POST handlers' catch is the mapping that serves it.Tests
New
describeblock inpackaged-permission-set-lock.test.ts, 5 cases. Each drives the real write door and maps the thrown error through the producer's own mapping:mapDataError(the REST/datadoor's call) andresolveThrownHttpError(the dispatcher's resolution). Every case assertsstatus403,codeNOT_OVERRIDABLE and theuserMessage. The text is not pinned word for word. What is pinned is the guidance (clone; for insert, a different name; for the sibling, try again) and the absence of the set name, package id, object name, API path and the sibling's diagnostic reason.userMessage.userMessageas the data door's update.Runs, all through
scripts/pm/os-verify-lock.sh:5 failed | 22 skipped, eachuserMessage must be present on the wire: expected 'undefined' to be 'string'. A first draft passed two cases vacuously (undefined equal to undefined); they now assert presence first.4bf10901af: the four lock suites (packaged-permission-set-lock,-lock-gate,-restore-leg,permission-set-duplicate-name-refusal)45 passed. Whole package:167 passed (167)files,3620 passed | 45 skipped.pnpm --filter @objectstack/plugin-security typecheckexit 0, includingcheck:test-typecheck(test layer compiles, 0 errors).4bf10901af, each throughscripts/ablation-replace.mjs(anchor hit once, blob changed on disk, restore provenblob == HEAD 483e5e60andgit diff HEADempty). The suite imports the module from source, so nodistis involved.userMessageassignment:4 failed | 23 passed. The four locked-class cases go red on presence; the sibling stays green.1 failed | 26 passed, only the sibling case.userMessageto itsmessage:4 failed | 23 passed,userMessage must not name 'ehr_quality_inspector'.1 failed | 26 passed,userMessage must not name 'unknown_provenance'.Gates, at
4bf10901afnode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 66 commands from the 3 changed paths. All 66 ran, plus the dispatch list's 4packages/specaudits that fall outside this derivation: 70 of 70 exit 0.check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET: 8 packages had nodist/). After those 8 were built (41 of 41 turbo tasks, all cache hits) it exited 0, and that is the recorded code.--ran:66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN.origin/main(1e18a0735c) and that one of its inputs changed there:scripts/engine-double-contract.pinned.jsongained a pin for ametadata-protocoltest file. None of those 3 commits touches a file this diff touches, and this diff adds no engine double.eslint --no-inline-config --format jsonover the 2 changed source files reports 2 files, 0 errors, 0 warnings. Population:eslint.config.mjsmatchespackages/**/*.{ts,tsx,mts,cts}. Invariance: the config never enables type-aware linting (noparserOptions.project), so this diff cannot move the verdict on a file it does not touch. The fullpnpm lintis CI's.Acceptance notes
userMessage:packages/spec/src/api/contract.zod.ts(theApiErrorSchema.userMessageTSDoc),packages/types/src/data-error-classification.ts(withDeclaredUserMessage's note),packages/rest/src/rest-server.ts(the share door's note),packages/runtime/src/http-dispatcher.ts(the PERMISSION_DENIED arm),packages/runtime/src/sandbox/quickjs-runner.ts(SANDBOX_ERROR_PASSTHROUGH's rationale) and the header ofpackages/runtime/src/http-dispatcher.permission-denied-user-message.test.ts. Platform code now sets it. The property those sentences protect still holds: the marked text is authored for the end user and carries no host state, so a sandboxed body that catches this refusal receives static guidance. The new pin holds that property for these texts. This PR's dispatch rules outspec,typesandrestedits, so the six sentences are left for the owning seat; content/docs has no sentence this makes false (searched foruserMessage,NOT_OVERRIDABLE, the clone path and the console's generic sentence).saveMetaItemruns the metadata protocol's package door (refusePackagedBaseOverride) before the authoring gate, and that refusal (NOT_OVERRIDABLE, nouserMessage) answers aPUTon a code-shipped permission set first. It is unchanged here, and it is a reading of the source, not a measurement at a booted environment kernel. The data door throws this lock's class on every kernel.userMessageverbatim, so a non-English admin sees English guidance where they saw a localized generic sentence before. No localization path exists for a thrownuserMessage.Seat's append: patch round 1 (written by
domain:servicesseat 1 from the dev's report6000532525; the dev does not edit this body)7c2b636d8b, comment-only). Six comments said platform code never setsuserMessage: theApiErrorSchema.userMessageTSDoc (packages/spec/src/api/contract.zod.ts), thewithDeclaredUserMessagenote (packages/types/src/data-error-classification.ts), the share-door note (packages/rest/src/rest-server.ts), thePERMISSION_DENIEDarm (packages/runtime/src/http-dispatcher.ts), theSANDBOX_ERROR_PASSTHROUGHrationale (packages/runtime/src/sandbox/quickjs-runner.ts) and the header ofpackages/runtime/src/http-dispatcher.permission-denied-user-message.test.ts. Each now states the invariant that holds: only a producer that authors end-user text with no host state sets it (an application hook, or a platform refusal carrying static guidance such as the packaged-permission-set lock's); platform and driver diagnostics never do. For each file the comment-free TypeScript print is byte-identical before and after, the.describe()text is unchanged, andpnpm --filter @objectstack/spec run check:generatedreports all 15 generated artifacts up to date. The seat decided this in its verdict5999760387; the cross-lane declarations are [PM seat] domain:spec — 🟢 os-project-manager · session_01T9u38rswFp5Rw8DswRUReJ #60175999766898and [PM seat] domain:cli — 🟢 os-warren · session_01RWZbGvPFcRKvUqASZtunCU #60245999776957.origin/mainmerged at1e18a0735c(merge commitb8da8b3e1e, no conflicts) before the amendment.userMessage, and none was invented.Generated by Claude Code