Repository navigation
feat(platform-objects,service-automation,service-realtime)!: seven deployment-level tables lose their injected organization column, and reads need manage_platform_settings (ADR-0131 D7) - #22107
Conversation
…nt-level plumbing loses its injected organization column and reads become platform-only (ADR-0131 D7) sys_job, sys_job_run, sys_job_queue, sys_flow_dispatch, sys_migration, sys_migration_journal and sys_presence declare systemFields.tenant: false. Each is confirmed by a writer census: every writer is a system-context write whose row names no organization (sys_presence has no ObjectQL writer at all). With no column there is no tenant wall, so reads are governed by object permission: requiredPermissions manage_platform_settings keeps a walled deployment's organization_admin off other organizations' job errors, queue payloads and migration traces. Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude <noreply@anthropic.com>
…irements in the protocol-18 ledger (ADR-0131 D7) One D3 semantic entry per removed column, with its writer census cited, plus the step-18 rationale fragment; regenerate the migration registry and the platform-object tenancy census, and re-premise the sys_job uniqueness pin on the column's absence. Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude <noreply@anthropic.com>
…lumn (ADR-0131 D7) Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 5 package(s): 29 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 142 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 c7b26b92b67fd563ed69943ed342f0fd463704e5 && git checkout c7b26b92b67fd563ed69943ed342f0fd463704e5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dd39171835831634e87023e900cf9f448e706fb0 1ef09a51270f02b8e541f7b913e811300c6a81bf && git checkout -B drift-repro dd39171835831634e87023e900cf9f448e706fb0 && git merge --no-ff 1ef09a51270f02b8e541f7b913e811300c6a81bf
node scripts/docs-audit/affected-docs.mjs --json dd39171835831634e87023e900cf9f448e706fb0
|
…ployment-level-no-org-column
…t partition (ADR-0131 D7) tenantWindowsFor now answers no windows when the registry provenance of organization_id is 'absent' (no injection, no declaration), the shape the federated case already has. A tenant-scope retention override naming a column-less table (the deployment-level tables this branch takes the column off) no longer makes the reaper and the archiver partition on a column the table lacks, which the SQL driver refused with INVALID_FILTER. Pin on a real ObjectQL engine and registry, with a control that keeps its per-tenant window; the federated reader census gains the new seam's row. Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude <noreply@anthropic.com>
…builtin node-config pair
Both fragments sat at order 85 after the merge of main; the tie broke by
id and put this one between the approval-node fragment and the one that
continues it ("Then the builtin arm stops being presence-only"). Order 86
is the next free one.
Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7
Co-authored-by: Claude <noreply@anthropic.com>
…ployment-level-no-org-column
Contract reviewServed-tier: Inputs read: card #15207 (body and all six comments, ① Derived judgmentsScope against the revision Membership of each object, by the writer (D7's rule): right.
The access change, per posture: right, and the narrowing is declared. Mechanism verified at the head: the security middleware short-circuits system contexts at
Data at rest: right. Schema sync is additive, so an existing database keeps the physical The lifecycle guard: right. Seven D3 step-18 entries: right. One per column, ids Step-18 rationale order 86: right against Files outside the claim's listed surface: all owed by this change. Public surface. No ② Semver level
③ Boundary flagsRound 1 report
Patch round report
The H5 security table and the better-sqlite3 lifecycle measurement were scratch harnesses the dev did not commit; this record relies on the committed pins (capability and column asserted per object with controls; the lifecycle pin on a real registry with a refusing driver) and on the middleware reading in ① for the mechanism, and takes the measured row counts as the dev's. Implemented-by: VERDICT: PASS Generated by Claude Code |
✅ ACCEPT: PR #22107 at
|
…al rung moves to the tenant-less sys_platform_setting (ADR-0131 D7) (objectstack-ai#22166) Part of objectstack-ai#15207 Clause-②: yes (widening: a new platform object `sys_platform_setting`, and a new name if `packages/spec` exports one; the global rung's storage narrows on `sys_setting`. The dev measures the built declaration closure.) The line above is the claim's (`6049595369`), copied verbatim. The measured arm is in the changeset: `Clause-②: yes (narrowing)` — the diff widens (a new object, `SysPlatformSetting` and `CONFIG_CHANGE_GLOBAL_OBJECT_NAME` exported, a new name in `PLATFORM_OBJECTS_BY_PACKAGE`) AND narrows (the `global` option of `sys_setting.scope` retires, and the global rung's storage leaves `sys_setting`). `@objectstack/spec`'s exported TYPE surface does not move: the name list is data, and the new ADR-0087 entry and rationale fragment live in the generated registry. ## Scope: item (3) only This PR builds scope item (3) of objectstack-ai#15207: the `scope: 'global'` rung of `sys_setting` leaves the tenant-scoped object (ADR-0131 D7, §6 Q3) for a tenant-less `sys_platform_setting`, and the settings cascade reads the new source. Item (1) landed as PR objectstack-ai#22107. Items (2) and (4) are not here, so objectstack-ai#15207 remains open. No stored row moves in this PR: existing global rows move in the v18 operator ceremony (C7, objectstack-ai#15211), per ADR-0131 D14. ## What changes - **`sys_platform_setting`** (`packages/platform-objects/src/system/sys-platform-setting.object.ts`), registered by the settings service beside `sys_setting`: - one row per `(namespace, key)` for the deployment: `unique: 'global'` on `(namespace, key)`; - the value and encryption columns of a `sys_setting` row: `value`, `value_enc` (read-only), `encrypted`, `locked`, `locked_reason`, `updated_by` (read-only); - no `scope`, no `user_id`, and no organization column (`systemFields: { tenant: false }`); - governed by object permission (D7): `requiredPermissions: ['manage_platform_settings']`; generic API `get` / `list` only. - **`SettingsService`** (`settings-service.ts`): - a write at a key declared `scope: 'global'` lands in `sys_platform_setting`, keyed `(namespace, key)`, never in `sys_setting`; - the cascade's global rung is read from `sys_platform_setting` alone. Every `sys_setting` read now names its rungs (`$or` over `tenant` / `user`, or `user_id` / `tenant`), so a `scope = 'global'` row a pre-v18 database still holds there is not a second source. There is no fallback read and no boot-time move (D14); - the rank table, the lock check, `SpecifierScope` and `source: 'global'` are unchanged; - the global rung does not depend on the user, so `getMany` reads it once per call: `sys_setting` keeps its objectstack-ai#10826 bound (at most two reads), plus one `sys_platform_setting` read. - **`config_change` audit row**: a global-scope change now names `sys_platform_setting` (`CONFIG_CHANGE_GLOBAL_OBJECT_NAME`, exported beside `CONFIG_CHANGE_OBJECT_NAME`). A tenant- or user-scope change still names `sys_setting`. Without this, the row would name a table the value is not in. - **`sys_setting.scope`** no longer declares `global` (H6). `sys_setting_audit.scope` keeps it, because the audit writer records the changed key's scope and a global change is still a change. The translation bundles drop the retired leaf, and the es-ES echo ledger drops its row (47 to 46 echoes). - **`os secret orphans` / `os secret rewrap`** (`packages/cli/src/utils/secret-reference-union.ts`): the settings family of the `sys_secret` reference union reads BOTH holders, `sys_setting.value_enc` and `sys_platform_setting.value_enc`. If either cannot be read, the whole family gaps (H3). - **ADR-0087**: one D3 semantic entry, `sys-setting-global-rung-moved`, and one step-18 rationale fragment. The registry is regenerated. `spec-changes.json` and the upgrade guide do not move, because step 18 is not projected yet. - **Regenerated census artefacts**: - platform-object tenancy census: 83 to 84 registered objects, out of reach 33 to 34, `systemFields.tenant: false` 8 to 9; - tenant-audit census: two sites moved from unreadable options to readable, because the `as any` spread of `bypass` is gone (171 to 173 threading a tenant context); - query-options erasure baseline: `settings-service.ts` 2 to 1, a ratchet down. ## Readings (measured at `51290bca2c`, re-checked after merging `main`) **H1 holds: nothing moves to configuration.** A census of every registered manifest (the built `builtinSettingsManifests` plus objectql's `lifecycleSettingsManifest`) found 109 keys at the global rung in seven namespaces: `auth` 29, `ai` 35, `storage` 11, `mail` 10, `sms` 10, `knowledge` 10 and `lifecycle` 4. `lifecycle.retention_overrides` is the one tenant key in a global manifest. Every one of the seven requires `manage_platform_settings` to read and to write through the door, so every one is edited live in Setup. Their in-tree consumers re-read on change: - `plugin-auth` and `organizations` use `getNamespace('auth')`, and plugin-auth re-applies on `subscribe('auth')`; - `plugin-email` (`mail`), `service-sms` (`sms`) and `service-storage` (`storage`) each re-apply on `subscribe`; - `lifecycle` is read on every sweep; - `ai` and `knowledge` have no in-tree value reader beyond their `test` actions, which `runAction` resolves live. No key is boot-read only, so there is no `open_questions` entry for a configuration move. **No writer attributes a global row to an organization (the stop condition did not fire).** - `SettingsService.setMany` is the only writer of a settings row. It writes under `{ isSystem: true }`, with no `tenantId` and a row that names no organization. - `sys_setting` is `unclassified` in the platform-object tenancy inventory, so `resolveSystemInsertOrganization` derives nothing. - The new pin writes a global key with a writer context of `tenantId: 'org_1'`, and the stored row carries no organization. - Two organization signals do sit on the global WRITE PATH, but neither attributes the settings ROW: - the `CryptoContext` passed to `encrypt` carries `tenantId: ctx.tenantId` for every rung. The only in-tree provider (`LocalCryptoProvider`) binds no tenant into the AAD, and `materialiseRow` decrypts with no `tenantId` at all; - the `config_change` audit row stamps the writer's organization (item (2)'s object). **H2: global-rung readers outside the service.** Census of every non-test source naming `sys_setting`. The procedure fires: it flags the union and the orphan command. | reader | reads the global rung? | disposition | |---|---|---| | `cli/src/utils/secret-reference-union.ts` (settings collector) | yes: every row, no scope filter | **fixed here** (reads both holders) | | `cli/src/commands/secret/orphans.ts`, lines 300 to 309 (legacy-inline guard rows) | yes: every row | **outside the claim's surface, not edited** (see Acceptance notes) | | `cli/src/utils/sys-secret-orphan-sweep.ts` | no read: pure, consumes the rows `orphans.ts` hands it | none | | `core/src/security/resolve-authz-context.ts`, line 1696 | no: the direct read pins `scope: 'tenant'`; the service leg goes through `getMany` | none | | `metadata-protocol/src/migrations/sys-setting-identity-index.ts` | index maintenance over all rows, pre-v18 global rows included; it reads no value | none (see Acceptance notes) | | `mcp/src/plugin.ts`, `plugin-hono-server/src/current-user-endpoints.ts` | no: comments only; they read through `resolveLocalizationContext` | none | | `service-settings/src/sys-secret-orphan-report.ts` | no read: a pure classifier over caller-supplied rows, with no in-tree caller | none | **H3 holds, and is closed here.** Before the union fix, a credential held only in `sys_platform_setting.value_enc` is attributable (its `(namespace, key)` is a declared encrypted specifier) and unreferenced by `sys_setting`. That is exactly the deletable shape, so the sweep would delete the credential in force. Ablation B below turns the three pins red, including the sweep's `referenced` → deletable. **H4: the AAD binds no holder object and no organization.** `LocalCryptoProvider`'s version-2 AAD is the 0xFF lead byte, a version label, then `lp(scope) || lp(namespace) || lp(key)`. `scope` is the producer vocabulary (`settings`). It binds no object name, no organization and no tenant (`aadForVersion2`, `local-crypto-provider.ts`). So **C7's row move needs no re-encryption**: copy `value_enc` (the `sys_secret` handle) unchanged into the new row with the same `(namespace, key)`. The `sys_secret` row does not move. A pin seals a handle with an organization in the context, places it in a `sys_platform_setting` row, and resolves it. **H5: object permission holds, and the settings door is unaffected.** This was measured with a scratch harness, not committed: the real `SecurityPlugin` middleware, the registry-processed `sys_platform_setting` and the shipped permission sets, running a `find`. | posture | organization admin | platform admin | member | system context (the service) | |---|---|---|---|---| | none registered | 403 `PERMISSION_DENIED` | admitted, no filter | 403 | admitted | | `single` | 403 `PERMISSION_DENIED` | admitted, no filter | 403 | admitted | | `isolated` | 403 `PERMISSION_DENIED` | admitted, no filter | 403 | admitted | | control: same object without `requiredPermissions`, any posture | **admitted, no filter** | admitted | 403 | admitted | The control row is why the gate ships with the object: with no column there is no wall. The settings door is unchanged. `settings-admission-tenancy-posture.test.ts` and `config-change-audit.test.ts` drive the plugin's own routes over a real `ObjectQL` with a `scope: 'global'` manifest, and the row lands in `sys_platform_setting` (200). **H6: half holds.** After this PR no writer writes `scope: 'global'` into `sys_setting`, so its `global` option is retired, with the ADR-0087 entry. The `sys_setting_audit.scope` mirror is still written (the audit writer records `entry.scope`), so it stays. The parity pin now reads: `sys_setting.scope` = `SpecifierScopeSchema` minus `global`, and `sys_setting_audit.scope` = `SpecifierScopeSchema`. **Zone 3's suggested pin "a tenant and a user value still override it" is falsified by the unchanged rank table.** `scopeRank` gives `global` rank 1 and `resolveKeyFromRows` takes the first non-null rung, so a global value OUTRANKS the tenant and user rungs. It did before this PR and does after it. The pin asserts what the rulings keep: global from the new store outranks both, and the tenant and user rungs answer once it is empty. ## What C7 (objectstack-ai#15211) must do with the rows (recorded, not built) - For every `sys_setting` row at `scope = 'global'`, write one `sys_platform_setting` row with the same `namespace` and `key`, and copy `value`, `value_enc`, `encrypted`, `locked`, `locked_reason` and `updated_by`. Then remove the source row. - `value_enc` is copied **verbatim**: re-encryption is neither needed nor wanted (H4). The `sys_secret` row stays where it is, and its handle id is unchanged. - A `(namespace, key)` with more than one global row is possible on a pre-objectstack-ai#8629 database, because NULL-distinct unique let duplicates in. The ceremony has to pick one. `sys_platform_setting`'s `(namespace, key)` unique refuses the second. - Until the ceremony runs, a moved key answers from its next rung or the manifest default. The v18 boot refusal (D10) is what stops a deployment from running in that state. ## Tests All suite counts below were read at the merged head `d0477879af`, unless a line says otherwise. - `pnpm --filter @objectstack/service-settings exec vitest run`: 37 files, 638 tests passed. This includes the new `settings-global-rung.test.ts` (8 cases, a real `ObjectQL`) and the fixtures re-premised so global rows sit in `sys_platform_setting`. - `pnpm --filter @objectstack/platform-objects exec vitest run`: 65 files, 1036 tests passed. Before the echo-ledger fix, the run had 4 red, all in `objects-es-es-echo-decisions.test.ts`, for the retired leaf. - `pnpm --filter @objectstack/spec exec vitest run src/system src/migrations src/data/api-methods-batch-conformance.test.ts`: 55 files, 1980 tests passed. - `pnpm --filter @objectstack/cli exec vitest run --project integration` over the touched files (union, sweep, rewrap, `src/commands/secret`): 7 files, 91 tests passed. The `unit` layer: 263 files, 3874 tests passed, at the pre-merge head `1137107352`. - Typecheck green for `service-settings`, `platform-objects`, `spec` and `cli`, at `1137107352`. - **Ablations** were one-off, through `scripts/ablation-replace.mjs` in WRAP mode. Each anchor hit 1 → 0 and the blob changed. Each restore read blob == HEAD with `git diff HEAD` empty, and `git status --porcelain` read 0 lines after all four. The subjects are imported by relative source path, so no `dist/` is in the resolution path. - A, the null-user `sys_setting` read readmits `scope: 'global'`: red, `a global-scope key never consults sys_setting at all`. - A2, the user-keyed read readmits `scope: 'global'`: red, `a scope=global row still in sys_setting is NOT read`. - D, the global rung read from `sys_setting`: 7 of 8 red. - B, the union reads `sys_setting` alone: red, `names a handle held ONLY by sys_platform_setting.value_enc`, `an unreadable sys_platform_setting gaps the WHOLE settings family`, and the sweep's `REFERENCED, never deletable`. ## Gates - **Derivation.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `d0477879af` derived 125 families. All 125 were run, each exit code captured before any pipe, and all 125 exited 0. `--ran` reconciles 125 derived, 125 run, 0 NOT-MEASURED, 0 UNRUN. - **What the battery covers.** It includes the claim-time list. It adds `check:engine-double-contract`, `check:objectql-double-limit`, `check:where-matcher`, `check:i18n-coverage`, `check:i18n-walk-parity`, `check:type-check-coverage`, `check:type-check-debt` and `check:empty-changeset`, which the diff touches. - **The generated-artifact gates are green:** `check:api-surface` ("unchanged"), `check:migration-registry`, `check:spec-changes`, `check:upgrade-guide`, `check:platform-object-tenancy-census`, `check-tenant-audit-census`, `check:query-options-erasure`, `check:i18n` and `check:nul-bytes`. - **The merge.** `main` was merged twice, the second time through `scripts/pm/os-regen-merge.sh`, because PR objectstack-ai#22103 also writes `packages/spec/src/migrations/registry.ts`. After `gen:migration-registry`, the regenerated registry is byte-identical to the merge, and it holds both this PR's entry and objectstack-ai#22103's `declared-index-bare-unique-true-retired`. - **Lint, a proven narrowing.** `eslint --no-inline-config --format json` at `d0477879af` read 37 results, the 37 changed `.ts` files: 0 errors and 0 warnings, so none was ignored by the config. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, per its own comment), so this diff cannot move the verdict on any untouched file. The full `pnpm lint` is CI's. ## Acceptance notes (observed, not changed here) - `cli/src/commands/secret/orphans.ts`, lines 300 to 309, builds the sweep's legacy-inline guard from `sys_setting` rows only. Today nothing writes legacy inline ciphertext into `sys_platform_setting`: the plugin always wires `LocalCryptoProvider` plus the `sys_secret` store. After C7 moves pre-Phase-3 global rows, though, an inline value could sit there. The guard is withhold-only, and the union, which decides deletion, already reads both holders. Natural carrier: C7 (objectstack-ai#15211). - `metadata-protocol/src/migrations/sys-setting-identity-index.ts`: two operator texts on its degraded arms still say global-scope settings rows "can still be created" in `sys_setting`. No writer creates one after this PR. The text goes fully stale when C7 empties the layer. Carrier: C7 (objectstack-ai#15211). - `packages/spec/src/system/settings-manifest.zod.ts`: the module TSDoc says values persist in `sys_setting`, and its resolution list was already missing the global rung. No `.describe()` names the store, so it is left untouched per the claim. - `SettingsService.setMany` passes `tenantId: ctx.tenantId` into the `CryptoContext` on every rung, while `materialiseRow` decrypts with none. No in-tree provider reads `tenantId`, so this pulls nothing today. A per-tenant-key KMS provider would seal and open under different keys on every rung. --- _Generated by [Claude Code](https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… organization column; tenant_id carries the organization a row is about and scopes organization readers (ADR-0131 D7) (objectstack-ai#22266) Part of objectstack-ai#15207 Clause-②: no (narrowing) The claim (`6055795594`) declared the value `yes` with the narrowing arm and asked the dev to measure the built declaration closure. Measured: `check:api-surface` on the rebuilt `@objectstack/spec` reports the public surface unchanged, and no package in this diff adds an export, a published key or an accepted value; the diff only narrows (the ledger refuses `organization_id`, and `organization_admin`'s ledger grant loses its superuser bits). So the value is `no`, the arm stays `(narrowing)`, and the changeset carries the same line. It is the reading item (1) declared for the same shape. Item (4) of objectstack-ai#15207 is not built here, so the card stays open. ## Scope: item (2) only ADR-0131 D7: the audit ledger may hold rows about deployment-level actions, so "the organization an audit row is *about* becomes a plain attribution field under a name the tenant-field resolver does not claim, never the tenancy anchor", and the object "is governed by object permission, not by the wall". The seat's ruling is option A of report `6042515710`. Item (1) landed as objectstack-ai#22107 and item (3) as objectstack-ai#22166. No stored row moves here (ADR-0131 D14): the column's fate is C7's (objectstack-ai#15211), below. **Patch round** (seat ruling `6058257824`, which widens the claim's file surface): - the changeset now states the `single`-posture consequence; - a global settings change's `config_change` row carries no `tenant_id`; - the inert `organization_id` stamps are removed in `platform-admin-standing-audit.ts` and `config-change-audit.ts`, and the predating comments are corrected, including `managed-object-write-denies.ts`' docblock. The open question on a `single` deployment holding more than one organization is ruled A (ADR-0131 D8 and §1.2(3)). ## What changes - **`sys_audit_log`** (`plugin-audit/src/objects/sys-audit-log.object.ts`) declares `systemFields: { tenant: false }`. The registry injects no `organization_id`, and a new table is provisioned without it. `tenant_id` (lookup to `sys_organization`) is unchanged and is now the only organization column; its help text says what it is. The four translation bundles and the README follow. - **The plugin-audit writers** (`audit-writers.ts`, `read-audit.ts`, `auth-event-audit.ts`) keep stamping `tenant_id` as before and drop their conditional `organization_id` stamp, which the registered schema can no longer satisfy. No fallback read or write of the retired column remains (D14). - **The settings writer** (`service-settings/src/config-change-audit.ts`): a GLOBAL-scope change is a deployment-level action about no organization, so its `config_change` row carries no `tenant_id`, whatever organization the writing session has active. Tenant- and user-scope changes keep the writer's organization. Its `organization_id` field probe and stamp are gone. The `SettingsAuditSink.tenantId` TSDoc in `settings-service.types.ts` says the same. - **The platform-admin standing writer** (`plugin-security/src/platform-admin-standing-audit.ts`): the `declaresOrganizationId` input and its stamp are gone, and the call site in `bootstrap-platform-admin.ts` with them. `tenant_id` stays NULL by ruling, and its comment block now reads against the column-less ledger. No behaviour moves: the registered ledger declares no such column after this PR. - **`managed-object-write-denies.ts`'s docblock** no longer cites the ledger as reached by the wildcard's superuser bits. `organization_admin` names it explicitly, without them. - **The read scope, option A** (`plugin-security/src/objects/default-permission-sets.ts`): - a platform row policy `sys_audit_log_org`: `tenant_id == current_user.organization_id`, operation `select`; - spread into `organization_admin` (and so its derived no-bypass variant), `viewer_readonly` and `member_default`. A set that holds no policy for an object leaves it unfiltered, so the policy goes where the shipped reads are. `viewer_readonly`'s wildcard reads the ledger. `member_default` is the baseline every authenticated human holds, so a ledger read an application set grants is scoped too. This is the placement `scimProjectionRowScope` already uses; - an explicit `sys_audit_log` entry in `organization_admin`: read only, with no `viewAllRecords` / `modifyAllRecords`; - stripped under `single` by the existing provenance rule (ADR-0105 D3), with no new code. - **Retention** (`objectql/src/lifecycle/lifecycle-service.ts`). `tenantWindowsFor` now returns the partition column with the windows. It is `organization_id` where the object has a provisioned one (unchanged). Otherwise it is the ledger's attribution field, from a one-row, name-keyed table `ATTRIBUTION_PARTITION_COLUMNS` (`sys_audit_log` → `tenant_id`), honoured only where the author really declares the field. The reaper and the archiver name only that column. - **`view_all_audit_log`** (`spec/src/security/capabilities.ts`): the description and its comment are re-premised on the attribution field and the row scope. `eval-user.zod.ts` lists the name only and restates nothing. - **ADR-0087**: the D3 entry `18.sys-audit-log-organization-column-retired.ts`, one step-18 rationale fragment, and the regenerated `registry.ts`. - **Censuses**: `scripts/platform-object-tenancy-census.json` regenerated by its own tool (in reach 50 → 49, out 34 → 35, `systemFields.tenant: false` 9 → 10). The tenant-audit and system-context censuses are green and did not move. ## Premise readings (each measured before code) - **P1, holds, with one refinement.** The ledger holds rows about deployment-level actions with no organization: - `platform_admin_standing_change`: `plugin-security` `bootstrap-platform-admin.ts` through `buildPlatformAdminStandingRow`, `tenant_id` always NULL, by ruling; - `import`: `plugin-auth` `admin-import-users.ts`, the run-level row of a platform-admin endpoint, no `tenant_id`; - administrative `create` / `update` on `sys_user`: `plugin-auth` `admin-user-endpoints.ts`, platform-admin endpoints, no `tenant_id`; - `config_change`: `service-settings` `config-change-audit.ts`. Refinement: it stamped the writing context's organization, so a global-scope key written by a session with an active organization carried that organization. Ruled into this PR (`6058257824`): a global-scope change now carries no `tenant_id`. - **P2, holds, measured** through the real permission compiler (a real `SecurityPlugin` over a real `ObjectQL` and SQL driver, with the shipped sets). With the explicit entry removed and the policy kept, an organization admin under `isolated` reads every organization's rows. Mechanism: `systemFields.tenant === false` makes `meta.tenancyDisabled` true, so `posturePermits` holds in `computeLayeredRlsFilter` and the wildcard's superuser bypass skips Layer 1. - **P3, holds.** `security-plugin.ts#collectRLSPolicies` drops a policy when `!this.orgScopingEnabled && isPlatformTenantPolicy(policy)`. `orgScopingEnabled` is `postureEnforcesWall(this.tenancyPosture)`. The provenance set is `PLATFORM_TENANT_POLICY_KEYS` in `platform-tenant-policies.ts`, built from the shipped sets' policies whose `using` names `current_user.organization_id`. The new policy is in that set (pinned). - **P4.** Writers that stamp `tenant_id`: - the record mirror (`audit-writers.ts`): the record's organization, else the session's; - the record-view writer (`read-audit.ts`): the record's organization, else the session's; - the sign-in writer (`auth-event-audit.ts`): the session's organization; - the settings writer (`config-change-audit.ts`): the writing context's organization, and none for a global-scope change since this round. Each stamped the injected column with the same value. Explicit NULL: the platform-admin standing writer. Not stamped: the two `plugin-auth` administrative writers. Writers that update existing rows: `stored-metadata-body-migration.ts` and the CLI's `audit-metadata-bodies` rewrite `old_value` / `new_value` only. A row with no `tenant_id` matches no organization under the new policy. Under a wall it is served to platform administrators only; under `single` it is served to every ledger reader. ## Who reads what (measured; rows `a1` about org A, `b1` about org B, `d1` about no organization) | posture | caller | before | after | |---|---|---|---| | `isolated` | organization admin of A | `a1` | `a1` | | `isolated` | viewer of A | not measured | `a1` | | `isolated` | platform admin (active org A) | `a1` | `a1 b1 d1` | | `isolated` | platform admin, no active organization | not measured | `a1 b1 d1` | | `isolated` | organization admin, no active organization | not measured | none | | `single` (one organization) | organization admin, both variants | `a1 d1` | `a1 d1` | | `group` (member of A and B, active A) | organization admin | `a1 b1` | `a1` | | `group` | platform admin | `a1 b1` | `a1 b1 d1` | | `single` holding two organizations | organization admin, viewer, platform admin | `a1 d1` | `a1 b1 d1` | | `isolated` | organization admin of A, reading a global settings change (`g1`, no `tenant_id`) and a tenant-scope change about A (`t1`) | not measured | `t1` | | `isolated` | platform admin, the same two rows | not measured | `g1 t1` | The `group` and two-organization `single` rows are readings, not pins. Under `group` the policy scopes to the ACTIVE organization, not the membership union; the seat accepted that as the fail-closed direction. A `single` deployment that holds two organizations boots today, reported at boot. Before this change, the SQL driver's native organization arm still narrowed the ledger there; with no column it cannot, and the policy is stripped under `single`. The seat ruled this A (ADR-0131 D8, §1.2(3)), and the changeset states it with its remedy, a walled posture. ## Pins (refused and still-accepted case each) - **Organization scope** (`plugin-security/src/sys-audit-log-row-scope.test.ts`): - an organization admin of A reads `a1` and not `b1` or `d1`; - CONTROL: the same read with the policy removed from every set serves all three; - the explicit entry is load-bearing: without it, the bypass reads all three; - a viewer is scoped the same way; - a global settings change's row (`config_change` on `sys_platform_setting`, no `tenant_id`) is not served to an organization admin, and a tenant-scope change about its organization is. The platform admin reads both. - **The settings writer** (`service-settings/src/config-change-audit.test.ts`): - the `objectstack-ai#8145 … WIRES the generic sink` case on a global manifest, written by a session with `org_1` active, asserts `tenant_id` null and no `organization_id`; - CONTROL: a tenant-scope write keeps `org_1`. - **Platform scope**: a platform admin reads all three, the deployment-level row included. CONTROL: before the change, the wall hid `d1` (and `b1`) from the platform admin. - **`single`**: the policy is a platform tenant policy by provenance, and both organization-admin variants read exactly what the pre-change read served. - **The write** (`plugin-audit/src/objects/sys-audit-log-attribution.test.ts`, a real kernel with SQLite): - a deployment-level row with no `tenant_id` is written with no refusal; - a write still naming `organization_id` is refused `INVALID_FIELD` / 400; - a filter on it is refused `INVALID_FILTER` / 400; - the record mirror stamps the record's organization into `tenant_id` and writes no `organization_id`. - **Retention** (`objectql/src/lifecycle/lifecycle-service.attribution-partition.test.ts`, a real `ObjectQL` registry): - the reaper and the archiver partition a tenant's override on `tenant_id`, and the global pass keeps the NULL rows; - no `INVALID_FILTER`, so a tenant's override no longer stops the table's reap; - CONTROLS: the ledger without the field and another column-less object carrying a `tenant_id` both run one global pass; the ledger with the injected column partitions on `organization_id`. - **DDL**: the injection plan carries no `organization_id`. The provisioned SQLite table (introspected after a real schema sync) has `tenant_id` and no `organization_id`. CONTROL: an ordinary object on the same sync has the column. ## Reverse verification (one-off, `scripts/ablation-replace.mjs`, each restore read blob == HEAD with `git diff HEAD` empty) - **A.** Delete the `sys_audit_log_org` policy literal (anchor 1 → 0). Exactly four red: the organization-admin pin, the viewer pin, the provenance pin and the `member_default` roster pin. 28 green, the controls and the platform-admin pins included. - **B.** Delete `organization_admin`'s explicit ledger entry. Exactly the organization-admin pin red; 8 green, the viewer pin included. - **C.** Delete the `ATTRIBUTION_PARTITION_COLUMNS` row. Exactly the reap and archive pins red; 4 green. - **D.** Make the settings writer stamp `tenant_id: entry.tenantId ?? null` for every scope again. Exactly the global `WIRES` case red; 16 green, the tenant-scope control included. The subjects are imported by relative source path, so no `dist` is in the path. ## Fate for C7's inventory (objectstack-ai#15211, ADR-0131 D10 fate 1) `sys_audit_log.organization_id`: **drop the column, once its values are confirmed in `tenant_id`; report the rows where they differ.** By the writer census, every writer that stamped the column stamped the same value into `tenant_id` (or NULL into both). The check is NULL-safe: a row differs when exactly one of the two is NULL, or both are set and unequal. A zero count means `os migrate apply --allow-destructive` drops the orphan the boot drift report already names. Any other count is listed with the row ids, never guessed and never dropped. Until then, schema sync is additive and the column stays as an orphan that nothing reads or writes. ## Files outside the claim's file surface - `packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts`. Its arming control read the ledger's `organization_id`. It is re-keyed to `tenant_id`, its prose names the row scope, and one test title says "superuser read bypass" for "wall bypass". With it, all 13 ledger dogfood files pass. - In the patch round, beyond the files `6058257824` names, two changes the named edits require: - `service-settings/src/settings-service.types.ts`: the `SettingsAuditSink.tenantId` TSDoc restated the removed stamp; - `plugin-security/src/bootstrap-platform-admin.ts`: the one caller passing the removed `declaresOrganizationId` input. - Within the claimed packages but not named in the claim: plugin-audit's three writer test files, translation bundles and README; plugin-security's `rbac-objects.test.ts` roster pin; objectql's federated reader census (`federated-injected-column-readers.test.ts`), whose `#reap` / `#archiveObject` rows go because those passes now name only the column `tenantWindowsFor` returns. ## Verification (at `b865914be5`) The patch round touched `service-settings` and `plugin-security` (source and tests), the changeset, and no objectql, spec or plugin-audit source. The objectql and spec runs were taken in round one, at `ef87292b75`, whose files they read are unchanged since. - service-settings: 38 files / 642 passed. plugin-security: 179 files / 3749 passed / 45 skipped. plugin-audit: 41 files / 649 passed. objectql (round one): 382 files / 7531 passed. - spec: `--project local`, 625 files / 18661 passed; `--project repo` `step18-rationale-merge` + `conversions-major18-merge`, 21 passed; `check:generated` 15 of 15 up to date; `check:api-surface` unchanged. - dogfood: the 13 files that touch the ledger, 93 passed, re-run at this head. - Typecheck exit 0 for service-settings and plugin-security at this head; for plugin-audit, objectql, spec and dogfood in round one. Test layers are included. - Gates: `dispatch-gates --commands` (no paths) derives 104 families at this head. All 104 ran with their exit codes recorded, and the `--ran` reconciliation reads a derived zero NOT-MEASURED. - Lint, a proven narrowing: `eslint --no-inline-config --format json` over the 31 changed `.ts` files gives 31 results, 0 errors, 0 warnings. The population is read from `eslint.config.mjs`'s `packages/**` and `**/*` globs. That config enables no type-aware linting, so an untouched file's verdict cannot move. The full `pnpm lint` is CI's. ## Acceptance notes - Out of reach of the row policy: an application set that grants the superuser read bypass on the ledger (`viewAllRecords` on it or on a wildcard) skips Layer 1, as it does on every object the wall does not cover. No example app ships such a grant. - The organization row scope is the active organization's under `group`, and stripped under `single`. Both are ruled; the changeset states each, with the remedy for a multi-organization `single` deployment. --- _Generated by [Claude Code](https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…form-global gets no organization column on that deployment — the objectstack-ai#12699 declaration made total (ADR-0131 D7) (objectstack-ai#22331) Fixes objectstack-ai#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 objectstack-ai#22107, objectstack-ai#22266 and objectstack-ai#22166), so line 1 closes it. ## Scope: item (4), the objectstack-ai#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 "objectstack-ai#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 (objectstack-ai#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 (objectstack-ai#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](https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #15207
Clause-②: no (narrowing: deployment-level objects lose their injected organization column and the global settings rung moves; whether any
@objectstack/specexport widens is measured on the built declaration closure by the dev, and the measurement decides)The line above is the claim's, copied verbatim. This PR moves no settings rung (scope item 3 is not here). The measurement it names:
check:api-surfaceis green on the built spec, so no@objectstack/specexport widens.Scope and the two decisions
This PR lands scope item (1) of #15207, plus the one
objectqledit item (1) needs so that it ships no regression. The seat's claim revision on the card (comment6043540291) narrows this claim's landing to item (1) and records two decisions on the first round's dev report (comment6042515710):packages/objectql/src/lifecycle/lifecycle-service.ts,tenantWindowsForonly, joins the file surface. The edit is in this PR (section below), so the lifecycle regression the first round measured is closed here, and nothing outside the PR remains before it can be readied. Refusing such an override at save is not decided, and it is not built.requiredPermissions: ['manage_platform_settings']. ADR-0131 D7 says objects without the column "are governed by object permission, not by the wall", and the security table below shows what happens without the gate.Items (2), (3) and (4) are not built under this claim, so this PR says
Part ofand the card stays open for them.What this PR does (scope item 1)
sys_job,sys_job_run,sys_job_queue,sys_flow_dispatch,sys_migration,sys_migration_journalandsys_presencenow declaresystemFields: { tenant: false }, so the registry injects noorganization_idon them (ADR-0131 D7). Each also declaresrequiredPermissions: ['manage_platform_settings']; the security table below is why that is part of the same change.18.sys-*-organization-column-retired.ts), as the card requires, plus one step-18 rationale fragment.systemFields.tenant: false1 → 8.sys_job.global-unique.test.ts: the pin that asserted the injected column is re-premised on its absence, which is the direction its own comment asked to re-check from.sys_secretandsys_automation_run, which are tenant-attributed, keep it).The lifecycle guard (Decision 1)
A tenant-scope
lifecycle.retention_overridesentry gives one organization its own retention window, and the reaper and the archiver apply it by partitioning the object's rows onorganization_id: a pass for that organization's rows, then a global pass whose$orcovers everyone else. On a table with noorganization_idcolumn both passes name a column the table lacks. The first round measured it on the real SQL driver (better-sqlite3): both predicates throwINVALID_FILTER, so on a new database such an override onsys_job_run,sys_job_queueorsys_flow_dispatch(the three of the seven that declare alifecycle) stopped that table's retention.tenantWindowsForis the one decision both passes ask. It already answered no windows for a federated object whoseorganization_idis the registry's unprovisioned injection. It now also answers no windows when the registry provenance oforganization_id(resolveInjectedColumnProvenance,@objectstack/spec/data) is'absent': no injection plan put the column there and the author declared none. The object then runs its one global pass at the global window. No row of such a table belongs to an organization, so a tenant override naming it has nothing to select, and it is not applied. An object that has the column keeps its per-tenant windows.ObjectQL's registry, carrysystemFields: { tenant: false }, have noorganization_idfield, and answer provenance'absent'. The finding(objectql): the cascade scan still probes a federated object on its other injected anchors — deleting a business unit answers 400 INVALID_FILTER on showcase_ext_customer.owning_business_unit_id (the family closing card after #7738 and #21910) #21918 federated shape answers'injected-unprovisioned', not'absent', so the federated line stays and the new line is a second answer to the same question (is there a provisionedorganization_id). A federated object that declaressystemFields: { tenant: false }answers'absent'and is covered by the new line.lifecycle-service.no-tenant-column.test.ts, on a realObjectQLengine and registry; the stub driver provisions each table from the registered object's fields and refuses a filter on a column the table lacks, as the SQL driver does): a premise case shows the driver refuses anorganization_idfilter on the column-less table (INVALID_FILTER, 400); the pin sweeps a column-lesssys_job_runwith one organization's90dtenant override, and gets no error and exactly one read,created_atbefore the30dcutoff; the control sweeps the same declaration without the opt-out and gets the per-tenant read at90dand the global$orread at30d.scripts/ablation-replace.mjs(anchor hit 1 → 0, blob changed): deleting the new line turns exactly the pin red (report.errorsgains the driver'sINVALID_FILTERrefusal forsys_job_run), with the premise and the control green; the federated reader census turns red too, because its row for the new seam no longer finds a use. Restored: blob equals HEAD andgit diff HEADis empty.federated-injected-column-readers.test.ts) scans every non-test source of the package for each use of a provenance seam, and fails on a use without a row. The new call is one, so it gains one row,skips, on the site that already skips; the set of skipping sites is unchanged. The three federated lifecycle pins from finding(objectql): the cascade scan still probes a federated object on its other injected anchors — deleting a business unit answers 400 INVALID_FILTER on showcase_ext_customer.owning_business_unit_id (the family closing card after #7738 and #21910) #21918 stay green.lifecycle-service.ts,loadGovernancereads the global and per-tenantretention_overrides(organization ids fromsys_organization, no read of the swept table),tenantWindowsForis the only reader of the per-tenant map, andreapandarchiveObjectare the only sites that putorganization_idinto a predicate, both built only fromtenantWindowsFor's answer. Governance quotas and growth count rows with no filter; the rotator falls back toreap; the archive's coldkeepprune filters oncreated_atonly; the retention floors compare durations and read no rows. Outsideobjectql, the settings manifest declares the key andservice-queueregisters a floor; neither reads rows. None names the column.Writer census, with a firing control
Read at
e67ba80049, all non-test sources underpackages/. Every write call naming the object (literal or a constant bound to it), its context, and whether the row or options name an organization; a row that is not an inline literal was traced to its type.sys_jobDbJobAdapter(service-job)sys_job_runDbJobAdaptersys_job_queueDbQueueAdapter(service-queue)sys_flow_dispatchObjectStoreFlowDispatchStore(service-automation)sys_migrationDataMigrationFlagSchemahas no organization fieldsys_migration_journalMigrationJournalEventSchemahas no organization fieldsys_presenceapiMethods: ['get', 'list']; presence travels the realtime pathRaw-SQL writes to the seven tables: 0. The same grep shape finds the raw
INSERT INTO sys_packageswrites elsewhere, so it can match.Firing control. The same procedure, run over the three tables the card and triage name as tenant-attributed:
sys_http_delivery: 6 write sites flagged (caller-supplied context).sys_secret: the engine's secret write passes the business write's driver options, so the SQL driver stamps the caller's organization.sys_email: the call-site pass is silent (system context), and the row trace fires: the email service stampsorganization_idinto the row it hands the persistence insert. That is why rows that are not inline literals were traced.sys_secretstays off this PR (triage's correction; its fate is C7's).sys_http_deliveryandsys_emailare excluded by the card.Security: who reads these tables, before and after
Measured by driving the real
SecurityPluginmiddleware with afindonsys_job_queue(a scratch harness, not committed), with the shippedorganization_admin,admin_full_accessandmember_defaultsets:organization_id = org-1: 0 rows, since every row is NULLmanage_platform_settings)The middle row is the reason for the gate (Decision 2). With no column there is no tenant wall, and
organization_admin's'*'grant carries the superuser bits, so Layer 1 is skipped too. D7 governs these tables by object permission, and the gate is that permission, on thesys_sso_providerprecedent. Thesinglerows are a declared narrowing: an organization administrator who is not a platform administrator loses generic reads of these seven tables. No shipped app or nav entry names any of them. Every platform reader and writer uses a system context, which the gate does not apply to.Existing databases
Schema sync only adds. On an existing database each table keeps its physical
organization_idcolumn (and its index where one was provisioned), and the boot drift report names it orphaned: "column exists in the database but not in metadata (orphaned) — os migrate apply --allow-destructive to drop it". It is never dropped automatically, and never in production. By the census the column holds only NULL, so no data step is needed and the drop loses nothing. The v18 ceremony of ADR-0131 D10 (C7) is where a guided drop belongs; this PR invents no migration. The lifecycle guard reads the registered object, not the physical table, so on such a database the orphaned column is simply never named.Acceptance notes
6043540291):sys_audit_log: removing the column opens the no-record rows (config_change,import,platform_admin_standing_change) to every organization's admin, by the same mechanism as the middle row above. The card's "RLS readers filter ontenant_idexplicitly" needs either a platform RLS policy ontenant_idplus an explicitsys_audit_logentry inorganization_adminwithout the superuser bits (plugin-security), or a filter inplugin-auditthat re-derives the wall's posture ladder. With the guard in this PR, a column-lesssys_audit_logwould get the global retention window only; whether that is right for audit is item (2)'s call, not made here.service-settingsmanifests (ai, auth, knowledge, mail, sms, storage) andobjectql'slifecyclemanifest are global and edited at runtime in Setup, so §6 Q3's measurement points to a tenant-lesssys_platform_setting, not to configuration. Existing global rows would need a data step to move; that is the C7 ceremony's.applySystemFields(objectql) has to receive the deployment's declaration, and the stand-down inplugin-securityretires.retention_overridesentry naming a table with no organization column is still accepted at save; it now has no effect instead of stopping retention.sys_job_queue.metadata_json's description says it carriestenant_id; no producer measured writes it there.main, this PR's fragment and the builtin node-config fragment both sat at order 85, and the id tie-break rendered this one between the approval-node fragment and the sentence that continues it ("Then the builtin arm stops being presence-only"). No pin refuses a duplicate order, but the rendered text read wrong, so this fragment moved to 86, the next free order. The rendered rationale now runs approval-node, builtin values, then this fragment.Merge of
mainTwo merges, both through
scripts/pm/os-regen-merge.sh, no rebase and no force-push:b016a64661(main ataa71c4d9d1) and1ef09a5127(main atdd39171835). Neither stopped on a conflict.packages/spec/src/migrations/registry.tstext-merged with the step-18 entries of the flow builtin node-config change, andcheck:migration-registryreads its generated regions current.scripts/platform-object-tenancy-census.jsonkept this branch's bytes (main had not changed it), andcheck-platform-object-tenancy-censusis green at this head..changeset/pre.jsondoes not exist atdd39171835, so the changeset keepsminorwith its BREAKING banner.Tests and gates
Readings at this head,
1ef09a5127, unless a line says otherwise.@objectstack/objectql:vitest --project local379 files, 7516 passed; typecheck (tsc --noEmitand the test-layer check) exit 0. The four lifecycle and census files alone: 128 passed.@objectstack/spec: build exit 0;check:generated15 of 15 up to date; typecheck exit 0;vitest --project local622 files, 18567 passed, 1 todo, and the step-18 ledger merge tests (step18-rationale-merge,conversions-major18-merge, projectrepo) 2 files, 21 passed, both measured atdc24a4e051. The second merge changed no file underpackages/spec(git diff dc24a4e051 1ef09a5127 -- packages/specis empty).@objectstack/platform-objects64 files, 1028 passed;@objectstack/service-automation175 files, 2120 passed;@objectstack/service-realtime5 files, 35 passed. Typecheck exit 0 for each.dispatch-gates --commands, derived with no paths at1ef09a5127: 103 commands. On the first pass 100 exited 0;check-engine-split-ratio --days 90exited 2 (this clone was shallow inside its 90-day window), andcheck:dual-build-cjs-loadsandcheck:i18nexited 3 (prerequisite: built output). After deepening history to 2026-07-02 and a full workspace build, all three re-ran with exit 0. Reconciled with--ran: "103 derived famil(ies) accounted for — 103 run, 0 NOT-MEASURED (a DERIVED zero — all 103 recorded an exit code and none of them is 3)". Among them:check-adr-0087-registration("1 declared-breaking changeset(s), each carrying an ADR-0087 disposition", the seven ids registered),check-changeset-no-major("This diff introduces nomajorbump"),check-platform-object-tenancy-census(83 objects, 50 in reach, 33 outside) andcheck:nul-bytesOK.check-governed-merges --pr 22107after the push: 0 of 25 paths hit the governed register.eslint --no-inline-config --format jsonover the 22 changed.tsfiles: 22 files in the report, 0 errors, 0 warnings. All 22 are inside the config'spackages/**population, andeslint.config.mjsenables no type-aware linting (noparserOptions.project, no typed rules), so this diff cannot move a verdict on an untouched file. The fullpnpm lintis CI's.dd39171835, under the 5,000-line human-merge threshold. The first round's 1022 grew by the lifecycle guard, its pin and census row, and the objectql changeset.Changesets
.changeset/15207-deployment-plumbing-no-organization-column.md:minorwith the BREAKING banner and the ADR-0087 marker registering the seven ids (pre mode is not on atdd39171835)..changeset/15207-lifecycle-no-tenant-column-no-partition.md:@objectstack/objectqlpatch, the lifecycle guard.This body was revised in patch round 1 by session
session_01GV6oYwgc1kWiUCb1YaprQ7, from the dispatch of thedomain:specseat 2 PM.