Skip to content

List schema

The form schema formats one escalation on the detail page. A role can also own a list_schema that formats its whole list page. It applies when the list is scoped to exactly one role (/escalations/available?role=<role>, and the personal inbox at /escalations/queue?role=<role>). Without one, the list renders the standard table; with one, the role’s view renders with a “Table view” toggle a click away.

The list schema is versioned independently of the form schema, so a list edit leaves the form version as is. Edit it at /admin/roles/:role/list-schema, or set list_schema through the role API or the update_role MCP tool. The list always renders the latest version.

Every string is a template run through the same {{domain.path}} binding as x-lt-help (domains escalation, metadata, envelope, payload, resolver, evaluated against each row). body strings render as markdown.

Key Purpose
x-lt-layout "active-history", "active", "facet-table", "facet-board", or "table"
x-lt-help Optional markdown header, interpolated with the active row
x-lt-active The live item card: { title, subtitle?, body?, fields?: [{ label, value }] }
x-lt-history History column: { row: { title, subtitle?, meta? }, limit?, status? }
x-lt-columns facet-table columns: [{ label, value, format?, priority?, width? }]
x-lt-group-by facet-board: the "domain.path" that identifies each entity. facet-table: a "metadata.<key>" whose rows read as one line (see Grouped rows)
x-lt-related facet-table: named related sets, a row’s items or rows in other roles (see Related sets)
x-lt-row-detail facet-table: sections a row expands into, each listing a related set
x-lt-card facet-board: the per-entity card, { title, state?, fields?: [{ label, value, format? }] }
x-lt-row-action The per-row action button: { action?, label?, durationMinutes?, submitOnClaim? }

All keys sit at the schema root.

Format Renders
age (or ago) A compact age (12m, 3h, 2d) with the absolute time as its tooltip, repainted each minute
date The full date the dashboard uses everywhere, with the exact time on hover. A bare ISO timestamp with no format renders the same way
user A user-id token such as {{escalation.assigned_to}} as that person’s display name
role A role name as its title (row-detail columns)

format: "user" resolution is batched: every id on a page goes into one POST /api/users/names request that returns display fields only, cached by id for the session; a user edit invalidates the cache. The name truncates with an ellipsis and shows in full on hover; an unassigned row shows a dash. assigned_to is the claimant while the item is worked and stays as the resolver once resolved, so one column reads correctly across the row’s whole life.

The active item is the first non-terminal escalation. The history column loads on demand through a “Load full history” link (status defaults to "resolved", limit to 25). An unknown or absent x-lt-layout falls back to the table.

Every layout carries a persistent action button: the active card’s call to action, a trailing button on each facet-table row, a footer button on each facet-board card.

"x-lt-row-action": {
"action": "claim", // "claim" | "view", default "claim"
"label": "Service", // button text, default "Claim" / "View"
"durationMinutes": 60, // claim hold time, default 30
"submitOnClaim": true // claim and submit, then transition on
}

claim is one click: the row is claimed for the template’s duration and the detail page opens already claimed. The button renders on every row for a uniform layout; on rows past the gesture (resolved, cancelled, or under a live claim) it shows disabled, and a lost claim race shows its message inline. view opens the detail page, for read-only templates. Row and card clicks still navigate; the button is the deliberate gesture.

On My Escalations (/escalations/queue) every row is already held by the viewer, so a claim action renders as View there. Authored view actions keep their label.

submitOnClaim: true makes the row action a one-gesture start: the row is claimed, its form’s seeded defaults are submitted, and when the resolve spawns a follow-on assigned to the person, the wait screen bridges to it. A “Start” button on the list claims, submits and lands the person on the next step.

The behavior lives on the form: the claim-and-submit and the hand-off are the form schema’s x-lt-submit-on-claim and x-lt-transition. The list action launches that flow with the authored claim duration, so one role owns the outcome. If the seeded defaults fail validation, the claim still lands and the person finishes on the detail page.

Use it when the queue holds one live item at a time and a history column is meaningful: a policy document, a looped review, a sequential approval chain.

{
"x-lt-layout": "active-history",
"x-lt-help": "# {{metadata.title}}\nThe authoritative policy. One revision is live at a time.",
"x-lt-active": {
"title": "{{metadata.title}}",
"subtitle": "Revision {{metadata.revision}} · effective {{metadata.effective_date}}",
"body": "{{metadata.document_markdown}}",
"fields": [
{ "label": "Owner", "value": "{{metadata.owner}}" },
{ "label": "Claimed by", "value": "{{escalation.assigned_to}}" }
]
},
"x-lt-history": {
"row": { "title": "{{metadata.title}}, revision {{metadata.revision}}" },
"limit": 25
}
}

