Measured on the pinned @objectstack/lint@17.3.0 artifact as installed by objectstack-ai/hotcrm, ⛔ not on the platform source tree. Filed unassigned by the hotcrm step-3 rule survey (hotcrm#1613); the local assertions currently standing in for these stay in place there.
The gap
validateReadonlyHookWrites — and the sibling hook-body write validator — open with:
const body = hook.body;
if (!isRec5(body) || body.language !== "js") return;
const source = body.source;
if (typeof source !== "string" || source.trim() === "") return;
A hook authored as a TypeScript handler function never reaches that door. It carries no body, so every rule behind it returns before looking at anything:
hook-api-update-readonly-field
hook-api-update-readonly-when-field
hook-body-write-unknown-field
hook-body-write-unprovisioned-anchor
hook-body-source-unparseable
This is not hypothetical authoring style. Normalizing objectstack-ai/hotcrm's stack through normalizeStackInput and counting:
hooks: 39
{ withBody: 0, withHandler: 39, withSource: 0 }
sample hook keys: name, object, events, handler, priority, async, description, onError, runAs
Thirty-nine of thirty-nine. Actions in the same stack are the other way round — 24 of 31 carry body.source — so the action-side rules DO reach that app, and the hook-side rules reach none of it.
Measurement: the same defect, twice, one shape apart
One committed tree (hotcrm at a0362a37), one injection at a time, restored by blob hash. Clean-tree baseline: 0 error(s), 17 warning(s), 12 suggestion(s), exit 0. Both injections write the same readonly field (crm_case.is_escalated, readonly: true) through the same call, into the same file, in the same run design.
| hook shape |
body of the injected hook |
pnpm lint |
handler: async (ctx) => { … } (the repo's own shape, all 39) |
await ctx.api.object('crm_case').update({ id: ctx.input.id, is_escalated: true }) |
nothing fires — 17 warnings, exit 0, byte-identical to baseline |
body: { language: 'js', source: "…" } |
the same statement, verbatim |
hook-api-update-readonly-field error, exit 1 |
The second row is the control: the rule is live, correct and gating on this exact stack. The first row is therefore a reading about the door, not about the rule.
For contrast, the flow-side and action-side counterparts of the same family both fire on this repo as authored:
| rule |
injected where |
result |
flow-update-readonly-field |
case_actions.escalate writes is_escalated |
error, exit 1 |
flow-update-readonly-when-field |
readonlyWhen added to crm_case.escalation_reason |
warning x4, exit 0 |
action-api-update-readonly-when-field |
readonlyWhen added to crm_contact.is_primary (written by mark_primary) |
warning, exit 0 |
So the hook surface is the only one of the three with no author-time signal at all, and it is the surface the reference app uses exclusively.
Why it matters more than a missing warning
The strip these rules describe is real at run time: on a non-system context the engine removes readonly keys from the UPDATE payload and the call still returns success. An author writing that from a handler gets no signal at any layer — not from lint, and not from the call, which succeeds. The rule exists precisely because that failure is silent, and the shape it is attached to is not the shape people write.
Suggested direction, not a prescription
The lowering path already exists and os lint already reasons about it: checkHookBodyLowering(config) reads FUNCTION values before any Zod parse, and #13651 made lowering's refusal loud rather than fail-open. If the write-set extraction can be run over a lowered body — or over the same extraction os build performs — the family reaches handler-authored hooks without changing what os build accepts. Failing that, the honest alternative is to say so on each rule: a rule that structurally cannot see the dominant authoring shape should not read as coverage.
⚠️ ⛔ This card asks for nothing about os build's acceptance. It is entirely about which hooks the author-time rules can see.
Related, and why this is not a duplicate
Refs: hotcrm#1613 · hotcrm#1582 (F1) · hotcrm#1587 (F6, whose test/hook-write-shape.test.ts row is credited entirely to the three unreachable hook-body-* ids)
Measured on the pinned
@objectstack/lint@17.3.0artifact as installed byobjectstack-ai/hotcrm, ⛔ not on the platform source tree. Filed unassigned by the hotcrm step-3 rule survey (hotcrm#1613); the local assertions currently standing in for these stay in place there.The gap
validateReadonlyHookWrites— and the sibling hook-body write validator — open with:A hook authored as a TypeScript
handlerfunction never reaches that door. It carries nobody, so every rule behind it returns before looking at anything:hook-api-update-readonly-fieldhook-api-update-readonly-when-fieldhook-body-write-unknown-fieldhook-body-write-unprovisioned-anchorhook-body-source-unparseableThis is not hypothetical authoring style. Normalizing
objectstack-ai/hotcrm's stack throughnormalizeStackInputand counting:Thirty-nine of thirty-nine. Actions in the same stack are the other way round — 24 of 31 carry
body.source— so the action-side rules DO reach that app, and the hook-side rules reach none of it.Measurement: the same defect, twice, one shape apart
One committed tree (hotcrm at
a0362a37), one injection at a time, restored by blob hash. Clean-tree baseline:0 error(s), 17 warning(s), 12 suggestion(s), exit 0. Both injections write the same readonly field (crm_case.is_escalated,readonly: true) through the same call, into the same file, in the same run design.pnpm linthandler: async (ctx) => { … }(the repo's own shape, all 39)await ctx.api.object('crm_case').update({ id: ctx.input.id, is_escalated: true })body: { language: 'js', source: "…" }hook-api-update-readonly-fielderror, exit 1The second row is the control: the rule is live, correct and gating on this exact stack. The first row is therefore a reading about the door, not about the rule.
For contrast, the flow-side and action-side counterparts of the same family both fire on this repo as authored:
flow-update-readonly-fieldcase_actions.escalatewritesis_escalatedflow-update-readonly-when-fieldreadonlyWhenadded tocrm_case.escalation_reasonaction-api-update-readonly-when-fieldreadonlyWhenadded tocrm_contact.is_primary(written bymark_primary)So the hook surface is the only one of the three with no author-time signal at all, and it is the surface the reference app uses exclusively.
Why it matters more than a missing warning
The strip these rules describe is real at run time: on a non-system context the engine removes
readonlykeys from the UPDATE payload and the call still returns success. An author writing that from ahandlergets no signal at any layer — not from lint, and not from the call, which succeeds. The rule exists precisely because that failure is silent, and the shape it is attached to is not the shape people write.Suggested direction, not a prescription
The lowering path already exists and
os lintalready reasons about it:checkHookBodyLowering(config)reads FUNCTION values before any Zod parse, and #13651 made lowering's refusal loud rather than fail-open. If the write-set extraction can be run over a lowered body — or over the same extractionos buildperforms — the family reaches handler-authored hooks without changing whatos buildaccepts. Failing that, the honest alternative is to say so on each rule: a rule that structurally cannot see the dominant authoring shape should not read as coverage.os build's acceptance. It is entirely about which hooks the author-time rules can see.Related, and why this is not a duplicate
os buildfail by default on the accidental hook-body-lowering class? — deferred until the new lint rule has produced a real population number #13838 (open) — whetheros buildshould fail by default on the accidental hook-body-lowering class, deferred until a real population number exists. Adjacent, and this card supplies a population number (39/39 unlowered in the reference CRM), but the ask is different.tryExtractBodyswallowsextractHookBody's refusal into a warnings array and bundles the closure — make it loud (ask 1; the constant-surface half is split out) #13651 (closed) — lowering failing open; fixed, and a precondition for anything here.ctx.apiwrite to areadonlyfield has no author-time signal — the hook-side gate has no action-side counterpart #13770 (closed) — the action-side counterpart of the readonly gate, which is why the action row above fires.Refs: hotcrm#1613 · hotcrm#1582 (F1) · hotcrm#1587 (F6, whose
test/hook-write-shape.test.tsrow is credited entirely to the three unreachablehook-body-*ids)