Koder Triage — `products/dev/triage`
- Area: Developer Platform (
products/dev/) - Path:
products/dev/triage - Kind: Authenticated web app — single Go binary (
triage-web) behind Koder Jet - Live:
triage.koder.dev· Koder ID auth · multi-tenant
Role in the stack
Koder Triage is the unified inventory & triage surface for the Koder Stack: the one place where the inventory an AI session keeps surfacing to the owner — icon candidates, component-name candidates, open backlog tickets, chat-extracted topics/tasks — is reviewed and dispositioned. It is the implementation of the operator cockpit standard surface (operator-cockpit-standard-surface.kmd): the surface every AI→owner actionable list/batch routes through, instead of each batch growing its own ad-hoc page.
The single most important architectural fact about Triage is that it is a decision surface, not an executor:
Inventory comes in per mode; the operator records a decision per card (keep / defer / promote / trash, plus a free-form Note); the decision goes out. The execution of a promotion — opening a backlog ticket, running
kicon generate, writingcomponent-names.md— happens out of band. The server never shells out, runs git, or mutates the monorepo. It only records intent. An out-of-band executor (a/k-triageoperator step, or the AI) reads the recorded decisions later and performs the real action.
That boundary is what lets the web service stay read-only over meta/brand, the backlogs and the repo while still driving real changes through the existing, audited tooling.
Identity note. This module was briefly mislabeled "Koder Kli" and corrected by the owner (
/k-arch→/k-go, 2026-06-22). Kli is a separate module (the rich UI for CLI AIs); Krew (agent-console-RFC-001) is a cross-session duplicate of Kli, now unified into it (products/dev/kli, 2026-06-23). Several of Triage's earliest backlog tickets still carry the KLI-era prefix/component (see Backlog state).
Architecture
The mode framework (internal/modes)
Every triage surface is a Mode bound to a Source and a Scope:
Item— one triageable unit: a stableKey(the decision address — neverrenumbered), a
Kind(icon|topic|name|ticket) telling the UI how to render,GroupGroupLabelfor bucketing, humanTitleSubtitle, a kind-specificPayloadthe UI renders verbatim, the currentStatus, and the per-cardNote.Source— lists the live inventory of a mode, read-only. It nevermutates the underlying data (the repo,
meta/brand, …). Must return a non-nil (possibly empty) slice so the collection contract is always[], nevernull(TRIAGE-008).Mode— a keyed Source bound to aScope.Registry— an ordered set of modes with role-aware listing:Visible(isOperator)filters operator-scope modes out of a plain tenant's list, andAuthorize(mode, isOperator)is the matching server-side gate (defence in depth — the API 403s a non-operator regardless of what the UI showed). Registration order is UI order;chat-topicsis registered first so the default surface is unchanged.
Two scopes
| Scope | Inventory | Decisions live in | Visible to | Modes |
|---|---|---|---|---|
ScopeTenant |
private to one Koder ID sub |
<data>/<sub>/… |
any authenticated user (self-isolated) | chat-topics |
ScopeOperator |
Stack-shared, read live from the repo / meta/brand |
<data>/_operator/… |
operators only (RBAC) | icons · names · tickets |
The tenant scope is the file-store analogue of RLS (multi-tenant-by-default.kmd + specs/multi-tenancy/contract.kmd): the tenant id is sanitized to a single path segment so traversal can never address another tenant's data.
Decisions + tenant stores
internal/tenantstore(TRIAGE-005) backs the chat-topics tree(chats → subjects → tasks) with the subject↔task cascade select, the 30-day trash and per-tenant isolation. One JSON document per tenant at
<data>/<sub>/triage.json, single-writer atomic-rename. Reused verbatim by the chat-topics mode (reuse-first — the store wasn't forked onto the new framework).internal/decisions(TRIAGE-009) is the generic per-(scope-partition,mode) store for the flat modes:
{itemKey → {status, deletedAt, promotedTo, note}}, one JSON doc per partition+mode, same single-writer atomic-rename and traversal-safe partition segment. The handler merges recorded decisions onto the freshly-listed inventory at read time, so the inventory stays live and the store only carries the disposition.OperatorPartition = "_operator"cannot collide with a tenantsub(ULIDs are 26 chars, no underscore).
RBAC (internal/rbac)
rbac.IsOperator resolves the operator role in two steps, swap-ready:
- id-role claim — if the Koder ID sessionid_token carries a rolegroup
claim (
operator), trust it. This is the destination design. - operator-sub allowlist — until Koder ID issues roles, an operator is any
subin a configured allowlist (--operators/KODER_TRIAGE_OPERATORS), defaulting to the owner sub so a fresh deploy is never operator-less and never open to everyone.
As of 2026-06-22 Koder ID issues no role/group claims (verified against the Go SDK auth.UserInfo and the services/foundation/id access-token claims), so the allowlist branch decides today. When ID grows a role claim, populating Caller.Roles at the session layer makes step 1 take over with zero change to call sites — the Resolver interface is the swap seam.
The four modes
| Mode | Scope | Source (read-only) | Promote executes (out of band) |
|---|---|---|---|
| chat-topics | tenant | tenantstore tree (chats → subjects → tasks) |
open backlog tickets in the right Stack components (cross-module routing) |
| icons | operator | meta/brand/icons/candidates/<batch>/manifest.toml + candidate SVGs |
write the [winner] block + run kicon generate for the chosen slug |
| names | operator | meta/brand/names/bank.json |
assign the promoted name to a component in registries/component-names.md |
| tickets | operator | pruned walk of `*backlog/pending |
backlog/pending` | Tickets-mode inventory (read-only frontmatter walk) |
infra/net/jet (Koder Jet) |
Reverse proxy + TLS at triage.koder.dev |
engines/sdk/go/auth |
Shared server-side OAuth login flow |
Backlog state
| Ticket | State |
|---|---|
| TRIAGE-005 — authenticated app + multi-tenant store | ✅ done |
| TRIAGE-008 — empty-tenant render + favicon | ✅ done |
| TRIAGE-009 — multi-mode foundation (ModeSourceDecisions/RBAC) + Icons mode | ✅ done |
| TRIAGE-011 — Names mode | ✅ done |
| TRIAGE-012 — Tickets mode | ✅ done |
| TRIAGE-013 — per-card Notes | ✅ done |
| TRIAGE-014 — cockpit en-US / i18n | ✅ done |
| TRIAGE-006 — KVS data-area store backend | ⏳ pending — blocked-by kvs#253 |
The earliest tickets predate the rename and still carry the KLI-era prefixcomponent — `pending002-chat-ingestion-command.kmd,
pending/003-triage-module-topics.kmd,
pending/005-cockpit-shell-web-auth-and-view-command.kmd (and the done
done001-…, done004-…) — a reconciliation to the productsdevtriage /
TRIAGE` identity is outstanding.
Superseded tooling (reuse-first — not deleted)
The in-app operator modes supersede the web-triage / review half of the two standalone Python triage tools; their generation role is left intact:
meta/brand/icons/triage/triage-gen.py— the self-contained HTML *eviewgallery it emits is superseded by Triage's Icons mode* The script is kept only for generating candidate galleries per
icon-batch-presentation.kmd(e.g. an offline/emailable review sheet); the durablemeta/brand/icons/candidates/batches remain the shared inventory the app reads.meta/brand/names/name-triage.py— theserveweb-triage page is supersededby Triage's Names mode; the
gen(validate + append to the bank) andlist(CLI view) halves remain, and thebank.jsonthey maintain is now read by the app.
Design references
meta/docs/stack/policies/operator-cockpit-standard-surface.kmd— the surfaceTriage implements
meta/docs/stack/policies/icon-batch-presentation.kmd— icons-mode inventorycontract
meta/docs/stack/policies/name-batch-presentation.kmd— names-mode inventorycontract
meta/docs/stack/specs/multi-tenancy/contract.kmd+policies/multi-tenant-by-default.kmd— per-tenant isolationmeta/docs/stack/registries/koder-service-ports.toml— port 9470products/dev/triage/README.kmd·products/dev/triage/koder.toml