Skip to content

Commit 244f585

Browse files
davet47dt
andauthored
verify: strict provenance mode — refuse, not warn, on inferred contracts (#49) (#87)
Opt-in .hashloom/config.json {"strict_provenance": true}: verify refuses any unit whose dependency closure contains a status: inferred contract — a structured inferred_contract error naming the unvetted contracts, ok false, CLI exit 1. The refusal fires before verify_one, so no tests run and no cache entry is written; a pre-existing green revives when the contract is confirmed (the flip stays free). Reads and writes keep their advisory flags so machine-drafted batches still land and get reviewed. Config-only: resolved inside api.verify, so no contracted signature changed (verify.yaml's invariant reword is hash-free) and no new flags, tools, or commands — the 5/5 surface is unchanged. Co-authored-by: dt <david@e3.com.au>
1 parent e05156c commit 244f585

10 files changed

Lines changed: 143 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77
## [Unreleased]
88

99
### Added
10+
- Strict provenance mode (#49): opt-in `.hashloom/config.json`
11+
`{"strict_provenance": true}`. `verify` refuses any unit whose dependency
12+
closure contains a `status: inferred` contract — a structured
13+
`inferred_contract` error per unit (`ok: false`, CLI exit 1), with the
14+
unvetted names listed. The refusal runs no tests and writes no cache
15+
entry, so a pre-existing green revives when the contract is confirmed
16+
(the flip stays free). Reads (`get_contract`, `get_dependents`, `status`)
17+
and writes keep their advisory flags so machine-drafted batches can still
18+
land and be reviewed. Config-only: no new flags, tools, or commands.
1019
- `integrations/speckit-hashloom/` (#75): a [GitHub
1120
spec-kit](https://github.com/github/spec-kit) extension — the greenfield
1221
counterpart to the graphify importer. `/speckit.hashloom.seams` drafts

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ A contract belongs on a stable seam: an interface other units depend on and that
100100

101101
Contracts are reviewed artifacts. Authoring one is cheap and getting cheaper, so the real cost is reviewing it, not writing it. A wrong contract is worse than no contract, because the durable artifact now lies: agents will regenerate code to satisfy a spec that is itself incorrect. Review a contract the way you review an interface, not the way you skim generated code.
102102

103-
A contract an agent reverse-engineers from existing code can declare that it hasn't earned that review yet: `status: inferred`. Tools then flag — never refuse — any blast-radius or verification answer that rests on it (`inferred: true` on dependents, an `inferred` list on verify results, a review queue in `status`). Absent means `confirmed`, and confirming an inferred contract after review is free: status is provenance, not meaning, so the flip invalidates nothing.
103+
A contract an agent reverse-engineers from existing code can declare that it hasn't earned that review yet: `status: inferred`. Tools then flag — by default, never refuse — any blast-radius or verification answer that rests on it (`inferred: true` on dependents, an `inferred` list on verify results, a review queue in `status`). Teams that want unvetted contracts to hard-fail can opt in to strict provenance mode (`.hashloom/config.json` → `{"strict_provenance": true}`): `verify` then refuses such units with a structured `inferred_contract` error — no tests run, no verdict cached — until they're reviewed; reads and writes stay advisory so drafts can still land and be inspected. Absent means `confirmed`, and confirming an inferred contract after review is free: status is provenance, not meaning, so the flip invalidates nothing (under strict mode, a pre-existing green simply revives on confirm).
104104

105105
### Hashing semantics
106106

@@ -115,7 +115,7 @@ A contract an agent reverse-engineers from existing code can declare that it has
115115
| `get_contract` | the ~300-token context packet: contract + hash + one-line dep signatures + caller list |
116116
| `put_contract` | validate, write `contracts/<name>.yaml`, return new hash, a semantic diff of what changed, and every invalidated dependent |
117117
| `get_dependents` | blast-radius query, direct or transitive, names + hashes; inferred (unreviewed) contracts flagged |
118-
| `verify` | per-unit `cached-pass` / `pass` / `fail` plus a top-level `ok` gate bit; `radius=true` widens each name to its full blast radius; runs tests only on cache misses; failures come back as a ≤40-token assertion summary, never a traceback; inferred contracts in the closure flagged |
118+
| `verify` | per-unit `cached-pass` / `pass` / `fail` plus a top-level `ok` gate bit; `radius=true` widens each name to its full blast radius; runs tests only on cache misses; failures come back as a ≤40-token assertion summary, never a traceback; inferred contracts in the closure flagged (refused under strict provenance mode) |
119119
| `status` | dirty contracts, stale verifications, cache hit-rate, resolved verify interpreter, cumulative token counters |
120120

121121
Every tool returns structured errors — `{"error": {"code": "unknown_dep", "message": "'Regoin' not found — nearest: 'Region'"}}` — never a stack trace.
@@ -129,10 +129,11 @@ globally-installed hashloom can verify a project against its own virtualenv with
129129
being installed into it; `hashloom status` shows which interpreter it resolved.
130130

131131
`.hashloom/config.json` also takes `verify_timeout` (seconds per pytest run,
132-
default 300) for suites that need longer than the default, and `pycache_trust`
132+
default 300) for suites that need longer than the default, `pycache_trust`
133133
(default `true`); set `pycache_trust: false` — or pass `--no-pycache-trust` — to
134134
clear the project's `__pycache__` before each verify run, so a stale `.pyc` can
135-
never shadow the current source.
135+
never shadow the current source, and `strict_provenance` (default `false`) to
136+
make `verify` refuse units whose closure contains inferred contracts.
136137

137138
### Beyond Python
138139

ROADMAP.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,10 @@ remaining hard parts from [docs/hosted-store.md](docs/hosted-store.md):
6767
honestly; shrinking it (flakiness detection, an optional re-verify TTL, or
6868
marking tests untrusted-for-caching) is open design work, not yet scheduled.
6969
- **Strict provenance mode** ([#49](https://github.com/davet47/hashloom/issues/49))
70-
— an opt-in config that upgrades inferred-contract warnings to structured
71-
refusals, for teams that want unvetted contracts to hard-fail. Deferred by
72-
design; layerable with no schema change.
70+
— ✓ **Shipped** (unreleased): `.hashloom/config.json` `{"strict_provenance":
71+
true}` upgrades verify's inferred-contract warnings to structured refusals
72+
(`inferred_contract`); reads and writes stay advisory. Landed with no schema
73+
change, as designed.
7374

7475
## Bigger bets
7576

contracts/verify.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ invariants:
77
- the top-level ok is the gate — true iff every unit is green; errors and unknown names block
88
- radius widens each name to itself plus every transitive dependent, spec-only units dropped, typos kept
99
- failure summaries stay within the token budget, never a traceback
10-
- inferred contracts anywhere in a unit's closure are named in its result, advisory only
10+
- inferred contracts anywhere in a unit's closure are named in its resultadvisory by default; with strict_provenance set, the unit refuses with inferred_contract instead of verifying, running no tests and writing no cache entry
1111
tests:
1212
- tests/test_verify.py::test_first_run_passes_then_caches
1313
- tests/test_verify.py::test_failures_are_not_served_from_cache
1414
- tests/test_verify.py::test_ok_bit_is_the_gate
1515
- tests/test_verify.py::test_radius_verifies_the_blast_radius_in_one_call
1616
- tests/test_verify.py::test_verify_flags_inferred_closure_even_on_cached_pass
1717
- tests/test_verify.py::test_confirming_inferred_contract_keeps_cached_green
18+
- tests/test_verify.py::test_strict_provenance_refuses_inferred_unit
19+
- tests/test_verify.py::test_strict_refusal_preserves_cached_green
1820
impl: src/hashloom/api.py::verify
1921
status: confirmed

docs/getting-started.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,11 @@ One call verifies the unit plus everything it invalidates, serves cached greens
100100
for whatever nothing busted, runs tests for the rest, and returns a single
101101
`ok: true|false` (the CLI exits nonzero on false — the same gate works in CI or
102102
a pre-commit hook). Until an inferred contract is confirmed, every verify and
103-
blast-radius answer that rests on it carries an `inferred` flag: advisory,
104-
never an error, but visible.
103+
blast-radius answer that rests on it carries an `inferred` flag: advisory by
104+
default, but visible. (Teams that want unvetted contracts to hard-fail can set
105+
`{"strict_provenance": true}` in `.hashloom/config.json` — `verify` then
106+
refuses such units with a structured `inferred_contract` error until they're
107+
reviewed; see the README.)
105108

106109
**Contract changes announce their blast radius.** When you later change a
107110
confirmed contract — a signature, an example — the `put_contract` response

src/hashloom/api.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import yaml
1212

13-
from .config import resolve_python
13+
from .config import resolve_python, resolve_strict_provenance
1414
from .contract import contract_hash, diff_contracts, parse_contract
1515
from .errors import HashloomError, unknown_name
1616
from .langs import adapter_for
@@ -175,16 +175,30 @@ def verify(
175175
names = _radius(store, names)
176176
if not pycache_trust:
177177
clear_pycache(root) # once per batch, before any pytest run
178+
strict = resolve_strict_provenance(root)
178179
results = []
179180
for name in names:
180181
try:
182+
# computed here, not in verify_one: outside the cache key, so the flag
183+
# reflects *current* status even when the verdict is a cached-pass
184+
inferred = []
185+
if store.get_contract(name) is not None: # unknown names keep erroring as unknown_contract
186+
inferred = [n for n in (name, *store.transitive_deps(name)) if _is_inferred(store, n)]
187+
if strict and inferred:
188+
# refuse before verify_one: no pytest runs, no cache entry is
189+
# written, and any existing green survives for after the confirm
190+
e = HashloomError(
191+
"inferred_contract",
192+
"strict provenance: verification rests on unvetted contracts: "
193+
f"{', '.join(inferred)} — review and confirm them (`hashloom status` lists the queue)",
194+
contract=name,
195+
)
196+
results.append({"name": name, "status": "error", "inferred": inferred, **e.to_dict()})
197+
continue
181198
r = verify_one(root, store, name, python=python, timeout=timeout)
182199
r.pop("key") # internal cache key — pure token weight to an agent
183200
if not r["summary"]:
184201
r.pop("summary")
185-
# computed here, not in verify_one: outside the cache key, so the flag
186-
# reflects *current* status even when the verdict is a cached-pass
187-
inferred = [n for n in (name, *store.transitive_deps(name)) if _is_inferred(store, n)]
188202
if inferred:
189203
r["inferred"] = inferred
190204
results.append(r)

src/hashloom/config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,17 @@ def resolve_pycache_trust(root: Path, override: bool | None = None) -> bool:
115115
return value
116116

117117

118+
def resolve_strict_provenance(root: Path) -> bool:
119+
"""Whether verify refuses units whose closure contains inferred contracts
120+
(default False — inferred stays advisory unless a team opts in)."""
121+
value = load_config(root).get("strict_provenance")
122+
if value is None:
123+
return False
124+
if not isinstance(value, bool):
125+
raise HashloomError("bad_config", f"strict_provenance must be true or false, got {value!r}")
126+
return value
127+
128+
118129
def resolve_shared_store(root: Path) -> dict | None:
119130
"""Config for a shared/remote verification cache, or None if not configured.
120131

src/hashloom/server.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ def verify(names: list[str], radius: bool = False) -> dict:
7070
already green in the cache. `radius=true` widens each name to its full
7171
blast radius (itself plus every transitive dependent); the top-level
7272
`ok` is the hard pass/fail to gate on. An `inferred` list names any
73-
unconfirmed contracts a verdict rests on."""
73+
unconfirmed contracts a verdict rests on; with `strict_provenance`
74+
set in .hashloom/config.json such units refuse (`inferred_contract`)
75+
instead of verifying."""
7476
return _respond(
7577
"verify",
7678
lambda: api.verify(

tests/test_cli.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,21 @@ def test_verify_cli_outside_project_errors(tmp_path, monkeypatch, capsys):
7474
assert err["error"]["code"] == "no_project"
7575

7676

77+
def test_verify_cli_strict_provenance_refuses_with_exit_1(project, monkeypatch, capsys):
78+
root, store = project
79+
text = (root / "contracts" / "total.yaml").read_text()
80+
(root / "contracts" / "total.yaml").write_text(text + "status: inferred\n")
81+
from hashloom.indexer import index
82+
83+
index(root, store)
84+
(root / ".hashloom" / "config.json").write_text('{"strict_provenance": true}')
85+
monkeypatch.chdir(root)
86+
rc = main(["verify", "total"])
87+
out = json.loads(capsys.readouterr().out)
88+
assert rc == 1
89+
assert out["results"][0]["error"]["code"] == "inferred_contract"
90+
91+
7792
def test_init_cli_creates_then_reports_already_initialised(tmp_path, monkeypatch, capsys):
7893
monkeypatch.chdir(tmp_path)
7994
assert main(["init"]) == 0

tests/test_verify.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22
changes anywhere in the dep closure bust the cache; failure summaries stay
33
within the token budget."""
44

5+
import json
6+
7+
import pytest
8+
59
from hashloom import api, tokens
10+
from hashloom.config import config_path, resolve_strict_provenance
11+
from hashloom.errors import HashloomError
612
from hashloom.indexer import index
713
from hashloom.verify import SUMMARY_MAX_TOKENS
814

@@ -202,6 +208,70 @@ def test_confirming_inferred_contract_keeps_cached_green(project):
202208
assert all("inferred" not in r for r in out["results"])
203209

204210

211+
def _strict_on(root) -> None:
212+
config_path(root).write_text(json.dumps({"strict_provenance": True}), encoding="utf-8")
213+
214+
215+
def test_strict_provenance_refuses_inferred_unit(project):
216+
root, store = project
217+
text = (root / "contracts" / "total.yaml").read_text()
218+
api.put_contract(root, store, "total", text + "status: inferred\n")
219+
_strict_on(root)
220+
out = api.verify(root, store, ["total"])
221+
assert out["ok"] is False
222+
r = out["results"][0]
223+
assert r["status"] == "error"
224+
assert r["error"]["code"] == "inferred_contract"
225+
assert "total" in r["error"]["message"]
226+
assert r["inferred"] == ["total"]
227+
# refused before verify_one: no pytest ran, no verdict was cached
228+
assert store.counters().get("test_runs", 0) == 0
229+
230+
231+
def test_strict_provenance_refuses_dependent_of_inferred(project):
232+
root, store = project
233+
text = (root / "contracts" / "total.yaml").read_text()
234+
api.put_contract(root, store, "total", text + "status: inferred\n")
235+
_strict_on(root)
236+
# report is confirmed, but its closure rests on the unvetted total
237+
out = api.verify(root, store, ["report"])
238+
assert out["ok"] is False
239+
assert out["results"][0]["error"]["code"] == "inferred_contract"
240+
assert out["results"][0]["inferred"] == ["total"]
241+
242+
243+
def test_strict_refusal_preserves_cached_green(project):
244+
root, store = project
245+
assert statuses(api.verify(root, store, ["total"])) == {"total": "pass"}
246+
runs_before = store.counters()["test_runs"]
247+
text = (root / "contracts" / "total.yaml").read_text()
248+
api.put_contract(root, store, "total", text + "status: inferred\n")
249+
_strict_on(root)
250+
assert api.verify(root, store, ["total"])["ok"] is False # refused, not failed
251+
# the review flip: the refusal wrote nothing, so the old green revives
252+
api.put_contract(root, store, "total", text)
253+
out = api.verify(root, store, ["total"])
254+
assert statuses(out) == {"total": "cached-pass"}
255+
assert store.counters()["test_runs"] == runs_before
256+
257+
258+
def test_strict_unknown_name_still_errors_as_unknown(project):
259+
root, store = project
260+
_strict_on(root)
261+
out = api.verify(root, store, ["nope"])
262+
assert out["ok"] is False
263+
assert out["results"][0]["error"]["code"] == "unknown_contract"
264+
265+
266+
def test_strict_provenance_resolver_defaults_off_and_rejects_bad_values(project):
267+
root, _ = project
268+
assert resolve_strict_provenance(root) is False # no key -> advisory as before
269+
config_path(root).write_text(json.dumps({"strict_provenance": "yes"}), encoding="utf-8")
270+
with pytest.raises(HashloomError) as exc:
271+
resolve_strict_provenance(root)
272+
assert exc.value.code == "bad_config"
273+
274+
205275
def test_status_reports_dirty_and_hit_rate(project):
206276
root, store = project
207277
s = api.status(root, store)

0 commit comments

Comments
 (0)