Skip to content

feat(plugin-audit,plugin-security)!: sys_audit_log loses its injected organization column; tenant_id carries the organization a row is about and scopes organization readers (ADR-0131 D7) - #22266

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-15207-audit-log-attribution
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-15207-audit-log-attribution

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Part of #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 #15207 is not built here, so the card stays open.

Scope: item (2) only

ADR-0131 D7: the audit ledger may hold rows about deployment-level actions, so "the organization an audit row is about becomes a plain attribution field under a name the tenant-field resolver does not claim, never the tenancy anchor", and the object "is governed by object permission, not by the wall". The seat's ruling is option A of report 6042515710. Item (1) landed as #22107 and item (3) as #22166. No stored row moves here (ADR-0131 D14): the column's fate is C7's (#15211), below.

Patch round (seat ruling 6058257824, which widens the claim's file surface):

  • 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 #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 (#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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/objectql, @objectstack/plugin-audit, @objectstack/plugin-security, @objectstack/service-settings, @objectstack/spec, touching 27 documentable anchor(s). ⚠️ 4 changed file(s) yielded no anchor (packages/plugins/plugin-audit/README.md, packages/plugins/plugin-audit/src/audit-log-field-redaction.ts, packages/plugins/plugin-audit/src/audit-log-read-visibility.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

21 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 c8bb3c8d9cdae41c51b72f1cb3cb5028988e1fe4.

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

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/plugins/plugin-audit/README.md, packages/plugins/plugin-audit/src/audit-log-field-redaction.ts, packages/plugins/plugin-audit/src/audit-log-read-visibility.ts, …) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 33 pages)
  • 10 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 — 146 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 c8bb3c8d9cdae41c51b72f1cb3cb5028988e1fe4 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d77e677886a3ceb76c3308d2c584fa315d22e5d2 — the merge of head b865914be57d0acb374da697ded50ab286331e8b into base c8bb3c8d9cdae41c51b72f1cb3cb5028988e1fe4, 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 d77e677886a3ceb76c3308d2c584fa315d22e5d2 && git checkout d77e677886a3ceb76c3308d2c584fa315d22e5d2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c8bb3c8d9cdae41c51b72f1cb3cb5028988e1fe4 b865914be57d0acb374da697ded50ab286331e8b && git checkout -B drift-repro c8bb3c8d9cdae41c51b72f1cb3cb5028988e1fe4 && git merge --no-ff b865914be57d0acb374da697ded50ab286331e8b

node scripts/docs-audit/affected-docs.mjs --json c8bb3c8d9cdae41c51b72f1cb3cb5028988e1fe4

⚠️ 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 c8bb3c8d9cdae41c51b72f1cb3cb5028988e1fe4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…ger row is about no organization; inert organization_id stamps removed (ADR-0131 D7)

Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK
Co-authored-by: Claude <noreply@anthropic.com>
…he global config_change pin

Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b865914be57d0acb374da697ded50ab286331e8b
Local-runs: none

Read-only review of PR #22266 (card #15207, ADR-0131 C6 item (2)) at the head above, merge base 31cd2104dc, net diff 34 files +1065 / -257. Inputs: the card body and all 19 comments, the PR body and net diff, the head's check-runs, ADR-0131 D7/D8/D10/D12/D14 and §1.2(3), ADR-0105 D2/D3, collectRLSPolicies / isPlatformTenantPolicy / PLATFORM_TENANT_POLICY_KEYS, computeLayeredRlsFilter and hasSuperuserReadBypass, the RLS compiler's unresolved-variable path, the accessible_org_ids docblock, and AGENTS.md's changeset rule. Rendered 2026-10-08T12:59Z. The dispatch orders were not read; the seat's conclusions were not taken as evidence.

Check-runs on the head (read twice, by API): 42 runs, 37 success, 5 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke opt-in, and the re-triggered Auto Label / Check PR Size), 0 failure. Check Changeset was queued on a 12:45Z re-trigger at the first read and success at the second. Test Core 6/6, Dogfood 3/3, Temporal Conformance, Build Core, all type-check lanes, Lint & Repo Gates, Spec property liveness, Governed Surface Queue Guard and the four claim guards are green. PR state: draft, mergeable_state: clean.

① Derived judgments

(a) No reader gains a row it should not see under a wall — HOLDS, with two named residuals. The shipped roster at the head is eight sets: admin_full_access, organization_admin, its derived organization_admin_no_bypass, member_default, viewer_readonly, and the three MCP ceilings. Per set, read against computeLayeredRlsFilter (Layer 1 is computed unless posturePermits && superuserBypass; posturePermits is true on the ledger because systemFields.tenant === false folds into meta.tenancyDisabled; Layer 0 contributes nothing because objectHasOrgIdField is false):

  • admin_full_access: '*' carries viewAllRecords (spec ADMIN_FULL_ACCESS_CAPABILITIES), no explicit ledger entry, so the bypass holds and the read is unfiltered. That is the platform administrator, item (b).
  • organization_admin: '*' carries the bits, but the diff adds an explicit sys_audit_log entry (read only, no bits); resolveObjectPermission resolves the per-object entry over the wildcard, so hasSuperuserReadBypass is false and Layer 1 compiles the policy. Pinned, with the control that removes the entry (all three rows served) and the ablation B.
  • organization_admin_no_bypass: deriveWallLessOrgAdmin spreads ...base (so rowLevelSecurity with the policy) and copies objects (so the explicit entry) and strips only the wildcard bits. Scoped.
  • viewer_readonly: '*' read-only, no bits, carries the policy. Pinned (a1 only).
  • member_default: no wildcard since member_default's * wildcard object grant (C/R/E) union-merges into every org member — app-side explicit-allow object gates are erased on three axes #5491, so no ledger read of its own; carries the policy, so a ledger read an application set grants to a human is scoped because the human baseline is fallbackPermissionSet composed with member_default on every authenticated human request (ADR-0090 D5, baselinePermissionSets). Ablation A turns the roster pin red.
  • mcp_agent_data_read / mcp_agent_data_write: '*' read with no policy and no bits. Not a leak: they are one side of the ADR-0090 D10 intersection and the middleware AND-composes the delegator's own computeLayeredRlsFilter (delegatorSets, delegator context) onto the same read, so the delegator's ledger scope governs. mcp_agent_restricted has objects: {}.
  • group: same sets, same policy, current_user.organization_id is the active organization (compiler: organization_id: executionContext?.tenantId), so every organization reader is scoped to the active organization; see (c).
  • A caller with no active organization: current_user.organization_id is undefined; compileExpression returns filter: null with a cause for an unresolved variable, the one applicable policy lands in deniedBy, and compileFilter returns RLS_DENY_FILTER (zero rows). The policy does NOT degrade to tenant_id == null, so the deployment-level NULL rows are not served to such a caller. This is the compiler's documented "no active organization" path; the PR's reading (none) agrees. The platform administrator with no active organization still reads every row through the bypass, which is the carried behaviour on every posture-permitting object.
  • Outside the shipped roster: no other non-test source in the tree grants a '*' read or names a sys_audit_log entry; no guest or anonymous set is shipped.

So no shipped set reads the ledger unfiltered under a wall except admin_full_access, by the bypass. Two residuals, both stated by the PR and consistent with D7 ("governed by object permission, not by the wall"): (i) an application set carrying viewAllRecords on the ledger or on '*' skips Layer 1, as it does on every object Layer 0 does not wall (the changeset says so under "Who reads what"); (ii) a deployment that sets fallbackPermissionSet: null disables the whole platform baseline, member_default included, and an application set that grants a bit-free ledger read then reads it unfiltered, as it loses every other baseline row policy too. Neither is a shipped set.

(b) Platform administrators read every row, deployment-level rows included — HOLDS, and the path is the superuser read bypass alone. admin_full_access's wildcard viewAllRecords makes hasSuperuserReadBypass true; with posturePermits true the Layer 1 block is skipped wholesale; Layer 0 has no column to scope. view_all_audit_log is not on this path: it lifts only plugin-audit's parent-record read gate (audit-log-read-visibility.ts), and the re-premised description says exactly that. The PLATFORM_ADMIN rung is not consulted for the Layer 1 skip (only superuserBypass && posturePermits), which is why residual (i) exists and why the explicit organization_admin entry is load-bearing. Pinned (a1 b1 d1, and g1 t1 for the settings rows) with the before-change control (the wall hid d1 and b1 from the platform administrator). This is the card's acceptance sentence, met.

