runtime.pool()) with a different unit of address: not “an agent profile in a pool”, but
a team, per tenant.
Legacy orchestration is frozen, not removed. Every existing route, CLI command and
config keeps answering, and none of it is scheduled for deletion. This page is where new
work goes; it is not a migration deadline.
The address is a pair
A team is declared once and instantiated per tenant. Every runtime operation therefore carries both:runtime.durable() and runtime.hermes() are a closed two-member union.
runtime.hermes() is the legacy runtime, declarable and marked deprecated so an existing
config can name what it already runs on; runtime.pool() is untouched.
Which runtime is this tenant on?
Every answer below depends on it. A tenant is on the durable runtime or on the legacy orchestration runtime, and which one is a single column —company_containers.sidecar_url.
There is no third state and no global switch: the decision is per tenant, one row at a
time.
What reads real rows today
Of the 32 operations on this surface, a durable tenant is served 22 and a hermes tenant is served 13. Ten are refused on both. The 13 a hermes tenant is served answer from real tables (tasks_mirror,
task_runs_mirror, task_events_mirror, agents, tenant_spend_events) with keyset
cursors, and that number has not moved. The durable runtime gaining nine operations cost
the frozen one nothing — ci/runtime-surface-counts.test.ts pins it as an equality, not a
floor, so a hermes tenant gaining or losing an answer here fails the build.
GET …/roster and GET …/plan read agents, which is a real per-tenant table on
either runtime, and …/approvals reads naive’s own queue. Those three are real for
a durable tenant. The six marked mirror are not.
POST …/approvals/{id}/decide is a second address for one write path, not a second
write path: it calls the same executeApproval / denyApproval the existing approvals
routes call. Deciding an approval here and deciding it in the dashboard are the same act.
GET …/board?status= filters in memory on both runtimes. On hermes it says so on
the wire (filtered_in_memory: true): the six board columns are a function of two
legacy columns, so the filter cannot be pushed into SQL yet. It is correct, just not
cheap on a large board. On the durable runtime it is filtered after the translation
for a different reason — the runtime’s six states are a different partition, so a
pushed-down ?status=awaiting_check would match nothing there and come back as an
empty board with a 200 on it.One consequence is worth stating on its own: ?status=unverified on a durable
tenant is a valid query that can only ever match nothing, because unverified is
not in the image of the translation. See
Board & cards.The nine a durable tenant gets and a hermes tenant does not
These reach the durable runtime through the same control-plane seam every other runtime call already used —${sidecar_url}/control/{company}/{tenant}{path} — and the
runtime’s answer is returned, refusals included. For a hermes tenant each one answers
501 not_configured with error.details.runtime: "hermes" and the legacy route that does
the equivalent job, because forwarding it to the legacy dispatcher would be a second write
path into a frozen runtime.
Three of the thirteen shared reads —
GET …/board, GET …/board/{card}, GET …/runs/{id} —
serve both runtimes but from different stores: tasks_mirror for a hermes tenant, the
runtime’s own board for a durable one. A durable tenant has no rows in tasks_mirror, so
serving the mirror to one would answer “this team has no work” for a team with a full
board — a wrong answer with a 200 on it, which is worse than any refusal.
A durable card carries
runtime_status beside status. The runtime’s six states
(todo ready doing review done blocked) and this surface’s six
(open claimed awaiting_check done unverified blocked) are not the same partition —
review has no column here and unverified has none there — so the mapped value and the
real one are both reported rather than one being silently substituted for the other.What is declared and refuses on both runtimes
The rest of the surface is addressable and honest: it answers501 not_configured
with error.details.missing naming each absent dependency by its real name, and
error.details.runtime naming which runtime the refusal is about. It never returns
{ items: [] } — an empty list is a claim about your tenant, and these operations cannot
make that claim.
Where the durable runtime has a verb and only the control-plane translation is missing,
the refusal says so and names the verb. That is a different fact from “the feature does not
exist”, and it is the difference between a refusal you can ask for and one you can only work
around.
GET …/runs/{id}/stream is served for a durable tenant even though two of its three original
prerequisites are still absent, and neither is waved away. The forgery argument was about
agent_run_events, a naive table that accepts caller-authored frames; a durable run’s frames
are the Durable Object’s channel log, built by the runtime from what its members said, with
no request body anywhere on the path — platform-set by construction rather than by a column
that does not exist. There is still no trace_id (the response says so in
X-Naive-Trace-Unavailable) and it is still not metered on open — /v1/teams carries no
meterPrimitive at all. What bounds it is the runtime’s own ceiling: it closes the stream
after 300s and polls at a fixed 250ms, and its front door rate-limits reads at 120/min. Both
ceilings are the runtime’s; a naive-side meter is owed the day teams is a registered
primitive.What still runs work
Nothing on this surface starts work yet —POST …/submit is in the refusing table. To
actually run an agent today you use the legacy runtime
(naive tasks, naive ceo, runtime.pool().start()), which is frozen and fully
supported. The durable surface is the read and governance half of the cutover; the
dispatcher is the other half.
Governance is the same governance
Every operation here sits behind the same session-or-key authentication and the same approvals machinery as the rest of the API. There is no second policy engine, no second approvals table, and no runtime-specific bypass — see the governance gateway and Approvals.Related
- The brain — what a team’s
brain.partition()binds - Approvals — the queue
…/approvalsreads - The decision ledger — why
decision_idis null here - Event & trigger router — inbound events, still on the legacy lane