diff --git a/.generated-work-records.mk b/.generated-work-records.mk index 30ede3f..73e2e28 100644 --- a/.generated-work-records.mk +++ b/.generated-work-records.mk @@ -3,6 +3,8 @@ CUSTODIAN_SOURCE ?= /home/worsch/the-custodian GENERATED_REPO_SLUG ?= net-kingdom .PHONY: generated-work-records generated-work-records-check generated-work-records: + python3 tools/coordination-frame/staged.py --repo . python3 "$(CUSTODIAN_SOURCE)/scripts/generate_staged_work_records.py" --repo . --repo-slug "$(GENERATED_REPO_SLUG)" generated-work-records-check: + python3 tools/coordination-frame/staged.py --repo . --check python3 "$(CUSTODIAN_SOURCE)/scripts/generate_staged_work_records.py" --repo . --repo-slug "$(GENERATED_REPO_SLUG)" --check diff --git a/Makefile b/Makefile index cde44d2..a780363 100644 --- a/Makefile +++ b/Makefile @@ -401,3 +401,9 @@ 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: coordination-frame-staged coordination-frame-staged-check +coordination-frame-staged: + python3 tools/coordination-frame/staged.py --repo . +coordination-frame-staged-check: + python3 tools/coordination-frame/staged.py --repo . --check diff --git a/SCOPE.md b/SCOPE.md index 1a37cc4..76717a8 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -244,3 +244,13 @@ WORK-RECORDS.md with the selected source paths. Existing generated-only guards and exact-scope secret scans remain in place. This is repository maintenance, not a runtime service, authorization decision, deployment or publication. Other index generators and hub-derived briefs retain their owner boundaries. + +## Coordination receiver generation — 2026-10-19 + +The shared opt-in commit recipe now compiles the local proposed Approval Engine +receiver from staged NetKingdom/coordination-engine/approval-engine sources, +then renders WORK-RECORDS. Dedicated Make generation/check targets preserve +unstaged authored work and refuse receiver conflicts or alternate-index writes. +Only the local receiver is staged; sibling sources and runtime registries are +untouched. Missing owner source checkouts fail closed. Multi-owner binding +publication and actual receiver activation retain their existing owner gates. diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 750b967..b40e513 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -160,6 +160,7 @@ | task | NK-WP-0035-T04 | progress | — | workplans/NK-WP-0035-emission-cadence-security-profile.md | | task | NK-WP-0035-T05 | done | — | workplans/NK-WP-0035-emission-cadence-security-profile.md | | task | NK-WP-0035-T06 | done | — | workplans/NK-WP-0035-emission-cadence-security-profile.md | +| task | NK-WP-0035-T07 | done | — | workplans/NK-WP-0035-emission-cadence-security-profile.md | | task | NK-WP-0036-T01 | done | — | workplans/NK-WP-0036-restore-user-portal-client-registration.md | | task | NK-WP-0036-T02 | done | — | workplans/NK-WP-0036-restore-user-portal-client-registration.md | | task | NK-WP-0036-T03 | done | — | workplans/NK-WP-0036-restore-user-portal-client-registration.md | diff --git a/tools/coordination-frame/README.md b/tools/coordination-frame/README.md index 9145439..1e3b6ac 100644 --- a/tools/coordination-frame/README.md +++ b/tools/coordination-frame/README.md @@ -40,3 +40,25 @@ This is the first rollout of a shared coordination setting. Other ecosystem coordination consumers must adopt explicit frame multipliers through their own configuration. Transport polling, security validity and credential leases are not silently rescaled by a coordination setting. + +## Staged local receiver generation + +`make coordination-frame-staged` compiles and stages only the NetKingdom receiver +JSON from Git-index snapshots of its renderer, coordination-engine's frame/API +and approval-engine's cadence. It never edits those sibling checkouts. Unstaged +owner source edits are excluded, and unstaged receiver edits, missing/conflicted +or symlink inputs and alternate-index writes are refused. Stage the reviewed +adapter before its first run. `make coordination-frame-staged-check` is read-only. + +The existing opt-in pre-commit recipe runs this adapter before WORK-RECORDS +rendering and the existing configured scanner. Missing sibling source checkouts +fail closed. Full multi-owner `render.py --write` remains an explicit preparation +command and is never run against real sibling worktrees by the commit hook. +`render.py --receiver-only` limits rendering to the one local proposed receiver; +none of these commands activate the receiver or publish runtime changes. + +For a partial commit, generate from the selected staged sources first and include +the receiver with those sources; `git commit --only` cannot generate missing +changes into its temporary index. Cross-repo owner commits still need their own +review and publication; local index snapshots are source evidence, not release +or operational acceptance. diff --git a/tools/coordination-frame/render.py b/tools/coordination-frame/render.py index f0ac7d1..6459a65 100644 --- a/tools/coordination-frame/render.py +++ b/tools/coordination-frame/render.py @@ -69,7 +69,27 @@ def bind_frame_annotation(text, digest, metadata_prefix): return updated -def compile_bindings(workspace: Path): +def receiver_binding(frame, provenance, approval_text): + receiver = { + "status": "proposed-not-applied", + "coordination_frame": provenance, + "sender": "approval-engine", + "source": "approval-engine", + "tenant": "tenant:platform", + "may_read": False, + "emission_cadence_ref": "approval-engine/cadence.yaml", + "emission_cadence_sha256": hashlib.sha256(approval_text.encode()).hexdigest(), + "heartbeat_classes": { + name: frame.seconds(2) for name in ("issuance", "use", "supersession", "revocation") + }, + "receiver_grace_factor": 1.5, + "effective_deadline_seconds": frame.seconds(3), + "preserve_existing_credentials_and_other_registry_fields": True, + } + return json.dumps(receiver, indent=2) + "\n" + + +def compile_bindings(workspace: Path, *, receiver_only=False): frame = owner_frame(workspace) digest = hashlib.sha256((workspace / FRAME_REF).read_bytes()).hexdigest() provenance = { @@ -97,6 +117,9 @@ def compile_bindings(workspace: Path): ext["effective_deadline_seconds"] = frame.seconds(3) approval["extensions"].setdefault("netkingdom", {})["coordination_frame"] = provenance outputs[approval_path] = json.dumps(approval, indent=2) + "\n" + if receiver_only: + path = workspace / "net-kingdom/generated/coordination-frame/approval-engine-receiver.json" + return frame, {path: receiver_binding(frame, provenance, outputs[approval_path])} qonto_path = workspace / "qonto-assistant/specs/audit-emission-cadence.yaml" qonto = yaml.safe_load(qonto_path.read_text()) @@ -160,37 +183,20 @@ def compile_bindings(workspace: Path): fixture["extensions"].setdefault("netkingdom", {})["coordination_frame"] = provenance outputs[fixture_path] = json.dumps(fixture, indent=2) + "\n" - receiver = { - "status": "proposed-not-applied", - "coordination_frame": provenance, - "sender": "approval-engine", - "source": "approval-engine", - "tenant": "tenant:platform", - "may_read": False, - "emission_cadence_ref": "approval-engine/cadence.yaml", - "emission_cadence_sha256": hashlib.sha256(outputs[approval_path].encode()).hexdigest(), - "heartbeat_classes": { - name: frame.seconds(2) for name in ("issuance", "use", "supersession", "revocation") - }, - "receiver_grace_factor": 1.5, - "effective_deadline_seconds": frame.seconds(3), - "preserve_existing_credentials_and_other_registry_fields": True, - } - outputs[ - workspace / "net-kingdom/generated/coordination-frame/approval-engine-receiver.json" - ] = json.dumps(receiver, indent=2) + "\n" + outputs[workspace / "net-kingdom/generated/coordination-frame/approval-engine-receiver.json"] = receiver_binding(frame, provenance, outputs[approval_path]) return frame, outputs def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--workspace", type=Path, default=ROOT.parent) + parser.add_argument("--receiver-only", action="store_true") mode = parser.add_mutually_exclusive_group() mode.add_argument("--check", action="store_true") mode.add_argument("--write", action="store_true") args = parser.parse_args(argv) try: - frame, outputs = compile_bindings(args.workspace) + frame, outputs = compile_bindings(args.workspace, receiver_only=args.receiver_only) changed = { p: text for p, text in outputs.items() if not p.exists() or p.read_text() != text } diff --git a/tools/coordination-frame/staged.py b/tools/coordination-frame/staged.py new file mode 100644 index 0000000..1ebd6a6 --- /dev/null +++ b/tools/coordination-frame/staged.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Stage only the local receiver from indexed owner sources; never mutate siblings.""" +import argparse +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +OUTPUT = 'generated/coordination-frame/approval-engine-receiver.json' +SELF = 'tools/coordination-frame/staged.py' +INPUTS = { + 'net-kingdom': ['tools/coordination-frame/render.py'], + 'coordination-engine': ['config/coordination-frame.json', 'src/coordination_engine/timing.py'], + 'approval-engine': ['cadence.yaml'], +} + + +def git(repo, *args, default_index=False): + env = dict(os.environ) + if default_index: + env.pop("GIT_INDEX_FILE", None) + return subprocess.check_output(['git', '-C', str(repo), *args], stderr=subprocess.PIPE, env=env) + + +def indexed(repo, path, *, default_index=False): + rows = git(repo, 'ls-files', '--stage', '--', path, default_index=default_index).decode().splitlines() + if len(rows) != 1: + raise ValueError('missing or conflicted source: ' + path) + metadata, name = rows[0].split('\t', 1) + mode, oid, stage = metadata.split() + if stage != '0' or mode not in ('100644', '100755') or name != path: + raise ValueError('nonregular or conflicted source: ' + path) + return git(repo, 'cat-file', 'blob', oid, default_index=default_index) + + +def generate(repo, *, check=False): + repo = Path(repo).resolve() + workspace = repo.parent + if Path(git(repo, 'rev-parse', '--show-toplevel').decode().strip()).resolve() != repo: + raise ValueError('repository root required') + if (repo / SELF).is_symlink() or (repo / SELF).read_bytes() != indexed(repo, SELF): + raise ValueError('stage the reviewed adapter before executing it') + target = repo / OUTPUT + if any(p.is_symlink() for p in (target, *target.parents)): + raise ValueError('redirected receiver output refused') + prior = indexed(repo, OUTPUT) + if target.read_bytes() != prior: + raise ValueError('unstaged receiver edits; preserve or reconcile first') + sources = [] + with tempfile.TemporaryDirectory(prefix='coordination-staged-') as directory: + snapshot = Path(directory) + for slug, paths in INPUTS.items(): + source_repo = repo if slug == 'net-kingdom' else workspace / slug + if Path(git(source_repo, 'rev-parse', '--show-toplevel').decode().strip()).resolve() != source_repo.resolve(): + raise ValueError('owner repository root required') + for path in paths: + dest = snapshot / slug / path + dest.parent.mkdir(parents=True, exist_ok=True) + content = indexed(source_repo, path, default_index=source_repo != repo) + sources.append((source_repo, path, content)) + dest.write_bytes(content) + subprocess.run([sys.executable, str(snapshot/'net-kingdom/tools/coordination-frame/render.py'), + '--workspace', str(snapshot), '--receiver-only', '--write'], + check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + result = (snapshot/'net-kingdom'/OUTPUT).read_bytes() + if any(indexed(owner, name, default_index=owner != repo) != content + for owner, name, content in sources): + raise ValueError("owner index changed during compilation") + if result == prior: + return False + if check: + raise ValueError('staged receiver drift; run make coordination-frame-staged') + default_index = Path(git(repo, 'rev-parse', '--git-path', 'index', default_index=True).decode().strip()) + if not default_index.is_absolute(): + default_index = repo/default_index + active_index = Path(os.environ.get('GIT_INDEX_FILE', str(default_index))) + if not active_index.is_absolute(): + active_index = repo/active_index + if active_index.resolve() != default_index.resolve(): + raise ValueError('alternate-index mutation refused; generate before commit --only') + # Optimistic check: refuse if a source/index update raced compilation. + if indexed(repo, OUTPUT) != prior or target.read_bytes() != prior: + raise ValueError('receiver changed during compilation') + target.write_bytes(result) + git(repo, 'add', '--', OUTPUT) + return True + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--repo', type=Path, default=Path.cwd()) + parser.add_argument('--check', action='store_true') + args = parser.parse_args() + try: + changed = generate(args.repo, check=args.check) + except (OSError, ValueError, subprocess.SubprocessError) as exc: + parser.exit(1, 'Coordination generation refused: ' + str(exc) + '\n') + print(json.dumps({'receiver_changed': changed, 'sibling_writes': False, 'live_operations': False})) diff --git a/tools/coordination-frame/tests/test_staged.py b/tools/coordination-frame/tests/test_staged.py new file mode 100644 index 0000000..24c5ed8 --- /dev/null +++ b/tools/coordination-frame/tests/test_staged.py @@ -0,0 +1,85 @@ +import importlib.util +import json +import os +from pathlib import Path +import subprocess + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +SPEC = importlib.util.spec_from_file_location('staged_frame', ROOT/'tools/coordination-frame/staged.py') +staged = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(staged) + + +def git(repo, *args): + return subprocess.check_output(['git','-C',str(repo),*args]) + + +@pytest.fixture +def repos(tmp_path): + workspace = ROOT.parent + for slug, names in staged.INPUTS.items(): + repo=tmp_path/slug;repo.mkdir() + git(repo,'init','-q') + for name in names: + dest=repo/name;dest.parent.mkdir(parents=True,exist_ok=True) + dest.write_bytes((workspace/slug/name).read_bytes()) + repo=tmp_path/'net-kingdom' + for name in (staged.SELF,staged.OUTPUT): + dest=repo/name;dest.parent.mkdir(parents=True,exist_ok=True) + dest.write_bytes((ROOT/name).read_bytes()) + for slug in staged.INPUTS:git(tmp_path/slug,'add','.') + return tmp_path,repo + + +def test_receiver_only_ignores_unstaged_sibling_source(repos): + workspace,repo=repos + path=workspace/'coordination-engine/config/coordination-frame.json' + original=path.read_bytes();path.write_text('uncommitted invalid source') + assert staged.generate(repo) is False + assert path.read_text()=='uncommitted invalid source' + assert git(workspace/'coordination-engine','show',':config/coordination-frame.json')==original + + +def test_staged_frame_change_only_updates_local_receiver(repos): + workspace,repo=repos + path=workspace/'coordination-engine/config/coordination-frame.json' + data=json.loads(path.read_text());data['frame_seconds']=600;path.write_text(json.dumps(data)) + git(path.parents[1],'add','config/coordination-frame.json') + approval=workspace/'approval-engine/cadence.yaml';before=approval.read_bytes() + with pytest.raises(ValueError,match='drift'):staged.generate(repo,check=True) + assert staged.generate(repo) + result=json.loads((repo/staged.OUTPUT).read_text()) + assert result['effective_deadline_seconds']==1800 + assert approval.read_bytes()==before + assert staged.generate(repo,check=True) is False + + +def test_unstaged_receiver_preserved(repos): + _,repo=repos;p=repo/staged.OUTPUT;p.write_text('local edit') + with pytest.raises(ValueError,match='unstaged receiver'):staged.generate(repo) + assert p.read_text()=='local edit' + + +def test_unstaged_adapter_refused(repos): + _,repo=repos;p=repo/staged.SELF;p.write_text(p.read_text()+'\n# local edit\n') + with pytest.raises(ValueError,match='stage the reviewed'):staged.generate(repo) + + +def test_symlink_source_refused(repos): + workspace,repo=repos;p=workspace/'approval-engine/cadence.yaml';p.unlink();p.symlink_to('/etc/passwd') + git(p.parent,'add','cadence.yaml') + with pytest.raises(ValueError,match='nonregular'):staged.generate(repo) + + +def test_alternate_index_write_refused_and_sibling_default_index_used(repos,monkeypatch): + workspace,repo=repos + path=workspace/'coordination-engine/config/coordination-frame.json' + data=json.loads(path.read_text());data['frame_seconds']=600;path.write_text(json.dumps(data)) + git(path.parents[1],'add','config/coordination-frame.json') + alternate=repo/'.git/partial-index';alternate.write_bytes((repo/'.git/index').read_bytes()) + before=(repo/staged.OUTPUT).read_bytes() + monkeypatch.setenv('GIT_INDEX_FILE',str(alternate)) + with pytest.raises(ValueError,match='alternate-index mutation'):staged.generate(repo) + assert (repo/staged.OUTPUT).read_bytes()==before diff --git a/workplans/NK-WP-0035-emission-cadence-security-profile.md b/workplans/NK-WP-0035-emission-cadence-security-profile.md index 4c22c53..d129e35 100644 --- a/workplans/NK-WP-0035-emission-cadence-security-profile.md +++ b/workplans/NK-WP-0035-emission-cadence-security-profile.md @@ -10,7 +10,7 @@ owner: codex topic_slug: netkingdom planning_priority: P1 created: "2026-09-04" -updated: "2026-10-15" +updated: "2026-10-19" related: - GH-DEC-2026-004 - canon/standards/security-layer-model_v0.7.md @@ -417,3 +417,33 @@ and collector implementation are no longer missing. Remaining work is actual elapsed-time capture, independent source/receiver correlation and observer acceptance. Local-identity remains explicitly incompatible, with no completeness claim. Detailed state: [owner acceptance plan](../docs/evidence/2026-10-15-cadence-adoption/owner-acceptance-plan.json). + +## Automate staged receiver derivation without sibling mutations + +```task +id: NK-WP-0035-T07 +status: done +priority: medium +``` + +Completed source/fixture work 2026-10-19 for CUST-WP-0079-T05. The existing +coordination-frame compiler now has a receiver-only mode, and its staged +adapter compiles indexed NetKingdom renderer, coordination-engine frame/API and +approval-engine cadence inputs in an isolated temporary snapshot. Only the +local proposed receiver is written/staged. Dedicated Make targets and the +existing shared opt-in pre-commit recipe invoke this path; authored sibling +files and unstaged source edits are untouched. Missing/conflicted/nonregular +inputs, an unstaged receiver/adapter, changed input snapshots and required +alternate-index mutations refuse. The existing guard/scanner remain intact. + +Eleven focused tests pass, including partial source staging, namespaced sibling +index isolation, receiver edit preservation and alternate-index refusal. +The first alternate-index test found that Git's default index query itself +honored GIT_INDEX_FILE; corrected the query and retained its regression. This +is a refined implementation, not an unchanged execution-success claim. + +T06 retains ownership of the full multi-consumer frame compiler. T07 owns only +the staged local receiver automation introduced here. T04 retains registry, +collector and operational lifecycle/observer acceptance; neither this task nor +its tests claim runtime activation or a clean week of fleet commit history. +See tools/coordination-frame/README.md for commands and source boundaries.