This document explains what the model means. It contains no current figures: what is true
right now is printed by majordomus plan status and rendered on the website's roadmap from
the same data. If you want to know how many issues are ready, run the command.
majordomus start supervises one task. This layer supervises the thing a task belongs to.
The problem
A task record says what one session is doing. It does not say why that work exists, what it depends on, what would prove it finished, or what should be done after it. That knowledge lived in conversation, and conversation does not survive a session, a provider switch, or a handover to a person.
Three failures follow. Work gets picked from memory rather than from a dependency order, so something is started before the thing it needs. "Done" gets asserted rather than shown. And the plan exists in several places at once — a chat, a README paragraph, a hand-drawn diagram, a GitHub milestone — which disagree the first time one of them changes.
The two records
A milestone is an executable specification of an outcome. Not a folder for tickets: a
statement of the problem, the outcome that ends it, the current and desired state, what is in
scope and what is deliberately not, the criteria that would make it true, the validation that
would demonstrate it, and the evidence required to accept it. One file under
.ai/repo/project/milestones/.
An issue is a bounded execution contract. It carries enough for a worker with no
conversation history to execute it: the objective, why it exists, the current and desired
state, the paths it may touch, what it depends on, the acceptance criteria, the validation
commands, and the evidence its completion requires. One file under
.ai/repo/project/issues/.
Both are hand-written YAML in the same restricted subset the policy uses, and both are checked against an allowlist: a key nobody reads is an error, not a comment.
An issue names its milestone. A milestone does not list its issues. One direction means the two can never disagree about which issues belong to which outcome.
Status is derived
Neither record has a status field. Writing one is an unknown key and fails validation.
An issue records what happened to it — started_at, verified_at, completed_at,
cancelled, and its evidence — and the status follows:
| status | when |
|---|---|
CANCELLED | cancelled: true |
DONE | completed_at is set and every evidence_required token has evidence |
VERIFY | implementation is claimed complete and the evidence is not yet sufficient |
ACTIVE | started_at is set and nothing further is recorded |
BLOCKED | a dependency is not DONE |
READY | nothing above applies |
A stamp counts only when its transition wrote it. majordomus plan start|verify|done (and
the plan.transition capability) write the stamp together with its seal — started_event,
verified_event, completed_event: the SHA-256 of the event, the issue and the stamp — and
append the plan_* event to the checkout's ledger. The ledger is never tracked, so the seal
is the part of the event a clone can check. A stamp without its seal moves no status and is a
stamp_without_event failure of plan validate; the stamps recorded before seals existed
are named in each record's unsealed_stamps, a list that only shrinks
(project.no-plan-stamp-without-its-event, ADR 0097). done is refused unless the issue is
ACTIVE or VERIFY.
A milestone's status follows from its issues and its own evidence:
| status | when |
|---|---|
PLANNED | it has no issues, or none of them has moved |
ACTIVE | at least one issue is active, verifying or done |
BLOCKED | it is unfinished and no issue is ready |
VERIFY | every issue that is not cancelled is done and the milestone's own evidence is not complete |
DONE | that evidence is complete too |
A milestone is never DONE because a count of closed issues reached its total. The last step
is the milestone's own acceptance, and it is evidence-gated like every other step.
A milestone has no version either. Which release carried it is inferred, never typed: its
released_in is the first release record whose commit contains the merge of its last
accepted issue, and unreleased while it is open (ROADMAP.md, ADR 0166). A record that carries
version is refused.
The active milestone is derived the same way: the lowest-ordered milestone that is
ACTIVE, or failing that the lowest-ordered one that is not finished. Nothing declares it.
The graph
depends_on is a list of issue ids. The graph they form is validated, not trusted. Each of
these is a distinct finding naming the issue that caused it:
- a dependency on an issue that does not exist
- an issue that depends on itself
- the same dependency named twice
- an issue naming a milestone that does not exist
- a cycle, with every issue trapped in or behind it named
- an issue that is
ACTIVE,VERIFYorDONEwhile a dependency is notDONE - an issue with no acceptance criteria or no validation command — a placeholder
A failing finding makes majordomus plan validate and majordomus doctor exit non-zero.
majordomus watch reports the same violations as drift. Work in progress is reported and
never blocked: an issue that is merely unfinished is not a failure of anything.
Execution waves
A wave is a layer of the graph. Wave zero is every issue with no dependencies; an issue sits one layer past its deepest dependency. Waves are computed on every read and stored nowhere, so an execution plan cannot go stale — changing one edge moves every wave that depends on it, immediately, on every surface.
Sharing a wave is necessary for two issues to run concurrently. It is not sufficient. If
their declared scope paths overlap — one equal to, inside, or containing another — the
overlap is reported and they serialise. The direction is deliberate: a false serialisation
costs time, a false parallel costs a conflict discovered after the work is done.
A scope names the files the record plans to add or change, never a bare shared directory.
test/cases holds every other issue's planned case, so a scope entry of test/cases
contains all of them: the record serialises against every issue that plans a case,
docs/PLAN_STATUS.md lists scope_conflict warnings naming case files that do not exist
yet, and case 59 (test/cases/59_reference_integrity.sh) refuses the push over those names.
Name the case file instead, test/cases/<number>_<slug>.sh, with its number taken from
scripts/case-numbers next or from the block the seat announced. plan validate does not
warn on a bare shared directory yet, so the record's author is the check.
Evidence
An issue declares evidence_required as a list of tokens. majordomus plan evidence
attaches one piece of evidence against one token and refuses a token the issue does not
declare. It also refuses without a command or an artifact — narrative is not evidence.
majordomus plan done refuses while any token is uncovered. An issue whose completed_at
is set but whose evidence is incomplete derives VERIFY, never DONE. The evidence lives in
the issue's own file, beside the contract it satisfies, with the commit it was recorded at.
What this does not do: rerun the command. The tool records what a worker says a command produced. The commit hash stored beside it is what makes a false record checkable later.
What the work is for: intents, coverage, and the two planning records
Above the milestones sits the intent (ADR 0070): what must become true, the invariants that must stay true, and the satisfaction criteria that settle it, each naming the evidence that decides it. An intent names the milestones that realise it. It stores no status: its stage follows the plan and its satisfaction follows the evidence ledger.
Beside the stage, every surface carries the intent's verdict (ADR 0107), which the criteria
derive alone and the plan never touches: satisfied when every criterion is met, unsatisfied
when a test or claim criterion — a kind the ledger or the claim join can settle — is not,
and unknown when only command or deployment criteria are unmet, or none is declared. Its
reasons name each unmet criterion with its evidence kind and state. The stage keeps its
meaning, so satisfied still requires every milestone DONE; the verdict can read satisfied
while a milestone is open, and nothing a finished task does moves it.
A criterion is met only by evidence that is current, and current is the evidence module's
own judgement at the working tree (evidence::freshness, read through evidence::current),
the one the claim report and the evidence pages make: a passing run whose commit is in this
history, that measured a tree that was its commit, whose test still hashes to what ran, and
since which nothing the criterion names has changed — for a test, its source and the source
and implementation of every claim that test proves; for a claim, what the claim names. A
change the criterion does not name, the evidence ledger's own included, leaves it met. A test
that proves no claim naming an implementation names no code under test, so for it any change
since the run but the ledger's is one nothing rules out: such a criterion is met only by a run
at the checkout's own commit. To keep a criterion met across unrelated work, name its test in
a claim of docs/CLAIMS.yaml that names the implementation. A pass recorded on a dirty tree,
or one whose inputs moved, is stale; a failed run is failing; either un-meets a criterion
that was met, and recording a current pass meets it again. Each criterion carries proof,
the evidence module's verdict behind its state, so a met criterion still says whether it is
proven at this revision or rests on inputs_unchanged.
That relates an intent to milestones and to evidence. What relates a criterion to the work
meant to make it true is serves on the issue (ADR 0073):
serves:
- intent-planning#criteria-are-covered
The link is authored on the issue, which is written after the intent, so replanning never
edits an intent. The plan carries serves as data and derives no finding from it; the intent
engine does the judging, and majordomus intent coverage prints it:
CRITERION STRENGTH ISSUES
intent-planning#criteria-are-covered covered I1901
A criterion is covered when a live issue that requires evidence serves it, weak when every
serving issue requires none, observed when the recorded gap saw it already true, and
uncovered when nothing in the plan will make it true. Every issue also answers why it
exists: intent when it serves a criterion, maintenance when its milestone is named by no
intent — legitimate operational work, stated as such and never given an invented intent — or
unexplained when its milestone realises an intent and it serves nothing.
majordomus intent validate refuses (exit 10):
| finding | what it means |
|---|---|
criterion_uncovered | the intent has work, and this criterion has none of it |
issue_without_purpose | the issue sits under an intent's milestone and serves nothing |
serves_outside_milestone | the issue serves a criterion of an intent that does not name its milestone |
serves_unknown_intent, serves_unknown_criterion, malformed_serves | the link points at nothing |
and warns on intent_not_planned (no live issue under any milestone it names — where every
intent starts), criterion_weakly_covered, duplicate_work and milestone_contributes_nothing.
The gap and the critique
Planning starts from two records rather than from a prompt, so that what a worker observed and what a review found survive the session that produced them.
A gap, one per intent under .ai/repo/project/gaps/, states the observations made at an
observed_at commit, each with its source, and answers every criterion of the intent with
satisfied, missing, conflicting or unknown, citing the observations behind it. A gap
that leaves a criterion out is refused; unknown must be written rather than omitted, and is
never read as satisfied. A criterion observed satisfied with its observations asks for no
planned work — it reads observed — while remaining met only by evidence.
A critique, one per intent under .ai/repo/project/critiques/, is the adversarial pass
over the plan: findings classed by the question asked — missed_requirement,
unproven_assumption, insufficient_work, unnecessary_work, regression_risk,
surface_missing, delivery_verification — each blocking or not, and each resolved open,
planned into an issue that serves the intent, or rejected with a reason. A dismissal
nobody can read is not a resolution.
Work is held to the review of its plan once it has started: an issue serving an intent that is
ACTIVE, VERIFY or DONE while the intent has no critique is executing_without_critique,
and while a blocking finding is open, executing_with_open_blocker. Both are failures of
intent validate and the intent-check gate. Whether the start itself is refused is the
policy's choice (intent.binding, under Binding
below): where it is required, the issue never becomes ACTIVE.
Majordomus judges these records; it does not write them. A person or a worker — Claude, Codex, Gemini — does the observing and the criticising, and the repository refuses the result when it does not hold together. Nothing here derives a plan from an intent automatically.
Preflight: may this work proceed, and what is it held to
majordomus intent preflight --issue <id> (or --path <p>, repeated) is the one join a session
or a transition consumes before work begins; the intents.preflight capability answers the
same value over HTTP and MCP. An issue is followed through the criteria it declares in serves
— never through its milestone alone — and its links are judged by the coverage intent validate reports from, so the two cannot disagree about which link is broken. The verdict is
one of three:
| verdict | when | exit |
|---|---|---|
serves | every link holds, and each intent served has a critique with no blocking finding open | 0 |
maintenance | the issue serves nothing under a milestone no live intent names, where validation allows it | 0 |
refused | any cause below | 10 |
| cause | what it means |
|---|---|
unknown_issue | the issue named is not in the plan |
no_issue_covers_paths | no open issue's scope covers any of the paths |
issue_serves_nothing | the issue's milestone realises a live intent and the issue serves none of its criteria |
serves_another_intent | it serves a criterion of an intent that does not name its milestone |
serves_unknown_criterion | it serves an intent or a criterion that does not exist, or a malformed reference |
intent_not_critiqued | an intent it serves has no critique |
open_blocking_finding | the critique of an intent it serves has a blocking finding still open |
With paths, every open issue whose scope covers one is judged and listed with its own
verdict; the answer is the worst of them, so one served issue never vouches for another that
serves nothing. Served work beside maintenance answers serves.
The answer carries the intents the work serves and no other: each with its statement, the served criteria with the live state of their evidence, its invariants, non-goals and governance, its critique with the blocking findings still open, and its recorded gap bounded to the served criteria.
Binding: what a task serves, asked before it starts
The preflight is a question. The binding is who asks it (ADR 0111): majordomus start,
majordomus context, majordomus handover and majordomus plan start each ask the
intents.binding capability — majordomus-cli intent binding, GET /api/v1/intents/binding,
majordomus_intent_binding — and decide nothing themselves. A task names the work it
executes:
| a task started with | is bound by |
|---|---|
--issue <id> | the criteria that issue serves |
--intent <id> | the open issues serving that intent |
--exempt <class> --because "<reason>" | nothing: a class the policy declares under intent.exemptions, and why |
| a scope alone | the open issues whose scope covers a path, when the policy asks at all |
The standing is bound, maintenance (the issues sit under milestones no live intent names),
exempt or refused. A refusal carries one of the preflight's causes, or one only a binding
can find:
| cause | what it means |
|---|---|
nothing_named | no issue, intent, path or exemption was given |
unknown_intent, intent_retired | the intent named does not exist, or is cancelled or superseded |
intent_has_no_open_work | no open issue serves the intent named |
issue_outside_intent | the issue named serves no criterion of the intent named beside it |
ambiguous_intent | paths alone reached more than one intent; name the issue |
unknown_exemption, exemption_without_reason, exemption_names_work | the class is not declared, no reason was given, or an exemption was given beside named work |
What a refusal costs is the policy's intent.binding:
| value | start | plan start |
|---|---|---|
absent or off | asks only when given --issue, --intent or --exempt; a refused or unreadable answer to that question does not start | unchanged |
advisory | always asks; a refused binding is reported and the task starts | unchanged |
required | a refused or unreadable binding does not start | an issue whose binding is refused does not become ACTIVE, in both engines |
A binding that cannot be read — no built executable, no jq — is unknown, and unknown is
never a pass where an answer was required.
The task record and a handover keep what the worker named and two pins, and nothing of the
intent itself: plan_revision moves when the intent's statement, invariants, non-goals or
criteria, a link, or the critique is edited; evidence_standing moves when a served
criterion's evidence changes state. majordomus context prints an INTENT section read from
the binding on every call, and majordomus handover --resolve prints Intent: unchanged,
evidence_moved, plan_changed or unknown. The issue a task named is a declared link in
the realization below.
Realization: who is making it true, and whether reality agrees
An intent belongs to the project, not to the session working on it. Which work realises it is therefore never written into a task, a session or a handover; it is joined on every read from the records the lifecycle already keeps (ADR 0075): the ledger's tasks, each with every episode that worked on it, the provider of each episode and the handovers between them; the closed session records; and the claims on the peer board.
majordomus intent realization # every intent, its unmet criteria, the work realising it
majordomus intent realization --intent <id> # one intent and the work linked to it
majordomus intent explain <id> # why it stands where it stands, sentence by sentence
Every link from work to an issue says how it is known, and a join keeps the strongest per issue:
| provenance | how the link was read |
|---|---|
declared | the claim or the task's own words cite the issue |
observed | a plan transition in the work's episode, or a closed session record's issues |
derived | the branch name carries the issue id |
inferred | only when nothing stronger exists: an open issue's scope overlaps the work's |
So a task a Claude Code episode starts and hands over, and a Codex episode resumes, is one unit
of work with two episodes and both providers, and the intent it serves lists both. The episode's
session.started ledger line names its provider, so the lineage survives the episode's end.
Closed work does not outrank evidence. When every milestone of an intent is DONE and a
criterion's latest recorded run is failing, or stale (no longer current, as above), the
realization reports closed_work_contradicted naming the criterion, and exits 10; the
intent-realization gate runs it. An intent that was satisfied and regresses lands exactly
there: its stage falls back to verifying, and nothing about the intent was written for it to.
closed_work_unproven (every milestone DONE, a criterion never evidenced) and
criterion_closed_unmet (every issue serving a criterion DONE, the criterion unmet) are
warnings, as is live work that serves no intent (work_serves_no_intent). Where the stage and
the verdict disagree, two more warnings name it once per intent: evidence_ahead_of_plan (the
verdict is satisfied while the stage is planned or executing) and
closed_work_not_satisfied (every milestone DONE, the verdict unsatisfied or unknown, with
the criteria holding it back).
A unit of work no intent links is classified by the facts it records (ADR 0075, amendment of
2026-10-15), and only the last class is work serving no intent: empty (a session record that
lists no issue, no commit and no changed path), before_intents (finished work that started
before the first intent record), orphan (anything else). The text summary reads
N serving no intent (E empty records, B before the first intent), each unlinked line names its
class, and the JSON answer carries orphans, empty and before_intents.
In the Cockpit, /cockpit/intents lists every intent with its stage and the work realising it,
and /cockpit/intents/<id> shows one: each criterion with its evidence state, linked to the test
object it names and to the issues serving it, and each unit of work with the provenance of its
link. Both pages render the capabilities above and decide nothing themselves.
test/cases/388_an_intent_is_realised_across_providers_and_held_to_reality.sh is the loop end
to end: declared, realised across two providers and a handover, closed while one case fails,
fixed, satisfied, broken again and repaired — with the intent file byte-identical throughout.
What intents do not do yet
Each of these is a planned claim in CLAIMS.yaml, and the homepage lists them
from there:
intent-closes-github-milestones— the GitHub projection closes a milestone from its derived plan status alone and reads no intent.intent-command-deployment-evidence— a criterion settled by acommandor adeploymentresolves and readsnot_derivable; the ledger records runs of tests and claims only, so such a criterion is never met.
Traceability: what realised an issue, and what an issue realised
The model reaches as far as a branch on its own: a branch path component equal to an issue id
(feature/I1305-traceability → I1305) is the one edge the topology already reads and the
pre-commit guard already enforces. Everything above it — the commits, the pull requests — is
derived on every read and stored nowhere. No canonical record under
.ai/repo/project/ names a branch, a commit, a pull request or a check run, and none may:
git and GitHub already hold those facts, and a record repeating one is a second truth that
starts rotting the moment history is rewritten.
Two systems hold the two halves, and the boundary between them is the same boundary
scripts/github-sync respects — the executable, bin/, lib/, share/ and test/ make no
network call, and test/cases/08_no_forbidden_constructs.sh proves it:
| half | source | where it lives |
|---|---|---|
| branches and commits | git for-each-ref, git log | the trace capability module of the Rust executable |
| pull requests | the GitHub API, through gh | scripts/traceability |
flowchart LR
issue["issue"]
subgraph git["derived from git"]
branch["branch"] -->|contains| commit["commit"]
end
subgraph github["derived from GitHub"]
pr["pull request"]
end
issue -->|names| branch
pr -->|"head branch of"| branch
milestone["milestone"] -->|"the one edge git does not hold:<br>the canonical issue record declares it"| issue
A branch's commits are the commits it holds that the trunk did not: measured against the trunk while the branch is open, and against the first parent of the merge commit that brought it in once it is merged. Read backwards, a commit belongs to the issue whose branches hold it.
Three answers are states rather than failures, and each is reported by name rather than silently dropped:
- absorbed — the branch reached the trunk with no merge commit of its own (fast-forwarded, or rebased onto it). Its commits cannot be told from the trunk's, so none are claimed and the trace says it is incomplete.
- unattributed — no branch naming an issue holds the commit, and no pull request's head branch names one. That is either work committed with no execution contract or a branch deleted after its merge, and the answer says so rather than choosing between them. Work with no contract is the thing a traceability report exists to make visible; omitting it would defeat the report.
- ambiguous — branches naming two different issues hold the same commit. The branch-name edge cannot decide, so it does not.
scripts/traceability every issue, and the trunk commits nothing accounts for
scripts/traceability --issue I1305 its branches, its commits and its pull requests
scripts/traceability --commit <rev> the issue and milestone it served, or unattributed
scripts/traceability --pull 42 the same, from the pull request's side
scripts/traceability --no-github the git half alone: no token, no network
scripts/traceability --strict exit 10 when something has no contract; the shape of a gate
The git half is majordomus_trace_issue, majordomus_trace_commit and
majordomus_traceability on MCP and /api/v1/trace, /api/v1/trace/issue,
/api/v1/trace/commit on HTTP; the script is a client of them rather than a second
implementation. MJ_GH_FIXTURE_PULLS reads the pull requests from a file in the shape the
live read produces, so the join is provable with no network and no token — the same seam
scripts/github-sync uses, and what test/cases/98_traceability.sh exercises.
Delivery: how far the work travelled
An issue's status is a stamp; its delivery is a fact about the objects that realised it.
majordomus plan delivery [<id>] [--at <rev>] [--json] answers, for every issue, the
furthest stage proved, each with the object that proved it and when it was observed
(ADR 0166). Nothing is stored, and nothing writes a stamp from it: a merge is not acceptance.
| stage | proved by |
|---|---|
none | nothing that was read names the issue |
branch | a branch whose path component is the issue id (the I1405 rule) or the issue's slug |
pr_open | an open pull request in the recorded forge observation whose title or head branch names it |
merged | the newest commit in the trunk's history that names it: a merge by its pull-request title or branch, or a commit by its subject (type(scope): description (I2740)); never a body, which mentions neighbouring issues as readily |
released | the first release record (.ai/repo/releases/v*.yaml) whose commit contains that merge |
deployed | the published latest.json names that release or a later one |
The live answer reads the trunk this clone knows (the remote-tracking trunk unless the local
one is ahead), every branch, the forge observation majordomus prs refresh recorded, the
release records, and the latest.json of the commit the last successful Pages deployment
published (majordomus served forge records it). Releases come from the records, never from
tags: a record names the commit it released and is a file of the tree, while a tag is a ref
that can appear on any day.
Committed derived data is a function of the commit alone, or every tag, push, pull request
and deployment would turn scripts/generate-site-data --check red on every open branch. So
--at <rev> reads only that commit's merges and the tree's release records: it proves
merged and released, and reports branches, the forge and the publication unread.
scripts/generate-site-data writes that answer to site/data/generated/delivery.json at
HEAD and reproduces it under --check at the commit the file names. The live stages
(branch, pr_open, deployed) reach the CLI, HTTP, MCP and the UI; bringing them to the
published site at Pages build time, as the UI snapshot is, is left to the plan surfaces
(I2743, I2744).
A source that was not read is reported unread with its reason, never as none. An issue an
unread source could have moved further is not settled and names that source in unread.
The same answer is GET /api/v1/plan/delivery and the MCP tool majordomus_plan_delivery.
A pull request merged inside a combined top reaches the trunk through an octopus merge that
names it only in prose, and its title is the forge's, not git's. majordomus served forge
therefore also records the newest merged pull requests (up to 1000, a page of 100 at a time)
in the deployment observation, and the live answer counts a merged pull request's title when
its merge commit or head is in the history read (via: forge_title). That is how #1097 is
seen to have realised I2653, which its commits name only in a body. The source is
merged_titles; at a commit it is unread, so the committed data stays a function of the
commit's history.
Where the work is now
plan.roadmap's now is the first open milestone in the plan's order, whether or not anybody
is working on it. plan.flight (GET /api/v1/plan/flight, MCP majordomus_plan_flight)
answers where the work is instead, live on every call (ADR 0166, decision 4):
- now is every open milestone holding an open issue with work in flight: an open pull
request that names it (
pr_open), a live mesh claim made for it, or a declaredACTIVEstatus. Each comes with those issues, why they are in flight, their declared status beside their inferred stage, and the objects that prove it, linked on the forge where it has them; - next is the first open milestone in the plan's order that is not now and whose unfinished prerequisites are all now;
- the latest release is the highest stable version among the release records, the
v*tags of the clone and the publishedlatest.json. Each is reported read or unread, andexecutable_taggedsays whether the answering executable's own version is tagged. A record that merges after its tag no longer makes a screen show an older release than the executable that drew it.
/ui/roadmap reads it, and ?milestone=<id> asks it for every issue of that milestone. The
mesh runs in a server; anywhere else its claims are reported unread.
Drift: where the stamps contradict the work
Inference never writes a stamp, so the two can disagree. majordomus plan drift [<id>] [--at <rev>] [--gate head|trunk] [--json] names every disagreement with the objects that
prove it (ADR 0166, decision 2):
| class | declared | delivered |
|---|---|---|
merged_not_recorded | READY, BLOCKED or ACTIVE | a merge names it (pull request, merge commit, release), and no open pull request names it |
active_without_work | ACTIVE, started before the window | no branch, pull request, merge or live mesh claim names it |
done_without_trace | DONE | no merge names it; advisory, because record-only issues exist |
Each finding carries its age, whether that is past the window, the sources that were not
read and could have dissolved it (unread; a finding with the forge unread is still a
finding, and is not settled), and the command that settles it:
majordomus plan verify|done <id>.
.ai/repo/ci/plan.yaml declares drift.window (seconds) and drift.enforcement
(advisory or blocking). The gate plan-drift (scripts/ci/plan-drift-check, every
plan) asks plan drift --gate trunk on a push to the default branch and --gate head
everywhere else:
- on a head, every
merged_not_recordedis PENDING; - on the trunk, one merged longer than the window ago is REFUSED, naming the settling command, and a younger one is PENDING;
active_without_workis a NOTE anddone_without_traceADVISORY: neither refuses;- a missing or unreadable window refuses (exit 12);
- refusals fail the gate (exit 10) under
blocking, and are reported with exit 0 underadvisory. The gate lands advisory, so that it does not turn the trunk red on drift it predates; I2748 settles that drift and makes it blocking.
The same answer is GET /api/v1/plan/drift and the MCP tool majordomus_plan_drift.
test/cases/3101_merged_work_unrecorded_is_drift.sh proves the classes, the window and the gate, with six code mutations failing it.
Projections
The canonical files are the only source. Everything else is generated from them by one
engine — lib/project.awk, loaded by lib/project.sh — so no two surfaces can hold
different opinions about what is ready:
| surface | how |
|---|---|
| the command line | majordomus plan |
| the Mermaid DAG | majordomus plan graph |
| GitHub milestones and issues | scripts/github-sync, proved current by scripts/ci/github-check |
| the website's roadmap, milestone, issue and DAG pages | scripts/generate-site-data |
| the documentation | this file explains the semantics; the figures are generated |
GitHub is a projection and a place to talk, never the source. A canonical change updates the generated region of an issue body; a person editing that region is reported as drift and not overwritten; a person's comments and any text outside the region are never touched. Nothing is read back: closing an issue on GitHub does not complete it here.
A record is found on GitHub by an identity it carries in its own body, written beside the
hash as <!-- majordomus:record I0001 -->. A GitHub number is GitHub's to assign and a
title is a person's to edit, so neither can be the key: matching on a title means a rename
orphans the record and the next --apply creates a second one for work that already exists.
Every finding about a body is one of six states, from two independent questions — has a person rewritten the region since it was posted, and has the canonical record moved since?
| state | meaning | --apply |
|---|---|---|
insync | intact and current | nothing |
behind | intact, the canonical record has moved | rewrites |
edited | a person rewrote the region | refuses without --force |
conflict | a person rewrote it and the record has moved | refuses without --force |
adopt | no identity marker; matched by title, this once | writes one |
missing | no counterpart on GitHub | creates |
and, of a remote record rather than a canonical one, unmanaged: an issue claiming a
canonical id this repository does not have.
A title GitHub would refuse is refused before it merges. GitHub caps a milestone title at
255 characters and an issue title at 256, and the projected title is <id> — <title>.
scripts/github-sync --plan, --render and --apply count each projected title in
characters and exit 14 naming every record over its limit, and scripts/ci/core-check runs
--plan in the structure job. Before this, a 282-character milestone title passed every
offline step, the landing's apply got 422, the milestone never existed, and every pull
request's structure job was red on it until an admin landing broke the deadlock.
Applying is a consequence of landing; agreement is a gate. Every push to the default branch
starts .github/workflows/github.yml, which runs --check, then --apply when something is
owed, then the gate (project.the-projection-is-applied-at-landing@2); a person's --apply
from the trunk is the fallback when that run fails. scripts/ci/github-check reads the
remote on every change that can move either side, refuses the first six states outright,
and ratchets missing and adopt against .ai/repo/ci/github-drift-baseline.txt, which
may fall and may never rise. It exists because the detector was written, never called, and
the projection decayed to a tenth of the model over five days with every build green
(project.github-projection-gated@1).
A head is judged against what it changes. GitHub shows the trunk's plan and nothing else,
so a record a pull request edits is behind there until the pull request has merged and
the landing has applied it. The gate reports the four states an apply writes (behind, state,
milestone, closed) as PENDING instead of refusing them, for the records the head
changed since its merge base, and on the trunk for the records that changed in a landing
younger than apply.window in .ai/repo/ci/github.yaml. Past that window the trunk is
refused and the refusal names scripts/github-sync --apply. A record counts as changed
when its file or its rendering differs, because closing one issue moves the status of every
issue that waited for it. Drift on a record the head did not change is refused as before.
The network calls live in scripts/github-sync, outside the tool. bin/, lib/, share/
and test/ contain no network client, and test/cases/08_no_forbidden_constructs.sh proves
it.
Pull requests
A pull request carries the metadata its records imply, projected by the same adapter
(scripts/github-sync --prs, I3970) and kept current by GitHub itself:
.github/workflows/pr-meta.yml applies it to a pull request whenever it is opened, edited,
synchronized or labeled (on pull_request_target, from the default branch's checkout, never
the head's code), and to every open one on a daily sweep, which carries what reached GitHub
after a pull request's last event (.ai/repo/ci/pr-meta.yaml).
What a pull request serves is derived, never typed twice: the issue ids (I<digits>) in its
title and in the subject line of each of its commits, then the issue records its head
changes. The metadata is projected from the trunk's records: an id whose record exists only
in the head is named by its id and lends no priority and no milestone, and an id no record
carries is reported and ignored. From the trunk's records:
| field | projected | owned by the projection |
|---|---|---|
| labels | type:<t> from the conventional title (feat, fix, perf, refactor, docs, test, chore, ci, style, build); priority:<p>, the highest of the issues served; train on an integration/train-* head; records when nothing outside .ai/repo/project/ changes but its derived projections | those four families; every other label (hold, collision:accepted, ci:yielded) is a person's |
| milestone | the GitHub milestone of the first served issue's canonical milestone | when one is derived |
| assignee | the pull request's author | only when nobody is assigned |
| body | a region <!-- majordomus:pr-meta begin <hash> --> … <!-- majordomus:pr-meta end --> with one Refs #<n> (<id> — <title>) per served issue and the canonical milestone | the region; the text around it is the author's |
The region follows the issue bodies' rule: its begin marker carries the hash of what was
written, so a region a person rewrote is reported edited and left alone (--force
overrides), and one the records moved past is behind and rewritten. A pull request that
serves no recorded issue gets its type label and nothing else.
A link is Refs #<n>, never a closing keyword. GitHub closes the issue that a merged
Closes, Fixes or Resolves names, which moves the projection ahead of its source: on
2026-10-03 one such commit closed a canonically open issue and reddened every structure job.
An issue closes through its record (majordomus plan done) and the issue projection carries
that to GitHub. So the adapter refuses to write a region that would carry a closing keyword,
writes a #<digit> inside a title as # <digit>, and reports one a person or a commit wrote.
scripts/github-sync --prs this branch as the pull request it would open, offline
scripts/github-sync --prs --check every open pull request against GitHub; exit 11 on drift
scripts/github-sync --prs --apply write what is owed (paced by MJ_GH_PACE)
scripts/github-sync --prs --pr <n> --apply one pull request, as the workflow runs it
scripts/github-sync --prs --fields milestone,assignee,body --apply
Labels are the workflow's to write. validate.yml starts a run on every labeled event, and
a label added with a person's token is one; a write with the workflow's own token starts no
workflow. So a backfill from a clone passes --fields without labels, and the sweep adds
them.
Reviewers and project boards are not projected: a review request from the workflow's token is one nobody chose, and a user-owned project cannot be written with it.
Working through the model
majordomus plan status # where the outcome stands
majordomus plan next # the one issue to take now
majordomus plan show I0007 # the whole contract, no chat history needed
majordomus plan start I0007 # refused unless it is READY
# ... execute, inside the scope the issue declares ...
majordomus plan verify I0007 # implementation complete, evidence pending
majordomus plan evidence I0007 --covers doctrine_test --type test \
--command "bash test/run.sh 44_model_doctrine" --result "1 passed"
majordomus plan done I0007 # refused while a required token is uncovered
majordomus plan next # recomputed, not chosen
A worker takes the issue the graph offers. Picking something else needs a reason: it is a blocker, it invalidates an assumption the milestone rests on, or a person reprioritised.
Replanning
The plan is expected to change. Add an issue, remove one, split one, merge two, move an edge, narrow a scope, change a validation — all of it is editing the canonical files, and all of it is re-validated on the next read. Two constraints survive every edit: the graph stays acyclic and resolvable, and evidence already recorded is not deleted to make a story tidier.
Replanning is not optional when an issue discovers a dependency nobody predicted, an acceptance criterion turns out to be untestable, an implementation path is disproven, or an issue becomes unnecessary. Leaving the graph describing a plan that reality has left behind is the failure this layer exists to prevent.
What was rejected
A status field with validation. Two sources of truth and a rule to reconcile them. The
graph is the only thing that can be right, so it is the only thing that is stored.
A milestone that lists its issues. A second edge, in the opposite direction, that can disagree with the first.
A separate evidence store. The contract and the proof that it was met belong in one record; splitting them lets one be read without the other.
A mapping file from canonical id to GitHub number. State that has to be kept in step with two systems. An issue carries its id in a marker inside its generated region, a milestone as the prefix of its title, and matching is a string comparison.
Estimates, velocity, burndown. None of them changes what a worker should do next, which is the only question this layer answers.
PLANNED as an issue status. It would only ever mean READY or BLOCKED with a
different word on it.
See also
docs/DOGFOODING.md— why this repository uses the model on itselfdocs/SCHEMAS.md— the fields of each filedocs/CLI.md—majordomus plan, subcommand by subcommanddocs/DOCTRINE.md— howmajordomus.project-integrityandmajordomus.dag-integrityare enforced