Skip to content

Commit f8ef144

Browse files
feat(objectql)!: under single, a platform object's organization-less system insert is stamped or refused (C8 stage 8b) (#24513)
Part of #15212 Clause-②: no (narrowing) C8 stage 8b, with stage 8c-T-α folded in: its commit `5d1b91e9b` (5 grant rows in 4 test files stamped with the organization their context reads under) is this branch's first commit. Shaped by epic ruling record 6259899486 on #15194: 18.0 is fresh installs, `single` only, and 8b lifts the exemption under `single` only. ## What changes `Engine.resolveSystemInsertOrganization` (`packages/objectql/src/engine.ts`) used to return before it read the posture for every in-reach platform object outside the 9-entry admit list. Those system inserts landed with `organization_id` NULL under every posture. - **Under `single`** there is no platform-object exemption now. A system insert into a platform object with a tenant column that names no organization goes through the same ADR-0131 D9 decision every application object gets: one organization (the Default Organization) stamps it; zero or several refuse it with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`. The census puts 35 objects in this newly reached set (44 in reach, minus the 9 admitted). - **Under a walled posture (`group` / `isolated`)** the #13491 exemption stands, as a named posture condition with a comment citing 6259899486 (item 4). It is not a per-table list. - **`PLATFORM_OBJECT_TENANCY` does not retire here.** Two readers remain: the walled exemption above, and the engine's diagnostics-only `bypassTenantAudit` isSystem mute (`buildDriverOptions`, every posture). The ledger header now says this. It retires with its walled reader in 18.x (D13). The census tooling and its JSON are unchanged and green, and `lint.yml` is untouched. - Docblocks are updated in `tenancy/platform-object-tenancy.ts` and `tenancy/system-write-organization.ts`. `packages/core/src/security/system-write-organization.ts` needed no change: its decision function is unchanged, and editing its JSDoc would change published `.d.ts` text for no behavioural reason. #14570 (`sys_business_unit_member` unadjudicated): under `single` its system-insert row is now stamped (pinned on the real showcase below). Under a wall it still lands NULL, because the exemption stands there in 18.0. So this is **Part of #14570**; the walled half rides 18.x. ## Boot order (the main risk), measured On a fresh `single` showcase boot (`pnpm dev -- --fresh`, SQLite) the boot is clean and the admin is seeded. Then: - the admin signs in; - the admin sends an invitation through `POST /auth/organization/invite-member`, which writes the invitation row and the identity mail; - the admin creates a record. A direct DB census after these steps found **0 rows with `organization_id` NULL in every platform table that has the column**, across `sys_setting`, `sys_setting_audit`, `sys_member`, `sys_invitation`, `sys_email`, `sys_user_permission_set`, `sys_user_position`, `sys_audience_binding_suggestion`, `sys_inbox_message`, `sys_notification_receipt`, `sys_webhook`, `sys_business_unit`, the approval family and `sys_record_share`. Every one was stamped with the Default Organization. No producer writes before the Default Organization exists, so no producer needed changing. (`AuthPlugin.start()` creates the Default Organization before the application plugins start, and the seed loader already stamps.) ## Tests **New pins** - `packages/objectql/src/tenancy-by-object-classification.test.ts`: - under `single`, un-admitted platform objects derive the sole organization; - with zero or two organizations they are refused, with the envelope `code`, `status` and `organizationCount`; - walled controls (`isolated`, `group`) still land unstamped. - `packages/objectql/src/system-write-organization.test.ts`: the same split, single against walled, on the #8844 fixture. - `packages/qa/dogfood/test/showcase-single-platform-insert-default-organization.dogfood.test.ts`, on the real showcase composition: - boot, sign-in and an invitation leave no organization-less row in any platform object with a tenant column (with a non-vacuity floor); - a bare `isSystem` insert into `sys_business_unit` / `sys_business_unit_member` takes the Default Organization; - with a second organization the same insert is refused (`ambiguous-organization`) and nothing is written. **Tests 8b broke, and how each was fixed.** No pin of a NULL reader was rewritten, and no test was deleted or skipped. - **Fixtures that hand-registered `sys_organization` / `sys_user` as bare schemas** now declare them as the platform does (`managedBy: 'better-auth'`, so no injected column). Otherwise the first organization's own insert was refused for want of an organization. - `runtime/src/system-write-tenancy-autonumber-split.integration.test.ts` - `runtime/src/seed-tenancy-autonumber-split.integration.test.ts` - `service-messaging/src/notification-organization-follows-event.integration.test.ts` - `plugin-approvals/src/status-mirror-cascade.integration.test.ts` - `plugin-security/src/sys-audit-log-row-scope.test.ts` - `plugin-security/src/granted-by-writer-provenance.test.ts` (plus the Default Organization row a `single` boot holds) - **PIN fixtures that model organization-less legacy rows** now land those rows through the driver, as the residue an older release left. These are global grants, the retired catalogue bucket, unscoped platform-admin holders and the pre-change ledger. The readers they pin still read NULL rows until 8c (18.x), so each assertion still pins what it pinned. - `plugin-security/src/__tests__/legacy-catalog-rows.testkit.ts` and its plugin-auth copy - `plugin-security/src/`: - `explain-enforce-parity` - `explain-removed-member-principal` - `explain-principal-person` - `explain-asker-gate` - `orgless-position-name-fold` - `catalog-reference-report` - `bootstrap-platform-admin-existing-holder-scan` - `controlled-by-parent-chain-middle-master` - `sys-audit-log-row-scope` (`LEDGER_BEFORE`) - `builtin-positions.boot` (legacy `sys_position` rows) - `plugin-auth/src/`: - `signup-existing-address-refusal` - `sso-register-platform-admin-gate` - `driver-fault-auth-log-carriers` - `scim-deactivation-reconcile-user` - `runtime/src/seed-tenancy-autonumber-split.integration.test.ts` (`[GUARD]` platform residue) - **One positive control rewritten to the ruled behaviour.** In `plugin-security/src/builtin-positions.boot.test.ts`, the census control's organization-less `sys_permission_set` insert now reads back with the sole organization under `single`, and unstamped under the wall. - **Incidental fixtures that only needed an owner** now carry one: - `plugin-audit/src/identity-objects-feeds-off.test.ts`; - the SCIM rigs `credential-at-rest-posture`, `scim-transaction-scope`, `scim-deactivation-reconcile-user` and `principal-less-producers-system-context`. Their mint carries an organization; the verifier reads the digest alone. The plugin-auth engine pool refuses a database holding an organization row, so adding a Default Organization there was not an option. - **Dogfood fixtures.** The ablation below proved each of these red only because of 8b. - **The Default Organization, asked of the `tenancy` service.** This replaces an empty-`where` `findOne` whose swallowed refusal minted a second organization (the trap that #14570's remainder named): - `showcase-scope-depth` - `showcase-scope-depth-fallback` - `showcase-scope-depth-write` - **A grant that carries the fixture organization its sessions are active in:** - `activity-withheld-update` - `identity-admin-fields-org-peer` - **Global grants that an organization-less principal must hold, landed beneath the engine:** - `write-door-unreadable-is-not-found` - `predicate-write-unreadable-not-matched` - `parent-derived-write-refusal-not-visible` - `sharing-rule-org-less-caller` - **Prechecks that resolved a principal with no organization** now resolve in the organization a `single` session carries. The HTTP doors in the same files use real sessions, and they were green throughout. - `wall-package-refusal` - `wall-package-doors-refusal` - `wall-authoring-trail-doors` - `wall-environment-metadata-refusal` - `catalog-activation-door` - `permission-set-clone-door` - `showcase-permission-zoo` (explain is asked in that organization) - **Pin rewritten to the ruled behaviour:** `single-tenant-identity-create`. A system `sys_team` insert now takes the Default Organization; the pin used to assert NULL. The plugin-auth edits are test-only. No plugin-auth source changed. 8b-0f (#24453) had merged into this branch's base before they were made. ## Verification Everything below is at head `d1838bf96`. Heavy runs went through `os-verify-lock.sh`. **Build and types** - **Build:** `pnpm build --concurrency=2` passed 76/76 tasks, after the second `origin/main` merge. - **Typecheck:** exit 0 in each of objectql, core, runtime, plugin-security, plugin-auth, plugin-audit, service-messaging, plugin-approvals and dogfood. **Tests** - **Unit and integration, in the downstream consumers of `@objectstack/objectql`, plus core.** All 48 packages are green after the fixes (vitest per package). Among them: - core 2646 - runtime 7455 (19 skipped) - plugin-security 3783 - plugin-auth 3168 - rest 6707 - metadata-protocol 20674 - service-analytics 4759 - cli `unit` 4610 One cli file (`boot-preparation-parity`) timed out at 5 s under parallel load; it passed alone, 37/37. The 18 originally red files re-ran green. - **objectql:** 446 files, 8369 tests. The only 4 reds were the pins this PR rewrites; those two files now pass 66/66. - **Dogfood (whole suite, single and walled lanes):** 2291 passed, 18 skipped, 0 failed (`pnpm --filter @objectstack/dogfood exec vitest run`). - **Fresh showcase boot:** clean, with the admin seeded and 0 NULL-organization platform rows (see above). **A1: the walled branch is unchanged** - The engine's walled branch is the same expression as before. - The walled controls in both objectql pin files pass, unchanged in shape. - Every walled dogfood test passes. The ones that broke were rigs whose engine ran at the default `single` and simulated a wall only in plugin services. **Ablation** - Tools: `scripts/ablation-replace.mjs` and `ablation-dist-preflight.mjs`, with a trap restore. - The mutation restored the exemption in every posture (`!== 'single'` became `!== '8b-ablated'`). Results: - **objectql pins:** 5 new cases went red. - **dogfood pin:** after a rebuild, with the marker proven present in `dist`, 3/3 went red. - **restore:** `git diff HEAD` empty, blob hash matched, rebuild exit 0, marker `--absent` exit 0. - The same ablation re-ran the 17 other dogfood files that 8b had turned red. All were green with the exemption back, so 8b is their only cause. **Gates** - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 80 commands, plus a 51-family artifact-roster block. All 128 ran, with exit codes captured before any pipe. - **Green:** every family not listed below. That includes: - `check-platform-object-tenancy-census`, `check-tenant-audit-census` and `check-system-context-census`; - `check-adr-0087-registration` (`not-required (no-migration-prescription)`) and `check-changeset-no-major`; - `check:cross-package-test-inputs`, `check:test-source-alias`, `check:engine-double-contract`, `check:nul-bytes` and `check:type-check-debt`. The families that read tests were re-run on the final head. - **Shallow clone:** `check-engine-split-ratio --days 90` and `check-plugin-teardown-shape --self-test` refused on the shallow clone at first. Both are green after `git fetch --shallow-since=2026-07-14`. - **NOT MEASURED locally:** `check-closing-target-claim`, `check-single-claim-paths` and `check-governed-queue-guard` need PR context that only CI supplies. `check-partof-closing-keyword` was run against this body and is green. ## Acceptance notes - `mintScimConnectionCredential` (plugin-auth) has no production caller. Under `single` an org-less mint is now stamped with the Default Organization. That is behaviour-neutral, because the verifier never reads the organization. - A showcase boot logs `[sharing-rule] … expands to NO recipients` for `share_new_inquiries_with_field_ops` with `organization: null` (the rule itself, under regime C). It is not a NULL membership row, and this stage does not touch it. - Under `single`, a non-system write with no `tenantId` also reaches the decision. The resolver runs for any context without an organization, not only `isSystem`, so such a write into a platform object is now stamped or refused as well. That is consistent with D9 ("every writer"). --- _Generated by [Claude Code](https://claude.ai/code/session_01AZMXDVkiaxSpJkGXACuVwr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 246aba0 commit f8ef144

