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, writing component-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-triage operator 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 stable Key (the decision address — never

    renumbered), a Kind (icon|topic|name|ticket) telling the UI how to render, GroupGroupLabel for bucketing, human TitleSubtitle, a kind-specific Payload the UI renders verbatim, the current Status, and the per-card Note.

  • Source — lists the live inventory of a mode, read-only. It never

    mutates the underlying data (the repo, meta/brand, …). Must return a non-nil (possibly empty) slice so the collection contract is always [], never null (TRIAGE-008).

  • Mode — a keyed Source bound to a Scope.
  • Registry — an ordered set of modes with role-aware listing:

    Visible(isOperator) filters operator-scope modes out of a plain tenant's list, and Authorize(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-topics is 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 tenant sub (ULIDs are 26 chars, no underscore).

RBAC (internal/rbac)

rbac.IsOperator resolves the operator role in two steps, swap-ready:

  1. id-role claim — if the Koder ID sessionid_token carries a rolegroup

    claim (operator), trust it. This is the destination design.

  2. operator-sub allowlist — until Koder ID issues roles, an operator is any

    sub in 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 *eview

    gallery 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 durable meta/brand/icons/candidates/ batches remain the shared inventory the app reads.

  • meta/brand/names/name-triage.py — the serve web-triage page is superseded

    by Triage's Names mode; the gen (validate + append to the bank) and list (CLI view) halves remain, and the bank.json they maintain is now read by the app.

Design references

  • meta/docs/stack/policies/operator-cockpit-standard-surface.kmd — the surface

    Triage implements

  • meta/docs/stack/policies/icon-batch-presentation.kmd — icons-mode inventory

    contract

  • meta/docs/stack/policies/name-batch-presentation.kmd — names-mode inventory

    contract

  • meta/docs/stack/specs/multi-tenancy/contract.kmd +

    policies/multi-tenant-by-default.kmd — per-tenant isolation

  • meta/docs/stack/registries/koder-service-ports.toml — port 9470
  • products/dev/triage/README.kmd · products/dev/triage/koder.toml