Skip to content

Every client gets an episode, and every provider capability cites its evidence

Every client gets an episode, and every provider capability cites its evidence

majordomus://adr/adr-0103proposed architecturesessionprovidersmcpgovernance

it references

referenced by

Context

Majordomus draws the episode boundary below the model. The argument is in lib/capture.sh and it is a good one: a worker told to open a session opens one when it remembers to, which is never the episode that mattered — the one that ended in a crash, a compaction, or somebody closing the window. The provider knows, it fires an event, and the boundary is drawn there or it is fiction.

Measured on 2026-09-11, that was wired for one provider, and the tool's own account of the situation was wrong in two directions at once.

It reported one provider out of six. share/providers.yaml declares agents, bb, claude-code, codex, gemini and generic. majordomus capture status looped over mj_capture_providers — the providers with a line in the prompt adapter table — and printed two rows, both claude-code. The five others were not reported as unsupported; they were not reported at all. The vocabulary had a word for them, unsupported, whose own definition was "no adapter: no documented event hands a command the prompt before the model runs" — and because the loop was over the adapter table, no provider a person could ask about could ever reach it. A state that cannot be printed is not a state. Somebody asking "can Codex capture prompts in this repository" got silence, and silence reads as no.

And the silence had become false. The comment above the adapter table says a provider absent from it "is unsupported: it has no documented event that hands the person's prompt to a command before the model runs". That was a true statement about the world when it was written and is not one now:

session boundaryprompt before the model
Claude CodeSessionStart, SessionEnd, PreCompact, PostCompactUserPromptSubmit (prompt_text)
Codex CLISessionStart, SessionEnd, PreCompact, PostCompactUserPromptSubmit (prompt)
Gemini CLISessionStart, SessionEnd, PreCompressBeforeAgent (prompt), BeforeModel

All three verified against vendor documentation on 2026-09-11; the citations are in share/providers.yaml, one per cell. Two products had shipped hook systems and this tool went on implying, by omitting them, that they had none. The omission was a statement about somebody else's product that the tool had no evidence for and had never been asked to defend, because nothing in the declaration ever asked "where did you verify that".

The third fact is the one the mandate was about. Even with adapters for all three, episode continuity would be a privilege of tools that happen to ship hooks. A client with none — the generic provider, "a worker with no convention of its own" — would have no boundary at all, and the repository's claim to draw the boundary below the model would remain a claim about whichever vendors had obliged.

Decision

One. A provider capability is declared as data, with a citation per cell.

share/providers.yaml grows an offers: block per provider: episode (hooks | connection | none), prompts (hook | none), and an evidence string for each — a vendor documentation URL, or the statement that no such documentation exists together with what was searched for it. The words deserialise into enums, so a value outside the vocabulary fails the read of the file with its path named rather than degrading to none.

The key is offers and not lifecycle because lifecycle: on a provider entry already names something else: the events this tool's own adapter for the provider drives (a list, read by the lifecycle.providers capability and held against MJ_CAPTURE_LIFECYCLE by test/cases/170). That list is the wiring; offers is the vendor's capability. They are two facts, kept in two keys, for the reason given under Two below.

