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:
parent
c89364d262
commit
5a6e78f746
7 changed files with 282 additions and 1 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,3 +2,4 @@ __pycache__/
|
|||
*.py[cod]
|
||||
build/source-inventory.json
|
||||
**/.ast_cache/
|
||||
build/source-inventory-review.json
|
||||
|
|
|
|||
4
Makefile
4
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
|
||||
|
|
|
|||
19
SCOPE.md
19
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
97
tests/test_staged_source_inventory.py
Normal file
97
tests/test_staged_source_inventory.py
Normal 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()
|
||||
113
tools/staged_source_inventory.py
Normal file
113
tools/staged_source_inventory.py
Normal 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())
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue