The property registry
Ten types, declared once per workspace, validated when written — and what each one can be sorted, filtered or grouped by.
Properties are declared per workspace, not per collection. One registry, shared by every list in it.
That is why a card keeps its values when it moves between lists: the property it holds a value for is the same object before and after. A per-list schema would have made "move this card" mean "drop the fields the destination doesn't have", which is a data loss disguised as a navigation.
Which properties a given list shows is a separate question — that is
displayedFieldIds on the collection, and it is presentation.
The ten types
| Type | Holds | Validated against | Sort / group |
|---|---|---|---|
text | A string | — | yes |
number | A number | — | yes |
select | One option | config.options, when declared | yes |
date | An ISO date | — | yes |
checkbox | A boolean | — | yes |
url | An absolute http, https or mailto URL | the scheme | yes |
person | A member of this workspace | the membership table | yes |
file | A file uploaded to this workspace | the file table | yes |
multi_select | Several options | config.options, when declared | no |
multi_person | Several members | the membership table | no |
Filter operators are not restricted per type by the API — the same set
(eq, neq, lt, lte, gt, gte, contains, starts_with, in,
not_in, is_empty, is_not_empty) compiles against any scalar property, and
what it means follows from the type. The interface offers a narrower, sensible
subset per type; a client driving the API directly is not held to it. The array
types are the exception, and it is worth knowing why before you design around
them.
Array types
multi_select and multi_person hold a list. They are filtered by
containment — eq and contains both mean "has this value among its values",
because that is what filtering on a tag means — and they cannot be sorted or
grouped by. Asking for either returns a 400 that says so.
That is a refusal, not a gap. Postgres would happily sort them; it would sort them by the document order of a JSON array, which means the interface would show a sort control that produces an ordering no user could predict or explain. A control that lies is worse than a control that is absent.
multi_person rather than a column
An "Assignees" property is a multi_person. It was deliberately not added as an
assignee_ids column on the card, and the reason generalises:
a board's invariant is that the columns add up to the view. A card with two assignees, grouped by assignee, would appear in two columns — so grouping by an array type is refused, which means an array type has no business being a promoted column that everything else assumes is scalar.
Once that argument was made, it also settled the singular: "Assignee" is a
declared person property, not a column either.
The three that depend on another table
person, multi_person and file are valid only if the value names something
that exists — a membership of this workspace, or a file of this workspace. That
is checked when written, not when read.
Validation happens on write
A value that does not match its declared type is rejected at write time, with a 400. It never lands in the database.
This barrier cannot be moved to read time, and the reason is worth stating
plainly: a badly typed value does not break its own row. It breaks the whole
query of every view that casts that property — a single bad number in a
number property makes an entire list fail to load, for everyone. So the one
place to stop it is the door.
PATCH on a card merges properties: patching one property does not erase
the others. Removing a property from a card is an explicit operation, not a
side effect of omitting it.
Options and their colours
A select or multi_select may declare options — the permitted values. Leave
it empty for free text.
It may also declare optionColors: a separate table, keyed by the option's
value, drawn from the same eight-token palette as statuses. One palette, so
a green tag is the same green as the green status next to it.
Two things follow from optionColors being a separate table rather than a
richer option object:
- the value written on a card is the string. That is what validation compares
and what containment searches for.
optionssays what is allowed,optionColorssays how it looks; - colour compiles nowhere — not into sorting, not into filtering, not into validity. An entry naming an option that no longer exists is ignored; an option with no entry renders neutral. Nothing is inferred at display time.
A new option written without a colour gets one from its label: the same
word always gets the same colour, in every list and every workspace. The API
picks it once and stores it in optionColors, so it reads like any other
colour, and you can change it or make it neutral afterwards. An option that
already exists is never recoloured, and passing color: null when creating
one keeps it neutral.
`config` is replaced wholesale
options and optionColors both live in config, and a write replaces the
whole object. Send the colours with the options or they are gone. This is
also why where a property is displayed lives in a different column entirely —
otherwise moving a field in the sidebar would have silently wiped its options.
To add one option, or change the colour of one, without resending the rest, use
POST /workspaces/{workspaceId}/fields/{fieldId}/options with { value, color }.
It merges on the server, so two people adding a tag at the same time both keep
theirs. Leaving out color keeps the current one (or, for a new option, gives it the
colour of its label); null makes it neutral. In
the app, this is what typing a new value into a select does: the menu offers
Create "x", and each option has a palette button on hover.
System fields
Some things are addressable in a view without being registry properties:
sys:title, sys:status, sys:order, sys:created_at and the like.
The API publishes the list as systemFields on the collection, so no client has
to keep its own copy of what the view compiler will accept.
A known sharp edge
A select sorts alphabetically, not in the order of its options. A view
sorted "by priority" returns critical, high, low, normal.
It is known, it is documented, and it is not a bug to be fixed casually — the
database index the sort relies on depends on that ordering. If you need
priority order, encode it: 1 — Low, 2 — Normal.
Adding one costs nothing structural
Creating a property is one row in a registry. It does not alter a table, does not lock anything, and does not rewrite existing cards — cards without a value simply have no key for it.
Sorting or filtering by one at scale may want an index. The API will tell you
which: POST …/index-hints reports what a given view would benefit from.
Collections and cards
Lists, the cards in them, the two identifiers every card carries, and the difference between where a card lives and where it appears.
Saved views
A view is data — a filter tree, sorts and a group-by — compiled to SQL in one place. What it selects, and what it only changes about looking at it.