Repository navigation
[0.2.0] - 2026-10-21
Range: v0.1.0-rc2..v0.2.0 — 660 non-merge commits (75 feat, 121 fix,
43 docs, 12 test, 4 ci, 262 chore, 97 board, plus qa/dogfood/gitreins/
security/refactor). No BREAKING CHANGE footer and no ! type token anywhere in
the range, so this is a minor release. The entries below are the release content
as it was written while the work landed.
Added
-
Optional model/provider hints and a correlated reply on the dispatch
envelope (CR-FEAT-036,docs/router-dispatch.md). A lane dispatch may carry
modelandprovider— the pair a consumer wants, or the members a bunker
task-router interprets when one or both are default — plusreply_to, the
Crier message id a returned result answers. Crier TRANSPORTS the two hints and
never chooses one: an explicit value survives delivery byte for byte (no
trimming, no validation against any catalogue of models), a blank string is
preserved as sent, andabsent/null/blank are all DEFAULT hints. Omission
is the canonical spelling: absent/null become omitted members, while blank
strings remain present and consumers must also treat them as default. No
concrete model or provider is filled in. The members ride the request, the stored message
(one JSONB column plusreply_to; migration 013) and every read of it —
retrieve/lease, peek, list — so the polling router sees exactly what the
scheduler wrote; a delivery that names none of them is byte-identical to
before, and the remote-proxy deliver path forwards them verbatim instead of
dropping them. Authorization is unchanged and stays out of the payload: the two
sends a router deployment needs are two deliberatesendgrants (scheduler →
the router's inbox, router → the scheduler's inbox), unrelated pairs are
refused with403 DELIVERY_FORBIDDEN, and areply_to— in the envelope or
inside the payload — confers nothing. Covered by
internal/registry/crfeat036_test.go: all four selection combinations across
every supported spelling, over the real HTTP surface, plus the round trip
(lease → correlated reply → ack), the signed-identity read gate, and the
lease-expiry redelivery. -
The session task lifecycle is now READABLE, not fire-and-forget
(DF-CRIER-304,specs/CHAT-ADDRESSING.md§2.5). Two read routes:
GET /sessions/{id}/taskslists every task in a session at its CURRENT state
(one entry per task id, each with the task id the transitions take, the state,
the owner once claimed, and the message record-version and thread the state
lives in), andGET /sessions/{id}/tasks/{task_id}reads one —404 TASK_NOT_FOUNDfor an id that is not a task in the session. ThePOST /sessions/{id}/tasks201 response also names the task's OWN id and state at
the TOP level (task_id,state) in addition to the existing nestedtask
object: the message view'sidis the MESSAGE id, so before this a caller
reading the obvious top-levelstatesaw null and a caller that claimed the
top-levelidwas refusedTASK_NOT_FOUNDon a task it had created moments
earlier. The current state is the latest record-version over the task id
(§3.7 keep-LAST). Covered byTestTaskLifecycle_Verifiable, on both the JSONL
log and the SQLite view. -
A2A push-notification configuration as a view over crier's webhook config
(INT-A2A-005,specs/A2A-OPTION.md§5.5). The fourTaskPushNotificationConfig
operations of the A2A specification (CreateTaskPushNotificationConfig,
GetTaskPushNotificationConfig,ListTaskPushNotificationConfigs,
DeleteTaskPushNotificationConfig, §9.4.7) are served as methods of the
existingPOST /a2abinding — no new route — and they configure the push
channel crier already has: the agent's webhook config. There is no second
store, no second delivery engine and no second write path. A create or a delete
is the bodyPATCH /agents/{id}already accepts, handed to that route's OWN
handler, so crier's strictwebhookmember decode, itswebhook.Config
validation and its agent-owned signature gate apply to an A2A write exactly as
they do to a direct one, and every field the A2A object has no member for
(retries,timeout_ms,batch,delivery_mode,schema_template) is
PRESERVED rather than reset. The configuration id is derived
(wh-<sha256(tenant‖url)>), never stored, so there is no second source of
truth to disagree with the row. The capability answer is a MUST, not a
nicety (§3.3.4): an agent with no webhook — the same condition its Agent Card
reports ascapabilities.pushNotifications: false— gets
PushNotificationNotSupportedError(-32003) from all four operations, never
a silent success.authentication.credentialsandtokenare refused rather
than stored or dropped, because crier references a secret by NAME
(auth_value_ref) and holds no credential to return; an inline
configuration.taskPushNotificationConfigon a send stays refused, now naming
the operations that do configure a channel. The notification itself is
§4.3.3'sStreamResponseenvelope,Content-Type: application/a2a+json,
delivered by the shipped webhook driver: the payload shape is the config's
custom_schema, so the driver, its retries, its batching and its headers are
untouched. Default-off and additive: withCR_A2A_ENABLEDunset nothing here
exists, no route or field was added, and a refused write leaves the row
byte-identical. A2A remains an extra, not first-class support. -
Capability-routed delivery — the registry's capability index as a dialable
worker pool (CR-FEAT-026).GET /agents?capability=solvercould already tell
you who advertises a capability, but delivery still required naming ONE agent
id, so a pool of interchangeable workers could not be addressed as a pool (the
gap the external reviewDISPATCH · CRI-001by Carter measured live at
ca28523d).POST /capabilities/{capability}/inboxtakes the same body as
POST /agents/{id}/inboxand picks the holder: candidates are every agent
whose advertisedcapabilitiesinclude the name (matched exactly like the
discovery filter), live holders rank first (liveness is the registry's own
derived status, so a crashed worker absorbs no work while a live one exists),
and the choice round-robins over that pool — one holder per delivery,
agent-id ascending, one cursor per capability, advanced once per dispatched
delivery. The accept names what it chose ("capability":"solver",
"target":"worker-b") because the sender has to know which worker took the
work; a by-id delivery's body is unchanged. Zero holders is a named error,
never a silent drop:404 NO_CAPABLE_AGENTnaming the capability and stating
that selection is local to the relay (federation forwards by agent id) — while
a capability whose holders are all merely stale still ACCEPTS, because the
inbox is durable and an untaken message is carried by the existing expiry
receipt. A holder that dies mid-lease needs no case of its own: the lease
expires and the message returns to the queue it was delivered to. A retry
carrying anidempotency_keyis scoped to the CAPABILITY (the holder is not
known when the key is resolved), so a repeated key is answered with the first
attempt's accept — same id, sametarget— instead of dispatching the same job
to a second worker, and it is answered even if the pool has since emptied.
Everything after the choice is the existing deliver path (guard choke point,
webhook driver, durable inbox, lease/ack/TTL, federation fallback), and the
detection layer observes a routed delivery with the RESOLVED holder as its
target and a zero-holder refusal with an empty one. Two counters
(capability_routed_total,capability_unheld_total) make a pool that has
gone empty visible. Proved byTestCapabilityDelivery*in
internal/registry(landing + signed retrieve + ack, the exact rotation, a
stale holder skipped, the named refusal with nothing stored, capability-scoped
idempotency, lease requeue after a holder dies, one observation per request,
and a concurrent 40-delivery rotation under-race). Source: the external
reviewDISPATCH · CRI-001by Carter, delivered via Bane. -
Priority lanes and real backpressure (CR-FEAT-035) — the three gaps the
external review (DISPATCH · CRI-001, by Carter) named in passing, closed
without changing what an existing deployment does:- an optional
priorityon a delivery (POST /agents/{id}/inbox,
integer 0..9, default 0) —GET /agents/{id}/inboxhands back the highest
priority claimable messages first, ties keep arrival order, and a delivery
that names no priority is exactly the FIFO message it always was (the key is
omitted from the wire at the default, so a pre-existing client parses
identical bytes). Out of range is a 400 naming the range, never a clamp. The
ordering is stored with the message (PostgreSQL migration 007,priority NOT NULL DEFAULT 0, range-checked), so it survives a restart and every
message an existing database already holds reads back in the order it has
always had. - a global ingest budget (
CR_RATE_LIMIT_GLOBAL_PER_MINUTE, default 0 =
no budget) — a shed on the delivery path itself, across every agent and
sender, which the per-agent publish cap could never be: a flood from many
ids spends every agent's own budget. A delivery over budget is refused 429
RATE_LIMITED_GLOBALwith aRetry-Afterheader (whole seconds) and the
same wait in the body, before any transport, guard call or store write —
and the check sits after decode/validation/containment, so a malformed
delivery still answers 400 and a contained agent still answers 403
AGENT_QUARANTINED. The pre-existing per-agent publish 429 gained the
Retry-Afterheader the review found missing; its body and its scope are
unchanged. Per-namespace budgets remain CR-FEAT-029's half (the shed counter
is already labelled by scope, so that change adds series rather than
renaming them). - queue depth where operators already look —
GET /statusreports
queue_depth(pending/leased/oldest_age_s) beside
global_rate_limit_per_minute, andGET /metricsexposes
inbox_queue_depth,inbox_queue_leasedand
inbox_queue_oldest_age_seconds(gauges;NaNwhen the serving store
cannot report a depth) plusinbox_shed_total{scope}. The store-wide
numbers use the same predicates the per-agent.../inbox/statscounters
already apply, so they are the sum of those, not a second definition of
"queued". - Both 429 lanes now shed through one sliding-window implementation
(internal/ratelimit), so "how long do I wait" has exactly one answer in
the codebase.
- an optional
-
Namespaces (realms) (CR-FEAT-029) — a realm dimension on agents and
messages, with per-realm policy. Named after the ancestor the external review
pointed at — WAMP's realms, "a routing and administrative domain … messages
are only routed within a Realm" — and opt-in: withCR_NAMESPACESunset
every agent is in the one implicit namespace and every wire surface (agent
rows, inbox entries, webhook envelopes, relay frames,/relay/topics) is
byte-identical to a build without this feature.- Per-realm policy (
CR_NAMESPACESinline orCR_NAMESPACES_FILE), each
axis inherited when unset: auth posture (shared— the deployment's gate —
ortoken, an additional realm credential resolved from anenv:VAR
reference and required on registration into the realm, delivery into it and
relay publish/subscribe in it), rate limits (rate_limit_per_minute,
counted per(realm, agent), so one realm's flood cannot spend another's
budget and the same agent id has one budget per realm), guard settings
(guard_enabled: falsekeeps a realm's traffic out of the process-wide
guard concurrency and circuit breaker — the noisy-neighbour valve — and
guard_policyis the realm-level default policy) and retention
(retention_seconds, the default message lifetime for the realm, which an
explicitttl_secondsstill overrides). - Realm-scoped routing with no implicit crossing: a message's realm is the
TARGET agent's stored realm, never the sender's claim — a delivery naming a
different realm is403 NAMESPACE_MISMATCHand stores nothing; an inbox
transfer between realms is refused; relay subscribers of the same topic name
in two realms are two disjoint sets (wildcards included); a realm name the
server does not declare is refused on every lane rather than falling back to
the default realm;PATCH /agents/{id}refuses the member (moving a live
agent is unregister + re-register). GET /namespaces— the policies actually being enforced, with a live
per-realm agent census, reporting postures andenv:references and never a
resolved secret. Not auth-exempt, like/status.- Storage: migration
008_add_namespacesadds a nullablenamespace
column toagents,inbox_entriesanddead_letters, whereNULLis the
default realm — an existing database gains three NULL columns and changes
none of its answers. The webhook envelope and dead-letter records carry the
realm too, so a message states it even after its agent's row is gone. - Spec:
specs/NAMESPACES.md(WAMP semantics read and
cited, the""-canonical mapping, the decision table per lane, the
non-regression table and the honest non-goals: the mesh lane is not
realm-scoped, there is no cross-realm bridging, and atoken-posture realm
is not subscribable from a browser page without a header-capable client).
Acceptance is measured live bycmd/server/crfeat029_test.goplus the relay
and registry namespace suites.
Source: external hands-on reviewDISPATCH · CRI-001by Carter (via Bane,
2026-09-25).
- Per-realm policy (
-
Detection & containment (CR-FEAT-030) — the other half of attribution, and
it is opt-in (CR_DETECT_ENABLED, defaultfalse; with the flag unset no
route is registered, no file is written and the delivery path is unchanged):- an append-only, ed25519-signed delivery log (
CR_DETECT_LOG,
CR_DETECT_KEY) — one record per delivery outcome (who sent what to whom,
when, what the bus decided), hash-chained, fsynced, keyed from a file so it
survives restarts, and refused at startup if a record was edited,
deleted or signed by another key.GET /delivery-logpages the window and
reports the true total;GET /delivery-log/verifyre-reads the file and
names the first record that does not verify. - behaviour baselines with alerts —
fanout_spike(N distinct targets in
a window),new_peer_burst(N first-ever conversations),odd_hour_volume
(N messages inside the quiet window) andcanary_trip, each one alert per
agent per window, each with its thresholds and evidence atGET /alerts. - a single-call kill-switch (
POST /agents/{id}/kill-switch) — pause the
outbound webhook lane (dropping what was queued), release the leases the
agent held, quarantine it (both directions refused with
403 AGENT_QUARANTINED) and remove its registry row, with every action
reported separately so a partial containment cannot read as a clean one. - canary tokens (
CR_CANARY_TOKENS, or two generated per boot and served
atGET /canaries) — a delivery addressed to a canary id, or carrying a
canary token in its payload, tripscanary_trip. - Acceptance, captured live against the real server wiring:
TestDetectionCatchesAndContainsACompromisedAgentfans a scripted
compromised agent out to five new peers, captures the alert, contains it in
one call, proves the enforcement and then proves the log survives a restart
with its chain intact. Design authority:specs/DETECTION.md. Source: the
external reviewDISPATCH · CRI-001by Carter.
- an append-only, ed25519-signed delivery log (
-
Task ownership: idempotency keys, expiry receipts, a dead-letter
destination, and transfer/reassign (CR-FEAT-025) — the four gaps the
external review (DISPATCH · CRI-001, by Carter) named under "the lease is
the lock", closed inside the existing lease model rather than beside it.
POST /agents/{id}/inboxnow takes an optionalidempotency_key: a repeated
key for the same agent insideCR_IDEMPOTENCY_WINDOW_S(default 24h) is
answered with the original accept — same id, same status,
"idempotent_replay":true— and stores nothing, so a sender that retried
after losing the response no longer duplicates the target's work (a blocking
webhook delivery replays the reply its single endpoint call produced; a
rejected delivery records nothing, so a corrected retry is delivered rather
than answered with a replay of the rejection). A message that expires
unacknowledged now produces exactly oneMESSAGE_EXPIREDreceipt in its
SENDER's inbox — the same durable error-notification shape asWEBHOOK_FAILED
andFEDERATION_FAILED, which is why the sender is now stored WITH the
message — and the message itself is preserved in a dead-letter destination,
GET /agents/{id}/inbox/dead-letters(newest first, bounded by 7-day
retention on a persisting backend and by capacity in memory, keyed by message
id so a message is dead-lettered once and reported once, and NOT tied to the
agent row so it survives the registration it was addressed to).
POST /agents/{id}/inbox/transferrebalances a stuck lease: messages move to
another inbox UNLEASED and immediately claimable, under their own lease (409
and nothing moved otherwise), withforce:trueas the explicit override for a
holder that is gone. Migration006_add_task_ownershipadds the two
provenance columns toinbox_entriesand thedead_letterstable; the
in-memory and PostgreSQL backends implement the samePurgeReporter,
DeadLetterStoreandTransferrercapabilities, andPurgeExpiredkeeps
its count-only contract for any backend that does not. -
crier keygen(CR-FEAT-027) — the signing ceremony, replaced. The server
binary now generates an ed25519 keypair in-process (no openssl, no xxd),
writes it as a PKCS#8 PEM at mode0600in the same shape
openssl genpkey -algorithm ED25519writes, and prints the agent config: the
agent id, the hex public key, the exactPOST /agentsbody and the next
command. Before printing anything it re-reads the file, parses it with the
server's own key loader and signs+verifies a sample payload, so the printed
public key is provably the file's key;-forceis required to overwrite an
existing private key, and-jsonprints the same facts machine-readably. The
external review (DISPATCH · CRI-001) named the openssl+xxd+sig()-helper
ceremony as the number-one adoption killer, and it is what the last three
testers each tripped over. -
First-party clients (CR-FEAT-027) —
clients/python/crier_client.py
(stdlib only, no packages to install) andclients/typescript/crier.ts
(Node 22.6+, no packages either) expose the bus as methods:register,
deliver,retrieve,ack,stats,publish,subscribe, plus
unregisterand arequest()escape hatch. Both build theX-Agent-ID/
X-Agent-Ts/X-Agent-Sigtrio for you, decode inbox payloads, and surface
the server's own error text. Runnable transcripts
(clients/python/round_trip.py,clients/typescript/round-trip.ts,
clients/python/two_agents.py) complete the round-trip and prove the
signatures are enforced with negative controls — an unsigned call and a
wrongly-signed call must both be refused. The Python signer prefers the
cryptographypackage when present and otherwise uses a bundled RFC 8032
implementation verified against the RFC 8032 §7.1 vectors and byte-identical
to the library-backed path. -
make client-roundtrip-check(CR-FEAT-027) — the acceptance drive for the
above: it builds the server, starts it on a port the shared port selector
chooses, asserts it owns that port, runscrier keygen, both clients, both
signing backends, the two-agent exchange and a second server with
CR_AUTH_TOKEN(refusing an unauthenticated round-trip and accepting an
authenticated one), and reaps the servers on exit. It also runs in CI. -
The release front door: prebuilt binaries + a curl-to-install path
(CR-FEAT-028) —gh release view v0.1.0-rc2answeredassets: []: the
Release object existed and carried notes only, so the only way in was
git cloneplus a Go toolchain plusmake build. An external hands-on review
(DISPATCH · CRI-001, by Carter) filed that as a tester-funnel problem. Every
release now carries 8 assets —crierandcrier-mcpcross-compiled for
linux/amd64, linux/arm64 and darwin/arm64, the installer, and aSHA256SUMS
manifest.scripts/release-artifacts.sh(andmake release-artifacts) builds
them withCGO_ENABLED=0 -trimpath, stripped, stamping the same
internal/buildinfoidentity the other build paths stamp; it re-verifies the
manifest and runs the host artifact's-versionbefore returning, so a set
whose identity did not land is never left on disk.scripts/install.shis the
one-line front door (curl -fsSL …/install.sh | sh): it detects the platform,
verifies both binaries against that manifest and installs them into
$HOME/.local/bin, and it REFUSES an unverified download — no checksum tool,
no manifest entry for the binary, or a digest that does not match installs
nothing, and there is deliberately no flag to skip the check.make release
builds the asset set for the tag it cuts, andmake release-uploadattaches it
to the Release object (creating it from the version's own changelog section and
the compare link when it does not exist yet) — the publish step that used to be
a hand-written, forgettablegh release create, which is how rc2 shipped
without binaries.make install-path-selftestis the acceptance drive: it
builds the set, serves it as a release tree, installs into a clean box whose
PATHcarries agoshim that exits 127 (so "no toolchain" is proven
positively, not assumed from an absentgo), runs the installed binary through
/health,/version, a registration and an inbox delivery, and proves both
unverified-download shapes are refused by name. -
A2A interoperability, as an opt-in extra (INT-A2A-001/002/003) — crier
can be reached by an A2A client without becoming
an A2A-first system: the whole option is behindCR_A2A_ENABLED(default
false) and a per-agenta2ablock, and with either half absent nothing
A2A-related is registered — the route table, response bodies, auth
requirements and storage schema are exactly what they were. The binding
decision is recorded inspecs/A2A-OPTION.md(JSON-RPC 2.0 over HTTP with SSE
is implemented; gRPC and HTTP+JSON/REST are declared permitted-but-absent):GET /.well-known/agent-card.json?agent_id=<id>serves the Agent Card
projected from that agent's registry row — no card store, no cache,404
for an id that is not an opted-in row,400when the request names no
agent, andCache-Control: private+ a body-hashedETagfor conditional
GETs.POST /a2ais the JSON-RPC binding.SendMessagetranslates an A2A
message into crier's existing delivery path — it calls
POST /agents/{id}/inbox's own handler, so the guard, sender idempotency
(an A2AmessageIdbecomes the deduplication key), the detection layer,
federation hold/retry, webhook push, the durable inbox and the lease/ack
lifecycle all apply unchanged — and answers aTask(accepted: inbox,
queued push, or a federation hold with a status message that says so) or a
directMessage(a target that answered inline).SendStreamingMessage
answerstext/event-stream, carrying the task's own inbox lifecycle
(submitted → working → completed/failed, closing on the terminal state) and
every event published to the task's relay topic — an SSE adapter over
the existing relay subscription, with the WebSocket path untouched. The part
model maps faithfully in both directions: A2Atext/raw/url/dataplus
metadata.alt/tags/caption⇄ crier message parts withalt/tags/
captionand reference-or-inline file parts (arawpart gains the size and
SHA-256 of its decoded bytes; aurlpart stays a reference), and a
round-trip test proves a multi-part message survives to a crier consumer
withalt/tagsintact. Errors use the specification's own JSON-RPC codes
and detail objects (google.rpc.BadRequestfieldViolations,
google.rpc.ErrorInfo), and a refusal raised by the delivery engine carries
crier's own status and body verbatim so a client can see exactly what crier
said. What is deliberately NOT here: the task lifecycle operations
(GetTask,ListTasks,CancelTask,SubscribeToTask) and the extended
card (INT-A2A-004/006) — each answers-32601 MethodNotFoundErrornaming
the row that lands it rather than pretending. (The push-notification
configuration operations are their own [Unreleased] entry above: INT-A2A-005
added them as further methods of this same binding.)
-
A2A task lifecycle, mapped onto the inbox entry (INT-A2A-004) — the four
task operations, on the same opt-inPOST /a2abinding:GetTask(the state
of one task),ListTasks(one agent's tasks, filtered bycontextId/status/
statusTimestampAfterand cursor-paginated withpageSize/pageToken),
CancelTaskandSubscribeToTask.
There is no second store: a task IS the inbox entry, its id IS crier's
message id, and every state is read from that row's own lifecycle — an
unclaimed message isTASK_STATE_SUBMITTED, a leased one
TASK_STATE_WORKING, one past its TTL (or one the expiry sweep recorded in a
dead letter)TASK_STATE_FAILED, andCancelTask— which releases the
message's lease and closes it, whatever its lease state — answers
TASK_STATE_CANCELED. Every task a read returns names the crier record its
state came from (metadata.crier.state_basis), because the mapping is where
an interop adapter most easily lies: there is noTASK_STATE_COMPLETEDfor an
acknowledged task, since the ack REMOVES the entry and crier keeps no
tombstone — such a task id answers the specification's ownTaskNotFoundError
("invalid, expired, or already completed and purged") instead of an invented
state, and the state mapping table is published inspecs/A2A-OPTION.md
§5.5.2. Refusals are refusals: a message sent to a task in a terminal state is
-32004 UnsupportedOperationError(mandatory in §3.1.1, and nothing is
delivered), a cancel of a terminal task is-32002 TaskNotCancelableError,
and a message naming an OPEN task is-32004too, because crier cannot
represent a continuation — a task is one inbox entry. The three store
capabilities this needed are OPTIONAL and additive (a lease-free inbox
listing, a per-entry close, and a single dead-letter lookup); no existing
route, column, method or response body changed, and with the switch off every
lifecycle method answers the router's404exactly as before. The
push-notification configs and the extended card remain INT-A2A-005/006 and
still answer-32601.
Full Changelog: v0.1.0-rc2...v0.2.0