The policy-document example in the long-tail repo is the reference: a looped workflow keeps one escalation live and each resolution folds into the next revision.

Use it when there is one live item and no history view: a single-request approval or a just-in-time form.

Use it when the queue holds many concurrent rows and the role’s context reads best as a table: an order queue, an inspection queue, a batch. Every pending escalation is a row, and x-lt-columns defines the columns.

{
"x-lt-layout": "facet-table",
"x-lt-columns": [
{ "label": "Order", "value": "{{metadata.orderId}}", "priority": 1 },
{ "label": "SKU", "value": "{{metadata.sku}}", "priority": 2 },
{ "label": "Color", "value": "{{metadata.color}}", "priority": 2 },
{ "label": "Station", "value": "{{metadata.station}}", "priority": 2 },
{ "label": "Assignee", "value": "{{escalation.assigned_to}}", "format": "user" },
{ "label": "Priority", "value": "{{escalation.priority}}", "priority": 3 },
{ "label": "Created", "value": "{{escalation.created_at}}", "format": "age" }
]
}

A status dot precedes the first column. Missing token values render as a dash. Clicking a row opens its detail page. The table stands alone: x-lt-help and x-lt-active are ignored in this layout, and totals live in the pagination bar.

The table renders at a dense setting (small type, tight side padding), so a row of six values fits. A column with an authored width (any CSS width: "40%", "12rem") keeps it, and the rest share the remainder. When no column declares a width, the first column holds 200px; the row action sizes to its label.

The table folds rather than scrolling sideways. Below the fold threshold each row becomes a card: priority: 1 columns form the title line, priority: 2 columns fold into label and value pairs, and priority: 3 columns are dropped. Undeclared, the first column is the identity and the rest fold.

Cells carry data only, with the full text on hover. Refining is a row gesture: every row with at least one pure metadata binding ({{metadata.key}}) carries a magnifier that opens the refine dialog, listing the row’s metadata-bound facts as touch-sized toggles. Pick one or several (they AND into one query), then Filter <role> narrows this role’s queue, Search everywhere spans every role, and Add to filters merges the selection into the live filter set. A row with a single fact arrives preselected.

Use it when the rows describe entities (machines, stations) rather than a queue. Within the current filter scope, rows group by the resolved x-lt-group-by value and each group renders one card from its most recent row (by created_at). Groups with no matching rows are left off the board.

{
"x-lt-layout": "facet-board",
"x-lt-group-by": "metadata.serialNumber",
"x-lt-card": {
"title": "{{metadata.serialNumber}}",
"state": "{{metadata.machineState}}",
"fields": [
{ "label": "Order", "value": "{{metadata.orderId}}" },
{ "label": "SKU", "value": "{{metadata.sku}}" },
{ "label": "Since", "value": "{{escalation.created_at}}", "format": "age" }
]
}
}

The card’s state renders as a status chip with a stable hue per value (commonly a subtype or a metadata state facet). The grid wraps to the viewport, which suits a wall screen. x-lt-help renders above the board. When each machine keeps one live pending row, the board is exact; for wider scopes it groups the fetched page, with standard pagination beyond it.

Cards are interactive (make x-lt-group-by a metadata.* path so the facet affordances apply):

  • Click opens the group’s latest row.
  • Shift-click adds the entity’s facet to the live filter set, narrowing the board in place.
  • The header magnifier opens the card’s refine dialog: the entity identity plus every field bound to a pure {{metadata.KEY}} token, with the same Filter, Search everywhere and Add to filters choices as the table.

A facet-table row is often a container: it holds items (an accumulator or batch row), and other rows are on their way to it. A related set names one of those collections once; columns and row details refer to it by name.

"x-lt-related": {
"contents": { "source": "items" },
"coming": {
"source": "query",
"query": {
"segment": "order-prep", // or "roles": ["order-prep", "order-check"]
"facets": { "destination": "{{metadata.destination}}" },
"narrow": { "serviceLevel": "{{metadata.lane}}" }, // applies when the row has a lane
"status": "pending" // the default
}
}
}
  • items: the row’s held items (envelope.accumulate_items, or a batch row’s batch_items). Each item reads through its own payload and, for a reciprocal add, through the reciprocal row (the item’s own escalation).
  • query: rows in other roles whose metadata contains the row’s facets. Facet values are {{domain.path}} templates over the host row; a lone token keeps the value’s JSON type, so a numeric facet matches a numeric facet. roles lists the roles in display order; segment names a Pace Board sequence by its first role (the chain composed with parent_role), so the sequence and its station titles come from the board. A host whose facets fail to resolve shows a dash and runs no query. status defaults to pending, which includes claimed rows. narrow facets apply only when the row resolves them: a lane looks ahead to its own service level, a bin without lanes to its whole destination, and a grouped tub reads the union of its lanes.

