Skip to content

cli: run --check-only prints a fingerprint that changes every invocation for the same topology #125

Description

@Shashankss1205

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:

  1. 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).
  2. 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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions