ADR 0048: Permanent facts over MCP
Date: 2026-10-03. Status: accepted; built 2026-10-03 (see “Built” at the end); amended 2026-10-12 (the three open
details settled: no time limit, and a request outlives its thread and server). Amended by
ADR 0062, built 2026-10-12: the pending channel carries a third kind of request, the
model’s question, which unlike a fact request is stale when its server has gone, since only its thread can take
the answer. Amends
ADR 0045 (who admits a permanent fact, and where: only the person, now also from a
terminal or wisp-tui when an MCP caller asks) and ADR 0046
(the pending channel gains a second kind of request, a fact to keep). Settles the question
ADR 0044’s second amendment left to the operator: how permanent facts are managed over
MCP.
Context
A permanent fact lives in ~/.wisp/facts.json and is given to every conversation, in chat and over MCP. D2 of
the layered-context proposal, recorded in ADR 0045, keeps admission to that store with the person: a tool or the
model may propose one, and the proposal waits in its conversation until the person moves it. Through 0.16.0 the
person could do that only in chat (/fact ID permanent, or /fact git/c3 permanent for another conversation’s
proposal), and set_fact_scope refused permanent and every p… fact. An agent delegating work to wisp mcp
could see a proposal (respond’s facts, wisp://facts/proposed) but had no way to put it to the person, and
the person, at a terminal or in the Claude mobile app, had no way to hear of it short of opening chat and
reading /inspect facts.
0.16.0 built the way for a question asked under wisp mcp to reach the person through another face: the pending
channel ~/.wisp/pending, a notification naming the request, wisp approvals approve|deny from a terminal, and
wisp-tui’s dialog (ADR 0046). The operator decided on 2026-10-03 to use the same way for permanent facts.
Considered for whether set_fact_scope waits for the person’s answer:
| Option | What it does | Why not, or why |
|---|---|---|
| (a) Wait, bounded | The call stays open until the answer or approval.timeoutSeconds, as a command’s approval does | A command blocks the turn that runs it; a fact blocks nothing. The calling agent’s loop would stall for up to ten minutes (the default bound) on a question whose answer changes nothing it is doing, and a client’s own tool-call timeout (Claude Code’s MCP_TOOL_TIMEOUT) can end the call first, leaving the request to be answered with nobody listening |
| (b) Return at once | The call files the request and returns pending with its id; the outcome is read later on the fact’s resource | Chosen. The agent goes on with its work, can say “I asked you to keep the codename” in its reply, and can read the outcome when it matters. The request is still bounded by approval.timeoutSeconds, so no question waits for ever (“no answer is not an answer”): silence keeps nothing |
Considered for how wisp-tui learns of fact requests:
| Option | What it does | Why not, or why |
|---|---|---|
(a) Ride approve-mcp | Send fact requests to any front end that declared approve-mcp | A 0.16.0 front end that declared it knows only command approvals: it would show a fact as a command dialog whose keys (y/s/p/a/n) mean nothing for a fact, and the relay would have to map once to something. An effect is a declared capability (ADR 0044); a new kind of question is a new capability |
(b) A new effect, keep-facts | Send fact requests only to a front end that declared it | Chosen. Additive: a front end that does not declare it sees nothing new. The lines reuse approval (with kind: "fact") and withdrawn, so the queue, the withdrawal, and the answer path are the ones 0.16.0 built |
Decision
- A caller asks; only the person keeps.
set_fact_scopewithscope: permanenton a thread’s fact (c…) or a session fact (s…) files a request in the pending channel and posts a notification through the session’s routes, sourceapproval: title “wisp: keep as a permanent fact?”, body “release codename = BLUE HERON — wisp facts keep a1b2c3d4”, subtitle naming who proposed it and the client and thread. The person answers where wisp asks:wisp facts pendinglists what waits;wisp facts keep IDandwisp facts drop IDanswer, from a terminal; a runningwisp-tuishows the request as a dialog with[k]eep [d]rop. Nothing in MCP answers it. - The call returns at once, with
state: "pending", the request’sid, itsexpiresAt, and the URI of the fact, where the outcome shows asrequest(pending,keptwith the permanent fact’s id,dropped,timed-out,withdrawn, orfailedwith a reason);wisp://facts/proposedshows it on each proposal too. Asking again while a request waits returns the same request, and posts no second notification. - Keep admits the fact to the shared store as the person’s, exactly as
/fact ID permanentdoes, provided the fact still says what the person was shown (an answer is bound to the fact’s id, subject, name, value, and source); otherwise nothing is kept and the request endsfailed. Audited asfact.scope.changedwithby: personand therequest. - Drop leaves the fact where it is, in its thread (or the session, for a session fact); it is not deleted. A
proposed permanent fact stops being proposed, in place, as a move to
threaddoes, so it no longer waits inwisp://facts/proposed. The thread remembers the drop by the fact’s subject, name, and value: asking about the same fact again is refused with the date and request of the drop, for as long as the thread is open. - Silence keeps nothing. A request unanswered within
approval.timeoutSeconds(0waits for ever) endstimed-out; the fact stays as it was, and the caller may ask again. A thread that closes or is evicted, and a server that stops, withdraw their requests (withdrawn). - A caller cannot remove or demote a permanent fact. Permanent facts are the person’s:
set_fact_scoperefuses everyp…fact, whatever the target. A caller that disagrees with one records a newer fact in its thread, which shows as a conflict (conflictin the facts resources) for the person to settle in chat. - No banner for the model’s proposals. A permanent fact the model distils or a tool extracts in an MCP thread
waits silently in
wisp://facts/proposed, as before.respond’s result says how many wait, asstructuredContent.factsProposed(countfor the server,threadfor this thread, anduri), so the calling agent can mention it and ask for the ones that matter. - The same channel, a second kind. A fact request is a pending request of kind
factin~/.wisp/pending, filed as<id>.fact.jsonbeside the commands’<id>.request.json, with the same mode, binding, first-answer-wins answer file, staleness (its server gone or its wait expired), and sweeps. The suffix is its own so that a 0.16.0wisp, which lists and sweeps.request.jsonfiles it can read, never sees a fact request. Each kind takes its own answers:keepordropfor a fact, the scopes andnofor a command;wisp approvals approveon a fact request, orwisp facts keepon a command, is refused with a pointer to the right command. - The same rules for answering, kept from ADR 0046:
wisp facts keep|dropanswer only with standard input a terminal, which an agent’s shell tool does not have (a speed bump, not a wall, as there); the sandbox does not let a command write under~/.wisp; and the default policy refuseswisp facts keep|dropoutright, so wisp’s own model cannot keep a fact it proposed. - Audited, every step, with the kinds ADR 0046 added:
approval.pendingwhen filed, thenotification,approval.answeredin the answering process (viacliortui), andapproval.settledon the asking thread (answeredwithdecisionkeepordropandkept;timed-out;withdrawn;failed;stale), each withkind: "fact"and the fact’s fields in place of the command’s.wisp factsrecords under entry pointfacts.
Consequences
- A caller can now get a fact into the shared store, with the person’s consent, from any client: the Claude
mobile app’s user keeps it from a terminal on the Mac or in
wisp-tui, prompted by a banner. - Every request posts a banner. Proposals the model makes do not, so the banners are as many as the caller’s requests; if that proves noisy, the caller is the one to ask less.
- A drop is remembered only for the thread’s life: a new thread may ask about the same fact again. Remembering drops across threads and sessions would need a store of declined facts; not built.
wisp chat --jsongains an effect a front end may declare,keep-facts, under which fact requests arrive asapprovallines withkind: "fact"and are answeredkeepordrop. Additive.- Plain terminal chat (
wisp chat --plain) does not show waiting fact requests, for ADR 0046’s reason. - ADR 0045 is amended: the person admits permanent facts from chat, and now also from
wisp factsandwisp-tuiwhen a caller asks. ADR 0046 is amended: the pending channel carries two kinds of request.
Built
Built 2026-10-03. PendingApprovals gained Kind (command, fact) and ProposedFact, a fact request’s own
binding and file suffix, and waiting(_ kind:); a request without a kind, as 0.16.0 filed them, decodes as a
command. FactKeeper files a request, notifies, starts a watch that polls the channel every 200 ms, applies the
answer through a closure the server gives it (ThreadActor.keepAsked and dropAsked, over Agent.keepAsked
and dropAsked, which check the fact is still what was shown), remembers drops per thread, and withdraws a
thread’s requests when it closes. WispServer routes set_fact_scope permanent to it and adds
factsProposed to respond; the facts resources gained request. wisp facts pending|keep|drop is
FactsCommand; PendingRelay takes the kinds a front end declared; wisp-tui declares keep-facts and shows a
fact dialog in wisp’s colour, [k]eep [d]rop, with Ctrl-C dropping as it refuses a command. Tested without the
model: the channel’s fact kind (filing apart, each kind’s answers, binding, an altered request, a replayed or
mistyped answer, staleness, a 0.16.0 request), the keeper (kept, dropped and remembered, asked twice, silence,
a closing thread, an answer that cannot be applied, a channel that cannot be used), the agent’s keep and drop,
the policy, the listing, the relay, the MCP path over the wire (asked, kept from the command line, dropped,
refused again, a closing thread, factsProposed, a permanent fact never moved), and the TUI’s dialog.
Amendment, 2026-10-12: the three open details settled
Three details were left for the operator to confirm when this was built (roadmap 0.23.0). The operator settled them on 2026-10-12.
- A fact request has no time limit. “Facts should not have a timeout for review.” The request no longer reuses
approval.timeoutSeconds: it is filed with no expiry and waits until the person answers. The bounded-wait rule (no answer is never a yes, and nothing waits on a person without end) still holds, because nothing waits:set_fact_scopereturns at once, as decided above, and the fact is not kept until the person answers. Silence keeps nothing, however long it lasts. The result dropsexpiresAt, and its text says the request waits with no time limit and nameswisp facts keep|drop IDandwisp-tui. Thetimed-outoutcome is gone for facts. - A request outlives its thread and its server. Chosen over withdrawing it, which this ADR did until now,
because keeping a fact needs no live conversation: the request names the fact the person is shown (subject, name,
value, and source, all in its binding), and a keep admits exactly that to the shared store. When the thread closes
or is evicted, the server’s watch goes on, and a later keep is admitted from the request
(
FactKeeper.keep(_:in:audit:)), audited on the server’s own log; a later drop just ends the request. When the server stops, the request stays in~/.wisp/pending, settledhanded-overon the thread. A fact request is never stale for its server having gone (a command’s still is), sowisp facts pendingandwisp-tuigo on showing it, and the process the person answers in takes its own answer and applies it (FactKeeper.settleUnwatched), audited there withdeliveryapplied. A request filed with an expiry by 0.22.0 or earlier still expires. The cost is that a request nobody answers stays filed until someone does;wisp facts pendingshows it, anddropclears it. - A drop is remembered for the thread’s life, as built: a new thread, or a thread reopened under the same id, may ask again. Not changed.
- A drop takes the proposal off the proposed list, as built: a proposed permanent fact is retargeted to the
thread in place and no longer waits in
wisp://facts/proposed. Not changed. A drop that comes after the thread closed changes nothing in it, the thread being gone.
The terminal-only answers were reviewed the same day and kept; see ADR 0046, “Review, 2026-10-12”.
Tested without the model: a request returning at once and staying filed with no deadline; a keep after the thread
closed admitted to the shared store and audited on the server’s log, over the wire too; a stopped server handing its
request over, and the answering process (wisp facts, the relay behind wisp-tui) applying a keep and a drop
itself; a request that names no fact, and a store that cannot be written; a 0.22.0 request with an expiry still
expiring; a drop still remembered for the thread, and forgotten when it closes.