From 5a6e78f746ed85369dc2a2b995ffc7161780ce31 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 19 Oct 2026 13:42:46 +0200 Subject: [PATCH] build: review staged source inventory without changing owner dispositions Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a15374-f59f-7a31-86d2-d23b5d2ed181 --- .gitignore | 1 + Makefile | 4 + SCOPE.md | 19 +++ docs/publication-contract.md | 23 ++++ tests/test_staged_source_inventory.py | 97 +++++++++++++++ tools/staged_source_inventory.py | 113 ++++++++++++++++++ ...WP-0006-build-mode-change-authorization.md | 26 +++- 7 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 tests/test_staged_source_inventory.py create mode 100644 tools/staged_source_inventory.py diff --git a/.gitignore b/.gitignore index 1ba0df5..3c39f2e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__/ *.py[cod] build/source-inventory.json **/.ast_cache/ +build/source-inventory-review.json diff --git a/Makefile b/Makefile index 43f303f..f9146f4 100644 --- a/Makefile +++ b/Makefile @@ -43,3 +43,7 @@ generated-work-records: $(MAKE) --no-print-directory -f .generated-work-records.mk generated-work-records generated-work-records-check: $(MAKE) --no-print-directory -f .generated-work-records.mk generated-work-records-check + +.PHONY: source-inventory-review +source-inventory-review: ## Review staged discovery without changing authored dispositions + python3 tools/staged_source_inventory.py diff --git a/SCOPE.md b/SCOPE.md index d5289c3..ab81d6c 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -54,3 +54,22 @@ Unstaged source edits are preserved; unstaged generated-file edits and unresolve conflicts stop the commit. The existing generated-only guard and secret scan remain in place. This adoption covers WORK-RECORDS.md only; other owner indexes retain their own generators. It makes no schedule or public-release change. + + +## Inventory source and generated publication evidence + +`source-inventory.config.json`, `publication.json`, and the disposition/reason +rows in `source-inventory.json` are authored review inputs. They must not be +blindly refreshed by a hook. The existing scheduled Forgejo publication workflow +fetches the configured source revisions and builds generated source inventory, +source-lock and publication manifests inside the immutable candidate image. +The scheduled workflow is source automation; no fresh successful run or public +release is claimed by this declaration. + +`make source-inventory-review` invokes `tools/staged_source_inventory.py` to +review the exact staged inputs with the existing owner refresh implementation +in a temporary snapshot. It emits only an ignored `build/` discovery delta, +preserves authored dispositions and Git staging, and fails on unresolved discovery +changes. This optional review command does not apply candidate rows, publish, +or bypass source-audit. The source inventory remains an explicit owner gate; +WORK-RECORDS pre-commit generation does not reinterpret it as disposable output. diff --git a/docs/publication-contract.md b/docs/publication-contract.md index 4489b98..ab829e9 100644 --- a/docs/publication-contract.md +++ b/docs/publication-contract.md @@ -175,3 +175,26 @@ grant credentials, autopilot, public publication approval or runtime authority. Public publication goes in the founder's daily red-lane batch; orange identity acts go in the attended-window checklist. Source generation and local checks can proceed before publication approval. + + +## Review staged inventory changes + +The inventory's discovery selectors and each row's disposition/reason are +reviewed source. `source_inventory.py refresh` rewrites the inventory and must +be followed by review; its default metadata-pending rows are not public +publication consent. + +Before applying such changes, run `make source-inventory-review`. It reads the +Git index, so unstaged selectors or documents do not enter the proposed delta. +The existing refresh function runs only on a temporary copy. The command writes +`build/source-inventory-review.json` (ignored, not a second registry), exits 1 +when added/removed source rows require review, and leaves authored files and the +Git index unchanged. Existing dispositions and reasons are preserved; duplicate +rows, symlink inputs and unresolved index conflicts fail closed. Review and edit +the authoritative source explicitly, then stage it and rerun review/source-audit. +No candidate is automatically applied or committed. + +The existing scheduled `.forgejo/workflows/publish-image.yaml` already generates +release inventory evidence and publication artifacts from fetched source +revisions. The review command is not another scheduler or release gate; current +source-audit, currency and founder publication requirements remain in force. diff --git a/tests/test_staged_source_inventory.py b/tests/test_staged_source_inventory.py new file mode 100644 index 0000000..1f32883 --- /dev/null +++ b/tests/test_staged_source_inventory.py @@ -0,0 +1,97 @@ +from pathlib import Path +import json +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).parents[1] +sys.path.insert(0, str(ROOT / 'tools')) +from staged_source_inventory import review + + +class StagedInventoryReviewTests(unittest.TestCase): + def fixture(self, root): + subprocess.run(['git', 'init', '-q', str(root)], check=True) + (root / 'tools').mkdir() + (root / 'tools/source_inventory.py').write_bytes((ROOT / 'tools/source_inventory.py').read_bytes()) + (root / 'docs/adr').mkdir(parents=True) + (root / 'docs/adr/one.md').write_text('# One') + (root / 'source-inventory.config.json').write_text(json.dumps({'schema_version': 1, 'repositories': {'policy-nexus': {'local': True, 'selectors': ['docs/adr/*.md']}}})) + (root / 'source-inventory.json').write_text(json.dumps({'schema_version': 1, 'sources': [{'source_repo': 'policy-nexus', 'source_path': 'docs/adr/one.md', 'disposition': 'excluded', 'reason': 'Owner withheld disclosure'}]})) + (root / 'publication.json').write_text('{"documents":[]}') + self.stage(root, '.') + + def stage(self, root, *paths): + subprocess.run(['git', '-C', str(root), 'add', '--', *paths], check=True) + + def test_partial_staging_preserves_authored_files_and_dispositions(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + self.fixture(root) + (root / 'docs/adr/two.md').write_text('# Two') + self.stage(root, 'docs/adr/two.md') + # An unstaged selector change and document must not enter discovery. + (root / 'source-inventory.config.json').write_text('unstaged user edit') + (root / 'docs/adr/three.md').write_text('# Three') + before = {p: p.read_bytes() for p in root.glob('*.json')} + staged_before = subprocess.check_output(['git', '-C', str(root), 'ls-files', '--stage']) + result = review(root) + self.assertEqual('review_required', result['status']) + self.assertEqual(['docs/adr/two.md'], [x['source_path'] for x in result['additions']]) + self.assertEqual('metadata-pending', result['additions'][0]['disposition']) + self.assertTrue(result['authored_dispositions_preserved']) + self.assertFalse(result['publication_authorized']) + self.assertEqual(staged_before, subprocess.check_output(['git', '-C', str(root), 'ls-files', '--stage'])) + for path, content in before.items(): + self.assertEqual(content, path.read_bytes()) + + def test_removed_source_retains_owner_reason_in_review(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + self.fixture(root) + (root / 'docs/adr/one.md').unlink() + self.stage(root, 'docs/adr/one.md') + result = review(root) + self.assertEqual('Owner withheld disclosure', result['removals'][0]['reason']) + self.assertEqual('excluded', result['removals'][0]['disposition']) + + def test_current_inventory_is_noop_and_repeatable(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + self.fixture(root) + self.assertEqual('current', review(root)['status']) + self.assertEqual(review(root), review(root)) + + def test_report_cannot_replace_authored_input(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + self.fixture(root) + before = (root / 'publication.json').read_bytes() + result = subprocess.run([sys.executable, str(ROOT / 'tools/staged_source_inventory.py'), + '--repo', str(root), '--report', str(root / 'publication.json')], + capture_output=True) + self.assertEqual(2, result.returncode) + self.assertIn(b'must not replace authored inputs', result.stderr) + self.assertEqual(before, (root / 'publication.json').read_bytes()) + + def test_symlink_and_duplicate_authored_entries_fail_closed(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + self.fixture(root) + path = root / 'source-inventory.json' + inventory = json.loads(path.read_text()) + inventory['sources'] *= 2 + path.write_text(json.dumps(inventory)) + self.stage(root, 'source-inventory.json') + with self.assertRaisesRegex(ValueError, 'duplicate authored'): + review(root) + path.unlink() + path.symlink_to('/etc/passwd') + self.stage(root, 'source-inventory.json') + with self.assertRaisesRegex(ValueError, 'non-regular'): + review(root) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/staged_source_inventory.py b/tools/staged_source_inventory.py new file mode 100644 index 0000000..5d07a53 --- /dev/null +++ b/tools/staged_source_inventory.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Review staged inventory discovery without changing authored dispositions. + +The inventory is a reviewed registry, not disposable generated output. This +adapter runs the existing refresh implementation in a temporary staged snapshot +and emits a discovery delta under ignored build/. It never stages or publishes. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import subprocess +import sys +import tempfile + +INPUTS = ('source-inventory.config.json', 'source-inventory.json', 'publication.json') + + +def git(repo: Path, *args: str) -> bytes: + return subprocess.check_output(['git', '-C', str(repo), *args]) + + +def review(repo: Path, *, source_root: Path | None = None) -> dict: + repo = Path(git(repo, 'rev-parse', '--show-toplevel').decode().strip()) + entries = git(repo, 'ls-files', '--stage', '-z') + blobs = {} + for entry in entries.split(b'\0'): + if not entry: + continue + meta, path = entry.split(b'\t', 1) + mode, oid, stage = meta.split() + if stage != b'0': + raise ValueError('resolve unmerged index before inventory review') + blobs[path.decode()] = (mode, oid.decode()) + with tempfile.TemporaryDirectory(prefix='policy-inventory-review-') as temporary: + snapshot = Path(temporary) + for name, (mode, oid) in blobs.items(): + if name in INPUTS or name == 'tools/source_inventory.py' or name.endswith('.md'): + if mode not in (b'100644', b'100755'): + raise ValueError('non-regular staged inventory input: ' + name) + path = snapshot / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(git(repo, 'cat-file', 'blob', oid)) + for name in (*INPUTS, 'tools/source_inventory.py'): + if not (snapshot / name).is_file(): + raise ValueError('missing staged input: ' + name) + config = json.loads((snapshot / INPUTS[0]).read_text()) + original = json.loads((snapshot / INPUTS[1]).read_text()) + # Refresh historically accepts duplicate rows by overwriting them; do + # not silently turn an authored conflict into a generated disposition. + keys = [(entry['source_repo'], entry['source_path']) for entry in original['sources']] + if len(keys) != len(set(keys)): + raise ValueError('duplicate authored inventory entry') + source_root = (source_root or repo.parent).resolve() + revisions = {} + for name, declaration in config['repositories'].items(): + if not declaration.get('local'): + revisions[name] = git(source_root / name, 'rev-parse', 'HEAD').decode().strip() + subprocess.run([sys.executable, str(snapshot / 'tools/source_inventory.py'), + 'refresh', '--config', str(snapshot / INPUTS[0]), + '--inventory', str(snapshot / INPUTS[1]), + '--publication', str(snapshot / INPUTS[2]), + '--policy-root', str(snapshot), '--source-root', str(source_root)], + check=True, capture_output=True) + proposed = json.loads((snapshot / INPUTS[1]).read_text()) + for name, revision in revisions.items(): + if git(source_root / name, 'rev-parse', 'HEAD').decode().strip() != revision: + raise ValueError('source revision changed during review: ' + name) + if git(repo, 'ls-files', '--stage', '-z') != entries: + raise ValueError('staged inputs changed during review') + old = {(x['source_repo'], x['source_path']): x for x in original['sources']} + new = {(x['source_repo'], x['source_path']): x for x in proposed['sources']} + additions = [new[key] for key in sorted(new.keys() - old.keys())] + removals = [old[key] for key in sorted(old.keys() - new.keys())] + changed = [key for key in old.keys() & new.keys() if old[key] != new[key]] + if changed: + raise ValueError('owner refresh changed existing authored dispositions') + return {'schema_version': 'policy-inventory-review/v1', + 'status': 'review_required' if additions or removals else 'current', + 'input': 'git-index', 'source_revisions': revisions, + 'additions': additions, 'removals': removals, + 'authored_dispositions_preserved': True, 'publication_authorized': False} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--repo', type=Path, default=Path.cwd()) + parser.add_argument('--source-root', type=Path) + parser.add_argument('--report', type=Path, default=Path('build/source-inventory-review.json')) + args = parser.parse_args() + try: + report = review(args.repo, source_root=args.source_root) + destination = args.report.resolve() + root = Path(git(args.repo, 'rev-parse', '--show-toplevel').decode().strip()) + if args.report.is_symlink() or destination in {(root / name).resolve() for name in INPUTS}: + raise ValueError('report must not replace authored inputs or follow a symlink') + if destination.is_relative_to(root) and git(root, 'ls-files', '--', str(destination.relative_to(root))).strip(): + raise ValueError('review report must not replace a tracked file') + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(report, indent=2, sort_keys=True) + '\n') + print(f"inventory {report['status']}: {len(report['additions'])} addition(s), {len(report['removals'])} removal(s); {args.report}") + if report['status'] != 'current': + print('Review owner dispositions explicitly; no authored registry or publication changed.', file=sys.stderr) + return 1 + return 0 + except (OSError, ValueError, KeyError, subprocess.SubprocessError) as exc: + print(f'inventory review refused: {exc}', file=sys.stderr) + return 2 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/workplans/PNEX-WP-0006-build-mode-change-authorization.md b/workplans/PNEX-WP-0006-build-mode-change-authorization.md index cbcd513..b9363e5 100644 --- a/workplans/PNEX-WP-0006-build-mode-change-authorization.md +++ b/workplans/PNEX-WP-0006-build-mode-change-authorization.md @@ -8,7 +8,7 @@ status: blocked owner: codex topic_slug: custodian created: "2026-10-11" -updated: "2026-10-11" +updated: "2026-10-19" flavor: implementation depends_on: [] state_hub_workstream_id: "774c5037-a3d0-5e65-893c-d18970acd66a" @@ -71,3 +71,27 @@ Full source audit also detects the unrelated unreviewed `net-kingdom/canon/standards/security-layer-model_v0.9.md`. Its owning source publication disposition must be resolved before the release gate passes; this workplan does not silently admit it or edit another owner's standard. + + +## Source automation and remaining gate review — 2026-10-19 + +The scheduled Forgejo workflow already fetches pinned source revisions and builds +source-inventory/source-lock/publication evidence into immutable candidate images. +That is generated-output automation, not evidence of a fresh successful run or +public promotion. Authored discovery selectors, publication mappings and inventory +disposition/reason rows remain owner review inputs. + +`make source-inventory-review` now reuses the owner refresh implementation in a +temporary staged snapshot, writing only an ignored discovery-delta report. It +does not rewrite or stage authored inputs. Partial staging, duplicate/symlink +rejection and preservation of dispositions are tested. No candidate hook or +second versioned registry was introduced. + +Current source-audit still fails closed for two unreviewed additions: +`net-kingdom/canon/standards/security-layer-model_v0.9.md` (proposed) and +`railiance-platform/docs/adr/ADR-0006-playwright-consumer-group.md` (accepted in its +owner source, not yet dispositioned for this publication corpus). The optional +review reports both additions and no removals. Neither row was auto-admitted; +no authored publication/config/inventory input changed. Both T01 and T02 remain +wait for those explicit owner dispositions and the existing founder publication +batch. No public release, credential access or runtime mutation was performed.