Workspace design

Design fields your dashboard can trust

Five field kinds, three operations, two filters: design types so a total is right or null.

A dashboard cannot repair a definition nobody wrote down. TopDo will count, sum and average the descendants of one node, and it does exactly that and nothing more: five field kinds, three operations, two filters. Every figure on screen is a consequence of what your node types declare and what your contributors actually filled in. The system is small enough to hold in your head, which makes the honest way to use it plain — learn precisely where it stops, then design the records so it never has to guess.

A product designer defining fields at a laptop while an engineer follows on a tablet
The type registry decides the definition.

There are five field kinds, and none of them is required

A node type carries a field_schema: an array of declarations, each with a name and a kind. The kinds are text, number, date, enum and principal. That is the whole vocabulary. validateFields rejects anything else with “Unknown field kind”, and an enum declared without a non-empty values array fails with “Enum fields need values”. A declaration also needs a unique, non-empty name, and that is the only constraint on names the registry applies.

There is no other per-field attribute. No required flag, no default, no minimum or maximum, no unit annotation on the declaration itself. If you were planning a schema around a required field, stop now: validateMetadata walks the declared fields and skips any name absent from the node’s metadata. A node whose type declares five fields and whose metadata is an empty object passes validation cleanly. Nothing is ever required, and no setting makes it so.

What validation does catch is a value of the wrong shape. A number field must hold a finite number; an enum field must hold one of its declared values. Failure is 422 with code invalid_field and the message “field <name> must be a <kind>”, carrying field, kind and values in the detail. That check runs on node create and on node update, which covers every path a person types into. run_process is the exception: instantiating a template copies each template node’s metadata straight into the new row in a second insert, with no validation in between.

What you can declare on a type is equally small: name, display, description, field_schema, rules, color and is_action. The stored row carries more than that, but the rest is bookkeeping you never write — is_system, created_by, created_at, updated_at. color is not a hex value — it is one of twenty named tokens, type-1 through type-9, type-deep, type-bone and type-10 through type-18, or auto, and anything else is refused with the allowed list attached. The type registry has no icon field at all. Your types are legible by their display label or not at all.

`date` and `principal` are checked for being strings, nothing more

This is the part to read twice. Inside validateMetadata, number and enum get real checks. Everything else — text, date and principal — falls through to one expression: typeof v === "string". There is no date parsing anywhere in that path, and no lookup confirming that a principal value is an id belonging to a member of your workspace, or to anyone at all.

The consequences are concrete. A date field accepts “next Tuesday”, “03/04/2026” and “soon” without complaint, and it accepts all three in the same workspace on the same day. A principal field accepts an id belonging to a member who was removed two quarters ago, or the bare string “omar”. Neither kind is ever parsed, compared or resolved by the dashboard, so nothing downstream reports the inconsistency either.

Design around that rather than against it. A field declaration has nowhere to put a note — name, kind and, for an enum, values are everything the registry reads — so fix the format in the type’s description, which the types panel prints beside the type, and enforce it in whatever writes the node: your own form, your own script, your own agent. Where a date has to be compared by software, it cannot live in a date field at all. Store the comparable quantity as a number, because number is the only kind the dashboard can aggregate.

The dashboard is configuration on one node, written as JSON

A dashboard is not a type-level feature and not a workspace-level one. It is an object at metadata.dashboard on a single node, and get_dashboard reads it from the node you asked about. Two sibling roots can carry two entirely different dashboards over the same node types. There is no dashboard table, no inheritance and no sharing; the panel in the app edits the node’s raw metadata JSON and sends it back as an ordinary node update, which is the same payload you would send over the API.

The object holds a title of at most 100 characters and a metrics array of at most 12 entries whose id values must be unique. Each metric declares an id matching ^[a-z][a-z0-9_-]{0,31}$, a label of 1 to 80 characters, an operation, and then the narrowing: type, state, field, a unit of at most 24 characters, and unit_field. The schema is strict, so a key you invented is rejected outright rather than quietly ignored.

