Skip to content

spec: formalize readonly write semantics — keep user-context strip, add design-time lint for flow writes to readonly fields (#2948 follow-up) #3425

Closed

Description

@os-zhuang

Summary

Writes to fields declared readonly: true are silently dropped — including writes issued by the platform's own automation engine (flow update_record / create_record nodes). The only signal is a WARN in the server log (Field 'X' is read-only — ignoring incoming change (#2948)); the flow step still reports status: "success" and the run completes "successfully" while the intended data change never happened.

Environment

  • @objectstack/* 16.1.0 (open edition), single-tenant, sqlite-wasm driver

Steps to reproduce

  1. Declare a field readonly: true (e.g. crm_opportunity.approval_status, crm_lead.converted_account, crm_case.is_sla_violated).
  2. Have a flow write it via an update_record node (e.g. the approval flow stamping approval_status: 'approved', or a lead-conversion flow stamping converted_account).
  3. Run the flow and inspect the flow run + the target record.

Actual behaviour

  • Server logs: WARN Field 'approval_status' is read-only — ignoring incoming change (#2948).
  • The flow run’s step shows {"nodeId":"mark_converted","status":"success"} — no error, no partial-failure marker.
  • The field retains its old value. Downstream logic that depends on it (approval entry conditions, "converted" list filters, SLA violation reports) silently misbehaves.

Expected behaviour

Pick one and make it consistent + documented:

  • (preferred) readonly should govern the external API / UI surface only, and not block trusted server-side writers (flow engine, hooks running as system, seeds). This matches the mental model most apps have — "users can't edit this, but automation maintains it."
  • OR, if readonly is meant to be absolute, a flow update_record that targets a readonly field should fail the node (surface an error), not silently succeed — so authors discover the mismatch at design time instead of shipping a no-op automation.

Either way, a dropped write should never be reported as a successful step.

Related: flow step reports success on failure

Separately observed: a create_record node that actually failed (crm_contact is required) still surfaced its error object stringified as "[object Object]" in one code path, and the enclosing run summary’s per-step status wasn’t always a reliable indicator of the node outcome. Step status should be authoritative and errors should serialize to a readable message.

Impact

High. This turns whole categories of automation (approval status tracking, lead-conversion stamping, SLA flags, campaign rollups) into silent no-ops. Because validate/build can’t see it and the run reports success, it only surfaces by manually reading server WARN logs or noticing wrong data days later.

Workaround

Remove readonly from any field an automation writes and enforce edit-protection with a validation rule instead (record.is_converted == true && record.x != previous.x → error). Works, but loses the field-level UI affordance and is easy to forget.

Activity

  1. os-zhuang commented on Jul 24, 2026

    @os-zhuang
    ContributorAuthor

    分诊一下这条与近期工作的关系:

    可观测性部分已交付(本单「a dropped write should never be reported as a successful step」这一诉求):#3407 → PR #3413(已合并)让引擎在 readonly(#2948)/ readonlyWhen(#3042)剥离时给步骤挂 advisory warnings[] + 结构化 droppedFields 输出,并随 run history 持久化;objectui #2802(已合并)让 Studio 的 Runs 面板把它渲染成琥珀色 warning(状态仍为 success,不降级)。所以「节点报 success 但写入没落且完全沉默」这一条,从数据层到 Studio 已经打通、不再沉默。

    但本单还有两个独立诉求没被 #3413 覆盖,建议保留跟进(或拆两条子单):

    1. readonly 的策略拍板(本单「Expected behaviour」首选项):readonly 是否应只管外部 API / UI 面、放行受信任的服务端写入方(流程引擎 / 系统 hook)?现状:runAs: 'system' 的流程本就跳过剥离,但 runAs: 'user' 的流程写 readonly 字段仍被剥离 —— feat(automation): update_record/create_record 步骤对被静默剥离的写入字段挂 warning(#3407) #3413 只是让它可见,没改变行为。要不要让「自动化可维护、用户不可编辑」成为 readonly 的正式语义,是个 spec 级拍板题。

    2. create_record 失败时错误序列化成 "[object Object]"(本单「Related」小节):这是独立的错误序列化 bug(step 错误对象没走 .message),与剥离无关,feat(automation): update_record/create_record 步骤对被静默剥离的写入字段挂 warning(#3407) #3413 未触及。

    如果同意,我可以把本单标题收敛到上面第 1 点(readonly 策略),把第 2 点拆成一条独立的「create_record 错误序列化」bug 单。要我来拆吗?


    Generated by Claude Code

  2. self-assigned this
    on Jul 24, 2026
  3. changed the title [-]readonly fields silently drop writes from the flow engine; failed step still reports success (#2948)[/-] [+]spec: formalize `readonly` write semantics — keep user-context strip, add design-time lint for flow writes to readonly fields (#2948 follow-up)[/+] on Jul 24, 2026
  4. os-zhuang commented on Jul 24, 2026

    @os-zhuang
    ContributorAuthor

    拍板 + 开发计划(分诊定稿)

    沿上面分诊评论的两个遗留点,结论如下。标题已按此收敛;[object Object] 序列化已拆出为独立子单 #3450。

    决策:不采纳「flow 引擎豁免 readonly」(本单原首选方案),维持现状语义并升格为正式契约

    理由:

    1. 安全。runAs:'user'(默认)的 flow 语义是「以触发用户的身份行事」——RLS 与 grants 都按该用户强制(service-automation: runAs:'user' runs data ops with a credential-less user — trigger context never carries the actor's permission sets/positions (follow-up to #1888) #3356)。若 readonly 对 flow 引擎整体豁免,任何用户可触发的 flow 都成为 readonly 绕过 gadget:用户控制的输入(触发记录字段、screen 输入)流进 update_record 即可改写审计戳/provenance/系统计算值。security: 服务端 readonly 字段在 UPDATE 时未强制(可被用户上下文覆盖) #2948 是安全修复,豁免等于打穿它。
    2. 正规通道已存在。「自动化可维护、用户不可编辑」正是 runAs:'system' 的语义——盖 approval_status 章、stamping converted_account 本就该以系统身份执行,而非以「恰好触发流程的那个人」的身份。本单复现场景的正确修法是 flow 声明 runAs:'system',而不是改平台语义。
    3. 本单给出的 workaround(去掉 readonly 改用 validation rule 挡编辑)方向相反,不推荐;文档将明确此点。

    正式契约(现状行为,零变更、零破坏,只是文档化):

    写入方 静态 readonly readonlyWhen
    REST API / UI(用户上下文 UPDATE) 剥离 条件成立时剥离
    flow runAs:'user'(默认) 剥离(#3413 起挂 step warning + droppedFields) 同上
    flow runAs:'system' / system hooks / seeds 放行 放行
    INSERT(所有调用方) 豁免(create 合法种 readonly 列) 豁免

    已交付,不再重复开发

    「dropped write 不得报 clean success」已由 #3407 → PR #3413(引擎侧 warnings[] + droppedFields 持久化)+ objectui #2802(Studio Runs 面板琥珀色警告)打通。本单不再覆盖可观测性。

    待开发

    D1(P1)· 设计时 lint 门 —— 本单剩余的开发主体。@objectstack/lint 新增 validateReadonlyFlowWrites(stack),接进 os validate / os compile 现有管线(cli/src/commands/validate.ts 已串接的 validate* 系列):

    • 规则:update_record 节点中字面可知的字段名 × 目标对象 readonly:true × flow runAs !== 'system' → error(该组合下写入 100% 无效,无假阳性空间);命中 readonlyWhen 字段 → 最多 warning(条件性,静态不可判定);
    • 动态形态(对象名为模板、整包 {$record} 展开、模板变量作字段名)静态不可知 → 跳过,不产假阳;
    • 实现须认全 config 历史别名(object/objectName、data/keyValue 两种形态,参照 feat(automation): publish configSchemas for the keyValue-capable nodes (designer parity, #3304) #3327 configSchemas),认不全即漏报;
    • 测试先验红(构造 runAs:'user' + 字面写 readonly 字段的 stack,断言 exit 1)。

    这把发现时机从「运行时琥珀警告」提前到「保存/构建时」,直接闭掉本单「validate/build 看不见、只能事后翻 WARN 日志」的核心抱怨。

    D2(P1,随 D1)· 语义契约文档化:objectstack-data skill(readonly 字段处)与 objectstack-automation skill(runAs 处)写入上表,推荐模式定为「自动化维护的字段 → flow 声明 runAs:'system'」,并把 validation-rule workaround 标注为不推荐。

    明确不做

    拆单


    Generated by Claude Code

  5. os-zhuang commented on Jul 24, 2026

    @os-zhuang
    ContributorAuthor

    D1 + D2 delivered in #3465:

    • D1 — new @objectstack/lint rule validateReadonlyFlowWrites, wired into os validate and os compile/os build. Static readonly write from a runAs:'user' update_record → build error; readonlyWhen → warning. Skips create_record (INSERT is engine-exempt), runAs:'system', and dynamic object/field shapes to stay false-positive-free. Verified red on a crafted app-crm flow, clean on all example apps.
    • D2 — the formal readonly contract (user/API surface strips; runAs:'system' / hooks / seeds maintain) documented in the objectstack-data and objectstack-automation skills.

    The create_record "[object Object]" error-serialization item remains split out as #3450.

  6. os-zhuang commented on Jul 24, 2026

    @os-zhuang
    ContributorAuthor

    Closing — all in-scope requests delivered:

    1. Dropped write never reported as clean success → feat(automation): update_record/create_record 步骤对被静默剥离的写入字段挂 warning(#3407) #3413 (engine warnings[] + droppedFields) + objectui 调查(核心):评估“服务不可用”的重试语义 #2802 (Studio Runs amber warning).
    2. readonly semantics + design-time discovery → feat(lint,cli): flag flow update_record writes to readonly fields at design time (#3425) #3465 (merged, 4340f1319): formalized the contract (user/API surface strips; runAs:'system' / hooks / seeds maintain) and added the validateReadonlyFlowWrites build gate to os validate / os build, with the contract documented in the objectstack-data / -automation skills.

    Remaining item (create_record failure serializing as "[object Object]") tracked independently in #3450.

  7. os-project-manager commented on Sep 3, 2026

    @os-project-manager
    Collaborator

    Cross-reference — the "INSERT (all callers): exempt" row of the 2026-07-24 ruling table is superseded (2026-09-03)

    Director seat, session session_01ShyhexkB2d1AeRZ85tgAAe. Maintainer ruling C on #14147 (recorded there today, provenance in that comment; verbatim reply 「同意」 to a batch item presented explicitly as overturning this row): static readonly is now stripped from non-system INSERT payloads inside engine.insert, the same way engine.update already strips it; seeding readonly columns at create time is done under system context, which this table already passes. The other rows of the table stand unchanged. Readers of this thread, of packages/lint/src/validate-readonly-action-writes.ts's "BY DECISION" comment, and of the skills/objectstack-data / skills/objectstack-automation prose should follow #14147.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions