From 33df2b579d067dda85f570c774770bfad1456f37 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 10:07:23 +0000 Subject: [PATCH 1/7] feat(objectql): declare a legitimately org-less write instead of inferring it from NULL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveSystemInsertOrganization` decided per object plus posture, so an object holding BOTH org-stamped and adjudicated org-less rows fit no verdict the #13491 ledger could express: `tenant-scoped` refuses its own ruled-legitimate writes on a walled install, `global` abandons the org-stamped majority. Both specimens were parked in `unclassified`, which put the tenant-audit control's blind spot on two of the largest write populations in the platform namespace. The root of it is that one `NULL` meant both "deliberate" and "bug". This adds the channel that separates them: a per-write `orgLessWrite` declaration naming its own object and an adjudicated reason, a fourth ledger verdict (`conditional`) that ADMITS such an object into #8844's derive-or-refuse machinery, and a refusal for every declaration the ledger does not admit — so the option has no silently-ignored spelling. The check runs ahead of every early return in the resolver, which is where that property lives. The declaration rides the write OPTIONS, not the row: the post-hook declared-field door judges the row payload and would refuse the declaration before the resolver it exists to inform ever saw it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- .../orgless-write-declaration-channel.md | 47 +++ .github/workflows/lint.yml | 14 + package.json | 1 + .../src/sys-metadata-repository.ts | 31 +- packages/objectql/src/engine.ts | 69 +++- packages/objectql/src/index.ts | 17 + .../tenancy-orgless-write-declaration.test.ts | 304 +++++++++++++++++ .../src/tenancy/orgless-write-declaration.ts | 186 ++++++++++ .../src/tenancy/platform-object-tenancy.ts | 189 ++++++++++- .../src/tenancy/system-write-organization.ts | 12 + .../plugins/plugin-audit/src/audit-writers.ts | 30 +- .../plugin-audit/src/auth-event-audit.ts | 12 +- .../plugins/plugin-audit/src/read-audit.ts | 33 +- .../plugin-auth/src/admin-import-users.ts | 13 +- .../src/config-change-audit.ts | 14 +- scripts/check-orgless-write-declarations.mjs | 318 ++++++++++++++++++ 16 files changed, 1270 insertions(+), 20 deletions(-) create mode 100644 .changeset/orgless-write-declaration-channel.md create mode 100644 packages/objectql/src/tenancy-orgless-write-declaration.test.ts create mode 100644 packages/objectql/src/tenancy/orgless-write-declaration.ts create mode 100644 scripts/check-orgless-write-declarations.mjs diff --git a/.changeset/orgless-write-declaration-channel.md b/.changeset/orgless-write-declaration-channel.md new file mode 100644 index 0000000000..a2dd82d5b4 --- /dev/null +++ b/.changeset/orgless-write-declaration-channel.md @@ -0,0 +1,47 @@ +--- +"@objectstack/objectql": minor +"@objectstack/metadata-protocol": minor +"@objectstack/plugin-audit": minor +"@objectstack/plugin-auth": minor +"@objectstack/service-settings": minor +--- + +feat(objectql): an explicit per-write declaration for a legitimately org-less row + +Until now one `NULL` organization on a platform object meant two different +things — a deliberate environment-level row, and a write that forgot to thread +an organization — and `resolveSystemInsertOrganization` had no way to tell them +apart. So the two objects that hold BOTH populations, `sys_metadata` (whose +non-overridable-type write lands env-wide by adjudication) and `sys_audit_log` +(whose writers enumerate their own legitimate org-less cases), had to stay +outside the tenant-audit control entirely. They are two of the largest write +populations in the platform namespace, which put the control's blind spot +exactly where it was least affordable. + +Writes may now DECLARE that their rows belong to an adjudicated org-less +population: + +```ts +await engine.insert('sys_metadata', row, { + context: { isSystem: true }, + orgLessWrite: { object: 'sys_metadata', reason: 'env-level-metadata' }, +}); +``` + +The platform tenancy ledger gains a fourth verdict, `conditional`, and both +objects are admitted under it. ⚠️ Admission makes them STRICTER, not looser: an +org-less system write on either is now derived on a single-organization install +and refused loudly on a walled one, exactly as a `tenant-scoped` object's is, +and the declaration is the only way through. It is checked against the ledger +before anything else the resolver does, so a declaration naming an object the +ledger has not admitted, a reason that object does not admit, or an object other +than the one being written throws `ERR_ORGLESS_WRITE_DECLARATION_REFUSED` — no +spelling of the option is silently ignored, which is what separates a +declaration from a bypass flag. ⛔ It is not a way to quiet a refusal: a write +that simply forgot to thread an organization is the defect the refusal reports. + +The platform's own six org-less writers declare, each on a test a reader can +check — the metadata repository on its own env-level scope, the audit writers on +whether the audited subject resolves an organization column at all. A new gate, +`pnpm check:orgless-write-declarations`, enumerates every declaration in the +tree, holds each to the same ledger the runtime does, and prints the count. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b699b7ea74..65102cdd81 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3528,6 +3528,20 @@ jobs: - name: Read-side tenant chokepoint gate run: pnpm check:tenant-chokepoint + # [#13636] The 'orgLessWrite' declaration census. The maintainer's + # 2026-08-31 ruling put 'loud, checkable, countable' in the decision text + # rather than in implementation notes; the engine discharges the first two + # at the write, and this is the third — every declaration in the monorepo + # enumerated, held to the same ledger the runtime holds it to, and COUNTED + # on every pass. A declaration nobody can count is the silent marker that + # ruling disqualified by name. + # + # Static scan over tracked sources, no build needed, so it belongs here. + # Runs its own --self-test first: a matcher that stopped matching would + # report OK while reading nothing. + - name: Org-less write declaration gate + run: pnpm check:orgless-write-declarations + # Every committed `pnpm --filter ` must name a real workspace # package (#10853). # diff --git a/package.json b/package.json index da36813abb..369fb1726e 100644 --- a/package.json +++ b/package.json @@ -150,6 +150,7 @@ "check:resume-authority-declared": "node scripts/check-resume-authority-declared.mjs --self-test && node scripts/check-resume-authority-declared.mjs", "check:spec-parsed-alias": "node scripts/check-spec-parsed-alias.mjs --self-test && node scripts/check-spec-parsed-alias.mjs", "check:tenant-chokepoint": "node scripts/check-tenant-chokepoint.mjs --self-test && node scripts/check-tenant-chokepoint.mjs", + "check:orgless-write-declarations": "node scripts/check-orgless-write-declarations.mjs --self-test && node scripts/check-orgless-write-declarations.mjs", "check:stall-guard": "node scripts/run-with-stall-guard.mjs --self-test", "check:stall-guard-budget": "node scripts/check-stall-guard-budget.mjs --self-test && node scripts/check-stall-guard-budget.mjs", "check:stall-guard-headroom": "node scripts/measure-stall-guard-headroom.mjs --self-test", diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index 5c52b34e16..73512b5a50 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -191,7 +191,20 @@ export interface SysMetadataEngine { insert( table: string, data: Record, - options?: { context?: any }, + options?: { + context?: any; + /** + * [#13636] The engine's per-write "these rows are legitimately org-less" + * declaration. Spelled structurally rather than imported: the canonical + * type is `OrgLessWriteDeclarationOptions` in + * `@objectstack/objectql`'s `tenancy/orgless-write-declaration.ts`, and + * THAT package depends on this one — importing it here would be a cycle. + * The engine validates every declaration against its ledger and throws on + * one it does not admit, so a drift between these two spellings fails + * loudly at the write rather than being tolerated. + */ + orgLessWrite?: { object: string; reason: string }; + }, ): Promise<{ id: string }>; update( table: string, @@ -608,7 +621,21 @@ export class SysMetadataRepository implements MetadataRepository { }); } else { parentRowData.created_at = now; - await this.engine.insert('sys_metadata', parentRowData, { context: ctx }); + // [#13636] An ENV-LEVEL repository writes `organization_id: null` on + // purpose — the #6190 option A population, a row that belongs to the + // installation rather than to any organization. `sys_metadata` is + // admitted as `conditional` in the engine's platform tenancy ledger, so + // without this declaration that write is indistinguishable from a + // forgotten stamp and is refused on a walled install. The condition is + // the repository's own scope, fixed at construction, not a property of + // the row being written — so an org-scoped repository never declares, + // and a missing stamp on ITS writes still refuses. + await this.engine.insert('sys_metadata', parentRowData, { + context: ctx, + ...(this.organizationId == null + ? { orgLessWrite: { object: 'sys_metadata', reason: 'env-level-metadata' } } + : {}), + }); } // Durable history append — same transaction, so the parent write diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 116d840815..89a5d705c7 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -136,7 +136,12 @@ import { } from './tenancy/system-write-organization.js'; // [#13491] The per-object tenancy inventory that replaced the blanket // namespace exemption — the ONE reading both narrowed gates consult. -import { isPlatformObjectOutOfTenantAuditScope } from './tenancy/platform-object-tenancy.js'; +import { + assertOrgLessWriteDeclarationAdmitted, + isPlatformObjectOutOfTenantAuditScope, +} from './tenancy/platform-object-tenancy.js'; +import type { OrgLessWriteDeclarationOptions } from './tenancy/orgless-write-declaration.js'; +import { readOrgLessWriteDeclaration } from './tenancy/orgless-write-declaration.js'; import { resolveTenancyPosture } from '@objectstack/types'; import { normalizeTenancyPosture, type TenancyPosture } from '@objectstack/spec/security'; @@ -3940,15 +3945,39 @@ export class ObjectQL implements IObjectQLEngine { * the module header for why each is outside the rule by construction rather * than by exemption. They are ordered cheapest-first, so an ordinary write * pays two property reads and a regexp. + * + * [#13636] `declaredOrgLess` is the write's `orgLessWrite` option — the + * explicit "these rows are legitimately org-less" declaration the 2026-08-31 + * ruling (决裁批 #17, direction B) added so that ONE `NULL` stops meaning both + * "deliberate" and "bug". See `tenancy/orgless-write-declaration.ts`. */ private async resolveSystemInsertOrganization( object: string, execCtx: ExecutionContext | undefined, rows: readonly Record[], + declaredOrgLess?: unknown, ): Promise { + // [#13636] The declaration is validated FIRST — ahead of every early return + // below, and on EVERY object rather than only the conditional ones. + // + // ⚠️ Placement is the load-bearing part, not an ordering preference. Each + // early return below is a population this resolver does not judge; if the + // check sat after them, a declaration on any of those objects would be + // silently ignored, and an option with a silently-ignored spelling is the + // 「静默可选标记」 the ruling disqualified by name — 「那只是给旁路换名」. + // Here, every spelling of the option either names an adjudicated population + // or throws. An ordinary write pays one `undefined` comparison for it. + const orgLessDeclaration = assertOrgLessWriteDeclarationAdmitted(object, declaredOrgLess); // Already carrying an organization on the context — a session write, or a // system write that threaded one. Nothing to resolve; this is the shape the // ruling asks every system write to reach. + // + // A declaration is not contradicted by this branch and does not refuse it: + // it describes the rows this write MAY land org-less, and a write that + // carries an organization simply never lands one. Keeping the two + // independent is what lets a writer state the claim once, at the call site, + // instead of duplicating the engine's own org-less test — which is what + // makes the declaration reviewable and countable where it is written. if (carriesOrganization(execCtx?.tenantId)) return undefined; // [#13491] Platform-namespace objects are excluded PER OBJECT, not // wholesale. The 2026-08-31 ruling withdrew the blanket @@ -3972,6 +4001,17 @@ export class ObjectQL implements IObjectQLEngine { // stamped resolves nothing — and a batch where some are not is decided by // the ones that are not. if (rows.every((row) => carriesOrganization(row?.[tenantField]))) return undefined; + // [#13636] Everything from here down is a row that WILL land org-less. An + // admitted declaration says which adjudicated population it belongs to, so + // there is nothing to derive and nothing to refuse — this is the ruled + // env-level / untenanted-subject row, not a missing stamp. + // + // Reached only after the object has been admitted as `conditional` + // (`isPlatformObjectOutOfTenantAuditScope` above lets it through) AND the + // declaration has been checked against the ledger, so the two halves of the + // ruling's discrimination meet exactly here: declared ⇒ deliberate, + // undeclared ⇒ the derive-or-refuse decision #8844 already owns. + if (orgLessDeclaration !== undefined) return undefined; const posture = this.resolveEnginePosture(); const decision = await resolveSystemWriteOrganization({ @@ -9639,7 +9679,7 @@ export class ObjectQL implements IObjectQLEngine { * says something `DUPLICATE_RECORD` cannot ("re-seeded, re-issued, still * refused"). It carries the driver error as `cause` exactly as before. */ - async insert(object: string, data: any | any[], options?: DataEngineInsertOptions & WriteObservabilityOptions): Promise { + async insert(object: string, data: any | any[], options?: DataEngineInsertOptions & WriteObservabilityOptions & OrgLessWriteDeclarationOptions): Promise { object = this.resolveObjectName(object); this.logger.debug('Insert operation starting', { object, isBatch: Array.isArray(data) }); this.assertWriteAllowed(object, 'insert'); @@ -9934,6 +9974,14 @@ export class ObjectQL implements IObjectQLEngine { object, opCtx.context, rowHookContexts.map((rowCtx) => rowCtx.input.data as Record), + // [#13636] Read off the OPTIONS, not the row payload. A declaration + // carried as a key on the row cannot work: the post-hook declared-field + // door above judges `rowCtx.input.data` and refuses any key the object + // does not declare, so it would refuse the declaration before this + // resolver — the one reader it exists to inform — ever saw it. Widening + // `PLATFORM_PROVISIONED_COLUMNS` is closed off by that door's own ⛔, and + // rightly: a declaration is not a column. + readOrgLessWriteDeclaration(rowHookContexts[0]?.input.options), ); const optionsBase = rowHookContexts[0]?.input.options as any; const driverOptions = this.buildDriverOptions( @@ -10353,7 +10401,7 @@ export class ObjectQL implements IObjectQLEngine { * supplied, so a caller holding the input rows can attribute each name back to * the rows that carried it (`insertManyData` does exactly that). */ - async insertMany(object: string, rows: any[], options?: DataEngineInsertOptions & WriteObservabilityOptions): Promise { + async insertMany(object: string, rows: any[], options?: DataEngineInsertOptions & WriteObservabilityOptions & OrgLessWriteDeclarationOptions): Promise { if (!Array.isArray(rows)) throw new Error('insertMany expects an array of rows'); return this.insert(object, rows, { ...(options ?? {}), __partialRowErrors: true } as any); } @@ -14015,15 +14063,24 @@ export class ObjectRepository implements IScopedObjectRepository { }); } - async insert(data: any): Promise { + /** + * [#13636] `options` carries the write-side knobs this handle has no other + * way to reach — today the `orgLessWrite` declaration, which the platform's + * own audit writers thread through `api.sudo().object(name).create(row)` and + * could otherwise only reach by abandoning that idiom for a bare + * `engine.insert`. The execution context stays this handle's to supply; a + * caller-passed `context` would make the scope a suggestion. + */ + async insert(data: any, options?: OrgLessWriteDeclarationOptions): Promise { return this.engine.insert(this.objectName, data, { + ...options, context: this.context, }); } /** Alias for insert() — matches @objectql/core convention */ - async create(data: any): Promise { - return this.insert(data); + async create(data: any, options?: OrgLessWriteDeclarationOptions): Promise { + return this.insert(data, options); } async update(data: any, options: any = {}): Promise { diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 9a6dc785f3..8e778afdf3 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -356,12 +356,29 @@ export { // the engine; the ledger itself is hand-adjudicated, never derived. export { PLATFORM_OBJECT_TENANCY, + admittedOrgLessReasons, + assertOrgLessWriteDeclarationAdmitted, classifyPlatformObjectTenancy, + conditionalPlatformObjects, isPlatformObjectOutOfTenantAuditScope, tenantScopedPlatformObjects, type PlatformObjectTenancy, type PlatformObjectTenancyEntry, } from './tenancy/platform-object-tenancy.js'; + +// [#13636] The explicit per-write "legitimately org-less" declaration — the +// 2026-08-31 ruling's channel for telling a deliberate env-level / untenanted +// row from a missing organization stamp. Exported so the writers of the +// admitted objects can type their declaration, and so the gate that counts +// declarations can read the vocabulary from one place. +export { + ORG_LESS_WRITE_REASONS, + OrgLessWriteDeclarationRefusedError, + readOrgLessWriteDeclaration, + type OrgLessWriteDeclaration, + type OrgLessWriteDeclarationOptions, + type OrgLessWriteReason, +} from './tenancy/orgless-write-declaration.js'; export type { SystemWriteOrganizationDecision, SystemWriteRefusalReason, diff --git a/packages/objectql/src/tenancy-orgless-write-declaration.test.ts b/packages/objectql/src/tenancy-orgless-write-declaration.test.ts new file mode 100644 index 0000000000..3b1869982a --- /dev/null +++ b/packages/objectql/src/tenancy-orgless-write-declaration.test.ts @@ -0,0 +1,304 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ── The explicit per-write "legitimately org-less" declaration (#13636) ────── +// +// Maintainer ruling, 2026-08-31 (总监席第 7 场决裁批 #17, verbatim 「同意」), +// direction B: +// +// 1. 「平台获得一个显式的每写入「合法 org-less」申报通道, +// `resolveSystemInsertOrganization` 据此区分「有意的环境级/无租户行」与 +// 「漏 stamp 的 bug」。同一个 NULL 不再身兼两义。」 +// 2. 「申报必须 loud, checkable, countable ... 静默可选标记不合格 —— 那只是给 +// 旁路换名。」 +// +// ## What each half of this file discriminates +// +// The two halves fail differently and both are pinned, because a file that +// pinned only one of them would stay green through the failure that matters: +// +// 1. **The discrimination.** A declared org-less write is accepted and a +// "same write, no declaration" CONTROL is refused, run through the same +// engine on the same posture. Pinning only the acceptance would stay green +// if admission had quietly stopped happening — the object would sail +// through as `unclassified` and the control would still "pass"; pinning +// only the refusal would stay green if the declaration did nothing at all. +// 2. **The anti-silent-marker property.** Every unadmitted spelling THROWS, +// including on objects whose writes this resolver never judges. That is +// what makes the option a declaration rather than a renamed bypass, and it +// is a property of WHERE the check sits — ahead of every early return — so +// it is pinned on the early-return objects specifically, where a check +// placed one line lower would silently ignore it. +// +// Refusals are asserted on `code` + `status` (ADR-0112), never on a bare +// `toThrow()`: a plain `Error` from anywhere in the pipeline satisfies that and +// would leave both directions of this file green while the control was dead. + +import { describe, it, expect } from 'vitest'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { ObjectQL } from './engine.js'; +import { + PLATFORM_OBJECT_TENANCY, + admittedOrgLessReasons, + classifyPlatformObjectTenancy, + conditionalPlatformObjects, + isPlatformObjectOutOfTenantAuditScope, +} from './tenancy/platform-object-tenancy.js'; + +const ORG_ID = 'org_msokm9oaz0cal87q'; +const SYSTEM_CTX: ExecutionContext = { isSystem: true } as ExecutionContext; +const PACKAGE_ID = '#13636'; + +const ENV_METADATA = { orgLessWrite: { object: 'sys_metadata', reason: 'env-level-metadata' } } as any; + +interface ObservedCall { + object: string; + method: string; + options: Record | undefined; +} + +function makeDriver(observed: ObservedCall[], organizations: string[]) { + const record = (object: string, method: string, options: any) => + observed.push({ object, method, options }); + return { + name: 'memory', + version: '0.0.0', + supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, _ast: any, options: any) { + record(object, 'find', options); + return object === 'sys_organization' ? organizations.map((id) => ({ id })) : []; + }, + async findOne() { return null; }, + async count() { return 0; }, + async create(object: string, data: any, options: any) { + record(object, 'create', options); + return { id: 'r_1', ...data }; + }, + async update(object: string, id: string, data: any, options: any) { + record(object, 'update', options); return { id, ...data }; + }, + async delete() { return true; }, + async bulkCreate(object: string, rows: any[], options: any) { + record(object, 'bulkCreate', options); + return rows.map((r, i) => ({ id: `r_${i + 1}`, ...r })); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async syncSchema() {}, + } as any; +} + +/** CONDITIONAL — #6190 option A's env-wide write is its adjudicated org-less half. */ +const SYS_METADATA = { name: 'sys_metadata', fields: { type: { type: 'text' } } } as any; +/** CONDITIONAL — its writers' enumerated untenanted-subject rows. */ +const SYS_AUDIT_LOG = { name: 'sys_audit_log', fields: { action: { type: 'text' } } } as any; +/** TENANT-SCOPED — in the machinery, but with NO org-less population to declare. */ +const SYS_FILE = { name: 'sys_file', fields: { key: { type: 'text' } } } as any; +/** UNCLASSIFIED — the resolver returns early for it, which is the point. */ +const SYS_UNADJUDICATED = { name: 'sys_audit_entry', fields: { subject: { type: 'text' } } } as any; +/** An application object: never in the platform exemption at all. */ +const DISPATCH_ORDER = { name: 'dispatch_order', fields: { subject: { type: 'text' } } } as any; +const ORG_OBJECT = { name: 'sys_organization', fields: { name: { type: 'text' } } } as any; + +async function makeEngine(opts: { posture?: string; organizations?: string[] } = {}) { + const observed: ObservedCall[] = []; + const engine = new ObjectQL(); + engine.registerDriver(makeDriver(observed, opts.organizations ?? [ORG_ID]), true); + await engine.init(); + for (const o of [SYS_METADATA, SYS_AUDIT_LOG, SYS_FILE, SYS_UNADJUDICATED, DISPATCH_ORDER, ORG_OBJECT]) { + engine.registry.registerObject(o, PACKAGE_ID); + } + if (opts.posture) engine.setTenancyPostureProvider(() => opts.posture as any); + return { engine, observed }; +} + +const lastWrite = (observed: ObservedCall[], object: string) => + [...observed].reverse().find((c) => c.object === object && c.method !== 'find'); + +/** The ADR-0112 envelope both refusals in this area carry. */ +async function expectRefusal(promise: Promise, code: string) { + await expect(promise).rejects.toMatchObject({ code, status: 500 }); +} + +describe('#13636 the ledger — `conditional` is the fourth verdict', () => { + it('admits exactly the two specimens the ruling named, as a LIST', () => { + // Pinned as a list rather than a count: constraint 3 fixes the FIRST BATCH + // at these two and requires every later member to arrive with its own + // writer evidence — 「⛔ 不从 #13491 的 51 只 cannot-determine 里凭猜挑成员」. + // A silent arrival here is a member picked by guess. + expect(conditionalPlatformObjects()).toEqual(['sys_audit_log', 'sys_metadata']); + expect(classifyPlatformObjectTenancy('sys_metadata')).toBe('conditional'); + expect(classifyPlatformObjectTenancy('sys_audit_log')).toBe('conditional'); + }); + + it('a conditional object is ADMITTED into the machinery, not exempted from it', () => { + // The direction that matters: `conditional` is the strictest verdict in the + // ledger, so it answers the same as `tenant-scoped` here. An implementation + // that read it as a softer `global` would answer `true` and quietly restore + // the blindness this card exists to remove. + expect(isPlatformObjectOutOfTenantAuditScope('sys_metadata')).toBe(false); + expect(isPlatformObjectOutOfTenantAuditScope('sys_audit_log')).toBe(false); + // Controls on both sides of it. + expect(isPlatformObjectOutOfTenantAuditScope('sys_file')).toBe(false); + expect(isPlatformObjectOutOfTenantAuditScope('sys_permission_set')).toBe(true); + expect(isPlatformObjectOutOfTenantAuditScope('sys_audit_entry')).toBe(true); + }); + + it('every conditional entry admits at least one reason, and no other verdict admits any', () => { + for (const [name, entry] of Object.entries(PLATFORM_OBJECT_TENANCY)) { + if (entry.tenancy === 'conditional') { + expect(entry.orgLessReasons?.length, name).toBeGreaterThan(0); + } else { + // A reason is never admissible everywhere: the channel checks the PAIR. + expect(admittedOrgLessReasons(name), name).toEqual([]); + } + } + expect(admittedOrgLessReasons('sys_metadata')).toEqual(['env-level-metadata']); + expect(admittedOrgLessReasons('sys_audit_log')).toEqual(['audit-of-untenanted-record']); + }); +}); + +describe('#13636 the discrimination — one NULL stops meaning two things', () => { + it.each(['isolated', 'group'] as const)( + '%s posture: the DECLARED env-level write lands org-less', + async (posture) => { + const { engine, observed } = await makeEngine({ posture }); + await engine.insert('sys_metadata', { type: 'datasource' }, { + context: SYSTEM_CTX, + ...ENV_METADATA, + } as any); + // Nothing was derived and nothing was stamped: this is the #6190 option A + // row, which belongs to the installation. + expect(lastWrite(observed, 'sys_metadata')?.options?.tenantId).toBeUndefined(); + }, + ); + + it.each(['isolated', 'group'] as const)( + '%s posture: the SAME write UNDECLARED is refused — the control', + async (posture) => { + const { engine } = await makeEngine({ posture }); + await expectRefusal( + engine.insert('sys_metadata', { type: 'datasource' }, { context: SYSTEM_CTX } as any), + 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED', + ); + }, + ); + + it('single posture: an UNDECLARED org-less write derives the organization', async () => { + // The other half of admission. Without this the acceptance test above would + // stay green even if `conditional` had silently become an exemption. + const { engine, observed } = await makeEngine({ posture: 'single' }); + await engine.insert('sys_audit_log', { action: 'create' }, { context: SYSTEM_CTX } as any); + expect(lastWrite(observed, 'sys_audit_log')?.options?.tenantId).toBe(ORG_ID); + }); + + it('single posture: a DECLARED write is NOT given the derived organization', async () => { + const { engine, observed } = await makeEngine({ posture: 'single' }); + await engine.insert('sys_audit_log', { action: 'create' }, { + context: SYSTEM_CTX, + orgLessWrite: { object: 'sys_audit_log', reason: 'audit-of-untenanted-record' }, + } as any); + expect(lastWrite(observed, 'sys_audit_log')?.options?.tenantId).toBeUndefined(); + }); + + it('a row that names its own organization is untouched by the declaration', async () => { + // The declaration describes the rows a write MAY land org-less; a write that + // carries an organization simply never lands one. Keeping the two + // independent is what lets a writer state the claim once at the call site. + const { engine, observed } = await makeEngine({ posture: 'isolated' }); + await engine.insert('sys_metadata', { type: 'datasource', organization_id: ORG_ID }, { + context: SYSTEM_CTX, + ...ENV_METADATA, + } as any); + expect(lastWrite(observed, 'sys_metadata')?.options?.tenantId).toBeUndefined(); + }); +}); + +describe('#13636 the declaration is not a bypass — every unadmitted spelling THROWS', () => { + it('refuses a declaration on a TENANT-SCOPED object, which has no org-less population', async () => { + const { engine } = await makeEngine({ posture: 'single' }); + await expectRefusal( + engine.insert('sys_file', { key: 'k1' }, { + context: SYSTEM_CTX, + orgLessWrite: { object: 'sys_file', reason: 'env-level-metadata' }, + } as any), + 'ERR_ORGLESS_WRITE_DECLARATION_REFUSED', + ); + }); + + it('refuses a reason the object does not admit, even though another object does', async () => { + const { engine } = await makeEngine({ posture: 'isolated' }); + await expectRefusal( + engine.insert('sys_metadata', { type: 'datasource' }, { + context: SYSTEM_CTX, + orgLessWrite: { object: 'sys_metadata', reason: 'audit-of-untenanted-record' }, + } as any), + 'ERR_ORGLESS_WRITE_DECLARATION_REFUSED', + ); + }); + + it('refuses a declaration that names an object OTHER than the one being written', async () => { + // What stops a declaration riding a shared sudo context or a spread options + // bag onto a different object's row. + const { engine } = await makeEngine({ posture: 'isolated' }); + await expectRefusal( + engine.insert('sys_audit_log', { action: 'create' }, { + context: SYSTEM_CTX, + ...ENV_METADATA, + } as any), + 'ERR_ORGLESS_WRITE_DECLARATION_REFUSED', + ); + }); + + it.each([ + ['an unclassified platform object', 'sys_audit_entry', { subject: 'e1' }], + ['an application object', 'dispatch_order', { subject: 'o1' }], + ])( + 'refuses a declaration on %s — the object the resolver returns EARLY for', + async (_label, object, row) => { + // ⭐ The placement pin. Each of these exits the resolver before the + // posture is ever read, so a check written one line lower would IGNORE the + // declaration here — and an option with a silently-ignored spelling is the + // 「静默可选标记」 the ruling disqualified by name. + const { engine } = await makeEngine({ posture: 'single' }); + await expectRefusal( + engine.insert(object, row, { + context: SYSTEM_CTX, + orgLessWrite: { object, reason: 'env-level-metadata' }, + } as any), + 'ERR_ORGLESS_WRITE_DECLARATION_REFUSED', + ); + }, + ); + + it('refuses an unadmitted declaration even when the context CARRIES an organization', async () => { + // The very first early return in the resolver. A write carrying an + // organization resolves nothing — but a bogus claim on it is still a claim, + // and this is the branch a check placed after the early returns would miss + // on every ordinary session write in the platform. + const { engine } = await makeEngine({ posture: 'isolated' }); + await expectRefusal( + engine.insert('sys_file', { key: 'k1' }, { + context: { isSystem: true, tenantId: ORG_ID } as ExecutionContext, + orgLessWrite: { object: 'sys_file', reason: 'env-level-metadata' }, + } as any), + 'ERR_ORGLESS_WRITE_DECLARATION_REFUSED', + ); + }); + + it.each([ + ['a bare boolean', true], + ['a string', 'env-level-metadata'], + ['an array', [{ object: 'sys_metadata', reason: 'env-level-metadata' }]], + ['an object naming no `object`', { reason: 'env-level-metadata' }], + ])('refuses %s in the declaration slot', async (_label, declared) => { + const { engine } = await makeEngine({ posture: 'isolated' }); + await expectRefusal( + engine.insert('sys_metadata', { type: 'datasource' }, { + context: SYSTEM_CTX, + orgLessWrite: declared, + } as any), + 'ERR_ORGLESS_WRITE_DECLARATION_REFUSED', + ); + }); +}); diff --git a/packages/objectql/src/tenancy/orgless-write-declaration.ts b/packages/objectql/src/tenancy/orgless-write-declaration.ts new file mode 100644 index 0000000000..1da0a71f91 --- /dev/null +++ b/packages/objectql/src/tenancy/orgless-write-declaration.ts @@ -0,0 +1,186 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#13636] The explicit per-write "this row is legitimately org-less" + * DECLARATION — the channel that stops one `NULL` meaning two things. + * + * ## The ruling this implements + * + * Maintainer, 2026-08-31 (总监席第 7 场决裁批 #17, verbatim 「同意」), direction + * **B** with five constraints. The two that shape this file: + * + * 1. 「平台获得一个显式的每写入「合法 org-less」申报通道, + * `resolveSystemInsertOrganization` 据此区分「有意的环境级/无租户行」与 + * 「漏 stamp 的 bug」。**同一个 NULL 不再身兼两义。**」 + * 2. 「申报必须 loud, checkable, countable ... 静默可选标记不合格 —— 那只是 + * 给旁路换名。」 + * + * ## The defect the declaration closes + * + * `resolveSystemInsertOrganization` (#8844) decides PER OBJECT plus posture. An + * object whose rows are sometimes org-stamped and sometimes legitimately + * org-less — `sys_metadata`'s env-level write (#6190 option A), + * `sys_audit_log`'s record-with-no-organization row — fits neither verdict the + * #13491 ledger could express: `tenant-scoped` refuses its own ruled-legitimate + * writes on a walled install, `global` gives up on the org-stamped majority. + * Both specimens were therefore left `unclassified`, which is where the + * control's two largest write populations went to die. + * + * The missing fact is not about the OBJECT. It is about the ROW, and only the + * writer knows it. So the writer states it, per write. + * + * ## ⛔ Why this is not the per-write bypass flag `system-write-organization.ts` + * forbids + * + * That module's header says, and still says, that a deliberately org-less + * population is declared on the OBJECT (`tenancy: { enabled: false }`, + * ADR-0066) and "never a per-write bypass flag, which is exactly the + * lenient-consumer accommodation Prime Directive #12 forbids". That sentence is + * about a BYPASS — an optional marker whose presence widens what is allowed and + * whose absence costs nothing. This is its opposite on all three counts, which + * is the whole of why the 2026-08-31 ruling could order it: + * + * - **It narrows, it does not widen.** A `conditional` classification ADMITS + * an object into #8844's machinery that was excluded before. Every org-less + * write on it is refused on a walled posture unless declared. The object + * gets STRICTER; the declaration is the ruled way back through, not a way + * around. + * - **Misuse is a refusal, not a no-op.** A declaration naming an object the + * ledger has not admitted, a reason that object does not admit, or an object + * other than the one being written, throws + * {@link OrgLessWriteDeclarationRefusedError}. There is no spelling of this + * option that is silently ignored — which is the precise property that + * separates a declaration from a renamed bypass, and the reason the check + * runs BEFORE every early return in the resolver rather than after them. + * - **It cannot travel.** The declaration names its own object and is checked + * against the write's object. A sudo `ExecutionContext` threaded through + * twenty writes cannot carry one object's declaration onto another's row. + * + * ## loud · checkable · countable — where each word is discharged + * + * - **loud** — the option is spelled at the call site in the writer's own + * source, next to the row it describes, and every misuse throws. A reviewer + * reading the writer sees the claim; a writer that gets it wrong stops. + * - **checkable** — the claim is validated against + * `PLATFORM_OBJECT_TENANCY`'s `conditional` entries, each of which carries a + * citable writer fact. The admission bar is the ledger's, not this file's. + * - **countable** — `scripts/check-orgless-write-declarations.mjs` enumerates + * every declaration in the monorepo, holds each to the ledger, and prints + * the count. That gate is why the option is a PLAIN LITERAL KEY rather than + * a factory call: `@objectstack/metadata-protocol` writes `sys_metadata` and + * **cannot import from `@objectstack/objectql`** — objectql depends on IT, + * so the edge would be a cycle. One spelling that every package can write is + * countable by `git grep` from anywhere in the tree; a factory would be + * countable only in the packages that happen to sit downstream of objectql. + * The type-safety a factory would have bought is bought instead by the + * runtime refusal above and by that gate, which rejects a non-literal + * argument outright. + */ + +/** + * Why a row on a `conditional` object is legitimately org-less. + * + * A CLOSED vocabulary, deliberately: an open string would let a writer invent a + * justification at the call site, which is the unwritten-rule shape this + * channel exists to remove. Each member cites the adjudication that makes its + * population legitimate, and `PLATFORM_OBJECT_TENANCY` decides which objects + * may use which member — a reason is never admissible everywhere. + */ +export type OrgLessWriteReason = + /** + * The #6190 ruling (option A): a non-overridable metadata type's write lands + * ENV-WIDE, belonging to the installation rather than to any organization. + * A deliberately org-less row by adjudication. + */ + | 'env-level-metadata' + /** + * An audit record whose SUBJECT has no organization to inherit — a row on an + * object with no organization column at all (single-tenant stacks and + * ADR-0066 platform-global objects), or one whose organization column is + * itself NULL. The enumeration is the audit writer's own + * (`plugin-audit/src/audit-writers.ts`); this names it. + */ + | 'audit-of-untenanted-record'; + +/** Every member of {@link OrgLessWriteReason}, for the gate and the tests. */ +export const ORG_LESS_WRITE_REASONS: readonly OrgLessWriteReason[] = [ + 'env-level-metadata', + 'audit-of-untenanted-record', +]; + +/** + * One write's claim that the rows it carries are legitimately org-less. + * + * `object` is REDUNDANT with the write's own object and that is the point: it + * is what stops a declaration riding a shared context or a spread options bag + * onto a different object's row. The resolver compares the two and refuses a + * mismatch. + */ +export interface OrgLessWriteDeclaration { + /** The object this declaration is about. Must equal the object being written. */ + readonly object: string; + /** Which adjudicated population the rows belong to. */ + readonly reason: OrgLessWriteReason; +} + +/** + * The write-option surface the declaration travels on. + * + * Intersected into `ObjectQL.insert` / `insertMany` in `engine.ts` rather than + * declared on `DataEngineInsertOptionsSchema`: `packages/spec` is a + * single-owner surface, and the option is an objectql-side control knob rather + * than part of the published data-engine contract. Every member optional, so an + * options bag that satisfies the spec contract still satisfies this one. + */ +export interface OrgLessWriteDeclarationOptions { + /** [#13636] See {@link OrgLessWriteDeclaration}. */ + orgLessWrite?: OrgLessWriteDeclaration; +} + +/** + * Read a declaration off an options bag without trusting its shape. + * + * Returns `undefined` for an absent option and for a present one that is not an + * object — the latter reaches {@link assertOrgLessWriteDeclarationAdmitted} as + * a malformed declaration only if it is object-shaped. A non-object value + * (`true`, a string) is refused by the caller rather than silently accepted, + * which is why this returns the raw value's presence separately. + */ +export function readOrgLessWriteDeclaration(options: unknown): unknown { + if (options == null || typeof options !== 'object') return undefined; + return (options as { orgLessWrite?: unknown }).orgLessWrite; +} + +/** + * The refusal for a declaration the ledger does not admit. + * + * Identified by `code` rather than `instanceof`, the convention every engine + * error in this area follows so the check survives crossing a package boundary + * where two copies of this module can exist. `status` is 500 for the same + * reason `SystemWriteOrganizationRequiredError` is: the fault is in SERVER-side + * code that made a claim it is not entitled to make, and blaming a 4xx at the + * HTTP client that happened to trigger it would both accuse the wrong party and + * mark the fault `isExpectedDataStatus`, which stops it being logged at all. + */ +export class OrgLessWriteDeclarationRefusedError extends Error { + readonly code = 'ERR_ORGLESS_WRITE_DECLARATION_REFUSED' as const; + readonly status = 500; + + constructor( + public readonly object: string, + public readonly detail: string, + ) { + super( + `Insert on '${object}' was REFUSED: its 'orgLessWrite' declaration is not admitted — ${detail}. ` + + `A declaration asserts that the rows of this write belong to an ADJUDICATED org-less population ` + + `(#13636, maintainer ruling 2026-08-31), so it is checked against the platform tenancy ledger ` + + `(PLATFORM_OBJECT_TENANCY, 'platform-object-tenancy.ts') and never taken on trust. Nothing was ` + + `written. Fix it by declaring the object this write targets with a reason that object admits, or ` + + `— if this object really does hold a ruled org-less population — by admitting it in the ledger ` + + `as 'conditional' with the citable writer fact the admission bar requires. ⛔ Do not reach for ` + + `this option to silence a refusal: a write that simply forgot to thread an organization is the ` + + `defect the refusal exists to report.`, + ); + this.name = 'OrgLessWriteDeclarationRefusedError'; + } +} diff --git a/packages/objectql/src/tenancy/platform-object-tenancy.ts b/packages/objectql/src/tenancy/platform-object-tenancy.ts index c439d95cce..d605133754 100644 --- a/packages/objectql/src/tenancy/platform-object-tenancy.ts +++ b/packages/objectql/src/tenancy/platform-object-tenancy.ts @@ -42,7 +42,7 @@ * half is not a runtime fact. It is a fact about the CODE, established once by * inventory and written down here. * - * ## The three classifications, and why `unclassified` is not a failure + * ## The four classifications, and why `unclassified` is not a failure * * The ruling's execution point 2 makes the escape hatch mandatory: * 「判不了的逐个列出回批呈裁,⛔ 不猜」 — an object whose tenancy cannot be @@ -60,19 +60,50 @@ * repair, or a writer that stamps the column — and an entry without one is the * guess the ruling forbids. * + * ## `conditional`, the fourth verdict (#13636) + * + * The 2026-08-31 ruling above cut the question two ways, and while implementing + * it a THIRD state was measured that neither verdict fits: an object holding + * both org-stamped rows and a ruled org-less population, where which one is + * correct is a property of the ROW. `tenant-scoped` would refuse that object's + * own adjudicated writes on a walled install; `global` would abandon its + * org-stamped majority. Both specimens were therefore parked in + * `unclassified` — and `sys_metadata` and `sys_audit_log` are two of the + * largest write populations in the platform namespace, so parking them is where + * the control's coverage went to die. + * + * The maintainer ruled the fourth verdict in on 2026-08-31 (总监席第 7 场决裁 + * 批 #17, direction B): the platform gets an explicit per-write declaration, and + * the resolver uses it to tell 「有意的环境级/无租户行」 from 「漏 stamp 的 + * bug」 — 「同一个 NULL 不再身兼两义」. See `orgless-write-declaration.ts` for + * the channel and for why it is not the per-write bypass flag + * `system-write-organization.ts` forbids. + * + * ⛔ The admission bar here is STRICTER than `tenant-scoped`'s, not looser: an + * entry must cite a writer that demonstrably produces BOTH populations. The + * ruling fixes the first batch at exactly the two specimens it named and + * requires each later member to arrive with its own writer evidence — ⛔ never + * picked out of the unclassified list by guess. + * * ## 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 + * ({@link SystemWriteOrganizationRequiredError}). A `conditional` one reaches + * the same decision by the same route, with one addition: a write carrying an + * `orgLessWrite` declaration this ledger admits for that object resolves + * nothing and is written org-less, which is the adjudicated population. 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). */ import { isPlatformNamespaceObject } from './system-write-organization'; +import type { OrgLessWriteDeclaration, OrgLessWriteReason } from './orgless-write-declaration'; +import { OrgLessWriteDeclarationRefusedError } from './orgless-write-declaration'; /** * What the one-time inventory concluded about one platform-namespace object. @@ -87,14 +118,34 @@ export type PlatformObjectTenancy = | 'tenant-scoped' /** #8672's reasoning inherits: rows are deliberately org-less. Out of scope. */ | 'global' + /** + * [#13636] BOTH populations, decided per ROW rather than per object: the + * object holds org-stamped rows AND a ruled org-less population, and only the + * writer knows which one a given write is. In scope EXACTLY LIKE + * `tenant-scoped` — every org-less write is derived or refused — except that + * a write carrying an admitted `orgLessWrite` declaration + * ({@link OrgLessWriteReason}) is the ruled population and resolves nothing. + * + * ⛔ This is the strictest verdict in the ledger, not a softer one. Promoting + * an object here ADMITS it into #8844's machinery; `unclassified` is what + * leaves behaviour where it is. + */ + | 'conditional' /** Not determinable from the tree. Out of scope, PENDING ADJUDICATION. */ | 'unclassified'; /** One inventory entry: the verdict plus the evidence it was reached on. */ export interface PlatformObjectTenancyEntry { readonly tenancy: PlatformObjectTenancy; - /** Why. A `tenant-scoped` or `global` entry must cite a source. */ + /** Why. A `tenant-scoped`, `global` or `conditional` entry must cite a source. */ readonly evidence: string; + /** + * [#13636] Which org-less populations THIS object admits. Required on a + * `conditional` entry and meaningless on every other verdict — a reason is + * never admissible everywhere, so the declaration channel checks the pair + * (object, reason) rather than the reason alone. + */ + readonly orgLessReasons?: readonly OrgLessWriteReason[]; } /** @@ -190,6 +241,43 @@ export const PLATFORM_OBJECT_TENANCY: Readonly name) .sort(); } + +/** + * [#13636] Every object admitted as `conditional`, for the gate, the tests and + * the census. The gate reads this list to hold every `orgLessWrite` declaration + * in the monorepo to the ledger, which is the "checkable" half of the ruling's + * three words. + */ +export function conditionalPlatformObjects(): readonly string[] { + return Object.entries(PLATFORM_OBJECT_TENANCY) + .filter(([, e]) => e.tenancy === 'conditional') + .map(([name]) => name) + .sort(); +} + +/** + * [#13636] The org-less populations `object` admits, or an EMPTY list for every + * object that admits none. + * + * Empty is the answer for an unlisted object, for a `tenant-scoped` one and for + * a `global` one alike, and all three mean the same thing to the caller: this + * object has no adjudicated org-less population, so no declaration naming it can + * be honoured. + */ +export function admittedOrgLessReasons(object: string): readonly OrgLessWriteReason[] { + const entry = PLATFORM_OBJECT_TENANCY[object]; + if (entry?.tenancy !== 'conditional') return []; + return entry.orgLessReasons ?? []; +} + +/** + * [#13636] Validate one write's `orgLessWrite` option against the ledger, or + * THROW. + * + * Returns the admitted declaration, or `undefined` when the write carries none + * — the ordinary path, which pays one `undefined` comparison. + * + * ⚠️ Every failure here is a THROW rather than a "treat it as absent". That is + * the ruling's 「静默可选标记不合格」 made mechanical: if a malformed or + * unadmitted declaration were ignored, the option would have a silent spelling, + * and a silent spelling is the renamed bypass the ruling disqualified. It also + * means the check cannot be moved below the resolver's early returns — an + * ignored declaration on an object that returns early is exactly the silence + * this refuses. + * + * @param object the object the write actually targets — compared against the + * declaration's own `object`, which is what stops a declaration on a shared + * context or a spread options bag reaching a different object's row. + */ +export function assertOrgLessWriteDeclarationAdmitted( + object: string, + declared: unknown, +): OrgLessWriteDeclaration | undefined { + if (declared === undefined) return undefined; + if (declared === null || typeof declared !== 'object' || Array.isArray(declared)) { + throw new OrgLessWriteDeclarationRefusedError( + object, + `it is not a declaration object (received ${Array.isArray(declared) ? 'an array' : typeof declared})`, + ); + } + const { object: declaredObject, reason } = declared as { object?: unknown; reason?: unknown }; + if (typeof declaredObject !== 'string' || declaredObject === '') { + throw new OrgLessWriteDeclarationRefusedError(object, "it names no 'object'"); + } + if (declaredObject !== object) { + throw new OrgLessWriteDeclarationRefusedError( + object, + `it declares '${declaredObject}', which is not the object being written`, + ); + } + const admitted = admittedOrgLessReasons(object); + if (admitted.length === 0) { + throw new OrgLessWriteDeclarationRefusedError( + object, + `the ledger does not classify '${object}' as 'conditional', so it has no adjudicated org-less ` + + 'population to declare', + ); + } + if (typeof reason !== 'string' || !admitted.includes(reason as OrgLessWriteReason)) { + throw new OrgLessWriteDeclarationRefusedError( + object, + `'${String(reason)}' is not a reason '${object}' admits (it admits: ${admitted.join(', ')})`, + ); + } + return { object, reason: reason as OrgLessWriteReason }; +} diff --git a/packages/objectql/src/tenancy/system-write-organization.ts b/packages/objectql/src/tenancy/system-write-organization.ts index 345138037f..1ffe9f61b2 100644 --- a/packages/objectql/src/tenancy/system-write-organization.ts +++ b/packages/objectql/src/tenancy/system-write-organization.ts @@ -65,6 +65,18 @@ * 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. + * + * ⚠️ [#13636] The sentence above is about a WHOLE-OBJECT population and it + * still stands. It does not cover the third class the maintainer ruled on + * 2026-08-31 (决裁批 #17): an object holding BOTH populations, where org-less + * is a property of the ROW. `tenancy: { enabled: false }` cannot state that + * — it would abandon the object's org-stamped majority — so the ruling added + * a per-write DECLARATION for it. ⛔ That is not the bypass flag this + * paragraph forbids and must not be read as one: it NARROWS (admitting an + * object puts every undeclared org-less write of it in front of the refusal + * below), it names its own object so it cannot travel, and every unadmitted + * spelling of it throws instead of being ignored. See + * `orgless-write-declaration.ts`, which argues all three. * - **Platform-namespace objects the inventory did not admit** ({@link * isPlatformObjectOutOfTenantAuditScope}, `platform-object-tenancy.ts`). * diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts index 8ba32dcf33..1a703cfd66 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.ts @@ -719,9 +719,10 @@ export function installAuditWriters( api: any, auditRow: Record, activityRow: Record | undefined, + auditWriteOptions?: { orgLessWrite?: { object: string; reason: string } }, ): Promise => { const sys = api.sudo(); - await sys.object('sys_audit_log').create(auditRow); + await sys.object('sys_audit_log').create(auditRow, auditWriteOptions); if (activityRow) await sys.object('sys_activity').create(activityRow); }; @@ -1387,7 +1388,32 @@ export function installAuditWriters( // (Comment @mention notifications remain a platform behavior — they are // handled separately by the sys_comment hook below, since SKIP_OBJECTS // excludes it from this writer.) - await persistAuditTrailRow(api, auditRow, activitiesEnabled ? activityRow : undefined); + // [#13636] Declare the ruled org-less population, and ONLY it. + // + // `sys_audit_log` is admitted as `conditional` in the engine's platform + // tenancy ledger, so an undeclared org-less audit row now takes #8844's + // derive-or-refuse decision. The half of this writer's org-less output + // that is ADJUDICATED legitimate is case 1 of the enumeration above — a + // record on an object that has no organization column at all + // (single-tenant stacks, ADR-0066 platform-global objects) — and + // `organizationFieldFor` answers exactly that question, from the schema, + // for the SUBJECT of the row. + // + // ⛔ Deliberately NOT declared unconditionally. Case 2 (the subject's own + // column is NULL) is byte-for-byte indistinguishable at this call site + // from the missing-stamp defect this control exists to find — #9516 was + // precisely that, on these lines — so blessing it here would hand the + // platform's largest write population back the blindness the 2026-08-31 + // ruling admitted this object to remove. + const subjectHasNoOrganization = recordOrgResolver.organizationFieldFor(ctx.object) === null; + await persistAuditTrailRow( + api, + auditRow, + activitiesEnabled ? activityRow : undefined, + subjectHasNoOrganization + ? { orgLessWrite: { object: 'sys_audit_log', reason: 'audit-of-untenanted-record' } } + : undefined, + ); } catch (err) { // #5226 — DURABILITY degradation, not a functional one, so it is reported // at `error` (AGENTS.md "Degradation log levels"): the audited write diff --git a/packages/plugins/plugin-audit/src/auth-event-audit.ts b/packages/plugins/plugin-audit/src/auth-event-audit.ts index 01f65820ed..258faac579 100644 --- a/packages/plugins/plugin-audit/src/auth-event-audit.ts +++ b/packages/plugins/plugin-audit/src/auth-event-audit.ts @@ -225,7 +225,17 @@ export function createAuthEventAuditSink(opts: AuthEventAuditSinkOptions): AuthE engine: IDataEngine, row: Record, ): Promise => { - await engine.insert('sys_audit_log', row, { context: { isSystem: true } } as any); + // [#13636] Every row this writer produces describes `SESSION_OBJECT` — a + // better-auth identity table, which resolves NO tenant field at all, so its + // records have no organization for an audit row to inherit. That is case 1 + // of `audit-writers.ts`'s enumeration, the population the 2026-08-31 ruling + // admitted `sys_audit_log` on. Unconditional here because the subject is + // fixed by this writer rather than varying per event, so there is no second + // population at this call site to discriminate against. + await engine.insert('sys_audit_log', row, { + context: { isSystem: true }, + orgLessWrite: { object: 'sys_audit_log', reason: 'audit-of-untenanted-record' }, + } as any); }; return { diff --git a/packages/plugins/plugin-audit/src/read-audit.ts b/packages/plugins/plugin-audit/src/read-audit.ts index 1eb727ee2a..4ff10284d5 100644 --- a/packages/plugins/plugin-audit/src/read-audit.ts +++ b/packages/plugins/plugin-audit/src/read-audit.ts @@ -93,6 +93,7 @@ import type { IDataEngine } from '@objectstack/spec/contracts'; // noise, ADR-0057 telemetry plumbing) is excluded from read auditing for the // identical reasons, and a second hand-kept list would disagree on the day // either is fixed. +import { createRecordOrganizationResolver } from '@objectstack/metadata-core'; import { AUDIT_EXCLUDED_OBJECTS, createFieldPresenceProbe } from './audit-writers.js'; /** @@ -453,11 +454,19 @@ export function installReadAuditWriter( * to `warn`. A bare `.insert()` is far too generic a name for a repo-wide * vocabulary. Same reasoning as `persistAuditTrailRow` / `persistAuthEventAuditRow`. */ - const persistReadAuditRows = async (rows: Record[]): Promise => { + const recordOrgResolver = createRecordOrganizationResolver(engine); + + const persistReadAuditRows = async ( + rows: Record[], + writeOptions?: { orgLessWrite?: { object: string; reason: string } }, + ): Promise => { // `sys_audit_log` exposes only `get`/`list` on the API and every field is // `readonly`, so a user-context write would be refused. The system context // is also what lets the row keep its VIEW timestamp — see `buildRow`. - await engine.insert('sys_audit_log', rows as any, { context: { isSystem: true } } as any); + await engine.insert('sys_audit_log', rows as any, { + context: { isSystem: true }, + ...writeOptions, + } as any); }; let failureReported = false; @@ -536,7 +545,25 @@ export function installReadAuditWriter( const batcher = createReadAuditBatcher({ async persist(events) { try { - await persistReadAuditRows(events.map(buildRow)); + // [#13636] `sys_audit_log` is admitted as `conditional`, so an org-less + // audit row must say which adjudicated population it belongs to. The + // declaration is a property of the WHOLE batch — the engine resolves one + // organization per insert — so it is only made when EVERY row's subject + // is an object with no organization column at all (case 1 of the + // enumeration in `audit-writers.ts`). A mixed batch declares nothing, + // which leaves its unstamped rows in front of the refusal exactly as an + // undeclared write is: ⛔ a batch must never be able to launder one + // untenanted subject into a blanket claim about its siblings. + const batch = events.map(buildRow); + const everySubjectUntenanted = events.every( + (event) => recordOrgResolver.organizationFieldFor(event.objectName) === null, + ); + await persistReadAuditRows( + batch, + everySubjectUntenanted + ? { orgLessWrite: { object: 'sys_audit_log', reason: 'audit-of-untenanted-record' } } + : undefined, + ); } catch (err) { reportReadAuditWriteFailure(events.length, err); } diff --git a/packages/plugins/plugin-auth/src/admin-import-users.ts b/packages/plugins/plugin-auth/src/admin-import-users.ts index cc79147d8a..987f5182af 100644 --- a/packages/plugins/plugin-auth/src/admin-import-users.ts +++ b/packages/plugins/plugin-auth/src/admin-import-users.ts @@ -532,6 +532,14 @@ export async function runAdminImportUsers( const auditRegistered = !engine.getSchema || Boolean(engine.getSchema('sys_audit_log')); if (auditRegistered) { try { + // [#13636] The run-level row describes `sys_user`, a better-auth table + // that resolves no tenant field, and the row itself carries no + // `organization_id` key at all — deliberately, since an import run + // belongs to the installation rather than to one organization. That is + // case 1 of `audit-writers.ts`'s enumeration and the population + // `sys_audit_log`'s `conditional` admission was ruled on; without the + // declaration this write is refused on a walled install, and the refusal + // would be swallowed by the best-effort `catch` below. await engine.insert('sys_audit_log', { action: 'import', user_id: actor.id, @@ -546,7 +554,10 @@ export async function runAdminImportUsers( // How `auto` (and the fixed policies) split the batch across channels. delivery, }), - }, { context: SYSTEM_CTX } as any); + }, { + context: SYSTEM_CTX, + orgLessWrite: { object: 'sys_audit_log', reason: 'audit-of-untenanted-record' }, + } as any); } catch (e) { // [#12981] An import must not fail over its own audit — control flow is // unchanged and the run still answers 200 with its summary. It must not diff --git a/packages/services/service-settings/src/config-change-audit.ts b/packages/services/service-settings/src/config-change-audit.ts index af6b0bb26a..c7d2455644 100644 --- a/packages/services/service-settings/src/config-change-audit.ts +++ b/packages/services/service-settings/src/config-change-audit.ts @@ -196,7 +196,19 @@ export function buildConfigChangeAuditSink( }; if (declares('organization_id')) row.organization_id = entry.tenantId ?? null; - await eng.insert('sys_audit_log', row, { context: SYSTEM_CTX }); + // [#13636] A `global`-scope setting belongs to the installation, not to + // an organization, so its audit row has none to inherit — an untenanted + // SUBJECT, case 1's sibling in `audit-writers.ts`'s enumeration, and the + // population `sys_audit_log`'s `conditional` admission was ruled on. The + // test is the setting's own declared scope, not the absence of a tenant + // id: an ORGANIZATION-scope entry that reached here without one is a + // missing stamp, and it must keep meeting the refusal. + await eng.insert('sys_audit_log', row, { + context: SYSTEM_CTX, + ...(entry.scope === 'global' + ? { orgLessWrite: { object: 'sys_audit_log', reason: 'audit-of-untenanted-record' } } + : {}), + }); } catch (err: any) { // Reported once per process, not once per settings write: a failure here // is systemic (plugin-audit not installed, table unreachable), so a line diff --git a/scripts/check-orgless-write-declarations.mjs b/scripts/check-orgless-write-declarations.mjs new file mode 100644 index 0000000000..f45da95750 --- /dev/null +++ b/scripts/check-orgless-write-declarations.mjs @@ -0,0 +1,318 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The `orgLessWrite` declaration census and gate (#13636). + * + * node scripts/check-orgless-write-declarations.mjs # gate + count + * node scripts/check-orgless-write-declarations.mjs --self-test # the gate's own cases + * + * ## What this discharges, and why a gate rather than a convention + * + * The maintainer's 2026-08-31 ruling (总监席第 7 场决裁批 #17, direction B) put + * three words in the decision text rather than leaving them to implementation: + * + * > 申报必须 **loud, checkable, countable** ... 静默可选标记不合格 —— 那只是 + * > 给旁路换名。 + * + * The engine discharges *loud* (every unadmitted declaration throws, at the + * write) and *checkable* (each one is validated against `PLATFORM_OBJECT_TENANCY` + * before the resolver's first early return). Neither of those produces a NUMBER, + * and neither is readable without running the platform. This gate is the third + * word: it enumerates every declaration in the monorepo, holds each to the same + * ledger the runtime holds it to, and prints the count on every CI pass. + * + * ⚠️ A declaration that only the runtime can check is checkable by nobody at + * review time, which is the point the ruling makes about silent markers. The + * ledger below is what makes a NEW declaration a diff a reviewer has to approve + * rather than a line that merges unnoticed among a hundred others. + * + * ## Why the declaration is a plain literal key and not a factory call + * + * `@objectstack/metadata-protocol` writes `sys_metadata` and **cannot import + * from `@objectstack/objectql`** — objectql depends on IT, so the edge would be + * a cycle. A factory would therefore be importable only in the packages that + * happen to sit downstream of the engine, and the two admitted objects do not + * both sit there. One literal spelling is writable from anywhere in the tree and + * countable from anywhere by this scan; the type safety a factory would have + * bought is bought instead by the runtime refusal and by the LITERAL rule below. + * + * ## The three refusals + * + * 1. **Non-literal.** `orgLessWrite: someVariable` is refused. A declaration + * computed at a distance is one a reviewer cannot check by reading the call + * site, and one this gate cannot count — both of the properties the ruling + * asked for, lost to one indirection. The CONDITION under which a site + * declares may be computed (and at four of the six sites it is); the + * declaration's own object and reason may not. + * 2. **Unadmitted.** The (object, reason) pair must appear in + * `PLATFORM_OBJECT_TENANCY`'s `conditional` entries. Same admission bar as + * the runtime's, read from the same source of truth, so the two cannot + * drift into disagreement. + * 3. **Unledgered.** The site must be listed in {@link DECLARATION_SITES}. This + * is the ratchet: adding a declaration means editing this file, in the same + * PR, with a reason a reviewer reads. ⛔ Do not add an entry to shorten a red + * gate — a new declaration is a new claim that some population is + * adjudicated org-less, and the adjudication is the maintainer's. + */ + +import { readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { isEntrypoint } from './invoked-as.mjs'; + +/** The hand-adjudicated ledger the runtime reads, as its committed source. */ +const LEDGER_PATH = 'packages/objectql/src/tenancy/platform-object-tenancy.ts'; + +/** + * Every declaration site in the monorepo, with the fact that makes its rows + * legitimately org-less. One entry per FILE; a file may declare more than once. + * + * `why` is not decoration: it is the half of the admission the ledger cannot + * carry. The ledger says an OBJECT has an adjudicated org-less population; this + * says which of that object's writers is producing it, and on what test. + */ +const DECLARATION_SITES = { + 'packages/metadata-protocol/src/sys-metadata-repository.ts': + 'The env-level repository (`organizationId == null`) writes the #6190 option A population — a ' + + 'metadata row that belongs to the installation. The test is the repository\'s scope, fixed at ' + + 'construction, so an org-scoped repository never declares.', + 'packages/plugins/plugin-audit/src/audit-writers.ts': + 'The record-change writer declares ONLY when the audited subject resolves no organization column ' + + 'at all (`recordOrgResolver.organizationFieldFor(...) === null`) — case 1 of its own enumeration. ' + + 'A subject whose column is present but NULL is indistinguishable here from the missing-stamp ' + + 'defect and is deliberately left to the refusal.', + 'packages/plugins/plugin-audit/src/read-audit.ts': + 'The read-audit flush declares only when EVERY subject in the batch resolves no organization ' + + 'column; a mixed batch declares nothing, so one untenanted subject cannot launder its siblings.', + 'packages/plugins/plugin-audit/src/auth-event-audit.ts': + 'Every row describes the better-auth session object, which resolves no tenant field, so the ' + + 'subject population is fixed by the writer rather than varying per event.', + 'packages/plugins/plugin-auth/src/admin-import-users.ts': + 'The run-level import row describes `sys_user` (better-auth, no tenant field) and belongs to the ' + + 'installation rather than to one organization.', + 'packages/services/service-settings/src/config-change-audit.ts': + 'A `global`-scope setting belongs to the installation, so its audit row has no organization to ' + + 'inherit. The test is the setting\'s declared scope — an organization-scope entry arriving without ' + + 'a tenant id is a missing stamp and keeps meeting the refusal.', +}; + +/** Files this scan never reads: tests state their own fixtures, including bad ones. */ +const isScannable = (file) => + /\.(ts|mts|tsx)$/.test(file) && !/\.(test|spec)\.[cm]?tsx?$/.test(file) && !file.startsWith('scripts/'); + +/** Every tracked source file, from git rather than a walk (untracked ≠ shipped). */ +export function trackedSources(cwd = process.cwd()) { + const out = execFileSync('git', ['ls-files'], { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + return out.split('\n').filter((f) => f && isScannable(f)); +} + +/** + * The (object → admitted reasons) map, read from the ledger's committed source. + * + * Parsed rather than imported because this gate is `.mjs` and the ledger is TS + * that imports across package boundaries; a parse of the literal is exact for + * the shape the ledger actually uses (an object literal of object literals) and + * costs no build. A ledger entry whose `orgLessReasons` cannot be read is a + * PARSE FAILURE reported as such, never an empty admission set — silently + * admitting nothing would turn every declaration red and read as a code defect. + */ +export function readAdmittedReasons(source) { + const admitted = new Map(); + const entry = /(\w+):\s*\{\s*tenancy:\s*'conditional',\s*orgLessReasons:\s*\[([^\]]*)\]/g; + let m; + while ((m = entry.exec(source)) !== null) { + const reasons = [...m[2].matchAll(/'([^']+)'/g)].map((r) => r[1]); + admitted.set(m[1], reasons); + } + const conditionalCount = (source.match(/tenancy: 'conditional'/g) ?? []).length; + if (conditionalCount !== admitted.size) { + throw new Error( + `ledger parse failed: ${conditionalCount} 'conditional' entr(ies) in ${LEDGER_PATH} but ` + + `${admitted.size} could be read with an orgLessReasons list. A conditional entry MUST carry ` + + 'one — the declaration channel has nothing to check against without it.', + ); + } + return admitted; +} + +/** + * Every `orgLessWrite:` declaration in one file. + * + * Deliberately a literal-shape match rather than a TS parse: the rule this + * enforces IS that the declaration is a literal, so a matcher that only sees + * literals reports exactly the population the rule admits, and everything else + * falls into {@link findNonLiteralDeclarations} to be refused by name. + */ +export function findDeclarations(source) { + const re = /orgLessWrite:\s*\{\s*object:\s*'([^']+)',\s*reason:\s*'([^']+)'\s*\}/g; + return [...source.matchAll(re)].map((m) => ({ object: m[1], reason: m[2] })); +} + +/** Every `orgLessWrite:` that is NOT the literal shape above. */ +export function findNonLiteralDeclarations(source) { + const all = [...source.matchAll(/orgLessWrite:/g)].length; + return all - findDeclarations(source).length; +} + +export function scan({ files, read, ledgerSource }) { + const admitted = readAdmittedReasons(ledgerSource); + const sites = []; + const problems = []; + for (const file of files) { + const source = read(file); + if (!source.includes('orgLessWrite')) continue; + const nonLiteral = findNonLiteralDeclarations(source); + if (nonLiteral > 0) { + problems.push( + `${file}: ${nonLiteral} 'orgLessWrite' occurrence(s) are not the literal ` + + "{ object: '…', reason: '…' } shape. A declaration a reviewer cannot read at the call site, " + + 'and this gate cannot count, is not the declaration the ruling asked for.', + ); + continue; + } + const declarations = findDeclarations(source); + if (declarations.length === 0) continue; + if (!(file in DECLARATION_SITES)) { + problems.push( + `${file}: declares an org-less write but is not in DECLARATION_SITES ` + + '(scripts/check-orgless-write-declarations.mjs). Add it in THIS PR with the writer fact that ' + + 'makes its rows legitimately org-less, so the claim is reviewed rather than merged unseen.', + ); + continue; + } + for (const d of declarations) { + const reasons = admitted.get(d.object); + if (!reasons) { + problems.push( + `${file}: declares '${d.object}', which ${LEDGER_PATH} does not classify as 'conditional'. ` + + 'Only an object with an adjudicated org-less population can be declared.', + ); + } else if (!reasons.includes(d.reason)) { + problems.push( + `${file}: declares reason '${d.reason}' for '${d.object}', which admits: ${reasons.join(', ')}.`, + ); + } + } + sites.push({ file, declarations }); + } + const unusedLedgerEntries = Object.keys(DECLARATION_SITES).filter( + (f) => !sites.some((s) => s.file === f), + ); + for (const file of unusedLedgerEntries) { + problems.push( + `${file}: listed in DECLARATION_SITES but declares nothing. A stale entry pre-authorises a ` + + 'declaration nobody reviewed — remove it in the PR that removed the declaration.', + ); + } + return { sites, problems, admitted }; +} + +const SELF_TEST_VERDICT = 'orgless-write-declarations-self-test-ok'; + +export function selfTest() { + const ledger = ` + sys_metadata: { + tenancy: 'conditional', + orgLessReasons: ['env-level-metadata'], + evidence: 'x', + }, + sys_permission_set: { tenancy: 'global', evidence: 'y' }, +`; + const admitted = readAdmittedReasons(ledger); + if (admitted.get('sys_metadata')?.[0] !== 'env-level-metadata') throw new Error('self-test: admission parse'); + if (admitted.has('sys_permission_set')) throw new Error('self-test: a global entry must not admit reasons'); + + // A conditional entry with no reasons list is a PARSE FAILURE, not an empty set. + let threw = false; + try { + readAdmittedReasons(" sys_x: {\n tenancy: 'conditional',\n evidence: 'z',\n },"); + } catch { + threw = true; + } + if (!threw) throw new Error('self-test: a conditional entry without orgLessReasons must fail the parse'); + + const good = "insert('sys_metadata', r, { orgLessWrite: { object: 'sys_metadata', reason: 'env-level-metadata' } })"; + if (findDeclarations(good).length !== 1) throw new Error('self-test: literal declaration not found'); + if (findNonLiteralDeclarations(good) !== 0) throw new Error('self-test: literal counted as non-literal'); + if (findNonLiteralDeclarations('{ orgLessWrite: decl }') !== 1) { + throw new Error('self-test: a computed declaration must be refused'); + } + + const read = () => good; + const unledgered = scan({ files: ['packages/x/src/a.ts'], read, ledgerSource: ledger }); + if (!unledgered.problems.some((p) => p.includes('DECLARATION_SITES'))) { + throw new Error('self-test: an unledgered site must be refused'); + } + const wrongReason = scan({ + files: [Object.keys(DECLARATION_SITES)[0]], + read: () => "{ orgLessWrite: { object: 'sys_metadata', reason: 'made-up' } }", + ledgerSource: ledger, + }); + if (!wrongReason.problems.some((p) => p.includes("'made-up'"))) { + throw new Error('self-test: an unadmitted reason must be refused'); + } + const wrongObject = scan({ + files: [Object.keys(DECLARATION_SITES)[0]], + read: () => "{ orgLessWrite: { object: 'sys_permission_set', reason: 'env-level-metadata' } }", + ledgerSource: ledger, + }); + if (!wrongObject.problems.some((p) => p.includes("does not classify"))) { + throw new Error('self-test: a non-conditional object must be refused'); + } + console.log( + '✓ check:orgless-write-declarations --self-test — ledger admission parse (including the ' + + 'conditional-without-reasons parse failure), the literal rule, the unledgered-site ratchet, the ' + + 'unadmitted-reason refusal and the non-conditional-object refusal all hold.', + ); + return SELF_TEST_VERDICT; +} + +function main() { + const files = trackedSources(); + const ledgerSource = readFileSync(LEDGER_PATH, 'utf8'); + const { sites, problems, admitted } = scan({ + files, + read: (f) => readFileSync(f, 'utf8'), + ledgerSource, + }); + const total = sites.reduce((n, s) => n + s.declarations.length, 0); + if (problems.length > 0) { + console.error('\n✗ check:orgless-write-declarations\n'); + for (const p of problems) console.error(` - ${p}`); + console.error( + `\nAn 'orgLessWrite' declaration asserts that a write's rows belong to an ADJUDICATED org-less\n` + + 'population (#13636, maintainer ruling 2026-08-31). It is checked here for the same reason the\n' + + 'engine checks it at the write: a declaration nobody can count is the silent marker the ruling\n' + + 'disqualified by name.\n', + ); + process.exit(1); + } + const byObject = new Map(); + for (const s of sites) for (const d of s.declarations) byObject.set(d.object, (byObject.get(d.object) ?? 0) + 1); + const breakdown = [...byObject.entries()].sort().map(([o, n]) => `${o}=${n}`).join(', '); + console.log( + `✓ check:orgless-write-declarations: ${total} declaration(s) across ${sites.length} file(s), ` + + `every one admitted by ${LEDGER_PATH} and ledgered here` + + (breakdown ? ` — ${breakdown}` : '') + + `; ${admitted.size} object(s) classified 'conditional'.`, + ); +} + +// Exports bindings, so an import for those exports alone must run nothing (#10667). +const invokedDirectly = isEntrypoint(import.meta.url); + +if (!invokedDirectly) { + // imported as a module — expose the exports and do nothing else +} else if (process.argv.includes('--self-test')) { + if (selfTest() !== SELF_TEST_VERDICT) { + console.error( + '\n✗ check-orgless-write-declarations self-test: selfTest() returned without reaching its\n' + + 'verdict, so no success line was printed. Exiting 0 here would report a self-test that never\n' + + 'finished as a self-test that passed.\n', + ); + process.exit(1); + } + process.exit(0); +} + +if (invokedDirectly) main(); From 9ef492f5d5b82c8a11eb6e1bcba23436d3d782e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 10:13:49 +0000 Subject: [PATCH 2/7] fix(docs): re-anchor the system-context census line citations after the engine import block moved `check:system-context-census` anchors the elevation-read page at file:line, and the #13636 import block shifted every anchor below it in `engine.ts` (and the read-audit flush). Repaired with the gate's own `--fix`; no prose changed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- content/docs/permissions/system-context.mdx | 26 ++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 2e27ae7abd..050e8bacf3 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -101,7 +101,7 @@ that silently does not happen. | 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `perf-timing.ts:474` | | 13 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `permission-set-overlay-discard.ts:142` | | 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` | -| 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` | +| 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:583` | | 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` | | 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1554` | @@ -109,18 +109,18 @@ that silently does not happen. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11289` | -| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11472` | -| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10024` | +| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11337` | +| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11520` | +| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10072` | | 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1746` | -| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10072`, `readonly-strict-errors.ts:66` | -| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5891` | -| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3735`, `:3745`, `:3772` | +| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10120`, `readonly-strict-errors.ts:66` | +| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5931` | +| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3740`, `:3750`, `:3777` | | 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` | | 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` | -| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6589` | -| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12084` | -| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:12013` | +| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6629` | +| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12132` | +| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:12061` | ### 3. Sharing (`plugin-sharing`) @@ -179,8 +179,8 @@ a reader tracing where elevation travels needs them. | # | Site | Package | What it does | |:--|:---|:---|:---| -| 62 | `objectql/src/engine.ts:3542` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 63 | `objectql/src/engine.ts:14433` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 62 | `objectql/src/engine.ts:3547` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | +| 63 | `objectql/src/engine.ts:14490` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | | 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report | | 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across | @@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs. |:---|:---|:---| | "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1971` (rationale at `:1881`–`1883`, #3760), `flow.zod.ts:702` | | "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) | -| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10007`–`10024` | +| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10055`–`10072` | | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` | From 4c2da50af145b26868aabe6b78ee111adac02751 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 11:31:45 +0000 Subject: [PATCH 3/7] fix(#14923 review): respell the gate's bare-root literal, register the wire code, and align the vocabulary with the writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1 — `check-orgless-write-declarations.mjs` spelled the repo tooling root as a quoted literal, which the dispatch derivation reads as this gate DECLARING that tree as a population it reads. It excludes it. Respelled as an anchored regex (remedy (b), `check-published-files.mjs`' worked instance), so the escapable- literal row discharges by construction rather than by a new ledger line. B4 — `ERR_ORGLESS_WRITE_DECLARATION_REFUSED` is `status` 500 and REST forwards a string code on any `status >= 500`, so it is wire vocabulary and belongs in the ADR-0112 ledger beside its sibling `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`. One entry plus its generated rows. Contradiction — the reason vocabulary and the `sys_audit_log` ledger evidence claimed a population no writer declares (a subject whose organization column is present but NULL). `audit-writers.ts` deliberately leaves that case to the refusal; both texts now say so, and both are runtime strings that reach operators. A1 — an empty batch built no row hook contexts, so a bogus declaration on one was the single spelling of the option that was silently ignored. It now reads the caller's options on that path, and two pins hold both directions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- content/docs/references/api/contract.mdx | 3 +- .../docs/references/api/error-code-ledger.mdx | 1 + packages/objectql/src/engine.ts | 16 +++++++- .../tenancy-orgless-write-declaration.test.ts | 29 ++++++++++++++ .../src/tenancy/orgless-write-declaration.ts | 26 ++++++++++--- .../src/tenancy/platform-object-tenancy.ts | 18 ++++++--- .../spec/src/api/error-code-ledger.zod.ts | 12 ++++++ scripts/check-orgless-write-declarations.mjs | 39 ++++++++++++++++++- 8 files changed, 130 insertions(+), 14 deletions(-) diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 867fef07ac..5f28bfcb2d 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +292 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +293 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -156,6 +156,7 @@ const result = ApiErrorSchema.parse(data); * `ERR_FILE_CONSTRAINT` * `ERR_FILE_REFERENCE_COPY` * `ERR_HOOK_TARGET_REBIND` +* `ERR_ORGLESS_WRITE_DECLARATION_REFUSED` * `ERR_READONLY_FIELD_REJECTED` * `ERR_SUMMARY_RECOMPUTE` * `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 44312c13df..8a8d771532 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -272,6 +272,7 @@ const result = ErrorCode.parse(data); * `ERR_FILE_CONSTRAINT` * `ERR_FILE_REFERENCE_COPY` * `ERR_HOOK_TARGET_REBIND` +* `ERR_ORGLESS_WRITE_DECLARATION_REFUSED` * `ERR_READONLY_FIELD_REJECTED` * `ERR_SUMMARY_RECOMPUTE` * `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 89a5d705c7..c4e4e10b10 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -9981,7 +9981,21 @@ export class ObjectQL implements IObjectQLEngine { // resolver — the one reader it exists to inform — ever saw it. Widening // `PLATFORM_PROVISIONED_COLUMNS` is closed off by that door's own ⛔, and // rightly: a declaration is not a column. - readOrgLessWriteDeclaration(rowHookContexts[0]?.input.options), + // + // The row context is the source for every non-empty write, because a + // `beforeInsert` hook may legitimately have replaced `input.options`. + // ⚠️ An EMPTY BATCH builds no row contexts at all, so that read comes + // back `undefined` and a bogus declaration would be the ONE spelling of + // this option that is silently ignored. Nothing is written on that path, + // so the gap costs no rows — it costs the PROPERTY, and the property is + // the whole of what separates a declaration from a renamed bypass flag + // (the ruling's 「静默可选标记不合格」). So the empty batch falls back to + // the caller's own options, which is where the declaration was spelled. + // ⛔ Not a general `??` fallback: on a non-empty batch the hooks' options + // stay the only source, so nothing about the measured path moves. + readOrgLessWriteDeclaration( + rowHookContexts.length > 0 ? rowHookContexts[0]?.input.options : opCtx.options, + ), ); const optionsBase = rowHookContexts[0]?.input.options as any; const driverOptions = this.buildDriverOptions( diff --git a/packages/objectql/src/tenancy-orgless-write-declaration.test.ts b/packages/objectql/src/tenancy-orgless-write-declaration.test.ts index 3b1869982a..54787f5b1e 100644 --- a/packages/objectql/src/tenancy-orgless-write-declaration.test.ts +++ b/packages/objectql/src/tenancy-orgless-write-declaration.test.ts @@ -286,6 +286,35 @@ describe('#13636 the declaration is not a bypass — every unadmitted spelling T ); }); + it('refuses an unadmitted declaration on an EMPTY BATCH — the absolute has no asterisk', async () => { + // ⭐ The one spelling that used to be silent. An empty batch builds NO row + // hook contexts, so the declaration — read off `rowHookContexts[0]` — came + // back `undefined` and the write returned `[]` without a word. Nothing is + // written, so there was no security consequence; the cost was to the + // PROPERTY, which is the whole of what separates this option from a renamed + // bypass. "Every unadmitted spelling throws" has to hold with no asterisk, + // or the next reader learns to expect asterisks and stops checking. + const { engine } = await makeEngine({ posture: 'single' }); + await expectRefusal( + engine.insert('sys_file', [], { + context: SYSTEM_CTX, + orgLessWrite: { object: 'sys_file', reason: 'env-level-metadata' }, + } as any), + 'ERR_ORGLESS_WRITE_DECLARATION_REFUSED', + ); + }); + + it('and an ADMITTED declaration on an empty batch still writes nothing, quietly', async () => { + // The other half: reading the caller's options on an empty batch must not + // turn a LEGAL declaration into a refusal. A batch with no rows is a no-op + // on every other axis and stays one here. + const { engine, observed } = await makeEngine({ posture: 'isolated' }); + await expect( + engine.insert('sys_metadata', [], { context: SYSTEM_CTX, ...ENV_METADATA } as any), + ).resolves.toEqual([]); + expect(lastWrite(observed, 'sys_metadata')).toBeUndefined(); + }); + it.each([ ['a bare boolean', true], ['a string', 'env-level-metadata'], diff --git a/packages/objectql/src/tenancy/orgless-write-declaration.ts b/packages/objectql/src/tenancy/orgless-write-declaration.ts index 1da0a71f91..a856c266d5 100644 --- a/packages/objectql/src/tenancy/orgless-write-declaration.ts +++ b/packages/objectql/src/tenancy/orgless-write-declaration.ts @@ -94,11 +94,27 @@ export type OrgLessWriteReason = */ | 'env-level-metadata' /** - * An audit record whose SUBJECT has no organization to inherit — a row on an - * object with no organization column at all (single-tenant stacks and - * ADR-0066 platform-global objects), or one whose organization column is - * itself NULL. The enumeration is the audit writer's own - * (`plugin-audit/src/audit-writers.ts`); this names it. + * An audit record whose SUBJECT resolves NO organization column at all, so + * there is no organization for the audit row to inherit: a record on an + * object with no tenant field (single-tenant stacks, ADR-0066 platform-global + * objects, the better-auth identity tables), or an installation-level subject + * that behaves the same way — a `global`-scope setting, an import run. That + * is case 1 of the audit writer's own enumeration + * (`plugin-audit/src/audit-writers.ts`); this names it, and names ONLY it. + * + * ⛔ Case 2 — the subject HAS an organization column and its value is NULL — + * is deliberately OUTSIDE this reason. At the writing call site it is + * indistinguishable from the missing-stamp defect the control exists to find, + * so no writer declares it: `audit-writers.ts` declares only when + * `organizationFieldFor(subject) === null`, `read-audit.ts` only when every + * subject in the batch does, and the three fixed-subject writers only because + * their one subject does. Those rows keep meeting the refusal. + * + * ⚠️ This text is not commentary — the vocabulary and the ledger `evidence` + * beside it are runtime strings that reach operators, so a reason describing + * a population no writer produces would read as a claim the platform makes. + * If a writer is ever taught to declare case 2, that is a ruling, and this + * paragraph is what has to change with it. */ | 'audit-of-untenanted-record'; diff --git a/packages/objectql/src/tenancy/platform-object-tenancy.ts b/packages/objectql/src/tenancy/platform-object-tenancy.ts index d605133754..605af22c15 100644 --- a/packages/objectql/src/tenancy/platform-object-tenancy.ts +++ b/packages/objectql/src/tenancy/platform-object-tenancy.ts @@ -265,17 +265,25 @@ export const PLATFORM_OBJECT_TENANCY: Readonly= 500`, so it + // is WIRE vocabulary whether or not a route means it to be. Not a synonym of + // any standard member — the fault is server-side code making a claim it is + // not entitled to make, not a client's bad input. + 'ERR_ORGLESS_WRITE_DECLARATION_REFUSED', // [#5320] Third EMITTER of the code (metadata-protocol and plugin-security // already register it) — the registration loop's `views:` tighten refuses a // non-container entry, and the `viewItems:` channel refuses an entry the diff --git a/scripts/check-orgless-write-declarations.mjs b/scripts/check-orgless-write-declarations.mjs index f45da95750..6c181e10eb 100644 --- a/scripts/check-orgless-write-declarations.mjs +++ b/scripts/check-orgless-write-declarations.mjs @@ -96,9 +96,44 @@ const DECLARATION_SITES = { 'a tenant id is a missing stamp and keeps meeting the refusal.', }; -/** Files this scan never reads: tests state their own fixtures, including bad ones. */ +/** + * Files this scan never reads. + * + * Both exclusions are about a file STATING a declaration rather than making + * one: a test states its own fixtures, including deliberately bad ones, and the + * repo's own tooling tree states them too — this gate's `--self-test` corpus + * carries the literal spelling as data, and a sibling gate or codemod written + * in `.mts` would carry it the same way. + * + * ⚠️ The tooling half is an anchored REGEX, and that is load-bearing rather + * than stylistic (#10705). `extractWatchHints` in `scripts/pm/dispatch-gates.mjs` + * reads any quoted span carrying a separator as this gate DECLARING that + * population, so the quoted literal this used to be — `file.startsWith(…)` over + * the repo tooling root — announced that root as a tree this gate READS. It + * does the exact opposite: the literal's whole job is to keep that tree OUT. + * The false announcement then collapsed to a bare top-level word `hintCovers` + * refuses as too generic, so the gate scored `silent` for every card in the + * tree while appearing to name a root it never opens, and + * `check:pm-dispatch-gates` reds on precisely that — the escapable-literal + * ledger, which is SHRINK-ONLY, so a new line in it is not a remedy. + * + * Two things not to do here, both of which look like the fix and are not: + * - ⛔ do NOT restore the quoted form. `check:pm-dispatch-gates` decides with + * the derivation's OWN extractor rather than a copy of it, so the row comes + * straight back; + * - ⛔ do NOT reach for the `ROOT_DIR_WATCH_HINTS` escape (the subtree + * spelling, as `check-parse-guard` and `check-role-word` legitimately use). + * For THIS gate that declaration would be false in the strongest way + * available — it would name the gate for every repo-root tooling edit as a + * tree it reads, when the predicate exists to exclude that tree. A + * fabricated lead costs more than a missing one (+139084, measured in + * `hintCovers`' docblock). + * + * The population this gate really reads is announced by {@link LEDGER_PATH} and + * the {@link DECLARATION_SITES} keys above — both already hints, both true. + */ const isScannable = (file) => - /\.(ts|mts|tsx)$/.test(file) && !/\.(test|spec)\.[cm]?tsx?$/.test(file) && !file.startsWith('scripts/'); + /\.(ts|mts|tsx)$/.test(file) && !/\.(test|spec)\.[cm]?tsx?$/.test(file) && !/^scripts\//.test(file); /** Every tracked source file, from git rather than a walk (untracked ≠ shipped). */ export function trackedSources(cwd = process.cwd()) { From d126ae85d0b80016ebaa6b405aac9d0eb87e8095 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 11:33:24 +0000 Subject: [PATCH 4/7] fix(docs): re-anchor the system-context census after this round's engine edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check-system-context-census.mjs --fix`: 9 anchors rewritten, ZERO files refused — a pure line shift, which is the gate's only signal that no elevation read site arrived or vanished. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- content/docs/permissions/system-context.mdx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 050e8bacf3..744d4c753d 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -109,18 +109,18 @@ that silently does not happen. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11337` | -| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11520` | -| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10072` | +| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11351` | +| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11534` | +| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10086` | | 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1746` | -| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10120`, `readonly-strict-errors.ts:66` | +| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10134`, `readonly-strict-errors.ts:66` | | 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5931` | | 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3740`, `:3750`, `:3777` | | 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` | | 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` | | 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6629` | -| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12132` | -| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:12061` | +| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12146` | +| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:12075` | ### 3. Sharing (`plugin-sharing`) @@ -180,7 +180,7 @@ a reader tracing where elevation travels needs them. | # | Site | Package | What it does | |:--|:---|:---|:---| | 62 | `objectql/src/engine.ts:3547` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 63 | `objectql/src/engine.ts:14490` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 63 | `objectql/src/engine.ts:14504` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | | 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report | | 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across | @@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs. |:---|:---|:---| | "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1971` (rationale at `:1881`–`1883`, #3760), `flow.zod.ts:702` | | "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) | -| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10055`–`10072` | +| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10069`–`10086` | | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` | From f5d20ac015e57604756f6b6580b4960901f90641 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 11:34:49 +0000 Subject: [PATCH 5/7] chore: regenerate the system-context census page on the merged tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge's os-regen deferral, discharged as its own commit on a known-good base (the driver merges these paths with exit 0 while silently keeping one side, so only a regeneration on the merged tree is honest). `--fix` rewrote 16 anchors and REFUSED ZERO files. One of them — `auth-plugin.ts:1380` -> `:1405` — is drift this branch did not cause: it arrived with the 15 commits merged in above. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- content/docs/permissions/system-context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 744d4c753d..0b6564cf19 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -97,7 +97,7 @@ that silently does not happen. | 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3857` | | 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` | | 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` | -| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1380` | +| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1405` | | 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `perf-timing.ts:474` | | 13 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `permission-set-overlay-discard.ts:142` | | 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` | From 2cb2976681f525999df17c60bb700e6047a63b19 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 15:11:32 +0000 Subject: [PATCH 6/7] fix(objectql): move the tracker id off the orgless-write refusal message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check:doc-authoring Rule 3b (cross-package prose-id leg) flagged the OrgLessWriteDeclarationRefusedError message: a runtime string reaching operators and generated surfaces cannot resolve `#13636`. Strip the id, keep the customer-resolvable maintainer-ruling date, and move the id to an adjacent `//` comment — the same pattern platform-object-tenancy.ts's `evidence` strings already use for the same reason. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- packages/objectql/src/tenancy/orgless-write-declaration.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/objectql/src/tenancy/orgless-write-declaration.ts b/packages/objectql/src/tenancy/orgless-write-declaration.ts index a856c266d5..8de9f63143 100644 --- a/packages/objectql/src/tenancy/orgless-write-declaration.ts +++ b/packages/objectql/src/tenancy/orgless-write-declaration.ts @@ -186,10 +186,15 @@ export class OrgLessWriteDeclarationRefusedError extends Error { public readonly object: string, public readonly detail: string, ) { + // [#13636] The message below is a RUNTIME string — it reaches operators + // and generated surfaces, where a tracker id resolves to nothing + // (maintainer ruling 2026-08-12). The date stays; the id anchors here + // instead, for the reader who can resolve it and is already looking at + // the source. super( `Insert on '${object}' was REFUSED: its 'orgLessWrite' declaration is not admitted — ${detail}. ` + `A declaration asserts that the rows of this write belong to an ADJUDICATED org-less population ` + - `(#13636, maintainer ruling 2026-08-31), so it is checked against the platform tenancy ledger ` + + `(maintainer ruling 2026-08-31), so it is checked against the platform tenancy ledger ` + `(PLATFORM_OBJECT_TENANCY, 'platform-object-tenancy.ts') and never taken on trust. Nothing was ` + `written. Fix it by declaring the object this write targets with a reason that object admits, or ` + `— if this object really does hold a ruled org-less population — by admitting it in the ledger ` + From 9dce2135072fb1e3933c3fdbe5893f1ba03fe54e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 15:20:20 +0000 Subject: [PATCH 7/7] fix(docs): repair system-context census anchor after merge conflict The merge of origin/main into this branch conflicted in content/docs/permissions/system-context.mdx (line-number anchors into packages/objectql/src/engine.ts). Taking the origin/main side blindly left a stale anchor: this branch's own orgless-write-declaration change shifts engine.ts line numbers, so the correct anchor is the pre-merge (this branch's) value, confirmed against check:system-context-census's diagnostic (ledger-excused site 10069, read site 10086). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- content/docs/permissions/system-context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 050435f009..237938bd04 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs. |:---|:---|:---| | "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1971` (rationale at `:1881`–`1883`, #3760), `flow.zod.ts:702` | | "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) | -| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10007`–`10024` | +| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10069`–`10086` | | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1537` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` |