Every surface is a projection of that one declaration. share::ProviderDeclaration carries it, product::ProductProvider carries it, and from there the command line (majordomus product providers), the HTTP route (/api/v1/product/providers), the MCP tool and resource (majordomus_providers, majordomus://product/providers), the generated docs/generated/providers.{json,yaml} and the site's product dataset all follow without a line of their own. Adding a provider or changing a cell is one edit and a majordomus generate.

Two. The capability is not the wiring, and capture status reports both.

Six words now, not five. unsupported is reserved for a provider that cannot do the thing, and the declaration's citation is the reason it prints. unadapted is new and is the honest name for the state Codex and Gemini are in today: the provider documents the event, this distribution ships no adapter line, and the gap is in the tool. Collapsing the two — which is what the code did — made this tool say something untrue about somebody else's product. unconfigured, named, wired and verified are unchanged.

capture status loops over the union of the declared providers and the adapter tables, so every provider is reported, with its declared capability in one column and this checkout's state in another.

Three. A client with no hooks gets its episode from the connection.

For episode: connection, the MCP connection is the boundary:

stateDiagram-v2
  state "peer attached, no episode" as peer
  state "episode open" as open
  state "detached, not closed" as detached
  state "closed" as closed
  [*] --> peer : initialize
  note right of peer : a reader is not a worker
  peer --> open : episodes.attach(external_id) opens it, or resumes the client's own
  open --> open : every message the client sends is its heartbeat
  open --> detached : connection lost
  detached --> open : episodes.attach again, same episode, new peer
  open --> closed : episodes.detach, deliberately
  detached --> closed : no reconnect in 15 min, by the reaper, as interrupted
  open --> closed : server stops, shutdown
  closed --> [*]

apps/majordomus-cli/src/episodes.rs holds the board. It does not write a session record: it runs majordomus capture session --provider generic --event start|end, the same command a provider hook's shim runs, with the same payload shape, through the same reader. lib/capture.sh admits a provider whose declared episode source is connection without an adapter line, because a provider that fires no event has no vendor payload shape to adapt to and nothing to install.

Four. A peer is not an episode, and an episode is keyed by an external identity.

A peer id (p1, p2, …) is this process's name for this connection. It is handed out in attachment order, reused by the next server, and is not a worker identity — on 2026-09-09 a session in this repository announced, was re-established under a new id, and was invisible to eight others for three hours because everybody treated it as one. So an episode is keyed by an identity the client supplies and can reproduce, the peer holding it is a mutable field, and the invariants are: a connection holds zero or one episode; initialize alone holds none; a reconnecting client re-associates with its own episode by name; and an episode outlives its connection for a bounded grace, because a dropped socket is the most ordinary thing that happens to work in progress and is not the end of it.

Five. What is not claimed.

Raw prompt capture stays provider-specific. An MCP server is handed initialize, tool calls and notifications; the person's prompt is never among them, in any version of the protocol. generic.offers.prompts is none with that reasoning written into its evidence field, and there is deliberately no connection value under prompts for any provider to reach for.

Alternatives rejected

Open an episode on initialize. It would make "every client gets an episode" literally true and it is wrong twice. A client that opens the server to read one rule is not a worker and should leave no record; and test/cases/90_mcp_shared_server.sh asserts that serving changes the repository not at all, which is a guarantee worth more than the convenience. Attach is one call and the client that wants an episode is the client that knows it wants one.

Key the episode by the peer id. The reason this is wrong is written above and was paid for in hours rather than argued.

Close the episode when the connection drops. It would publish a record saying the work ended every time a socket did. The reattach grace is fifteen minutes against the transport's ninety-second idle timeout, and they are two clocks because they answer two questions: is this socket alive, and is this work over.

Write the session record from the Rust executable. A second writer of the record the provider hooks already write is the repeated semantic definition ADR 0004 forbids. The board runs the one command that already does it.

Ship adapters for Codex and Gemini in this change. They are real and they are a different piece of work: each needs a writer for a configuration format this tool does not write today (a TOML [hooks] table, a JSON hooks block), and each carries caveats an adapter must encode — Gemini does not await SessionEnd, its PreCompress cannot block, Codex's SessionEnd.reason is a constant. Declaring the capability with its citation and reporting unadapted names the gap accurately today; implementing it while guessing at it would not.

Consequences

  • capture status reports twelve rows, not two, and every one carries the evidence behind it. Two of them say unadapted, which is a standing, visible gap in this distribution rather than a silence.
  • majordomus product providers and every projection of it gain the capability matrix. Nothing had to be edited surface by surface, which is the point.
  • A generic MCP client — anything that can be pointed at bin/majordomus-mcp — gets an episode, a working context, a briefing and a closed record, with no vendor cooperation.
  • The new blocking invariant is project.a-provider-capability-cites-its-evidence: a declared capability without a citation is refused. Its validator is scripts/provider-evidence-check and test/cases/200_generic_mcp_episode.sh drives the whole lifecycle over the real transport.
  • Two clocks now govern a client's disappearance, and a reader of the logs will see a connection reaped long before the episode it was carrying. That is intended and is written down here because it will otherwise look like a leak.

Where this comes from

canonical file
.ai/repo/adrs/0103-every-client-gets-an-episode-and-every-provider-capability-cites-its-evidence.md
identity
adr-0103
also served as
MCP resource majordomus://adr/adr-0103 · majordomus entity show adr/adr-0103 · GET /api/v1/entity?uri=majordomus://adr/adr-0103
in the Cockpit
/cockpit/objects/adr/adr-0103

This page is generated from the file above by scripts/generate-site-data; editing the page changes nothing, editing the file changes every surface. The relations above are edges of docs/generated/graph.json, which the executable writes from the same index.

Searches the titles and descriptions of every page this site publishes.