From aa86aa279dec7081871b5cb8b3d4a05aefc33b21 Mon Sep 17 00:00:00 2001 From: ahrazzle <38987266+ahrazzle@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:15:10 -0500 Subject: [PATCH 1/2] Re-pin six drifted ingredient pins; harden check-lock --verify Fixes #11: the ingredient repos' published histories were rewritten and their release tags re-pointed after the pins were taken, so cold-cache installs failed with an integrity error while warmed machines passed. New shas read from the live remote tags; content-preserving, so treeSha256/fileCount are unchanged. check-lock.py --verify now reports per-pin provenance (remote vs warm cache), re-resolves every tag against the remote, and fails closed when the published tag has moved. --- CHANGELOG.md | 7 +++++++ build/check-lock.py | 47 ++++++++++++++++++++++++++++++++++++++++++--- kit.lock.json | 14 +++++++------- 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2e7aaf..60fc4a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,13 @@ outside this repository. ## Unreleased +- **What:** re-pin the six drifted ingredient pins in `kit.lock.json` from their live remote tags (fixes #11). The ingredient repositories' published histories were rewritten and their release tags re-pointed after the pins were taken, so every fresh install failed with an integrity error (exit 4) while warmed machines kept passing. New peeled commit shas, read from the remote rather than from a local cache: `protean-doctrine` v1.1.0 -> `838f71ea00139bc79643ee5625523733bdf0652d`, `protean-ops` v1.1.0 -> `f278db48e8e58f3107df8fd1391c4a145e5ffea0`, `protean-drafts` v1.0.0 -> `c091210c296bad9366cb6e4bb7ebf531d6bc40e3`, `protean-github-flow` v1.0.0 -> `4995e4dfad21744440f485554c5db52c1c9cac14`, `protean-sym2p` v1.0.0 -> `43334b2fa756b11481a7c021931870864f95ea71`, `protean-control-plane` v1.1.0 -> `6c7e8f8e7b13c882a37e2cd0847ecdc17dc489cb`. The rewrite was content-preserving: `treeSha256`, `fileCount`, contract line, and `installTargets` are unchanged for all six, so only the `sha` fields move. `protean-handoff` v1.0.0 and `protean-context` v0.1.0 were already current and are untouched. `generatedAt` moves to the re-pin date. +- **Why:** a cold-cache `bash install.sh --all` could not install the kit at all; the lock named commits that no published ref reaches. +- **Also in this change:** `build/check-lock.py --verify` now reports each pin's verification provenance (`remote`, `cache`, or `ingredients-dir`) and re-resolves every tag against the remote even when a warm cache entry sits at the pinned sha, failing closed when the published tag has moved. `--verify --offline` keeps passing on warm caches but says plainly that the remote tag pairing was not re-resolved, so a local cache can never present itself as a published-release verification. +- **Evidence class:** [VERIFIED — internal operating record]; the new shas were read from the remotes and their `protean-tree-v1` hashes recomputed against freshly fetched trees. +- **Verification:** `python3 build/check-lock.py kit.lock.json --verify` passes (all eight pins at provenance `remote`); a fixture reproducing the issue's warm-cache scenario now fails closed where the pre-fix code passed; `python3 build/verify-all.py`, `tests.test_kit_lock`, and `tests.test_contribution_mode` all pass. +- **Open item:** the cold-cache acceptance install now clears every integrity check but stops at exit 5 inside the `protean-ops` ingredient's own `op-contrib-state` gate: its example `CONTRIB-STATE.md` carries a grant that expired 2026-09-24. That is the ingredient's content, not the composer's; it needs a `protean-ops` release and a follow-up re-pin, outside this change. The installer's closing README line (noted in #11) is likewise untouched as adjacent, out-of-scope observation. + - **What:** add an install-time contribution-mode toggle to the composer. The installer takes `--contribution-mode off|on` (default `off`) and, when switching on, a required `--operator-ref `. After a successful install it writes the contribution-state diff --git a/build/check-lock.py b/build/check-lock.py index 6225c00..cc8ee3d 100644 --- a/build/check-lock.py +++ b/build/check-lock.py @@ -21,8 +21,11 @@ plus parity against ingredient trees that are already present in the cache or an ingredients directory. --verify requires every pin's tree to be present (fetching it from - the pinned tag when needed) and recomputes the sha pairing - and treeSha256 for each one; a mismatch is exit 4. + the pinned tag when needed), re-resolves every tag against + the remote, and recomputes the sha pairing and treeSha256 + for each one. Each pin's verification provenance (remote + tag pairing or local tree only) is reported; a moved tag + or a hash mismatch is a violation. Usage: check-lock.py [LOCK] [--cache DIR] [--ingredients-dir DIR] [--verify] [--offline] @@ -107,6 +110,22 @@ def descriptor_of(pin, cache, ingredients_dir): return None, None +def remote_tag_sha(repo, ref): + """Resolve a tag ref against the remote. Returns the peeled commit sha, + or None when the ref cannot be resolved.""" + result = run(["git", "ls-remote", repo, ref]) + if result.returncode != 0: + return None + tag_sha = None + for line in result.stdout.splitlines(): + sha, _, name = line.partition("\t") + if name == ref + "^{": + return sha + if name == ref: + tag_sha = sha + return tag_sha + + def main(): parser = argparse.ArgumentParser( prog="check-lock.py", @@ -216,6 +235,7 @@ def main(): base = xdg if xdg else os.path.join(os.path.expanduser("~"), ".cache") cache = os.path.join(base, "protean-kit") + provenance = {} for slug in sorted(seen): pin = seen[slug] tree = None @@ -223,8 +243,10 @@ def main(): candidate = os.path.join(args.ingredients_dir, slug) if os.path.isdir(os.path.join(candidate, ".git")): tree = candidate + provenance[slug] = "ingredients-dir" if tree is None and os.path.isdir(os.path.join(cache, slug, pin["sha"], ".git")): tree = os.path.join(cache, slug, pin["sha"]) + provenance[slug] = "cache" if tree is None: if args.verify: if args.offline: @@ -243,11 +265,24 @@ def main(): continue run(["git", "checkout", "--quiet", "--detach", pin["sha"]], cwd=work) tree = work - unchecked.append(slug) + provenance[slug] = "remote" else: unchecked.append(slug) if tree is None: continue + if args.verify and not args.offline and provenance.get(slug) != "remote": + # A warm cache or an ingredients-dir checkout can sit at the pinned + # sha while the published tag has moved on. --verify must never + # present a local cache entry as a published-release verification, + # so the tag pairing is re-resolved against the remote. + remote = remote_tag_sha(pin["repo"], pin["ref"]) + if remote is None: + violations.append("%s: cannot resolve %s against the remote" % (slug, pin["ref"])) + elif remote != pin["sha"]: + violations.append("%s: %s resolves on the remote to %s, not the pinned %s" + % (slug, pin["ref"], remote, pin["sha"])) + else: + provenance[slug] = "remote" head = run(["git", "rev-parse", "HEAD"], cwd=tree) if head.returncode == 0 and head.stdout.strip() not in (pin["sha"], ""): violations.append("%s: tree is at %s, not the pinned %s" @@ -272,6 +307,12 @@ def main(): print("note: " + note) if unchecked: print("unverified (no tree available; run --verify): %s" % ", ".join(sorted(set(unchecked)))) + if args.verify: + for slug in sorted(seen): + print("verify: %s [provenance: %s]" % (slug, provenance.get(slug, "no tree"))) + if args.offline: + print("note: --verify --offline checked pins against local trees only; " + "the remote tag pairing was not re-resolved") if violations: print("LOCK VIOLATION: %d" % len(violations)) for v in violations: diff --git a/kit.lock.json b/kit.lock.json index a50de85..64345bd 100644 --- a/kit.lock.json +++ b/kit.lock.json @@ -5,7 +5,7 @@ "version": "2.0.0", "tag": "v2.0.0" }, - "generatedAt": "2026-09-17T00:00:00Z", + "generatedAt": "2026-10-08T14:15:00Z", "allowedHosts": [ "github.com" ], @@ -27,7 +27,7 @@ "slug": "protean-doctrine", "repo": "https://github.com/aska-digital/protean-doctrine", "ref": "refs/tags/v1.1.0", - "sha": "f72d6da9be8bdf11dfa300ace7cbd47e2453fa79", + "sha": "838f71ea00139bc79643ee5625523733bdf0652d", "version": "1.1.0", "contract": "The operating doctrine: the default pipeline, role delegation map, handoff protocol, QA gates, and external-writing discipline.", "kind": "skills", @@ -63,7 +63,7 @@ "slug": "protean-ops", "repo": "https://github.com/aska-digital/protean-ops", "ref": "refs/tags/v1.1.0", - "sha": "0b78ff77a2ba32c1b0d1c6464a18ad36053ed303", + "sha": "f278db48e8e58f3107df8fd1391c4a145e5ffea0", "version": "1.1.0", "contract": "The dispatch/rotation/inflight ops kit: append-only record schemas and the deterministic gates that check them.", "kind": "records", @@ -129,7 +129,7 @@ "slug": "protean-drafts", "repo": "https://github.com/aska-digital/protean-drafts", "ref": "refs/tags/v1.0.0", - "sha": "f09703b05a5491074f870fe6fdc15f81baa71d0e", + "sha": "c091210c296bad9366cb6e4bb7ebf531d6bc40e3", "version": "1.0.0", "contract": "The draft-review pipeline: render a draft as a self-contained dark HTML review page through one command that runs the prose, identifier, quote-integrity, and render-fidelity gates.", "kind": "skills", @@ -174,7 +174,7 @@ "slug": "protean-github-flow", "repo": "https://github.com/aska-digital/protean-github-flow", "ref": "refs/tags/v1.0.0", - "sha": "e79f1685b0d429676fd5e4a6273da35d334a9b60", + "sha": "4995e4dfad21744440f485554c5db52c1c9cac14", "version": "1.0.0", "contract": "The GitHub workflow skill pack: issue triage, issue-to-PR, PR lifecycle, PR audit, and upstream-contribution procedures.", "kind": "skills", @@ -216,7 +216,7 @@ "slug": "protean-sym2p", "repo": "https://github.com/aska-digital/protean-sym2p", "ref": "refs/tags/v1.0.0", - "sha": "efeec42a77c04bf4d3b40dbc780e07885f16f02f", + "sha": "43334b2fa756b11481a7c021931870864f95ea71", "version": "1.0.0", "contract": "SYM-2P: a compact versioned agentic message language - one canonical JSON packet per line over durable text artifacts - with validator, enums, and fixtures.", "kind": "skills", @@ -268,7 +268,7 @@ "slug": "protean-control-plane", "repo": "https://github.com/aska-digital/protean-control-plane", "ref": "refs/tags/v1.1.0", - "sha": "ca71a6686710e713a3dc8f2fad666b892c300d37", + "sha": "6c7e8f8e7b13c882a37e2cd0847ecdc17dc489cb", "version": "1.1.0", "contract": "The control-plane skill pack: one canonical procedure home that routes a request to the minimum skill bundle, with the gate table and failure policy.", "kind": "skills", From 3e5c657cfaca1f7f5483b32eb0713152045c9c48 Mon Sep 17 00:00:00 2001 From: Mozi Date: Thu, 8 Oct 2026 09:48:38 -0500 Subject: [PATCH 2/2] check-lock: resolve peeled commit for annotated tags in --verify remote_tag_sha requested only the tag ref pattern from ls-remote, which omits the peeled ^{} line for annotated tags and returned the tag-object sha, causing false LOCK VIOLATIONs on the warm-cache re-resolution branch. Request refs/tags/vX and refs/tags/vX^{} and prefer the peeled line; lightweight tags still resolve via fallback. Add tests/test_check_lock_remote.py: unit coverage for peeled-tag preference, pattern request, lightweight fallback, unresolvable ref, plus end-to-end warm-cache --verify for current-annotated-tag-pass and moved-tag-fails-closed with a faked ls-remote transport. --- build/check-lock.py | 4 +- tests/test_check_lock_remote.py | 243 ++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 tests/test_check_lock_remote.py diff --git a/build/check-lock.py b/build/check-lock.py index cc8ee3d..959c10f 100644 --- a/build/check-lock.py +++ b/build/check-lock.py @@ -113,13 +113,13 @@ def descriptor_of(pin, cache, ingredients_dir): def remote_tag_sha(repo, ref): """Resolve a tag ref against the remote. Returns the peeled commit sha, or None when the ref cannot be resolved.""" - result = run(["git", "ls-remote", repo, ref]) + result = run(["git", "ls-remote", repo, ref, ref + "^{}"]) if result.returncode != 0: return None tag_sha = None for line in result.stdout.splitlines(): sha, _, name = line.partition("\t") - if name == ref + "^{": + if name == ref + "^{}": return sha if name == ref: tag_sha = sha diff --git a/tests/test_check_lock_remote.py b/tests/test_check_lock_remote.py new file mode 100644 index 0000000..529716d --- /dev/null +++ b/tests/test_check_lock_remote.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Regression tests for check-lock.py remote_tag_sha (warm-cache --verify). + +An annotated tag has two shas: the tag object and the peeled commit. The +warm-cache / ingredients-dir re-resolution branch of --verify must compare +the pinned sha against the *peeled commit* returned by `git ls-remote`. +A ref-pattern-only ls-remote omits the peeled ^{} line, which used to make +--verify report a LOCK VIOLATION for every current annotated-tag pin. + +Everything runs offline: `git ls-remote` is faked at the check-lock run() +boundary while all other git operations run for real against fixture +repositories. No network access, no third-party import. + +Run from the repo root: python3 -m unittest discover +""" +import contextlib +import importlib.util +import io +import json +import os +import subprocess +import sys +import tempfile +import types +import unittest + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CHECK_LOCK = os.path.join(ROOT, "build", "check-lock.py") +CONTRACT = "The fixture capability: proves the composition contract end to end." + + +def load_check_lock(): + spec = importlib.util.spec_from_file_location("kit_check_lock", CHECK_LOCK) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +CHECK = load_check_lock() + +IDENTITY = ["-c", "user.name=fixture", "-c", "user.email=fixture@example.com"] + + +def git(args, cwd): + result = subprocess.run(["git"] + args, cwd=cwd, capture_output=True, text=True) + if result.returncode != 0: + raise AssertionError("git %s failed: %s" % (args, result.stderr)) + return result.stdout.strip() + + +def ok(result): + return types.SimpleNamespace(returncode=0, stdout=result, stderr="") + + +class RemoteTagShaCase(unittest.TestCase): + """Unit tests for remote_tag_sha with a faked ls-remote transport.""" + + def resolve(self, stdout, ref="refs/tags/v1.0.0", record=None): + real_run = CHECK.run + + def fake(cmd, cwd=None): + if len(cmd) >= 2 and cmd[0] == "git" and cmd[1] == "ls-remote": + if record is not None: + record.append(cmd) + return ok(stdout) + return real_run(cmd, cwd=cwd) + + old = CHECK.run + CHECK.run = fake + try: + return CHECK.remote_tag_sha("https://github.com/aska-digital/x", ref) + finally: + CHECK.run = old + + def test_annotated_tag_returns_peeled_commit_not_tag_object(self): + tag_object = "a" * 40 + commit = "b" * 40 + out = "%s\trefs/tags/v1.0.0\n%s\trefs/tags/v1.0.0^{}\n" % (tag_object, commit) + self.assertEqual(self.resolve(out), commit) + + def test_annotated_tag_prefers_peeled_line_whatever_the_order(self): + tag_object = "a" * 40 + commit = "b" * 40 + out = "%s\trefs/tags/v1.0.0^{}\n%s\trefs/tags/v1.0.0\n" % (commit, tag_object) + self.assertEqual(self.resolve(out), commit) + + def test_ls_remote_is_asked_for_the_peeled_ref(self): + record = [] + self.resolve("c" * 40 + "\trefs/tags/v1.0.0\n", record=record) + self.assertEqual(len(record), 1) + self.assertIn("refs/tags/v1.0.0^{}", record[0], + "ls-remote must request the peeled ^{} ref or annotated " + "tags resolve to the tag object") + + def test_lightweight_tag_without_peeled_line_still_resolves(self): + sha = "c" * 40 + self.assertEqual(self.resolve("%s\trefs/tags/v1.0.0\n" % sha), sha) + + def test_unresolvable_ref_returns_none(self): + real_run = CHECK.run + CHECK.run = lambda cmd, cwd=None: types.SimpleNamespace( + returncode=128, stdout="", stderr="no such ref") + try: + self.assertIsNone(CHECK.remote_tag_sha("https://example.invalid/r", "refs/tags/v9.9.9")) + finally: + CHECK.run = real_run + self.assertIsNone(self.resolve("")) + + +class WarmCacheVerifyCase(unittest.TestCase): + """End-to-end warm-cache --verify against a fixture annotated tag. + + ls-remote is faked to serve the fixture repository's real tag lines; + every other git call (rev-parse, ls-tree, clone) runs for real. + """ + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.base = self._tmp.name + self.repos = os.path.join(self.base, "repos") + self.cache = os.path.join(self.base, "cache") + os.makedirs(self.repos) + os.makedirs(self.cache) + self.addCleanup(self._tmp.cleanup) + self.ls_calls = [] + + def make_fixture(self, slug="protean-one"): + path = os.path.join(self.repos, slug) + os.makedirs(path) + git(["init", "--quiet", "-b", "main", path], cwd=self.repos) + with open(os.path.join(path, "README.md"), "w", encoding="utf-8") as fh: + fh.write(CONTRACT + "\n\n# %s\n" % slug) + with open(os.path.join(path, "LICENSE"), "w", encoding="utf-8") as fh: + fh.write("MIT License\n") + os.makedirs(os.path.join(path, "payload", slug)) + with open(os.path.join(path, "payload", slug, "file.txt"), "w", encoding="utf-8") as fh: + fh.write("payload for %s\n" % slug) + descriptor = { + "slug": slug, "version": "1.0.0", "contract": CONTRACT, "kind": "skills", + "entrypoint": "install.sh", "installTargets": ["payload/" + slug], + "gates": [], "requires": [], "recommends": [], + "runtime": {"python": ">=3.9", "bin": ["git"]}, + "license": "MIT", "open": [], + } + with open(os.path.join(path, "protean-ingredient.json"), "w", encoding="utf-8") as fh: + json.dump(descriptor, fh, indent=2) + git(["add", "-A"], cwd=path) + git(IDENTITY + ["commit", "--quiet", "-m", "v1"], cwd=path) + git(IDENTITY + ["tag", "-a", "v1.0.0", "-m", "v1.0.0"], cwd=path) + sha = git(["rev-parse", "HEAD"], cwd=path) + tree = CHECK.tree_sha256(path, sha) + return {"path": path, "sha": sha, "tree": tree, "descriptor": descriptor} + + def warm_cache(self, slug, fixture): + entry = os.path.join(self.cache, slug, fixture["sha"]) + os.makedirs(os.path.dirname(entry), exist_ok=True) + subprocess.run(["git", "clone", "--quiet", fixture["path"], entry], check=True, + capture_output=True) + git(["checkout", "--quiet", "--detach", fixture["sha"]], cwd=entry) + return entry + + def write_lock(self, slug, fixture): + lock = { + "lockVersion": 1, + "kit": {"name": "protean-kit", "version": "2.0.0", "tag": "v2.0.0"}, + "generatedAt": "2026-09-17T00:00:00Z", + "allowedHosts": ["github.com"], + "allowedOwners": ["aska-digital"], + "installOrder": [slug], + "ingredients": [{ + "slug": slug, + "repo": "https://github.com/aska-digital/" + slug, + "ref": "refs/tags/v1.0.0", + "sha": fixture["sha"], + "version": "1.0.0", + "contract": CONTRACT, + "kind": "skills", + "installTargets": ["payload/" + slug], + "requires": [], "recommends": [], + "integrity": {"fileCount": 0, "treeSha256": fixture["tree"], + "algorithm": "protean-tree-v1"}, + "gates": [], + }], + } + path = os.path.join(self.base, "kit.lock.json") + with open(path, "w", encoding="utf-8") as fh: + json.dump(lock, fh, indent=2) + return path + + def ls_remote_lines(self, fixture_path): + """Real ls-remote-shaped output for the fixture's annotated tag.""" + tag_object = git(["rev-parse", "v1.0.0"], cwd=fixture_path) + commit = git(["rev-parse", "v1.0.0^{commit}"], cwd=fixture_path) + return "%s\trefs/tags/v1.0.0\n%s\trefs/tags/v1.0.0^{}\n" % (tag_object, commit) + + def run_verify(self, lock, ls_stdout): + real_run = CHECK.run + + def fake(cmd, cwd=None): + if len(cmd) >= 2 and cmd[0] == "git" and cmd[1] == "ls-remote": + self.ls_calls.append(cmd) + return ok(ls_stdout) + return real_run(cmd, cwd=cwd) + + old_run, old_argv = CHECK.run, sys.argv + CHECK.run = fake + sys.argv = ["check-lock.py", lock, "--cache", self.cache, "--verify"] + buf = io.StringIO() + try: + with contextlib.redirect_stdout(buf): + code = CHECK.main() + finally: + CHECK.run = old_run + sys.argv = old_argv + return code, buf.getvalue() + + def test_current_annotated_tag_passes_warm_cache_verify(self): + fixture = self.make_fixture() + self.warm_cache("protean-one", fixture) + lock = self.write_lock("protean-one", fixture) + code, out = self.run_verify(lock, self.ls_remote_lines(fixture["path"])) + self.assertEqual(code, 0, out) + self.assertIn("check-lock: PASS", out) + self.assertIn("[provenance: remote]", out) + + def test_moved_tag_fails_closed_on_warm_cache_verify(self): + fixture = self.make_fixture() + self.warm_cache("protean-one", fixture) + lock = self.write_lock("protean-one", fixture) + # Move the published tag to a new commit; the lock still pins the old one. + with open(os.path.join(fixture["path"], "payload", "protean-one", "file.txt"), + "a", encoding="utf-8") as fh: + fh.write("upstream change\n") + git(["add", "-A"], cwd=fixture["path"]) + git(IDENTITY + ["commit", "--quiet", "-m", "v2"], cwd=fixture["path"]) + git(IDENTITY + ["tag", "-f", "-a", "v1.0.0", "-m", "v1.0.0 moved"], cwd=fixture["path"]) + code, out = self.run_verify(lock, self.ls_remote_lines(fixture["path"])) + self.assertEqual(code, 1, out) + self.assertIn("resolves on the remote to", out) + + +if __name__ == "__main__": + unittest.main(verbosity=2)