Skip to content

fix(plugin-security): the delegated walk counts the permission set of the position's own name - #24793

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-24736-delegated-walk-own-name-set
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-24736-delegated-walk-own-name-set

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24736

Clause-②: no

What changes

The delegated-administration gate's position walk, setsBoundToPosition, now takes its set names from setsCarriedByPosition. That is the helper the organization-level branch already reads. So a delegate's position assignment is judged on every permission set the assignment grants. ADR-0090 D12, as amended on 2026-10-17, lists them: the names the position's definition lists and, while the resolver folds held position names into the requested set names, the permission set of the position's own name when the catalog holds one.

The walk has three callers on main, and all three now judge that set:

  • assertAssignmentWrite handles a sys_user_position insert, update or delete by an adminScope holder. Both the allowlist check and strict containment now see the own-name set.
  • describeDelegableScope, delegated branch: a position whose own-name set is outside every placing scope's allowlist is no longer offered. GET /api/v1/security/my-delegable-scope now agrees with the write.
  • assertSelfDelegation rule 6 (ADR-0091 D3): a delegatable position whose own-name set carries an adminScope can no longer be self-delegated.

A refusal names the set and says it is the set of the position's own name, because the position's definition need not list it. The envelope is unchanged: 403 PERMISSION_DENIED.

Not changed: the organization-level branch, tenant-level administrators, the resolver's fold in security-plugin.ts, packages/spec, and the shipped permission sets. The change only narrows what a delegate may do. Nothing a delegate was refused before is admitted now.

