Reactions, conflict resolution & the relay’s version control
knock-knock is ledger-native: the only thing it persists is an append-only, content-addressed DAG of Interactions, and everything you see in Discord — every reaction, card, and DM — is a thin surface over that log. This doc covers the three layers of that surface:
- Reactions — the glyph vocabulary you use to drive and read a turn.
- Conflict resolution — what happens when two equal-role drafts collide.
- Version control — how the relay records, supersedes, and rewinds actions, and why nothing is ever lost.
The deep design of the log itself lives in
knock-knock-ledger-model.md; this doc is the
operator’s view.
A task runs in its own Discord thread (opened from a top-level
@mention); the reactions and cards below all appear there. An interaction’schannelis that task scope — the thread — while permissions and the roster stay keyed to the parent room. See the room/scope note in the ledger model.
1. Reactions — the glyph vocabulary
GLYPHS in ledger/render/surface.ts is the single visual vocabulary; every
cue draws the same symbol for the same concept. Pure renderers build the text,
a synchronization or an AgentHost subscriber decides when, and Discord I/O is
thin glue.
Presence & outcome (on your message)
| Glyph | Meaning |
|---|---|
| 👀 | received — the bot started a turn (transient) |
| 🏁 | turn completed (replaces 👀, persists) |
| ⚠️ | turn errored or produced nothing (persists) |
| ⏹ | turn was stopped by the owner (persists) |
The bot reacts 👀 the instant it starts, then swaps it for a persistent outcome marker when the turn ends — so scrolling back shows at a glance which requests succeeded.
The Workbench (the pinned activity log)
The pinned Workbench message (one per task thread, edited in place as the turn runs) uses its own read-only glyphs — these label state, you never react with them:
| Glyph | Meaning |
|---|---|
| ▸ | the agent is working (turn header) |
| ◆ | live state line — working… / replying… |
| → | a tool step, suffixed with its status |
| ✓ | a step executed / a turn finished cleanly |
| ✗ | a step failed / a turn finished with errors |
| ⛔ | a step was denied by the permission gate |
When the agent keeps a plan (its TodoWrite list), the Workbench renders it as a live checklist above the tool steps — edited in place as the agent advances it, so you watch the plan fill in:
| Glyph | Meaning |
|---|---|
| ○ | planned — not started yet |
| ◐ | in progress |
| ✓ | done |
The plan list replaces the noisy per-call → TodoWrite ✓ steps; only the
agent’s latest list is shown (it rewrites the whole list each update).
Retry re-marks cleanly. A 🔁 retry re-runs the same inbound message, so its outcome marker is re-markable: the host clears any prior outcome glyph (🏁/⚠️/⏹) before applying the new one, and the inbound side-table is retained (FIFO-bounded) rather than deleted on the first outcome. A retried-then-resolved message therefore never stacks two outcomes.
Owner controls (react on a message)
| React | Action |
|---|---|
| 🛑 | Stop the scope’s in-flight turn — aborts it promptly via the turn’s AbortController, posts a short “Stopped” note. Owner only. |
| 🔁 | Retry — re-run the turn (turn.retry). |
| ⏪ | Rewind the frontier — the next prompt starts from this point (frontier.rewind). |
| 🧷 | Checkpoint — pin a named point in the conversation (frontier.checkpoint). |
These are recorded as interactions (see §3) and acknowledged with a terse line. Only the agent’s owner can trigger them (verified by user id).
Reacting on the original message works. A top-level
@mentionspawns a task thread, but you most naturally react 🛑/🔁/⏪/🧷 on the original message — whose channel is the parent, not the thread the turn runs in. Every reaction control is routed through a single scope resolver (resolveReactionScope) that maps the reacted message back to the task scope its turn actually runs in, so a reaction on the top-level message reaches the in-flight turn in its thread. (Reactions typed inside a thread already act there.) Before this seam, 🛑/🔁 on a top-level message silently no-op’d.
Off-Discord too. Inbound reactions are normalized to this project glyph vocabulary per platform before the host sees them — Discord/Telegram/WhatsApp surface unicode, Slack surfaces named shortcodes (e.g.
:octagonal_sign:→ 🛑), and anything that isn’t a control glyph is ignored. So the same controls work on every supported surface, not just Discord.
Approvals (reserved)
| React / button | Action |
|---|---|
| Allow / Deny buttons, or ✅ / ❌ | Approve or reject a gated (ask) tool call. Owner only. |
✅ / ❌ are approval-reserved — the button/reaction handlers route them to the
Approvals service, so they are never reused for status. See the deny-floor
discussion in getting-started-agents.md.
Session sharing & watches
| Glyph | Meaning |
|---|---|
| 📥 | a session-sharing card / imported-context cue — see session-sharing.md |
| ⏳ | an armed watch — see knock-knock-watches.md |
Reserved glyphs: ✅ / ❌ (approvals), 🛑 (stop), and 📥 (session sharing) are reserved — never reuse them for status.
2. Conflict resolution
When two agents (or an agent and a human) edit the same thing, the relay surfaces the collision instead of resolving it silently.
Role-ordered merge
Every write is admitted through a role-ordered merge gate
(ledger/merge.ts, ledger/admit.ts). Roles rank owner > human > agent
(ROLE_RANK). When a write lands at an anchor another write already holds:
- Higher role wins — the lower-role write is superseded (kept in the log,
flagged on the loser’s
know:actor/<actor>/inbox), and its owner gets an override DM 🔁 explaining what changed. - Equal role ties — neither can silently win, so the relay posts a conflict card.
The conflict card 🔀
On a held equal-role conflict the relay posts a Take A / Take B / … / Write my
own card (ledger/synchronizations/conflict-card.ts). Only the owner can
resolve it; the click admits an owner-role merge.resolve that supersedes the
unchosen branches. The losing draft is kept in the ledger, never deleted,
and surfaced back to its author’s inbox.
Branch letters are hash-sorted, not arrival order. 🅰 / 🅱 come from sorting the branch set by hash — the lower-hash tiebreak is exactly what lets two machines render byte-identical cards. The second writer to land can take 🅰 if its hash sorts lower; the card says so.
Override DM 🔁
When a higher-role write supersedes your agent’s draft, the gate writes a note to
the loser’s inbox and dm-on-supersede DMs that agent’s owner a short summary —
so you learn what changed even though the draft was dropped, not deleted.
3. The relay’s version control
The ledger is the version control system. It is an append-only,
content-addressed DAG of Interactions — {actor, role, channel, target, verb, patch, effect, caused_by, …} (where channel is the task scope), hashed over
its immutable fields. Three properties make it a version control system rather
than a log:
-
Nothing is deleted — only superseded. A losing draft, a retracted note, a resolved conflict: all stay in the log with a
supersededlifecycle. The audit trail is complete; you can always see what was proposed and what later happened to it. -
State is a fold, not a mutation. A channel’s transcript, what an agent knows, a concept’s runtime state — each is a projection over a slice of the log (
ledger/fold.ts). Re-folding the log reproduces the exact same state: replay is deterministic, so a relay restart loses nothing. A supersession shows up in the live view immediately too — the store signals the fold engine, which re-folds the affected artifact, so a dropped draft leaves the projection without waiting for a restart. (Across machines on Postgres, a remote supersession still reaches a peer relay’s folds only on its restart.) -
The frontier is the head. The current set of live interactions is the frontier. The rewind/checkpoint/retry reactions (§1) are frontier control:
React Verb Effect 🔁 turn.retryre-run the most recent turn ⏪ frontier.rewindmove the head back; the next prompt starts there 🧷 frontier.checkpointpin a point you can refer back to All three are just interactions recorded against the frontier — version-control operations expressed as reactions.
Staleness cascade ⚠️
Knowledge notes carry their causal parents. Invalidating a note (knowledge.invalidate)
tombstones it and every note transitively derived from it — so a conclusion
built on retracted evidence is automatically flagged stale. A reply resting on
invalidated knowledge gets a ⚠️ stale-note flag, surfacing to the human the same
staleness the agent’s prompt context already filters out.
Cross-machine
With the Postgres backend (KNOCK_KNOCK_LEDGER_URL) the DAG is shared across
machines via LISTEN/NOTIFY, and the role-ordered merge + hash tiebreak make two
relays compute byte-identical conflict cards and supersessions. Each machine’s
folds converge on the same projected state.
See also
session-sharing.md— importing/resuming a local coding session’s context (📥).knock-knock-watches.md— the watch primitive: a turn that defers and is resumed by the world (⏳).knock-knock-ledger-model.md— the full design of the Interaction record, content addressing, role-ordered merge, fold engine, and cross-machine sync.