Skip to content

feat(plugin-security)!: no seeder or materializer writes a sys_permission_set row; the boot seeds no row for a declared set (#15204 stage 6b-2) - #22900

Open
objectstack-fleet[bot] wants to merge 27 commits into
mainfrom
claude/issue-15204-s6b2-declared-permissions
Open

objectstack-fleet[bot] wants to merge 27 commits into
mainfrom
claude/issue-15204-s6b2-declared-permissions

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: no seeder or materializer writes a sys_permission_set row, and the boot seeds no row for a declared set. The declared-permission seeder, the platform-default materialization, the permission publish materializer, the metadata:reloaded re-seed and the organization-creation hook are deleted, with drift, the seed refusal diagnostics and the managed_by normalizer ("only delete, never fix", batch #310 item 4). An uninstall still revokes every grant of the package's sets, now by name (option 1, the maintainer's ruling 6123311533; mechanism B, the seat's choice under option 1). Until stage 7 (7a, #22975) retires the ADR-0094 projector, a permission-set publish still has it write a transitional admin projection row (see "The ADR-0094 projector until stage 7" below). This PR leaves #15204 open: stages 7a, 7b, 8, 8-post, C7b and 6c remain.

Landing — draft until the seat marks it ready

What changes

Surface Before Now
bootstrap-declared-permissions.ts (522 lines) seeded every declared set as a managed_by:'package' row, once per organization under a wall deleted
bootstrap-platform-admin.ts seeded the platform defaults insert-once; resync reconciled them writes no catalog row. It reads the legacy admin_full_access row only for leg C (a grant written before the name column; restrict-only). A refused read withholds the promotion and says so once. No resync option, no seeded / resynced / resyncSkipped
permission publish materializer (SP) upserted a package-door publish into a row; ran the suggestion reconcile and everyone's re-derivation only when it materialized and the body carried isDefault deleted. The reconcile and the re-derivation hang off onMetadataMutation: every landed permission publish, direct save or delete, unconditionally. Drafts and other types do nothing (closes U2 contract flag A, 6109964380). The ADR-0094 projector still writes an admin projection row on the publish until stage 7 (next section)
metadata:reloaded re-seed, organization-creation hook (SP) re-seeded rows after a hot install and per new organization deleted
permission-set-drift.ts, normalize-managed-by.ts, seed-refusal-diagnostics.ts boot diagnostics over the seeded rows deleted; their public exports go (listed in the changeset)
warnOrganizationLessRows, catalogIsPerOrganization, listSeedOrganizationIds (organization-scope.ts, internal) the per-organization sweep and the organization-less row guard; the seeder was their one caller deleted (re-measured: no live caller, no test)
sys_permission_set.drift_status / drift_detail and the Needs Attention view written by drift removed with their writer (translations regenerated). The packaged-overlay report still names a shadowed set at boot
readDeclared exported by the seeder re-homed into declared-capability-context.ts (its two readers: the capability context and the managed-object write denies). It is a read of the registry, the catalog's one home, never of a row
Uninstall (security.package-permissions, both doors) found a package's grants through its managed_by:'package' rows see the next section

The ADR-0094 projector until stage 7 (measured)

With the permission materializer gone, the awaited ADR-0094 projector (projectPermissionMutation, retired in stage 7 / 7a #22975) is the one writer left on a publish. Measured on the CRM stack, fresh database, this head against main:

this PR main
package-door publish (draft, then publishPackageDrafts) 1 row: managed_by:'admin', no package_id 1 row: managed_by:'package', package_id
writable base's direct saveMetaItem publish 1 row: admin, no package_id the same (pre-existing)
admin data-door PATCH accepted; written through into the base's own sys_metadata row (no environment overlay); the catalog serves the edit the same
admin data-door DELETE deletes the row, the base's sys_metadata row and the served set answers success:false; the row stays, the sys_metadata row goes
deletePackage the row is removed (the projector's delete leg) the row is removed
boot with a base-published set and no row (file-backed DB, re-boot) the boot reconcile re-creates one admin row not measured

No authorization path reads the row (the resolver reads set bodies from the catalog). The one change from main is the provenance of a package-door publish's row (package → admin) and what a data-door delete of it does. Stage-plan rule: permission-set-projection.ts retires in 7a, so it is not fixed here ("only delete, never fix"). The seat carries "after 7a, a package-door publish writes no row" on 7a's card. The alternative, a projector guard that creates no row for a package-held name, is offered to the maintainer as (A).

Uninstall: the grants naming the package's sets (ADR-0090 D5)

With no row standing for a package's set, the cleanup found no grant to revoke: a grant naming the set survived the uninstall, inert while nothing served the name, and live again on reinstall. Option 1 (the maintainer's ruling on #15204, 6123311533): revoke by the package's catalog set names, read before the package is withdrawn. Mechanism B (the seat's choice under option 1): capture before the withdrawal and hand the names to the cleanups; the lifecycle order is untouched.

  • One capture, both doors, the same point. captureUninstallDeclared(registry, packageId, stored?) (@objectstack/metadata-protocol) runs just before registry.uninstallPackage, in deletePackage and in install-local's DELETE /api/v1/marketplace/install-local/:id. The withdrawal still precedes the cleanups ([finding] After an install-local uninstall, a later hot install of ANOTHER package re-projects the uninstalled package's permission set, which survives the restart as an orphan package-managed row #21576 / [finding] An install-local uninstall (DELETE /api/v1/marketplace/install-local/:id) leaves the package's permission sets in sys_permission_set: the "no ghost grants" uninstall cleanup never runs on that door #21490 race guard unchanged); the capture is synchronous, so install-local still has no await between the ledger removal and the withdrawal.
  • Two sources, unioned:
    • the registry: SchemaRegistry.securityCatalogNamesHeldBy(packageId, 'permission') (new; the package's claims and <packageId>:<name> item slots, unfiltered by disable, since a disabled package still holds its names). This names an installed (artifact / install-local) package's sets.
    • on deletePackage, the permission rows of the package's sys_metadata that the door already read before any delete. A base-authored item is held in the registry's bare slot with no _packageId (provenance org) (measured on the CRM stack: securityCatalogNamesHeldBy(base) answers [], while sys_metadata holds { type: 'permission', name }), so the registry alone cannot attribute it to the base.
  • The hand-off is additive and optional: UninstallCleanup's args gain declared?: { permission?: string[] }, passed by runUninstallCleanups only when the capture named a set. A package that declared none hands its cleanups exactly the request they had before. PACKAGE_JOBS_UNINSTALL_CLEANUP is unchanged.
  • The cleanup revokes every sys_user_permission_set grant naming a captured set. A name has one holder (the registry refuses a second at install), so each such grant was a grant of this package's set.
  • The legacy row path stays: an already-seeded database keeps its managed_by:'package' rows until C7b (this PR's changeset says so). Grants written before the name column and sys_position_permission_set bindings (which carry only permission_set_id, no name column) are still reached through such a row.
  • Not touched: a position declared in the catalog that lists the uninstalled package's set is metadata an author wrote, not a grant row; it stays as authored.

Pins

  • builtin-positions.boot.test.ts (real plugin boot, real SQLite, every scenario): zero sys_permission_set rows after boot, with a positive census control. The first platform administrator (single) and the organization administrator are still granted. The grant golden is unchanged, with no row at all.
  • grant-permission-set-name.writers.test.ts: the promotion leaves zero rows and the holder reads by name. The 6b-2-pre pins stay green.
  • everyone-rederivation-on-permission-mutation.test.ts: one listener even across a bootstrap re-run. Publish and delete of a permission re-derive; a draft or another type does not.
  • showcase-permission-seeding.dogfood.test.ts (the declarative-permission-seeding authz row, rewritten): the showcase's sets are served by the catalog with their package, and the boot writes no row.
  • bootstrap-platform-admin-refused-existence-read.test.ts: a refused legacy read withholds the promotion and writes nothing.
  • Uninstall, each source with its own ablation:
    • registry-held source (install-local door). marketplace-install-local-uninstall-withdrawal.test.ts: the order is capture → withdraw → cleanups, and the cleanups get the names; control: a registry that cannot say hands nothing. End to end, package-install-local-uninstall-cleanups.integration.test.ts (CLI, four orders of events, rewritten to the catalog): grant by name → DELETE → the grant is gone → after a restart, another package's hot install, and a reinstall of the same package, the grant is not live again ("the grant revoked by the DELETE does not come back with the set"). Ablation: the capture answering nothing turned the door pin red (1 of 8); the controls stayed green.
    • base-authored source (deletePackage door). package-uninstall-revokes-named-grants.dogfood.test.ts (CRM stack): a writable base publishes a set, a user is granted it by name, deletePackage → the grant is gone; the base publishes the set again → it is served, and the grant does not come back. Ablation: dropping the capture in deletePackage turned 2 of 3 red. Before the stored-row source existed, this pin was red with the registry-only capture.
    • protocol.uninstall-declared-capture.test.ts: the helper (both sources, the union, nothing when nothing names a set), the runner's hand-off, and deletePackage's order. protocol.uninstall-cleanups-runner.test.ts's control (a request with no capture is passed through unchanged) stays green.
    • registry-security-catalog-names-held-by.test.ts: the names a package holds, a disabled package included, none after uninstall.
    • cleanup-package-permissions.test.ts: the named grants go, with a no-names control in which they survive.

Tests this branch rewrites (they pinned the rows it removes)

Also in this PR

  • Liveness ledger. permission.packageId re-points to its surviving reader (the CLI's compile-time collision door). permission.managedBy is corrected to dead (see Acceptance notes).
  • Tenant-audit census regenerated after merging main: main has 227 write sites, and this branch's removal of the normalizer's and the platform-admin bootstrap's update sites takes it to 225. The hand-written figures on tenant-audit-census.mdx follow.
  • Docs: permission-sets.mdx (built-in sets, provenance, the drift section reduced to overlay shadow) and authorization.mdx.
  • Gate ledgers: the double-contract ledgers drop the retired suites and pin the new one.

Changeset

One file, .changeset/15204-s6b2-declared-permission-seeders.md:

  • @objectstack/plugin-security minor, BREAKING, Clause-②: yes (narrowing), ADR-0087 not-required (no-migration-prescription). The level is unchanged by the uninstall work: nothing an author writes moves.
  • @objectstack/metadata-protocol minor and @objectstack/objectql minor, additive: the optional declared argument of the exported UninstallCleanup type and of runUninstallCleanups' request, the UninstallDeclared type, captureUninstallDeclared, and SchemaRegistry.securityCatalogNamesHeldBy. @objectstack/cloud-connection patch (its door hands the names on). cleanupPackagePermissions takes an optional fourth argument.
  • check:changeset-no-major and check:adr-0087-registration exit 0.

Verification (local, at this head)

At ecfb54f8b8, main df7a137923 merged in (c60e08810). The merge had two conflicts: install-local's imports, where both sides' imports were kept, and the declaration-copy test's persisted-row case, which #22994 had rewritten and which keeps this branch's "no row" side.

  • Build: turbo run build, every task green. Typecheck green for the 21 packages below and dogfood.
  • Suites (turbo run test, 21 packages: plugin-security, platform-objects, runtime, metadata-protocol, cli (unit and integration), objectql, cloud-connection, plugin-auth, organizations, plugin-approvals, plugin-sharing, mcp, rest, hono, client, http-conformance, core, example-crm, example-showcase, example-todo, example-multi-package): 82 of 82 tasks green. plugin-auth: 136 files, 2,780 passed. cloud-connection: 43 files, 519 passed (from source; see below).
  • Dogfood, in full: 258 files passed, 1 skipped; 2,117 tests passed, 9 skipped (the deletePackage door pin, the grant-name pickers and main's walled-catalog suites included).
  • The rewritten suites (before this merge, on a fresh full build): (a) runtime 13/13; (b) cli boot-steps 7/7; (c) cli uninstall-cleanups 17/17. All three are inside the suite run above.
  • check-i18n-bundles: green after the full build.
  • dispatch-gates --commands: 117 derived families, all run and reconciled by --ran, 0 not measured.
    • check:test-source-alias went red on this diff earlier (cloud-connection's new runtime import of @objectstack/metadata-protocol). It is now aliased to source and green.
    • check-engine-split-ratio needs history covering its 90-day window; the clone was deepened to it, exit 0.
    • The one non-zero: check:platform-checklist, the same 8 absent-symbol anchors on T1 (which touches no checklist file). That check is the scheduled watchdog's (checklist-status.yml), not a per-PR gate, and main's checklist cites none of T1's deleted files.
  • check:changeset-no-major and check:adr-0087-registration: exit 0. check-tenant-audit-census: OK, 225 sites.

After ecfb54f8b8, three text rounds from the contract reviews: 5cc2ef8629, 70a42bf2f5 and 320ce20b74. They change docs prose, code comments, the changeset's wording, two sys_permission_set field descriptions (with the English translation bundle regenerated) and two install-local warn strings; no code path. At 320ce20b74:

  • plugin-security: 191 files, 3,973 passed;
  • cloud-connection: 44 files, 538 passed;
  • the protocol capture pins: 10/10;
  • check-i18n-bundles, the docs checks, check:changeset-no-major and check:adr-0087-registration: all 0;
  • dispatch-gates: 119/119 run, the only non-zero being the checklist watchdog.

Acceptance notes

  • Data-door reach on a package-door-published set (a writable base, package installed; measured on the CRM stack, this PR vs main):

    • a data-door PATCH is 200 on both and lands in the base's own sys_metadata row, with no environment overlay;
    • a data-door DELETE deletes end to end here (the row, the sys_metadata row and the catalog entry), while main half-applies it (success:false; sys_metadata gone, the row and the served body kept);
    • a grant naming the deleted set stays and goes dead, and is live again if the base republishes that name, as it would be on main after a reload;
    • under single the wall is inert, so the door is open to any caller with CRUD on sys_permission_set (the platform operator in practice); under group / isolated the wall refuses everyone but the operator (ADR-0131 D3);
    • the provenance key: the row's managed_by:'package' is what the projector's delete leg, the data-layer gate's transfer / restore / purge refusals and the uninstall cleanup's legacy row path key on, and a publish no longer stamps it here. The packaged-set lock (packaged-permission-set-lock.ts) keys on the artifact, not the row, and answers the same on both trees (delta review 6130075012 ①8);
    • carrier: 7a retires the projector and both write-throughs (C3 #15204 stages 7a ∥ 7b → 8-post, build ahead: delete the permission-set projection and the position write-through that stage 8 leaves unreferenced, then the post-stage-8 catalog gates and residue #22975, seat note 6129253247).
  • Stale text left to its carriers: packages/spec/src/security/permission.zod.ts:845 (managedBy's JSDoc, "seeded by bootstrapDeclaredPermissions, re-seeded on upgrade"): the spec seat, with the managedBy enforce-or-remove item. seed-name-lookup.ts:9 and packages/objectql/src/engine-capability-provenance.test.ts:164–165 (comments): C7b.

  • permission.managedBy is an authored key with no reader (pre-existing; the ledger now says dead). Retiring it is an enforce-or-remove item for the spec seat. It is not done here.

  • The name-fold census instrument (scripts/measure-position-name-fold-census.mjs, not a CI gate) refuses to report since 6b-2a deleted the binders. Carrier: whoever next runs it re-aims its junction scan at PositionSchema.permissionSets.

  • Two dogfood suites (two-doors-permission, showcase-permission-projection) keep their data-door cases on an existing database (the fixture writes the legacy row), because the gate still guards such rows until 7a / stage 8.

  • plugin-auth's two grant-reader suites read the legacy platform-bucket rows; their fixture is T2's item (d) (test(dogfood): fixtures read permission sets from the security catalog (#15204 stage 6b-2, T2) #22898, a38b54f6f), carried here byte-identical (8e40fb06b) as this branch carries T2's other bytes, so it cancels out of the measured size.

维护者速读(草稿)

改了什么:不再有任何播种器或物化器向 sys_permission_set 表写行,启动时也不再为声明的权限集播种行(在阶段 7 删除 ADR-0094 投影器之前,发布权限集时投影器仍会写一条 admin 投影行,鉴权不读它,卸载包时会被删掉)。删除了:包声明权限集的播种器、平台默认权限集的物化、包发布时的物化器、热安装后的重播种、新建组织时的播种,以及基于这些行的漂移诊断、managed_by 归一化和播种拒绝诊断。drift_status/drift_detail 两个字段及「需要关注」视图随写入者一并删除。卸载包时仍会撤销所有指向该包权限集的授权:两个卸载入口在把包从注册表撤下之前,先记下该包持有的权限集名称(注册表中的名称,加上 deletePackage 已读到的该包已存储的权限集行),交给清理步骤按名称撤销授权(方案 1 是你的裁定 6123311533;机制 B 是席位在方案 1 之下的选择)。

为什么改:ADR-0131 D3 规定权限集只有一个家——注册表。鉴权解析器早已从注册表读定义,授权按名称写入,这些行已不再决定任何权限。卸载按名称撤销,是为了守住 ADR-0090 D5「不留幽灵授权」:否则授权会在卸载后残留,并在重新安装后重新生效。

风险与代价(含回滚):已有数据库保留旧行(直到 C7b),旧行路径的撤销照旧。删除了若干公开导出和 bootstrapPlatformAdmin 的 resync 选项,changeset 已逐项列出;新增的卸载交接字段是可选的,不读它的清理步骤行为不变。本 PR 单独计 3,601 行(超过 3,000),因此为 Tier H。回滚:直接 revert,无数据迁移。

席位意见:

你要做的:T1、T2、本 PR 依次合并;本 PR 因体量为 Tier H,需要你批准。


🤖 Generated with Claude Code

https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49

claude added 14 commits October 11, 2026 19:05
…efusal diagnostics and managed_by normalizer (#15204 stage 6b-2)

Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49
Co-authored-by: Claude <noreply@anthropic.com>
…log row; os meta resync is retired (#15204 stage 6b-2)

Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49
Co-authored-by: Claude <noreply@anthropic.com>
…us retires with its writer (#15204 stage 6b-2)

Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49
Co-authored-by: Claude <noreply@anthropic.com>
…ift_status / drift_detail (#15204 stage 6b-2)

Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49
Co-authored-by: Claude <noreply@anthropic.com>
…the legacy platform-bucket rows themselves (#15204 stage 6b-2)

Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49
Co-authored-by: Claude <noreply@anthropic.com>
…face-only disposition (#15204 stage 6b-2)

Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49
Co-authored-by: Claude <noreply@anthropic.com>
…ed seeders (#15204 stage 6b-2)

Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49
Co-authored-by: Claude <noreply@anthropic.com>
…new one (#15204 stage 6b-2)

Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49
Co-authored-by: Claude <noreply@anthropic.com>
…wn changeset (#15204 stage 6b-2a)

Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49
Co-authored-by: Claude <noreply@anthropic.com>
…ermission_set row; existing-database cases write the legacy row themselves (#15204 stage 6b-2)

Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49
Co-authored-by: Claude <noreply@anthropic.com>
…places did (#15204 stage 6b-2)

Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49
Co-authored-by: Claude <noreply@anthropic.com>
…r, a command that exists (#15204 stage 6b-2a)

Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 6 package(s): @objectstack/cli, @objectstack/cloud-connection, @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/plugin-security, @objectstack/spec, touching 118 documentable anchor(s). ⚠️ 10 changed file(s) yielded no anchor (packages/cli/src/utils/permission-set-name-collisions.ts, packages/cloud-connection/vitest.config.ts, packages/metadata-protocol/src/index.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

54 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 038d4c958837fb42e71842c18f062f37bd866811.

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

What this run could not see
  • 10 changed file(s) yielded no anchor (packages/cli/src/utils/permission-set-name-collisions.ts, packages/cloud-connection/vitest.config.ts, packages/metadata-protocol/src/index.ts, …) — pages documenting those are invisible to this run
  • 10 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 — 150 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 038d4c958837fb42e71842c18f062f37bd866811 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 038d4c958837fb42e71842c18f062f37bd866811

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

… seeder was the only caller of (#15204 stage 6b-2)

warnOrganizationLessRows, catalogIsPerOrganization and listSeedOrganizationIds
had one caller, bootstrap-declared-permissions.ts, deleted in this PR. Re-measured:
no live caller and no test. Internal (never exported from the package index).
Also carries T1's buildExistingByName key-set fence byte-for-byte.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49
…15204 stage 6b-2)

No assertion changes; keeps the residual under the 3,000-line ruling after the
organization-scope deletion.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49
Regenerated with `node scripts/tenant-audit-census.mjs --write`; the
population is unchanged at 225, only the measuring commit moves.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ecfb54f8b8044f328883d5e42496a480f23c0e73
Local-runs: probe — git-only size and byte replay for (g)/(h)

Inputs: card #15204 (body and all 188 comments; the maintainer's rulings record 6123311533 「W3 选 A,D 选 1」, the next ruling 6125978136, the 6b-2 split 6114116727, the precursor audit 6114241228, the 6b-2a delta review 6114456408 and its Landed record 6115993646, the 6b-2-pre Landed record 6112556332, the developer's rounds 6114091544 / 6114486311 / 6114555870 / 6114591668 / 6125793932); ADR-0131 D3 and ADR-0090 D5 on main; the U2 contract record 6109964380 (flag A); PRs #22897 (T1, bb7b4cef36), #22898 (T2, a38b54f6f2) and #22900 (D, ecfb54f8b8): bodies, file lists and net diffs against main df7a137923; the check-runs on ecfb54f8b8. The replay for (g) and (h) ran in a separate --shared clone under the reviewer's scratch directory with git only (merge, diff, show, log); nothing was built, run or re-run.

① Derived judgments

Each line names a change the diff implies and judges it; (a) to (k) follow the brief's lettering.

(a) The ruled uninstall cleanup.

  1. What was ruled, and by whom. 6123311533 rules option 1: "the cleanup revokes those grants by the package's catalog set names, read before the package is withdrawn. ADR-0090 D5 stands unchanged." No card record rules a lettered "mechanism B": the developer's own addendum 6125793932 reads "Maintainer: option 1; seat: mechanism B", so B is the seat's choice under option 1. B is the mechanism the ruling's own sentence describes (capture before the withdrawal, hand to the cleanups), so the build follows the ruling; the PR body's "Maintainer ruling on refactor(plugin-security,platform-objects,spec): retire the catalog seeders, the per-organization catalog machinery and the four catalog objects; Setup creation is an environment write under single and refused under a wall (ADR-0131 D2/D3/D5/D13) #15204: option 1 (…), mechanism B" overstates by one letter — ③ item 1. Right in substance; the body's attribution is corrected in ③.
  2. Both doors, one capture, same point. deletePackage (protocol.ts): captureUninstallDeclared(registry, packageId, rows) sits immediately before registry.uninstallPackage, after assertPackageUninstallable, the store delete and the sys_metadata deletes; runUninstallCleanups follows the withdrawal, so the [finding] After an install-local uninstall, a later hot install of ANOTHER package re-projects the uninstalled package's permission set, which survives the restart as an orphan package-managed row #21576 / [finding] An install-local uninstall (DELETE /api/v1/marketplace/install-local/:id) leaves the package's permission sets in sys_permission_set: the "no ghost grants" uninstall cleanup never runs on that door #21490 order (withdraw, then cleanups) is unchanged. Install-local DELETE /api/v1/marketplace/install-local/:id: after ledger.remove, the capture is ctx.getService('objectql')?.registry plus a synchronous call, then withdrawFromRunningKernel, then the awaited cleanups — no await between the ledger removal and the withdrawal. The withdrawal unit pin asserts the event order capture → withdraw → cleanups. Right.
  3. A third door. The runtime's DELETE /packages/:id (packages/runtime/src/domains/packages.ts) delegates to protocol.deletePackage and withdraws from the registry itself only when the protocol has not (a host with no protocol service ran no cleanups before this PR either); the PACKAGE_JOBS_UNINSTALL_CLEANUP cleanup (app-artifact-handlers.ts, unchanged) is a cleanup the runner hands declared to and that ignores it; disablePackage keeps the package installed, and securityCatalogNamesHeldBy is unfiltered by disable, so a later uninstall of a disabled package still captures its names. No other caller of registry.uninstallPackage exists in the tree. No door is missed.
  4. Both name sources. SchemaRegistry.securityCatalogNamesHeldBy(packageId, type) reads the claims map, the composite packageId:name item slots and any bare slot stamped _packageId === packageId, sorted. deletePackage unions the permission (and permissions) rows of the package's sys_metadata, which the door read before any delete (rows, read with isSystem, refused reads thrown, never []). The body's claim that a base-authored item sits in the bare slot with no _packageId is the registry's own doctrine (security-catalog-namespace.ts: "the environment catalog — … the registry's bare slot carrying no _packageId"; "Only a registration with no package ever writes the bare slot"), and a writable base's publish lands through that write-through, so the registry alone cannot attribute it; the stored rows carry package_id and can. Install-local hands no stored because an install-local package is an artifact whose sets live in composite slots. Right.
  5. One holder per name. The registry refuses a second holder at registration (ruling Q4 = A on feat(core,objectql,plugin-security,plugin-sharing): the catalog is read from the registry; assignment tables reference it by name (ADR-0131 D2/D3/D4) #15196) and at cold boot (letter A on [decision] cold boot admits a package whose permission set or position name the environment catalog already holds (package registration runs before sys_metadata hydration), while a hot install of the same package is refused #22307); a built-in name cannot be claimed; the metadata door refuses a save over a package-held permission name through the packaged-permission-set lock (403), and OS_METADATA_COLLISION=warn is explicitly not extended to this rule; a permission fork stored before the lock refuses the boot (ADR-0048 cold-boot check). So on a tree that boots, a captured name belongs to the uninstalled package alone, and the by-name revoke cannot take another holder's grant; an unmanaged install's copied sets are stored rows under its own package id and are captured by the stored source. The revoke's find and delete run { isSystem: true } with no tenant, as the per-set revoke did before — environment-wide, which is what an uninstall is (W2 made a walled uninstall of a catalog-carrying package the operator's). Right; no case found.
  6. Each ablation, read against its pin. marketplace-install-local-uninstall-withdrawal.test.ts asserts handed equals [{ permission: ['tasks_app_task_user'] }] and the capture → withdraw → cleanups order; a capture answering nothing hands [undefined], so that case goes red while the "registry that cannot say" control stays green — the reported 1 of 8. package-uninstall-revokes-named-grants.dogfood.test.ts (CRM stack, writable base, deletePackage): with the capture dropped the grant survives, so "the grant is gone with the set" and "does not come back" go red and the precondition stays green — the reported 2 of 3. package-install-local-uninstall-cleanups.integration.test.ts (CLI, four orders) now reads GET /meta/permission/… and adds "the grant revoked by the DELETE does not come back with the set" after a reinstall. protocol.uninstall-declared-capture.test.ts pins both sources, the union, nothing-when-nothing, the runner's hand-off and deletePackage's order; registry-security-catalog-names-held-by.test.ts pins a disabled package's names and none after uninstall; cleanup-package-permissions.test.ts pins the named grants gone with a no-names control in which they survive. Each ablation the body reports would turn its pin red.
  7. The hand-off is additive. UninstallCleanup args and runUninstallCleanups' request gain optional declared; the runner spreads it only when present, so a cleanup that ignores it receives the request it had; cleanupPackagePermissions' fourth argument is optional and the legacy row path (package_id rows, their junction and legacy-id grants) is untouched. Right.

(b) Position bindings against ADR-0090 D5, answered from the code and the ADR text.

  1. sys_position_permission_set.permission_set_id is Field.lookup('sys_permission_set', { required: true }) with no name column. On a fresh database under D no sys_permission_set row exists, so a non-system writer (the data API, Setup, a delegated admin) cannot create a binding row at all — the engine's reference check answers reference_not_found — and no system writer in the tree inserts one (the cleanup deletes; U2's confirm edits the everyone derivation; the clone door feat(runtime): POST /security/permission-sets/:name/clone, the permission-set clone door (#15204 stage CD) #22881 publishes a new environment permission item under its own name). Since stage 1 the resolver reads a position's sets from its definition (resolve-authz-context.ts positionSetNamesOf, position-binding-conversion.ts: "no longer reads the junction"), so even a system-written junction row would confer nothing. The console pickers (feat(plugin-security,platform-objects): grant forms pick a position or permission set by name from the catalog (#15204 B2) #22939) write sys_user_permission_set.permission_set by name, which D's cleanup revokes; a Setup edit of a position's sets goes through position-write-through.ts into the position's definition. The only fresh-database position-side binding to a package's set is PositionSchema.permissionSets in a position definition (code-declared by another package, or Studio-authored under single; refused under a wall by W).
  2. For that definition: while the set is unserved, catalogSetBody answers undefined and the binding confers nothing — D5's "revokes it everywhere at once" holds; when the package is reinstalled it grants again. ADR-0131 D3 makes that list "part of the position's definition … a code-declared position names its sets there, a Studio-authored position names them in its definition", i.e. an author's metadata, and ADR-0090 D5's sentence speaks of the moment of uninstall. The ruling's "a user or position grant naming the package's set" reads, against D3's vocabulary, as the grant rows (sys_user_permission_set, the legacy junction), both of which D revokes; editing an author's position definition on uninstall would be a package writing into a foreign definition, which the same ADR family forbids. "Stays as authored" is consistent with D5's text and D3's. Two residuals, escalated in ③ item 2: the maintainer has not read the ruling's word "position" against this case, and the boot's catalog-reference report lists five classes of dangling reference (assignments, grants, unnamed grants, row-only positions, name conflicts) but not a position definition whose permissionSets names an unserved set, so that fail-closed binding is silent until the set returns.

(c) The deletions and their callers.

  1. git grep at D's head over source, tests, scripts, docs and the CLI: no code imports or calls bootstrapDeclaredPermissions, normalizeManagedByVocab, the three drift functions and three types, the nine seed-refusal exports and three types, upsertPackagePermissionSet, PermissionSeedOutcome, warnOrganizationLessRows, catalogIsPerOrganization or listSeedOrganizationIds; the last three were never on the index (6114486311's re-measure holds). What remains are comments and prose naming the retired files as history, plus three sentences now false (③ item 3). readDeclared is re-homed into declared-capability-context.ts with two readers: that module's own readDeclaredCapabilityContext ('capability') and security-plugin.ts:4456 (readDeclared(ql, 'object'), the managed-object write denies). It reads engine.registry.listItems, never a row. Right.
  2. bootstrapPlatformAdmin leg C: one tryFind of the legacy admin_full_access row with a refusal observer; a refused read reports once through reportThroughSink ("promotion WITHHELD …") and returns admin_permission_set_unreadable with nothing written; an empty answer leaves adminPsId undefined and the pass goes on; resync, seeded, resynced, resyncSkipped, tryUpdate, platformOwnedFields and the write-refusal plumbing are gone. The real-boot pin (builtin-positions.boot.test.ts, SQLite, every scenario) asserts zero sys_permission_set rows and usr_admin → admin_full_access under single, and the writers pin asserts findExistingPlatformAdmin answers usr_first with no row. The first platform administrator under single is still promoted on a fresh database. Right.

(d) Schema and data.

  1. drift_status, drift_detail and the drifted ("Needs Attention") view leave sys-permission-set.object.ts. The SQL driver's sync is additive-only (schema-drift.ts: "creates missing … never migrates a column's type"), so an existing database keeps the two physical columns with their last values and nothing reads them: the view is gone, permission-set-drift.ts was their only writer, and the one remaining mention (rule-validator.ts:2330) is a comment. No reader loses data. The seven translation bundles change by exactly the drift and view keys and their eight source hashes (every removed line is one of those keys or its brace), and check:i18n-bundles runs inside the green Lint & Repo Gates. permission.managedBy → dead is correct: the seeder stamped the column managed_by: 'package' and never read ps.managedBy; no source reads the authored key at D's head; the ledger's own note says so, and Spec property liveness is green. Right.

(e) Publish-time re-derivation.

  1. Flag A (6109964380) named the materializer's condition (applied above zero and the body carrying an isDefault key): a later publish of an accepted set whose body dropped the key was not re-derived until the next boot, accept, revoke or uninstall. D deletes the materializer and registers one protocol.onMetadataMutation listener in runBootstrap behind a start()-scoped everyoneMutationWired guard: type === 'permission' and state !== 'draft' (the event's state is 'active' | 'draft' | 'deleted') run the suggestion reconcile and declareEveryone, unconditionally on the body; publish, direct save, delete, rollback and the cluster replay all pass through emitMetadataMutation. The pin boots the real plugin and asserts one listener across a kernel:ready re-run, re-derivation on active and deleted, none on a draft or another type. Flag A is closed; the listener is registered once. Right.

(f) Changesets — ② below.

(g) Size and tier, reproduced. In the scratch clone: df7a137923 + T1 merges clean; + T2 conflicts in scripts/engine-double-contract.baseline.json and scripts/objectql-double-limit.baseline.json (adjacent removed entries); resolved as "drop every entry each side removed" the two files equal D's head blobs exactly (verified as JSON entry sets: D's baselines are main minus the union of T1's and T2's removals, nothing else). git diff of that tree against ecfb54f8b8: 48 files, +945 / −2,656 = 3,601 changed lines, the developer's figure. None of the 119 files on the PR touches docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md or docs/NORTH-STAR.md (GOVERNED_SURFACES); Governed Surface Queue Guard is green. Tier H by size alone. Confirmed.

(h) The T1 / T2 bytes D carries. T1's 16 files: 13 blob-identical at D's head (the six deleted suites deleted, the seven trims byte-identical); 3 differ — the two baselines (above: merged with T2's removals) and scripts/engine-double-contract.pinned.json (T1's −35 merged with main's later movement of that file; against main + T1 + T2 D adds exactly 10 lines, the two pins of everyone-rederivation-on-permission-mutation.test.ts). T2's 58 files: 56 blob-identical, 2 differ — the same two baselines. The three plugin-auth files of T2's item (d) are blob-identical to T2's head and arrive in D as commit 8e40fb06bb ("test(plugin-auth): the grant-reader suites write the legacy platform-bucket rows they read"). The serial landing T1 → T2 → D meets exactly the conflict the body names and, with the body's resolution, produces D's tree; main has moved since, and that re-merge is out of this record's scope. No byte of D silently differs from what T1 or T2 will land.

(i) Rewritten tests. standalone-stack-seeder-declaration-copy.test.ts replaces the byte-for-byte persisted-row case with "no sys_permission_set row for probe_agent" and keeps the sharing-rule and door/registry-copy cases. package-install-local-boot-steps.integration.test.ts reads GET /meta/permission/… and asserts item.name; as the body admits, that case no longer pins #21322's defect (the catalog served the set before #21322 too) — the flow cases still do, and the description says so. The uninstall-cleanups integration suite moves every sys_permission_set read to the catalog and adds the reinstall case — strengthened. showcase-permission-seeding.dogfood.test.ts pins catalog provenance (_packageId), a platform default served, and zero rows with a positive census control. two-doors-permission loses 块1 with the mechanism it pinned (the materializer) and runs 块2 on ensureLegacyPermissionSetRow rows that model an existing database; showcase-permission-projection, permission-set-lock-row-provenance and permission-set-write-through-package-binding do the same; armed's "a row that really seeded" controls move to the signed-up member's sys_user row, which still gives the negative reading a real negative. T1's trims each pin a deleted symbol (the removed imports name bootstrap-declared-permissions, normalize-managed-by, seed-refusal-diagnostics, the drift_status field), and the #11451 key-set fence is re-pinned in seed-name-lookup-degradation-audible.test.ts with a predicate-carrying control. The double-contract ledgers drop only the retired suites' entries and pinned.json adds the new suite's two rows. No assertion is weakened to pass; every pin the body lists is present and asserts what it says.

(j) The developer's verification and the 8 anchors. check:platform-checklist is the scheduled watchdog's, not a per-PR gate (it is kept out of lint.yml's per-PR run). The eight absent-symbol anchors the developer names (anonymousFormIntakeOrgScopeRefusal, anonymousFormIntakeReopenRefusal, envWideRawViewRows in protocol.ts, canEdit in attachment-access-hooks.ts, countable in protocol.ts, and the attachments-storage floor) resolve to nothing on main df7a137923 as well as on D, and no checklist file names any module, symbol or suite D deletes. None of D's deletions owns one of the 8.

(k) No collateral. No CHANGELOG.md, no content/docs/releases/ in the 119 files; git log --format=%B df7a137923..ecfb54f8b8 (24 commits) carries only Co-Authored-By: Claude and Claude-Session: trailers, no model identifier. Right.

Gate verdicts on ecfb54f8b8 (check-runs read immediately before this record was posted, 47 runs): success on Build Core, Build Docs, Check Changeset, Check Documentation Links, Dogfood Regression Gate (roll-up), Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates and its nine Lint jobs, No other open PR may claim the same issue / single-writer path, Part-of PR must not also close its card, Spec property liveness, The card this PR closes must claim this branch, Type Check · source gates / consumer gates / debt ledger / workspace, filter, Auto Label, Check PR Size; skipped by design on PRs (#22998): the Dogfood Regression Gate shards, Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Console Pin Gate, Packed-tarball smoke (opt-in). Red: none. Pending at the time of writing: Test Core 2/6, 3/6, 4/6 and 6/6 (1/6 and 5/6 completed green). The judgments above are of the diff and do not depend on them; the Tier H landing is the maintainer's and the owning seat reads those shards before it asks. The developer's local battery at this head (82/82 turbo tasks, full dogfood 258 files / 2,117 passed, check-i18n-bundles green, dispatch-gates 117/117) is not re-run here.

② Semver level

  • .changeset/15204-s6b2-declared-permission-seeders.md, one file. @objectstack/plugin-security minor, body **BREAKING**, summary feat(plugin-security)!:, Clause-②: yes (narrowing), adr-0087 marker not-required (no-migration-prescription). Right on all four. .changeset/pre.json is mode: pre, tag: next, and the repo spells a breaking change minor plus the banner under the launch-window no-major rule (check-changeset-no-major.mjs); the diff both widens (an optional declared key on the published UninstallCleanup args and runUninstallCleanups request, a new SchemaRegistry method) and narrows (removed exports, the removed resync option and three dropped return fields), which is the yes (narrowing) shape the gate's own fixtures read as the breaking declaration. The removed-export list in the body matches index.ts' diff symbol for symbol (bootstrapDeclaredPermissions; normalizeManagedByVocab; the three drift functions and three types; the nine seed-refusal exports and three types), each "with no replacement", and bootstrapPlatformAdmin's dropped resync option and seeded / resynced / resyncSkipped are stated with their consequence. The disposition fits: no spec key is removed, renamed or re-shaped (managedBy stays declared; only its ledger verdict moves), no stored shape changes, so objectstack migrate meta has nothing to rewrite; runtime-interface-only cannot name a symbol that no longer exists at HEAD, and the body carries no FROM → TO rewrite, so no-migration-prescription is the honest category and the gate (inside the green Lint & Repo Gates) accepts it. The author-facing surfaces that move are TypeScript exports and a function option, each named; nothing an author writes in metadata moves, so no migration prescription is owed.
  • @objectstack/metadata-protocol minor (additive: captureUninstallDeclared, UninstallDeclared, optional declared on UninstallCleanup and the runner's request) and @objectstack/objectql minor (additive: securityCatalogNamesHeldBy). Right. @objectstack/cloud-connection patch: its door hands names on through a local type and a new source alias in vitest.config.ts; no export changes. Right.
  • No entry is missing: @objectstack/cli, @objectstack/runtime and @objectstack/plugin-auth change tests only; @objectstack/spec changes only liveness/permission.json and its state count — a verification ledger shipped as reference material, not an authoring or runtime surface, and Check Changeset is green on the head. Clause-②: on the PR body's second line reads yes (narrowing) and matches the changeset.

③ Boundary flags

Dev acceptance notes (4) — each answered:

  1. permission.managedBy is an authored key with no reader → correct, and not this stage's; enforce-or-remove under ADR-0049, carrier the spec seat as filed. Closed here.
  2. measure-position-name-fold-census.mjs refuses since 6b-2a deleted the binders → not a CI gate; carrier its next runner (re-aim at PositionSchema.permissionSets). Noted.
  3. two-doors-permission and showcase-permission-projection keep their data-door cases on legacy rows → right while the gate guards such rows (7a / stage 8). Closed.
  4. plugin-auth's two grant-reader suites ride T2's item (d) byte-identical → confirmed in (h). Closed.

Reviewer's flags (4) — none fails the head:

  1. Attribution of "mechanism B". The maintainer ruled option 1 (6123311533); B is the seat's choice under it (6125793932), and the ruling's sentence already prescribes the capture point. The PR body's "Maintainer ruling on refactor(plugin-security,platform-objects,spec): retire the catalog seeders, the per-organization catalog machinery and the four catalog objects; Setup creation is an environment write under single and refused under a wall (ADR-0131 D2/D3/D5/D13) #15204: option 1 (…), mechanism B" should read "option 1 (maintainer); mechanism B (seat)". Text-only; the seat's call whether to fold it into the round below.
  2. The position-definition residual (b). Under D a Studio-authored or code-declared position whose permissionSets names an uninstalled package's set confers nothing while the set is unserved and grants again on reinstall, unreported by the boot's catalog-reference report. ①8–9 read this as consistent with ADR-0131 D3 (the list is the position's definition) and ADR-0090 D5 (revoked at once); the ruling's word "position" is read as the grant rows D does revoke. Escalated for the maintainer's reading because the ruling named it in words; and the silent half — no boot report of a position definition naming an unserved set — is an out-of-scope finding with a carrier: the catalog-reference report's owner, or stage 8 when the junction retires. Not a FAIL: no ghost row, no widening, and the only cure would be a package editing an author's definition.
  3. Prose made false by this diff, in files it touches or leaves. content/docs/permissions/authorization.mdx lines 225–231 still describe the Package door publishing through "a registered materializer (registerPublishMaterializer) … reusing the same upsert as the boot seeder (upsertPackagePermissionSet)" — both deleted by D, and the file is on D's list; docs/design/permission-model.md:165 ("On install, bootstrapDeclaredPermissions seeds these rows"); comments in permission-set-name-collisions.ts:10,65, marketplace-install-local-plugin.ts:2851 and sys-permission-set.object.ts:284 speak of the retired writers in the present tense. One text-only round before the Tier H kit goes up is the right size; none moves an accept set.
  4. Four Test Core shards pending when this record was written. Named above; the owning seat reads them before the maintainer is asked, and the Tier H rule needs every check green regardless of this verdict.

Out-of-scope findings, carriers accepted as filed: the 8 checklist anchors (the checklist areas' owners, via the watchdog); PACKAGE_JOBS_UNINSTALL_CLEANUP and any future cleanup that wants the names read declared (no action; the hand-off is in place).

Implemented-by: session_013XKtmoR5QCUSTMjiQ3os49
Reviewed-by: session_01Rerax7QTjKMPCUZxQUtPFR

VERDICT: PASS


Generated by Claude Code

…retired seeder and materializer

Text only, from the contract review's flag ③3: `authorization.mdx`'s
package-door paragraph, `permission-model.md`'s install sentence, and
three code comments (the name-collision report, install-local's
`metadata:reloaded` note, `sys_permission_set.managed_by`) spoke of the
seeder, the publish materializer and the managed_by normalizer in the
present tense. They now describe the catalog serving the package's sets,
or name those writers as retired.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5cc2ef862938d418a376b63b06fe69937a170027
Local-runs: none

Inputs: the delta brief; the full contract review 6128268740 on ecfb54f8b8 (adopted below for everything this delta does not touch); the rulings on #15204 it rests on, 6123311533 (the maintainer: option 1) and 6125793932 (the developer's addendum: "Maintainer: option 1; seat: mechanism B"); the PR body, the 121-file list and the comment thread at 5cc2ef8629; git diff ecfb54f8b8 5cc2ef8629, git show and git grep over the head's tree in wt-main; the check-runs on 5cc2ef8629, read at 2026-10-12T17:15Z. Nothing was built, run or re-run.

① Derived judgments

Delta round. This record judges the one commit ecfb54f8b8..5cc2ef8629 on the brief's (a) to (e), plus the grep it asked for, and adopts 6128268740's ①1 to ①13 for the rest of the diff.

(a) The delta is text-only. Right. One commit, 5cc2ef8629, whose only parent is ecfb54f8b8 — no merge rode in. 5 files, +25 / −20: content/docs/permissions/authorization.mdx (+7 / −6), docs/design/permission-model.md (+4 / −3), packages/cli/src/utils/permission-set-name-collisions.ts (+7 / −5), packages/cloud-connection/src/marketplace-install-local-plugin.ts (+4 / −4), packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts (+3 / −2). Every changed line in the three .ts files sits inside a comment: the CLI module's two hunks are inside its header block comment (opened at line 3, closed at line 90); the install-local hunk is inside the JSDoc above announceHotInstall; the object file's hunk is // lines above managed_by. No code, test, changeset, ledger or main byte moved. The commit's trailers are Co-Authored-By: Claude and Claude-Session: only.

(b) ③3, sentence by sentence against the tree at 5cc2ef8629.

  1. permission-model.md:165–168 — on install the security catalog serves the sets from the package's registered metadata, no row is seeded, an upgrade serves the new version's definitions. Right. The boot writes no row (the real-boot pin and the showcase dogfood pin, ①11 and ①12 adopted); the resolver reads a set's body through catalogSetBody → catalog.resolve('permission', name) (packages/core/src/security/resolve-authz-context.ts:826), never a row. The upgrade clause has no dedicated path to pin; it is the registry model's consequence (the catalog serves what the installed version registered) and nothing at the head contradicts it.
  2. permission-set-name-collisions.ts:10–14 and :67 — the seeder's refusal in the past tense, and "the registry now refuses, at install, a package declaring a set name another holder already holds". Right. packages/objectql/src/security-catalog-namespace.ts:12–13: a name another package, the environment catalog or a built-in already holds is refused at registration, and the refusal names both holders (ruling Q4 = A); an install is a registration.
  3. marketplace-install-local-plugin.ts:2851–2854 — the re-seed retired; the catalog serves a hot-installed package's sets from the registry as soon as the install registers them, so nothing re-runs for them. Right. plugin-security has no metadata:reloaded handler at the head, and the catalog reads the registry. One line eight rows below the rewritten comment is now contradicted by it: the announceHotInstall warn string (:2878–2881) still says a kernel that cannot announce leaves "the record-change flows and permission sets" of the package to "take effect only after a restart" — for permission sets that is no longer so. ③3-b.
  4. sys-permission-set.object.ts:283–285 — the 'user' normalizer retired; such rows keep 'user'. Right: no normalizer at the head, and the runtime's provenance reads key on 'package' (the projector's branches, the two-doors gate). Eleven lines above, package_id's description (:273) still reads "Populated by bootstrapDeclaredPermissions" — present tense, user-facing, and mirrored into the four generated bundles (src/translations/{en,es-ES,ja-JP,zh-CN}.objects.generated.ts:229, hash-pinned by check:i18n-bundles); managed_by's own description (:292–297) still says "re-seeded on upgrade" and "never touched by package seeding". ③3-b.
  5. authorization.mdx:227–231, the Package door. Two of its three claims hold: the catalog serves the published body from the registry, owned by the package (ADR-0131 D3: a definition lives only in the environment registry); the landed publish fires onMetadataMutation, which runs the audience-binding suggestion reconcile and re-derives the everyone anchor's accepted package defaults (security-plugin.ts:4490–4502: type === 'permission' and not a draft → reconcileAudienceBindingSuggestions, then declareEveryone; declareEveryone at :4845 reads readAcceptedEveryoneSets). The middle claim — "no sys_permission_set row is written" (:229) — is false by the code at this head, through a path 6128268740 ①13 did not examine. The chain, each link read at 5cc2ef8629:
    • runPublishSideEffects (packages/metadata-protocol/src/protocol.ts:22466–22517) runs on every publish path, the package-draft batch included: publishPackageDrafts calls it per promoted item with the item's packageId (:23602–23609). With no permission materializer registered any more (plugin-security registers none at the head) it goes straight to the awaited ADR-0094 projector, runMutationProjector({ type: 'permission', name, state: 'active', body }) (:22504), which has no package guard (:6808–6822). Its own comment still says the projector "runs AFTER the package-door materializer (which stamps package provenance)" (:22501–22503).
    • projectPermissionMutation (packages/plugins/plugin-security/src/permission-set-projection.ts:835–896) skips only a draft, another type or an unnamed event. With a readable SchemaRegistry it takes body = overlay ?? declared, and readDeclaredBody (:494–510) scans registry.listItems('permission') — every slot of the type, bare and composite (packages/objectql/src/registry.ts:4879–4899) — so a package's just-published set is found and body is non-null.
    • upsertEnvPermissionSet (:571) finds no row for the name and inserts one with managed_by: 'admin' and no package_id (:613–655), through tryInsert under seedCtx() = { isSystem: true } (:170–176; organization-scope.ts:101); the data-door lock lets a system write through (:1143). The unit pin permission-set-projection.test.ts:350 ("CREATES a missing record (managed_by admin)") is that branch on the live path.
    • Before D the permission materializer ran first in the same function and the row was managed_by:'package' + package_id; the projector then only patched facets. So on a fresh database at this head a package-door publish — and a writable base's saveMetaItem({ packageId, mode: 'publish' }) — mints a row for the package's set with admin provenance.
    • No pin at this head measures it: package-uninstall-revokes-named-grants.dogfood.test.ts publishes exactly that way but reads only the catalog and the grants; two-doors-permission 块1 went with the materializer; showcase-permission-seeding counts rows after the boot. The PR body's table ("upserted a package-door publish into a row → deleted") and that dogfood file's header ("no door writes a sys_permission_set row") share the sentence's premise.
    • What rides on it: Setup badges the package's set "Admin"; cleanupPackagePermissions' legacy row path keys on package_id, so the row outlives the uninstall — a ghost row carrying the package's definition which, once the package's registration is withdrawn, is an "env-authored" set the data door edits in place (the allowRuntimeCreate tier). Not a ghost grant (ADR-0090 D5's clause is about grants) and not a widening: enforcement reads the registry. The Environment-admin bullet just below the rewritten one still says the data record "is a pure projection the platform re-derives on every metadata mutation (awaited)" — that is this path, and the two bullets now contradict each other.
      This is a read, not a run. The one measurement that settles it is a package-door publish on a fresh database followed by a sys_permission_set count — the 块1 shape the branch removed.

(c) ③1. Closed. The PR body at 5cc2ef8629 reads, in the opening paragraph, "option 1, the maintainer's ruling 6123311533; mechanism B, the seat's choice under option 1"; in the uninstall section, "Option 1 (the maintainer's ruling on #15204, 6123311533) … Mechanism B (the seat's choice under option 1)"; in the draft 速读, 「方案 1 是你的裁定 6123311533;机制 B 是席位在方案 1 之下的选择」. Each matches 6123311533 (the ruling is option 1) and 6125793932 ("Maintainer: option 1; seat: mechanism B"). One cosmetic residue: the Landing section still calls the mechanism "option B" ("The uninstall revoke and its pins (option B) took it over 3,000") — a naming slip that attributes nothing to anyone.

(d) CI on 5cc2ef8629 (47 check-runs, read 2026-10-12T17:15Z): success on Build Core, Build Docs, Check Changeset, Check Documentation Links, Dogfood Regression Gate (roll-up), Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates and its eight Lint jobs, the single-writer / Part-of / card-claims gates, Spec property liveness, Test Core 1/6 and 5/6, Type Check · source gates / consumer gates / debt ledger / workspace, TypeScript Type Check, filter, Auto Label, Check PR Size; skipped by design on PRs (#22998): the Dogfood Regression Gate shards, Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Console Pin Gate, Packed-tarball smoke. Red: none. Pending: Test Core 2/6, 3/6, 4/6, 6/6 — the same four 6128268740 ③4 left pending on ecfb54f8b8; a text-only commit cannot turn them.

(e) No collateral. Right. The 121 files on the PR hold no CHANGELOG.md and nothing under content/docs/releases/; the delta adds no file. The new commit's trailers carry no model name.

(f) The grep the brief asked for — present-tense mentions of the four names at 5cc2ef8629, outside history (the CHANGELOGs, content/docs/releases/v12.mdx:105, ADR-0086, ADR-0131:272) and retirement notes (the changeset, packages/spec/liveness/permission.json:32):

  • bootstrapDeclaredPermissions: sys-permission-set.object.ts:273 and the four generated bundles at :229 (above); authorization.mdx:269–270 ("boot writes carry isSystem and bypass it, so the seeder and materializer are never self-blocked") and :278 (the lifecycle table's "sharingRules / permissions seeded at boot with provenance") — both in a file the delta edited; plugin-security/src/permission-set-name-collision.ts:10–11 (the runtime-door module: "refuses … unchanged here"); permission-set-projection.ts:479 ("the same source bootstrapDeclaredPermissions seeds from"); packaged-permission-set-lock.ts:49 ("bootstrapDeclaredPermissions' readDeclared already uses"); seed-name-lookup.ts:9 ("… are read-then-write reconcilers"; the module itself is live through buildExistingByName in the boot reconcile); packages/objectql/src/engine-capability-provenance.test.ts:164–165 ("a boot seeder reads back … bootstrapDeclaredPermissions"); permission-set-projection.test.ts:44 (a rationale dated "since Boot seeds permission sets and positions one at a time: 4 sequential DB round trips each, 2 of them an unconditional UPDATE #10946" — history in shape); packages/spec/src/security/permission.zod.ts:845 (managedBy's JSDoc: "seeded by bootstrapDeclaredPermissions, re-seeded on upgrade" — the spec seat's, with the managedBy enforce-or-remove item).
  • upsertPackagePermissionSet: the CHANGELOGs only. Clean.
  • registerPublishMaterializer: a live protocol API (protocol.ts:5988), used by service-automation for flow and by protocol / objectql tests with 'permission', 'view' and 'seed' as type labels; no prose claims plugin-security still registers a permission materializer except authorization.mdx:269 above. (content/docs/concepts/metadata-lifecycle.mdx:116's "package-bound rows materialized through the metadata door" is ambiguous rather than false: plugin-security: a data-door edit of a permission set saved into a writable runtime package forks it — the write-through's update leg saves without the row's package, leaving two active sys_metadata rows for one name #21861's data-door rows exist.)
  • normalizeManagedByVocab: the changeset, the CHANGELOGs and the retired note at sys-permission-set.object.ts:284. Clean.
  • Related, not one of the four: reportPermissionSetNameCollisions is still exported from plugin-security's index.ts:67 with no caller left in the tree (its runtime door was the seeder); the CLI consumes only permissionSetNameIsForeign and the diagnostic. A liveness note, not a text one.

② Semver level

Unchanged by this delta: it touches no .changeset/ file, .changeset/15204-s6b2-declared-permission-seeders.md is byte-identical to ecfb54f8b8's, and the PR body's second line still reads Clause-②: yes (narrowing). 6128268740's ② is adopted as it stands: @objectstack/plugin-security minor + BREAKING + Clause-②: yes (narrowing) + ADR-0087 not-required (no-migration-prescription); @objectstack/metadata-protocol and @objectstack/objectql minor (additive); @objectstack/cloud-connection patch. One note for the round that answers ③3-a: if the answer is code in plugin-security (the projector skipping, or attributing to the package, a name the registry holds for a package), it is behaviour of the same breaking entry and wants a sentence there, not a new level.

③ Boundary flags

  1. ③1 — attribution of "mechanism B". Closed (①(c)). The "option B" naming slip in the Landing section is the seat's to tidy or leave.
  2. ③2 — the position-definition residual. Carried forward exactly as 6128268740 raised it: escalated to the maintainer for the reading of the ruling's word "position"; the silent half (no boot report of a position definition naming an unserved set) is an out-of-scope finding with its carrier. Untouched by this delta.
  3. ③3 — re-raised, narrowed.
    • ③3-a (fails the head). authorization.mdx:229 "no sys_permission_set row is written" is false at 5cc2ef8629: a package-door publish on a fresh database mints a managed_by:'admin' row through the ADR-0094 projector now that nothing pre-creates the package row (①(b)5, with the chain line by line). The sentence cannot be made true without a decision about the code: the projector skips a name the registry holds for a package (the reading D's title and ADR-0131 D3's "one home" imply), or attributes the row to the package, or the sentence says what happens. That decision moves behaviour on the publish seam and the row's provenance, and the maintainer's "lock the base" line runs through it, so it is the seat's to carry to the maintainer with a measurement, not this record's to rule. A pin of the 块1 shape (package-door publish → row count) belongs with whichever answer lands.
    • ③3-b (text, the same files). The five sentences the full review named are rewritten and four are right; still false in the two files the delta edited: authorization.mdx:269–270 and :278; sys-permission-set.object.ts:273 (with its four generated bundles) and :292–297; and the announceHotInstall warn string at marketplace-install-local-plugin.ts:2878–2881. The wider list in ①(f) (permission-set-name-collision.ts, permission-set-projection.ts, packaged-permission-set-lock.ts, seed-name-lookup.ts, the objectql test comment, permission.zod.ts) is text 6128268740 ①10 under-counted; carriers: this branch for the files it already touches, the spec seat for permission.zod.ts:845 alongside the managedBy retirement, C7b for the rest if it is not folded in.
  4. ③4 — CI. Test Core 2/6, 3/6, 4/6 and 6/6 pending at 17:15Z, none red; the owning seat reads them before the maintainer is asked. Unchanged in kind from 6128268740.

Out-of-scope findings, carriers as filed: 6128268740's two (the 8 checklist anchors; PACKAGE_JOBS_UNINSTALL_CLEANUP and declared) stand. Added: reportPermissionSetNameCollisions exported with no caller (①(f)), carrier C7b or whoever next trims plugin-security's entry.

Implemented-by: session_013XKtmoR5QCUSTMjiQ3os49
Reviewed-by: session_01Rerax7QTjKMPCUZxQUtPFR

VERDICT: FAIL


Generated by Claude Code

…publish; retire the remaining seeder prose

Delta review ③3-a: no seeder or materializer writes a `sys_permission_set`
row, but until stage 7 retires the ADR-0094 projector a permission-set
publish (a package-door publish, or a writable base's direct publish)
still has it write a projection row, `managed_by: 'admin'` with no
`package_id`, and the boot reconcile re-creates it when missing. No
authorization path reads it, and uninstalling the package removes it.
`authorization.mdx`, the changeset and the deletePackage dogfood header
now say so.

③3-b: the remaining present-tense mentions of the retired seeder and
materializer are reworded: `authorization.mdx`'s isSystem bypass and the
lifecycle table; `sys_permission_set`'s `package_id` and `managed_by`
descriptions (English bundle regenerated with `check-i18n-bundles
--write`); install-local's two metadata:reloaded warnings, which no
longer claim permission sets wait for a restart; and comments in
`protocol.ts`, `permission-set-projection.ts`,
`packaged-permission-set-lock.ts` and `permission-set-name-collision.ts`.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49
…ional projection row

The Environment-admin bullet's "pure projection … re-derived on every
metadata mutation" now notes that a package's published set gets such a
row too until stage 7, the transitional `admin` row the Package-door
bullet describes, so the two bullets agree.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49
@objectstack-fleet objectstack-fleet Bot changed the title feat(plugin-security)!: the boot writes no sys_permission_set row (#15204 stage 6b-2) feat(plugin-security)!: no seeder or materializer writes a sys_permission_set row; the boot seeds no row for a declared set (#15204 stage 6b-2) Oct 12, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 320ce20b743c40d6908ae9fb83ccc2e1efba3cec
Local-runs: none

Inputs: the round-2 delta brief; the full contract review 6128268740 on ecfb54f8b8 and the delta review 6129112138 on 5cc2ef8629 (adopted below for everything this round does not touch); the seat's 7a note 6129253247 on #22975; the PR title, body (opening paragraph, the new section "The ADR-0094 projector until stage 7 (measured)", the Acceptance notes and the draft 速读), the 123-file list and the comment thread at 320ce20b74; git diff 5cc2ef8629 320ce20b74, git show and git grep over the head's tree in wt-main; the check-runs on 320ce20b74, read at 2026-10-12T18:18Z. Nothing was built, run or re-run.

① Derived judgments

Delta round 2. This record judges the two commits 5cc2ef8629..320ce20b74 on the brief's (a) to (e) and adopts 6128268740's ①1 to ①13 and 6129112138's (a), (c) and (e) for the rest of the diff.

(a) Scope of the round. Text-only; right. Two commits, 70a42bf2f5 (parent 5cc2ef8629) and 320ce20b74 (parent 70a42bf2f5): no merge rode in. 10 files, +41 / −30, each hunk read: the changeset's summary line and transitional paragraph (prose); authorization.mdx (four prose hunks); marketplace-install-local-plugin.ts:2871–2874 and :2880–2883 (the two announceHotInstall template strings, no control flow); protocol.ts:22501–22504 (comment); sys-permission-set.object.ts:271–273 and :291–296 (the package_id and managed_by description strings); packaged-permission-set-lock.ts:49, permission-set-name-collision.ts:10–13, permission-set-projection.ts:479 (comments); en.objects.generated.ts:229 and :233; the dogfood header :3–9 (comment). No executable logic, test assertion, ledger or main byte moved. The en bundle is the generator's output in shape: the extractor writes each field's help leaf from field.help ?? field.description (packages/cli/src/utils/i18n-extract.ts:1228), the two changed leaves equal the two new descriptions byte for byte, and nothing else in the file moved; Lint & Repo Gates, which carries check:i18n, is green on the head. The es-ES, ja-JP and zh-CN bundles are legitimately untouched by the generator: their leaves are hand-written translations the merge never overwrites (the bundle header: "a present-but-stale string is not a gap, so it is left as-is"), and *.source-hashes.generated.ts holds no entry for either path, so they are legacy-trusted and never reported stale. They do, however, still carry the retired sentence in translation — ③ item 2. Both commits' trailers are Co-Authored-By: Claude and Claude-Session:; no model name.

(b) ③3-a, sentence by sentence against the tree at 320ce20b74 and the measured table.

  1. PR title and opening paragraph. Right. "no seeder or materializer writes a sys_permission_set row; the boot seeds no row for a declared set" is what the tree does: plugin-security registers no permission materializer (registerPublishMaterializer has no non-test caller left; protocol.ts:5988 is the API), no seeder exists, and the real-boot pin asserts zero rows for the declared sets (①11–12 adopted). The opening paragraph states the transitional row in its own sentence, naming 7a / C3 #15204 stages 7a ∥ 7b → 8-post, build ahead: delete the permission-set projection and the position write-through that stage 8 leaves unreferenced, then the post-stage-8 catalog gates and residue #22975.
  2. Changeset summary and transitional sentence. Right. The summary no longer says "the boot writes no row" alone; the paragraph drops "not on a package-door publish" and adds: a package-door publish or a writable base's direct publish still has the ADR-0094 projector write a projection row, managed_by: 'admin' with no package_id; a package-door publish wrote managed_by: 'package' before this release; no authorization path reads it; uninstalling the package removes it. Each clause matches the code chain 6129112138 ①(b)5 laid out (runPublishSideEffects → runMutationProjector → projectPermissionMutation → upsertEnvPermissionSet:632 inserts managed_by: 'admin', no package_id) and the measured rows 1, 2 and 5. "No authorization path reads it": resolve-authz-context.ts:1060–1065 (a grant names its set by name, catalogSetBody reads the catalog, "the grant's permission_set_id is not read"); admin-standing-surface.ts:115 ("the sys_permission_set and sys_position rows are no longer read at all"); permission-set-row-definition.ts is the C7b ceremony's mapping, "never at boot"; sys_permission_set in GRANTS_CACHE_WATCHED_OBJECTS is a cache-invalidation trigger, not a read. "Uninstalling the package removes it": deletePackage runs the projector's delete leg and retirePermissionSetRecord (permission-set-projection.ts:786–812) deletes any row that is not managed_by:'package' — measured on both trees. One completeness note, not a falsehood: the changeset does not say what the body's table does, that an admin data-door DELETE of such a set now deletes it end to end where the previous release answered success:false and kept the row and the served body. That is a consumer-visible difference the transitional paragraph could carry in one clause; ③ item 1.
  3. authorization.mdx, Package door (:224–237). Right. "no sys_permission_set row is written" is gone; the bullet now says the publish (a package-door draft publish, or a writable base's direct publish) also has the projector write a projection row, managedBy:'admin' with no packageId, that the boot reconcile re-creates it if missing, that no authorization path reads it, and that uninstalling the package removes it. The boot-reconcile clause: reconcilePermissionSetProjection:1580–1612 reads every sys_metadata row of type permission/permissions, state active, organization_id null — a package-door publish's row and a base's row alike — and runs projectPermissionMutation for each, which inserts when no row exists. Measured for the base case (file-backed SQLite, re-boot); the package-door case follows by the same code and is not separately measured, which the body's table says ("boot with a base-published set"). Nothing in the bullet is absolute any more.
  4. authorization.mdx, Environment-admin door (:238–255). Right. "pure projection … re-derived on every metadata mutation … and at boot" now carries "A package's published set gets such a row too, until stage 7 — the transitional managedBy:'admin' row described in the Package-door bullet above", so the two bullets agree. The posture sentence that follows (:250–252) is unchanged and right: walled-catalog-data-door.ts refuses everyone but the platform operator under a wall and is inert under single.
  5. authorization.mdx :274–276 and :284. Right. The isSystem bypass now reads "(the ADR-0094 projector's among them), so the platform's own writes are never self-blocked" — the projector writes under SYSTEM_CTX = { isSystem: true } (permission-set-projection.ts:105, :170–176), and the write-through lets a system write through (:1143). The lifecycle table's row 1 now reads "sharingRules seeded at boot with provenance; permission sets and positions served from the registry, with no seeded row (ADR-0131 D2/D3)": bootstrapDeclaredSharingRules is still called from sharing-plugin.ts:768, and the permission half is this PR.
  6. The dogfood header (:3–9). Right. "no door writes a sys_permission_set row" became "no seeder or materializer writes", with the parenthesis that the publish below still has the projector write an admin row no authorization path reads and that the uninstall removes it — the suite's own uninstall is deletePackage, the door measured.
  7. The body's new section. Right, with one clause to weigh. The six measured rows read as the code reads: row 1 is the chain in item 2; row 2 is the same insert on a base's publish; row 3 (PATCH 200 on both, landing in the base's sys_metadata row, no overlay) is the capable write-through translating into saveMetaItem on a sys_metadata-backed set, the allowRuntimeCreate tier; row 4's difference is retirePermissionSetRecord:795 — a managed_by:'package' row is "left to the package door" and stays while the sys_metadata row goes (main's half-apply), an admin row is deleted (this head); row 5 is the same leg on deletePackage; row 6 is item 3. "No authorization path reads the row" — item 2. "Stage-plan rule … not fixed here", "the seat carries … on 7a's card" (6129253247 adds the zero-row acceptance to 7a) and "(A) is offered to the maintainer" each match the record. The clause "The one change from main is the provenance of a package-door publish's row (package → admin) and what a data-door delete of it does" names the two measured differences; the row's provenance also moves what else keys on managed_by:'package' — Setup's badge, the data-layer gate's transfer/restore/purge refusals (security-plugin.ts:6957–7024), the uninstall cleanup's legacy row path (cleanup-package-permissions.ts:131), the boot reconcile's skip (:1640) and the customized flag (:676) — none of which an authorization path reads. A consequence list, not a contradiction.
  8. The Acceptance notes' data-door bullet. Five clauses right, one false about a mechanism, one loose. PATCH 200 on both; DELETE end to end here and half-applied on main; the grant that stays, goes dead and is live again when the base republishes the name (nothing revokes a grant on a set delete — the onMetadataMutation listener re-derives everyone and reconciles suggestions, and the by-name revoke runs only on uninstall); the carrier 7a — each matches the code. False: "the provenance key: the packaged-set lock keys on managed_by:'package', which a publish no longer stamps here." The packaged-set lock is packaged-permission-set-lock.ts (the tree's own name for it, in its tests, CHANGELOGs and 17-7.mdx), and it keys on the artifact, never on the row: classifyPackagedPermissionSet:198–260 reads registry.listItems('permission') and the layered code layer, declaredPackageIdOf:168–184 answers a package id only for an item that is neither a projection echo nor tenant-authored, and the module's header has a section titled "Why not managed_by" ("provenance is decided from the ARTIFACT"). So the lock answers the same on both trees — a base-published set is org and editable, a code-shipped set stays locked — which is exactly why the measured PATCH is 200 on main too, where the row was package. What managed_by:'package' does key, and what the provenance change therefore moves, is the list in item 7: the projector's delete leg (the measured DELETE difference), the data-layer gate's lifecycle-op refusals, the cleanup's legacy row path, the reconcile's skip, customized. A reader of the Tier H brief would take the sentence to mean the lock-the-base ruling's enforcement lapsed for a package's published set under (B); it did not. The body is not the head, so the correction is a body edit (text offered in ③ item 1). Loose: "under single the only actor is the platform operator" — the wall is inert under single (walled-catalog-data-door.ts:46), so the door is open to any caller holding CRUD on sys_permission_set; the platform operator is the actor in practice, not by refusal.
  9. Left unstated? Nothing measured is missing from the body: the provenance key is stated (misattributed, item 8), the dead grant's revival is stated, the boot reconcile and the uninstall removal are stated in the body, the changeset and the docs. Nothing overstates the transitional behaviour: no text claims the projector row carries the package, grants anything, or survives the uninstall.

(c) ③3-b.

  1. The two field descriptions. Right in English. package_id (:271–273): "Set on rows an earlier release seeded for a package's set; no current writer sets it" — true: the only package_id writer was the seeder's and the materializer's permissionSetRowFields path for package rows, both gone; the projector inserts no package_id. managed_by (:291–296): 'package' = "a row an earlier release seeded for a package's set (read-mostly for admins)"; 'admin' = "created/owned in this environment by an administrator (live-edited)"; "re-seeded on upgrade" and "never touched by package seeding" are gone. The en bundle leaves (:229, :233) equal them. Not closed in the three translated bundles: es-ES.objects.generated.ts:229 ("Lo rellena bootstrapDeclaredPermissions"), ja-JP…:229 ("bootstrapDeclaredPermissions が設定し") and zh-CN…:229 ("由 bootstrapDeclaredPermissions 填充") still name the retired seeder as the writer of package_id, present tense, in Setup's tooltips for those locales; their managed_by leaves (:233) are short paraphrases that carry neither retired clause. The generator cannot fix them (①(a)); a hand edit of three strings can, and no gate reports them. ③ item 2.
  2. The warning strings. Closed. :2871–2874 no longer says the package's permission sets wait for a restart; :2880–2883 no longer says they "may be missing from sys_permission_set". git grep at the head finds neither old fragment anywhere; the one test that reads these strings, marketplace-install-local-hot-resync.test.ts:173 and :183, matches on 'metadata:reloaded re-sync FAILED' and 'cannot announce metadata:reloaded', both of which survive, so no test or doc quotes the old wording.
  3. The four comments. Closed. permission-set-projection.ts:479 and packaged-permission-set-lock.ts:49 call the seeder retired (the latter still names readDeclared, which is live in declared-capability-context.ts); permission-set-name-collision.ts:10–13 puts the seeder's refusal in the past tense and names the registry's one-holder refusal at install (security-catalog-namespace.ts, ①5 adopted); protocol.ts:22501–22504 says the projector runs after any materializer registered for the type and that no permission materializer is registered since 6b-2 — true at the head.
  4. "(mechanism B)" in the Landing section. Closed. "The uninstall revoke and its pins (mechanism B) took it over 3,000"; "option B" no longer appears in the body.
  5. The carriers named in the Acceptance notes. Right. Each named line still carries the text it is named for at the head: permission.zod.ts:843–847 (managedBy's JSDoc, "seeded by bootstrapDeclaredPermissions, re-seeded on upgrade … never touched by package seeding") to the spec seat with the managedBy item; seed-name-lookup.ts:9–10 and engine-capability-provenance.test.ts:164–165 (comments naming the seeder as a live reader) to C7b.
  6. The sentence the round made newly inexact. managed_by's 'admin' gloss, "created/owned in this environment by an administrator (live-edited)", now also describes the projector's transitional row for a package's published set, which the package owns and the administrator does not. A Setup reader sees such a set badged "Admin" with that tooltip. Inexact rather than contradicted: the row's provenance and its transience are stated in authorization.mdx, the changeset and the body, and the value is the one the projector has always stamped on a row it creates. ③ item 2 names the carrier.

(d) Changeset and semver. Unchanged and right. The round's two hunks in .changeset/15204-s6b2-declared-permission-seeders.md are the summary line and the first paragraph; the front matter (plugin-security minor, metadata-protocol minor, objectql minor, cloud-connection patch), Clause-②: yes (narrowing), the **BREAKING** banner and the adr-0087 marker not-required (no-migration-prescription) are byte-identical to ecfb54f8b8's. The transitional paragraph describes a row no author writes and no authorization path reads; it adds nothing an author writes or a migration must rewrite, so the level and the disposition stand (② below).

(e) CI on 320ce20b74 (46 check-runs, read 2026-10-12T18:18Z): success on Build Core, Build Docs, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate (roll-up), Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates and its eight Lint jobs (CI/release/engine gates, ESLint, PM dispatch-gates, docs & code guards, repo & tooling gates, query-options erasure, slot lookup & corpus, tooling self-tests), the card-claims / single-writer / same-issue / Part-of gates, Spec property liveness, Type Check · consumer gates / debt ledger / source gates, filter, Auto Label; skipped by design on PRs (#22998): the Dogfood Regression Gate shards, Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Console Pin Gate, Packed-tarball smoke. Red: none. Pending: Test Core 1/6 to 6/6 and Type Check · workspace — a second run started on this head after the PR edit, so every shard is in flight again; a text-only pair of commits cannot turn them, and the Tier H rule needs every check green regardless of this verdict.

② Semver level

Unchanged by this round in level and marker: .changeset/15204-s6b2-declared-permission-seeders.md still declares @objectstack/plugin-security minor + **BREAKING** + Clause-②: yes (narrowing) + ADR-0087 not-required (no-migration-prescription), @objectstack/metadata-protocol and @objectstack/objectql minor (additive), @objectstack/cloud-connection patch; the PR body's second line reads Clause-②: yes (narrowing). 6128268740's ② is adopted as it stands, and 6129112138's note is discharged: the answer to ③3-a was option (B), no code, so the breaking entry gained the sentence that describes the transitional row rather than a new behaviour or level. Check Changeset is green on the head.

③ Boundary flags

  1. ③3-a — closed in the tree; one body sentence to correct before the Tier H brief. Every sentence in the head's files that stated "no row" now states the transitional admin row, its re-creation at boot, that nothing authorizes from it and that the uninstall removes it, and each matches the code and the measured table (①2–7). In the PR body, the Acceptance notes' clause "the packaged-set lock keys on managed_by:'package', which a publish no longer stamps here" is false about the lock (①8); suggested replacement: "the provenance key: the row's managed_by:'package' is what the projector's delete leg, the data-layer gate's transfer/restore/purge refusals and the uninstall cleanup's legacy row path key on, and a publish no longer stamps it here; the packaged-set lock (packaged-permission-set-lock.ts) keys on the artifact, not the row, and answers the same on both trees". The posture clause could read "under single the wall is inert, so the door is open to any caller with CRUD on sys_permission_set (the platform operator in practice)". Optional, one clause: the changeset's transitional paragraph could carry the data-door DELETE difference the body's table measures (①2). Body edits change no head and need no new record; 6128268740 ③1 treated the body's attribution the same way.
  2. ③3-b — closed in English and in every comment named; two residues with carriers. (i) The three translated package_id help leaves (es-ES, ja-JP, zh-CN .objects.generated.ts:229) still name bootstrapDeclaredPermissions as the writer (①10): hand-edit the three strings, which the merge keeps and no hash entry guards — this branch's next head if one is cut, otherwise C7b with the other stale seeder prose. (ii) The managed_by 'admin' gloss now also covers the projector's transitional row for a package's published set (①15): 7a, which deletes the projector and the row with it, is the natural carrier; if a head is cut for (i), one clause ("also the transitional projection row of a published set, until the projector retires") closes it here. Neither moves an accept set or a grant; both are the class of stale prose 6128268740 ③3 and 6129112138 ③3-b carried as flags.
  3. ③2 — the position-definition residual. Carried forward exactly as 6128268740 ③2 raised it and 6129112138 ③2 carried it: escalated to the maintainer for the reading of the ruling's word "position"; the silent half (no boot report of a position definition naming an unserved set) is an out-of-scope finding with its carrier. Untouched by this round.
  4. (A) / (B) — the maintainer's choice, escalated by the seat. Not this record's question. What this record adds for it: under (B) at this head the lock-the-base enforcement is unchanged from main (①8), the only behaviours that move with the row's provenance are the projector's delete leg and the lifecycle-op refusals, and 7a's card carries the zero-row acceptance (6129253247).
  5. ③4 — CI. Test Core 1/6 to 6/6 and Type Check · workspace pending at 18:18Z, none red; the owning seat reads them before the maintainer is asked.

Out-of-scope findings, carriers as filed: 6128268740's two (the 8 checklist anchors; PACKAGE_JOBS_UNINSTALL_CLEANUP and declared) and 6129112138's one (reportPermissionSetNameCollisions exported with no caller) stand.

Implemented-by: session_013XKtmoR5QCUSTMjiQ3os49
Reviewed-by: session_01Rerax7QTjKMPCUZxQUtPFR

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat ACCEPT: PR #22900 (stage 6b-2 D, the declared-permission seeders retired) at 320ce20b74: Tier H, awaiting the maintainer

Epic PM session_01Rerax7QTjKMPCUZxQUtPFR (marchtian), seat epic:#15194, 2026-10-12T18:40Z. Cloud dev session_013XKtmoR5QCUSTMjiQ3os49.

  • Rulings: option 1 (6123311533); the first-next ruling (6125978136).

Contract reviews:

  • full PASS 6128268740 on ecfb54f8b8;
  • delta FAIL 6129112138 on 5cc2ef8629 (③3-a: the ADR-0094 projector still writes a row on publish);
  • delta PASS 6130075012 on 320ce20b74.

Checklist, read on GitHub and on the tree, not from the report:

  • PR form: draft, base main; line 1 Part of #15204, line 2 Clause-②: yes (narrowing); no closing keyword; assignee marchtian. No CHANGELOG.md, no content/docs/releases/. No model name in any trailer.

  • Size and tier:

    • D's own diff over main df7a137923 + T1 bb7b4cef36 + T2 a38b54f6f2 is 3,601 changed lines (48 files) at ecfb54f8b8. The review reproduced it with git in a separate clone.
    • The two text rounds add +66 / −50 across 14 files.
    • No governed path is touched. Tier H by size: an approval from GOVERNED_APPROVERS, or the maintainer's merge.
  • The ruled uninstall cleanup:

    • one capture, at the same point on both doors (deletePackage and install-local DELETE), from two sources: the registry's securityCatalogNamesHeldBy, and deletePackage's stored rows;
    • each source has its own ablated pin;
    • the by-name revoke cannot take another holder's grant, since the registry admits one holder per name;
    • option 1 is the maintainer's, and mechanism B is the seat's choice under it. The PR body now says so.
  • The T1 / T2 bytes D carries:

    • T1: 13 of 16 files blob-identical;
    • T2: 56 of 58 blob-identical;
    • the rest are the two baseline ledgers (the union of both sides' removals) and engine-double-contract.pinned.json (D's 10 new lines).
    • Landing T1 → T2 → D produces D's tree.
  • ③3-a, the transitional projection row, measured by the dev on a fresh database (CRM stack), D against main:

    • a package-door publish leaves one managed_by:'admin' row with no package_id (main: package + package_id);
    • a writable base's direct publish is admin on both;
    • the boot reconcile re-creates it for a base-published set;
    • data-door PATCH: 200 on both;
    • data-door DELETE: deletes end to end on D, half-applies on main;
    • deletePackage removes the row on both;
    • no authorization path reads it.

    The seat chose (B): no code in D, under the stage plan's "only delete, never fix" for permission-set-projection.ts, which stage 7 retires. D's title, changeset, docs, test header and body state the behaviour. The zero-row acceptance is on 7a's card (6129253247). (A), a projector guard of about +90 lines, is offered to the maintainer in the brief.

  • Escalated to the maintainer, not blocking:

    • ③2: the reading of "position" in the ruling. A catalog position definition listing an uninstalled package's set stays as authored: inert while the set is unserved, live again on reinstall, and not reported at boot.
    • (A) / (B), above.
  • Evidence:

    • local at ecfb54f8b8 (main merged in): the 21 packages' suites 82/82; full dogfood 258 files, 2,117 passed; dispatch-gates 117/117;
    • text rounds: plugin-security 191 files / 3,973 passed; cloud-connection 44 / 538; check-i18n-bundles 0; dispatch-gates 119/119.
    • The one non-zero is check:platform-checklist, the watchdog's: 8 anchors that are absent on main too.
  • CI on 320ce20b74: 34 success and 7 skipped by design (the dogfood shards, Dogfood Verify CLI, Temporal Conformance and Console Pin Gate under skills(ci): two-speed CI — fast set on PR and merge-queue runs, full battery hourly on main and before a release, main-red stops the lane, four required contexts plus the guard, timing gates report-only, test budgets not deletions #22998; Packed-tarball smoke; Auto Label and Check PR Size on the body-edit rerun, both success on the push run); 0 red, 0 pending. T1 bb7b4cef36 and T2 a38b54f6f2 are green.

In this same stroke:

The seat does not flip D ready or enqueue it before an authorized approval.

Landing, after approval:

  1. T1 merges main and lands, then T2 does the same, then D.
  2. Each re-merged head is byte-checked by the seat against the head this record accepts, plus main's movement, before it is queued.
  3. If the re-merge dismisses the approval, the seat asks for it again on the final head.
  4. On D MERGED: a Landed record, and C3 #15204 stage 8: S8-T #22962 + S8 retire the four catalog row objects; both Tier H; land right after D #22900 (handed to the engine lane) #23056 goes from pm:blocked to pm:queue.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读(终稿)· #15204 阶段 6b-2 D:播种器和物化器不再写 sys_permission_set 行

席位 epic:#15194(epic PM session_01Rerax7QTjKMPCUZxQUtPFR,marchtian)· 2026-10-12T18:41Z。本稿以席位读过的 diff 和契约审查记录为准,替代 PR 正文里的草稿。审核记录:契约审查 PASS 6128268740(ecfb54f8b8);文字轮增量审查 FAIL 6129112138(5cc2ef8629,过渡期行为,见下);第二轮增量审查 PASS 6130075012(320ce20b74);席位 ACCEPT 6130401575。

改了什么

  • 不再有播种器或物化器向 sys_permission_set 表写行,启动时也不再为声明的权限集播种行(阶段 7a 之前还有一处过渡期写入,见下)。删掉了以下写入者:
    • 包声明权限集的播种器;
    • 平台默认权限集的物化;
    • 包发布时的物化器;
    • 热安装后的重新播种;
    • 新建组织时的播种。
  • 随写入者一起删掉的:
    • 基于这些行的漂移诊断、managed_by 归一化和播种拒绝诊断;
    • drift_status、drift_detail 两个字段和「需要关注」视图。
  • 权限集发布后,「建议」对账和 everyone 的重新推导改为跟着元数据变更事件跑:每次发布、直接保存或删除 permission 都跑;草稿和其他类型不跑。这也修掉了 U2 审查留下的一个缺口:发布的内容去掉 isDefault 后,要等到下次启动才重新推导。
  • 卸载包时,仍会撤销所有指向该包权限集的授权,改为按名称撤销。两个卸载入口在把包从注册表撤下之前,先记下该包持有的权限集名称,交给清理步骤撤销。名称有两个来源:注册表里的名称,加上 deletePackage 已读到的该包存储行。方案 1 是你裁的(6123311533);「先记名、再撤下、交给清理」这个做法(机制 B)是席位在方案 1 下选的。

为什么改

  • ADR-0131 D3 规定权限集只有一个家:注册表。鉴权早已从注册表读定义,授权也已按名称记录(6b-2-pre),这些行不再决定任何权限。
  • 按名称撤销是为了守住 ADR-0090 D5「不留幽灵授权」。否则卸载后授权会残留,重新安装后又生效。

风险与代价(含回滚)

  • 已有数据库: 保留旧行,直到 C7b;走旧行的撤销路径照旧。两个被删字段的物理列还在库里,没人再读,不丢需要的数据。
  • 对外接口: 删除了若干公开导出,以及 bootstrapPlatformAdmin 的 resync 选项。changeset 逐项列出,是 BREAKING(按发布期规则记为 minor)。作者写的元数据一项都不变,所以不需要迁移说明。新增的卸载交接字段是可选的,不读它的清理步骤行为不变。
  • 体量: 只算 D 自己的改动,在 main + T1 + T2 之上是 48 个文件、3,601 行,超过 3,000 行,所以是 Tier H。不碰受管控的路径。审查方用 git 独立复算,结果一致。
  • 测试: 开发在合并 main 后跑了全量测试,82/82 通过;dogfood 258 个文件、2,117 项通过。320ce20b74 上的 CI 全绿:34 项通过,7 项按设计跳过,0 项失败;T1、T2 的 CI 也全绿。
  • 回滚: 直接 revert,不涉及数据迁移。

过渡期的一处行为(增量审查 6129112138 发现,开发已实测)

  • D 删掉了发布时的物化器,但阶段 7a 才删的 ADR-0094 投影器还在。所以 7a 合入之前,通过包入口发布某个包的权限集,表里会出现一行标成「管理员」、不带包名的记录;main 上这一行原本标「包」。
  • 实测:
    • 权限判定不读这一行。
    • 卸载包时这一行会被删掉,不会残留。
    • 在数据页面编辑这一行,D 和 main 结果相同。
    • 在数据页面删除这一行,两边不同。D 上会删得干净:这一行、base 存的元数据、目录里的权限集一起没了。main 上同样的删除只删了一半,并返回失败。被删权限集上的授权,两边都会留下、变成失效;base 以后再发布同名权限集,这条授权会重新生效。
    • 谁能做这个删除:group/isolated 下,隔离墙只放行平台运营者;single 下隔离墙不起作用,任何对 sys_permission_set 有数据权限的人都能做,实际上就是运营者本人。
    • 可写 base 直接发布时,main 上本来就写「管理员」行。
    • 已经发布过权限集的 base,如果这一行被删了,下次启动时投影器的对账会把它重新建出来(开发用文件数据库重启实测过)。
  • 席位按阶段计划「待退役代码只删不修」选了 B:D 不动投影器,只把文字改准,说明 7a 之前的这个行为;7a 的卡(C3 #15204 stages 7a ∥ 7b → 8-post, build ahead: delete the permission-set projection and the position write-through that stage 8 leaves unreferenced, then the post-stage-8 catalog gates and residue #22975)已加上「7a 之后发布不再写任何行」的验收和测试(6129253247)。
  • 另一个选项 A:在 D 里给投影器加约 15 行判断,让它不为包的权限集新建行(数据页面也就无从编辑或删除这类权限集),D 再增加约 90 行。你如果要 A,说一声即可,开发随即补上。

需要你确认的一处读法(审查方升级给你,不阻塞)

  • 你裁决里说的「指向该包权限集的用户或岗位授权」,审查方读作授权行:用户授权行和旧的岗位绑定行。D 对这两种都会撤销。
  • 新库里还剩一种情况:某个岗位的定义(代码声明的,或 Studio 里作者写的)在 permissionSets 里列了被卸载包的集合。
    • 这属于作者写的元数据,D 不去改它。
    • 包卸载后,这一项立刻不生效,符合 D5「立刻撤销」;包重新安装后会恢复生效。
    • 启动时的目录引用报告不会提示这种悬空引用。
  • 审查方的判断:这与 ADR-0131 D3、ADR-0090 D5 的原文一致。要改,只能让卸载去改别人写的岗位定义,而同一组 ADR 不允许这样做。
  • 你同意这个读法,就不需要动 D。你如果希望启动时能报出这类悬空引用,说一声,席位会另立一张卡交给负责的车道。

席位意见
建议批准。

  • 完整契约审查 PASS。文字轮的增量审查一度 FAIL,原因是上面的过渡期行为没写对;如实写清后,第二轮增量审查 PASS。完整审查逐条核过:两个卸载入口、两个名称来源、各自的消融测试都会变红;按名称撤销不会误伤别人的授权;删掉的导出在树上已经没人调用;single 下首个平台管理员仍会被提升。
  • D 携带的 T1、T2 内容:T1 的 16 个文件中 13 个、T2 的 58 个文件中 56 个,与它们的 PR 头逐字节一致。其余只是两个基线账本取两边删除项的并集,外加 D 自己新增的 10 行测试登记。按 T1 → T2 → D 依次合入,得到的就是 D 现在的树。
  • 两轮文字修订已完成:PR 正文里谁裁了什么;文档、注释和用户可见说明里描述已删写入者的过时句子;以及上面过渡期行为的如实说明。

你要做的


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review October 12, 2026 21:36
@os-zhuang
os-zhuang enabled auto-merge October 12, 2026 21:36
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 12, 2026
@os-zhuang
os-zhuang removed this pull request from the merge queue due to a manual request Oct 12, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants