Skip to content

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

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-15207-deployment-level-no-org-column
Oct 7, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-15207-deployment-level-no-org-column

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Part of #15207

Clause-②: no (narrowing: deployment-level objects lose their injected organization column and the global settings rung moves; whether any @objectstack/spec export 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-surface is green on the built spec, so no @objectstack/spec export widens.

Scope and the two decisions

This PR lands scope item (1) of #15207, plus the one objectql edit item (1) needs so that it ships no regression. The seat's claim revision on the card (comment 6043540291) narrows this claim's landing to item (1) and records two decisions on the first round's dev report (comment 6042515710):

  • Decision 1 = A. packages/objectql/src/lifecycle/lifecycle-service.ts, tenantWindowsFor only, 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.
  • Decision 2 = A. The seven objects keep 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 of and 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_journal and sys_presence now declare systemFields: { tenant: false }, so the registry injects no organization_id on them (ADR-0131 D7). Each also declares requiredPermissions: ['manage_platform_settings']; the security table below is why that is part of the same change.

  • One ADR-0087 D3 semantic entry per removed column (18.sys-*-organization-column-retired.ts), as the card requires, plus one step-18 rationale fragment.
  • The platform-object tenancy census artefact regenerated: 57 → 50 objects in reach, systemFields.tenant: false 1 → 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.
  • New pins: the injection plan, the declared opt-out and the capability, per object, each with a control (the same declaration with the opt-out removed gets the column; sys_secret and sys_automation_run, which are tenant-attributed, keep it).

The lifecycle guard (Decision 1)

A tenant-scope lifecycle.retention_overrides entry gives one organization its own retention window, and the reaper and the archiver apply it by partitioning the object's rows on organization_id: a pass for that organization's rows, then a global pass whose $or covers everyone else. On a table with no organization_id column both passes name a column the table lacks. The first round measured it on the real SQL driver (better-sqlite3): both predicates throw INVALID_FILTER, so on a new database such an override on sys_job_run, sys_job_queue or sys_flow_dispatch (the three of the seven that declare a lifecycle) stopped that table's retention.

tenantWindowsFor is the one decision both passes ask. It already answered no windows for a federated object whose organization_id is the registry's unprovisioned injection. It now also answers no windows when the registry provenance of organization_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.

  • Measured on the real registry, at this head: the seven objects, registered through ObjectQL's registry, carry systemFields: { tenant: false }, have no organization_id field, 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 provisioned organization_id). A federated object that declares systemFields: { tenant: false } answers 'absent' and is covered by the new line.
  • Pin and control (lifecycle-service.no-tenant-column.test.ts, on a real ObjectQL engine 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 an organization_id filter on the column-less table (INVALID_FILTER, 400); the pin sweeps a column-less sys_job_run with one organization's 90d tenant override, and gets no error and exactly one read, created_at before the 30d cutoff; the control sweeps the same declaration without the opt-out and gets the per-tenant read at 90d and the global $or read at 30d.
  • Ablation, one-off. Through scripts/ablation-replace.mjs (anchor hit 1 → 0, blob changed): deleting the new line turns exactly the pin red (report.errors gains the driver's INVALID_FILTER refusal for sys_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 and git diff HEAD is empty.
  • The federated reader census (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.
  • Other readers of the override and the window, census at this head: in lifecycle-service.ts, loadGovernance reads the global and per-tenant retention_overrides (organization ids from sys_organization, no read of the swept table), tenantWindowsFor is the only reader of the per-tenant map, and reap and archiveObject are the only sites that put organization_id into a predicate, both built only from tenantWindowsFor's answer. Governance quotas and growth count rows with no filter; the rotator falls back to reap; the archive's cold keep prune filters on created_at only; the retention floors compare durations and read no rows. Outside objectql, the settings manifest declares the key and service-queue registers a floor; neither reads rows. None names the column.

Writer census, with a firing control

Read at e67ba80049, all non-test sources under packages/. 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.

object writers write sites verdict
sys_job DbJobAdapter (service-job) 4 system context; rows name no organization
sys_job_run DbJobAdapter 2 system context; rows name no organization
sys_job_queue DbQueueAdapter (service-queue) 9 system context; rows name no organization
sys_flow_dispatch ObjectStoreFlowDispatchStore (service-automation) 2 system context; key and outcome only
sys_migration migration-flag helpers, the engine's two flag writes, seed-tenancy, membership-backfill and flow-credential receipts 11 in 6 files system context; DataMigrationFlagSchema has no organization field
sys_migration_journal core migration runner 1 system context, or the transaction it opened with one; MigrationJournalEventSchema has no organization field
sys_presence none through ObjectQL 0 apiMethods: ['get', 'list']; presence travels the realtime path

Raw-SQL writes to the seven tables: 0. The same grep shape finds the raw INSERT INTO sys_packages writes 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 stamps organization_id into the row it hands the persistence insert. That is why rows that are not inline literals were traced.

sys_secret stays off this PR (triage's correction; its fate is C7's). sys_http_delivery and sys_email are excluded by the card.

Security: who reads these tables, before and after

Measured by driving the real SecurityPlugin middleware with a find on sys_job_queue (a scratch harness, not committed), with the shipped organization_admin, admin_full_access and member_default sets:

posture shape organization admin platform admin member
isolated before (column injected) admitted, organization_id = org-1: 0 rows, since every row is NULL same: 0 rows 403
isolated column removed, no gate admitted, no filter: every organization's rows admitted, no filter 403
isolated column removed, with the gate (this PR) 403 (missing manage_platform_settings) admitted, no filter 403
single before admitted, no filter admitted, no filter 403
single with the gate (this PR) 403 admitted, no filter 403

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 the sys_sso_provider precedent. The single rows 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_id column (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

  • Scope items not in this PR (not built under this claim, per the revision 6043540291):
    • (2) 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 on tenant_id explicitly" needs either a platform RLS policy on tenant_id plus an explicit sys_audit_log entry in organization_admin without the superuser bits (plugin-security), or a filter in plugin-audit that re-derives the wall's posture ladder. With the guard in this PR, a column-less sys_audit_log would get the global retention window only; whether that is right for audit is item (2)'s call, not made here.
    • (3) the settings global rung: six service-settings manifests (ai, auth, knowledge, mail, sms, storage) and objectql's lifecycle manifest are global and edited at runtime in Setup, so §6 Q3's measurement points to a tenant-less sys_platform_setting, not to configuration. Existing global rows would need a data step to move; that is the C7 ceremony's.
    • (4) feat(spec,security): OrgScopingEntitlement grows platform-global exemption + unbounded-admin suppression, consumed by Layer 0 arming #12699 made total: applySystemFields (objectql) has to receive the deployment's declaration, and the stand-down in plugin-security retires.
  • Not decided, not built: a tenant-scope retention_overrides entry naming a table with no organization column is still accepted at save; it now has no effect instead of stopping retention.
  • Noted only: sys_job_queue.metadata_json's description says it carries tenant_id; no producer measured writes it there.
  • Step-18 rationale order. After the merge of 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 main

Two merges, both through scripts/pm/os-regen-merge.sh, no rebase and no force-push: b016a64661 (main at aa71c4d9d1) and 1ef09a5127 (main at dd39171835). Neither stopped on a conflict. packages/spec/src/migrations/registry.ts text-merged with the step-18 entries of the flow builtin node-config change, and check:migration-registry reads its generated regions current. scripts/platform-object-tenancy-census.json kept this branch's bytes (main had not changed it), and check-platform-object-tenancy-census is green at this head. .changeset/pre.json does not exist at dd39171835, so the changeset keeps minor with its BREAKING banner.

Tests and gates

Readings at this head, 1ef09a5127, unless a line says otherwise.

  • @objectstack/objectql: vitest --project local 379 files, 7516 passed; typecheck (tsc --noEmit and the test-layer check) exit 0. The four lifecycle and census files alone: 128 passed.
  • @objectstack/spec: build exit 0; check:generated 15 of 15 up to date; typecheck exit 0; vitest --project local 622 files, 18567 passed, 1 todo, and the step-18 ledger merge tests (step18-rationale-merge, conversions-major18-merge, project repo) 2 files, 21 passed, both measured at dc24a4e051. The second merge changed no file under packages/spec (git diff dc24a4e051 1ef09a5127 -- packages/spec is empty).
  • @objectstack/platform-objects 64 files, 1028 passed; @objectstack/service-automation 175 files, 2120 passed; @objectstack/service-realtime 5 files, 35 passed. Typecheck exit 0 for each.
  • Derived gate families: dispatch-gates --commands, derived with no paths at 1ef09a5127: 103 commands. On the first pass 100 exited 0; check-engine-split-ratio --days 90 exited 2 (this clone was shallow inside its 90-day window), and check:dual-build-cjs-loads and check:i18n exited 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 no major bump"), check-platform-object-tenancy-census (83 objects, 50 in reach, 33 outside) and check:nul-bytes OK. check-governed-merges --pr 22107 after the push: 0 of 25 paths hit the governed register.
  • Lint, narrowed and proven: eslint --no-inline-config --format json over the 22 changed .ts files: 22 files in the report, 0 errors, 0 warnings. All 22 are inside the config's packages/** population, and eslint.config.mjs enables no type-aware linting (no parserOptions.project, no typed rules), so this diff cannot move a verdict on an untouched file. The full pnpm lint is CI's.
  • Changed lines: 1229 (1195 added, 34 deleted) across 25 files against the merge base 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: minor with the BREAKING banner and the ADR-0087 marker registering the seven ids (pre mode is not on at dd39171835).
  • .changeset/15207-lifecycle-no-tenant-column-no-partition.md: @objectstack/objectql patch, the lifecycle guard.

This body was revised in patch round 1 by session session_01GV6oYwgc1kWiUCb1YaprQ7, from the dispatch of the domain:spec seat 2 PM.

claude added 5 commits October 7, 2026 13:07
…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>
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/objectql, @objectstack/platform-objects, @objectstack/service-automation, @objectstack/service-realtime, @objectstack/spec, touching 14 documentable anchor(s).

29 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json dd39171835831634e87023e900cf9f448e706fb0.

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

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 33 pages)
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 142 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json dd39171835831634e87023e900cf9f448e706fb0 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c7b26b92b67fd563ed69943ed342f0fd463704e5 — the merge of head 1ef09a51270f02b8e541f7b913e811300c6a81bf into base dd39171835831634e87023e900cf9f448e706fb0, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs dd39171835831634e87023e900cf9f448e706fb0 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

claude added 4 commits October 7, 2026 17:49
…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1ef09a51270f02b8e541f7b913e811300c6a81bf
Local-runs: none

Inputs read: card #15207 (body and all six comments, 6018668592 and 6038050559 binding, claim 6038184526 and its revision 6043540291 bounding scope), PR #22107 (body, 25-file list, net diff against the merge base dd39171835), the 42 check-runs on the head (37 success, 5 skipped, 0 failures; combined status success), ADR-0131 D7 and D10 at main, and read-only git show / git grep of the head and of main (a959493cdf) through refs of my own. The three open PRs the brief names (#22084, #22094, #22103) were read by gh api GET for the two cross-PR questions.

① Derived judgments

Scope against the revision 6043540291: right. The diff lands item (1) and nothing else: seven object files gain systemFields: { tenant: false } and requiredPermissions: ['manage_platform_settings']; one objectql edit (tenantWindowsFor, Decision 1 = A); the gate kept (Decision 2 = A). Items (2), (3), (4) are untouched, the PR says Part of #15207, and the "Part-of PR must not also close its card" check is green, so the card stays open as the revision requires.

Membership of each object, by the writer (D7's rule): right.

  • sys_job, sys_job_run, sys_job_queue, sys_flow_dispatch, sys_migration, sys_migration_journal are the six D7 names the ADR itself lists as "operational plumbing whose rows no writer attributes to an organization". The round-1 census (6042515710, writer_census) cites system contexts for every write site; I spot-checked the three sole-writer files at the head: service-job/src/db-job-adapter.ts (11 SYSTEM_CTX uses, one isSystem: true), service-queue/src/db-queue-adapter.ts (16 SYSTEM_CTX), service-automation/src/flow-dispatch-store.ts (7 SYSTEM_CTX, one isSystem: true). The row contracts DataMigrationFlagSchema and MigrationJournalEventSchema carry no organization field.
  • sys_presence is not in D7's list by name but is in the card's and was re-verified by triage ("no ObjectQL writer at all"). At the head no non-test source under service-realtime/src writes it; the only other naming sites in the tree are skip lists (plugin-audit/src/audit-writers.ts SKIP_OBJECTS, metadata-protocol/src/protocol.ts search-index prefix skip). A table nothing writes has no writer attributing rows, so by D7's rule it is deployment-level. Right.
  • The firing control is credible: the same procedure flags sys_http_delivery (6 caller-context sites), sys_secret (driver-options stamp) and sys_email (row trace), the three the card and triage name as tenant data; sys_secret stays off per triage's correction. 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 security-plugin.ts:2331, and the requiredPermissions AND-gate (step 1.5, :2716) runs before the CRUD grant for every principal that holds a permission set. organization_admin (default-permission-sets.ts:391) carries the '*' object grant with viewAllRecords / modifyAllRecords and systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], so it does not hold the gate; admin_full_access spreads the kernel platform-admin list from @objectstack/spec, and manage_platform_settings is scope: 'platform' (capabilities.ts:63) and in PLATFORM_ADMIN_ONLY_CAPABILITIES (security-plugin.ts:202).

  • isolated / group, before: the wall compared a NULL column to the caller's organization, so every non-system reader, platform administrators included, got zero rows (unless the deployment declared the table platform-global). After: a manage_platform_settings holder lists every row; everyone else is refused 403. No non-system reader is newly refused anything it could read before; the platform administrator is newly admitted, which is what D7 means by deployment-level state governed by object permission. Without the gate the organization_admin wildcard would have admitted every organization's admin to every row, so the gate is part of the same change, not a follow-up. Right.
  • single, before: any principal with a read grant on the object, an organization administrator included, could read every row through the generic data door. After: only a manage_platform_settings holder. This is the one real narrowing. It is declared in the changeset, in all seven D3 entries and in the step-18 fragment; no shipped app, nav, view or client package names any of the seven (the only non-code hits are generated translation tables); and every in-tree reader is a system context the gate does not apply to. I accept it as D7's "object permission" on the sys_sso_provider / sys_scim_connection_credential precedent: the tables hold other organizations' job errors, queue payloads and migration traces, and option B (explicit set entries) is bypassable by any app set carrying a '*' grant. Right.
  • sys_presence exposes apiMethods: ['get', 'list'] only; presence itself travels the realtime path, so the gate changes nothing on that path. Right.

Data at rest: right. Schema sync is additive, so an existing database keeps the physical organization_id (and its index where one was provisioned); driver-sql/src/schema-drift.ts:1380 names it "column exists in the database but not in metadata (orphaned)" with the --allow-destructive remedy, and nothing drops it automatically. After this change nothing writes it: SqlDriver.injectTenantOnInsert stamps only resolveTenantField(object), which answers none for the seven; nothing reads it: Layer 0 keys on the registered object's field, the lifecycle guard reads the registry, and the field resolver now answers organization_id as unknown on these objects. The column held only NULL by the census, so the guided drop in C7's v18 ceremony (D10 fate 1) loses nothing, and this PR invents no migration. Right.

The lifecycle guard: right. tenantWindowsFor now returns no windows when resolveInjectedColumnProvenance(obj, 'organization_id') === 'absent'. At the head that function (spec/src/data/injected-system-column-provenance.ts:317) answers 'absent' exactly when the injection plan carries no tenant column and the author declared none, and injectedSystemColumnDefs reads resolveInjectedSystemColumns, which honours systemFields.tenant: false and tenancy.enabled: false. The predicate is the right one because it names the very column the two partition predicates hardcode (reap :1650/:1658, archiveObject :1499/:1504), and those are the only two sites that put organization_id into a filter, both built solely from this function's answer. It is disjoint from the federated line: isFederatedUnprovisionedInjectedColumn requires 'injected-unprovisioned', so the #21918 case keeps its own branch; an author-declared organization_id answers 'author' and keeps its partition; a local default answers 'injected-provisioned' and keeps its windows (the control in the new pin shows the per-tenant 90d read and the global $or read). The two other provenance readers in engine.ts (:15946 relation scan, :16438 cascade walk) iterate an object's declared fields, so a column-less object never reaches them with organization_id; nothing else throws. The pin runs on a real ObjectQL registry with a driver that refuses unknown columns, carries a premise case and a control, and the dev's one-off ablation turned exactly the pin red. One observation, not a flag: an object whose tenancy anchor is a differently named tenantField also answers 'absent' for organization_id; before this PR its partitioned pass would have named organization_id anyway and been refused, so the guard turns an existing refusal into one global pass. Not a regression.

Seven D3 step-18 entries: right. One per column, ids sys-flow-dispatch-, sys-job-, sys-job-queue-, sys-job-run-, sys-migration-journal-, sys-migration-, sys-presence-organization-column-retired, each citing the writer census, the posture change, the gate and the orphaned-column remedy; inserted in sorted position in the generated region of migrations/registry.ts. The claim allowed "conversions/registry.ts or wherever the playbook places a column removal"; the entries' header is right that no authorable spec key moves, so no D2 conversion and no RETIRED_KEYS_BY_MAJOR row exists to pair with, and a D3 semantic entry is the ADR-0087 record the card asks for. The changeset's adr-0087 marker comment registers the same seven ids; Check Changeset and Lint & Repo Gates are green on the head.

Step-18 rationale order 86: right against main; one collision to name. On the merge base and on main today (a959493cdf, which has moved past the merge base by 18.cbp-master-detail-required-lint-error.ts and four changesets, none adding a fragment above 85) the highest STEP18_RATIONALE order is 85 (flow-builtin-node-config-values-refused), so 86 is the next free order and the move from 85 restores the approval-node / builtin-values continuity the dev describes. Open PRs: #22094 holds manifest-permissions-string-list-retired at 85 on a base that predates main's 85, so it will duplicate 85 at its own merge, not this PR's concern; #22103 holds declared-index-bare-unique-true-retired at 86 on the same stale base, so whichever of #22103 and this PR lands second renders a duplicate 86. main already carries seven duplicate orders (56, 60, 62, 66, 67, 74, 77) and no gate refuses one; the id tie-break puts declared-index-... before deployment-plumbing-..., and both open a new topic, so no sentence is split. The second lander owns the re-check; nothing is owed here.

Files outside the claim's listed surface: all owed by this change. objectql/src/federated-injected-column-readers.test.ts (one skips row for the new seam; the census fails on any provenance use without a row, so it is forced, and skips is the right disposition since the guard returns before a partition is built); objectql/src/lifecycle/lifecycle-service.no-tenant-column.test.ts (Decision 1's pin); platform-objects/src/audit/sys-job.global-unique.test.ts (its pin asserted the injected column and had to be re-premised, in the direction its own comment asked for); scripts/platform-object-tenancy-census.json (the gate artefact, 57 to 50 in reach, opt-outs 1 to 8, with check-platform-object-tenancy-census green); migrations/registry.ts generated regions plus the fragment; the three new object-package pins. Nothing in the 25 files is unexplained.

Public surface. No @objectstack/spec export is added, removed or widened; the spec change is additive data in the migrations registry. The object packages narrow (a column leaves seven objects; reads gain a capability gate), which is what the ! and the BREAKING banner say.

② Semver level

  • .changeset/15207-deployment-plumbing-no-organization-column.md: @objectstack/platform-objects, @objectstack/service-automation, @objectstack/service-realtime, @objectstack/spec at minor, with the BREAKING banner and the ADR-0087 disposition. .changeset/pre.json is absent at the merge base, at the head and at main a959493cdf (verified by git cat-file), so pre mode is not in, and triage's release-state note (6038050559) plus the claim grade the change exactly this way. check-changeset-no-major is what the launch-window convention enforces, and it is green. Right.
  • .changeset/15207-lifecycle-no-tenant-column-no-partition.md: @objectstack/objectql patch. The guard is a behaviour fix in a published package with no surface change. Right; a second changeset rather than a widened first one is the right shape because the package and the grade differ.
  • If this lands before chore(release): enter Changesets pre mode (next) with one major marker, so v18 opens at 18.0.0-next.0 #22084 (pre mode): the sentence above is the whole story, minor plus BREAKING is the only grade the gate admits, and nothing is owed. If chore(release): enter Changesets pre mode (next) with one major marker, so v18 opens at 18.0.0-next.0 #22084 lands first: pre mode is in with the fixed group already taken to 18 by its one major marker, so the version outcome is identical either way, but the convention's letter ("major once it is") then applies to this PR's BREAKING changeset, and the queue lander owes a one-line re-grade of the four minor entries in the first changeset to major before merge; the objectql patch is unaffected.
  • Clause-②: no (narrowing: ...), copied verbatim from the claim into the PR body, and Clause-②: no (narrowing) in the changeset. Matches the diff: nothing widens on @objectstack/spec; the narrowing is the object packages'.

③ Boundary flags

Round 1 report 6042515710:

  • open_questions[0] (claim expansion to tenantWindowsFor): answered, Decision 1 = A in 6043540291; built at the head with pin, control and census row. Closed.
  • open_questions[1] (is the capability gate D7's "object permission", given the single narrowing): answered, Decision 2 = A; the revision hands the single narrowing to this review, and ① above accepts it as declared and right. Closed.
  • open_questions[2] (sys_audit_log), [3] (settings global rung), [4] (feat(spec,security): OrgScopingEntitlement grows platform-global exemption + unbounded-admin suppression, consumed by Layer 0 arming #12699 made total): escalated by the revision to the next claim on this card; the card stays open with the dev's four-axis readings recorded. Not this PR's.
  • out_of_scope_findings: a tenant-scope retention_overrides entry naming a column-less table is accepted at save (Decision 1 keeps it a note; after this PR it has no effect instead of stopping retention, so the note is now harmless); sys_job_queue.metadata_json's description claims tenant_id with no measured producer (acceptance note only). Both stay notes.
  • deviations: AGENTS.md trailer and footer forms (repository rule, not a defect); main not merged (resolved in the patch round); /cli-ids.log written at the container root outside the repo (the user's to delete; not in the diff); scratch probes removed. Nothing in the diff.

Patch round report 6044947704:

  • deviations: the forced census row (right, see ①); fragment order 85 to 86 (right, see ①); main merged twice through os-regen-merge.sh, no rebase, no force-push, both merges conflict-free and the spec tree unchanged by the second (check:migration-registry and check:generated current); a second changeset (right, see ②); PR body without a platform footer (the PM's platform-readings.md reading, not a contract matter); two mis-launched suites terminated and re-run with no reading taken from them; history deepened in the local clone for the split-ratio gate; /build1.pid at the container root (the user's); scratch not committed. Nothing in the diff.
  • open_questions: none.
  • out_of_scope_findings: the two notes above, plus the platform-readings.md footer discrepancy carried to the PM. None owed here.
  • not_measured: the full spec vitest --project repo locally; CI's Test Core shards and the workspace type-check lanes are green on the head, which is the gate verdict.

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: claude/issue-15207-deployment-level-no-org-column
Reviewed-by: session_01GV6oYwgc1kWiUCb1YaprQ7

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

✅ ACCEPT: PR #22107 at 1ef09a5127 (scope item 1 of #15207: seven deployment-level objects lose the injected organization column and are governed by object permission; the lifecycle tenant partition reads the column's absence). It lands through the queue now; Part of #15207 leaves the card open

domain:spec seat 2 · os-warren · session session_01GV6oYwgc1kWiUCb1YaprQ7 · 2026-10-07T19:22Z · holder of claim 6038184526 (narrowed to item 1 and with Decisions 1 and 2 in 6043540291); the review of record for the reports 6042515710 and 6044947704 (patch round 1).

Checklist (read on GitHub and on origin/main, not from the reports):

  • Form: draft, base main, first line Part of #15207, and no closing keyword in the body. Clause-②: no (narrowing: …) starts a line of the body (the claim's line, verbatim), and the changesets carry Clause-②: no (narrowing). PR assignee os-warren. No model identifier in the commits.
  • Scope: 25 files, inside the revised claim:
    • the seven object files and their pins (platform-objects, service-automation, service-realtime);
    • objectql's lifecycle-service.ts (tenantWindowsFor), its new pin, and one census row the federated reader census requires;
    • seven D3 step-18 entries, the generated migrations/registry.ts and the tenancy census JSON;
    • two changesets.
      The cross-lane declarations are 6038330914 and 6043548909 ([PM seat] domain:engine — ⏳ vacant #6367) and 6038321237 ([PM seat] domain:services · seat 2 — 🟢 os-elon-musk #21118). Items (2), (3) and (4) are not here.
  • What the diff does, as graded:
  • Contract review: PASS at CONTRACT_REVIEW_TIER, record 6045154038, on this head (Local-runs: none, identity pair present).

Checks on 1ef09a5127: 37 success and 5 skipped. check-expected-skips reads all 5 as on the roster (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in)). check-governed-merges --pr 22107: not governed, 1,229 changed lines. git merge-tree onto origin/main a959493cdf: clean, and .changeset/pre.json is absent there.

Landing condition the record names: if PR #22084 (pre mode) merges first, the four minor entries in .changeset/15207-deployment-plumbing-no-organization-column.md are re-graded major before this PR merges. The objectql patch is unaffected.

Acceptance notes (not filed):

  • carrier: none · A tenant-scope lifecycle.retention_overrides entry naming a column-less table is accepted at save and, after this PR, has no effect. Decision 1 keeps refusing it at save out of scope.
  • carrier: none · sys_job_queue.metadata_json's description says it carries tenant_id; no producer measured writes it there.

After the merge: the seat checks the squash, removes pm:dispatched, and releases #15207 to pm:queue with items (2), (3) and (4) and the report's questions for them, as the revision 6043540291 records.

Landing: the relay's pr_ready + automerge_enable follows this comment.


Generated by Claude Code

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
… 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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants