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>
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