diff --git a/api/services/repo_manager_dual_run.py b/api/services/repo_manager_dual_run.py index 2d2e8ce..49b9bed 100644 --- a/api/services/repo_manager_dual_run.py +++ b/api/services/repo_manager_dual_run.py @@ -249,6 +249,166 @@ def rm_update_task_status( return result +def rm_update_workplan( + *, + repo_path: str | Path, + workplan_id: str, + operation: str = "update", + title: str | None = None, + goal: str | None = None, + status: str | None = None, + owner: str | None = None, + domain: str | None = None, + topic_slug: str | None = None, + repo_slug: str | None = None, + reason: str = "state-hub dual-run", + push: bool | None = None, + correlation_id: str | None = None, + confirm_archive: bool = False, +) -> dict[str, Any]: + """Delegate a file-backed workplan mutation to Repo Manager. + + ``operation=archive`` is the recoverable counterpart of State Hub's + DELETE workplan route; it never erases repository history. + """ + correlation_id = correlation_id or str(uuid.uuid4()) + if operation not in {"create", "update", "archive"}: + return { + "status": "rejected", + "correlation_id": correlation_id, + "error": {"code": "validation_error", "message": f"invalid operation {operation!r}"}, + } + if push is None: + push = writeback_push_enabled() + cli_operation = "delete" if operation == "archive" else operation + args = [ + "workplan", + cli_operation, + "--path", + str(repo_path), + "--workplan-id", + str(workplan_id), + "--reason", + reason, + "--correlation-id", + correlation_id, + "--idempotency-key", + f"sh-dual-workplan-{operation}-{workplan_id}-{correlation_id}", + ] + for flag, value in ( + ("--title", title), + ("--goal", goal), + ("--status", status), + ("--owner", owner), + ("--domain", domain), + ("--topic-slug", topic_slug), + ("--slug", repo_slug), + ): + if value is not None: + args.extend([flag, value]) + if operation == "archive" and confirm_archive: + args.append("--confirm") + if push: + args.append("--push") + + code, out, err = run_rmgr(args) + try: + result = json.loads(out.strip() or "{}") + except json.JSONDecodeError: + result = { + "status": "failed", + "error": { + "code": "internal", + "message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}", + }, + "correlation_id": correlation_id, + } + if code != 0 and result.get("status") not in ("applied", "rejected"): + result.setdefault("status", "failed") + result.setdefault( + "error", + {"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"}, + ) + return result + + +def rm_update_register_entry( + *, + repo_path: str | Path, + kind: str, + entry_id: str, + operation: str = "put", + title: str | None = None, + status: str | None = None, + data: dict[str, Any] | None = None, + note: str | None = None, + author: str | None = None, + repo_slug: str | None = None, + reason: str = "state-hub retirement adapter", + push: bool | None = None, + correlation_id: str | None = None, +) -> dict[str, Any]: + """Delegate register create/update/defer/note to Repo Manager.""" + correlation_id = correlation_id or str(uuid.uuid4()) + if operation not in {"put", "defer", "note"}: + return { + "status": "rejected", + "correlation_id": correlation_id, + "error": {"code": "validation_error", "message": f"invalid operation {operation!r}"}, + } + if push is None: + push = writeback_push_enabled() + args = [ + "register", + operation, + "--path", + str(repo_path), + "--kind", + kind, + "--entry-id", + entry_id, + "--reason", + reason, + "--correlation-id", + correlation_id, + "--idempotency-key", + f"sh-dual-register-{operation}-{kind}-{entry_id}-{correlation_id}", + ] + for flag, value in ( + ("--title", title), + ("--status", status), + ("--note", note), + ("--author", author), + ("--slug", repo_slug), + ): + if value is not None: + args.extend([flag, value]) + if data is not None: + args.extend(["--data-json", json.dumps(data, separators=(",", ":"))]) + if push: + args.append("--push") + + code, out, err = run_rmgr(args) + try: + result = json.loads(out.strip() or "{}") + except json.JSONDecodeError: + result = { + "status": "failed", + "error": { + "code": "internal", + "message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}", + }, + "correlation_id": correlation_id, + } + if code != 0 and result.get("status") not in ("applied", "rejected"): + result.setdefault("status", "failed") + result.setdefault( + "error", + {"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"}, + ) + return result + + def rm_scaffold( *, repo_path: str | Path, diff --git a/tests/test_repo_manager_workplan_adapter.py b/tests/test_repo_manager_workplan_adapter.py new file mode 100644 index 0000000..5accfb3 --- /dev/null +++ b/tests/test_repo_manager_workplan_adapter.py @@ -0,0 +1,95 @@ +"""Compatibility adapter coverage for RMGR-WP-0008-T01.""" + +from api.services import repo_manager_dual_run as adapter + + +def test_rm_update_workplan_builds_governed_cli_command(monkeypatch): + seen = {} + + def fake_run(args, *, timeout=120): + seen["args"] = args + return 0, '{"status":"applied","evidence":{"git_sha":"abc"}}', "" + + monkeypatch.setattr(adapter, "run_rmgr", fake_run) + result = adapter.rm_update_workplan( + repo_path="/repos/demo", + workplan_id="DEMO-WP-0001", + operation="update", + title="Renamed", + status="active", + repo_slug="demo", + correlation_id="00000000-0000-4000-8000-000000000001", + push=True, + ) + + assert result["status"] == "applied" + assert seen["args"][:5] == [ + "workplan", + "update", + "--path", + "/repos/demo", + "--workplan-id", + ] + assert "--title" in seen["args"] + assert "--status" in seen["args"] + assert "--push" in seen["args"] + + +def test_rm_update_workplan_maps_delete_to_confirmed_archive(monkeypatch): + seen = {} + + def fake_run(args, *, timeout=120): + seen["args"] = args + return 0, '{"status":"applied"}', "" + + monkeypatch.setattr(adapter, "run_rmgr", fake_run) + result = adapter.rm_update_workplan( + repo_path="/repos/demo", + workplan_id="DEMO-WP-0001", + operation="archive", + confirm_archive=True, + push=False, + ) + + assert result["status"] == "applied" + assert seen["args"][0:2] == ["workplan", "delete"] + assert "--confirm" in seen["args"] + assert "--push" not in seen["args"] + + +def test_rm_update_workplan_rejects_unknown_operation_without_invocation(monkeypatch): + def fail_run(*args, **kwargs): + raise AssertionError("rmgr must not run") + + monkeypatch.setattr(adapter, "run_rmgr", fail_run) + result = adapter.rm_update_workplan( + repo_path="/repos/demo", + workplan_id="DEMO-WP-0001", + operation="erase", + ) + assert result["status"] == "rejected" + assert result["error"]["code"] == "validation_error" + + +def test_rm_update_register_entry_builds_shared_spine_command(monkeypatch): + seen = {} + + def fake_run(args, *, timeout=120): + seen["args"] = args + return 0, '{"status":"applied"}', "" + + monkeypatch.setattr(adapter, "run_rmgr", fake_run) + result = adapter.rm_update_register_entry( + repo_path="/repos/demo", + kind="technical-debt", + entry_id="TD-001", + operation="put", + title="Debt", + data={"severity": "high"}, + push=False, + ) + + assert result["status"] == "applied" + assert seen["args"][0:2] == ["register", "put"] + assert "technical-debt" in seen["args"] + assert '{"severity":"high"}' in seen["args"] diff --git a/workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md b/workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md new file mode 100644 index 0000000..aa57338 --- /dev/null +++ b/workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md @@ -0,0 +1,230 @@ +--- +id: STATE-WP-0081 +type: workplan +title: "Cluster self-sufficiency: remove workstation coupling and fix the registrar" +domain: infotech +repo: state-hub +status: proposed +owner: codex +topic_slug: infotech +created: "2026-08-21" +updated: "2026-08-21" +parent_project: prj-state-hub-retirement +parent_workplan: SHR-WP-0001 +related: + - STATE-WP-0079 + - RMGR-WP-0005 + - RMGR-WP-0008 + - ADR-007 + - ADR-010 +--- + +# Cluster self-sufficiency: remove workstation coupling and fix the registrar + +## Goal + +Make the railiance01-hosted State Hub stand on its own: its own clones, its own +git identity, no hostPath into an operator's home directory, and no dependence +on workstation paths, processes, or checkouts. Fix the identifier registrar as +part of that, because the registrar is broken *by* this coupling rather than +beside it. + +End state: **workstation coding agents push to forgejo; cluster infrastructure +reads from forgejo. Neither reads the other's disk.** + +## Admissibility under the retirement freeze + +`policies/retirement-freeze.md` allows changes that fix operational risk or +reduce scope. This is both: the sweep is currently broken in production, and the +work removes a coupling rather than adding capability. It establishes no new +permanent ownership here — the deployment chart already lives in this repo at +`deploy/railiance/apps/charts/state-hub/`. + +## The coupling, as measured 2026-08-21 + +| # | Coupling | Evidence | +| --- | --- | --- | +| 1 | Pod mounts the operator home as a hostPath | `sweep.hostPath: /home/tegwick`, mounted rw at the same path | +| 2 | Pod runs as root | `securityContext: {}`, no `runAsUser`; produced **999 root-owned files** under `~/state-hub` | +| 3 | Pod uses the operator's personal SSH key as its service identity | `sweep.sshHostPath: /home/tegwick/.ssh` → `/root/.ssh` | +| 4 | Hub repo registry points at workstation paths | **72 of 75** records have `local_path: /home/worsch/...`, a path railiance01 will never have | +| 5 | Hub repo registry records the retired git host | `remote_url: gitea-remote:...` after the 2026-08-21 forgejo transition | +| 6 | Dashboard is a workstation process | Observable dev server on `127.0.0.1:3000`, not served from the cluster | + +**And the fault that blocks everything:** inside the pod `/home/tegwick` is +mounted **read-only** — `ro,relatime,discard,errors=remount-ro` — although the +chart sets no `readOnly` on that volumeMount and `securityContext` is empty. The +host has the same device mounted `rw` with a healthy disk and no dmesg errors, so +this is imposed by the runtime or an admission path, not by hardware. + +The sweep therefore dies with +`OSError: [Errno 30] Read-only file system: '/home/tegwick/info-tech-canon/.custodian-brief.md'` +before it can mint anything. That is the whole registrar problem: **the registrar +cannot write identifiers into files it cannot write.** It explains the 12 queued +sync requests and why the newest `custodian-sync` commits are from July. + +## Restore the write path + +```task +id: STATE-WP-0081-T01 +status: todo +priority: high +``` + +Find why the hostPath mounts read-only against the chart's own spec, and restore +writes. Candidates in order: an admission controller or PodSecurity policy +forcing hostPath read-only; a k3s/containerd default for hostPath volumes; a +missing explicit `readOnly: false` on the volumeMount. + +This is the minimum to make the registrar function, and the only task here that +unblocks anything today. Do it first even though T02 later deletes the mount — +a working baseline makes every later change verifiable. + +Verification is end-to-end, not a green pod: run the sweep and confirm +`EBIND-WP-0002` gets a `state_hub_workstream_id` written back into its file. + +## Give the pod its own clones + +```task +id: STATE-WP-0081-T02 +status: todo +priority: high +``` + +Replace the `sweep-repos` hostPath with storage the pod owns — a PVC the sweep +clones into and maintains, seeded from forgejo. + +This is the change that actually severs coupling #1 and #2. Mounting a human's +home directory into a production workload is what created 999 root-owned files, +what made `git pull` fail on the host, and what put the operator's checkouts one +`reset --hard` away from a scheduled job. + +Sizing input: the current tree is ~78 repos; `markitect_project` alone is 24 MB. + +Keep the sweep's repo list driven by the hub's repo registry, not by whatever +happens to be on a disk. + +## Replace the operator SSH key with a service identity + +```task +id: STATE-WP-0081-T03 +status: todo +priority: high +``` + +Issue a dedicated forgejo deploy key or service account for the sweep and mount +it from a Secret. Remove `sweep.sshHostPath` and the `/root/.ssh` mount. + +The current arrangement gives a root-running production workload the operator's +personal private key. It is read-only, so this is a blast-radius problem rather +than a live compromise — but the key that can push to every repository in the +fleet should not be the same key a human uses interactively. + +Credential custody routes through OpenBao, not this repo — see +`.claude/rules/credential-routing.md`. Do not put key material in the chart. + +## Run as a non-root user + +```task +id: STATE-WP-0081-T04 +status: todo +priority: medium +``` + +Set `runAsUser`/`runAsGroup` and a `fsGroup` matching the PVC. Depends on T02: +once the pod owns its storage there is no reason for it to be root. + +Closes the recurrence: today's ownership fix will be undone by the next sweep +while the pod still runs as root. + +## Correct the hub repo registry + +```task +id: STATE-WP-0081-T05 +status: todo +priority: high +``` + +72 of 75 repo records carry `local_path: /home/worsch/...` and stale +`remote_url: gitea-remote:...`. Both are wrong for a cluster that reads from +forgejo into its own clone tree. + +Decide first whether `local_path` should be **per-instance rather than global** — +one column cannot describe a workstation checkout and a cluster clone at once, +and its current single value is precisely how the workstation leaked into +cluster configuration. `RMGR-WP-0008` is building the repository-representation +surface that inherits this; settle the shape with it rather than patching values +that will move. + +`remote_url` correction is unambiguous and can proceed immediately. + +## Serve the dashboard from the cluster + +```task +id: STATE-WP-0081-T06 +status: todo +priority: medium +``` + +The dashboard runs as an Observable dev server on the workstation at +`127.0.0.1:3000`. Serve it from the cluster behind the same ingress as the API so +it survives the workstation being off, and so what the operator sees is what the +cluster holds. + +Check first whether this is worth building here at all: `hub-projection-ui` is +dispositioned `replace` → `hub-core` (14 items, slice B5 in +`docs/retirement-cutover-slice-plan.md`). If B5 lands first this task is a +redirect, not a build. Confirm with `HUB-WP-0004` before writing any chart. + +## State and enforce the boundary + +```task +id: STATE-WP-0081-T07 +status: todo +priority: medium +``` + +Write the rule down so it stops being re-derived: **workstation coding agents +push to forgejo; cluster infrastructure reads from forgejo; neither reads the +other's disk.** + +Record it where agents will meet it — `docs/`, the repo `AGENTS.md` templates, and +as an ADR if it constrains other repos, which it does. + +Include the failure this prevents. On 2026-08-21 railiance01's 70 checkouts were +found still pointed at the retired gitea host, six weeks stale, and the +`evidence-binder` workplan the registrar was asked to index **did not exist** in +the copy the cluster could see. A shared-disk assumption made a stale reader look +like a queue. + +## Close out the registrar + +```task +id: STATE-WP-0081-T08 +status: todo +priority: high +``` + +With the write path restored, confirm the registrar end-to-end and drain the +backlog: 12 queued requests from `evidence-binder`, `kaizen-agentic`, +`glas-harness`, and `agentic-resources`, plus this fleet's own unregistered +`RMGR-WP-0008` and `RMGR-WP-0009`. + +Then remove the interim: `RMGR-WP-0005-T03` (UUIDv5 derivation, keyed on +`(namespace, identifier)` and deriving for live records only per the 2026-08-21 +`ADR-007` amendment) makes writeback idempotent and retires the single-writer +rule entirely. Coordinate rather than duplicate — the derivation belongs to +`repo-manager`. + +Reply to the queued agents when it is done; several have been waiting since +2026-08-20. + +## Acceptance + +- [ ] Sweep writes successfully from the pod; `EBIND-WP-0002` registered +- [ ] Pod uses its own clone volume; no hostPath into any home directory +- [ ] Pod authenticates with a dedicated key, runs as non-root +- [ ] No `/home/worsch` path in any cluster-consumed record; `remote_url` values current +- [ ] Dashboard reachable without the workstation, or formally handed to `hub-core` +- [ ] Boundary rule written and discoverable by agents +- [ ] Registrar queue drained; interim single-writer rule retired or explicitly deferred