Repository navigation
delegated-admin gate: scope violations answer PERMISSION_DENIED, so a client cannot tell them from an opaque denial (own registered code) #26189
Description
Filing gate ②: a maintainer ruling, carried out as child 3 of the nine that #26059's ruling (Q3) names. ⛔ Not a claim.
When a delegated admin acts outside their scope, the server knows exactly why. Examples: "business unit … is outside the delegated subtree", or "position 'pos_mgr' distributes permission set 'ps_mgr', which is not in the scope's allowlist". But it throws that reason as a plain PERMISSION_DENIED 403. A client cannot tell it from a denial whose reason must stay hidden, so the console shows only the fixed "You don't have permission to save this record." This card is the server half: the gate answers its own code. The UI half is child 4 and waits on this one.
Part of #26059
Ruling-ref: 6416233738 (#26059 (comment))
Ruling: batch #396 item 1, letter Q1 是 / Q2 不做 / Q3 分拆 / Q4 框架. This card is Q3 child 3 (server, p2): item 3, server half. Brief 6415795834, item-by-item reading 6415971852 (item 3).
Filed by domain:ui seat 2 (domain:ui#2) · os-steve · session session_01SMps7wq7rBTk53e8KTSEup, first claimant of #26059. Domain, grade and route are triage's on first touch.
Business reason. A delegated admin (a regional manager who assigns positions in their own subtree) who hits the boundary sees only "you don't have permission". They cannot tell whether they picked the wrong unit, the wrong position, or lack the right altogether. These reasons describe the caller's own scope, so they are safe to show. They just cannot be told apart on the wire today.
Reader who acts. The domain:server execution seat, dispatch step: one os-dev, one PR on packages/plugins/plugin-security/src/delegated-admin-gate.ts plus its ledger row in packages/spec/src/api/error-code-ledger.zod.ts, with pins.
Duplicate check. A local scan of the 1,962 newest issues (all states, #22168–#26181) for DELEGATION_SCOPE_VIOLATION|delegated.?admin.{0,80}(code|reason|toast|message) found 4 hits, all closed and none this. #24082 / #23945 gave the invitation role-cap refusals their own codes, a precedent rather than a duplicate. #23323 and #23957 are docs / invite-dialog cards.
Ruled scope (ruling Q3 item 3)
- The delegated-admin gate (
delegated-admin-gate.ts:403–503, reasons:1136–1179) answers its own code, for exampleDELEGATION_SCOPE_VIOLATION, with status 403. The code gets a row in the ADR-0112 ledger and stays distinct fromPERMISSION_DENIED. - The UI side is child 4 (
Blocked-by:this card). Ruling A screen flow whose write node is refused shows the end user the engine's raw node text, in English, with the node id and the object API name ("Node 'create_account' failed: create_record(crm_account) failed: …") #24914 B′ is unchanged: an ordinaryPERMISSION_DENIEDstays opaque.
Anchors (re-read on origin/main 83a26c38)
packages/plugins/plugin-security/src/delegated-admin-gate.ts::403–503holds: the gate's entry refusals, each aPermissionDeniedError(throws at:403,:415,:446,:480,:487,:503).:1136–1179holds: the scope-violation reasons. One more reason, "target user … is outside the delegated subtree", sits at:1185.- Those reasons are thrown at
:1157(sys_user_position) and:1193(sys_user_permission_set).
packages/plugins/plugin-security/src/errors.ts:41–44:PermissionDeniedError, withcode = 'PERMISSION_DENIED'and status 403.packages/spec/src/api/error-code-ledger.zod.ts:ERROR_CODE_LEDGERis at:190.- The same gate family already has its own 403 code:
INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE(:586, error-code ledger: name the codes for plugin-auth's eight remaining code-less hook refusals (organization create, invitation placement, slug guard, OTP walls), so #24243 can stamp each one #24254). - The ledger's synonym check (
StandardSynonymWaiverSchema/STANDARD_SYNONYM_WAIVERS,:1795–1850, whereFORBIDDENalready shadowsPERMISSION_DENIED) means the new code has to register as its own meaning, not as a synonym.
docs/adr/0112-error-code-vocabulary-and-ledger.md: the ledger ADR.
Note for the dev (found while verifying, not a new rule). The :403–503 block also holds refusals that are not about the caller's delegated scope:
- the audience anchor (
:403) - principal-less writes (
:415) sys_memberbeing tenant-only (:446)- plain CRUD with no scope (
:487) - filter writes (
:503)
The ruling's test decides which ones move: a reason that describes the caller's own scope may take the new code, and anything else keeps PERMISSION_DENIED. Name the split in the PR.
Expected / acceptance
- A delegated-admin write outside the caller's scope (unit outside the subtree, set not in the allowlist, target user outside the subtree) answers 403 with the new code and the gate's reason as the message.
- Every other refusal of the gate, and every other
PERMISSION_DENIEDin the platform, is unchanged on the wire. - The code has its ADR-0112 ledger row and passes the ledger's admission / synonym gate.
- Pins cover the new code on each scope-violation path and
PERMISSION_DENIEDon a non-scope refusal.
Activity
objectstack-fleet commentedon Oct 26, 2026
Triage: first grade — as filed ·
pm:queue· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-26T15:59Z⛔ Not a claim, ⛔ not a dispatch.
Generated by Claude Code
objectstack-fleet commentedon Oct 26, 2026
Claim: PM loop round 8 · card thread
Session:
session_01R1q2xdtYgU6FKrZ7LgjPCC(the seat); the card thread issession_01MZUz5qaFxUqiNqA3LqEVzdAccount:
os-project-manager(the seat's linked user; the card's assignee)Branch:
claude/project-thread-okhr87(the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)Worktree: the card thread's own container checkout
Domain:
domain:serverSeat:
domain:server#1Ruling-ref: 6416233738 (#26059, maintainer ruling Q1 是 / Q2 不做 / Q3 分拆 / Q4 框架; this card is Q3 child 3) and triage first grade (2026-10-26T15:59Z). A delegated-admin refusal that describes the caller's own scope answers its own 403 code with the gate's reason, registered in the ADR-0112 ledger as its own meaning. Every other
PERMISSION_DENIEDis unchanged on the wire. Ruling #24914 B′ stands.File surface:
packages/plugins/plugin-security/src/{errors.ts,delegated-admin-gate.ts}and their tests;packages/plugins/plugin-auth/src/auth-manager.ts(isInvitationPlacementGateRefusal, about:1398, a one-line addition so an out-of-scope invitation stays 403INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE) and its test;packages/spec/src/api/error-code-ledger.zod.ts, one row, and the ledger test. The new code's message must not open with[Security] Access denied, whichmapDataError,isPermissionDeniedErrorandsecurity-routes.tswould re-flatten; pin all three. Changesets:patchon spec, plugin-security and plugin-auth. ⛔ Noexplain-engine.ts, noui/. Any otherpackages/spec/**change: stop and explain in the reportContainer & model:
S,mode:thread, default model and effort (set at thread open)Clause-②: no
Metadata-protocol: none
Responsibility: a delegated admin's scope violation answers an opaque
PERMISSION_DENIED, so the console cannot tell it apart (delegated-admin-gate.tsscope-violation throws)Thread-read: none
Premise: holds (read 2026-10-26T18:20Z at
origin/main21371552b0, which contains #26064: card open, unassigned,pm:queue, no claim; the scope-violation reasons still throwPermissionDeniedError)Serial constraints cleared: #26064 merged (it is on
21371552b0); PR #26250 adds a row to the same ledger, append-only, so whichever lands second mergesmainand keeps both; #26188 (in parallel) stays off this surface; the dev re-measures beforepr_createobjectstack-fleet commentedon Oct 26, 2026
os-dev-report
{
"issue": 26189,
"status": "done",
"branch": "claude/project-thread-okhr87",
"pr": "#26320",
"session": "session_01MZUz5qaFxUqiNqA3LqEVzd",
"premise_still_valid": true,
"summary": "The delegated-admin gate's three scope reasons (unit outside the subtree, new row or pre-image; set not in the allowlist, assignment or direct grant; target user outside the subtree) now throw DelegationScopeViolationError: 403, code DELEGATION_SCOPE_VIOLATION, message '[Security] Outside your delegated scope: ...' carrying the gate's reason. Every other refusal keeps PERMISSION_DENIED (split table per throw site in the PR body). The message avoids the '[Security] Access denied' matcher prefix, which mapDataError would re-flatten (shown by ablation). plugin-auth's isInvitationPlacementGateRefusal accepts the new code so an out-of-scope invitation stays 403 INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE (red first: 503). One ADR-0112 ledger row under @objectstack/plugin-security, no waiver; #26250's DUPLICATE_MATCH_KEY row kept (main merged); docs regenerated by gen:docs.",
"tests": "Head b563cd4. Red first on e05e0de + tests: gate [#26189] 5 scope cells red ('expected [ PERMISSION_DENIED, 403, 403 ] to deeply equal [ DELEGATION_SCOPE_VIOLATION, ...]'), 10 non-scope controls green; plugin-auth stand-in red 'expected 503 to be 403'; ledger red ZodError invalid_value. Green: plugin-security 227 files / 4941 passed / 45 skipped / 0 failed; plugin-auth 198 / 3780 passed / 10 skipped / 0 failed; error-code-ledger.test.ts 24 passed; dogfood door files (showcase-permission-zoo, delegated-admin-invite) 17 passed; typecheck plugin-security, plugin-auth, spec, dogfood exit 0; eslint --no-inline-config on touched sources exit 0. Ablation (scripts/ablation-replace.mjs): the new class's message reopened with '[Security] Access denied:'; ablation-dist-preflight exit 0; showcase-permission-zoo 2 failed / 9 passed (both door pins answer 403 PERMISSION_DENIED, control green); restored blob == HEAD, git diff HEAD empty, rebuilt, --absent preflight exit 0. A first ablation attempt failed the DTS build and measured nothing.",
"gates": "Local, at b563cd4: dispatch-gates --commands 119 derived, all run, exit codes captured before any pipe; --ran: 119 derived, 119 run, 0 NOT-MEASURED. Two needed a container step and were re-run green: check-plugin-teardown-shape --self-test (git fetch of pinned fixture 621a487, then exit 0) and check:type-check-debt (queue-timeout while my dogfood held the lock, re-run under the lock exit 0, 26 raw tsc errors, none above ledger). spec check:generated: content/docs/references stale after the ledger row, regenerated with --fix and committed. CI check-runs: in_progress at report time; the seat reads them.",
"dogfood_battery": "b563cd4c4 · os-verify-lock VERDICT command-exit 0 (held 1225s) · Test Files 297 passed / 3 skipped (300) · Tests 2528 passed / 18 skipped (2546) · no failures",
"mcp_calls": "5 — read-only: issue_read get #26189, issue_read get_comments #26189 (x2), pull_request_read get #26064, issue_read get_comments #26059. No MCP writes.",
"api_writes": "2 — fleet-write relay POST /repos/objectstack-ai/objectstack/dispatches for pr_create (#26320, run 40399885401, read-back identical, assignee os-project-manager); this os-dev-report comment via post-stamped. git push is not a REST write.",
"open_questions": [
{
"question": "Two refusals concern the caller's own scope but stay PERMISSION_DENIED under the ruling's literal list: a scope without manageAssignments, and adminScope containment ('would hand out an adminScope not strictly contained by yours'). Move them to DELEGATION_SCOPE_VIOLATION too?",
"options": [
"A keep them PERMISSION_DENIED, as built (the ruling lists three reasons and says every other refusal keeps PERMISSION_DENIED)",
"B move both: each describes the caller's own adminScope, and the message names only the caller's own scope"
],
"recommendation": "A, no decision needed to land: the ruling's list is explicit, and the console (child 4, #26190) gains nothing it needs from B today. Revisit only if #26190 measures an admin confused by either message."
}
],
"out_of_scope_findings": [
"carrier: #26190 (UI half, Blocked-by this card) · noted, not filed: the console's isPermissionError (ui/packages/react/src/utils/error-message.ts:202) treats any 403 as the fixed line, so the new code changes nothing in the UI until #26190."
],
"deviations": [
"Clause-②: the claim wrote 'no'; measured 'yes (widening)' (the ledger header: registering a code widens the published face). Changeset and PR body declare it, with the BREAKING wire-answer banner and ADR-0087 not-required (no-migration-prescription) marker; level patch (Metadata-protocol: none). PR title carries '!', so the full dogfood ran.",
"File surface: besides the claim's files, packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts (the order's 'pin it at a real door': its two delegated-admin refusals now assert the code, plus a PERMISSION_DENIED control), plugin-security delegable-scope-no-identity-positions.posture.test.ts, delegated-admin-own-name-set.posture.test.ts, delegated-admin-gate-catalog.test.ts, errors.test.ts (pins flipped or extended), and content/docs/references/api/{automation-api,contract,error-code-ledger}.mdx (gen:docs output). No other packages/spec change.",
"security-routes.ts: the explain route's catch cannot receive this throw (explain-access swallows the gate's throw with a catch that answers false), so it is pinned at the predicate level: the unit cells assert all three limbs it reads (code, name, message prefix).",
"DelegationScopeViolationError is not exported from plugin-security's index (smallest surface; clients read the code).",
"The session's default 'request review from os-project-manager' step was not done: it needs a GitHub MCP write, which the order forbids."
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 26, 2026
Landed
PR #26320 merged at 2026-10-26T21:46Z by the merge queue as
495fa07c2aonmain(Fixes #26189; ACCEPT6427652251).git ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-26320-*'origin/mainby contentgit cat-file -e origin/main:packages/plugins/plugin-security/src/delegated-admin-gate.tsgit merge-base --is-ancestor 495fa07c2a origin/mainState: this card closed by
Fixes(completed);pm:dispatchedremoved.Generated by Claude Code