Skip to content

feat(plugin-security,plugin-auth,verify,platform-objects)!: a permission-set grant names its set by name — no writer fills permission_set_id (#15204 stage 6b-2-pre) - #22863

Merged
os-zhuang merged 16 commits into
mainfrom
claude/issue-15204-s6b2pre-grant-by-name
Oct 11, 2026
Merged

os-zhuang merged 16 commits into
mainfrom
claude/issue-15204-s6b2pre-grant-by-name

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Part of #15204

Clause-②: yes (narrowing)

Stage 6b-2-pre, grant by name (ADR-0131 D10's first step: every grant names its set, then the id column goes). This PR leaves #15204 open: stage 6b-2 (the seeders), stage 8 / C7b (the permission_set_id column and its data) and the rest of the epic remain.

What changes

sys_user_permission_set.permission_set is the grant's one reference to its permission set, by machine name. permission_set_id is optional; no writer fills it, and no write derives anything from it.

Surface Before Now
objects/sys-user-permission-set.object.ts permission_set_id required lookup; permission_set read-only, derived permission_set required and writable; permission_set_id optional ("legacy id"); title reads the name
grant-permission-set-name.ts hooks derived the name from the id row, refused a disagreeing name hooks refuse a non-system write naming a set the security catalog does not hold: 400 VALIDATION_FAILED, reference_not_found at permission_set (sibling of position-catalog-refusal.ts); a missing name is the engine's 400 VALIDATION_FAILED / required
bootstrap-platform-admin.ts (promotion) needed the seeded admin_full_access row id writes { permission_set: 'admin_full_access' }; findExistingPlatformAdmin reads by name only
auto-org-admin-grant.ts resolved the organization's own row id (per-organization copy under a wall) writes the name; checks the bound security catalog holds the set; no row read to grant
plugin-auth/auth-manager.ts (self-registration) read sys_permission_set rows for the id writes the staged name; admission's catalog + ledger check is the only read
plugin-auth/last-admin-guard.ts a write re-pointing the id was read as losing standing standing rides on the name alone; permission_set_id left GRANT_STANDING_KEYS
delegated-admin-gate.ts an insert / re-point was judged by the id's row name judged by the written name, against the catalog
cleanup-package-permissions.ts (uninstall) revoked grants by the set row's id revokes grants naming the set, plus legacy grants by id
verify/src/rls.ts refused when the boot seeded no row; wrote the row id writes the name; no row read
platform-objects/pages/sys-user.page.ts picker stored the row id (linkField: permission_set_id) picker stores the name (valueField: 'name', linkField: 'permission_set'); list shows the name

The property 6b-2 needs, pinned: no grant path needs a sys_permission_set row. Fresh-database pins with zero rows: the organization-admin reconcile under a wall and under single (grant-permission-set-name.writers.test.ts, auto-org-admin-grant.test.ts), the data door (grant-permission-set-name.test.ts), self-registration (plugin-auth/audience-posture.test.ts), the verify persona (verify/rls-persona-grant.test.ts). The first-platform-admin promotion still runs inside bootstrapPlatformAdmin, which seeds rows until 6b-2; its grant is pinned name-only and the holder read is pinned with every row deleted afterwards.

Restrict-only legacy reach (kept, deliberately). A grant written before the name column carries only its id until the existing one-time backfill names it (at kernel:bootstrapped). These reads reach such a grant through its id only to restrict: the platform-admin bootstrap's withheld promotion (leg C, now only when a legacy row exists), the org-admin revoke / superseded-variant sweep / duplicate check, and the uninstall sweep. Nothing confers through the id. They go with the column (C7b). The backfill itself and the D10 reference report are unchanged.

Not in this stage (measured)

  • sys_position_permission_set.permission_set_id (the junction lookup) stays: since stage 1 it grants nothing; its writers left on this tree are Setup's position page, the app-crm / app-showcase bind-position-sets.ts, U2's suggestion accept, and the anchor-binding gate in security-plugin.ts. It goes with the junction object in stage 8.
  • packages/spec/src/ui/component.zod.ts (~:1597): the TSDoc's "Assigned Users" example named relationshipField=permission_set_id, which this PR's head refuses. The patch round (the seat's review 6110944288, commit d453ea50d7) rewrote it by name (relationshipField=permission_set, relationshipValueField=name) with a @objectstack/spec patch changeset. It is text only, and no generated artifact moved. The same round moved content/docs/api/error-catalog.mdx's dangling-reference example to sys_user_permission_set.user_id → sys_user. (Updated by the seat.)
  • suggested-audience-bindings.ts untouched (U2's; feat(plugin-security)!: a package's isDefault suggestion takes effect once a manage_metadata holder accepts it for the deployment (#15204 stage U2) #22854 landed and is merged into this branch).

Setup's grant picker and objectui

The picker now writes the name with objectstack metadata alone. It still lists sys_permission_set rows: objectui's RelatedList Add picker (pinned 0df67f237c5a, packages/plugin-detail/src/RelatedList.tsx) reads object records only (add.picker.object through the data source); C9's registry reads live in the metadata-admin pages, not in this picker. Listing the security catalog needs an objectui change; reported to the seat for objectui#7611.

Size, tiers and review

  • 94 files, 845 additions / 1,867 deletions = 2,712 changed lines (under 3,000; generated translations and test deletions included; updated by the seat after the patch round).
  • Tier H: skills/objectstack-data/rules/security.md is edited — it prescribed writing the record id into permission_set_id, which this PR refuses. Readings: that file 216 → 213 lines; the skills/objectstack-data package (all .md) 3,721 → 3,718.
  • A same-head contract-tier review is owed (Clause-②: yes (narrowing)); the PM spawns it.
  • Changesets: @objectstack/plugin-security minor (BREAKING, ADR-0087 not-required (no-migration-prescription)), @objectstack/plugin-auth, @objectstack/verify, @objectstack/platform-objects patch, and @objectstack/spec patch (TSDoc text only).

Verification (local)

plugin-security and the dogfood files below were re-run on the merged tree (origin/main 55382dc02, U2 included); the other suites ran on this branch before that merge, which brought no change to plugin-auth, verify, platform-objects, plugin-hono-server, plugin-sharing or organizations source (it did touch rest and cli; CI re-runs them).

  • Build: closure of @objectstack/dogfood (includes all four packages) after the origin/main merge.
  • @objectstack/plugin-security full suite on the merged tree: 196 files, 3,973 passed. @objectstack/plugin-auth: 136 files, 2,774 passed. @objectstack/verify: 31 files. @objectstack/platform-objects: 69 files, 1,083 passed. @objectstack/rest: 277 files, 5,454 passed. plugin-hono-server 28, plugin-sharing 41, organizations 12 files green. @objectstack/cli: unit tier 281 files, 4,185 passed; integration tier package-install-local-uninstall-cleanups 16 passed (it grants the package set by name and proves the uninstall revokes it).
  • Dogfood, full: on the first run 235 files passed, 1 was skipped and 11 were red. The 11 red files were fixture grants by id; they were fixed and re-run green (11/11), plus U2's suggested-binding-acceptance after the merge. (Count corrected by the seat; the dev's report flagged it.)
  • Typecheck: plugin-security, plugin-auth, verify, platform-objects, dogfood, rest, plugin-hono-server, plugin-sharing, organizations, cli: all green.
  • dispatch-gates --ran: 106 derived families accounted for, 106 run; every one green or re-run green after a fix (check-system-context-census row 23c rewritten; check:query-options-erasure back at 236 after removing three test casts). Three first runs were prerequisite-blocked (shallow clone, unbuilt dists) and re-measured green after fetching the fixture commit and building.

Acceptance notes

  • Uniqueness: the declared unique index still names (user_id, permission_set_id, organization_id). A by-name grant stores NULL there, which SQL keys apart, so two by-name grants of one set to one user in one organization are not refused by the database; every platform writer checks existence by name first. A unique key on the name belongs with the column drop (C7b), which can dedupe the data.
  • Pre-ADR-0113 databases: a column created NOT NULL before protocol 17 for permission_set_id would refuse a name-only insert at the driver. ADR-0113's ruling records such databases as not measured to exist; the drift report already offers relax_not_null for it.
  • Writing permission_set_id is not refused; it is stored and read by nothing that grants. Refusing it is left to the column's retirement rather than built here.
  • Translated labels and help (zh-CN, ja-JP, es-ES) for the two columns were rewritten so they no longer describe the retired derivation.

维护者速读(草稿)

改了什么:用户↔权限集的直接授权(sys_user_permission_set)改为只按权限集的名称记录。所有写授权的地方(首个平台管理员、组织管理员自动授权、自助注册、verify 探针、Setup 用户页的「授予权限集」)都只写名称,不再读 sys_permission_set 行去取 id;permission_set_id 变为可选,不再被写入或用于推导任何东西。非系统写入若名称不在安全目录中,返回 400 VALIDATION_FAILED(reference_not_found)。

为什么改:ADR-0131 D10 规定先让每条授权按名称引用权限集,再删除 id 列。下一阶段 6b-2 要删除写这些行的种子程序;若授权仍依赖这些行,新库将没有首个平台管理员、墙内没有组织管理员授权、Setup 授权选择器为空。本 PR 让授权路径不再需要这些行。

风险与代价(含回滚):外部调用方若仍只写 permission_set_id 会被 400 拒绝(需改写为 permission_set: '集名'),changeset 已写明。旧数据的授权仍由已有的一次性回填命名,旧 id 仅用于撤销与去重。数据库层面对按名称的重复授权没有唯一约束,由写入方先查后写。回滚:直接 revert 本 PR,无数据迁移、无列变更。另:改了对外发布的技能文件 skills/objectstack-data/rules/security.md(原文指导写 id,现已不成立),因此本 PR 为 Tier H。

席位意见:

你要做的:契约复核(PM 安排)通过后,批准合并(Tier H)。另需决定 Setup 授权选择器何时改为直接列出安全目录(需 objectui 改动)。


Generated by Claude Code

claude added 15 commits October 11, 2026 13:39
… their permission set — no writer fills permission_set_id

Claude-Session: https://claude.ai/code/session_01MZuVPXoYeF1Pa3G4ERmTPz
Co-authored-by: Claude <noreply@anthropic.com>
…min reconcile, name backfill)

Claude-Session: https://claude.ai/code/session_01MZuVPXoYeF1Pa3G4ERmTPz
Co-authored-by: Claude <noreply@anthropic.com>
…ngine in tests; the reconcile's duplicate check keeps its legacy reach

Claude-Session: https://claude.ai/code/session_01MZuVPXoYeF1Pa3G4ERmTPz
Co-authored-by: Claude <noreply@anthropic.com>
…s its set with zero sys_permission_set rows

Claude-Session: https://claude.ai/code/session_01MZuVPXoYeF1Pa3G4ERmTPz
Co-authored-by: Claude <noreply@anthropic.com>
…m-context census row follows the by-name refusal

Claude-Session: https://claude.ai/code/session_01MZuVPXoYeF1Pa3G4ERmTPz
Co-authored-by: Claude <noreply@anthropic.com>
…id; no new query-option casts

Claude-Session: https://claude.ai/code/session_01MZuVPXoYeF1Pa3G4ERmTPz
Co-authored-by: Claude <noreply@anthropic.com>
…no duplicate keys

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

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/plugin-security, @objectstack/spec, @objectstack/verify, touching 73 documentable anchor(s).

50 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 55382dc02a2168585a024ba142b98d8bce9910e6.

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

What this run could not see
  • 2 anchor(s) matched too much of the corpus to be a work list: created_at (literal, 38 pages), organization_id (literal, 33 pages)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 147 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 55382dc02a2168585a024ba142b98d8bce9910e6 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 55382dc02a2168585a024ba142b98d8bce9910e6

⚠️ 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 55382dc02a2168585a024ba142b98d8bce9910e6 → 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

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 92bda9e9dd0ebc0b2f02962003ab99ed66c97c67
Local-runs: none

PR #22863 (stage 6b-2-pre of #15204, branch claude/issue-15204-s6b2pre-grant-by-name, base main at 55382dc02a), read as the net diff against main at the head above: 91 files, +833 / −1,860. Judged against seat answer 6109520247 (Q1 → A, the stage this PR builds), the stage-8 measurement 6110720794 and its seat answer 6110777865 (E2), and ADR-0131 D4 / D10 as the record the stage executes. Read-only: the diff, the card's body and comments, the head's check-runs; nothing built, run or re-run.

The stage's three sentences (6109520247, Q1 → A), each against the diff:

  1. "every grant writer stores the set's machine name only" — met. The five writers the 6b-2 stop named write { permission_set: name } and read no sys_permission_set row: the bootstrap-platform-admin.ts promotion, auto-org-admin-grant.ts (walled and single), plugin-auth/auth-manager.ts self-registration, verify/src/rls.ts, and the Setup user page's Add picker (linkField: 'permission_set', valueField: 'name'). Pinned on databases holding zero sys_permission_set rows: grant-permission-set-name.writers.test.ts, auto-org-admin-grant.test.ts, grant-permission-set-name.test.ts, plugin-auth/audience-posture.test.ts, verify/rls-persona-grant.test.ts. The repository's own fixtures (dogfood, cli, rest, hono-server, sharing, organizations) were moved to the name.
  2. "permission_set_id stops being required, and nothing writes or derives from it any more" — met. required: false; no writer fills it; grant-permission-set-name.ts no longer derives the name from it; last-admin-guard and delegated-admin-gate judge the name. What still reads the id does so only to restrict (the one-time backfill, the D10 reference report, bootstrap leg C, the org-admin revoke / superseded-variant / duplicate reach, the uninstall reach), each labelled for C7b. That is D10's own order: mirrors and the column go after the rewrite is verified, never before.
  3. "the Setup grant picker lists the security catalog" — not met on this head, by the dev's own account: the picker writes the name but still lists sys_permission_set rows, because objectui's related-list Add picker reads object records only. Seat answer 6110777865 moved this sentence to E2 (C9 part 3, measure-only, on objectui#7611) and rules that 6b-2-pre lands with the picker on rows, which exist until 6b-2. The gap is owned and gates 6b-2's landing, not this PR's; this record does not fail on it.

① Derived judgments

Every accept-set and public-surface change the diff implies, named right or wrong:

  1. sys_user_permission_set.permission_set: required: false, readonly: true → required: true, writable. Narrowing for every caller: an id-only write is now refused 400 VALIDATION_FAILED / required (pinned for a non-system and a system writer). Widening for non-system callers: the name is now theirs to supply. Right — D4, "every reference names it by its machine name".
  2. sys_user_permission_set.permission_set_id: required: true → required: false, relabelled "Permission Set (legacy id)"; the Field.lookup('sys_permission_set') declaration stays. Widening (an id-less write lands). Right for this stage; the lookup's target retires in stage 8, where E3 → A turns the field into Field.text — not here.
  3. New write-time refusal: a non-system insert, or an update that changes permission_set, naming a set the bound security catalog does not hold → 400 VALIDATION_FAILED, reference_not_found at permission_set, constraint { target: 'permission', targetField: 'name' }. It replaces the former invalid_value mismatch envelope (and its constraint.derivedFrom). Narrowing on the value domain, in the envelope sys_user_position.position already answers with. Right — one dialect for "this reference names nothing", no new error code (ADR-0112 / ADR-0114). A deactivated set is still accepted and switched off by the activation ledger, not the write door — right under D3.
  4. System writes are no longer judged against the catalog (the former derivation refused a disagreeing name from system writers too). Widening for isSystem only; stored as written, confers nothing until the name resolves. Right — the seed-ordering stand-down the engine's own lookup refusal takes; declared in the changeset and in census row 23c.
  5. A catalog read that throws refuses nothing: the write proceeds and the operator is told at warn (pinned). The former hook refused on a thrown read. Widening in a degraded state only: no grant confers through a name that resolves nowhere (D4 fails closed at resolution; the D10 reference report names it at boot). Right, with one note: the changeset text does not say it. Not a failure — pinned, logged, inert for authorization.
  6. plugin-auth/last-admin-guard.ts: permission_set_id / permissionSetId leave GRANT_STANDING_KEYS; simulatedGrantSetName is deleted. A write re-pointing the sole administrator's grant by id is no longer refused 403 (pinned: "writing the legacy id moves no standing"); re-naming it by permission_set is still refused (pinned). Widening, right: nothing derives standing from the id any more.
  7. delegated-admin-gate.directGrantSet: an insert or a re-name is judged by the written name against the catalog; no loadSetRowById on this path. Right.
  8. auto-org-admin-grant.ts: resolvePermissionSetId (the per-organization row resolution, its cache and its refusal warning) is deleted; the grant is written by name once orgAdminSetMissingFromCatalog (reading securityCatalogReaderOf(ql)) answers. Behaviour change: under a wall an organization with no own sys_permission_set copy is now granted where it was skipped / permission_set_missing; the skip survives only when the bound catalog lacks the set, and a new reason: 'catalog_unreadable' (a new value on the result) covers a catalog read that throws. backfillOrgAdminGrants no longer returns early on zero rows. The duplicate check reaches a legacy unnamed grant only through a row the backfill would name it by (organization-less or the organization's own); a legacy grant on another organization's copy is left and the pair is granted by name — one conferring row, pinned. Right under D3 / D4; this widening is the stage's purpose.
  9. bootstrap-platform-admin.ts: the promotion writes the name; findExistingPlatformAdmin reads by name with leg C off (adminPsId undefined); bootstrapPlatformAdmin keeps admin_permission_set_missing (the set is not shipped) and admin_permission_set_unreadable (the row table did not answer, so leg C cannot ask — no promotion). Right, pinned with every sys_permission_set row deleted afterwards. Unchanged here, correctly: the single grant still carries organization_id: null; D5's Default-Organization ownership is another stage's sentence, not this one's.
  10. cleanup-package-permissions.ts: revokes grants naming the set plus legacy grants by the set row's id, de-duplicated by row id. Right — uninstall still revokes (the cli integration test pins it).
  11. verify/src/rls.ts: no row read; the "boot seeded no row" refusal is gone, the "catalog does not serve the set" refusal stays. Right.
  12. platform-objects/pages/sys-user.page.ts: list column permission_set (the id column leaves the list), picker valueField: 'name', linkField: 'permission_set', still object: 'sys_permission_set'. Right as far as objectstack metadata reaches (sentence 3 above).
  13. Exports: plugin-security's grantSetNameMismatchMessage / grantSetNameMismatchError are removed and grantSetNotInCatalogMessage / grantSetNotInCatalogError added; registerGrantPermissionSetNameHooks(engine, logger?) gains an optional parameter; plugin-auth's grant-set-name.ts drops GRANT_SET_ID_FIELD (its header: internal, not re-exported). No index.ts is in the file list, so no package entry changes; the plugin-security minor covers its removals either way.
  14. Object metadata: titleFormat, highlightFields and the display_title formula read permission_set; the generated translations (en, zh-CN, ja-JP, es-ES) are rewritten for both columns and no longer describe the retired derivation. Right.
  15. Docs and skill: permission-sets.mdx grants by name; system-context.mdx row 23c is rewritten and the census counts move 122 → 123 (the hooks' new session.isSystem read); skills/objectstack-data/rules/security.md prescribes permission_set by name and calls permission_set_id legacy. Right; the skill edit is what makes the PR Tier H, as the dev reports.
  16. Not changed, correctly: no index change, no DDL, no data rewrite, no boot migration — D10 keeps every one of those for the operator ceremony (C7b). The declared unique index still keys permission_set_id (③ Q4).

ADR-0131 reading: this is D10's first step as the seat named it — every writer references the set by name (D4); the write door and the writers consult the registry-backed catalog and nothing else (D4, "resolution reads the registry"); nothing is deleted or constrained before the rewrite is verified (D10 fate 2's gate). None of §8's inherited rules is touched: no driver arm, no default owner, no deletion, no merged two-source list, no edit under content/docs/releases/.

② Semver level

  • .changeset/15204-grant-by-name-security.md: @objectstack/plugin-security minor, BREAKING named, with before / now / unchanged / remedy paragraphs and the ADR-0087 disposition not-required (no-migration-prescription). Matches the diff: the accept-set change is on a platform object's data rows (two declared fields flip required / optional and a value-domain refusal is added), not on any spec schema key, so there is nothing for objectstack migrate meta to rewrite and no tombstone; the existing one-time backfill and the id column's later drop are the migration story it names. Right.
  • .changeset/15204-grant-by-name-writers.md: @objectstack/plugin-auth, @objectstack/verify, @objectstack/platform-objects patch, one sentence each that matches its diff (self-registration by name with no row read and the guard reading the name alone; the probe persona no longer needing a row; the picker writing the name while still listing rows). Right under the repository's launch-window convention as Check Changeset (green on this head) reads it. One reading for the seat: plugin-auth's GRANT_STANDING_KEYS loses two members and a formerly-403 write now lands — declared in the changeset text, a loosening that confers nothing; patch is defensible and the gate accepts it.
  • The PR body's Clause-② declaration reads yes (narrowing), the one spelling for a diff that widens one surface and narrows another (clause2-line.mjs), which is exactly ① rows 1–6: narrowing on the data door (the name is required and must be in the catalog), widening on the id column, the system stand-down and the standing keys. Right, and it matches the minor + BREAKING changeset. The expectation in 6109520247 ("a required field becomes optional") names the widening half; the arm names the breaking half the gates must see.
  • Noted, not failed: the catalog-unreadable stand-down (① row 5) is absent from the changeset text.
  • Size: 2,693 changed lines, under the 3,000 line (Check PR Size green).

③ Boundary flags

Dev report 6110797712, open_questions, each answered:

  1. The grant picker still lists sys_permission_set rows → A, and already escalated: 6110777865 sends it to C9 part 3 (objectui#7611, measure-only, including the spec-18 pin question); 6b-2's landing is gated on E2 or a maintainer ruling; this PR lands as built. The position picker shares the gap (the dev's third out-of-scope finding) and rides the same measure.
  2. The restrict-only legacy-id reaches (backfill, D10 report, leg C, org-admin revoke / superseded / duplicate, uninstall) → A, keep them until C7b. Each one only restricts; deleting them now opens the second-platform-admin and standing-organization_admin windows on an upgraded database's first boot; D10 sanctions the deletion only after the verified rewrite; [Decision] ADR-0131 剩余部分(C2 余下阶段 + C3):继续分段、合并成一次切换,还是改成「registry 支撑的对象」 #22601 B forbids conferring fallbacks, and none of these confers.
  3. Refuse a written permission_set_id now? → A, leave it inert. The name is required, so an id-only write already fails loudly; an id beside a name is read by nothing that grants; a refusal for a column C7b drops is a one-stage gate. Carried to C7b: the unique index still keys the id, so a stray id written beside a name is the one way a by-name grant can still collide in the database — harmless, and it goes with the column.
  4. The by-name unique key → A, with C7b. Until then the database does not refuse two by-name grants of one set to one user in one organization from the data door (the platform writers check by name first). Grants are additive, so a duplicate confers nothing extra; the ceremony dedupes before it constrains, and an index added here would fail on live data this stage is barred from rewriting.

PR body acceptance notes: uniqueness (= Q4). Pre-ADR-0113 databases with a NOT NULL permission_set_id → accepted: recorded as not measured to exist, and the drift report's relax_not_null names the remedy for an operator who meets one. Writing permission_set_id not refused (= Q3). Translations rewritten → verified in the diff, four locales.

out_of_scope_findings: the junction sys_position_permission_set.permission_set_id and its writers → stage 8 (the junction object retires there; E3 → A covers the user-grant lookup); the component.zod.ts TSDoc naming relationshipField=permission_set_id → prose only, stage 8 or the next spec toucher; the position picker → E2. All three are already carried on the card; nothing new to file.

deviations: (1) skills/objectstack-data/rules/security.md edited → Tier H; the shipped text was falsified by the diff and now matches it — right to edit, maintainer approval owed. (2) Docs and fixtures outside the landing zone → each a producer this change falsified; right. (3) Spec not regenerated → right, the mention is prose. (4) label-write no-op → nothing to judge. (5) Footer form → nothing to judge. (6) PR body "236 files green" against the report's 235 passed + 1 skipped + 11 red, then fixed → a body figure the seat may correct; the record does not turn on it.

Not in this stage (PR body): suggested-audience-bindings.ts (U2, #22854) untouched — U2 writes the junction, not a user grant; the dev's merged-tree dogfood run covered its acceptance suite, and the head's Dogfood Regression Gate (all three shards green, below) is the gate of record.

One pin removed and not replaced: an unrelated update to a legacy unnamed grant (name NULL, before the backfill) through the engine is no longer pinned to land without naming it; the object comment says it does ("an update that leaves the column alone does not have to name it"). Not a contract change this PR makes; noted for the backfill's own tests.

Gates on this head, read at render time. Completed and green: Auto Label, Build Core, Build Docs, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate (1/3, 2/3, 3/3), Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, Temporal Conformance (live PG + MySQL), Test Core (6/6), The card this PR closes must claim this branch, Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, Type Check · workspace, filter. Skipped by design: Console Pin Gate, Packed-tarball smoke (opt-in). Still pending at render time: Test Core (1/6), (2/6), (3/6), (4/6), (5/6). Their conclusions are the gate verdicts; the queue holds the landing on them and this record does not stand in for them. Nothing on the head is red.

Implemented-by: session_01MZuVPXoYeF1Pa3G4ERmTPz
Reviewed-by: session_01Rerax7QTjKMPCUZxQUtPFR

VERDICT: PASS


Generated by Claude Code

…ce example no longer name permission_set_id

Claude-Session: https://claude.ai/code/session_01MZuVPXoYeF1Pa3G4ERmTPz
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d453ea50d7f894e4bf451b51778df0a952f8500c
Local-runs: none

Delta round on PR #22863 (stage 6b-2-pre of #15204, branch claude/issue-15204-s6b2pre-grant-by-name, base main at 55382dc02a). The previous head 92bda9e9dd0ebc0b2f02962003ab99ed66c97c67 carries a same-form PASS, record 6110902443; seat review 6110944288 sent a text-only patch round for two published texts; the dev's addendum is #15204 comment 6111119186. Read on this head: the compare 92bda9e9dd...d453ea50d7, the net diff against main (94 files, +845 / −1,867 = 2,712 changed lines), the card's body and every comment, seat answer 6109520247 (Q1 → A), the stage-8 measurement 6110720794 and its seat answer 6110777865 (E2), ADR-0131 D4 / D10 on main, and the head's check-runs. Read-only: nothing built, run or re-run.

(a) The delta is one text-only commit. compare/92bda9e9dd...d453ea50d7: ahead by 1, behind by 0, one commit (d453ea50d7, "docs(spec,api): the related-list Add example and the dangling-reference example no longer name permission_set_id"), three files, +12 / −7: .changeset/15204-grant-by-name-spec-tsdoc.md (added), content/docs/api/error-catalog.mdx (one JSON example), packages/spec/src/ui/component.zod.ts (two lines inside the doc-comment block above add:). Every changed line is a comment line, Markdown or a changeset; no schema member, .describe() string, export, type or test moves. Text only, as the addendum says. The addendum's size line reads 850 / 1,867 = 2,717; the files API sums to 845 / 1,867 = 2,712 (833 + 12, 1,860 + 7). Five lines of arithmetic, both under 3,000; Check PR Size is green.

(b) The delta's own ①②③.

① The TSDoc on RecordRelatedListProps.add — true on this head. The canonical example now reads objectName sys_user_permission_set, relationshipField permission_set, relationshipValueField name, picker.object sys_user, linkField user_id. On this head sys_user_permission_set.permission_set is a required, writable Field.text holding the set's machine name (objects/sys-user-permission-set.object.ts), and relationshipValueField's own TSDoc on the same schema (component.zod.ts:1524-1531) says it is used both to filter the list (relationshipField equals the parent's relationshipValueField value) and as the parent-side value the Add picker writes. So the example's Add writes permission_set = the set's name and user_id = the picked user's id — a row the grant object accepts and the catalog name refusal passes for any set the catalog holds. It is the mirror of the Holders example that paragraph already gives for sys_user_position.position. One imprecision is pre-existing, not the delta's: two lines above, the link-row sentence still spells the parent side parentId, which for the example beneath it now means the parent's relationshipValueField value. Not a false statement about the accept set; noted for the next spec toucher, not failed.

② The error-catalog example — matches the engine's real refusal at this head. packages/objectql/src/engine.ts#assertReferencesResolve (:8557-8641) builds buildFieldError({ field, code: 'reference_not_found', def, value: String(v), constraint: { target } }) for a non-empty, caller-supplied, non-readonly reference whose probe answers false, and throws ValidationError(failures). The reference_not_found template in packages/spec/src/system/validation-message.ts:129 is {{label}}: no {{target}} record has id "{{value}}"; resolveFieldLabel returns the declared label; sys_user_permission_set.user_id is Field.lookup('sys_user', { label: 'User', required: true }), not readonly, so a caller's value is checked. Rendered: User: no sys_user record has id "usr_missing", field user_id, code reference_not_found, label User, constraint { target: 'sys_user' }, value usr_missing — the six values the example shows. The top-level error equals the one field's message, which is what ValidationError's constructor joins (record-validator.ts:238-256), and the callout's three non-rejections (an empty value, an isSystem write, a target that cannot be checked) are the method's own guards (:8565, isEmptyReferenceValue, a null from referenceExists). The entry elides message exactly as the previous example did. The dev's probe (6111119186) returned the same six values; this record did not re-run it. The example now names a lookup that outlives the cutover: sys_user is not among the objects ADR-0131 retires, and user_id stays a lookup after C7b.

③ The spec changeset level — patch fits. The spec diff is a doc comment: not a .describe(), not a schema member, so nothing in sdui.manifest.json, the authorable surface, the liveness ledger or any other generated artifact moves (the addendum's check:generated found all 14 artifacts up to date; Spec property liveness and Build Core are green on this head). The repository's convention for a shipped-TSDoc-only change in packages/spec is patch, three times on this card: #22709 (6101036695), #22711 (6101787808), U2 #22854 (6109979840). The changeset sentence ("names its canonical example by name … Text only; no schema changes") matches the diff. Check Changeset is green on this head.

(c) The earlier record on this head. Nothing in 6110902443's ①–③ changes: the compare shows the delta touches no source, test, object, page, skill or changeset that record judged, so every hunk it read is byte-identical here. Three of its lines move: its ① row 15 gains the two texts above; its ③ out_of_scope_findings item 2 (the TSDoc → "stage 8 or the next spec toucher") is done on this head instead; its deviations item 3 ("spec not regenerated — right, the mention is prose") still holds with the TSDoc now edited, since the edit regenerates nothing.

① Derived judgments

Adopted by citation from 6110902443, rows 1–16 and its ADR-0131 reading, each re-read against the net diff at this head: permission_set to required / writable (D4; narrowing on the data door, widening for non-system callers); permission_set_id to optional with the lookup kept (D10's order); the reference_not_found catalog refusal replacing the invalid_value derivation; the isSystem stand-down; the catalog-unreadable warn-and-proceed; GRANT_STANDING_KEYS losing the two id spellings; directGrantSet judged by the written name; the org-admin reconcile granting by name with catalog_unreadable as a new skip reason and the backfill no longer returning early on zero rows; the promotion by name with leg C only on a legacy row; uninstall revoking by name plus legacy id; verify by name; the user-page picker writing the name (valueField: 'name', linkField: 'permission_set') while still listing rows; the export changes; metadata, translations, docs and skill; and nothing deleted, indexed, migrated or constrained. All right, as before.

The delta adds:

  1. packages/spec TSDoc on RecordRelatedListProps.add: the canonical example keys the grant on permission_set with relationshipValueField: 'name'. Right — (b) ①. No accept-set change: no schema member moves.
  2. content/docs/api/error-catalog.mdx dangling-reference example: sys_user_permission_set.user_id → sys_user, usr_missing. Right — (b) ②. Documentation; no public surface moves.

Seat answer 6109520247's three sentences stand as the earlier record found them: 1 and 2 met on the diff; 3 ("the Setup grant picker lists the security catalog") not on this head, moved by 6110777865 to E2 and since measured (6111111092: on a fresh main database the grant picker still matches its registry 17 of 17 until 6b-2; the path is product decision #22865, which gates 6b-2's landing and stage 8, not this PR). This record does not fail on it.

② Semver level

  • .changeset/15204-grant-by-name-security.md — @objectstack/plugin-security minor, BREAKING, ADR-0087 not-required (no-migration-prescription). Unchanged since 6110902443; right for the reasons given there.
  • .changeset/15204-grant-by-name-writers.md — @objectstack/plugin-auth, @objectstack/verify, @objectstack/platform-objects patch. Unchanged; right.
  • .changeset/15204-grant-by-name-spec-tsdoc.md — @objectstack/spec patch, text only. New on this head; right — (b) ③.
  • Clause-②: yes (narrowing) on the PR body, line 2 — unchanged, still the one spelling for a diff that widens one surface (the id column optional, the isSystem stand-down, the standing keys) and narrows another (the name required and catalog-checked). The delta adds no widening and no narrowing; the arm still matches the minor + BREAKING changeset. Right.
  • Every package whose src/** the diff moves is graded: plugin-security, plugin-auth, verify, platform-objects, spec. Check Changeset is green on this head.
  • Noted for the seat, not failed: the PR body is not edited (os-dev forbids it), so three of its sentences are stale on this head — "Not in this stage" bullet 2 (the TSDoc "left untouched (no spec regeneration in this PR)"), the size line (91 files, 2,693), and the "Changesets:" line, which does not list @objectstack/spec patch. Body prose only; the Clause-② declaration and the changeset files, which the gates read, are right.

③ Boundary flags

Gates on this head, read at 2026-10-11T16:37Z. Completed and green: Auto Label, Build Core, Build Docs, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate (aggregate, 1/3, 2/3, 3/3), Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, Temporal Conformance (live PG + MySQL), Test Core (1/6), (2/6), (6/6), The card this PR closes must claim this branch, Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, Type Check · workspace, TypeScript Type Check, filter. Skipped by design: Console Pin Gate, Packed-tarball smoke (opt-in). Still pending at render time: Test Core (3/6), (4/6), (5/6). Their conclusions are the gate verdicts; the queue holds the landing on them and this record does not stand in for them. Nothing on the head is red.

Implemented-by: session_01MZuVPXoYeF1Pa3G4ERmTPz
Reviewed-by: session_01Rerax7QTjKMPCUZxQUtPFR

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读(终稿)· #15204 阶段 6b-2-pre:授权按名称记录

席位 epic:#15194(epic PM session_01Rerax7QTjKMPCUZxQUtPFR,marchtian)· 2026-10-11T16:55Z。本稿按席位自己读过的 diff 校正了 PR 正文里的草稿。审核记录在 #15204:ACCEPT 6111404714。

改了什么

  • 用户与权限集之间的直接授权(sys_user_permission_set),改为只按权限集的名称记录。
  • 写授权的地方全部只写名称,不再去读 sys_permission_set 数据行取 id,包括:首个平台管理员、组织管理员自动授权、自助注册、verify 探针、Setup 用户页的「授予权限集」。
  • permission_set_id 改为可选,不再被写入,也不再用来推导任何东西。
  • 非系统写入如果写了安全目录里没有的名称,返回 400 VALIDATION_FAILED(reference_not_found);不写名称返回 400 required。
  • 规范注释里的「已分配用户」示例和错误目录页示例,同步改为不再使用 permission_set_id。

为什么改

  • ADR-0131 D10 规定先让每条授权按名称引用权限集,再删 id 列,这是第一步。
  • 下一阶段 6b-2 要删掉写这些数据行的种子程序。如果授权还依赖这些行,新库会没有首个平台管理员,墙内也不会有组织管理员授权。
  • 本 PR 之后,授权路径不再需要这些行。PR 里有在零行数据库上跑的测试证明这一点。

风险与代价(含回滚)

  • 对外部调用方: 只写 permission_set_id 的调用会被 400 拒绝,需要改写为 permission_set: '集名'。变更说明里写明了。
  • 旧数据: 已有授权仍由现有的一次性回填补上名称。旧 id 只用于撤销和去重,不会因为它多给权限。
  • 重复授权: 数据库层面没有按名称的唯一约束,同一用户可能被重复授予同一个集。权限只会叠加,不会越权;唯一约束随 C7b 去重后再加。
  • 目录读不到时: 写入照常放行并记一条告警,和引擎外键检查的既有做法一致。解析权限时这条授权不会生效,所以不会多给权限。
  • 已知未完成: Setup「授予权限集」的选择框仍列数据行。6b-2 之前这些行都在,不受影响;改法等你裁 [Decision] 用户页「分配岗位」「授予权限集」两个选择器改读注册表:加规范字段(A/A')、去掉用户页入口(B1),还是给授权的名称字段配注册表选择控件(B2) #22865。
  • 为什么是 Tier H: 改了对外发布的技能文件 skills/objectstack-data/rules/security.md。原文教人写 id,这一条现在不成立了。
  • 回滚: 直接 revert 本 PR,没有数据迁移,也没有列变更。

席位意见
建议合并。

  • 契约审查两轮都是 PASS:6110902443(首轮)、6111245592(文字补丁轮)。
  • 当前提交 d453ea50d 上的 CI 全绿。
  • 合并 main 之后,完整 dogfood 249 个文件中 248 个通过、1 个跳过。
  • 席位复核时要求补的两处已发布文字已经修好。
  • 全部改动 94 个文件,2,712 行,在 3,000 行以内。

你要做的
手动合并本 PR;或者由 os-zhuang / hotlong 在 PR 上点 Approve,之后本席过落地前检,经合并队列落地。二选一即可。


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review October 11, 2026 17:07
@os-zhuang
os-zhuang enabled auto-merge October 11, 2026 17:08
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit 0253192 Oct 11, 2026
48 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-15204-s6b2pre-grant-by-name branch October 11, 2026 17:36
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 protocol:ui size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants