Skip to content

fix(security): retire delegated_from from sys_user_permission_set (ADR-0049 enforce-or-remove, #9730) - #9998

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-9730-retire-delegated-from-ups
Aug 19, 2026
Merged

fix(security): retire delegated_from from sys_user_permission_set (ADR-0049 enforce-or-remove, #9730)#9998
os-warren merged 1 commit into
mainfrom
claude/issue-9730-retire-delegated-from-ups

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#9730
Part of #9876 — this PR carries the authorization.mdx hunk that card mandates ride the removal PR; per that card's own text it should be closed as absorbed when this merges (left to the unlock scan / PM rather than a closing keyword, since its wording is "close as absorbed if the hunk rode along").

What

Retires delegated_from from sys_user_permission_set under ADR-0049 enforce-or-remove — maintainer ruling 2026-08-18 on #9730: REMOVE. The runtime delegation gate is structurally scoped to sys_user_position (isDelegationWrite at delegated-admin-gate.ts returns false for every other object, making assertSelfDelegation unreachable here), and the explain engine reads delegation provenance from position rows only. On this table the column was declared and data-door-writable while no runtime consumer read it — authoring-lint-only enforcement on a security object.

Three declared surfaces, plus the ledger the ruling's "ADR-0087-style registry entries as applicable" clause forces:

  1. plugin-security — the field declaration is removed (a retirement note replaces it, naming the ledger entry); the stale half of the #9046 comment block ("reason/delegated_from are read by the delegated-admin gate and the security-posture lint") is corrected; i18n bundles regenerated (pure deletion of the retired entry, all four locales — the sys_user_position entry is untouched). Negative pin added in rbac-objects.test.ts: the object no longer declares the column; sibling positive pin: sys_user_position.delegated_from stays declared as a sys_user lookup.
  2. lintvalidate-security-posture.ts: the two ADR-0091 seed rules deliberately stop sharing one object scope. Measured, not dropped wholesale: D2 (valid_until expired/unparseable at authoring) still covers both grant tables — valid_until remains declared and resolution-enforced on sys_user_permission_set (the existing unparseable-valid_until test on that very object pins this). D3 (delegation row must carry reason) is scoped to sys_user_position only, the one table that declares delegated_from. Fixtures triaged per the ruling: the reachability-corpus fixture and the runtime-gate fixtures that used a permission-set delegation row now use position rows (they pinned the branch the ruling removed); a new negative pin holds that a permission-set seed carrying the retired key draws no D3 finding at the real runtime gate.
  3. content/docs/permissions/authorization.mdx (The delegated_from removal on sys_user_permission_set must update authorization.mdx's per-object grant-column table in the same PR #9876) — the four-column D1 intro now states delegated_from exists only on sys_user_position; the per-object table's sys_user_permission_set cell for delegated_from reads "Not declared — retired" with the refusal channel; the seed-lint paragraph no longer claims the D3 rule "runs on both grant tables". The D5 pair rows and the reason row are unchanged, as The delegated_from removal on sys_user_permission_set must update authorization.mdx's per-object grant-column table in the same PR #9876 specifies.

ADR-0087 disposition (measured against what check-adr-0087-registration demands): the changeset is declared-breaking and carries a FROM→TO prescription, which closes the no-migration-prescription escape — so the honest category is registered. This is a platform-object column, not an authorable spec key, so the bookkeeping follows the audit-log-action-enum-retired shape: a semantic entry (ups-delegated-from-column-retired, new file under entries/semantic/, gen:migration-registry + gen:spec-changes + gen:upgrade-guide regenerated), nothing in RETIRED_KEYS_BY_MAJOR, and the four surface ratchets byte-identical (verified: no authorable-surface/, api-surface/, or json-schema.manifest/ file moves in this diff).

Migration posture — measured, not assumed

  • Existing rows: expected none carrying a value. Survey grep reproduced at the branch point (git grep -n "delegated_from" -- packages apps examples ':!*CHANGELOG*' at b3042e328): zero production writers on this object; the only literals naming both the table and the column are lint test fixtures. The platform's own writer to this table (auto-org-admin-grant) stamps reason/granted_by, never delegated_from.
  • Physical DDL on deployed instances: untouched, deliberately. Schema sync is additive (ADR-0045) — no DROP COLUMN is issued; the platform stops declaring, projecting and accepting the column. No data migration is written, and none is required.
  • Enforcement after removal is loud: the engine's schema preflight refuses an undeclared field on insert/update with 400 INVALID_FIELDbefore the driver or any hook runs (Undeclared fields still reach the driver: beforeInsert hooks run and an auto-number is consumed before the request is refused, and the whole INSERT with its values is logged at ERROR (17.0.0 GA) #8682 preflight), so a stale seed or client write is told exactly what to remove — strictly louder than the advisory lint that used to be this column's only coverage.

Ruling guardrails held

  • Sibling untouched, proven both ways: the diff (merge-base vs origin/main) never touches sys-user-position.object.ts or any position-table path; targeted green run of delegated-admin-gate.test.ts + explain-engine.test.ts + rbac-objects.test.ts (139 tests) on the final tree.
  • No dormant scaffolding: nothing delegation-shaped remains on the permission-set table; re-declaring requires a runtime reader in the same PR (stated in the retirement note, the ledger entry, and the docs cell).

Ablation (standing lane clause)

Re-added the field declaration → predicted the negative pin's signature (rbac-objects.test.ts › "no longer declares delegated_from" fails on expect(fields).not.toHaveProperty('delegated_from')) → observed exactly that red → restored byte-identically, hash-verified (git hash-object identical before/after: 5982129da6c08723838bdda3d3c73387a74b03fc). Both legs ran against source via same-package relative imports (no dist resolution on this pair), so no rebuild step applies; the restore leg re-ran the pin green.

Verification (all at head 00a79d51e, clean tree)

  • pnpm --filter @objectstack/plugin-security test — 66 files, 1295 tests passed; package typecheck exit 0.
  • pnpm --filter @objectstack/lint test — 76 files, 2107 tests passed; package typecheck exit 0.
  • @objectstack/spec: vitest run src/migrations src/conversions — 5 files, 307 tests passed (chain replay + fixture disjointness over the regenerated registry); package typecheck exit 0.
  • Gate union (PM-named + dispatch-gates.mjs derived from the actual merge-base diff, 31 gates): all PASS at 00a79d51e — including check:adr-0087-registration, check:changeset-no-major, check:i18n, check:migration-registry / check:spec-changes / check:upgrade-guide, the docs family, the spec-liveness family, check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:type-check-coverage, and check:type-check-debt (after building the workspace closure, as lint.yml does). check:dev-prereqs initially FAILED on its own stated precondition ("the workspace is not built" — 12 packages this task never touched had no local dist); green after pnpm build. Per-gate exit codes were captured before any pipe.

Out-of-scope finding filed: #9997 (ADR-0091 D1's declaration list still names the column on both tables — docs/adr/ is governed and outside this PR's ruled surface).

Generated by Claude Code


Generated by Claude Code

…R-0049 enforce-or-remove)
The runtime delegation gate is structurally scoped to sys_user_position, so
on the permission-set grant table the column was declared and data-door-
writable while no runtime consumer read it. Maintainer ruling 2026-08-18:
REMOVE. Lint D3 rule scoped to the position table; docs per-object grant-
column table updated (#9876); ADR-0087 semantic ledger entry
ups-delegated-from-column-retired registered.
Fixes#9730
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/lint, @objectstack/plugin-security, @objectstack/spec, touching 6 documentable anchor(s).

25 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 fe9447b0c0c32c0f2e936b02be0b36c341e081a1.

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/spec-changes.json) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 118 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 fe9447b0c0c32c0f2e936b02be0b36c341e081a1packageMentionDocs.

Which tree this was computed on

This run read content/docs from 078e1bb8c9d407d4c40b6b75a796e8595aa8cc74 — the merge of head 00a79d51eb0c61e39c76678374ad2237903fa169 into base fe9447b0c0c32c0f2e936b02be0b36c341e081a1, 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 078e1bb8c9d407d4c40b6b75a796e8595aa8cc74 && git checkout 078e1bb8c9d407d4c40b6b75a796e8595aa8cc74
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fe9447b0c0c32c0f2e936b02be0b36c341e081a1 00a79d51eb0c61e39c76678374ad2237903fa169 && git checkout -B drift-repro fe9447b0c0c32c0f2e936b02be0b36c341e081a1 && git merge --no-ff 00a79d51eb0c61e39c76678374ad2237903fa169
node scripts/docs-audit/affected-docs.mjs --json fe9447b0c0c32c0f2e936b02be0b36c341e081a1

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

@os-warren
os-warren marked this pull request as ready for review August 19, 2026 14:05
@os-warren
os-warren added this pull request to the merge queueAug 19, 2026
Merged via the queue into main with commit 73cfddfAug 19, 2026
27 checks passed
@os-warren
os-warren deleted the claude/issue-9730-retire-delegated-from-ups branch August 19, 2026 14:27
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-warren@claude