From 7ad6aef48dfaf462fe8e5e3642fbc46d3804ccea Mon Sep 17 00:00:00 2001 From: Vishnu Vettrivel <23726095+cloudronin@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:23:35 -0700 Subject: [PATCH] fix(verify): a package cannot state a fork its vocabulary lacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported live. `uofa verify packs/vv40/examples/morrison/cou1/...` printed decision 1: provenance '' — the fork says which warrant is owed, so this record cannot be checked at all. beside two PASSING signatures. The package declares context v0.5; `decisionProvenance` arrived in v0.9. The term is not in its vocabulary, so the message described this checker's expectation rather than the package's condition -- and read to anyone running it as though the package were broken. The same shape `protocol_check` already fixed for the run-log pins, one file over: refusing a package for lacking a field that did not exist when it was written punishes age rather than negligence. `sign_roles` gains `PROVENANCE_INTRODUCED = (0, 9)` and `predates_provenance`. An inlined context declares no version and is NOT treated as old -- silence is not a claim of age, and such a document is usually signed, so the term was available when it was made. **Reading and signing split, deliberately.** `verify` reads an existing artifact and now advises. `sign_package_scoped` makes a NEW claim today and still REFUSES -- narrowing the check, never weakening it -- but names age as the cause and says the fix is re-importing under a current context, rather than leaving the caller hunting for a field to fill. `unclassified_records` is unchanged: it still reports the fact for every package. Only the callers decide whether that fact is a refusal or a note. Test seen red by disabling the branch. Its first draft built a fixture keyed `decision` where the real block key is `hasDecisionRecord`, so the checker found nothing and the test went green on the exact case it exists to catch; the fixture now reads DECISION_BLOCK_KEY and the reason is recorded in it. 3432 passed, 14 skipped. --- src/uofa_cli/commands/verify.py | 12 +++ src/uofa_cli/package_policy.py | 16 +++ src/uofa_cli/sign_roles.py | 38 +++++++ tests/test_provenance_has_a_jurisdiction.py | 112 ++++++++++++++++++++ 4 files changed, 178 insertions(+) create mode 100644 tests/test_provenance_has_a_jurisdiction.py diff --git a/src/uofa_cli/commands/verify.py b/src/uofa_cli/commands/verify.py index 7e657969..da2b43cf 100644 --- a/src/uofa_cli/commands/verify.py +++ b/src/uofa_cli/commands/verify.py @@ -265,6 +265,18 @@ def _report_record(doc, rec, i, keys, signer_ids, doc_path, failures) -> None: prefix = f" decision {i}" if fork not in sign_roles.FORKS: + # **Advised, not refused, when the package predates the term.** A + # package whose own @context is older than `decisionProvenance` cannot + # state a fork: the word is not in its vocabulary. Reporting that as + # "cannot be checked at all" describes this checker's expectation + # rather than the package's condition, and reads to anyone running it + # as though the package were broken. It is not -- the signatures above + # it verify. + if sign_roles.predates_provenance(doc): + v = ".".join(str(x) for x in sign_roles.context_version(doc)) + info(f"{prefix}: no provenance fork — this package declares context " + f"v{v}, which predates the term; advised, not refused.") + return warn(f"{prefix}: provenance {fork or ''!r} — the fork says which " f"warrant is owed, so this record cannot be checked at all.") return diff --git a/src/uofa_cli/package_policy.py b/src/uofa_cli/package_policy.py index 02880b98..524896f6 100644 --- a/src/uofa_cli/package_policy.py +++ b/src/uofa_cli/package_policy.py @@ -194,6 +194,22 @@ def sign_package_scoped( unclassified = sign_roles.unclassified_records(doc) if unclassified: + # **Still refused, and deliberately.** `verify` reads an existing + # artifact and so advises a package older than the term; SIGNING makes + # a new claim today and is held to today's bar. But the refusal must + # name the real cause: a v0.5 package is not negligent, it is old, and + # the fix is re-importing under a current context rather than hunting + # for a field to fill. + if sign_roles.predates_provenance(doc): + v = ".".join(str(x) for x in sign_roles.context_version(doc)) + raise PackagePolicyError( + f"this package declares context v{v}, which predates " + f"`decisionProvenance`, so its {len(unclassified)} decision " + f"record(s) cannot state which warrant they owe. Signing makes " + f"a claim today and needs the current vocabulary: re-import " + f"under a context of v" + f"{'.'.join(str(x) for x in sign_roles.PROVENANCE_INTRODUCED)} " + f"or later, then sign. `uofa verify` reads it as-is.") raise PackagePolicyError( f"this package carries {len(unclassified)} decision record(s) whose " f"provenance is unstated. The fork says which warrant is owed, so " diff --git a/src/uofa_cli/sign_roles.py b/src/uofa_cli/sign_roles.py index a15f38f2..0c17df6b 100644 --- a/src/uofa_cli/sign_roles.py +++ b/src/uofa_cli/sign_roles.py @@ -126,6 +126,44 @@ def unsigned_asserted(doc: dict) -> list[dict]: EXTRACTED = "extracted" FORKS = (ASSERTED, EXTRACTED) +#: The context version that introduced `decisionProvenance`. A package whose +#: own `@context` predates this CANNOT state a fork -- the term is not in its +#: vocabulary -- so reporting it as unclassifiable describes the checker's +#: expectation rather than the package's condition. +#: +#: Reported live on `packs/vv40/examples/morrison/cou1/`, which declares v0.5 +#: and drew "provenance '' -- this record cannot be checked at all" +#: beside two passing signatures. The same shape `protocol_check` already +#: fixed for the run-log pins: refusing a package for lacking a field that did +#: not exist when it was written punishes age rather than negligence. +PROVENANCE_INTRODUCED = (0, 9) + + +def context_version(doc: dict) -> tuple[int, ...]: + """The version the PACKAGE declares, from its own `@context`. + + An inlined context -- a resolved or signed document -- declares no version + and returns (), which sorts below everything and takes the advisory path + rather than being guessed at. + """ + ref = doc.get("@context") + if not isinstance(ref, str): + return () + tail = ref.rsplit("/", 1)[-1] + return tuple(int(x) for x in tail.lstrip("vV").removesuffix(".jsonld").split(".") + if x.isdigit()) + + +def predates_provenance(doc: dict) -> bool: + """Can this package state a fork at all? + + False for a document that declares no context version: silence is not a + claim of age, and an inlined context is usually a SIGNED package, which had + the term available when it was made. + """ + v = context_version(doc) + return bool(v) and v < PROVENANCE_INTRODUCED + def unclassified_records(doc: dict) -> list[dict]: """Records whose fork is absent or unrecognised. diff --git a/tests/test_provenance_has_a_jurisdiction.py b/tests/test_provenance_has_a_jurisdiction.py new file mode 100644 index 00000000..3185abe7 --- /dev/null +++ b/tests/test_provenance_has_a_jurisdiction.py @@ -0,0 +1,112 @@ +"""A package cannot state a fork its vocabulary lacks. + +Reported live: `uofa verify packs/vv40/examples/morrison/cou1/...` printed + + decision 1: provenance '' — the fork says which warrant is owed, + so this record cannot be checked at all. + +beside two PASSING signatures. The package declares context **v0.5**, and +`decisionProvenance` arrived in **v0.9** — the term is not in its vocabulary, so +the message described this checker's expectation rather than the package's +condition, and read to anyone running it as though the package were broken. + +The same shape `protocol_check` already fixed for the run-log pins. Refusing a +package for lacking a field that did not exist when it was written punishes age +rather than negligence. + +**The split is deliberate.** `verify` READS an existing artifact and advises. +`sign_package_scoped` makes a NEW claim today and still refuses — but names age +as the cause, so the fix is re-importing rather than hunting for a field. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from uofa_cli import package_policy, sign_roles + +MORRISON = Path("packs/vv40/examples/morrison/cou1/uofa-morrison-cou1.jsonld") + + +def _forkless_at(version_ref: str) -> dict: + """A package with one decision record that states no fork. + + Built with the real block key rather than a guessed one -- the first draft + used "decision" and `decision_records` found nothing, so the test passed + for the wrong reason on the case it exists to catch. + """ + from uofa_cli.package_policy import DECISION_BLOCK_KEY + + return {"@context": version_ref, + DECISION_BLOCK_KEY: [{"id": "d1", "outcome": "Accepted"}]} + + +# ── the era rule ──────────────────────────────────────────────────────────── + +def test_the_shipped_example_predates_the_term(): + """The real package that reported this, read from the repo.""" + doc = json.loads(MORRISON.read_text(encoding="utf-8")) + assert sign_roles.context_version(doc) == (0, 5) + assert sign_roles.predates_provenance(doc) + + +def test_a_current_package_does_not_predate_it(): + assert not sign_roles.predates_provenance( + _forkless_at("x/v0.9.jsonld")) + + +def test_an_inlined_context_is_not_treated_as_old(): + """A resolved or signed document inlines its context and declares no + version. Silence is not a claim of age — and such a document is usually + SIGNED, so the term was available when it was made.""" + assert not sign_roles.predates_provenance({"@context": {"a": "b"}}) + assert not sign_roles.predates_provenance({}) + + +@pytest.mark.parametrize("ref,old", [ + ("x/v0.5.jsonld", True), ("x/v0.8.jsonld", True), + ("x/v0.9.jsonld", False), ("x/v0.10.jsonld", False), +]) +def test_the_boundary_is_the_version_that_introduced_the_term(ref, old): + assert sign_roles.predates_provenance(_forkless_at(ref)) is old + + +# ── the check still has teeth where it should ─────────────────────────────── + +def test_a_current_package_with_no_fork_is_still_unclassified(): + """The whole point of the era rule is that it narrows, not weakens.""" + assert len(sign_roles.unclassified_records( + _forkless_at("x/v0.9.jsonld"))) == 1 + + +def test_the_old_package_is_still_unclassified_too(): + """`unclassified_records` is unchanged: it reports the fact. Only the + CALLERS decide whether that fact is a refusal or a note.""" + doc = json.loads(MORRISON.read_text(encoding="utf-8")) + assert len(sign_roles.unclassified_records(doc)) == 1 + + +# ── verify advises; signing refuses ───────────────────────────────────────── + +def test_verify_advises_rather_than_warns_on_an_old_package(capsys): + """The reported symptom, as a test.""" + from uofa_cli.commands import verify as V + + doc = json.loads(MORRISON.read_text(encoding="utf-8")) + V._report_record(doc, doc["decision"][0] if isinstance(doc.get("decision"), list) + else sign_roles.decision_records(doc)[0], + 1, [], set(), MORRISON, []) + out = capsys.readouterr().out + assert "predates the term" in out and "advised, not refused" in out + assert "cannot be checked at all" not in out + + +def test_signing_still_refuses_but_names_age_as_the_cause(): + """Signing makes a claim TODAY. The refusal stays; the message improves.""" + with pytest.raises(package_policy.PackagePolicyError) as e: + package_policy.sign_package_scoped(MORRISON, issuer_key_bytes=b"x") + msg = str(e.value) + assert "predates" in msg and "v0.5" in msg + assert "re-import" in msg, "the refusal does not say how to fix it"