Skip to content

context: bind a local graph to its revision and fail closed on everything else - #926

Merged
jeffhuber merged 34 commits into
mainfrom
claude/913-graph-lifecycle
Sep 12, 2026
Merged

context: bind a local graph to its revision and fail closed on everything else#926
jeffhuber merged 34 commits into
mainfrom
claude/913-graph-lifecycle

Conversation

@jeffhuber

Copy link
Copy Markdown
Contributor

Closes #913

Part of #902. Builds on the adopt decision recorded for #876 in docs/graphify-evaluation.md.

What this is

The lifecycle around the optional local graph: the part that decides whether a graph should have existed at all, as distinct from context_graph (from #876), which decides whether a delivered packet's citations may be used.

No dependency, no indexer, no background service, no hook, no watcher, no default indexing step. Nothing on a default install path builds, reads, or requires a graph. The provider seam is an injected callable, so the whole lifecycle and its suite run offline with no graph package installed.

Against the acceptance criteria

Work starts only if #876 records adopt. It does — docs/graphify-evaluation.md records adopt-with-conditions, merged as #924.

Every artifact binds full commit/tree SHA, provider version/options, build time, tracked census/digest, graph digest/bytes, and completeness. All fourteen fields are in BuildManifest, validated on every read by load_manifest, and re-derivable: the census digest covers mode, blob name, size and path for every entry in sorted order. The pin is exact by construction — a range, a marker, or a missing wheel digest is rejected — because the distribution name differs from the repository name by one character and the evaluation flagged that.

Untracked/private files cannot enter the graph. A build never touches the live checkout. It reads the census from the commit's tree, not the working tree and not the index, and materializes those blobs into a fresh 0700 directory as 0600 files. Untracked and ignored files have no path in because they are never written, not because something filtered them afterwards. Symlinks (120000) and gitlinks (160000) are skipped and recorded as skipped: one can name a target the build was never shown, the other a commit in a repository it was never authorized to read.

Refresh is explicit and atomically publishes a new immutable generation. build is the first-time verb and refuses when a usable generation already binds the revision; refresh is the rebuild verb and publishes a new generation rather than mutating one in place. Publication is two atomic renames in an order a reader survives: the generation directory becomes visible whole, then current starts naming it.

Stale, corrupt, oversized, partial, and permission-invalid artifacts fail closed. graph_status resolves to exactly one state and only current is usable. Nothing falls back to an older generation — a consumer that cannot have the revision it asked for is told so rather than handed a stale answer that looks fresh. The privacy check runs on every read, not only at creation, so state loosened after the fact fails closed. partial exists specifically because of the requeue defect the clean-room run recorded: a fast incremental repeat is not proof the graph is complete.

Also here

Environment scrubbing was not in the criteria list but is in the scope line. The indexer inherits PATH, TMPDIR, LANG, LC_ALL, TZ and nothing else; every proxy variable is emptied, no_proxy is *, and HOME/XDG point into the build's own scratch area. The list names what is kept, so a newly invented secret variable is excluded by default instead of needing a denylist entry. Git itself runs with system, global and local configuration disarmed — an untrusted checkout can otherwise install a clean/smudge filter that runs code during what looks like a read.

code-mower context-graph build | refresh | status | remove | doctor. status exits non-zero when the graph is unusable so a script can branch on it; doctor reports skip rather than fail when nothing is pinned or built, because an operator who never opted in has nothing wrong with their installation.

Validation

The test suite was not run on this lane. Python execution is not permitted in this session — /usr/bin/python3 -c "print(1)" is refused before it runs, so no interpreter, venv, pytest, or ruff invocation was possible. The tests in tests/test_context_graph_lifecycle.py are written to run offline against a real throwaway Git repository with an injected indexer, and are unexecuted here. CI is the first execution. Reviewers should treat green CI, not this description, as the evidence.

Coverage written: pin exactness (ranges, markers, missing digests, unknown fields); census from commit vs working tree, digest sensitivity, symlink/submodule skipping; materialization contents, modes, fresh-directory refusal, and six escaping-path shapes; environment scrubbing with four ambient secrets planted, HOME redirection, and an allowlist-completeness assertion; manifest field binding and round-trip; atomic publish, immutability of superseded generations, pruning, and that a failed or empty build publishes nothing; each of the six status states including group-readable state and artifact; remove idempotence and its refusal to delete non-private state; doctor skip/pass/fail; and the five CLI verbs end to end through a shell stand-in for a pinned provider CLI.

Three assertions check that no shareable summary, doctor report, or command output carries indexed content, a planted secret, or the private state path.

Not in scope

Adoption conditions 4 and 6 (symbol-first bounded queries; gating delivery on meets_gate()) belong to the retrieval adapter, which does not exist yet. No hooks, watcher, hosted service, MCP HTTP service, semantic or model-based extraction, clustering, provider API key, or default dependency — each remains a separate explicit decision.

🤖 Generated with Claude Code

…hing else

Adds the lifecycle around the optional Graphify provider adopted in #876: the
part that decides whether a graph should have existed at all, as distinct from
context_graph, which decides whether a delivered packet's citations may be used.

A build never touches the live checkout. It resolves a full commit and tree,
takes the tracked census from that commit's tree rather than the working tree or
the index, and materializes those blobs into a fresh 0700 directory as 0600
files. Untracked and ignored files have no path into the graph because they are
never written. Symlinks and gitlinks are skipped and recorded as skipped: one
can name a target the build was never shown, the other a commit in a repository
it was never authorized to read.

The indexer runs with an allowlisted environment -- PATH, TMPDIR, LANG, LC_ALL,
TZ, and nothing else -- every proxy emptied, and HOME redirected into the build's
own scratch area. A newly invented secret variable is excluded by default,
because the list names what is kept rather than what is dropped. Git itself runs
with system, global and local configuration disarmed, so an untrusted checkout
cannot install a clean/smudge filter that runs during what looks like a read.

Provenance is Code Mower's job because the provider owns none: constraint 1 of
the evaluation. Every manifest binds full commit and tree, the exact pin with its
wheel digest and options, build time, the tracked census count/bytes/digest, the
graph's own digest and bytes, and the provider's completeness admission. The pin
is exact by construction -- a range, a marker, or a missing artifact digest is
rejected -- and no default path acquires anything.

Publication is two atomic renames in an order a reader survives: the generation
becomes visible whole, then current starts naming it. Refresh publishes a new
immutable generation rather than mutating one in place.

graph_status resolves to exactly one state and only current is usable; nothing
falls back to an older generation. Stale, corrupt, oversized, partial, and
permission-invalid all fail closed, and the privacy check runs on every read, so
state loosened after the fact is refused rather than trusted because it was
private when it was written. partial exists specifically because of the requeue
defect the clean-room run recorded: a fast incremental repeat is not proof the
graph is complete.

Stdlib only. The provider seam is an injected callable, so the whole lifecycle
and its suite run offline with no graph package installed; subprocess_indexer
builds the argv and scrubbed environment for an install the operator already
made.

Closes #913

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment on lines +654 to +656
if any((parent / ".git").exists() for parent in (self.path, *self.path.parents)):
raise ContextError("local graph state must stay outside Git repositories")
self.verify_private(create=True)

@gitar-bot gitar-bot Bot Sep 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Bug: State-root git-ancestor check walks to filesystem root, blocking common setups

GraphStateRoot.ensure() (context_graph_lifecycle.py:654) rejects the state directory if any ancestor up to / contains a .git entry. Since the default state root lives under default_context_root() (typically under $HOME), any operator whose home directory is itself a git repository (a very common dotfiles-management pattern) will have every build/refresh call fail with "local graph state must stay outside Git repositories", even though the state directory has nothing to do with the checkout being indexed. The intent (don't nest private state inside the target checkout) is sound, but the implementation over-applies it to any git repo anywhere above the state root, including ones unrelated to the repository being graphed. Consider only refusing when the state root is nested inside the target repository being built (compare against repository's working tree/.git location) rather than checking for git-ness of arbitrary ancestors of the state root.

Only refuse placement when the state root is nested inside the checkout being indexed, not any unrelated ancestor repo.:

def ensure(self) -> None:
    """Create the private tree, refusing to place state inside the target checkout."""
    try:
        self.path.relative_to(self.repository)
    except ValueError:
        pass
    else:
        raise ContextError("local graph state must stay outside the checkout it binds")
    self.verify_private(create=True)

Was this helpful? React with 👍 / 👎

@gitar-bot

gitar-bot Bot commented Sep 12, 2026

Copy link
Copy Markdown

Note

Automatic reviews are paused because your team has used its included automatic processing for this billing period (headroom scales with your seat count). You can still comment "Gitar review" to run one anytime, and automatic reviews resume on their own by October 1. Add seats for more headroom.
Learn more

Code Review ⚠️ Changes requested 0 resolved / 1 findings

Adds lifecycle management for the optional local graph with comprehensive validation and atomic publication, but the state-root git-ancestor check is overly broad and blocks common dotfiles-repository setups. The check should only refuse when the state root is nested inside the target repository being built, not when arbitrary ancestors contain .git entries.

⚠️ Bug: State-root git-ancestor check walks to filesystem root, blocking common setups

📄 src/code_mower/context_graph_lifecycle.py:654-656

GraphStateRoot.ensure() (context_graph_lifecycle.py:654) rejects the state directory if any ancestor up to / contains a .git entry. Since the default state root lives under default_context_root() (typically under $HOME), any operator whose home directory is itself a git repository (a very common dotfiles-management pattern) will have every build/refresh call fail with "local graph state must stay outside Git repositories", even though the state directory has nothing to do with the checkout being indexed. The intent (don't nest private state inside the target checkout) is sound, but the implementation over-applies it to any git repo anywhere above the state root, including ones unrelated to the repository being graphed. Consider only refusing when the state root is nested inside the target repository being built (compare against repository's working tree/.git location) rather than checking for git-ness of arbitrary ancestors of the state root.

Only refuse placement when the state root is nested inside the checkout being indexed, not any unrelated ancestor repo.
def ensure(self) -> None:
    """Create the private tree, refusing to place state inside the target checkout."""
    try:
        self.path.relative_to(self.repository)
    except ValueError:
        pass
    else:
        raise ContextError("local graph state must stay outside the checkout it binds")
    self.verify_private(create=True)
🤖 Prompt for agents
Code Review: Adds lifecycle management for the optional local graph with comprehensive validation and atomic publication, but the state-root git-ancestor check is overly broad and blocks common dotfiles-repository setups. The check should only refuse when the state root is nested inside the target repository being built, not when arbitrary ancestors contain `.git` entries.

1. ⚠️ Bug: State-root git-ancestor check walks to filesystem root, blocking common setups
   Files: src/code_mower/context_graph_lifecycle.py:654-656

   `GraphStateRoot.ensure()` (context_graph_lifecycle.py:654) rejects the state directory if *any* ancestor up to `/` contains a `.git` entry. Since the default state root lives under `default_context_root()` (typically under `$HOME`), any operator whose home directory is itself a git repository (a very common dotfiles-management pattern) will have every `build`/`refresh` call fail with "local graph state must stay outside Git repositories", even though the state directory has nothing to do with the checkout being indexed. The intent (don't nest private state inside the *target* checkout) is sound, but the implementation over-applies it to any git repo anywhere above the state root, including ones unrelated to the repository being graphed. Consider only refusing when the state root is nested inside the *target repository* being built (compare against `repository`'s working tree/`.git` location) rather than checking for git-ness of arbitrary ancestors of the state root.

   Fix (Only refuse placement when the state root is nested inside the checkout being indexed, not any unrelated ancestor repo.):
   def ensure(self) -> None:
       """Create the private tree, refusing to place state inside the target checkout."""
       try:
           self.path.relative_to(self.repository)
       except ValueError:
           pass
       else:
           raise ContextError("local graph state must stay outside the checkout it binds")
       self.verify_private(create=True)

Options

Display: compact → Counting what did not apply, without listing it.

Comment with these commands to change the behavior for this request:

Compact
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

test_cli_command_registry_is_single_source_of_truth pins the exact handler
tuple, which is the point of it: a new command has to be declared in the
registry rather than appearing by accident. Declaring it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jeffhuber

Copy link
Copy Markdown
Contributor Author

Pushed fe08a2b. test_cli_command_registry_is_single_source_of_truth pins the exact CLI handler tuple — that is the point of it, so a new command has to be declared there rather than appearing by accident. context-graph is now declared.

That was the only failure in the run: 1 of 2929 tests, with every test in tests/test_context_graph_lifecycle.py passing on 3.12, 3.13 and 3.14. That CI run is the first execution of this suite — Python could not run on the lane, as the PR body says.

Audit target head: fe08a2b.

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Codex audit (merge-authority lane)

Head SHA: 3cff55a47b75be83ab00ef82b10ad67f9f499945

Head SHA changed during review (3cff55a4fe08a2b4). Skipping this verdict and requeuing for re-review of the new head.

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Codex audit (merge-authority lane)

Head SHA: fe08a2b4d0fda04c9a0b685c4abfac713496be55
Findings: P0=0, P1=2, P2=1, P3=0 (blocker policy: any P0/P1/P2 → BLOCKED)

Codex Audit: BLOCKED

Summary:

The network boundary is not enforced, concurrent builds can delete the published generation, and relative indexer paths fail.

Findings:

  • [P1] Enforce network isolation rather than bypassing proxies -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-wu03rxsy/wt/src/code_mower/context_graph_lifecycle.py:87
    Finding ID: codex:008ea69052009fa4b475
    On a host with direct internet access, these settings do not prevent outbound requests: empty proxy variables disable proxies, and NO_PROXY=* explicitly requests direct connections. The provider launched by subprocess_indexer can therefore reach the network while reading repository content, contrary to the promised offline boundary. Enforce network denial at the subprocess sandbox level and test that an actual connection attempt fails.
  • [P1] Prune generations before releasing the build lock -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-wu03rxsy/wt/src/code_mower/context_graph_lifecycle.py:930
    Finding ID: codex:503c218d9f28e73ec01e
    With concurrent builds, the first build can release its lock, pause, and then prune after the second build has published. It will delete the second build's generation while current still points to it, leaving the graph unusable despite both builds succeeding. Pruning can also remove the second publisher's staging directory. Keep publication and pruning within the same locked critical section.
  • [P2] Resolve relative indexer paths before changing directories -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-wu03rxsy/wt/src/code_mower/context_graph_lifecycle.py:462
    Finding ID: codex:56b7158dc3e54c132bf7
    When an operator supplies a relative executable path such as --indexer .venv/bin/graphify, subprocess execution resolves it relative to the materialized source directory because of this cwd, not relative to the invocation directory. The already-installed executable consequently cannot be found and the build fails. Resolve executable paths containing a directory component before launching the child, while retaining PATH lookup for bare command names.

Three findings from the codex audit of fe08a2b.

P1, network isolation. Emptied proxy variables and NO_PROXY=* were
never a boundary: on a host with direct connectivity they ask a
cooperating client to connect directly. The provider is now launched
behind an argv prefix that denies it sockets at the OS level
(sandbox-exec, bwrap --unshare-net, unshare --net), and no mechanism is
trusted on its name -- each candidate is accepted only after a probe
child launched behind it has been observed failing to connect with a
denial. A refused connection is the failure case: it proves the syscall
reached the network stack. A host where nothing passes gets no build,
and doctor reports it as a failing context-graph-isolation check once a
provider is pinned rather than waiting for the build to refuse.

P1, pruning outside the lock. Publication and pruning now share one
locked critical section. A builder that pruned after unlocking could
delete the generation a second builder had just published, or its
staging directory, leaving current naming a directory that is gone with
both builds reporting success.

P2, relative indexer paths. The child runs in the materialized copy, so
--indexer .venv/bin/graphify was resolved there instead of against the
invocation directory. A path with a directory component is now resolved
before launch; a bare command name keeps its PATH lookup.

Tests: the boundary is proven against a real loopback listener the
sandboxed child cannot reach, with an unsandboxed control so a child
that merely failed to start cannot read as isolation, plus a check that
a pass-through launcher is rejected as a candidate. Also covered: the
sandbox prefix on the provider argv, the refusal when no mechanism
exists, relative and bare executable resolution, and prune ordering
against lock release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jeffhuber

Copy link
Copy Markdown
Contributor Author

Fix round: all three findings from the fe08a2b audit

Head SHA: a011eb45b93ba20f0055924d776eb491f4b93b9d. Re-requested needs-codex-audit.

[P1] codex:008ea69052009fa4b475 — enforce network isolation rather than bypassing proxies. Correct, and the PR body oversold it. Empty proxy variables and NO_PROXY=* are a request to a cooperating client, not a boundary; on a host with direct connectivity they deny nothing.

The provider is now launched behind an argv prefix that denies it sockets at the OS level — sandbox-exec on macOS, bwrap --unshare-net or unshare --net on Linux. No mechanism is trusted on its name: each candidate is accepted only after a probe child launched behind it has been observed failing to open a TCP connection with a denial (EPERM, ENETUNREACH, and the like). A refused connection is the failure case — it proves the syscall reached the network stack, so the candidate is rejected. The result is cached per process, since it is a property of the host.

A host where no candidate passes gets no build: subprocess_indexer() raises before a single blob is materialized, and doctor reports it as a failing context-graph-isolation check as soon as a provider is pinned, rather than leaving the operator to discover it when a build refuses. Running unconfined is not offered as a fallback. The old environment settings stay as hygiene and are commented as such.

[P1] codex:503c218d9f28e73ec01e — prune generations before releasing the build lock. Publication and pruning now sit in the same locked critical section. The scenario named — first builder releases, pauses, then deletes the second builder's published generation or its staging directory while current names it — is no longer reachable.

[P2] codex:56b7158dc3e54c132bf7 — resolve relative indexer paths before changing directories. A path with a directory component is resolved against the invocation directory at factory time, before the child is launched into the materialized copy. A bare command name keeps its PATH lookup, which the child's directory does not affect.

Tests added

The network boundary is proven the only way it can be: NetworkIsolationTests binds a real listener on loopback in the test process and asserts a sandboxed child cannot reach it, with an unsandboxed control so that a child which merely failed to start cannot pass as isolation, plus a check that a pass-through launcher (/usr/bin/env) is rejected as a candidate. The sandboxed case skips where the host offers no mechanism; so does the one CLI test that launches a provider for real.

Also added: the sandbox prefix on the provider argv, the refusal when no mechanism exists, relative/bare/empty executable resolution, prune ordering against lock release, and doctor's isolation check in both directions.

Validation

Unrun on this lane. Python execution is refused in this session before it starts — /opt/homebrew/bin/python3.12 -m venv /tmp/cm-venv-926 came back This command requires approval, as did /usr/bin/sandbox-exec ... /bin/echo and command -v ruff. So no interpreter, pytest, or ruff run was possible, and I could not probe this Mac's sandbox-exec behaviour directly either. CI is the first execution of this change; treat green CI, not this comment, as the evidence.

Docs updated: docs/context-graph-lifecycle.md gains a "The network boundary" section stating what is enforced and what a host without a mechanism gets, the scrubbed-environment step no longer claims to be the boundary, and --indexer documents relative-path resolution.

Remaining

Nothing from this audit. Adoption conditions 4 and 6 stay out of scope — they belong to the retrieval adapter, which does not exist yet.

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Codex audit (merge-authority lane)

Head SHA: a011eb45b93ba20f0055924d776eb491f4b93b9d
Findings: P0=0, P1=2, P2=3, P3=0 (blocker policy: any P0/P1/P2 → BLOCKED)

Codex Audit: BLOCKED

Summary:

The provider adapter uses the wrong extraction interface and does not validate completeness. Git can bypass the offline boundary, and concurrent lifecycle operations can delete active state or misreport healthy generations.

Findings:

  • [P1] Invoke the evaluated provider's actual extraction interface -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-yy0oelng/wt/src/code_mower/context_graph_lifecycle.py:584
    Finding ID: codex:a6a9af51225688eb9396
    The documented graphifyy==0.9.58 installation uses the extract interface recorded in docs/graphify-evaluation.md, but the launcher assumes index --source ... --output .... The documented build command therefore fails with the installed Graphify CLI. Implement provider-specific extraction and artifact collection, and test the actual argument contract.
  • [P1] Prevent Git from lazily fetching outside the network sandbox -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-yy0oelng/wt/src/code_mower/context_graph_lifecycle.py:325
    Finding ID: codex:4a06f21d8237f8deed08
    For partial clones with missing blobs, ls-tree --long or cat-file --batch can trigger promisor-remote fetches. Repository-local configuration remains enabled, and both Git processes run outside the network sandbox, allowing an offline build to contact a remote or execute its configured transport. Disable lazy fetching and restrict transports in both invocation paths, failing closed when required objects are unavailable.
  • [P2] Derive completeness from the provider result -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-yy0oelng/wt/src/code_mower/context_graph_lifecycle.py:600
    Finding ID: codex:bae7a8088292950eab83
    Every successful subprocess is classified as complete without inspecting provider completeness or processed-file metadata. Successful partial extraction can therefore produce a usable current generation, bypassing the default partial-build refusal. Existing partial-state tests only exercise injected callables. Parse and validate provider completion evidence instead of equating exit status zero with completeness.
  • [P2] Serialize removal with active builds -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-yy0oelng/wt/src/code_mower/context_graph_lifecycle.py:923
    Finding ID: codex:95b7d1934e49fc363df6
    Running remove during a build recursively deletes its source, output, generations, and lock file without acquiring the build lock. The build can fail or recreate state after removal reports success, and another builder can acquire a new lock inode while the original builder holds the deleted one. Coordinate deletion with builders using a stable lock whose inode remains present during the protected operation.
  • [P2] Protect readers from pruning their selected generation -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-yy0oelng/wt/src/code_mower/context_graph_lifecycle.py:1068
    Finding ID: codex:87bf2a8bf95985efb6dd
    During concurrent refresh, graph_status() can read the old current pointer just before the builder publishes and prunes that generation. Subsequent manifest or artifact access then reports invalid or corrupt despite healthy builds. Atomic pointer replacement does not protect unlocked readers. Coordinate reads with pruning, retain referenced generations, or retry when the current pointer changes during validation.

CI on ubuntu-latest skipped the two host-dependent isolation tests: no
candidate passed the probe there, because the runner image restricts
unprivileged user namespaces, which is what both unshare and bwrap
need. That is the documented refusal working as intended, but it left
the accept half of the decision unexercised on Linux.

Stand-in launchers now pin both halves on every host -- a child that
reports a denial is accepted, one that reached the network stack is
rejected, one that cannot start is rejected -- alongside the real
loopback proof that runs wherever the host offers a mechanism. The docs
name the remedy for a restricted Linux host rather than leaving an
operator to infer it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jeffhuber

Copy link
Copy Markdown
Contributor Author

CI is green on this fix round, and it told me something worth reporting

Head SHA: b60a1afadca0867c3a5fc58057400834eb415b17. Labels: builder:claude, needs-codex-audit.

Run 34686873990: 2943 tests, OK (skipped=3) on 3.12, 3.13 and 3.14, with ruff, the privacy scan, release readiness and the install rehearsals passing. The previous head ran 2929 with 1 skip.

Two of those skips are new, and they are the two host-dependent isolation tests. No sandbox candidate passed the probe on ubuntu-latest: that image restricts unprivileged user namespaces, which is exactly what both unshare --net and bwrap --unshare-net need. So on GitHub's runner a real build would refuse — the documented fail-closed behaviour, working, but it also meant the accept half of the decision went unexercised on Linux.

b60a1af closes that gap: stand-in launchers pin the classifier in both directions on every host (a child reporting a denial is accepted, one that reached the network stack is rejected, one that cannot start is rejected), alongside the real loopback proof that runs wherever the host offers a mechanism. The docs now name the remedy for a restricted Linux host — install bubblewrap, which ships an AppArmor profile permitting the namespaces it needs — rather than leaving an operator to infer it from a refusal. Lifting the restriction machine-wide is noted as the operator's call, not this repository's.

Earlier in this session I reported the suite as unrun on the lane; that is still true of the lane itself (Python is refused here), but CI has now executed both pushes. The evidence is the run above.

Where each finding stands

Finding Status
codex:008ea69052009fa4b475 — network isolation Enforced at the OS level with a probe-verified mechanism; builds refuse where none exists. Proven against a real loopback listener with an unsandboxed control.
codex:503c218d9f28e73ec01e — prune before unlocking Publication and pruning share one locked critical section; ordering is asserted.
codex:56b7158dc3e54c132bf7 — relative indexer paths Resolved against the invocation directory before launch; bare names keep PATH lookup.

Nothing else remains from this audit.

…fecycle races

Fix round for the `a011eb4` audit, all five findings.

[P1] The launcher assumed `index --source ... --output ...`. The evaluated
release takes `extract` plus options and writes its state beside the sources it
was run over, so the adapter now runs that interface in the materialized copy
and collects the state directory into one reproducible archive afterwards.

[P1] Git children of a build are outside the provider's sandbox, and in a
partial clone `ls-tree` and `cat-file` can fetch missing objects from a remote
mid-build. Both invocation paths now share one environment with lazy fetching
off and an empty transport allowlist, `protocol.allow=never` travels on the
command line because that is the only level above the checkout's own config,
and a partial clone is refused outright rather than read one blob at a time.

[P2] Completeness is read from the provider's own report, never from its exit
status. Requeued, pending, or failed entries make a build partial, and so does
a run that left no readable report: absent evidence is not evidence.

[P2] `remove` takes the build lock, so it can no longer delete a running
build's sources, output, and generations. The lock moved beside the state
directory so it survives the removal it serializes -- a lock inside the deleted
tree would be unlinked mid-removal and the next builder would hold a new inode.

[P2] Readers take no lock, so a refresh can publish and prune between a
reader's pointer read and its validation. A failing verdict is confirmed
against the pointer before it is returned, and a pointer that moved is read
again, so a healthy refresh no longer surfaces as `invalid` or `corrupt`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jeffhuber

Copy link
Copy Markdown
Contributor Author

Fix round: all five findings from the a011eb4 audit

Head SHA: c4f6bfed7e07dba81806d2fd533c9a5f2584117f. needs-codex-audit is still on the PR; this is the head to review.

[P1] codex:a6a9af51225688eb9396 — invoke the evaluated provider's actual extraction interface. Correct. index --source ... --output ... is a conventional-looking CLI, not the one the adopt decision evaluated. docs/graphify-evaluation.md records the clean-room run as extract --code-only --no-cluster --max-workers 4: one verb, options, no source/output pair, reading the directory it is run in.

The launcher now runs extract with the pinned options and the working directory set to the materialized copy. Because there is no --output to name, the adapter collects afterwards instead: the state directory the provider wrote (.graphify or .graph, both already on the excluded-roots list) is packed into a single archive with names sorted, timestamps and ownership fixed, modes normalized, and symlinks dropped — two builds of one commit must produce identical bytes, since the manifest binds a digest of them. That state lands inside the throwaway copy, never inside the indexed checkout, and the copy is deleted when the build ends.

The subcommand, state-directory names, and report counters are constants in one place, commented as encoding the interface as the evaluation recorded it. Neither this lane nor the last one has installed the package, so the first installation against a real pinned release should confirm them and correct them there. That is now written into the doc rather than left implicit.

[P2] codex:bae7a8088292950eab83 — derive completeness from the provider result. Same root cause, and the sharper half of it. Exit status zero was being equated with a complete graph, which is exactly what the evaluation warns against: the no-op repeat finished in 1.63 s having requeued 54 manifest entries.

Completeness is now read from the provider's own report — requeued, pending, or failed entries, or an outright denial, make the build partial. A run that left no readable report is partial too. Absent evidence is not evidence of a complete build, and partial is the state graph_status refuses by default, so the operator sees a failure they can act on rather than a usable generation built on a guess. Tested through the real launcher end to end, not only through injected callables: a stand-in that exits zero and writes state whose report denies completion produces a build that status reports as partial with a non-zero exit.

[P1] codex:4a06f21d8237f8deed08 — prevent Git from lazily fetching outside the network sandbox. Correct, and the gap was structural: the provider is sandboxed, but the census reader and the blob materializer are Git children of Code Mower itself, outside it. They also had two different hand-rolled environments, so anything added to one missed the other.

Both now share one git_environment(): GIT_NO_LAZY_FETCH=1, and GIT_ALLOW_PROTOCOL set but empty, which git reads as the complete list of permitted transports. protocol.allow=never travels on the command line, because that is the only level that outranks the repository's own .git/config — which belongs to the untrusted checkout and is always read, as the finding noted.

That still leaves the repository shape, and GIT_NO_LAZY_FETCH is not honoured by older Git releases, so a build refuses a partial clone outright. Where extensions.partialclone or a promisor remote is configured, the build declines the checkout rather than discovering missing objects one blob at a time; there is no bounded way to prove in advance which objects are local. Fail closed, as the finding asked. Covered for both config shapes, on the census path, the materialization path, and end to end through a build that publishes nothing.

[P2] codex:95b7d1934e49fc363df6 — serialize removal with active builds. Correct on both halves, including the inode half, which was the harder one. remove now takes the build lock. The lock moved from inside the state directory to beside it, so it survives the removal it serializes: a lock file inside the deleted tree gets unlinked mid-removal, and the next builder then creates a new inode and holds a lock nobody is waiting on — two processes, two files, no mutual exclusion. What is left behind is an empty 0600 file outside the deleted tree carrying nothing; that is asserted, along with the inode being unchanged across the removal.

One consequence worth naming: lock() no longer creates the generations tree, only what the lock file needs, so a removal cannot report success for a tree it created itself. build_graph calls ensure() explicitly under the lock instead. A removal on an installation that never opted in still creates nothing at all.

[P2] codex:87bf2a8bf95985efb6dd — protect readers from pruning their selected generation. Correct: atomic pointer replacement protects a reader from seeing a half-written generation, not from validating one that has since been pruned. Readers take no lock and should not start.

graph_status() now confirms a failing verdict against the pointer before returning it, and re-reads when the pointer moved — bounded at three attempts, since only a publish moves it. A healthy concurrent refresh no longer surfaces as invalid or corrupt. A verdict about the generation current still names is returned as it stands; there is a test asserting a genuinely corrupt generation is reported on the first read and not polled.

Validation

The suite was not run on this lane. Python execution is denied in this session — /usr/bin/python3 -m unittest ... and creating a virtualenv are both refused before they run, so no interpreter, pytest, or ruff invocation was possible. As with the previous rounds on this PR, CI is the first execution. Treat green CI, not this comment, as the evidence.

docs/context-graph-lifecycle.md records all five changes: the real extract contract and how the artifact is collected, completeness-from-report including the absent-report rule, the Git environment and the partial-clone refusal, the removal lock and why it lives outside the tree, and the reader retry.

🤖 Generated with Claude Code

@jeffhuber

Copy link
Copy Markdown
Contributor Author

CI on c4f6bfe

Run 34687545004.

Confirmed green at the time this lane's budget ran out:

  • 3.14 — job success. Ruff lint, privacy scan, unit tests, compile sources, workflow-template guard all pass.
  • 3.13 — job success, same steps.
  • 3.12 — ruff lint, privacy scan, unit tests, compile sources, actionlint, and release readiness all pass; the install rehearsals (easy-mode smoke, fresh-clone, package-install first-user) were still running.

So the whole suite passed on all three interpreters, and lint and the privacy scan passed on all three. What was unfinished is the 3.12 install-rehearsal tail, which this change does not touch — no dependency was added, and the lifecycle still imports nothing outside the standard library.

Worth stating plainly: this lane could not execute Python at all, so CI is the first and only execution of the ~15 tests added in this round. If the rehearsal tail goes red, it is mine to fix and I did not see it.

Audit target head: c4f6bfed7e07dba81806d2fd533c9a5f2584117f. Labels: builder:claude, needs-codex-audit.

🤖 Generated with Claude Code

@jeffhuber

Copy link
Copy Markdown
Contributor Author

The 3.12 rehearsal tail I left open in the previous comment finished green: all three matrix jobs (3.12, 3.13, 3.14) are success end to end — ruff, privacy scan, unit tests, compile, actionlint, release readiness, and all three install rehearsals. Run 34687545004, head c4f6bfed7e07dba81806d2fd533c9a5f2584117f. Nothing outstanding from CI on this fix round.

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Codex audit (merge-authority lane)

Head SHA: 9f3099a533d94d8b5efd2477c9d2970447af2f49
Findings: P0=0, P1=0, P2=3, P3=0 (blocker policy: any P0/P1/P2 → BLOCKED)

Codex Audit: BLOCKED

Summary:

Completion validation can authorize unusable graphs, tracked provider caches can undermine fresh revision-bound builds, and report reading bypasses its memory bound.

Findings:

  • [P2] Require affirmative evidence before marking a build complete -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-fqnnggar/wt/src/code_mower/context_graph_lifecycle.py:702
    Finding ID: codex:293b33c3cd44bf289e43
    A readable report such as {} or {"schema": "unexpected"} is classified as complete despite containing no completion evidence, allowing graph_status() to consider an unsupported or incomplete generation usable. Validate the expected report shape and require affirmative completion evidence; otherwise retain the partial classification.
  • [P2] Reject pre-existing provider state before extraction -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-fqnnggar/wt/src/code_mower/context_graph_lifecycle.py:1257
    Finding ID: codex:4a34d6c0c10b5b8afa8f
    Tracked .graphify or .graph state is copied into the provider's working directory, allowing extraction to reuse an old incremental cache. _provider_state_directory() accepts it as newly produced output and prefers tracked .graphify over fresh .graph output, potentially binding stale graph contents to the new commit. Reject or exclude provider-state directories before extraction.
  • [P2] Bound the report read before allocating its contents -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-fqnnggar/wt/src/code_mower/context_graph_lifecycle.py:667
    Finding ID: codex:220fe7a26bb89fcf8b5a
    read_bytes() loads the entire manifest before slicing, before _pack_state() checks the artifact budget. Oversized provider output can therefore exhaust the parent process's memory. Read at most MAX_MANIFEST_BYTES + 1 bytes from an open stream and explicitly reject oversized reports.

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Branch updated onto current main. New head dad9aac.

Acting on the orchestrator follow-up, which authorized this lane — the sole writer on claude/913-graph-lifecycle — to merge origin/main at dc2a308 into the branch, push, and re-request the Codex audit. This is the one item my previous round left open and explicitly declined to do unasked.

Head SHA: 741fcbcdad9aac02580478fefd4f809f1ec67b7d04288ef. Labels: builder:claude + needs-codex-audit (codex-audit-done removed, because it named the old head).

I did not merge the PR, touch auto-merge, or bypass any protection.

What the merge contains

A merge commit and nothing else. main's two commits — 9af0a2d (normalize GitHub bot login suffix) and dc2a308 (recognize Devin completion from running sessions) — touch seven files, none of which this PR touches:

main brought in This PR's files
devin_work_orders.py, remote_session.py, test_devin_review.py, test_devin_work_orders.py, test_remote_session.py, docs/devin-work-orders.md, docs/sessions.md context_graph_lifecycle.py, context_graph_command.py, cli.py, package_manifest.py, test_context_graph_lifecycle.py, test_release_hygiene.py, .github/workflows/ci.yml, four docs

Disjoint sets, so ort merged with no conflicts and no manual resolution. I checked that the result is exactly the union rather than trusting the absence of conflict markers:

  • git diff --name-only origin/main...HEAD lists the same 11 files as it did at 741fcbc — the merge added no product change of its own.
  • git diff HEAD 741fcbc --stat is only main's 7 files — the merge lost nothing from this PR.
  • git merge-base --is-ancestor origin/main HEAD passes, so main is fully contained and mergeStateStatus has moved off BEHIND.

mergeable is MERGEABLE; mergeStateStatus is now BLOCKED on the audit rather than BEHIND on the branch, which was the point of the update.

CI at the new head — green

Run 34714992390, conclusion success, head dad9aac:

Job Result
graph containment success
graph containment (macOS) success
package_matrix (3.12 / 3.13 / 3.14) success
package success

Ran 3148 tests ... OK (skipped=7) on the package matrix, and Ran 163 tests ... OK in the containment job. This is a merge of disjoint files, so re-running was the point: it proves main's Devin/session changes and this PR's graph lifecycle changes hold together in one tree, which neither branch's own prior green run could show.

The load-bearing part is unchanged and still passing under the real mechanisms, not stubs — the P1 from the e568537 audit narrowed a filesystem boundary whose sufficiency is a fact about the host loader, so these are the tests that would catch a narrowing that cut too far:

test_a_sandboxed_child_cannot_reach_the_listening_socket ... ok
test_an_unsandboxed_child_reaches_the_listening_socket ... ok
test_the_selected_mechanism_hides_a_file_outside_the_exposure ... ok
test_the_selected_mechanism_really_contains_a_child_on_this_host ... ok
test_build_status_refresh_remove_round_trip ... ok

code-mower/gate is pending — waiting for audit: Codex, which is the correct state: the fresh exact-head audit the orchestrator required has not run yet, and the gate is holding auto-merge until it does.

Tests run on this lane

None. Python execution is refused in this session — I probed /usr/bin/python3 -c "print(1)" once and it was denied before it ran, same posture every previous round on this PR recorded, and I did not loop on it. CI is the evidence, and for this change it is the better evidence anyway: three interpreters and both real sandbox mechanisms on the merged tree.

Remaining

One thing, and it is the whole reason this head exists: the Codex audit must now run against dad9aac and pass before auto-merge can proceed. needs-codex-audit is set and the gate is waiting on it.

If main moves again before that audit lands, this branch goes BEHIND again and needs the same update — strict required status checks make that unavoidable, and each update resets the audit target. Nothing I can do from here to prevent it.

Carried forward, still not a finding (no verdict has ever cited it): gitar-bot's suggestion to narrow the state-root check in context_graph_lifecycle.py so it refuses only when state is nested inside the repository being built, rather than inside any Git repository. Today a machine where $HOME is itself a repo (git init ~, yadm) refuses every context-graph verb. Deliberately unchanged across all four rounds — the broad form is the enforcement half of adoption condition 2, and narrowing it is a product call, not a correctness fix. It disappears with this comment thread on squash, so if it is worth keeping it wants its own issue.

🤖 Generated with Claude Code

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Codex audit (merge-authority lane)

Head SHA: dad9aac02580478fefd4f809f1ec67b7d04288ef
Findings: P0=0, P1=0, P2=1, P3=0 (blocker policy: any P0/P1/P2 → BLOCKED)

Codex Audit: BLOCKED

Summary:

Checkout identity depends on the invocation directory, causing lifecycle commands within the same worktree to operate on different state. Tests were not run because the environment is read-only.

Findings:

  • [P2] Normalize the checkout root before deriving its workspace ID -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-bza84jty/wt/src/code_mower/context_graph_lifecycle.py:2229
    Finding ID: codex:8deac5a95089e0d393ab
    When a command runs from a repository subdirectory, the default --repo-path produces a different workspace ID even though Git resolves the same commit and indexes the entire checkout. Consequently, a graph built from the repository root appears absent from src/, and remove from that directory leaves the existing graph untouched. Resolve the Git worktree root before deriving the state identity, while preserving separate identities for distinct worktrees.

Addresses codex:8deac5a95089e0d393ab (P2) at dad9aac.

The workspace name was a hash of the invocation directory, so every
subdirectory of one checkout was its own workspace. The census is read
from the commit with --full-tree, so `status` in src/ asks about exactly
the generation `build` at the root published -- and got `absent`, while
`remove` there deleted nothing and reported success.

Identity is now derived from the worktree root Git reports. Git answers
per worktree, so linked worktrees keep the separate state they need: each
may hold a different revision. A path Git cannot place -- not a
repository, a bare one, or no Git on the host -- keeps its resolved path,
which is what the name derived from before; naming state must not be the
step that fails, or `remove` could not clean up after a checkout Git has
stopped recognizing.

The same root is now what the provider exposure boundary is drawn
against. That one is load-bearing rather than cosmetic: the rule refuses
a provider install inside the checkout, and a subdirectory narrowed the
refusal to part of one -- `<checkout>/provider-venv` would have been
exposed to the sandboxed child for a build run from `<checkout>/src`,
which is the live working tree the materialized copy exists to replace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jeffhuber

Copy link
Copy Markdown
Contributor Author

Fix round: P2 codex:8deac5a95089e0d393ab addressed. New head 259f09d.

Head SHA: dad9aac259f09d09ccd58ca14cd6dd06598e06e725566a4. Labels: builder:claude + needs-codex-audit (codex-audit-blocked removed).

The audit at dad9aac found one P2 and nothing else. This round is that finding and nothing else.

The finding

[P2] Normalize the checkout root before deriving its workspace ID — context_graph_lifecycle.py:2229
When a command runs from a repository subdirectory, the default --repo-path produces a different workspace ID even though Git resolves the same commit and indexes the entire checkout.

Correct as described, and the mechanism is exactly the one the finding names. workspace_id hashed the resolved path it was handed, and --repo-path defaults to Path.cwd(), so <checkout>/src was a different workspace from <checkout>. Nothing else about a build varies with that directory: resolve_revision names the same commit, and read_tracked_census reads it with ls-tree -r --full-tree, so the graph covers the whole tree wherever the command ran. One directory's name was filed against another directory's content.

What the operator saw: build at the root, then status in src/ reporting absent, and remove in src/ deleting nothing while printing Removed local graph state. and exiting 0.

The fix

checkout_root() asks Git for the worktree root and identity is derived from that, in GraphStateRoot.__init__ — the one place every verb reaches its state through. build_graph and graph_status then read the checkout back through state.repository, so the root the identity came from is the root Git is run against.

Separate identities for distinct worktrees are preserved, which the finding asks for explicitly: Git answers --show-toplevel per worktree, so a linked worktree reports itself, not the repository it was created from. That is not a nicety — two worktrees may sit on different revisions, so a generation built for one is not a graph of the other's content. Covered with a real git worktree add, not a stand-in.

A path Git cannot place — not a repository, a bare one, or no Git on the host — keeps its resolved path, which is what the name derived from before. Nothing is refused at this step: naming state must not be what fails, or remove could not clean up after a checkout Git has stopped recognizing, and the verbs that need Git already fail on their own terms.

One more place the same path was load-bearing

subprocess_indexer is handed the same --repo-path, and passes it to _provider_read_paths and containment_prefix, both of which refuse what lives inside the checkout. A subdirectory narrowed that refusal to part of one: a provider at <checkout>/provider-venv was inside <checkout> but not inside <checkout>/src, so a build run from src/ would have exposed the live working tree to the sandboxed child — the working tree the materialized copy exists to replace. Normalized there too, with the refusal and a positive control both tested from a subdirectory.

This was not in the verdict. It is the same defect one call away from the one that was, and it fails toward exposure rather than toward a wrong answer, so it is fixed in the same round rather than filed.

CI at the new head — green

Run 34715746330, conclusion success, head 259f09d:

Job Result
graph containment success
graph containment (macOS) success
package_matrix (3.12 / 3.13 / 3.14) success
package success

Ran 173 tests ... OK in both containment jobs, up from 163. The eleven new tests ran under both real mechanisms — bubblewrap on Linux, Seatbelt on macOS — not stubs:

test_a_subdirectory_resolves_to_the_checkout_root ... ok
test_a_graph_built_at_the_root_is_current_from_a_subdirectory ... ok
test_a_build_from_a_subdirectory_publishes_the_checkouts_generation ... ok
test_remove_from_a_subdirectory_deletes_the_checkouts_graph ... ok
test_doctor_from_a_subdirectory_sees_the_checkouts_generation ... ok
test_linked_worktrees_of_one_repository_keep_separate_state ... ok
test_two_unrelated_checkouts_keep_separate_state ... ok
test_a_symlinked_spelling_of_one_checkout_is_one_workspace ... ok
test_a_path_outside_any_repository_keeps_its_resolved_path ... ok
test_the_verbs_address_one_checkout_from_any_directory_inside_it ... ok
test_a_provider_inside_the_checkout_is_refused_from_a_subdirectory ... ok

The build-from-a-subdirectory test also asserts the published census_digest equals the census read at the root, so the claim that the census is whole-tree either way is checked rather than asserted in prose. test_the_verbs_address_one_checkout_from_any_directory_inside_it drives status, doctor and remove through the real CLI with --repo-path <checkout>/src, which is the shape the operator actually hits, and then confirms the root reports absent — a remove that deleted a namesake would leave the root current.

I removed one test: test_two_worktrees_of_one_repository_keep_separate_state compared the checkout against a plain sibling directory that was never a worktree, so it proved nothing about worktrees. Both halves of what it was meant to say are now real — one test with git worktree add, one with two unrelated directories.

Tests run on this lane

None. Python execution is refused in this session — I probed /usr/bin/python3 -c "print(1)" once and it was denied before it ran, the same posture every round on this PR has recorded, and I did not loop on it. CI is the evidence.

Remaining

The branch is one commit behind main (26135c6, #944) and mergeStateStatus is BEHIND again; main requires up-to-date branches. This is the treadmill my last round predicted: each update resets the audit target, so doing it now would move the head off the SHA the audit was just requested for. The last update-onto-main was done only under the orchestrator's explicit authorization naming dc2a308; I am not repeating it unasked. Auto-merge is still enabled (squash, since 19:38:33Z), so either GitHub updates the branch for it or one Update branch click does.

Not a finding, carried forward unchanged from all five rounds (no verdict has ever cited it): gitar-bot's suggestion to narrow the state-root check in context_graph_lifecycle.py so it refuses only when state is nested inside the repository being built rather than inside any Git repository. Today a machine where $HOME is itself a repo (git init ~, yadm) refuses every context-graph verb. Deliberately unchanged: the broad form is the enforcement half of adoption condition 2, and narrowing it is a product call, not a correctness fix. It disappears with this comment thread on squash, so if it is worth keeping it wants its own issue.

🤖 Generated with Claude Code

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Orchestrator follow-up: refresh onto current main

The orchestrator explicitly authorizes the existing sole Claude writer to merge current origin/main at 26135c62f191b171a5b88d0234eb82c306ece169 into this PR branch, push the resulting head, and request a fresh Codex audit. The isolated merge simulation is conflict-free and the new main-only commit touches no Graphify PR files. Do not merge the PR or bypass protection. Full CI, the Code Mower gate, and a clean exact-head Codex audit must complete on the updated head before auto-merge may proceed.

Authorized by the orchestrator follow-up on PR #926 to bring the branch
up to date with main before the exact-head Codex audit.
@jeffhuber

Copy link
Copy Markdown
Contributor Author

Branch updated onto current main. New head 9d36b28.

Acting on the orchestrator follow-up at 20:04:58Z, which authorized this lane — the sole writer on claude/913-graph-lifecycle — to merge origin/main at 26135c6 into the branch, push, and re-request the Codex audit. That is this round in full: no product change, no verdict to address.

Head SHA: 259f09d9d36b2828df0b62bc9db93ee8220f1889012748b. Labels: builder:claude + needs-codex-audit.

I did not merge the PR, touch auto-merge, or bypass any protection.

Why there was no finding to fix

The audit at dad9aac found one P2 (codex:8deac5a95089e0d393ab), and the previous round fixed it at 259f09d — CI was green there and no verdict has landed against that head. The open item 259f09d left was the BEHIND state, which the orchestrator has now authorized clearing. So this push carries the merge and nothing else.

What the merge contains

A merge commit and nothing else. main's one new commit — 26135c6 (recover collection after stale work-order completion, #944) — touches five files, none of which this PR touches:

main brought in This PR's files
devin_work_orders.py, remote_session.py, test_devin_work_orders.py, test_remote_session.py, docs/devin-work-orders.md context_graph_lifecycle.py, context_graph_command.py, cli.py, package_manifest.py, test_context_graph_lifecycle.py, test_release_hygiene.py, .github/workflows/ci.yml, four docs

Disjoint sets, so ort merged with no conflicts and no manual resolution. As in the previous update, I checked that the result is the exact union rather than trusting the absence of conflict markers:

  • git diff --name-only origin/main...HEAD lists the same 11 files as it did at 259f09d — the merge added no product change of its own.
  • git diff 259f09d HEAD --stat is only main's 5 files, +189/−16 — the merge lost nothing from this PR.
  • git merge-base --is-ancestor origin/main HEAD passes, so main is fully contained.

mergeable is MERGEABLE; mergeStateStatus has moved from BEHIND to BLOCKED — blocked on the audit rather than on the branch, which was the point of the update.

CI at the new head — green

Run 34716097174, conclusion success, head 9d36b28:

Job Result
graph containment success
graph containment (macOS) success
package_matrix (3.12 / 3.13 / 3.14) success
package success

Ran 173 tests ... OK in both containment jobs — unchanged from 259f09d, which is the expected number for a merge that adds no graph code. Ran 3162 tests ... OK (skipped=7) on the package matrix, up from 3148 at dad9aac: main's new Devin tests plus this PR's eleven checkout-root tests, both present in one tree.

Re-running was the point. These are disjoint files, so neither branch's own prior green run could show that main's Devin/session changes and this PR's graph lifecycle changes hold together — this run does, on three interpreters and both real sandbox mechanisms (bubblewrap on Linux, Seatbelt on macOS), not stubs.

code-mower/gate is pending — waiting for audit: Codex, which is the correct state: the exact-head audit the orchestrator required has not run yet, and the gate is holding auto-merge until it does.

Tests run on this lane

None. Python execution is refused in this session — I probed /usr/bin/python3 -c "print('probe-ok')" once and it was denied before it ran, the same posture every round on this PR has recorded, and I did not loop on it. CI is the evidence, and for a merge of disjoint files it is the better evidence anyway.

Remaining

The Codex audit must run against 9d36b28 and pass before auto-merge can proceed. needs-codex-audit is set — I removed and re-added it so the request carries a fresh event pointing at the new head rather than resting on the one raised for 259f09d. The gate is waiting on it.

If main moves again before that audit lands, this branch goes BEHIND again and needs the same update; strict required status checks make that unavoidable, and each update resets the audit target. Nothing I can do from here to prevent it, and I will not repeat the update unasked — both updates so far happened only under an explicit orchestrator authorization naming the target SHA.

Not a finding, carried forward unchanged from all six rounds (no verdict has ever cited it): gitar-bot's suggestion to narrow the state-root check in context_graph_lifecycle.py so it refuses only when state is nested inside the repository being built rather than inside any Git repository. Today a machine where $HOME is itself a repo (git init ~, yadm) refuses every context-graph verb. Deliberately unchanged: the broad form is the enforcement half of adoption condition 2, and narrowing it is a product call, not a correctness fix. It disappears with this comment thread on squash, so if it is worth keeping it wants its own issue.

🤖 Generated with Claude Code

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Codex audit (merge-authority lane)

Head SHA: 9d36b2828df0b62bc9db93ee8220f1889012748b
Findings: P0=0, P1=0, P2=1, P3=0 (blocker policy: any P0/P1/P2 → BLOCKED)

Codex Audit: BLOCKED

Summary:

The lifecycle records the supplied provider pin without verifying that it identifies the executable actually used, undermining generation provenance. Assessment was based on static inspection; tests were not run.

Findings:

  • [P2] Verify the provider installation against the supplied pin -- /private/var/folders/f2/g9zqfdjx7z3ckcsx_f46htwm0000gn/T/codex-audit-tn6fpy_7/wt/src/code_mower/context_graph_lifecycle.py:2101
    Finding ID: codex:9d43c1a5caa02f437d74
    If the executable named by --indexer belongs to a different release than --pin-file, the adapter still runs it and publishes a manifest containing the requested version and wheel digest. Only extraction options are taken from the pin; nothing checks the installed provider's identity. Consequently, status can report a current generation with incorrect provider provenance, and a substituted distribution is not identifiable from the manifest. Validate the installation against the pin before extraction rather than recording unverified identity as build metadata.

A manifest records the provider pin as the provenance of every byte in a
generation, but nothing compared that pin to the install it was recorded
for: --indexer named an executable, --pin-file named a release, and a
build ran the one while recording the other. A generation could publish a
manifest naming graphifyy 0.9.58 over a graph a different release -- or a
different distribution answering to extract -- produced, and neither
status nor the manifest could tell afterwards.

subprocess_indexer now takes the pin and checks the executable against it
before a single blob is materialized: which distribution installed the
file, read from the installed RECORD rather than guessed from its name;
that distribution's name and version from METADATA, names compared the
way an installer normalizes them; and the file's own bytes against the
digest its installer recorded, so an install rewritten in place is
refused even though its .dist-info still names the pinned release.

The install is read, never asked. graphify --version would mean launching
the very executable whose identity is in question, outside the sandbox
that exists to confine it, and believing what it said about itself.
wheel_sha256 stays unchecked because an unpacked install does not retain
the artifact it came from; it remains the operator's record of which
artifact they installed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jeffhuber

Copy link
Copy Markdown
Contributor Author

Fix round: P2 codex:9d43c1a5caa02f437d74 addressed. New head 14768aa.

Head SHA: 9d36b2814768aa2304ef217fb48d794a784cfdb8d681d18. Labels: builder:claude + needs-codex-audit (codex-audit-blocked removed).

The audit at 9d36b28 — which is the head it was run against, so this is a real fix round rather than a stale verdict — found one P2 and nothing else. This round is that finding and nothing else.

The finding

[P2] Verify the provider installation against the supplied pin — context_graph_lifecycle.py:2101
If the executable named by --indexer belongs to a different release than --pin-file, the adapter still runs it and publishes a manifest containing the requested version and wheel digest. Only extraction options are taken from the pin; nothing checks the installed provider's identity.

Correct, and the mechanism is exactly the one the finding names. --indexer named an executable, --pin-file named a release, and the only thing the pin was read for on the launch path was _extraction_options(request.pin.options). build_graph then recorded pin.as_metadata() as the provenance of the artifact. A build could therefore publish a manifest naming graphifyy 0.9.58 over a graph that a different release — or a different distribution that happens to answer to extract — produced, and neither status nor the manifest could tell afterwards. For a distribution whose name differs from this repository's by one character, that is precisely the substitution the pin exists to make identifiable.

The fix

subprocess_indexer now takes the pin and checks the executable against it in the factory, beside the executable resolution and the sandbox check — so an install that is not the pinned release fails before a single blob is materialized, for the same reason an uncontainable host does.

Three things are checked, in _verify_provider_installation:

  1. Which distribution installed this file. Read from the installed RECORD, not guessed from the file's name. A pinned release and a lookalike can both ship a console script called graphify, and one environment is free to hold both; what the pin has to be checked against is the distribution that wrote this file. An executable no installed distribution claims is refused.
  2. Its name and version, from that distribution's METADATA — header block only, stopping at the first blank line so a crafted long description cannot supply headers. Names are compared the way an installer normalizes them (PEP 503), so a pin does not fail against its own install over Graphify_Y vs graphify-y; versions are compared exactly, because deciding 1.0 and 1.0.0 are one release is a version-comparison policy this module has no business inventing.
  3. That the file is still what was installed, against the digest the installer recorded for it in RECORD. An install whose console script was rewritten in place still carries the .dist-info of the release it was, so name and version alone would accept it. An entry whose digest the installer left empty is not treated as a mismatch — RECORD is allowed to carry empty digests and some installers write them.

The install is read, never asked. graphify --version would mean launching the very executable whose identity is in question, outside the sandbox that exists to confine it, and then believing what it printed about itself.

wheel_sha256 stays unchecked, and is not checkable here. An unpacked install does not retain the artifact it came from. It remains the operator's record of which artifact they installed, carried into the manifest so a substitution is identifiable after the fact. I did not invent a check that would have looked like one; the docs now say so in as many words.

Every foreign file this reads is bounded at the stream like every other one in this module: METADATA at 64 KiB, RECORD at 8 MiB, the executable at 64 MiB while hashing. A RECORD that cannot be parsed claims nothing, which leaves the executable unowned and the build refused — an unreadable install fails closed rather than passing unchecked.

One seam the change itself opened

The pin now arrives twice: once at the factory, once on each IndexRequest. The launch reads the request's options and the manifest records the request's pin, so an adapter checked against one and driven by another would publish provenance nothing verified — the same defect one call away. One caller passes both, so a divergence is a miswiring rather than an operator's doing, and run refuses it rather than leaving it to be noticed later. Tested.

CI at the new head — green

Run 34717033370, conclusion success, head 14768aa:

Job Result
graph containment success
graph containment (macOS) success
package_matrix (3.12 / 3.13 / 3.14) success
package success

Ran 186 tests ... OK in both containment jobs, up from 173. The thirteen new ProviderIdentityTests ran under both real sandbox mechanisms — bubblewrap on Linux, Seatbelt on macOS:

test_the_pinned_release_installed_where_it_is_named_is_accepted ... ok
test_a_different_version_of_the_pinned_distribution_is_refused ... ok
test_a_different_distribution_under_the_same_script_name_is_refused ... ok
test_an_executable_installed_by_another_distribution_in_the_same_environment_is_refused ... ok
test_an_executable_no_installed_distribution_claims_is_refused ... ok
test_an_install_that_records_no_name_and_version_is_refused ... ok
test_the_name_is_compared_the_way_an_installer_normalizes_it ... ok
test_an_executable_modified_since_it_was_installed_is_refused ... ok
test_an_install_whose_record_carries_no_digest_still_verifies ... ok
test_the_check_reads_the_install_rather_than_running_the_provider ... ok
test_a_build_refuses_a_pin_the_adapter_was_not_checked_against ... ok
test_an_unparseable_record_claims_nothing_and_the_build_is_refused ... ok
test_an_oversized_record_is_refused_without_being_read_whole ... ok

They run the real check against a real install — a virtual environment holding a console script and the .dist-info its installer wrote, METADATA and RECORD included. A fixture that faked what the check reads would have proved nothing.

The one that carries the finding is test_an_executable_installed_by_another_distribution_in_the_same_environment_is_refused: the pinned release is installed in that environment, so a check that asked only "is the pinned release installed?" passes it, while --indexer names a console script a different distribution wrote. Its positive control is the pinned release's own script in that same environment, still accepted — so the refusal is about which distribution installed the file and not about there being two.

The end-to-end CLI tests now run against an install with real .dist-info metadata rather than a loose script, so build/refresh exercise the check through the real launcher.

Tests run on this lane

None. Python execution is refused in this session — I probed /usr/bin/python3 -c "print('probe-ok')" once and it was denied before it ran, the same posture every round on this PR has recorded, and I did not loop on it. CI is the evidence.

Remaining

The Codex audit must run against 14768aa and pass before auto-merge can proceed. needs-codex-audit is set and codex-audit-blocked is cleared; mergeStateStatus is BLOCKED on the audit rather than on the branch, and auto-merge (squash) is still enabled from 19:38:33Z. The branch is not BEHIND: main has not moved since the update onto 26135c6. If it moves again this branch needs another update, which resets the audit target; I will not do that unasked, as both updates so far happened only under an explicit orchestrator authorization naming the target SHA.

Not a finding, carried forward unchanged from all seven rounds (no verdict has ever cited it): gitar-bot's suggestion to narrow the state-root check so it refuses only when state is nested inside the repository being built rather than inside any Git repository. Today a machine where $HOME is itself a repo (git init ~, yadm) refuses every context-graph verb. Deliberately unchanged: the broad form is the enforcement half of adoption condition 2, and narrowing it is a product call, not a correctness fix. It disappears with this comment thread on squash, so if it is worth keeping it wants its own issue.

🤖 Generated with Claude Code

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Orchestrator follow-up: final post-#940 base refresh

PR #940 has merged. The orchestrator explicitly authorizes the existing sole Claude writer to merge current origin/main at d09e523895dd12abfa5775bd088f7d687d45d6bc into this PR branch, push the resulting head, and request a fresh Codex audit. An isolated merge simulation is conflict-free. Both sides add separate PACKAGE_FILES entries in src/code_mower/package_manifest.py; preserve both sets and verify packaging. Do not merge the PR or bypass protection. Full CI, the Code Mower gate, and a clean exact-head Codex audit must complete on the updated head before auto-merge may proceed.

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Branch refreshed onto current main (post-#940). New head 1149b76.

Acting on the orchestrator follow-up at 22:17:28Z, which authorized this lane — the sole writer on claude/913-graph-lifecycle — to merge origin/main at d09e523 into the branch, push, and re-request the Codex audit. That is this round in full: no product change, no verdict to address.

Head SHA: 14768aa1149b764575dbf054a77e8581893a47f3904333a. Labels: builder:claude + needs-codex-audit.

I did not merge the PR, touch auto-merge, or bypass any protection.

Why there was no finding to fix

The audit at 9d36b28 found one P2 (codex:9d43c1a5caa02f437d74) and the previous round fixed it at 14768aa, where CI was green. No verdict has landed against 14768aa. The only open item it left was the base refresh the orchestrator has now authorized, so this push carries the merge and nothing else.

What the merge contains

A merge commit and nothing else. main's one new commit — d09e523 (Devin setup/readiness, #940) — touches 24 files. Exactly one of them is also touched by this PR:

Overlapping file src/code_mower/package_manifest.py
Disjoint main's other 23 files vs this PR's other 10

ort merged with no conflicts and no manual resolution. As in the previous updates, I checked the result is the exact union rather than trusting the absence of conflict markers:

  • git diff --name-only origin/main...HEAD lists the same 11 files as at 14768aa — the merge added no product change of its own.
  • git diff 14768aa HEAD --stat is only main's 24 files, +3767/−64 — the merge lost nothing from this PR.
  • git merge-base --is-ancestor origin/main HEAD passes, so main is fully contained.

PACKAGE_FILES — both sets preserved

The orchestrator flagged this specifically. Both sides append to the same tuple, but in disjoint regions: this PR inserts at the context_graph block (line ~36), main inserts three entries lower down (lines ~223/274/334). Nothing to reconcile by hand, and both sets survive:

Side Entries in the merged tuple
This PR context_graph_lifecycle.py, context_graph_command.py
main (#940) local_cli_commands.py, doctor_checks/devin.py, devin_readiness.py

Verified two ways rather than by reading the merged file: git diff 14768aa HEAD -- package_manifest.py is exactly main's three additions and nothing else, and git diff origin/main...HEAD -- package_manifest.py is exactly this PR's two additions and nothing else. A merge that dropped either side would show up as a missing hunk in one of those two diffs. All five referenced files are present in the merged tree.

CI at the new head — green

Run 34722407187, conclusion success, head 1149b76:

Job Result
graph containment success
graph containment (macOS) success
package_matrix (3.12 / 3.13 / 3.14) success
package success

Ran 186 tests ... OK in both containment jobs — unchanged from 14768aa, the expected number for a merge that adds no graph code, and confirmation that the provider-identity check added last round still holds under both real sandbox mechanisms (bubblewrap on Linux, Seatbelt on macOS).

Ran 3249 tests ... OK on all three interpreters, up from 3162 at 9d36b28: #940's Devin setup/readiness suite plus this PR's, both present in one tree.

This covers the packaging verification the orchestrator asked for. package_matrix and package are green on 3.12/3.13/3.14, and tests/test_release_hygiene.py — the test that holds PACKAGE_FILES to the tree — runs inside that suite. Re-running was the point: neither branch's own prior green run could show that #940's manifest entries and this PR's hold together in one tuple, and this run does.

Tests run on this lane

None. Python execution is refused in this session — I probed /usr/bin/python3 -c "print('probe-ok')" once and it was denied before it ran, the same posture every round on this PR has recorded, and I did not loop on it. A shell-loop fallback to check the manifest paths statically was also refused by the sandbox; I did not loop on that either, and CI's package jobs verify the same property properly. CI is the evidence, and for a merge of near-disjoint files it is the better evidence anyway.

Remaining

The Codex audit must run against 1149b76 and pass before auto-merge can proceed. needs-codex-audit is set — I removed and re-added it so the request carries a fresh event pointing at the new head rather than resting on the one raised for 14768aa. mergeable is MERGEABLE; mergeStateStatus is BLOCKED on the audit rather than on the branch, which was the point of the update. code-mower/gate is PENDING, correctly holding auto-merge (squash, enabled 19:38:33Z) until the exact-head audit lands.

If main moves again this branch goes BEHIND and needs the same update, which resets the audit target. I will not do that unasked — all three updates so far happened only under an explicit orchestrator authorization naming the target SHA.

Not a finding, carried forward unchanged from all eight rounds (no verdict has ever cited it): gitar-bot's suggestion to narrow the state-root check in context_graph_lifecycle.py so it refuses only when state is nested inside the repository being built rather than inside any Git repository. Today a machine where $HOME is itself a repo (git init ~, yadm) refuses every context-graph verb. Deliberately unchanged: the broad form is the enforcement half of adoption condition 2, and narrowing it is a product call, not a correctness fix. It disappears with this comment thread on squash, so if it is worth keeping it wants its own issue.

🤖 Generated with Claude Code

@jeffhuber

Copy link
Copy Markdown
Contributor Author

Codex audit (merge-authority lane)

Head SHA: 1149b764575dbf054a77e8581893a47f3904333a
Findings: P0=0, P1=0, P2=0, P3=0 (blocker policy: any P0/P1/P2 → BLOCKED)

Codex Audit: PASS

Summary:

The review identified no sufficiently supported, actionable regression. Runtime behavior and the new containment CI jobs were not verified in the read-only environment.

Findings: none.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

builder:claude Code Mower generated label codex-audit-done

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Graphify: private revision-bound local graph lifecycle

1 participant