You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
"A field a hook or flow must write cannot be readonly" has no author-time signal — the strip is silent, and six fields in the reference app carry a hand-written comment as the only protection against re-adding the flag #13653
stripReadonlyFields deletes a readonly key from any payload whose CALLER supplied it, for every context that is not isSystem. A hook's ctx.api is a ScopedContext over the acting user's execution context, not a system one — so a hook or flow writing a readonly field has its write dropped. The engine logs
Field 'last_activity_date' is read-only — ignoring incoming change
…and moves on. The column stays null for the life of the app.
Why it is worth a platform signal
It is not a corner. In src/objects/ alone, six fields carry a hand-written comment whose entire job is to stop a future author re-adding readonly:
crm_account.last_activity_date — the signal at_risk_accounts and customer_churn_signals are built on. It was readonly, so the churn report counted every account as silent from the day it was written.
crm_case.first_response_date — the most standard SLA metric a service desk reports.
Each note says the same thing: not readonly, and here is the outage that taught us. The protection is prose. Nothing stops the next author adding the flag back — readonly: true on a hook-written field is a perfectly valid schema, it passes os validate, it passes every test that does not assert the derived value end to end, and the only symptom is a column that is quietly always null.
Note the asymmetry that makes this hard to reason about from the outside: beforeInsert/beforeUpdate hook stamps survive the strip (only caller-supplied keys are dropped), so readonly + a before-hook is a correct and widely used pairing in the same app (crm_account.name_normalized, crm_forecast.seed_key, crm_opportunity.stage_entry_date). It is specifically the ctx.api write — from another object's hook, or from a flow — that is dropped. An author who has internalised the first pattern has every reason to expect the second to work.
What would make the mistake impossible
The write-set information already exists: the platform knows which fields a flow's update_record targets, and #4305 established static write-set analysis for hook bodies. So:
Failing that, make the strip observable. The Field '…' is read-only — ignoring incoming change line is a log entry on a write the caller believes succeeded. A dropped derived write is not a debug-level event; at minimum it belongs in the operation result so a test can assert on it, rather than requiring an end-to-end read-back to detect.
Filed from the reference app while slimming
src/objects/comments (objectstack-ai/hotcrm#1184).The constraint app authors have to know
stripReadonlyFieldsdeletes areadonlykey from any payload whose CALLER supplied it, for every context that is notisSystem. A hook'sctx.apiis aScopedContextover the acting user's execution context, not a system one — so a hook or flow writing areadonlyfield has its write dropped. The engine logs…and moves on. The column stays null for the life of the app.
Why it is worth a platform signal
It is not a corner. In
src/objects/alone, six fields carry a hand-written comment whose entire job is to stop a future author re-addingreadonly:crm_account.last_activity_date— the signalat_risk_accountsandcustomer_churn_signalsare built on. It wasreadonly, so the churn report counted every account as silent from the day it was written.crm_case.first_response_date— the most standard SLA metric a service desk reports.crm_case.is_sla_violated— SLA violation tracking, silently disabled.crm_case.escalated_datecrm_campaign_member.added_datecrm_event_attendee.invited_dateEach note says the same thing: not readonly, and here is the outage that taught us. The protection is prose. Nothing stops the next author adding the flag back —
readonly: trueon a hook-written field is a perfectly valid schema, it passesos validate, it passes every test that does not assert the derived value end to end, and the only symptom is a column that is quietly always null.Note the asymmetry that makes this hard to reason about from the outside:
beforeInsert/beforeUpdatehook stamps survive the strip (only caller-supplied keys are dropped), soreadonly+ a before-hook is a correct and widely used pairing in the same app (crm_account.name_normalized,crm_forecast.seed_key,crm_opportunity.stage_entry_date). It is specifically thectx.apiwrite — from another object's hook, or from a flow — that is dropped. An author who has internalised the first pattern has every reason to expect the second to work.What would make the mistake impossible
The write-set information already exists: the platform knows which fields a flow's
update_recordtargets, and #4305 established static write-set analysis for hook bodies. So:lintrule: a field declaredreadonlythat appears in the write-set of any hook'sctx.apicall or any flow's record write is an error at author time. This is the ask that closes the class — it is the same shape as the flow-side ask in spec: formalizereadonlywrite semantics — keep user-context strip, add design-time lint for flow writes to readonly fields (#2948 follow-up) #3425, which was closed without the hook side.Field '…' is read-only — ignoring incoming changeline is a log entry on a write the caller believes succeeded. A dropped derived write is not a debug-level event; at minimum it belongs in the operation result so a test can assert on it, rather than requiring an end-to-end read-back to detect.Prior art, none of it open
readonlywrite semantics — keep user-context strip, add design-time lint for flow writes to readonly fields (#2948 follow-up) #3425 — asked for exactly the design-time lint, for flow writes. Closed.readonlyWhenstripsbeforeUpdate-derived values too, leaving no server-side write path at all. Closed.None of them gave the author a signal, which is the part that keeps costing.
Refs objectstack-ai/hotcrm#1184.