Skip to content

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

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-15207-platform-global-no-column
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-15207-platform-global-no-column

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 yes holds: @objectstack/core gains exports, because the one fail-closed reader of the org-scoping keys moved there. resolveInjectedSystemColumns gains an optional second parameter but no new spec export, and check:api-surface on 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 carries Clause-②: 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

  • The plan (spec/src/data/injected-system-columns.ts): resolveInjectedSystemColumns(def, deployment?). The second argument carries the deployment's validated platformGlobalObjects. A declared object is planned with no organization_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.
  • The reader (objectql/src/registry.ts, objectql/src/plugin.ts):
    • ObjectQLPlugin.start() reads org-scoping FIRST, before loadMetadataFromService and before the first installRegisteredSchemas.
    • It installs the validated set with the new SchemaRegistry.setDeploymentPlatformGlobalObjects().
    • The registry passes the set to applySystemFields as the plan's input, and to the tenant-index predicate (carriesTenantScopeColumn).
    • materializeBaseLayer gains a first stamp, applyDeploymentTenancy. It records the plan's answer on the base layer as systemFields: { tenant: false }, which is the vocabulary every registered-object reader already answers "no organization column" from. Its write-side inverse is the last strip in stripMaterializedStampsFrom, so a Studio GET → PUT stores the body the author wrote ([P3] Read decorations (_diagnostics, _draft) round-trip into persisted sys_metadata bodies #4326).
    • Objects registered before the install (inside other plugins' init()) are re-planned at the install, on every contributor layer.
    • The registry reads the declaration in ONE place, deploymentWithholdsTenant, which asks the spec plan with and without the deployment input.
    • The plugin logs the declared list once. It warns once, by name, for an object that DECLARES its own organization_id: that column is the author's, so it stays and is walled. It warns once for a refused (malformed) key.
  • The stand-down retires (plugin-security/src/security-plugin.ts):
    • The third tenancyDisabled clause in getObjectSecurityMeta is gone, the one that read platformGlobalObjects. A declared object reaches the wall as its own systemFields.tenant: false.
    • The [security] deployment declares N platform-global object(s) boot line is gone; the engine logs the list.
    • The plugin now warns only for the refused key it reads, suppressUnboundedOrgAdminGrant. That key and its behaviour are unchanged.
  • The reader moves (deployment-org-scoping-entitlement.ts: plugin-security → core/src/security/). Its consumers are now in two packages that cannot import each other: objectql reads platformGlobalObjects, plugin-security reads suppressUnboundedOrgAdminGrant. It is exported from @objectstack/core with its rules unchanged: absent ⇒ nothing declared; junk ⇒ the whole key refused, never coerced, each key independently.
  • The provider declaration (plugins/organizations/src/organizations-plugin.ts): providesServices = ['org-scoping'] (ADR-0116 D2). See P2.
  • Narratives: tenancy-posture.ts (the platformGlobalObjects and suppressUnboundedOrgAdminGrant docs), tenant-layer0-verdict.ts (the carve-out row), engine.ts (two docblocks), and auto-org-admin-grant.ts (one docblock).
  • ADR-0087: the D3 entry 18.platform-global-object-organization-column-retired.ts, one step-18 rationale fragment (order 89), and the regenerated registry.ts.
  • Changeset .changeset/15207-platform-global-no-organization-column.md: major for @objectstack/objectql and @objectstack/plugin-security (Changesets pre mode is in, .changeset/pre.json), minor for spec and core, patch for organizations, with its BREAKING banner, its FROM → TO and the marker.

Premise readings (each measured before code)

  • P1, HOLDS. The harness: a booted ObjectKernel with ObjectQLPlugin over SQLite, the real SecurityPlugin, and a fixture provider composed after the objects plugin declaring platformGlobalObjects: ['qa_widget_registry']. It was measured at 799eb000c7, before any edit:

    • the declared object was registered with organization_id, and its table was created with the column (columnInfo);
    • security.getReadFilter(declared, member) answered no wall (the fold in getObjectSecurityMeta), and the sibling answered { organization_id: 'org_acme' };
    • a system read of the declared table carrying tenantId: 'org_acme' returned only the org_acme row 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.

    • Where the plan is computed: SchemaRegistry.registerObject → applySystemFields → resolveInjectedSystemColumns. That runs inside whichever plugin's init() calls manifest.register, and again for later registrations (loadMetadataFromService and restoreMetadataFromDb at start(), installs after it). The columns are fixed at ObjectQLPlugin.start() → installRegisteredSchemas.
    • When org-scoping is registered: in OrganizationsPlugin.init(), which hard-depends on the engine (its init() calls manifest.register). serve composes 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.
    • What orders them: ADR-0116's Phase 1/2 split. Every init() completes before any start(), so the provider has registered by the engine's start(), and no table exists yet. The provider now declares it in providesServices, so "absent at start" is a declared fact (ADR-0116 D2; the AGENTS.md startup-registry cure 2).
    • Measured: neither ADR-0116 edge can order the provider ahead of the registration-time plan itself. An engine-side optionalDependencies on the provider is a cycle, and resolvePluginOrder throws 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.
    • Validation: a provider that registers outside init() with a different declaration fails the boot at kernel:ready, naming both lists and providesServices.
    • No boot move: no plugin moved, and no data step runs at boot.
  • 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' REFUSED once, and every object keeps its column and its wall.

  • P4, HOLDS. At 799eb000c7, git grep platformGlobalObjects finds 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_id for a declared object:

    • the linter's addressable-name set (lint/src/system-fields.ts);
    • the import mapper (spec/src/data/import-mapping-target.ts);
    • the tenancy census (scripts/platform-object-tenancy-census.mjs);
    • the CLI's authoring filter judge.

    Runtime surfaces on that deployment agree with it: the registry, the DDL, the /meta read exits (pinned through ObjectStackProtocolImplementation), the metadata bridge that describe reads, 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 / apply composes the host config's plugins, not serve's posture-driven OrganizationsPlugin. A declaring deployment whose provider arrives only through serve would get a migrate plan that adds the column back. That is under Acceptance notes, for C10.

Pins (refused and still-accepted case each)

  • The plan (spec/src/data/injected-system-columns.test.ts, 5 cases):
    • a declared object has no organization_id, and only that moves;
    • CONTROL: a sibling keeps it;
    • the array form works;
    • absent, empty set and empty list are byte-identical over eight shapes;
    • a nameless record is never declared.
  • The registry (objectql/src/registry-deployment-platform-global.test.ts, 7 cases):
    • registered after the install: no column, no tenant index, the record present;
    • registered before the install: re-planned on every contributor layer, extend included;
    • absent or empty: JSON-identical;
    • an authored organization_id is kept and reported;
    • an object that opted out itself is untouched;
    • the /meta read exit serves the registry's answer;
    • a stored body converges at the read seam, and the write seam takes the record off. CONTROL: an author's other systemFields member survives, and a non-declared object is never touched.
  • The kernel (plugin-security/src/platform-global-no-organization-column.test.ts, 8 cases, booted kernel with a fixture provider):
    • the plan and the DDL, with the sibling control;
    • absent key;
    • junk key (warned once; every column kept);
    • Layer 0 composes no wall and the driver reaches every row, while the sibling is walled at both;
    • a write naming organization_id is refused INVALID_FIELD / 400, and the sibling accepts it;
    • no stand-down path remains: the plugin over an engine whose plan never received the declaration walls the object;
    • a provider registered after the objects reaches the plan;
    • a provider that registers in start() refuses the boot by name.
  • Layer 0 and the driver at once (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 names org_acme.
  • plugin-security reads no declaration (deployment-platform-global-exemption.test.ts, rewritten):
    • a declared object that still carries its column is walled under isolated and group, and on the ADR-0123 D2 write path;
    • the registered shape is not walled, and the sibling is;
    • under single, Layer 0 is inert on both;
    • a junk platformGlobalObjects draws no warning from this plugin, and a junk suppress key is warned once;
    • the arming log carries no platform-global line.
  • The reader (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.mjs in hold mode with a trap restore)

The plan's read of the declaration in injected-system-columns.ts was neutralised: the anchor !(name !== '' && deploymentDeclaresPlatformGlobal was replaced so it never matches (anchor 1 → 0, blob 6e571966dd → 88efcbbb14). The spec was rebuilt, and ablation-dist-preflight.mjs found the marker in 6 built files. Results:

  • the spec plan: exactly the two declared-object pins red, 20 green;
  • the registry: 5 of 7 red, with the absent-declaration and opted-out controls green;
  • the kernel: 4 of 8 red. The plan, Layer 0 / driver, write refusal and ordering pins went red. The absent-key, junk-key, no-stand-down and late-provider controls stayed green;
  • the end-to-end verdict: the declared-object sweep red, 2 green.

Restore leg:

  • blob == HEAD (6e571966dd), git diff HEAD empty, the whole tree clean;
  • the spec rebuilt, and ablation-dist-preflight.mjs --absent finds the marker in none of the 234 built files;
  • the same files re-run green: spec 22, registry 7, kernel and end-to-end 11.

Fate for C7 (#15211) and C10

On a declaring deployment, each declared object's existing organization_id column 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, then os 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.ts and .test.ts, and core/src/security/index.ts: the reader's new home, so that both consumers can import it (the claim allows "if its reader moves"; core is on the claim's declared lanes).
  • packages/objectql/src/federated-injected-column-readers.test.ts: two census rows for the two new organization_id seams. That census fails on any undisposed seam.
  • Within the claimed packages: 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 / apply compose the host config's plugins, not serve's posture-driven organizations runtime. On a declaring deployment whose provider is composed only by serve, 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.
  • Author-time tools name organization_id on 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.providesServices was absent before, so ADR-0116's stage-1 check could not name it for an init()-time requirer of org-scoping. None exists in-repo (check:init-service-contract green).

Verification (head 5322c2b755, which merged origin/main at dc4a5c6308 through os-regen-merge.sh; the regeneration wrote nothing)

  • Suites, each through the verify lock:
    • spec --project local: 626 files, 18728 passed, 1 todo;
    • objectql --project local: 385 files, 7562 passed;
    • core: 83 files, 2258 passed;
    • organizations: 11 files, 151 passed;
    • plugin-security: 183 files, 3835 passed, 45 skipped. It was run at 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.
    • spec --project repo: step18-rationale-merge and conversions-major18-merge, 21 passed.
  • Typecheck exit 0 for core, objectql, plugin-security, organizations and spec. Each package's script includes check:test-typecheck, and every ledger held unchanged.
  • Spec artifacts: check:generated reports 15 of 15 up to date. check:migration-registry: 400 semantic entries. check:api-surface unchanged.
  • Gates: dispatch-gates --commands --repo objectstack-ai/objectstack (no paths) at 5322c2b755 derives 109 families. All 109 ran, each exit code captured before any pipe, all 0. The --ran reconciliation: 109 derived, 109 run, 0 NOT-MEASURED, 0 UNRUN.
    • Fixed on the way: check:engine-double-contract asked for the new pin's three doubles in the ledger (--write), and check:slot-lookup refused one untyped service lookup in a test.
    • check:dts-closure, check:dual-build-cjs-loads and check:i18n first stopped on unbuilt packages (a prerequisite). They are green after a full build (72 tasks, 71 cached).
    • Also green, run by hand: check:init-service-contract (36 declared) and check:startup-registry-verdict (none recording a verdict the boot can contradict).
  • Lint, a proven narrowing: eslint --no-inline-config --format json over the 21 changed .ts files gives 21 results, 0 errors, 0 warnings. The population is eslint.config.mjs's packages/** and **/* TS globs. The config states it enables no type-aware linting, so an untouched file's verdict cannot move. The full pnpm lint is CI's.
  • Measurements, one-off and not committed:
    • P1, on a scratch copy of the kernel harness at 799eb000c7;
    • P2's cycle, resolvePluginOrder over the two declarations: it throws Circular dependency detected: com.objectstack.engine.objectql. CONTROL: without the soft edge, the order is engine then organizations;
    • the reverse verification above, restored and proven (preflight --absent, tree clean, the same files green).
  • origin/main has moved 8 commits since dc4a5c6308, 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 through os-regen-merge.sh.

Generated by Claude Code

claude added 9 commits October 8, 2026 14:45
…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>
@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/core, @objectstack/objectql, @objectstack/organizations, @objectstack/plugin-security, @objectstack/spec, touching 39 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/auto-org-admin-grant.ts, packages/spec/src/security/tenant-layer0-verdict.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

17 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 28bff18d0c4013db86d61eba87c739c3145e17fd.

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

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/auto-org-admin-grant.ts, packages/spec/src/security/tenant-layer0-verdict.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 33 pages)
  • 9 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 — 149 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 28bff18d0c4013db86d61eba87c739c3145e17fd → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9c4e687b57e7472fdc1d9a473baa807d741ff86f — the merge of head 6b055079e8f2e874d4ec46e5249246b50bc5ee1f into base 28bff18d0c4013db86d61eba87c739c3145e17fd, 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 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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5322c2b7557fe7ec1bed04ad86527c58cffa0c71
Local-runs: none

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 dc4a5c6308; the PR's own net diff is 23 files, +1479 / −263, matching the PR object's file list. Inputs: the card's body and all 23 comments (claim 6061910188, dev report 6065642320, ACCEPT 6065729426, the three landing records and the C6 re-verification), the PR body and net diff, the head's check-runs read once, ADR-0131 (D7, D10, D13, D14, §8), ADR-0116 (D1 to D5), ADR-0078, the entitlement reader at the base, AGENTS.md's changeset rules, pr-automation.yml's WHICH LEVEL prose, check-changeset-no-major.mjs's pre-mode branch and clause2-line.mjs's four combinations. Adversarial by design: the dispatch order and the seat's ACCEPT were read as claims to test, not as findings. Stamp: 2026-10-08T17:59Z.

① Derived judgments

  1. The plan withholds exactly one column, and is byte-identical with the key absent. resolveInjectedSystemColumns(def, deployment?) (spec/src/data/injected-system-columns.ts) adds one conjunct to tenant: the object's name is not in deployment.platformGlobalObjects (a set or an array; a nameless record is never declared). audit, owner and owningBusinessUnit are untouched, so a declared object's plan equals the authored plan minus organization_id. With no second argument, an absent key, an empty set or an empty list the function evaluates the same expression as before. Pinned in the spec test over eight shapes (declared, sibling, both self opt-outs, systemFields: false, managedBy: better-auth, a business-unit object, an empty record). In the registry, applySystemFields and carriesTenantScopeColumn pass the set only when it is non-empty, so a deployment that declares nothing takes the one-argument path byte for byte; the registry pin asserts JSON equality of every registered object between a registry with no install and one with an empty install. Holds.

  2. No tenant index on the declaring deployment. provisionTenantScopeIndex asks carriesTenantScopeColumn(schema, platformGlobalObjects), which asks the same plan with the same input; a declared object answers false unless it carries an authored organization_id. Pinned: no organization_id index entry on the declared object, the sibling keeps the platform entry. Holds.

  3. The junk-key and whole-key refusal rules are unchanged by the move to @objectstack/core. I diffed the base file (plugin-security/src/deployment-org-scoping-entitlement.ts at dc4a5c6308) against the head file in core with comments stripped: the code is byte-identical; the rename's five hunks are all docblock. PlatformGlobalObjectsSchema.safeParse still refuses the whole key on one bad entry, each key is still validated independently, and the WeakMap memo per service instance stands. The core test pins absent, well-formed, four junk shapes, per-key independence and the memo. The reader was never a public export of @objectstack/plugin-security (its index.ts at the base names it nowhere), so the move removes nothing published; @objectstack/core exports it from its root barrel. Holds.

  4. The stand-down fold is gone with no fallback read (D14), and nothing consumes the declaration a second time. In security-plugin.ts the third tenancyDisabled clause (orgScopingEnabled and platformGlobalObjects.has(object)) is deleted; the clause that remains is systemFields.tenant === false, which the registry now records on a declared object. The [security] deployment declares N platform-global object(s) boot line is gone, and deploymentOrgScopingEntitlement() skips every refusal except suppressUnboundedOrgAdminGrant. A head-wide grep for platformGlobalObjects in non-test source finds behavioural consumers only in the spec plan, the registry (through the plan) and ObjectQLPlugin; in plugin-security only docblocks remain. ADR-0131 D13 names "feat(spec,security): OrgScopingEntitlement grows platform-global exemption + unbounded-admin suppression, consumed by Layer 0 arming #12699's stand-down semantics (replaced by D7's no-column)", and the retirement is clean: the kernel pin boots the real SecurityPlugin over an engine whose registry never received the declaration and shows the declared object walled, and the rewritten exemption test shows a declared object that still carries its column walled under isolated, group and the ADR-0123 D2 write path. One qualification: ObjectQLPlugin reads the service twice, at start() to install and at kernel:ready to assert the declaration has not changed. The second read decides no column and no wall; it is a consistency assertion that refuses the boot, not a fallback, so D14's "no dual read" holds in the sense that matters. Holds.

  5. The ordering is sound and is not a boot move outside ADR-0116. installDeploymentPlatformGlobalObjects(ctx) is the first statement of ObjectQLPlugin.start(), ahead of loadMetadataFromService (line 685) and both installRegisteredSchemas calls (lines 917 and 940); the only non-test syncSchemas callers outside the plugin are on the marketplace install path, which runs after boot. OrganizationsPlugin.init() calls ctx.registerService('org-scoping', this) as its first act after a log line, with no condition in front of it, so providesServices = ['org-scoping'] is a truthful D2 declaration (D2 forbids declaring a conditionally registered service). ADR-0116 names the Phase 1/2 split as "the primary ordering tool: everything registered in any init is visible to every start", and D4 forbids deriving order from service declarations; the design uses the split and adds no derived edge. The dev's measurement that an engine-side optionalDependencies edge on the provider is a cycle is consistent with the provider's dependencies = ['com.objectstack.engine.objectql']. Objects registered inside other plugins' init() are re-planned by setDeploymentPlatformGlobalObjects on every contributor layer (own and extend), with invalidateAll() after; pinned in the registry test (an extend layer loses the injected column too) and in the kernel test, where the fixture provider is composed after the objects plugin and records that the declared object carried the column when the provider initialised. The kernel:ready hook that carries the new refusal is registered unconditionally in start() (line 713; the registerProtocol branch is in init()), and the refusal names both sets and providesServices; pinned with a provider that registers in its start(). The refusal reads the service, never a row or a column, so D14's "no boot step reads or writes the column" is respected. Holds.

  6. The authored organization_id exception is consistent with D7. D7's mechanism is "the injected-columns plan reads the declaration": the plan governs the platform's injection, and an author-declared organization_id is not an injection. applyDeploymentTenancy and the re-plan tell the two apart with isInjectedColumnDefinition(orgField, TENANT_SCOPE_FIELD_DEF); the authored column stays, the object stays walled on it, and the plugin warns once by name with the remedy. On such an object Layer 0 and the driver still agree, both scoping on the column that exists, which is D7's stated point. Loud, not silently inert (ADR-0078). Holds.

  7. Layer 0 and the driver agree on a declared object. The driver's computeTenantField resolves the tenant column from tenancy.tenantField or the presence of an organization_id field and returns null otherwise, so a schema registered without the field gives the driver nothing to scope; plugin-security composes no Layer 0 predicate from systemFields.tenant === false. The kernel pin shows getReadFilter undefined on the declared object and { organization_id: 'org_acme' } on the sibling, a read carrying tenantId reaching both declared rows and one sibling row, and columnInfo() without the column. The end-to-end pin's member sweep now matches two rows where it matched one before (the D8 driver leg the stand-down never reached), and the bulk event names no organization. Holds.

  8. The ADR-0087 D3 entry and the regenerated registry.ts. 18.platform-global-object-organization-column-retired.ts is a semantic entry with id, surface (no backticks, as build-upgrade-guide.ts requires), replacement, reason and acceptanceCriteria; it states that no authorable key moves, so no D2 conversion pairs with it, and it performs no data step. The regenerated registry.ts carries the entry in step 18 and the rationale fragment at order 89 under the same id. The changeset's registration marker names the same id. Check Changeset is green on the head; check:generated itself runs in Lint and Repo Gates, still in progress at my read. Holds subject to that run.

  9. The /meta read exits and the write seam. materializeBaseLayer stamps applyDeploymentTenancy first, so materializeServedObjectOnto converges a stored body the read exit's own injection pass had re-injected, and stripMaterializedStampsFrom takes the recorded tenant: false back off; pinned, with the control that an author's other systemFields member survives and a non-declared body is returned by reference. Holds.

② Semver level

  • Clause-② reads yes (narrowing), which clause2-line.mjs defines as "a diff that widens one surface and narrows another; both facts are true and both are read". Both are true here. Widenings, measured on the built declaration closure as the claim required: @objectstack/core gains three root exports (readDeploymentOrgScopingEntitlement, DeploymentOrgScopingEntitlementReading, RefusedEntitlementKey); resolveInjectedSystemColumns gains an optional second parameter with no new spec symbol; SchemaRegistry, a public export of @objectstack/objectql, gains setDeploymentPlatformGlobalObjects(). Narrowing: on the declaring deployment a declared object loses its column, a write naming it is refused INVALID_FIELD and a filter INVALID_FILTER, and the stand-down that unwalled a declared object which still carried its column is gone. The PR's line 2 is the claim's line verbatim, and the changeset carries Clause-②: yes (narrowing).
  • Grades against what each package publishes. @objectstack/objectql major: the plan's new input, the registry re-plan, the recorded systemFields.tenant: false, and a new boot refusal at kernel:ready; breaking for a declaring deployment. @objectstack/plugin-security major: the stand-down is retired, so a declared object that still carries its column is now walled; breaking for a deployment that relied on the fold without the plan (none in-repo, P4). Both are on the breaking side of the narrowing, and AGENTS.md reads (narrowing) as BREAKING. .changeset/pre.json is mode: pre, tag: next, and check-changeset-no-major.mjs takes its exempt branch when pre.mode equals pre, so major is admissible here and the claim asked for it ("major under pre mode if breaking"); Check Changeset is green. @objectstack/spec minor and @objectstack/core minor: additive widenings of published surfaces, which the WHICH LEVEL rule puts at least at minor. @objectstack/organizations patch: one providesServices declaration on an existing Plugin slot, no new index symbol and no new accepted key; defensible, and the level axis ("at least one moved package at minor or above") is satisfied several times over.
  • The BREAKING banner is present and scoped ("on a deployment whose org-scoping service declares platformGlobalObjects; nothing changes on any other deployment"), the title carries the bang, the FROM and TO are stated for the consumer (an authored reference naming organization_id on a declared object, to the same reference without it; the reader's move from plugin-security to core, which removed no published export), the ADR-0087 disposition marker reads registered with the entry id, and D14's "nothing moves automatically" is stated as the migration posture. Level: consistent.

③ Boundary flags

  1. Mechanism deviation (report deviation 1): answered. The dispatch's ordering bullet asked for ADR-0116's mechanisms, "not assumed". The dev declared the provider (providesServices, D2), measured that a requirer edge from the engine is a cycle and that an edge from every registrant is open-ended, and used the Phase 1/2 split that ADR-0116 itself names as the primary tool, with a boot refusal as the validation half. That is a declared order with its check, not an assumption, and no plugin moved. Accepted. The Zone 3 "requirer with no provider" pin has no subject in this design, as the report says: the start()-time read is optional and single has no provider.
  2. Files beyond the claim's surface (report deviation 3): answered, with one item outside my inputs. organizations-plugin.ts (the provider declaration the claim's ordering bullet names), the reader's new home in core/src/security/ with its test and barrel line (the claim allowed the move, and core is on the declared cross-lane), two census rows in federated-injected-column-readers.test.ts (that census fails on an undisposed seam), and the engine-double-contract ledger rows for the new pin. Each is named in the PR body. The ACCEPT states the lanes were re-declared on four seat posts; those posts are not among this review's inputs, so the re-declaration is recorded here as the seat's assertion, not verified.
  3. The os migrate plan composition gap: answered in-repo, escalated for C10. os migrate plan and apply compose the host config's plugins, not serve's posture-driven provider, so a declaring deployment whose provider arrives only through serve would get a plan that re-adds the column (additive sync). No in-repo declarer exists (P4), so no public door in this repository reaches it, and ADR-0131 §8 makes C10 (cloud: per-deployment no-column, backfill, tests) the carrier, blocked by C6 then C8. The report says "noted, not filed"; the ACCEPT names cloud#1979. Escalation: the landing record should carry a pointer to the C10 card so the one-shot path's composition is a written obligation there, not only an Acceptance note on this PR.
  4. Author-time surfaces (report out-of-scope finding 2): answered. The linter's addressable-name set, the import mapper, the census and the CLI filter judge compute the plan without a deployment and still name organization_id on a declared object; the declaring deployment refuses it at INVALID_FIELD / INVALID_FILTER. That is loud, not silently inert, so ADR-0078's fourth state is not entered; the plan's module doc and the changeset state the difference. No card is owed.
  5. The eight main commits after the base. origin/main is at 28bff18d0c, eight commits past dc4a5c6308; feat(plugin-auth,objectql,metadata-protocol,runtime)!: under single the Default Organization exists before the seeds and the listener; an unowned seed row or system write is derived there or refused (ADR-0131 C1) #22186, feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197 and fix(plugin-security)!: the Layer 0 tenant write wall refuses an update that empties a row's organization #22317 touch engine.ts, registry.ts and security-plugin.ts. A git in-object merge query (git merge-tree; no checkout, no working-tree change) merges the head into origin/main with no conflict; the main-side hunks in those three files touch none of the seam symbols (platformGlobalObjects, resolveInjectedSystemColumns, applySystemFields, tenancyDisabled, carriesTenantScopeColumn, getObjectSecurityMeta), and origin/main adds no new reader of the declaration. CI judged the merge: the docs-drift comment records that the checks ran on ebc74706a3, the merge of this head into 28bff18d0c. The landing hop merges main through os-regen-merge.sh, with the serial coupling to feat(spec)!: PROTOCOL_VERSION 17 → 18 in an ordinary PR — regenerated spec-changes.json and upgrade guide, ^18 handshakes, pre-mode lockstep exception (#22085 Q1 → B) #22215 (step-18 entries) the ACCEPT names.
  6. Check-runs on the head, read once at the stamp above. 23 completed: 20 success (Auto Label, Build Core, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate 1/3, 2/3 and 3/3, Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, the three issue-claim guards, Part-of PR must not also close its card, Spec property liveness, Type Check consumer gates, Type Check debt ledger, Type Check source gates, filter) and 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke). 10 still in_progress: Dogfood Regression Gate (the roll-up), Lint and Repo Gates, Temporal Conformance (live PG and MySQL), Test Core 1/6 through 6/6, Type Check workspace. Their conclusions are the gate verdicts and this record does not pre-empt them; the PASS below is on the diff and is conditional on those ten concluding success.
  7. Two observations, no action owed. (a) The write-side strip drops an author's own systemFields: { tenant: false } from a declared object's body on its first save on the declaring deployment; the docblock discloses the trade, the deployment re-derives the same answer at every load, and the registry pin covers the sibling member. It is a narrow [P3] Read decorations (_diagnostics, _draft) round-trip into persisted sys_metadata bodies #4326 lossy edge on one deployment and does not touch D7. (b) The docs-drift run could not anchor auto-org-admin-grant.ts and tenant-layer0-verdict.ts; both changes in this PR are docblock-only.
  8. Read-only discipline. No worktree, no checkout, no build, no test, no gate re-run. Reads were gh api and git against commit ids and origin/* refs; the one git query that writes into the object store (merge-tree) touched no working tree and is reported in flag 5.

Implemented-by: claude/issue-15207-platform-global-no-column
Reviewed-by: session_01LAi5BVvQNiYzepSAcsoFLK

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6b055079e8f2e874d4ec46e5249246b50bc5ee1f
Local-runs: none

Delta review of PR #22331 (card #15207 scope item (4), ADR-0131 D7, C6) at the head above. It supersedes the PASS 6065958488 on 5322c2b755, which that push voided. Merge base is still dc4a5c6308; the PR's net diff is still 23 files, +1479 / −263, and it matches the PR object. The delta is one commit, 6b055079e8, fast-forward from 5322c2b755, +3 / −3 in two files: the step-18 D3 entry 18.platform-global-object-organization-column-retired.ts (one line of reason) and the regenerated packages/spec/src/migrations/registry.ts (the generated copy of that reason, and the step-18 rationale fragment at order 89). New inputs read as claims to verify: the dev's fix-round report 6066999757 and the seat's delta ACCEPT 6067029757; plus the CLI test the red run named, packages/cli/test/migrate-meta-engine-guidance.test.ts, read at this head. Everything else is the brief's input set, unchanged. The VERDICT below is for the whole PR at this head; the earlier findings are reused only where this commit cannot reach them, and that is stated per item. Stamp: 2026-10-08T19:06Z.

① Derived judgments

  1. Every printed field of the entry is free of a tracker id, under the test's own regex. The test defines the detector as the pattern hash followed by four or five digits at a word boundary, and printedBlock(e) prints exactly surface, replacement, reason (as why:) and acceptanceCriteria (as verify:); the id appears only in the assertion message. I applied the same pattern with grep -E to the entry file at this head with comment lines stripped: no match in any string field. The only match in the file is the leading source comment (#15207, #12699), which printedBlock never prints and which Prime Directive [WIP] Add Chinese version of the documentation #13's "leave its id in the code" wants kept. The registry's copy of the entry (lines 17027 to 17064 of registry.ts, code lines only) matches nothing, and neither does the rationale fragment at order 89. The dev's claim that all five fields are clean holds, and the fragment the test does not hold is clean too. Holds.

  2. The meaning is unchanged. reason moved from "The feat(spec,security): OrgScopingEntitlement grows platform-global exemption + unbounded-admin suppression, consumed by Layer 0 arming #12699 declaration used to stand the security layer's organization wall down" to "Before this, the declaration stood the security layer's organization wall down"; the referent is fixed by the sentence before it, the quoted D7 clause "the injected-columns plan reads the declaration", and nothing else in the field moved. The fragment moved from "the feat(spec,security): OrgScopingEntitlement grows platform-global exemption + unbounded-admin suppression, consumed by Layer 0 arming #12699 deployment declaration" to "the deployment's platform-global declaration", which names the same thing descriptively. No fact is added, dropped or weakened in surface, replacement, reason or acceptanceCriteria. Holds.

  3. The regenerated registry.ts is consistent with the entry. I extracted the reason block from the entry file and from the registry's copy at this head and diffed them: identical. The order-89 fragment lives only in registry.ts (a head-wide grep finds its text nowhere else), so it is authored in place and build-migration-registry.ts preserves it; check:migration-registry runs in lint.yml and Lint and Repo Gates is green on this head, which is the gate's own word that the file is current. The fix report's "registry.ts is current (400 semantic, 247 retired-key, 222 retired-def)" is consistent with the first review's 400. Holds.

  4. Reused from 6065958488, because this commit touches only the entry's reason string, its generated copy and the fragment text. The plan (resolveInjectedSystemColumns(def, deployment?): one conjunct, byte-identical with the key absent, pinned over eight shapes); no tenant index on the declaring deployment; the reader's rules unchanged by the move to @objectstack/core (code byte-identical, comments stripped); the stand-down fold gone with no fallback read and no second behavioural consumer of the declaration (the kernel:ready re-read is a consistency assertion); the ordering (install first in start(), ahead of loadMetadataFromService and both installRegisteredSchemas calls; OrganizationsPlugin.init() registers org-scoping unconditionally so providesServices is a truthful D2 declaration; re-plan on every contributor layer; the refusal hook unconditional; no derived edge under D4; no boot data step under D14); the authored organization_id exception consistent with D7; Layer 0 and the driver agreeing (driver computeTenantField null without the field; kernel and end-to-end pins); the /meta read exits and the write seam. None of those files is in this commit. The ADR-0087 entry's id, surface, replacement and acceptanceCriteria are byte-identical to the reviewed head, and the changeset's registration marker still names the same id.

② Semver level

Unchanged by this commit, which touches no published surface and no changeset: Clause-② yes (narrowing) is the right combination (core, objectql and spec widen; the declaring deployment narrows); major for @objectstack/objectql and @objectstack/plugin-security is admissible under pre mode (.changeset/pre.json is mode: pre, and check-changeset-no-major.mjs takes its exempt branch there), minor for @objectstack/spec and @objectstack/core, patch for @objectstack/organizations defensible; the BREAKING banner, the bang, the FROM and TO and the registered disposition are present. Check Changeset is green on this head, and the fix report's check-adr-0087-registration reading ("major, BREAKING, bang, clause-② narrowing; registered platform-global-object-organization-column-retired, new here") is the same reading as before. Level: consistent.

③ Boundary flags

  1. Check-runs on this head, read once at the stamp above: nothing red, nothing in progress. 35 check-runs, all completed: 32 success and 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke). Every required context on main is green: TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG and MySQL), Lint and Repo Gates, Governed Surface Queue Guard. Test Core (1/6), the context that was red on 5322c2b755 for this PR's own entry, is success; so are Test Core 2/6 through 6/6 and the roll-up. No red required context is this PR's.

  2. The root cause of the miss (report deviation 1 and the seat's process note): answered. The round-1 report listed the CLI layers as CI-only because no CLI file was touched, but migrate-meta-engine-guidance.test.ts reads the whole migration registry through @objectstack/spec/migrations, so a new semantic entry is a CLI test input. The first review did not catch it either: it judged the entry's shape and registration, not its prose under the CLI's author-shown rule. The seat recorded the rule on the seat post (a PR that changes the migration registry runs the full test task of every package that reads it). Nothing is owed on this card; the test holds the rule in the required set.

  3. Nine older step-18 fragments still carry tracker ids (report deviation 1). Out of this card's scope, the test does not hold fragments, and the dev left them. Noted, no card owed by this PR.

  4. The eight main commits are now twelve. origin/main moved from 28bff18d0c to 3599fef123 after the first review (feat(plugin-audit): AuditPluginOptions.getLocale, a host locale resolver asked before the settings-derived locale #22324, fix(cli): os migrate meta runs on a composeStacks project and migrates its package bodies #22326, fix(spec,service-automation)!: an undeclared config key on 10 more builtin node types is refused at the build doors; one judge per type #22319, fix(service-storage)!: the chunked completion assembles the parts the upload holds, and a re-sent chunk is counted once (#22313) #22330). Of this PR's 23 files only packages/spec/src/migrations/registry.ts is touched on that side (+96 lines, fix(spec,service-automation)!: an undeclared config key on 10 more builtin node types is refused at the build doors; one judge per type #22319's entries). A git in-object merge query (git merge-tree; no checkout, no working-tree change) merges this head into origin/main with no conflict. CI judged the merge commit 9c4e687b57, this head into 28bff18d0c, so the four newest main commits are not in what CI ran; the PR object reports mergeable not yet recomputed. The landing hop's os-regen-merge.sh pass regenerates registry.ts, spec-changes.json and the upgrade guide over the merged tree, which the ACCEPT 6065729426 already names for the feat(spec)!: PROTOCOL_VERSION 17 → 18 in an ordinary PR — regenerated spec-changes.json and upgrade guide, ^18 handshakes, pre-mode lockstep exception (#22085 Q1 → B) #22215 coupling; fix(spec,service-automation)!: an undeclared config key on 10 more builtin node types is refused at the build doors; one judge per type #22319's registry additions ride the same hop.

  5. Carried from 6065958488, untouched by this commit: the mechanism deviation answered (Phase 1/2 split plus providesServices plus the kernel:ready refusal is a declared order with its check); the files beyond the claim's surface answered, with the cross-lane re-declaration recorded as the seat's assertion (those posts are outside this review's inputs); the os migrate plan composition gap answered in-repo and escalated to C10 (the landing record should carry a pointer to the C10 card, cloud#1979); the author-time surfaces answered (a loud INVALID_FIELD refusal, not silently inert); the two observations with no action owed (the write-side strip of an author's own tenant: false on a declared object, disclosed in the docblock; the docs-drift run's two unanchored docblock-only files).

  6. Read-only discipline. No worktree, no checkout, no build, no test, no gate re-run. Reads were gh api and git against commit ids and origin/* refs; the regex check was grep -E over git show output, and the one git query that writes into the object store (merge-tree) touched no working tree.

Implemented-by: claude/issue-15207-platform-global-no-column
Reviewed-by: session_01LAi5BVvQNiYzepSAcsoFLK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 19:09
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 19:09
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 41d0d40 Oct 8, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-15207-platform-global-no-column branch October 8, 2026 19:48
os-litant pushed a commit that referenced this pull request Oct 8, 2026
… 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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/xl tests tooling

Projects

None yet

2 participants