Skip to main content

The six columns

unverified is a terminal state, and it is not a failure. It means the work was claimed done and the platform could not independently confirm it. Every card in that column carries unverified_because with the reason — a terminal state a user cannot explain becomes “the product is broken”.
A durable tenant’s board never reports unverified. Five of the six columns are reachable on both runtimes; that one is reachable only on hermes. It is not a column the durable lane happens not to have filled yet — it is a column the translation cannot produce, and unverified_because is the literal null on every durable row.This matters most to the reader who is not looking at the board at all. If you run naive teams tasks --fail-on-unverified in CI (exit 3), that check silently stops firing the moment the tenant is moved to the durable runtime: the flag matches status === "unverified" on these rows, and that value can no longer occur. A gate that cannot fail is worse than no gate, so read the two derivations below before you rely on it.The durable runtime has no notion of an unadjudicated completion at all — its task states are a closed SQL CHECK of six values and none of them is unverified. What it has instead is a reviewer that rejects a completion and requeues the card for another attempt, which is a different mechanism with a different failure mode: an attempt that honestly reports “I could not verify this” is a rejection, and the retry that follows is pressure to claim something stronger. See Review and retries.

How the six columns are computed — hermes

On a hermes tenant the six columns are derived from two legacy columns, tasks_mirror.status and tasks_mirror.verification_status, by a single reader: There is no database constraint making those two columns agree, so an unrecognised legacy status is reported as open rather than dropped. This mapping disappears the day the constraint lands; until then it is the one place the rule lives.

How the six columns are computed — durable

A durable tenant’s board is not read from tasks_mirror. It is the runtime’s own board, fetched over the control seam and translated. The runtime’s task state is a closed domain — a SQL CHECK of exactly six values — and the translation is total over it: Six states in, five columns out. unverified is not in the image of that map, which is why the warning above is a statement about what can happen rather than about what has been seen to happen.
The runtime’s own state is carried through, so nothing is lost silently. Every durable row carries runtime_status beside status, holding the untranslated value. The two partitions do not agree in either direction: review — the work is done and the reviewer has not accepted it — has no column of its own here and maps onto awaiting_check, and unverified has no runtime state at all. A reader that needs the distinction reads runtime_status.

Review and retries — durable only

A durable card carries attempts. When the reviewer rejects a completion the card goes back to ready and the attempt count advances; the card is blocked with block_reason: "exhausted" once the attempts run out.
done on a durable card means the reviewer accepted the claim. It does not mean the claim was checked against anything naive owns. The two terminal states this runtime has are done and blocked — there is no third state for “finished, and nobody could confirm it”. A card whose first attempt honestly reported that it could not substantiate a claim, and whose second attempt asserted it, lands in done with attempts: 2, and the board cannot tell you which of the two happened.attempts is the only signal this API gives you, and it is a weak one: attempts > 1 on a done card means the reviewer rejected at least one earlier completion, but not what it rejected or why. The reason exists — the runtime records a rejection frame carrying the reviewer’s sentence — and no naive endpoint can reach it. GET …/events reads naive’s own task_events_mirror, which has no rows for a durable tenant, so it answers 200 with an empty list rather than with the runtime’s log. Treat attempts > 1 on a terminal card as “read the runtime’s own event log”, and see Not wired for why that log has no address here.

Query parameters

?status= filters in memory on both runtimes, for two different reasons.On hermes, because a column is a function of two database columns, a SQL predicate on status alone would answer a different question. The route over-fetches and filters after mapping, and adds "filtered_in_memory": true to the response when it does. One page of a single-column filter that spans more than the maximum limit can therefore return fewer rows than a page you would get unfiltered. Page with the cursor; do not assume a full page means more rows exist.On durable, because the runtime’s six states are a different partition from these six columns: ?status=awaiting_check pushed down would match nothing there and come back as an empty board with a 200 on it. So the filter is applied after the translation, where the two vocabularies agree.
?status=unverified on a durable tenant answers 200 with an empty list, and that is not the same as “no cards are unverified”. unverified is a valid column name, so it passes validation; it is unreachable on that runtime, so it matches nothing. There is no unverified_unavailable_because on this response — the empty list is the whole answer. Branch on provider, which every response on this surface carries, rather than on the emptiness of the result.

Paging is hermes-only

A durable tenant’s board comes back whole, in one answer: the runtime’s board route has no cursor and takes no limit. The response says so rather than leaving a null to be misread as “that was the last page”:
limit and cursor are accepted and ignored on that lane. A client that pages until next_cursor is null terminates correctly on both runtimes; a client that sizes its own buffer from limit does not.

task_id vs row_id

Two ids, on purpose.
  • task_id is the id the runtime addresses a card by, and the id every existing caller already holds. GET …/board/{card} takes this one.
  • row_id is the naive UUID of the mirror row.
They are reported side by side rather than one replacing the other, because swapping which one task_id means would break the mirror close path. On a durable tenant there is no mirror row, so both fields carry the runtime’s card id and are equal. Address the card by task_id on either runtime and the code is the same; do not use the pair’s equality to detect the provider, because provider already says it.

blocked_reason is inferred — on hermes

When a hermes card is blocked, blocked_reason is "assignment" and blocked_reason_is_inferred is true. There is no stored reason column; the value is the reader’s guess, and the sibling boolean is how you know not to quote it back to a user as fact. On a durable tenant the reason is real: the runtime stores it under a closed CHECK, blocked_reason_is_inferred is false, and a longer blocked_detail accompanies it where the runtime supplies one. The seven values are blocked_reason_is_inferred is the field that tells you which of the two you are reading; branch on it rather than on the provider.
"exhausted" is the durable runtime’s report of a completion it refused to accept, not of work that could not be done. The card was attempted, reviewed and rejected until the attempts ran out. Whether the rejections were right is a question the board cannot answer — GET …/events carries the reviewer’s reason for each one, and that is the only place it is recorded.

One card

{card} is the task_id, not row_id. Returns the same card object plus:
  • runs — up to 50 runs against this card, newest first
  • events_url — the exact path to page this card’s frames
  • trace_id_unavailable_because — there is no trace id column on the run mirror
404 not_found means no card with that id exists for this tenant.

Frames

source is always platform, and that is a claim you can rely on. These rows are written by the API from the runtime mirror and never from a request body, so no caller can append a frame to this list claiming to be someone else. This is not true of every event table in the product, which is why the field is stated rather than assumed — and it is the reason GET …/runs/{id}/stream is refused on a hermes tenant rather than approximated from a table a caller can write to.That argument does not carry to the durable lane, and the stream is not refused there: a durable tenant’s transcript is proxied frame for frame from the runtime, which is not naive’s mirror and is not caller-writable. Read Runs for what the durable stream does and does not carry — it is served with two of its stated prerequisites still absent, and the page names both.
seq is monotonic per frame and is what the cursor pages on. It is a mirror row id, not a per-run sequence number.