Repository navigation
feat(objectql,plugin-security)!: an object a deployment declares platform-global gets no organization column on that deployment — the #12699 declaration made total (ADR-0131 D7) - #22331
Conversation
…o organization column (ADR-0131 D7) The injected-columns plan takes the deployment's platformGlobalObjects as its input; the engine reads it at start() before the first schema sync and re-plans objects registered earlier; plugin-security's stand-down fold retires. Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
… and the declared order (ADR-0131 D7) Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
…he plan (ADR-0131 D7) Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
…column plan (ADR-0131 D7) Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
…R-0131 D7) Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
…atform-global-no-column
…ed ledger Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
…umn pin Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 5 package(s): 17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 149 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9c4e687b57e7472fdc1d9a473baa807d741ff86f && git checkout 9c4e687b57e7472fdc1d9a473baa807d741ff86f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 28bff18d0c4013db86d61eba87c739c3145e17fd 6b055079e8f2e874d4ec46e5249246b50bc5ee1f && git checkout -B drift-repro 28bff18d0c4013db86d61eba87c739c3145e17fd && git merge --no-ff 6b055079e8f2e874d4ec46e5249246b50bc5ee1f
node scripts/docs-audit/affected-docs.mjs --json 28bff18d0c4013db86d61eba87c739c3145e17fd
|
Contract reviewServed-tier: Scope: PR #22331, card #15207 scope item (4), the #12699 declaration made total (ADR-0131 D7, C6). Reviewed at the head above against merge base ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…ker id (ADR-0131 D7) The step-18 D3 entry's reason said the declaration by its tracker number; os migrate meta prints that field, and author-shown guidance carries none. The step rationale fragment says it the same way. Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Delta review of PR #22331 (card #15207 scope item (4), ADR-0131 D7, C6) at the head above. It supersedes the PASS ① Derived judgments
② Semver levelUnchanged by this commit, which touches no published surface and no changeset: Clause-② ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
… merging main at 41d0d40 (step 18: 64 conversions, 324 semantic entries) main added two step-18 semantic entries since 6729e10: flow-builtin-node-config-undeclared-keys-refused (#22319) and platform-global-object-organization-column-retired (#22331). At protocol 18 both generators project every step-18 entry, so both documents gain them. The conversion ids are unchanged. Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
…ion-set name column (ADR-0131 D4, C2 stage S5b) (objectstack-ai#22352) Part of objectstack-ai#15196 Clause-②: no ADR-0131 D4, C2 stage S5b. The `domain:services` grant readers key on `sys_user_permission_set.permission_set`, the grant's permission set by name, instead of `permission_set_id`. S4a dual-writes that column and S4b backfills it. Untouched: the resolver in `@objectstack/core` (S5a), `verify` (S5c), `security-plugin.ts`, `packages/spec` and the id column (C8). Nobody's resolved permissions change. The goldens below hold that per principal, and the ablation shows the readers really read the name. ## The census's REWRITE-C2 read sites, re-read on the landed shape The base is `28bff18d0c`, which is `origin/main` at worktree creation; `fe98cc63..28bff18` touches only `packages/cli`. The census was taken at `e67ba80049`. | File · function | Census → base | Read by id at base | Read after this PR | |:--|:--|:--|:--| | `explain-engine.ts` · `collectGrantProvenance` (grants) | :510 → :510 | maps `permission_set_id` | maps `permission_set` | | `explain-engine.ts` · `collectGrantProvenance` (set rows) | :522 → :522 | set rows by id | set rows by the grant's name: own organization's row, else the organization-less one (`readGrantSetRows`) | | `delegated-admin-gate.ts` · `assertDirectGrantWrite` | :790 → :825 | pre-image's id leads to the set row | pre-image's name (delete, or an update that keeps the set); an insert or re-pointing update still reads the id it writes | | `delegated-admin-gate.ts` · `loadSetRowById` | :1238 → :1270 | the set row by id | kept for the written id only; the pre-image uses `loadSetRowForGrant` | | `bootstrap-platform-admin.ts` · `findPlatformAdminGrantHolder` legs A and B | :637 → :638, :653 → :654 | grants by the admin row's id | grants naming `admin_full_access`; leg C (new) is described under "A grant whose name is empty" | | `bootstrap-platform-admin.ts` · `findExistingPlatformAdmin` | :706 → :706 | set row by name, to get the id | unchanged (existence precondition; the id feeds leg C only) | | `bootstrap-platform-admin.ts` · `bootstrapPlatformAdmin` promote | :1133 → :1170 | writer | unchanged (writes both columns since S4a) | | `auto-org-admin-grant.ts` · `resolvePermissionSetId` | :452 → :452 | set row by name, to get the id | unchanged (the id a new grant is written with) | | `auto-org-admin-grant.ts` · `resolvePermissionSetIdsForName` | :560 → :560 | every copy's id | unchanged; feeds the unnamed id leg only | | `auto-org-admin-grant.ts` · `reconcileOrgAdminGrant` superseded / existing / revoke reads | :721, :763, :818 → :721, :763, :821 | grants by id | grants by name, plus unnamed grants by id (`grantsHoldingName`) | | `auto-org-admin-grant.ts` · `backfillOrgAdminGrants` orphan sweep | :935 → :938 | grants by id | by name, plus unnamed grants by id | | `auth-manager.ts` · `findPermissionSetRows` | :5007 → :5006 | set rows by name | unchanged (the writer's id, and `active`) | | `auth-manager.ts` · `settleSelfRegistrationGrant` existence | :5274 → :5273 | `(user, id)` | `(user, name)` | | `ensure-default-organization.ts` · `ensureDefaultOrganization` | :354, :359 → :464, :471 | set row by name, then grants by id | set row (existence) unchanged; grants naming `admin_full_access` | | `last-admin-guard.ts` · `resolveAdminUserIds` | :945, :961 → same | set rows by name, then grants by id | grants naming the set, when every row bearing the name is in effect | | `last-admin-guard.ts` · `refuseIfEmptiedRatherThanFresh` | :1137, :1151, :1180 → same | known ids, held ids, `$nin` known ids | known names, held by name, `$nin` known names | | `last-admin-guard.ts` · grant and set hooks | :1554, :1560, :1581, :1617 → same | via the enumeration | via the enumeration; `permission_set` joins `GRANT_STANDING_KEYS` | The census predates no other by-id grant read in these seven files. The other by-id reads there (`delegated-admin-gate.ts` binding write and `setsBoundToPosition`) are the position-binding junction, which C3 keeps. ## A grant whose name is empty A grant written before the column existed has no name until S4b's backfill names it. The backfill runs at `kernel:bootstrapped`. The bootstrap, the organization-admin backfill and the default-organization bind all run at `kernel:ready`, earlier (`security-plugin.ts` near :4992, :5020, :5185). On an upgraded deployment's first boot, then, every grant is still unnamed when those readers run. S4b also leaves some grants unnamed for good: dangling ids, ids on another organization's row, and names the catalog does not resolve. Each reader takes the fail-closed side: | Reader | Answer for an unnamed grant | |:--|:--| | explain provenance | reports nothing; the grant names no set | | delegated-admin gate (pre-image) | refuses the delegate (`names no permission set`); a tenant admin is not judged here | | platform-admin bootstrap | leg C reaches an unscoped human grant on the admin row through its id and **withholds** the promotion (`reason: 'admin_grant_unnamed'`, warn). It names no `adminUserId`, so `findExistingPlatformAdmin` answers `undefined` | | organization-admin reconcile | revoke reach and duplicate check still find it through its id; nothing is granted through it | | last-admin guard | counted as no administrator; an unscoped, in-window one is **evidence** in the zero-administrator path, so the write is refused rather than the bootstrap window opening | | default-organization bootstrap | `no_admin`; no decision is recorded, so a later trigger binds | | self-registration | n/a: a new user's grants are written with names | The id leg is used only to restrict (revoke, refuse a promotion, block a duplicate insert), never to confer. It goes when C8 counts the unnamed grants and drops the column. ## Goldens, recorded on the base tree, and the ablation - `plugin-security/src/grant-readers-by-name.golden.test.ts` covers explain, platform standing and organization-admin standing per principal (platform admin, organization admin, member, agent) in `single`, `group` and `isolated`. `plugin-auth/src/grant-readers-by-name.golden.test.ts` covers the last-admin guard's verdicts and the default-organization bind. - Recorded on `856ed8f1ad` (goldens only, production code at base), green there, and green with the change. - **Ablation: one grant's name column pointed at another set, with the name hooks unbound.** - Base `856ed8f1ad`: all four mutations together leave both suites green (3/3 and 3/3). Base readers read the id. - With this change, each leg is red: - member's deactivated-set grant: 3/3 red (droppedGrants loses the deactivated entry); - platform admin's grant: 1/3 red (the `single` posture: the re-run promotes, `already_have_admin` is lost); - organization admin's grant: 3/3 red (the demotion no longer revokes it); - plugin-auth, platform admin's grant: 1/3 red (`no_admin`, and the guard verdicts change). - Every leg ran through `scripts/ablation-replace.mjs` in wrap mode, and each restore was proven: blob equals HEAD and `git diff HEAD` is empty. ## Durability gate `persistGrantNameBackfillRecord` joins `DURABILITY_CRITICAL_CALLEES` (`scripts/check-durability-degradation-log-level.mjs`). The gate goes from 41 to 42 seams, all loud, and its self-test passes (63 + 57 cases). Ablation: the backfill's verdict-row catch was demoted to `warn`, and the gate went red at `grant-permission-set-name-backfill.ts:550`. Restore proven by blob. ## Verification All at head `fff0b0adb7` (main `5ff7cbe364` merged in as `e6879a88d1`) unless named. - `@objectstack/plugin-security`: vitest 185 files, 3880 passed, 45 skipped. `typecheck` exit 0; test layer 0 files / 0 errors / 0 debt. - `@objectstack/plugin-auth`: vitest 132 files, 2672 passed, 10 skipped. `typecheck` exit 0; test-layer debt unchanged at 10 files / 94 errors / 23 signatures. - Dogfood: 46 files that write or read grant rows, or that exercise these readers (closure built at `e6879a88d1`). 45 passed and 1 skipped (`rls-multitenant`'s own `skipIf`); 436 tests passed, 3 skipped. - New pins: `grant-readers-unnamed-grant.test.ts` in both packages (7 + 7), and the pre-image block in `delegated-admin-gate.test.ts` (5). - Edited gate scripts: - `pnpm check:durability-log-level` (self-test included) exit 0: 42 seams. - `measure-durability-swallow-family.mjs`: its gate-vocabulary copy gained the same name, because `check:swallow-census-controls` went red on the drift. `--self-test` and `--self-test=gated` both exit 0. - The metadata-protocol test that reads the gate script: 44/44. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 91 commands at `fff0b0adb7`, and every one exited 0, captured before any pipe. `--ran`: 91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN. `pnpm check:error-status-conformance` is in that set, and it also exited 0 when run by hand. - Lint, narrowed: `eslint --no-inline-config --format json` over the 30 changed TS/MJS files. 30 linted, 0 errors, 0 warnings, none ignored. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules), so untouched files' verdicts cannot move. The full `pnpm lint` is CI's. - `origin/main` is 3 commits ahead of the head. The only overlap is a comment-only hunk in `auto-org-admin-grant.ts` (PR objectstack-ai#22331), far from this diff. ## Acceptance notes - **S5a/S5b window.** These readers answer by name, while the resolver still answers by id until S5a. The two diverge only on a grant whose columns disagree: an unnamed grant, or a named grant whose id no longer resolves. Each reader rounds toward refusing or conferring nothing, so none widens; some narrow in that window (above). - **Organization-admin reconcile.** A pair already holding the organization-admin set by name, through another organization's copy, is not handed a second grant. The dedupe still deletes only exact same-id duplicates, never a grant against another copy. - **Guard and the name column.** The guard now judges a write to `permission_set`. Clearing the last administrator's grant name is refused (pinned). With the S4a hooks bound, a cleared name was already written back. - **S4a and S4b changesets.** Their text says "no reader uses the column yet". That no longer holds for these readers. This PR's changesets say so rather than editing the earlier ones. - **Delegated-admin gate comment.** `callerOrganizationId`'s docblock now states the landed `single` behaviour (the Default Organization), per the seat's note. Comment only. --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ssion-set name the environment catalog already holds, as a hot install does (ADR-0048 N.3) (objectstack-ai#22365) Fixes objectstack-ai#22307 Clause-②: no Executes the maintainer's ruling letter A on objectstack-ai#22307 (ruling record 6063176077): the restart path refuses too. After `sys_metadata` hydration and before `kernel:ready`, the engine checks every package-held permission set and position name against the environment catalog, and a name the environment already holds fails the boot with the 422 `NAMESPACE_CONFLICT` envelope the package door uses, naming both holders. A cold boot, a hot install and an artifact boot now answer alike (Q4 = A, ruling record 6050490870). The ADR-0048 addendum N.3 amendment is Tier H and rides its own draft PR, from branch `claude/issue-22307-adr-0048-n3-amendment`. This PR carries no `docs/adr/**` file. ## What changed - **`packages/objectql/src/plugin.ts`.** `ObjectQLPlugin.start()` calls a new private `refuseEnvironmentHeldSecurityCatalogNames()` right after the hydration block (`restoreMetadataFromDb`, or the project-kernel skip line) and before Phase 3's schema sync. Any conflict throws `SecurityCatalogNameConflictError` with `door: 'cold-boot'`, which fails `start()` and with it the boot. It runs whether or not the kernel hydrated. - **`packages/objectql/src/registry.ts`.** - A private `SchemaRegistry.environmentHeldSecurityCatalogConflicts()` returns every package-held position and permission-set name that also has a bare-slot item. Built-in names are skipped. Results are sorted by type, then name. - A private `securityCatalogPackageHolders()` reads the package half of the holder reading: composite slots and install claims, never the bare slot. - A module-level `findEnvironmentHeldSecurityCatalogNames(registry)` is the plugin's handle on that reading. It is not re-exported from `index.ts` or `core.ts`, so the public surface does not grow. - `SecurityCatalogNameConflictError` takes an optional `{ door: 'cold-boot' }`, which changes only the message: which package declares each name, and a remedy stated for a restart. `code`, `status`, `httpStatus` and `conflicts[]` are unchanged. - **`packages/objectql/src/security-catalog-namespace.ts`.** `ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES` (`position`, `permission`: the two types the metadata-type registry declares `allowRuntimeCreate: true`), and a module-doc section, "The cold boot". - **`.changeset/22307-cold-boot-catalog-refusal.md`** (new). `'@objectstack/objectql': major`, the BREAKING banner, the ADR-0087 marker `not-required (no-migration-prescription)`, the upgrade shape and the remedy. - **`.changeset/22135-security-catalog-one-holder.md`** (pending, not yet released). See Acceptance notes, "A pending release note this PR corrects". - **`scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json`.** The invariant gains the cold-boot half. No new error code, no `packages/spec` change. ## Where each refusal sits (for the merge with objectstack-ai#22331, which landed first) `main` was merged at e3ae92a, after objectstack-ai#22331 landed. The merge was clean, and the order in `ObjectQLPlugin.start()` on this head is: 1. objectstack-ai#22331's `installDeploymentPlatformGlobalObjects(ctx)`, the first statement of `start()`. 2. `restoreMetadataFromDb(ctx)`: `sys_metadata` hydration. 3. **This PR's `refuseEnvironmentHeldSecurityCatalogNames()`**: right after the hydration `if`/`else` and before Phase 3's `installRegisteredSchemas`. It runs before any plugin that depends on the engine starts, and before `kernel:ready`. 4. objectstack-ai#22331's `assertDeploymentPlatformGlobalObjectsUnchanged(ctx)`, at the top of the `kernel:ready` hook. The two changes share no hunk. This PR's new method sits directly after `restoreMetadataFromDb`'s method body, and its import line comes after the `picklist-resolution` import block. ## Mechanism assumptions, measured - **M1, the admission today.** Reproduced through `bootStack` on one database file, on the untouched base 28bff18. Boot 1 saved a permission set and a position through `PUT /api/v1/meta/permission/NAME` and `PUT /api/v1/meta/position/NAME`. Both answered `200`; a new position name needs no `OS_METADATA_WRITABLE`. Boot 2, cold, added a package declaring both: it booted, with two `[Registry] Collision` warnings, and the by-name read answered the environment's definitions. Boot 3 hot-installed the same package: `422 NAMESPACE_CONFLICT`, both names held by `environment`. - **M2, where the check sits.** As above. Boot shapes: - standalone `os serve` / `os dev` / `bootStack`: `environmentId` unset, hydration runs, the check runs (measured, dogfood); - the artifact boot (`createStandaloneStack`): `environmentId: 'env_local'` with `hydrateMetadataFromDb: true`, hydration runs, the check runs (measured, runtime pin); - a project kernel with `environmentId` and no `hydrateMetadataFromDb`: hydration is skipped, and the check runs over whatever reached the bare slot, normally nothing (code reading); - a host with no `protocol` service, or one without `loadMetaFromDb`: nothing hydrates, and the check runs with nothing to judge (code reading). `loadMetadataFromService` at the top of `start()` syncs `object`, `view`, `app`, `flow` and `hook` only, so no other boot-time path writes these two types into the bare slot. - **M3, the holder reading. Partly falsified, route changed by the ruling's intent.** At a cold boot the hydrated environment row is NOT an unstamped bare-slot item. Hydration runs after the package registered, and the protocol's artifact-protection merge grafts the package's envelope onto the stored row. Measured on base: the bare slot `probe22307_set` carries `_packageId: com.probe.addon22307` and `_provenance: package`, so objectstack-ai#22197's stamp-based reading answers "the package itself" and finds no second holder. The check therefore reads every bare-slot item as the environment's, whatever stamp it wears: only a registration with no package writes the bare slot. A package holds a name through a composite slot or a claim, never through the bare slot. The envelope class, holder kinds and claims are objectstack-ai#22197's. - **M4, built-ins.** Built-in names are skipped. Through `bootStack`, with `OS_METADATA_WRITABLE=position`, environment saves under `org_admin` and `everyone` answered `200`, and the restart boots, with `GET /api/v1/meta/position/org_admin` answering the saved definition. S2b's pins are green: `builtin-positions.boot.test.ts` is in the plugin-security suite below. - **M5, the legacy shape.** The save door refuses it now (`PUT /api/v1/meta/permission/NAME` over a package-held set answers `403`, with or without `?package=`), so the rows were written at the driver. A row bound to no package refuses the restart, naming both holders (pinned). So does a row bound to the package itself (`package_id` = the package; objectql pin). A hot install refuses that bound row alike: measured, holder `environment`. A legacy row over one of the platform security plugin's own permission sets (`member_default`) refuses the restart, naming `com.objectstack.plugin-security`. On base, all three boot. - **M6, capabilities.** `PUT /api/v1/meta/capability/NAME` answers `403` ("code-only … allowRuntimeCreate=false"), so the environment catalog holds no capability. The check reads permission sets and positions only, and no capability path reaches it. ## Door table: base vs head "Base" is the untouched 28bff18, or a15b8af with the check ablated, as each row says. "Head" is 72dcb8e (3c160a2 changes comments only). Boots go through `@objectstack/verify`'s `bootStack` on one database file unless the row says otherwise. | Door | Base | Head | |---|---|---| | Cold boot: environment-saved permission set and position, then a package declaring both | boots; two `[Registry] Collision` warnings; the by-name read answers the environment's definitions (28bff18 and ablated) | refused: `Plugin com.objectstack.engine.objectql failed to start`, cause `422 NAMESPACE_CONFLICT`, two conflicts, incoming the package, holder `environment` | | Hot install (post-boot `manifest.register`) of that package | refused, `422`, holder `environment`, both names | unchanged | | Artifact boot (`createStandaloneStack`, `file:` database), a package added over environment-saved names | boots (ablated: runtime pin red) | refused, same envelope | | Built-in shadow: environment saves under `org_admin` and `everyone`, restart | boots (ablated) | boots; the stored definition answers | | Legacy row (written at the driver, bound to no package) over a package-held set and position, restart | boots, one collision warning (28bff18) | refused, holder `environment`, both names | | Legacy row bound to the package itself, restart | boots (ablated) | refused, holder `environment` | | Legacy row over the platform's `member_default`, restart | boots (ablated) | refused, incoming `com.objectstack.plugin-security` | | Same-package restart; a package whose names the environment does not hold | boots | boots | | Remedy: boot without the package, `DELETE /api/v1/meta/permission/NAME` and `/position/NAME`, boot with it | (n/a) | both `200`, no row left, the boot with the package comes up | | Environment save of a capability | `403` code-only | unchanged | ## In-repo census The examples ship no `sys_metadata` rows, so the environment catalog holds no names on a fresh boot. Measured on a15b8af: a fresh boot of each example on a database file, then a restart. | Example | Package-held items | Environment rows (`permission`/`position`) after the boot | Restart | |---|---|---|---| | `app-crm` | 10 permission sets, 9 positions | 0 | boots | | `app-showcase` | 17 permission sets, 16 positions | 0 | boots | | `app-multi-package` | 8 permission sets, 6 positions | 0 | boots | The counts include the platform's own items (`plugin-security`'s 8 permission sets and 6 built-in positions). Names held twice: 0. `app-todo` declares no catalog name (objectstack-ai#22197's census) and is not a dogfood dependency, so it was not booted. Deployed environments: NOT MEASURED. ## Tests The head is 3c160a2. Against 72dcb8e it changes comment lines only, in the new dogfood file (5 added, 3 removed, 0 outside a `//` comment). The runs below are at 72dcb8e or earlier, as each line says. - `@objectstack/objectql`, whole suite at e3ae92a: 387 files / 7615 passed. At 72dcb8e, `protocol-boot-hydration-scoped.test.ts`: 16 passed (8 of them new). - `@objectstack/plugin-security`, whole suite at e3ae92a: 184 files / 3869 passed, 45 skipped. That includes S2b's `builtin-positions.boot.test.ts` and `bootstrap-declared-positions.test.ts`. - `@objectstack/runtime`, whole suite at e3ae92a: 340 files / 4777 passed, 19 skipped. `standalone-stack-security-catalog-one-holder.test.ts` has 6, 1 of them new. - Dogfood, the CI split, at e3ae92a: - 1/3: 76 files / 567 passed; - 2/3: 75 files passed and 1 failed (539 tests, 1 failed, 1 skipped); - 3/3: 75 files passed and 1 skipped (669 passed, 8 skipped). The one red was this PR's own built-in control: its `PUT /api/v1/meta/position/org_admin` answered `403` with the hatch set. The protocol memoises `OS_METADATA_WRITABLE` at its first read in a process, and the control set it only after the file's first case had already saved through the metadata door. It passed in isolation before the second merge and failed in the full shard after it; what made that difference is NOT MEASURED. At 72dcb8e the file opens the hatch before its first boot. The new file and the re-shaped Discard Overlay file then ran: 2 files / 11 passed. - Before the second merge, at bdfba35: dogfood 1/3 76 files passed; 2/3 75 passed and 1 failed (the Discard Overlay file, re-shaped since); 3/3 74 passed and 1 skipped. - `typecheck` at 72dcb8e: `objectql` (`tsc --noEmit` plus `check:test-typecheck`: 40 files, 234 errors, 65 pinned signatures, no new signature) and `dogfood`, exit 0. `runtime` at e3ae92a, exit 0; no runtime file changed after it. - `pnpm exec eslint --no-inline-config --format json` over the 7 touched TypeScript files at 72dcb8e: 7 files, 0 errors, 0 warnings. This narrowed run is a measurement, not a skipped one, on three grounds: - the population comes from `eslint.config.mjs` itself (`files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` minus `NEVER_LINTED`), and all 7 files are in it; - the count, 7, is read from the JSON output; - the config enables no type-aware linting (no `parserOptions.project`, as stated at `eslint.config.mjs:328`), so this diff cannot move any untouched file's verdict. The whole-repo `pnpm lint` is CI's. ## Ablation The call was neutralised through `scripts/ablation-replace.mjs`, which wraps the run and restores on exit. In `plugin.ts`, `this.refuseEnvironmentHeldSecurityCatalogNames();` became the same call behind an always-false guard carrying the marker `ABLATION_22307_MARKER`, so the method stays referenced and the DTS build still runs. - **Landed on disk:** anchor 1 → 0, replacement 0 → 1, blob `399ddf47c127` → `2cf40c49e6e2`. `objectql` was rebuilt (exit 0), and `ablation-dist-preflight` found the marker in 2 built files. - **objectql pins (from `src`):** 5 failed / 11 passed of 16 in `protocol-boot-hydration-scoped.test.ts`. All 5 refusal pins went red: per type, the environment-held name and the row bound to the package, plus every conflict in one refusal. The controls stayed green: distinct names per type, and a built-in name the platform declares beside a stored definition. - **runtime pins (from `dist`):** 1 failed / 5 passed. The artifact-boot case went red; objectstack-ai#22197's five stayed green. - **dogfood pins (from `dist`):** 2 failed / 2 passed. The cold-boot case and the legacy-row case went red; the built-in shadow and distinct-name controls stayed green. - **Base readings under ablation** (an uncommitted probe): the cold boot booted with two collision warnings; the row bound to the package booted cold and was refused hot; the `member_default` overlay booted; S2b booted. - **Restore:** blob back to `399ddf47c127` == HEAD, `git diff HEAD` empty, `git status --porcelain` empty. After a rebuild, `ablation-dist-preflight --absent` is green: the marker is absent from all 14 built files and the tree is clean. The ablation ran at a15b8af. The second `main` merge (e3ae92a) brought objectstack-ai#22331's `plugin.ts` hunks, none of them on this check's lines, and the refusal pins were re-run green at 72dcb8e. ## Clause-② (measured on the built entry declarations at 72dcb8e) `packages/objectql/dist/{index,core}.d.ts` and the shared chunk declare no new exported name. `findEnvironmentHeldSecurityCatalogNames`, `ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES` and `SecurityCatalogNameConflictError` are absent from the entries' export lists. The only new declaration text is three private member names (`SchemaRegistry.environmentHeldSecurityCatalogConflicts`, `SchemaRegistry.securityCatalogPackageHolders`, `ObjectQLPlugin.refuseEnvironmentHeldSecurityCatalogNames`) plus JSDoc. No widening was found, so `Clause-②: no` stands. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` derived 81 commands at the head, 3c160a2. All 81 ran with exit codes recorded, and `--ran` reconciles 81/81 with 0 NOT-MEASURED (a derived zero). 80 exited 0. The same 81 were derived and run at 72dcb8e, with the same answers. One exited 1, by design: `check-empty-changeset --base origin/main`. It is the deliberate correction of objectstack-ai#22135's pending note (see Acceptance notes), and the gate's own text says to confirm that class on the PR, not restore the note. On e3ae92a, `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (exit 3) until eight packages outside this change were built: `studio`, `client-react`, `embedder-openai`, `knowledge-memory`, `knowledge-ragflow`, `organizations`, `service-cluster-redis` and `service-knowledge`. On 72dcb8e and 3c160a2 it exits 0. The changeset gates: `check-changeset-no-major --base` exits 0 (pre mode `next`), `check:adr-0087-registration` exits 0, and `check:changeset-gate-self-tests` exits 0. CI's own lanes are declared to CI and are NOT MEASURED here: the Test Core shards, Temporal Conformance, Dogfood Verify CLI, Build Core and the workspace type-check lanes. `origin/main` is 7 commits ahead of the head, among them objectstack-ai#22352 (`plugin-security` grant readers) and objectstack-ai#22353 (`metadata-protocol` seed loader); none touches a file of this PR. `git merge-tree` against it is clean, so `main` was not merged again. ## Acceptance notes - **A pending release note this PR corrects (`check-empty-changeset` stays red by design).** `.changeset/22135-security-catalog-one-holder.md` is objectstack-ai#22135's pending note, not yet consumed by a release (`packages/objectql` is at `17.7.0`). Its "What is NOT refused" paragraph said a package added at cold boot over an environment-held name "is not refused at cold boot". On this PR's merge that sentence is false, and both notes would publish in the same release. That one sentence now says the door cannot see the name at cold boot, and that the engine checks it right after the environment catalog loads and refuses the boot. Nothing else in the note changed. The gate's own text names this shape a DELIBERATE CORRECTION, to be confirmed on the PR, not restored. If a release consumes the note before this PR lands, the edit no longer reaches a published CHANGELOG, and the correct move then is an erratum PR against that CHANGELOG entry. - **The 2026-08-24 legacy-overlay remedies lose their boot-time population for code-package-declared sets.** The overlay detection reading and the drift pass's `overlay_shadow` run in `plugin-security`'s `kernel:ready`. A boot carrying an environment overlay of a package-declared set is now refused before `kernel:ready`, so on a deployment that boots, those branches see no such overlay. The same holds for the Discard Overlay action's discard path for such a set. The ruling names this cost ("including rows saved before the packaged locks"). The upgrade route is in the changeset: rename, or remove the row. A deployment can also run Discard Overlay on the release it runs now, before upgrading. `permission-set-discard-overlay-eligibility.dogfood.test.ts` (objectstack-ai#21860's pin) wrote its legacy overlay before a cold boot, which is now refused. It now writes the overlay into the running deployment and runs the two passes the boot ran for it, by the functions the security plugin's boot calls (`reconcilePermissionSetProjection`, then the drift pass), so its preconditions and its control still hold. - **The refusal leaves `start()`, so the kernel wraps it.** `bootstrap()` rejects with `Plugin com.objectstack.engine.objectql failed to start - rollback complete: …`, and the envelope is the wrapper's `cause`, as with any `start()`-time refusal (objectstack-ai#22197's item-seam refusal from `plugin-security.start` included). The pins read `cause`. - **Org-scoped rows are not judged.** Boot hydration loads env-wide rows only (`organization_id IS NULL`), and org-scoped rows never reach the registry, so the check judges the env-wide catalog. That is the population hydration serves. - **A refused boot over a `sqlite-wasm` file can still flush after the refusal.** In a probe, removing the database directory right after the refused `bootStack` raised `ENOENT` from the driver's atomic write. The committed dogfood file keeps its database files in the test file's working directory, which the dogfood run removes at its end, and never boots a file again after it was refused. Noted, not filed: a boot that failed has no process left to serve. - **Files outside the engine lane:** - `packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts` (new) and `packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts` (re-shaped, above): `domain:cli`. - `packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts` (one case added, and the artifact-stack helper takes a `databaseUrl`): `domain:cli`. - `scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json`. - `.changeset/22135-security-catalog-one-holder.md` (above). ## Patch round 1 — the release note's remedy, completed Both contract reviews passed: 6070947709 on this PR, which also confirms the correction of objectstack-ai#22135's pending note, and 6070955792 on the ADR PR. This round changes text only. The code, the pins and `.changeset/22135-security-catalog-one-holder.md` are unchanged. The head is cf1a9dd. - **`.changeset/22307-cold-boot-catalog-refusal.md`.** "The upgrade shape" names the legacy plural types. "The one-line fix" now has three parts: - **Before upgrading, for a permission set.** The `kernel:ready` overlay reading names the sets this release refuses. The audited Discard Overlay action, or `DELETE /api/v1/meta/permission/NAME`, removes each overlay without touching the database, including on the platform's own sets. - **After upgrading, for a package that can be left out.** Boot without it, then delete through the metadata API. - **After upgrading, for a name the platform security plugin declares.** The SQL delete of the active, environment-wide rows under the type or its legacy plural. The changeset also says that no `os` command deletes a `sys_metadata` row offline. - **`content/docs/permissions/permission-sets.mdx`.** One clause under "Declared ≠ enforced", on the Discard Overlay remedy: discard such an overlay before you upgrade, because a deployment that still holds one does not boot. **Measured, clause by clause:** - **The current release.** This branch with the check ablated through `scripts/ablation-replace.mjs` (blob `9b18363e90ef` → `b3701fcc3a70`, marker in `dist/`), a legacy `member_default` overlay written at the driver, then a restart: - The boot logged one `kernel:ready` warning, "[security] 1 package-declared permission set(s) are being shadowed by an environment overlay — … use the audited "Discard Overlay" action on it …", naming `member_default`. - The record read `drift_status: overlay_shadow`, and Discard Overlay answered `200` and left no active row. - On the same release, `DELETE /api/v1/meta/permission/viewer_readonly` over a legacy overlay of that platform set answered `200` ("Customization overlay deleted — permission/viewer_readonly reset to artifact default") and left no active row. So Discard Overlay is not the only database-free remedy before the upgrade; the changeset names both. - **The restore.** `ablation-replace` put the blob back (== HEAD, `git diff HEAD` empty). After the rebuild, `ablation-dist-preflight --absent` was green on `dist/` at once. It was green on the tree once this round's doc edit, the one dirty path at that moment, was committed (cf1a9dd). - **The head, check live:** - The database on which the current release ran Discard Overlay on `member_default` boots. - Rows of type `permissions` and `positions` (the legacy plurals) over package-held names refuse the restart, both named. - A `draft` row over a third package-held name is not loaded and not named. - `loadMetaFromDb` selects `state: 'active'` and `organization_id: null`, and folds the type through `PLURAL_TO_SINGULAR`, which maps `permissions` to `permission` and `positions` to `position` on `main`. It sets no `package_id` condition: a row bound to the package itself refuses too, measured in the first round. - **The SQL.** The changeset's `DELETE` statements, run through Python's `sqlite3` against the refused database files (one per type, and one for `member_default`), deleted 1 row each. Each restart then booted. - **The CLI.** `os meta delete` and `os data delete` build an API client and require a token (`createApiClient`, `requireAuth`), and no command under `packages/cli/src/commands` deletes a `sys_metadata` row. - **The action.** `discard_permission_set_overlay`, labelled "Discard Overlay", on `sys_permission_set`, in the list-item and record-header locations, visible while `drift_status` is `overlay_shadow`. It is documented on `content/docs/permissions/permission-sets.mdx` under "Declared ≠ enforced — diagnosing a frozen package set". Positions have no overlay reading (it reads the `permission` / `permissions` types) and no such action. - **NOT MEASURED:** the metadata API delete on a set a non-platform package ships, and a position overlay before upgrading. **Gates at cf1a9dd.** `dispatch-gates --commands` derived 107 commands; the doc page added the docs families. All 107 ran with exit codes recorded, and `--ran` reconciles 107/107 with 0 NOT-MEASURED. 106 exited 0, including `check-changeset-no-major --base`, `check-adr-0087-registration --base`, `check:doc-authoring`, `check:docs-*`, `check-doc-frontmatter`, `@objectstack/spec`'s `check:docs` and `check:doc-formula-expressions`. One exited 1 by design: `check-empty-changeset --base origin/main`, the confirmed objectstack-ai#22135 correction. `origin/main` is 12 commits ahead; `git merge-tree` against it is clean, so `main` was not merged. **One more file outside the engine lane:** `content/docs/permissions/permission-sets.mdx` (`domain:devx`). ## Patch round 2 — the metadata-API delete reaches singular-typed rows only The at-tier contract review on cf1a9dd (6071828819) failed two remedy sentences, and judged everything else right: the code, the objectstack-ai#22135 correction (confirmed on that head), case 3's SQL, the CLI sentence, the docs clause and the semver. The two sentences are case 1's "So does `DELETE /api/v1/meta/permission/NAME`" and case 2's metadata-API delete. Both are false for a row stored under the legacy plural `permissions` / `positions`, a shape the changeset's own "upgrade shape" paragraph names. This round changes `.changeset/22307-cold-boot-catalog-refusal.md` only. No code, pin, docs page or `.changeset/22135-security-catalog-one-holder.md` change. The head is 39ef237. **Measured first; the review's reading holds.** - **The current release** (this branch with the check ablated through `scripts/ablation-replace.mjs`, blob `9b18363e90ef` → `b3701fcc3a70`, marker in `dist/`): - A legacy overlay of `viewer_readonly` stored under `permissions`: `DELETE /api/v1/meta/permission/viewer_readonly` answered `200` with `{"success":true,"reset":false,"message":"No customization overlay found for permission/viewer_readonly — already at artifact default."}`, and the `permissions` row stayed active. Discard Overlay on the same set answered `200` and left no active row. - `mcp_agent_restricted` with two active rows, one bound to no package and one bound to `com.objectstack.plugin-security`: the first `DELETE` answered `200` "Customization overlay deleted — … reset to artifact default" and removed one row, leaving the bound one. A second `DELETE` removed it. - **The head, check live, case 2.** A package's permission set and position stored under `permissions` / `positions`. Booted without the package, `DELETE /api/v1/meta/permission/pr2_set` answered `200` "No permission 'pr2_set' found — nothing to delete.", and `DELETE /api/v1/meta/position/pr2_pos` answered "No position 'pr2_pos' found — nothing to delete." Both rows stayed active, and the boot with the package added back was refused, both names held by `environment`. - **The restore.** Blob == HEAD and `git diff HEAD` empty. After the rebuild, `ablation-dist-preflight --absent` is green on `dist/` and on the tree. **The text fix, as the record names it:** - **Case 1:** "neither touches the database" now reads "neither needs direct database access". - **Case 3's heading** now reads "for a name the platform security plugin declares, or for any row the metadata API does not reach". - **One paragraph after the three cases**, before the CLI sentence: - the two `DELETE` routes reach a row stored under `permission` or `position` only, one row per call; - a plural-typed row is not reached: `200`, nothing found, nothing removed; - where a name has two active rows, each call removes one; - a plural-typed row is removed by Discard Overlay before upgrading (a permission set), or by the SQL above after upgrading, for any name. This also corrects round 1's summary above: the metadata-API delete is a database-free remedy before the upgrade only for a row stored under the singular type. - `content/docs/permissions/permission-sets.mdx`'s clause does not name the metadata-API delete, so the page is unchanged. **Gates at 39ef237.** `dispatch-gates --commands` derived 107 commands. All 107 ran with exit codes recorded, and `--ran` reconciles 107/107 with 0 NOT-MEASURED. 106 exited 0; one exited 1 by design: `check-empty-changeset --base origin/main`, the confirmed objectstack-ai#22135 correction. `origin/main` is 22 commits ahead. `git merge-tree` against it is clean, so `main` was not merged. --- _Generated by [Claude Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #15207
Clause-②: yes (narrowing: on a deployment that declares an object platform-global, that object carries no organization column; the dev measures the built declaration closure)
Measured on the built declaration closure. The value
yesholds:@objectstack/coregains exports, because the one fail-closed reader of theorg-scopingkeys moved there.resolveInjectedSystemColumnsgains an optional second parameter but no new spec export, andcheck:api-surfaceon the rebuilt spec reports the surface unchanged. The arm stays(narrowing), because the behaviour narrows: a declared object loses its column on the declaring deployment. The changeset carriesClause-②: yes (narrowing). This is the last open item of the card (items (1), (2) and (3) landed as #22107, #22266 and #22166), so line 1 closes it.Scope: item (4), the #12699 declaration made total
ADR-0131 D7: "an object a deployment declares platform-global gets no organization column on that deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by having nothing to scope." ADR-0131's retirement list names "#12699's stand-down semantics (replaced by D7's no-column)". Claim
6061910188. No stored row moves and no step runs at boot (ADR-0131 D14).What changes
spec/src/data/injected-system-columns.ts):resolveInjectedSystemColumns(def, deployment?). The second argument carries the deployment's validatedplatformGlobalObjects. A declared object is planned with noorganization_id, and the rest of its plan is unchanged. With the argument absent or empty, every plan is byte-identical to the one-argument call (pinned over eight object shapes). Author-time callers pass nothing. The module doc says where that leaves them: see P5.objectql/src/registry.ts,objectql/src/plugin.ts):ObjectQLPlugin.start()readsorg-scopingFIRST, beforeloadMetadataFromServiceand before the firstinstallRegisteredSchemas.SchemaRegistry.setDeploymentPlatformGlobalObjects().applySystemFieldsas the plan's input, and to the tenant-index predicate (carriesTenantScopeColumn).materializeBaseLayergains a first stamp,applyDeploymentTenancy. It records the plan's answer on the base layer assystemFields: { tenant: false }, which is the vocabulary every registered-object reader already answers "no organization column" from. Its write-side inverse is the last strip instripMaterializedStampsFrom, so a Studio GET → PUT stores the body the author wrote ([P3] Read decorations (_diagnostics, _draft) round-trip into persisted sys_metadata bodies #4326).init()) are re-planned at the install, on every contributor layer.deploymentWithholdsTenant, which asks the spec plan with and without the deployment input.organization_id: that column is the author's, so it stays and is walled. It warns once for a refused (malformed) key.plugin-security/src/security-plugin.ts):tenancyDisabledclause ingetObjectSecurityMetais gone, the one that readplatformGlobalObjects. A declared object reaches the wall as its ownsystemFields.tenant: false.[security] deployment declares N platform-global object(s)boot line is gone; the engine logs the list.suppressUnboundedOrgAdminGrant. That key and its behaviour are unchanged.deployment-org-scoping-entitlement.ts: plugin-security →core/src/security/). Its consumers are now in two packages that cannot import each other: objectql readsplatformGlobalObjects, plugin-security readssuppressUnboundedOrgAdminGrant. It is exported from@objectstack/corewith its rules unchanged: absent ⇒ nothing declared; junk ⇒ the whole key refused, never coerced, each key independently.plugins/organizations/src/organizations-plugin.ts):providesServices = ['org-scoping'](ADR-0116 D2). See P2.tenancy-posture.ts(theplatformGlobalObjectsandsuppressUnboundedOrgAdminGrantdocs),tenant-layer0-verdict.ts(the carve-out row),engine.ts(two docblocks), andauto-org-admin-grant.ts(one docblock).18.platform-global-object-organization-column-retired.ts, one step-18 rationale fragment (order 89), and the regeneratedregistry.ts..changeset/15207-platform-global-no-organization-column.md:majorfor@objectstack/objectqland@objectstack/plugin-security(Changesets pre mode is in,.changeset/pre.json),minorfor spec and core,patchfor organizations, with its BREAKING banner, its FROM → TO and the marker.Premise readings (each measured before code)
P1, HOLDS. The harness: a booted
ObjectKernelwithObjectQLPluginover SQLite, the realSecurityPlugin, and a fixture provider composed after the objects plugin declaringplatformGlobalObjects: ['qa_widget_registry']. It was measured at799eb000c7, before any edit:organization_id, and its table was created with the column (columnInfo);security.getReadFilter(declared, member)answered no wall (the fold ingetObjectSecurityMeta), and the sibling answered{ organization_id: 'org_acme' };tenantId: 'org_acme'returned only theorg_acmerow of two (the driver's tenant arm).So Layer 0 and the driver disagreed about one object. Control: with no declaration, both objects were walled.
P2, HOLDS through the Phase 1/2 split, NOT through a per-plugin edge.
SchemaRegistry.registerObject→applySystemFields→resolveInjectedSystemColumns. That runs inside whichever plugin'sinit()callsmanifest.register, and again for later registrations (loadMetadataFromServiceandrestoreMetadataFromDbatstart(), installs after it). The columns are fixed atObjectQLPlugin.start()→installRegisteredSchemas.org-scopingis registered: inOrganizationsPlugin.init(), which hard-depends on the engine (itsinit()callsmanifest.register).servecomposes it after Auth and the app plugins, so it initializes after many object registrants, and the fixture measured that too: the declared object was already registered when the provider initialized.init()completes before anystart(), so the provider has registered by the engine'sstart(), and no table exists yet. The provider now declares it inprovidesServices, so "absent at start" is a declared fact (ADR-0116 D2; the AGENTS.md startup-registry cure 2).optionalDependencieson the provider is a cycle, andresolvePluginOrderthrows on an optional edge too. An edge from every object registrant is an open-ended set. That is why the registry re-plans at the install.init()with a different declaration fails the boot atkernel:ready, naming both lists andprovidesServices.P3, HOLDS. With the key absent, every object's registered shape is byte-identical: pinned as JSON equality between a registry with no install and one with an empty install, plus the spec pin over eight shapes, plus the kernel pin. With junk (
platformGlobalObjects: 'qa_widget_registry'), the engine warns'platformGlobalObjects' REFUSEDonce, and every object keeps its column and its wall.P4, HOLDS. At
799eb000c7,git grep platformGlobalObjectsfinds no declarer outside tests: the spec schema and docs, the reader, plugin-security's consumer and log, and tests only. Every pin uses a fixture provider.P5, measured. Author-time surfaces compute the plan with no deployment, so on the declaring deployment they still name
organization_idfor a declared object:lint/src/system-fields.ts);spec/src/data/import-mapping-target.ts);scripts/platform-object-tenancy-census.mjs);Runtime surfaces on that deployment agree with it: the registry, the DDL, the
/metaread exits (pinned throughObjectStackProtocolImplementation), the metadata bridge thatdescribereads, the lifecycle provenance (absent), and the field doors (INVALID_FIELD/INVALID_FILTER). The plan's module doc and the changeset say so. One one-shot surface depends on composition:os migrate plan/applycomposes the host config's plugins, notserve's posture-drivenOrganizationsPlugin. A declaring deployment whose provider arrives only throughservewould get a migrate plan that adds the column back. That is under Acceptance notes, for C10.Pins (refused and still-accepted case each)
spec/src/data/injected-system-columns.test.ts, 5 cases):organization_id, and only that moves;objectql/src/registry-deployment-platform-global.test.ts, 7 cases):extendincluded;organization_idis kept and reported;/metaread exit serves the registry's answer;systemFieldsmember survives, and a non-declared object is never touched.plugin-security/src/platform-global-no-organization-column.test.ts, 8 cases, booted kernel with a fixture provider):organization_idis refusedINVALID_FIELD/ 400, and the sibling accepts it;start()refuses the boot by name.tenant-layer0-verdict-end-to-end.test.ts): a member's predicate update on the declared object now matches both rows (it matched one before, the driver's tenant arm), and the bulk event names no organization. CONTROL: the sibling sweep matches one row and namesorg_acme.deployment-platform-global-exemption.test.ts, rewritten):isolatedandgroup, and on the ADR-0123 D2 write path;single, Layer 0 is inert on both;platformGlobalObjectsdraws no warning from this plugin, and a junk suppress key is warned once;core/src/security/deployment-org-scoping-entitlement.test.ts, 11 cases): absent, well-formed, four junk shapes (whole-key refusal), per-key independence, and memo per instance.Reverse verification (one-off,
scripts/ablation-replace.mjsin hold mode with a trap restore)The plan's read of the declaration in
injected-system-columns.tswas neutralised: the anchor!(name !== '' && deploymentDeclaresPlatformGlobalwas replaced so it never matches (anchor 1 → 0, blob6e571966dd→88efcbbb14). The spec was rebuilt, andablation-dist-preflight.mjsfound the marker in 6 built files. Results:Restore leg:
6e571966dd),git diff HEADempty, the whole tree clean;ablation-dist-preflight.mjs --absentfinds the marker in none of the 234 built files;Fate for C7 (#15211) and C10
On a declaring deployment, each declared object's existing
organization_idcolumn is ADR-0131 D10 fate 1 (column dropped). Schema sync is additive, so the physical column stays, and the boot drift report names it orphaned. The declarer (cloud's control plane, C10) owns the data step: confirm nothing reads it, thenos migrate apply --allow-destructive. Its backfill decides any value that must survive. C7's inventory records the declared set per deployment with this entry id. No boot step reads or writes the column (D14).Files outside the claim's file surface
packages/plugins/organizations/src/organizations-plugin.ts: one declaration,providesServices. It is the "provider declaration" the claim's ordering bullet names, in the provider's own file. Its lane is re-declared by the seat.packages/core/src/security/deployment-org-scoping-entitlement.tsand.test.ts, andcore/src/security/index.ts: the reader's new home, so that both consumers can import it (the claim allows "if its reader moves";coreis on the claim's declared lanes).packages/objectql/src/federated-injected-column-readers.test.ts: two census rows for the two neworganization_idseams. That census fails on any undisposed seam.objectql/src/plugin.ts,plugin-security/src/auto-org-admin-grant.ts(one docblock), and four plugin-security test files.Acceptance notes
os migrate plan/applycompose the host config's plugins, notserve's posture-driven organizations runtime. On a declaring deployment whose provider is composed only byserve, a migrate plan reads no declaration and would add the column back to a declared object's table (additive sync). Carrier: C10 (cloud's control plane composition), noted, not filed: there is no in-repo declarer to reach it with.organization_idon a declared object; the declaring deployment refuses it as an unknown field. This is inherent to a deployment input, and stated in the plan's docs and the changeset.OrganizationsPlugin.providesServiceswas absent before, so ADR-0116's stage-1 check could not name it for aninit()-time requirer oforg-scoping. None exists in-repo (check:init-service-contractgreen).Verification (head
5322c2b755, which mergedorigin/mainatdc4a5c6308throughos-regen-merge.sh; the regeneration wrote nothing)--project local: 626 files, 18728 passed, 1 todo;--project local: 385 files, 7562 passed;86db7e86f4; since then plugin-security changed one test file's type annotation, and objectql (aliased to source there) lost one unused accessor. The four plugin-security files this PR touches were re-run green afterwards.--project repo:step18-rationale-mergeandconversions-major18-merge, 21 passed.check:test-typecheck, and every ledger held unchanged.check:generatedreports 15 of 15 up to date.check:migration-registry: 400 semantic entries.check:api-surfaceunchanged.dispatch-gates --commands --repo objectstack-ai/objectstack(no paths) at5322c2b755derives 109 families. All 109 ran, each exit code captured before any pipe, all 0. The--ranreconciliation: 109 derived, 109 run, 0 NOT-MEASURED, 0 UNRUN.check:engine-double-contractasked for the new pin's three doubles in the ledger (--write), andcheck:slot-lookuprefused one untyped service lookup in a test.check:dts-closure,check:dual-build-cjs-loadsandcheck:i18nfirst stopped on unbuilt packages (a prerequisite). They are green after a full build (72 tasks, 71 cached).check:init-service-contract(36 declared) andcheck:startup-registry-verdict(none recording a verdict the boot can contradict).eslint --no-inline-config --format jsonover the 21 changed.tsfiles gives 21 results, 0 errors, 0 warnings. The population iseslint.config.mjs'spackages/**and**/*TS globs. The config states it enables no type-aware linting, so an untouched file's verdict cannot move. The fullpnpm lintis CI's.799eb000c7;resolvePluginOrderover the two declarations: it throwsCircular dependency detected: com.objectstack.engine.objectql. CONTROL: without the soft edge, the order is engine then organizations;--absent, tree clean, the same files green).origin/mainhas moved 8 commits sincedc4a5c6308, three of them through this PR's files (registry.ts,engine.ts,security-plugin.ts) and the double ledger. A no-commit merge probe auto-merges them with no conflict. The next hop merges them throughos-regen-merge.sh.Generated by Claude Code