Skip to content

Commit 37a8f2b

Browse files
fix(objectql): make the static-readonly write strip state its consequence and remedy (#4903) (#5123)
A `readonly: true` column written by server-side code — a cron/background job reaching the engine via `ctx.getService('data')` — was dropped while the call reported success, leaving one log line that said what the engine did but not what it cost the caller or how to fix it. The strip now names the object and field, states that the update was COMMITTED WITHOUT the field, and carries both remedies: `{ context: { isSystem: true } }` for genuinely trusted server code, and `options.onFieldsDropped` (#3407) for a machine-readable signal. Level stays `warn` — this seam cannot tell a forged client body from trusted server code (`ExecutionContext` has no origin marker; `isSystem` is the only trust bit and it is the exemption), so `error` would be client-triggerable log spam and `debug` would restore the silent drop. Behaviour unchanged. Adds a pin suite for the hook-backfill asymmetry and its mechanism (`suppliedKeys` is snapshotted at engine entry, before hooks run), and documents the semantics on the security protocol page. Claude-Session: https://claude.ai/code/session_01NrmBxj8rK2uGCnh9aipjwX Co-authored-by: Claude <noreply@anthropic.com>
1 parent d17df80 commit 37a8f2b

4 files changed

Lines changed: 404 additions & 2 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): the static-`readonly` write strip now logs its consequence and its remedy
6+
7+
Writing a `readonly: true` column from server-side code — a cron job or background
8+
task reaching the engine through `ctx.getService('data')` — dropped the value and
9+
reported success. The only trace was:
10+
11+
```
12+
WARN Field 'work_duration' is read-only — ignoring incoming change (#2948)
13+
```
14+
15+
which says what the engine did, not what it cost the caller or how to fix it. The
16+
downstream symptom is a field that persists fine through every REST path and never
17+
persists from cron (os-project-titanwind-ehr#750), which reads as "cron is broken"
18+
rather than "the value was stripped". The strip now names the object, states that
19+
the update was **committed without the field**, and carries both remedies: trusted
20+
server code declares itself with `{ context: { isSystem: true } }`, and any caller
21+
can detect drops programmatically with `options.onFieldsDropped` (the machine-readable
22+
strip signal that has existed since #3407 — one event per strip pass, with `fields`
23+
and `reason`).
24+
25+
The level stays `warn`, deliberately: this seam cannot distinguish a hostile REST
26+
body forging `created_by` from trusted server code, because `ExecutionContext`
27+
carries no origin marker and `isSystem` — the only trust bit — is precisely the
28+
exemption. `error` would make the error log client-triggerable; `debug` would restore
29+
the silent drop.
30+
31+
Behaviour is unchanged: what is stripped, what survives, and what `onFieldsDropped`
32+
reports are all identical. Documented in the [security protocol
33+
page](/docs/protocol/objectql/security) — strip condition, the caller-supplied-keys
34+
scope, why a `beforeUpdate` hook's backfill is exempt (the key snapshot is taken at
35+
engine entry, before hooks run), and the `isSystem` convention for plugin writes —
36+
and pinned in `engine-readonly-strip-signal.test.ts`.

content/docs/protocol/objectql/security.mdx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,64 @@ fields:
246246

247247
> There is no separate `create` flag for fields. `editable` governs both create-time and update-time writes; read stripping is governed by `readable`.
248248

249+
### Static `readonly` fields on the write path
250+
251+
`readonly: true` on a field definition is a *schema-level* lock, independent of permission
252+
sets: no permission set can grant a write to it. It is enforced by **stripping**, not by
253+
rejecting — the offending key is removed from the payload and the rest of the write is
254+
committed. The write therefore **succeeds** (REST answers `200`), and the read-only column
255+
simply keeps its stored value.
256+
257+
Four rules decide whether a given value survives:
258+
259+
| # | Rule | Effect |
260+
|:--|:---|:---|
261+
| 1 | **Trusted context is exempt** | A write carrying `context.isSystem === true` skips the strip entirely and may set read-only columns. |
262+
| 2 | **Only *caller-supplied* keys are candidates** | The engine snapshots the payload's keys at entry (`suppliedKeys`), *before* middleware and `beforeUpdate` hooks run. Only keys in that snapshot can be stripped. |
263+
| 3 | **Hook / middleware backfill survives** | A key a `beforeUpdate` hook *adds* to `data` is absent from the entry snapshot, so it is not a candidate — this is why the built-in `updated_by` / `updated_at` stamps land even though those columns are `readonly`. |
264+
| 4 | **`context.preserveAudit` admits a whitelist** | An opt-in historical import reinstates the audit/timestamp family and author-declared business `readonly` fields; platform-managed `system` columns (tenancy, generated) stay stripped. |
265+
266+
Rule 2 is scoped to keys, not values: a key the caller sent stays a strip candidate even if
267+
a hook later overwrites its value. So a `beforeUpdate` hook can *backfill* a read-only
268+
field, but cannot *rescue* one the caller supplied.
269+
270+
<Callout type="warn">
271+
**Server-side plugins are not trusted by default.** `ctx.getService('data')` hands back the
272+
same engine the REST layer uses, with an **empty execution context** — so a cron job or
273+
background task writing a system-computed read-only column travels the same strip as
274+
untrusted client input, and its value is dropped while the call reports success. Trusted
275+
server code must say so:
276+
277+
```ts
278+
const data = ctx.getService('data');
279+
await data.update('attendance', { id, status: 'closed', work_duration: 480 },
280+
{ context: { isSystem: true } }); // ← required to write a `readonly` column
281+
```
282+
283+
`isSystem` is the documented convention for this, and it is deliberately explicit: it
284+
bypasses permission checks too, so it declares "this write is platform code acting as the
285+
platform", not "this write came from a plugin".
286+
</Callout>
287+
288+
**Detecting a drop programmatically.** Every strip pass reports itself to the caller
289+
through `options.onFieldsDropped`, so a caller that reports per-field success (a flow's
290+
`update_record` step, an import runner) can surface a warning instead of a clean success:
291+
292+
```ts
293+
await data.update('attendance', { id, work_duration: 480 }, {
294+
onFieldsDropped: ({ object, fields, reason }) => {
295+
// { object: 'attendance', fields: ['work_duration'], reason: 'readonly' }
296+
logger.warn(`dropped ${fields.join(', ')} on ${object} (${reason})`);
297+
},
298+
});
299+
```
300+
301+
`reason` is `'readonly'` for this static lock and `'readonly_when'` for a conditional
302+
[`readonlyWhen`](/docs/references/data/field) predicate. The listener is an in-process
303+
callback: it is delivered by the local engine, and does **not** cross the RPC / Virtual
304+
Data Engine boundary, so a remote caller never receives these events. Without a listener,
305+
the only trace is a server-side `WARN` naming the object, the field, and both remedies.
306+
249307
---
250308

251309
## 4. Data Masking
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #4903 — the static `readonly: true` write strip, from the CALLER's side.
4+
//
5+
// A server-side plugin (cron / background job) that writes a `readonly` column
6+
// through `ctx.getService('data').update(...)` gets a SUCCESSFUL call whose
7+
// value never lands: `getService('data')` hands back the plain engine, whose
8+
// execution context is empty, so `!context.isSystem` sends trusted server code
9+
// down the same strip as untrusted client input. Downstream report:
10+
// os-project-titanwind-ehr#750 — a settled `work_duration` stayed `null` when
11+
// (and only when) the auto-clock-out cron wrote it, while every REST path for
12+
// the same field worked, which reads as "cron is broken" rather than "the field
13+
// was stripped".
14+
//
15+
// This suite pins the three things that decide how expensive that is to
16+
// diagnose:
17+
//
18+
// 1. WHY the hook path differs (`suppliedKeys` is snapshotted at engine entry,
19+
// BEFORE middleware and beforeUpdate hooks run) — the asymmetry the issue
20+
// reports is pinned as EXISTING behaviour so the next change to it is
21+
// deliberate. Nothing here endorses it.
22+
// 2. The strip's log states the CONSEQUENCE and both REMEDIES, so the log is
23+
// actionable on its own (#4632's second-class shape: caller believes
24+
// persisted, database disagrees, log is the only trace).
25+
// 3. The machine-readable signal that ALREADY exists — `onFieldsDropped`
26+
// (#3407) — is reachable from exactly the caller shape the issue describes
27+
// (in-process engine, no context), and `{ context: { isSystem: true } }`
28+
// makes the same write land.
29+
//
30+
// What is NOT here: a strict/reject mode. That needs a new write-option key,
31+
// and both homes for it (`EngineUpdateOptionsSchema`, `WriteObservabilityOptions`)
32+
// live in `packages/spec` — see the issue thread.
33+
34+
import { describe, it, expect, beforeEach } from 'vitest';
35+
import { ObjectQL } from './engine.js';
36+
import { readonlyStripWarning, stripReadonlyFields } from './validation/rule-validator.js';
37+
38+
function makeDriver() {
39+
const stores = new Map<string, Map<string, any>>();
40+
const storeFor = (o: string) => {
41+
let s = stores.get(o);
42+
if (!s) { s = new Map(); stores.set(o, s); }
43+
return s;
44+
};
45+
const matches = (row: any, where: any): boolean => {
46+
if (!where || typeof where !== 'object') return true;
47+
return Object.entries(where).every(([k, v]: [string, any]) => row?.[k] === v);
48+
};
49+
let n = 0;
50+
const driver: any = {
51+
name: 'memory', version: '0.0.0', supports: {},
52+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
53+
async find(object: string, ast: any) {
54+
return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where));
55+
},
56+
async findOne(object: string, ast: any) {
57+
for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r;
58+
return null;
59+
},
60+
async create(object: string, data: Record<string, unknown>) {
61+
n += 1;
62+
const id = (data.id as string) ?? `r_${n}`;
63+
const row = { ...data, id };
64+
storeFor(object).set(id, row);
65+
return row;
66+
},
67+
async update(object: string, id: string, data: Record<string, unknown>) {
68+
const s = storeFor(object);
69+
const row = { ...s.get(id), ...data, id };
70+
s.set(id, row);
71+
return row;
72+
},
73+
async updateMany(object: string, ast: any, data: Record<string, unknown>) {
74+
const s = storeFor(object);
75+
let count = 0;
76+
for (const row of [...s.values()]) {
77+
if (!matches(row, ast?.where)) continue;
78+
s.set(row.id, { ...row, ...data, id: row.id });
79+
count += 1;
80+
}
81+
return count;
82+
},
83+
async delete(object: string, id: string) { return storeFor(object).delete(id); },
84+
async count() { return 0; },
85+
async bulkCreate(object: string, rows: Record<string, unknown>[]) {
86+
return Promise.all(rows.map((r) => this.create(object, r, undefined)));
87+
},
88+
async bulkUpdate() { return []; }, async bulkDelete() {},
89+
async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; },
90+
async commit() {}, async rollback() {},
91+
};
92+
return { driver, storeFor };
93+
}
94+
95+
/** A logger that records the lines the engine writes, for the log-contract pin. */
96+
function makeCapturingLogger() {
97+
const warns: string[] = [];
98+
const logger: any = {
99+
warns,
100+
debug() {}, info() {}, error() {}, trace() {}, fatal() {},
101+
warn(msg: string) { warns.push(String(msg)); },
102+
child() { return logger; },
103+
};
104+
return logger;
105+
}
106+
107+
describe('static `readonly` write strip — caller-facing signal (#4903)', () => {
108+
let engine: ObjectQL;
109+
let logger: ReturnType<typeof makeCapturingLogger>;
110+
let storeFor: ReturnType<typeof makeDriver>['storeFor'];
111+
112+
beforeEach(async () => {
113+
logger = makeCapturingLogger();
114+
engine = new ObjectQL({ logger });
115+
const d = makeDriver();
116+
storeFor = d.storeFor;
117+
engine.registerDriver(d.driver, true);
118+
await engine.init();
119+
// The downstream shape, trimmed: an attendance record whose worked-hours
120+
// column is settled by the platform, never typed by a user.
121+
engine.registry.registerObject({
122+
name: 'attendance',
123+
fields: {
124+
status: { type: 'text' },
125+
check_out_time: { type: 'datetime' },
126+
work_duration: { type: 'number', readonly: true },
127+
},
128+
} as any);
129+
storeFor('attendance').set('att_1', { id: 'att_1', status: 'open', work_duration: null });
130+
});
131+
132+
const att = () => storeFor('attendance').get('att_1');
133+
134+
// ── 1. the reported behaviour, and the documented way out ───────────────
135+
136+
it('THE REPORT: a contextless plugin write lands every field EXCEPT the readonly one', async () => {
137+
await engine.update('attendance', {
138+
id: 'att_1', status: 'closed', check_out_time: '2026-08-04T09:00:00Z', work_duration: 480,
139+
});
140+
// The call resolved without throwing — that is the whole complaint.
141+
expect(att()).toMatchObject({ status: 'closed', check_out_time: '2026-08-04T09:00:00Z' });
142+
expect(att().work_duration).toBeNull();
143+
});
144+
145+
it('the SAME write lands in full once the caller declares itself trusted', async () => {
146+
await engine.update(
147+
'attendance',
148+
{ id: 'att_1', status: 'closed', work_duration: 480 },
149+
{ context: { isSystem: true } } as any,
150+
);
151+
expect(att()).toMatchObject({ status: 'closed', work_duration: 480 });
152+
});
153+
154+
// ── 2. the machine-readable signal that already exists (#3407) ───────────
155+
156+
it('reports the drop to a contextless caller via onFieldsDropped', async () => {
157+
// The listener is reachable from exactly the shape the issue describes:
158+
// `ctx.getService('data')` registers the in-process engine (plugin.ts), so
159+
// no RPC boundary swallows the event.
160+
const events: any[] = [];
161+
await engine.update(
162+
'attendance',
163+
{ id: 'att_1', status: 'closed', work_duration: 480 },
164+
{ onFieldsDropped: (e: any) => events.push(e) } as any,
165+
);
166+
expect(events).toEqual([{ object: 'attendance', fields: ['work_duration'], reason: 'readonly' }]);
167+
});
168+
169+
it('reports the drop on the BULK path too', async () => {
170+
const events: any[] = [];
171+
await engine.update(
172+
'attendance',
173+
{ work_duration: 480 },
174+
{ where: { status: 'open' }, multi: true, onFieldsDropped: (e: any) => events.push(e) } as any,
175+
);
176+
expect(events).toEqual([{ object: 'attendance', fields: ['work_duration'], reason: 'readonly' }]);
177+
expect(att().work_duration).toBeNull();
178+
});
179+
180+
// ── 3. the log is actionable on its own ─────────────────────────────────
181+
182+
it('the strip WARNs the consequence and BOTH remedies, naming the object and field', async () => {
183+
await engine.update('attendance', { id: 'att_1', work_duration: 480 });
184+
const line = logger.warns.find((w: string) => w.includes('work_duration'));
185+
expect(line, 'the strip must log').toBeDefined();
186+
// Consequence — not just "ignoring incoming change".
187+
expect(line).toContain("Field 'work_duration' on 'attendance'");
188+
expect(line).toContain('COMMITTED WITHOUT IT');
189+
// Remedy A: the documented convention for genuinely trusted server code.
190+
expect(line).toContain('{ context: { isSystem: true } }');
191+
// Remedy B: the machine-readable signal, so the log is not the only way out.
192+
expect(line).toContain('onFieldsDropped');
193+
});
194+
195+
it('stays at WARN — the seam cannot tell a forged client body from trusted server code', () => {
196+
// Pinned deliberately: `ExecutionContext` carries no origin/channel marker,
197+
// so an `error` level here would be client-triggerable log spam and a
198+
// `debug` level would restore the silent drop. One level, chosen.
199+
const calls: Array<[string, string]> = [];
200+
const probe: any = {
201+
warn: (m: string) => calls.push(['warn', m]),
202+
error: (m: string) => calls.push(['error', m]),
203+
info: (m: string) => calls.push(['info', m]),
204+
debug: (m: string) => calls.push(['debug', m]),
205+
};
206+
stripReadonlyFields(
207+
{ name: 'attendance', fields: { work_duration: { type: 'number', readonly: true } } } as any,
208+
{ work_duration: 480 },
209+
new Set(['work_duration']),
210+
probe,
211+
);
212+
expect(calls.map(([level]) => level)).toEqual(['warn']);
213+
expect(calls[0][1]).toBe(readonlyStripWarning('work_duration', 'attendance'));
214+
});
215+
216+
it('omits the object clause when the schema carries no name', () => {
217+
expect(readonlyStripWarning('work_duration')).toContain("Field 'work_duration' is read-only");
218+
});
219+
220+
// ── 4. the hook/plugin asymmetry, PINNED as-is ──────────────────────────
221+
//
222+
// NOT an endorsement. The issue asks whether a beforeUpdate hook SHOULD be
223+
// able to write a column a plugin cannot; that question is open. This pins
224+
// the current answer and — more usefully — the MECHANISM, so a future change
225+
// is made on purpose instead of by accident.
226+
227+
describe('beforeUpdate backfill vs. caller supply (pinned mechanism)', () => {
228+
it('a hook-written readonly field LANDS while the same field supplied by the caller is stripped', async () => {
229+
engine.registerHook('beforeUpdate', async (ctx: any) => {
230+
ctx.input.data.work_duration = 480;
231+
}, { object: 'attendance' });
232+
233+
// Caller supplies nothing read-only; the hook backfills it → persisted.
234+
await engine.update('attendance', { id: 'att_1', status: 'closed' });
235+
expect(att().work_duration).toBe(480);
236+
});
237+
238+
it('a hook cannot rescue a key the CALLER supplied — the snapshot is taken first', async () => {
239+
// The mechanism, stated: `suppliedKeys` is `new Set(Object.keys(data))`
240+
// captured at engine entry, BEFORE middleware and beforeUpdate hooks run.
241+
// A key the hook ADDS is absent from that snapshot and survives; a key the
242+
// caller sent is in it and is stripped no matter what the hook does to the
243+
// value afterwards.
244+
storeFor('attendance').set('att_2', { id: 'att_2', status: 'open', work_duration: null });
245+
engine.registerHook('beforeUpdate', async (ctx: any) => {
246+
if (ctx.input.data.work_duration !== undefined) ctx.input.data.work_duration = 999;
247+
}, { object: 'attendance' });
248+
249+
await engine.update('attendance', { id: 'att_2', status: 'closed', work_duration: 480 });
250+
expect(storeFor('attendance').get('att_2')).toMatchObject({ status: 'closed' });
251+
expect(storeFor('attendance').get('att_2').work_duration).toBeNull();
252+
});
253+
254+
it('the engine-stamped audit column is the same exemption, not a special case', () => {
255+
// `updated_by` survives a user write for exactly one reason: the audit
256+
// hook writes it, so it is not in `suppliedKeys`. Supplied explicitly, it
257+
// is dropped like any other readonly field.
258+
const schema = {
259+
name: 'attendance',
260+
fields: { updated_by: { type: 'text', readonly: true, system: true } },
261+
} as any;
262+
const stamped = stripReadonlyFields(schema, { updated_by: 'hook-stamp' }, new Set());
263+
expect(stamped).toEqual({ updated_by: 'hook-stamp' });
264+
const forged = stripReadonlyFields(schema, { updated_by: 'attacker' }, new Set(['updated_by']));
265+
expect(forged).toEqual({});
266+
});
267+
});
268+
});

0 commit comments

Comments
 (0)