Skip to content

Commit b992b1d

Browse files
baozhoutaoclaude
andauthored
fix(lint): stop the readonlyWhen hints ruling out the working remedy and offering a useless one (#14202)
Message text only across three carriers; rule ids, severities and match sets untouched. The flow hint recommended runAs:'system'. The conditional strip has no isSystem guard at all, so that is a privilege widening for no behaviour change (LOCK 2 pins it). The hook hint and the hook-bodies.mdx bullet asserted readonlyWhen strips a beforeUpdate-derived value -- the behaviour #9107 removed -- thereby ruling out the one remedy that works. All three now name the two measured remedies and refuse elevation, following the shape action-api-update-readonly-when-field already ships. The static-readonly hints that recommend elevation are deliberately unchanged; a new pin holds the two apart. Fixes #13832 Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV Co-authored-by: Claude <noreply@anthropic.com>
1 parent 34ce8e7 commit b992b1d

6 files changed

Lines changed: 157 additions & 22 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): stop the `readonlyWhen` hints ruling out the remedy that works and offering one that does not (#13832)
6+
7+
Message text only. Rule ids, severities and match sets are untouched, and no
8+
finding changes shape — but the hint **is** the whole product of an advisory
9+
rule (neither finding blocks a build), so the sentence is all the author acts
10+
on, and both of these sentences were measured false against the engine.
11+
12+
`flow-update-readonly-flow-writes`' `flow-update-readonly-when-field` hint said:
13+
14+
> If automation must maintain this field regardless of record state, run the flow runAs:'system'.
15+
16+
It does not. The conditional strip has **no `isSystem` guard at all**
17+
`stripReadonlyWhenFields` runs unconditionally on the update path, unlike the
18+
static `readonly` strip beside it that really is skipped for system callers.
19+
So the advice bought the author a `runAs:'system'` flow, a re-run, the same
20+
missing column, and an elevated run identity in the tree with no compensating
21+
behaviour: **a privilege widening for no effect**. Pinned as "LOCK 2 — isSystem
22+
does NOT exempt a caller-supplied value" in
23+
`engine-readonly-when-derived-writes.test.ts`, and from the strict-mode side as
24+
"covers readonlyWhen too — the arm a trusted (isSystem) caller can still hit".
25+
26+
Both the `hook-api-update-readonly-when-field` hint and the matching
27+
`content/docs/automation/hook-bodies.mdx` bullet carried the same defect from
28+
the other direction — they **ruled out the remedy that works**:
29+
30+
> readonlyWhen strips even a beforeUpdate-derived value, so an own-hook stamp is NOT a workaround here
31+
32+
That is the behaviour #9107 removed. The conditional strip now judges the
33+
*caller's* entry snapshot, so a value a `beforeUpdate` hook **derives** is not
34+
caller-supplied and lands even on a locked record —
35+
`engine-readonly-when-derived-writes.test.ts` opens with "THE REPORT: a
36+
hook-derived value on a TRUE readonlyWhen field now LANDS", and pins the bulk
37+
path on the same terms. Between them the two halves left the author's only
38+
working option struck out and a useless one recommended.
39+
40+
All three hints now name the same two measured remedies — confirm the write
41+
only targets records whose predicate is FALSE, or derive the field in a
42+
`beforeUpdate` hook on the target object — and refuse elevation explicitly,
43+
matching the shape `action-api-update-readonly-when-field` already shipped.
44+
The hook hint keeps its stronger, separate reason that `sudo()` is a
45+
`TypeError` from a sandboxed body, and now also carries the reason that
46+
survives if that one is ever fixed: a system context does not waive the
47+
conditional lock either.
48+
49+
Deliberately **not** flattened: the static-`readonly` hints and docs rows that
50+
recommend elevation stay exactly as they are, because for *that* strip
51+
elevation is the intended channel. The two disagree for a reason, and a pin now
52+
holds them apart.

content/docs/automation/hook-bodies.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ There is an asymmetry here that costs data if you learn it the hard way, so lear
264264
The dropped case is the dangerous one: nothing fails, the step reports success, and the column is simply always null. Because both halves of that judgement are declared in your own stack, it is checked at author time and **gates the build**:
265265

266266
- `hook-api-update-readonly-field`**error**. A body's literal `ctx.api.object('…').update()` / `.updateById()` writes a field the named object declares `readonly: true`.
267-
- `hook-api-update-readonly-when-field`**warning**. The same write against a `readonlyWhen` field, which strips per record *state*. Note that `readonlyWhen` also strips a `beforeUpdate`-derived value, so the own-hook stamp is **not** a workaround for it — and neither is `sudo()`, which a body cannot reach (see the row above). On this shape, confirm the write only targets records whose predicate is `false`, or drop the field from the payload.
267+
- `hook-api-update-readonly-when-field` — **warning**. The same write against a `readonlyWhen` field, which strips per record *state*. The own-hook stamp **is** the workaround here, exactly as it is for static `readonly`: since [#9107](https://github.com/objectstack-ai/objectstack/issues/9107) the conditional strip judges the *caller's* entry payload, so a value a `beforeUpdate` hook **derives** is not caller-supplied and lands even on a locked record. (Deriving is the operative word — a hook that merely echoes the caller's own value back has written nothing the strip can tell from the caller's, and it still goes.) What does **not** help is elevation: `sudo()` a body cannot reach (see the row above), and — unlike the static strip — the conditional lock is **not** waived by a system context either, so there is no elevated caller for which a caller-supplied value survives. On this shape, confirm the write only targets records whose predicate is `false`, or derive the field in a `beforeUpdate` hook on the target object.
268268

269269
Only literal object names and literal payload keys are seen; a `sudo()` chain, a dynamic object name, an object this stack does not declare, and `insert`/`create` are all skipped, so the rule has no opinion on them. The flow surface has carried the same gate as `flow-update-readonly-field` since [#3425](https://github.com/objectstack-ai/objectstack/issues/3425).
270270

packages/lint/src/validate-readonly-flow-writes.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,46 @@ describe('validateReadonlyFlowWrites', () => {
146146
expect(findings[0].message).toContain('#3042');
147147
});
148148

149+
// The hint is the WHOLE product of an advisory rule - the finding blocks
150+
// nothing, so the sentence is all the author acts on. This one used to read
151+
// "run the flow runAs:'system'", which is advice to widen a write's
152+
// privileges for NO behaviour change: the conditional strip has no `isSystem`
153+
// guard at all, so the elevated run drops the field on a locked record
154+
// exactly as the user run does.
155+
it('does NOT offer elevation as the remedy — runAs:system does not waive the conditional lock', () => {
156+
const [finding] = validateReadonlyFlowWrites({
157+
objects: [opportunityObject],
158+
flows: [flowWith({ amount: 5000 }, { runAs: 'user' })],
159+
});
160+
161+
// The refusal, and the reason that makes it checkable rather than a slogan.
162+
// Pinned against "LOCK 2 - isSystem does NOT exempt a caller-supplied value"
163+
// in `engine-readonly-when-derived-writes.test.ts`, and the strict-mode
164+
// sibling's "covers readonlyWhen too - the arm a trusted (isSystem) caller
165+
// can still hit".
166+
expect(finding.hint).toContain('Elevation is not a workaround here');
167+
expect(finding.hint).toContain('NOT waived by a system context');
168+
expect(finding.hint).not.toMatch(/run the flow runAs:'system'/);
169+
170+
// The two remedies that DO work, both named — the same pair the action and
171+
// hook siblings offer. (2) is #9107: the strip judges the CALLER's entry
172+
// payload, so a hook-derived value lands even on a locked record ("THE
173+
// REPORT: a hook-derived value on a TRUE readonlyWhen field now LANDS").
174+
expect(finding.hint).toContain('readonlyWhen predicate is FALSE');
175+
expect(finding.hint).toContain('beforeUpdate hook');
176+
expect(finding.hint).toContain('does land, even on a locked record');
177+
178+
// The static-`readonly` sibling hint keeps recommending runAs:'system',
179+
// because for THAT strip elevation really is the intended channel. The two
180+
// disagree for a reason; pinned here so a future sweep cannot flatten them.
181+
const staticFinding = validateReadonlyFlowWrites({
182+
objects: [opportunityObject],
183+
flows: [flowWith({ approval_status: 'approved' }, { runAs: 'user' })],
184+
})[0];
185+
expect(staticFinding.rule).toBe(FLOW_UPDATE_READONLY_FIELD);
186+
expect(staticFinding.hint).toContain("runAs:'system'");
187+
});
188+
149189
it('separates readonly (error) + readonlyWhen (warning) + plain (clean) in one node', () => {
150190
const findings = validateReadonlyFlowWrites({
151191
objects: [opportunityObject],

packages/lint/src/validate-readonly-flow-writes.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,20 @@
1818
// field is NOT a no-op and is never flagged.
1919
//
2020
// • Only `runAs !== 'system'`. A `runAs:'system'` run is elevated and the
21-
// engine skips the strip entirely, so a system flow legitimately MAINTAINS
22-
// readonly fields ("users can't edit this, but automation does"). That is
23-
// the intended channel, so it is never flagged.
21+
// engine skips the STATIC `readonly` strip, so a system flow legitimately
22+
// MAINTAINS readonly fields ("users can't edit this, but automation does").
23+
// That is the intended channel, so it is never flagged.
24+
//
25+
// ⚠️ That exemption is the STATIC strip's alone. `stripReadonlyWhenFields`
26+
// runs with no `isSystem` guard at all (engine.ts, the #9107 note: "`isSystem`
27+
// is still NOT an exemption here, unlike the static strip below"), pinned as
28+
// "LOCK 2 - isSystem does NOT exempt a caller-supplied value" in
29+
// `engine-readonly-when-derived-writes.test.ts`. So elevation is NOT a
30+
// `readonlyWhen` remedy, and this rule's hint must never offer it. The skip
31+
// above is therefore WIDER than the conditional lock warrants - a
32+
// `runAs:'system'` flow writing a `readonlyWhen` field is still stripped on a
33+
// locked record and goes unflagged. Left as-is deliberately: the match set is
34+
// out of scope for the message-text correction that fixed the hint.
2435
//
2536
// • Static `readonly:true` + a LITERAL field name is a 100%-certain no-op →
2637
// ERROR (gates the build). `readonlyWhen` is per-record-state — it strips
@@ -204,8 +215,12 @@ export function validateReadonlyFlowWrites(stack: AnyRec): ReadonlyFlowWriteFind
204215
`where that predicate is TRUE, a runAs:'${runAs}' UPDATE strips the field (#3042), so this ` +
205216
`write may silently not land depending on the record's state.`,
206217
hint:
207-
`If automation must maintain this field regardless of record state, run the flow runAs:'system'. ` +
208-
`Otherwise confirm this node only targets records whose readonlyWhen predicate is FALSE.`,
218+
`Elevation is not a workaround here: unlike the static readonly strip, the conditional lock ` +
219+
`is NOT waived by a system context, so runAs:'system' strips this field on a locked record ` +
220+
`exactly as this run does. Either confirm this node only targets records whose readonlyWhen ` +
221+
`predicate is FALSE, or derive '${fieldName}' in a beforeUpdate hook on '${objectName}' - a ` +
222+
`hook-derived value is not caller-supplied and does land, even on a locked record. Otherwise ` +
223+
`remove '${fieldName}' from this update_record node. This warning never blocks a build.`,
209224
});
210225
}
211226
}

packages/lint/src/validate-readonly-hook-writes.test.ts

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -337,23 +337,48 @@ describe('validateReadonlyHookWrites - GREEN: nothing statically knowable is gue
337337
});
338338

339339
describe('validateReadonlyHookWrites - readonlyWhen is a SECOND shape, not the same verdict', () => {
340-
// #9107: readonlyWhen strips per record STATE, and it strips a
341-
// beforeUpdate-derived value too. So the write is conditional, not certain -
342-
// warning, exactly as the flow sibling grades it.
340+
// readonlyWhen strips per record STATE, so the write is conditional, not
341+
// certain - warning, exactly as the flow sibling grades it.
343342
it('grades a readonlyWhen field as an advisory warning, not an error', () => {
344343
const findings = validateReadonlyHookWrites(
345344
crmStack("await ctx.api.object('crm_account').update({ credit_hold: true });"),
346345
);
347346
expect(findings).toHaveLength(1);
348347
expect(findings[0].rule).toBe(HOOK_API_UPDATE_READONLY_WHEN_FIELD);
349348
expect(findings[0].severity).toBe('warning');
350-
// The own-hook stamp is NOT the remedy here, and the hint must not offer it.
351-
expect(findings[0].hint).not.toContain('ctx.input.credit_hold');
352-
// [#14010] Nor is sudo, for the sandbox-reachability reason above - so this
353-
// hint offers NEITHER, and says which record states the write is safe on.
354-
expect(findings[0].hint).toContain('not marshalled into the sandbox');
355-
expect(findings[0].hint).not.toMatch(/write it through ctx\.api\.sudo/);
356-
expect(findings[0].hint).toContain('readonlyWhen predicate is FALSE');
349+
});
350+
351+
// The hint is the WHOLE product of an advisory rule - the finding blocks
352+
// nothing, so the sentence is all the author acts on. Both remedies it names
353+
// are pinned against the engine, and the one it refuses to name is the one
354+
// that would cost the author a privilege widening for no behaviour change.
355+
it('offers the beforeUpdate-derived stamp as the remedy, and does NOT offer elevation', () => {
356+
const [finding] = validateReadonlyHookWrites(
357+
crmStack("await ctx.api.object('crm_account').update({ credit_hold: true });"),
358+
);
359+
360+
// Remedy (1): the record states on which the write is safe.
361+
expect(finding.hint).toContain('readonlyWhen predicate is FALSE');
362+
363+
// Remedy (2). #9107 made the conditional strip judge the CALLER's entry
364+
// snapshot, so a hook-DERIVED value survives on a locked record - pinned in
365+
// `engine-readonly-when-derived-writes.test.ts` as "THE REPORT: a
366+
// hook-derived value on a TRUE readonlyWhen field now LANDS". The hint used
367+
// to assert the opposite and thereby rule out the one remedy that works.
368+
expect(finding.hint).toContain('beforeUpdate hook');
369+
expect(finding.hint).toContain('does land, even on a locked record');
370+
expect(finding.hint).not.toMatch(/strips even a beforeUpdate-derived value/);
371+
expect(finding.hint).not.toMatch(/own-hook stamp is NOT a workaround/);
372+
373+
// NOT elevation, for two independent reasons, both stated. `sudo()` is
374+
// unreachable from a body ([#14010] - QuickJS `ctx.api` carries no `sudo`),
375+
// AND a system context does not waive the conditional lock anyway ("LOCK 2 -
376+
// isSystem does NOT exempt a caller-supplied value"). The second reason is
377+
// what makes this hint's refusal survive if the first is ever fixed.
378+
expect(finding.hint).toContain('Elevation is not a workaround here');
379+
expect(finding.hint).toContain('not marshalled into the sandbox');
380+
expect(finding.hint).toContain('does not waive the conditional lock');
381+
expect(finding.hint).not.toMatch(/write it through ctx\.api\.sudo/);
357382
});
358383

359384
it('reports a field carrying BOTH flags as the certain (static readonly) finding', () => {

packages/lint/src/validate-readonly-hook-writes.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -315,17 +315,20 @@ export function validateReadonlyHookWrites(stack: AnyRec): ReadonlyHookWriteFind
315315
rule: HOOK_API_UPDATE_READONLY_WHEN_FIELD,
316316
where,
317317
path,
318-
// The conditional strip is #3042; that it also removes a
319-
// beforeUpdate-derived value is #9107. Both ids stay in this comment.
318+
// The conditional strip is #3042. #9107 REMOVED its one over-reach:
319+
// the strip now judges the CALLER's entry snapshot, so a value a
320+
// beforeUpdate hook derives is no longer deleted. Both ids stay here.
320321
message:
321322
`body writes field '${w.field}' through ${call}, and object '${objectName}' declares it ` +
322323
`readonlyWhen. On records whose predicate is TRUE that UPDATE strips the field, so this ` +
323324
`write may silently not land depending on the record's state.`,
324325
hint:
325-
`readonlyWhen strips even a beforeUpdate-derived value, so an own-hook stamp is NOT a ` +
326-
`workaround here - and neither is ctx.api.sudo(), which is not marshalled into the sandbox ` +
327-
`(calling it from a body is a TypeError at run time). Confirm this call only targets records ` +
328-
`whose readonlyWhen predicate is FALSE, or drop '${w.field}' from this payload.`,
326+
`Either confirm this call only targets records whose readonlyWhen predicate is FALSE, or ` +
327+
`derive '${w.field}' in a beforeUpdate hook on '${objectName}' - a hook-derived value is not ` +
328+
`caller-supplied and does land, even on a locked record. Elevation is not a workaround here: ` +
329+
`ctx.api.sudo() is not marshalled into the sandbox (calling it from a body is a TypeError at ` +
330+
`run time), and a system context does not waive the conditional lock in any case. Otherwise ` +
331+
`drop '${w.field}' from this payload. This warning never blocks a build.`,
329332
});
330333
}
331334
}

0 commit comments

Comments
 (0)