Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/15212-8b-single-platform-insert-stamped.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/objectql': minor
---

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

Clause-②: no (narrowing)

<!-- 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. -->

**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9, D11).

- **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`.
- **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.
- **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.

**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.
4 changes: 2 additions & 2 deletions packages/core/src/security/resolve-authz-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1090,8 +1090,8 @@ describe('resolveUserAuthzGrants — userId-driven authz for non-HTTP surfaces (
const ql = makeQl({
sys_user: [{ id: 'u1', email: 'ada@x.com' }],
sys_member: [{ user_id: 'u1', role: 'admin', organization_id: 'o1' }],
sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: null }],
sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'ehr_all', organization_id: null }],
sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: 'o1' }],
sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'ehr_all', organization_id: 'o1' }],
sys_permission_set: [{ id: 'psA', name: 'ehr_all', system_permissions: ['cap_ehr'] }],
});
const grants = await resolveUserAuthzGrants(ql, 'u1', { tenantId: 'o1' });
Expand Down
24 changes: 14 additions & 10 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7173,16 +7173,20 @@ export class ObjectQL implements IObjectQLEngine {
// system write that threaded one. Nothing to resolve; this is the shape the
// ruling asks every system write to reach.
if (carriesOrganization(execCtx?.tenantId)) return undefined;
// [#13491] Platform-namespace objects are excluded PER OBJECT, not
// wholesale. The 2026-08-31 ruling withdrew the blanket
// `isPlatformNamespaceObject(object)` exemption that used to stand here:
// commit ff08691e6's "an org-less row is defensible for `sys_permission_set`"
// inherits per object, and the namespace also holds objects whose org-less
// rows are a defect — five instances of that class, all found by hand.
// `unclassified` keeps the old exclusion so an unadjudicated object's
// behaviour does not move; see `platform-object-tenancy.ts` for the
// inventory and why the classification cannot be read off the schema.
if (isPlatformObjectOutOfTenantAuditScope(object)) return undefined;
// [ADR-0131 D9, C8 stage 8b] Under `single` a platform object has no
// exemption: with a tenant column it is in scope like any other object, so
// its organization-less system insert is stamped with the Default
// Organization or refused below — never left NULL.
//
// ⚠️ WALLED POSTURES ONLY: #13491's per-object exemption still stands under
// `group` / `isolated` (epic ruling record 6259899486 on #15194, item 4).
// 18.0 refuses a walled posture at the operator's boot path, and the walled
// half of the lift lands in 18.x, after the stages that give the walled
// writers an organization of their own. The cut is a POSTURE condition on
// purpose — ⛔ never re-narrow it with a per-table list.
if (isPlatformObjectOutOfTenantAuditScope(object) && this.resolveEnginePosture() !== 'single') {
return undefined;
}
const objectSchema = this._registry.getObject(object) as any;
// A federated object's schema is the REMOTE's (ADR-0015); the platform's
// injected column says nothing about it, which is the same reason
Expand Down
37 changes: 23 additions & 14 deletions packages/objectql/src/system-write-organization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,11 @@ const DISPATCH_ORDER = {
/**
* A platform-namespace object the #13491 inventory did NOT adjudicate.
*
* ⚠️ Its exclusion below is `unclassified`, not `global`. Until the 2026-08-31
* ruling this file read it as "platform namespace ⇒ deliberately org-less
* (…)"; that wholesale reading was withdrawn, and what these cases now pin
* is that an UNADJUDICATED object's behaviour did not move — which is what
* bounds the reclassification's blast radius to the admitted list. The admitted
* and `global` sides are pinned in `tenancy-by-object-classification.test.ts`.
* ⚠️ Its exclusion below is `unclassified`, not `global`, and since ADR-0131
* C8 stage 8b it holds under a WALLED posture only (epic ruling record
* 6259899486 on #15194, item 4). Under `single` the object is judged like any
* other — derived or refused. The admitted and `global` sides are pinned in
* `tenancy-by-object-classification.test.ts`.
*/
const SYS_LEDGER = {
name: 'sys_audit_entry',
Expand Down Expand Up @@ -389,20 +388,28 @@ describe('#8844 multi-organization — the write is REFUSED, never defaulted', (
});

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

it('[C8 stage 8b] single posture: a platform-namespace object is no longer excluded — it takes the sole organization', async () => {
// ADR-0131 D9 / D11: `single` is never a reason for a row to be org-less.
// The walled case above is the control: same object, same write, only the
// posture differs.
const { engine, observed } = await makeEngine({ posture: 'single', organizations: [ORG_ID] });
await engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any);
expect(lastWrite(observed, 'sys_audit_entry')?.options?.tenantId).toBe(ORG_ID);
});

it.each(['isolated', 'single'] as const)(
'an object declaring `tenancy.enabled: false` stays org-less on the %s posture',
async (posture) => {
Expand All @@ -418,8 +425,10 @@ describe('#8844 the exclusions — populations the refusal must not touch', () =
it('never probes `sys_organization` for a write it does not judge', async () => {
// The cost half of the exclusions: an ordinary write must pay two property
// reads and a regexp, not a query.
// [C8 stage 8b] A platform object is judged under `single` now, so it is
// not in this population: only the walled exemption skips its probe, and
// a walled refusal never probes anyway.
const { engine, observed } = await makeEngine({ organizations: [ORG_ID] });
await engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any);
await engine.insert('billing_license', { subject: 'lic' }, { context: SYSTEM_CTX } as any);
await engine.insert('dispatch_order', { subject: 'session' }, { context: SESSION_CTX } as any);
expect(observed.filter((c) => c.object === 'sys_organization')).toEqual([]);
Expand Down
51 changes: 41 additions & 10 deletions packages/objectql/src/tenancy-by-object-classification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,17 +196,48 @@ describe('#13491 gate 1 — an admitted platform object reaches #8844 derive/ref
expect(lastWrite(observed, 'sys_file')?.options?.tenantId).toBe(ORG_ID);
});

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

it('single posture: an UNCLASSIFIED platform object stays org-less — behaviour did not move', async () => {
const { engine, observed } = await makeEngine({ posture: 'single' });
await engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any);
expect(lastWrite(observed, 'sys_audit_entry')?.options?.tenantId).toBeUndefined();
});
it.each([
['no organization', [] as string[], 0],
['two organizations', [ORG_ID, 'org_second'], 2],
] as const)(
'single posture, %s: an org-less system insert on an un-admitted platform object is REFUSED, loudly, and never written',
async (_label, organizations, count) => {
const { engine, observed } = await makeEngine({ posture: 'single', organizations: [...organizations] });
await expect(
engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any),
).rejects.toMatchObject({
code: 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED',
status: 500,
organizationCount: count,
});
expect(lastWrite(observed, 'sys_audit_entry')).toBeUndefined();
},
);

it.each(['isolated', 'group'] as const)(
'%s posture: an UNCLASSIFIED platform object still stays org-less — the walled exemption stands in 18.0',
async (posture) => {
const { engine, observed } = await makeEngine({ posture });
await engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any);
expect(lastWrite(observed, 'sys_audit_entry')?.options?.tenantId).toBeUndefined();
},
);

it.each(['isolated', 'group'] as const)(
'%s posture: an org-less system write on an admitted object is REFUSED, loudly',
Expand Down
29 changes: 25 additions & 4 deletions packages/objectql/src/tenancy/platform-object-tenancy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,26 @@
/**
* [#13491] Per-object tenancy classification for the platform namespaces.
*
* ## ⚠️ What still reads this ledger (ADR-0131 C8 stage 8b)
*
* Under the `single` posture the ledger no longer decides any write: ADR-0131
* D9 made the system-insert rule structural there — a platform object with a
* tenant column is in scope like any other, so its organization-less system
* insert takes the Default Organization or is refused
* (`Engine.resolveSystemInsertOrganization`). Two readers remain, and they
* are why the ledger has not retired yet (D13):
*
* 1. the system-insert exemption under a WALLED posture (`group` /
* `isolated`), which stands in 18.0 by epic ruling record 6259899486 on
* #15194, item 4 — the walled half of the lift lands in 18.x;
* 2. the engine's `bypassTenantAudit` isSystem mute
* (`Engine.buildDriverOptions`), a diagnostics-only reading in every
* posture that changes nothing a write touches.
*
* ⛔ Do not add an entry to make a `single` write behave: under `single` an
* entry changes nothing but the mute. The ledger retires with its walled
* reader.
*
* ## The ruling this implements
*
* Maintainer, 2026-08-31 (总监席第 5 场, 联案 #13491 + #13497, verbatim 「同意」):
Expand Down Expand Up @@ -78,10 +98,11 @@
* ## What admission actually changes, per object
*
* A `tenant-scoped` classification lets the object reach
* `resolveSystemInsertOrganization` (#8844): on a `single` posture with exactly
* one organization the write DERIVES it, and on a walled posture
* (`group` / `isolated`) an org-less write is REFUSED loudly
* ({@link SystemWriteOrganizationRequiredError}). It also stops the engine
* `resolveSystemInsertOrganization` (#8844) on a walled posture
* (`group` / `isolated`), where an org-less write is REFUSED loudly
* ({@link SystemWriteOrganizationRequiredError}). Under `single` every
* platform object with a tenant column reaches it whatever its entry says
* (stage 8b, above). It also stops the engine
* auto-muting the driver's tenant-audit warning for elevated writes on it.
* Both directions are the ruled one — a refusal or a warning, never a silent
* rewrite of what the write touches (execution point 3).
Expand Down
15 changes: 10 additions & 5 deletions packages/objectql/src/tenancy/system-write-organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,11 @@
* org-less rows is `tenancy: { enabled: false }` — a metadata declaration,
* loud and checkable — never a per-write bypass flag, which is exactly the
* lenient-consumer accommodation Prime Directive #12 forbids.
* - **Platform-namespace objects the inventory did not admit** ({@link
* isPlatformObjectOutOfTenantAuditScope}, `platform-object-tenancy.ts`).
* - **Under a WALLED posture only, platform-namespace objects the inventory
* did not admit** ({@link isPlatformObjectOutOfTenantAuditScope},
* `platform-object-tenancy.ts`). Under `single` this exclusion is gone
* (ADR-0131 C8 stage 8b): a platform object with a tenant column takes the
* Default Organization or is refused, like every other object.
*
* ⚠️ This exclusion used to be the whole `sys_` / `cloud_` / `ai_`
* NAMESPACE, on the reasoning commit ff08691e6 recorded, that "an org-less row is defensible for
Expand All @@ -84,8 +87,9 @@
* none by this control. So the exclusion is now a per-object verdict, and an
* object nobody has adjudicated stays excluded and stays ON THE LIST.
*
* ⚠️ This per-object exclusion STAYS until ADR-0131 C8, which retires it
* together with the ledger (D13); C1 withdrew only the seed loader's
* ⚠️ C8 stage 8b lifted this exclusion under `single` only. Under a wall it
* stands in 18.0 (epic ruling record 6259899486 on #15194, item 4) and
* retires with the ledger in 18.x (D13). C1 withdrew the seed loader's
* namespace exemption. #8686's backfill (`seed-tenancy-backfill.ts`) still
* carries the NAMESPACE form; its residue attribution is C7's. Read the
* three together before changing any of them.
Expand Down Expand Up @@ -156,7 +160,8 @@ export const DEFAULT_TENANT_FIELD = 'organization_id';
* #13491's re-ruling (2026-08-31, 联案 #13497) withdrew the wholesale namespace
* reading — but its scope was the RUNTIME control only. So today:
*
* - **the runtime write path** cuts PER OBJECT, by the hand-adjudicated
* - **the runtime write path** no longer cuts under `single` (C8 stage 8b).
* Under a walled posture it cuts PER OBJECT, by the hand-adjudicated
* classification in `platform-object-tenancy.ts` — the
* `PLATFORM_OBJECT_TENANCY` ledger, read through
* {@link classifyPlatformObjectTenancy} and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,10 @@ describe('an approval decision cascades as the deciding user (#3783)', () => {
{
name: 'sys_organization',
label: 'Organization',
// [C8 stage 8b] better-auth owns the table, as `sys-organization.object.ts`
// declares: no injected organization column, so the Default Organization's
// own insert below is not judged as an organization-less platform write.
managedBy: 'better-auth',
fields: { name: { type: 'text', label: 'Name' }, slug: { type: 'text', label: 'Slug' } },
} as any,
'@objectstack/plugin-auth',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,11 @@ describe('Setup identity objects declare enable.feeds: false — a comment creat
const { engine, storeFor } = await boot();

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

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