← Ledger


title: Phase 7b (relay slice) close — console emits agent.* for RELAY date: 2026-06-29 status: Accepted phase: 7b (relay slice) predecessor: docs/decisions/2026-06-29-phase-7b-ralph-close.md spec: docs/specs/2026-06-29-agent-registration-v1.md plan: docs/plans/2026-06-29-phase-7b-relay.md

Phase 7b relay-slice close

Shipped

Locked decisions

1. Synchronous triple-emit (no separate cancel path)

Unlike RALPH (async runs + separate POST /cancel), relay invocations are synchronous from the route's POV — one HTTP request enters POST /api/relay, execute() resolves with outcome, response goes back. All three lifecycle events fire from one route handler. No background observer, no cancel route, no terminal-state coverage gap.

2. invocation_id = result.executionId

Already minted by execute() as a UUID. Deterministic 1:1 mapping to the audit row already keyed by executionId.

3. capability = relay.${result.route}

Each of the 5 route types is a registered capability. Hatch CAPABILITIES column shows the full set; per-invocation rows show the specific verb that fired. Future route types are additive (registration body re-emitted on next invocation; consumer dedupes).

4. outcome mapping: failure + timeout both → error

agent.completed.outcome enum is ok | error | cancelled. Timeout is a failure mode at the agent-projection altitude; the existing relay audit status column preserves the timeout distinction for operators who care. cancelled is not reachable on the synchronous relay path.

5. No 5xx-path emission

If execute() throws (caught by the outer 500 handler) there is no ExecutionResult and therefore no executionId to use as invocation_id. Emitting a partial triple with a synthetic id would muddy the projection. The 5xx case stays invisible to the agent view; the existing route's 500 response and server logs remain authoritative for that failure mode.

6. No header → no emission

Mirrors the cancel-path guard in 7b-ralph. Agent identity requires an owner; no x-rocky-workspace → skip the triple. Existing audit logging continues independently.

Deferred

Polar entitlement check at invocation time

tier_floor: "solo" + seats_required: 0 → no-op for this slice. Real check waits for Phase 7c.

Airlock agent.approve verb

approval.required: false → no airlock interaction. First user remains SNIFFER or hearth driver.

Per-tool agent identity

A relay executor is one agent per workspace. Individual pebble:/n8n: tool calls remain rows in the existing audit record's toolsCalled[], not nested agents. If operators want per-tool projection later, those tools register their own agents in their own producer subsystems (PLUGINS / n8n) — not here.

Carry-forward to remaining 7b producers

This slice confirms the carry-forward pattern from 2026-06-29-phase-7b-ralph-close.md §Carry-forward holds for synchronous producers:

  1. <subsystem>/agent.ts builders + re-export emitAgentHatch from src/lib/ralph/agent.ts. (Factor to src/lib/hatch/agent-emit.ts when the third producer lands — still not warranted at 2.)
  2. Single-handler emit of all three lifecycle events when the producer is synchronous from the route's vantage point. Async producers (background workers, long-running runs) keep the split-emit pattern from RALPH.
  3. Capability list = the producer's declared route/verb space; per-invocation capability picked from that set.
  4. Header gate for x-rocky-workspace is the consistent way to express "no agent identity without owner."

Remaining producers: COUNCIL (persona CRUD; static-fixture agent_id), SNIFFER (analyzer-run; first approval.required: true candidate), STRATT (strategy entry), hearth driver (cross-subsystem; Go producer in hearth/).

Acceptance verified

Sources