A schema declares up to 4 sets; a query names up to 12 roles.

A column with related aggregates its set for each row:

{ "label": "Holds", "related": "contents", "aggregate": "count",
"splitBy": "reciprocal.metadata.serviceLevel", "labels": { "standard": "Standard", "express": "Express" } },
{ "label": "Coming", "related": "coming", "aggregate": "count", "splitBy": "role" }

The cell reads 3 · Standard 2 · Express 1: the count, then each split value as a chip.

Set splitBy
items payload.<path> or reciprocal.metadata.<key>
query role (chips in sequence order, labeled by role title) or metadata.<key>

labels maps split values to display text. A role chip opens that role’s list filtered to the row’s facets.

display reads a count as text. An exact count wins, then default; {n} is the count:

{ "label": "Coming", "related": "coming", "display": { "0": "Ready", "default": "Waiting on {n}" } }

The cell reads Ready or Waiting on 3, with its split chips beside it and the count in its tooltip.

aggregate: "min" or "max" shows the least or greatest value across the set. value names it: {{item.payload.<path>}} on an items set, {{metadata.<key>}} on a query set. format renders it as on any column:

{ "label": "Earliest commit", "related": "contents", "aggregate": "min",
"value": "{{item.payload.commitBy}}", "format": "date" }

Values compare as numbers when every value is a number, otherwise as text, so ISO dates order by time. Strings, numbers and booleans count as values; an item without one is skipped, and a set with none shows a dash. A min or max column takes no splitBy or display.

x-lt-row-detail lists sections a row expands into. Each section loads when the row opens and lists its set’s rows with its own columns:

"x-lt-row-detail": [
{ "related": "contents", "label": "Holds", "columns": [
{ "label": "Order", "value": "{{item.itemKey}}" },
{ "label": "Service", "value": "{{metadata.serviceLevel}}" },
{ "label": "Placed", "value": "{{item.at}}", "format": "date" } ] },
{ "related": "coming", "label": "Coming", "columns": [
{ "label": "Order", "value": "{{metadata.orderId}}" },
{ "label": "At", "value": "{{escalation.role}}", "format": "role" },
{ "label": "Since", "value": "{{escalation.created_at}}", "format": "age" } ] }
]

Item sections read {{item.itemKey}}, {{item.payload.*}}, {{item.actor}} and {{item.at}} beside the reciprocal row’s domains; query sections read each related row’s own domains. A sub-row opens its own row: an item’s reciprocal row, or the related row. A section shows up to its limit rows (default 25, at most 100) with the total. The expansion folds with the table at narrow widths and inside a portal panel.

On a facet-table, x-lt-group-by: "metadata.<key>" reads every row sharing that facet as one line that expands to its members. The list pages by groups, so a page holds whole groups and the total counts groups; a row without the facet is a group of one.

Plain columns fold across a group with group: first (default), min, max, sum, count or distinct. min and max compare numbers as numbers and instants as text; distinct lists each value once (“standard, express”). Related columns merge across the group: counts and splits add, and a min or max takes the extreme across the group.

Containment reads by indentation and band. An open line takes a light band; its members step in under it on a stronger band; a row’s detail sections step in past their row. A member leaves blank every value it shares with its group, and a detail section drops a column whose every row restates a facet the opened row carries. A section’s first column always stays.

Related sets read under the viewer’s own read scope, as every list does:

  • An item total comes from the host row, so it shows whenever the row does. A split counts only reciprocal rows the viewer reads; when none are readable the cell shows the total with split restricted.
  • A query set counts only roles the viewer reads in full. The roles it cannot count are named in the cell’s tooltip and in each expansion (partial). Granting read on those roles widens the view.
  • A viewer who cannot read the host role gets a 404.

The server reads the declaration from the role’s stored list schema at the version the page rendered, never from the request. POST /api/escalations/related aggregates one page of rows in one statement per declared (set, split) or (set, min/max value) pair, whatever the page size or item count; a query set’s facet match uses the metadata GIN index. POST /api/escalations/related/rows reads one row’s set for its expansion. The list refreshes the sets live from the row’s own role events and from the roles each set reads.

A role’s list schema is validated wherever it is written (the role API, the MCP tool, the startup apply): a column naming an undeclared set, an unknown split path, or a malformed query is refused with the path that failed. Keys outside this vocabulary stay free-form.

The consolidation example in the long-tail repo is the reference: orders walk two stations and are staged; containers per destination show what they hold by service level and what is coming by station. See the consolidation use case.