context: bind a local graph to its revision and fail closed on everything else - #926
Conversation
…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>
| 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) |
There was a problem hiding this comment.
⚠️ 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 👍 / 👎
|
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. Code Review
|
| Compact |
|
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>
|
Pushed That was the only failure in the run: 1 of 2929 tests, with every test in Audit target head: |
Codex audit (merge-authority lane)Head SHA: Head SHA changed during review ( |
Codex audit (merge-authority lane)Head SHA: Codex Audit: BLOCKED Summary: The network boundary is not enforced, concurrent builds can delete the published generation, and relative indexer paths fail. Findings:
|
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>
Fix round: all three findings from the
|
Codex audit (merge-authority lane)Head SHA: 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:
|
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>
CI is green on this fix round, and it told me something worth reportingHead SHA: 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
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
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>
Fix round: all five findings from the
|
CI on
|
|
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 |
Codex audit (merge-authority lane)Head SHA: 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:
|
Branch updated onto current
|
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...HEADlists the same 11 files as it did at741fcbc— the merge added no product change of its own.git diff HEAD 741fcbc --statis onlymain's 7 files — the merge lost nothing from this PR.git merge-base --is-ancestor origin/main HEADpasses, somainis fully contained andmergeStateStatushas moved offBEHIND.
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
Codex audit (merge-authority lane)Head SHA: 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:
|
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>
Fix round: P2
|
| 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
Orchestrator follow-up: refresh onto current mainThe orchestrator explicitly authorizes the existing sole Claude writer to merge current |
Authorized by the orchestrator follow-up on PR #926 to bring the branch up to date with main before the exact-head Codex audit.
Branch updated onto current
|
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...HEADlists the same 11 files as it did at259f09d— the merge added no product change of its own.git diff 259f09d HEAD --statis onlymain's 5 files, +189/−16 — the merge lost nothing from this PR.git merge-base --is-ancestor origin/main HEADpasses, somainis 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
Codex audit (merge-authority lane)Head SHA: 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:
|
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>
Fix round: P2
|
| 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
Orchestrator follow-up: final post-#940 base refreshPR #940 has merged. The orchestrator explicitly authorizes the existing sole Claude writer to merge current |
Branch refreshed onto current
|
| 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...HEADlists the same 11 files as at14768aa— the merge added no product change of its own.git diff 14768aa HEAD --statis onlymain's 24 files, +3767/−64 — the merge lost nothing from this PR.git merge-base --is-ancestor origin/main HEADpasses, somainis 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
Codex audit (merge-authority lane)Head SHA: 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. |
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.mdrecords 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 byload_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.
buildis the first-time verb and refuses when a usable generation already binds the revision;refreshis 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, thencurrentstarts naming it.Stale, corrupt, oversized, partial, and permission-invalid artifacts fail closed.
graph_statusresolves to exactly one state and onlycurrentis 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.partialexists 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,TZand nothing else; every proxy variable is emptied,no_proxyis*, andHOME/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.statusexits non-zero when the graph is unusable so a script can branch on it;doctorreportsskiprather thanfailwhen 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, orruffinvocation was possible. The tests intests/test_context_graph_lifecycle.pyare 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