Skip to content

feat(plugin-security,cli,spec)!: retire sys_user_permission_set.permission_set_id; the grant key is unique per organization; the boot grant-name backfill is deleted (C7b S3) - #23503

Merged
os-zhuang merged 19 commits into
mainfrom
claude/issue-23430-grant-column
Oct 14, 2026
Merged

os-zhuang merged 19 commits into
mainfrom
claude/issue-23430-grant-column

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23430 · Part of #15211 (C7b stage S3; #15211 stays open for its other stages)
Clause-②: yes (narrowing)

What this does

Retires sys_user_permission_set.permission_set_id (ADR-0131 D4/D10, C7b S3).

plugin-security

  • Declaration. permission_set_id and its (permission_set_id) index leave the grant object. The unique key moves to { fields: ['user_id', 'permission_set', 'organization_id'], unique: 'organization' }. Its organization key part is NULL-safe (ADR-0120 D3, COALESCE(organization_id, '__global__')), so the index refuses a second platform-wide grant too, for every writer and every write path. This answers seat answer Q-S3a (feat(objectql,cli): inventory + migration — four fates per object, mirrors deleted only after the id→name rewrite is verified, per-table boot report (ADR-0131 D10) #15211 6167037655), which retired an earlier write-door check. A write naming the retired column is refused 400 INVALID_FIELD.
  • Boot backfill deleted. grant-permission-set-name-backfill.ts, its anchor and its kernel:bootstrapped wiring are gone (Q4 → A). The v18 ceremony's verified rewrite replaces them.
  • The id lead removed from three readers.
    • auto-org-admin-grant.ts: the id reach and the id dedupe are gone. Per Q-S3b (ii), the reconcile writes nothing for a pair that holds an unnamed grant in that organization.
    • catalog-reference-report.ts: UnnamedGrant.permissionSetId is gone. The remedy now leads with running the ceremony. This merges cleanly with 8-post's sixth class.
    • bootstrap-platform-admin.ts: Leg C no longer checks for a legacy id. Any unnamed unscoped human grant withholds the promotion.
  • Generated files. The i18n bundles are regenerated, and the ADR anchor for grant-permission-set-name.ts is restated.

cli: os migrate organization-ownership (the v18 ceremony)

spec: one ADR-0087 column-retired semantic entry, 18.ups-permission-set-id-column-retired, after the precedent 17.ups-delegated-from-column-retired.

Files outside the claim's literal surface

The claim names organization-ownership.ts only. Q5 → A and the review's fixes also need these cli files:

  • utils/organization-ownership-apply.ts (+ its test)
  • utils/organization-ownership-plan.ts
  • utils/organization-ownership-grants.ts (new)
  • utils/organization-ownership-column-drops.ts (new, + its test)
  • utils/organization-ownership-apply.served-catalog.integration.test.ts (one line)

The other files outside it:

  • plugin-auth: one test, plus two comment-only edits.
  • core: one comment-only edit (resolve-authz-context.ts).
  • Dogfood: the grant-name-pickers pin.
  • The tenant-audit census, re-measured.
  • scripts/engine-double-contract.pinned.json, which records the fixture's new engine shim.

Verification

All runs below are on head 7b50158db8 (main merged at 0d3ea295d1):

  • build (turbo, the cli, plugin-security, plugin-auth and dogfood closures): exit 0
  • plugin-security typecheck + full suite: 185 files, 3,690 passed, 45 skipped
  • plugin-auth full suite: 140 files, 2,815 passed, 10 skipped
  • cli typecheck: exit 0 · cli --project unit: 287 files, 4,307 passed · cli --project integration: 129 files, 1,125 passed, 2 skipped
  • spec build + check:generated + vitest src/migrations: 209 passed · core typecheck: exit 0
  • dogfood test/grant-name-pickers.dogfood.test.ts: 3 passed
  • node scripts/pm/dispatch-gates.mjs --ran: 127 derived, 127 run, all exit 0, 0 NOT MEASURED

Pins and their ablations. Each ablation went through scripts/ablation-replace.mjs, and each restore matched HEAD with an empty git diff HEAD.

  • Index. Respelling it unique: 'global' turned exactly the three platform-wide pins red: an insert by an admin and by a system writer, a two-row batch plus raw SQL beneath the engine, and a rename update. The org-scoped pin and the control stayed green.
    • Wire shape: the engine throws DUPLICATE_RECORD 409, and mapDataError answers 409 with code UNIQUE_VIOLATION.
    • A revoke of the one grant leaves no grant of that set in force, both org-scoped and platform-wide.
  • Q-S3b. With the skip removed, both pins go red. End to end, the real backfillOrgAdminGrants runs over a 17.x-shaped grant table that carries the declared index. It writes no row, --plan counts no collision, and the rewrite names the grant and completes.
  • Rewrite collision. The safety net still works for any other writer: --plan counts the pair, --apply blocks naming both ids and deletes nothing, and it finishes once the operator removes one grant. With the check disabled, the pin goes red.
  • Retired indexes before the column. This is pinned on a real SQLite driver whose 17.x table carries both legacy indexes. With index-first disabled, the ordering pin and the failed-index pin go red. On SQLite the column drops either way, so the MySQL refusal comes from reading the driver code, not from a run.

S5 ordering, read from S5's source and not run (claude/issue-23429-boot-refusal@540c5b3c28). On os serve, the boot guard (serve.ts:3880) is registered before the auto-registered SecurityPlugin (:4238), and neither depends on the other. kernel:ready dispatch is propagating, so the guard's refusal stops the org-admin sweep. The index is still created at that boot.

Acceptance notes

  • Size. 3,267 changed lines (+1,229 / −2,038). That is over the 3,000 threshold, so this lands only on a governed approver's approval, after the seat's contract-review ACCEPT. The card's required deletions alone are 1,359 lines: the backfill and its test.
  • Open questions (accepted by the seat): two NULL-safe concurrent writes are covered by the index itself. The reconcile's unnamed-grant read keeps the module's best-effort tryFind contract, and the ceremony's skip-and-report covers any pair that results.
  • On a 17.x database: between the first v18 boot and --apply, no grant confers anything, the platform administrator's included. Restart the server after --apply.
  • skills/objectstack-data/rules/security.md still says "a legacy column: do not write it". That is still right in effect, and editing it is Tier H, so it is left for a follow-up.

维护者速读(草稿)

改了什么

  • 用户授权表 sys_user_permission_set 下线旧的 permission_set_id 列及其两个索引。
  • 授权唯一键改为 (user_id, permission_set, organization_id),并声明为 unique: 'organization':组织为空(平台级)的重复授权也由索引直接拒绝。
  • 删除启动时的“授权按名补全”模块,由 v18 迁移仪式(os migrate organization-ownership --apply)的已验证改写取代。
  • 仪式在删列前先删两个旧索引;遇到会撞键的旧授权只报告、不删除;--plan 预先统计这些情况。

为什么改

风险与代价(含回滚)

  • 17.x 库在首次以 v18 启动到执行 --apply 之间,任何授权都不生效(包括平台管理员);--apply 后需重启。
  • 带 permission_set_id 写授权的种子或调用会收到 400 INVALID_FIELD;重复授权收到 409 UNIQUE_VIOLATION。
  • 回滚:revert 本 PR 即恢复声明与启动补全;已被仪式删除的列不会自动恢复(仪式本身不删任何授权行)。
  • 改动行数超过 3,000,需人工合并。

席位意见

(留空,由席位定稿)

你要做的

  • 席位契约复审 ACCEPT 后,以受治理审批账号批准本 PR,再由席位落地。

Generated by Claude Code

claude added 19 commits October 14, 2026 07:58
…t_id retired; unique grant index on the name; the ceremony drops the column (ADR-0131 D4/D10, C7b S3)

WIP: tests not yet run.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH
…y writer; fixtures drop the retired id; write-door pins

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fyKy7M7fEuwkfqc5JHdJJ
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fyKy7M7fEuwkfqc5JHdJJ
…chor for the retired grant id

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fyKy7M7fEuwkfqc5JHdJJ
…s 409 needs; re-measure the tenant-audit census

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fyKy7M7fEuwkfqc5JHdJJ
# Conflicts:
#	packages/cli/src/commands/migrate/organization-ownership.ts
#	packages/cli/src/utils/organization-ownership-apply.ts
#	packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts
…y; the plan counts grant keys without the name column; the retired indexes drop before the column

- organization-ownership-grants.ts: one pure decision for --plan and --apply.
  A legacy grant whose derived name its user already holds in its
  organization (NULL keyed with NULL) is kept unnamed and reported with both
  ids; the rewrite blocks on it and deletes nothing.
- --plan: sys_user_permission_set carries a grants block (duplicate groups,
  rewrite collisions); without the permission_set column it counts zero and
  names the boot, instead of refusing the whole plan (the inventory category
  is retired for it).
- columnDrops: the port lives in organization-ownership-column-drops.ts and
  drops a column's retired indexes in an earlier, separate apply.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fyKy7M7fEuwkfqc5JHdJJ
…s within one batch or multi-row write

A batch insert or a multi-row update runs every row's before* hook before
any row is written, so a read of the stored grants alone admitted two
identical platform-wide rows of one call. The door now keeps the keys of one
caller write in its dispatch.scope. Its 409 carries a cause naming what it
saw. Kept in its own commit: if unique: 'organization' replaces the door
check, this commit reverts cleanly.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fyKy7M7fEuwkfqc5JHdJJ
…the unnamed-grant report leads with the ceremony; operators restart after --apply

The changeset, the ADR-0087 entry, the object note and the ADR anchor say
what the door enforces (stored grants and repeats within one write; not two
concurrent single writes) and what the ceremony does with rewrite
collisions and the retired indexes. The catalog reference report's remedy
leads with running the ceremony: a grant re-written by name first is one the
ceremony reports, not names. Two stale comments name the retired column and
the deleted backfill no more.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fyKy7M7fEuwkfqc5JHdJJ
…s repeats within one batch or multi-row write"

Retires fix 2 (seat answers on #15211, Q-S3a): the grant key's index is
respelled unique 'organization', whose NULL-safe key part refuses a second
platform-wide grant for every writer, so the door's in-call check goes.

This reverts commit bc15097.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fyKy7M7fEuwkfqc5JHdJJ
…ation', and the door's platform-wide check retires

Seat answers on #15211, Q-S3a: ADR-0120 D3 makes an 'organization' index's
organization key part NULL-safe (COALESCE(organization_id, '__global__')),
so the index itself refuses a second platform-wide grant, for every writer
and write path (batches, multi-row updates, concurrent writes, raw SQL). The
write door's duplicate check, its DuplicateRecordError and plugin-security's
objectql dependency go; the pins now read the index's refusal, engine
envelope and wire shape (409 UNIQUE_VIOLATION), including a batch insert and
raw SQL beneath the engine.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fyKy7M7fEuwkfqc5JHdJJ
… nothing for a pair holding an unnamed grant in that organization

Seat answers on #15211, Q-S3b (ii): a named grant written beside a 17.x
grant the ceremony has not named yet would hold the very key the rewrite's
rename needs. The unnamed grant confers nothing meanwhile, as every 17.x
grant does until --apply; once named, the next sweep finds it. Pinned end to
end in the ceremony fixture: the real sweep over a 17.x grant table carrying
the declared index writes no row, and the rewrite then names the grant and
completes. The rewrite's skip-and-report stays as the safety net for any
other duplicate.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fyKy7M7fEuwkfqc5JHdJJ
…r every writer; restate the changeset, the ADR-0087 entry and the anchor

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fyKy7M7fEuwkfqc5JHdJJ
…ssertEngineDeleteDispatch; the pinned-double ledger learns it

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fyKy7M7fEuwkfqc5JHdJJ
…ant-column

# Conflicts:
#	content/docs/permissions/tenant-audit-census.mdx
#	docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
#	packages/plugins/plugin-security/src/catalog-reference-report.test.ts
#	packages/plugins/plugin-security/src/catalog-reference-report.ts
#	packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts
…eset's reconcile bullet moves out of the --plan list

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/cli, @objectstack/core, @objectstack/plugin-auth, @objectstack/plugin-security, @objectstack/spec, touching 111 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/core/src/security/resolve-authz-context.ts, packages/plugins/plugin-security/src/position-catalog-refusal.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

51 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 394f65e891f0ac6c6be708fb807740d5cb446520.

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

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/core/src/security/resolve-authz-context.ts, packages/plugins/plugin-security/src/position-catalog-refusal.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: logError (9 routes)
  • 1 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 35 pages)
  • 33 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 — 158 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 394f65e891f0ac6c6be708fb807740d5cb446520 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 394f65e891f0ac6c6be708fb807740d5cb446520

⚠️ 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 394f65e891f0ac6c6be708fb807740d5cb446520 → 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: 7b50158db85fc41fb1b6a7c2b77f00be5f857651
Local-runs: none

Served on opus by the maintainer's order 「后续契约复审用 opus就够了」 (seat note 6166448512 on #6367).

This re-reviews PR #23503 (branch claude/issue-23430-grant-column) after the pre-PR FAIL record 6166827307 at c38af85e0e, as amended by relay 6167238426 and seat answers 6167037655.

  • Net diff: merge base 0d3ea295d1 (8-post) to head. 47 files, +1,229 / −2,038 = 3,267 changed lines.
  • Face: packages/spec/src/** (the ADR-0087 entry) puts it on the published-schema face. No governed-surface path is touched.
  • Check-runs on this head: 41 in all: 36 success, 5 skipped, 0 failed.
    • Skipped: Console Pin Gate, the dogfood shards, Dogfood Verify CLI, the opt-in tarball smoke, and Temporal Conformance.
    • All five required contexts are success: Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard.
    • Test Core runs the cli integration project at OS_TEST_TIERS=queue, so the ceremony pins ran in CI.

① Derived judgments

Blocking fixes of 6166827307, as amended

  • Fix 1 (amended to a safety net): discharged.
    • Skip and report. organization-ownership-grants.ts planGrantRewrite is one decision, taken by both --plan and --apply.
      • It keys a grant by (user, name, organization), with a NULL organization keyed with NULL.
      • It checks stored named grants and grants named earlier in the same run. That second check also covers two legacy platform-wide grants of one id, which the old 'global' index allowed.
      • A collision blocks the rewrite. The blocker names both ids, and nothing is deleted.
    • The --plan count. The plan reports grants.rewriteCollisions.
    • End-to-end pins in organization-ownership-apply.test.ts: the Q-S3b sweep pin and the safety-net pin. Both run on a table carrying UNIQUE (user_id, permission_set, COALESCE(organization_id, '__global__')).
      • That index is the ADR-0120 D3 shape spelled by hand, because the fixture is raw SQLite.
      • The driver's own materialization of it is pinned in plugin-security (Q-S3a below).
      • Reported ablation: with the collision check disabled, the safety-net pin goes red.
    • The logReport wording. It leads with the ceremony, then the restart. It says that re-granting by name makes a second grant, which the ceremony reports and does not name.
  • Fix 2: withdrawn, and the withdrawal is clean.
    • bc150970b5 built the check, and b3420c9e6e reverts it exactly: git diff bc150970b5^ b3420c9e6e over its two files is empty. Then 32971a8646 retires the door check.
    • On the head, grant-permission-set-name.ts differs from the merge base only in comments and in two unused constants that were never on the package entry (GRANT_SET_ID_FIELD, PERMISSION_SET_CATALOG_OBJECT).
    • No assertNoPlatformWideDuplicate, DuplicateRecordError or dispatch.scope remains in plugin-security source.
    • plugin-security/package.json equals both the merge base and current main.
    • pnpm-lock.yaml equals the merge base; it differs from current main only by main's own later change. The packages/apps/account churn is gone.
  • Fix 3: discharged.
    • dropColumnsThroughDrift (organization-ownership-column-drops.ts) drops the proposed drop_index entries for a column's retiredIndexes first, in their own applyMigrationEntries call, and the drop_column follows in a later call. Index names come from buildIndexName.
    • An index drop that is skipped fails its column, naming the index, and the column stays.
    • Pinned on a real SqlDriver whose 17.x table carries both legacy indexes:
      • the differ proposes both orphans and the column;
      • the two calls happen in that order;
      • a refused index keeps its column.
    • MySQL's refusal is still a reading of the driver arm, not a run.
  • Fix 4: discharged.
    • The inventory category is gone: organization-ownership-inventory.ts equals main.
    • Without the name column, planGrantKeys answers nameColumn: 'absent', zero counts, and the "Boot this release once" note.
    • --apply's rewrite then blocks with the verifyReferences remedy. Both halves are pinned.
  • Fix 5: discharged.
    • The dev ran cli --project integration on this head: 129 files, 1,125 passed, 2 skipped (6170828501).
    • Test Core (1/6 to 6/6) is green on this head and runs the same tier.

Q-S3a: the index respelled unique: 'organization'

  • The premise holds.
    • The object declares no tenancy, so computeTenantField falls back to the implicit organization_id.
    • normalizeDeclaredIndex takes the 'organization' arm with the tenant column already listed (schema-drift.ts:1864–1866). The columns stay as listed, and organization_id becomes the NULL-safe COALESCE(organization_id, '__global__') key part.
  • The real-driver pin is grant-permission-set-name.test.ts, on a real ObjectQL engine over a real SqlDriver, with SecurityPlugin started.
    • A platform-wide duplicate is refused for both an admin and a system writer.
    • Bypass path 1: a two-row batch is refused and stores nothing.
    • Bypass path 2: a raw knex INSERT beneath the engine is refused with UNIQUE constraint failed. That raw refusal directly proves that the tenant field resolved to organization_id.
    • A rename update that would make a duplicate is refused.
    • An organization-scoped duplicate is refused.
    • The control lands: two organizations plus platform-wide, two users by batch and by raw SQL, and an edit.
    • The revoke pins are kept for both the scoped and the platform-wide case.
  • The ablation. Respelling the index 'global' turned exactly the three platform-wide pins red (reported).
  • The wire shape. The engine throws DUPLICATE_RECORD 409, and mapDataError answers 409 UNIQUE_VIOLATION. Both are asserted on every refusal case.
  • The restatements. Four places now name the index as the guard for every writer and write path: the changeset, the ADR-0087 entry's acceptanceCriteria, the object note, and the anchor.

Q-S3b: answer (ii)

  • The change. In reconcileOrgAdminGrant, after the by-name existence check, a find of { user_id, organization_id: orgId, permission_set: null } with limit 1 runs.
    • Any hit returns skipped: unnamed_grant_awaits_ceremony and writes nothing.
    • backfillOrgAdminGrants and the sys_member hook both go through this function.
    • An unnamed grant in another organization does not hold the pair back (control).
  • The pins:
    • a stub pin;
    • a real-engine pin in grant-readers-unnamed-grant.test.ts;
    • the end-to-end cli pin. There, the real backfillOrgAdminGrants writes no row (granted 0, skipped 1), --plan counts no collision, the rewrite names the grant with collisions: 0, and the next sweep writes nothing.
  • The ablation. It is reported for the stub and real-engine pins. The cli pin reads plugin-security's dist, so that ablation did not reach it. Read directly, it still turns red without the skip: the sweep would write the named grant, and granted: 0 would fail.

The main merges (read through git show --remerge-diff)

Whole-diff re-run of the rest

  • These stand as 6166827307 judged them:

    • the declaration;
    • the three readers lose their id leg;
    • the backfill deletion and the deletion clause;
    • the plugin-auth and dogfood flips.
  • No engine reader of the retired column survives in non-test source. The only reads left are the ceremony's raw SQL: runRewrite, verifyReferences and planGrantKeys.

  • The PR body lists the cli files outside the claim's literal surface:

    • organization-ownership-apply.ts and its test;
    • organization-ownership-plan.ts;
    • organization-ownership-grants.ts (new);
    • organization-ownership-column-drops.ts and its test (new);
    • one line of the served-catalog test.

    It also lists the other files outside it: plugin-auth, the core comment, the dogfood pin, the census, and the engine-double ledger. All are right in scope.

② Semver level

  • The level is right.
    • @objectstack/plugin-security, @objectstack/cli and @objectstack/spec each take minor, marked BREAKING under the launch-window convention.
    • Clause-②: yes (narrowing) appears in the changeset and on the PR body's line 2.
    • There is exactly one ADR-0087 marker, registered ups-permission-set-id-column-retired, in the same changeset body.
    • Check Changeset and Lint & Repo Gates are green.
  • What narrows in plugin-security:
    • the declaration: a write naming the column is refused 400 INVALID_FIELD, meta stops serving the field, and records stop carrying it;
    • the index now refuses a platform-wide duplicate (409) that it used to accept;
    • the boot backfill is gone, with its sys_migration entry;
    • the reconcile writes nothing beside an unnamed grant;
    • Leg C now restricts on any unnamed, unscoped human grant.
  • cli:
    • --plan gains a grants block, which is additive.
    • --apply's rewrite can now block on a collision.
    • column-drops now drops a new column and two indexes.
    • ColumnDropPort is not on the package entry.
  • spec: one additive entry.
  • No changeset is needed for: plugin-auth (comments and one test), core (one comment), and dogfood (private).
  • The migration text is present:
    • FROM → TO with a one-line fix for the column, the indexes and the backfill;
    • the 17.x window sentence;
    • the instruction to restart after --apply.
  • Optional: the first FROM → TO bullet could also say that records no longer carry the key.

③ Boundary flags

Size: Tier H by AGENTS §7 (c).

  • 3,267 changed lines (1,229 + 2,038), which is over 3,000.
  • It stays a single PR, by the seat's answer to 6169001411's open question 1. The 'organization' index and the ceremony's collision handling are coupled, so I agree.
  • Landing path:
    1. The seat gives its ACCEPT.
    2. The seat posts a final ## 维护者速读 as a COMMENT, because the body's 草稿 does not stand in for it, and adds needs-user-decision.
    3. One authorized APPROVED review from GOVERNED_APPROVERS.
    4. The owning seat lands it.

## 维护者速读(草稿)

  • Present, in Chinese, from the business angle.
  • It covers what changes, why, the risks and costs (the 17.x window, 400/409, rollback), and the action. 席位意见 is left empty for the seat. It is accurate.
  • One nit: the risk line says 「需人工合并」, while the action line gives the real path (approval, then the seat lands it).

Open questions and out-of-scope findings in the three reports

  • 6165963520:
  • 6169001411:
    • OQ1 (size): one PR, as above.
    • OQ2 (the safety net's "any other writer"): accepted. Setup, data-door grants and seeds are the in-repo writers left.
    • OQ3 (tryFind fails open, so the pair is granted): accepted. The rewrite reports the pair. That costs the operator work; no data is lost.
    • Non-blocking items:
      • lockfile churn: gone;
      • the 17.x-window sentence and the restart instruction: added;
      • DuplicateRecordError without a cause: moot;
      • both stale comments: fixed;
      • the two doubles still carrying the key: left, since they declare their own rows;
      • skills/objectstack-data/rules/security.md: left as a Tier H follow-up.
  • 6170828501: no new questions.

Composition with the sibling PRs

Landing order and merge obligations

  1. S4: landed.
  2. S3 feat(plugin-security,cli,spec)!: retire sys_user_permission_set.permission_set_id; the grant key is unique per organization; the boot grant-name backfill is deleted (C7b S3) #23503 next.
    • Re-read its mergeability against f9b421716d before the approval request.
    • The queue's merge group re-runs the five required contexts on current main.
    • A conflict, a red caused by main, or an ejection means: merge main, re-run the cli integration project, and write a new record for that head.
  3. S1 feat(cli): the organization-ownership ceremony carries the sys_metadata organization layer into the environment, and a choice deletes only the rows the reviewed plan listed (ADR-0131 D10, C7b S1) #23452. It already owes a merge of main for S4, per its body. Taking S3 in that same merge resolves the TablePlan and test-file conflicts once, and S1 is re-reviewed once.
  4. S2 feat(metadata-core,metadata-protocol)!: the sys_metadata family declares no organization column; indexes re-keyed (ADR-0131 D6/D7, C7b S2) #23469 and S5 feat(runtime,cli)!: os serve refuses a v17-shape database until the organization-ownership ceremony has verified it (ADR-0131 D10 item 5, C7b S5) #23461 together, after S1, into a release that carries S5 ([Decision] C7b (#15211) finish: move the v18 boot refusal into C7b so the metadata column drop never misreads (Q3); walled promotion of organization screens (Q6); unchosen conflict rows (Q1) #23413 Q3 → A). S2 regenerates the engine-double ledger.

If S1 reaches its record first, steps 2 and 3 swap, and the second lander carries the merge.

Should S3 be tied to the joint S2 + S5 landing? No.

  • S2 is tied because, without the boot refusal, it misreads legacy organization rows as environment rows. That fails open.
  • Without S5, S3 fails closed. On a 17.x database:
    • every grant confers nothing until --apply;
    • Leg C withholds the platform-admin promotion;
    • the reconcile writes nothing.
  • That is an availability cost, and the changeset states it.
  • A stable v18 needs S5 anyway (D10 item 5). A prerelease cut between S3 and S5 carries the window, as the changeset describes it.

Non-blocking notes

  1. Wording left stale by Q-S3a:
    • organization-ownership-grants.ts:62 and organization-ownership-plan.ts:166 key NULLs "as the write door keys it";
    • the apply-test title at :867 says GROUP BY keys NULLs together "unlike the index". The index now keys them together too.
  2. A stale module note: plugin-auth grant-set-name.ts:4–18 still describes the one-time backfill and says that C8 drops permission_set_id.
  3. Dead vocabulary rows: persistGrantNameBackfillRecord remains in scripts/check-durability-degradation-log-level.mjs:412 and scripts/measure-durability-swallow-family.mjs:334. Its only definition is deleted here. The gate is green.
  4. Remedy text after S4: logReport, the changeset and the ADR-0087 replacement name a bare --apply. Since S4, --apply also requires a reviewed --plan-file, and S4's refusal names that remedy. Fix it at the next touch.
  5. Index names: the retired indexes are matched by today's buildIndexName. A database whose legacy indexes carry other names gets no index-first drop and falls back to the bare column drop.
  6. The cli column-drops fixture declares tenancy: { enabled: false } and unique: true. It pins the ordering, not the 'organization' declaration; the plugin-security pins carry that.
  7. Runs that did not happen in CI: the dogfood grant-name-pickers pin ran only in the dev's run, because PR CI skips the dogfood shards; the full battery on main runs it. Fix 3's MySQL arm has not been run anywhere.
  8. No carrier card yet for the skills/objectstack-data/rules/security.md follow-up (Tier H).

Implemented-by: session_014fyKy7M7fEuwkfqc5JHdJJ
Reviewed-by: session_01Rerax7QTjKMPCUZxQUtPFR

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读 · PR #23503(#23430,C7b S3)· domain:engine seat 1 · 2026-10-14T15:42Z

改了什么

  • 下线旧列: 用户授权表 sys_user_permission_set 去掉旧的 permission_set_id 列和它的两个索引。授权只按权限集名称引用。
  • 唯一键改为 (user_id, permission_set, organization_id),声明为 unique: 'organization'。
    • 组织为空的平台级授权也由数据库索引本身拒绝重复。
    • 拦截覆盖所有写入路径:普通写入、批量写入、并发写入和迁移脚本的原始 SQL。
  • 删除启动时的"授权按名补全"模块, 改由 v18 迁移仪式(os migrate organization-ownership)在验证后改写。改写时:
    • 先删旧索引,再删旧列;
    • 撞键的旧授权只报告、不删除;
    • --plan 会预先统计这类情况。
  • 扫描不再补写: 用户在某组织已有未命名的旧授权时,组织管理员授权的启动扫描不再在旁边另写一条,避免迁移时撞键。

为什么改

风险与代价(含回滚)

  • 17.x 库的过渡期: 从第一次用 v18 启动,到执行迁移仪式 --apply 之间,所有授权都不生效,平台管理员也一样;--apply 后需要重启。
  • 接口变化:
    • 带 permission_set_id 写授权的种子数据或调用,会收到 400 INVALID_FIELD;
    • 重复授权会收到 409 UNIQUE_VIOLATION。
  • 回滚: revert 本 PR 即恢复列声明和启动补全。已被迁移仪式删掉的物理列不会自动恢复;仪式本身不删除任何授权行。

席位意见

你要做的


席位核对

ACCEPT — PR #23503 @ 7b50158db85fc41fb1b6a7c2b77f00be5f857651, card #23430. domain:engine seat 1, session_01Rerax7QTjKMPCUZxQUtPFR.

  • Implemented-by: session_014fyKy7M7fEuwkfqc5JHdJJ.
  • Contract review of record: 6172184615 (PASS, opus by the maintainer's order 6166448512).
  1. Shape.
    • One PR: 47 files, +1,229 / −2,038 = 3,267 changed lines.
    • check-governed-merges.mjs --pr 23503: 0 of 47 paths on the governed register, so the size decides. Tier H by size: an authorized approval, then the owning seat lands it.
    • Clause-②: yes (narrowing). @objectstack/plugin-security, @objectstack/cli and @objectstack/spec are minor, BREAKING. One ADR-0087 marker: registered ups-permission-set-id-column-retired.
  2. Premises measured.
  3. Gate check-runs on the head: 36 success, 5 skipped by design (Console Pin Gate, the dogfood shards, Dogfood Verify CLI, the tarball smoke, Temporal Conformance), 0 failed. All five required contexts are success. The dev's cli --project integration run: 129 files, 1,125 passed (6170828501).
  4. Accepted deviations:
    • Open questions 2 and 3 of 6169001411: the safety net covers "any other writer", and the reconcile's tryFind fails open into a reported pair.
    • Fix 3's MySQL arm is a reading of the driver, not a run.
    • The dogfood grant-name-pickers pin runs only on main's full battery.
    • The record's non-blocking notes 1–6 ride the next PR that touches each file.
  5. Out-of-scope carriers:
    • skills/objectstack-data/rules/security.md (Tier H, governed) and the post-S4 remedy wording are carried by a follow-up card this seat files with this ACCEPT. The wording note: logReport, the changeset and the ADR-0087 replacement name a bare --apply, where S4 now requires a reviewed --plan-file.
    • The two engine doubles that still carry the key stay as they are: they declare their own rows.
  6. Landing terms:

Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review October 14, 2026 16:03
@os-zhuang
os-zhuang enabled auto-merge October 14, 2026 16:03
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit 6f21216 Oct 14, 2026
49 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-23430-grant-column branch October 14, 2026 16:33
marchtian pushed a commit that referenced this pull request Oct 14, 2026
…issue-23429-metadata-family

One conflict: packages/rest/src/meta-delete-drop-storage-other-rows.test.ts
— main parameterised the seed (seedStoredRow(packageId)); S2 drops the
retired organization_id key from it. Took main's shape without the key.

Auto-merged files both sides touch (main = S1/S3/#23489, kept as merged):
organization-ownership.ts keeps S2's template-lookup column guard (S2's own
hunk); organization-ownership-plan.ts and -inventory.ts are main's (S1)
byte-for-byte; protocol.ts keeps #23489's packagelessReadAddress beside S2's
legacy-row filtering; rest-server.ts keeps S2's deletion of the form doors'
organization read; generated translations and the engine-double ledger are
regenerated after the merge.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
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/xl tests tooling

Projects

None yet

3 participants