Skip to content

Commit 4c2da50

Browse files
committed
fix(#14923 review): respell the gate's bare-root literal, register the wire code, and align the vocabulary with the writer
B1 — `check-orgless-write-declarations.mjs` spelled the repo tooling root as a quoted literal, which the dispatch derivation reads as this gate DECLARING that tree as a population it reads. It excludes it. Respelled as an anchored regex (remedy (b), `check-published-files.mjs`' worked instance), so the escapable- literal row discharges by construction rather than by a new ledger line. B4 — `ERR_ORGLESS_WRITE_DECLARATION_REFUSED` is `status` 500 and REST forwards a string code on any `status >= 500`, so it is wire vocabulary and belongs in the ADR-0112 ledger beside its sibling `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`. One entry plus its generated rows. Contradiction — the reason vocabulary and the `sys_audit_log` ledger evidence claimed a population no writer declares (a subject whose organization column is present but NULL). `audit-writers.ts` deliberately leaves that case to the refusal; both texts now say so, and both are runtime strings that reach operators. A1 — an empty batch built no row hook contexts, so a bogus declaration on one was the single spelling of the option that was silently ignored. It now reads the caller's options on that path, and two pins hold both directions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
1 parent 9ef492f commit 4c2da50

8 files changed

Lines changed: 130 additions & 14 deletions

File tree

content/docs/references/api/contract.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data);
2727

2828
| Property | Type | Required | Description |
2929
| :--- | :--- | :--- | :--- |
30-
| **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) |
30+
| **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) |
3131
| **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) |
3232
| **message** | `string` || Readable error message |
3333
| **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);
156156
* `ERR_FILE_CONSTRAINT`
157157
* `ERR_FILE_REFERENCE_COPY`
158158
* `ERR_HOOK_TARGET_REBIND`
159+
* `ERR_ORGLESS_WRITE_DECLARATION_REFUSED`
159160
* `ERR_READONLY_FIELD_REJECTED`
160161
* `ERR_SUMMARY_RECOMPUTE`
161162
* `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`

content/docs/references/api/error-code-ledger.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ const result = ErrorCode.parse(data);
272272
* `ERR_FILE_CONSTRAINT`
273273
* `ERR_FILE_REFERENCE_COPY`
274274
* `ERR_HOOK_TARGET_REBIND`
275+
* `ERR_ORGLESS_WRITE_DECLARATION_REFUSED`
275276
* `ERR_READONLY_FIELD_REJECTED`
276277
* `ERR_SUMMARY_RECOMPUTE`
277278
* `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`

packages/objectql/src/engine.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9981,7 +9981,21 @@ export class ObjectQL implements IObjectQLEngine {
99819981
// resolver — the one reader it exists to inform — ever saw it. Widening
99829982
// `PLATFORM_PROVISIONED_COLUMNS` is closed off by that door's own ⛔, and
99839983
// rightly: a declaration is not a column.
9984-
readOrgLessWriteDeclaration(rowHookContexts[0]?.input.options),
9984+
//
9985+
// The row context is the source for every non-empty write, because a
9986+
// `beforeInsert` hook may legitimately have replaced `input.options`.
9987+
// ⚠️ An EMPTY BATCH builds no row contexts at all, so that read comes
9988+
// back `undefined` and a bogus declaration would be the ONE spelling of
9989+
// this option that is silently ignored. Nothing is written on that path,
9990+
// so the gap costs no rows — it costs the PROPERTY, and the property is
9991+
// the whole of what separates a declaration from a renamed bypass flag
9992+
// (the ruling's 「静默可选标记不合格」). So the empty batch falls back to
9993+
// the caller's own options, which is where the declaration was spelled.
9994+
// ⛔ Not a general `??` fallback: on a non-empty batch the hooks' options
9995+
// stay the only source, so nothing about the measured path moves.
9996+
readOrgLessWriteDeclaration(
9997+
rowHookContexts.length > 0 ? rowHookContexts[0]?.input.options : opCtx.options,
9998+
),
99859999
);
998610000
const optionsBase = rowHookContexts[0]?.input.options as any;
998710001
const driverOptions = this.buildDriverOptions(

packages/objectql/src/tenancy-orgless-write-declaration.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,35 @@ describe('#13636 the declaration is not a bypass — every unadmitted spelling T
286286
);
287287
});
288288

