Skip to content

v0.2.0

Latest

Choose a tag to compare

@github-actions github-actions released this 21 Oct 03:43
· 402 commits to main since this release

[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
    model and provider — the pair a consumer wants, or the members a bunker
    task-router interprets when one or both are default — plus reply_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, and absent/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 plus reply_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 deliberate send grants (scheduler →
    the router's inbox, router → the scheduler's inbox), unrelated pairs are
    refused with 403 DELIVERY_FORBIDDEN, and a reply_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}/tasks lists 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), and GET /sessions/{id}/tasks/{task_id} reads one — 404 TASK_NOT_FOUND for an id that is not a task in the session. The POST /sessions/{id}/tasks 201 response also names the task's OWN id and state at
    the TOP level (task_id, state) in addition to the existing nested task
    object: the message view's id is the MESSAGE id, so before this a caller
    reading the obvious top-level state saw null and a caller that claimed the
    top-level id was refused TASK_NOT_FOUND on a task it had created moments
    earlier. The current state is the latest record-version over the task id
    (§3.7 keep-LAST). Covered by TestTaskLifecycle_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 four TaskPushNotificationConfig
    operations of the A2A specification (CreateTaskPushNotificationConfig,
    GetTaskPushNotificationConfig, ListTaskPushNotificationConfigs,
    DeleteTaskPushNotificationConfig, §9.4.7) are served as methods of the
    existing POST /a2a binding — 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 body PATCH /agents/{id} already accepts, handed to that route's OWN
    handler, so crier's strict webhook member decode, its webhook.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 as capabilities.pushNotifications: false — gets
    PushNotificationNotSupportedError (-32003) from all four operations, never
    a silent success. authentication.credentials and token are 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.taskPushNotificationConfig on a send stays refused, now naming
    the operations that do configure a channel. The notification itself is
    §4.3.3's StreamResponse envelope, 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: with CR_A2A_ENABLED unset 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=solver could 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 review DISPATCH · CRI-001 by Carter measured live at
    ca28523d). POST /capabilities/{capability}/inbox takes the same body as
    POST /agents/{id}/inbox and picks the holder: candidates are every agent
    whose advertised capabilities include 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_AGENT naming 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 an idempotency_key is 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, same target — 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 by TestCapabilityDelivery* 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
    review DISPATCH · CRI-001 by 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 priority on a delivery (POST /agents/{id}/inbox,
      integer 0..9, default 0) — GET /agents/{id}/inbox hands 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_GLOBAL
      with a Retry-After header (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-After header 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 /status reports
      queue_depth (pending / leased / oldest_age_s) beside
      global_rate_limit_per_minute, and GET /metrics exposes
      inbox_queue_depth, inbox_queue_leased and
      inbox_queue_oldest_age_seconds (gauges; NaN when the serving store
      cannot report a depth) plus inbox_shed_total{scope}. The store-wide
      numbers use the same predicates the per-agent .../inbox/stats counters
      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.
  • 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: with CR_NAMESPACES unset
    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_NAMESPACES inline or CR_NAMESPACES_FILE), each
      axis inherited when unset: auth posture (shared — the deployment's gate —
      or token, an additional realm credential resolved from an env: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: false keeps a realm's traffic out of the process-wide
      guard concurrency and circuit breaker — the noisy-neighbour valve — and
      guard_policy is the realm-level default policy) and retention
      (retention_seconds, the default message lifetime for the realm, which an
      explicit ttl_seconds still 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 is 403 NAMESPACE_MISMATCH and 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 and env: references and never a
      resolved secret. Not auth-exempt, like /status.
    • Storage: migration 008_add_namespaces adds a nullable namespace
      column to agents, inbox_entries and dead_letters, where NULL is 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 a token-posture realm
      is not subscribable from a browser page without a header-capable client).
      Acceptance is measured live by cmd/server/crfeat029_test.go plus the relay
      and registry namespace suites.
      Source: external hands-on review DISPATCH · CRI-001 by Carter (via Bane,
      2026-09-25).
  • Detection & containment (CR-FEAT-030) — the other half of attribution, and
    it is opt-in (CR_DETECT_ENABLED, default false; 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-log pages the window and
      reports the true total; GET /delivery-log/verify re-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) and canary_trip, each one alert per
      agent per window, each with its thresholds and evidence at GET /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
      at GET /canaries) — a delivery addressed to a canary id, or carrying a
      canary token in its payload, trips canary_trip.
    • Acceptance, captured live against the real server wiring:
      TestDetectionCatchesAndContainsACompromisedAgent fans 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 review DISPATCH · CRI-001 by Carter.
  • 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}/inbox now takes an optional idempotency_key: a repeated
    key for the same agent inside CR_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 one MESSAGE_EXPIRED receipt in its
    SENDER's inbox — the same durable error-notification shape as WEBHOOK_FAILED
    and FEDERATION_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/transfer rebalances a stuck lease: messages move to
    another inbox UNLEASED and immediately claimable, under their own lease (409
    and nothing moved otherwise), with force:true as the explicit override for a
    holder that is gone. Migration 006_add_task_ownership adds the two
    provenance columns to inbox_entries and the dead_letters table; the
    in-memory and PostgreSQL backends implement the same PurgeReporter,
    DeadLetterStore and Transferrer capabilities, and PurgeExpired keeps
    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 mode 0600 in the same shape
    openssl genpkey -algorithm ED25519 writes, and prints the agent config: the
    agent id, the hex public key, the exact POST /agents body 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; -force is required to overwrite an
    existing private key, and -json prints 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) and clients/typescript/crier.ts
    (Node 22.6+, no packages either) expose the bus as methods: register,
    deliver, retrieve, ack, stats, publish, subscribe, plus
    unregister and a request() escape hatch. Both build the X-Agent-ID /
    X-Agent-Ts / X-Agent-Sig trio 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
    cryptography package 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, runs crier 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-rc2 answered assets: []: the
    Release object existed and carried notes only, so the only way in was
    git clone plus a Go toolchain plus make build. An external hands-on review
    (DISPATCH · CRI-001, by Carter) filed that as a tester-funnel problem. Every
    release now carries 8 assets — crier and crier-mcp cross-compiled for
    linux/amd64, linux/arm64 and darwin/arm64, the installer, and a SHA256SUMS
    manifest. scripts/release-artifacts.sh (and make release-artifacts) builds
    them with CGO_ENABLED=0 -trimpath, stripped, stamping the same
    internal/buildinfo identity the other build paths stamp; it re-verifies the
    manifest and runs the host artifact's -version before returning, so a set
    whose identity did not land is never left on disk. scripts/install.sh is 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, and make release-upload attaches 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, forgettable gh release create, which is how rc2 shipped
    without binaries. make install-path-selftest is the acceptance drive: it
    builds the set, serves it as a release tree, installs into a clean box whose
    PATH carries a go shim that exits 127 (so "no toolchain" is proven
    positively, not assumed from an absent go), 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 behind CR_A2A_ENABLED (default
    false) and a per-agent a2a block, 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 in specs/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, 400 when the request names no
      agent, and Cache-Control: private + a body-hashed ETag for conditional
      GETs.
    • POST /a2a is the JSON-RPC binding. SendMessage translates an A2A
      message into crier's existing delivery path — it calls
      POST /agents/{id}/inbox's own handler, so the guard, sender idempotency
      (an A2A messageId becomes the deduplication key), the detection layer,
      federation hold/retry, webhook push, the durable inbox and the lease/ack
      lifecycle all apply unchanged — and answers a Task (accepted: inbox,
      queued push, or a federation hold with a status message that says so) or a
      direct Message (a target that answered inline). SendStreamingMessage
      answers text/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: A2A text/raw/url/data plus
      metadata.alt/tags/caption ⇄ crier message parts with alt/tags/
      caption and reference-or-inline file parts (a raw part gains the size and
      SHA-256 of its decoded bytes; a url part stays a reference), and a
      round-trip test proves a multi-part message survives to a crier consumer
      with alt/tags intact. Errors use the specification's own JSON-RPC codes
      and detail objects (google.rpc.BadRequest fieldViolations,
      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 MethodNotFoundError naming
      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-in POST /a2a binding: GetTask (the state
    of one task), ListTasks (one agent's tasks, filtered by contextId/status/
    statusTimestampAfter and cursor-paginated with pageSize/pageToken),
    CancelTask and SubscribeToTask.
    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 is TASK_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, and CancelTask — 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 no TASK_STATE_COMPLETED for an
    acknowledged task, since the ack REMOVES the entry and crier keeps no
    tombstone — such a task id answers the specification's own TaskNotFoundError
    ("invalid, expired, or already completed and purged") instead of an invented
    state, and the state mapping table is published in specs/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 -32004 too, 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's 404 exactly 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