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
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).
- 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.
- 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)
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
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/frameworkthat frame the design: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).FieldTypeis a flatz.enum; onlymultiplehints "Stores as Array/JSON". The value shape is not part of the contract.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 forselect, but both must producestring/string[]. So:@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.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:
params(AI correctness), and validates action-endpoint inputs against the contract (object CRUD is already validated by ObjectQL; the gap is custom action endpoints).Conceptually
fileandlookupunify as "a reference to a stored entity" — the model least likely to confuse an AI author.What existing/planned work becomes
@objectstack/specfieldRuntimeType, FieldType-keyedFileRefvsFileDescriptoras a transitional dual typeFileRef, add file expansion, records stop storing the descriptorparams; tsc backs itPlan (dependency order; each step a real down-payment, not a band-aid)
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)defineActionHandler<typeof action>()/ codegen deriving handler param types fromparamsviafieldRuntimeType. (AI-correctness win; do first among framework work)$expandso list cells cheaply get name/size).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
@objectstack/specbefore consuming).FileCellRenderer/ImageCellRendererread.url/.name; seed data). Gate it behind good file expansion.Refs
serializeParamValuesfile→fileId — subsumed byFileRef) · feat(fields): select+multiple → multi-value chip picker; restore fields/core lint gates #2709 (select+multiple→string[])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)