Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A kernel that reports a changed identity no longer has that report filed as a gate verdict (graph amendment 29). `KernelIdentityChangedError` is new. `gates.battery@1` raises it when its binding registry, or a source file in a binding's closure, has changed since the kernel was built, or can no longer be read or parsed. It checks before it evaluates, before it returns, and when evaluation raises. The executor re-raises that error instead of filing a `fail` under the node key. The run refuses, nothing for that node is stored, and the nodes it had already completed keep their stored results. So a build edited mid-run no longer leaves a stale `fail` that later runs of the unedited registry are served. The change moves the implementation hashes, and therefore the node keys, of `gates.battery@1`, `export.prepare@1` and `transport.package@1` and of their descendants, so stores recompute those nodes once.
61 changes: 61 additions & 0 deletions docs/graph-acceptance.md
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,67 @@ lock unchanged:
this validation changes no graph computation key. `decl.py` is re-locked.
Adopted during the Orrery export review in #888.

29. **A kernel's report of a changed identity is never a verdict.**
Amendment 7 makes a raising gate a `fail` verdict, and the executor files
that verdict under the node key, so every later run that derives the key
is served it. Keys are derived from each kernel's `implementation_hash`
before any node runs. A kernel whose identity can move during a run would
therefore have its failure filed under the key of an identity it no
longer has. `gates.battery@1` is that kernel. It binds its binding
registry and the registry's source closure, and raises once either moved
after the kernel was built. An edit to a binding while a build ran, or a
closure file rewritten during a long build, stored `fail` under the
unedited registry's key, and a later run with the correct registry on the
same store was served that `fail` (#1125 review, finding 1).

`errors.py` gains `KernelIdentityChangedError`, a `NodeRejectedError`. A
kernel raises it when state its `implementation_hash` describes has moved
since that hash was fixed. When any kernel's `run` raises it, whatever the
kernel's role, the executor re-raises it. For a gate, that replaces
amendment 7's verdict and F4's "becomes `fail`" in this one case: the run
refuses, and nothing for the node is applied or stored. The nodes the run
completed before the refusal keep their stored results, as after any
refused run whose sources are unchanged (`_settle_failed_run`). Their keys
still name what computed them, so the next run reuses them and computes
the refused node afresh. Every other gate exception is still a `fail`
verdict, filed and reused.

The executor does not re-derive a kernel's hash after `run` to detect a
move itself. Most gate identities in this codebase are source hashes that
re-read module files on every call. A file rewritten under a running
process does not change the code that process already imported, so a
re-derived hash moves without the computation changing. Comparing against
the keyed hash would then refuse a correct build at the first gate whose
hashed files a save, checkout or pull touched after the keys were
derived, however long before that gate ran. Only the
kernel knows whether its identity is state that can move in memory. A
kernel whose identity can move in process must therefore check it in `run`
and raise `KernelIdentityChangedError`. `gates.battery@1` does so in three
places:

- before it evaluates;
- before it returns;
- when evaluation raises, chained to that exception.

A closure file that can no longer be read or parsed counts as a change. A
move that is undone before the kernel's next check is not seen.

Runtime-only. `decl.py` and `kernel.py` are untouched and the interface
lock is unchanged. Three implementation hashes move:

- `gates.battery@1`, whose own source changed. A binding whose source
closure reaches `microcosm.graph` also binds the edited executor and
errors modules.
- `export.prepare@1` and `transport.package@1`, whose hashes include the
battery's source.

Their node keys move, and so do their descendants'. No other kernel's
hash reads a file this change edits; all 41 `implementation_hash`
definitions in `packages/*/src` were checked. A `fail` filed by an earlier
version under a stale identity therefore sits under a key that no run of
this version derives, and is never served. Raised by the independent
review of #1125 (finding 1, Subfleet job 20261007-081708); adopted with the
fix on 2026-10-07.

Adding a normative field with a default changes the canonical projection
of every node that carries it, so node keys moved with amendments 11 and
Expand Down
32 changes: 29 additions & 3 deletions docs/transport-graph-kernels.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,35 @@ source and resolved case-exactly with the import system's path finder,
without executing anything. Closures, partials, bound methods, lambdas,
nested functions, decorated wrappers, builtins, numpy scalars and other
objects are refused when the kernel is constructed. The identity is fixed at
construction; if a binding or a module in its source closure changes
afterwards (a `dict` field edited in place, a source file rewritten), the
kernel refuses to run.
construction. If a binding or a module in its source closure changes
afterwards (a `dict` field edited in place, a source file rewritten, deleted
or left unparseable), the kernel raises `KernelIdentityChangedError` from
`implementation_hash()` and from `run()`. `run()` checks before it evaluates,
before it returns, and when evaluation raises. The run then refuses with that
error:

- A change made before `run_graph` derives its node keys refuses the run
before anything is written.
- A change made after the keys are derived is refused at the gate node. That
covers an edit to a binding while the build runs, and a closure file
rewritten during a long build. The executor never files a kernel's
`KernelIdentityChangedError` as a `fail` verdict (graph amendment 29), and
nothing for the gate node is stored. Nodes the run completed earlier keep
their stored results, so a later run with the unedited registry on the same
store reuses them and computes the gate afresh. Before amendment 29 that
later run was served a `fail` stored under the unedited binding's key.

The executor does not re-derive the hash itself, because it cannot tell this
kernel's in-memory bindings from the module files other gate kernels hash, and
a file rewritten under a running process does not change code that process has
already imported. The check therefore belongs to the kernel. A change made
during evaluation and undone before the next check is not seen.

The change that introduced amendment 29 moved every `gates.battery@1` node
key: it edited this kernel's own source, and `export.prepare@1` and
`transport.package@1` hash that source as well. A `fail` that an earlier
version filed under a stale identity is therefore under a key that no later
run derives, so it is never served.

The closure is honest rather than small: `microcosm.build.gates` imports
`us_runtime` lazily inside one function, so a binding over a gate in that
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,15 @@
and undecorated top-level functions without closures), and the hash covers
its fields, its functions' defaults and the source of every first-party module
its code can import, read from the import statements in the source. A binding
outside that vocabulary is refused when the kernel is built; a binding, or a
module in its source closure, that changes afterwards makes the kernel refuse
to run. Data a binding's modules read from disk is not bound, so a binding
outside that vocabulary is refused when the kernel is built. A binding, or a
module in its source closure, that changes afterwards (or can no longer be
read or parsed) makes the kernel raise
:class:`~microcosm.graph.KernelIdentityChangedError`: from
``implementation_hash``, and from ``run``, which checks before it evaluates,
before it returns, and when evaluation raises. The executor never files that
error as a ``fail`` verdict (graph amendment 29), so a change made while a
build runs leaves no record for the refused gate node under the unchanged
binding's key. Data a binding's modules read from disk is not bound, so a binding
takes its values through the manifest's parameters or evidence artifacts. A
declared gate with no binding still resolves to a named ``evidence_absent``
gap, exactly as the battery does.
Expand Down Expand Up @@ -110,6 +116,7 @@
Determinism,
KernelBase,
KernelContext,
KernelIdentityChangedError,
KernelRegistry,
KernelResult,
KernelRole,
Expand Down Expand Up @@ -705,12 +712,34 @@ def _binding_identity(self) -> tuple[dict[str, object], dict[str, str]]:
sources.update(digests)
return descriptions, dict(sorted(sources.items()))

def _require_identity(self, cause: Exception | None = None) -> None:
"""Refuse once a binding or its source closure moved since construction.

Raised during key derivation, this refuses the run before anything is
written; raised from ``run``, the executor refuses the node instead of
filing an outcome under the key of the unchanged registry (graph
amendment 29). A closure file that can no longer be read or parsed is
a change too. ``cause`` is the exception evaluation raised, if any.
"""

try:
moved = self._binding_identity() != self._identity
except Exception as error:
raise KernelIdentityChangedError(
f"{_REF} binding registry, or a module in its source closure, "
"changed after the kernel was built and can no longer be "
f"described ({type(error).__name__}: {error}); its node keys "
"would no longer describe it."
) from error
if moved:
raise KernelIdentityChangedError(
f"{_REF} binding registry, or a module in its source closure, "
"changed after the kernel was built; its node keys would no "
"longer describe it."
) from cause

def implementation_hash(self) -> str:
if self._binding_identity() != self._identity:
raise ValueError(
f"{_REF} binding registry changed after the kernel was built; its "
"node keys would no longer describe it."
)
self._require_identity()
descriptions, sources = self._identity
code = source_hash(
sys.modules[__name__],
Expand Down Expand Up @@ -801,11 +830,19 @@ def _upstream(
def run(self, context: KernelContext) -> KernelResult:
require_params(context, _REF, required=self._required, optional=self._optional)
require_outputs(context, _REF)
if self._binding_identity() != self._identity:
raise ValueError(
f"{_REF} binding registry changed after the kernel was built; its "
"node keys would no longer describe it."
)
# The identity is checked on both sides of evaluation, and again when
# evaluation raises: an outcome or failure produced while it moved is
# not one the node key names, so it must never be filed as a verdict.
self._require_identity()
try:
result = self._evaluate(context)
except Exception as error:
self._require_identity(cause=error)
raise
self._require_identity()
return result

def _evaluate(self, context: KernelContext) -> KernelResult:
country = string_param(context, _REF, "country")
phase = string_param(context, _REF, "phase")
gates_sha256 = sha256_param(context, _REF, "gates_sha256")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
ContentStore,
Kernel,
KernelContext,
KernelIdentityChangedError,
KernelRegistry,
KernelRole,
Node,
Expand Down Expand Up @@ -386,7 +387,9 @@ def test_a_binding_mutated_after_construction_refuses_to_run() -> None:
kernel = GateBatteryKernel({**TOY_BINDINGS, "per_family_fit": binding})
kernel.run(_context(_node()))
arguments["diagnostics"] = "other_alias"
with pytest.raises(ValueError, match="changed after the kernel was built"):
with pytest.raises(
KernelIdentityChangedError, match="changed after the kernel was built"
):
kernel.run(_context(_node()))


Expand Down
Loading
Loading