Spunto CMS
Concepts

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

TypeHoldsValidated againstSort / group
textA string—yes
numberA number—yes
selectOne optionconfig.options, when declaredyes
dateAn ISO date—yes
checkboxA boolean—yes
urlAn absolute http, https or mailto URLthe schemeyes
personA member of this workspacethe membership tableyes
fileA file uploaded to this workspacethe file tableyes
multi_selectSeveral optionsconfig.options, when declaredno
multi_personSeveral membersthe membership tableno

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. options says what is allowed, optionColors says 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.

On this page