ADR-0034: migrate apply enforces the merge
- Status: accepted
- Date: 2026-09-12
- Deciders: Theurian maintainers
- Requirements: SEC-17, FR-I3, INV-7, T-12, T-15
- Situates against ADR-0013 (point 4 — approval is the merge, which is what makes this checkable at all), ADR-0027 (the other pre-apply gate), ADR-0032 (the surface this is a precondition for) and #281 (the approval-provenance pointer, which is explicitly not this)
This ADR recorded a decision and shipped no code: no check ran, no flag
existed and no module was created with it, and its own diff was confined to
docs/. The decision is implemented as of Phase B slice B3
(PR #683), which is why the
status above is accepted. Compliance names what that slice discharged, with
the test that discharges it, and what stays owed and where it lives. Nothing
above Compliance is rewritten: the measurements below are dated and anchored
to be977ea7, and re-writing them to today's tree would delete the evidence the
decision rests on.
Every repository fact below was measured on 2026-09-12 against be977ea7,
which is reachable from origin/main.
Context
ADR-0013 point 4 says approval is "a human reviews the proposal, moves the
migration into .theurian/migrations/, and merges the pull request". The
merge is not a ceremony around the approval; in this product it is the
approval, which is what makes it a thing code can ask about.
Nothing asks. docs/security/threat-model.md records it as T-15's residual, in
its own words:
Residual: nothing enforces the merge.
migrate applyapplies whatever is in.theurian/migrations/, committed or not — the human's review is a workflow convention, not a check the code makes, and the actors table's untrusted same-UID process can run it directly.
The code says the same thing, measured — and the shape is reads that describe, not no reads at all. An apply reaches git four times, and the four split three to one:
| # | Call | Helper | What it decides |
|---|---|---|---|
| 1 | rev-parse --show-toplevel |
find_git_root (cli/context.py) |
whether the command runs at all |
| 2 | remote get-url origin |
repository_url |
nothing — stored as Project.repository_url |
| 3 | symbolic-ref --short HEAD |
default_branch |
nothing — stored as Project.default_branch |
| 4 | rev-parse HEAD |
current_commit |
nothing — stored as Project.last_seen_commit |
The count and the order are a trace of the shipped CLI, not a reading of the
source. A git shim first on PATH recorded every invocation of a real
theurian migrate apply, run in a scratch repository under a redirected HOME
and THEURIAN_DATA_DIR (2026-09-12):
$ cat -n "$GIT_TRACE_LOG"
1 rev-parse --show-toplevel
2 remote get-url origin
3 symbolic-ref --short HEAD
4 rev-parse HEAD
Calls 2 to 4 are read-only metadata built into the Project record in
cli/commands.py's migrate_apply, and none of the three is ever compared to
anything. last_seen_commit is the clearest case — its whole population is a
write in project register, a write here, the field on domain/project.py, the
column in infrastructure/sqlite/schema.py, three lines of the upsert in
infrastructure/sqlite/store.py and the read-back that reconstructs the
object:
$ git grep -n "last_seen_commit" be977ea7 -- packages/theurian-core/src | wc -l
8
$ git grep -l "last_seen_commit" be977ea7 -- packages/theurian-core/src | wc -l
4
Eight sites across four files, none of them a comparison — cli/commands.py
(two writes), domain/project.py (the field), infrastructure/sqlite/schema.py
(the column) and infrastructure/sqlite/store.py (three upsert lines and the
read-back).
Call 1 is the one that decides something, and the earlier draft of this
document did not count it at all. find_git_root is the read resolve_context
uses to answer is there a working tree here, and the command refuses when the
answer is no (decision 3). It is a real gate, and it is not this ADR's gate: it
asks whether git is present, never what git tracks. So the gap is not that
migrate apply cannot reach git. It reaches it four times, asks once whether a
repository exists, and asks nothing at all about the file it is about to apply.
No tracking check exists anywhere in src/, and the key is the argument
vectors themselves. Walking every list or tuple literal in the shipped package
whose first element is the string "git" returns five vectors in two
modules, and the subcommand is what settles it:
| Module | Vector, after git |
|---|---|
cli/context.py |
rev-parse --show-toplevel |
cli/context.py |
rev-parse HEAD |
cli/context.py |
symbolic-ref --short HEAD |
cli/context.py |
remote get-url origin |
infrastructure/git/trailer_source.py |
-c log.showSignature=false --no-optional-locks --no-replace-objects log -z |
Not one of them asks what git tracks or whether a file matches HEAD: there
is no ls-files, no status, no diff, no cat-file. The key's recorded
limit is the one tests/unit/test_network_call_sites.py states about its own
scan — a vector assembled at runtime rather than written as a literal is
invisible to it, and no name-based walk can do better.
Phase B is what turns this from a background fact into a precondition, and
docs/roadmap.md already says so in the Phase B security row: "T-15's
'nothing enforces the merge' residual is a Phase B precondition, not a
background fact: opening a protocol-level write path multiplies the callers
who can put a file in .theurian/migrations/, and migrate apply does not ask
whether it was committed."
That is the whole argument for doing it now rather than later. Today the actors
who can write that directory are the operator and whatever runs as them. After
ADR-0032, any MCP client the daemon serves produces migration documents on
disk — under .theurian/proposals/, not .theurian/migrations/, and the
distance between those two directories is currently one mv.
Decision
1. migrate apply refuses, by default, a migration file that is not committed
The predicate is: the file is tracked by git, and its working-tree bytes are
identical to the bytes at HEAD.
Both halves are load-bearing, and the weaker candidates each fail against a real sequence:
| Candidate predicate | What passes it that should not |
|---|---|
| tracked | git add with no commit. A file staged and never committed is tracked, and nothing has been reviewed or merged. |
| committed anywhere in history | a file whose content was committed once and edited since. The approved bytes are in history; the bytes that would apply are not. |
tracked and byte-identical to HEAD |
— this is the one taken |
The predicate is evaluated against the bytes the engine applies, not against a
second read of the file. The loader already digests each migration file's raw
bytes — Migration.checksum is ContentHash.of_bytes(raw) over exactly what it
read, and Migration.source_path is that file's project-relative path
(infrastructure/filesystem/migration_loader.py). The check is therefore a
comparison of two digests: migration.checksum against ContentHash.of_bytes
of what git cat-file blob HEAD:<source_path> hands back. It is not a stat,
and it is not a re-read.
That is what closes the check-to-load race by construction rather than by timing. The shape that loses the race is check the file on disk, then let the engine load it: between the two reads the actors table's untrusted same-UID process replaces the file, and the control certifies bytes that never apply. There is no window here, because there is no second read of the working tree.
One query answers both halves of the predicate, which is why it is one
query rather than a ls-files followed by a diff. git cat-file blob
HEAD:<path> fails when the path is not in HEAD — which is the tracked
half, since a staged-but-never-committed file is not — and hands back the
approved bytes when it is. Measured in a scratch repository (2026-09-12):
$ printf 'approved\n' > sub/m.yaml && git add sub/m.yaml && git commit -qm add
$ printf 'tampered\n' > sub/m.yaml
$ git cat-file blob HEAD:sub/m.yaml | shasum -a 256 | cut -d' ' -f1
7f8518f7db5e9a55049f49c4ea6d6e8f509695231e60cbd607bcb36c88a75a14
$ shasum -a 256 < sub/m.yaml | cut -d' ' -f1
92e78d0b032962f47792a9fa95fd981ef63e1e3ef074d536d6304c75eddbe29f
$ printf 'x\n' > sub/staged.yaml && git add sub/staged.yaml
$ git cat-file blob HEAD:sub/staged.yaml; echo $?
fatal: path 'sub/staged.yaml' exists on disk, but not in 'HEAD'
128
The <rev>:<path> spelling also costs less to make safe than a separate path
argument would: the whole argument begins with HEAD:, so a filename shaped
like an option is read as a path. Driven with a committed file literally named
--force.yaml, git cat-file blob 'HEAD:sub/--force.yaml' returned its
contents and exited 0. That closes the option half of the untrusted-filename
question and not the rest of it, which stays owed in Compliance.
What it does and does not prove, said plainly. It proves the bytes that are
about to apply are the bytes at the current commit. It does not prove that
commit reached main through a reviewed pull request — a local commit on a
local branch satisfies it. That is a deliberate floor rather than an oversight:
the properties that would distinguish merged into the default branch from
committed are branch-protection facts held by a forge, not by the working
tree, and a check that asked the forge would be a network call inside
migrate apply. The floor still closes the gap the residual names, which is a
file that was never committed at all — the gap an agent, a script or a
mistaken mv reaches.
Whose reach it narrows, actor by actor — because "closes the gap" is not one
statement, it is four, one per actor in docs/security/threat-model.md's own
table who can put a migration in front of this command, and they are not worth
the same. That table lists six; the two outside this control are a visited
web page, which the Origin/Host check stops at TB-1 and which reaches no
filesystem, and an external system (GitHub), whose capability is "supplies
review content" and which authors no migration. The four that remain, named
here in this control's terms rather than the table's — in row order they are
Another local process, An AI agent, A repository contributor (the one
spelling that is verbatim) and The user:
| Actor (mapped from the threat model's actors table) | What the floor costs them | Worth |
|---|---|---|
| An untrusted same-UID process | git add && git commit — two commands it can already run, since it has the user's account |
A speed bump. It converts a silent apply into one that leaves a commit in the repository, and nothing more. Said plainly here rather than implied away by the ADR's title |
| An MCP client, through the write-intent tools (ADR-0032) | Nothing today; everything once slice B4's draft-only facade lands — at which point it cannot reach .theurian/migrations/ at all, because the tools write under .theurian/proposals/ and the distance to the applied directory is a human's propose accept plus a merge |
Real, and it is the reason this is a Phase B precondition — conditional on that facade. See the note below the table; this is the actor whose population Phase B multiplies |
| A repository contributor | Nothing. Their migration arrives by commit, so in the checkout that carries it, it is tracked and its working-tree bytes are the bytes at that commit — which is exactly what the predicate asks | Zero, and that is the row that shows what the floor is. Decision 1 asks committed, and this actor's whole route is committing; the control between them and approved knowledge is the human reviewing the pull request — ADR-0013 point 4, not this ADR |
| A human operator mid-development | One flag on the command (decision 2) | The intended user of the escape hatch, not a defeat of the control |
The MCP-client row states a property that does not hold yet, and the
conditioning is the point. .theurian/migrations/ is unreachable to that
caller only when nothing it can call can write there.
ADR-0032 decision 8 records that
this is not today's state and names the two controls slice B4 owes for it: a
draft-only facade at the MCP composition root, and the forbidden-name set grown
to the application-layer movers. Its measurement is that
test_no_registered_tool_can_reach_a_canonical_write's forbidden set is 17
names and contains neither draft, accept nor _commit, so a tool holding a
ProposalService and calling accept() passes the sweep green while _commit
writes into .theurian/migrations/.
Round 2 of this pull request's review
records that gap as "driven with a positive control (accept/_commit pass
green; append_revision caught)". Until B4's facade lands, this row's worth is
the plan's, not the tree's.
The Positive section below is scoped to that row for that reason — and is scoped the same conditional way: the last unenforced link gets a check against the actor Phase B is about to add once that actor is contained, and a speed bump against the actor who was already inside the boundary.
2. There is one escape hatch, it is a flag, and using it is visible
An explicit flag — --allow-uncommitted is the working name; the exact spelling
is slice B3's — restores today's behaviour for the two cases that need it:
development, where a migration is written and applied before it is committed,
and recovery, where the repository is present but its object store cannot answer
for a file and the knowledge still has to be rebuilt.
Both cases are inside a git working tree, which is the whole reach of the flag. A directory with no repository at all never gets this far (decision 3), so the flag is not a way back into one.
It is a flag and not a configuration key, and that is the decision. A config default is invisible at the moment of use: a project that set it once applies uncommitted migrations for ever, and nobody reviewing an incident sees it in the command that ran. A flag is in the command line, in the shell history, in the CI log and in whatever recorded the invocation. The control this ADR adds is weak enough — decision 1 says how weak — that making it trivially and silently disablable would leave nothing.
3. A tree with no git already refuses, this ADR does not change it, and the flag does not reach it
migrate apply in a directory that is not a git repository refuses today,
unconditionally. The refusal is resolve_context's, not this control's:
find_git_root returns None and the command raises before a project exists
(cli/context.py). Driven in the same sandbox as the trace above, against a
directory with no .git (2026-09-12):
$ theurian migrate apply
error: <dir> is not inside a Git repository. Theurian scopes a project to a
Git working tree, so that branches and worktrees stay isolated.
Run this inside a Git repository.
$ echo $?
1
$ cat -n "$GIT_TRACE_LOG"
1 rev-parse --show-toplevel
One git call, no project, exit 1, and nothing written.
So decision 2's flag governs exactly one case: a git tree whose migration file is not committed. It does not restore applying where there is no repository, and at decision 4's seat it could not: the check runs after the project resolves, and in a non-git tree the project never resolves, so the band the check sits in is never entered.
An earlier draft of this ADR said the opposite, and the correction is recorded
rather than swept. It designed a "refuses without the flag" path into a
refusal that already ships, and it owed a driving test for it. That test cannot
go RED — the behaviour it would assert is the behaviour on main — and its
stated control, the same tree applies under the flag, cannot be constructed at
all without replacing project resolution, which is a change this ADR neither
prices nor proposes. The owed item is deleted rather than re-milestoned: an
obligation whose control cannot be built is a sentence, not an obligation.
What survives of the original reasoning is a non-goal, and it is kept as one. A tree with no git has no approval record for this check to read, so raising the non-git case to apply anyway would be the fail-open shape ADR-0031 decision 5 refuses. Nothing here proposes to, and ADR-0013 point 4 — this project's approval model is the merge — is why the existing refusal is the right answer rather than an inconvenience to be flagged away.
4. The check's seat: the CLI's pre-apply refusals, with the git query in infrastructure/git/
The check runs at the CLI layer, beside the refusals already there.
cli/commands.py's apply path calls _refuse_a_set_a_static_guard_rejects
before create_database, with the reason recorded in place: the refusal is
checked "before create_database below, so a refused apply leaves no database
file behind (issue #63, #210, T-21)". A merge check belongs in that same
band — after the project resolves, before anything is created — and for the same
reason.
The git query lives in a new module under
packages/theurian-core/src/theurian/infrastructure/git/, beside
trailer_source.py, which is that package's only current member. Two reasons:
- It is an adapter, and ADR-0003 puts adapters there. Asking git what it
tracks is infrastructure, and
infrastructure/git/already exists precisely as the place that reads git. cli/context.py's existing git helpers are the wrong home.current_commit,repository_urlanddefault_branchcollect descriptive metadata for aProjectrecord. A predicate that decides whether an apply proceeds is a different kind of thing, and putting it beside them would blur a security check into a metadata read.
This adds a spawn site, and the pinned set is an equality.
tests/unit/test_network_call_sites.py's PROCESS_SPAWN_SITES holds four
entries today — cli/context.py, infrastructure/git/trailer_source.py,
infrastructure/github/gh_cli.py, infrastructure/services/runner.py — and it
"fails when a site is added and when one is removed". Slice B3's commit is
therefore the commit that grows that set by exactly one entry, and the file's
own admission checklist applies: the argument vector is fixed by the adapter
rather than taken from a document or a configuration file, the command cannot be
handed a URL or a remote, there is a timeout, and a test goes red when any of
those stops holding. The new site reaches no network, which is the same
answer trailer_source.py gives.
One record and two pins move with the set, and the coupling is measured rather
than assumed. The record is docs/security/threat-model.md's T-7 spawn
bullet, which spells the number word four and names each of the four module
paths; the pins are PROCESS_SPAWN_SITES's own equality and the test that holds
the bullet against it. Counting the two tests as one record is what an earlier
draft did, and it makes the owed work read as smaller than it is — a prose edit
and two test edits, not one of each.
tests/unit/test_threat_model_t7_claims.py::test_the_t7_spawn_bullet_names_every_pinned_spawn_site_and_spells_how_many
derives both sides independently — the fact side from PROCESS_SPAWN_SITES, the
prose side from the entry — so the bullet reddens the moment the set grows.
Planting a fifth entry in a throwaway checkout takes both pins RED together,
against a green control on the same two files:
$ python -m pytest .../test_threat_model_t7_claims.py .../test_network_call_sites.py -q
62 passed in 0.81s
# a fifth ("infrastructure/git/merge_state.py", "subprocess") planted
$ python -m pytest .../test_threat_model_t7_claims.py .../test_network_call_sites.py -q
FAILED test_threat_model_t7_claims.py::test_the_t7_spawn_bullet_names_every_pinned_spawn_site_and_spells_how_many
FAILED test_network_call_sites.py::test_no_module_outside_the_recorded_spawn_sites_can_start_another_program
2 failed, 60 passed in 0.72s
Slice B3's commit is therefore the commit that grows the set, rewrites the T-7 bullet's number word and module list, and lands the adapter — one commit, because two would be a red gate in between.
One inconsistency is named rather than inherited silently. Both existing git
sites spawn the bare name git and let the child's PATH resolve it
(cli/context.py's subprocess.run(["git", ...]) carries a
noqa: S607 - resolved via PATH; trailer_source.py does the same), while
ADR-0030 clause 5 requires the gh
binary to be resolved to an absolute path so that PATH does not choose the
executable. Whether this check follows the git precedent or the gh one is
slice B3's, and it is a decision rather than a default: this site's answer
decides whether a security check's executable is chosen by the environment. It
is recorded here so the choice is made deliberately instead of by copying the
file next door.
5. The documents that record the residual move in the same commit as the check
The population, with its key:
$ git grep -n -i -E "nothing enforces the merge|does not verify that a migration was merged|committed or not" be977ea7 -- . ':!docs/work-logs' | wc -l
11
$ git grep -l -i -E "nothing enforces the merge|does not verify that a migration was merged|committed or not" be977ea7 -- . ':!docs/work-logs' | wc -l
7
The exclusion is measured: docs/work-logs/ drops 0 at this frame, and
it stays excluded on the standing ground that a work log is a dated record of
what was believed then.
One of the seven files is a dated release record and does not move.
packages/theurian-core/CHANGELOG.md's hit sits under ## [0.1.0.dev5] -
2026-08-19, and correcting a released section would falsify it. The rule is
owner_position_cites's: a dated entry describes a shipped tree, while
[Unreleased] describes the tree a reader has checked out.
The key's recorded limit. It matches one physical line, so a statement of the residual split across a wrap is invisible to it, and it misses the places that spell the residual differently. The number is a dispatch input, re-measured when slice B3 is briefed, not a claim about how many sentences are wrong. The T-15 entry itself is the one that must be rewritten per control rather than repointed: the residual narrows, it does not vanish, because decision 1's floor does not prove a merge into a protected branch.
Consequences
Positive
- The last unenforced link in ADR-0013's chain gets a check against the actor
Phase B adds. Proposal → PR → human merge →
migrate applycloses the end that was pure convention. The MCP end is not enforced structurally today and this bullet used to say it was: the containment that keeps an MCP client out of.theurian/migrations/is ADR-0032 decision 8's draft-only facade, which slice B4 owes and nothing in the tree holds — decision 1's actor table and its note say so. So the honest form of this bullet is that the two changes are worth their price together: B4 contains the caller, B3 checks the directory, and either one alone leaves a path. Against the untrusted same-UID process it is a speed bump either way, and against a repository contributor it is nothing at all. - Phase B's stated precondition is satisfied by a change rather than by a plan. The threat model's own lesson applies here — "an owner has to be the change that would implement the control, and an epic in the right milestone is not automatically that."
- The failure mode is a refusal with a remedy, not a silent apply. An agent
or a script that moved a file into
.theurian/migrations/gets told what is missing.
Negative
- The floor is lower than the sentence "enforces the merge" suggests, and this ADR's own title is the thing to watch. A local commit on a local branch passes. What is enforced is committed, not reviewed, and decision 1 states that limit rather than leaving the title to imply otherwise. The T-15 rewrite owed at slice B3 must narrow the residual, not delete it.
- A new spawn site. Every spawn site is a hole no name-based scan can fully
watch —
test_network_call_sites.pyrecords that about itself — and this adds the fifth. - Development friction, and an escape hatch that will be reached for. The flag exists because the friction is real; a flag that everyone types every time is a control that has become a habit, which is a thing to watch for rather than a thing this ADR can prevent.
- Repositories with unusual git layouts will meet the predicate. A worktree,
a submodule, a
GIT_DIRpointed elsewhere: each is a case the check has to answer correctly or it refuses an honest project. That is implementation work slice B3 owes, and it is a real cost rather than an edge note. - The migration files are parsed before they are refused, and that residual
stays. The check's seat is after
resolve_context, which has already runload_migrationsover every file in.theurian/migrations/— so an uncommitted file is read, parsed and schema-validated before this control sees it. The work is bounded byvalidate_migration_document's existing caps (MAX_DOCUMENT_NESTING,MAX_DOCUMENT_NODES,MAX_DOCUMENT_RENDERED_CHARS, recorded for #291 and #245), so it is a bounded residual rather than an unbounded one — but "refused before it is read" is not what this control delivers, and the alternatives table's engine-seat row is where that trade was made. - Every temporary-directory harness that runs the CLI must
git initfirst, which is decision 3's pre-existing cost rather than one this ADR adds — and the population that pays it is two orders larger than "both suites that drive the real CLI", which is what an earlier draft of this bullet said. Measured over the test tree with one key per figure:
console
$ K='\["git", "init"|_git\([^)]*"init"'
$ git grep -n -E "$K" -- 'packages/theurian-core/tests' 'tests' | wc -l
103
$ git grep -l -E "$K" -- 'packages/theurian-core/tests' 'tests' | wc -l
58
$ git grep -l -E '"migrate", "apply"' \
-- $(git grep -l -E "$K" -- 'packages/theurian-core/tests' 'tests') | wc -l
42
$ git grep -l 'shutil.which("theurian")' -- 'packages/theurian-core/tests' 'tests' | wc -l
5
103 git init sites in 58 files; 42 of those files drive migrate apply,
and each such harness needs a commit (or the decision-2 flag) once decision 1
lands, because git init alone leaves the migration untracked. Six files
spawn a theurian console script: the five the shutil.which key above
finds, plus tests/integration/test_session_start_hook.py under
packages/theurian-core/, whose _REAL_THEURIAN is
Path(sys.executable).parent / "theurian" and so escapes that key. Under the
widened key:
console
$ git grep -lE 'shutil\.which\("theurian"\)|sys\.executable\)\.parent / "theurian"' \
-- 'packages/theurian-core/tests' 'tests' | wc -l
6
The sixth file drives migrate apply (:756) and commits before it does —
_register_a_fresh_project (:688-704) runs git add -A then
git commit -q -m init before the apply — so decision 1 leaves it alone and
the conclusion above is unmoved.
Three keys, three limits. The first counts invocation sites, not
fixtures — a file with three harnesses contributes three; so 103 is an upper
bound on the harnesses and 58 a lower one.
The second is a file-level overlap: a file that git-inits in one fixture
and drives migrate apply from an unrelated one is counted, so 42 is the
upper bound on the files that actually need the change. The remaining 16 files
contain no "migrate" token at all, checked one by one. The third is that
every key here matches a spelling: the git-init key matches
["git", "init" and _git(... "init" and nothing else, so the same file that
escaped the binary key escapes this one too — its _init_git_repo builds
[str(_GIT), "init", ...] from shutil.which("git"). The miss runs one way:
103 and 42 bound what the key matched, not the tree, so neither is an upper
bound on the work slice B3 costs; 58 stays a floor on the harnesses, now with
more slack than the key can see.
- One of the two fixtures the earlier draft named does not drive the CLI.
tests/integration/test_mcp_tools.py's registry fixture (:267-291) does
git init -q and then calls _run("init"), and _run (:294-296) is
runner.invoke(app, [*args, "--json"]) — Typer's in-process CliRunner, in
the test process, spawning nothing. tests/e2e/test_daemon_single_instance.py's
running_daemon (:92-110) is the one that spawns: its cli closure runs
subprocess.run([THEURIAN, *args], ...) against shutil.which("theurian").
The distinction matters to slice B3 because a check implemented in the CLI
layer is exercised by both, while a check that shelled out would be exercised
by neither in the in-process harnesses.
Neutral
last_seen_commitis unaffected. It stays descriptive, and this check does not read it: a stored value from an earlier run is not evidence about the file in front of the current one.theurian propose acceptis unchanged. ADR-0027's validate-before-move gate runs where it ran; this is a second, later gate answering a different question.- Nothing about who approved is recorded. That stays Git and PR metadata by design.
What this does not close
- Whether the commit reached the default branch through a reviewed pull
request. Decision 1 names this as the deliberate floor. Raising it needs a
forge, and a network call inside
migrate applyis a different decision with different costs. - Who merged. Approval identity stays Git/PR metadata outside Theurian's
model — #281 verified zero
hits for an approver field across
src/andschemas/, and records that this stays. - The approval-provenance pointer. #281
proposes recording the delivering merge commit in
migration_history— "a pointer to the approval event, not an approver field and not an enforcement mechanism", and its own text says it explicitly does not close T-15's residual. It is adjacent work with its own issue and is not folded in here. - The exact flag spelling and the exact refusal wording. Slice B3's.
- Non-git version control. A project under something other than git already
cannot run
migrate applyat all (decision 3), and this ADR neither changes that nor designs a second backend. The flag does not reach it. theurian ingestand the index build. Neither applies a migration, so neither is in this control's population.
Alternatives considered
| Alternative | Why rejected |
|---|---|
| Require only that the file is tracked | git add with nothing committed passes it, and that is not an approval by any reading. The staged-not-committed state is exactly what a script that wanted to look compliant would produce. |
| Require only that the content appears somewhere in history | A file committed once and edited afterwards passes, and the bytes that apply are not the bytes anyone reviewed. This is the same class as ADR-0027's digest pin, one layer out: what matters is that these bytes were approved, not that some ancestor of them was. |
| Ask the forge whether the commit is on a merged pull request | It would raise the floor to something worth the title, and it costs a network call and a credential inside migrate apply — a command that works offline today, on a machine that may have no gh login. ADR-0030 took a deliberate, heavily-argued route to make one command reach GitHub; making the apply path reach it is a much larger decision than T-15's residual justifies. |
| A configuration key instead of a flag | Invisible at the moment of use. A project that set it once stops enforcing anything, and nobody reading the failed command sees why. Decision 2's whole content is that the disable is in the command line. |
| Raise the non-git case to "apply anyway" | Fail-open on a missing input, which is the shape ADR-0031 decision 5 refuses for the same reason — a control that applies wherever its input happens to be present is a control a caller removes by removing the input. Kept as a rejected alternative rather than as a decision, because the refusal it would overturn already ships (decision 3). |
| Check the file on disk, then let the engine load it | It advertises a guarantee it can lose a race for: between the check's read and the loader's, the untrusted same-UID process replaces the file and the control certifies bytes that never apply. Decision 1 compares digests of the bytes the loader already read, so there is no second read and no window. |
| Enforce it in the migration engine rather than at the CLI | The engine applies a MigrationSet that the loader has already read off disk; by then the file is bytes in memory and the filesystem question has been answered somewhere else. The CLI band is where the other pre-apply refusals sit, and where a refusal costs nothing because create_database has not run. |
| Record the delivering merge commit instead of refusing (#281) | Provenance is not enforcement, and #281 says so itself: "the pointer records provenance, it does not gate." The two are complementary, and doing the recording instead of the check would answer a different question than the one T-15 asks. |
Compliance
This ADR shipped no behaviour when it was written, so at the time it had no shipped test to name. Its enforcement at design time is the measurements it cites; its enforcement at implementation time is the tests slice B3 owed and has now delivered. The two halves are kept apart below: what B3 landed is named with the test that holds it, and what stays owed keeps the house Still owed heading and names where it lives — the same honest split ADR-0030 states for the same reason.
Measured at be977ea7 on 2026-09-12 — the state this decision was taken
against, reproducible from that sha and deliberately not re-measured, since
slice B3 moved several of them (the spawn set is now five, the new module
exists) and re-writing them to today's tree would delete the evidence the
decision rests on:
migrate applyreads git four times, traced through aPATHshim on the shipped CLI:rev-parse --show-toplevel(find_git_root, the one read that decides anything — whether a repository exists at all), thenremote get-url origin,symbolic-ref --short HEADandrev-parse HEAD, the three descriptive reads theProjectconstruction incli/commands.pymakes and compares to nothing.git grep -n "last_seen_commit" be977ea7 -- packages/theurian-core/srcreturns 8 lines across 4 files (a write inproject register, the write here, the domain field, the schema column, three upsert lines and the read-back — the first two both incli/commands.py), and none is a comparison.- A directory that is not a git repository already refuses, from
resolve_contextand not from any flag: onerev-parse --show-toplevel, exit 1, no project, nothing written. Driven against the shipped CLI in the same sandbox as the trace (decision 3). - A loaded
Migrationcarries the digest of the bytes it was read from (checksum = ContentHash.of_bytes(raw)) and its project-relativesource_path, so decision 1's predicate needs no second read of the working tree (infrastructure/filesystem/migration_loader.py). - The shipped package builds 5
gitargument vectors in 2 modules, and none is a tracking question — the table and its recorded limit are in Context. PROCESS_SPAWN_SITESholds 4 entries and is asserted by equality against the whole set (tests/unit/test_network_call_sites.py).infrastructure/git/holds exactly one module besides its__init__.py:trailer_source.py.- The merge-unenforced prose population is 11 lines across 7 files, key and measured exclusion above, of which 1 file is a dated release section that does not move.
- The test tree holds 103
git initinvocation sites in 58 files, of which 42 also drivemigrate apply, and 5 files resolve the installed binary withshutil.which("theurian")— 6 under the widened key that also admitsPath(sys.executable).parent. Keys and their three recorded limits are in Consequences → Negative.tests/integration/test_mcp_tools.py'sregistryfixture drives Typer's in-processCliRunner(_run, :294-296), not the installed binary;tests/e2e/test_daemon_single_instance.py'srunning_daemon(:92-110) is the one that spawns it. - An MCP client's containment is ADR-0032 decision 8's owed facade, not a
property of the tree. That ADR's measurement is that the canonical-write
sweep's forbidden set is 17 names containing neither
draft,acceptnor_commit, so decision 1's actor table conditions the MCP-client row on slice B4 rather than asserting it.
Landed in Phase B slice B3 (PR #683), each item with the test that discharges it:
- The predicate is tracked and byte-identical to
HEAD(decision 1). Three driving cases separate it from the weaker candidates, intests/integration/test_migrate_apply_committed_check.py: a committed-and-unmodified migration applies (test_a_committed_unmodified_migration_applies); a tracked, staged-but-never-committed one refuses (test_a_staged_but_never_committed_migration_is_refused); a committed-then-edited one refuses (test_a_committed_then_edited_migration_is_refused). The third is the one that goes RED if the implementation drifts to committed-anywhere-in-history. - The escape hatch restores the old behaviour, is a flag, and is not a
configuration key (decision 2).
--allow-uncommittedapplies each of the two refusing cases —test_a_staged_migration_applies_under_the_escape_hatchandtest_a_committed_then_edited_migration_applies_under_the_escape_hatchin the file above — andtests/unit/test_allow_uncommitted_is_not_a_config_key.py::test_no_published_config_key_selects_the_committed_check_escape_hatchreads the config schema and asserts no key selects it, in the shapetest_config_key_call_sites.pyuses for config-key claims. - The refusal's remedy names the flag, and the predicate reads the loader's own
bytes (decision 1).
test_the_refusal_remedy_names_the_flagasserts the remedy; and the comparison is againstmigration.checksum, driven both ways intests/integration/test_committed_migration_check_adapter.py— a working-tree edit after the load does not change the verdict (test_a_working_tree_edit_after_the_load_does_not_change_the_verdict) while an edit before the load is seen as modified (test_an_edit_before_the_load_is_seen_as_modified), which is the same window the check-then-load alternative loses. Deliberately not owed: a driving case for a tree that is not a git repository. That refusal already ships unconditionally (decision 3), so a test of it cannot go RED against this change, and its control — the same tree applies under the flag — cannot be constructed without replacing project resolution. The earlier draft of this ADR owed exactly that test; it is deleted with the reason, not carried to another milestone. - The refusal leaves no database behind (decision 4's seat).
tests/integration/test_migrate_apply_committed_check.py::test_a_refused_apply_leaves_no_database_behinddiffs the tree after a refused apply — the property #63 and #210 established for the refusals already in that band, extended to this one rather than assumed to carry. - The new spawn site joins
PROCESS_SPAWN_SITES, and its argument vector is fixed.tests/unit/test_network_call_sites.py'sPROCESS_SPAWN_SITESnow holds five entries —infrastructure/git/committed_check.pyis the new one — asserted by equality against the whole set, andtests/integration/test_committed_migration_check_adapter.py::test_the_git_vector_is_fixed_and_carries_a_timeoutholds that the vector is fixed by the adapter, cannot be handed a URL or a remote, and carries a timeout. - The T-7 spawn bullet moved in the same commit as the set.
docs/security/threat-model.md's bullet now spells five and namesinfrastructure/git/committed_check.py;tests/unit/test_threat_model_t7_claims.py::test_the_t7_spawn_bullet_names_every_pinned_spawn_site_and_spells_how_manyandtests/unit/test_network_call_sites.py::test_no_module_outside_the_recorded_spawn_sites_can_start_another_programderive the two sides independently, so no commit in between is red — measured, above. - The git query is bounded and does not trust its input. The
HEAD:<path>form already forecloses the option half (measured, decision 1);tests/integration/test_committed_migration_check_adapter.py::test_an_untrusted_filename_never_resolves_to_a_different_revisiondrives a filename carrying a:, a leading../, or a newline through the adapter and asserts it refuses or resolves to the file it names, never to a different revision. - The residual population is rewritten per control, in the same commit as the check. The key and its measured exclusion are in decision 5. The T-15 entry narrows rather than closing: what becomes enforced is committed, and what stays owed is merged into a reviewed branch. Whether that rewrite is faithful is a reading and no mechanical check reaches it, which is said here rather than left to be inferred from a test name beside it. The dated CHANGELOG section is not a mover.
Nothing from this ADR's decisions remains owed to a future slice, so this
ADR heads no Still owed section — the discharge that moves the roadmap's
literal Still owed count down by one (row 10), the first such move in the
Phase B wave. The two things the check does not reach are deliberate floors
recorded in What this does not close, not implementation debt: that the commit
reached the default branch through a reviewed pull request (raising it needs a
forge and a network call inside migrate apply), and the approval-provenance
pointer that #281 proposes
and states is itself not an enforcement mechanism.