Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
5edb190
Graphify: bounded query normalization and context packets
jeffhuber Sep 14, 2026
cc66dc3
Fix the query suite and package the new graph query seam
jeffhuber Sep 14, 2026
74367d9
Merge remote-tracking branch 'origin/main' into claude/914-graph-quer…
jeffhuber Sep 14, 2026
45e041d
Graph queries: private packet replacement, true edges, seed truncation
jeffhuber Sep 14, 2026
e3c2acb
Merge remote-tracking branch 'origin/main' into claude/914-graph-quer…
jeffhuber Sep 14, 2026
fcf6a5a
Graph queries: read the pinned Graphify export, not an invented schema
jeffhuber Sep 14, 2026
5de38f9
Graph queries: name the test-layout derivation's parts
jeffhuber Sep 14, 2026
c10bba9
Graph queries: reach the local graph from guided sessions
jeffhuber Sep 14, 2026
7ec69d5
Graph queries: bind the generation the evidence came from
jeffhuber Sep 14, 2026
1741f4f
Merge remote-tracking branch 'origin/main' into claude/914-graph-quer…
jeffhuber Sep 14, 2026
db30846
Graph context: require stated direction and the consuming revision
jeffhuber Sep 14, 2026
f1cdd34
Merge remote-tracking branch 'origin/main' into claude/914-graph-quer…
jeffhuber Sep 14, 2026
94cf43f
Graph context: consume the actual raw no-cluster extraction
jeffhuber Sep 14, 2026
c563988
Graph lifecycle: pass the scan target and derive linked libraries
jeffhuber Sep 14, 2026
d605a74
Merge commit 'c542536d68a5774d5c7db5d2f33a0e860e908872' into claude/9…
jeffhuber Sep 14, 2026
3960d98
Graph lifecycle: collect the pin's real output and prove coverage
jeffhuber Sep 14, 2026
d48444f
Graph lifecycle tests: finish the output-root rename in the exposure …
jeffhuber Sep 14, 2026
71896ed
Graph queries: bare callable seeds, and hashes checked against the bytes
jeffhuber Sep 14, 2026
85f966a
Graph lifecycle: classify inputs the way the pin does, and report wha…
jeffhuber Sep 14, 2026
ae5021d
Merge origin/main into claude/914-graph-query-packets
jeffhuber Sep 14, 2026
5a6d16d
Graph lifecycle: count the pin's byte-decided .m dispatch as unsupported
jeffhuber Sep 14, 2026
d969d12
Context: bind the standalone fetch to the consuming revision
jeffhuber Sep 14, 2026
9693a23
Graph queries: retain relationships between already-seen nodes
jeffhuber Sep 14, 2026
a61b276
Validate derived native dependencies and report missing graph endpoints
jeffhuber Sep 14, 2026
2e98ec6
Validate the whole native container, not its header
jeffhuber Sep 14, 2026
7cad390
Declare the adjacent-slice zip as intentionally unequal in length
jeffhuber Sep 14, 2026
7008f54
Merge commit 'db4506d2b3232e4c6a7c5683251eeb536b2b8355' into claude/9…
jeffhuber Sep 14, 2026
05a3cc2
Bound graph packets to the selected document budget
jeffhuber Sep 14, 2026
1720b14
Report evidence the depth limit left behind
jeffhuber Sep 14, 2026
1d3258a
Keep canonically equivalent tracked paths apart
jeffhuber Sep 14, 2026
1d0e4da
Merge commit 'refs/code-mower/accepted-main' into claude/914-graph-qu…
Sep 16, 2026
fef6faa
Bind published guided delivery to the actual consuming checkout (code…
Sep 16, 2026
c75ea40
Bind attached replay to the consuming checkout (codex:b7f5dbb1412eb89…
Sep 16, 2026
135a51f
Bind fresh attachments to the consuming checkout (codex:65a17212478a5…
Sep 16, 2026
9d85ad1
Complete pending packet cleanup before reconnect (codex:57555c67a42d8…
Sep 16, 2026
c01e158
Correct reconnect cleanup regression coverage (codex:57555c67a42d8aeb…
Sep 16, 2026
de68dea
Preserve Graphify call-site edge identity (codex:90851736427e7bca1693)
Sep 16, 2026
a86cef2
Restore guided attachment recovery (codex:1a4bc7b34687da726908)
Sep 16, 2026
bcabc16
Make attachment rollback resumable (codex:1a4bc7b34687da726908)
Sep 16, 2026
9e64f17
Complete reserving state integration (codex:1a4bc7b34687da726908)
Sep 16, 2026
9216db2
Use local reservation in prepare recovery test (codex:1a4bc7b34687da7…
Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions code-mower-package-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@
"source": "docs/context-graph-lifecycle.md",
"target": "docs/context-graph-lifecycle.md"
},
{
"kind": "doc",
"source": "docs/context-graph-queries.md",
"target": "docs/context-graph-queries.md"
},
{
"kind": "doc",
"source": "docs/context-packet-schema.md",
Expand Down Expand Up @@ -727,11 +732,21 @@
"source": "src/code_mower/context_graph_command.py",
"target": "src/code_mower/context_graph_command.py"
},
{
"kind": "core",
"source": "src/code_mower/context_graph_connection.py",
"target": "src/code_mower/context_graph_connection.py"
},
{
"kind": "core",
"source": "src/code_mower/context_graph_lifecycle.py",
"target": "src/code_mower/context_graph_lifecycle.py"
},
{
"kind": "core",
"source": "src/code_mower/context_graph_query.py",
"target": "src/code_mower/context_graph_query.py"
},
{
"kind": "core",
"source": "src/code_mower/context_guided.py",
Expand Down
36 changes: 35 additions & 1 deletion docs/context-delivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,41 @@ that exact revision from the trusted current comment. If a write returns an
uncertain result and the revision is not current, status pauses the workflow;
after independently checking the PR, `attach --retry-uncertain` republishes the
same revision rather than creating another one. No uncertain or unpublished
binding can deliver evidence.
binding can deliver evidence. A saved uncertain or pending intent is never
cleared just because a later attach was asked for: it may already be the
GitHub-accepted state a lost response only looked like it missed.

Before attempting a fresh reservation, the session first saves a durable
`reserving` marker -- proof that no GitHub write has happened yet, because
publication only ever follows a reservation that has itself already become
durable. If that reservation then fails within the same `attach` call -- for
example because the actual consuming checkout has moved past the evidence it
was prepared from, or the local graph was rebuilt in the meantime -- the same
call rolls the session back to its prior prepared, unattached state inline,
keeping the failure reason visible in status. If the process instead stops
before that same-call rollback, or before the reservation's own durable
transition out of `reserving` completes -- a crash, or a storage fault like
the one that also surfaces as a `ContextError` from a saved-state transition
that did not complete -- the saved intent is left as `reserving` instead. The
next `attach` recognizes a saved `reserving` marker, finishes abandoning it
and clearing the session back to its prior prepared, unattached state, and
asks the caller to rerun attach rather than completing a fresh attempt in
that same call, since resuming immediately could race a concurrent recovery.
Only once that cleanup has actually finished is the session safe to
explicitly rebuild, rerun `prepare --refresh`, or attach again; refresh must
not be used while a `reserving` cleanup is still outstanding, and a storage or
other fault surfaced while attach itself was trying to advance a saved intent
does not promise that this cleanup has already happened -- rerunning attach is
what finishes it.

A saved `pending` or `uncertain` intent is different: its reservation is
already durable, and GitHub publication may have begun or even completed
before a lost response left the local state unconfirmed, so it is never
blanket-cleared the way a `reserving` marker is. Once the trusted current pull
request head has genuinely moved past such a saved intent, that old identity
is retired before any new evidence is authorized, so a checkout that has since
moved on cannot block its own recovery. This retirement, like the `reserving`
cleanup above, is safe to retry after an interruption partway through.

The lower-level expert form remains available for scripts that intentionally
manage request files and revisions:
Expand Down
206 changes: 182 additions & 24 deletions docs/context-graph-lifecycle.md

Large diffs are not rendered by default.

460 changes: 460 additions & 0 deletions docs/context-graph-queries.md

Large diffs are not rendered by default.

11 changes: 9 additions & 2 deletions docs/graphify-evaluation.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,15 @@ lifecycle in
outside every checkout, a manifest that binds full commit and tree with build
time, opt-in acquisition with no default dependency, and a `partial`
completeness state that refuses to read a fast incremental repeat as a complete
graph. Conditions 4 and 6 belong to the retrieval adapter, which does not exist
yet.
graph.

Conditions 4 and 6 are implemented by the query and packet adapter in
[Local repository graph: bounded queries and context packets](context-graph-queries.md)
(issue #914): symbol-first, relationship-filtered traversals with explicit
depth and node budgets, truncation reported as `truncated` plus a
`provider_has_more` omission rather than hidden, and a packet whose citations
are validated against the bound commit's tracked tree before any of it reaches
a recipient.

## Boundary

Expand Down
12 changes: 10 additions & 2 deletions src/code_mower/context_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,12 @@ def finish(self, *, head, prose):
if not review_matches(marker(current, review=True), current, head=head):
return False
if self.delivery is not None:
# The immutable audited target head, not a control checkout's
# revision: the checkout running this audit may differ from
# the code being reviewed.
verified = deliver(self.store, current["revision"], repository=self.repository,
pr=self.pr, head=head, recipient=self.recipient, current=current, backend=self.backend)
pr=self.pr, head=head, recipient=self.recipient, current=current,
consuming_revision=head, backend=self.backend)
if verified.text != self.delivery.text:
return False
save_feedback(self.store, verified, self.recipient.split(":")[0], prose)
Expand Down Expand Up @@ -109,8 +113,12 @@ def prepare(*, repository, pr, head, host, authorities, fetch_comments,
state.ready = review_matches(marker(current, review=True), current, head=head)
return state
state.store = store if store is not None else ContextStore(state_dir)
# ``head`` is the immutable review-target revision this audit is for,
# never derived from ``repo_path`` or ``Path.cwd()``: the audit
# control checkout may differ from the selected review target.
state.delivery = deliver(state.store, current["revision"], repository=repository,
pr=pr, head=head, recipient=state.recipient, current=current, backend=backend)
pr=pr, head=head, recipient=state.recipient, current=current,
consuming_revision=head, backend=backend)
state.ready = True
except (ContextError, OSError, ValueError, RuntimeError, TypeError):
pass
Expand Down
19 changes: 15 additions & 4 deletions src/code_mower/context_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from .claude_audit_pr import _decision_authorities_for_repo
from .context_contract import ContextError, ContextRequest, _object, normalize_policy
from .context_delivery import SUPPORTED_HOSTS, SUPPORTED_RECIPIENTS, attach, deliver, read_binding, render_evidence
from .context_packets import load_authorized
from .context_packets import consuming_revision, load_authorized
from .context_store import ContextStore, strict_json
from .provider_runners import fetch_issue_comments, fetch_pull_request, post_pr_comment
from .provider_runners.github_auth import resolve_github_token_from_env_or_gh
Expand Down Expand Up @@ -63,7 +63,8 @@ def main(argv=None):
if args.recipient not in SUPPORTED_RECIPIENTS or args.recipient.endswith(":reviewer"):
raise ContextError("independent reviewers consume an attached review revision")
packet = load_authorized(store, args.connection, args.packet, spec["policy"],
ContextRequest(spec["repository"], spec["work_item"], args.recipient))
ContextRequest(spec["repository"], spec["work_item"], args.recipient,
consuming_revision(args.repo_path)))
print(render_evidence(packet, args.packet), end="")
return 0
if args.command in ('deliver', 'feedback') and (args.connection or args.request_stdin):
Expand Down Expand Up @@ -107,9 +108,14 @@ def publish(metadata):
# The current trusted gate independently rejects policy downgrade.
publish(metadata)
else:
# The actual consuming checkout, derived from --repo-path, not
# the remote PR head: a checkout at a different commit, or a
# directory that is not a Git checkout at all, must fail
# before any evidence is reserved or published.
metadata = attach(store, args.connection, spec["packet"], spec["policy"],
ContextRequest(spec["repository"], spec["work_item"], args.host + ":orchestrator"),
pr=spec["pr"], head=head, publish=publish)
pr=spec["pr"], head=head, publish=publish,
consuming_revision=consuming_revision(args.repo_path))
print(json.dumps({"status": "attached", **metadata}, sort_keys=True))
return 0
if not args.revision or args.packet:
Expand All @@ -120,8 +126,13 @@ def publish(metadata):
if current is None:
raise ContextError("no trusted current context input is declared")
head = fetch_pull_request(binding["repository"], binding["pr"], token=token)["head"]["sha"]
# The actual consuming checkout, derived from --repo-path, not the
# remote PR head: a checkout at a different commit, or a directory
# that is not a Git checkout at all, must fail before any evidence or
# feedback is produced.
delivery = deliver(store, args.revision, repository=binding["repository"], pr=binding["pr"], head=head,
recipient=args.recipient, current=current)
recipient=args.recipient, current=current,
consuming_revision=consuming_revision(args.repo_path))
if args.command == "feedback":
feedback = delivery.binding["feedback"].get(args.reviewer)
if feedback is None:
Expand Down
93 changes: 81 additions & 12 deletions src/code_mower/context_delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import uuid
from dataclasses import dataclass, field

from . import context_graph_connection as graph_connection
from . import context_review
from .context_connections import _state
from .context_contract import ContextError, ContextRequest, ValidatedPacket, _identifier, _object, _text, normalize_policy
Expand Down Expand Up @@ -75,11 +76,17 @@ def read_binding(store, revision):
return _binding(lookup.artifact("d-" + revision).read())


def _packet_for_binding(store, binding, recipient, *, backend=None):
def _packet_for_binding(store, binding, recipient, *, backend=None, revision=None):
if recipient not in SUPPORTED_RECIPIENTS:
raise ContextError("this participant cannot consume private context in this release")
# ``revision`` is the caller's actual consuming checkout revision, or an
# already-verified immutable review-target head; it is never defaulted to
# the head this binding happens to have been published for. A repository
# connection re-derives its authorization from exactly that commit and
# fails closed when it is missing; an organization connection ignores it.
packet = load_authorized(store, binding["connection"], binding["handle"], binding["policy"],
ContextRequest(binding["repository"], binding["work_item"], recipient), backend=backend)
ContextRequest(binding["repository"], binding["work_item"], recipient), backend=backend,
revision=revision)
if packet.sha256 != binding["packet_sha256"]:
raise ContextError("context evidence changed; attach the new input and review again")
return packet
Expand All @@ -95,27 +102,63 @@ def reserve_attachment(
pr,
head,
revision=None,
consuming_revision=None,
backend=None,
):
"""Reserve one unpublished binding before any remote publication.

A caller-supplied revision lets a guided session persist its intent before
touching GitHub and resume that exact intent after a crash. Repeating the
same reservation is idempotent; a conflicting reuse fails closed.

``revision`` here is that attachment handle, not a Git revision. ``head``
is attachment metadata: the PR head the caller read from the trusted
remote. ``consuming_revision`` is the caller's actual consuming checkout
revision, or ``None`` when the caller cannot name one; it is never
defaulted to ``head``, because a caller that knows only the trusted
remote head, not the local checkout doing the work, must say so
explicitly rather than let a repository-kind connection be silently
authorized against a revision it never held. Repository-kind evidence is
authorized against that consuming revision and separately checked against
``head``, so a packet prepared while the checkout sat at commit A cannot
be attached to a PR whose head is commit B, and a checkout that has
itself moved to commit B cannot attach evidence for commit A.
"""
if request.recipient not in SUPPORTED_RECIPIENTS or not request.recipient.endswith(":orchestrator"):
raise ContextError("an approved orchestrator must attach context")
policy = normalize_policy(policy)
packet = load_authorized(store, name, handle, policy, request, backend=backend)
packet = load_authorized(
store, name, handle, policy,
ContextRequest(request.repository, request.work_item, request.recipient, consuming_revision),
backend=backend, revision=consuming_revision,
)
payload = packet.private_payload()
revision = revision or uuid.uuid4().hex
_handle(revision)
metadata = context_review.validate({"revision": revision, "head": head, "required": policy["required"],
"state": "available", "expires_at": payload["binding"]["expires_at"]})
render_evidence(packet, handle)
with store.locked(name) as locked:
state = _state(locked.read(), name)
if state["state"] != "verified" or state["generation"] != payload["binding"]["generation"]:
saved = locked.read()
if graph_connection.is_graph(saved):
state = graph_connection.saved_state(saved, name)
# The local graph's "authorization changed" is a rebuild: the
# published generation is what a packet binds, so a graph rebuilt
# between preparation and attachment fails the same check a revoked
# organization authorization does. ``consuming_revision`` cannot be
# ``None`` here: a graph connection already refused the load above
# when it was missing.
generation = graph_connection.current_generation(state, root=store.root, revision=consuming_revision)
# Stated here as well as enforced on the load, because this is the
# line an attachment is read off: repository evidence describes one
# commit's code, and the commit this PR is at is the only one it may
# be attached to.
if payload["source_revision"] != head:
raise ContextError("context evidence is not bound to the current pull request head")
else:
state = _state(saved, name)
generation = state["generation"]
if state["state"] != "verified" or generation != payload["binding"]["generation"]:
raise ContextError("context authorization changed before attachment")
index_file, index = _index(locked)
entry = next((item for item in index["entries"] if item["handle"] == handle), None)
Expand Down Expand Up @@ -162,6 +205,16 @@ def mark_published(store, name, revision):


def _remove_attachment(store, name, handle, revision, *, published):
"""Remove one identity-checked binding, idempotent for its own interrupted cleanup.

The index is written without ``revision`` before the artifact is deleted,
so a crash between those two writes leaves an artifact whose index entry
already omits it. Retrying with the exact same ``handle``/``revision``
recognizes that state -- the artifact's own binding still names them --
and finishes deleting the artifact rather than reporting an inconsistent
index. A missing or mismatched identity, or a missing index entry, is
never treated as that same interrupted cleanup and still fails closed.
"""
_handle(handle)
_handle(revision)
with store.locked(name) as locked:
Expand All @@ -175,12 +228,16 @@ def _remove_attachment(store, name, handle, revision, *, published):
index_file.write(index)
return
binding = _binding(saved)
if binding["handle"] != handle or binding["revision"] != revision:
raise ContextError("context attachment index is inconsistent")
if binding["published"] and not published:
raise ContextError("published context attachment cannot be abandoned")
if entry is None or revision not in entry.setdefault("deliveries", []):
if entry is None:
raise ContextError("context attachment index is inconsistent")
entry["deliveries"].remove(revision)
index_file.write(index)
deliveries = entry.setdefault("deliveries", [])
if revision in deliveries:
deliveries.remove(revision)
index_file.write(index)
artifact.delete()


Expand All @@ -194,14 +251,17 @@ def retire_attachment(store, name, handle, revision):
_remove_attachment(store, name, handle, revision, published=True)


def attach(store, name, handle, policy, request: ContextRequest, *, pr, head, publish, backend=None):
def attach(store, name, handle, policy, request: ContextRequest, *, pr, head, publish,
consuming_revision=None, backend=None):
"""Publish a new input revision before any participant can use that binding.

``publish`` is a trusted runtime callback for the selected repository/PR,
never provider code. A failed publication leaves the local binding unusable.
``consuming_revision`` is forwarded to ``reserve_attachment`` unchanged.
"""
metadata = reserve_attachment(
store, name, handle, policy, request, pr=pr, head=head, backend=backend,
store, name, handle, policy, request, pr=pr, head=head,
consuming_revision=consuming_revision, backend=backend,
)
revision = metadata["revision"]
try:
Expand All @@ -222,7 +282,16 @@ class Delivery:
binding: dict = field(repr=False)


def deliver(store, revision, *, repository, pr, head, recipient, current, backend=None):
def deliver(store, revision, *, repository, pr, head, recipient, current, consuming_revision=None, backend=None):
"""Replay one published binding for an approved recipient.

``consuming_revision`` is the caller's actual consuming checkout revision,
or an already-verified immutable review-target head; ``None`` means the
caller cannot name one. It is never defaulted to ``head`` here: a caller
that knows only the trusted remote head, not the local checkout doing the
work, must say so explicitly rather than let a repository-kind connection
be silently authorized against a revision it never held.
"""
binding = read_binding(store, revision)
try:
current = context_review.validate(dict(current))
Expand All @@ -233,7 +302,7 @@ def deliver(store, revision, *, repository, pr, head, recipient, current, backen
raise ContextError("context input is missing, unpublished, or no longer current")
if not context_review.review_matches(context_review.marker(current, review=True), current, head=head):
raise ContextError("context input is unavailable or expired")
packet = _packet_for_binding(store, binding, recipient, backend=backend)
packet = _packet_for_binding(store, binding, recipient, backend=backend, revision=consuming_revision)
return Delivery(dict(current), render_evidence(packet, binding["handle"]), binding)


Expand Down
Loading
Loading