(c) The group narrowing — an accepted, stated consequence, in the fail-closed direction; not a defect. current_user.organization_id is the active organization. The alternative, tenant_id IN (current_user.accessible_org_ids), would widen isolated: the docblock says the set is "every organization this principal currently holds a valid membership in", resolved in every posture, and under isolated "tenantId also bounds reads" while the set does not, so a policy on it would hand a walled organization admin every membership's ledger rows (the ADR-0095 W2 / ADR-0105 D4 line). A per-posture choice of predicate is the second posture ladder ADR-0131 D8 forbids. ADR-0131 D12 item 1 (the union wall) is a rule on organization_id, and item 14 puts the ledger under D7 with no such column, so the union wall never reaches it; the narrowing from the pre-change union read to the active organization is therefore outside D12's restated rule, not against it. The changeset states it ("not the union of its memberships"). It is a reading, not a pin, as the PR says.

(d) The single strip on a deployment holding more than one organization — conforms to ADR-0131 D8 and §1.2(3). collectRLSPolicies drops a policy when !this.orgScopingEnabled && isPlatformTenantPolicy(policy); orgScopingEnabled is postureEnforcesWall; PLATFORM_TENANT_POLICY_KEYS is built at module load from the shipped sets' policies whose using contains current_user.organization_id, keyed (object, name, using), so the new policy is in the provenance set (pinned). D8 says nothing is computed under single; §1.2(3) says a single deployment holding several organizations is a reported state (an error at boot since #17010), not a refused one; ADR-0105 D3 is the strip. The measured cost (every ledger reader reads every organization's rows there) is a widening relative to the driver's native arm that narrowed the column-less-no-more ledger before, and it is the widening D8/C8 will apply to every tenant object once the arms go; the changeset states it with the remedy (a walled posture). One prose note: the ADR-0087 D3 entry's acceptanceCriteria says "under single ... lists every row, as before", which is exact for the one-organization single the ADR defines and silent on the multi-organization case the changeset carries. Not a contract defect.

(e) The settings writer — true of the diff and pinned in both directions. config-change-audit.ts writes tenant_id: entry.scope === 'global' ? null : entry.tenantId ?? null; makeFieldProbe and the organization_id stamp are gone; the SettingsAuditSink.tenantId TSDoc says the same. Pins: the #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 key; the tenant-scope case asserts org_1; plugin-security's row-scope pin serves t1 and not g1 to the organization admin and both to the platform administrator. Ablation D turns exactly the global case red. The user-scope branch is the same non-global arm as tenant scope and is covered by the code shape rather than a pin of its own.