metadata.dashboard on the root node
{
  "dashboard": {
    "title": "Pipeline",
    "metrics": [
      { "id": "open_positions", "label": "Open positions",
        "operation": "count", "type": "position", "state": "open" },
      { "id": "committed", "label": "Committed", "operation": "sum",
        "type": "position", "field": "value_usd", "unit": "USD" }
    ]
  }
}

A dashboard that is absent, rather than invalid, is not an error. get_dashboard falls back to a built-in configuration titled “Overview” with three count metrics — nodes, open and done — so a node nobody configured still answers over the API. The app is stricter: it asks for the dashboard only when the node actually carries metadata.dashboard, so an unconfigured root shows no panel at all rather than a generic one. Generic figures are figures you asked for by calling the operation yourself.

`count`, `sum`, `average`, and exactly two filters

The operation vocabulary is count, sum and average. Nothing else exists: no minimum, no maximum, no median, no percentile, no distinct count. A metric whose operation is not count must set both type and field; omit either and the schema refuses the whole dashboard with code invalid, carrying “Numeric metrics require a node type and numeric field” in its issues. Name a field that exists but is not a number and a separate registry cross-check refuses it with “Numeric metrics require a declared numeric field”. That cross-check is not a write-time check only: it re-runs on every get_dashboard call, so an update_type that changes the field’s kind turns a dashboard that saved cleanly last week into a 422 on the next read.

The filter vocabulary is smaller still, and it works in two stages. Two equalities decide matched: the metric’s type against the row’s type, its state against the row’s state, with a key left unset narrowing nothing. A third equality then decides observed, reading unit_field out of the row’s metadata and comparing it to unit — but that one selects nothing, because a row it rejects stays in matched and turns the answer null. The next section is about why. Beyond those three there is no predicate at all: nothing on labels, assignee, depth or date range, no comparison operator, and no group-by.

node typePositionvalue: numberconviction: enumreviewed: datedashboardSum of value across the subtreeCount of Position nodesCount of the open onescount, sum, average; by type and state only
Every metric here is one TopDo can compute.

A null is the honest answer to a missing number

Once a sum or an average has its matched set, aggregate reads the metric’s field out of each row’s metadata and keeps only finite numbers. The count it kept is observed; matched minus observed is missing. If missing is anything other than zero, or if there were no observations at all, the metric’s value is null. One row in forty without a usable number takes the whole total down with it.

That refusal is the reason to trust the figure when it is not null. The alternative — treating an absent field as a zero — produces a total that is quietly wrong in exactly the situation where you most need it right, which is when the records are half filled in. Every metric returns matched, observed, missing and complete beside value, so the response tells you how far short the data falls.

A zero is a measurement. An absent field is the absence of one. A total that adds them together has stopped reporting and started guessing.

In the app a null renders as an em dash, and a small line under it reads “N values missing or units mismatched”. When that count is zero the line reads “No matching data” instead — which is what a blanked count metric shows once the walk has passed ten thousand descendants, not only what an empty matched set shows. The reason string prints separately below the metrics, so that is the line to read first. Read the em dash as the intended output of an incomplete record set and go fix the records, because there is no setting that makes a sum tolerant of gaps. The dashboard has one way to describe a partial answer, and it is refusing to give a total.

The unit gate asserts one unit, it does not narrow to one

A metric whose operation is not count may carry unit_field and unit. unit_field must name a field declared on the same type with kind text or enum, and unit is the literal string a row has to hold there; leaving unit empty is refused outright. It reads like a filter — sum the dollar values only where the currency is USD — and it is not one. Understanding the difference is what stops a plausible configuration from returning nothing forever.

Rows dropped by the unit gate stay in matched. missing is matched minus observed, so every row carrying a different currency is counted as missing, and the value comes back null. The gate does not select a subset. It asserts that the entire matched set shares one unit, and it returns no number at all when that assertion turns out to be false.

Use it that way deliberately. Narrow by type and state until the matched set genuinely is one unit, then set unit_field as the assertion that keeps it that way as the tree grows. If you really do hold three currencies under one root, you need a node type per currency. type and state are the two equalities you have, and state says nothing about currency, so type is the only one that can separate them — a second numeric field will not help, since a row that does not carry it counts as missing.

