Skip to content

[design] Field runtime value-shape as a first-class contract (spec-owned), typed action handlers, and file-as-reference #2731

Description

@os-zhuang

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:

  1. The storage layer's canonical file reference is a fileId string (storage.zod.ts: presigned → upload → CompleteUploadRequest { fileId }). The { file_id, name, url, size, mime_type } descriptor that FileField holds 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 how lookup should work (store id, $expand for display).
  2. The spec declares no runtime value shape. FieldType is a flat z.enum; only multiple hints "Stores as Array/JSON". The value shape is not part of the contract.
  3. Action params and the handler have zero type flow. An action binds to a handler by name (target); params is runtime metadata. An AI (or human) writing the handler gets no type contract telling it a param arrives as string[], a FileRef, 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:

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 / audio at every boundary. The descriptor becomes a read-time expansion, exactly like lookup. Storage stays fileId. This dissolves the param-vs-form divergence at the source, and serializeParamValues' 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): pin fieldRuntimeType, the FileRef/RecordRef unification, typed handlers, and the three-consumer model. (design-first, blocks the rest)
  • @objectstack/spec: fieldRuntimeType(type, { multiple }) + FileRef / RecordRef named types; declare file/image/video/audioFileRef, lookup/user/owner/master_detailRecordRef. (pure, additive, no migration — the foundation)
  • framework (a): defineActionHandler<typeof action>() / codegen deriving handler param types from params via fieldRuntimeType. (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 $expand so list cells cheaply get name/size).
  • objectui: FileField/ImageField emit FileRef + file expansion for display; retire serializeParamValues' 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 spec fieldRuntimeType (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/spec before consuming).
  • file-as-reference is the only migration (existing records store descriptors; FileCellRenderer/ImageCellRenderer read .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

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions