Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

?recordId= carries no object, so a form action fired from the wrong object can edit a same-id record in the target object #4292

Closed

Description

@yinlianghui

Found while implementing #4278 (which makes /forms/:name?recordId= load and PATCH that record). Filed unassigned for triage; deliberately not fixed there — the producer side is packages/core/src/actions/ActionRunner.ts, which #4278's ruling put out of scope and which #4046 currently holds.

What is missing

ActionRunner.executeForm forwards only the record id:

const recordId =
  (this.context.record && (this.context.record as { id?: unknown }).id) ??
  (this.context.data && (this.context.data as { id?: unknown }).id);
const base = `/forms/${name}`;
const to = recordId != null ? `${base}?recordId=${encodeURIComponent(String(recordId))}` : base;

Nothing in that URL says which object the id belongs to. The consumer therefore has to pick one, and the only sane choice is the FormView's own target object — which is what #4278 implements: it reads GET /api/v1/data/:object/:id with the view's object, and writes PATCH /api/v1/data/:object/:id to the same.

That is correct whenever the action was fired from a record of the view's target object. Nothing enforces that it was. An action's locations bind it to a record surface, but the target FormView's object is authored independently, so target: 'showcase_task.edit' on an Account record header is publishable metadata today.

Why it can be silent

Two outcomes when the fired-from object and the view's target object differ:

The consumer cannot detect this. The server echoes the path object back (getData returns object: request.object), so the response looks perfectly valid — #4278 pins an object-mismatch guard at the consumer and documents that a live packages/rest deployment cannot trigger it for exactly this reason.

Why it is worth grading now rather than before

Pre-#4278 the same ambiguity existed but the blast radius was smaller: the route ignored recordId entirely, so a mis-scoped action produced an empty form and an INSERT. Now that the route honours the param, the same mis-scoped action produces a targeted UPDATE of a different object's record. The fix made the surface correct; it also made this hole worth naming.

Options a fix would choose between

Not obvious enough to guess — hence filed rather than fixed:

  1. Forward the object too (?recordObject= alongside recordId) and have the form route refuse when it disagrees with the view's target. Closes it at both ends, but adds a URL param to the console's reserved contract (app-shell/src/urlParams.ts).
  2. Validate at the producer: executeForm resolves the target view's object and refuses to navigate when the context record's object differs. No new URL surface, but it makes ActionRunner a metadata reader it currently is not.
  3. Validate at publish time: reject a type: 'form' action whose locations put it on an object other than its target view's. Structurally prevents the mis-authoring instead of catching it at runtime — the "make AI-written metadata hard to get wrong" direction — but only covers statically-placed actions.

Measured on

origin/main at 90e792e11 plus the #4278 branch, by reading ActionRunner.executeForm, packages/metadata-protocol/src/protocol.ts (getData echoes object: request.object), and the data plugin's route table in @objectstack/spec api/plugin-rest-api.zod.ts. Not browser-verified; the id-collision half depends on the deployment's primary-key strategy.


Generated by Claude Code

Activity

  1. self-assigned this
    on Aug 11, 2026
  2. yinlianghui commented on Aug 11, 2026

    @yinlianghui
    ContributorAuthor

    CLAIM + 代决 — session session_017Qqyix2QcnpUC9XeYVDzx3(objectui whole-repo PM seat;否决窗口开放)。Branch: claude/issue-4292-recordid-object-identity。闸门:PR #4293 落地(同 FormPage 面;#4285 已落,core/actions 面已释放)。

    裁决(不变量表述):id 不得脱离其对象身份旅行;不匹配在两端都 fail-closed。

    1. Producer 半场(ActionRunner.executeForm,core 面已由 [P3] objectui ActionDef's [key: string]: any absorbs every retired and misspelled action key — removing a spec key produces no compile error #4046 释放):只有当发起上下文记录的对象与 target view 的对象(<object>.<view> 前缀)匹配时才转发 ?recordId=;不匹配(如从 task 记录发起、目标是另一对象的 create 视图——"log time" 形状)则不转发,保持 create 语义——这正是 pre-type: 'form' action fired from a record opens an empty CREATE form — /forms/:name ignores the ?recordId= ActionRunner forwards #4278 该场景的正确行为。同时转发对象身份参数(如 recordObject),作为纵深防御的第二半。上下文如何得知对象名由 dev 实测(context.record 的元字段 / objectName / target 前缀),不许发明第二个"对象身份"事实源。
    2. Consumer 半场(FormPage,建于 type: 'form' action fired from a record opens an empty CREATE form — /forms/:name ignores the ?recordId= ActionRunner forwards #4278 之上):recordObject 参数存在且与 FormView 目标对象不符 ⇒ fail-closed 错误态(沿用 type: 'form' action fired from a record opens an empty CREATE form — /forms/:name ignores the ?recordId= ActionRunner forwards #4278 已钉的 fail-closed 通道),不加载、不写。参数缺席(手写深链/旧链)⇒ 维持现行为(按视图目标对象解析)——守卫只在有身份信息时收紧,不破坏既有深链。
    3. 两半同 PR 落地;红先 pin 用卡片自述的碰撞场景(跨对象同 id:producer 不再转发 → create;伪造 URL 直构不匹配参数 → 错误态);对照 pin:同对象 edit 场景(type: 'form' action fired from a record opens an empty CREATE form — /forms/:name ignores the ?recordId= ActionRunner forwards #4278 的主路径)不回归、showcase_log_time 类跨对象 create 场景恢复 create。

    Generated by Claude Code

  3. yinlianghui commented on Aug 11, 2026

    @yinlianghui
    ContributorAuthor

    ACCEPT — PM review of record (session session_017Qqyix2QcnpUC9XeYVDzx3)。

    PR #4311 按不变量("id 不得脱离对象身份旅行,不匹配两端 fail-closed")落地,验收要点:

    • 身份源测量到位:context.objectName 是仓库既有的唯一拼写(ActionContext 平铺三元组、useConsoleActionRuntime 播种、recordFormNavigation 已按同键解析)——零新事实源。action.objectName 被有据排除(描述动作挂载处而非记录来源)且有 pin 防它把 runner 骗过边界,这层细致正是本卡要防的错位。
    • Producer 三态设计正确:匹配 ⇒ 双参;不匹配 ⇒ 裸路由(跨对象 "log time" 形状恢复 create 语义);不可比较 ⇒ 保持原行为——在信息缺席时收紧会把所有不发布 objectName 的宿主的编辑流打成 create,克制正确。前缀匹配按 ADR-0017 视图身份(元数据持有事实,不解析猜测)。
    • Consumer 守卫的位置是承重点:在视图对象解析之后、schema 与记录读取之前——不匹配零 /data 流量、永不触写(已钉);断言而非覆盖;错误措辞与 payload-mismatch 区分,失败自报哪道检查开火。
    • 反向验证双向,方向全中,诚实缺口如实声明(consumer revert 态该套件在收集期即失败,pin 改在 producer revert 方向行使);core 上游全仓套件 15929 测试过;check:action-forward-parity 零白名单改动确认"行为非词汇"。CI 20 项收敛。

    Flipping ready + auto-merge。#4278 → #4292 这条"实现中发现更深缺陷 → 立卡 → 闸门接力"的链条,两天内从重复插入修到身份守卫,是滚动模式的理想形状。


    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions