Repository navigation
[design] Field runtime value-shape as a first-class contract (spec-owned), typed action handlers, and file-as-reference #2731
Description
Tracking / design issue — no implementation yet. Design-first: an ADR pins the contract before any code. Foundational work lands in
@objectstack/spec+framework; objectui is a consumer. A linked framework issue should be opened when the spec work is scheduled.
Background
Since ADR-0059, every action param (and every object-form field) renders through the shared field-widget renderer, so each emits its widget's own runtime value shape on confirm/submit, and those shapes differ per type. #2714 (merged in #2720) added a value-shape test net in objectui to pin the param contract. That net is useful as a regression guard, but the discussion it triggered surfaced that the value-shape contract is placed at the wrong layer and is not where the real leverage is.
Three facts from @objectstack/spec / framework that frame the design:
- The storage layer's canonical file reference is a
fileIdstring (storage.zod.ts: presigned → upload →CompleteUploadRequest { fileId }). The{ file_id, name, url, size, mime_type }descriptor thatFileFieldholds is a client-side convenience object; the authoritative reference is the id. → The param boundary (fileId string) is aligned with storage; the form/record boundary storing the full descriptor is the denormalized outlier — and it's structurally identical to howlookupshould work (store id,$expandfor display). - The spec declares no runtime value shape.
FieldTypeis a flatz.enum; onlymultiplehints "Stores as Array/JSON". The value shape is not part of the contract. - Action
paramsand the handler have zero type flow. An action binds to a handler by name (target);paramsis runtime metadata. An AI (or human) writing the handler gets no type contract telling it a param arrives asstring[], aFileRef, etc. This is the structural root of AI-authoring errors.
The core correction
The runtime value that reaches the backend is a property of the FieldType (+ multiple), not of the widget — two renderers may pick different widgets for select, but both must produce string/string[]. So:
- The contract belongs in
@objectstack/spec, keyed byFieldType(per Commandment #0: the spec owns contracts; the renderer conforms). [P2] Action-param value-shape parity net: assert every FORM_FIELD_TYPES param round-trips to the endpoint contract #2714's objectui-owned, widget-keyed table is the renderer-side shadow of it. - Keying by
FieldTypealso removes the multi-type-per-widget ambiguity (json→string andcode→string are distinct FieldType facts;toggle→boolean;tree→reference).
Long-term design
Spec exports a pure fieldRuntimeType(type, { multiple }) → RuntimeType — the single source of truth — with two named reference types:
FileRef(fileId string):file/image/video/audioat every boundary. The descriptor becomes a read-time expansion, exactly likelookup. Storage staysfileId. This dissolves the param-vs-form divergence at the source, andserializeParamValues' file special-casing retires.RecordRef(record id string):lookup/user/owner/master_detail.
Three consumers of that one function:
- objectui (renderer) produces the declared runtime type; the widgets must conform.
- framework generates typed handler params from an action's
params(AI correctness), and validates action-endpoint inputs against the contract (object CRUD is already validated by ObjectQL; the gap is custom action endpoints). - producer (AI / Studio / humans) writes handler/flow/formula code that is tsc-checked against the derived types.
Conceptually file and lookup unify as "a reference to a stored entity" — the model least likely to confuse an AI author.
What existing/planned work becomes
| Thing | Retired (short-term band-aid) | Adopted (long-term design) |
|---|---|---|
| Value-shape source of truth | objectui-owned, widget-keyed (#2714) | @objectstack/spec fieldRuntimeType, FieldType-keyed |
| #2714 param net | local table as source | conformance check: "renderer emits the spec-declared shape" (derives from spec) |
| Form value-shape net | light-vs-heavy debate | not a separate net; form & param are both "conforms to the same spec contract" |
| file / image | FileRef vs FileDescriptor as a transitional dual type |
file-as-reference: widget emits FileRef, add file expansion, records stop storing the descriptor |
| AI correctness | a docs table | framework derives typed handler params from params; tsc backs it |
Plan (dependency order; each step a real down-payment, not a band-aid)
- ADR (in
framework/docs/adr): pinfieldRuntimeType, theFileRef/RecordRefunification, typed handlers, and the three-consumer model. (design-first, blocks the rest) -
@objectstack/spec:fieldRuntimeType(type, { multiple })+FileRef/RecordRefnamed types; declarefile/image/video/audio→FileRef,lookup/user/owner/master_detail→RecordRef. (pure, additive, no migration — the foundation) - framework (a):
defineActionHandler<typeof action>()/ codegen deriving handler param types fromparamsviafieldRuntimeType. (AI-correctness win; do first among framework work) - framework (b): action-endpoint input validation against the contract.
- framework (c): file-as-reference storage + read-time expansion (the only real data/display migration — schedule separately; requires solid
$expandso list cells cheaply get name/size). - objectui:
FileField/ImageFieldemitFileRef+ file expansion for display; retireserializeParamValues' file special-casing; re-point the [P2] Action-param value-shape parity net: assert every FORM_FIELD_TYPES param round-trips to the endpoint contract #2714 net to consume the specfieldRuntimeType(conformance).mapFieldTypeToFormType(widget selection) stays renderer-local — it is genuinely the renderer's concern.
Notes / risks
- Cross-repo & ordered: spec/framework changes must merge first; objectui picks them up after (spec dist staleness — rebuild
@objectstack/specbefore consuming). - file-as-reference is the only migration (existing records store descriptors;
FileCellRenderer/ImageCellRendererread.url/.name; seed data). Gate it behind good file expansion. - Not a lenient renderer fallback (Commandment #0.1): this moves the contract to the spec and makes both ends conform — the opposite of accreting renderer-side dialects.
Refs
- objectui [P2] Action-param value-shape parity net: assert every FORM_FIELD_TYPES param round-trips to the endpoint contract #2714 / test(app-shell): value-shape parity net for action params (#2714) #2720 (param value-shape net — becomes the conformance check) · ADR-0059 (shared-widget param rendering) · [P2] approvals: declare attachments on approve/reject and retire the inbox's hardcoded composer #2698 / feat(approvals-inbox): retire the approve/reject composer for declared actions with file attachments (#2698) #2710 (
serializeParamValuesfile→fileId — subsumed byFileRef) · feat(fields): select+multiple → multi-value chip picker; restore fields/core lint gates #2709 (select+multiple→string[]) - spec:
framework/packages/spec/src/data/field.zod.ts(FieldType),framework/packages/spec/src/api/storage.zod.ts(fileIdcontract),framework/packages/spec/src/ui/action.zod.ts(params/targethandler binding)
Activity
docs(audit): spec enum ↔ renderer coverage audit for #2901 (#2937)
os-zhuang commentedon Aug 6, 2026
Blocked-by: objectstack-ai/objectstack#5867分诊:跨座位移交 +
pm:blocked⛔ objectui 仓内单不打
domain:*(#5468)。为什么移交而不是在本仓入队
本单自己的第一句就是判据:"Design-first:an ADR pins the contract before any code. Foundational work lands in
@objectstack/spec+ framework;objectui 是消费者"。按 One-main-backlog 与「shared contract surfaces have one owner」两条规则,任何触及packages/spec的工作一律归domain:spec座位,无论谁需要它 —— 只有那个座位看得见 spec 队列的在飞批次与生成基线冲突。本单六步计划里前五步全在上游(ADR → spec 的
fieldRuntimeType→ framework 的类型化 handler → 端点校验 → file-as-reference 迁移),objectui 只承担第六步。在本仓入队会让一个 dev 拿到一张它无权改的契约卡。已做的动作
domain:spec+needs-user-decision+Part of objectstack-ai/objectui#2731),把三个待拍板问题(值形状契约层次 /FileRef+RecordRef/ 类型化 handler)与那段唯一的真实数据迁移(file-as-reference)分开陈述,迁移建议单独定 appetite;pm:blocked,Blocked-by:见本评论首行。上游 ADR 与 spec 落地后,本单收缩为第六步的执行单(FileField/ImageField产出FileRef+ 展开显示、退役serializeParamValues的 file 特判、把 [P2] Action-param value-shape parity net: assert every FORM_FIELD_TYPES param round-trips to the endpoint contract #2714 的值形状网改为消费 spec 的一致性检查),届时由 objectui 座位重新定价 —— ⛔ 不要沿用立单时的成本估计,上游落地会显著改变这一步的形状。按 SKILL,
pm:blocked的机器半边(Blocked-by:行)应当写在正文里,供解锁扫描 grep。本轮未改写正文:经 API 取回的正文里所有'/"/>均以 HTML 实体形式返回,整体回写会把正文里约 40 处标点变成字面实体串(正文被 sanitizer 破坏正是本座位负责识别的一类缺陷,不能自己制造一个)。⇒ 该行落在本评论首行,标签为准。请 objectui 座位下次编辑本单正文时顺手补一行Blocked-by: objectstack-ai/objectstack#5867。查重
已在三仓搜
fieldRuntimeType/FileRef/value-shape:本仓与 cloud 无同题单;objectstack 最近邻是 #4633(导入 dry-run 不预检结构化值形状,domain:cli,已在队列),是不同的一件事,已在 #5867 里点名两者的潜在复用关系。本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
claude commentedon Aug 14, 2026
Unlock sweep: blocker cleared.
pm:blocked→pm:queue.Declared blocker: objectstack-ai/objectstack#5867 — verified closed against the GitHub API on 2026-08-14. No other blocker is declared on this card.
Blocked-by:line lives in a comment, not the body. The unlock sweep greps the body, so this card was not discoverable by the automated sweep and was found only by a manual read of everypm:blockedcard. Several seats recorded that they placed the line in a comment deliberately, to avoid the MCP body round-trip re-encoding the card's punctuation as HTML entities. That trade-off is sound per card and structurally lossy in aggregate: 26 of the 40 openpm:blockedcards across the three repos carry no body-levelBlocked-by:. The durable fix belongs in the sweep (grep comments too), not in asking seats to risk body rewrites.Authorization: maintainer directive (this session) —「先做 23 张 blocked 解锁 10 张 on-hold 按各自自述处理」· PM session
01JaVVMrSxt7Tgi1uwEuDtH7Generated by Claude Code
os-zhuang commentedon Aug 23, 2026
清 assignee — objectstack#11196 维护者裁决 2026-08-23「A:本席清 agent 残留」,skills 席 session
757f0dc0-5dae-51d7-9053-c15c57ebfe3b。证据:assignee 自 07-20 立卡起即在;08-06 移交 +pm:blocked,08-14 解锁扫描回pm:queue只翻标签(#11196 根因 1 的三条路径之一,本卡即活标本)——原会话早收束,assignee 为残留。标签未动;认领席按协议 assign + 声明即可。Generated by Claude Code
yinlianghui commentedon Sep 2, 2026
前提复核:上游决策卡已关但决定从未记录 —— 本卡按「暂时不准备开发 ⇒ 直接关闭」处置(否决窗口,重开免费)
一句话问题:这张「字段运行时值形状升为 spec 一等契约」的设计卡,在 objectui 只剩第六步(消费端)可做,而地基(spec 的
fieldRuntimeType/FileRef/RecordRef)一行都不存在。上游决策卡 objectstack#5867 于 2026-08-07 被关为 completed:零评论、无裁决文字,关卡人qq9340100是 write 权限协作者账号,不是裁决账号。08-14 解锁扫描只看到「blocker 已关」就把本卡翻回pm:queue;上一班席位标注「不可派」却留在队列 —— 队列标签在撒谎。实测(2026-09-02,可复跑):
origin/main d63c8a2:git grep -n -E "fieldRuntimeType|FileRef\b|RecordRef\b" origin/main -- packages/spec/src零命中;对照git grep -c FieldType origin/main -- packages/spec/src/data/field.zod.ts= 7。选项 × 真实代价
domain:spec立 ADR 前置的多周程序(spec 契约 + framework 类型化 handler + 端点校验 + file-as-reference 存量迁移)pm:on-hold+Restart-when: git grep -q fieldRuntimeType origin/main -- packages/spec/src(objectstack)业务上:A = 承认现在不做;B = 立项;C = 挂着等。
<!-- os-decision-facets -->出处三件:谁的指令 —— 维护者;原话 —— 「我建议有些暂时不准备开发的应该直接关闭。」(2026-08-16);在哪 —— pm-dispatch SKILL.md 状态模型「标签纪律」。执行:关闭 not planned,同笔摘
pm:queue(domain:spec/enhancement留下)。异议 ⇒ 重开即回滚。Generated by Claude Code
yinlianghui commentedon Sep 2, 2026
Marker repair (audit only): the facets block in comment 5503066235 above lost its machine-findable first line to the sanitizer (an HTML comment inside backticks reads back as an empty code span). Literal marker for grep, escaped spelling: <!-- os-decision-facets --> — the four-facet block it labels is the bulleted ①–④ list in that comment.
Generated by Claude Code