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 9ba5a50d82..189f5351ef 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3596,6 +3596,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/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index f7448ca67c..237938bd04 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:1552` | @@ -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: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: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: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: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: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`) @@ -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: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: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` | 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/package.json b/package.json index d31c2f84e7..55cf568321 100644 --- a/package.json +++ b/package.json @@ -151,6 +151,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..c4e4e10b10 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,28 @@ 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. + // + // 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( @@ -10353,7 +10415,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 +14077,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..54787f5b1e --- /dev/null +++ b/packages/objectql/src/tenancy-orgless-write-declaration.test.ts @@ -0,0 +1,333 @@ +// 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('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'], + ['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..8de9f63143 --- /dev/null +++ b/packages/objectql/src/tenancy/orgless-write-declaration.ts @@ -0,0 +1,207 @@ +// 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 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'; + +/** 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, + ) { + // [#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 ` + + `(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..605af22c15 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,51 @@ 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/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 2954a3f116..ab17ec60f9 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -513,6 +513,18 @@ export const ERROR_CODE_LEDGER = { // `hook-target-rebind-errors.ts`), during a write the dispatcher is // serving. Same #8087-gate family. 'ERR_HOOK_TARGET_REBIND', + // [#13636] A write carried an `orgLessWrite` declaration the platform + // tenancy ledger does not admit — an object it has not classified + // `conditional`, a reason that object does not admit, a malformed value, or + // an object other than the one being written + // (`OrgLessWriteDeclarationRefusedError`, `tenancy/orgless-write-declaration.ts`). + // Registered for the same reason its sibling + // `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` below is: it carries `status` 500 + // and the REST layer forwards a string `code` on any `status >= 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 new file mode 100644 index 0000000000..6c181e10eb --- /dev/null +++ b/scripts/check-orgless-write-declarations.mjs @@ -0,0 +1,353 @@ +#!/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. + * + * 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) && !/^scripts\//.test(file); + +/** 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();