What happens
grapharc run --check-only prints a fingerprint under this comment in grapharc/cli/graphrun.py:285-287:
# Wider than the label column on purpose, and always has been: the
# fingerprint is what a later run is compared against.
style.kv("fingerprint", verdict.fingerprint, width=width, tint=style.accent),
It cannot be compared against a later run, because it changes on every invocation for the same topology file.
$ grapharc run topo.json --check-only --registry grapharc.stdlib:build_registry
fingerprint: 07d76338b4f30f3c
$ grapharc run topo.json --check-only --registry grapharc.stdlib:build_registry
fingerprint: 24b5d07e4112b214
Why
Subgraph.proposal_id defaults to a fresh value, and fingerprint() hashes the whole model:
# grapharc/planner/proposal.py:196
proposal_id: str = Field(default_factory=lambda: uuid.uuid4().hex[:12])
# grapharc/planner/proposal.py:257
return hashlib.sha256(self.model_dump_json().encode("utf-8")).hexdigest()[:16]
Confirmed directly — pin the field and the rest is stable:
from grapharc.planner.proposal import Subgraph
topo = {"nodes": [{"name": "gather", "kind": "collect_context"}],
"edges": [{"source": "__start__", "target": "gather"},
{"source": "gather", "target": "__end__"}]}
a, b = Subgraph.model_validate(topo), Subgraph.model_validate(topo)
assert a.fingerprint() != b.fingerprint() # same topology, different hash
c = Subgraph.model_validate({**topo, "proposal_id": "fixed"})
d = Subgraph.model_validate({**topo, "proposal_id": "fixed"})
assert c.fingerprint() == d.fingerprint() # the id is the only difference
Scope, and what is not broken
The approval path is fine. There the proposal is persisted in plan.json and re-loaded, so proposal_id is stable and the fingerprint identifies the bytes a human was shown — which is what write_decision and _discard_own_stale_decision need, and what the 0.1.6 note means by "an approval binds to the plan's fingerprint, which does not change between runs".
What is broken is the run path, where a Subgraph is built fresh from a topology file each time and the printed value is therefore per-invocation. Anyone comparing two --check-only runs to see whether a topology changed gets a difference every time.
The decision this needs
fingerprint()'s docstring says "identity, not semantic equality... the recorded decision was made about these bytes", and materialize.py:309 notes that overriding it was an attack the design defends against. So narrowing what it hashes is a semantics change with approval-security implications, not a cleanup. Two directions:
- Exclude provenance from the hash —
proposal_id and origin are provenance, not content, and _stamp discards them on arrival anyway. Then the fingerprint follows the topology, run's comment becomes true, and two structurally identical proposals share a fingerprint (which may be correct: the human approved these bytes of graph, not this uuid).
- Leave the hash alone and stop printing it as if it were stable — give
run a topology-only digest for the "did this change" job, and keep fingerprint() for the approval binding.
I lean towards 1 as the smaller and more honest change, but it touches what an approval is bound to, so it should be a maintainer's call.
Side effect
tests/test_cli_style.py normalises this value so run can stay in the byte-for-byte styling comparison (see the _FINGERPRINT comment there, added with #17). Whichever option wins, that normaliser should be deleted and the comparison becomes stricter.
What happens
grapharc run --check-onlyprints a fingerprint under this comment ingrapharc/cli/graphrun.py:285-287:It cannot be compared against a later run, because it changes on every invocation for the same topology file.
Why
Subgraph.proposal_iddefaults to a fresh value, andfingerprint()hashes the whole model:Confirmed directly — pin the field and the rest is stable:
Scope, and what is not broken
The approval path is fine. There the proposal is persisted in
plan.jsonand re-loaded, soproposal_idis stable and the fingerprint identifies the bytes a human was shown — which is whatwrite_decisionand_discard_own_stale_decisionneed, and what the 0.1.6 note means by "an approval binds to the plan's fingerprint, which does not change between runs".What is broken is the
runpath, where aSubgraphis built fresh from a topology file each time and the printed value is therefore per-invocation. Anyone comparing two--check-onlyruns to see whether a topology changed gets a difference every time.The decision this needs
fingerprint()'s docstring says "identity, not semantic equality... the recorded decision was made about these bytes", andmaterialize.py:309notes that overriding it was an attack the design defends against. So narrowing what it hashes is a semantics change with approval-security implications, not a cleanup. Two directions:proposal_idandoriginare provenance, not content, and_stampdiscards them on arrival anyway. Then the fingerprint follows the topology,run's comment becomes true, and two structurally identical proposals share a fingerprint (which may be correct: the human approved these bytes of graph, not this uuid).runa topology-only digest for the "did this change" job, and keepfingerprint()for the approval binding.I lean towards 1 as the smaller and more honest change, but it touches what an approval is bound to, so it should be a maintainer's call.
Side effect
tests/test_cli_style.pynormalises this value soruncan stay in the byte-for-byte styling comparison (see the_FINGERPRINTcomment there, added with #17). Whichever option wins, that normaliser should be deleted and the comparison becomes stricter.