Publish canon-derived build-mode guardrail source and evaluator
Some checks failed
Build and publish policy-nexus image / build-and-push (push) Failing after 1m21s

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
This commit is contained in:
tegwick 2026-10-11 21:30:47 +02:00
parent 444b67e9ae
commit f784f017b0
11 changed files with 359 additions and 4 deletions

View file

@ -10,7 +10,7 @@ build:
check:
python3 -m unittest discover -s tests -p 'test_*.py'
python3 -m py_compile tools/render.py tools/build_site.py tools/check_currency.py tools/verify_release.py tools/source_inventory.py tools/fetch_sources.py tools/adr_review_ledger.py
python3 -m py_compile tools/render.py tools/build_site.py tools/check_currency.py tools/verify_release.py tools/source_inventory.py tools/fetch_sources.py tools/adr_review_ledger.py tools/guardrails.py
$(MAKE) source-audit
git diff --check

View file

@ -0,0 +1,28 @@
{
"schema_version": "policy-nexus-guardrail-validation/v1",
"validated_at": "2026-10-11T19:08:06.581967+00:00",
"workplan": "PNEX-WP-0006",
"native_publication": false,
"task_closures": [],
"tests": {
"command": "python3 -m unittest discover -s tests -p 'test_*.py'",
"passed": 60,
"failed": 0
},
"compile": "passed",
"diff_whitespace": "passed",
"local_build": {
"document_count": 74,
"artifact_equals_canon_block": true,
"expiry_visible_in_header": true,
"source_sha256": "7135ad8e36041f6dc9633c029109b4751be275f912cec44fc462c9f55befaab8",
"artifact_sha256": "a86462a57bb1df5804d3088881af22569082d43ff68bcab2b67cbe56b6fc46cd"
},
"source_audit": {
"status": "failed_closed",
"unreviewed_source": "net-kingdom/canon/standards/security-layer-model_v0.9.md",
"unrelated_inventory_refreshed": false
},
"publication_lane": "red",
"pending_daily_batch": "PNEX-WP-0006: public page and canon-derived JSON artifact"
}

View file

@ -139,3 +139,39 @@ matching source; publication stays one explicit entry per document.
| `net-kingdom-architecture` | `net-kingdom` | `net-kingdom` | `docs/architecture/net-kingdom_v0.1.md` |
| `state-hub-architecture` | `state-hub` | `state-hub` | `docs/architecture/state-hub_v0.1.md` |
| `policy-nexus-architecture` | `policy-nexus` | `policy-nexus` | `docs/architecture/policy-nexus_v0.1.md` |
## Machine-readable build-mode authorization
The build-mode standard's publication entry declares
`guardrail_path: guardrails/build-mode-change-authorization.json`. The builder
extracts its single machine-checkable YAML block from the canonical Markdown
and emits JSON at that stable path. No second policy copy is maintained. The
release manifest binds the artifact digest to the document's source provenance;
release verification rejects missing, changed or unsupported rule artifacts.
`expires_on` is shown in the document header.
A consumer can evaluate a change record with the stdlib-only checker:
```sh
python3 tools/guardrails.py build/guardrails/build-mode-change-authorization.json change.json --build-mode
```
```json
{
"categories": [],
"diff_ref": "reviewed.patch",
"rollback": "Restore the previous deployment image and source revision",
"abort_owner": "owning-repo",
"receipt_path": "execution-receipt.json"
}
```
Category declarations are mandatory, even when empty. The checker returns
`blue` only with all four nonempty preparation references and confirmed build
mode. Missing references, unknown categories or unconfirmed/expired build mode
return `incomplete`. Red overrides orange when both are declared. Only `blue`
returns exit code zero. Classification does not verify the referenced review or
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.

View file