(f) D14 — no fallback read or write of the retired column; the C7 fate text does not over-claim. Every ledger organization_id stamp and probe is removed: audit-writers.ts, read-audit.ts, auth-event-audit.ts, config-change-audit.ts, platform-admin-standing-audit.ts (the declaresOrganizationId input and its one caller in bootstrap-platform-admin.ts). lifecycle-service.ts names organization_id only where resolveInjectedColumnProvenance says it is provisioned, i.e. on other objects. A grep of every non-test source at the head that names both sys_audit_log and organization_id finds no remaining read or write of the ledger's column (the hits are unrelated objects, the registry and comments). The pinned sibling at .objectui-sha a58626c88d names organization_id on the ledger only in auditHistoryDisplay.ts's hide-set of system fields, which tolerates absence; no filter or column selection names it. The attribution test pins that a write naming organization_id is refused INVALID_FIELD / 400 and a filter INVALID_FILTER / 400, so nothing can fall back silently. The fate text: the drift report really names a physical column absent from metadata as "orphaned" with the os migrate apply --allow-destructive remedy (schema-drift.ts); the "same value" premise is scoped to the in-tree writers and is accurate for them; a row the premise does not cover (one stamped by a lower layer where a writer stamped neither) is routed to "listed with ids, never guessed and never dropped", so the procedure over-claims nothing. Existing databases move nothing (D14), schema sync is additive, and C7 (#15211) owns the drop.

(g) Retention — honoured only where declared; a tenant override no longer stops the reap. tenantWindowsFor returns null with no windows; organization_id where the object has a provisioned one (unchanged); otherwise ATTRIBUTION_PARTITION_COLUMNS[obj.name] only when resolveInjectedColumnProvenance(obj, column) === 'author', which for a non-injected name means "declared by the author". The reaper and the archiver name only the returned column, with the NULL arm in the global pass. Pinned on a real registry with a driver that refuses an unknown column: reap and archive partition on tenant_id with no error; controls: the ledger without the field runs one global pass, sys_job_run carrying a tenant_id field runs one global pass (the table is name-keyed), the ledger with the injected column partitions on organization_id. Ablation C turns exactly the reap and archive pins red.

② Semver level

  • Changeset .changeset/15207-audit-log-attribution-field.md: @objectstack/plugin-audit, plugin-security, service-settings, spec at minor; @objectstack/objectql at patch (not minor as the brief's summary put it). The objectql grade is right: no export, key or accepted value moves there (ATTRIBUTION_PARTITION_COLUMNS and tenantWindowsFor are private), and the change is a behaviour fix in a partition decision. One presentation note: a single changeset's body lands in every listed package's CHANGELOG, so objectql's patch entry will carry the BREAKING banner and the ledger headline; item (1) used a separate objectql patch changeset for the same reason. Not a grade error.
  • BREAKING banner, bang headline and the ADR-0087 disposition are present: the body carries the adr-0087: registered sys-audit-log-organization-column-retired marker, the D3 entry 18.sys-audit-log-organization-column-retired.ts exists, the regenerated registry.ts carries it and one step-18 rationale fragment at order 88. The migration text states the FROM → TO mapping (organization_id to tenant_id) and the one-line fix, as AGENTS.md's changeset rule requires. Check Changeset and Lint & Repo Gates (the ADR-0087 registration and no-major gates) are green on the head.
  • Grade under pre mode. .changeset/pre.json is mode: pre, tag: next at the merge base, the head and origin/main, so major is open (the no-major gate's RC exemption), but the launch-window convention the gate documents ships a breaking change as minor with its banner; items (1) and (3) landed the same way. minor is the convention-conforming grade.
  • Clause-②: no (narrowing) is correct. The declaration's criterion (pm-dispatch execution duties) is whether the card relaxes the published accepted set or widens the public surface. This diff narrows the accepted set (organization_id on sys_audit_log is refused on write and filter; organization_admin's ledger grant loses its superuser bits) and widens no declaration: the spec diff changes a string literal inside PLATFORM_CAPABILITIES and adds a typed registry entry, neither of which moves the declaration closure, consistent with the dev's check:api-surface reading. The platform administrator reading more rows is a runtime read-reach change, stated under the changeset's "Who reads what", not a widening of the accepted set, so it does not flip the value to yes. The PR's line 2 and the changeset carry the same declaration, as required.

③ Boundary flags

From 6058193724 (round one):

  1. Clause-② restated from the claim's yes to no (narrowing) after measurement — answered, correct (② above).
  2. Grade kept at minor + BREAKING although pre mode is in — answered, correct (② above).
  3. Policy placement in four sets where the claim named none — answered, necessary: a set with no policy for an object leaves it unfiltered, so the policy goes where the shipped reads are; each placement is judged in ①(a). The explicit organization_admin entry is also read-only, which narrows nothing in practice because user-context writes on the append-only ledger were already refused at the engine (ADR-0103).
  4. Files outside the claim's surface (dogfood arming control re-keyed to tenant_id; writer tests; translation bundles; README; rbac-objects.test.ts roster; federated-injected-column-readers.test.ts rows) — answered, acceptable: each follows a claimed edit. The hand-written zh-CN / ja-JP / es-ES help strings are what AGENTS.md's AUTO-GEN row allows ("translated-locale values are hand-written"); en is regenerated; check:i18n is inside the green Lint & Repo Gates.
  5. main merged once, not re-merged after it moved — answered: the head is mergeable_state: clean, CI ran green on the PR's merge ref, and registry.ts is merge=os-regen, so a later conflict is the queue's pure-regeneration hop under the standing rule, with a Regen-provenance: line.
  6. Model-free commit trailers — answered: AGENTS.md's rule, outside the contract faces.
  7. open_questions[0], a single deployment holding more than one organization — answered, ruled A, conforms (①(d)).
  8. Out-of-scope 1, a global settings change stamped with the writer's organization — brought into the PR and pinned both ways (①(e)).
  9. Out-of-scope 2, the inert organization_id stamps and stale comments — brought into the PR; the managed-object-write-denies.ts docblock now states the ledger exception correctly.
  10. Out-of-scope 3, an application set granting viewAllRecords on the ledger skips the policy — answered as an accepted, stated residual, not escalated: it is D7's own consequence on an object the wall does not cover, identical on every tenancy-disabled object, no shipped or example set carries such a grant, and the changeset tells consumers. A structural closure (an injected bit-free ledger entry in wildcard-carrying sets, the shape applyManagedWriteDenies already uses) would be a design change outside this item; it is noted here for the seat, not required.

From 6060115514 (patch round):
11. Two files beyond the ruling's list (settings-service.types.ts TSDoc; the bootstrap-platform-admin.ts caller) — answered, required by the named edits.
12. The global-change row-scope pin in plugin-security rather than service-settings — answered, right place: service-settings runs no security layer.
13. @objectstack/service-settings added at minor — answered, correct: a consumer reading config_change rows by tenant_id no longer sees global changes there, a published behaviour change the shared BREAKING body legitimately covers.
14. No re-sync merge after origin/main moved 16+ commits — answered as flag 5.
15. Verification battery in foreground chunks, one run backgrounded — answered: process, outside the contract faces; the head's check-runs are the gate verdicts here.
16. Trailers — as flag 6.
17. Out-of-scope (app-set viewAllRecords) — as flag 10.

Additional notes from this read, none blocking: packages/metadata-core/src/record-organization.ts's docblock still narrates the ledger's column as unconditionally provisioned (prose only, outside the diff); the group and multi-organization single rows of the who-reads-what table are readings, not pins, as the PR body says; residual (ii) in ①(a) (fallbackPermissionSet: null) is an operator choice that disables every baseline row policy at once.

Implemented-by: claude/issue-15207-audit-log-attribution
Reviewed-by: session_01LAi5BVvQNiYzepSAcsoFLK

VERDICT: PASS


Generated by Claude Code

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…, and the data API read of the settings stores applies each namespace's readPermission (objectstack-ai#22295)

Fixes objectstack-ai#22261

Clause-②: no (narrowing)

Executes option A as ruled on the card. This body stays at the level of
classes, positions and functions, as the card asks; the detailed
measurement went to the dispatching seat privately.

## What changes

### `SettingsService`
(`packages/services/service-settings/src/settings-service.ts`)

The service reads and writes `sys_setting` under its own system context,
and that context names no organization. So no driver tenant scope and no
organization wall reaches those calls. The organization now travels in
the service's own query and row identity.

- **Row identity.** `rowIdentity` keys a `tenant` or `user` row by the
identity `sys_setting` declares, organization included. `setMany` writes
every such row with the caller's organization
(`SettingsContext.tenantId`). A `global` row is unchanged: it lives in
`sys_platform_setting` and has no organization column.
- **Reads.** `loadScopedRows` filters explicitly, in the query itself,
by the caller's organization plus rows stored with no organization. The
in-memory store applies the same reach (`OrganizationReach`). The global
rung (`loadGlobalRows`) is unchanged.
- **Cascade.** `preferredRow` makes the tenant and user rungs take the
caller organization's own row ahead of an organization-less one. The
lock pre-flight in `setMany` reads the same row, so a lock applies only
where the cascade reads.
- **Refusal.** Under a walled posture (`group` / `isolated`), a
tenant-scope write that names no organization is refused whole, before
anything is written. It reuses the vocabulary the ownerless user-key
refusal already has: `SettingsValidationError`, `code:
SETTINGS_VALIDATION`, HTTP 400 at the settings routes, one field entry
per key with `code: invalid_value` and `constraint: { scope: 'tenant'
}`. A reset is refused the same way.
- **Posture.** The posture comes from the `tenancy` service. The plugin
passes it through `bindEngine` (`tenancyPosture`), read the same way its
HTTP door reads it. The service only asks when the caller names no
organization.

### The generic read door (`settings-read-door.ts`, registered by
`SettingsServicePlugin`)

- An engine middleware registered by object name on `sys_setting`,
`sys_setting_audit` and `sys_platform_setting`. It ANDs a `namespace`
predicate into every non-system read (`find`, `findOne`, `count`,
`aggregate`). It is a filter, not a pass over the result, so counts,
aggregates and pages see exactly the rows a list returns.
- The predicate is `SettingsService.namespaceReadScope`. It uses the
same `requiredCapability` table the settings door enforces. A namespace
with no registered manifest reads at the default capability,
`setup.access`.
- **Seam (H4).** The seam sits inside `service-settings`. No file in
`objectql`, `runtime` or `plugin-security` is edited.

### Census page

`content/docs/permissions/system-context.mdx` gains row 18b for the
middleware's `isSystem` read. `check:system-context-census` requires a
row for every elevation read site. The counts were regenerated with
`pnpm gen:system-context-census`.

## Posture `single`

The default organization keeps the answers it had. These are pinned in
`settings-organization-isolation.pin.test.ts`:

- a value stored before rows carried an organization is still read by
the default organization;
- the default organization's new write is read by itself and by a
process-wide reader that names no organization;
- a reset reads back the default for both;
- an organization-less tenant-scope write is not refused under `single`,
nor where no posture is reported.

## Existing rows (H3)

No stored row is rewritten, as the dispatch fences. What stored rows
carry today, and the decision that follows from it, went to the seat
privately.

## Tests

Every run in this table is on HEAD `61911cf17a`, after
`service-settings` was rebuilt and its `dist/` was proven to carry the
HEAD source.

| Suite | Result |
|:--|:--|
| `pnpm --filter @objectstack/service-settings exec vitest run
--maxWorkers=2` | 41 files, 752 passed |
| `settings-organization-isolation.pin.test.ts` (part of the suite
above) | 18 passed |
| `settings-read-door.pin.test.ts` (part of the suite above) | 27 passed
|
| `test/settings-organization-isolation.dogfood.test.ts`, real stack
over HTTP, two organizations under a non-degraded `isolated` posture | 6
passed |
| `single`-posture HTTP regression: the existing dogfood files that
write and read settings (`settings-config-change-audit`,
`analytics-timezone`, `audit-log-parent-read-gate`) | 3 files, 14 passed
|
| `pnpm --filter @objectstack/service-settings typecheck` | exit 0 |
| `pnpm --filter @objectstack/dogfood typecheck` | exit 0 |

### Over HTTP, with a positive control per refusal

- Each organization sets and reads its own tenant-scope value. One
organization's write and its reset leave the other's value unchanged. A
`global` value is read by both.
- An organization-less tenant-scope write under the walled posture
answers `400` with `error.code` `SETTINGS_VALIDATION` and a field entry
`invalid_value`, and writes nothing. Control: the identical write from
inside an organization answers 200.
- The data-API read of `sys_setting` hides a namespace's row from a
principal lacking that namespace's `readPermission`: the list returns 0
rows and the by-id read answers 404. Control 1: the same principal reads
its own row of a namespace whose capability it holds. Control 2: a
holder of the withheld capability reads the withheld row (200).

### Ablations

Each ablation started from a committed fix, restored from `HEAD` under a
trap, and was proven restored by blob hash and an empty `git diff HEAD`.
All ablations ran at `e1407dc55f`, except the two dogfood rows, which
ran at `672e54e08e`.

| Mutation | Result |
|:--|:--|
| `settings-service.ts` set to the base commit | isolation pin 11 red, 5
green. The 5 that stay green are the regression guards: the global row,
and the three `single` cases plus its no-refusal control. |
| `namespaceReadScope` answers no predicate | read-door pin 19 red, 8
green. The 8 green: system context, registration by name, writes
untouched, refusal of a read with no query. |
| `preferredRow` made positional | isolation pin 2 red (both preference
cases) |
| Dogfood, service and plugin set to the base commit, package rebuilt,
`ablation-dist-preflight --absent` passed | 4 of 6 red. Green: the
guard, and the global row read by both. Restored, rebuilt, marker
present again: 6 of 6 green. |
| Dogfood, only the door predicate disabled, plant proven in `dist/` |
only the read-door case red (1 of 6). Restored, rebuilt, `--absent`
passed, tree clean. |

### Gates

- **Derived set.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 94 commands at HEAD `61911cf17a`.
All 94 were run and exit 0, plus `check:settings-bind-window` as
dispatched.
- **Reconciliation.** `--ran` reports 94 derived, 94 run, 0
NOT-MEASURED, 0 UNRUN.
- **Fixed on the way.** `check:system-context-census` went red on the
first pass for the new read site. It is green after the row-18b edit.
- **Lint, narrowed.** The 9 changed `.ts` files lint with 0 errors and 0
warnings at `61911cf17a` (`eslint --no-inline-config --format json`).
The repository runs no type-aware lint, so this change cannot move an
untouched file's lint verdict. The full lint run is CI's.
- **Base.** The branch sits on `c8bb3c8d`. `origin/main` gained 4
commits since, and none of them touches `service-settings`. CI on the
merge ref is the arbiter.

## Acceptance notes

- **Changeset**: one `@objectstack/service-settings` `minor` changeset,
declared breaking, with ADR-0087 disposition `not-required
(no-migration-prescription)`. `check-adr-0087-registration` and
`check-changeset-no-major` are green.
- **Fence**:
- `settings-service.types.ts` changes only `SettingsRow` (the
`organization_id` row-shape field). PR objectstack-ai#22266 edits a different region
of it.
- `settings-service-plugin.ts` is touched for wiring only: the posture
source and the read-door registration.
- Two existing test fixtures were triaged. `settings-getmany.test.ts`
rows now spell `organization_id: null` the way a real driver returns it.
`settings-routes.test.ts` passes the reach its private `loadRows` call
now takes.
- **Out of scope, not changed here**:
- The organization attribution of the settings-specific audit trail rows
belongs to objectstack-ai#15207's family (audit ledgers); that card remains open.
- **For later**: `sys-setting.object.ts` (platform-objects) quotes a
`loadRows` comment sentence this change retires. The quote is prose
only; no gate reads it.

> Seat's append (`domain:services` seat 1, objectstack-ai#6021), carried verbatim from
the dev's round-2 report `6060893787`; the dev never edits a PR body.

## Round 2

- **Merge.** `origin/main` `d1dbe70ebd` is merged with a merge commit
(`dcbf66b41a`), with no rebase and no force-push. The only conflict was
the derived counts on `content/docs/permissions/system-context.mdx`.
Both rows are kept, 18b from this branch and 23d from main, and the
counts were regenerated with `pnpm gen:system-context-census`.
`check:system-context-census` is green: 118 elevation read sites in 20
packages across 55 files.
- **Re-verified at `dcbf66b41a`.** All results below follow a rebuild of
what the merge touched.

  | Check | Result |
  |:--|:--|
  | `service-settings` suite | 41 files, 752 passed |
  | `settings-organization-isolation.dogfood.test.ts` | 6 passed |
  | single-posture settings dogfood files | 3 files, 14 passed |
  | typecheck of `service-settings` and `dogfood` | exit 0 |

- **Gates.** `dispatch-gates --commands`, run with no paths, derives 94
commands at `dcbf66b41a` with no stale-tree warning. All 94 were run,
plus `check:settings-bind-window`. `--ran` reports 94 derived, 94 run, 0
NOT-MEASURED and 0 UNRUN. Two gates first refused with exit 3 on unbuilt
packages; they were re-run after those packages were built and exit 0.
- **Measurement.** Under the isolated posture, with the real tenant wall
in the composition, a non-system read of `sys_setting` or
`sys_setting_audit` by one organization's administrator does not return
another organization's organization-stamped row. The harness is a
temporary test, removed after the run.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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