Skip to content

Instantly share code, notes, and snippets.

@chad
Created July 2, 2026 22:03
Show Gist options
  • Select an option

  • Save chad/60ad3c201299dfe9d762a27834772f5f to your computer and use it in GitHub Desktop.

Select an option

Save chad/60ad3c201299dfe9d762a27834772f5f to your computer and use it in GitHub Desktop.
RFC v0.4: freeq.at/act — stateful, signed, addressed actions for IRCv3

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

(with handoff as the first action kind)

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

This is a casual RFC. Poke holes in it.

What changed since v0.3 (thanks to review feedback): (1) Invalid ≠ unverifiable. The validator now distinguishes a bad signature (reject, like an illegal transition) from an unverifiable one (the key's origin server is unreachable — defer and retry, never reject). v0.3 conflated a forgery with a third-party outage and turned transient network blips into permanent, silent action loss. The same stall-not-fork stance we already took for an unreachable minting server now applies to key lookup. (2) The canonical is no longer a fixed field list. The signature covers every act-* tag present, sorted — so new kinds can add fields without a canonical version bump, and there is no such thing as an unsigned kind-specific tag riding a signed action. This dissolves most of v0.3's "canonical versioning" open question. (3) act-to is un-overloaded. It only ever names an assignee DID. Open/claimable actions simply omit it — unassigned is the natural encoding of unassigned — and the posting venue comes from the message target, where it always actually was. (4) Orphaned actions are specced, not implied. If a minting server dies, its actions stall — but peers may now mark them orphaned in their local view (non-authoritative, reversible), and an assignee's terminal event is still recorded in the signed log even when it can't yet be ordered. Done work is never silently lost; it's just not yet authoritative. (5) The nick→DID stamp is flagged as unauthenticated — it's origin-asserted, and because DM persistence keys off the DID, a bad stamp has a durable blast radius, unlike today's ephemeral nick spoofing. Receivers verify-when-possible; actions never depend on the stamp (the assignee DID is inside the sender-signed canonical). (6) Two asymmetries are now owned instead of implicit: the offerer wins cancel-vs-accept ties by construction (the serializer is its own origin — and cancel-biased ties are the safe default), and under interim key lookup, claim ordering is really "first verified," which depends on each claimant's origin responding. (7) Deadline enforcement gets an explicit skew grace window — everything else in the design avoids wall clocks; the one place we compare against one shouldn't assume synchronized clocks (we have the drift incident scars to prove it). (8) Four of v0.3's open questions are decided (see Decided in v0.4).


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.

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 ("addressed to an identity," "signed," "survives offline") and that don't actually hold until we firm them up. Everything else really is reuse.

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 — 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/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: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/act-id=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/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. (And per the signing section: a kind may add its own act-* fields — grant will 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.)

Addressing: DIDs, not nicks

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 msgid. No per-server interpretation, no collision. This wants DID targets at the wire level (PRIVMSG did:plc:x, and likewise for the TAGMSG an action rides — CHATHISTORY already accepts did: targets, so there's precedent), so an agent can address a peer by DID and never has to resolve a nick or reason about a collision.
  • 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-to sits 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.

Two orthogonal axes

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 + act-caps=… → starts unassigned; any capable agent claims it; first valid claim wins.
  • 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.

(v0.3 allowed act-to=#swarm for open actions, which stuffed a venue into the assignee slot and duplicated the message target — and left "what does act-to=#a on a TAGMSG to #b mean?" undefined. Gone. act-to is an assignee or absent.)

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

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, checks the signature first — and the outcome is three-way, not two:

  • Valid → 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.
  • 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 canonical rebuilds fine, but the key can't currently be fetched — the signer's key-origin server is unreachable) → 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.

That third branch is the same stall-not-fork stance the rest of the design takes: an outage stalls ordering, it never converts into a verdict. Deferral rules: the parked queue is bounded per origin (evict oldest, log loudly); 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 (we have production scars: a drifted NTT once broke every auth handshake with a 60s window on this very stack). Deadlines are coarse-grained business facts (minutes to days), so the grace window is harmless; 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 semantics (open/claimable)

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 act-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 act-id has exactly one origin. Transitions relay to it; it emits the authoritative ordering; 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 (see the validator) 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.

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 the two participant DIDs — see Addressing), 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.

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 act-id/verb/DIDs/deadline — never the title or context — so an encrypted-content mode (encrypted act-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. (Same footnote as the trust section: DM prekeys are server-served today, so even that encrypted body holds only against an honest server — one more consumer of the same root-of-trust gap.)

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

⚠️ freeq's current PRIVMSG signing isn't sufficient for a signed action. Its canonical is {sender_did}\0{target}\0{text}\0{timestamp} — it assumes a message body (a body-less TAGMSG has no text) and folds in a wall-clock timestamp that's minted at send and never stored or relayed, so a downstream server can't reconstruct it to verify. act therefore defines a canonical built to survive federation:

  • Canonical: deterministic JSON (JCS / RFC 8785) over every +freeq.at/act-* tag present on the message, keys sorted, plus act-from. Not a fixed field list. v0.3 enumerated ten fields, which was secretly handoff-shaped: the first kind needing one more field (grant wants a scope/resource on day one) would have forced either a canonical version bump or — worse — unsigned kind-specific tags riding a signed action, a tampering surface that quietly defeats the point. Sign-what's-present fixes both, and it's self-policing: adding an act-* tag in transit changes the receiver's rebuilt canonical (sig fails), and stripping one does too (sig fails). Tag add/strip are both detected as invalid, so no separate covered-fields declaration (DKIM's h=) is needed — S2S must relay the tags verbatim anyway. (If a future kind genuinely needs a mutable tag, that's the moment to introduce an exclusion list — not before.)
  • Sign over the ULID (act-id), not a wall-clock timestamp. A ULID embeds its own creation time, is immutable, and already travels as a first-class tag — so 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>, where kid is a truncated hash of the public key (recommended: first 16 bytes of SHA-256, base64url). A hash-derived kid is self-certifying: whatever key a lookup returns can be checked against the kid, so a key server can't substitute a different key for an old signature — the substitution is detectable by construction. (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. Since DID and ULID are both already tags, this is far more achievable than retrofitting PRIVMSG.

This is meant to be freeq's signing model, not an act-only special case. The same weakness — a signed wall-clock timestamp that never crosses S2S — means PRIVMSG signatures don't survive federation today either: a receiver can't rebuild the canonical, so it can't actually check the sig — the 🔒 a federated client shows just means a signature is attached, not that anyone verified it. act gets the fix first because it's greenfield (no deployed clients signing the old way, no stored history to keep verifiable) and because non-repudiation is load-bearing for a durable action, where on an ephemeral chat line it's near-cosmetic. But there's no principled reason chat stays on the old path: sign a hash of the freeform body, sign the wire bytes (ciphertext when E2EE, so encryption and verification stay orthogonal), and carry the signed fields verbatim over S2S. This RFC proposes that all message signing — PRIVMSG included — should eventually migrate onto this canonical, as follow-on work. The end state we're arguing for is one signing path, not two; act just proves it first.

The harder problem: key existence + key lookup

Reconstructing the signed bytes is only half of verification; you also need the public key that made the sig, and today that key doesn't survive. Two problems compound:

  • Keys are per-session and overwritten. Clients mint a fresh ed25519 keypair each session and register it (MSGSIG); the key store keeps one key per DID and overwrites on re-register. So the moment a signer reconnects, every signature from a prior session becomes unverifiable. Chat tolerates this; actions are specifically long-lived, so "verify an offer signed in a session that has since ended" is the normal case, and the current model can't do it at all.
  • Even a retained key has to be findable. A verifier needs to know who to ask.

The fix is two matching parts — and as of v0.4 the sequencing is decided (was an open question):

  1. Key existence — the key store becomes append-only (history, not overwrite), keyed by (DID, kid), so the exact key that signed always resolves. The kid in the sig tag is hash-derived (above), so a resolved key is checkable against the signature that named it.
  2. Key lookup — ship the origin-server path now, anchor in DID documents next. Interim: sessions register keys where they lived, every relayed event names its origin, so the lookup is (origin, DID, kid) — no new "home server" concept. The costs, plainly: verification is hostage to that origin being reachable (hence the validator's defer branch — outage means not yet verifiable, never rejected) and honest at registration time (the hash-kid stops retroactive key substitution, but not a lie about the original DID↔key binding). The real answer is DID-document anchoring (next section): 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.

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. The hash-derived kid narrows this — a key server can no longer swap keys under an existing signature without detection — but it cannot attest the original binding.
  • The nick→DID stamp on cross-server DMs sits under the same bound (see Addressing): origin-asserted, unauthenticated, and now 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.
  • 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, and it's also what retires the defer branch, the claim-race latency asymmetry, and the origin-reachability dependence in one move. It's a prerequisite for trustworthy cross-server claimable queues and for verifiable long-lived actions generally.

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. The sweep is owned by the action's serializer (its act-id-minting server) and broadcast as a normal terminal event, so peers garbage-collect their views by relay rather than each computing expiry locally.

Orphaned actions: when the sweeper is the casualty

v0.3 left a hole here: the TTL sweep exists to handle abandoned actions, and it's 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. Left implicit, every implementation would invent its own incompatible cleanup. So v0.4 specs it:

  • Peers may mark an action orphaned in their local view once its minting server has been unreachable past an orphan TTL (recommended: 24h, configurable). orphaned is a view-only, non-authoritative, reversible annotation — it is never emitted as a lifecycle event, never relayed, and never enters the signed log. It exists so views can stop advertising claimable/assigned work whose authority is gone, and so REST clients see honest state (state=orphaned) instead of a forever-fresh offered.
  • If the origin returns, authority resumes and the annotation dissolves: the origin's authoritative ordering (including any sweep events it emits on catch-up) replaces the local marking. Stall, not fork — the annotation never made anyone an authority.
  • 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's a message, it lands in channel/DM history and relays like any other, it just isn't authoritative until the origin orders it. The signed log and the ordering authority are separate things: an outage stalls ordering; it never loses the record. Peers' views may display such an event as complete (unconfirmed). When the origin returns, it processes the queued/replayed transitions and confirms; if it never returns, the signed, timestamped complete in the log is still the audit trail a human or orchestrator needs to settle the question out-of-band. Done-but-unrecordable would have been the nastiest state in the system; done-but-not-yet-ordered is merely annoying.

Federation

Action events propagate over S2S like any tagged message, preserving act-id, all act-* tags, and sig. A directed action to a DID on a remote server routes to that DID's sessions (see Addressing); the act-id-minting server owns claim serialization and TTL expiry for that action. Receivers rebuild and verify the canonical from the relayed tags before applying it (see Signing) — deferring, not rejecting, when the key is temporarily unfetchable — so cross-server verification and cross-server claimable both remain gated on the key-store + 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 (state=orphaned included, so clients see honest liveness)
  • GET /api/v1/actions/{act-id} — current state + context ref + event log (unconfirmed events flagged)
  • 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 — including noticing orphaned actions and re-offering them under a fresh act-id on a live server. 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, with three-way signature checking as its first gate (valid / invalid-reject / unverifiable-defer), a bounded defer queue with retry, and a ±120s skew grace on deadline checks; for open actions this includes claim serialization at the act-id-minting server — claims route to it, it atomically assigns (in verification order) and rejects the rest.
  3. A materialized view + the REST query interface + reconnect replay (reusing CHATHISTORY/DM replay), including the orphaned local annotation and unconfirmed-event flagging.
  4. A liveness sweep for non-terminal actions past TTL, owned by the act-id-minting server, plus the peer-side orphan TTL.
  5. The new canonical — JCS over all act-* tags present, signed over the ULID — and S2S relaying the signed tags verbatim.
  6. A durable signing-key model — hash-derived kid in the sig tag, an append-only per-DID key history (never overwrite), origin-server lookup (origin, DID, kid) now, DID-document anchoring as the follow-on.
  7. DID-native addressing — DID targets at the wire level, and the resolve-once-at-sender-and-stamp path for nick DMs, with receiver-side cross-checking of the (unauthenticated) stamp.

Everything else (delivery transport, 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, sequenced (origin-server lookup now, DID-doc anchoring next), but not closed here.

Decided in v0.4

Four of v0.3's open questions, closed:

  • Substrate now, or handoff-first? → Handoff-first, factor the substrate out. The acceptance test ("does approval reuse it or reinvent it?") is the forcing function. The one thing pulled forward is the wire decision that would have been expensive to retrofit: sign-what's-present replaces the fixed canonical field set now.
  • Key lookup (a) origin-server vs (b) DID-document? → Ship (a) now, with the hash-derived kid in the sig from day one; migrate to (b). (a) makes signatures mean something immediately; the kid means the wire doesn't change when the authority moves; (b) retires the defer branch, the claim-latency asymmetry, and the reachability dependence.
  • Claim fairness beyond first-wins? → Keep it dumb. Bidding, priority, and capability scoring are orchestrator policy, per the substrate/policy split: the substrate generalizes plumbing, not policy. An orchestrator that wants an auction runs one above the primitive and then issues a directed offer.
  • Minting-server outage: stall, or hand authority to the assignee's server at accept? → Stall rather than fork, kept. A mid-lifecycle authority handoff is a new distributed-systems problem grafted on to avoid an old one already accepted everywhere else in the stack. The orphan annotation + recorded-but-unordered terminal events (see Liveness) make the stall survivable without making anyone a second authority.

Open questions

  • Per-kind authz spec format — how do we declare each kind's transition table + rules so it's reviewable and not ad-hoc? (A table like handoff's, checked in as data the validator loads, is the current hunch.)
  • Defer-queue and orphan parameters — bounds, backoff curve, orphan TTL default. Numbers above are recommendations; operational experience should set them before anything freezes.
  • The encrypted-content mode — encrypted act-title/act-ctx with 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? (Design the wire to be de-vendorable now regardless.)

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

@chad

chad commented Jul 31, 2026

Copy link
Copy Markdown
Author

Went through all three of these against the actual tree rather than from memory, and your factual claims about the code all hold: addressing is live (connection/routing.rs — did: targets, the recipient_did stamp, reconcile_recipient_did's mismatch fallback), the kid-derived append-only key store already shipped (db.rs:1509-1550), a delete really does record nothing but deleted_at (db.rs:1085), an unreact really does DELETE FROM reactions (db.rs:1265), root-msgid identity is on main across the server and all five clients, and nothing emits act yet (act.rs is canonical + vectors only — no CAP, no validator, no wire handling). So: §1 and §2 are right and should be built as described. On §3 I'd say stream, born complete — but store the hash, not the body. Four things you haven't hit, one of which breaks every signature on day one.

1. The venue binding is right, but "byte-verbatim target" is already false in our own server.

Your Jul 10 comment prices the fix at one invariant: no case-folding #Ops→#ops between wire and verify. We already do exactly that — connection/mod.rs:1358 lowercases the PRIVMSG target before handle_privmsg ever sees it, and messaging.rs:204 does it again for TAGMSG. Sign the venue as typed and a client sending PRIVMSG #Ops fails verification at its own origin, every time.

So the contract has to be "target after canonical normalization" (lowercased channel), specified once and applied identically by signer and verifier. That's better anyway — it's reproducible on any peer and stable across replay.

DMs need a different answer entirely, and this one I think you'll want: the DM venue can't be the wire target at all. It's a nick (mutable, per-server) or a did: — two strings for one conversation — and CHATHISTORY serves DM frames under raw_target, the requester's view of the conversation, not the sender's (messaging.rs:1454, resolve_history_target). Reader-perspective rewrite is not a hypothetical cost to accept; it's what the code does. Bind canonical_dm_key(did_a, did_b) instead: reader-independent, reproducible by both ends and any peer, and already the persistence key.

2. Two ids, and no uniqueness on the one we have.

The message document has "msgid":"<ULID>" and §2 says the signer mints it, but subject/reply/edit name server-minted root msgids. Which is a message's own id?

If the server adopts the client's id as the msgid — the only self-consistent option, and the one that keeps signed value == filed value all the way down — then note there is no UNIQUE index on messages.msgid (001_schema_baseline.rs:86 is a plain index). Adoption needs three guards: reject reuse per DID (replay), reject collision with an existing id (find_message_by_msgid returns a row — id squatting is an impersonation surface), and bound the ULID's embedded timestamp against server time. Plus a server-minted fallback for guests and legacy clients.

If it doesn't adopt, every event carries two ids, one signed and one filed, and no verifier can say "this signed event is the event I'm rendering" without a mapping table. Worse. So: adopt, with the guards — but "chat ships client-minted ULIDs as a general rail" is a bigger change than the sentence lets on and deserves its own section.

3. On coverage — the rewrites cut both ways, and the body hash needs pinning harder.

You're right that covering coord makes sanitizing break a sig exactly when it altered something. What's worth making explicit: the server today renames +draft/react→+react and +draft/reply→+reply at ingress and on the S2S receive side (messaging.rs:213, server.rs:3760), and rewrites +reply/+draft/delete values from a revision to the root (messaging.rs:220). The renames are harmless only because your documents use semantic keys (reply, edit) instead of wire tag names — state that as a deliberate property, because it also means the chat canonical survives a future de-vendoring that act's sign-the-tag-name rule would not. The value rewrites are what your reject-a-signed-revision rule replaces, and that rule must be CAP-gated: keep rewriting unsigned events, reject only signed ones, or every deployed client breaks the day enforcement lands.

Two smaller ones: coord should be a closed enumerated set, not "the client-authored ones," or a future server-written +freeq.at/* tag silently lands inside a covered document. And "body":"sha256:<hex>" needs its bytes nailed down: ciphertext under E2EE (v0.4 says wire bytes — restate it in the chat profile), and for multiline the assembled body as stored, because encode_privmsg_text_for_s2s rewrites \n→\\n on the S2S hop (s2s.rs:148) and the far side re-splits into BATCH frames. The concat flags are body-determining, not framing-to-ignore. Hex vs base64, case, the sha256: prefix — all in the vectors.

4. Free ammunition for the whole thesis: client signing is barely working today.

The current canonical folds a wall-clock second, and the client mints it while the server verifies against its own clock (freeq-sdk-js/src/signing.ts:42 vs messaging.rs:647 → resolve_signature). Same second and same clock, or it fails and silently falls back to server signing with a warn!. /api/v1/verify/{msgid} rebuilds from the stored timestamp (web.rs:1332), so "Verified — signed on the sender's device" is luck, not a verdict. The RFC says federated signatures aren't verifiable; the truth is local ones mostly aren't either.

Two consequences. The cutover is more urgent than it reads — I've started on it. And all pre-cutover history is permanently unverifiable, so it must render as unverifiable (legacy), never invalid — which is your own invalid-vs-unverifiable rule applied to a third place, alongside legacy peers.

§3, my answer: the stream, born complete, storing the body hash rather than the body.

Your stated cost ("storage roughly doubles") rests on "the stream row needs the body to re-derive the hash" — it needs access to the body, not a copy. Join messages on msgid. That removes the cost, and it removes a correctness hazard you didn't price: two copies of a body drift (an edit path, a re-index, an at-rest-encryption migration), and then verification and the UI disagree about which one is real.

Hash-only also fixes the thing that would otherwise make the table a liability: an append-only store of signed original bodies is un-erasable by construction. A soft-deleted message whose plaintext lives forever in an audit table is a landmine on a product shipping encrypted channels. Hash-only means delete actually deletes and the proof still survives.

And an argument in your favour you under-use: coordination_events is already (event_id, event_type, actor_did, channel, ref_id, payload_json, signature, timestamp) (001_schema_baseline.sql:215). The stream isn't a fourth one-off table, it's that one generalized — fold coordination_events into it and the "schema keeps accumulating these" objection becomes a net reduction. That tips it for me: stream, now, born complete.

§2: yes, merge the id tags — with one consistency fix.

Cheap now, so do the whole job. As drafted, act's canonical ends up with mixed key styles — bare from/id/target next to act-verb/act-title — and four implementations will get that wrong. One rule for both profiles: canonical key = semantic field name; the wire tag name is framing. spec/act-signing-vectors.json regenerates from one ignored test, so this costs a commit. Also say explicitly that id/from/target are mandatory: with id no longer act-prefixed, sign-every-act-* no longer strip-detects it, so a missing one must read unverifiable, not "no act tags."

Naming nit: +freeq.at/id is thin in a namespace already carrying act-id, task-id, av-id, approval-id. +freeq.at/eventid costs nothing and never gets misread as "the id of the thing this refers to."

One prerequisite nobody has named. relay_coordination_tags strips +freeq.at/sig (s2s.rs:122) because PRIVMSG carries it in a dedicated S2S field. S2sMessage::Tagmsg (s2s.rs:354) has no such field and relays raw tags — so mutation signatures cross today by accident and message signatures cross by design, via two different mechanisms. Pin that down with a test before the first signed delete ships, or federated mutation verification fails in a way that looks exactly like tampering.

Order I'd build in:

  1. Freeze the venue rules (normalized channel / canonical DM key) and the body-hash byte rules — then generate spec/chat-signing-vectors.json. Vectors last, not first.
  2. +freeq.at/eventid + server adoption of the client-minted msgid, with the three guards. Unblocks everything else.
  3. New canonical for chat behind a CAP; legacy → unverifiable; retire the timestamp canonical.
  4. The events stream (hash-only, coordination_events folded in), then signed delete/react on top of it.
  5. Act profile realigned to the shared key rule, act vectors regenerated in the same commit.

Everything in §1 and §2 is buildable as written once venue normalization and id adoption are settled — those two are the only places where proceeding as drafted produces signatures that fail on day one. Starting on (1) and the timestamp fix now.

@chad

chad commented Jul 31, 2026

Copy link
Copy Markdown
Author

Follow-up: steps 1–3 of that build order are on main. Four commits, 465d07ff → 0e0ad132.

What landed

  • freeq-sdk/src/chatsig.rs — your message/delete/react/unreact documents, JCS over the enumerated per-kind form, signed over the signer's own event id. The +freeq.at/sig machinery (kid derivation, tag parse, raw sign/verify) moved to a shared sigtag module both profiles use, so act and chat can't drift on the format. act keeps its public surface and its committed vectors byte-for-byte.
  • +freeq.at/eventid — the sender mints, the server adopts it as the message's msgid, so signed value == filed value all the way down to what edits and reactions reference. Guarded as described: authenticated senders only, ULID shape, ±120s of our clock, and not already on file (soft-deleted rows count as taken — a spent id stays spent). A refused id is a FAIL naming the reason and the message is dropped, never filed under a substitute the signature doesn't cover. The tag doesn't travel: once adopted the id is msgid, and two tags that must agree forever with no tie-break rule is the ambiguity signing exists to remove.
  • Server-side verification with the three-way verdict, /api/v1/verify/{msgid} rebuilt on the document, and the web badge now distinguishing "could not be checked here" from "does not match its signing key."
  • Both SDKs signing — Rust (which is TUI plus iOS/macOS/Android through the FFI) and TS (web plus the JS bots).
  • spec/chat-signing-vectors.json: 8 vectors (plain, folded channel venue, DM venue, reply+edit+coord, multiline with escaping, delete, react, unreact) and 6 negatives (altered body, stripped edit, sanitized coord tag, re-venued target, swapped subject, react→unreact verb swap). Rust generates it, TS reproduces every canonical, kid and signature byte-for-byte, and every negative reaches the same verdict. It agreed on the first run, which is the outcome worth having. Your published delete canonical is in there verbatim:
{"from":"did:plc:k2n3e2vsihf3farequ44t5j7","kind":"delete","msgid":"01KYVT5Z8Q0000000000000000","subject":"01KYVT1W2P0000000000000000","target":"#freeq"}

Three live bugs fell out of it, and together they explain why nobody had noticed the canonical was broken

  1. MSGSIG was being thrown away by both SDKs. Both sent it right after 903, before CAP END — and the server drops MSGSIG from an unregistered connection (if !conn.registered { continue }). So the session key was never registered, the server could never verify a client signature, and every "client-signed" message silently fell back to a server signature. The 🔒 has been decorative since it shipped. Both SDKs now mint on 903 and register on 001, before any queued message goes out.

  2. A signature that failed verification was relayed anyway. The client's +freeq.at/sig was already in the tag map being broadcast, and the server only ever overwrote it — so anything it declined to endorse travelled untouched. A guest could attach an invented signature and every client would draw a lock beside it. There's now a test that sends exactly that forgery. The tag is cleared before it's set, and a failed client signature is not replaced with the server's: laundering a signature that didn't check into a badge saying it did is the one thing worse than no badge.

  3. Tagged sends were never signed at all in the Rust SDK — replies, media, coordination events formatted their own line and went out as Command::Raw, past the signing path entirely. Command::Privmsg now carries its tags, so the tags that get signed are the tags that travel.

Also: two existing tests had "try every second in the last five and accept any hit" loops around signature verification. They only ever passed because a test was allowed to guess at a timestamp no receiver ever had. Both are now single-shot and deterministic, which is the whole point.

Decided by the code, so flag anything you'd have differently

  • Venue is normalized, not verbatim — folded channel name, or dm:<did_a>,<did_b> sorted. canonical_dm_key in the server now delegates to chatsig::dm_venue so the persistence key and the signed venue can't drift by a byte. There's an acceptance test that signing #sig while sending PRIVMSG #SIG verifies, and signing #SIG does not — the rule is unambiguous rather than "whatever the sender typed."
  • No venue, no signature. A DM to a bare nick whose DID the client hasn't learned is sent unsigned. Signing a guessed venue would produce something that reads as tampering to whoever checked it.
  • A plugin-rewritten body reads unverifiable, not invalid. Our own server changed the bytes; that's not evidence about the sender.
  • is_unverifiable() lives in one place and the whole stack routes through it, so the invalid-vs-unverifiable line can't be re-litigated per call site. Pre-cutover history reads unverifiable-legacy-format; an unknown algorithm reads unverifiable-unknown-algorithm.
  • +freeq.at/eventid, not +freeq.at/id — too thin next to act-id, task-id, av-id, approval-id.
  • freeq.at/msgsig in CAP LS tells a client the server verifies documents. No per-session state: each signature declares its own format, so the server doesn't need to be told which one is coming.

The test I care about most is end-to-end with no shortcut in the middle: an SDK client sends a message, a bystander on raw IRC captures the line, fetches the signer's key from the server's key store, rebuilds the document from the wire alone and verifies it — then asks the server's verify endpoint, which agrees the device signed it, not the server. That path was impossible before this. 83 test binaries green, web suites green.

Next, in your order: mutation signing on the events stream (I'm going with hash-only rows — the argument's in my last comment), then Swift and Kotlin canonicals against the same vectors, then act realigned to the shared key rule with its vectors regenerated in the same commit. Ports first, mutations second, if you'd rather have four implementations agreeing before adding a fifth event kind.

@zapnap

zapnap commented Aug 2, 2026

Copy link
Copy Markdown

Closing the loop on both replies. The decided set is accepted as-is and I'm treating it as frozen: normalized channel venue and canonical_dm_key(did_a, did_b) for DMs; server adoption of the client-minted id with the guards; +freeq.at/eventid; semantic field names as canonical keys in both profiles, with id/from/target mandatory and a missing one reading as unverifiable; CAP-gated enforcement; body-hash bytes pinned (ciphertext under E2EE, multiline hashed as stored); and the stream born complete with hash-only rows. On §3 we're in the same place — the stream is the right end state — and hash-only is the version of it to build: the transitional double-storage cost drops out, deletes still actually delete under an append-only store, and with the coordination_events fold the stream doesn't just stop the one-off tables accumulating, it eats one. No relitigating from me on any of it.

Your named prerequisite is done, on public main. a4f113b2 adds a two-server e2e (mutation_sig_tag_crosses_the_hop_byte_identical, freeq-server/tests/federation.rs) asserting that a mutation TAGMSG's +freeq.at/sig crosses the S2S hop byte-identical. Mutation signatures were crossing federation by accident before; now it's pinned — a relay change that strips or rewrites the tag fails CI instead of looking like tampering downstream. Btw — I don't see your commits: 465d07ff→0e0ad132 are on no public ref, main is 9fc35997 plus the test above, so everything below responds to your description rather than the code.

A correction on bug 1, since this thread is becoming the record: the MSGSIG registration bug is the Rust SDK only. Rust writes MSGSIG immediately on 903, before CAP END, and the server silently drops MSGSIG from an unregistered connection — so the TUI and all three native apps (which reach the Rust SDK through the FFI) have never registered a session key. Confirmed on public main. The JS SDK escapes by accident: it generates the key in an async callback, so its MSGSIG goes out after CAP END has already been sent — it arrives after registration and the server accepts it. That's not just a code reading — my zerosum deployment serves a key-store signing key for my DID, and MSGSIG registration is the only production writer to that table. So the honest history is: 🔒 decorative for every native client; web has been registering keys all along — but a web signature only verified when sender and server landed in the same wall-second, and a miss was silently laundered into a server signature. We confirmed bugs 2 and 3 on public main as described: a failed signature gets replaced with a server one, a guest's invented signature relays untouched, and the Rust SDK's tagged sends skip the signing path entirely. Your conclusion survives the correction fully — the cutover is urgent — it just rests on "verification was a coin flip that lied about its misses," not "no key was ever registered."

Five more negatives for spec/chat-signing-vectors.json. All six of yours test the same thing: a signed field got tampered with, and the right answer is invalid. But there are other ways a signature check can go wrong, and some are supposed to land on unverifiable rather than invalid. If the vectors don't pin those answers, each implementation will pick its own. Five worth adding:

  • injected edit: a validly signed plain message with an edit tag added in flight, re-presenting it as a correction of some earlier message → invalid. You cover stripping an edit off a signed edit; injection is the reverse direction and the more dangerous one.
  • wrong kid: a good signature whose tag names a different registered key than the one that made it. The lookup returns the wrong key, the bytes don't verify — the vector pins which verdict that produces, so implementations don't split between "invalid" and "unverifiable" on it.
  • unknown algorithm label: a sig tag reading rsa4096:<kid>:<sig> → unverifiable, never invalid. You built the reason label; no vector pins the verdict.
  • cross-kind swap: a signed delete re-presented as a react — same actor, subject, venue, and id, only kind differs → invalid. Your react↔unreact swap changes a value within one kind; this one swaps the kind itself, which is exactly what putting kind inside the signed document is supposed to catch.
  • legacy bare-base64 tag: a pre-cutover signature (raw base64, no ed25519:<kid>: prefix) → detected as legacy and read unverifiable-legacy-format, not invalid — your own rule, applied to the vector file.

On id adoption, we assume "not already on file" means a UNIQUE constraint. The baseline schema's msgid index is a plain index today, and a check-then-insert races: two concurrent sends carrying the same id both pass the lookup before either inserts. Flag it if your changeset does this some other way.

No-venue-no-sig: agreed, and most of the ground is already covered. Web, TUI, and Android resolve a DM target to the peer's DID on every send, so once a client has learned the DID, its DMs carry a venue and can sign. Three gaps remain. Guest DMs: no DID exists, unsigned forever under any scheme. First DM to a never-seen nick: the nick→DID maps are populated passively in-session, and no client does a lookup before first contact. And iOS: it formats its outgoing IRC lines itself instead of sending through the SDK, so the resolution never runs at all (macOS does the same for /me and DM edits). The fleet update that ships client-minted ids should close the last two, and then unsigned means guest.

Your ordering question: mutations first, ports second. Two reasons. Mutations are the last thing that changes the vector file — do them first and the Swift/Kotlin ports get written once, against the finished set, instead of chasing a moving one. And an unsigned delete is a real hole today: a server receiving one over federation has nothing to check, it just trusts the peer. The ports don't close any hole — native messages get server-signed either way in the meantime.

We're also taking your bystander test as the acceptance bar for our half: someone on plain IRC captures the line, fetches the key, rebuilds the document, and the signature checks out with no server in the loop.

@zapnap

zapnap commented Aug 9, 2026

Copy link
Copy Markdown

Status update on the chat profile, since the vector file is the contract this thread negotiated and it moved. The enumerated documents gained a fourth kind: coordination, covering the +freeq.at/event task family we run in production. The TAGMSG the server stores and serves as task cards and audit rows now signs its own document — {event, from, kind:"coordination", msgid, payload, ref?, evidence?, target} — instead of riding unsigned next to signed chat.

To be clear about what this is: transitional, and required. That family is act's offer/progress/complete in an older spelling — task-id is act-ref, the payload is an inline act-ctx — and it folds into act as a kind once act has server handling, at which point this document retires. It's signed under the chat profile now because the traffic exists now: unsigned, a coordination event arriving over S2S or replayed from a peer's log is trust-the-peer — the same hole we closed for deletes, on the events bots actually act on.

payload hashes the wire value of +freeq.at/payload — IRC-unescaped, otherwise verbatim, any app-level encoding opaque to the signature — the multiline body reasoning applied again: a verifier checks bytes it holds, not a decoding it has to agree about. ref is covered because it's the linkage: without it a completion can be re-pointed at a different task with the signature intact. Both wire spellings (+freeq.at/ref, +freeq.at/task-id) fold onto the one canonical key.

The covered set also gained evidence-type and the media and link-preview fields — client-authored, rendered, and forgeable while they sat outside the enumeration. Neither would have been a hole under act's sign-what's-present. That's enumeration's standing failure mode: a new client-authored tag is uncovered by default and nothing announces it. Vectors don't catch it either — they only freeze the tags you remembered.

spec/chat-signing-vectors.json now holds 12 vectors and 15 negatives: your six, the five from my last comment (including the cross-kind swap I owed the file), plus the coordination and attachment repointings. Rust generates, TS reproduces byte-for-byte. One covered tag from this batch was added and removed within a day — a signed join between an event's TAGMSG and its human-readable companion — and the commit history carries the full reasoning; the rule that replaced it is the one worth writing down: the TAGMSG is the event, and a message carrying event tags is a rendering of it, never a second copy.

@zapnap

zapnap commented Aug 17, 2026

Copy link
Copy Markdown

Ran into three things building out the action lifecycle / state machine:

  1. cancel doesn't cover open actions. The table has cancel going from offered/assigned only. So if you post something open and nobody claims it, you can't take it back — it just sits there until it expires. We let the poster cancel from open too.

  2. claim ignores the deadline. accept checks it, claim doesn't. But the deadline is just how long the offer stands, and that's the same whether you named someone or left it open. We check both.

  3. Minor, and covered in previous replies: the body still says offers mint their own act-id, which hasn't been true since the switch to eventid — worth folding in whenever you next touch it.

And an addendum - we implemented handoff but we're also planning to implement a bounty kind for a related thing we're building. The v0.4 decided list keeps bidding out of the substrate: run the auction above it, then send a directed offer. I want to make a small mod to that, so a bounty type's table collects bids, but the poster still picks the winner with a signed event — the server never picks. That's a transition table like any other, and it stops every orchestrator inventing its own bidding shape — which is exactly what happens when the substrate declines to have one. It's also the best way to find out whether the rules file really holds up for a second kind.

One naming thing that falls out of that: terminal states are final, so reviving anything — a failed handoff, a forfeited bounty — is a new action that names the old one. I think that link is act-replaces, rather than a generic ref. The working assumption / rule behind it: one act- tag per relation, named for the relation, so the checker knows what the link claims and can verify it. Adding a tag is free — the signature already covers every act- tag.

@zapnap

zapnap commented Aug 19, 2026

Copy link
Copy Markdown

Building out task federation we hit a question v0.4 doesn't answer: when the home orders competing transitions, what does it actually put on the wire? The body just says it "emits the authoritative ordering." We tried the obvious answer — re-deliver the winner's own signed event under the home's origin — and it 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. Proposing the fix here before any of it ships.

The receipt. When a home rules on a participant's transition on a task other servers are involved in, it appends one small signed event — its did:web: identity, the same one that signs expiry — containing the id of the event it accepted. Nothing else.

Why an event, not a flag or a re-delivery: catch-up replay short-circuits on event ids the receiver already holds, 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 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 should be no exception.

Rules:

  • A receipt carries no state. Receivers verify the home's signature, then run the pointed-at event through the shared rules themselves. A receipt that disagrees with the rules is rejected and kept as evidence.
  • One rule overall: a transition is authoritative when the home's signature stands behind it. Home-authored transitions (expiry) already carry it; participant-authored ones gain the receipt. The participant's event keeps its meaning — so the orphan guarantee is untouched: a dead home still leaves complete (unconfirmed) as the audit record.
  • Losers unchanged: refused at the home's door, nothing filed; peers mark their copies superseded when the winner's receipt lands.

Spec points that come with it: a receipt is never evicted from the defer queue; one arriving before its subject parks rather than drops; receivers take the venue from the signed document (a home-signed event in a DM otherwise reads as mis-venued); receipts ride the act capability; expiry federates as the same pattern — home-minted system events that broadcast and replay like everything else.

Until keys anchor in DID documents a home's signature is self-vouched — day one this buys durability, replay, and an audit record; the proof arrives with anchoring, events unchanged. And nothing federated has shipped yet, so no compat question: receipts would be the only mechanism ever fielded.

Open to argument: one generic receipt definition shared by every kind, rather than a row in each kind's transition table. Sequence numbers stay out for day one; nothing stops us adding them later. Thoughts?

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