@ -666,6 +666,15 @@
"canonical_path": "methods/risk-dependencies/v1/index.html",
"revision_path": "methods/risk-dependencies/v1/revisions/{revision}/index.html",
"review_interval": "6m"
},
{
"id": "canon-build-mode-change-authorization",
"source_repo": "the-custodian",
"source_path": "canon/standards/build-mode-change-authorization_v0.1.md",
"canonical_path": "standards/build-mode-change-authorization/v0.1/index.html",
"revision_path": "standards/build-mode-change-authorization/v0.1/revisions/{revision}/index.html",
"review_interval": "3m",
"guardrail_path": "guardrails/build-mode-change-authorization.json"
}
],
"published_history": [

View file

@ -913,6 +913,12 @@
"source_path": "canon/standards/autonomy-lanes_v0.1.md",
"source_repo": "the-custodian"
},
{
"source_repo": "the-custodian",
"source_path": "canon/standards/build-mode-change-authorization_v0.1.md",
"disposition": "published",
"reason": "Published through an explicit publication.json document entry."
},
{
"disposition": "metadata-pending",
"reason": "In scope; awaits explicit publication addressing and owner/revision/review metadata.",

120
tests/test_guardrails.py Normal file
View file

@ -0,0 +1,120 @@
from __future__ import annotations
import json
import hashlib
from pathlib import Path
import sys
import tempfile
import unittest
ROOT = Path(__file__).parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
from guardrails import extract_rule, evaluate
from build_site import build, load_manifest
from test_publication import _fixture
from test_release import _release
from verify_release import verify
CANON_FIXTURE = '---\nid: canon-build-mode-change-authorization\ntitle: Build-Mode Change Authorization\nstatus: accepted\nrevision: accepted-1\nowner: the-custodian\nlast_reviewed: 2026-10-11\nreview_interval: 3m\nexpires_on: production-close-down\n---\n\n# Build-Mode Change Authorization\n\n```yaml\nrule: build-mode-change-authorization\nversion: 0.1\napplies_while: build-mode\nblue_lane_requires: # every field must be present on the change record\n - diff_ref # path or commit of reviewed diff / dry-run output\n - rollback # command or procedure reference\n - abort_owner # repo or agent name\n - receipt_path # where the execution receipt is written\nred_lane_categories:\n - destructive-deletion\n - access-scope-expansion\n - secret-scope-expansion\n - spend-ceiling\n - public-publication\norange_lane_categories:\n - founder-identity-act\n - platform-admin-login\n```\n'
class GuardrailTest(unittest.TestCase):
def setUp(self):
self.source = CANON_FIXTURE
self.rule = extract_rule(self.source)
self.record = {'categories': [], 'diff_ref': 'reviewed.patch', 'rollback': 'revert source/image',
'abort_owner': 'policy-nexus', 'receipt_path': 'receipt.json'}
def test_canon_block_contract(self):
self.assertEqual(self.rule, {
'rule': 'build-mode-change-authorization', 'version': 0.1,
'applies_while': 'build-mode',
'blue_lane_requires': ['diff_ref', 'rollback', 'abort_owner', 'receipt_path'],
'red_lane_categories': ['destructive-deletion', 'access-scope-expansion',
'secret-scope-expansion', 'spend-ceiling', 'public-publication'],
'orange_lane_categories': ['founder-identity-act', 'platform-admin-login']})
def test_blue_requires_complete_record_and_confirmed_build_mode(self):
self.assertEqual(evaluate(self.rule, self.record, build_mode=True), {'lane': 'blue'})
self.assertEqual(evaluate(self.rule, self.record, build_mode=False)['lane'], 'incomplete')
for key in self.rule['blue_lane_requires']:
for invalid in ['', ' ', None, True, [], {}]:
with self.subTest(key=key, value=invalid):
result = evaluate(self.rule, self.record | {key: invalid}, build_mode=True)
self.assertEqual(result['lane'], 'incomplete')
self.assertIn(key, result['missing'])
def test_all_red_and_orange_categories_and_red_precedence(self):
for lane in ('red', 'orange'):
for category in self.rule[f'{lane}_lane_categories']:
self.assertEqual(evaluate(self.rule, {'categories': [category]}, build_mode=True)['lane'], lane)
self.assertEqual(evaluate(self.rule, {'categories': ['public-publication', 'platform-admin-login']}, build_mode=True)['lane'], 'red')
def test_unknown_or_undeclared_categories_cannot_grant_blue(self):
for categories in [None, 'public-publication', [False], ['typo']]:
self.assertEqual(evaluate(self.rule, self.record | {'categories': categories}, build_mode=True)['lane'], 'incomplete')
def test_unsupported_yaml_duplicate_fields_and_duplicate_blocks_refused(self):
block = self.source[self.source.index('```yaml'):self.source.index('```', self.source.index('```yaml') + 7) + 3]
for source in [self.source + '\n' + block + '\n', self.source.replace('version: 0.1', 'version: 0.1\nversion: 0.1'),
self.source.replace(' - rollback', ' - rollback: &alias'),
self.source.replace('version: 0.1', 'version: 0.2')]:
with self.assertRaises(ValueError):
extract_rule(source)
def test_build_published_artifact_equals_canon_and_expiry_is_prominent(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
manifest = _fixture(root)
data = json.loads(manifest.read_text())
document = data['documents'][0]
source = root / 'canon-repo' / document['source_path']
source.write_text(self.source)
document.update(id='canon-build-mode-change-authorization', guardrail_path='guardrails/build-mode-change-authorization.json')
manifest.write_text(json.dumps(data))
records = build(manifest, root / 'build')
artifact = root / 'build' / document['guardrail_path']
self.assertEqual(json.loads(artifact.read_text()), extract_rule(source.read_text()))
page = (root / 'build' / document['canonical_path']).read_text()
self.assertIn('Expires on: production-close-down', page[:page.index('</header>')])
self.assertIn('blue_lane_requires:', page)
self.assertIn('guardrail_digest', records[0])
self.assertIn('Build-Mode Change Authorization', (root / 'build/index.html').read_text())
def test_release_verifier_rejects_missing_tampered_or_invalid_guardrail(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
output = _release(root)
manifest_path = output / 'publication-manifest.json'
manifest = json.loads(manifest_path.read_text())
artifact = output / 'guardrails/rule.json'
artifact.parent.mkdir()
good = (json.dumps(self.rule) + '\n').encode()
artifact.write_bytes(good)
document = manifest['documents'][0]
document.update(guardrail_path='guardrails/rule.json', guardrail_digest=hashlib.sha256(good).hexdigest())
manifest_path.write_text(json.dumps(manifest))
verify(output)
artifact.write_bytes(good + b' ')
with self.assertRaisesRegex(ValueError, 'guardrail artifact digest'):
verify(output)
artifact.unlink()
with self.assertRaises(OSError):
verify(output)
invalid = json.dumps(self.rule | {'version': 99}).encode()
artifact.write_bytes(invalid)
document['guardrail_digest'] = hashlib.sha256(invalid).hexdigest()
manifest_path.write_text(json.dumps(manifest))
with self.assertRaisesRegex(ValueError, 'unsupported machine rule'):
verify(output)
def test_guardrail_path_collision_and_traversal_refused(self):
with tempfile.TemporaryDirectory() as directory:
manifest = _fixture(Path(directory))
original = json.loads(manifest.read_text())
for path in ['../outside.json', '/outside.json', 'example.html']:
data = json.loads(json.dumps(original))
data['documents'][0]['guardrail_path'] = path
manifest.write_text(json.dumps(data))
with self.assertRaises(ValueError):
load_manifest(manifest)

View file

@ -17,6 +17,7 @@ import tempfile
from typing import Any
from render import STYLE, render_page, split_frontmatter
from guardrails import extract_rule
SOURCE_REVISION = re.compile(
@ -111,6 +112,7 @@ def load_manifest(path: Path) -> dict[str, Any]:
document["canonical_path"],
document["revision_path"].replace("{revision}", "revision"),
*document.get("legacy_paths", []),
*([document["guardrail_path"]] if document.get("guardrail_path") else []),
*(document["revision_path"].format(revision=h["revision"])
for h in document.get("historical_revisions", [])),
):
@ -397,6 +399,22 @@ def build(
target.parent.mkdir(parents=True, exist_ok=True)
canonical_url = "/" + canonical.as_posix()
target.write_text(_redirect(canonical_url, meta["title"]), encoding="utf-8")
guardrail_metadata = {}
if document.get("guardrail_path"):
rule = extract_rule(source.read_text(encoding="utf-8"))
artifact_path = _safe_path(document["guardrail_path"])
if artifact_path.suffix != ".json":
raise ValueError("guardrail artifact must use a JSON path")
if artifact_path in archived_paths:
raise ValueError("guardrail conflicts with archived immutable path")
artifact_bytes = (json.dumps(rule, indent=2, sort_keys=True) + "\n").encode()
target = temporary / artifact_path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(artifact_bytes)
guardrail_metadata = {
"guardrail_path": artifact_path.as_posix(),
"guardrail_digest": hashlib.sha256(artifact_bytes).hexdigest(),
}
records.append(
{
"id": document["id"],
@ -414,6 +432,7 @@ def build(
"source_path": document["source_path"],
"source_revision": source_revision,
"source_digest": source_digest,
**guardrail_metadata,
}
)

98
tools/guardrails.py Normal file
View file

@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Publish and evaluate the restricted machine-checkable canon rule, fail closed.
This deliberately accepts only scalar mappings and block lists used by the canon
contract. Unsupported YAML, unknown categories and malformed records never grant
execution. Lane classification is not a credential or runtime approval token.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import re
from render import split_frontmatter
def extract_rule(source: str) -> dict:
_meta, body = split_frontmatter(source)
blocks = re.findall(r'^```yaml\n(.*?)^```\s*$', body, re.M | re.S)
candidates = [b for b in blocks if re.search(r'^rule: build-mode-change-authorization\s*$', b, re.M)]
if len(candidates) != 1:
raise ValueError('expected exactly one build-mode authorization YAML block')
rule = {}
current = None
for line in candidates[0].splitlines():
text = line.split('#', 1)[0].rstrip()
if not text.strip():
continue
item = re.fullmatch(r' - ([a-z][a-z0-9_-]*)', text)
if item and current and isinstance(rule[current], list):
rule[current].append(item[1])
continue
field = re.fullmatch(r'([a-z][a-z0-9_]*):(?: ([a-z0-9_.-]+))?', text)
if not field or field[1] in rule:
raise ValueError('unsupported or duplicate machine rule YAML field')
current = field[1]
value = field[2]
rule[current] = [] if value is None else float(value) if re.fullmatch(r'\d+\.\d+', value) else value
validate_rule(rule)
return rule
def validate_rule(rule: dict) -> None:
fields = {'rule', 'version', 'applies_while', 'blue_lane_requires', 'red_lane_categories', 'orange_lane_categories'}
if not isinstance(rule, dict) or set(rule) != fields:
raise ValueError('machine rule fields differ from supported contract')
if (rule['rule'] != 'build-mode-change-authorization' or rule['version'] != 0.1
or rule['applies_while'] != 'build-mode'):
raise ValueError('unsupported machine rule identity, version or lifetime')
for name in ('blue_lane_requires', 'red_lane_categories', 'orange_lane_categories'):
values = rule[name]
if (not isinstance(values, list) or not values
or any(not isinstance(v, str) or not re.fullmatch(r'[a-z][a-z0-9_-]*', v) for v in values)
or len(values) != len(set(values))):
raise ValueError(f'invalid {name}')
if set(rule['red_lane_categories']) & set(rule['orange_lane_categories']):
raise ValueError('lane categories overlap')
def evaluate(rule: dict, record: dict, *, build_mode: bool) -> dict:
validate_rule(rule)
if not isinstance(record, dict):
raise ValueError('change record must be an object')
if not build_mode:
return {'lane': 'incomplete', 'reason': 'standing grant expired or build mode unconfirmed'}
categories = record.get('categories')
if not isinstance(categories, list) or any(not isinstance(c, str) for c in categories):
return {'lane': 'incomplete', 'reason': 'categories must be an explicit list'}
known = set(rule['red_lane_categories']) | set(rule['orange_lane_categories'])
unknown = sorted(set(categories) - known)
if unknown:
return {'lane': 'incomplete', 'unknown_categories': unknown}
for lane in ('red', 'orange'):
declared = sorted(set(categories) & set(rule[f'{lane}_lane_categories']))
if declared:
return {'lane': lane, 'categories': declared}
missing = [key for key in rule['blue_lane_requires']
if not isinstance(record.get(key), str) or not record[key].strip()]
return {'lane': 'incomplete', 'missing': missing} if missing else {'lane': 'blue'}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('rule', type=Path, help='published JSON rule artifact')
parser.add_argument('record', type=Path, help='JSON change record')
parser.add_argument('--build-mode', action='store_true', help='confirm build mode has not closed down')
args = parser.parse_args()
try:
result = evaluate(json.loads(args.rule.read_text()), json.loads(args.record.read_text()), build_mode=args.build_mode)
except (ValueError, OSError, TypeError) as exc:
parser.error(str(exc))
print(json.dumps(result, sort_keys=True))
return 0 if result['lane'] == 'blue' else 1
if __name__ == '__main__':
raise SystemExit(main())

View file

@ -379,6 +379,11 @@ def render_page(
if review_due
else ""
)
expiry_line = (
'<p class="rule-quote"><strong>Expires on: '
f'{html.escape(meta["expires_on"])}</strong></p>'
if meta.get("expires_on") else ""
)
lifecycle = publication.get("lifecycle", "active")
successor = publication.get("successor", "")
lifecycle_notice = ""
@ -428,6 +433,7 @@ def render_page(
+ (f'<p class="sub">{html.escape(subtitle)}</p>' if subtitle else "")
+ source_line
+ review_line
+ expiry_line
+ '</header><div class="layout">'
f'<nav class="rail" aria-label="Sections"><ol>{rail_html}</ol></nav>'
f"<main>{lifecycle_notice}{currency_notice}{body}"

View file

@ -121,6 +121,12 @@ def verify(build: Path) -> dict[str, Any]:
raise ValueError(
f"{document_id}: immutable revision page was not built from a clean Git commit"
)
if document.get("guardrail_path"):
artifact = build / _safe_relative(document["guardrail_path"])
if hashlib.sha256(artifact.read_bytes()).hexdigest() != document.get("guardrail_digest"):
raise ValueError(f"{document_id}: guardrail artifact digest differs from manifest")
from guardrails import validate_rule
validate_rule(json.loads(artifact.read_text(encoding="utf-8")))
verified.append(document_id)
return {

View file

@ -4,7 +4,7 @@ type: workplan
title: "Publish the build-mode change authorization guardrail"
domain: infotech
repo: policy-nexus
status: ready
status: blocked
owner: codex
topic_slug: custodian
created: "2026-10-11"
@ -23,8 +23,10 @@ repo's intent: publication only, the content stays in canon.
```task
id: PNEX-WP-0006-T01
status: todo
status: wait
priority: high
needs_human: true
intervention_note: "Public publication is red-lane; add PNEX-WP-0006 to the single daily founder batch. Local code and tests are prepared; no per-item approval request."
state_hub_task_id: "ee3c6f88-91b5-5dac-94e7-d1b05b0276bc"
```
@ -35,8 +37,10 @@ machine-checkable YAML block intact. Show `expires_on: production-close-down` pr
```task
id: PNEX-WP-0006-T02
status: todo
status: wait
priority: high
needs_human: true
intervention_note: "Public publication is red-lane; add PNEX-WP-0006 to the single daily founder batch. Local code and tests are prepared; no per-item approval request."
state_hub_task_id: "92247888-82ad-52ca-962f-1a578adeb9f7"
```
@ -44,3 +48,26 @@ Serve the YAML block as a stable JSON/YAML artifact (for example
`/guardrails/build-mode-change-authorization.json`). CI checks in other repos and access-engine
can then fetch and evaluate it deterministically. Add a test that the published artifact equals
the canon block.
## Implementation and publication boundary (2026-10-11)
T01/T02 source implementation is prepared: canonical document inventory/index
entry, visible expiry, canon YAML-derived JSON artifact, digest-bound release
verification, and fail-closed local evaluator. Local checks pass; the native
public page/artifact have not been published and neither task is closed.
Validation receipt: `docs/evidence/2026-10-11-build-mode-guardrail/validation.json`.
Daily red-lane package: publish only the new build-mode standard and its derived
JSON artifact after founder batch disposition and clean source provenance.
Expected addresses: `/standards/build-mode-change-authorization/v0.1/` and
`/guardrails/build-mode-change-authorization.json`. Abort owner: policy-nexus.
Rollback: restore the previously deployed image/source revision; retain all
previous immutable publication paths. Verification: canonical page expiry/YAML,
JSON equality with the approved canon block, artifact digest and installed
source revision. No native action, access-scope change or new spend is authorized
by local preparation.
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.