An episode records decisions, resolves questions and finishes tasks, and every one of those
is a typed line in the ledger of one machine. The ledger is retention-capped and local; the
note a blocked task leaves is local; a decision is visible in majordomus history until the
cap removes it. Each is evidence of something true about the repository, and until this
subsystem none of it reached the tracked tree, another checkout or a surface the registry
feeds.
Majordomus derives it. At the moment what the episode knows stops being reachable — the
provider's end event and its compaction event — the ledger lines the episode stamped are
turned into candidate knowledge records under .ai/repo/knowledge/candidates/, from the
ledger and git, deterministically, without a model and without reading a conversation. A
person reviews the queue and promotes what deserves it. Nothing about it depends on a worker
remembering a command, and a repository in which episodes close and no knowledge advances is
reported as a stopped writer. .ai/repo/adrs/0091-knowledge-is-derived-at-the-episode-boundary-and-a-person-promotes-it.md
is the decision.
What a record is
The knowledge base is the knowledge kind, schema knowledge/v1, under
.ai/repo/knowledge/, and nothing else: no new kind, no second store, no memory service, no
agent (ADR 0010, project.no-new-nouns). A record is one assertion about the repository, in
one line, with:
| Field | Values | Meaning |
|---|---|---|
class | fact, convention, constraint, memory, lesson | what kind of thing is asserted |
status | candidate, verified, superseded | who has stood behind it: the deriver, a person, or nobody any more |
epistemics | observed, inferred, decided | how it is known |
provenance.origin | authored, extracted | a person wrote it, or the tool derived it |
provenance.derived_from | session:, decision:, task:, commit:, issue:, file:, test: | the evidence, as typed references that resolve |
relations | relates_to, documents, supports, contradicts, supersedes, depends_on | what it stands in relation to, never what it restates |
A record's provenance outlives the files it cites (I1987). A file: or test: reference
the tree no longer holds resolves through git history — the newest commit reachable from
HEAD that touched the path, which still holds it or deleted it — and file:<path>@<commit>
pins it to one commit. knowledge check names such a reference as historical rather than
passing it in silence (--json lists them under historical), and knowledge record resolves
it as historical with the blob that answers, <commit>:<path>, as its target; the shell
check (mj_knowledge_file_in_history) and the Rust resolver (file_in_history) answer alike.
A path no commit of this history ever held still resolves to nothing, and so does a pin at a
commit that did not hold it. A shallow clone that lacks the commit cannot vouch for it, as for
a commit: reference. test/cases/3023 deletes a cited file and commits the deletion.
A record is never a summary of a conversation and never quotes a prompt or a turn; the check
that keeps project.never-store-transcripts applies to its title, its description and its
body. An ADR, a rule and a document are sources of their own: a record relates to them and
never restates them. SCHEMAS.md has the file format with an example.
curated/ and candidates/ hold records, one status apart. curated/ is what this repository chose to
write about itself, status: verified. candidates/ is what the tool derived and nobody has
reviewed, status: candidate; it is tracked, for the reason every closed session record is
tracked: a record only one machine can read is a record nobody reads (ADR 0014). Both are
source classes in .ai/repo/knowledge/sources.yaml, discovered through the git index like
every kind, and both carry a README.md contract.
On the trunk, the records wait in the outbox
A trunk that changes only through pull requests cannot commit what an episode boundary writes
there, so a candidate written into candidates/ on the trunk would leave the trunk dirty for
good. The ADR 0091 amendment of 2026-10-15 (I1984) moves where a trunk episode writes, not what
it writes.
Where they go. The trunk is the remote's default branch as mj_git_trunk_ref reads it
(refs/remotes/<remote>/HEAD); a checkout is on it when HEAD is the local branch of that
name (mj_on_trunk). There, knowledge derive writes each new candidate to
.ai/local/state/outbox/candidates/<id>.md, and session close and recover episodes
write the closed session record to .ai/local/state/outbox/sessions/, instead of the
tracked sections. The outbox is checkout-local and never tracked, so closing an episode on the trunk
leaves git status --porcelain empty. A candidate whose id is already promoted or superseded
is skipped as before, and one already tracked with the same bytes is reported unchanged and
is not staged. A repository with no remote, or no remote default branch, and a detached
HEAD are not on a trunk: there the records go where they always have.
When they leave. The next session close on any other branch of the same clone drains
the outbox (mj_session_outbox_drain), after the episode's own record and before its
derivation. Each file moves into .ai/repo/sessions/ or .ai/repo/knowledge/candidates/
unchanged and under its own name, so a record still names the episode and the branch that
wrote it, never the one that carried it, and that branch's pull request brings it to the
trunk. The move happens once per record: a session record the section already holds by
session_id, and a candidate already promoted, superseded or tracked with the same bytes,
is retired from the outbox, not written again. Each move is one line on standard error
(outbox: carried <path>, outbox: <id> is already tracked; the staged copy is retired),
because the close's standard output is the path of its own record.
What says so meanwhile. Until a branch carries them, staged candidates are not in the
review queue: knowledge candidates, the index and the surfaces read candidates/, and
majordomus.candidates-reviewed neither counts them nor reports them as untracked. So a
non-empty outbox is named where a person looks first. context prints one line,
outbox <n> session record(s), <m> knowledge candidate(s) in .ai/local/state/outbox/, waiting for a branch episode to carry them; doctor reports an INFO finding outbox
under the category clone with the same counts and ls .ai/local/state/outbox/*/ as its
reproduce. Neither is a failure: the outbox is where a trunk episode's records belong, and a
clone that never branches again still says what it holds. An empty outbox says nothing.
A file that is not a record. A session file without session/v1 front matter naming a
session_id, or a candidate without knowledge/v1 front matter whose id is its file name,
is named on standard error and left where it is: moving it would put an invalid file into a
tracked section, and removing it would lose whatever it was. So is a session file whose name
another episode's record already takes in the tracked section. The drain ends with
outbox: <n> file(s) stay in .ai/local/state/outbox/ (run: majordomus doctor), and doctor
and context keep counting the file until a person moves or removes it. Case 3060 proves
the trunk half and case 3063 the drain.
What is derived, from what
The evidence is the ledger lines the episode stamped and the commits it made. The mapping is this table and the deriver implements exactly this table; ADR 0118 added its observation row.
| evidence | record | class | epistemics | derived_from |
|---|---|---|---|---|
decision.recorded | the decision text | convention | decided | session:<episode>, decision:<task-id>; the session alone when the line carries no task |
question.resolved | the answer, stating the question it answers | fact | observed | session:<episode>, task:<task-id> |
task.finished with outcome blocked, failed or no_match | the outcome and the first line of the task note's # Reason | lesson | inferred | session:<episode>, task:<task-id> |
task.finished with outcome completed and a verify command | the verification that passed | fact | observed | session:<episode>, task:<task-id> |
observation.recorded (ADR 0118) | the statement, with about set to the subject and route derived from it | lesson | observed | session:<episode>, task:<task-id> when one was active, and each evidence reference that resolves now (a commit, a tracked file or test) |
commits of the episode touching .ai/repo/rules/ or .ai/repo/adrs/ | no record; each record of the episode gains commit:<sha> in derived_from and relates_to file:<path> in relations | — | — | — |
Every derived decision is a convention. Reading the wording to decide whether a decision
binds would be a kind inferred from prose, which the knowledge compiler refuses everywhere
else, and it is exactly the judgement promotion exists to record: --class at promotion is
where a person makes it a constraint or a lesson. A task.finished with any other
outcome, or completed without verification, yields no record. A handover's Next Action is
continuation state, not knowledge, and is never derived from.
Same ledger, same git, same bytes. The record id is the episode id followed by a digest
of the canonical evidence string, so the same fact always lands in the same file and two
worktrees never write one name for different content. The date is the day of the evidence
line, never the clock, and no random value enters the file. A ledger line's free text is
unescaped once when read and escaped once when the record is composed, so a quotation mark
inside a decision neither cuts the record short nor changes its identity. A line whose text
carries a transcript marker yields no record; it is reported as skipped rather than written
for the integrity check to refuse. No network, no model, no eval
(project.no-network-no-eval).
What a session observed
A worker records friction it met with majordomus knowledge observe --kind <kind> --subject <ref> [--evidence <ref>]... "<statement>" (ADR 0118). The kinds are friction,
workaround, defect, drift and repetition; the subject is a repository path or
capability:, rule:, command:, gate: with an identifier; evidence references are
file:, test:, commit:, issue: or claim:. The command writes one
observation.recorded line and judges nothing; malformed input is refused with exit 2 and
nothing is written.
Where an observation routes. In this order: a command:, capability: or
rule:majordomus.* subject, a path under the vendored rules, or a path under the tool's own
share/ only when that directory lies inside the repository, routes platform; another
rule: or a gate: routes enforcement; a path the scope declares generated or
.gitattributes marks merge=derived routes generator; a path under docs/ routes
documentation; anything else routes project. The words are never read, and the route
decides nothing: promotion and rejection stay acts. In a repository that adopted the tool,
share/ is the project's own and routes project.
Recurrence is read. Every observation candidate is its own episode's file, so two
episodes, worktrees or machines never write one name and a merge adds files without a
conflict. knowledge candidates groups the candidates and rejected records about one subject,
with the episodes behind them — the same answer from the shell and from
knowledge_base.candidates. An episode that observed nothing writes no candidate and still
appends knowledge.derived with written: 0: evaluated, nothing found.
The two moments
The deriver runs when the checkpoint runs, for the checkpoint's reason: the provider's end event and its compaction event are the moments at which what the episode knows stops being reachable.
The close path is one. mj_session_close in lib/session.sh runs the deriver after the
session record is published and before session.closed is appended, whenever
session.knowledge_on_end is not off. Every close — the provider's end adapter, majordomus session close typed by a person, any future caller — is therefore followed by its
knowledge.derived line, and the derivation precedes the close in ledger order. The end
adapter in lib/capture.sh reads the close's outcome and reports a derivation failure through
mj_capture_session_failed; it does not derive a second time.
The compaction adapter has no close to ride on, so it calls the deriver itself, switched by
session.knowledge_on_compact and independent of checkpoint_on_compact: a compaction that
skips its checkpoint still derives.
It runs whether or not a task is active. The lifecycle is the episode's (ADR 0052), and a
task is an optional relation a record names when it has one. On demand, majordomus knowledge derive does the same for the open episode or the one named with --episode.
The switches live beside the ones that exist, with the reason each is there written as its comment:
| Key | Default | Why |
|---|---|---|
session.knowledge_on_end | true | the evidence the episode produced is about to stop being reachable; derive what it says before the handover |
session.knowledge_on_compact | true | a compaction discards the conversation while the work continues; the ledger lines it produced are still there and are derived now |
knowledge.candidates_max_files | 40 | more candidates than this awaiting review is a WARN: the queue is measured, never emptied by a machine |
knowledge.candidate_max_age_minutes | 20160 | a candidate this old was not reviewed, it was ignored; doctor names it |
.ai/repo/policy.yaml and share/skeleton/policy.yaml carry them; the policy schema and the
regenerated allow-list accept them; mj_validate_policy_defaults and
mj_validate_policy_completeness see them as they see every key.
The evidence that it ran
Every derivation is a ledger line, registered in share/events.yaml before it is ever
written, because an unknown event name is an error.
| event | extra fields | written by |
|---|---|---|
knowledge.derived | episode, written, unchanged, paths, and skipped when a line was refused | every non-dry-run derivation that resolved an episode, even when it wrote nothing |
knowledge.promoted | id, path | knowledge promote |
knowledge.rejected | id, reason, and superseded_by when --by was given | knowledge reject |
A derivation that fails inside a provider hook is recorded as provider.event.failed through
mj_capture_session_failed, and the hook exits 0: the provider is never blocked and a failure
is never silent. A switch that is off is said on stderr and leaves no derivation line, which
is what the stopped-writer check treats as deliberate.
The promotion act
verified is written by an act, never by the deriver.
majordomus knowledge promote <id> [--class <c>] < evidence.md moves the candidate to
curated/, sets status: verified, sets the class the person chose, dates it today, keeps
derived_from and adds the promoting episode when one is open, appends the evidence under
# Evidence, removes the candidate, appends knowledge.promoted and prints the curated path
last. An empty stdin is refused, because an act without evidence is an assertion; stdin that
carries a transcript marker is refused for the reason a record may not carry one.
majordomus knowledge reject <id> --reason "<why>" [--by <id>] rewrites the candidate in
place as superseded, names superseded_by when --by gives the record that replaces it,
appends the reason under # Rejected, appends knowledge.rejected and prints the path last.
A superseded record stays under candidates/ as the rejection a person made, and counts
toward neither the cap nor the age.
A person editing the file is the other act. The deriver skips an id that has been promoted or superseded, so neither act is undone by the next episode boundary.
What the next worker is told
The start briefing (ADR 0017) gains one bounded section, after the open questions and before
the handover: the candidates awaiting review on this branch, the count on one line and the
ids beneath it, bounded the way the open-questions block is and inside
session.briefing_budget_lines. A candidate's branch is the branch of the episode its
derived_from names, read from the tracked session record or, while that record is not yet
written, from the ledger. A candidate whose episode this checkout cannot resolve is named on
a distinct line as unattributed rather than hidden, because a record the briefing cannot
place is still a record somebody has to review. Absence is printed rather than omitted.
The commands
majordomus knowledge is no longer read-only; share/commands.yaml says so and CLI.md has
the prose. Every write is atomic — a temporary file in the same directory, renamed over the
target — never staged, and prints the path written on the last line, the way handover and
checkpoint do.
| Command | Reads | Writes | Exit codes |
|---|---|---|---|
knowledge derive [--episode <id>] [--dry-run] [--json] | the ledger window of the episode, the task notes under .ai/local/state/completed/, the sessions store, git (the episode's commits and the paths they touched), the policy, both record directories | candidates/<id>.md per record, on the trunk .ai/local/state/outbox/candidates/<id>.md; knowledge.derived; nothing under --dry-run | 0 (also with no episode or nothing changed), 2 usage, 10 the policy does not parse, 13 write failure |
knowledge candidates [--json] | candidates/ on disk (tracked or not, so a candidate the hook just wrote is listed), the sessions store, the ledger | nothing | 0, 2 usage |
knowledge promote <id> [--class <c>] < evidence | the candidate, stdin, the open episode | curated/<id>.md; removes the candidate; knowledge.promoted | 0, 2 usage, 10 empty or transcript stdin, status not candidate, class outside the enumeration, 12 no such candidate, 13 write failure |
knowledge reject <id> --reason "<why>" [--by <id>] | the candidate, both directories for --by | the candidate rewritten in place; knowledge.rejected | 0, 2 usage or empty reason, 10 status not candidate or --by names no record, 12 no such candidate, 13 write failure |
knowledge check [--json] | both record directories, share/allow/knowledge.txt, the sessions store, the ledger, git | nothing | 0 no failure, 2 usage, 10 any failure |
knowledge validate [--json] | the same nodes and edges nodes compiles, and the executable's index for the schema | nothing | 0 no owned failure, 2 usage, 10 an owned reference is broken |
knowledge sources, nodes, edges | unchanged; edges --type derived_from now lists a record's provenance edges | nothing | unchanged |
The output of derive is one line per record — written <path>, unchanged <path>,
skipped <path> (promoted|superseded|transcript), or would write <path> under --dry-run —
then a summary line naming the counts and the episode; with no episode to derive from it says
so and exits 0 without a ledger line. --json on every subcommand is one object with the
same content as the text.
The Rust executable serves the read side: bin/majordomus-cli knowledge candidates,
knowledge record <id> and knowledge status, each the command-line projection of a
capability declared once in apps/majordomus-cli/src/capability/builtin/knowledge_base.rs
and served identically over HTTP, OpenAPI, MCP, the cockpit's catalogue and the benchmark
inventory (CAPABILITIES.md). record resolves every reference the record names to an
object of the index, an external fact of the ledger or git, a historical file, or missing; status reports the
last derivation, the newest close, the freshness against the policy and the stopped-writer
judgement. It writes nothing and reports absence as absence.
Enforcement
The rules below belong to the standard package, vendored into every repository the tool
initialises and dispatched from the doctrine registry (DOCTRINE.md). They ship with the tool rather than as
project rules because doctor refuses, in every repository, a validator no rule of the
effective set declares, and a disposable test repository carries only the vendored package;
ADR 0052 met the same constraint and answered it the same way.
| Rule | Class | Validator | Enforced by | Finding |
|---|---|---|---|---|
majordomus.knowledge-observed | blocking | mj_validate_knowledge_lifecycle | doctor, watch | the newest session.closed names an episode no knowledge.derived names and is older than session.freshness.stale_minutes; or the switch is on and the lifecycle source never calls the deriver |
majordomus.knowledge-graph | advisory | mj_validate_knowledge_graph | doctor, watch | a duplicate node id, an edge with a missing end or without provenance, a declared path or a tracked source that no longer exists; and whether the graph can be built in this checkout at all |
majordomus.knowledge-integrity | blocking | mj_validate_knowledge_integrity | check, doctor | a record that is not schema-valid, uniquely identified, evidenced when verified, fully resolved, correctly superseded, or that is a candidate claiming verified, or that carries a conversation |
majordomus.candidates-reviewed | advisory | mj_validate_knowledge_accumulation | doctor | more candidates than the cap, a candidate whose review age exceeds the policy age, or a candidate version control does not track |
The stopped-writer judgement is gated the way ADR 0052 gated its own: a checkout whose ledger
holds no knowledge.derived line at all is reported as a skip, so the day the deriver
arrives no checkout turns red, and the first derivation ends the skip. Its reproduce is
read-only — majordomus history --event session.closed and majordomus history --event knowledge.derived — and the remedy follows fix: in the message. The blocking
validators span the shell writer and the Rust reader: the freshness half of the judgement is
made on both sides from the same ledger and the same policy numbers, and the wiring half is
the shell validator's alone, because only the shell tool can read its own source.
The advisory rule measures the queue and empties nothing. Review age is the age of the queue
entry — the oldest knowledge.derived line naming the file, then the commit that added it,
then the record's date — and the finding says which source it used. It is dispatched from
doctor only: under watch an advisory finding is drift, and a full review queue must not
turn watch red in a hook. A candidate staged in the trunk's outbox is outside candidates/
and outside this rule; doctor names the outbox under clone instead.
The graph rule judges what the compiler built rather than what a record asserts, and it has
one judge: every finding is one the compiler emits while it extracts, and knowledge validate, doctor and watch only classify it. Which break fails is decided by where the
relationship was stated. One Majordomus derives from a structured field — an issue's
milestone and depends_on, a claim's source, implementation and test, a rule's
dependency — is owned, and a broken one fails the command (exit 10), warns under doctor,
where the rule is advisory, and is drift under watch. A link an author wrote in prose may
point outside the repository on purpose, so a target it names that resolves to nothing is
reported and never fails; doctor and watch summarise those in one line. A tracked source
the working tree no longer holds is reported as source_missing — a deletion in progress —
and is drift under watch. No generated graph is stored (I0810 was cancelled because none
exists), so there is no index to go stale and no stored hash to mismatch; the command says
so on every run, and doctor answers whether the graph can be built here by compiling it
into a temporary file it removes. Nothing is rebuilt or repaired.
Each rule names its tests and its claims; scripts/ci/rule-proof-check and
scripts/ci/claim-proof-check refuse a rule whose proof is missing and a claim whose test
does not name it. CLAIMS.yaml carries one claim per enforced behaviour.
From evidence to surface
flowchart TD work["work happens: decision add, question resolve, finish"] ledger["the ledger: decision.recorded, question.resolved,<br>task.finished, stamped with the episode"] git["git: the episode's commits and the rules<br>and decision records they touched"] pc["provider fires PreCompact"] se["provider fires SessionEnd, or a person<br>runs session close"] derive["majordomus knowledge derive<br>deterministic, no model, no network"] cand[".ai/repo/knowledge/candidates/<episode>-<digest>.md<br>TRACKED, status candidate, origin extracted"] event["knowledge.derived in the ledger:<br>episode, written, unchanged, paths"] brief["the next start briefing:<br>candidates awaiting review on this branch"] promote["majordomus knowledge promote <id> < evidence<br>a person's act"] reject["majordomus knowledge reject <id> --reason<br>superseded in place"] curated[".ai/repo/knowledge/curated/<id>.md<br>status verified"] index["the index: source classes curated and candidates"] surfaces["CLI · HTTP · OpenAPI · Swagger UI · MCP · cockpit · site<br>knowledge_base.candidates · record · status"] doctor["doctor and watch:<br>knowledge-observed · knowledge-integrity · candidates-reviewed"] work --> ledger work --> git pc --> derive se --> derive ledger --> derive git --> derive derive --> cand derive --> event cand --> brief cand --> promote --> curated cand --> reject cand --> index curated --> index index --> surfaces event --> doctor cand --> doctor
Linked from this diagram (3)
What it does not do
It does not call a model, a network or eval, and it does not read a conversation, a prompt
or a handover. It does not add a kind, a directory family, an agent, a role or a registry;
the knowledge kind with a class and a status carries the meaning. It does not gate on a
task being active, finished or handed over. It does not write verified, does not stage and
does not commit. It does not decide which candidates deserve promotion; the advisory rule
exists so that the queue is seen, not so that it is emptied by a machine.
Related
CONTINUITY.md for the episode and the records that survive it ·
CLI.md for every command · SCHEMAS.md for the record format and
the events · DOCTRINE.md for how the three rules are dispatched and proved ·
CAPABILITIES.md for how the Rust reader reaches every surface ·
CLAIMS.yaml for the claims and their tests.