Repository navigation
feat(plugin-audit,plugin-security)!: sys_audit_log loses its injected organization column; tenant_id carries the organization a row is about and scopes organization readers (ADR-0131 D7) - #22266
Conversation
…its injected organization column; tenant_id row scope (ADR-0131 D7) Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
…cle pins 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>
…dit-log-attribution
…1 D7) Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
…udit_log_org Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 5 package(s): 21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 146 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 d77e677886a3ceb76c3308d2c584fa315d22e5d2 && git checkout d77e677886a3ceb76c3308d2c584fa315d22e5d2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c8bb3c8d9cdae41c51b72f1cb3cb5028988e1fe4 b865914be57d0acb374da697ded50ab286331e8b && git checkout -B drift-repro c8bb3c8d9cdae41c51b72f1cb3cb5028988e1fe4 && git merge --no-ff b865914be57d0acb374da697ded50ab286331e8b
node scripts/docs-audit/affected-docs.mjs --json c8bb3c8d9cdae41c51b72f1cb3cb5028988e1fe4
|
…ger row is about no organization; inert organization_id stamps removed (ADR-0131 D7) Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
…he global config_change pin Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Read-only review of PR #22266 (card #15207, ADR-0131 C6 item (2)) at the head above, merge base Check-runs on the head (read twice, by API): 42 runs, 37 ① Derived judgments(a) No reader gains a row it should not see under a wall — HOLDS, with two named residuals. The shipped roster at the head is eight sets:
So no shipped set reads the ledger unfiltered under a wall except (b) Platform administrators read every row, deployment-level rows included — HOLDS, and the path is the superuser read bypass alone. (c) The (d) The (e) The settings writer — true of the diff and pinned in both directions. (f) D14 — no fallback read or write of the retired column; the C7 fate text does not over-claim. Every ledger (g) Retention — honoured only where declared; a tenant override no longer stops the reap. ② Semver level
③ Boundary flagsFrom
From Additional notes from this read, none blocking: Implemented-by: VERDICT: PASS Generated by Claude Code |
…, and the data API read of the settings stores applies each namespace's readPermission (objectstack-ai#22295) Fixes objectstack-ai#22261 Clause-②: no (narrowing) Executes option A as ruled on the card. This body stays at the level of classes, positions and functions, as the card asks; the detailed measurement went to the dispatching seat privately. ## What changes ### `SettingsService` (`packages/services/service-settings/src/settings-service.ts`) The service reads and writes `sys_setting` under its own system context, and that context names no organization. So no driver tenant scope and no organization wall reaches those calls. The organization now travels in the service's own query and row identity. - **Row identity.** `rowIdentity` keys a `tenant` or `user` row by the identity `sys_setting` declares, organization included. `setMany` writes every such row with the caller's organization (`SettingsContext.tenantId`). A `global` row is unchanged: it lives in `sys_platform_setting` and has no organization column. - **Reads.** `loadScopedRows` filters explicitly, in the query itself, by the caller's organization plus rows stored with no organization. The in-memory store applies the same reach (`OrganizationReach`). The global rung (`loadGlobalRows`) is unchanged. - **Cascade.** `preferredRow` makes the tenant and user rungs take the caller organization's own row ahead of an organization-less one. The lock pre-flight in `setMany` reads the same row, so a lock applies only where the cascade reads. - **Refusal.** Under a walled posture (`group` / `isolated`), a tenant-scope write that names no organization is refused whole, before anything is written. It reuses the vocabulary the ownerless user-key refusal already has: `SettingsValidationError`, `code: SETTINGS_VALIDATION`, HTTP 400 at the settings routes, one field entry per key with `code: invalid_value` and `constraint: { scope: 'tenant' }`. A reset is refused the same way. - **Posture.** The posture comes from the `tenancy` service. The plugin passes it through `bindEngine` (`tenancyPosture`), read the same way its HTTP door reads it. The service only asks when the caller names no organization. ### The generic read door (`settings-read-door.ts`, registered by `SettingsServicePlugin`) - An engine middleware registered by object name on `sys_setting`, `sys_setting_audit` and `sys_platform_setting`. It ANDs a `namespace` predicate into every non-system read (`find`, `findOne`, `count`, `aggregate`). It is a filter, not a pass over the result, so counts, aggregates and pages see exactly the rows a list returns. - The predicate is `SettingsService.namespaceReadScope`. It uses the same `requiredCapability` table the settings door enforces. A namespace with no registered manifest reads at the default capability, `setup.access`. - **Seam (H4).** The seam sits inside `service-settings`. No file in `objectql`, `runtime` or `plugin-security` is edited. ### Census page `content/docs/permissions/system-context.mdx` gains row 18b for the middleware's `isSystem` read. `check:system-context-census` requires a row for every elevation read site. The counts were regenerated with `pnpm gen:system-context-census`. ## Posture `single` The default organization keeps the answers it had. These are pinned in `settings-organization-isolation.pin.test.ts`: - a value stored before rows carried an organization is still read by the default organization; - the default organization's new write is read by itself and by a process-wide reader that names no organization; - a reset reads back the default for both; - an organization-less tenant-scope write is not refused under `single`, nor where no posture is reported. ## Existing rows (H3) No stored row is rewritten, as the dispatch fences. What stored rows carry today, and the decision that follows from it, went to the seat privately. ## Tests Every run in this table is on HEAD `61911cf17a`, after `service-settings` was rebuilt and its `dist/` was proven to carry the HEAD source. | Suite | Result | |:--|:--| | `pnpm --filter @objectstack/service-settings exec vitest run --maxWorkers=2` | 41 files, 752 passed | | `settings-organization-isolation.pin.test.ts` (part of the suite above) | 18 passed | | `settings-read-door.pin.test.ts` (part of the suite above) | 27 passed | | `test/settings-organization-isolation.dogfood.test.ts`, real stack over HTTP, two organizations under a non-degraded `isolated` posture | 6 passed | | `single`-posture HTTP regression: the existing dogfood files that write and read settings (`settings-config-change-audit`, `analytics-timezone`, `audit-log-parent-read-gate`) | 3 files, 14 passed | | `pnpm --filter @objectstack/service-settings typecheck` | exit 0 | | `pnpm --filter @objectstack/dogfood typecheck` | exit 0 | ### Over HTTP, with a positive control per refusal - Each organization sets and reads its own tenant-scope value. One organization's write and its reset leave the other's value unchanged. A `global` value is read by both. - An organization-less tenant-scope write under the walled posture answers `400` with `error.code` `SETTINGS_VALIDATION` and a field entry `invalid_value`, and writes nothing. Control: the identical write from inside an organization answers 200. - The data-API read of `sys_setting` hides a namespace's row from a principal lacking that namespace's `readPermission`: the list returns 0 rows and the by-id read answers 404. Control 1: the same principal reads its own row of a namespace whose capability it holds. Control 2: a holder of the withheld capability reads the withheld row (200). ### Ablations Each ablation started from a committed fix, restored from `HEAD` under a trap, and was proven restored by blob hash and an empty `git diff HEAD`. All ablations ran at `e1407dc55f`, except the two dogfood rows, which ran at `672e54e08e`. | Mutation | Result | |:--|:--| | `settings-service.ts` set to the base commit | isolation pin 11 red, 5 green. The 5 that stay green are the regression guards: the global row, and the three `single` cases plus its no-refusal control. | | `namespaceReadScope` answers no predicate | read-door pin 19 red, 8 green. The 8 green: system context, registration by name, writes untouched, refusal of a read with no query. | | `preferredRow` made positional | isolation pin 2 red (both preference cases) | | Dogfood, service and plugin set to the base commit, package rebuilt, `ablation-dist-preflight --absent` passed | 4 of 6 red. Green: the guard, and the global row read by both. Restored, rebuilt, marker present again: 6 of 6 green. | | Dogfood, only the door predicate disabled, plant proven in `dist/` | only the read-door case red (1 of 6). Restored, rebuilt, `--absent` passed, tree clean. | ### Gates - **Derived set.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 94 commands at HEAD `61911cf17a`. All 94 were run and exit 0, plus `check:settings-bind-window` as dispatched. - **Reconciliation.** `--ran` reports 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN. - **Fixed on the way.** `check:system-context-census` went red on the first pass for the new read site. It is green after the row-18b edit. - **Lint, narrowed.** The 9 changed `.ts` files lint with 0 errors and 0 warnings at `61911cf17a` (`eslint --no-inline-config --format json`). The repository runs no type-aware lint, so this change cannot move an untouched file's lint verdict. The full lint run is CI's. - **Base.** The branch sits on `c8bb3c8d`. `origin/main` gained 4 commits since, and none of them touches `service-settings`. CI on the merge ref is the arbiter. ## Acceptance notes - **Changeset**: one `@objectstack/service-settings` `minor` changeset, declared breaking, with ADR-0087 disposition `not-required (no-migration-prescription)`. `check-adr-0087-registration` and `check-changeset-no-major` are green. - **Fence**: - `settings-service.types.ts` changes only `SettingsRow` (the `organization_id` row-shape field). PR objectstack-ai#22266 edits a different region of it. - `settings-service-plugin.ts` is touched for wiring only: the posture source and the read-door registration. - Two existing test fixtures were triaged. `settings-getmany.test.ts` rows now spell `organization_id: null` the way a real driver returns it. `settings-routes.test.ts` passes the reach its private `loadRows` call now takes. - **Out of scope, not changed here**: - The organization attribution of the settings-specific audit trail rows belongs to objectstack-ai#15207's family (audit ledgers); that card remains open. - **For later**: `sys-setting.object.ts` (platform-objects) quotes a `loadRows` comment sentence this change retires. The quote is prose only; no gate reads it. > Seat's append (`domain:services` seat 1, objectstack-ai#6021), carried verbatim from the dev's round-2 report `6060893787`; the dev never edits a PR body. ## Round 2 - **Merge.** `origin/main` `d1dbe70ebd` is merged with a merge commit (`dcbf66b41a`), with no rebase and no force-push. The only conflict was the derived counts on `content/docs/permissions/system-context.mdx`. Both rows are kept, 18b from this branch and 23d from main, and the counts were regenerated with `pnpm gen:system-context-census`. `check:system-context-census` is green: 118 elevation read sites in 20 packages across 55 files. - **Re-verified at `dcbf66b41a`.** All results below follow a rebuild of what the merge touched. | Check | Result | |:--|:--| | `service-settings` suite | 41 files, 752 passed | | `settings-organization-isolation.dogfood.test.ts` | 6 passed | | single-posture settings dogfood files | 3 files, 14 passed | | typecheck of `service-settings` and `dogfood` | exit 0 | - **Gates.** `dispatch-gates --commands`, run with no paths, derives 94 commands at `dcbf66b41a` with no stale-tree warning. All 94 were run, plus `check:settings-bind-window`. `--ran` reports 94 derived, 94 run, 0 NOT-MEASURED and 0 UNRUN. Two gates first refused with exit 3 on unbuilt packages; they were re-run after those packages were built and exit 0. - **Measurement.** Under the isolated posture, with the real tenant wall in the composition, a non-system read of `sys_setting` or `sys_setting_audit` by one organization's administrator does not return another organization's organization-stamped row. The harness is a temporary test, removed after the run. --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- 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)
The claim (
6055795594) declared the valueyeswith the narrowing arm and asked the dev to measure the built declaration closure. Measured:check:api-surfaceon the rebuilt@objectstack/specreports 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 refusesorganization_id, andorganization_admin's ledger grant loses its superuser bits). So the value isno, 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 #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 #22107 and item (3) as #22166. No stored row moves here (ADR-0131 D14): the column's fate is C7's (#15211), below.Patch round (seat ruling
6058257824, which widens the claim's file surface):single-posture consequence;config_changerow carries notenant_id;organization_idstamps are removed inplatform-admin-standing-audit.tsandconfig-change-audit.ts, and the predating comments are corrected, includingmanaged-object-write-denies.ts' docblock.The open question on a
singledeployment 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) declaressystemFields: { tenant: false }. The registry injects noorganization_id, and a new table is provisioned without it.tenant_id(lookup tosys_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.audit-writers.ts,read-audit.ts,auth-event-audit.ts) keep stampingtenant_idas before and drop their conditionalorganization_idstamp, which the registered schema can no longer satisfy. No fallback read or write of the retired column remains (D14).service-settings/src/config-change-audit.ts): a GLOBAL-scope change is a deployment-level action about no organization, so itsconfig_changerow carries notenant_id, whatever organization the writing session has active. Tenant- and user-scope changes keep the writer's organization. Itsorganization_idfield probe and stamp are gone. TheSettingsAuditSink.tenantIdTSDoc insettings-service.types.tssays the same.plugin-security/src/platform-admin-standing-audit.ts): thedeclaresOrganizationIdinput and its stamp are gone, and the call site inbootstrap-platform-admin.tswith them.tenant_idstays 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_adminnames it explicitly, without them.plugin-security/src/objects/default-permission-sets.ts):sys_audit_log_org:tenant_id == current_user.organization_id, operationselect;organization_admin(and so its derived no-bypass variant),viewer_readonlyandmember_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_defaultis the baseline every authenticated human holds, so a ledger read an application set grants is scoped too. This is the placementscimProjectionRowScopealready uses;sys_audit_logentry inorganization_admin: read only, with noviewAllRecords/modifyAllRecords;singleby the existing provenance rule (ADR-0105 D3), with no new code.objectql/src/lifecycle/lifecycle-service.ts).tenantWindowsFornow returns the partition column with the windows. It isorganization_idwhere the object has a provisioned one (unchanged). Otherwise it is the ledger's attribution field, from a one-row, name-keyed tableATTRIBUTION_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.tslists the name only and restates nothing.18.sys-audit-log-organization-column-retired.ts, one step-18 rationale fragment, and the regeneratedregistry.ts.scripts/platform-object-tenancy-census.jsonregenerated by its own tool (in reach 50 → 49, out 34 → 35,systemFields.tenant: false9 → 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-securitybootstrap-platform-admin.tsthroughbuildPlatformAdminStandingRow,tenant_idalways NULL, by ruling;import:plugin-authadmin-import-users.ts, the run-level row of a platform-admin endpoint, notenant_id;create/updateonsys_user:plugin-authadmin-user-endpoints.ts, platform-admin endpoints, notenant_id;config_change:service-settingsconfig-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 notenant_id.P2, holds, measured through the real permission compiler (a real
SecurityPluginover a realObjectQLand SQL driver, with the shipped sets). With the explicit entry removed and the policy kept, an organization admin underisolatedreads every organization's rows. Mechanism:systemFields.tenant === falsemakesmeta.tenancyDisabledtrue, soposturePermitsholds incomputeLayeredRlsFilterand the wildcard's superuser bypass skips Layer 1.P3, holds.
security-plugin.ts#collectRLSPoliciesdrops a policy when!this.orgScopingEnabled && isPlatformTenantPolicy(policy).orgScopingEnabledispostureEnforcesWall(this.tenancyPosture). The provenance set isPLATFORM_TENANT_POLICY_KEYSinplatform-tenant-policies.ts, built from the shipped sets' policies whoseusingnamescurrent_user.organization_id. The new policy is in that set (pinned).P4. Writers that stamp
tenant_id:audit-writers.ts): the record's organization, else the session's;read-audit.ts): the record's organization, else the session's;auth-event-audit.ts): the session's organization;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-authadministrative writers. Writers that update existing rows:stored-metadata-body-migration.tsand the CLI'saudit-metadata-bodiesrewriteold_value/new_valueonly. A row with notenant_idmatches no organization under the new policy. Under a wall it is served to platform administrators only; undersingleit is served to every ledger reader.Who reads what (measured; rows
a1about org A,b1about org B,d1about no organization)isolateda1a1isolateda1isolateda1a1 b1 d1isolateda1 b1 d1isolatedsingle(one organization)a1 d1a1 d1group(member of A and B, active A)a1 b1a1groupa1 b1a1 b1 d1singleholding two organizationsa1 d1a1 b1 d1isolatedg1, notenant_id) and a tenant-scope change about A (t1)t1isolatedg1 t1The
groupand two-organizationsinglerows are readings, not pins. Undergroupthe policy scopes to the ACTIVE organization, not the membership union; the seat accepted that as the fail-closed direction. Asingledeployment 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 undersingle. 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)
plugin-security/src/sys-audit-log-row-scope.test.ts):a1and notb1ord1;config_changeonsys_platform_setting, notenant_id) is not served to an organization admin, and a tenant-scope change about its organization is. The platform admin reads both.service-settings/src/config-change-audit.test.ts):#8145 … WIRES the generic sinkcase on a global manifest, written by a session withorg_1active, assertstenant_idnull and noorganization_id;org_1.d1(andb1) 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.plugin-audit/src/objects/sys-audit-log-attribution.test.ts, a real kernel with SQLite):tenant_idis written with no refusal;organization_idis refusedINVALID_FIELD/ 400;INVALID_FILTER/ 400;tenant_idand writes noorganization_id.objectql/src/lifecycle/lifecycle-service.attribution-partition.test.ts, a realObjectQLregistry):tenant_id, and the global pass keeps the NULL rows;INVALID_FILTER, so a tenant's override no longer stops the table's reap;tenant_idboth run one global pass; the ledger with the injected column partitions onorganization_id.organization_id. The provisioned SQLite table (introspected after a real schema sync) hastenant_idand noorganization_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 withgit diff HEADempty)sys_audit_log_orgpolicy literal (anchor 1 → 0). Exactly four red: the organization-admin pin, the viewer pin, the provenance pin and themember_defaultroster pin. 28 green, the controls and the platform-admin pins included.organization_admin's explicit ledger entry. Exactly the organization-admin pin red; 8 green, the viewer pin included.ATTRIBUTION_PARTITION_COLUMNSrow. Exactly the reap and archive pins red; 4 green.tenant_id: entry.tenantId ?? nullfor every scope again. Exactly the globalWIREScase red; 16 green, the tenant-scope control included.The subjects are imported by relative source path, so no
distis in the path.Fate for C7's inventory (#15211, ADR-0131 D10 fate 1)
sys_audit_log.organization_id: drop the column, once its values are confirmed intenant_id; report the rows where they differ. By the writer census, every writer that stamped the column stamped the same value intotenant_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 meansos migrate apply --allow-destructivedrops 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'sorganization_id. It is re-keyed totenant_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.6058257824names, two changes the named edits require:service-settings/src/settings-service.types.ts: theSettingsAuditSink.tenantIdTSDoc restated the removed stamp;plugin-security/src/bootstrap-platform-admin.ts: the one caller passing the removeddeclaresOrganizationIdinput.rbac-objects.test.tsroster pin; objectql's federated reader census (federated-injected-column-readers.test.ts), whose#reap/#archiveObjectrows go because those passes now name only the columntenantWindowsForreturns.Verification (at
b865914be5)The patch round touched
service-settingsandplugin-security(source and tests), the changeset, and no objectql, spec or plugin-audit source. The objectql and spec runs were taken in round one, atef87292b75, whose files they read are unchanged since.--project local, 625 files / 18661 passed;--project repostep18-rationale-merge+conversions-major18-merge, 21 passed;check:generated15 of 15 up to date;check:api-surfaceunchanged.dispatch-gates --commands(no paths) derives 104 families at this head. All 104 ran with their exit codes recorded, and the--ranreconciliation reads a derived zero NOT-MEASURED.eslint --no-inline-config --format jsonover the 31 changed.tsfiles gives 31 results, 0 errors, 0 warnings. The population is read fromeslint.config.mjs'spackages/**and**/*globs. That config enables no type-aware linting, so an untouched file's verdict cannot move. The fullpnpm lintis CI's.Acceptance notes
viewAllRecordson 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.group, and stripped undersingle. Both are ruled; the changeset states each, with the remedy for a multi-organizationsingledeployment.Generated by Claude Code