Reachability, measured on origin/main 795ff708d8

  1. Shipped and example catalogs hold no permission set named like a position. This is a source census of the declarations in packages/** and examples/**, test fixtures excluded. Positions: the six built-ins (builtin-positions.ts), three in app-crm and ten in app-showcase, 19 in all. Permission sets: the platform's eight (default-permission-sets.ts), two in app-crm and nine in app-showcase, all prefixed showcase_. The two name lists share 0 names.
  2. A named real producer holds the shape. The vendored HotCRM 17.1 artifact, packages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json, carries the permission sets sales_manager and sales_rep. HotCRM declares positions of the same names, and Setup: a position's same-named permission set is in force but invisible — the set's Assigned Users panel is empty and the position reads as granting nothing #24757 measured that set in force through the fold on a booted HotCRM.
  3. On a real stack, the gap was reachable. The new posture suite was run against the unmodified gate, with only the test file added. A delegate's allowlist covered the position's listed set but not its own-name set, and its assignment was answered 200 in all three postures (single, group, isolated). The suite's ground-truth case shows the assignee's resolved sets then include the own-name set; that case is green before and after. The same held for an own-name set that carries an adminScope, and the delegable-scope report offered both positions.

The triage note on this card says a reachable shape makes it p1.

Tests, at head d110c15116

The branch merged origin/main 7d45c8e29a, which touches plugin-security's shipped sets. The closure was rebuilt and the package run again on the merged head:

  • pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2: Test Files 192 passed (192), Tests 3995 passed | 45 skipped (4040). Before the merge, at a3fc36f503: 191 passed (191), 3961 passed | 45 skipped.
  • pnpm --filter @objectstack/plugin-security typecheck: exit 0 at both heads. Both new test files are in the tsconfig.test.json program; tsc -p tsconfig.test.json --listFilesOnly lists both.
  • pnpm --filter @objectstack/plugin-security build: exit 0.
  • New unit pins in src/delegated-admin-gate.test.ts, in the describe block on the permission set of the position's own name:
    • a refusal asserting code PERMISSION_DENIED and status 403 that names the set;
    • the control, admitted;
    • a position with no own-name set, unchanged;
    • a removal, judged on the same sets;
    • strict containment on an own-name set that carries an adminScope;
    • the listing compared with the write, position by position;
    • the self-delegation refusal, with its control.
  • New real-stack suite, src/delegated-admin-own-name-set.posture.test.ts: a real ObjectQL, SqlDriver and SecurityPlugin in three postures. Each posture runs the ground truth, the 403 refusal naming the set, the control, the unchanged no-own-name case, strict containment, and the listing compared with the write.

Ablation

Taken at a3fc36f503, before the merge, which did not touch the gate or its tests. The change was committed first. Then the old walk, which read position.definition.permissionSets only, was put back through scripts/ablation-replace.mjs: anchor 1 to 0, blob 2a202d1153e1 to 2e45318f6351. Both files were run:

  • Tests 14 failed | 125 passed (139).
  • Red: the five new unit pins, and per posture the refusal, strict containment and the listing (nine cases).
  • Green: the controls, the no-own-name cases and the ground truth.

Restored with git checkout HEAD -- PATH: the blob is back to 2a202d1153e1, the same as HEAD, git diff HEAD is empty, and the marker count is 0. The tests import the subject through relative source paths (./delegated-admin-gate, ./security-plugin.js), not a dist/, so the ablation needed no rebuild.

Gates

Local subset, at head d110c15116: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, with no paths, derived 67 commands from the merge base 7d45c8e29a. All 67 were run one by one, each exit captured before any pipe, and all 67 exited 0. --ran printed: Run reconciliation — 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN.

On the first head (a3fc36f503), check:dual-build-cjs-loads and check:i18n refused with PREREQUISITE NOT MET (exit 3): nothing was measured. After the build that check:i18n names, check:i18n passed on that head, and both passed in the battery on the merged head.

Every family CI runs is read from this PR's check-runs. None of them was run again here.

Acceptance notes


Generated by Claude Code

… the position's own name

ADR-0090 D12 as amended 2026-10-17: the sets a position carries are every
set an assignment of it grants, the own-name set included while the
resolver folds held position names into the requested set names. The
delegated branch's setsBoundToPosition now reads setsCarriedByPosition,
the helper the organization-level branch already used, so the allowlist
check, strict containment, the self-delegation rule against handing out
administration and the delegable-scope listing all judge that set.

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 7 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/delegated-administration.mdx (via DelegatedAdminGate (symbol, a top-level class), /api/v1/security/my-delegable-scope (route, the route ledger binds it to client method security.describeDelegableScope))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v13.mdx (via DelegatedAdminGate (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 15 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 46bb5e3e5405fad31ccba6fe571e98b9234aac78 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0bdd048947982c6ecb0c0e6aa6e6af4eaf07d8c8 — the merge of head d110c151168562f35123475bc12338f9118261f8 into base 46bb5e3e5405fad31ccba6fe571e98b9234aac78, 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 0bdd048947982c6ecb0c0e6aa6e6af4eaf07d8c8 && git checkout 0bdd048947982c6ecb0c0e6aa6e6af4eaf07d8c8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 46bb5e3e5405fad31ccba6fe571e98b9234aac78 d110c151168562f35123475bc12338f9118261f8 && git checkout -B drift-repro 46bb5e3e5405fad31ccba6fe571e98b9234aac78 && git merge --no-ff d110c151168562f35123475bc12338f9118261f8

node scripts/docs-audit/affected-docs.mjs --json 46bb5e3e5405fad31ccba6fe571e98b9234aac78

⚠️ 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 46bb5e3e5405fad31ccba6fe571e98b9234aac78 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:services seat 1 · 2026-10-20T07:15Z

os-justin · session_011TdZ8GjixNZFLz1xJD9zzK (seat post #6021). Reviewed against GitHub and origin/main a43405d596, not against the report (os-dev-report on #24736). Disclosure read first: the PR, the changeset and the report state the rule and its refusal; none gives a recipe for handing out a set outside a delegate's allowlist. Head under review:

d110c151168562f35123475bc12338f9118261f8
  1. Shape. Draft, base main, first line Fixes #24736, line-start Clause-②: no in the body and the changeset. The body names Permission-set resolution binds by POSITION NAME while sys_position_permission_set sits near-empty — confirm name-based resolution is the intended mechanism, not an accident the empty junction hides #13419, plugin-security: organisation admins assign positions and permission sets to their own members inside the D12 boundary — gate branch, overrides, explain and my-delegable-scope, three-posture acceptance (ruling B on #23960) #24136, feat(plugin-security): organization administrators assign positions and permission sets inside the D12 organization boundary #24732 and Setup: a position's same-named permission set is in force but invisible — the set's Assigned Users panel is empty and the position reads as granting nothing #24757 with no keyword beside any of them. 4 files, +429/−19, none on a governed surface (governedPathsIn answers []). The trial merge onto origin/main is clean. PR fix(plugin-security): an on-behalf-of by-id write never admits a row its delegator's own by-id write refuses (ADR-0090 D10) #24792 (plugin-security: an on-behalf-of by-id write can admit a row the delegator's own by-id write refuses, where an app-authored row-level write policy narrows the delegator (ADR-0090 D10) #24746) edits security-plugin.ts and explain-engine.ts, files this PR does not touch. No contract review is owed.

  2. Narrowing only, read in the code.

    • setsBoundToPosition now takes its names from setsCarriedByPosition, the helper PR feat(plugin-security): organization administrators assign positions and permission sets inside the D12 organization boundary #24732's organization-level branch reads: the listed sets plus the own-name set when the catalog holds one. It then adds each set's adminScope as before. There is no second copy of the rule.
    • A position the catalog does not hold still distributes nothing, and a listed name the catalog lacks is still returned with no adminScope, as before.
    • All three callers judge the same or more sets:
      • the assignment write (allowlist and strict containment);
      • describeDelegableScope's delegated branch;
      • self-delegation rule 6.
    • Refusal messages gain a note naming the own-name set. The resolver's fold, the organization-level branch and tenant-level admins are untouched.
  3. Reachability and pins.

    • Reachability:
    • After the fix:
      • 139 of 139 pins pass across the two files.
      • The full package passes: 3995 passed, 45 skipped.
      • Refusals assert PERMISSION_DENIED / 403 and name the set; the controls are admitted; the listing equals the write.
      • The ablation (the old walk put back) turns 14 red, with controls and ground truth green, and the restore is proven by blob equality.
  4. Changeset, @objectstack/plugin-security patch, read sentence by sentence:

    • the D12 rule, quoted;
    • "What a delegate is now refused": insert, update and delete; 403; the named set; strict containment; self-delegation;
    • the delegable-scope sentence;
    • "Unchanged": a position with no own-name set; tenant and organization branches; the fold kept;
    • the remedy, to add the own-name set to assignablePermissionSets.

    Each matches the diff in 2.

  5. Gates. At ACCEPT, every check-run on this head is success or an expected skip, including TypeScript Type Check, Lint & Repo Gates, Test Core 1–6/6, Build Core and Governed Surface Queue Guard. Locally, 67 of 67 derived families exited 0 on the merged head.

  6. Accepted deviations.

    • One new real-stack posture file, beside the claimed unit file. It is the pin the dispatch preferred, and no other claim holds it.
    • Three postures instead of two.
    • Self-delegation rule 6 narrowed as the walk's third caller, pinned with a control.
    • One merge of main before the PR, with the closure rebuilt and every reading re-run on the merged head.
    • Commit trailers are model-free, per AGENTS.md.
  7. Out-of-scope dispositions. describeDelegableScope's delegated branch offers the ADR-0068 D2 identity positions the write refuses. That is the same family as plugin-security: my-delegable-scope offers a platform administrator the built-in identity positions that sys_user_position refuses from every writer #24737 (the tenant-level branch), so it is folded into plugin-security: my-delegable-scope offers a platform administrator the built-in identity positions that sys_user_position refuses from every writer #24737 as one closing card covering both branches with an enumerated pin (6285203700). plugin-security: my-delegable-scope offers a platform administrator the built-in identity positions that sys_user_position refuses from every writer #24737 is serial behind this PR.

  8. Landing clause. Tier S: not governed, under the human-merge threshold. Next step: ready + auto-merge through the relay once every check on the current head is green or an expected skip. A push resets this reading.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 20, 2026 07:16
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 20, 2026 07:16
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit 21301a3 Oct 20, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24736-delegated-walk-own-name-set branch October 20, 2026 07:55
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/m tests tooling

Projects

None yet

2 participants