53 files changed

Lines changed: 625 additions & 167 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
feat(objectql)!: under the `single` posture, a system insert into a platform object that names no organization takes the Default Organization, or is refused; it no longer lands with `organization_id` NULL
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A validity narrowing at the engine's system-insert door: no key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. No export, type or error code is added, removed or renamed: `PLATFORM_OBJECT_TENANCY`, `isPlatformObjectOutOfTenantAuditScope` and `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` keep their shapes. The remedy is a run-time one, stated below. -->
10+
11+
**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9, D11).
12+
13+
- **Before.** A system-context insert (`isSystem`, no `tenantId`) into a `sys_` / `cloud_` / `ai_` object that carries an organization column, but that the tenancy inventory had not admitted, skipped the organization rule entirely, under every posture. The row landed with `organization_id` NULL. Thirty-five platform objects in this repository were in that population, among them `sys_business_unit`, `sys_business_unit_member`, `sys_member`, `sys_team`, `sys_user_permission_set`, `sys_user_position`, `sys_sharing_rule`, `sys_share_link`, `sys_comment`, `sys_attachment`, `sys_activity`, `sys_email`, `sys_inbox_message`, `sys_secret`, `sys_setting`, `sys_setting_audit`, `sys_webhook` and `sys_flow_credential`.
14+
- **Now, under `single`.** That insert goes through the same decision every application object already gets. With exactly one organization (the Default Organization), the row is stamped with it. With none, or with several, the insert is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500), and the message names the missing organization. Nothing is written.
15+
- **Unchanged.** Under a walled posture (`group` / `isolated`) the per-object exclusion stands as before; the walled half of this change ships in a later 18.x release. A write that carries an organization, on the execution context or on the record, is never re-stamped or refused. Objects with no organization column, objects declaring `tenancy: { enabled: false }`, and federated objects are outside the rule. Updates are untouched. The engine's diagnostics-only tenant-audit mute is unchanged.
16+
17+
**The remedy.** If a refusal names your insert, carry the organization on the write: `ql.insert(object, { ...row, organization_id: orgId }, { context: { isSystem: true } })`, or `{ context: { isSystem: true, tenantId: orgId } }`. On a fresh `single` deployment the auth plugin creates the Default Organization at boot, so a refusal reading `no-organization` means the write ran before it, or in a composition that registers `sys_organization` but mounts no auth plugin to create the Default Organization.

