From 448182263a3d1f62692518c1e7a6d5fb72f9504a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 23:16:39 +0000 Subject: [PATCH 1/5] fix(objectql): the validation-message bridge negotiates the locale through the one rule every other consumer uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ExecutionContext.locale` is the `Accept-Language` header's first tag verbatim — `preferredLocaleFromHeader` reports what was ASKED FOR and expands nothing, deliberately. The engine handed that tag straight to `II18nService.t()`, making the write-path message bridge the one consumer that never negotiated: a served adapter resolves a locale exactly and then falls to its declared fallback (`FileI18nAdapter.t()` is `resolveFromLocale(key, locale)` then `resolveFromLocale(key, fallbackLocale)`), so a bare `zh` missed a `zh-CN` bundle and the caller read an English refusal — on the same response whose dataset, view and object labels were Chinese, because those go through `pickData` and `pickData` negotiates. `validationMessageContext` now resolves the requested tag against what the bridged service reports it holds (`getLocales()`), using `@objectstack/spec`'s `resolveBundleLocale` — the SAME rule `pickData` runs for every document translator. The rule is not re-implemented here: the translators ask it about a bundle's keys, this asks it about the service's locales. Passes the tag through untouched when there is nothing to negotiate against — no service, no `getLocales`, an empty/non-array/throwing answer, or no match. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- ...gine-validation-locale-negotiation.test.ts | 251 ++++++++++++++++++ packages/objectql/src/engine.ts | 64 ++++- 2 files changed, 312 insertions(+), 3 deletions(-) create mode 100644 packages/objectql/src/engine-validation-locale-negotiation.test.ts diff --git a/packages/objectql/src/engine-validation-locale-negotiation.test.ts b/packages/objectql/src/engine-validation-locale-negotiation.test.ts new file mode 100644 index 0000000000..9830e353a0 --- /dev/null +++ b/packages/objectql/src/engine-validation-locale-negotiation.test.ts @@ -0,0 +1,251 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { preferredLocaleFromHeader, translateObject } from '@objectstack/spec/system'; +import { ObjectQL } from './engine'; +import { SchemaRegistry } from './registry'; + +/** + * #15757 — ONE negotiation rule, not two. + * + * `@objectstack/spec`'s `resolveBundleLocale` already decides which bundle a + * requested tag reaches (exact → case-insensitive → base language → VARIANT + * expansion, which is where a bare `zh` reaches a `zh-CN` bundle). `pickData` + * calls it, and every document translator — `translateObject`, + * `translateView`, `translateDataset`, … — goes through `pickData`. + * + * The write path's message bridge did not. `ExecutionContext.locale` is the + * header's first tag verbatim (`preferredLocaleFromHeader`, deliberately: it + * reports what was ASKED FOR and expands nothing), and the engine handed that + * tag straight to `II18nService.t()`. A production adapter resolves a locale + * EXACTLY and then falls to its declared fallback — `FileI18nAdapter.t()` + * (`@objectstack/services-i18n`) is `resolveFromLocale(key, locale)` then + * `resolveFromLocale(key, fallbackLocale)` — so `zh` missed the `zh-CN` bundle + * and the English text came back. + * + * The result was a half-translated response with no way for the app to see it + * coming: same server, same bundle, same header, two paths giving opposite + * answers. The table below is that observation, rebuilt in-repo. + * + * ⛔ These tests must not be satisfied by teaching this package to match + * variants. The whole point of the card is that a THIRD negotiation rule is + * the disease; the engine consults the one in `@objectstack/spec`. + */ +vi.mock('./registry', async () => { + // [#10551] The one shared factory — see `registry-module-mock.ts`. + const { createRegistryModuleMock } = await import('./registry-module-mock.js'); + return createRegistryModuleMock(); +}); + +const AUTHORED_EN = 'Say why the duty is being returned — the owner needs to know what to change.'; +const AUTHORED_ZH = '请写明打回的原因——负责人需要据此知道该改什么。'; + +/** + * ONE bundle, feeding BOTH paths — that is what makes the table a control + * rather than two unrelated readings. `t()` addresses it by dot-notation key + * (`objects.duly_duty._validations.returned_needs_note.message`) and the + * document translators read the same nested locations out of the same object. + */ +const BUNDLE: Record = { + en: { + objects: { + duly_duty: { + label: 'Duty', + fields: { form: { label: 'Form' }, name: { label: 'Duties on the register' } }, + _validations: { returned_needs_note: { message: AUTHORED_EN } }, + }, + }, + }, + 'zh-CN': { + objects: { + duly_duty: { + label: '职责', + fields: { form: { label: '形式' }, name: { label: '清单内职责' } }, + _validations: { returned_needs_note: { message: AUTHORED_ZH } }, + }, + }, + }, +}; + +/** + * An `II18nService` shaped like the adapter a served deployment actually runs. + * + * Faithful to `FileI18nAdapter.t()`: the requested locale EXACTLY, then the + * declared fallback locale, then the key echoed back (the contract's miss + * signal). It performs no variant matching of its own — which is the point: + * negotiation is the caller's job, and every other consumer in the platform + * already delegates it to `@objectstack/spec`. + */ +function servedI18n(bundle: Record, fallbackLocale = 'en') { + const asked: string[] = []; + const dig = (data: unknown, key: string): unknown => + key.split('.').reduce( + (cur, part) => (cur && typeof cur === 'object' ? (cur as any)[part] : undefined), + data, + ); + return { + asked, + t(key: string, locale: string): string { + asked.push(locale); + const exact = dig(bundle[locale], key); + if (typeof exact === 'string') return exact; + const fallback = dig(bundle[fallbackLocale], key); + return typeof fallback === 'string' ? fallback : key; + }, + getLocales: () => Object.keys(bundle), + }; +} + +const DUTY_SCHEMA = { + name: 'duly_duty', + fields: { + name: { type: 'text', label: 'Duties on the register' }, + form: { type: 'select', label: 'Form' }, + status: { type: 'select', label: 'Status' }, + return_note: { type: 'text', label: 'Return note' }, + }, + validations: [{ + type: 'script', + name: 'returned_needs_note', + message: AUTHORED_EN, + condition: "record.status == 'returned' && (record.return_note == null || record.return_note == '')", + fields: ['return_note'], + }], +}; + +function makeDriver() { + return { + name: 'memory', + supports: {}, + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + find: vi.fn().mockResolvedValue([{ id: 'r1' }]), + findOne: vi.fn().mockResolvedValue({ id: 'r1', status: 'open' }), + create: vi.fn(async (_o: string, row: any) => ({ id: 'r1', ...row })), + update: vi.fn(async (_o: string, id: string, row: any) => ({ id, ...row })), + updateMany: vi.fn(async () => 1), + delete: vi.fn(), + } as any; +} + +async function makeEngine(i18n?: { t: (k: string, l: string) => string; getLocales?: () => string[] }) { + vi.mocked((SchemaRegistry as any).getObject).mockImplementation((name: string) => + name === 'duly_duty' ? DUTY_SCHEMA : undefined, + ); + const ql = new ObjectQL(); + ql.registerDriver(makeDriver(), true); + await ql.init(); + if (i18n) ql.setI18nService(i18n as any); + return ql; +} + +/** + * PATH A — the authored validation message. One `PATCH`-shaped write that + * trips `returned_needs_note`, with nothing varying but `accept-language`. + */ +async function refusalFor(header: string, i18n: ReturnType): Promise { + const ql = await makeEngine(i18n); + const locale = preferredLocaleFromHeader(header); + try { + await ql.update( + 'duly_duty', + { id: 'r1', status: 'returned' }, + { context: { locale } } as any, + ); + } catch (e: any) { + return String(e?.fields?.[0]?.message ?? e?.message ?? ''); + } + throw new Error('expected the write to be rejected'); +} + +/** PATH B — the cross-path control: a document translator, same bundle, same header. */ +function datasetStyleLabelsFor(header: string): string[] { + const locale = preferredLocaleFromHeader(header); + const translated = translateObject(DUTY_SCHEMA as any, BUNDLE, { locale }) as any; + return [translated.fields.form.label, translated.fields.name.label]; +} + +describe('#15757 the validation-message bridge negotiates the locale like every other consumer', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + /** + * The card's four rows. THREE OF THEM ARE CONTROLS: `zh-CN` and + * `zh-CN,zh;q=0.9` prove the path itself works (the key is present, the + * bundle is loaded); `en` proves the fallback is right. Only bare `zh` was + * the defect. + */ + it('answers a bare `zh` in Chinese, and leaves the three control rows exactly as they were', async () => { + const i18n = servedI18n(BUNDLE); + // Built as a whole table so ALL FOUR rows are reported by one run — a row + // that stops the test is a row whose controls were never read. + const table: Record = {}; + for (const header of ['zh-CN', 'zh-CN,zh;q=0.9', 'zh', 'en']) { + table[header] = await refusalFor(header, i18n); + } + expect(table).toEqual({ + 'zh-CN': AUTHORED_ZH, // control: the path works at all + 'zh-CN,zh;q=0.9': AUTHORED_ZH, // control: a q-weighted header is unchanged + zh: AUTHORED_ZH, // THE DEFECT: was AUTHORED_EN + en: AUTHORED_EN, // control: the fallback is right + }); + }); + + /** + * The cross-path control, and the whole point of the card: the SAME `zh`, + * against the SAME bundle, must not produce a Chinese screen with an English + * refusal on it. + */ + it('agrees with the document translators on the same header and the same bundle', async () => { + const i18n = servedI18n(BUNDLE); + expect(datasetStyleLabelsFor('zh')).toEqual(['形式', '清单内职责']); + expect(await refusalFor('zh', i18n)).toBe(AUTHORED_ZH); + + expect(datasetStyleLabelsFor('en')).toEqual(['Form', 'Duties on the register']); + expect(await refusalFor('en', i18n)).toBe(AUTHORED_EN); + }); + + /** + * The mechanism, asserted rather than inferred: the bridge asks the service + * for `zh-CN`, the locale `resolveBundleLocale` picked out of what the + * service reports it HAS. `preferredLocaleFromHeader` still returns the bare + * tag — it is not this card's job to change what a client asked for. + */ + it('asks the service for the locale it actually has, not the tag the client sent', async () => { + const i18n = servedI18n(BUNDLE); + expect(preferredLocaleFromHeader('zh')).toBe('zh'); + await refusalFor('zh', i18n); + expect(i18n.asked).not.toContain('zh'); + expect(i18n.asked).toContain('zh-CN'); + }); + + /** + * A tag no variant of which is on offer must not be bent into one. `de` has + * nothing behind it, so the service is asked for `de`, misses, and the + * deployment's own fallback answers — unchanged behaviour. + */ + it('leaves a locale with no match alone', async () => { + const i18n = servedI18n(BUNDLE); + expect(await refusalFor('de', i18n)).toBe(AUTHORED_EN); + expect(i18n.asked).toContain('de'); + }); + + /** + * `getLocales()` is required by `II18nService`, but the engine's setter + * accepts anything with a `t` — a partial double, a host-supplied shim. With + * nothing to negotiate against, the requested tag is passed through + * verbatim: negotiation needs a list of what EXISTS, and inventing one is + * how a second rule gets born. + */ + it('passes the tag through when the service cannot report its locales', async () => { + const asked: string[] = []; + const ql = await makeEngine({ + t: (key: string, locale: string) => { asked.push(locale); return key; }, + }); + try { + await ql.update('duly_duty', { id: 'r1', status: 'returned' }, { context: { locale: 'zh' } } as any); + } catch { /* expected */ } + expect(asked).toContain('zh'); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 3250bf4fcf..333fce6857 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -55,6 +55,7 @@ import { isDataMigrationFlagVerified, renderOperationMessage, objectLabelKey, + resolveBundleLocale, } from '@objectstack/spec/system'; import { ExecutionContext, ExecutionContextSchema } from '@objectstack/spec/kernel'; import type { FlowFunctionEffect } from '@objectstack/spec/automation'; @@ -2572,7 +2573,13 @@ export class ObjectQL implements IObjectQLEngine { // i18n service backing validation-message + field-label localization (#3957). // Optional: without it, messages render from the built-in catalog against the // declared labels. - private i18nService?: { t?: (key: string, locale: string, params?: Record) => string }; + private i18nService?: { + t?: (key: string, locale: string, params?: Record) => string; + // [#15757] `II18nService` requires `getLocales()`; it is optional HERE for + // the same reason `t` is — the setter accepts any partial shim, and a shim + // that cannot say what it holds is simply not negotiated against. + getLocales?: () => string[]; + }; // Crypto provider backing `secret`-typed fields. Optional: when absent, // writing an object that declares a secret field fails closed (never @@ -6060,7 +6067,7 @@ export class ObjectQL implements IObjectQLEngine { * a deployment's `validation.field.*` message overrides, and the field's * TRANSLATED label for apps whose declared labels are in another language. */ - setI18nService(service: { t?: (key: string, locale: string, params?: Record) => string }): void { + setI18nService(service: { t?: (key: string, locale: string, params?: Record) => string; getLocales?: () => string[] }): void { this.i18nService = service; this.logger.info('I18nService configured for validation messages'); } @@ -6084,11 +6091,62 @@ export class ObjectQL implements IObjectQLEngine { const t = this.i18nService?.t; return { objectName, - locale: context?.locale, + locale: this.negotiatedMessageLocale(context?.locale), translate: t ? (key, locale, params) => t.call(this.i18nService, key, locale, params) : undefined, }; } + /** + * The locale the bridged i18n service can actually ANSWER in, for the locale + * the caller asked for (#15757). + * + * `ExecutionContext.locale` is the `Accept-Language` header's first tag + * verbatim — `preferredLocaleFromHeader` reports what was ASKED FOR and + * expands nothing, deliberately, because every one of its callers negotiates + * differently. Handing that tag straight to `II18nService.t()` made this the + * one consumer that never negotiated at all: a served adapter resolves a + * locale EXACTLY and then falls to its declared fallback + * (`FileI18nAdapter.t()` is `resolveFromLocale(key, locale)` then + * `resolveFromLocale(key, fallbackLocale)`), so a bare `zh` missed a `zh-CN` + * bundle and the caller read an English refusal — on the same response whose + * dataset, view and object labels were Chinese, because those go through + * `pickData` and `pickData` negotiates. + * + * ⛔ The rule is NOT re-implemented here. `resolveBundleLocale` + * (`@objectstack/spec/system`) is the single negotiation rule the document + * translators already run — exact → case-insensitive → base language → + * variant expansion, which is the step that reaches `zh-CN` from `zh` — and + * this asks it the same question about a different set of available locales: + * the translators' set is the bundle's keys, and the service's set is what + * `getLocales()` reports it holds. A second rule living here is the defect, + * not the fix. + * + * Passes the requested tag through untouched whenever there is nothing to + * negotiate against — no service, no `getLocales`, an empty or non-array + * answer, a throwing one, or no match. Negotiation needs a list of what + * EXISTS; inventing one is how a second rule gets born. + */ + private negotiatedMessageLocale(requested: string | undefined): string | undefined { + if (!requested) return requested; + const getLocales = this.i18nService?.getLocales; + if (typeof getLocales !== 'function') return requested; + let available: unknown; + try { + available = getLocales.call(this.i18nService); + } catch { + // A misbehaving i18n service must not turn a write into a 500. + return requested; + } + if (!Array.isArray(available) || available.length === 0) return requested; + // `resolveBundleLocale` matches against a bundle's KEYS; the offered + // locales are exactly that key set, with no data behind them. + const offered: Record = {}; + for (const code of available) { + if (typeof code === 'string' && code.length > 0) offered[code] = true; + } + return resolveBundleLocale(offered, requested) ?? requested; + } + /** * An OBJECT's display name in the caller's locale: translation bundle → * declared `label` → API name (#7307). From 727b457df87f4c95742a4387db28f9d575f15766 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 23:35:04 +0000 Subject: [PATCH 2/5] test(objectql): measure the refusal envelope across locales; changeset The four-row table now also reads the MACHINE-READABLE half of the envelope for every header: same `code`, same `field`, same refusal, and a satisfying record still accepted in every locale. That turns "no request is newly accepted or rejected" from an assertion into a measurement. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../validation-message-locale-negotiation.md | 15 +++++++ ...gine-validation-locale-negotiation.test.ts | 41 ++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 .changeset/validation-message-locale-negotiation.md diff --git a/.changeset/validation-message-locale-negotiation.md b/.changeset/validation-message-locale-negotiation.md new file mode 100644 index 0000000000..0a67b9bd8e --- /dev/null +++ b/.changeset/validation-message-locale-negotiation.md @@ -0,0 +1,15 @@ +--- +"@objectstack/objectql": minor +--- + +`accept-language: zh` now reads a Chinese refusal on the response whose labels are already Chinese. + +`@objectstack/spec` has one locale-negotiation rule — `resolveBundleLocale`: exact match, then case-insensitive, then base language, then **variant expansion**, which is the step that reaches a `zh-CN` bundle from a bare `zh`. `pickData` calls it, and every document translator (`translateObject`, `translateView`, `translateDataset`, …) goes through `pickData`. That is why an app shipping only `zh-CN` still answered `accept-language: zh` with translated object, view and dataset labels. + +The write path's message bridge was the one consumer that never negotiated. `ExecutionContext.locale` is the header's first tag verbatim — `preferredLocaleFromHeader` reports what was *asked for* and expands nothing, deliberately, because each of its callers negotiates differently — and the engine handed that tag straight to `II18nService.t()`. A served adapter resolves a locale exactly and then falls to its declared fallback (`FileI18nAdapter.t()` is `resolveFromLocale(key, locale)` then `resolveFromLocale(key, fallbackLocale)`), so `zh` missed the `zh-CN` bundle and the English text came back. The result was a half-translated response an app had no way to see coming: the bundle key was present and correct and the coverage gate was green. + +`ObjectQL`'s validation-message context now resolves the requested tag against what the bridged service reports it holds (`II18nService.getLocales()`), through that same `resolveBundleLocale`. The rule is not re-implemented in the engine — the document translators ask it about a bundle's keys, and this asks it about the service's locales. Authored `objects.._validations..message` text, `validation.field.*` overrides and translated field labels all follow, because they read one locale. + +Unchanged: **which** writes are refused, and everything machine-readable about a refusal — the `code`, the `field`, the `constraint`, the status. Only the language of the sentence moves. `preferredLocaleFromHeader` is untouched, and so is every other caller of it. A request with nothing to negotiate against — no i18n service, a service that cannot report its locales, or a tag no variant of which is on offer — passes through exactly as before. + +`ObjectQL.setI18nService` accepts an optional `getLocales?: () => string[]` alongside `t`. `II18nService` has always required `getLocales()`, so every real service already satisfies it; a partial shim that omits it keeps today's behaviour rather than being negotiated against. diff --git a/packages/objectql/src/engine-validation-locale-negotiation.test.ts b/packages/objectql/src/engine-validation-locale-negotiation.test.ts index 9830e353a0..2185dc4970 100644 --- a/packages/objectql/src/engine-validation-locale-negotiation.test.ts +++ b/packages/objectql/src/engine-validation-locale-negotiation.test.ts @@ -143,7 +143,10 @@ async function makeEngine(i18n?: { t: (k: string, l: string) => string; getLocal * PATH A — the authored validation message. One `PATCH`-shaped write that * trips `returned_needs_note`, with nothing varying but `accept-language`. */ -async function refusalFor(header: string, i18n: ReturnType): Promise { +async function refusalEnvelopeFor( + header: string, + i18n: ReturnType, +): Promise<{ code: string; field: string; message: string }> { const ql = await makeEngine(i18n); const locale = preferredLocaleFromHeader(header); try { @@ -153,11 +156,16 @@ async function refusalFor(header: string, i18n: ReturnType): { context: { locale } } as any, ); } catch (e: any) { - return String(e?.fields?.[0]?.message ?? e?.message ?? ''); + const f = e?.fields?.[0] ?? {}; + return { code: String(f.code), field: String(f.field), message: String(f.message ?? e?.message ?? '') }; } throw new Error('expected the write to be rejected'); } +async function refusalFor(header: string, i18n: ReturnType): Promise { + return (await refusalEnvelopeFor(header, i18n)).message; +} + /** PATH B — the cross-path control: a document translator, same bundle, same header. */ function datasetStyleLabelsFor(header: string): string[] { const locale = preferredLocaleFromHeader(header); @@ -192,6 +200,35 @@ describe('#15757 the validation-message bridge negotiates the locale like every }); }); + /** + * The MACHINE-READABLE half of the envelope is what a client ACTS on, and it + * is untouched: the write is refused in exactly the same cases, with the same + * `code` and the same `field`, for every one of the four headers. Only the + * sentence's LANGUAGE moved — no request is newly accepted, and none is newly + * rejected. + */ + it('changes the language of a refusal and nothing about the refusal', async () => { + const i18n = servedI18n(BUNDLE); + const envelopes = []; + for (const header of ['zh-CN', 'zh-CN,zh;q=0.9', 'zh', 'en', 'de']) { + envelopes.push(await refusalEnvelopeFor(header, i18n)); + } + // Every header is refused, and refused identically where it counts. + expect(envelopes.map((e) => e.code)).toEqual( + ['rule_violation', 'rule_violation', 'rule_violation', 'rule_violation', 'rule_violation'], + ); + expect(envelopes.map((e) => e.field)).toEqual( + ['return_note', 'return_note', 'return_note', 'return_note', 'return_note'], + ); + // A record that satisfies the rule is still accepted, in every locale. + for (const locale of ['zh-CN', 'zh', 'en', 'de']) { + const ql = await makeEngine(i18n); + await expect( + ql.update('duly_duty', { id: 'r1', status: 'returned', return_note: 'why' }, { context: { locale } } as any), + ).resolves.toBeDefined(); + } + }); + /** * The cross-path control, and the whole point of the card: the SAME `zh`, * against the SAME bundle, must not produce a Chinese screen with an English From 1a55ef2f25c5d0186968da1c7a5442eb1acfea3d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 23:57:05 +0000 Subject: [PATCH 3/5] docs(permissions): re-anchor the system-context census after the engine.ts line shift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure line rot from this branch's insertion into `packages/objectql/src/engine.ts`, repaired by the gate's own `node scripts/check-system-context-census.mjs --fix`. Line-number anchors only; no prose, no row, no behaviour. Control: with `engine.ts` swapped to the merge base and every other file left at this head, `check-system-context-census` exits 0 — so the shift is the sole cause. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- content/docs/permissions/system-context.mdx | 24 ++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 241eedd25b..ce454f87a2 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -109,17 +109,17 @@ 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:11675` | -| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11858` | -| 20 | **`readonly` strip bypassed — INSERT** | objectql | Same, on create — one gate over BOTH create-side passes since the 2026-09-03 ruling moved the static-`readonly` strip in beside the runtime-owned one and deleted the DataProtocol ingress copy. `isSystem` is the **only** exemption on this path: `preserveAudit` is deliberately not read on create, so a non-system historical import is still stripped | `objectql/src/engine.ts:10323` | -| 21 | 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:10456`, `readonly-strict-errors.ts:66` | -| 22 | **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:6182` | -| 23 | 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:3913`, `:3923`, `:3950` | +| 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:11733` | +| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11916` | +| 20 | **`readonly` strip bypassed — INSERT** | objectql | Same, on create — one gate over BOTH create-side passes since the 2026-09-03 ruling moved the static-`readonly` strip in beside the runtime-owned one and deleted the DataProtocol ingress copy. `isSystem` is the **only** exemption on this path: `preserveAudit` is deliberately not read on create, so a non-system historical import is still stripped | `objectql/src/engine.ts:10381` | +| 21 | 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:10514`, `readonly-strict-errors.ts:66` | +| 22 | **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:6240` | +| 23 | 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:3920`, `:3930`, `:3957` | | 24 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` | | 25 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:99` | -| 26 | 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:6881` | -| 27 | 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:12477` | -| 28 | 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:12406` | +| 26 | 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:6939` | +| 27 | 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:12535` | +| 28 | 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:12464` | | 29 | **Bulk data event `organizationId` OMITTED** — the batch is published "not asserted" | plugin-security | Get: nothing — the `data.records.*` event still publishes. Lose: the per-organization attribution: this exit is taken before the security middleware composes any tenant wall, so it records no Layer 0 verdict on the operation (`OperationContext.tenantLayer0Verdict`, #15813), and the engine's bulk producer — which reads that recorded verdict and nothing else — omits the key rather than filling it from the caller's `tenantId`; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) | `security-plugin.ts:1686` | ### 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:3720` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 63 | `objectql/src/engine.ts:14923` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 62 | `objectql/src/engine.ts:3727` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | +| 63 | `objectql/src/engine.ts:14981` | 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:2032` (rationale at `:1942`–`1944`, #3760), `flow.zod.ts:743` | | "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:10306`–`10323` | +| "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:10364`–`10381` | | "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:1581` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` | From 0dce1bf850c718b83c596ace5dc66e9e1d75e7a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 00:18:25 +0000 Subject: [PATCH 4/5] docs(permissions): regenerate the system-context census from the merged tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `content/docs/permissions/system-context.mdx` is an os-regen artifact: the merge driver resolved it with exit 0 while silently keeping one side, so it is regenerated from the merged tree with the repo's own tooling (`pnpm gen:system-context-census`) rather than hand-reconciled. Blast radius measured against `origin/main` rather than assumed: 65 rows before and 65 after, and every changed line is the same line with a different `packages/objectql/src/engine.ts` line number — no row dropped, no prose moved. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- content/docs/permissions/system-context.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index ce454f87a2..4a92ebcf4e 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -47,7 +47,7 @@ nothing to do with elevation. | Declaration | What it is | This page? | |:---|:---|:---:| | `ExecutionContext.isSystem` — `packages/spec/src/kernel/execution-context.zod.ts:269` | The elevation flag on an operation's context | ✅ | -| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1595` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ | +| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1634` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ | | `EmailTemplate.isSystem` — `packages/spec/src/system/email-template.zod.ts:125` | Built-in template; tenants may override but should not delete | ❌ | | `Environment.isSystem` — `packages/spec/src/cloud/environment.zod.ts:137` | Platform-infrastructure environment, not user data | ❌ | @@ -196,7 +196,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:2032` (rationale at `:1942`–`1944`, #3760), `flow.zod.ts:743` | | "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:10364`–`10381` | -| "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:1581` (#3493 / #6640) | +| "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:1590` (#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` | | "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1565`, `:1594`; `domains/actions.ts:414` | From 2634da48b127296e4e3c636f0c6309eacbc0f061 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 00:45:58 +0000 Subject: [PATCH 5/5] docs(permissions): re-anchor the system-context census on the merged tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge with `origin/main` brought `share-link-service.ts` line moves into the census while this branch's `engine.ts` change had moved fifteen anchors of its own. `content/docs/permissions/system-context.mdx` is routed to the `os-regen` merge driver precisely because a text merge of the two cannot be right; regenerated from the merged tree, both sides' anchors are present. `check:system-context-census` verdict on the result: OK — 105 elevation read sites in 19 packages across 44 files, all anchored; 140 anchors resolve, 27 declared non-read. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- content/docs/permissions/system-context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 4a92ebcf4e..183fbb7070 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -135,7 +135,7 @@ The largest single consumer — **17 of the 105 sites**. | 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1476` (guard at `:1501`) | | 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1528` | | 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1189` | -| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:469`, `:523`, `:527`, `:600`, `:630` | +| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:459`, `:513`, `:517`, `:590`, `:620` | | 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` | | 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:278`, `:503` |