Narrow a field schema and every existing node is re-checked

Widening a type is free. Narrowing is not. update_type validates the proposed field_schema and then re-runs validateMetadata over the metadata of every node of that type. A single node that fails takes the whole update down with 422 in_use and “Existing nodes do not satisfy the narrowed field schema”. The error does not name which node, so the work of finding it is yours.

That makes the enum the declaration to think hardest about. Removing an entry from an enum’s values array, or changing a kind from text to number, is a narrowing, and the registry refuses it for as long as one node still holds the old value. Correct the nodes first and change the declaration second, the same order you would use for a column constraint in a database. One warning about that advice: the re-check selects every node of the type with no filter on deleted_at, so a node somebody soft-deleted months ago still blocks the narrowing, and since the error names no node you will not find it by looking through the app.

Name fields the way a metric will have to reference them. A name in field_schema needs only to be unique and non-empty, but a metric’s field and unit_field must match ^[a-z][a-z0-9_]{0,63}$. A field called “Value USD” or “value-usd” is a perfectly legal declaration that no metric can ever name, and you will not discover that until you try to configure the total. Lower case with underscores, and put the unit in the name: value_usd still reads correctly on the day somebody adds a second currency.

The walk stops at templates and at ten thousand descendants

The set a dashboard reads is the parent_id tree below the node you asked about, with the root itself excluded from the metric rows and soft-deleted nodes excluded entirely. It prunes at templates: a node with is_template set is left out and so is everything beneath it, so a process template parked in the tree never inflates a count. This particular query has no depth cap, unlike the depth-50 recursion the subtree and graph walks run under.

The query ends with LIMIT 10002. If more than 10,001 rows come back, complete is false, every metric’s value is set to null, and reason carries the literal string “More than 10,000 descendants”. That is all or nothing across the whole dashboard rather than per metric. A root with that many descendants does not hand you a degraded dashboard; it hands you no figures at all, which is a reason to put dashboards on the roots that correspond to a decision rather than on the top of the workspace.

There is no history. The response carries root_id, title, as_of, complete, reason and metrics, and as_of is computed while that response is being assembled; nothing is persisted. Every call re-runs the recursive query, with no caching and no materialization, and no row anywhere records what the total was yesterday. If you want a trend, you have to store each reading yourself — in a node, on a cadence you choose — because the dashboard will never do it for you.

One more thing to know before you plan around it: the plan definitions carry a feature row keyed dashboards, marked included on all three tiers, and nothing enforces it. planSnapshot serializes the whole feature list to the pricing surface, so the key is read — but the function that turns a plan into a refusal consults only the plan’s capabilities list, and dashboards is not in any of them. Dashboards are not gated by plan today. That is a statement about the code as it stands in private beta, not a promise about what the next release will charge for.

Checks to run on your own workspace

Everything above is checkable today, without waiting for anything to ship. Take one root that somebody on your team already looks at, and run these five in order. Each one either confirms a definition or finds the place where a number is being read more confidently than it deserves.

  1. Open the root’s metadata and look for the dashboard key. If it is not there, the app renders no panel at all, and get_dashboard answers with the built-in Overview default rather than a definition anyone chose.
  2. For each metric whose operation is not count, find its field in the type’s field_schema and confirm the kind is number. A sum that answered this morning is not proof it still will: the cross-check re-runs on every read, so an update_type in between blanks the whole dashboard with a 422.
  3. Call get_dashboard and read missing on every metric before you read value. A missing of zero is the only condition in which a sum is a real total.
  4. Count the descendants under that root. Anything approaching 10,000 is a dashboard that will go blank rather than degrade, and the reason string will say so.
  5. List every date and principal field you have declared, then read the raw strings stored in three nodes. That inspection is the only validation those two kinds will ever receive.

The pattern behind all five is the same. The dashboard does very little, says clearly when it cannot answer, and refuses to invent a figure to fill the gap. Everything that decides whether the figure means anything — what a record represents, which field holds the comparable quantity, whether the units agree — is settled in your type declarations. Settle it before the first metric, not after the first total looks wrong.