Repository navigation
feat(objectql,plugin-security)!: an object a deployment declares platform-global gets no organization column on that deployment — the #12699 declaration made total (ADR-0131 D7) - #22331
Conversation
…o organization column (ADR-0131 D7) The injected-columns plan takes the deployment's platformGlobalObjects as its input; the engine reads it at start() before the first schema sync and re-plans objects registered earlier; plugin-security's stand-down fold retires. Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
… and the declared order (ADR-0131 D7) Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
…he plan (ADR-0131 D7) Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
…column plan (ADR-0131 D7) Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
…R-0131 D7) Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
…atform-global-no-column
…ed ledger Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
…umn pin Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 5 package(s): 17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 149 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9c4e687b57e7472fdc1d9a473baa807d741ff86f && git checkout 9c4e687b57e7472fdc1d9a473baa807d741ff86f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 28bff18d0c4013db86d61eba87c739c3145e17fd 6b055079e8f2e874d4ec46e5249246b50bc5ee1f && git checkout -B drift-repro 28bff18d0c4013db86d61eba87c739c3145e17fd && git merge --no-ff 6b055079e8f2e874d4ec46e5249246b50bc5ee1f
node scripts/docs-audit/affected-docs.mjs --json 28bff18d0c4013db86d61eba87c739c3145e17fd
|
Contract reviewServed-tier: Scope: PR #22331, card #15207 scope item (4), the #12699 declaration made total (ADR-0131 D7, C6). Reviewed at the head above against merge base ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…ker id (ADR-0131 D7) The step-18 D3 entry's reason said the declaration by its tracker number; os migrate meta prints that field, and author-shown guidance carries none. The step rationale fragment says it the same way. Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Delta review of PR #22331 (card #15207 scope item (4), ADR-0131 D7, C6) at the head above. It supersedes the PASS ① Derived judgments
② Semver levelUnchanged by this commit, which touches no published surface and no changeset: Clause-② ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
… merging main at 41d0d40 (step 18: 64 conversions, 324 semantic entries) main added two step-18 semantic entries since 6729e10: flow-builtin-node-config-undeclared-keys-refused (#22319) and platform-global-object-organization-column-retired (#22331). At protocol 18 both generators project every step-18 entry, so both documents gain them. The conversion ids are unchanged. Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
Fixes #15207
Clause-②: yes (narrowing: on a deployment that declares an object platform-global, that object carries no organization column; the dev measures the built declaration closure)
Measured on the built declaration closure. The value
yesholds:@objectstack/coregains exports, because the one fail-closed reader of theorg-scopingkeys moved there.resolveInjectedSystemColumnsgains an optional second parameter but no new spec export, andcheck:api-surfaceon the rebuilt spec reports the surface unchanged. The arm stays(narrowing), because the behaviour narrows: a declared object loses its column on the declaring deployment. The changeset carriesClause-②: yes (narrowing). This is the last open item of the card (items (1), (2) and (3) landed as #22107, #22266 and #22166), so line 1 closes it.Scope: item (4), the #12699 declaration made total
ADR-0131 D7: "an object a deployment declares platform-global gets no organization column on that deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by having nothing to scope." ADR-0131's retirement list names "#12699's stand-down semantics (replaced by D7's no-column)". Claim
6061910188. No stored row moves and no step runs at boot (ADR-0131 D14).What changes
spec/src/data/injected-system-columns.ts):resolveInjectedSystemColumns(def, deployment?). The second argument carries the deployment's validatedplatformGlobalObjects. A declared object is planned with noorganization_id, and the rest of its plan is unchanged. With the argument absent or empty, every plan is byte-identical to the one-argument call (pinned over eight object shapes). Author-time callers pass nothing. The module doc says where that leaves them: see P5.objectql/src/registry.ts,objectql/src/plugin.ts):ObjectQLPlugin.start()readsorg-scopingFIRST, beforeloadMetadataFromServiceand before the firstinstallRegisteredSchemas.SchemaRegistry.setDeploymentPlatformGlobalObjects().applySystemFieldsas the plan's input, and to the tenant-index predicate (carriesTenantScopeColumn).materializeBaseLayergains a first stamp,applyDeploymentTenancy. It records the plan's answer on the base layer assystemFields: { tenant: false }, which is the vocabulary every registered-object reader already answers "no organization column" from. Its write-side inverse is the last strip instripMaterializedStampsFrom, so a Studio GET → PUT stores the body the author wrote ([P3] Read decorations (_diagnostics, _draft) round-trip into persisted sys_metadata bodies #4326).init()) are re-planned at the install, on every contributor layer.deploymentWithholdsTenant, which asks the spec plan with and without the deployment input.organization_id: that column is the author's, so it stays and is walled. It warns once for a refused (malformed) key.plugin-security/src/security-plugin.ts):tenancyDisabledclause ingetObjectSecurityMetais gone, the one that readplatformGlobalObjects. A declared object reaches the wall as its ownsystemFields.tenant: false.[security] deployment declares N platform-global object(s)boot line is gone; the engine logs the list.suppressUnboundedOrgAdminGrant. That key and its behaviour are unchanged.deployment-org-scoping-entitlement.ts: plugin-security →core/src/security/). Its consumers are now in two packages that cannot import each other: objectql readsplatformGlobalObjects, plugin-security readssuppressUnboundedOrgAdminGrant. It is exported from@objectstack/corewith its rules unchanged: absent ⇒ nothing declared; junk ⇒ the whole key refused, never coerced, each key independently.plugins/organizations/src/organizations-plugin.ts):providesServices = ['org-scoping'](ADR-0116 D2). See P2.tenancy-posture.ts(theplatformGlobalObjectsandsuppressUnboundedOrgAdminGrantdocs),tenant-layer0-verdict.ts(the carve-out row),engine.ts(two docblocks), andauto-org-admin-grant.ts(one docblock).18.platform-global-object-organization-column-retired.ts, one step-18 rationale fragment (order 89), and the regeneratedregistry.ts..changeset/15207-platform-global-no-organization-column.md:majorfor@objectstack/objectqland@objectstack/plugin-security(Changesets pre mode is in,.changeset/pre.json),minorfor spec and core,patchfor organizations, with its BREAKING banner, its FROM → TO and the marker.Premise readings (each measured before code)
P1, HOLDS. The harness: a booted
ObjectKernelwithObjectQLPluginover SQLite, the realSecurityPlugin, and a fixture provider composed after the objects plugin declaringplatformGlobalObjects: ['qa_widget_registry']. It was measured at799eb000c7, before any edit:organization_id, and its table was created with the column (columnInfo);security.getReadFilter(declared, member)answered no wall (the fold ingetObjectSecurityMeta), and the sibling answered{ organization_id: 'org_acme' };tenantId: 'org_acme'returned only theorg_acmerow of two (the driver's tenant arm).So Layer 0 and the driver disagreed about one object. Control: with no declaration, both objects were walled.
P2, HOLDS through the Phase 1/2 split, NOT through a per-plugin edge.
SchemaRegistry.registerObject→applySystemFields→resolveInjectedSystemColumns. That runs inside whichever plugin'sinit()callsmanifest.register, and again for later registrations (loadMetadataFromServiceandrestoreMetadataFromDbatstart(), installs after it). The columns are fixed atObjectQLPlugin.start()→installRegisteredSchemas.org-scopingis registered: inOrganizationsPlugin.init(), which hard-depends on the engine (itsinit()callsmanifest.register).servecomposes it after Auth and the app plugins, so it initializes after many object registrants, and the fixture measured that too: the declared object was already registered when the provider initialized.init()completes before anystart(), so the provider has registered by the engine'sstart(), and no table exists yet. The provider now declares it inprovidesServices, so "absent at start" is a declared fact (ADR-0116 D2; the AGENTS.md startup-registry cure 2).optionalDependencieson the provider is a cycle, andresolvePluginOrderthrows on an optional edge too. An edge from every object registrant is an open-ended set. That is why the registry re-plans at the install.init()with a different declaration fails the boot atkernel:ready, naming both lists andprovidesServices.P3, HOLDS. With the key absent, every object's registered shape is byte-identical: pinned as JSON equality between a registry with no install and one with an empty install, plus the spec pin over eight shapes, plus the kernel pin. With junk (
platformGlobalObjects: 'qa_widget_registry'), the engine warns'platformGlobalObjects' REFUSEDonce, and every object keeps its column and its wall.P4, HOLDS. At
799eb000c7,git grep platformGlobalObjectsfinds no declarer outside tests: the spec schema and docs, the reader, plugin-security's consumer and log, and tests only. Every pin uses a fixture provider.P5, measured. Author-time surfaces compute the plan with no deployment, so on the declaring deployment they still name
organization_idfor a declared object:lint/src/system-fields.ts);spec/src/data/import-mapping-target.ts);scripts/platform-object-tenancy-census.mjs);Runtime surfaces on that deployment agree with it: the registry, the DDL, the
/metaread exits (pinned throughObjectStackProtocolImplementation), the metadata bridge thatdescribereads, the lifecycle provenance (absent), and the field doors (INVALID_FIELD/INVALID_FILTER). The plan's module doc and the changeset say so. One one-shot surface depends on composition:os migrate plan/applycomposes the host config's plugins, notserve's posture-drivenOrganizationsPlugin. A declaring deployment whose provider arrives only throughservewould get a migrate plan that adds the column back. That is under Acceptance notes, for C10.Pins (refused and still-accepted case each)
spec/src/data/injected-system-columns.test.ts, 5 cases):organization_id, and only that moves;objectql/src/registry-deployment-platform-global.test.ts, 7 cases):extendincluded;organization_idis kept and reported;/metaread exit serves the registry's answer;systemFieldsmember survives, and a non-declared object is never touched.plugin-security/src/platform-global-no-organization-column.test.ts, 8 cases, booted kernel with a fixture provider):organization_idis refusedINVALID_FIELD/ 400, and the sibling accepts it;start()refuses the boot by name.tenant-layer0-verdict-end-to-end.test.ts): a member's predicate update on the declared object now matches both rows (it matched one before, the driver's tenant arm), and the bulk event names no organization. CONTROL: the sibling sweep matches one row and namesorg_acme.deployment-platform-global-exemption.test.ts, rewritten):isolatedandgroup, and on the ADR-0123 D2 write path;single, Layer 0 is inert on both;platformGlobalObjectsdraws no warning from this plugin, and a junk suppress key is warned once;core/src/security/deployment-org-scoping-entitlement.test.ts, 11 cases): absent, well-formed, four junk shapes (whole-key refusal), per-key independence, and memo per instance.Reverse verification (one-off,
scripts/ablation-replace.mjsin hold mode with a trap restore)The plan's read of the declaration in
injected-system-columns.tswas neutralised: the anchor!(name !== '' && deploymentDeclaresPlatformGlobalwas replaced so it never matches (anchor 1 → 0, blob6e571966dd→88efcbbb14). The spec was rebuilt, andablation-dist-preflight.mjsfound the marker in 6 built files. Results:Restore leg:
6e571966dd),git diff HEADempty, the whole tree clean;ablation-dist-preflight.mjs --absentfinds the marker in none of the 234 built files;Fate for C7 (#15211) and C10
On a declaring deployment, each declared object's existing
organization_idcolumn is ADR-0131 D10 fate 1 (column dropped). Schema sync is additive, so the physical column stays, and the boot drift report names it orphaned. The declarer (cloud's control plane, C10) owns the data step: confirm nothing reads it, thenos migrate apply --allow-destructive. Its backfill decides any value that must survive. C7's inventory records the declared set per deployment with this entry id. No boot step reads or writes the column (D14).Files outside the claim's file surface
packages/plugins/organizations/src/organizations-plugin.ts: one declaration,providesServices. It is the "provider declaration" the claim's ordering bullet names, in the provider's own file. Its lane is re-declared by the seat.packages/core/src/security/deployment-org-scoping-entitlement.tsand.test.ts, andcore/src/security/index.ts: the reader's new home, so that both consumers can import it (the claim allows "if its reader moves";coreis on the claim's declared lanes).packages/objectql/src/federated-injected-column-readers.test.ts: two census rows for the two neworganization_idseams. That census fails on any undisposed seam.objectql/src/plugin.ts,plugin-security/src/auto-org-admin-grant.ts(one docblock), and four plugin-security test files.Acceptance notes
os migrate plan/applycompose the host config's plugins, notserve's posture-driven organizations runtime. On a declaring deployment whose provider is composed only byserve, a migrate plan reads no declaration and would add the column back to a declared object's table (additive sync). Carrier: C10 (cloud's control plane composition), noted, not filed: there is no in-repo declarer to reach it with.organization_idon a declared object; the declaring deployment refuses it as an unknown field. This is inherent to a deployment input, and stated in the plan's docs and the changeset.OrganizationsPlugin.providesServiceswas absent before, so ADR-0116's stage-1 check could not name it for aninit()-time requirer oforg-scoping. None exists in-repo (check:init-service-contractgreen).Verification (head
5322c2b755, which mergedorigin/mainatdc4a5c6308throughos-regen-merge.sh; the regeneration wrote nothing)--project local: 626 files, 18728 passed, 1 todo;--project local: 385 files, 7562 passed;86db7e86f4; since then plugin-security changed one test file's type annotation, and objectql (aliased to source there) lost one unused accessor. The four plugin-security files this PR touches were re-run green afterwards.--project repo:step18-rationale-mergeandconversions-major18-merge, 21 passed.check:test-typecheck, and every ledger held unchanged.check:generatedreports 15 of 15 up to date.check:migration-registry: 400 semantic entries.check:api-surfaceunchanged.dispatch-gates --commands --repo objectstack-ai/objectstack(no paths) at5322c2b755derives 109 families. All 109 ran, each exit code captured before any pipe, all 0. The--ranreconciliation: 109 derived, 109 run, 0 NOT-MEASURED, 0 UNRUN.check:engine-double-contractasked for the new pin's three doubles in the ledger (--write), andcheck:slot-lookuprefused one untyped service lookup in a test.check:dts-closure,check:dual-build-cjs-loadsandcheck:i18nfirst stopped on unbuilt packages (a prerequisite). They are green after a full build (72 tasks, 71 cached).check:init-service-contract(36 declared) andcheck:startup-registry-verdict(none recording a verdict the boot can contradict).eslint --no-inline-config --format jsonover the 21 changed.tsfiles gives 21 results, 0 errors, 0 warnings. The population iseslint.config.mjs'spackages/**and**/*TS globs. The config states it enables no type-aware linting, so an untouched file's verdict cannot move. The fullpnpm lintis CI's.799eb000c7;resolvePluginOrderover the two declarations: it throwsCircular dependency detected: com.objectstack.engine.objectql. CONTROL: without the soft edge, the order is engine then organizations;--absent, tree clean, the same files green).origin/mainhas moved 8 commits sincedc4a5c6308, three of them through this PR's files (registry.ts,engine.ts,security-plugin.ts) and the double ledger. A no-commit merge probe auto-merges them with no conflict. The next hop merges them throughos-regen-merge.sh.Generated by Claude Code