‎packages/core/src/security/resolve-authz-context.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,8 +1090,8 @@ describe('resolveUserAuthzGrants — userId-driven authz for non-HTTP surfaces (
10901090
const ql = makeQl({
10911091
sys_user: [{ id: 'u1', email: 'ada@x.com' }],
10921092
sys_member: [{ user_id: 'u1', role: 'admin', organization_id: 'o1' }],
1093-
sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: null }],
1094-
sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'ehr_all', organization_id: null }],
1093+
sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: 'o1' }],
1094+
sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'ehr_all', organization_id: 'o1' }],
10951095
sys_permission_set: [{ id: 'psA', name: 'ehr_all', system_permissions: ['cap_ehr'] }],
10961096
});
10971097
const grants = await resolveUserAuthzGrants(ql, 'u1', { tenantId: 'o1' });

‎packages/objectql/src/engine.ts‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7237,16 +7237,20 @@ export class ObjectQL implements IObjectQLEngine {
72377237
// system write that threaded one. Nothing to resolve; this is the shape the
72387238
// ruling asks every system write to reach.
72397239
if (carriesOrganization(execCtx?.tenantId)) return undefined;
7240-
// [#13491] Platform-namespace objects are excluded PER OBJECT, not
7241-
// wholesale. The 2026-08-31 ruling withdrew the blanket
7242-
// `isPlatformNamespaceObject(object)` exemption that used to stand here:
7243-
// commit ff08691e6's "an org-less row is defensible for `sys_permission_set`"
7244-
// inherits per object, and the namespace also holds objects whose org-less
7245-
// rows are a defect — five instances of that class, all found by hand.
7246-
// `unclassified` keeps the old exclusion so an unadjudicated object's
7247-
// behaviour does not move; see `platform-object-tenancy.ts` for the
7248-
// inventory and why the classification cannot be read off the schema.
7249-
if (isPlatformObjectOutOfTenantAuditScope(object)) return undefined;
7240+
// [ADR-0131 D9, C8 stage 8b] Under `single` a platform object has no
7241+
// exemption: with a tenant column it is in scope like any other object, so
7242+
// its organization-less system insert is stamped with the Default
7243+
// Organization or refused below — never left NULL.
7244+
//
7245+
// ⚠️ WALLED POSTURES ONLY: #13491's per-object exemption still stands under
7246+
// `group` / `isolated` (epic ruling record 6259899486 on #15194, item 4).
7247+
// 18.0 refuses a walled posture at the operator's boot path, and the walled
7248+
// half of the lift lands in 18.x, after the stages that give the walled
7249+
// writers an organization of their own. The cut is a POSTURE condition on
7250+
// purpose — ⛔ never re-narrow it with a per-table list.
7251+
if (isPlatformObjectOutOfTenantAuditScope(object) && this.resolveEnginePosture() !== 'single') {
7252+
return undefined;
7253+
}
72507254
const objectSchema = this._registry.getObject(object) as any;
72517255
// A federated object's schema is the REMOTE's (ADR-0015); the platform's
72527256
// injected column says nothing about it, which is the same reason

‎packages/objectql/src/system-write-organization.test.ts‎

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -124,12 +124,11 @@ const DISPATCH_ORDER = {
124124
/**
125125
* A platform-namespace object the #13491 inventory did NOT adjudicate.
126126
*
127-
* ⚠️ Its exclusion below is `unclassified`, not `global`. Until the 2026-08-31
128-
* ruling this file read it as "platform namespace ⇒ deliberately org-less
129-
* (…)"; that wholesale reading was withdrawn, and what these cases now pin
130-
* is that an UNADJUDICATED object's behaviour did not move — which is what
131-
* bounds the reclassification's blast radius to the admitted list. The admitted
132-
* and `global` sides are pinned in `tenancy-by-object-classification.test.ts`.
127+
* ⚠️ Its exclusion below is `unclassified`, not `global`, and since ADR-0131
128+
* C8 stage 8b it holds under a WALLED posture only (epic ruling record
129+
* 6259899486 on #15194, item 4). Under `single` the object is judged like any
130+
* other — derived or refused. The admitted and `global` sides are pinned in
131+
* `tenancy-by-object-classification.test.ts`.
133132
*/
134133
const SYS_LEDGER = {
135134
name: 'sys_audit_entry',
@@ -389,20 +388,28 @@ describe('#8844 multi-organization — the write is REFUSED, never defaulted', (
389388
});
390389

391390
describe('#8844 the exclusions — populations the refusal must not touch', () => {
392-
it.each(['isolated', 'single'] as const)(
393-
'a platform-namespace object stays org-less on the %s posture',
391+
it.each(['isolated', 'group'] as const)(
392+
'a platform-namespace object stays org-less on the %s posture — the walled exemption stands in 18.0',
394393
async (posture) => {
395-
// ⚠️ [#13491] Reads as an UNCLASSIFIED verdict now, not a namespace one.
396-
// The card commit ff08691e6 cites measured this primitive on `sys_permission_set` and filed it as an
397-
// observation because an org-less row is defensible there. The #8844
398-
// ruling confirms that reasoning holds for platform objects and does NOT
399-
// generalize to application objects — which is exactly the boundary here.
394+
// ⚠️ [#13491] Reads as an UNCLASSIFIED verdict, not a namespace one, and
395+
// [C8 stage 8b] holds under a wall only: 18.0 refuses a walled posture at
396+
// the operator's boot path, and the walled half of the lift is 18.x's
397+
// (epic ruling record 6259899486 on #15194, item 4).
400398
const { engine, observed } = await makeEngine({ posture });
401399
await engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any);
402400
expect(lastWrite(observed, 'sys_audit_entry')?.options?.tenantId).toBeUndefined();
403401
},
404402
);
405403

404+
it('[C8 stage 8b] single posture: a platform-namespace object is no longer excluded — it takes the sole organization', async () => {
405+
// ADR-0131 D9 / D11: `single` is never a reason for a row to be org-less.
406+
// The walled case above is the control: same object, same write, only the
407+
// posture differs.
408+
const { engine, observed } = await makeEngine({ posture: 'single', organizations: [ORG_ID] });
409+
await engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any);
410+
expect(lastWrite(observed, 'sys_audit_entry')?.options?.tenantId).toBe(ORG_ID);
411+
});
412+
406413
it.each(['isolated', 'single'] as const)(
407414
'an object declaring `tenancy.enabled: false` stays org-less on the %s posture',
408415
async (posture) => {
@@ -418,8 +425,10 @@ describe('#8844 the exclusions — populations the refusal must not touch', () =
418425
it('never probes `sys_organization` for a write it does not judge', async () => {
419426
// The cost half of the exclusions: an ordinary write must pay two property
420427
// reads and a regexp, not a query.
428+
// [C8 stage 8b] A platform object is judged under `single` now, so it is
429+
// not in this population: only the walled exemption skips its probe, and
430+
// a walled refusal never probes anyway.
421431
const { engine, observed } = await makeEngine({ organizations: [ORG_ID] });
422-
await engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any);
423432
await engine.insert('billing_license', { subject: 'lic' }, { context: SYSTEM_CTX } as any);
424433
await engine.insert('dispatch_order', { subject: 'session' }, { context: SESSION_CTX } as any);
425434
expect(observed.filter((c) => c.object === 'sys_organization')).toEqual([]);

‎packages/objectql/src/tenancy-by-object-classification.test.ts‎

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -196,17 +196,48 @@ describe('#13491 gate 1 — an admitted platform object reaches #8844 derive/ref
196196
expect(lastWrite(observed, 'sys_file')?.options?.tenantId).toBe(ORG_ID);
197197
});
198198

199-
it('single posture: a GLOBAL platform object still stays org-less — the control', async () => {
200-
const { engine, observed } = await makeEngine({ posture: 'single' });
201-
await engine.insert('sys_permission_set', { label: 'Admin' }, { context: SYSTEM_CTX } as any);
202-
expect(lastWrite(observed, 'sys_permission_set')?.options?.tenantId).toBeUndefined();
203-
});
199+
// [ADR-0131 C8 stage 8b] Under `single` the classification no longer decides
200+
// the write: every platform object with a tenant column is judged, admitted
201+
// or not. The walled cases below are the control — there the exemption
202+
// stands in 18.0 (epic ruling record 6259899486 on #15194, item 4).
203+
it.each([
204+
['sys_permission_set', { label: 'Admin' }],
205+
['sys_audit_entry', { subject: 'e1' }],
206+
] as const)(
207+
'single posture, one organization: the un-admitted platform object %s now DERIVES it',
208+
async (object, row) => {
209+
const { engine, observed } = await makeEngine({ posture: 'single' });
210+
await engine.insert(object, { ...row }, { context: SYSTEM_CTX } as any);
211+
expect(lastWrite(observed, object)?.options?.tenantId).toBe(ORG_ID);
212+
},
213+
);
204214

205-
it('single posture: an UNCLASSIFIED platform object stays org-less — behaviour did not move', async () => {
206-
const { engine, observed } = await makeEngine({ posture: 'single' });
207-
await engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any);
208-
expect(lastWrite(observed, 'sys_audit_entry')?.options?.tenantId).toBeUndefined();
209-
});
215+
it.each([
216+
['no organization', [] as string[], 0],
217+
['two organizations', [ORG_ID, 'org_second'], 2],
218+
] as const)(
219+
'single posture, %s: an org-less system insert on an un-admitted platform object is REFUSED, loudly, and never written',
220+
async (_label, organizations, count) => {
221+
const { engine, observed } = await makeEngine({ posture: 'single', organizations: [...organizations] });
222+
await expect(
223+
engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any),
224+
).rejects.toMatchObject({
225+
code: 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED',
226+
status: 500,
227+
organizationCount: count,
228+
});
229+
expect(lastWrite(observed, 'sys_audit_entry')).toBeUndefined();
230+
},
231+
);
232+
233+
it.each(['isolated', 'group'] as const)(
234+
'%s posture: an UNCLASSIFIED platform object still stays org-less — the walled exemption stands in 18.0',
235+
async (posture) => {
236+
const { engine, observed } = await makeEngine({ posture });
237+
await engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any);
238+
expect(lastWrite(observed, 'sys_audit_entry')?.options?.tenantId).toBeUndefined();
239+
},
240+
);
210241

211242
it.each(['isolated', 'group'] as const)(
212243
'%s posture: an org-less system write on an admitted object is REFUSED, loudly',

‎packages/objectql/src/tenancy/platform-object-tenancy.ts‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,26 @@
33
/**
44
* [#13491] Per-object tenancy classification for the platform namespaces.
55
*
6+
* ## ⚠️ What still reads this ledger (ADR-0131 C8 stage 8b)
7+
*
8+
* Under the `single` posture the ledger no longer decides any write: ADR-0131
9+
* D9 made the system-insert rule structural there — a platform object with a
10+
* tenant column is in scope like any other, so its organization-less system
11+
* insert takes the Default Organization or is refused
12+
* (`Engine.resolveSystemInsertOrganization`). Two readers remain, and they
13+
* are why the ledger has not retired yet (D13):
14+
*
15+
* 1. the system-insert exemption under a WALLED posture (`group` /
16+
* `isolated`), which stands in 18.0 by epic ruling record 6259899486 on
17+
* #15194, item 4 — the walled half of the lift lands in 18.x;
18+
* 2. the engine's `bypassTenantAudit` isSystem mute
19+
* (`Engine.buildDriverOptions`), a diagnostics-only reading in every
20+
* posture that changes nothing a write touches.
21+
*
22+
* ⛔ Do not add an entry to make a `single` write behave: under `single` an
23+
* entry changes nothing but the mute. The ledger retires with its walled
24+
* reader.
25+
*
626
* ## The ruling this implements
727
*
828
* Maintainer, 2026-08-31 (总监席第 5 场, 联案 #13491 + #13497, verbatim 「同意」):
@@ -78,10 +98,11 @@
7898
* ## What admission actually changes, per object
7999
*
80100
* A `tenant-scoped` classification lets the object reach
81-
* `resolveSystemInsertOrganization` (#8844): on a `single` posture with exactly
82-
* one organization the write DERIVES it, and on a walled posture
83-
* (`group` / `isolated`) an org-less write is REFUSED loudly
84-
* ({@link SystemWriteOrganizationRequiredError}). It also stops the engine
101+
* `resolveSystemInsertOrganization` (#8844) on a walled posture
102+
* (`group` / `isolated`), where an org-less write is REFUSED loudly
103+
* ({@link SystemWriteOrganizationRequiredError}). Under `single` every
104+
* platform object with a tenant column reaches it whatever its entry says
105+
* (stage 8b, above). It also stops the engine
85106
* auto-muting the driver's tenant-audit warning for elevated writes on it.
86107
* Both directions are the ruled one — a refusal or a warning, never a silent
87108
* rewrite of what the write touches (execution point 3).

‎packages/objectql/src/tenancy/system-write-organization.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,11 @@
7171
* org-less rows is `tenancy: { enabled: false }` — a metadata declaration,
7272
* loud and checkable — never a per-write bypass flag, which is exactly the
7373
* lenient-consumer accommodation Prime Directive #12 forbids.
74-
* - **Platform-namespace objects the inventory did not admit** ({@link
75-
* isPlatformObjectOutOfTenantAuditScope}, `platform-object-tenancy.ts`).
74+
* - **Under a WALLED posture only, platform-namespace objects the inventory
75+
* did not admit** ({@link isPlatformObjectOutOfTenantAuditScope},
76+
* `platform-object-tenancy.ts`). Under `single` this exclusion is gone
77+
* (ADR-0131 C8 stage 8b): a platform object with a tenant column takes the
78+
* Default Organization or is refused, like every other object.
7679
*
7780
* ⚠️ This exclusion used to be the whole `sys_` / `cloud_` / `ai_`
7881
* NAMESPACE, on the reasoning commit ff08691e6 recorded, that "an org-less row is defensible for
@@ -84,8 +87,9 @@
8487
* none by this control. So the exclusion is now a per-object verdict, and an
8588
* object nobody has adjudicated stays excluded and stays ON THE LIST.
8689
*
87-
* ⚠️ This per-object exclusion STAYS until ADR-0131 C8, which retires it
88-
* together with the ledger (D13); C1 withdrew only the seed loader's
90+
* ⚠️ C8 stage 8b lifted this exclusion under `single` only. Under a wall it
91+
* stands in 18.0 (epic ruling record 6259899486 on #15194, item 4) and
92+
* retires with the ledger in 18.x (D13). C1 withdrew the seed loader's
8993
* namespace exemption. #8686's backfill (`seed-tenancy-backfill.ts`) still
9094
* carries the NAMESPACE form; its residue attribution is C7's. Read the
9195
* three together before changing any of them.
@@ -156,7 +160,8 @@ export const DEFAULT_TENANT_FIELD = 'organization_id';
156160
* #13491's re-ruling (2026-08-31, 联案 #13497) withdrew the wholesale namespace
157161
* reading — but its scope was the RUNTIME control only. So today:
158162
*
159-
* - **the runtime write path** cuts PER OBJECT, by the hand-adjudicated
163+
* - **the runtime write path** no longer cuts under `single` (C8 stage 8b).
164+
* Under a walled posture it cuts PER OBJECT, by the hand-adjudicated
160165
* classification in `platform-object-tenancy.ts` — the
161166
* `PLATFORM_OBJECT_TENANCY` ledger, read through
162167
* {@link classifyPlatformObjectTenancy} and

‎packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,10 @@ describe('an approval decision cascades as the deciding user (#3783)', () => {
327327
{
328328
name: 'sys_organization',
329329
label: 'Organization',
330+
// [C8 stage 8b] better-auth owns the table, as `sys-organization.object.ts`
331+
// declares: no injected organization column, so the Default Organization's
332+
// own insert below is not judged as an organization-less platform write.
333+
managedBy: 'better-auth',
330334
fields: { name: { type: 'text', label: 'Name' }, slug: { type: 'text', label: 'Slug' } },
331335
} as any,
332336
'@objectstack/plugin-auth',

‎packages/plugins/plugin-audit/src/identity-objects-feeds-off.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,11 @@ describe('Setup identity objects declare enable.feeds: false — a comment creat
236236
const { engine, storeFor } = await boot();
237237

238238
await expect(
239-
engine.insert('sys_comment', { thread_id: 'crm_account:acc-1', body: 'hello' } as any),
239+
// [C8 stage 8b] The comment carries its organization: under `single` an
240+
// organization-less system insert into a platform object is refused when
241+
// no organization exists, and this harness registers the organization
242+
// object with none of its rows.
243+
engine.insert('sys_comment', { thread_id: 'crm_account:acc-1', body: 'hello', organization_id: 'org_fixture' } as any),
240244
).resolves.toBeTruthy();
241245

242246
expect(Array.from(storeFor('sys_comment').values()).map((r) => r.thread_id)).toEqual(['crm_account:acc-1']);

0 commit comments

Comments
 (0)