Spunto CMS
Concepts

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.

A collection is a list. A card (an item, in the API) is a row in it. That is the whole shape, and almost everything else in the product is a way of looking at those two.

A card

Structurally, a card carries very little in its own right:

  • a title;
  • an icon — an emoji, or nothing;
  • a cover image (coverFileId) — an uploaded image file, shown on the card page as a landscape banner above the title (the default) or as a square to its left — a workspace setting, coverLayout — and at the top of the card on a board; or nothing;
  • a body — a block document;
  • a status — one of the workspace's statuses;
  • its place in a tree — a card can have a parent card, so sub-tasks and sub-pages are cards too;
  • its rank in the manual order;
  • a readable key, like DOC-12;
  • who created it (createdBy), frozen at creation.

Everything else — assignee, due date, priority, tags — is a property, declared in the workspace registry and stored on the card as JSON.

Why so little is built in

An assignee and a due date look universal until you use the product for something other than task tracking. A content catalogue has neither; a CRM wants different ones. Columns that only some workspaces fill are a schema pretending to be a domain. So the rule is a card hard-codes what carries structure — what the tree, the ordering or the key depend on — and nothing else. Being common is not a reason for promotion.

This is not theoretical: assignee_id, due_date and start_date were once real columns on the card, and were removed. They now come back as ordinary declared properties, through the same compiler, the same cursor and the same casts.

The icon and the author pass the same test, for different reasons. An icon does not describe a card, it names it — it shows up everywhere the title shows up and nowhere else, so a declared property would have forced every one of those places to resolve a registry key to draw two characters. The author is a column because the event log is partitioned by month and gets pruned: an author you read from a journal disappears with the first retention policy, on the oldest cards first — exactly the ones whose origin you end up wondering about.

The cover follows the icon: it presents the card rather than describing it, and a board needs to know which image to draw without guessing which file property plays that role.

What the icon is not

It is not sortable, filterable or groupable. A view that names it is rejected, rather than silently ordering cards by Unicode code point — which is an order nobody can predict or explain.

createdBy is null on cards created by an import, a seed or a background job, and on every card that predates the column: it was not backfilled, because there was nothing true to put there. The UI hides the row rather than showing "unknown".

Two identifiers

Every card has two, and they are not interchangeable:

id is a UUIDv7. It is what the API takes in paths, and it is time-ordered, which is what makes keyset pagination cheap.

key is the readable one — DOC-12. It is composed by the API from the key prefix of the card's home collection and its number.

Never compose a key on the client. A view can include cards from other lists, so the prefix in front of a number is not necessarily the prefix of the list you are looking at. And numbers are never recycled: delete DOC-12 and the next card is DOC-13.

Home list, and the lists a card appears in

A card belongs to exactly one home collection — that is where it was created and where its key comes from.

It can also be attached to other collections. Attachment does not move or copy the card; it makes the same card show up in a second list. One card, one set of values, several places it can be found.

Views decide whether they follow those attachments, through includeSecondaryLists in the view definition. The default view — what you get for a list with no saved views — does include them.

A real bug this caused

That default used to be re-implemented in each client rather than published by the API. The web app included attachments; the iOS app did not. A list whose cards all arrive by attachment therefore looked full on the web and empty on the phone, with nothing wrong anywhere. The API now publishes it as CollectionDetail.defaultView. A copied default doesn't break — it diverges.

Reading a whole list in one call

GET /workspaces/{workspaceId}/collections/{collectionId} returns everything a screen needs at once: the collection, the property registry, the system fields, the statuses, the saved views, the default view, the members and your role.

The cards are not in it. They come from POST …/query, because which cards you want depends on which view you are looking at.

Statuses

Statuses belong to the workspace, not to a list, so a card keeps its status when it moves. Each one has:

  • a label — whatever you call it;
  • a category — todo, doing or done;
  • a position — the workspace's default ordering;
  • a colour — one of eight closed palette tokens.

The category is the part that means something. It is what lets a view say "not finished yet" and stay true in a workspace whose statuses are called something else entirely. The colour means nothing at all and compiles nowhere — it exists so two doing statuses can be told apart at a glance, and so changing a category doesn't repaint the board.

The palette is closed (gray, red, orange, yellow, green, blue, purple, pink) on purpose. A free-form colour would be unreadable a third of the time and would force every surface that renders a status to invent a contrast.

Groups in the sidebar

Collections can be filed into groups, and groups into one level of sub-groups. Two levels, not three.

That bound is what keeps the sidebar a flat loop over a flat list, and a move a one-row update — no materialised paths, no subtree rewrites, no recursive queries.

A group is a label, not a container: deleting one detaches its lists in the same transaction and deletes nothing. It has no page of its own, which is why nothing in the API addresses a group as a location.

A card does carry its filing, though: every entry of ItemDetail.collections has a groupPath — the groups that file that list, outermost first, [] for a list at the root. The server resolves it with two left joins rather than letting clients reassemble a hierarchy from the flat group list, for the same reason it composes key: the two-level bound is a service rule, and a client that reconstructs it reconstructs it wrong the day it changes.

Archiving versus deleting

PATCH { "archived": true } takes a card out of every view and out of search, and is reversible.

DELETE is permanent and takes the card's whole subtree with it.

The interface offers the first. The second exists for cases where you mean it. Both live in the same ⋯ menu, in the top right of an open card, next to the one gesture there that adds rather than removes: duplicating it.

On this page