POLICY.md owns repository rules and the operations contract owns the operational ones. This page describes the runtime entry point that installs, updates and diagnoses the MCP bridge and the session relay, and it is written against that contract's clause numbers so a reader can check a claim against the rule it came from.
Updating is the half that can lose something. A first install has nothing to destroy; a second one is standing on a runtime somebody is using and a database nobody can rebuild, so most of what follows is about what is read before anything moves and what is put back when it does not. Updating an installation is where that lives.
Two entry points exist and they are deliberately not one:
| Command | Installs | Contract |
|---|---|---|
python3 scripts/install.py --check or --apply |
Skill links into Codex | OPS-2.3 |
python3 scripts/runtime_install.py |
The bridge, the relay, the MCP registration and the Linear hook | OPS-2.4, OPS-6.3 |
The first is unchanged by this page. It stays standard-library-only and idempotent, it refuses to
replace an existing directory or a foreign link, and runtime installation is never folded into it.
The runtime entry point reuses its LINKED, MISSING and CONFLICT vocabulary so one word means
one thing across both layers, and it reads the skill-link layer by running scripts/install.py --check rather than by reimplementing it.
scripts/crw_runtime/components.json is the single compatibility definition OPS-1.1 requires.
Both installation and diagnosis read it; neither carries a second copy of a revision, a version or
a digest.
It carries only what this checkout can prove about itself. Every field is either re-derived from the checkout at check time or marked with the OPS-0 status word that says it was not:
| Field | How it is established |
|---|---|
subdirectory, packageLocation |
Paths in this repository |
subdirectoryTree, packageTree |
git rev-parse HEAD:<path> |
sourceDigest |
The OPS-1.2 walk over the package directory |
version, requiresPython |
Read from the component's pyproject.toml |
upstream remote, revision, tree and licence |
recorded: carried from the import, not re-derivable here |
measuredPoints |
Empty, and unmeasured: this repository has exercised no combination |
python3 scripts/runtime_install.py verify-definition re-derives every derivable field and fails on
any disagreement, so the definition cannot drift from the source it describes. It runs in CI through
scripts/ci/contracts.py. The upstream revision is not derivable from
this checkout, because the import brought source rather than history, so it is instead required to
appear in packages/README.md, which is the provenance narrative OPS-1.5
says is retained rather than replaced. That keeps one machine-readable owner without letting the
prose and the definition disagree.
The repository commit is deliberately absent from the file. A commit SHA recorded inside the commit it names is self-referential, so it is measured at run time and reported, never committed.
What the definition does not carry is as important. Installed locations, entry points, interpreters, host names and measured points are host facts. OPS-3.2 makes a real record a private receipt, so the runtime entry point writes those to a host record outside this repository and this repository never commits one.
The host record at ${XDG_STATE_HOME:-~/.local/state}/codex-relay-workflow/host-record.json is the
other half of the definition and is never committed. It holds the repository commit and tree
measured at run time, checkout cleanliness, one entry per install location
(location, installMode, entryPoint, environment, interpreter,
interpreterPath, integrity, reachedVia) and the measured points.
This is what makes reuse reachable. Under OPS-1.3 a point means the combination was exercised, so
no amount of reading bytes produces one, and a component whose bytes match but whose combination
nobody has run classifies unmeasured and is preserved rather than reused.
runtime_install.py measure is the operation that produces a point, and it has to run something
real. Starting a process is not exercising a combination: the bridge's entry point starts a stdio
server and never contacts the App Server, so a recipe built on startup would record success against
an unreachable host. measure therefore runs two actual operations under the resolved interpreter:
| Component | Operation | What makes it an exercise |
|---|---|---|
| Relay | codex-session-relay --socket <sock> --state <dir> doctor |
actorReachability.socketConnect is a real connect and must equal ok |
| Bridge | packages/codex-thread-bridge/scripts/check_connection.py --socket <sock> |
The package's own read-only check starts the MCP server, lists its tools and calls get_capabilities, which is an App Server round trip |
That check is invoked, never modified or reimplemented. A connection, protocol or tool-call failure
records no qualifying point and the run reports why. The point records
{interpreter, codexCli, appServer, host, date, measuredBy, method} bound to one install location,
one combination and the sourceDigest it was measured against, so it is evidence tied to the bytes
it covers rather than an independently editable expectation. A point recorded against another
interpreter is a different combination and does not satisfy this one. Points are appended, never
replaced.
The appServer dimension is the bridge check's whole get_capabilities payload, serialized and
compared by exact string equality. That is deliberate: the identity of the server a combination was
exercised against is everything the round trip reported, not a version string someone chose to trust.
It also means any change to that payload's shape changes the dimension, so a point measured before
such a change does not cover a bridge built after it and the installation reads unmeasured until
it is measured again. The old point is not wrong and is not discarded; it remains evidence about the
build it was taken against, which is the behaviour a dimension is for. CRW-21 changed that payload,
so any point recorded before it needs one measure run to cover the current bridge. The same
property is why the payload carries no timestamp and no probe result: a dimension that varied
between two calls to the same build would never match itself.
interpreterPath is recorded because a console script has more than one written shape. pip emits a
direct #!<python> shebang when the destination allows it, and a #!/bin/sh trampoline that execs
the interpreter on a following line when it does not, which is what a path containing a space
produces. Reading the first line answered /bin/sh for the second shape, so nothing could be
asked of the interpreter: it reported no version and located no module, the component classified
unreadable, the install never promoted, and recovery deleted the environment the run had just
built as though it belonged to somebody else.
So the interpreter for a script this command created comes from the install record, written by the
run that used it, and is confirmed by running it. Records written before interpreterPath existed
still name the environment, whose interpreter is the one that environment was built with. Reading
the shebang stays the answer only for a script this command did not create, where there is no
recorded environment to ask, and the classification reports which of the three it used.
Every record this command reads — the definition, the host record, the Codex configuration, the hook file — is read at a narrow boundary that turns a failure into an answer rather than a traceback. The answer is one of four states, decided by an ordered observation rather than by a convenience test:
| State | What was observed |
|---|---|
ABSENT |
nothing exists at the path. The only state that may be read as a host with no history. |
PRESENT |
it was read. An existing record with nothing in it is PRESENT, not ABSENT. |
UNREADABLE |
something is there and its shape cannot be read: a directory or other non-regular file, a symlink whose target is established missing or looping, invalid UTF-8, unparseable JSON, or containers of the wrong type. |
ACCESS_ERROR |
nothing could be established: a permission or I/O failure reaching the path, a symlink whose target could not be resolved, or a parent directory that cannot be traversed. |
The distinction that matters most is the last row. Being unable to ask is not being told no, so a failure to establish existence is never reported as absence, and a permission problem is never reported as a malformed record.
The service reading is classified the same way, and the invocation wins: a service status command
that did not run yields ACCESS_ERROR, an answer with no boolean running yields UNREADABLE, and
only an answer that arrived yields RUNNING or STOPPED. A daemon is never reported stopped
because nobody could ask it.
A refusal names what failed: the exception type, the source path, and the file and line that raised. That is deliberate. Swallowing everything into a generic "unreadable" would file a defect in this command as a problem with the user's data, and the defect would then disappear from the record.
The guarantee is bounded and stated rather than implied. What it guarantees: the worst case for a record this command reads is a named refusal, not a crash. What it does not guarantee: that a record which could have been read is never refused. Validation is per known consumed field where the shape is known, and a class guarantee at the boundary everywhere else, so the residue is a record refused conservatively. That direction is the safe one and the refusal carries its reason, so it is reportable rather than silent.
Two further limits, for the same reason:
- "Nothing was written" is scoped to what can be guaranteed. Malformed input detected before
the first mutating step refuses and the target file's bytes are unchanged. A read failure after
a mutation reports the mutation instead of denying it: the outcome is
APPLIED_UNVERIFIEDwithapplied,wroteandreadBack: false, and the command exits non-zero. Reporting a landed write as a refusal that wrote nothing would invite a retry that appends a second registration, which is the outcome this command exists to prevent. The unchanged-bytes claim is about the target file; a lock file is created and removed beside it. - A read-only diagnosis reports rather than refuses.
diagnosenames the failed reading inhostRecordStateandhostRecordReadingand continues with what it could still observe, because refusing the whole diagnosis would discard the readings that did answer. It never reads an unreadable record as a clean host: the affected components classifyunreadable. Commands that would write —install,measure,register-mcp,hook— refuse outright.
Every change to the host record goes through one helper that takes the lock, loads the record inside it, applies the caller's narrow delta and saves. The helper never accepts a record. A caller that loads a record, spends minutes installing and exercising a runtime, and then hands the record back to be saved would overwrite whatever another run committed in between, and holding a lock over that save does not help, because the staleness is already inside the value being written. So a caller says what it learned — this install, these points, this selection — and the merge happens against the record as it then stands.
Recovery follows from the same rule. A failed install removes the directory it created and drops only the install records keyed to that directory. It leaves the selection exactly as found, because another run's successful promotion is not this run's to undo.
Registering an MCP server needs a controller on Python 3.11 or newer. tomllib arrived in
3.11 and it is the reader; without it every non-empty configuration is refused, and registration
refuses even into an empty one because it reads back the content it proposes to write. The refusal
names the interpreter that is running and says what to do about it.
The controller's interpreter is not the runtime's. This command installs 3.11+ runtimes whatever
interpreter started it, so an old controller does not mean an old installation - it means the
process reading your configuration cannot parse TOML, and rerunning runtime_install.py on a
newer interpreter is the whole fix. Diagnosis still reports everything that does not need the
parser and marks the configuration unreadable rather than guessing at it.
tomllib reads the configuration wherever it exists, which is Python 3.11 and newer: the host
interpreter and every runtime this command installs. A hand-written TOML reader is an open
correctness problem, and this one cost eight review rounds - delimiter counting, escape decoding,
dotted names, quoted keys, the three-quote sequence, brackets inside quoted names, Unicode line
boundaries, quoted member assignments - so it stopped being the reader.
No fallback remains. The narrow subset written to replace the hand-written reader produced two
more defects of its own - a quoted name read as a list of its characters, and a duplicate key
silently taking the last value - and it existed only to give one CI job something to run. So the
validate and tests jobs on Python 3.10 exercise the refusal rather than a second reader, and the
checks simulate the absence of tomllib on an interpreter that has it, so the refusal is verified on
both jobs rather than only where it bites.
Parsing is not reading a registration. A file where args is the string "ab" parses cleanly and
list() turns it into ["a", "b"], so the shape is validated before anything is compared:
mcp_servers a table, each entry a table, command a string, args a list of strings. Other
fields such as env are left alone rather than refused, and the comparison is a symmetric
projection onto the two fields registration actually decides on.
Appending gets the same treatment. Reading a file correctly does not make a trailing table mean
what it says: a root mcp_servers = {} is a closed inline table that [mcp_servers.x] cannot
extend, and under [[mcp_servers]] an appended table attaches to the last array element. So the
proposed content is read back before it is written, and it must carry the intended registration
and leave every other one unchanged, or nothing is written.
Every signal classification decides on carries the value its own question's reading produced, and nothing else. A reading that did not answer leaves its cell empty and names itself unreadable.
The failure this replaces was quiet. definition.git answers nothing when it cannot read,
nothing compared with a recorded tree hash is false, and false is what classification reads as
a disagreement: an installation this command owns was reported as somebody's fork, from a read
nobody performed. The sibling three lines above, the repository commit, was already correct.
Writing the comparison out at each site is what let one of them be right and the next one wrong.
ownership.Judgement is where the comparison lives now. compare returns nothing when the
observation was not made and records why; answer does the same for a reading that IS the
signal. The interpreter version, the host name, the Codex CLI, the App Server, the component
tree and the checkout status all go through it, and a repository commit nobody could read is
reported as unknown drift rather than as drift.
Four outcomes are declared per cell, because one rule would be wrong about most of them: a
reading that answers nothing has to stop the classification, a reading that raises is a named
refusal at the boundary, absence is sometimes a real no, and some cells are answered by no
observation this command makes. The cells come from ownership.Signals itself, so a signal
added without saying which reading answers it fails the inventory.
The cells come from ownership.Signals and each names the observation that answers it. That
catches a cell whose reading is wrong; it cannot catch a cell whose declaration is a lie. A cell
declared to have no reading is simply skipped, and that is the path the next defect took:
link_conflict sat empty while this command's own skill_links() was answering the very
question, because the declaration read "the skill installer's reading, not this command's" and
nothing tested that sentence.
So the claim is verified. A cell's subject comes off its own name by stripping the suffixes the
declaration lists, and for a cell that names no observation no function of this command may
carry that subject. link_conflict against skill_links is the case that would have failed.
The reading itself moved ahead of classification, where it should have been: scripts/install.py --check reports CONFLICT for a path this command does not own, and that is an
OPS-2.1 conflict exactly as a differing MCP registration is. A caller that makes no such reading
says so - linkConflictRead - because no conflict found and nobody looked are different
answers, and install has no Codex home in scope to read.
Every cell is declared, every declaration is verified, and install still promoted over a
conflict, because that defect lives one dimension up: the command that moves the selection
passed neither conflict reading. CONFLICT_READINGS names them, every call of
classify_component in this command has to pass each one, and the classification reports
conflictsRead so a caller that made no reading is distinguishable from one that found no
conflict. A cell may legitimately be None for a caller - the MCP registration is the bridge's
and says nothing about the relay - but the caller says so by passing the keyword.
install takes a --codex-home for this, defaulting the way diagnose does, and compares the
command alone. It knows which entry point it installed and knows nothing about the arguments a
host chose, and an empty argument list is not the absence of an expectation: it is the
expectation that there are none, which reports a conflict for a registration that is correct and
merely carries supported bridge arguments.
The filesystem listing is the whole inventory when the relay cannot answer, which is exactly
when hiding a store matters. The state root had its own branch for relay.sqlite3 and
everything else was looked for in child directories, so an operations ledger beside the root
database was in neither and was never listed. A third branch would reopen at the next place, so
the places are a rule - the state home, then each scope directory under it - and every store
pattern is looked for in every one of them. The root comes first and unconditionally, so a
directory listing that cannot be read loses the scopes and not the root.
release_candidate takes the host-record lock, and a TimeoutError used to leave it. That meant
the cleanup path of an already-failing install raised, and the run reported an internal error
instead of whether its destination is retriable - the two things criterion 2 and criterion 4 ask
of a failed run. A lock another run holds establishes nothing about the selection, which is the
answer the same function already gives for a record it cannot read, so it takes that branch: the
candidate is kept and the refusal says why.
That was one sibling. install and register-mcp answered the same event properly and the hook
path did not: with the hook file locked, hook --apply reported
internalError: TimeoutError naming hostrecord.py:292 - a claim that this command has a
defect, which is about the code rather than about the host and sends whoever reads it somewhere
that has nothing wrong with it. Answering it at the hook and stopping would be the repair that
reopens at the next sibling, so main() answers a busy lock as well, ahead of the arm that files
anything unmodelled as a defect. cmd_hook still answers for itself, because it is the one that
knows the settings are written before the hook and a lock taken between them leaves them on disk.
The check reads the lock reachers as a call graph rather than a list, and requires the busy arm to
precede the catch-all, because an arm after it is unreachable.
Review then found the other half of it. TimeoutError is an OSError, and a destination on a
network mount raises it with ETIMEDOUT for an ordinary filesystem call, so answering the
built-in would claim another run holds a lock that was never involved - the same defect, inside
the contract that exists to prevent it. The lock raises hostrecord.Busy, its own type, which
subclasses TimeoutError so a caller that already answered the broader question keeps working.
The check requires the narrow type and forbids the broad one.
Two readings that answer different questions are never joined into one value. summarise
made three doctor invocations and then read selected or discovery, so a selected store that
did not answer borrowed the discovered store's path, store id and socketConnect while the
service status, the assignment lookup and the trial all kept acting on the selected one. That is
the conflict OPS-3.4 asks this reading to surface, reported as agreement.
The summary now says which question answered, in scopeAnsweredBy, and hands the caller the
invocation it came from in scopeCommand so a field derived from that scope names the same
reading instead of deciding the provenance a second time. An explicit selection that could not
be read reports no scope at all; the discovered store is a different store.
The same distinction reaches the registration. LINKED means the file registers exactly the
command this run asked about; PRESENT means a registration is there and nothing was compared,
because no expected command was supplied. Collapsed into one set, mcpExposed reported
verified with evidence reading "the configuration registers this exact command" for a host
registering something else entirely.
A partition belongs to the module that declares it, and a consumer asks that module rather than
testing one of its members. == UNREADABLE answers for one of the four reading states and
silently says yes to another, which is how a configuration that could not be reached at all
reached classification as one that had been read. The same shape produced a bridge classified
against whatever PATH resolved, a point recorded with a dimension nobody observed, and a replay
decided on one of the seven values the relay actually compares.
A check reads every UPPER_CASE module-level binding out of the source, resolves the strings it
names - including names, cross-module references and concatenations - and reports any comparison
against one of those strings from a module that can see the declaration. Scoped to importers,
because unrelated modules share short words: a destination kind spelled host has nothing to do
with the hostname dimension whose key is spelled the same.
Its limit is stated rather than papered over. It reads comparisons; literal key access is not covered, because payload keys are data and forbidding them would forbid reading a payload at all. The one map where that distinction decides something is guarded separately, by an access contract over the comparison loop itself.
Declaring a set fixes what belongs to it and nothing else. The gate over that set still applies whatever predicate it wrote and the probe over it still asks whatever runtime was nearest, which is how one defect reopened a dimension up four separate times: a whitespace-only turn id read as supplied here and as blank by the relay, an empty server table read as an absent registration, an artifact rule asked of this checkout while a different installed relay acts on the answer, and a smoke check whose bytes decided a point that named only the installed package.
So a member is a pair. TRIAL_REQUIRED_INPUTS maps each input to its flag and to the predicate
its consumer applies - NON_BLANK for this command's own minimum, the relay's own
validated_turn_id for the anchors the relay refuses blank. PREFLIGHT_PROBES names the
read-only probes, and each runs the interpreter it was handed rather than this controller.
PRESENCE_READINGS pairs a presence question with the reader whose sentinel answers it, because
an empty mapping is falsey and is not an absent one. And exerciseDigest is a dimension of a
point, because the bridge's smoke check lives outside the installed package and its bytes decide
the claim the point records.
Two consequences are worth stating rather than discovering. --trial needs the selected relay's
interpreter to be resolvable before it writes anything, and says so instead of falling back to
this checkout's copy of a rule the installation owns. And a point recorded before exerciseDigest
existed no longer qualifies: it cannot name the instrument that produced its claim, so a host that
reached own on such a point measures again.
Declaring a member as a pair closed the layer above and opened this one. A pair says a member HAS a predicate and a cell HAS a reading. It says nothing about whether that predicate is the strongest one the consumer applies, or whether the cell has more than one place that writes it. Both gaps produced a working, well-formed, wrong answer.
NON_BLANK is this command's own minimum and nothing more, so a member left on it has every
further question about its value answered here. The artifact root was that member: its real
question is containment, and containment was decided by base in path.parents, a second copy of
a rule the relay owns. The copy was not the safe approximation it looked like. It disagreed with
the relay in both directions - it refused <root>/../<root>, which the relay accepts end to
end, and where the relay does refuse a root it named the deliverable as the thing at fault. Driven
directly, a relative root registers - the relationship row is written - and is refused at emit
with scope_escape.
So a member carries the predicate its consumer applies, and where that predicate is relational it
names the member supplying the other operand. --artifact-root is asked of scope.assert_within
after normalize_declared_path, which is the pair AuthorizedFile itself asks, in that order.
--recipient is asked of scope.check_recipient. --turn-thread cannot be asked of anything:
the relay holds that rule inside a method that needs a store. It is restated here and declared
as restated, naming where the original lives, and a check reads that place back - which is how the
commit introducing it was caught naming a class the relay does not have.
A cell has the same shape one level down. entry_point_recorded declared one reading and had two
assignments. The second filled the ownership cell from the interpreter a console script's first
line names, which interpreter_of already calls the fallback rather than the answer. A wrapper
this command never created, sitting outside every recorded root, whose author wrote a shebang
naming an interpreter inside a recorded environment, classified as this installation. The second
site now answers only from an interpreter the record names, and the cell declares both readings.
Two scans hold these instead of the instances, and neither names a member, a rule or a cell. One
follows a declared member's value through the preflight and reports any comparison this command
makes about it that is neither its own minimum nor the consumer's answer; the count comes off the
declared restatements, so a rule restated without being declared fails. The other reads which
local feeds each judgment cell, out of the Signals call itself, and requires every assignment to
it to name a reading that cell declares. Both carry a negative control.
The same class reached from underneath. A reading can also fill a cell wrongly because it never
reported a failure at all. ops12_digest walked with rglob, which answers a subtree it cannot
read by leaving it out. For a package with one unreadable subdirectory the digest that came back
was not merely wrong: it was byte for byte the digest that smaller tree really has. Nothing raised,
so the reading region around the call saw a value, the comparison saw a mismatch, and the component
was reported a fork - a claim that somebody had modified an installation nobody could read.
An incomplete reading is not a value. The walk is now explicit and fails on a directory it cannot
open, so the boundary reports ACCESS_ERROR and the cell goes unread. The file set is unchanged:
both committed package digests re-derive exactly, and verify-definition still reports no
findings. OMITTING_READERS names the readers whose answer to an unreadable subtree is omission -
rglob, glob, iterdir and os.walk, whose default onerror discards the error - and
OMISSION_DECLARED names each place one is used with what omission means there. os.scandir is
deliberately absent from that list: it raises, which is the behaviour the list exists to require.
Pruning is not omission, and review found where the difference bites. The walk opened every
directory, including the __pycache__ the definition excludes, so a cache directory nobody can
read turned a perfectly readable package into an unreadable one at every boundary that asks for
its digest. An excluded directory cannot change the answer, so it must not be able to withhold
it: it is pruned before it is opened, and every subtree that can affect the answer still raises.
Four instances of one class, each driven against the commit before the fix and against the commit after it, by the same probe. None of them asks whether a fix is present; each one exercises the defect and reports what the code answered.
| Instance | Criterion it reopened | Before | After |
|---|---|---|---|
| a foreign wrapper's first line decides ownership | 3 | entryPointInRecordedPath=True, interpreterFrom="the script's first line", class fork |
False, class foreign |
| an incomplete walk comes back as a value | 1, 3 | raised nothing and returned the smaller tree's own digest | raises PermissionError; classification refuses with ACCESS_ERROR |
| a busy hook lock is reported as an internal defect | 6 | internalError: TimeoutError at hostrecord.py:292 |
outcome: BUSY, internalError: null |
| the artifact-root question is answered by a rule written here | 5 | the relay holds <root>/../<root> and the preflight refuses it |
the two verdicts agree on every form of the root |
So the four criteria hold for the reasons they were written, rather than by assertion. Criterion 1 and criterion 3 required a reading that cannot answer to stop the classification; a walk that omitted a subtree was answering, and it no longer is. Criterion 5 required the trial to write nothing it cannot complete; the root is now judged by the rule that will actually be applied to it. Criterion 6 required a failed run to report whether its destination is retriable rather than an internal error; the hook path was the sibling still doing the latter.
One residue is recorded rather than fixed: scripts/hook_comparison.py also walks with rglob.
It is outside this change's scope and fills no judgment cell, so it is named here instead of being
swept in.
Three review rounds in a row produced what read as three separate defects, and they were one thing missing in three places. It was never a check nobody had written. It was a value an answer set could not express.
| Where | The answer it could not give | What that cost |
|---|---|---|
| the in-flight cell | established absent | a clean host could never promote, while the schema cell answered NO_STORE about the same store |
| the pointer rollback | restore to absence | a failed first install left a link naming a candidate nothing selected, and the candidate was then kept BECAUSE the pointer named it |
| the selection rollback | remove a selection that had none | the same install left its own candidate selected, and a selected candidate is never released |
All three end in the permanent refusal the update path exists to remove, reached from three directions. So the rule is stated at the layer the instances came from rather than patched a fourth time: where absence is a normal state, the answer set is incomplete until it can say so. A rollback that can only restore a value cannot restore "there was nothing"; a cell that can only report a reading or a failure cannot report a question whose true answer is zero.
ABSENCE_ANSWERS declares each place and the operation it answers absence with, and the check
derives the places from the source instead of reading that list: a function that is handed
the state it found — a parameter named previous, before or presence — is answering about
something that may not have been there. A derived place with no declaration fails, and so does a
declared operation that either does not exist or is never used where the answer is given, because
a capability nothing calls is the same silence as no capability at all. The scan carries injected
violations of each form, so an empty finding list is not mute.
The two absence deltas are compare-and-remove rather than remove. deselect takes away only an
entry that still names what this run wrote, and drop_pointer only the ownership record for the
path this run recorded. Undoing a promotion this run never made is a worse outcome than the
failure being rolled back.
restore_pointer is the third rollback delta and the only one that puts a value back, for
the half of that question absence cannot answer. The pointer ownership entry answers two things
at once: path is which path this host's pointer is, and the placement keys
(hostrecord.POINTER_PLACEMENT) are the evidence that a link this command placed is there.
Absence is the right rollback only for a run that INTRODUCED the entry. A run that inherited one
and failed must not erase it, because the path goes with it and the registration names that
path — a retry with a different --dest then derives another path and reads a registration
nobody changed as a conflict. So an inherited entry goes back: whole where the link was put
back, and with its placement withdrawn where the rollback established the link is absent,
which keeps the path and still refuses a link that turns up there afterwards. It compares
against the path this run wrote and carries the entry it found as two separate values,
because a caller handed its path before the lock can have written over an entry naming
somewhere else. What the rollback actually did is read back from the record rather than inferred
from the delta having been sent, so it can answer moved on truthfully. It reports the state
the record was left IN, which is not the same claim as "this call wrote it": a compare that
matched what was already there reports the same answer, and that is the honest one, because the
question is what a later run will read.
The reading boundary answers questions about records. Underneath it, main() converts anything
that escapes a handler into a controlled result and exits non-zero. The two are deliberately
separate:
| Reading refusal | internalError |
|
|---|---|---|
| means | this record could not be read | a defect in this command reached the top |
| carries | a state from the four-state partition |
the exception type and the line that raised it |
| about | the record | the code |
A defect is never filed as a statement about somebody's data, which is what would make it
disappear. What this guarantees is narrow and worth stating plainly: the worst case is a named
result rather than a traceback. It does not guarantee that every input was anticipated.
diagnose still reports unreadability and exits zero; the contract is about tracebacks, not about
forcing every command to refuse.
Reporting a refusal does not delete a directory. After a failed installation the result says which of these happened:
- retriable - removal was verified on the filesystem, so the same destination can be used again.
- not retriable - removal could not finish. The result names the residual path and what recovery needs, and the original failure is reported alongside the cleanup failure rather than replaced by it.
Whether the candidate may be removed at all is read, never remembered. hostrecord.update saves
inside the lock and releasing the lock can still raise afterwards, so a run can commit its
promotion and raise anyway; a flag set from "the call returned" would then delete a runtime that is
now selected. Recovery reads the selection back under its own lock and keeps the candidate when the
environment is selected and when the selection cannot be established, because an unreadable
record says nothing about what is selected. A raised failure releases exactly like a returned one.
Everything the trial needs is checked before its first command, and "needs" means what the relay
itself enforces rather than what is merely present. --turn-status is one of the four the relay
declares; --turn-thread equals --child-task, because a receipt's thread has to be the
relationship's child task; and every --artifact is an absolute, already-normalised path to a
regular file with no symbolic link at any component, readable, and inside --artifact-root, which
is what the relay checks while building the manifest. The relay revalidates afterwards, because a
path can change in between.
The recipient's settings are checked for usability, not presence. settings-record now runs
after register, so a value that is there but cannot be used - a malformed object, an @path
that is not readable, a settings object missing a required field - would be discovered after a
relationship row exists. The preflight therefore asks the relay's own reader and the relay's own
predicate, run read-only in the relay's interpreter: neither opens a store and neither writes. A
second copy of those rules here would be a restatement of something that lives in the relay, and
the next change would move only one of them. When the relay's interpreter cannot be resolved the
answer is unknown and the trial refuses, because a check that could not be made is not a check
that passed.
runtime_install.py install refuses unless verify-definition passes, then resolves an
interpreter that satisfies both components' requires-python. The controller itself runs on
Python 3.10 for CI and never selects itself for a runtime that requires 3.11 or newer; when no
suitable interpreter exists it refuses and names the requirement.
The environment is created as a new directory, so an existing one is never overwritten. Each module's imported location is then read back from the interpreter rather than assumed, because an editable install leaves nothing under site-packages and a copied install does, and its OPS-1.2 digest is computed from whatever the interpreter actually resolved.
The candidate is then exercised, and the recorded pointer moves only after a qualifying point exists for it. OPS-2.4 sequences an update as measure, install, measure again, and the second measurement is the one that produces the point; promoting before it would select a runtime that imports cleanly and fails the moment it is used. A candidate whose exercise fails stays unselected and the previously selected runtime remains selected. A failure at any step up to the promotion leaves the previous runtime in place, and nothing here removes, moves or recreates the store: update failure and store loss are different accidents and the recovery for one must not cause the other. Past the promotion there is one exception, and it carries its own exit status.
The staging claim is written last. It says this staging finished, and until the selection is committed and the owned pointer names the environment there is nothing finished to say -- so by the time writing it can fail, the registered command already resolves into the new runtime. The replacement has happened and only its record has not, and those are reported as two outcomes rather than folded into one.
| Field | Answers |
|---|---|
promoted |
whether THIS run replaced a runtime. A resumed promotion did; adopting bookkeeping for an installation the record already selected did not |
inService |
whether this destination must be kept. True while the record selects this environment or the owned pointer names it, true once its claim has settled -- a runtime promoted once may still have a process running out of it, which is why staging.decide() never reclaims a settled claim either -- and true when none of that could be read, because an environment nobody could establish as free is not one that is free. False only when the readings say so |
claimSettled |
whether the claim recording it was written |
claim |
the claim's own two outcomes -- settled for the record landing, released for the call finishing -- with the readback and the selection snapshot that decided them, any residual path, and what raised |
recoveryRequires |
what has to be done next, under the same key a refusal reports it |
So install has three exit statuses rather than two:
| Status | What this run changed | The record | What it means |
|---|---|---|---|
0 |
it landed | written | the run finished |
3 |
it landed | not written | what this run changed on the host landed and the record of it did not |
1 |
nothing | not written | this run changed nothing and recorded nothing; whatever the host selected and reached before it, it still does |
"What this run changed" is not always a replacement. Promoting a candidate replaces the
selected runtime; finishing an interrupted promotion places the pointer a dead run never
wrote; adopting an installation older than claims changes only its bookkeeping and replaces
nothing at all. All three reach 0 or 3 on the same rule -- whether the claim settled --
and promoted is what distinguishes them.
Exit 1 says what this run did, not that the host is consistent. A resume refuses with it when the owned pointer is unreadable or names something this record does not account for, and in that case a previous run had already committed the new selection before it died -- so the record names the new environment while the pointer still names the old one. The run changed nothing; the disagreement it found was already there, and the result names it.
Exit 3 is not a refusal and must not be read as one. Non-zero here means the opposite of what it means everywhere else in this command: what the run changed on the host landed, and a process may be running out of the environment it changed. A wrapper that reads every non-zero install status as "nothing changed" would report the old runtime as selected, or clean up an environment that is still in service. This command releases a candidate only on exit 1.
It does not follow that an exit-3 environment is still the one the host reaches. A competing
install can supersede it during the claim write, and then the same result carries
selection.selects: false, selection.pointerNames: false and inService: false. The status
says the run's change landed; inService says whether the destination must be kept. Read the
second for any cleanup decision.
The status says what this run did; it does not promise what is true when you read it. A
competing install can supersede this environment between the promotion and the result, and
then status 3 is still correct about this run while inService is false. Key a cleanup
decision on inService and never on the status alone, and read promoted for the narrower
question of whether this run replaced anything: an adoption reports neither.
Which accident happened, and what to do about it, is in recoveryRequires -- derived from the
claim as it reads back and from a selection snapshot taken under the promotion lock, never from
the exception alone:
| What the result says | What happened | What to do |
|---|---|---|
settled true, released false |
the claim landed and the call failed on its way out | nothing about the record; any lock file left behind is named, and the next claim write clears one older than STALE_LOCK_SECONDS |
| the record still selects this environment | the write failed and the promotion stands | clear what stopped the write, then rerun: the next run reads an interrupted promotion and records it, rebuilding nothing. Rerunning before the write can succeed returns this same result and changes nothing |
| the record selects only part of this environment | the selection is split across two environments | read the record before rerunning; a resume requires every configured component in the same place and refuses otherwise, so there is no single promotion here to finish |
| the record selects elsewhere but something still reaches this environment | a promotion moved on, or died before its pointer | leave the directory alone; the next run keeps and reports it rather than repairing it |
| the record selects elsewhere and nothing reaches it | a later promotion superseded this staging | nothing; do not rerun here to settle it, because the next run reads an abandoned staging and would remove and rebuild it |
| the claim could not be read, and the record still selects this environment | the claim at that path is unreadable | make it readable or remove it first; an unreadable claim is not one this command may act on, so a rerun reports the directory and leaves it |
| the claim could not be read, and the record selects elsewhere | the claim is unreadable for a staging that has been superseded | leave it alone. Repairing it as STAGING has the next run read an abandoned staging and rebuild the directory, and removing it leaves one populated and claimless that every later install refuses as somebody else's |
| the claim could not be read, and the selection could not be either | both readings failed | read the host record first; which of the two rows above applies depends on it, and nothing is at risk meanwhile because a claim this command cannot read is one it leaves alone |
| the host record is gone | the authority for what this host selected was lost | restore the record before rerunning, and do not remove the environment: the owned pointer may still reach it |
| the selection could not be established | the snapshot could not be taken | read the host record before acting; it decides whether a rerun records, keeps or rebuilds |
The snapshot is consistent, not durable. Nothing holds the promotion lock until an operator reads the result, so what is reported is what the record said at that moment; re-read it before acting if time has passed.
The first install is the easy half. The second one is where the previous runtime and the store can be lost, and until this section existed it could not happen at all.
The environment is named from the definition version and the combined source digests, so a new
combination always gets a new directory. The entry point recorded for it is that concrete path,
and the promotion gate compared the Codex registration against it. So once an installation had
registered env-A/bin/codex-thread-bridge, every later update registered nothing, compared the
new entry point against the old registration, read CONFLICT, classified the candidate
conflict, refused to promote, and then deleted the environment it had just built and
exercised. The registration was pinned to the first install for ever, and register-mcp could
not move it either: it writes only on CREATED and reports CONFLICT for a name already
registered with a different command.
The fix is an indirection this command owns rather than a rewrite of somebody's configuration.
<destination>/current is a directory symlink. The registration and any user-facing command
name <destination>/current/bin/<console script>, which is stable across every update, so
config.toml is written once and never rewritten. That matters more than it looks: this
repository refuses to approximate TOML, and the byte-preservation proof the registration rests on
is that the prior content is an exact prefix of the new file. An in-place edit cannot satisfy
that, so a registration that had to change on every update would have to give up the one property
that makes appending safe.
A console script keeps the absolute shebang pip wrote, so a process started through the pointer
reports the concrete environment as its sys.prefix and its sys.executable. The pointer is a
way to reach a runtime and never an identity. A bridge Codex has already spawned goes on running
its own environment after the pointer moves, which is how criterion 4's process liveness survives
an update, and it survives only because nothing here removes a predecessor.
Two strings answer two questions, and they are not interchangeable. The candidate is classified
through its concrete entry point, because before the swap current still resolves to the
predecessor: classifying through it would read the previous interpreter, digest the previous
bytes, and report the new candidate as a fork of itself. Only the registration expectation uses
the pointer, and the pointer path is read from the host record rather than rebuilt from the
destination argument, because the registration comparison is string equality and --dest
spelled differently on a later run is a different string for the same directory.
Registering the pointer widens what a registration means, and the evidence that widening would
cost is taken back rather than lost. LINKED against the pointer says the configuration names
the pointer; it no longer says which runtime that is. So the link target is its own judgment cell,
read with readlink and compared against the recorded selection, and diagnosis reports the
registered command, the link target and where the entry point resolves as three fields. A
current repointed by hand at a fork is caught by the cell whose question that is, instead of
passing because a neighbouring cell was still satisfied.
The pointer only helps a host that has one. A host installed by an earlier version of this command registered a concrete entry point, and comparing that with the pointer reads as a conflict — which refuses the update and then deletes the candidate it has just built. That made the installed base whose pinned registration the pointer exists to unpin the one base that could never receive it.
So a conflict is checked against the host record before it is believed. A registered command that the record names as an entry point of an install this command made is this command's own earlier registration, not somebody else's, and it does not refuse the update. Ownership is established positively from the record: a path that merely looks familiar proves nothing, and a registration nobody recorded stays the conflict it is.
Recognising it is not migrating it, and the difference is worth stating plainly. After the
update the configuration still names the predecessor. That is not a broken host — the
predecessor is preserved and still works — but Codex goes on spawning the previous bridge until
register-mcp is aimed at the pointer. Moving an existing registration would need this
repository to rewrite a table it did not write, and the append-only writer proves it preserved
everything by requiring the prior content to be an exact prefix of the new file, which an
in-place edit cannot satisfy. That is a different contract, so it is named here rather than
improvised.
The environment name is deterministic and the directory is created with an exclusive mkdir,
which is what proves a run owns it. That proof used to expire badly: a run killed outright left
the directory behind, and every retry of the same destination refused at the existence check for
ever.
A run now leaves two files in the directory, and they are two because they answer two questions. The lock answers whether anybody is still building, and it is created once and never replaced. The claim answers what that run said it was doing, and it is rewritten when the staging settles. Collapsing them is not a tidiness question: an advisory lock belongs to an inode rather than to a name, so locking the file that is later replaced by rename leaves the lock on an unlinked inode while the next reader opens the new one and finds it free. That reported a live build as abandoned, and the next run deleted a directory somebody was still building. It is two files because of that.
Removing anything needs positive proof of ownership, so the claim has to carry this command's own marker, its claim version, and a state from the declared set. Readable JSON at that path is not proof; a file somebody else left is left alone.
| Observed | Answer |
|---|---|
| No claim, and the directory holds files | Somebody else's. Refused, nothing touched |
| No claim, and the directory is empty | Taken over as it stands with rmdir, which succeeds only on an empty directory, so the operation is its own proof that nothing was destroyed |
| A claim of this command's, the lock held | Another run is building it. Refused, nothing touched |
| A claim of this command's, the lock free, nothing using it | An abandoned staging. Reclaimed |
| A claim, and whether anyone holds it could not be established | Kept, and reported as a residual path with what recovery needs |
| A settled claim, and the environment is in use | Already installed. Reported, nothing rebuilt |
| A settled claim, and nothing selects it any more | Kept. It is a runtime that was promoted once, and a process may still be running out of it |
| An unsettled claim for an environment that IS selected | An interrupted promotion. Finished rather than rebuilt |
| A lock held with no claim written | A run between taking the lock and writing its claim. Refused, nothing touched |
The first row hid the installed base. Claims are newer than the installations they describe, so every installation made before them is populated and carries nothing saying who made it — which is exactly how the table read somebody else's directory. The environment name is derived from the sources, so that refusal is permanent for that combination: there was no installed host this updater could move forward, which makes it not an updater.
| Observed | Answer |
|---|---|
| No claim, the directory holds files, and the host record selects a runtime inside it | This host's own installation, older than claims. Brought under this command's bookkeeping; nothing rebuilt, nothing removed |
The branch order is deliberately unchanged. Asking the conservative protection reading earlier would let a reading that FAILED authorise reuse, which is the one substitution this whole path exists to prevent. Ownership is established positively instead, from the narrow reading: the host record was read, and it says the runtime it selects lives in this very directory. Nothing else qualifies — a populated directory the record does not select is still somebody else's, and a selection reading that failed authorises nothing.
What that writes is the bookkeeping the installation never had: a settled claim, and a pointer aimed at the environment the record already selects, recorded as this command's. The record matters as much as the link, because the promotion refuses to replace a link this record never recorded placing — so adopting a host without recording the pointer would adopt it once and refuse it for ever after.
Two limits belong with it. The adopted environment's bytes are not re-measured here and the swap gate is not asked, because this replaces nothing: the directory can only be at that path if it was built from these sources, and the record already selects it, so the runtime a host reaches afterwards is the one it was already running. And where a pointer exists naming a different recorded environment, aiming it at the selected one is the documented repair for a selection and a pointer that disagree — the same repair a resume performs, and under the same gate, which both re-run rather than inherit.
A directory taken over with rmdir first has this command's own two files cleared from it, and
only those two. A run whose claim write failed used to leave its lock file behind, and rmdir
refuses a directory that still holds one — so the deterministic destination was blocked for ever,
which is the failure this whole path exists to remove, arriving by a narrower door. Such a failure
now releases the directory it created like any other.
Finishing an interrupted promotion asks a narrower question about the link than an ordinary promotion does. It cannot ask for agreement, because a resume necessarily finds the pointer disagreeing with the selection — that IS the interruption it repairs. It asks instead whether the link still names a runtime this host record accounts for, and refuses one repointed by hand while the run was dead.
It also asks OPS-4.4 again, and not the reading the interrupted run took. What a resume takes
over is durable — a selection on disk and a claim beside it, sitting there for however long it
took somebody to notice — while all three gate cells read state outside the process and all
three move in the meantime: a supervisor can be started, attempts open and close, the store’s
schema is whatever the selected runtime has since migrated it to. None of them is reusable, and
the daemon is the strongest case, where a prior ALLOWED cannot cross a process boundary at all.
There is nothing to reuse in any case, which states the point more exactly. The interrupted run
died before recording a verdict, so the durable state holds no gate reading: the resume was not
carrying a stale ALLOWED, it was moving a host’s runtime having never asked. The candidate’s own
declared schema is the one input that cannot have changed, being derived from bytes already
built, but it is read only as half of a comparison against a store that can.
The gate is asked where something is REPLACED, which is not every caller of this path. A resume finds a link naming the predecessor and moves a host from it to this environment. An installation older than claims has no link at all, and writing the first one changes which path reaches a runtime the record already selects rather than which runtime is reached — so it is not gated, for the same reason its result says its bytes were not re-measured. The test is the link and not the caller: a link that already names this environment, or no link, replaces nothing.
A verdict that is not ALLOWED refuses by name, carrying the verdict and the cells that blocked
or could not answer, because "the gate said no" sends an operator to this command’s source while
the cell sends them to the daemon, the attempts or the store. It refuses before the ownership
entry, which is this call’s first write, so nothing is written and nothing removed: the selection
is left as found, the pointer still names what it named, and the destination can be retried as it
stands once the named condition is cleared.
"Accounts for" is equality against a recorded environment or install location, and containment in neither direction. A target that CONTAINS a recorded path is not a recorded runtime: the destination root is the parent of every environment under it, so a link repointed at the destination read as accounted for and was replaced. The containment helper asks the opposite question — is this path inside that root — and is right everywhere it is used; it was the wrong question here.
Liveness is the lock and not the recorded process id, for the reason the relay already recorded
about its own supervisor: inside a container sharing a kernel, the same process id under the same
boot id is a different process, and a process identity that can lie is worse than no reading. The
lock cannot lie about contention. Where flock is unavailable the answer is that nobody could
tell, and an owner nobody could establish is never read as an owner that is gone: deleting a live
run's environment is the accident this exists to prevent. Such a directory is kept and named, so
an orphan is findable and reportable rather than either silently accumulated or silently removed.
The lock's lifetime is the run's. The operating system releases it when the process ends however it ends, which is what makes a killed run readable as abandoned, and a run that reaches an end of its own releases it rather than leaving the answer to exit.
Deciding and acting are one step, under a second lock beside the directory. Reading first and
acting later is not safe even with everything above: two retries can both find the same
abandoned staging and both decide to reclaim it, and the first then deletes it, recreates it and
starts building while the second deletes that live build on the strength of an answer it got
before any of it happened. So the reading is taken again inside that lock, immediately before the
removal, and a run that cannot take the lock reports that and touches nothing. Past this point
the exclusive mkdir is what a competing run loses to, as it always was.
OPS-4.4 sequences an update around a daemon that is not running and open attempts that have been reconciled. Three readings answer that, each filling only its own cell:
| Cell | The reading that answers it |
|---|---|
daemon |
the relay's service status, whose running is decided by the lock a supervisor holds |
inFlight |
whether a store is there at all, then the relay's doctor, whose contents.openAttempts counts in-flight and held-uncertain attempts |
storeSchema |
the store's own schema inventory — every object the catalog reports, read read-only through the relay's read_only_rows. Keyed by kind AND name, so its evidence lists carry index sync_ready rather than sync_ready: a trigger may share a table's name, and an object whose kind changed is one object lost and a different one gained rather than one redefinition. The key was storeTables while it already held all of that, which named it narrower than its contents |
The in-flight cell reads twice, and the order is the point. The relay reports contents
unavailable both for a store that is missing and for one it cannot read, and those are opposite
answers here: an absent store has no open attempt, an unreadable one has an unknown number.
Without the first reading the cell could not say "established absent", so a first install on a
clean host refused for ever while the schema cell, which does look at the path, answered
NO_STORE about the very same store. Two readings of one cell's own question is not a cell
borrowing its neighbour's answer; it is the ordered observation the record reader already makes,
where absence is settled by looking before anything is opened. Two readings that disagree are
still no answer.
The swap proceeds only when the daemon is established stopped, the open attempts are established
zero, and the store's schema is established compatible. Any cell that could not be read decides
UNESTABLISHED, which keeps the existing installation exactly as a blocking answer does. A
check that could not be made is not a check that passed, and a daemon is never reported stopped
because nobody could ask it.
This command never starts or stops a daemon. OPS-4.1 gives the service to the scope operator, so a running daemon is a refusal here and not something to resolve.
What the daemon cell does not guarantee is worth stating, because the gate would otherwise
read as stronger than it is. The relay's own liveness answer releases its lock before returning,
so STOPPED describes a moment that has already passed. Taking the reading inside the promotion
lock narrows the window to the promotion's own length; it cannot close it, because that lock
excludes other runs of this command and says nothing to a supervisor. Closing it would mean
holding the store's daemon lock across the gate and the promotion — a lock OPS-4.1 deliberately
keeps in the scope operator's hands — so it is a question about the contract rather than about
this code, and it is left open rather than answered here.
The obvious reading would compare the store's recorded schema version with the candidate's. It
would also be worthless. The relay declares SCHEMA_VERSION = 1, has never raised it, writes it
once with INSERT OR IGNORE when the database is created, and grows its schema through
separate CREATE ... IF NOT EXISTS statements, tables and indexes alike. Every store therefore
agrees with every candidate at version one, and the comparison would detect neither a downgrade
nor an upgrade while looking exactly like a check.
So the cell compares what actually differs: each object's CREATE statement in the store's
sqlite_master against the statements the candidate relay declares. Statements and not names,
because names agree while a column, a constraint or a default differs, and that difference is a
schema change the new runtime would apply the first time it opens the store for writing.
Runs of whitespace outside quoted text are normalised away, because SQLite keeps the original
CREATE text verbatim and formatting drifts between a store written long ago and a candidate's
current DDL. Nothing else is. Going further is not free: lowercasing the statement made
DEFAULT 'A' and DEFAULT 'a' compare equal, and collapsing whitespace inside quotes made
'a b' and 'a b' compare equal, and both are real schema differences reported as agreement.
What remains is stated rather than implied: two statements that mean the same thing written
differently are reported as a difference, which refuses an update and therefore keeps the
previous installation. A reading that carries object names without their statements cannot answer
this cell at all and says so, because names agree while a column differs.
Every object, and not only the tables. Both readings ask the catalog one question that names no
kind at all, so indexes, triggers and views are compared on the same terms tables are. Asking
only for type = 'table' was the name comparison's mistake one level up: it agreed about
everything it had not looked at, and the relay's own schema has carried indexes all along. A
store that had lost one compared identical to a candidate that declares it, and the new daemon
would have re-created it on its first write-open — a migration arrived at by not looking.
What the catalog is asked for is every row it holds, less the objects SQLite maintains for
itself: the autoindexes a UNIQUE or PRIMARY KEY constraint creates, whose definition is
already inside the table statement being compared, and the bookkeeping tables AUTOINCREMENT
and ANALYZE leave behind. The exclusion is an exact prefix rather than NOT LIKE 'sqlite_%',
because LIKE reads _ as a one-character wildcard and that pattern also dropped a legal user
object named sqlitexfoo.
Each object is keyed by its kind and its name, so the evidence lists and the refusal text
read index sync_ready rather than sync_ready. A trigger may share a name with a table, so
names alone can collide, and an object whose kind changed would otherwise be reported as one
redefinition when it is really one object lost and a different one gained. onlyInStore,
onlyInCandidate and definedDifferently carry entries in that <type> <name> form.
| Answer | Observed | Decision |
|---|---|---|
NO_STORE |
no store exists at the resolved selection | allowed, and reported as absence rather than as agreement |
AGREES |
the same schema objects, defined identically | allowed |
EXTENDS |
the candidate declares schema objects the store does not hold | refused |
DIFFERS |
a shared object is defined differently | refused |
NARROWS |
the store holds schema objects the candidate does not declare | refused |
NARROWS is the implicit downgrade the issue forbids: a runtime that does not know an object
cannot preserve what is in it. The other two refuse for the contract's reason rather than that
one. The relay opens its store read-write and runs its whole DDL script on every open, so a
candidate whose schema is not the store's schema applies the difference the moment the new
daemon first starts. OPS-4.5 reserves that for its own decision, in its own issue, with a copied
backup of the whole state directory taken first, so letting an update wave it through is exactly
the implicit migration the clause forbids. An update is not the place either direction is decided,
and the refusal names the objects so the next step is obvious.
The reading is the relay's own, run under the relay's own interpreter. A second copy of the rule here would be a restatement of something the relay owns, and the next change would move only one of them. It opens the database read-only and runs no schema script, so asking the question does not create the store the question is about. Absence is established by looking at the path, never inferred from a failed open, because a permission failure and a locked database also fail to open and neither of them means nothing is there. The report names which selection answered, since an absent store at the wrong state directory while a sibling store holds the in-flight attempts is the OPS-3.4 conflict rather than a clean host.
The selection in the host record and the pointer on disk are two truths, and both the order they are written in and the lock they are written under are the safety argument. They are written inside one critical section, holding the pointer's lock, and the selection is committed first.
The reverse order has a real failure: the symlink lands, the record write then fails or the process raises, recovery reads a selection that does not name this environment, concludes the candidate was never promoted, removes it, and leaves the registered MCP command pointing into a directory that no longer exists. OPS-4.4 requires every state transition to be committed before its side effect, and this is that rule applied to the two halves of one promotion. Recovery also refuses to remove an environment the pointer names, so neither truth alone can authorise deleting a runtime the other one is still using.
Every judgment the promotion makes is decided on state read INSIDE that lock, and that rule is
declared rather than remembered. Three separate review findings turned out to be one defect
arriving three times: the swap gate ran against the record loaded before the build, the rollback
baseline was captured before the build, and the classification read a pointer at the destination
rather than the one the record names and the swap replaces. Each is the same shape, a decision
taken in the critical section on a value read outside it, and each was reported on its own
because nothing was looking at the class. PROMOTION_FRESH names the set and a check fails any
member read there without being read fresh there, so a fourth fails a test instead of arriving as
another round.
That lock is an advisory lock on one host-wide file beside the host record, and both halves of that are corrections. The lock this command uses for the short staging decision excludes by FILENAME on a path the caller derives, and decides validity by a 300-second modification time. Neither survives a promotion: two installs with different destinations derived different pointer paths, locked different files and never met, and a promotion that outstayed the window had its lock unlinked by a waiter while it was still working — because excluding by filename gives the holder's open descriptor no protection at all. Three reported defects, one set drawn wrong. The promotion lock is created once, never unlinked, and released by the operating system when its owner dies however it dies.
Holding one lock across both writes is what keeps two runs of this command from interleaving there and finishing with the record selecting one runtime while the pointer reaches another. It is a lock between runs of this command and nothing more: an editor or another tool that does not take it is not coordinated with, exactly as the configuration writer says of its own. And it cannot stop the process being killed, so a kill inside that window leaves a runtime that is selected and unreachable. That state is recognisable rather than fatal: the claim is unsettled and the environment is selected, so the next run finishes the promotion instead of rebuilding it. Rebuilding would be the wrong repair, because the runtime is built, it is already selected, and a process may be running out of it.
Ownership of the pointer is established from the record before it is replaced. Renaming over an
existing symlink succeeds whoever created it, so a current this command never recorded is left
alone; a real directory at that path fails the rename outright, which is the safe direction.
The pointer is read through its own partition over lstat and readlink. The record reader
cannot answer for it: that reader follows a link and then refuses anything that is not a regular
file, so a working directory symlink would be reported as an unreadable record.
A failed update leaves the previous runtime selected, the previous pointer target in place, the
owned configuration untouched, and the store exactly as it was. The result says which step failed
rather than only that something did: failedStep names the step and the boundary it was at, and
restored names the selection that was put back or says there was none to put back.
"Restores the previous selection" is narrower than it sounds, and deliberately. The rollback runs
under the promotion's own lock and puts back only the entries that still name what this run
wrote. If another run has promoted something else in the meantime, that entry is left alone and
the result says so in movedOnByAnotherRun: rolling back on top of somebody else's success is a
worse outcome than the failure being rolled back.
Putting a selection back includes putting it back to nothing. A component that had no previous
selection — a first install, and a legacy install whose combination was never selected before —
has the entry this run wrote taken away, in the same write that restores the entries that had a
previous value, so half a rollback cannot land. Until the delta set could say that, the run
reported a rollback, the pointer correctly went back to absence, and the candidate stayed
selected; being selected is then exactly what keeps a candidate from being released, so the
destination could never be retried. The result names what went back in restored and what was
taken away in removed.
What the pointer is put back to includes being put back to nothing. A first or legacy install has no pointer, so the swap creates one, and a rollback that could only restore a previous target left that link naming a candidate the selection had just been taken away from — after which recovery kept the candidate precisely BECAUSE the pointer named it, and the staging could never be reclaimed. Absence was the value missing from that answer set, the same shape as the established-absent answer the in-flight cell was missing. Removing a pointer is guarded the way placing one is: only a symbolic link, only while it still names what this run placed, and the absence is read back before it is claimed. A restoration that cannot be read back reports a residual pointer and keeps the candidate rather than claiming the rollback completed.
The ownership record goes with the link, for the run that PUT IT THERE. The record is what makes
a link this command's — the promotion refuses to replace one the record never recorded placing —
so a rollback that removed the link and left the record behind said this command owns a link that
is not there, and armed that guard in favour of whatever appeared at that path next. An entry this
run introduced is therefore dropped, and only for the path this run recorded. Where the rollback
restored ABSENCE the record is written only after the link is verifiably gone, because writing it
first would leave a link nobody recorded — the refusal shape from the opposite side. Where a link
was REPLACED the record is written whichever way the restoration went, including when putting the
previous target back could not be read back: a link is at that path either way, so the ordering
that protects the absence case has nothing to protect here, and the result reports the link's own
verified: false for what did not land.
An entry this run INHERITED is a different question, because a link that is missing does not mean
a record that is missing: a host whose recorded link was deleted out from under it has the entry
and no link. Erasing it takes away the path the registration names, and a retry aimed at a
different --dest then derives another path and reads a registration nobody changed as a
conflict. So the entry stays and its PLACEMENT is withdrawn — the path the registration depends
on is kept, and the guard goes on refusing whatever link turns up at that path, which is stricter
than the state the update found. Where the link is instead put back, the entry goes back whole,
which also takes this run's refreshed stamp off one it did not introduce; that happens whenever
the link was replaced, including when the restoration could not be read back, because the path in
the record is the same either way and the payload reports verified: false for the link itself.
The two outcomes recovery already had are unchanged. Removal verified on the filesystem means the destination is retriable; removal that could not finish reports the residual path, what recovery needs, and the original failure alongside the cleanup failure rather than replaced by it. A candidate that is selected, or whose record could not be read, or that the pointer names, is kept.
Two of the failure points the issue names do not exist in this command, and saying so is better
than implying a rollback that has nothing to roll back. Applying configuration is
register-mcp's, not install's, and with the pointer in place it happens once rather than on
every update; its own failure contract is above, including the one case where a write lands and
cannot be read back, which is reported as APPLIED_UNVERIFIED rather than as a refusal that wrote
nothing. Starting does not happen here at all: OPS-4.1 gives the service to the scope operator,
so this command refuses while a daemon runs and never starts one, and there is no start to fail or
to undo.
Nothing here removes, moves or recreates the store. Update failure and store loss are different accidents and the recovery for one must not cause the other.
This is a POSIX path. The environment layout, the interpreter under bin, the directory symlink
and the advisory lock are all POSIX assumptions this command already made elsewhere; Windows is
out of scope rather than approximated.
Classification reads the four OPS-2.1 signals and nothing else: where the entry point actually resolves, the checkout's commit, tree and cleanliness, whether the definition agrees with the installed bytes, and what the Codex configuration registers. A signal that cannot be read is reported as unreadable and stops classification; it never counts as a signal that agreed.
Cleanliness is read across the whole checkout, not only the component's subdirectory. An uncommitted change to a root script or to another package leaves every package digest untouched while the checkout is no longer the revision the definition names, and only the checkout-wide reading catches it.
The five OPS-2.2 classes are evaluated in their fixed order and the first match wins:
conflict, fork, foreign, unmeasured, own. Only own is reused. Because the committed
definition carries no measured points, a host install whose bytes match still classifies
unmeasured until the host record carries a point for the combination it runs under. That is the
intended answer, not a gap to close by relaxing the rule: OPS-1.3 refuses to let a matching digest
stand in for a run nobody performed. measure is the supported way out, and it is the only one.
Nothing outside a recorded path is ever overwritten, and no predecessor is removed: an environment a previous install built stays on disk after the pointer moves off it, which is what lets a process already running from it keep running. A foreign relay, a local fork, an existing directory, an existing link and an MCP name already registered with a different command are each reported with both values, and the run changes nothing.
The server is registered as [mcp_servers.<name>] in <CODEX_HOME>/config.toml, the supported
configuration path, through runtime_install.py register-mcp. What it registers is the owned
pointer, <destination>/current/bin/<console script>, and not the environment underneath it,
so an update moves the pointer and this file is never written a second time. The two are
separate claims and stay separately reported: the registration says which command Codex will
spawn, and the link target says which runtime that command reaches. Registration is append-only and
idempotent: an identical registration is reported LINKED and nothing is written, an absent one is
appended, and a different command or argument list is reported CONFLICT and nothing is written.
Every other table in the file, including other MCP servers and hook settings, is preserved byte for
byte, which the command checks by requiring the prior content to be an exact prefix of the new file
rather than by asserting it.
The reader is tomllib, which arrived in Python 3.11. A controller older than that refuses every
non-empty configuration instead of approximating one, and refuses into an empty one too, because
registration reads back the content it proposes to write. The refusal names the interpreter that is
running and says to rerun on a newer one. The controller's interpreter is not the runtime's: this
command installs 3.11+ runtimes whatever started it.
Parsing correctly is still not reading a registration. The parsed shape is validated before anything
is compared - mcp_servers a table, each entry a table, command a string, args a list of strings -
because a file where args is the string "ab" parses cleanly and list() turns it into
["a", "b"]. Anything that fails that validation is unreadable, and an unreadable file is never
appended to. Other fields such as env are left alone rather than refused.
A name that is not a bare key is written quoted, because a name containing a dot written raw becomes a sub-table of another server: the registration the command believes it made would not be the one in the file, and the next run would append a second.
The CRW plugin package declares this server too, so a host can end up with two of them: the configuration entry this command writes, and the declaration an installed package carries. Both start a bridge.
--owner names which one this host uses. user is the default and is the behavior above.
plugin writes no configuration entry at all; it writes crw-bridge-mcp.json beside the
completion hook's settings, which is the one fact the package cannot carry: the pointer that
names the installed runtime. The packaged launcher reads that record, and the declaration is
what registers the server.
Either owner can be installed first, so the refusal runs both ways. The user path is refused by a record naming the plugin; the plugin path is refused by an entry already in the configuration. Both report the other side with its evidence and write nothing, and a record or a configuration that could not be read refuses rather than defaulting, because installing on an unanswered question is how the second bridge arrives.
Writing the record is not registering a server. On a host with no plugin installed the record is inert, and the command says so rather than reporting an installation.
The property that fixes is a round trip, not three cases: what the writer emits, the reader reads
back unchanged, and a rerun then answers LINKED — including values carrying backslashes, quotes,
control characters and the three-quote sequence.
Writing is held under an exclusive lock for the whole read-modify-write, re-reads immediately before replacing, and replaces by temp file. That coordinates runs of this command with each other and removes truncation. It cannot coordinate with an editor that does not take the same lock, so it is not called compare-and-swap: a writer ignoring the lock can still land in the remaining window. The hook file is written the same way, with the same stated limit.
Diagnosis reports the six OPS-6.1 fields separately, in the OPS-6.2 shape, each with its own
evidence, exact command, acting process and measurement time. A field with no timed observation
behind it reports unknown; no time is ever invented or copied from another field.
| Field | Established by | Never established by |
|---|---|---|
installed |
The component classifies own |
The package importing somewhere |
mcpExposed |
Registration plus tool names observed in a live session | A configuration entry |
connected |
doctor reporting actorReachability.socketConnect as ok |
A socket file on disk |
deliveryAccepted |
An attempt that recorded a returned turn id | A dispatch or an absent error |
verificationComplete |
Every OPS-6.4 condition at once | A completed turn or a green check |
alwaysActive |
A supervised runtime surviving a host restart | Any of the five above |
A live session is the only thing that can list MCP tools, so mcpExposed stays not_verified on a
registration alone. It reaches verified from real tool names only: either --observed-tool
supplied by a caller that is itself in a live session, or the tool list the bridge's own
check_connection.py returns, which is an actual MCP session over stdio. Every record names the
destination it measured and whether that destination was temporary, so a temporary-destination
proof cannot be read as a claim about a host.
Two further results are reported beside those six and are never merged into them, because importing and preserving settings are separately falsifiable:
| Result | Established by |
|---|---|
imported |
The module imported under the resolved interpreter, carrying the __file__ it resolved to, so an import satisfied by another copy is visible |
settingsPreserved |
Every other table in config.toml and every other hook entry byte-identical before and after |
Diagnosis creates no work. deliveryAccepted requires an attempt that recorded a returned turn id,
which means creating one, so it reports not_applicable unless --trial is given. Trial mode is
the only mode that registers a relationship and sends, it names the scope it acted in, and it is
never implied by any other flag.
The send has to go far enough to produce the evidence. emit stores the receipt and enqueues the
delivery; the attempt itself happens in deliver. So the trial registers, emits, and then runs a
bounded deliver for that one event, and the field's evidence is the attempt's returned turn id.
An emitted receipt's own turn id never satisfies it. The authorized recipient and the requested
settings are recorded before the send, and the settings the host reported back are recorded with the
result.
Everything the trial needs is checked before its first command, so an incomplete trial writes
nothing: an artifact, a dispatch turn, a recipient equal to the parent task, and the recipient's
authorized settings. Each of those was previously discovered at the relay, after rows had already
been written. Settings are supplied with --recipient-settings, or acknowledged with
--settings-already-recorded when they are already authorized on the host; that acknowledgement is
recorded as the caller's own unverified claim, because every relay read constructs a store and there
is no read-only way to confirm it from here. A false acknowledgement can still reach the relay and
leave rows behind.
The dispatch request id is keyed on the issue and the dispatch turn. Keyed on the issue alone, a
second trial for the same issue replays the first generation, generation-bind then refuses the new
anchor, and the trial can only ever succeed once — which is not a delivery test. Keyed on both, a
retry of one dispatch still replays and reaches the same generation, and a genuinely new dispatch
opens its own.
The lookup's agreement is decided against the answer's responsibleRelationship field, not against
its serialized text. A substring test matches an archived assignment sitting anywhere in the
payload, so the guard meant to prove this process reads the expected store would pass against a
store where that relationship is closed.
register is the first mutating step, and the order is the guarantee. It is the producer of
the replay rule: it compares seven values - the parent task, the child task and the issue key
it hashes into a relationship id, plus the artifact roots, the allowed recipients and the two
host ids - and either
replays the relationship that already exists or refuses the whole registration without writing
anything else. The read-only lookup exposes only the first three, and no relay command returns the
other four, so the trial compares what it can read and lets register decide the rest before any
settings are recorded. A settings write placed ahead of it lands for a trial that register then
refuses on a scope or a host the lookup could never have shown.
What the trial compares is the whole identity the lookup exposes, not the responsible child alone.
An assignment carrying this issue and this child under a different parent hashes to a different
relationship id, and comparing the child alone read it as the same relationship. The report names
which fields were compared and which are decided by register, so it never reads as a complete
comparison of all seven.
One thing the trial cannot promise is that nothing at all was written: assignment-find
constructs a store, which creates the database and its schema. What a refusal before register
guarantees is that no settings and no relationship row were written.
Getting that far takes more than three commands, and each of the extra ones exists because the relay refuses the send without it. Measured against a running App Server, the sequence is:
| Step | Why the send needs it |
|---|---|
assignment-find |
Runs first. A lookup run afterwards could find the relationship the trial itself just created, which says nothing about the store |
register |
Creates the relationship and opens its first generation, and is the first mutating step on purpose |
settings-record |
A send is withheld until the recipient's authorized settings are on record, because preserving them is what the delivery checks against. Recorded after the relationship exists |
generation-open |
Replays that same dispatch request id to read the generation number back; it opens no second generation |
generation-bind |
The generation register opened is unbound, and an unbound generation cannot be emitted against |
admit-turn |
Only the anchor turn is admitted by default; a turn the child actually ran is a continuation |
emit |
Stores the receipt and enqueues it. It carries an artifact, because a reviewable receipt with an empty manifest is refused |
deliver |
The attempt itself, and the only step that can return the turn id this field needs |
Those invocations are built as data rather than inline, so a test can compare them against the required arguments the relay's own parser declares without performing a delivery. That check reads the parser statically, because the relay declares a newer Python than this repository runs its own checks with, and it covers every command the trial can send rather than the ones a fixture happened to build.
OPS-3.1 puts one relay service and one durable store behind an entire operating scope, which is one host, one OS user and one App Server. A second repository or a second project installs into that same scope and reuses the same service and the same store; nothing here creates a daemon or a store per project, per repository or per parent.
Diagnosis therefore reports the resolved scope, the store directory and database, the service owner
and whether a service is running, by calling the relay's own doctor and service status rather
than by rediscovering any of it.
doctor is called three times, because one call cannot answer all three questions.
A call that selects a store explicitly skips sibling discovery altogether and reports
checked: false, because the caller already decided which participants share that directory. That
applies to CODEX_SESSION_RELAY_STATE exactly as it applies to --state, so the discovery call
passes the socket, no --state, and a sanitized environment with that variable removed; inheriting
it would silently produce an empty conflict inventory. The selected call uses the explicit
--state for the store actually in use.
The third call is targeted at the root of the state home. Discovery enumerates child directories
only, so a relay.sqlite3 sitting directly in <state home>/codex-session-relay is invisible to
both calls above. A host can be in exactly that state, so it is inspected explicitly rather than
left out of the inventory. All three results are reported, a checked: false is preserved as not
checked rather than as none found, and no candidate is adopted.
A relay build that reports no siblingStores at all is a third answer again, and it is reported as
its own: an installed relay older than the revision that added sibling reporting emits nothing for
that field, and rendering that silence as an empty inventory would hide exactly the conflict the
inventory exists to surface. The summary distinguishes not reported, not checked, and checked.
Because a relay cannot always answer, the inventory also lists every relay database and operations ledger visible under the state home, each marked as listed rather than identified. Listing files is not rediscovering anything: nothing opens a database, chooses between candidates or decides which one serves a socket, and that judgement stays with the relay. It is there so a host whose installed relay is too old to report siblings still sees every store it has.
Equality of path strings is not proof under OPS-3.4. Proof is doctor from each participating
process reporting the same stateDirectory together with assignment-find --issue returning the
expected relationship. A relationship count is reported as the weaker observation it is: a count of
zero where an assignment is expected means the process is pointed somewhere else, and a nonzero
count from a different populated database would satisfy a count check while proving nothing.
That lookup constructs a writable store, and a diagnosis constructs none, so plain diagnose does
not run it and says so rather than claiming it happened. It runs in the trial, before any write, and
its result is compared against the relationship the caller independently supplies with
--expect-relationship; a store that does not hold it stops the trial before anything is written.
diagnose --assignment-lookup runs the same lookup on its own where that is wanted, and is
documented as constructing a store. One command cannot produce the reading from every participating
process that OPS-3.4 also asks for, and the report says so.
Every subsequent call sets both selectors, --state and CODEX_SESSION_RELAY_STATE, to the same
resolved absolute path, because under OPS-3.3 the flag alone moves the store while leaving the
adapter's operations-<scope>.sqlite3 ledger behind. That ledger is reported as its own artifact.
Other stores beside the resolved one are reported, never adopted and never hidden. doctor already
distinguishes stores that record no socket from stores claiming the same socket, and a host can
hold both alongside a separate operations-<scope>.sqlite3 ledger, which OPS-3.3 explains is
selected differently from the store. A host in that state is reported as ambiguous with its
candidates listed, because adopting one on a guess is how the wrong store gets served.
runtime_install.py hook installs the next-step Linear hook into the user hook file. Under OPS-6.3
a hook's identity is <source>:<event>:<matcher-index>:<hook-index> and Codex records a trusted
hash against it, so installation appends at the end and never inserts: inserting renumbers every
later hook in the same file and detaches the trusted hash recorded against the old identity. For
the same reason removal is refused rather than performed, and no content is silently updated.
The command records the identity, the trusted hash, the hook file path with its SHA-256 and the
issue that installed it, then reads the registration back. Installed, enabled and observed to have
fired are three separate claims and are reported as three. Installation is not activation: this
command never enables a daemon, and alwaysActive is a separate field with separate evidence.
What survives a hook installation is every existing identity and its hook content, so the trusted hash Codex recorded against each one stays attached. The file bytes do not: the document is reserialized. The MCP registration is the one that preserves bytes, by appending and leaving the prior content as an exact prefix.
runtime_install.py hook --adapter completion registers the Stop hook that catches a managed
turn ending without the records a completion needs. It is the same install path as above, with the
command derived from this checkout instead of typed, and it lands on Stop unless the caller
names another event.
The decision is not made in the hook. The hook contract
fixes the rules and the relay's guard-evaluate implements them, down to the exact Stop JSON to
print. scripts/completion_hook.py is the piece between the host and that guard: it reads the
delivered payload, asks the configured runtime, and prints only a block that runtime produced.
It cannot cost a turn. The host reads exit 2 as the blocking code and takes stderr as the
continuation prompt, and argparse exits 2 on any usage error, so the entry point parses no
arguments, writes nothing to stderr, captures the subprocess's streams rather than inheriting
them, and exits 0 on every path. A stale flag left in somebody's hook file is a hook that does
nothing, not a hold on every ordinary turn.
Its settings are its own file, crw-completion-hook.json beside the hook file, and they are
written before the hook that reads them: a hook registered against settings that are not there
answers config_absent on every Stop and releases, which is an installed hook that does
nothing and says so nowhere. Settings that already say something else are refused rather than
overwritten, because they carry the mode and a silent rewrite changes whether turns can be held
at all. observe is what an install writes; holding depends on per-session write isolation this
command cannot grant.
Settings this command can generate but its own reader cannot act on are refused rather than written, so an install cannot report success and leave every later Stop reading those settings as malformed. The adapter's budget is checked against the registered timeout at the same point, because that is the one value whose meaning needs both files: a budget the host's timeout does not exceed lets the host kill the adapter before it records why it did not answer.
--socket is recorded into those settings as socketPath when the install names one, and both
adapter copies pass it back as the global --socket option. It is what lets the guard tell that
the state directory it resolved holds another App Server's store: a store records the socket it
serves, and a Stop hook that inherits CODEX_SESSION_RELAY_STATE from a second installation would
otherwise read that store, find no relationship for its assignment, and hold a child that has
finished. The field is optional, so a document written before it existed stays byte-identical and
an ordinary reinstall is still unchanged rather than refused; a host with no socket configured
compares no provenance, which is what every host did before. Configure it wherever more than one
installation shares a machine.
With --owner plugin this command also writes crw-stop-hook.py beside those settings, and it
writes it before them. That file is the second candidate the package’s Stop declaration
opens, and it exists because a hook command is fixed when a turn starts, with the plugin root
already resolved into it. Installing a version removes the previous cache directory whole, so an
update landing while a task still holds that command leaves it naming a file that is gone, and
python3 exits 2 for a missing script — the hook protocol’s blocking code. Measured on the
user's host: eleven repeated Stop prompts in one turn of one task and eight in a turn of a
second, and neither turn able to finish until a compatibility path was restored. An isolated
reproduction produced thirty-seven in one turn.
Plugin packaging owns the full reference table and the
supported range; what belongs here is who writes the file and what that writer refuses.
| Question | Answer |
|---|---|
| Which command writes it | This one, with --owner plugin. plugin_transition.py transition writes it too, because it installs the same plugin-owned settings and would otherwise leave a host with the settings and no fallback |
| In what order | Launcher first. A launcher with no settings stands down in silence; settings whose fallback was never placed look installed and are not |
| What it refuses | A file that does not carry the launcher marker, and anything that is not a regular file. A symlink is reported by kind and never followed, because replacing through one writes to a file this command was never given |
| What the marker proves | That CRW put a launcher at that path. Not who ran the command, and not that the bytes are intact. The digest is reported beside it for the second question |
| How it is replaced | Temp file and os.replace, then read back, with the kind and the marker re-judged under the lock immediately before the write |
| Which command removes it | plugin_transition.py remove, under the launcher’s own lock and only while the marker is still there |
What disable does to it |
Nothing. disable stops new calls by retiring the settings and deletes no bytes |
The two files take separate locks and there is deliberately no lock spanning them. Widening one
means reworking a write path that is already proven, and it is not needed: every state the pair
can be left in is harmless. A launcher alone stands down. Settings alone are what the host had
before this file existed. What an interleaving can still do is make a receipt wrong, so the run
reads both paths back at the end and reports launcherObserved and settingsObserved — what the
host held, not what the run intended — and exits 3 when either half is missing, changed, or no
longer what it wrote. That status is the same fourth answer install uses: not a refusal,
because bytes really were written, and not success, because the result did not stay in effect.
A plugin-owned budget is capped at completion.MAX_PLUGIN_GUARD_SECONDS, the launcher ceiling
minus its margin. The launcher waits min(timeoutSeconds + 2, 9), so a budget above 7 collapses
the margin it exists to keep and the launcher’s deadline arrives while the adapter is still
writing the record of its own timeout. That bound lived in the transition alone until this
launcher became something every plugin host depends on; it now sits with the validation both
writers share, and the transition aliases it rather than keeping a second copy.
The plugin package declares this hook as well, and a host holding both registrations runs both
on every Stop: each asks the guard, each journals, and the turn's one hold goes to whichever
wins the reservation. --owner decides which registration exists.
user is the default and appends to the hook file as before. Its settings document is
byte-identical to what installed hosts already hold -- the owner is written only when it is not
the default -- so an ordinary reinstall is still unchanged rather than refused as settings that
say something else.
plugin writes the settings and registers nothing. It also records the interpreter and the
adapter entry point, which the user path carries in the command line it writes into the hook
file and a packaged command has no way to resolve for itself. The refusal runs both ways: the
user path is refused by settings naming the plugin, the plugin path by a registration already in
the hook file, and an unreadable hook file refuses both.
The packaged launcher repeats the check at run time, because installing the plugin is not a command this repository runs and cannot be refused from here. It reads the owner out of the settings and stands down unless the plugin owns the registration, so a host that acquires both still answers once.
The event is checked the same way and for the same reason. This adapter implements the Stop
contract and has a decision for no other event: elsewhere the payload means something else and
the output schema carries no top-level decision, so a hook registered on another event would
never see the turn it was installed to watch. Naming one is refused; another event still takes
an explicit --hook-command. Settings, budget and event are three checks of one kind, and the
kind is that an install must not succeed and leave a hook that is registered, inert and silent
about it.
The read-back belongs to that same kind. Settings that were written and could not be read back
as written answer config_applied_unverified: applied, because reporting a landed write as
nothing written invites a retry over a file that now exists, and unsettled, because a hook must
not be registered against settings nobody has read. Which outcomes settle follows the read-back
rather than the write's intention.
The registered command is a command line, so its two words are joined with shell quoting and
read back by the same rules. Concatenating them raw fails in two sizes: a path holding a space
is delivered as more words than it is, and a path holding shell syntax is delivered as syntax
and runs on every Stop. Ordinary paths are unchanged by the quoting. hook-status recognises
this adapter's own registrations by a complete argument whose last component is the entry point's
name, never by the command's text containing it: a program called not-completion_hook.py
contains that name and is a different program.
The runtime is named through the owned pointer, <dest>/current/bin/codex-session-relay, and
never through PATH or a checkout path. A host can carry a relay on PATH whose build
predates the guard: the file is there, it runs, and it rejects the call. That the executable
exists and that it offers guard-evaluate are two questions, and hook-status answers them
as two cells for that reason.
Every path the settings record is made absolute when they are written and required to be
absolute when they are read, because this hook runs with the session's own workspace as its
directory: a relative path would resolve somewhere the install never named, and a bare name
would be looked up on PATH. The pointer itself is not followed, so an update moves the link
and these settings keep naming the runtime that is actually selected.
The registered command carries the settings path the install resolved, as a third word the entry
point reads positionally and never parses. Otherwise the path would be resolved twice, in two
different directories and under two different values of CODEX_HOME, and the second resolution
is the one that decides what every Stop reads. The install decided which file it wrote, so the
install is what says which file to read.
hook-status reads that same word rather than resolving a path of its own, and reports which
of the two it used. Recomputing it would answer about a file the hook may never open: an install
that used the override embedded the resolved path in its command, and a later diagnosis has no
reason to be running under the same environment.
A registration naming its settings with a relative path is reported rather than resolved. The hook resolves such a path against each session's workspace, so no single file answers for it, and inspecting the one the diagnosis would resolve would report an unrelated file as the hook's own. Nothing downstream of those settings is read either.
That judgment uses the same expansion the hook applies, so a ~ path is absolute here too;
calling it relative would hide a working configuration and every cell below it.
The interpreter the registration names is its own cell beside the script. A virtual environment that moved after installation leaves the script in place and the interpreter gone, and then the host cannot start the adapter at all: no decision, no journal entry, and a registration that still looks correct.
A bare interpreter name is resolved on PATH, the way the host resolves it, so a working hook
is not failed in diagnosis for not spelling a file path. A wrapper's own target is not followed,
and the cell says so rather than implying the program behind it was checked.
A relative spelling carrying a separator is reported as workspace-dependent instead: resolving it
would answer about a program under whatever checkout the diagnosis ran from, not the one the host
starts in a session's workspace.
Whether the runtime offers guard-evaluate requires it to describe the subcommand, not merely
to exit 0. A program that ignores its arguments and succeeds would otherwise be reported as
offering one it has never heard of.
A flag the real help carries is required beside the subcommand's own name, because a program
that echoes its arguments prints that name back while offering nothing.
A registration naming the adapter with a relative path is reported rather than resolved, for the same reason a relative settings path is: the file this command would find is not the one the host runs.
Registrations naming different settings files are reported as ambiguous and nothing below them is read. Every one of them runs, so naming one would describe one hook while reporting the others' state as if it were that one's. A relative spelling counts as its own unresolved source there, because two of them name two files, and so do one relative and one absolute.
A matcher is part of a registration. Installation only ever appends an unconditional group and
the hook file's own installer treats only that group as already installed, so an identical
command sitting under a matcher is a duplicate: appending would add a second registration beside
it and both would run on a matching Stop.
The interpreter in the registered command is settled the same way, and a bare name is looked up at install time, on the machine doing the install. That is the only moment the lookup means anything, because the hook runs later from each session's own workspace.
It also has to be a Python this adapter runs on. Executable is not the question: /bin/true is
executable and exits 0, and a Python below the supported version fails the same way and looks
identical from the hook file. Both are refused. The candidate is executed only when the command
is going to write, because a plan that writes nothing should not run a program the caller named,
and what it did not check it does not claim.
The registered timeout is held to the one this repository has evidence for. The host clamps an over-long timeout at discovery and the clamped value was not measured, so a large number is not the deadline it appears to be and could land under the guard budget.
The marker root follows the relay's own resolution, including
CODEX_SESSION_RELAY_MARKER_ROOT. A default that skipped it would not be a default but a
disagreement: the coordinator would publish its intents under one tree while this hook looked
under another, and every managed turn would read as unmanaged with nothing recorded.
--mode hold additionally requires --isolation-asserted-by, recorded in the settings and
shown by hook-status. The contract makes per-session write isolation a prerequisite for
holding and not for observing, so the assertion is a named record rather than something
inferred from the mode having been set.
A second registration of this adapter that differs from the one already there is refused rather
than appended. Installation appends and never removes, so appending would leave two copies
running on every Stop; the existing identity is named so it can be edited.
Every precondition is checked before any write, and that ordering is the point rather than an accident of how the command grew. The interpreter, the budget, the event, the settings' own readability and the duplicate registration are one list. The shape this protects against is the expensive one: settings written, duplicate refused afterwards, and a hook already in the file running against settings the command had just reported it would not install. The duplicate check and the append are still not one atomic step, so the registration is read back afterwards and a second copy is reported rather than claimed away.
hook-status writes nothing of its own, but it is not inert: it RUNS two of the programs a
host registers, and what those programs do is outside this command's control. Answering whether
the runtime offers guard-evaluate means running that runtime with --help. Answering whether
the registered interpreter is an interpreter at all means running the first word of the
registered command with -c and a generated nonce, because a file being present and executable
establishes nothing about what runs: a program that exits quietly is indistinguishable from a
working interpreter by any reading of the filesystem, and every Stop would succeed at running it
and never reach the adapter. Both invocations exist only to answer and exit. Neither is the
adapter's firing path, and neither can write a journal record, so a reading cannot manufacture
the firing evidence it is reporting on. The command names the program it ran and the invocation
it made in the cell's own evidence, so a receipt shows the reading's cost rather than only its
conclusion.
What running the interpreter establishes is narrow, and the limit is stated rather than left to
be inferred: the registered first word answers as a supported Python at the spelling this host
resolves. It does not establish that the registration would start. The command was never run as
written, the argument convention was never exercised, and a wrapper's own target is deliberately
not followed -- so a wrapper that answers -c while rejecting its normal argv still reads as
answering. firingRecordAbsence therefore treats a present pair as "nothing establishes that
this cannot start" and never as "this starts".
hook-status probes these paths through the four reading states rather than asking whether a
file is there. A runtime behind a permission wall and one that was never installed answer
differently, because they are repaired in different places, and a runtime that could not be
reached is not asked whether it offers the subcommand.
Only a verdict that agrees with itself is acted on. Both halves are read: a verdict whose own
decision releases while its hook_output holds did not come from the guard, and rebuilding a
block out of the nested half alone would let this adapter deliver a hold nobody decided. Any
disagreement reads as guard_verdict_incomplete and releases.
Failures keep their own names. A runtime that could not be run carries its errno, because a
moved pointer and a file that cannot be executed are different repairs. An exit of 2 carrying the
relay's own error record is the relay declining a request it understood; an exit of 2 carrying
nothing is its argument parser refusing before any command ran. Every one of these releases the
turn and is recorded.
runtime_install.py hook-status writes nothing, and reads rather than changes -- with the one
exception above, that it runs the registered runtime and the registered interpreter to ask them
what they are. It answers registration, the host's
trust state, whether the registered command's target still exists, the settings, the runtime,
whether that runtime offers the subcommand, this hook's own record of its invocations, why
there is no such record when there is none, the guard's records, and the daemon, each as its
own cell. not_read is used where a question was not asked and is never written as an absence.
The cause cell is firingRecordAbsence, and it exists because "there is no record" had several
repairs behind it and the command answered none of them. It takes no reading of its own: each
cause declares which cells answer it, every rule runs rather than the first match winning — a
host whose settings and whose adapter are both gone needs two repairs and is told so — and an
ambiguity is carried as candidates instead of being settled by choosing. Where several
registrations name several settings files, each of those files and the journal under it is
read, because reading one of them and reporting an absence says nothing about the others, and
reading none of them is what made a hook that had fired indistinguishable from one that never
had. The operator procedure below lists every cause and the two limits that remain.
This hook records one entry per invocation by default, and the default is not frugality. The guard publishes an observation only when it selected an assignment, so on a host with no managed session it writes nothing at all, and an empty firing record would be indistinguishable from a hook that never runs. The count is always reported beside the policy that produced it.
A record is whole or it is absent. A short write is finished rather than accepted, and a write that cannot finish removes what it left: a truncated record survives under a name nothing will reuse and would be counted as an invocation whose contents no longer read back.
The settings are read before the payload is looked at, because the settings say where a record goes. Reading them second meant a payload the adapter could not parse was released with nothing written anywhere, which is the one class of invocation that most needs a record.
The daemon is not on this path. The guard reads the marker and a read-only database, so a stopped
daemon is not observable from a Stop and is never inferred from one; hook-status answers it
separately or says it did not look. Long retries and whole verification loops belong to the daemon
and to the coordinating task, not to a hook with a five-second budget.
Installing, updating and hooking each have their own cases above. What none of them states is
the sequence a host actually lives through, on one destination, with the state that has to
survive it put there before the first install and read again after the last refusal. A suite of
separately passing cases is not that sequence, and the difference is where a host loses a
runtime. scripts/ci/tests/test_install_acceptance.py is that sequence.
It reuses the update fixture rather than restating it: the same host, the same injected seams, the same snapshot of everything a failed update promised not to change. What it adds is that the installation recovered at the end is one the run itself promoted, not a directory a fixture placed on disk. An installer that had lost the ability to install would leave the update cases green; it fails here at stage one.
| Stage | What runs | What must be true afterwards |
|---|---|---|
| A new install | install --apply onto a destination holding nothing |
An environment was built, the record selects it, current reaches it, the claim is COMPLETE |
| The same run again | the identical command | alreadyInstalled, staging SETTLED, the claim and the configuration byte-identical, no orphan directory |
| An update that fails | a source arrives, so the candidate is a different directory, and one of the eight seams refuses | exit 1, the seam names itself, and the refusal names the arriving environment |
| The install it replaced | nothing further | selection, pointer target, configuration bytes, store bytes, store inode and store rows all as stage two left them, and a further install still answers alreadyInstalled |
The last column is the reason the stage list is not the test. Every one of those equalities also holds for a run that did nothing at all, so the sequence separately requires what a no-op cannot produce: an environment in the destination, a selection naming it, and a seam that reported the boundary it stopped at against the environment it was building.
The criterion asks for seven judgements and forbids one standing in for another. They are not
one payload: two are commands and one is a comparison the acceptance module performs. READINGS
declares the cell, the source that answers it and the path the answer is read from, and a single
read() is the only way a cell is filled. A reading that could not be made reads UNREADABLE --
never False, and never the value of the cell beside it.
| Judgement | Answered by | Read from | Never established by it |
|---|---|---|---|
| Skill link | diagnose |
skillLinks, from scripts/install.py --check |
that a linked skill is loaded or trusted by a host |
| Runtime import | diagnose |
checks.results.imported |
that an imported module is the one a pointer reaches |
| MCP tool exposure | diagnose |
checks.results.mcpExposed |
that a registered server was started, or that a session listed its tools |
| App Server connection | diagnose |
checks.results.connected |
that a socket that accepted a connection will accept delivery |
| Real hook callback | hook-status |
firingJournal |
that a registered hook is an enabled one, or that a firing was judged correctly |
| Model and permission preservation | the acceptance module | the model and permission keys, read before and again after | that anything else in the configuration survived |
| Delivery acceptance | diagnose |
checks.results.deliveryAccepted |
that an accepted delivery was acted on |
Reaching an answer, reaching it down the path the row names, and reaching it on the host the
rest of the run describes are three questions, and the last two are the ones a shortcut passes
silently. All seven readings are taken against one host: the Codex home this run installed
into, the destination it built in, and the state directory it was pointed at. A row answered
from a second Codex home made for it would compose readings about two machines and look
exactly like a composition. In the suite the link, import,
registration, hook and preservation rows travel the thing the run installed: the links it
created, the packages inside the candidate, the registration register-mcp wrote, the command
line in the hook file executed as a program with a Stop payload on its stdin, and the
configuration the install acted over. The connection and delivery rows travel a relay this
suite wrote, because no App Server runs there -- which is why those two are the rows the table
above says a host reading needs the live half for.
The hook row is worth naming twice. Calling the adapter helper directly would answer exactly as the registered command does, while leaving the entry point, the settings argument the install chose and the stdin contract entirely untested. The registration is half of what this page documents, so the row reads it out of the hook file and runs it -- out of the hook file in the Codex home the rest of the run used, because a registration read from anywhere else is a registration on another machine.
The vocabularies are deliberately not merged. The result rows answer in check.VALUES, the hook
row answers in the completion module's own words, and the listing row answers with a listing. A
cell rewritten into a neighbour's vocabulary is the same borrowed answer with better manners.
Two of the seven are established by construction rather than by trusting a number. The hook row
counts records, and a count taken from a directory somebody populated says nothing, so the case
establishes it by the transition instead: the journal is established absent, the Stop hook is
actually run, and the journal then names exactly one invocation. And no verdict cell anywhere
reads model or permission state on both sides of an install, so the acceptance module performs
that comparison itself. checks.settingsPreserved is not that reading -- it answers whether a
command that writes nothing left config.toml alone, which is true of a diagnosis whatever the
model keys say -- and neither is settings_usable, which answers whether a value a caller
supplied is admissible to the relay. Filling the preservation cell from either would be the
borrowed answer the whole arrangement refuses, so the missing cell is recorded as missing.
A version that moved and a source that moved are likewise two findings and not one. Only
definition.verify reports the first, and it refuses the install rather than filling a cell; a
reader wanting a version disagreement reads the refusal, not a classification.
Every path is temporary, and fifteen names are stand-ins inherited from the update fixture: the two build steps, the relay, the measurement, the component classification, the definition load and verification, the interpreter version, the pointer steps and the store readings. The acceptance module declares them in a record it derives by running the fixture and watching which attributes are replaced, rather than by reading how the fixture is written, so a stand-in added there fails this suite until the record acknowledges it. A provenance record a later change can silently outgrow is worse than none.
A stand-in is only half of what a record has to say. A reading can reach its success answer, down the path it declared, on the host it declared, and still answer about something the scenario never built -- so the module also declares, function by function, whether what that function hands the command is the value the scenario built or a stand-in, and a stand-in names what the scenario has instead and what a row reading through it therefore does not prove. That inventory is derived by asking the module for its functions, so a helper added there arrives unclassified. What it does not reach is a value written inline inside a function body: the granularity is the function, and the imported fixture's own replacements are covered by the separate record above.
The store is where that mattered. Every install in this suite used to tell the run its store was absent and its tables unknown while the fixture had built a populated one at the same path, and nothing failed, because a run told there is no store settles that cell as established absence and moves on. A regression that detected a store and then lost it stayed green underneath. The readings the run takes about the store now describe the store the fixture built, one case reads them back out of the run's own result and compares them with it, and no call site may hand that switch again.
Rows this repository has exercised are fixture: a temporary destination whose build steps and
relay are simulated. No committed row can say host, and a check enforces that. The diagnosis
runs with HOME, XDG_STATE_HOME, CODEX_HOME and PATH pointed inside the temporary
directory and with --relay-command naming a path in it, because redirecting --state alone is
not isolation: the survey runs discovery of its own, the filesystem side reads the real home, and
an installed entry point on PATH would be resolved and run. The case then requires every path
the diagnosis reported to be inside that directory.
Paths are only half of it. Resolving where a component lives imports it, and the bridge's smoke
script starts a server, both under whatever interpreter the record names -- so a fallback to the
interpreter running the suite would reach whatever this machine has installed, which a check may
read about and must not run. Clearing PYTHONPATH and the user site does not reach a system
site directory, and checking the resolved location afterwards is too late, because by then the
import has happened. The runtime is therefore supplied rather than discovered: a -S -E
interpreter inside the destination, named by the record for every component and reached through
the entry points on a PATH of the suite's own. The assertion is on the interpreter, which is
settled before any probe runs; a component asked through this machine's interpreter fails the
case whatever it happened to find.
A real combination is an operator action, not a check. It needs a destination, a Codex home, a
host record and a state directory that are yours to change, and it establishes nothing until it
is recorded. Four of the seven need an input the command cannot supply for itself, so a bare
diagnose produces four answers and three admissions that it did not look.
# Substitute every <...> below before running any of it. They are placeholders, not literals:
# an unsubstituted one is a shell redirection rather than a value, which is as true of the
# controller assignment below as of the flags further down. Nothing here runs as it stands.
# The receipt directory has to exist before the first write, or the baseline redirect and the
# install redirect below both fail -- and the second of those stops the install from running at
# all rather than merely losing a file. It has to be a NEW one. Every reading below is written
# under a fixed name, and two of them are pairs where one run writes only one of the two names,
# so a directory still holding an earlier run's files is two runs wearing one name: a guard
# further down would find that run's snapshot and report a comparison this run never took.
# mkdir without -p is the check, because it fails rather than adopting a directory already
# there. It ends the procedure rather than reporting and continuing, because a shell without
# set -e would run every step below into the directory mkdir just refused, and the mixing works
# in both directions: an older run's snapshot read as this run's preservation, and an older
# run's absence marker read as a side this run did not have. Name the receipt under a parent
# that exists, and a new one for every run.
mkdir <receipt> || exit 1
# One controller for the whole block, on 3.11 or newer. Five of the steps below read a Codex
# configuration and they do not fail alike without a reader, so naming the interpreter once is
# the difference between a block that can be copied and a block whose readings quietly degrade.
# The runtimes this command installs are 3.11+ whatever starts it, so this is a choice about the
# controller only. What an older one does to each step is recorded after the block.
controller=<python3.11-or-later>
# Before anything: the model and permission keys as they stand, because preservation is a
# comparison and there is no cell that makes it for you.
# A fresh Codex home legitimately has no config.toml at all -- the reader treats absence as an
# empty configuration -- so record the absence rather than failing on it. And keep the two
# apart: a baseline that was ABSENT makes the later comparison one between two absences, which
# establishes that nothing was added and nothing about a posture anybody had set.
if [ -f <codex-home>/config.toml ]; then
cp <codex-home>/config.toml <receipt>/config.before.toml
else
printf 'no configuration existed before this run\n' > <receipt>/config.before.absent
fi
"$controller" scripts/runtime_install.py install --dest <destination> --record <record> \
--codex-home <codex-home> --state <state> --apply > <receipt>/install.json
# The exit code belongs IN the receipt rather than on the terminal: it is the install's own, a
# pipeline would hide it, and a receipt that kept the result and lost the status cannot say
# whether the install refused.
printf 'install exit=%s\n' "$?" > <receipt>/install.exit; cat <receipt>/install.json
# The skill links are a layer of their own: install builds the runtime, and the diagnosis reads
# the links by running scripts/install.py --check separately. Skip this and the link row answers
# that every crw-* skill is missing -- an accurate reading of a Codex home nobody linked, and
# not a reading of the installation just made.
"$controller" scripts/install.py --apply --dest <codex-home>/skills
# Registration is a separate operation from installing, and tool exposure compares the
# registered command with the tools a session actually listed. Both halves or neither.
# Name a 3.11 or later interpreter here too. Registration reads back the content it proposes to
# write, so it refuses without tomllib for an absent, an empty and a populated configuration
# alike -- measured on the 3.10 floor: exit 1, outcome CONFLICT naming the interpreter, nothing
# written. Run this on the floor and there is no registration for the exposure row to compare
# against, and that row is unreadable rather than unverified.
"$controller" scripts/runtime_install.py register-mcp --codex-home <codex-home> \
--bridge-command <destination>/current/bin/<console-script> --apply
# This payload carries five of the seven rows, plus repositoryCommit and definitionVersion --
# the revision a reader needs to reproduce any of it. Printed to a terminal it is gone, and the
# receipt then cannot substantiate the readings this procedure says it recorded, so it goes to
# the receipt with its exit status like the install did.
"$controller" scripts/runtime_install.py diagnose --dest <destination> --record <record> \
--codex-home <codex-home> --state <state> --socket <socket> \
--bridge-command <destination>/current/bin/<console-script> \
--relay-command <destination>/current/bin/codex-session-relay \
--observed-tool get_capabilities \
--trial --issue <issue> \
--parent-task <parent-task> --child-task <child-task> --recipient <recipient> \
--artifact-root <artifact-root> --artifact <artifact> \
--turn-thread <turn-thread> --turn-id <turn-id> --dispatch-turn-id <dispatch-turn-id> \
--recipient-settings <settings-or-@path> > <receipt>/diagnose.json
printf 'diagnose exit=%s\n' "$?" > <receipt>/diagnose.exit; cat <receipt>/diagnose.json
# The hook has to have fired FOR THIS TURN, and no count can say that. hook-status reports what
# this hook has recorded about itself cumulatively, so an old nonzero count reads as evidence
# for a callback that never happened -- and comparing before with after does not repair it,
# because any other session stopping inside the measurement window moves the same number. A
# count that went up answers "did this hook fire at all lately", which is a different question
# from the one this row asks.
#
# The record carries sessionId and turnId, so ask with them.
"$controller" scripts/runtime_install.py hook --codex-home <codex-home> --adapter completion \
--dest <destination> --apply
# ... then end a real turn, and only then:
"$controller" scripts/runtime_install.py hook-status --codex-home <codex-home> > <receipt>/hook.json
# hook-status names the journal it counted; the records in it name the turn they belong to.
# This is the ONLY turn-specific reading in the block, and it is the one hook.json above cannot
# supply: that file carries the cumulative cell and nothing about which turn moved it. So this
# reading goes to the receipt with its exit status, like the install and the diagnosis did.
# Printed to a terminal it is gone, and a receipt left holding only the cumulative count cannot
# say that the named session and turn are the ones that fired.
"$controller" - <receipt>/hook.json <session-id> <turn-id> > <receipt>/hook.turn.json <<'PY'
import json, re, sys
from pathlib import Path
status, session, turn = sys.argv[1], sys.argv[2], sys.argv[3]
payload = json.load(open(status))
cell = payload["firingJournal"]
# hook-status omits journalRoot whenever its firing-journal reading could not name a usable
# journal, and that is several states rather than one. The command now answers WHICH of them,
# in firingRecordAbsence, so this reads the cause beside the absence instead of stopping at it.
# Read with a default, because a host carrying an older runtime answers the absence and not the
# cause, and a traceback where a reading belongs is worse than a row that says so.
cause = payload.get("firingRecordAbsence", {})
if "journalRoot" not in cell:
print(json.dumps({"firingJournal": cell.get("value"),
"firingJournalEvidence": cell.get("evidence"),
"journalRoot": None,
"recordsForThisTurn": None,
"cause": cause.get("value"),
"causeEvidence": cause.get("evidence"),
"causeCandidates": [c["cause"] for c in cause.get("candidates") or []],
"detail": "no journal to attribute a turn to, so this row is unreadable"
" for this run rather than zero. 'cause' says why there is"
" none, and carries every candidate rather than choosing one"
" when it could not be settled"}, indent=2))
raise SystemExit(0)
root = Path(cell["journalRoot"]).expanduser()
# The same shapes hook-status counts, and one entry that cannot be decoded does not take the
# reading with it: the hook creates a record before it finishes writing it, so a file being
# written while you look is neither a match nor a failure of your turn.
day, name = re.compile(r"^[0-9]{8}$"), re.compile(r"^[0-9a-f]{32}\.json$")
records, unreadable = [], 0
for directory in sorted(p for p in root.glob("*") if p.is_dir() and day.match(p.name)):
for entry in sorted(e for e in directory.glob("*.json") if name.match(e.name)):
try:
records.append(json.loads(entry.read_text(encoding="utf-8")))
except (OSError, ValueError):
unreadable += 1
mine = [r for r in records if r.get("sessionId") == session and r.get("turnId") == turn]
print(json.dumps({"recordsRead": len(records), "recordsUnreadable": unreadable,
"recordsForThisTurn": len(mine), "record": mine[:1]}, indent=2))
PY
printf 'hook turn exit=%s\n' "$?" > <receipt>/hook.turn.exit; cat <receipt>/hook.turn.json
# Afterwards: the other half of the preservation reading. The KEYS, not the file -- the
# registration above deliberately appended a table, so a whole-file diff reports a change that
# is this procedure's own doing and would report it whether or not anything was preserved.
#
# The post-install side becomes a file first, beside the baseline. Preservation is a comparison
# between two moments, and a receipt holding only the earlier one cannot substantiate it once
# <codex-home>/config.toml has moved on. An absence is recorded here the way the baseline
# branch recorded one, rather than being failed on.
if [ -f <codex-home>/config.toml ]; then
cp <codex-home>/config.toml <receipt>/config.after.toml
else
printf 'no configuration exists after this run\n' > <receipt>/config.after.absent
fi
# The comparison reads the two snapshots the receipt now holds, so a later reader can re-take
# exactly this reading from the receipt alone. Which branch runs is decided by what THIS run
# recorded, and the absence markers are read first for that reason. On a fresh Codex home the
# branch before the install wrote config.before.absent and no config.before.toml at all, and
# this reader opens its inputs by name: a comparison that ran anyway would end in
# FileNotFoundError with no reading written, or -- in a receipt carrying an older run's files
# -- would compare that run's snapshot and record it as this run's preservation. Where a side
# was absent there were no model or permission keys to preserve on that side, and the row is
# recorded as that absence, not as a preservation and not as a failure. Which side it was is
# not guessed here: the config.before.* and config.after.* names in the receipt already say it.
# Name a 3.11 or later interpreter, because the reader arrives there. On a host whose python3
# is the 3.10 floor this command exits before it reads anything, and the receipt then records
# what the suite records on that interpreter: the reading was not made, and the row is
# unreadable rather than preserved. The exit line and the captured stderr beside it are what
# say so. Do not substitute a pattern match for it -- a value guessed out of TOML is a value
# whose wrongness is invisible.
if [ -f <receipt>/config.before.absent ] || [ -f <receipt>/config.after.absent ]; then
printf '%s\n%s\n' \
'no comparison was made: a side of it was absent during this run' \
'config.before.* and config.after.* in this receipt name which side' \
> <receipt>/config.preservation.absent
cat <receipt>/config.preservation.absent
elif [ -f <receipt>/config.before.toml ] && [ -f <receipt>/config.after.toml ]; then
"$controller" -c 'import sys, tomllib
keys = ("model", "approval_policy", "sandbox_mode")
for path in sys.argv[1:]:
with open(path, "rb") as handle:
document = tomllib.load(handle)
print(path, {key: document.get(key) for key in keys})' \
<receipt>/config.before.toml <receipt>/config.after.toml \
> <receipt>/config.preservation.txt 2> <receipt>/config.preservation.err
printf 'preservation exit=%s\n' "$?" > <receipt>/config.preservation.exit
cat <receipt>/config.preservation.txt
else
printf '%s\n' \
'no comparison was made: a side of it has neither a snapshot nor an absence here' \
> <receipt>/config.preservation.absent
cat <receipt>/config.preservation.absent
fi
# If an update has failed here, it has already restored what it found. Read that back rather
# than assuming it -- and read residualPaths out of the FAILED RUN'S OWN result, which is what
# THAT RUN left. diagnose answers residualPaths too, and it is a different reading of a
# different question: what is on the destination NOW. Neither is a superset of the other, so
# the install result above is kept rather than replaced by the diagnosis below.
#
# residualOwnership and recoveryRequires are read from the same result and for the same reason.
# A rollback can settle the LINK and fail to settle the RECORD, and what that leaves is a claim
# rather than a path: nothing is on disk to delete, so residualPaths is empty and correct while
# the record still says something about that path. residualOwnership names the path whose claim
# is outstanding. recoveryRequires is COMPOSED rather than chosen from a list, because what has
# to be settled is two separate readings -- what became of the LINK (taken away, put back to a
# named target, or not put back at all) and where the ENTRY came from (introduced by that run,
# or inherited and left carrying its stamp) -- and the consequence follows from the pair. A
# sentence that assumed either would tell an operator the link was put back when it was not, or
# report a disagreement between a link and a record that in fact agree.
#
# Both are empty for a rollback that found the entry belonged to ANOTHER run by the time it
# wrote. Nothing there is this run's to settle, so asking an operator to settle it would send
# them after somebody else's record. That case is reported where it belongs, under
# pointer.pointerRestored: 'ownership' reads "moved on", 'verified' is false because the
# rollback did not do what it set out to, and 'detail' names the path the record holds now. The
# command below prints 'pointer', so the receipt carries it.
#
# A RESUME or an adoption that fails reports the same rollback at the TOP level rather than
# under 'pointer', because it never reaches the update's exit. It carries residualOwnership and
# recoveryRequires from the same helper, so those two read the same either way, and the receipt
# reads 'pointerRestored' as well so the rollback's own detail is there for both.
"$controller" -c 'import json, sys
result = json.load(open(sys.argv[1]))
print(json.dumps({key: result.get(key) for key in
("failedStep", "retriable", "residualPaths", "residualOwnership",
"recoveryRequires", "removedCandidate", "pointer", "pointerRestored")},
indent=2))' <receipt>/install.json
# Kept the same way, and under its own name: this is the recovery read-back, a different
# reading from the one above, and a receipt holding only one of them cannot say which.
"$controller" scripts/runtime_install.py diagnose --dest <destination> --record <record> \
--codex-home <codex-home> --state <state> > <receipt>/diagnose.after-failure.json
printf 'diagnose exit=%s\n' "$?" > <receipt>/diagnose.after-failure.exit
cat <receipt>/diagnose.after-failure.jsonWhat this block is, and what it is not. It installs, registers, takes the seven readings and
reads the result back. It does not re-run the install, it does not present an arriving source,
and it does not fail an update -- and this page will not tell an operator to break a runtime
their host is using in order to watch it come back. Those three stages are exercised against a
temporary destination by scripts/ci/tests/test_install_acceptance.py, at every one of the
eight seams an update crosses.
So a receipt from this block records the stages it actually performed, and it is not a receipt for the composed run. The last command above is there for the host that arrives at it having had an update fail on its own, which is the only way that stage is reached here.
A receipt also has to record which interpreter took it, and the block names one controller for
every step for exactly that reason: a page that recommends 3.11 in prose and then invokes bare
python3 is a page whose readings degrade for anyone who copies it. Five of its steps read a
Codex configuration -- install, register-mcp, both diagnose invocations and the
preservation reader -- and they do not fail alike without tomllib, so an operator who runs it
on the 3.10 floor anyway gets a mixture rather than a refusal. Measured on 3.10 rather than
inferred: register-mcp refuses outright,
exit 1 with outcome CONFLICT naming the interpreter and nothing written, for an absent, an
empty and a populated configuration alike, against a temporary Codex home. diagnose does not
refuse: run the same way it reports the configuration UNREADABLE and the exposure row reads
not_verified -- for want of a reader, not for want of a registration. install does not
refuse either, and that one is measured against a temporary destination with the build steps
simulated, which is the acceptance suite's arrangement and not a host: it promotes there on
3.10. Nobody has run this block against a real host from this repository, and it does not claim
otherwise. The preservation reader exits before reading anything.
So on the floor two of the seven readings are unreadable and the rest still stand, and a receipt records them that way rather than carrying them forward. The runtimes this command installs are 3.11 or newer whatever interpreter started it, so an old controller is never a reason to postpone the install; it is only a reason two of the seven cannot be taken.
Every field this section tells you to read is one the command it names actually emits, which is
worth stating because it was not always true: the closing diagnose used to be where an
operator was sent for residualPaths when only an install failure result carried that field.
failedStep, retriable, residualPaths, removedCandidate and pointer come from the
install result kept above. diagnose emits residualPaths and residue of its own, read
from the destination as it stands: an entry is residue when the installer's own decision
would reclaim it, so this reports that decision rather than a second opinion about the same
directory. It is not guaranteed to be the same SET as a later install's: this command asks
about the pointer the host record names, cmd_install asks about the destination it was
invoked with, and on a host whose recorded pointer lies elsewhere those differ -- with this
command the conservative of the two. Which question the installer should ask is a decision
about the installer and is not this issue's to make. A staging the record selects, one somebody
still holds, one whose owner could not be established, and a finished environment nothing
selects are each reported with that decision's own reason and none of them is listed for
removal — a dead staging lock says no installer holds the directory, never that nothing is
running out of it. The owned pointer reaches residualPaths only when it dangles AND the host
record records that a link this command placed is at that path; a dangling link the record
does not claim is reported as foreign and left alone, because a link's shape is not its
ownership. Those are two different readings of one entry: a failed promotion keeps the pointer
path, so a retry derives the same pointer, and a rollback that established the link it placed
is gone withdraws recordedAt and recordedBy. A record in that state names a location and
claims no link, so whatever link stands there afterwards is reported as foreign.
firingJournal, journalRoot and firingRecordAbsence come from hook-status;
skillLinks, the checks.results cells, scope.socketConnect, definitionVersion and
repositoryCommit from diagnose; sessionId and turnId from the journal records
themselves, which is why the snippet reads the records rather than the count.
That reading used to stop at the absence. hook-status omits journalRoot whenever its
firing-journal reading could not name a usable journal, and that covers several different
states — no hook registered for the event, registrations naming different settings files, a
settings path spelled relatively, settings the command could not read, and journalling not
configured — which an operator then had to guess between or stop at. firingRecordAbsence
answers which one. It takes no reading of its own: every cause is decided over the cells
beside it, and each declares which of them answers it.
| Cause | What it says | What it does not say |
|---|---|---|
not_registered |
the hook file was read and registers this adapter for nothing, so nothing on this host invokes it now | that the file is the one the host loads, or that nothing was ever recorded — a registration removed after the hook fired leaves its journal where it was, and this answer names those records rather than reading past them |
record_path_unidentified |
a registration spells its settings relatively, or names none, so no file reachable from here answers for it | that the hook has or has not recorded |
adapter_cannot_run |
the registered adapter or its interpreter is not there, so the host cannot start it | that it was ever startable |
settings_absent / settings_unusable |
one or more registrations name a settings file that is absent, or that this hook's own reader rejects, so every invocation of those registrations releases without recording | which repair the file needs, or anything about a peer registration whose settings are fine |
journalling_off |
one or more registrations keep no journal, so those record nothing about their own invocations by configuration | anything about firing, for those registrations |
recorded_on_another_path |
one journal these registrations name holds records while another was read and holds none | which registration the host ran |
nothing_recorded |
every journal belonging to a registration that can start and has usable settings was read and holds no record | that the hook never ran |
several_causes |
more than one cause is established and each needs its own repair | that repairing one of them is enough |
cause_unreadable |
the cause was not settled; candidates carries every one still standing |
which of them it is |
Every cause above is decided per registration, because every registration in the hook file
runs and reads its own settings. One registration with a missing settings file beside one that
is fine answers several_causes, not the healthier of the two — a peer that works is not
evidence about a peer that does not. The one place that goes the other way is deliberate: a
registration the host cannot start is left out of the journal questions entirely, because its
journal is empty because it cannot start, and reading it as a fact about journalling would
invent a second cause for one repair.
Two limits remain, and they are the reason the last two values exist. Under
journalPolicy: faults_only the guard records only an invocation that faulted, so an empty
journal is equally what a hook that fires constantly and never faults leaves behind and what a
hook that never fired leaves behind; that host answers cause_unreadable carrying both
policy_records_only_faults and nothing_recorded, and it does not choose. And
nothing_recorded is named for the journal rather than for the hook on purpose: a journal
write that fails removes what it left and cannot record its own failure, so "it never ran" and
"it ran and every record failed to be written" are one observation here. Neither of those is
resolved by this command, and neither is guessed at.
A third limit is about cost rather than about truth. hook-status now opens every absolute
settings path a registration names and lists the journal under it, so its work is bounded by
the number of registrations rather than by one file. That bound is a count and not a clock: a
journal root on an unavailable network mount makes this command slow, and it has no budget of
its own to stop at. The hook's own Stop path is unaffected — it reads the one settings file its
own registration names, under the timeout it is registered with.
This changes what the command answers and not what the acceptance readings are. The hook
callback row is still answered by firingJournal; the cause is detail beside it, and the
seven readings remain seven.
Name the relay too. Left out, the entry point is discovered on PATH, which finds whichever
relay this host already has rather than the runtime just installed under the destination -- and
with none on PATH the trial refuses before it runs. Every flag after --trial is required and
a blank one is refused before anything is written; the set is declared once in the source as
TRIAL_REQUIRED_INPUTS, together with --recipient-settings, which is additionally asked of the
relay's own settings reader.
The absences are answers, and they are different answers. Omitting --trial leaves delivery
not_applicable: nothing was attempted. Asking for a trial whose inputs are missing or blank
gives not_verified naming the input that was not supplied: something was attempted and did not
establish itself. Without --observed-tool the exposure answer is that no tool names were
observed, and before a Stop has reached the hook the callback row is an absence. What none of
them is, is a failure of the thing they were asked about, and recording them as though the
questions had been put is the one way this procedure can lie.
recordsForThisTurn is the reading. One record naming the session and the turn that was ended
is a callback this procedure can attribute; zero is not a smaller number of callbacks, it is a
turn that did not reach the hook, and the row is unreadable for this run whatever the totals
say. Do not record a total instead -- it is the answer to a question nobody asked here, and it
is the one piece of this procedure another session can move.
Read recordsUnreadable before concluding. Zero matches beside a nonzero unreadable count is
not an answer either: a record the hook had created but not finished writing is neither your
turn nor evidence against it, and the honest move is to look again rather than to write down a
callback that did not happen or rule out one that did.
Three of the seven are readings of something live, and the command supplies none of it. The fixture answers them with stand-ins it builds; an operator has the real thing or has nothing, and an absence recorded as a result is the one way a receipt from here misleads.
| Reading | What has to be there already | How you know it was |
|---|---|---|
| App Server connection | an App Server accepting connections at <socket> |
scope.socketConnect reads ok; anything else leaves connected not_verified or unknown, which is an answer about the socket and not about the install |
| MCP tool exposure | a session that actually listed the bridge tools, whose names go in --observed-tool |
without the flag the row says no tool names were observed; with it, the evidence names the tools compared against the registered command |
| Delivery acceptance | a relay that can carry the eight steps through to a returned turn id, and a recipient whose settings its own predicate accepts | deliveryAccepted reads verified only with that turn id in the evidence; every refusal names the step or the input that stopped it |
None of those is a precondition to arrange around. They are the questions, so if the live half is absent the honest receipt records the absence for that row and says the rest. What it must not do is carry a row forward as though the question had been put.
Stopping is not on that list, because nothing here starts anything. The installer never starts or
stops a daemon, and a successful install is reported as alwaysActive: not_verified however well
it went; whoever operates the service starts and stops it. A refused update naming a residual
pointer is telling you to look at that link rather than telling you it is fine: the restoration
happened on disk but could not be read back, and the command declines to claim what it could not
confirm.
A real run records the exact revision it ran at, the interpreter and host it ran on, the
destination kind, and the answer to each of the seven with the command that produced it and the
time it was produced. repositoryCommit and definitionVersion are in the payload for that
reason. Those receipts are host facts: they belong in the private record outside this repository,
not in a commit, and measuredPoints in the committed definition stays empty until a measured
point is made. This page is the procedure and the shape. It is not a record that anybody ran it.
Running the entry point against a temporary destination proves what it did there. It is not
evidence about a host's real Codex home, its installed runtime, its MCP registration or its
operational database. installed, mcpExposed, connected, deliveryAccepted,
verificationComplete and alwaysActive are six separate facts under OPS-6.1, and imported and
settingsPreserved are two more beside them. None of the eight is read from another.
A registered completion hook is not a fired one, and a fired one is not a delivered hold. That a line is in the hook file says nothing about the host having run it, about the runtime it names being able to answer, or about any turn having been judged. Those claims need the host's own evidence, not this command's.
A successful update is not one of them either. That the pointer moved, that the gate found the daemon stopped and no attempt open, and that the store's tables were compatible are three readings taken at one moment, about one destination. They say a swap was permitted and performed; they do not say the new runtime works, and the point that would say so is measured before the swap rather than after it. Nor does a refused update establish that a store is healthy: the gate reads whether it is safe to replace a runtime, and reads nothing about whether the data in the store is correct.