The closed 9-type vocabulary every LCAP consumer resolves against. Type drives the default component; @ui/component overrides it within the type's allowed slug list.
Field types
Every LCAP-aware annotation row carries a closed 9-type
vocabulary in field_type. The resolver maps each type to a
default React component; ui_config_json["@ui/component"]
overrides the default within the type's allowed slug list.
The vocabulary is closed by design — adding a new type ships with the SDK, not at runtime. Admins who need a fully custom component author an LLM-authored extension instead.
The matrix
field_type | Default component | Wire shape | Notes |
|---|---|---|---|
string | <TextInput /> | string (≤ max_length) | Single-line text. |
longtext | <TextArea /> | string (no length cap unless declared) | Multi-line. |
number | <NumberInput /> | number | Honors min/max/@ui/step. |
boolean | <Checkbox /> | boolean | |
timestamp | <DatePicker /> | ISO-8601 string | Storage stays UTC; display via @ui/timezone. |
enum | <Select /> | string from options_json | |
fsm_state_ref | <FsmStatePicker /> | string (state name) | Free-text input by default; <select> when options_json carries a curated list. |
file_ref | summary span | FileRef JSON | Read-only summary; rich preview lives in the Card block. |
relationship | <RelationshipDisplay /> | string (id) or { id, display_path } | Annotation-only in v0. No graph fetch, no inline picker. |
NULL field_type (pre-LCAP rows) degrades to string —
existing tenants who haven't started using LCAP keep working.
Platform-issued numbers
Some number fields are not typed by a person. A sequence — the
gapless identifier an app declares when every number it issues has to
be accounted for — is stored as a number row, because it is a number
to everything that reads it: it renders as one, sorts as one and
exports as one. What makes it a sequence is who writes the value,
and that is recorded on the row rather than in the type, so the
vocabulary above stays closed.
The practical consequence for an app author: nothing renders an input
for it. The row carries @ui/readonly, so
forms, the data grid and the detail view all show the value and none
of them offers to edit it — the detail view badges it Issued.
Sending a value for one is refused rather than ignored. Creating a record with a sequence field in its payload returns 422 naming the field, whether the write comes through the API or a script, so the number on a record is always the one the platform issued.
@ui/component overrides per type
When the default component isn't enough, set
ui_config_json["@ui/component"] to one of the allowed slugs.
Mismatched combos are rejected at write time (the backend's PUT
validation refuses, returning 422) and at render time (the
resolver console-warns and falls back to the type's default).
field_type | Allowed @ui/component slugs |
|---|---|
string | text (default), password, email, url, phone, slug |
longtext | textarea (default), richtext, markdown, code |
number | number (default), currency, percent, slider, rating |
boolean | checkbox (default), switch, radio |
timestamp | date (default), datetime, time, daterange |
enum | select (default), radio, tags |
fsm_state_ref | fsm_state_picker |
file_ref | file_payload |
relationship | display (default), id_only |
richtext / markdown / code resolve to lazy peer packages
(@fastyoke/lcap-richtext, @fastyoke/lcap-markdowneditor,
@fastyoke/lcap-codeeditor). When the peer package isn't
installed, the resolver falls back to a plain <TextArea />
with a one-time console.warn.
Storage shapes
Storage shape is the wire format — what lands in
entity_records.data_payload[field_key]. Display formatting is
applied by the resolver only at render time; storage stays
canonical so an export script that bypasses <SmartField />
gets sortable, parseable values.
string,longtext— UTF-8 string. The legacytextalias forlongtextis still accepted by the SDK helper.number— JSON number. Currency components render withIntl.NumberFormatat display time; the stored value is the raw number. Percent components store decimal (0.07); the display layer multiplies by 100 for the user.boolean— JSON boolean.timestamp— ISO-8601 UTC string.@ui/timezoneshifts display only.enum— string fromoptions_json[].value. Emptyoptions_jsonfalls back to a permissive string schema.fsm_state_ref— string state name. Same wire shape as a free-text field; the rendering hint is what makes it special.file_ref— object:{ ref, filename?, mime?, size_bytes? }. Matches the shipped Forms v2 file-upload pipeline. Optional uploads acceptnull.relationship— string id (typically a record id from another entity).
zod refinements
The shipped entityAnnotationToZod helper (@fastyoke/sdk)
maps each type to the right zod leaf with constraints from the
annotation row applied:
required: true→z.string().min(1)for stringy types,z.number()(must be present) for numeric.max_length→.max(n)on string leaves.min/max→.min(...).max(...)on number leaves.options_json(for enum) →z.enum([...values]).timestamp→ string with aDate.parserefinement so a malformed value surfaces before the write hook fires.@ui/component = "percent"→[0, 1]storage range by default; explicitmin/maxoverrides for "growth %" cases.@ui/decimal_places: N→parseFloat(v.toFixed(N)) === vrefinement (rejects extra precision; handles IEEE-754 quirks like0.07.toFixed(2) === '0.07').file_ref→z.object({ ref, filename?, mime?, size_bytes? }) .nullable().fsm_state_ref/relationship→ string id (with.min(1)whenrequired).
Cross-references
- See annotations for how to set
field_type+ui_config_jsonfrom the admin UI. - See formatting for the full set of number / timestamp formatting keys.
- See ui-config reference for the
exhaustive list of
@ui/*keys.