← Ledger

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:

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

Open question — COUNCIL personas

The predecessor decision flagged whether COUNCIL personas register as agents. This spec resolves yes, with the following constraints:

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

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:

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:

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):

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

Out of scope

Acceptance for the spec PR

Spec-PR-only acceptance (this PR):

Implementation acceptance (deferred to Phase 7a):