289+
it('refuses an unadmitted declaration on an EMPTY BATCH — the absolute has no asterisk', async () => {
290+
// ⭐ The one spelling that used to be silent. An empty batch builds NO row
291+
// hook contexts, so the declaration — read off `rowHookContexts[0]` — came
292+
// back `undefined` and the write returned `[]` without a word. Nothing is
293+
// written, so there was no security consequence; the cost was to the
294+
// PROPERTY, which is the whole of what separates this option from a renamed
295+
// bypass. "Every unadmitted spelling throws" has to hold with no asterisk,
296+
// or the next reader learns to expect asterisks and stops checking.
297+
const { engine } = await makeEngine({ posture: 'single' });
298+
await expectRefusal(
299+
engine.insert('sys_file', [], {
300+
context: SYSTEM_CTX,
301+
orgLessWrite: { object: 'sys_file', reason: 'env-level-metadata' },
302+
} as any),
303+
'ERR_ORGLESS_WRITE_DECLARATION_REFUSED',
304+
);
305+
});
306+
307+
it('and an ADMITTED declaration on an empty batch still writes nothing, quietly', async () => {
308+
// The other half: reading the caller's options on an empty batch must not
309+
// turn a LEGAL declaration into a refusal. A batch with no rows is a no-op
310+
// on every other axis and stays one here.
311+
const { engine, observed } = await makeEngine({ posture: 'isolated' });
312+
await expect(
313+
engine.insert('sys_metadata', [], { context: SYSTEM_CTX, ...ENV_METADATA } as any),
314+
).resolves.toEqual([]);
315+
expect(lastWrite(observed, 'sys_metadata')).toBeUndefined();
316+
});
317+
289318
it.each([
290319
['a bare boolean', true],
291320
['a string', 'env-level-metadata'],

packages/objectql/src/tenancy/orgless-write-declaration.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,27 @@ export type OrgLessWriteReason =
9494
*/
9595
| 'env-level-metadata'
9696
/**
97-
* An audit record whose SUBJECT has no organization to inherit — a row on an
98-
* object with no organization column at all (single-tenant stacks and
99-
* ADR-0066 platform-global objects), or one whose organization column is
100-
* itself NULL. The enumeration is the audit writer's own
101-
* (`plugin-audit/src/audit-writers.ts`); this names it.
97+
* An audit record whose SUBJECT resolves NO organization column at all, so
98+
* there is no organization for the audit row to inherit: a record on an
99+
* object with no tenant field (single-tenant stacks, ADR-0066 platform-global
100+
* objects, the better-auth identity tables), or an installation-level subject
101+
* that behaves the same way — a `global`-scope setting, an import run. That
102+
* is case 1 of the audit writer's own enumeration
103+
* (`plugin-audit/src/audit-writers.ts`); this names it, and names ONLY it.
104+
*
105+
* ⛔ Case 2 — the subject HAS an organization column and its value is NULL —
106+
* is deliberately OUTSIDE this reason. At the writing call site it is
107+
* indistinguishable from the missing-stamp defect the control exists to find,
108+
* so no writer declares it: `audit-writers.ts` declares only when
109+
* `organizationFieldFor(subject) === null`, `read-audit.ts` only when every
110+
* subject in the batch does, and the three fixed-subject writers only because
111+
* their one subject does. Those rows keep meeting the refusal.
112+
*
113+
* ⚠️ This text is not commentary — the vocabulary and the ledger `evidence`
114+
* beside it are runtime strings that reach operators, so a reason describing
115+
* a population no writer produces would read as a claim the platform makes.
116+
* If a writer is ever taught to declare case 2, that is a ruling, and this
117+
* paragraph is what has to change with it.
102118
*/
103119
| 'audit-of-untenanted-record';
104120

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -265,17 +265,25 @@ export const PLATFORM_OBJECT_TENANCY: Readonly<Record<string, PlatformObjectTena
265265
"The env-level write declares itself ('env-level-metadata'); an undeclared org-less write on this " +
266266
'object is a missing stamp and is refused.',
267267
},
268-
// #13636 specimen 2; the enumeration is the writer's own
268+
// #13636 specimen 2; the enumeration is the writer's own, and this entry
269+
// states CASE 1 of it only — `audit-writers.ts` declares exactly when
270+
// `organizationFieldFor(subject) === null` and deliberately leaves case 2
271+
// (column present, value NULL) to the refusal. ⛔ Do not widen this prose to
272+
// the writer's whole enumeration: `evidence` is a runtime string, so it would
273+
// tell an operator the platform blesses a population no writer declares.
269274
sys_audit_log: {
270275
tenancy: 'conditional',
271276
orgLessReasons: ['audit-of-untenanted-record'],
272277
evidence:
273278
'Holds both populations, and its writer enumerates the org-less one in its own source: an audit row ' +
274279
'inherits the organization of the RECORD it describes, falling back to the acting session ' +
275-
"(`audit-writers.ts`), and neither answers for a record on an object with no organization column at " +
276-
'all (single-tenant stacks and ADR-0066 platform-global objects) or a record whose own column is ' +
277-
"NULL. Those rows declare themselves ('audit-of-untenanted-record'); an undeclared org-less audit " +
278-
'row is the invisible-audit-row defect that writer already guards against, and is refused.',
280+
"(`audit-writers.ts`), and neither answers for a record whose object resolves no organization column " +
281+
'at all — single-tenant stacks, ADR-0066 platform-global objects, the better-auth identity tables, ' +
282+
"and the installation-level subjects that behave the same way. Exactly those rows declare themselves " +
283+
"('audit-of-untenanted-record'). A record whose column is PRESENT and NULL is deliberately NOT " +
284+
'declared by any writer: at the call site it cannot be told apart from a missing stamp, so it keeps ' +
285+
'meeting the refusal, as does every other undeclared org-less audit row — the invisible-audit-row ' +
286+
'defect that writer already guards against.',
279287
},
280288

281289
// ── global ───────────────────────────────────────────────────────────────

packages/spec/src/api/error-code-ledger.zod.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,18 @@ export const ERROR_CODE_LEDGER = {
513513
// `hook-target-rebind-errors.ts`), during a write the dispatcher is
514514
// serving. Same #8087-gate family.
515515
'ERR_HOOK_TARGET_REBIND',
516+
// [#13636] A write carried an `orgLessWrite` declaration the platform
517+
// tenancy ledger does not admit — an object it has not classified
518+
// `conditional`, a reason that object does not admit, a malformed value, or
519+
// an object other than the one being written
520+
// (`OrgLessWriteDeclarationRefusedError`, `tenancy/orgless-write-declaration.ts`).
521+
// Registered for the same reason its sibling
522+
// `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` below is: it carries `status` 500
523+
// and the REST layer forwards a string `code` on any `status >= 500`, so it
524+
// is WIRE vocabulary whether or not a route means it to be. Not a synonym of
525+
// any standard member — the fault is server-side code making a claim it is
526+
// not entitled to make, not a client's bad input.
527+
'ERR_ORGLESS_WRITE_DECLARATION_REFUSED',
516528
// [#5320] Third EMITTER of the code (metadata-protocol and plugin-security
517529
// already register it) — the registration loop's `views:` tighten refuses a
518530
// non-container entry, and the `viewItems:` channel refuses an entry the

scripts/check-orgless-write-declarations.mjs

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,44 @@ const DECLARATION_SITES = {
9696
'a tenant id is a missing stamp and keeps meeting the refusal.',
9797
};
9898

99-
/** Files this scan never reads: tests state their own fixtures, including bad ones. */
99+
/**
100+
* Files this scan never reads.
101+
*
102+
* Both exclusions are about a file STATING a declaration rather than making
103+
* one: a test states its own fixtures, including deliberately bad ones, and the
104+
* repo's own tooling tree states them too — this gate's `--self-test` corpus
105+
* carries the literal spelling as data, and a sibling gate or codemod written
106+
* in `.mts` would carry it the same way.
107+
*
108+
* ⚠️ The tooling half is an anchored REGEX, and that is load-bearing rather
109+
* than stylistic (#10705). `extractWatchHints` in `scripts/pm/dispatch-gates.mjs`
110+
* reads any quoted span carrying a separator as this gate DECLARING that
111+
* population, so the quoted literal this used to be — `file.startsWith(…)` over
112+
* the repo tooling root — announced that root as a tree this gate READS. It
113+
* does the exact opposite: the literal's whole job is to keep that tree OUT.
114+
* The false announcement then collapsed to a bare top-level word `hintCovers`
115+
* refuses as too generic, so the gate scored `silent` for every card in the
116+
* tree while appearing to name a root it never opens, and
117+
* `check:pm-dispatch-gates` reds on precisely that — the escapable-literal
118+
* ledger, which is SHRINK-ONLY, so a new line in it is not a remedy.
119+
*
120+
* Two things not to do here, both of which look like the fix and are not:
121+
* - ⛔ do NOT restore the quoted form. `check:pm-dispatch-gates` decides with
122+
* the derivation's OWN extractor rather than a copy of it, so the row comes
123+
* straight back;
124+
* - ⛔ do NOT reach for the `ROOT_DIR_WATCH_HINTS` escape (the subtree
125+
* spelling, as `check-parse-guard` and `check-role-word` legitimately use).
126+
* For THIS gate that declaration would be false in the strongest way
127+
* available — it would name the gate for every repo-root tooling edit as a
128+
* tree it reads, when the predicate exists to exclude that tree. A
129+
* fabricated lead costs more than a missing one (+139084, measured in
130+
* `hintCovers`' docblock).
131+
*
132+
* The population this gate really reads is announced by {@link LEDGER_PATH} and
133+
* the {@link DECLARATION_SITES} keys above — both already hints, both true.
134+
*/
100135
const isScannable = (file) =>
101-
/\.(ts|mts|tsx)$/.test(file) && !/\.(test|spec)\.[cm]?tsx?$/.test(file) && !file.startsWith('scripts/');
136+
/\.(ts|mts|tsx)$/.test(file) && !/\.(test|spec)\.[cm]?tsx?$/.test(file) && !/^scripts\//.test(file);
102137

103138
/** Every tracked source file, from git rather than a walk (untracked ≠ shipped). */
104139
export function trackedSources(cwd = process.cwd()) {

0 commit comments

Comments
 (0)