RPF-WP-0061-T04: request-decision opens the founder hub decision; CCR-2026-0051 decision e633607e
Some checks failed
CI Smoke / host-smoke (push) Waiting to run
CI Smoke / container-smoke (push) Waiting to run
CCR intake gate / gate (push) Has been cancelled

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 306568@bnt-lap001
Assistant-Session: 8bdf8150-ea56-41e7-9132-13f04f6b0b91
This commit is contained in:
codex 2026-10-18 12:40:36 +02:00
parent 3d971178d8
commit 8545bf8e4c
4 changed files with 110 additions and 1 deletions

View file

@ -99,3 +99,6 @@ lifecycle:
metadata version only. metadata version only.
compromised: Revoke the SMTP credential at Scaleway, disable the role, rotate the compromised: Revoke the SMTP credential at Scaleway, disable the role, rotate the
KV value, then re-enable after review. KV value, then re-enable after review.
state_hub:
decision_id: e633607e-ff3e-46ec-bb6f-143e8b44f1af
decision_api_url: http://127.0.0.1:8000/decisions/e633607e-ff3e-46ec-bb6f-143e8b44f1af

View file

@ -29,6 +29,7 @@ CCR_FILE_RE = re.compile(r"^(CCR-\d{4}-\d{4})(-[a-z0-9-]+)?\.ya?ml$")
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
TTL_UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400} TTL_UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
PLATFORM_OWNER = "railiance-platform-owner" PLATFORM_OWNER = "railiance-platform-owner"
PLATFORM_TOPIC_ID = "ca369340-a64e-442e-98f1-a4fa7dc74a38"
def load_profile(path: Path = DEFAULT_PROFILE) -> tuple[dict[str, Any], str]: def load_profile(path: Path = DEFAULT_PROFILE) -> tuple[dict[str, Any], str]:
@ -278,6 +279,63 @@ def command_check_branch(args: argparse.Namespace, cc: Any) -> int:
return 1 if errors else 0 return 1 if errors else 0
def decision_payload(ccr: dict[str, Any], cc: Any) -> dict[str, Any]:
openbao = ccr["openbao"]
auth = openbao["auth"]
review = ccr["review"]["comments"][-1]
description = "\n".join([
f"Founder decision for {ccr['id']}: {ccr.get('title')}.",
"",
"Non-secret request summary:",
f"- Requester: {(ccr.get('requester') or {}).get('agent')}",
f"- OpenBao path: {openbao.get('kv_path')} fields {openbao.get('fields')}",
f"- Policy: {openbao.get('policy_name')} ({openbao.get('policy_file')})",
f"- Auth: {auth.get('method')} role {auth.get('role')}, SA {bound_list(auth, 'service_account_names')}"
f" in {bound_list(auth, 'service_account_namespaces')}, ttl {auth.get('ttl')}",
f"- Risk: {(ccr.get('risk') or {}).get('classification')}",
f"- Platform review: {review.get('reviewer')}: {review.get('comment')}",
"",
cc.render_decision_templates(ccr),
"",
"Approval permits apply after confirm-binding; it exposes and stores no value.",
])
return {
"topic_id": PLATFORM_TOPIC_ID,
"title": f"Review {ccr['id']} {ccr.get('title')}",
"description": description,
"escalation_note": "Founder decision required before OpenBao credential lane apply.",
}
def request_decision(path: Path, cc: Any, base_url: str) -> dict[str, Any]:
"""Open the founder's State Hub decision for a platform-reviewed CCR (RPF-WP-0061-T04)."""
ccr, _warnings = cc.validate_or_exit(path)
if ccr.get("status") != "proposed":
cc.fail(f"request-decision requires status proposed, got {ccr.get('status')}")
if (ccr.get("state_hub") or {}).get("decision_id"):
cc.fail(f"{ccr['id']} already has State Hub decision {ccr['state_hub']['decision_id']}")
comments = (ccr.get("review") or {}).get("comments") or []
last = comments[-1] if comments else {}
if last.get("decision") != "approved" or not str(last.get("reviewer", "")).startswith(PLATFORM_OWNER):
cc.fail("request-decision requires a platform-owner approval as the latest review comment")
decision = cc.state_hub_post_json(base_url, "/decisions/", decision_payload(ccr, cc))
if not decision.get("id") or decision.get("status") not in (None, "open"):
cc.fail(f"State Hub returned an unexpected decision: {decision.get('id')} {decision.get('status')}")
state_hub = ccr.setdefault("state_hub", {})
state_hub["decision_id"] = decision["id"]
state_hub["decision_api_url"] = f"{base_url.rstrip('/')}/decisions/{decision['id']}"
ccr["updated"] = datetime.now(timezone.utc).date().isoformat()
cc.dump_yaml(path, ccr)
return decision
def command_request_decision(args: argparse.Namespace, cc: Any) -> int:
path = cc.resolve_ccr(args.ref)
decision = request_decision(path, cc, args.state_hub_url)
print(f"[OK] {path.name}: State Hub decision {decision['id']} opened; commit the CCR file")
return 0
def git(*argv: str) -> None: def git(*argv: str) -> None:
subprocess.run(["git", "-C", str(REPO_DIR), *argv], check=True) subprocess.run(["git", "-C", str(REPO_DIR), *argv], check=True)
@ -359,6 +417,13 @@ def add_parsers(sub: Any, cc: Any, state_hub_url: str) -> None:
check_branch.add_argument("--main-root", required=True, help="tree of main to compare against") check_branch.add_argument("--main-root", required=True, help="tree of main to compare against")
check_branch.set_defaults(func=lambda args: command_check_branch(args, cc)) check_branch.set_defaults(func=lambda args: command_check_branch(args, cc))
decision_p = sub.add_parser(
"request-decision", help="Open the founder State Hub decision for a platform-reviewed proposed CCR"
)
decision_p.add_argument("ref")
decision_p.add_argument("--state-hub-url", default=state_hub_url)
decision_p.set_defaults(func=lambda args: command_request_decision(args, cc))
submit_p = sub.add_parser( submit_p = sub.add_parser(
"submit", "submit",
help="File a client CCR draft under the next CCR id and run the standard review", help="File a client CCR draft under the next CCR id and run the standard review",

View file

@ -247,6 +247,32 @@ class CcrIntakeTests(unittest.TestCase):
errors = ccr_intake.branch_gate(main_root, Path(STANDARD).stem, cc) errors = ccr_intake.branch_gate(main_root, Path(STANDARD).stem, cc)
self.assertTrue(any("already exists on main" in e for e in errors), errors) self.assertTrue(any("already exists on main" in e for e in errors), errors)
def test_request_decision_records_id_after_platform_review(self) -> None:
ccr_intake.review_standard(self.path, cc)
with mock.patch.object(cc, "state_hub_post_json", return_value={"id": "dec-1", "status": "open"}) as post:
ccr_intake.request_decision(self.path, cc, "http://hub")
payload = post.call_args.args[2]
self.assertIn("APPROVE: CCR-2026-0051", payload["description"])
self.assertNotIn("status", payload)
state_hub = cc.load_yaml(self.path)["state_hub"]
self.assertEqual(state_hub["decision_id"], "dec-1")
self.assertEqual(cc.load_yaml(self.path)["status"], "proposed")
with self.assertRaises(SystemExit):
ccr_intake.request_decision(self.path, cc, "http://hub")
def test_request_decision_requires_platform_approval(self) -> None:
self.edit(lambda d: d["risk"].update(classification="high"))
ccr_intake.review_standard(self.path, cc)
with mock.patch.object(cc, "state_hub_post_json") as post, self.assertRaises(SystemExit):
ccr_intake.request_decision(self.path, cc, "http://hub")
post.assert_not_called()
def test_request_decision_refuses_pre_resolved_decision(self) -> None:
ccr_intake.review_standard(self.path, cc)
resolved = {"id": "dec-2", "status": "resolved"}
with mock.patch.object(cc, "state_hub_post_json", return_value=resolved), self.assertRaises(SystemExit):
ccr_intake.request_decision(self.path, cc, "http://hub")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -143,11 +143,26 @@ fact.
```task ```task
id: RPF-WP-0061-T04 id: RPF-WP-0061-T04
status: todo status: progress
priority: medium priority: medium
state_hub_task_id: "15ac9eae-9890-5140-b1ca-cd0ad3ca77ac" state_hub_task_id: "15ac9eae-9890-5140-b1ca-cd0ad3ca77ac"
``` ```
2026-10-18: `credential-change.py request-decision <ccr>` opens the founder
decision for a `proposed` CCR whose latest review is a platform-owner
approval, refuses a second decision or a decision returned already resolved,
and writes `state_hub.decision_id` into the CCR. The existing `sync-decision`
moves the file. First use: CCR-2026-0051, decision e633607e. The CI gate
passed on main at 3d97117 after adding the jsonschema pin.
Open, needs a founder choice: the hub does not authenticate decisions.
`decided_by` on resolve is free text, and `POST /decisions/` accepts an
already-resolved decision. With self-service, the requester could resolve its
own CCR decision and run `sync-decision`. This gap existed before (the
platform worker never verified the resolver) but self-service makes it
cheaper to exploit. Until it is closed, `sync-decision` on a self-service CCR
stays a platform/founder step.
## T05 — Client documentation and first self-service CCR ## T05 — Client documentation and first self-service CCR
```task ```task