Spec — agent-registration.v1
Date: 2026-06-29
Status: Draft
Phase: 7a (cross-cut; lands after Phase 5c/6 implementation phases)
Predecessor decision: docs/decisions/2026-06-29-agentic-layer-projection.md
Related contracts:
docs/decisions/2026-05-08-agentic-emission-v1.md(CAIRNET producer envelope — distinct surface)docs/decisions/2026-05-19-agentic-emission-canonical-fold.mddocs/specs/2026-05-04-rocky-phase-5.md§6 (HEARTH wire contracts — pattern to mirror)
Purpose
Define the wire format that makes the agentic layer a HATCH projection per the predecessor decision: a registration schema, a HATCH event family, and the parser semantics that let every producer subsystem (council / sniffer / stratt / ralph / relay / hearth driver) declare and emit agent-shaped work without inventing its own vocabulary.
agent-registration.v1 answers "what is an agent and who owns it". It does NOT replace agentic-emission.v1, which answers "what did an agent emit to CAIRNET". The two contracts compose: a CAIRNET stone produced via agentic-emission.v1 carries an agent_id whose declaration is governed by agent-registration.v1.
Distinction from agentic-emission.v1
| Aspect | agent-registration.v1 (this spec) |
agentic-emission.v1 (ADR 0008) |
|---|---|---|
| Surface | HATCH audit ledger | CAIRNET stone POST |
| Purpose | Identity + capability declaration | Per-emission payload envelope |
| Cardinality | One registration per agent (with revocation) | One envelope per stone |
| Authority | Producer subsystem (HATCH event emission) | Producer subsystem (HTTP POST to pebble) |
| Projection target | hatch.devarno.cloud/agents table |
LORE causality graph |
| Versioning | Independent semver (v1, future v2) |
Independent semver |
An emission from an unregistered agent_id is valid at the CAIRNET layer (pebble does not gate on registry presence — keeps the two surfaces decoupled), but the hatch consumer will render unregistered agents in the projection as OWNER: unknown until a registration arrives.
Schema — registration payload
Lives at contracts/src/agent/registration.ts.
import { z } from "zod";
export const AgentScopeSchema = z.enum([
"council", // workspace council persona
"sniffer", // sniffer analyzer run-class
"stratt", // stratt strategy entry
"ralph", // ralph worker
"relay", // relay executor
"driver", // hearth driver invocation surface
]);
export type AgentScope = z.infer<typeof AgentScopeSchema>;
export const AgentCapabilityVerbSchema = z.string().regex(
/^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/,
"verb must be <namespace>.<action> in snake_case"
);
export const AgentRegistrationSchema = z.object({
schema: z.literal("agent-registration.v1"),
agent_id: z.string().regex(
/^[a-z][a-z0-9-]*-(council|sniffer|stratt|ralph|relay|driver)-[a-z0-9-]+$/,
"agent_id must be <owner>-<scope>-<local-id>"
),
name: z.string().min(1).max(120),
scope: AgentScopeSchema,
owner: z.object({
workspace_slug: z.string().min(1),
subsystem: z.enum(["SS-01", "SS-02", "SS-03", "SS-04", "SS-05", "SS-06", "SS-07", "SS-08"]),
}),
capabilities: z.array(AgentCapabilityVerbSchema).min(1).max(64),
approval: z.object({
required: z.boolean(),
airlock_verb: z.string().regex(/^agent\.[a-z_]+$/).optional(),
}),
rate: z.object({
tier_floor: z.enum(["solo", "team", "fleet", "enterprise"]),
seats_required: z.number().int().min(0),
}),
declared_at: z.string().datetime({ offset: true }),
}).passthrough();
// `.passthrough()` per KILN-extensibility: unknown fields ride through
// without rejection so v1.x extensions land additively.
export type AgentRegistration = z.infer<typeof AgentRegistrationSchema>;
Field semantics
agent_id— global identifier. Format<owner>-<scope>-<local-id>. Owner is the workspace slug (or static service name for cross-workspace agents likehearth-driver-localdocker-001). Local-id is producer-assigned, must be stable across registrations of the same logical agent (re-registration with a different local-id creates a distinct agent). The format aligns withagentic-emission.v1'sagent_idconvention so cross-referencing the two surfaces is mechanical.scope— fixed enum gating the producer subsystem. Adding a new scope is av2change. This list is closed against drift-watch #1 (noscope: other).owner.subsystem— the SS-NN holding authoritative state. Hatch projection uses this to route operator deep-links back into rocky.erid.tech.capabilities— declared verbs. Format<namespace>.<action>. Examples:ralph.run_submit,council.respond,relay.webhook_post. Mirrored into the hatch CAPABILITIES column.approval.required— iftrue, the hatch APPROVAL column startspendingand producers MUST gate invocation on airlock returning approved. Iffalse, APPROVAL rendersn/a. Theairlock_verbfield names the specific verb airlock should expose (defaultagent.approve).rate.tier_floor+seats_required— Polar entitlement inputs. The hatch RATE column projects to<tier_floor>+ · <seats_required> seat${s}. Polar check happens at invocation time, not registration time (registration is free).
Open question — COUNCIL personas
The predecessor decision flagged whether COUNCIL personas register as agents. This spec resolves yes, with the following constraints:
- Each persona registers once per workspace council, with
scope: "council"andowner.subsystem: "SS-04"(WORKSPACE owns council state). agent_idformat:<workspace-slug>-council-<persona-slug>.capabilitiesis a single-element array:["council.respond"]in v1. Future capability verbs (council.draft,council.dissent) are additive v1.x extensions.approval.required: false— personas are operator-curated workspace fixtures, not provisioned tools.
This sets the precedent: an "agent" is anything that produces work an operator might want to audit, gate, or rate-limit. Static fixtures qualify; dispatched workers qualify; ephemeral one-shots do not (no point registering something that lives one HTTP request).
HATCH event family — agent.*
Lives at contracts/src/agent/hatch.ts. Mirrors the discriminated-union pattern from contracts/src/hearth/hatch.ts.
import { z } from "zod";
import { AgentRegistrationSchema } from "./registration.js";
const envelope = z.object({
ts: z.string().datetime({ offset: true }),
actor: z.string().min(1), // operator or system identity emitting the event
agent_id: z.string().min(1),
});
export const AgentHatchEventSchema = z.discriminatedUnion("kind", [
envelope.extend({
kind: z.literal("agent.registered"),
registration: AgentRegistrationSchema,
}).passthrough(),
envelope.extend({
kind: z.literal("agent.invoked"),
invocation_id: z.string().min(1),
capability: z.string().min(1), // one of the registered capabilities
request_summary: z.string().max(280).optional(),
}).passthrough(),
envelope.extend({
kind: z.literal("agent.completed"),
invocation_id: z.string().min(1),
outcome: z.enum(["ok", "error", "cancelled"]),
duration_ms: z.number().int().min(0),
result_summary: z.string().max(280).optional(),
}).passthrough(),
envelope.extend({
kind: z.literal("agent.revoked"),
reason: z.enum(["operator", "policy", "superseded"]),
}).passthrough(),
]);
export type AgentHatchEvent = z.infer<typeof AgentHatchEventSchema>;
Event semantics
agent.registered— first event for any agent_id. Carries the full registration payload (the HATCH ledger thus IS the registration store — no separate persistent registry; query by replay or by hatch's existing index). Idempotent onagent_id: re-emission with the same registration body is a no-op for the projection; re-emission with a different body is treated as a re-declaration (latest wins, prior preserved in ledger).agent.invoked— every invocation.invocation_idis producer-assigned (UUID or producer-specific id).capabilityMUST appear in the registeredcapabilitiesarray; consumers should drop invocations whose capability is unregistered (defensive — registration drift indicator).agent.completed— paired withinvokedbyinvocation_id. An invocation with no matching completion after a producer-defined timeout is rendered in hatch asoutcome: timeout(synthetic, not in the schema).agent.revoked— terminal. After revocation the projection drops the agent from the visible table; subsequent invocations are still appended to the ledger but flagged in the hatch UI as post-revocation.
Wire transport
agent.* events ride the existing HATCH emission path — same emitHatch() shape as console/src/lib/ralph/hatch.ts:6 and console/src/lib/relay/executor.ts:42. No new transport, no new endpoint. The existing /api/relay/ralph style aggregator gains an agent scope filter in Phase 7c.
Parser pattern
Lives at contracts/src/agent/parsers.ts. Follows the HEARTH throw-on-invalid pattern (admin-boundary semantics — bad input is an operator error, surface as 4xx) per Phase 5b §5 carry-forward, not the ralph SSE ParseResult<T> pattern.
export function parseAgentRegistration(input: unknown): AgentRegistration {
return AgentRegistrationSchema.parse(input);
}
export function parseAgentHatchEvent(input: unknown): AgentHatchEvent {
return AgentHatchEventSchema.parse(input);
}
Rationale: registration is an admin-RPC-shaped action (operator declares an agent → HATCH writes ledger record → fail loudly if payload is malformed). Invocation/completion events ride a high-volume audit stream but the cost of throwing is acceptable because producers control their own payloads — there is no third-party stream to keep alive on bad input.
Go mirror
Lives at contracts/go/agent/. Generated by quicktype via the same scripts/build-go-bindings.mjs pipeline established in Phase 5b. Follows the nested-Go-module tagging convention from 2026-05-25-phase-5b-close.md §1 — when contracts ship v0.3.0, also push go/v0.3.0.
Consumed initially by:
hearth/forscope: "driver"agent registration (driver-invocation events).cairnet/for cross-referencingagent_idagainstagentic-emission.v1envelopes (advisory; non-blocking).
Future Go consumers (other submodules adopting the registration contract) inherit the pattern.
Versioning
agent-registration.v1 is additive-only within v1. Breaking changes ship as agent-registration.v2 with parallel coexistence. Specific guarantees:
- Closed enums (
scope,outcome,reason,owner.subsystem) — adding an enum value is a v2 change. .passthrough()on every union variant — unknown fields ride through, enabling additive v1.x extensions (e.g., addingregistration.tags: string[]is v1.1 and consumers ignore it until they care).- HATCH event family is co-versioned with the registration schema. A v2 registration ships with a renamed event family (
agent.v2.registeredetc.) to avoid mixed-version replay ambiguity in the ledger.
Migration / coexistence
Existing surfaces that already speak agent-shaped language continue to operate unchanged through Phase 6. They join the projection only when their producer subsystem ships its 7b PR (per the phasing in the predecessor decision):
- COUNCIL personas: pre-7b workspaces show empty in hatch; 7b adds emission to council CRUD paths.
- RALPH runs: pre-7b runs invisible to hatch agent view; 7b adds
agent.invokedon run submit,agent.completedon run terminal state. Existingralph.run.submitted/ralph.run.cancelledHATCH events remain —agent.*is a parallel projection, not a replacement. - RELAY executors, SNIFFER analyzer, STRATT strategies, hearth drivers: same pattern.
No back-fill. The projection is forward-only by design (per predecessor decision §consequences). An operator who wants pre-7b history goes to the original subsystem's UI.
Cross-cut interactions
- Airlock — gains one new verb
agent.approve. Producer emitsagent.registeredwithapproval.required: true; hatch UI exposes an "approve" button that calls airlock; airlock signs an approval token that producers verify on subsequent invocations. Spec for the airlock verb itself ships separately as part of Phase 7a. - Polar entitlement — checked at invocation time (in the producer, not in the hatch consumer). Producer reads
rate.tier_floor+rate.seats_requiredfrom the registration record (in HATCH ledger) and compares to the workspace's current Polar tier. Hatch RATE column is a display of these declared values; enforcement is producer-side. This split keeps the projection a pure view. - CAIRNET /
agentic-emission.v1— no schema dependency, but operationally: a registered agent'sagent_idSHOULD match theagent_idfield of any CAIRNET stones it emits. Hatch UI will deep-link from an agent row tocairnet.erid.tech?agent_id=<id>for traceability.
Out of scope
- Agent invocation transport. This spec defines what gets recorded, not how agents are called. Each producer keeps its existing invocation surface (ralph HTTP, council request/response, relay webhook, etc.).
- Multi-tenant rate enforcement. RATE column displays declared limits; cross-workspace aggregate rate-limiting waits for Phase 7c+ telemetry.
- Persistent registration index. HATCH ledger IS the index. If projection performance becomes a problem, Phase 7c may add a derived index — out of scope for v1.
- Agent-to-agent invocation graph.
agent.invokedevents do not carry parent-agent-id. LORE already builds causality graphs fromagentic-emission.v1'sinspired_by_stones[]; cross-agent dispatch is queued for v2. - Sandboxing or capability enforcement.
capabilities[]is declarative. Producers do not check incoming invocations against the declared verbs (operator review during approval is the gate).
Acceptance for the spec PR
Spec-PR-only acceptance (this PR):
- Schema fields and semantics review against
docs/decisions/2026-06-29-agentic-layer-projection.md. - COUNCIL-persona resolution (yes, with constraints listed) matches what 7a should implement.
- Parser pattern matches Phase 5b §5 carry-forward (HEARTH throw, ralph ParseResult — this spec follows HEARTH).
- Versioning + migration sections reviewed against KILN-extensibility (
.passthrough()on every variant). - No code in this PR; contract source lands in Phase 7a implementation PR against
rocky-hq/contracts.
Implementation acceptance (deferred to Phase 7a):
-
contracts/src/agent/lands schemas + parsers + index + tests. -
contracts/go/agent/generated andgo-parityjob green. - Tagged
v0.3.0+go/v0.3.0on the same commit. -
airlock.devarno.cloudexposesagent.approveverb. - Existing HATCH event schema gains
agent.*discriminator additions (or remains permissive — final call by the 7a PR author).