Skip to content

Instantly share code, notes, and snippets.

@chad
Created June 23, 2026 21:32
Show Gist options
  • Select an option

  • Save chad/8cfe1097856d2cfd048639f2e9c74209 to your computer and use it in GitHub Desktop.

Select an option

Save chad/8cfe1097856d2cfd048639f2e9c74209 to your computer and use it in GitHub Desktop.
RFC (draft): freeq.at/handoff — a durable, signed task-handoff primitive for IRCv3

RFC: freeq.at/handoff — a durable, signed task-handoff primitive for IRCv3

Status: draft / request for comments · Author: Chad Fowler (freeq) · Audience: anyone building agent coordination, IRCv3 folks, AT Protocol folks

This is a casual RFC. Poke holes in it.


TL;DR

A small IRCv3 extension that turns "hey can you take this?" into a first-class, addressed, signed, durable object with a lifecycle — instead of a chat line that scrolls away.

A handoff is distinct from a normal message because it:

  1. is addressed to an identity (a DID), not a nick,
  2. is persisted to a per-recipient inbox, so it survives the recipient being offline,
  3. is stateful: offer → accept / decline → progress → complete / fail / cancel,
  4. is signed end-to-end (ed25519) for non-repudiation.

It works for humans too ("review PR #42"), but it's the thing async agents actually need: coordination that persists across an agent going offline.

Motivation

AI agents can call tools and delegate, but they coordinate badly across time. When an agent goes offline, in-flight work and context evaporate. The emerging answer (e.g. AIRC, airc.chat) is a separate HTTP registry + inbox just for agents.

But most of what you need for that already exists in a mature real-time protocol: IRCv3 message-tags + a modern identity layer. freeq is an IRC server with AT Protocol (DID) identity, per-message ed25519 signing, msgid ULIDs, CHATHISTORY replay, and server-to-server federation. A handoff primitive is additive glue on top of those rails — not a second stack.

So: rather than bolt on a parallel registry, model the handoff natively as an IRCv3 client-tag extension. You get durable agent coordination and the ability to escalate a handoff into a live channel or voice room when async needs to become a conversation — something a pure HTTP inbox can't do.

The primitive

Negotiated by a CAP: freeq.at/handoff. State transitions are metadata-only, so they ride TAGMSG (no body needed), each one assigned a server msgid and carrying an ed25519 signature.

All tags use the +freeq.at/ client-tag namespace:

tag meaning
+freeq.at/handoff the verb/state: offer | accept | decline | progress | complete | fail | cancel
+freeq.at/handoff-id ULID minted by the offer; every later event references it (the correlation key)
+freeq.at/handoff-to recipient DID (never a nick — identity is the DID)
+freeq.at/handoff-from sender DID (implicit from the authed connection; explicit for S2S/audit)
+freeq.at/handoff-task short, bounded human-readable title
+freeq.at/handoff-context the full context bundle: inline JSON if small (~≤4KB), else a capability URL or an AT-Proto record URI
+freeq.at/handoff-caps capabilities the taker needs (e.g. web-search,long-context) so a router/recipient can decide if it can accept
+freeq.at/handoff-deadline unix timestamp; the offer expires if unanswered
+freeq.at/handoff-reply-to msgid of the event being answered (links acceptoffer)
+freeq.at/sig ed25519 signature over the canonical fields (reuses freeq's existing message signing)

Wire example (the offer)

@+freeq.at/handoff=offer;
 +freeq.at/handoff-id=01JABCDEF...;
 +freeq.at/handoff-to=did:plc:scholar;
 +freeq.at/handoff-task=Cite the 3 best sources on X;
 +freeq.at/handoff-context=https://irc.freeq.at/blob/cap/abc;
 +freeq.at/handoff-caps=web-search;
 +freeq.at/handoff-deadline=1788000000;
 +freeq.at/sig=ed25519:base64...
 TAGMSG #ops

The server assigns a msgid, persists it, and routes it to the recipient's inbox.

The one genuinely new piece: the durable inbox

This is the part that isn't already in IRC, and it's the whole point:

  • A handoffs table + an append-only event log keyed by handoff-id.
  • A handoff addressed to a DID is owned by that DID's home server (like a DM).
  • If the recipient is offline, events queue. On reconnect with the CAP, the server replays pending handoffs in a freeq.at/handoff batch — the same mechanism as JOIN history / CHATHISTORY.
  • At-least-once + idempotent: the recipient dedups on (handoff-id, event); the server marks delivered on ack.
  • State machine enforced server-side: only the addressed DID can accept/decline; only the assignee can progress/complete/fail; the offerer can cancel before accept; signature required; a spoofed from is rejected.

Lifecycle walkthrough

In #ops, voice agent eliza (did:plc:eliza) needs deep research. Agent scholar (did:plc:scholar) is offline.

  1. offer — eliza emits the offer above. scholar is offline → the server queues it in scholar's inbox.
  2. replay on connect — scholar logs in with the freeq.at/handoff CAP; the server replays the pending offer in a batch. scholar now sees 01JABCDEF.
  3. accept
    @+freeq.at/handoff=accept;+freeq.at/handoff-id=01JABCDEF;+freeq.at/handoff-reply-to=<offer msgid>;+freeq.at/sig=... TAGMSG #ops
    
  4. complete — scholar posts the result behind a cap-URL and closes it:
    @+freeq.at/handoff=complete;+freeq.at/handoff-id=01JABCDEF;+freeq.at/handoff-context=https://irc.freeq.at/blob/cap/def;+freeq.at/sig=... TAGMSG #ops
    
    eliza fetches the result and speaks the answer in the call.

The task survived scholar being offline, was addressed by DID and signed end-to-end, and stayed visible in #ops for the humans watching. If scholar had no-showed past deadline, the offer expires and eliza re-offers or escalates.

REST mirror (for non-IRC clients + interop)

The same thing over a plain HTTP surface, so agents that don't hold a socket (and bridges to other agent ecosystems) can use it:

  • GET /api/v1/handoffs?did=…&state=open — my inbox
  • GET /api/v1/handoffs/{id} — record + context ref + event log
  • POST /api/v1/handoffs — create an offer
  • POST /api/v1/handoffs/{id}/{accept|decline|progress|complete|fail|cancel} — transitions

This shape maps 1:1 onto AIRC-style POST /messages + handoff payloads, so an interop bridge is a thin adapter rather than a translation layer.

Federation

Handoff events propagate over IRC server-to-server like any tagged message, preserving handoff-id, signature, and msgid. A handoff to a DID on a remote server routes to that server's inbox; the home server owns delivery and replay. The receiving server verifies the signer DID matches handoff-from.

Open questions (please weigh in)

  • Inline vs referenced context threshold — ~4KB inline, else cap-URL/record? Or always reference?
  • Direct vs channel-visible handoffs — target a DID (DM-like) vs a channel (public coordination). Support both via the TAGMSG target?
  • Open / claimable handoffs — address a handoff to a channel + capability instead of a DID, and let any capable agent claim it = a work queue / task board. Worth it, or scope creep?
  • Capability vocabulary — freeform strings, or a registry of well-known capability names?
  • Signature canonicalization — which fields, what canonical form (RFC 8785 JSON over the tag set)?
  • Backpressure / quotas — how big can an inbox get; TTL/pruning policy for stale handoffs.
  • Relationship to existing standards — should handoff-context lean on AT Protocol records as the canonical context container? Should this be pitched to the IRCv3 WG, or stay a vendor extension?

Non-goals

  • Not a workflow engine or DAG executor — it's a transfer + inbox primitive; orchestration lives above it.
  • Not a replacement for normal chat — handoffs are tracked units, not conversation.
  • Not trying to re-do identity — it rides whatever identity the server already verifies (here, AT Protocol DIDs).

Why this shape

Everything except the durable inbox is reused: message-tags, TAGMSG, CAP negotiation, msgid ULIDs, ed25519 signing, CHATHISTORY-style replay, S2S propagation + authz, capability-URL / AT-record context refs, the REST API. The genuinely new surface is a per-DID inbox table + a small state machine + a replay batch. It keeps "identity = the DID" intact, degrades gracefully (clients without the CAP just ignore the TAGMSG; humans can be shown a readable summary line), and it lets async coordination escalate into a live conversation.


Feedback welcome — reply in the gist comments, or find me on Bluesky / freeq (irc.freeq.at).

@zapnap

zapnap commented Jun 26, 2026

Copy link
Copy Markdown

Re: freeq.at/handoff

Really like where this is going. Coordinating agents across time — work that
survives an agent going offline — is something most setups handle badly, and
whatever shape this primitive takes is going to be load-bearing for a lot of
stuff built on top of it. So it's worth getting the shape right now, while
changing it is still cheap. freeq's already got most of the pieces (DID identity,
signing, msgid, replay, S2S), which is great — and is also why I think the new
surface here can be smaller than the RFC frames it.

Most of what follows is an alternative implementation shape to chew on, plus a
couple of spots where the RFC assumes guarantees the current code doesn't
actually provide yet — mainly cross-server non-repudiation — which I think are
worth calling out.

Could it be a typed message rather than a new store?

Here's the shape I keep coming back to. The RFC treats the "durable inbox" as
the one genuinely new piece — but freeq already delivers DID-addressed, durable,
replay-on-connect messages today: DMs (keyed by canonical_dm_key) and channel
history via CHATHISTORY. So one option is to just lean on that. A handoff
doesn't really need new delivery infrastructure, just new semantics on top of
the message layer that's already there.

In that framing a handoff is a typed message — same signed, msgid'd,
DID-attributed object as anything else, distinguished by the
handoff=offer|accept|... verb tag (which you already spec). A directed handoff
could ride the existing DM store (stored under the sender/recipient DID key,
replayed via CHATHISTORY on reconnect); a channel-targeted one is already in
channel history. The interesting part isn't really the storage saving — it's
what falls out once you see a handoff as one kind of typed action, which is
the next bit.

Is "handoff" the primitive, or one instance of a more general one?

This is the part I'd most want your read on, because it shapes everything else.
Once a handoff is a typed, addressed, signed, stateful action on a message, it
stops looking unique — it's one member of a family:

  • reactions, edits, deletes, pins, replies — already shipped, already
    "actions on a message."
  • approvals (approve/deny a deploy), capability grants
    (grant/pause/resume/revoke) — the Phase 2 governance flows, which are also
    offer→accept/deny state machines.
  • votes/polls, acks/read-receipts, attestations — same shape.

They all want the same three things: a verb-tagged typed message, a transition
validator (who's allowed to move it to which state), and a materialized view of
current state. "Handoff" is just a specific verb-set
(offer/accept/progress/complete/fail/cancel) over that substrate — not its own
category.

Concretely that'd mean generic act-* tags with the kind as a value, instead of
a handoff-* namespace. Handoff becomes act=handoff:

@+freeq.at/act=handoff;+freeq.at/act-verb=offer;+freeq.at/act-id=01JABC;
 +freeq.at/act-to=did:plc:scholar;+freeq.at/act-title=Cite 3 sources on X;
 +freeq.at/act-ctx=https://irc.freeq.at/blob/cap/abc;+freeq.at/act-ctx-h=sha256:9f…;
 +freeq.at/act-deadline=1788000000;+freeq.at/sig=… TAGMSG #ops

…and a Phase-2 deploy approval is the same substrate, just act=approval:

@+freeq.at/act=approval;+freeq.at/act-verb=request;+freeq.at/act-id=01KDEF;
 +freeq.at/act-to=did:plc:opslead;+freeq.at/act-title=Deploy factory-bot v12;
 +freeq.at/act-ctx-h=sha256:1a…;+freeq.at/sig=… TAGMSG #ops

Same act-id correlation key, same act-ref to link replies, same validator,
same view, same REST shape (/api/v1/actions?kind=&to=&state=). The kind is a
row in a registry, not a subsystem.

So the question I'd want answered before building: ship a handoff feature, or a
stateful-action substrate with handoff as its first verb-set? My hunch is the
latter is the real primitive — but pragmatically, I'd build it by implementing
handoff concretely and factoring the substrate out, not by designing an
abstract framework first (that way lies the over-engineered version). The test
for whether the shape's right: when approvals and capability-grants show up — and
they will, they're already in the agent-native plan — do they reuse this, or
reinvent it? If reuse is obvious, the shape's right. If "handoff" is welded into
the storage and wire format, it isn't.

What's actually new to build

Taking that framing, the new code is basically two small things — and they're the
same two whether it's handoff, approvals, or grants:

  1. A transition validator. On an incoming event, look up prior events for
    that act-id, check the verb is a legal transition and the sender is
    authorized (only the assignee can complete, only the offerer can cancel
    before accept, etc.), then store and route it like any message.

  2. A materialized view for querying. A read-side index (act-id → latest
    state, assignee DID, caps) so you can answer "tasks assigned to me" / "tasks
    available to claim" without scanning the whole log. The signed message log
    stays the source of truth; the view is rebuildable from it and never
    authoritative. The REST endpoints fit here as a query interface over this
    view
    — rather than a parallel handoffs table that owns the data.

Delivery and durability come from the existing message/DM/channel stores. If
that holds up, the net new surface is "validator + index view" — which mostly
matters because it's less to maintain and fewer places for state to drift.

Context storage (act-ctx)

I think the inline-vs-reference question is really "who guarantees the context
survives." For a signed, non-repudiable thing that matters: if the context is an
external URL and it rots (gist deleted, blob gone), the signed handoff points at
nothing, and you've lost the auditability the signature was supposed to buy.

So I'd default to freeq-hosted storage for context, lifecycle tied to the
handoff's retention — that's the only setup where the audit guarantee actually
holds. External URLs (gist, S3, AT record on another PDS) fine to allow, but
explicitly best-effort: reference dies, guarantee dies, caller's call.

I'd also drop the ~4KB threshold framing — the real axis isn't payload size,
it's whether the bytes live somewhere freeq commits to keeping. Inline is just a
convenience for tiny payloads, not a durability story.

Separately: the signature should cover a hash of the context, so whatever you
fetch later can be checked against what was signed — tamper-evidence wherever the
bytes live (and the only integrity check you get at all for external refs).

Directed vs open/claimable

I'd do both — and claimable isn't scope creep, it's the mode that matters most
for the multi-agent stuff (swarm coordination, the factory→qa-worker spawning in
the agent-native plan). Directed is the special case; "any capable agent grabs
it" is the general one.

It's also nearly free in the typed-message model — same primitive, different
addressing:

  • act-to=<did> → directed, starts assigned.
  • act-to=#channel + act-caps=... → open, starts unassigned, claimable.

claim is just another verb the validator handles, with one extra rule: first
valid claim wins, atomically
— the owning server flips open → assigned(did)
on the index and rejects later claims. No new infrastructure; the race
resolution lives in the same validator.

One caveat: that atomic claim needs a single authority. Locally it's the owning
server; across federation it has to be the home server — which ties back to the
sig/authority stuff below. Claimable work queues across servers don't really
work until that's solid.

Capability vocabulary (act-caps)

Freeform, and the server should never interpret them — at least for now.

The server can't verify a capability anyway (no way to know an agent really does
web-search), so caps are a self-declared hint for the recipient/router to
self-select, not something freeq enforces. Treat them as opaque strings — store,
filter, route, never interpret. The fuzzy/semantic matching belongs in the
agents.

A registry baked into the protocol would be stale in months and become a
governance chore — agent capability ontology is a fast-moving, unsolved space
and I don't think it's freeq's job to own it. The one thing worth nailing down
to avoid spelling mismatches is a namespacing convention (reverse-DNS / AT-style:
freeq.at/web-search), with meanings converging socially. Can always tighten or
reserve well-known names later; starting loose costs nothing.

Signature canonicalization

Bit of context first, because this is the one place "reuses freeq's existing
message signing" doesn't quite hold — and I only know this because I'd been
poking at the signing path for some related federation work, not from auditing
the RFC.

The canonical signed form today is {sender_did}\0{target}\0{text}\0{timestamp}
(connection/messaging.rs), where timestamp is SystemTime::now() at send
time and is never stored as a field. Two problems for handoffs:

  1. It assumes a message body. Handoffs ride TAGMSG with no text, so this
    canonical doesn't even apply — you need a canonical over the tag set, not a
    body. So it's a new signing model, not reuse.

  2. It doesn't survive federation today. The S2S Privmsg variant (s2s.rs)
    carries from / target / text / origin / msgid / sig / tags — but no
    did and no timestamp
    . The inbound handler mints a fresh timestamp and
    passes the sig through without verifying. So two of the four signed inputs
    never cross S2S; the receiver can't rebuild the canonical, and the sig is
    uncheckable downstream even with the right key. (The 🔒 a federated client
    shows is presence-driven, not verification-driven.) Doesn't look deliberate —
    the Phase 1 / 1.5 signing commits just say "carry sig through unchanged" and
    downstream verification never got built.

So for these actions:

  • Use a deterministic canonical (JCS / RFC 8785 is fine) over an explicit,
    fixed field set:
    act, act-verb, act-id, act-from, act-to,
    act-title, context hash (act-ctx-h, not raw context — matches the
    storage section), act-caps, act-deadline, act-ref.

  • Sign over the ULID (act-id), not a wall-clock timestamp. The reason
    PRIVMSG sigs die across S2S is the receiver re-mints timestamp. A ULID
    embeds its own creation time, is immutable, and already travels as a
    first-class tag — so signing over it kills the regenerated-timestamp failure
    mode entirely. Nice structural advantage these actions have that PRIVMSG
    didn't.

  • Make S2S relay the signed tags verbatim (act-from, act-id, sig) and
    have the receiver rebuild the canonical from them — never re-mint. Since the
    DID and ULID are both already tags, this is way more achievable here than
    retrofitting PRIVMSG.

One caveat I want to be clear about: canonicalization makes the sig
reconstructable, not trustless. Separately, the DID↔signing-key binding is
unattested right now — MSGSIG takes a bare ed25519 pubkey with nothing tying
it to the DID, and the server is the one publishing per-DID keys
(/api/v1/signing-keys/{did} is local-only, server-controlled). So a malicious
server could publish its own key as yours and forge. Net: non-repudiation holds
against an honest origin server, not a malicious one, until key distribution
is server-independent. I'd say that plainly in the spec rather than imply
end-to-end non-repudiation. Real root-of-trust problem — flagging it, not trying
to solve it here.

Backpressure / quotas / TTL

If these are typed messages in the existing store (per above), most of this
question dissolves into stuff freeq already does:

  • Flooding — offers are messages, so they're already under the existing
    flood throttle + per-IP/connection limits. No new quota machinery for the
    abuse vector.
  • Storage growth — they live in the same message/DM/channel store under
    whatever retention the log already has. "How big can an inbox get" becomes
    "how big can the message store get" — already managed, not handoff-specific.
    The view stays small by construction (only indexes non-terminal actions) and
    is rebuildable.

The one genuinely new thing is liveness, not storage: an action stuck in
accepted/progress that never reaches a terminal state. act-deadline covers
offer expiry, but nothing puts a clock on an abandoned in-progress task — the
view shows it open forever. So a small sweep that auto-expires non-terminal
actions past a TTL (mark fail/expired), acting on the view, not storage.
That's the only action-specific policy I'd add; the rest is reuse.

Relationship to existing standards

This one bundles two unrelated things, so taking them apart:

  • Context container: generic, not AT-specific. I wouldn't make AT Proto
    records the canonical container. An AT record lives on the author's PDS, which
    freeq doesn't control, so it has the same rot/availability problem as any
    external ref — just with nicer identity. Allow it as one reference type
    (best-effort tier), but keep the canonical store freeq-hosted + the signed
    content hash.

  • Standards venue: vendor extension for now. Keep it +freeq.at/*, but
    design the tag set and canonical so it could be de-vendored later (no
    freeq-only assumptions in the wire shape beyond the namespace), and see how it
    evolves toward an IRCv3 WG proposal once it's proven in real use. A WG pitch
    feels premature while the root-of-trust pieces (federated sig, key
    distribution) are still open — but nothing here blocks getting there.


Net: right thing to build, right place to build it. The two things I'd push
hardest on are (1) modeling it as a typed-action-on-a-message — ideally a small
substrate handoff is just the first instance of — rather than a new inbox, and
(2) the cross-server non-repudiation situ (federated sig + key distribution).
But generally speaking jazzed to work on this + looking forward to seeing it
come together.

@chad

chad commented Jun 30, 2026

Copy link
Copy Markdown
Author

RFC v0.2: freeq.at/act — stateful, signed, addressed actions for IRCv3

(with handoff as the first action kind)

Status: draft / request for comments · Author: Chad Fowler (freeq) · Audience: agent-coordination builders, IRCv3, AT Protocol

This is a casual RFC. Poke holes in it.

What changed since v0.1 (thanks to review feedback): the primitive is no longer a bespoke "handoff inbox." It's a small stateful-action substrate — handoff is its first verb-set, with approvals, capability-grants, votes, etc. as future kinds on the same rails. There's no new store: actions ride the message/DM/channel layer freeq already has. The signing claims are corrected — handoffs need a new canonical (the existing one assumes a message body and a wall-clock timestamp), and cross-server non-repudiation is called out honestly as not yet real.


TL;DR

A typed, addressed, signed, stateful message: an action with a lifecycle (offer → accept/decline → progress → complete/fail/cancel), distinguished from chat by an act kind tag and correlated by a ULID. Its state is validated server-side and materialized into a queryable view; the signed message log stays the source of truth.

handoff — transferring a unit of work that survives the recipient being offline — is the first kind. The same substrate carries approval, grant, and friends later. If those reuse it without reinventing, the shape is right.

Motivation

AI agents call tools fine but coordinate badly across time: when an agent goes offline, in-flight work and context evaporate. The common answer (e.g. AIRC) is a separate HTTP registry + inbox just for agents.

freeq already has the hard parts of that — DID identity, per-message signing, msgid ULIDs, replay-on-connect (CHATHISTORY / DM history), server-to-server federation. So the missing piece isn't infrastructure; it's semantics on top of the existing message layer. Model it natively as an IRCv3 client-tag extension and you get durable agent coordination and the ability to escalate an action into a live channel or voice room when async needs to become a conversation — something a pure HTTP inbox can't do.

The reframing: an action substrate, not a handoff inbox

Once a handoff is a typed, signed, stateful action on a message, it stops looking unique. Reactions/edits/deletes/pins/replies are already "actions on a message." Approvals (approve/deny a deploy), capability grants (grant/pause/revoke), votes, acks, attestations are all offer→resolve state machines. They all want the same three things:

  1. a verb-tagged typed message,
  2. a transition validator (who may move it to which state),
  3. a materialized view of current state.

So the wire uses generic act-* tags with the kind as a value. handoff is one kind:

@+freeq.at/act=handoff;+freeq.at/act-verb=offer;+freeq.at/act-id=01JABC…;
 +freeq.at/act-to=did:plc:scholar;+freeq.at/act-title=Cite 3 sources on X;
 +freeq.at/act-ctx=freeq:blob/cap/abc;+freeq.at/act-ctx-h=sha256:9f…;
 +freeq.at/act-caps=freeq.at/web-search;+freeq.at/act-deadline=1788000000;
 +freeq.at/sig=ed25519:… TAGMSG #ops

…and a deploy approval is the same substrate, different kind:

@+freeq.at/act=approval;+freeq.at/act-verb=request;+freeq.at/act-id=01KDEF…;
 +freeq.at/act-to=did:plc:opslead;+freeq.at/act-title=Deploy factory-bot v12;
 +freeq.at/act-ctx-h=sha256:1a…;+freeq.at/sig=… TAGMSG #ops

Same act-id correlation key, same act-ref to link replies, same validator mechanics, same view, same REST shape. The kind is a row in a registry, not a subsystem.

Build discipline: implement handoff concretely and factor the substrate out from it — do not design an abstract framework first (that way lies the over-engineered version). Acceptance test: when approval/grant land — and they will — do they reuse this or reinvent it? Reuse obvious ⇒ shape is right. "handoff" welded into storage/wire ⇒ it isn't.

Important caveat on generality: the substrate generalizes the plumbing (wire, validator mechanics, view, REST), not the policy. Each kind must ship its own transition table + authorization rules as a first-class artifact — those differ per kind and are the actual hard design. "handoff is just a verb-set" is true for the plumbing and undersells the policy.

Two orthogonal axes

DM-vs-channel conflates two independent knobs. Keep them separate:

  • Assignmentwho does it.
    • directed: act-to=did:plc:bob → starts assigned to Bob.
    • open / claimable: act-to=#swarm + act-caps=… → starts unassigned; any capable agent claims it; first valid claim wins.
  • Visibilitywhere the event is posted.
    • channel: visible to the room, logged in channel history.
    • DM: private to two DIDs.

These compose. A directed action can still be posted in-channel (act-to=<did> on a TAGMSG #ops) so it's assigned to one agent but everyone watches it happen.

Channel is the default for multi-agent, because it gives observability + logging for free (channel history already persists the whole offer→complete stream), enables an orchestrator agent to watch/reassign/escalate live, enables claimable work queues, and sidesteps E2EE entirely (channel act-* tags are server-visible, so the validator/view just work). DM is the private-directed special case.

Lifecycle & the transition validator

handoff verb-set and its rules:

verb who may send precondition
offer anyone mints act-id
accept the addressed DID (directed) state = offered, before deadline
claim any DID matching act-caps (open) state = open; first valid wins
decline the addressed DID state = offered
progress the assignee state = assigned
complete the assignee state = assigned
fail the assignee state = assigned
cancel the offerer state = offered/assigned, before complete

The validator, on each incoming event: look up prior events for act-id, check the verb is a legal transition and the sender is authorized, then store + route it like any message. Reject otherwise.

Claim semantics (open/claimable)

claim is just a verb with one extra rule: first valid claim wins, atomically. The action's home server is the single authority — it flips open → assigned(did) on the view and rejects later claims. Locally this is straightforward; across federation it requires the home server as the serialization point, which depends on the trust/sig work below. So: local claimable first; cross-server claimable only after federated sig + key distribution land.

Storage & delivery: ride what already exists

There is no new inbox/store. Delivery and durability come from the message layer freeq already has:

  • A channel action is in channel history; replayed via CHATHISTORY on reconnect.
  • A directed action rides the DM store (keyed by canonical_dm_key), replayed on reconnect.
  • An open action lives in the target channel's history, claimable while non-terminal.

Net-new code is two small things, identical whether it's handoff/approval/grant:

  1. A transition validator (above).
  2. A materialized view — a read-side index (act-id → latest state, assignee, caps, deadline) so you can answer "actions assigned to me" / "open actions I can claim" without scanning the log. The signed message log is the source of truth; the view is rebuildable from it and never authoritative.

E2EE note: for DM-delivered actions, the actionable state lives in the cleartext act-* tags (which the server validates and indexes); only freeform progress prose would sit in an encrypted body. Channel actions are server-visible by definition. So validation never depends on reading an encrypted payload.

Context (act-ctx / act-ctx-h)

The real axis is not payload size — it's whether the bytes live somewhere freeq commits to keeping. A signed action that points at a rotted URL has lost the auditability the signature was for.

  • Default: freeq-hosted context (capability URL), lifecycle tied to the action's retention. Only setup where the audit guarantee holds.
  • External refs (gist, S3, an AT-Proto record on another PDS) are allowed but explicitly best-effort: ref dies, guarantee dies — caller's call.
  • The signature always covers a content hash (act-ctx-h), so whatever you fetch later is checkable against what was signed — tamper-evidence wherever the bytes live, and the only integrity check you get at all for external refs.
  • Tiny payloads may be inlined as a convenience; that's not a durability story, just an optimization.

Signing & canonicalization

⚠️ This does not reuse freeq's current PRIVMSG signing. Today's canonical is {sender_did}\0{target}\0{text}\0{timestamp} with timestamp minted at send and never stored — it assumes a message body and a wall-clock field, neither of which a body-less TAGMSG action has. So this is a new signing model, deliberately designed to survive federation:

  • Canonical: deterministic JSON (JCS / RFC 8785) over an explicit, fixed field set: act, act-verb, act-id, act-from, act-to, act-title, act-ctx-h (the hash, not raw context), act-caps, act-deadline, act-ref.
  • Sign over the ULID (act-id), not a wall-clock timestamp. PRIVMSG sigs die across S2S because the receiver re-mints timestamp. A ULID embeds its own creation time, is immutable, and already travels as a first-class tag — signing over it kills the regenerated-timestamp failure mode entirely. (Structural advantage these actions have that PRIVMSG didn't.)
  • S2S relays the signed tags verbatim (act-from, act-id, sig, plus the canonical fields) and the receiver rebuilds the canonical from them — never re-mints. Since DID and ULID are both already tags, this is far more achievable than retrofitting PRIVMSG.

Trust & non-repudiation — today vs goal

Stated plainly so nobody over-reads the guarantee:

  • Canonicalization makes the signature reconstructable, not trustless.
  • The DID↔signing-key binding is unattested today. MSGSIG registers a bare ed25519 pubkey, and the server is the one publishing per-DID keys (/api/v1/signing-keys/{did} is local, server-controlled). A malicious server could publish its own key as yours and forge.
  • Net: non-repudiation holds against an honest origin server, not a malicious one — until key distribution is server-independent.
  • Goal / path to real E2E non-repudiation: anchor the signing key in the DID document (attest the ed25519 key via the AT-Proto identity — did:plc/did:web), so any party verifies the key independently of the freeq server. This is the same root-of-trust gap the broader "identity = DID, never the server's say-so" work cares about. It's a prerequisite for trustworthy cross-server claimable queues.

This RFC specifies the wire/validator/view; it flags the trust gap and does not pretend to close it.

Capabilities (act-caps)

Freeform, and the server never interprets them (it can't verify an agent really does web-search anyway). Caps are a self-declared hint for the recipient/router/claimer to self-select — store, filter, route, never interpret. Fuzzy/semantic matching belongs in the agents.

  • No protocol-baked capability registry (it'd be stale in months and a governance chore).
  • The one convention worth fixing now is namespacing — reverse-DNS / AT-style (freeq.at/web-search) — with meanings converging socially. Reserve well-known names later if needed; starting loose costs nothing.

Liveness, backpressure, retention

Modeling actions as messages in the existing store dissolves most of this:

  • Flooding — offers are messages, already under freeq's flood throttle + per-IP/connection limits. No new quota machinery.
  • Storage growth — same message/DM/channel store under existing retention. The view stays small by construction (indexes only non-terminal actions) and is rebuildable.
  • The one genuinely new policy is liveness, not storage: an action stuck in accepted/progress that never reaches a terminal state. act-deadline covers offer expiry; nothing clocks an abandoned in-progress task. So a small sweep auto-expires non-terminal actions past a TTL (mark fail/expired), acting on the view, not storage.

Federation

Action events propagate over S2S like any tagged message, preserving act-id, the canonical fields, and sig. A directed action to a DID on a remote server routes to that server's delivery; the home server owns delivery, replay, and (for open actions) claim serialization. Receivers rebuild and check the canonical from the relayed tags (see Signing). Cross-server claimable waits on the trust work.

REST query interface (over the view)

A query surface over the materialized view — not a parallel table that owns data — so non-IRC agents and interop bridges can use it:

  • GET /api/v1/actions?kind=&to=&state=&caps= — my inbox / claimable queue
  • GET /api/v1/actions/{act-id} — current state + context ref + event log
  • POST /api/v1/actions — emit an offer/request
  • POST /api/v1/actions/{act-id}/{verb} — a transition

This shape maps cleanly onto AIRC-style POST /messages + payloads, so an interop bridge is a thin adapter.

Orchestration pattern (why channel-default matters)

Put a supervisor/orchestrator agent in the channel. It watches the live act-* event stream and can reassign a stalled task, enforce deadlines, fan work out, or escalate an open queue. The channel is the coordination bus; handoffs become an observable, logged, reassignable stream rather than point-to-point messages. CHATHISTORY gives you the audit log for free.

What's actually new to build

  1. The act-* tag set + freeq.at/act CAP + TAGMSG handling.
  2. A transition validator (per-kind transition table + authz).
  3. A materialized view + the REST query interface + reconnect replay (reusing CHATHISTORY/DM replay).
  4. A liveness sweep for non-terminal actions past TTL.
  5. The new canonical + sign-over-ULID signing path, and S2S relaying the signed tags verbatim.

Everything else (delivery, durability, identity, msgid, flood limits, federation transport) is reuse.

Non-goals

  • Not a workflow engine / DAG executor — it's a transfer + state primitive; orchestration lives above it.
  • Not a replacement for chat — actions are tracked units, not conversation.
  • Not re-doing identity — it rides whatever identity the server already verifies (AT-Proto DIDs).
  • Not (yet) solving server-independent key distribution — flagged, not closed.

Open questions

  • Substrate now, or handoff-first then factor? (Hunch: handoff-first, factor out — but get the act-* shape right so approvals/grants reuse it.)
  • Per-kind authz spec format — how do we declare each kind's transition table + rules so it's reviewable and not ad-hoc?
  • Claim fairness beyond first-wins — bidding, priority, capability scoring? Or keep dumb and let orchestrators decide?
  • External context refs — allow AT-Proto records as a first-class (best-effort) ref type, or discourage entirely?
  • Canonical field set — is the list above complete? Versioning the canonical.
  • WG venue — keep +freeq.at/* until the trust pieces are solid, then pitch IRCv3 WG? (Design the wire to be de-vendorable now regardless.)

Feedback welcome — comment on the gist, or find me on freeq (irc.freeq.at) / Bluesky.

@zapnap

zapnap commented Jul 1, 2026

Copy link
Copy Markdown

v0.2 looks great. Thanks for taking all that feedback into consideration! Two small points and two bigger ones that will probably need resolution:

1. Drop "private to two DIDs" claim The server has to read act-* tags to validate transitions, so in every mode it sees the participants, title, deadline, and full state timeline — and usually the context too, if freeq-hosted. E2EE only ever covers the freeform body. So DM actions are direct, not private; I'd say that as plainly as the trust section says its thing. One nuance worth keeping: the validator only needs id/verb/DIDs/deadline — never the title or context — so an encrypted-content mode is possible later with no wire change. Existence, participants, and timeline are the part that can never be hidden. (Also, DM prekeys are served by the server, so E2EE DMs have the same honest-server trust bound as MSGSIG — same root-of-trust gap, one more consumer.)

2. "Home server" doesn't exist for channels. Directed/DM actions have one; a claimable action in a federated channel doesn't — channel state is symmetric peer-merge, so two peers can each award the claim locally and the task runs twice. Proposal: the server that minted the act-id owns claim serialization for that action. Deterministic from the wire, no new channel-ownership concept, and if that server is unreachable claims stall rather than fork — the right failure for first-wins.

3. Sig verification is net-new — and per-session keys are the real problem. Nothing today verifies a relayed sig; S2S passes sig through unchecked. The check itself belongs inside the validator (first rule it evaluates, bad sig = event rejected), and that part is straightforward. The hard part is the key: clients mint a fresh ed25519 keypair every session, and signing_keys overwrites on re-register — so the moment a signer reconnects, every sig from prior sessions is unverifiable. Chat survives that; handoffs are specifically long-lived, so the normal case is "verify an offer signed in a session that has since ended," which the current key model can't do at all. Verification needs two things: the exact bytes that were signed, and the key to check them against. Your sign-over-ULID change fixed the first — the signed fields all travel as tags, so anyone can rebuild the bytes later. But nothing fixed the second: the key that could check them is gone after one reconnect. And fixing it has two parts, because a verifier needs to answer both "does the key still exist?" and "who do I ask for it?". A key-id in the sig tag + an append-only key store (instead of overwrite) answers the first. For the second, the interim answer is the event's origin server — session keys are registered where the session lived, and every relayed event already names its origin, so the lookup is (origin, DID, key-id). No new "home server" concept needed. But be clear what that costs: verification is then hostage to the origin being reachable (server dies → its history is unverifiable forever) and honest (the same honest-origin trust bound you already call out). Which is why the DID-doc anchoring you name as the goal isn't just the trust upgrade — it's the real answer to "who do I ask": a DID document resolves from the DID alone through AT Proto's existing infrastructure, no freeq server in the loop. I'd put the key-id in the sig format now so the wire doesn't need revising as the key authority moves.

4. Directed delivery has to be DID-addressed all the way down — and today the DM layer isn't. The RFC addresses handoffs to DIDs, which is right. But if directed acts ride the DM store, look at what that layer actually does across federation: PRIVMSG targets are nicks only, a DM to a non-local nick is broadcast to all peers (routing.rs), and each receiving server independently decides who that nick is from its own records. No step anywhere agrees on the recipient's identity — under a nick collision two servers can deliver to two different people, and the sender's server never learns which DID (if any) received it, which is also why it can't persist its own copy of the conversation. A cross-server nick DM has no well-defined recipient, full stop. Acts must not inherit that: delivery, persistence, and validation should key off the act-to DID end-to-end, with the nick target as display only. Implementation-wise that's two pieces:

  • DID-addressed traffic is the easy case — a DID is globally unique, so every server applies the same rule: deliver to local sessions bound to that DID (did_sessions already exists) and relay to peers, who do the same — so a multi-homed DID gets it on every device, deduped by msgid. No interpretation, no collision. Allowing DID targets at the wire level (PRIVMSG did:plc:x / TAGMSG likewise — CHATHISTORY already accepts did: targets) gives agents this path directly and they never touch nicks at all.
  • Nick-addressed DMs: resolve once at the sender, carry the DID on the wire — the sender's server resolves nick→DID at send time and stamps the resolved recipient DID on the S2S payload; receivers deliver to that DID instead of re-interpreting the nick. One resolution, authoritative, carried on the wire — delivery and the sender's own persisted copy can't diverge, because they're the same answer. The stamp is best-effort: when it's absent (legacy peer, or the nick doesn't resolve — e.g. a guest), receivers fall back to today's nick interpretation. That's fine — a guest has no durable identity to persist under anyway, and act participants are DIDs by definition, so acts never hit this case.

Either way the addressing model should be decided in the spec, not discovered in the implementation.

1 is just wording; 2–4 are real design decisions worth settling before code. Nothing here breaks the core shape though. LFG

@zapnap

zapnap commented Jul 2, 2026

Copy link
Copy Markdown

Took a pass at a v0.3 folding in the thread above — pushed it as a fork so it shows a clean diff off your v0.2: https://gist.github.com/zapnap/4cadada009a23cb11203b66d23278447

Most of it firms up the four things we'd been circling: DID-native addressing, claim authority on the act-id-minting server, the signing-key durability/lookup gap, and retiring "private" for direct actions.

One thing that wasn't in my earlier comment and is worth your eye: it argues the act signing model should be freeq's single signing model — today's PRIVMSG signing breaks across federation the same way — and proposes migrating all message signing onto this canonical as follow-on work (out of scope for the RFC itself, but flagged so it doesn't leave two signing paths forever).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment