ADR-0022: Annotation and Trace Lifecycle Events, With a Server-Derived Actor Kind
Context and Problem Statement
Outbound webhooks (ADR-0017, SPEC-0012) announce exactly one thing: an artifact
was created. internal/outboundhook/outboundhook.go hard-codes it —
const EventKind = "artifact.created" — and the only emission point is
store.emitCreated, reached from CreateArtifact and CreateBundle. Everything
that happens to an artifact afterwards is silent. A comment, a reaction, a live
trace closing: none of them leave Cairn unless someone polls.
That silence is now the blocker for three things:
- On-demand one-shots. The cross-product milestone for on-demand one-shots has a trusted signal from Switchboard start a Harness one-shot within seconds, with no polling. Cairn's contribution is a signal that says "a human looked at this and said yes" or "this trace finished". Today there is none.
- Approvals. A self-hosting customer's rollout plan uses reactions as a lightweight approval gate: a human 👍 on a work order means "go". Cairn stores the reaction and tells nobody.
- Forgery. Even if the reaction were announced, it could not be trusted. An
agent's MCP OAuth token and the human's browser session resolve to the same
actor_id(the human's email; ADR-0004 subject-vs-actor). The reactions table's uniqueness key is(artifact_id, anchor_type, anchor_key, emoji, actor_id)and has noon_behalf_ofcolumn at all (#159). So a 👍 posted by the human's own agent is indistinguishable from the human's own click. It is worse than that: the agent's 👍 occupies the human's row, so the human's later click is a no-op duplicate, andUnreactscoped byactor_idlets the agent withdraw a 👍 the human made. Agent tokens carryannotations:writeby design (agentScopes()ininternal/httpapi/auth.go), so this is reachable today.
The question: how does Cairn announce annotation and trace lifecycle events so that a consumer can act on them, and specifically trust a human approval, without building a second delivery system?
Decision Drivers
- One envelope, one signer, one queue. ADR-0017's envelope
(
{source, kind, event_id, created_at, data}), HMAC signature and bounded delivery are deployed, and Switchboard verifies them. A second shape is a second verifier. - Additive or nothing. SPEC-0012 REQ "Event Payload" requires payload changes
to be additive, with the
artifact.createdbytes for an untagged artifact pinned by a golden test. Existing consumers must see nothing new unless they ask. - Human versus agent must be server-derived. The only facts a client cannot
forge are how it authenticated.
Principal.Ambientis true only for a cookie session, which is CSRF-guarded.Principal.IsAgentis true for every MCP OAuth token.on_behalf_ofis self-reported: on REST comments it is a request-body field (commentRequest.OnBehalfOf). - A human's credential can sit where an agent reads it. A personal access
token minted with
is_agent=falsestill lives in a CLI config file on disk, and agents run shells. "Human token" is not "human presence". - Multi-tenancy is a hard rule. Every resource a user creates is owned by that
user or by a team, never globally. Events about a user's artifact must be
routable to that owner's subscriptions (Teams, ADR-0029), and must
never reach a target the operator chose. ADR-0029 removes that firehose
(
CAIRN_OUTBOUND_WEBHOOK_URLS, #185) outright. - Volume. Annotation events can outnumber creations by an order of magnitude
on a busy artifact. They must not starve
artifact.createdin the bounded queue, or flood a consumer that never asked for them.
Considered Options
- A. Extend ADR-0017's emitter with new kinds, a per-subscription kind filter,
and a server-derived
actor_kind. Approval-class reactions from non-session credentials are recorded, but markedagentand never counted as approval. - B. As A, but refuse approval-class reactions from agent credentials outright (403).
- C. A separate annotation-event channel: a new emitter, secret and target list for annotation events, leaving ADR-0017 frozen.
- D. No push: consumers poll the comment and reaction read endpoints (or subscribe to the live span SSE for run close).
Decision Outcome
Chosen option: A. A is the only option that makes a forged approval impossible without breaking agents that already react, and it reuses the one delivery path Switchboard already verifies.
Event kinds
Kinds are <noun>.<past-tense verb>. This ADR adds:
| Kind | Fires when | Notes |
|---|---|---|
comment.created | a comment or reply commits | body carried, capped at 4 KiB |
reaction.added | a reaction row is inserted | not on an idempotent duplicate |
reaction.removed | a reaction row is deleted | an approval can be withdrawn, and a gate must see that |
run.closed | a trace reaches closed | explicit close and batch runs born closed |
reaction.removed is included deliberately. An approval gate that sees only
additions cannot tell a live approval from a withdrawn one. comment.edited and
comment.deleted are reserved names, to be emitted by the comment edit and
delete routes when #158 lands. This ADR does not add those routes.
Envelope and payload
The ADR-0017 envelope is unchanged. X-Cairn-Event carries the kind. In data,
for every kind:
id,url,share_type,title,tags,expires_atalways describe the subject artifact. Consumers already project these fields (Switchboard's routing envelope maps them to.artifact), so a rule written forartifact.createdkeeps resolving the artifact for every other kind.actor_id,channel,on_behalf_ofdescribe whoever caused the event. Forartifact.createdthat is the creator, as today.- A kind-specific object carries the rest:
data.comment,data.reaction,data.run.
Two new fields are added to every kind, including artifact.created:
actor_kind:"human"only when the principal authenticated with an ambient browser session (Principal.Ambient: Pocket ID, GitHub, or the dev login). Every bearer credential is"agent": MCP OAuth tokens, personal access tokens whatever theiris_agentflag, andCAIRN_API_TOKENSentries.auth:"session" | "oauth" | "pat" | "api_token", so a consumer can apply a finer policy than the binary kind.
Both are appended after the existing fields. Adding them to artifact.created
changes its bytes. That is additive under SPEC-0012, which keeps names and
meanings stable, but the golden fixture is updated and consumers see two extra
keys. The alternative, omitting actor_kind from artifact.created, would make
the most common event the one that cannot be trusted.
Approval class: mark, do not refuse
An operator-configurable set of emoji is the approval class
(CAIRN_APPROVAL_REACTIONS, default 👍 ✅ ✔️, matched after stripping skin-tone
modifiers and variation selectors). A reaction.added event carries:
data.reaction.approval_class: the emoji is in the class.data.reaction.approval:approval_class && actor_kind == "human".
An agent's 👍 is recorded, shown and announced, but always with
approval: false. Consumers gate on approval, a field the server computes and
the signature covers, and no client input can set it.
The storage change is what makes this real rather than cosmetic.
reactions and comments gain actor_kind. The reaction uniqueness key
becomes (artifact_id, anchor_type, anchor_key, emoji, actor_id, actor_kind),
and every un-react path (Unreact, UnreactByID) matches the caller's
actor_kind as well as its actor_id. The effects:
- The human's 👍 and the agent's 👍 are two rows.
- The agent cannot occupy the human's row.
- The agent cannot withdraw the human's approval.
The same rule governs comment edit and delete when #158 lands: an agent credential cannot edit or delete a comment its human wrote from the browser. This resolves #159's open uniqueness question in favour of per-kind idempotency.
Who receives events
This ADR does not change who receives events.
- Owner and team subscriptions (Teams, ADR-0029). These are the intended audience. Every internal event carries the subject artifact's owner, so ADR-0029 routes a comment on Alice's artifact only to Alice's or Alice's team's subscriptions. The owner is internal routing data and is not on the wire.
- No instance-wide target. The new kinds are never sent to
CAIRN_OUTBOUND_WEBHOOK_URLS. ADR-0029 removes those variables in the change that ships owned subscriptions, superseding ADR-0017's instance-wide delivery, and this ADR adds no kind allowlist, transition or interim path for them (Joe's design review, 2026-09-22: "nuke it 100%"). Each subscription's own event-type filter (ADR-0029) selects the kinds it receives. - Until subscriptions ship (#185), the new kinds have no recipient. They are
emitted internally and counted, and nothing is delivered.
artifact.createdkeeps whatever deliverymainhas at the time, and gains the two actor keys. - Never the actor's subscriptions. An event about Alice's artifact never goes to a subscription belonging to Bob just because Bob commented or reacted.
Delivery
Delivery keeps the same queue, retry and signature as ADR-0017. Annotation events
must not starve creations: when the bounded queue is more than half full, new
annotation events are dropped (and counted) before any artifact.created is. The
doorbell stays a hint, and the artifact and its annotations stay the record.
Consequences
- Good, because a human approval becomes a signed, server-derived fact that the human's own agents cannot mint, occupy or withdraw.
- Good, because one emitter, envelope and signer carry every kind, and a subscription sees only the kinds its filter admits. No operator-chosen target ever sees a comment or reaction.
- Good, because Switchboard's
.artifactprojection keeps working unchanged for every kind: the subject fields sit where they always have. - Good, because it resolves #159's uniqueness question with a rule that also closes the un-react and edit hijacks.
- Bad, because a human who reacts 👍 from the CLI with their own personal token
is recorded as
agent, and the reaction is not an approval. Approvals happen in the browser. That is the honest price of "a token on disk is not human presence", and the docs say so plainly. - Bad, because an agent driving the human's logged-in browser (browser automation over the human's own profile) is an ambient session. Server-side credential typing cannot see that. The residual risk is recorded, not solved.
- Bad, because
artifact.createdgains two keys, and the golden fixture pinning its bytes is deliberately updated. - Bad, because the new kinds reach nobody until ADR-0029's owned subscriptions ship (#185, raised to P0 for this reason). The instance env targets that could have carried them sooner are being removed, not extended, so the approval signal waits for the tenancy work.
- Bad, because the reactions uniqueness change needs a migration (a new column, plus a unique index rebuilt concurrently).
- Neutral, because
on_behalf_ofstays self-reported context, as SPEC-0012 already says. REST comments still accept it from the body; it is documented as asserted and never used for trust.
Confirmation
- Golden tests pin the bytes of each new kind, and the updated
artifact.createdfixture shows the change is two appended keys. - An integration test posts a 👍 over MCP, then the same 👍 from a browser
session for the same human. It asserts two rows,
approval: falseon the first event andapproval: trueon the second. It then asserts the MCP un-react removes only the agent's row. - A delivery test asserts that no new kind is ever sent to an env target while that code still exists, and that each kind reaches only subscriptions of the subject artifact's workspace whose filter admits it.
- Switchboard's story (below) verifies a signed
reaction.addedend to end.
Pros and Cons of the Options
A. Extend ADR-0017, filter kinds per subscription, mark agent approvals (chosen)
- Good, because no new secret, verifier or queue is needed.
- Good, because agents that react today keep working; their reactions are simply labelled.
- Good, because
approvalis computed where the credential is known, so a consumer cannot misinterpret it by forgetting a check. - Bad, because a consumer that ignores
approvaland gates on the raw emoji can still be fooled. The field's name and the docs make that the consumer's explicit choice, not an accident.
B. Refuse approval-class reactions from agent credentials
- Good, because there is no agent 👍 to misread, even for a careless consumer.
- Bad, because it breaks agents that already use 👍 to acknowledge an artifact. The unified annotation layer (ADR-0006) makes reactions a shared vocabulary for humans and agents.
- Bad, because it forces a per-emoji policy into the write path of every surface (REST, MCP, web), and a 403 on an emoji is an odd contract to explain.
- Bad, because it still needs A's storage fix. Without the per-kind uniqueness key, an agent could still withdraw the human's 👍.
C. A separate annotation-event channel
- Good, because ADR-0017 stays frozen and its consumers are untouched.
- Bad, because it is a second emitter, secret, target list and verifier for Switchboard, for the same kind of fact.
- Bad, because per-owner routing (ADR-0029) would then have to be built twice.
D. Poll
- Good, because it needs no server work.
- Bad, because it defeats the on-demand one-shot milestone, whose acceptance criterion is "no polling".
- Bad, because polling reads can't tell a human reaction from an agent's either, so the forgery problem remains.
Architecture Diagram
flowchart LR
subgraph Surfaces
W[Browser session<br/>Ambient]
M[MCP OAuth token]
P[PAT / API token]
end
W -- actor_kind=human --> S
M -- actor_kind=agent --> S
P -- actor_kind=agent --> S
subgraph Cairn
S[annotation / trajectory / store services] -- post-commit event<br/>+ owner --> E[outboundhook emitter<br/>one queue, one signer]
E --> F{route}
end
F -- owner or team subscription<br/>whose filter admits the kind<br/>ADR-0029 --> T2[User or team targets]
T2 --> SB[Switchboard cairn webhook]
SB -- rule: reaction.approval == true --> H[Harness one-shot]
More Information
- Extends ADR-0017. This ADR deliberately reverses ADR-0017's "the sole event kind today" framing. It keeps ADR-0017's delivery semantics: in-memory, best-effort, three attempts.
- Extends ADR-0006. Reactions and comments gain a stored
actor_kind, and reaction idempotency becomes per-kind. - Related records (front-matter edges): Cairn ADR-0029 / SPEC-0023 (Teams and tenancy; owner and team webhook subscriptions, folding in #185); Cairn ADR-0023 / SPEC-0017 (secret redaction runs before persist, so an event never carries an unredacted comment body).
- Switchboard: its
cairnwebhook kind must accept and project the new kinds. It is filed as a cross-repo story.actor_kindis a natural input to Switchboard's trusted-actors decision (Switchboard ADR-0031 / SPEC-0026) and to its notify hooks (Switchboard SPEC-0024). - Harness: a Harness one-shot can be triggered from a Switchboard rule on
reaction.addedwithapproval == true(Harness ADR-0021, on-demand one-shots). - Existing issues: #142 (annotation surface gaps), #158 (comment edit and
delete), #159 (reaction
on_behalf_of), #185 (instance-wide targets). - Design review, Joe, 2026-09-22: the instance-wide env targets are removed
outright by ADR-0029, with no deprecation or dual path, so this ADR drops the
CAIRN_OUTBOUND_WEBHOOK_EVENTSallowlist it first proposed. Delivery of the new kinds therefore depends on #185. - Design review, 2026-09-22, resolutions: #185 (owned subscriptions) is raised to
P0, because it is the only delivery path for these events and this work is
P0. An agent's approval-class reaction is recorded but marked
agent, and only a browser session's reaction counts as an approval, as decided above. - Spec: SPEC-0016 (
docs/openspec/specs/lifecycle-events/).