Skip to content

"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

Description

@os-trump

Filed from the reference app while slimming src/objects/ comments (objectstack-ai/hotcrm#1184).

The constraint app authors have to know

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.
  • crm_case.is_sla_violated — SLA violation tracking, silently disabled.
  • crm_case.escalated_date
  • crm_campaign_member.added_date
  • crm_event_attendee.invited_date

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:

  1. A lint rule: a field declared readonly that appears in the write-set of any hook's ctx.api call 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: formalize readonly write 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.
  2. 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.

Prior art, none of it open

None of them gave the author a signal, which is the part that keeps costing.

Refs objectstack-ai/hotcrm#1184.

Metadata

Metadata

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions