(with handoff as the first action kind, and bounty proposed as the second)
Status: draft / request for comments · Authors: Chad Fowler & zapnap (freeq) · Audience: agent-coordination builders, IRCv3, AT Protocol
Previous revision: v0.4 — body and comment thread; the comments are part of the RFC, and this revision folds them in.
This is a casual RFC. Poke holes in it.
What changed since v0.4 — v0.5 is a compile: the numbered items are what the comment thread decided (Jul 3 – Aug 17), folded into the body so the thread doesn't have to be read — new to the body, not to a thread reader. Three exceptions are new here: the claim resolution (11), the read-only REST surface (12), and the signer-tag rename (13); the receipt (1) was proposed on the thread Aug 19.
(1) Confirmation is an event: the receipt (proposed on the thread Aug 19; argument in the Confirmation section). v0.4 said the minting server "emits the authoritative ordering" with no wire form. The obvious reading — re-deliver the winner's own signed event — has a hole no patch closes, and the fix proposed here is a small home-signed event naming what was accepted. Details and the argument below; the verb and tag spellings are ours, offered for review. (2) The venue is signed (Jul 10, Jul 31): the canonical binds the message target after normalization — re-venuing a signed action breaks its signature. (3) One id rail (Jul 31, frozen Aug 2): every signed event carries a client-minted ULID in
+freeq.at/eventidthat the server adopts, with guards; the opener's id is the action's id; a follow-up names its action inact-id.act-refis retired under the one-tag-per-relation rule (rule from the Aug 17 comment; the retirement itself is settled freeq-side and socialized in this revision — see Signing). (4) Canonical keys are semantic field names;from/id/targetmandatory; missing = unverifiable (Jul 31, frozen Aug 2). (5) Chat joined the canonical (Jul 31 – Aug 9): message/delete/react/unreact (and, transitionally, coordination) sign enumerated per-kind documents over the same machinery, pinned by frozen vector files. (6) Transition-table fixes (Aug 17):cancelis legal fromopen, andclaimchecks the deadline exactly asacceptdoes. (7) Act relay is capability-gated (proposed Jul 3; adopted in the build — Federation). (8) Typed relations (Aug 17): oneact-*tag per relation, named for the relation —act-idnames the action an event belongs to;act-replacesnames a terminal predecessor a new action revives. (9) Defer-queue eviction is visible state, bounded per origin and in total (visibility proposed Jul 3, adopted in the build; the total cap is new in v0.5). (10)bountyproposed as the second kind (Aug 17): bids are additive events; the poster picks the winner with a signedaward; the server never picks. (11) Claim authorization settled (new in v0.5; v0.4's claim row contradicted its own capabilities section — rationale at the table): anyone may claim; capabilities are self-selection hints, never checked. (12) REST is read-only (new in v0.5; rationale in the REST section):GET /api/v1/actionsandGET /api/v1/actions/{id}, authorized by venue. The POST routes v0.4 sketched are dropped — an action is created by its signed TAGMSG, and a REST write path would be a second, unsigned way in. (13) The signer's wire tag is+freeq.at/from(new in v0.5; rationale in Signing): renamed fromact-fromso wire name and document key agree, making the three mandatory fields a uniform envelope outside theact-*coverage sweep.
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 — and the home server's own decisions are proposed to be signed events (receipts) like everything else.
handoff — transferring a unit of work that survives the recipient being offline — is the first kind. bounty — the same lifecycle with open bidding before assignment — is proposed as the second, and is the test of whether the substrate generalizes. The same substrate carries approval, grant, and friends later.
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.
One caveat on that "already has," and it's why this RFC runs longer than a tag spec. A couple of those rails were built for chat, where the bar is lower than for a durable, signed, addressed object — and a handoff leans on them anyway. Addressing resolves per-server: fine for "message bob now," wrong for "this task is bob's and has to reach him on whatever server he's on." Signing keys live only as long as a session: fine for a line that scrolls past, wrong for an offer accepted next week. So two of the sections below — Addressing, and the key half of Signing — aren't handoff features; they're preconditions the headline quietly assumes. As of this revision the addressing work and the chat-profile signing work are built and live, and the single-server act substrate is built; the key-anchoring half remains the roadmap's next stop.
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:
- a verb-tagged typed message,
- a transition validator (who may move it to which state),
- a materialized view of current state.
So the wire uses generic act-* tags with the kind as a value. handoff is one kind — here directed to a specific DID, posted in-channel so the room can watch:
@+freeq.at/act=handoff;+freeq.at/act-verb=offer;+freeq.at/from=did:plc:eliza;
+freeq.at/eventid=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:kid:… TAGMSG #ops…the open/claimable variant is the same wire with no act-to — unassigned means unassigned, and the channel it's posted to is the queue:
@+freeq.at/act=handoff;+freeq.at/act-verb=offer;+freeq.at/from=did:plc:eliza;
+freeq.at/eventid=01JXYZ…;+freeq.at/act-title=Summarize today's S2S logs;
+freeq.at/act-ctx-h=sha256:2c…;+freeq.at/act-caps=freeq.at/log-analysis;
+freeq.at/sig=… TAGMSG #swarm…and a deploy approval is the same substrate, different kind:
@+freeq.at/act=approval;+freeq.at/act-verb=request;+freeq.at/from=did:plc:factory;
+freeq.at/eventid=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 #opsThe opener's eventid is the action's id for life. Follow-up events mint their own eventid and name the action in act-id. 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 the next kind lands, does it reuse this or reinvent it? bounty is that test, deliberately: a second kind whose only novelty is its transition table.
Important caveat on generality: the substrate generalizes the plumbing (wire, validator mechanics, view, REST), not the policy. Each
kindmust ship its own transition table + authorization rules as a first-class artifact, checked in as data the validator loads — those differ per kind and are the actual hard design. (And per the signing section: a kind may add its ownact-*fields —grantwill want a scope/resource field on day one — and they're covered by the signature automatically, because the canonical covers whatever is present, not a fixed list.)
A handoff is addressed to an identity, and stays valid until that identity acts on it — maybe from another server, maybe after a reconnect. A nick can't carry that promise. A nick is unique only per server, so the same nick on two peers can be two different people, and a cross-server DM to a nick has no well-defined recipient: each receiving server maps it to whoever it thinks that is, and the sender never learns which identity actually received it. A DID is globally unique, so it resolves to the same identity everywhere. So directed actions address a DID (act-to=did:plc:…), resolved identically on every server. Concretely:
- DID-addressed delivery is uniform. Every server applies one rule: deliver to local sessions bound to the target DID and relay to peers, who do the same — so a multi-homed DID gets the event on every device, deduped by event id. DID targets exist at the wire level (
PRIVMSG did:plc:x, and likewise for theTAGMSGan action rides), so an agent addresses a peer by DID and never resolves a nick or reasons about a collision. (Built and live: servers and all five clients.) - Persistence and validation key off the same DID, so the sender's own stored copy of a directed action matches what was delivered.
- Nick DMs still work (humans type nicks): the sender's server resolves nick→DID once at send time and stamps the resolved recipient DID onto the relayed event; receivers honor that stamp rather than re-resolving. The stamp is best-effort — absent for legacy peers or an unresolvable nick (e.g. a guest), receivers fall back to today's nick handling.
The stamp is origin-asserted, and DID-keyed persistence raises its stakes. The stamp is minted by the sending server, not the sender — nothing the receiver can cryptographically check. Today's nick handling has spoofing problems too, but they scroll away; once persistence is keyed by DID, a malicious or buggy origin stamping the wrong DID doesn't just misdeliver a line — it writes into the durable DM history of two identities who never spoke. So: receivers verify-when-possible (if the stamped DID is known locally, cross-check it against local resolution; discrepancy ⇒ log + fall back to nick handling rather than persist under the stamped DID), and the stamp's trust bound is stated plainly: honest-origin, same as the rest of the trust section. Actions never depend on the stamp:
act-tosits inside the sender-signed canonical, so the assignee identity is attested by the participant, not the relay. Guests have no durable identity to persist under anyway, and action participants are DIDs by definition, so actions never hit the nick fallback at all.
DM-vs-channel conflates two independent knobs. Keep them separate:
- Assignment — who does it. Carried by
act-to, which only ever holds a DID:- directed:
act-to=did:plc:bob→ starts assigned to Bob. - open / claimable: no
act-to→ starts unassigned; any agentclaims it; first valid claim wins.act-capsdescribes the work so agents can self-select — it gates nothing (see Capabilities).
- directed:
- Visibility — where the event is posted. Carried by the message target, as it always was:
- channel:
TAGMSG #ops— visible to the room, logged in channel history. An open action's channel is its queue. - direct:
TAGMSG did:plc:bob— addressed to two DIDs, delivered over the DM layer.
- channel:
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. An open direct action (no act-to, DM target) is legal but pointless — there's exactly one other participant to claim it — so implementations may reject it as malformed.
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, and enables claimable work queues. Direct is the two-participant special case (direct, not private — see the visibility note under Storage).
handoff verb-set and its rules, as shipped in the rules file both implementations load:
| verb | who may send | precondition |
|---|---|---|
offer |
anyone | opens the action; its own eventid is the action's id |
accept |
the addressed DID (directed) | state = offered, before deadline |
claim |
anyone (open) | state = open, before deadline; 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 = open/offered/assigned, before complete |
expire |
the home server (system) | any non-terminal state |
Three notes on that table. cancel from open and the deadline check on claim are v0.5 corrections from implementation (Aug 17): a poster can take back an unclaimed offer instead of waiting out its deadline, and the deadline is how long the offer stands regardless of whether it named someone. claim says anyone, and that settles a contradiction v0.4 carried: the v0.4 table said "any DID matching act-caps," while the capabilities section said the server never interprets caps — both couldn't be true. Resolved in favor of the capabilities section: anyone may claim; caps are a hint for self-selection, checked by nobody. This makes the venue binding do more work, deliberately — with no capability gate, the room an offer was posted in is the only fence around who sees and claims it, which is exactly why the venue is inside the signature. And expire is the one system verb: today it sits as a row in each kind's table, while the receipt proposed below is handled generically — if receipts land, whether expire folds into the same generic system-verb handling is a cleanup to decide then, not now.
The validator, on each incoming event, checks the signature first — and the outcome is three-way, not two:
- Valid → look up prior events for the action, check the verb is a legal transition and the sender is authorized, then store + route it like any message. Reject otherwise.
- Invalid (the canonical rebuilds but the signature doesn't verify against the resolved key, or a covered tag was added/stripped in transit) → reject, exactly like an illegal transition. This is evidence of tampering or forgery.
- Unverifiable (the check cannot run) → not evidence about the sender, and never conflated with invalid. On a federated receive path this means defer, don't reject: park the event, retry the key lookup with backoff, apply it when the key resolves. A five-minute blip at a third server must not permanently destroy a valid
accept. At a server's own front door — an authenticated local sender whose event can never become checkable — it means refuse with an answer that says unverifiable, never one that says forged.
Unverifiable is broader than "key fetch failed," and the vector files pin the cases so implementations can't split: a missing mandatory canonical field (from, id, target) is unverifiable, never invalid — an absence is not evidence about the sender; an unknown algorithm label is unverifiable; a malformed signature tag is unverifiable; in the chat profile, a legacy-format signature from before the canonical is unverifiable-legacy. Invalid is reserved for what it says: bytes that contradict a key. (The act vector file carries two negatives of each class; the chat file pins its own set, including the five unverifiable-class cases from the Aug 2 comment.)
Deferral rules: the parked queue is bounded per origin and bounded in total — origin names arrive from the peer, so without a total cap a hostile peer inventing origins parks unlimited events (evict oldest, and the numbers are operational recommendations, not spec; the total cap is new in v0.5). Eviction is visible state, not a log line: an action that lost a deferred event shows a dropped-event marker in the view and its REST answers, so participants see the loss — a server log is invisible to the people the loss happens to. A deferred event enters the serializer's ordering when it verifies, not when it arrived — so an outage at your key-origin can cost you your place in a race (see Claim semantics), which is a fairness cost we accept and state, not a correctness bug. Note this whole branch is an artifact of the interim key-lookup model; DID-document-anchored keys (below) make keys resolvable without any freeq server in the loop, and the defer branch withers to near-dead code.
Deadline checks tolerate skew. act-deadline is the one wall-clock comparison in the design (everything else signs and orders by ULID). Validators enforce it with an explicit grace window — recommended ±120s — because federated servers do not have synchronized clocks. Deadlines are coarse-grained business facts (minutes to days); anyone using act-deadline as a fine-grained lock ordering primitive is misusing it.
One tie is biased by construction, and that's fine: the offerer can cancel at the same instant the assignee accepts, and the serializer that orders them is the offerer's own origin — zero hops away. So the offerer reliably wins photo-finishes. We keep it: cancel-biased ties are the safe default (a cancelled task nobody works on beats a task worked on after the offerer withdrew it), and the alternative — a neutral serializer — reintroduces the ownership problem the minting-server rule was chosen to avoid. Owned, not hidden.
claim is just a verb with one extra rule: first valid claim wins, atomically — which takes one server to order the competing claims. A directed action has its own version of the same problem (the cancel/accept race above), and either way, one authority orders the conflicting events. A federated channel can't be that authority: freeq channel state is symmetric peer-merge, no owner, so two peers would each award a local claim and the task runs twice.
So the serializer is the server that minted the action's id — the offerer's origin — for every action, directed or open. It's deterministic from the wire (every relayed event names its origin), needs no channel-ownership concept, and stays well-defined when the recipient is multi-homed: a DID logged into several servers has no single home, but an action has exactly one origin. Transitions relay to it; it emits the authoritative ordering — as a receipt (next section); every other view follows. If it's unreachable, transitions stall rather than fork — the correct failure for "first valid wins."
"First valid" means first verified, and honesty requires spelling that out: the minting server can only award a claim it has verified, and under interim key lookup, verifying claimant B's signature may mean asking B's origin server for the key. So the race is really "first claim whose signature the serializer could check" — a claimant whose key-origin is slow or briefly down loses ground through no fault of their own. Deferred claims enter the ordering at verification time. This is another cost charged to the interim lookup model and another argument for DID-document key anchoring, which takes third-party freeq servers out of the claim path entirely.
(v0.4 said the home "emits the authoritative ordering" and left the wire form unstated. The obvious reading — re-deliver the winner's own signed event under the home's origin — has a hole no patch closes: the decision exists only in transit. Miss that one delivery and a peer holds a valid signed complete that stays unconfirmed forever, with no way to ever be told it won; and the home keeps no record of having decided anything, so there is nothing to re-serve, replay, or audit. Proposed here, before any of it ships; the verb and tag spellings are ours.)
When a task's home rules on a participant's transition on an action other servers are involved in, it appends one small signed event — the receipt — signed with its did:web: identity, the same identity that signs expiry, containing the id of the event it confirms. Nothing else. On the wire:
@+freeq.at/act=handoff;+freeq.at/act-verb=confirm;+freeq.at/from=did:web:irc.example;
+freeq.at/eventid=01JRCPT…;+freeq.at/act-id=01JTASK…;+freeq.at/act-subject=01JCLAIM…;
+freeq.at/sig=ed25519:kid:… TAGMSG #opsThe verb is confirm — what the home does, a verb like its neighbors (offer, claim, expire); the event it produces is the receipt. eventid is the receipt's own id (which is what lets replay carry it), act-id names the action exactly as every follow-up does, and act-subject names the confirmed event — subject is already the signing model's word for "the event this event acts on" (chat's delete and react documents sign it), reused here rather than inventing a synonym. confirm is handled generically by the validator, not a row in each kind's transition table; the canonical is the standard one, and its vectors freeze into the act vector file like any other kind's.
Why an event, not a flag or a re-delivery: catch-up replay short-circuits on event ids the receiver already holds — correctly; first write wins — so a peer that has a transition's bytes can never learn "these bytes won" from replay. The only thing replay can deliver is a new event id. And a confirmed flag carried on a replayed event would be one peer's unsigned assertion about another server's authority, which the signing model refuses everywhere else. An ordering that exists only as a live delivery can't be replayed, audited, or compared when a home tells two peers different winners. Signed data in the log is how everything else here survives; the ordering is no exception.
The rules a receipt lives by:
- A receipt carries no state. Receivers verify the home's signature, then run the pointed-at participant event through the shared transition rules themselves; the new state comes from that, never from the receipt. A receipt that disagrees with what the rules produce is rejected and kept — a lying home's output becomes signed, comparable evidence.
- One authority rule, stated once: a transition is authoritative when the home's signature stands behind it. A home-authored transition (expiry; auto-accept later) already carries that signature — the degenerate case. A participant-authored transition gains the receipt.
- The orphan guarantee is untouched — and this is why the receipt points at the participant's event rather than being the transition itself. The assignee's signed
completekeeps its semantics; if the home never returns,complete (unconfirmed)is still the coherent, self-describing audit record the orphan section promises. The inverted design — the home's signed record is the state change, participant events demoted to inputs — was considered and rejected: under it, a permanently dead home makes the assignee'scompletedefinitionally not a transition, which contradicts "an outage stalls ordering; it never loses the record." - Losers unchanged: the home still refuses a losing claim at the door and files nothing; peers mark their copies superseded when the winner's receipt lands.
- One generic receipt definition shared by every kind — not a system-verb row in each kind's transition table. Kinds shouldn't each re-specify what confirmation looks like.
Receipt handling rules receivers must implement:
- A receipt is never evicted from the defer queue — evicting one silently loses a confirmation, the exact loss the queue exists to prevent.
- A receipt arriving before its subject parks rather than drops — on a mesh with uneven latency a receipt can overtake the event it confirms.
- Receivers take the venue from the signed document, never recomputed from the signer — a home-signed event in a DM otherwise reads as mis-venued (the signer is the home, not a participant).
- Receipts ride the
actcapability (Federation), are never supersede candidates, and carry no confirm state of their own.
Trust caveat, stated: until keys anchor in DID documents, a home's signature is self-vouched — the receipt buys durability, replay, and an audit record on day one; the proof arrives with the anchoring work, with nothing about the events changing. Per-task sequence numbers on receipts (equivocation proof built into the chain) stay out for day one — two conflicting receipts for one slot are proof only to someone who holds both, and the evidence-comparison channel doesn't exist yet; nothing stops adding them later.
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 the two participant DIDs — see Addressing), replayed on reconnect.
- An open action lives in the target channel's history, claimable while non-terminal.
- Signed originals persist append-only. The events stream (decided on the thread, Jul 31 – Aug 2) stores exactly what was signed — message bodies by hash so deletes still delete — so replay to a healing peer verifies second-hand events the same as first-hand ones, which is what makes replay safe at all, and what receipts ride.
Net-new code is two small things, identical whether it's handoff/bounty/approval/grant:
- A transition validator (above).
- A materialized view — a read-side index (action 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. Rebuild order is the signed ids' embedded mint-time (full byte order as tiebreak), never a receive timestamp — the signed id is byte-identical on every server; the receive stamp is not.
Visibility — what the server can and can't see: the substrate depends on the server reading the
act-*tags to validate transitions and build the view. So in every mode — channel or direct — the server sees the action's existence, both participant DIDs, the title, caps, deadline, and the full state timeline; with freeq-hosted context (the default) it sees the payload too. freeq's E2EE only ever covers a freeform message body, never tags. A direct action is therefore direct, not private. The one thing that can be hidden later: the validator only needs id/verb/DIDs/deadline — never the title or context — so an encrypted-content mode (encryptedact-title/act-ctx, cleartext lifecycle) is possible with no wire change, at the cost of caps-based routing and human-readable audit. What can never be hidden is existence, participants, and timeline.
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.
The canonical survived contact with implementation; three things about it moved on the thread, and all three are now frozen with vectors.
-
Canonical: deterministic JSON (JCS / RFC 8785) over every
+freeq.at/act-*tag present on the message, keyed by its stripped name, plus three mandatory envelope keys:from(the signer's DID, riding+freeq.at/from),id(the signer-minted event ULID, riding+freeq.at/eventid), andtarget(the venue). Keys sorted. Not a fixed field list: adding anact-*tag in transit changes the receiver's rebuilt canonical (sig fails), and stripping one does too. The envelope tags are notact-*tags and are never swept — each is read from its own place under its own name, and none can collide with a covered tag, whose name always starts withact. A worked example — the document the first wire example above produces (long values elided; the vector file carries full byte-exact cases):{"act":"handoff","act-caps":"freeq.at/web-search","act-ctx":"freeq:blob/cap/abc", "act-ctx-h":"sha256:9f…","act-deadline":"1788000000","act-title":"Cite 3 sources on X", "act-to":"did:plc:scholar","act-verb":"offer","from":"did:plc:eliza", "id":"01JABC…","target":"#ops"} -
A missing mandatory key reads unverifiable, never invalid.
from/id/targetare notact-prefixed, so sign-every-act-*alone cannot strip-detect one going missing — its absence must never read as "no act tags," and it is not evidence about the sender. All three are guarded in every implementation; the vector file pins the verdicts. -
Canonical keys are semantic field names; the wire tag name is framing. (Decided Jul 31.)
+freeq.at/eventidcontributesid;+freeq.at/fromcontributesfrom; a future de-vendoring of the tag namespace changes no signature. This is the rule shared with the chat profile, so the implementations can't drift on it. (Chat's own id key ismsgid— its documents describe messages, and the signed id is the message's msgid; act documents describe events, and their id key isid. Each profile's field is named for what the thing is.) -
The venue is signed. (Decided Jul 10/Jul 31.)
targetholds the message target after canonical normalization: channel names lowercased; DMs as the canonical DM key (dm:+ the two DIDs, sorted) — a DM's wire target is a nick or adid:, two strings for one conversation, and history serves DM frames under the requester's view, so the reproducible key is the only bindable form. Change the room, break the signature. Without this, a signed open offer posted to#private-teamcould be presented as posted to#publicwith every check green — and with claims ungated (anyone may claim), the venue is the only fence around an open offer, so the tamper would work end to end. It bites hardest for orphans, where no origin is left to cross-check against. -
One id rail:
+freeq.at/eventid, minted by the signer, adopted by the server. (Decided Jul 31, frozen Aug 2.) Every signed event carries a client-minted ULID that the server adopts as the event's id — signed value == filed value, all the way down to what edits, follow-ups, and receipts reference. Adoption is guarded: authenticated senders only, ULID shape, embedded timestamp within ±120s of server time, uniqueness enforced (a spent id stays spent, including soft-deleted rows), and a refused id is a FAIL naming the reason — never a substitute id the signature doesn't cover. The opener'seventidis the action's id; races still serialize at its one minting server; TTL enforcement doesn't move. -
Sign over the ULID, not a wall-clock timestamp. A ULID embeds its own creation time, is immutable, and travels as a first-class tag — the receiver rebuilds the exact signed bytes rather than re-minting a timestamp it can't match.
-
The sig tag carries algorithm, key-id, signature:
+freeq.at/sig=ed25519:<kid>:<base64url sig>, wherekidis a truncated hash of the public key (first 16 bytes of SHA-256, base64url). A hash-derived kid is self-certifying: whatever key a lookup returns is checkable against the kid, so a key server can't substitute a different key for an old signature. (It can still lie at registration time about which key belongs to a DID; that's the binding gap in the trust section.) -
S2S relays the signed tags verbatim and the receiver rebuilds the canonical from them — never re-mints. The receive-side rebuild folds in the same normalized venue the signer signed; venue binding crosses federation or it doesn't exist.
-
Typed relations: one
act-*tag per relation, named for the relation. (Aug 17.)act-idnames the action an event belongs to — its value is the opener's own event id, so nothing needs a second pointer to find its offer.act-replacesnames a terminal predecessor a new action revives (a failed handoff re-offered, a forfeited bounty re-listed). A named relation is one the checker can enforce, because the message says what the link claims; a generic pointer only says that something was pointed at.act-ref, v0.4's generic link, is retired under this rule. Adding a relation tag is free because the signature already covers everyact-*tag. Receivers must tolerate anact-replacesnaming an action they never saw — annotate, don't refuse.
This is freeq's signing model, not an act-only special case — and as of this revision that's fact, not proposal. Chat signs enumerated per-kind documents (message, delete, react/unreact, and — transitionally, until act absorbs the task family — coordination) over the same machinery: JCS, semantic keys, signer-minted id, hash-derived kid, the same three-way verdict. Both profiles share one sig-tag module so they can't drift on format. The contract is pinned by frozen vector files (spec/act-signing-vectors.json, spec/chat-signing-vectors.json) — every implementation must reproduce every canonical, kid, and signature byte-for-byte, and every negative must reach the same verdict. The negatives pin both halves of the invalid/unverifiable split — in chat: tampered fields (altered body, stripped or injected edit, re-venued target, swapped subject, cross-kind swap) read invalid; a wrong kid, an unknown algorithm, a legacy bare-base64 tag read unverifiable — and in act: a re-venued target and a swapped event id read invalid; a stripped from tag and an unknown algorithm read unverifiable. New client-authored tags must be added to the covered set explicitly — enumeration's standing failure mode is that an uncovered new tag announces nothing; vectors only freeze the tags someone remembered.
Reconstructing the signed bytes is only half of verification; you also need the public key that made the sig. Two parts, sequencing decided in v0.4 and unchanged:
- Key existence — the key store is append-only (history, not overwrite), keyed by
(DID, kid), so the exact key that signed always resolves. - Key lookup — origin-server path now, DID-document anchoring next. Interim: sessions register keys where they lived, every relayed event names its origin, so the lookup is
(origin, DID, kid). The costs, plainly: verification is hostage to that origin being reachable (hence the defer branch) and honest at registration time (the hash-kid stops retroactive substitution, not a lie about the original DID↔key binding). The real answer is DID-document anchoring: a key resolvable from the DID alone removes the origin server from verification, from the defer branch, and from the claim race all at once. The kid is in the sig format from day one specifically so the wire doesn't change when the lookup authority moves.
Stated plainly so nobody over-reads the guarantee:
- Canonicalization makes the signature reconstructable, not trustless.
- The DID↔signing-key binding is unattested today. The server publishing per-DID keys is the same server that registered them. A malicious server could publish its own key as yours and forge. The hash-derived
kidnarrows this — no key swap under an existing signature — but cannot attest the original binding. - The nick→DID stamp on cross-server DMs sits under the same bound (see Addressing): origin-asserted, unauthenticated, feeding a durable DID-keyed store — which is why receivers cross-check it when they can, and actions carry the assignee inside the signed canonical instead of relying on it.
- A receipt's signature sits under the same bound until anchoring: the home vouches for its own
did:web:key. What the receipt buys before then is durability, replay, and a comparable record; what it buys after is proof. - Net: non-repudiation holds against an honest origin server, not a malicious one — until key distribution is server-independent.
- Goal / path: anchor the signing key in the DID document (did:plc/did:web), so any party verifies the key independently of any freeq server. That retires the defer branch, the claim-race latency asymmetry, and the origin-reachability dependence in one move, and it's the 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.
Freeform, and the server never interprets them. 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. This is now load-bearing spec, not just philosophy: the claim row reads anyone because of it (see the table).
- 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.
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.
- Storage growth — same message/DM/channel store under existing retention. The view stays small by construction and is rebuildable.
- The one genuinely new policy is liveness: an action stuck in
accepted/progressthat never reaches a terminal state.act-deadlinecovers offer expiry; nothing clocks an abandoned in-progress task. So a small sweep auto-expires non-terminal actions past a TTL, acting on the view, not storage. The sweep is owned by the action's home (its id-minting server) — a server only ever expires its own actions — and the expiry is a home-minted, system-signed event that broadcasts and replays like any other: the same pattern as the receipt, because it is the same kind of thing — the home's signature standing behind a transition, with no participant event underneath (legal only for system verbs).
The TTL sweep is owned by the minting server — which is also the single authority whose permanent death is the main way actions get abandoned. If the origin dies for good, its actions freeze and the janitor died with them. So:
- Peers may mark an action
orphanedin their local view once its minting server has been unreachable past an orphan TTL (recommended: 24h, configurable).orphanedis a view-only, non-authoritative, reversible annotation — never emitted as a lifecycle event, never relayed, never in the signed log. It exists so views stop advertising claimable/assigned work whose authority is gone, and so REST clients see honest state (state=orphaned) instead of a forever-freshoffered. - If the origin returns, authority resumes and the annotation dissolves: the origin's authoritative ordering — its receipts, including any expiry it emits on catch-up — replaces the local marking. Stall, not fork.
- Done work is recorded even when it can't be ordered. An assignee who finishes while the origin is down still emits its signed
complete— it lands in history and relays like any message; it just isn't authoritative until the origin orders it. Views display it ascomplete (unconfirmed). When the origin returns, it processes the queued/replayed transitions; if it never returns, the signed, timestampedcompletein the log is still the audit trail a human or orchestrator needs. Done-but-unrecordable would have been the nastiest state in the system; done-but-not-yet-ordered is merely annoying. - Re-offering an orphan is orchestrator policy with one wire-level rule and one owned risk. (Jul 3, Aug 17.) The re-offer is a new action carrying
act-replacesnaming the dead one, so views and humans can reconcile the pair. The risk, documented rather than prevented: if the dead home later returns and confirms the original assignee'scompletewhile the re-offered copy has meanwhile run elsewhere, the task executed twice under two ids. Fine for idempotent work; blind re-offer of non-idempotent kinds (anapproval-gated deploy) is on the orchestrator. Prevention would require a second authority over the original action — the fork this design rejects.
Action events propagate over S2S to peers that declared the act capability (Jul 3), preserving every act-* tag, the event id, and sig verbatim. The gate exists because sign-what's-present makes a tag-stripping legacy peer indistinguishable from an attacker downstream: a peer that never receives act events can never launder them. When events are withheld from a non-declaring peer, log it — a wrong capability declaration otherwise fails silent. The validator's reject path logs enough to distinguish "sig fails, peer predates act" from "sig fails, tags were touched" — the gate can't cover a buggy peer or a future multi-hop path. A non-declaring peer's users see companion prose but no actions until it upgrades; its backfill on upgrade reaches what catch-up retention holds, the same truth ordinary messages live with.
A directed action to a DID on a remote server routes to that DID's sessions (see Addressing). State transitions on an action route to its home — the id-minting server — which orders them. Which events those are is read from each kind's table, not known by verb name: a verb whose from state equals its to state is additive (progress, a bounty's bids) — broadcast, never serialized, which is why bids from every server are all visible; a verb whose from differs from its to is a state transition, ordered by the home and confirmed by receipt. Receivers rebuild and verify the canonical from the relayed tags before applying — deferring, never rejecting, when the key is temporarily unfetchable. Catch-up replay serves stored signed originals with each event's true minter as its origin — a replaying peer conveys who minted an event, never claims it — and the receiver verifies every replayed event itself, so second-hand history is exactly as checkable as first-hand delivery. Confirmations heal the same way: a receipt is an event, so a peer that was down when it broadcast picks it up in replay like anything else.
One trust rule federation-wide, stated because getting it wrong is an impersonation hole: authority derives from the authenticated link, never from a field in the payload. A message body's origin claim is data; whether the peer is an action's home is decided against the transport-authenticated peer identity.
A query surface over the materialized view — not a parallel table that owns data. As built:
GET /api/v1/actions?kind=&assignee=&state=&limit=— my inbox / claimable queueGET /api/v1/actions/{id}— current state + full event log, in the exact bytes each signature covers; still served after the action ends
Authorization is by venue: a channel action answers to whoever could read the channel; a direct action only to its two participants. Two deltas against v0.4's sketch: the POST routes are dropped — an action is created and moved over IRC as a signed TAGMSG, and a REST write path would be a second, unsigned way in. And the filter set is narrower than sketched (caps= and to= are not served yet; state=orphaned arrives with the orphan annotation). This still maps cleanly onto AIRC-style reads; an interop bridge is a thin adapter.
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, run an auction over a bounty's bids, or escalate an open queue — including noticing orphaned actions and re-offering them under a fresh id with act-replaces (see the double-run warning above). The channel is the coordination bus; handoffs become an observable, logged, reassignable stream. CHATHISTORY gives you the audit log for free.
- The
act-*tag set +freeq.at/actCAP + TAGMSG handling. - A transition validator — per-kind transition table + authz shipped as data, with three-way signature checking as its first gate (valid / invalid-reject / unverifiable-defer), a bounded defer queue (per-origin + total) with visible eviction, and the ±120s skew grace on deadline checks; claims serialize at the action's minting server.
- Receipts — the home's signed confirmation event, the receiver rules above (unevictable, park-before-subject, venue from the signed document), and peers deriving authoritative state from confirmed events only.
- A materialized view + REST + reconnect replay, including (with federation) the
orphanedannotation, unconfirmed-event flagging, and the dropped-deferred-event marker; rebuilt in signed-id order under the full checker. - A liveness sweep for non-terminal actions past TTL, owned by the home, emitted as a system-signed event that broadcasts and replays.
- The canonical — JCS over all
act-*tags present plus mandatoryfrom/id/target(semantic keys, normalized venue), signed over the signer-minted ULID — with the frozen cross-implementation vector files. - A durable signing-key model — hash-derived
kid, append-only per-DID key history, origin-server lookup(origin, DID, kid)now, DID-document anchoring as the follow-on. - DID-native addressing — DID targets at the wire level, and the resolve-once-and-stamp path for nick DMs with receiver-side cross-checking.
Everything else (delivery transport, durability, identity, ids, flood limits, federation transport) is reuse.
- Not a workflow engine / DAG executor — 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, sequenced, not closed here.
(Kept for the record.) Handoff-first, factor the substrate out. Origin-server key lookup now with the hash-kid in the sig from day one, DID-document anchoring next. Claim fairness stays dumb — first valid wins; richer selection is orchestrator policy (amended in v0.5 by one degree: a proposed bounty kind's table may collect bids as additive events, with the poster picking the winner by a signed award — the server never picks, and selection stays the poster's signed choice; the kind itself is just another table, and re-listing a forfeited bounty is act-replaces). Minting-server outage stalls rather than forks — kept, with the orphan annotation making the stall survivable.
- Venue binding (Jul 10; normalization rule Jul 31): the canonical signs the normalized target; DMs bind the canonical DM key.
- CAP-gated act relay + reject-path logging (proposed Jul 3, adopted in the build).
- Semantic field names as canonical keys;
from/id/targetmandatory; missing = unverifiable (Jul 31, frozen Aug 2). +freeq.at/eventidreplaces the self-minted action id; the server adopts the client-minted id, guarded (Jul 31, frozen Aug 2).- Enumerated per-kind chat documents on the same machinery; one shared sig-tag format; frozen vector files with pinned negatives (Jul 31 – Aug 9).
- The events stream, born complete, hash-only rows (Jul 31 – Aug 2).
cancelfromopen;claimchecks the deadline (Aug 17).act-replacesas a typed relation; oneact-*tag per relation, named for the relation (Aug 17).
(Decided by the code — flag anything you'd have differently.)
act-refretired — a follow-up finds its offer throughact-id(which holds the opener's own event id), so the generic link had no remaining job; the one-tag-per-relation rule replaced it.- Claim = anyone; caps never gate — resolving v0.4's internal contradiction in favor of its own capabilities section.
- REST is read-only at
GET /api/v1/actions— creation and transitions go over IRC, signed. - The signer's wire tag is
+freeq.at/from— renamed fromact-fromso wire name and document key agree. The three mandatory fields form a uniform envelope outside theact-*sweep (from,eventid, the target), and the sweep has no exceptions. - Uncheckable always answers as unverifiable — including at a server's own front door: an unknown algorithm or malformed signature tag gets an unverifiable answer, never an invalid one, matching the vector files' pinned verdicts.
- The receipt (above): confirmation is a home-signed
confirmevent pointing at the participant event it confirms; one generic definition across kinds; expiry shares the pattern. Spellings (confirm,act-subject) ours, offered for review.
- Defer-queue and orphan parameters — bounds (per-origin and total), backoff curve, orphan TTL default. Numbers above are recommendations; operational experience sets them before anything freezes.
- Receipt sequence numbers — deferred, deliberately: equivocation proof needs an evidence-comparison channel that doesn't exist yet. Revisit with the trust work.
- The encrypted-content mode — encrypted
act-title/act-ctxwith cleartext lifecycle needs no wire change, but when is it worth the loss of caps-routing and readable audit? - External context refs — allow AT-Proto records as a first-class (best-effort) ref type, or discourage entirely?
- WG venue — keep
+freeq.at/*until the trust pieces are solid, then pitch IRCv3 WG? The semantic-canonical-keys rule means de-vendoring the tag namespace breaks no signatures; the wire is de-vendorable by construction now.
Feedback welcome — comment on the gist, or find me on freeq (irc.freeq.at) / Bluesky.