build: review staged source inventory without changing owner dispositions

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a15374-f59f-7a31-86d2-d23b5d2ed181
This commit is contained in:
tegwick 2026-10-19 13:42:46 +02:00
parent c89364d262
commit 5a6e78f746
7 changed files with 282 additions and 1 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@ __pycache__/
*.py[cod]
build/source-inventory.json
**/.ast_cache/
build/source-inventory-review.json

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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()

View file

@ -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())

View file

@ -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.