diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/state-hub/Makefile b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/state-hub/Makefile new file mode 100644 index 00000000..7925592e --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/state-hub/Makefile @@ -0,0 +1,517 @@ +.PHONY: start install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard dashboard-local sync check-primary primary-port-clear check check-local test test-python clean register-project register-codex-project register-mcp configure-codex bootstrap-env dev-hub edge-relay mcp-profile validate-adr add-domain rename-domain add-repo list-repos register-path register-from-classification register-from-classification-all cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory token-reconcile railiance-state-hub-render railiance-state-hub-client-dry-run railiance-state-hub-server-dry-run railiance-state-hub-headroom + +COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env +PYTHON ?= python3 +HELM ?= $(shell command -v helm 2>/dev/null || if [ -x "$$HOME/.local/bin/helm" ]; then printf "%s" "$$HOME/.local/bin/helm"; else printf "%s" "helm"; fi) +KUBECTL ?= $(shell command -v kubectl 2>/dev/null || if [ -x "$$HOME/.local/bin/kubectl" ]; then printf "%s" "$$HOME/.local/bin/kubectl"; else printf "%s" "kubectl"; fi) + +RAILIANCE_STATE_HUB_RELEASE ?= state-hub +RAILIANCE_STATE_HUB_NAMESPACE ?= state-hub +RAILIANCE_STATE_HUB_CHART ?= deploy/railiance/apps/charts/state-hub +RAILIANCE_STATE_HUB_VALUES ?= deploy/railiance/apps/helm/state-hub-values.yaml +RAILIANCE_STATE_HUB_IMAGE_TAG ?= main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1 +RAILIANCE_STATE_HUB_PLATFORM_DIR ?= deploy/railiance/platform +RAILIANCE_STATE_HUB_APP_MANIFESTS ?= deploy/railiance/apps/manifests +# Codex/WSL non-login shells may not source ~/.profile; keep uv discoverable. +UV ?= $(shell command -v uv 2>/dev/null || if [ -x "$$HOME/.local/bin/uv" ]; then printf "%s" "$$HOME/.local/bin/uv"; else printf "%s" "uv"; fi) +RMGR ?= $(shell command -v rmgr 2>/dev/null || if [ -x "$$HOME/repo-manager/.venv/bin/rmgr" ]; then printf "%s" "$$HOME/repo-manager/.venv/bin/rmgr"; else printf "%s" "rmgr"; fi) +STATE_HUB_API_BASE ?= http://127.0.0.1:8000 +SYNC_PATH ?= . +SYNC_PUSH ?= 1 + +start: + @echo "# Normal production UI access (run in order)" + @echo "make bridges # connect local :8000 to the railiance01 primary" + @echo "make dashboard # verify the primary, then serve the UI on :3000" + @echo "make sync # push file-backed records and reconcile the exact forge commit" + @echo "" + @echo "# Optional local MCP adapter" + @echo "make mcp-http # local SSE adapter on :8001; not needed by the UI" + @echo "" + @echo "# Local fallback/development only" + @echo "make api # local Postgres + API; conflicts with the production :8000 tunnel" + @echo "make dashboard-local # dashboard against the deliberate local API" + +install: + $(UV) sync + +dashboard/node_modules/.bin/observable: dashboard/package.json dashboard/package-lock.json + cd dashboard && npm ci + +dashboard-install: dashboard/node_modules/.bin/observable + +dashboard-check: dashboard-install + cd dashboard && npm run build + +## Symlink the custodian CLI into ~/.local/bin so it's on PATH system-wide +install-cli: install + mkdir -p ~/.local/bin + ln -sf "$(shell pwd)/.venv/bin/custodian" ~/.local/bin/custodian + ln -sf "$(shell pwd)/.venv/bin/statehub" ~/.local/bin/statehub + @echo "Installed: custodian → $$(readlink -f ~/.local/bin/custodian)" + @echo "Installed: statehub → $$(readlink -f ~/.local/bin/statehub)" + @echo "Make sure ~/.local/bin is on your PATH:" + @echo " echo 'export PATH=\"\$$HOME/.local/bin:\$$PATH\"' >> ~/.bashrc && source ~/.bashrc" + +db: + $(COMPOSE) up -d postgres + +db-tools: + $(COMPOSE) --profile tools up -d + +migrate: + $(UV) run alembic upgrade head + +seed: + $(UV) run python scripts/seed.py + +## Start (or restart) the MCP SSE server on :8001 — primary transport for Claude Code. +## Remote clients (e.g. COULOMBCORE) connect via the ops-bridge tunnel (port 18001). +## Registration: claude mcp add-json -s user state-hub '{"type":"sse","url":"http://127.0.0.1:8001/sse"}' +mcp-http: + @fuser -k 8001/tcp 2>/dev/null && echo "Stopped running MCP server" || true + MCP_TRANSPORT=sse MCP_PORT=8001 $(UV) run python mcp_server/server.py + +## Require the production identity before serving the normal dashboard. This +## prevents an accidental local `make api` from presenting an empty fallback DB +## as the live State Hub. +check-primary: + @health="$$(curl -fsS --max-time 5 http://127.0.0.1:8000/state/health 2>/dev/null)" || { \ + echo "ERROR: State Hub primary is not reachable on 127.0.0.1:8000." >&2; \ + echo "Run 'make bridges' first." >&2; \ + exit 1; \ + }; \ + identity="$$(printf '%s' "$$health" | $(PYTHON) -c 'import json, sys; d=json.load(sys.stdin); print("{}/{}".format(d.get("instance_role", ""), d.get("instance_label", "")))')"; \ + if [ "$$identity" != "primary/railiance01" ]; then \ + echo "ERROR: 127.0.0.1:8000 is '$$identity', not the primary/railiance01 State Hub." >&2; \ + echo "Stop the local API, then run 'make bridges'. Use 'make dashboard-local' only for intentional local development." >&2; \ + exit 1; \ + fi + +## Refuse to start the production tunnel when a non-primary API already owns +## port 8000. With no listener, bridge is free to establish the tunnel. +primary-port-clear: + @if health="$$(curl -fsS --max-time 2 http://127.0.0.1:8000/state/health 2>/dev/null)"; then \ + identity="$$(printf '%s' "$$health" | $(PYTHON) -c 'import json, sys; d=json.load(sys.stdin); print("{}/{}".format(d.get("instance_role", ""), d.get("instance_label", "")))')"; \ + if [ "$$identity" != "primary/railiance01" ]; then \ + echo "ERROR: port 8000 is occupied by '$$identity'." >&2; \ + echo "Stop the local 'make api' process before starting the production tunnel." >&2; \ + exit 1; \ + fi; \ + fi + +dashboard: check-primary + @fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true + $(MAKE) dashboard-install + cd dashboard && npm run dev + +## Deliberate local-development dashboard; bypasses the production identity gate. +dashboard-local: + @echo "WARNING: serving the dashboard against the local/fallback API on :8000." + @fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true + $(MAKE) dashboard-install + cd dashboard && npm run dev + +## Fast work-record path. Repo Manager assigns only missing deterministic IDs, +## verifies the primary/railliance01 bridge, and asks central to derive the +## exact pushed Forgejo commit in one transactional request. +sync: + $(RMGR) sync --path "$(SYNC_PATH)" --api-base "$(STATE_HUB_API_BASE)" $(if $(filter 1 true yes,$(SYNC_PUSH)),--push,) + +check: check-primary + @echo "State Hub primary/railiance01 is healthy." + +check-local: + curl -sf http://127.0.0.1:8000/state/health | python3 -m json.tool + +# CUST-WP-0067-T09. The chart ships a copy of the-custodian canon allowed-values +# because a container has no checkout to read it from. Regenerate after any +# canon vocabulary change; the -check target fails on drift so a release cannot +# validate classification against a stale vocabulary. +sync-classification-allowed: + python3 scripts/sync_classification_allowed.py + +check-classification-allowed: + python3 scripts/sync_classification_allowed.py --check + +# STATE-WP-0091: read-only surge-headroom check. Does not start Helm. +# Live: make railiance-state-hub-headroom +# Fixtures: make railiance-state-hub-headroom HEADROOM_FIXTURE=tests/fixtures/release_headroom/rev63-105m.json +HEADROOM_FIXTURE ?= +railiance-state-hub-headroom: + @if [ -n "$(HEADROOM_FIXTURE)" ]; then \ + $(PYTHON) scripts/release_headroom_preflight.py --fixture "$(HEADROOM_FIXTURE)"; \ + else \ + $(PYTHON) scripts/release_headroom_preflight.py --kubectl "$(KUBECTL)"; \ + fi + +railiance-state-hub-render: check-classification-allowed + $(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \ + --namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \ + -f $(RAILIANCE_STATE_HUB_VALUES) \ + --set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG) + +railiance-state-hub-client-dry-run: + @set -e; \ + tmpdir="$$(mktemp -d)"; \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + $(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \ + --namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \ + -f $(RAILIANCE_STATE_HUB_VALUES) \ + --set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG) > "$$tmpdir/state-hub.yaml"; \ + $(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-credentials.sops.yaml.template; \ + $(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-cluster.yaml; \ + $(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-networkpolicies.yaml; \ + $(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-namespace.yaml; \ + $(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \ + $(KUBECTL) apply --dry-run=client -n $(RAILIANCE_STATE_HUB_NAMESPACE) -f "$$tmpdir/state-hub.yaml" + +railiance-state-hub-server-dry-run: + @set -e; \ + tmpdir="$$(mktemp -d)"; \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + $(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \ + --namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \ + -f $(RAILIANCE_STATE_HUB_VALUES) \ + --set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG) > "$$tmpdir/state-hub.yaml"; \ + $(KUBECTL) apply --server-side --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-credentials.sops.yaml.template; \ + $(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-cluster.yaml; \ + $(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-networkpolicies.yaml; \ + $(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-namespace.yaml; \ + if $(KUBECTL) get namespace $(RAILIANCE_STATE_HUB_NAMESPACE) >/dev/null 2>&1; then \ + $(KUBECTL) apply --server-side --dry-run=server -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \ + $(KUBECTL) apply --dry-run=server -n $(RAILIANCE_STATE_HUB_NAMESPACE) -f "$$tmpdir/state-hub.yaml"; \ + else \ + echo "Namespace $(RAILIANCE_STATE_HUB_NAMESPACE) does not exist; validating namespaced app manifests with client dry-run."; \ + $(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-namespace.yaml; \ + $(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \ + $(KUBECTL) apply --dry-run=client -n $(RAILIANCE_STATE_HUB_NAMESPACE) -f "$$tmpdir/state-hub.yaml"; \ + fi + +test: test-python dashboard-check + +test-python: + TEST_DATABASE_URL=postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian_test \ + $(UV) run pytest -x -q + +## Benchmark /state/summary revision cache (API must be running on :8000) +benchmark-summary-cache: + $(UV) run python scripts/benchmark_summary_cache.py + +## ops-bridge managed tunnels +## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge +tunnels-up: primary-port-clear + bridge up + +tunnels-status: + bridge status + +## End-to-end check: verifies SSH process alive + remote port listening on COULOMBCORE. +## Exits non-zero if any tunnel is not fully operational. +tunnels-check: + bridge check + +## Ensure all ops-bridge tunnels are up and healthy. +## Brings up any stopped/stale tunnels, shows final status, exits non-zero if anything is still down. +bridges: primary-port-clear + @echo "==> Bringing up all tunnels..." + bridge up + @echo "" + @echo "==> Tunnel status:" + bridge status + @echo "" + @echo "==> Checking tunnel health..." + bridge check + +## Start (or restart) the LOCAL FALLBACK backend — db + migrate + uvicorn. +## This replaces anything on :8000, including the production State Hub tunnel. +api: db + @echo "WARNING: starting the local fallback API; this is not the railiance01 primary." + @echo "Waiting for postgres..."; \ + for i in 1 2 3 4 5 6 7 8 9 10; do \ + nc -z 127.0.0.1 5432 2>/dev/null && break; \ + sleep 1; \ + done + $(MAKE) migrate + @fuser -k 8000/tcp 2>/dev/null && echo "Stopped running API" || true + $(UV) run uvicorn api.main:app --reload --reload-dir api --reload-dir mcp_server --reload-dir task_flow_engine --host 127.0.0.1 --port 8000 + +## Register a project (Claude Code): make register-project DOMAIN=railiance PROJECT_PATH=/home/worsch/railiance +register-project: + @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required. Usage: make register-project DOMAIN= PROJECT_PATH="; exit 1) + @test -n "$(PROJECT_PATH)" || (echo "ERROR: PROJECT_PATH is required."; exit 1) + scripts/register_project.sh "$(DOMAIN)" "$(PROJECT_PATH)" + +## Register a Codex project (AGENTS.md + HTTP API): make register-codex-project DOMAIN=capabilities PROJECT_PATH=/home/worsch/my-repo +register-codex-project: + @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required. Usage: make register-codex-project DOMAIN= PROJECT_PATH="; exit 1) + @test -n "$(PROJECT_PATH)" || (echo "ERROR: PROJECT_PATH is required."; exit 1) + scripts/register_project.sh "$(DOMAIN)" "$(PROJECT_PATH)" --codex + +## Register State Hub MCP for Claude Code. Optional: make register-mcp MCP_URL=http://127.0.0.1:18001/sse +register-mcp: + scripts/register-mcp.sh \ + $(if $(MCP_URL),--url "$(MCP_URL)",) \ + $(if $(API_BASE),--api-base "$(API_BASE)",) \ + $(if $(DRY_RUN),--dry-run,) + +## Configure Codex workspace-write networking for local State Hub access. +configure-codex: + scripts/configure-codex.sh \ + $(if $(CODEX_HOME),--codex-home "$(CODEX_HOME)",) \ + $(if $(API_BASE),--api-base "$(API_BASE)",) \ + $(if $(WITH_MCP),--with-mcp,) \ + $(if $(DRY_RUN),--dry-run,) + +## Bootstrap a new operator/collaborator environment. Optional: make bootstrap-env ARGS="--install-missing" +bootstrap-env: + scripts/bootstrap-env.sh $(ARGS) + +## Files-first local dev hub (CUST-WP-0054-T07): postgres + migrate + seed + API + repo registration. +## Optional: WITH_EDGE=1 WITH_MCP=1 make dev-hub +## Fleet tunnel when reachable: make dev-hub PROFILE=fleet +dev-hub: + bash scripts/dev_hub_up.sh $(if $(PROFILE),$(PROFILE),dev) + +## Local edge relay for offline write buffering (STATE-WP-0068). +edge-relay: + @fuser -k 18080/tcp 2>/dev/null && echo "Stopped running edge relay" || true + STATEHUB_UPSTREAM_URL=$${STATEHUB_UPSTREAM_URL:-http://127.0.0.1:8000} \ + $(UV) run uvicorn api.edge.relay:app --host 127.0.0.1 --port 18080 + +## Switch MCP/API profile: make mcp-profile PROFILE=dev|fleet|status +mcp-profile: + @test -n "$(PROFILE)" || (echo "ERROR: PROFILE is required (dev|fleet|status)."; exit 1) + bash scripts/mcp_hub_profile.sh "$(PROFILE)" + +## Add a second repo to an existing domain: make add-repo DOMAIN=railiance REPO_PATH=/home/worsch/railiance-infra +add-repo: + @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required."; exit 1) + @test -n "$(REPO_PATH)" || (echo "ERROR: REPO_PATH is required."; exit 1) + scripts/register_project.sh "$(DOMAIN)" "$(REPO_PATH)" --additional + +## Create a new domain: make add-domain DOMAIN=my_domain NAME="My Domain" +add-domain: + @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required (slug)."; exit 1) + @test -n "$(NAME)" || (echo "ERROR: NAME is required (display name)."; exit 1) + curl -sf -X POST http://127.0.0.1:8000/domains/ \ + -H "Content-Type: application/json" \ + -d "{\"slug\": \"$(DOMAIN)\", \"name\": \"$(NAME)\"}" | python3 -m json.tool + +## Rename a domain: make rename-domain DOMAIN=old_slug NEW_SLUG=new_slug NEW_NAME="New Name" +rename-domain: + @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN (old slug) is required."; exit 1) + @test -n "$(NEW_SLUG)" || (echo "ERROR: NEW_SLUG is required."; exit 1) + @test -n "$(NEW_NAME)" || (echo "ERROR: NEW_NAME is required."; exit 1) + curl -sf -X PATCH http://127.0.0.1:8000/domains/$(DOMAIN)/rename \ + -H "Content-Type: application/json" \ + -d "{\"new_slug\": \"$(NEW_SLUG)\", \"new_name\": \"$(NEW_NAME)\"}" | python3 -m json.tool + +## Register this machine's local path for a repo: make register-path REPO=marki-docx PATH=/home/tegwick/marki-docx +register-path: + @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make register-path REPO= PATH="; exit 1) + @test -n "$(PATH)" || (echo "ERROR: PATH is required. Usage: make register-path REPO= PATH="; exit 1) + curl -sf -X POST "http://127.0.0.1:8000/repos/$(REPO)/paths" \ + -H "Content-Type: application/json" \ + -d "{\"host\": \"$$(hostname)\", \"path\": \"$(PATH)\"}" | python3 -m json.tool + +## List repos for a domain: make list-repos DOMAIN=railiance +list-repos: + @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required."; exit 1) + curl -sf "http://127.0.0.1:8000/repos/?domain=$(DOMAIN)" | python3 -m json.tool + +## Ingest SBOM data for a repo (all mechanisms: lockfiles + ansible + sbom-tools.yaml). +## Auto-detect all sources: make ingest-sbom REPO=the-custodian REPO_PATH=/home/worsch/the-custodian +## Single lockfile (explicit): make ingest-sbom REPO=the-custodian LOCKFILE=/path/to/uv.lock +## Dry-run (no submit): make ingest-sbom REPO=the-custodian REPO_PATH=... DRY_RUN=1 +## Tip: run capture-tools first for repos with system-level tool dependencies. +ingest-sbom: + @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1) + $(UV) run python scripts/ingest_sbom.py --repo "$(REPO)" \ + $(if $(LOCKFILE),--lockfile "$(LOCKFILE)") \ + $(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \ + $(if $(DRY_RUN),--dry-run) + +## Ingest capability declarations from SCOPE.md into the catalog. +## Usage: make ingest-capabilities REPO=the-custodian [REPO_PATH=/home/worsch/the-custodian] +## Or: make ingest-capabilities-all +## Add DRY_RUN=1 to preview without writing. +ingest-capabilities: + @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1) + $(UV) run python scripts/ingest_capabilities.py --repo "$(REPO)" \ + $(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \ + $(if $(DRY_RUN),--dry-run) + +ingest-capabilities-all: + $(UV) run python scripts/ingest_capabilities.py --all \ + $(if $(DRY_RUN),--dry-run) + +## Check Repository Definition of Integrated (DoI) criteria for a repo. +## Usage: make check-doi REPO=llm-connect +## Or: make check-doi-all +## Add JSON=1 for machine-readable output. +check-doi: + @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1) + $(UV) run python scripts/check_doi.py --repo "$(REPO)" $(if $(JSON),--json) + +check-doi-all: + $(UV) run python scripts/check_doi.py --all $(if $(JSON),--json) + +## Ingest tpsc.yaml service declarations from a repo into the TPSC catalog. +## Usage: make ingest-tpsc REPO=llm-connect +## Or: make ingest-tpsc-all +## Add DRY_RUN=1 to preview without writing. +ingest-tpsc: + @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1) + $(UV) run python scripts/ingest_tpsc.py --repo "$(REPO)" \ + $(if $(DRY_RUN),--dry-run) + +ingest-tpsc-all: + $(UV) run python scripts/ingest_tpsc.py --all \ + $(if $(DRY_RUN),--dry-run) + +## Run SBOM capture agent for a repo — generates/updates sbom-tools.yaml. +## Usage: make capture-tools REPO=railiance-infra [REPO_PATH=/home/worsch/railiance-infra] +## Add DRY_RUN=1 to preview without writing. +capture-tools: + @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1) + $(UV) run python scripts/capture_sbom_tools.py --repo "$(REPO)" \ + $(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \ + $(if $(DRY_RUN),--dry-run) + +## Check a repo for ADR-001 compliance: make validate-adr REPO=/path/to/repo [DOMAIN=custodian] +validate-adr: + @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make validate-adr REPO= [DOMAIN=]"; exit 1) + $(UV) run python scripts/validate_repo_adr.py "$(REPO)" $(if $(DOMAIN),--domain "$(DOMAIN)",) + +## Consistency exit contract: +## - Direct scripts/consistency_check.py: 0 clean, 2 warnings-only, 1 failures. +## - Agent/operator Make wrappers below normalize warning-only 2 to shell success +## while preserving visible WARN output and keeping real failures non-zero. +## Check a single repo for ADR-001 consistency: make check-consistency REPO=the-custodian [REPO_PATH=/override] +## Exit 0 = clean or warnings-only (warnings stay visible), exit 1 = failures +check-consistency: + @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make check-consistency REPO="; exit 1) + $(UV) run python scripts/consistency_check.py --repo "$(REPO)" \ + $(if $(API_BASE),--api-base "$(API_BASE)",) \ + $(if $(REPO_PATH),--repo-path "$(REPO_PATH)",); \ + e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e + +## Check and auto-fix a single repo: make fix-consistency REPO=the-custodian [REPO_PATH=/override] +## Exit 0 = clean or warnings-only (warnings stay visible), exit 1 = failures +fix-consistency: + @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make fix-consistency REPO="; exit 1) + $(UV) run python scripts/consistency_check.py --repo "$(REPO)" --fix \ + $(if $(API_BASE),--api-base "$(API_BASE)",) \ + $(if $(REPO_PATH),--repo-path "$(REPO_PATH)",); \ + e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e + +## Normalize workplan frontmatter and task status literals in attached repos. +## Usage: make normalize-attached-workplans REPO=artifact-store +## make normalize-attached-workplans DIRTY=1 +normalize-attached-workplans: + $(UV) run python scripts/normalize_attached_repo_workplans.py \ + $(if $(REPO),--repo "$(REPO)",) \ + $(if $(DIRTY),--dirty,) \ + $(if $(DRY_RUN),--dry-run,) + @test -n "$(REPO)$(DIRTY)" || (echo "ERROR: set REPO= or DIRTY=1"; exit 1) + +## Regenerate AGENTS.md / CLAUDE.md / .claude/rules from templates. +## Usage: make update-agent-instructions REPO=artifact-store +## make update-agent-instructions DIRTY=1 +update-agent-instructions: + $(UV) run python scripts/update_agent_instruction_files.py \ + $(if $(REPO),--repo "$(REPO)",) \ + $(if $(DIRTY),--dirty,) + @test -n "$(REPO)$(DIRTY)" || (echo "ERROR: set REPO= or DIRTY=1"; exit 1) + +## Reconcile measured token sources against State Hub. +## Usage: make token-reconcile [SINCE=2026-05-19] [APPLY=1] [ZERO_FALLBACKS=1] +token-reconcile: + $(PYTHON) scripts/token_reconcile.py \ + $(if $(SINCE),--since "$(SINCE)",) \ + $(if $(API_BASE),--api-base "$(API_BASE)",) \ + $(if $(CODEX_HOME),--codex-home "$(CODEX_HOME)",) \ + $(if $(CLAUDE_HOME),--claude-home "$(CLAUDE_HOME)",) \ + $(if $(APPLY),--apply,) \ + $(if $(ZERO_FALLBACKS),--zero-superseded-fallbacks,) + +## Pull then fix: single repo or all repos if REPO omitted +## make fix-consistency-remote — smart pull+fix all repos that need it +## make fix-consistency-remote REPO=slug — pull+fix one repo +fix-consistency-remote: + $(UV) run python scripts/consistency_check.py \ + $(if $(REPO),--repo "$(REPO)",--all) \ + --remote \ + $(if $(API_BASE),--api-base "$(API_BASE)",) \ + $(if $(NO_WRITEBACK),--no-writeback,); \ + e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e + +## Infer repo slug from git remote URL and check: make check-consistency-here [REPO_PATH=/path/to/repo] +## Omit REPO_PATH to use the Python script's CWD (i.e. pass an empty --here flag). +check-consistency-here: + $(UV) run python scripts/consistency_check.py \ + --here $(if $(REPO_PATH),"$(REPO_PATH)",) \ + $(if $(API_BASE),--api-base "$(API_BASE)",); \ + e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e + +## Infer repo slug from git remote URL and fix: make fix-consistency-here [REPO_PATH=/path/to/repo] +fix-consistency-here: + $(UV) run python scripts/consistency_check.py \ + --here $(if $(REPO_PATH),"$(REPO_PATH)",) \ + --fix \ + $(if $(API_BASE),--api-base "$(API_BASE)",); \ + e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e + +## Check all registered repos for ADR-001 consistency +check-consistency-all: + $(UV) run python scripts/consistency_check.py --all $(if $(API_BASE),--api-base "$(API_BASE)",); \ + e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e + +## Check and auto-fix all registered repos +fix-consistency-all: + $(UV) run python scripts/consistency_check.py --all --fix $(if $(API_BASE),--api-base "$(API_BASE)",); \ + e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e + +## Cancel open tasks belonging to completed/archived workstreams. +## Safe to run at any time; also suitable for a daily cron job. +## Cron example: 0 3 * * * cd ~/state-hub && make cleanup-stale +cleanup-stale: + $(UV) run python scripts/cleanup_stale_tasks.py + +## Install custodian post-commit sync hook into one repo: make install-hooks REPO=marki-docx +install-hooks: + @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make install-hooks REPO="; exit 1) + bash scripts/install_hooks.sh --repo "$(REPO)" + +## Install custodian post-commit sync hook into all active registered repos +install-hooks-all: + bash scripts/install_hooks.sh --all + +## Remove custodian post-commit sync hook from one repo: make remove-hooks REPO=marki-docx +remove-hooks: + @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make remove-hooks REPO="; exit 1) + bash scripts/install_hooks.sh --repo "$(REPO)" --remove + +## Compare Gitea coulomb org repos against state-hub registered repos +## Requires GITEA_TOKEN in env or .env: make gitea-inventory GITEA_TOKEN= +gitea-inventory: + $(UV) run python scripts/gitea_inventory.py $(if $(JSON),--json) + +## Register/update one repo from .repo-classification.yaml: +## make register-from-classification REPO=state-hub +## make register-from-classification PATH=/path/to/repo +## Optional: DRY_RUN=1 +register-from-classification: + @test -n "$(REPO)" -o -n "$(PATH)" || (echo "ERROR: REPO or PATH is required."; exit 1) + $(UV) run python scripts/register_from_classification.py \ + $(if $(PATH),--repo-path "$(PATH)",--slug "$(REPO)") \ + $(if $(DRY_RUN),--dry-run,) + +## Bulk register/update all active repos with accessible local paths +register-from-classification-all: + $(UV) run python scripts/register_from_classification.py --bulk \ + $(if $(DRY_RUN),--dry-run,) + +clean: + $(COMPOSE) down -v diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/state-hub/deploy/railiance/apps/charts/state-hub/Chart.yaml b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/state-hub/deploy/railiance/apps/charts/state-hub/Chart.yaml new file mode 100644 index 00000000..6e4ddce5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/state-hub/deploy/railiance/apps/charts/state-hub/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: state-hub +description: State Hub API service for private Railiance operation +type: application +version: 0.1.0 +appVersion: "main-c103cbf" diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/state-hub/deploy/railiance/apps/helm/state-hub-values.yaml b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/state-hub/deploy/railiance/apps/helm/state-hub-values.yaml new file mode 100644 index 00000000..99adb99a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/state-hub/deploy/railiance/apps/helm/state-hub-values.yaml @@ -0,0 +1,69 @@ +# Production values for the State Hub Railiance chart handoff. +# Non-secret values only. DATABASE_URL comes from the Secret `state-hub-env`. + +namespace: + create: false + +image: + repository: forgejo.coulomb.social/coulomb/state-hub + tag: "main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1" + +ingress: + enabled: false + +# MCP layer on central (CUST-WP-0067-T08). Enabled together with the image tag +# above: MCP_HOST landed in main-6c1262e, and enabling this on an older image +# would deploy a pod that binds loopback and never becomes reachable. +# ClusterIP only — no ingress, same reason the API's is disabled. +mcp: + enabled: true + resources: + requests: + # 2026-09-10: 1,078 observed 5-minute samples, p99 2.35m, peak 30.45m. + # Preserve the 500m limit for bursts; free 40m for the API rollout surge. + cpu: 10m + +config: + # This deployment is the authoritative hub. Callers verify this rather than + # trusting that whatever answered on a port is central (CUST-WP-0067-T03). + instanceRole: primary + instanceLabel: railiance01 + sbomNexusUrl: "http://sbom-nexus.sbom-nexus.svc.cluster.local:8010" + # Reversible T04 read cutover; set back to `legacy` to roll back. + sbomNexusReadMode: nexus + # Reversible T04 write cutover; set back to `legacy` to roll back future writes. + sbomNexusWriteMode: nexus + +resources: + # Restored to the chart's 250m on 2026-09-21. The earlier 100m was a workaround + # for a node with <250m unallocated; ~830m of Knative/Kourier requests were + # released that day (the-custodian docs/kubernetes-change-gate-decision.md). + # State Hub idles near 5m but bursts past 400m under concurrent agent load. + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: 1000m + memory: 2Gi + +sweep: + # RMGR-WP-0005-T11: disabled while railiance01 checkouts still target the + # stale gitea-remote lineage. Re-enable only after the governed remote + # reconciliation and registrar preflight are complete. + enabled: false + # Present in the live release but previously missing here, so deploying from + # this file alone silently dropped it. Inert while enabled is false. + hostname: 239.62.205.92.host.secureserver.net + hostPath: /home/tegwick + sshHostPath: /home/tegwick/.ssh + +# Forge read credential (STATE-WP-0084-T02). Coordinates only — the token lives +# in OpenBao at platform/workloads/state-hub/forge-derivation and reaches the +# pod through Kubernetes auth, never through this file or a Secret. +forgeRead: + enabled: true + +# RPF-WP-0035-T04: dedicated ESO signing key, API-only explicit reference. +renamePreflight: + enabled: true + secretName: state-hub-rename-preflight diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/state-hub/workplans/STATE-WP-0095-historical-source-receiving.md b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/state-hub/workplans/STATE-WP-0095-historical-source-receiving.md new file mode 100644 index 00000000..2788e09a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage1/state-hub/workplans/STATE-WP-0095-historical-source-receiving.md @@ -0,0 +1,422 @@ +--- +id: STATE-WP-0095 +type: workplan +title: "Preserve exact closed historical source bindings in Forge receiving" +domain: infotech +repo: state-hub +status: active +owner: codex +created: "2026-10-04" +updated: "2026-10-17" +flavor: implementation +depends_on: [] +state_hub_workstream_id: "79579572-8120-5d3b-aca5-dd4aff60d058" +--- + +## Goal + +Normal Forge receiving must preserve the seven bound Activity legacy source +views and the Custodian retired ADHOC source without new identities, source +rewrites, task movement, deletion, relabeling or unretirement. This implements +receiving support before RMGR-WP-0019 registrar admission. CUST-WP-0077 owns +cross-owner coordination; deployment and replay remain explicit owner returns. + +## Verify source bindings and shared views + +```task +id: STATE-WP-0095-T01 +status: done +priority: high +state_hub_task_id: "6a5f87bb-e63d-5e20-b9fc-c1b5cd403ff7" +``` + +Reviewed seven exact Activity files: four existing workplans and 64 UUID-bound +closed tasks. The archived custodian-WP-0003 parent and three split_from views +share one existing parent UUID; every repeated task binding/content agrees. +Custodian ADHOC-2026-08-25-retired retains parent +1c53d3db-6633-5e2d-987f-10706d73be9e and task +d8af3e6b-26ae-580c-bbeb-caa8ad419bec. No identity migration is authorized. + +## Implement preservation receiving and global collision refusal + +```task +id: STATE-WP-0095-T02 +status: done +priority: high +state_hub_task_id: "ebb24194-e4cc-5130-aaa6-663f2ea2c822" +``` + +Prepared exact source digest manifest, existing-parent/task verification, +immutable receiving and UUID-based historical diff. Ordinary canonical sources +continue deriving their identities. Missing/changed/symlinked source, absent +bound rows or nonterminal hub histories refuse. Check task UUID holders globally, +including retired and acknowledged-stale parent workplans, before INSERT. + +Offline preview tests and snapshot proof are evidence of source behavior only; +finish after applying reviewed source and its focused tests to the owning repo. + +## Deliver receiving through normal deployment and verify owner return + +```task +id: STATE-WP-0095-T03 +status: done +priority: high +depends_on: [STATE-WP-0095-T02] +state_hub_task_id: "e8566341-cf6e-5b9c-ad80-8564d14cd52a" +decision_id: "36772026-f43d-4331-b466-5f4b1afe4eca" +``` + +No direct live code patch. This task is a deployment return, not policy authority +or permission to resolve identity decisions. Exact receiving fixture acceptance +and global collision rejection must hold in the delivered version. + +## Verify normal historical replay preserves existing identities + +```task +id: STATE-WP-0095-T04 +status: done +priority: high +depends_on: [STATE-WP-0095-T03, RMGR-WP-0019-T03] +state_hub_task_id: "3bbb311f-194e-5862-8aed-358a580f22b4" +``` + +Verify Activity four historical parent UUIDs/64 task UUIDs and Custodian retired +parent/task unchanged; no historical row creation, movement, deletion, +relabeling or unretirement. Unrelated stale projections remain separate decisions. + +2026-10-05: Source exact-set retirement support now exists. Repository projection +reconcile accepts `expected_retirement_uuids`; that set must equal the stale +projections, and any task or dependency edge on a stale row refuses the call. +Live apply still needs a reviewed rollout. On 2026-10-05 the founder accepted +review `review-1ece4f3e-d0e3-4fd6-abc8-272f1b1876f4`, which names only the three +empty projections and preserves parent `b4eb45a9-69e3-4ab0-b00c-67a53c3117c5`. +That acceptance is not a live retirement and does not resolve +`91b7c633-a66f-5d43-9f4b-6565aefb9356`. + +## Offline evidence, 2026-10-04 + +Prepared receiver suite: Forge projection regression plus historical preservation, +missing/changed source, terminality, immutable UUID diff, and global holder query. +Real-source offline snapshot proof preserves four Activity parents/64 tasks and +one Custodian parent/task without mutations; no API writes or deployment occurred. +Exact original bindings are maintained in api/services/historical_projection.py. + +2026-10-04 actual owning-source validation: 155 focused receiver/qualifier/planner +cases passed; the independent offline current-source/current-hub-snapshot proof +retained five existing parents and 65 task UUIDs, no mutations, clean historical +diff. T02 is source-complete; T03/T04 remain wait for reviewed deployment and +normal registrar/replay receiving, respectively. Evidence: docs/evidence/2026-10-04-historical-receiving-source.json. + + +## Verified receiving rollout and remaining replay gate, 2026-10-04 + +T03 is done on actual founder approval of proposal +STATE-WP-0095-T03-historical-receiving revision 1, SHA256 +`d6789359b3fe7988a6201fff6f367cb46948aa197e1cc0ca66c31f45e1ea9981`, +approval receipt reference `user_reply:call_o87uEfFs3AtXfxO5YqUnSpcg:0`. +The normal owner Helm path delivered `main-5dd3df9`, commit +`5dd3df9c69eb564af10ea5ea01e349786212501a`, revision 72; API and MCP are Ready, +primary/railiance01 health and schema a0b1c2d3e4f5 verified, running source +hashes match. Rollback remains revision 71; no receipt-table downgrade is needed. +Actual historical preservation and global task-holder refusal were verified in +the delivered version. Durable receipt: +[verified historical receiving rollout](../docs/evidence/2026-10-04-historical-receiving-rollout.json). + +Custodian immutable replay applied while preserving the retired historical +parent/task UUIDs. Activity immutable replay retained all four historical parent +and 64 task UUIDs and historical row contents, but refused transactionally on +three unrelated stale projections: custodian-wp-0003 (archived), +custodian-wp-0003a (done), custodian-wp-0003b (done). No retirement acknowledgement +was issued and that refusal is not represented as normal replay success. + +Registrar source `c41d36c2` has 38 actual passing tests and Repo Manager's own +normal sync succeeded; registrar admission does not authorize retiring Activity +projections. T04 therefore remains wait, retaining RMGR-WP-0019-T03 and awaiting +Activity Core's precise reviewed disposition of those three stale rows. No +rename, identity migration, new paid native trial or further live action is +included in this source status update. + +## Activity Core return, 2026-10-06 + +Activity Core returned the exact disposition (message +`cf2d5847-8b3f-4653-9d9f-d5fcfbe3f1e4`, ACTIVITY-WP-0045, activity-core commit +`941fac0`). `expected_retirement_uuids`: +`683724d9-4249-5e0e-88e0-7baff4330d58` (custodian-wp-0003), +`644017c7-c5f2-5a64-81c3-d8ee806f8251` (custodian-wp-0003a), +`a5806df9-5b9a-50da-81c1-7e606076bed9` (custodian-wp-0003b). Each has zero +tasks and zero dependency edges. None is preserved parent +`b4eb45a9-69e3-4ab0-b00c-67a53c3117c5`. Repo Manager independently verified the +set (message `916841f6`, repo-manager commit `498682c`). Nothing was retired. +T04 now waits on decision `91b7c633` and a reviewed live rollout. After that, +Repo Manager re-runs the Activity owner sync and closes RMGR-WP-0019-T03. + +## Retirement rollout and T04 verification, 2026-10-06 + +The founder resolved decision `91b7c633` (retire exactly the three projections) +and approved the rollout. The founder ran the normal Helm upgrade to +`main-968a811` (revision 74; rollback revision 73, `main-fad9b3f`). API and MCP +are Ready, health is ok on primary/railiance01, and schema `b3c4d5e6f7a8` is +unchanged; no migrations. The activity-core reconcile at commit +`67a92d2ff8e033baf2c36998d24187fe83edec84` with `expected_retirement_uuids` set +to the three UUIDs applied: 0 created, 3 retired (tombstoned with an +`@retired-20261006` slug; UUIDs and statuses kept), 0 refused, 0 tasks +cancelled. All 7 bound Activity source files still match their digests. The four +historical parents and 64 task UUIDs are present under their bound parents with +unchanged fields. The Custodian retired parent and task are unchanged. Activity +had 54 workplans and 288 tasks before and after. Evidence: +[activity stale retirement rollout](../docs/evidence/2026-10-06-activity-stale-retirement-rollout.json). +Repo Manager's re-run of the Activity owner sync (RMGR-WP-0019-T03) and +ACTIVITY-WP-0045-T02 remain with their owners. + + +## Cluster exact historical receiving successor, 2026-10-17 + +This scoped extension retains the completed Activity/Custodian outcomes above. +Three frozen cluster archives contain three existing parent UUIDs and 15 existing +closed task UUIDs. Extend the existing exact historical receiving path before +registrar admission; never derive replacement identities or modify those +sources. Cross-owner observation attaches to existing SHR-WP-0001, with +RMGR-WP-0019 as obligor/beneficiary and NK-WP-0049-T35 benefiting indirectly. +The local derived case is waiting; no live worker injection is authorized. + +## Prepare exact cluster source preservation and refusal cases + +```task +id: STATE-WP-0095-T05 +status: done +priority: high +state_hub_task_id: "339b9a37-181c-5599-9a89-0393e199bd4b" +``` + +Prepared the same full-file SHA256 and 15 task binding manifest as Repo Manager. +Candidate receiving tests pass 26: existing rows remain entirely unchanged; +missing/foreign/nonterminal rows and unknown, changed, missing or symlinked +source refuse. Read-only public metadata confirms all 18 existing rows have the +expected ownership and terminal status. These are source and metadata checks, +not delivered receiving or a committed native trial. + +## Deliver reviewed cluster receiving successor before registrar admission + +```task +id: STATE-WP-0095-T06 +status: done +priority: high +depends_on: [STATE-WP-0095-T05] +needs_human: false +intervention_note: "Exact revision 4 approved and verified; no pending rollout intervention." +state_hub_task_id: "6af973b4-1964-5d4d-a2f8-8dbd4c01fda4" +``` + +Completed 2026-10-17 on the founder's approval of exact revision 4 of stable +proposal STATE-WP-0095-T06-cluster-frozen-receiving-r1 (decision + e3798419-8aa9-5425-8c08-20df6bd82a19; approved digest + sha256:63a02e8296d126c0158b7eb2c4ce79c7eeb6f8d6f61298d473c1065c7557ba32). +The ordinary owner Helm path delivered revision 76, runtime source 51bc70d and +exact image manifest sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015. +API/MCP Ready and primary/railiance01 health report expected schema +c4d5e6f7a8b9; delivered historical module sha256:e3c4bf965764b32a6df5d61c71272ee22569df9a9ddae2a3fc3df97ec554e0b2 matches. +All three parent and 15 task full DB row hashes/columns/UUIDs remained unchanged. +Five actual native probes passed: preserve, actual foreign-parent holder move, +nonterminal task, nonterminal parent and missing task. Every probe rolled back; +a fresh transaction confirmed complete row restoration. The foreign-holder +case precisely verified historical binding refusal, not the separate global +UUID-holder creation branch. No committed probe writes were made. + +Evidence: [verified receiving rollout](../docs/evidence/2026-10-17-cluster-historical-receiving-rollout.json). +This actual receiving return satisfies the prerequisite of RMGR-WP-0019-T05. +Normal cluster publication and its immutable-source/row return remain T07; +source readiness and this deployment do not count that later acceptance twice. +The additive inbox migration is retained; Helm rollback does not downgrade +schema or stop an active migration Job. Original failures and exact-UID recovery +limits remain in the referenced approved packet. No TLS recovery/native private +admission or new autonomy/cost grant is inferred. + +## Verify ordinary cluster receiving and immutable historical rows + +```task +id: STATE-WP-0095-T07 +status: wait +priority: high +depends_on: [STATE-WP-0095-T06, RMGR-WP-0019-T06, STATE-WP-0095-T10] +state_hub_task_id: "cfd1ea99-e75b-542e-a434-acefb62f3c7d" +``` + +Capture normal cluster owner publication/receiving receipts and compare entire +existing row snapshots and exact source bytes. No creation, movement, relabel, +unretirement or retirement is authorized for these histories. A rollback first +fences cluster sync and restores registrar refusal before removing receiving +support; it never rewrites archived IDs or UUIDs. This work does not establish +original TLS recovery or owner-native admission. + +## Fix policy-compatible Secret-template server dry-run preparation + +```task +id: STATE-WP-0095-T08 +status: done +priority: high +state_hub_task_id: "2dd9b04f-2250-5a8a-b5bc-c8a009872858" +``` + +Prepared the narrow Makefile successor after the existing readiness command +refused its first Secret-template phase. Ordinary client-side apply adds the +last-applied annotation, correctly denied by the existing Platform +reject-secret-last-applied policy. Only the two Secret-template server +dry-run requests now use server-side apply with dry-run; no force-conflicts, +Secret value change, policy relaxation or actual Secret write is introduced. +Command-fixture tests verify both requests and preserve other dry-run phases. +Evidence and original refusal remain in the Custodian cluster historical +publication packet, revisions/r2 and r3-readiness. This is completed source +preparation; T06 separately records the verified actual rollout. The runtime image +continues to be main-51bc70d; Makefile/workplan changes do not create a new +application image or native acceptance. + + +T08 source repair refinement, 2026-10-17: the required normal Make render +correctly refused a pre-existing generated chart classification copy at v1.0 +after authoritative canon reached v1.1. Regenerated only through the existing +scripts/sync_classification_allowed.py, preserving canon and strict check. +The exact source diff is version 1.0→1.1 plus publication/history/participation. +Normal Make rendering and the focused classification check passed in an +isolated owner-layout copy. The original refusal is retained in the Custodian +owner-followthrough-r1 validation receipt. This is source repair only: no live +ConfigMap apply, new runtime image, policy relaxation or T07 completion. + + +T07 actual ordinary-publication return, 2026-10-17: cluster owner deep consistency +and normal fast projection completed, exact archived source bytes and all 15 +historical task full-row hashes remained unchanged. The later full-row check +FAILED for exactly the three parent rows. The earlier approved T06 rollout had +preserved all 18 rows at its verification stage; that success is not the later +whole-pipeline acceptance. The original snapshots retain hashes only, so exact +changed columns and original field preimages are not established. No guessed +DB repair or claim that all 18 original row hashes are restored is authorized. +T07 and RMGR-WP-0019-T06 remain waiting pending T09/T10 and a reviewed return. + +## Guard immutable historical index bindings in CLI and receiving API + +```task +id: STATE-WP-0095-T09 +status: done +priority: high +depends_on: [STATE-WP-0095-T06] +state_hub_task_id: "8be8f13b-6352-5295-9755-de6fbfb13593" +``` + +Prepare and publish the narrowly scoped ordinary-binding repair for the three +existing task-bound Cluster registry entries. The CLI verifies exact frozen +source metadata/bytes and omits those bindings, including on old receivers. +The API independently validates the complete batch before any mutation, checks +actual repo ownership and the existing terminal parent/task closure, and +preserves valid frozen rows without field or timestamp assignments. Invalid +metadata, missing/foreign/nonterminal rows refuse the batch. Unknown ordinary +bindings retain existing behavior; historical IDs, UUIDs and source bytes stay +unchanged. Isolated regression tests and normal owner rendering are required. +Reviewed source preparation and canonical regression proof completed on +2026-10-17: 102 isolated tests pass and normal Make render/classification +check passes (eight objects, three unchanged delivered runtime images). +This completion record accompanies the same source publication/projection; +source preparation does not establish delivered runtime or T07 acceptance. +Central check/fix engines also refuse the complete repair batch if any fixable +context touches an exact registered parent/task UUID or frozen source path. +This runs before index bindings and before reconcile/push/individual fixes; +ordinary earlier fixes cannot partially execute before a later frozen refusal. + +## Deliver and verify the ordinary-binding guard successor + +```task +id: STATE-WP-0095-T10 +status: done +priority: high +depends_on: [STATE-WP-0095-T09] +needs_human: false +intervention_note: "Exact revision 2 approved and verified; no pending rollout intervention." +state_hub_task_id: "88585c69-d9b9-5e1f-a256-6416ff71e824" +``` + +After source publication, resolve normal CI status and immutable image identity, +verify delivered guard source closure, prepare exact chart/reuse-values dry-run, +full closure/headroom/schema baseline and conditional recovery. Retain additive +migration and active-Job cancellation limitations; no Helm schema downgrade or +bounded-outage claim. Obtain scoped approval before live rollout. Then verify +new full-row baseline preservation and actual rollback-only refusal probes, +and only then return to T07 ordinary-pipeline acceptance. This prevents future +binding mutation; it does not reconstruct or restore original parent hashes. +No production changes, new autonomy or credential authority are granted here. + + +T09 source repair refinement, 2026-10-17: the attempted current-client regression +actually loaded an older packaged consistency checker from the owner virtualenv, +not the reviewed canonical source. That trial again failed parent-row hash +preservation; it is retained separately and does not count as R5 client proof. +Normal C-23 also legitimately normalized WP18 ready to active and created a +source commit, but the generic writeback commit included an unrelated staged +brief. A narrow successor scopes git add and git commit --only to the actual +writeback file, preserving unrelated index/worktree state. Real disposable Git +regression tests cover pre-existing staged changes, hooks, no-op and path names. +Verify the actually loaded CLI checker hash after ordinary owner package refresh +before any retry; capture legitimate source HEAD transitions rather than assume +a static revision. T07 and T10 remain wait. No original parent preimage recovery, +new runtime delivery or broader authority follows from this source repair. + + +T07 current-baseline client return, 2026-10-17: after the owner refreshed the +installed package and verified the actually loaded checker matched the reviewed +R6 source, ordinary Cluster deep consistency and fast projection passed. +Bindings synced 20/20, omitting the three frozen histories; all three parent +and 15 task CURRENT full-row hashes remained unchanged across +before-verified-client.json / after-verified-client.json. Unrelated staged brief +state was preserved. This proves the corrected installed client stage against +its fresh current baseline; earlier parent-hash failures remain retained +historical exceptions, not restored preimages or backfilled successes. + +T10 remains wait/needs_human for exact proposal +STATE-WP-0095-T10-frozen-bindings-guard-r1, now revision 2, metadata +sha256:5d0ddebc21bcb035809cc8b57f4c2e1ba8ed09204be5b965460e178a90913f1e, +pending decision df703266-6719-4c04-ae1b-ba9afd3bca1c. The inspected candidate +image is main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1, +from runtime source c103cbf3c8bed5dba87a03e320595ddded9301a3. Image-only +server dry-run passed eight identities with only three image-field changes. +The exact proposal and recovery limits are retained in the Custodian dated +2026-10-17 guard-successor-review-r2 packet. No new API runtime is approved or +delivered by this source-only note; its later workplan commit does not change +the image's source pin. T09 stays done, T07/T10 stay wait pending reviewed API +delivery and explicit successor/current-baseline acceptance disposition. + + +T10 proposal revision history: revision 1 metadata +sha256:81d365926fc585a186955d8c98ee83501fd3cec12135b2256d71b46a75097932 +and its original helper remain immutable local evidence, unapproved and +unexecuted. Revision 2 of the same stable proposal changes only the native +probe's EXPECTED hash metadata into module/sha256 records for normal publication +scanning; probe behavior, exact image, recovery and acceptance scope remain +unchanged. The same decision df703266-6719-4c04-ae1b-ba9afd3bca1c remains open +and pending. This source-only amendment grants no runtime authority or new +image/code change; T07/T10 remain wait and T09 remains done. + + +T10 verified receiving return, 2026-10-17: the founder approved and the owner +executed exact revision 2 of STATE-WP-0095-T10-frozen-bindings-guard-r1, +sha256:5d0ddebc21bcb035809cc8b57f4c2e1ba8ed09204be5b965460e178a90913f1e, +under decision df703266-6719-4c04-ae1b-ba9afd3bca1c. Helm revision 77 delivered +runtime source c103cbf3c8bed5dba87a03e320595ddded9301a3 and image +main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1. +API/MCP are Ready on expected schema c4d5e6f7a8b9. Eight actual native cases +passed with zero probe commits. All three parent and 15 task CURRENT full-row +hashes remained unchanged after delivery/probes and the actual normal Cluster +pipeline (20 ordinary bindings, three frozen bindings omitted by the verified +loaded checker). All three archived sources remained byte-identical. Exact +owned Job/pod success was observed, then their absence and removal of owned +chart staging were verified; no recovery was needed. + +The verified return is the Custodian dated 2026-10-17 + guard-successor-execution-r2/verified-receiving-return.json, +sha256:09958bd69367cc6903e2a21a56eb81b6a8a46c2a1907afc93f787cd63732e92a. +The original three-parent hash failures remain the founder-accepted historical +exception: unavailable original preimages were not guessed/restored, and failed +outcomes were not backfilled. The 15 original task rows and archived bytes stay +preserved. Earlier pending review notes are retained as proposal history; T10 +is now done and needs_human false. T07 and this parent remain open until the +actual RMGR-WP-0019-T06 owner closure is published and returned. No native +security-target gate or broader autonomy is established by this infrastructure +return. Source appVersion and both maintained image pins now declare this same +verified image; the approved frozen rollout archive remains unchanged. diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/manifest.json b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/manifest.json new file mode 100644 index 00000000..7730723f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/manifest.json @@ -0,0 +1,16 @@ +{ + "schema": "state95.final-owner-closure.v1", + "base_revision": "bb525bc304cbd79b8a8a1540aa28bf073a4e2c32", + "files": [ + { + "path": "workplans/STATE-WP-0095-historical-source-receiving.md", + "base_sha256": "bcde1a6502dfad71586dc38b2f681723dcb310c22f66f052b305ded89bf9b00d", + "candidate_sha256": "31bea1d5b54f9f6e5bc01d11d271c3a86ac76389350b467ba568b9dc93e41c61" + } + ], + "patch_sha256": "sha256:cb29c018ba5ebe189e3bf40b3c4889484ed622f6f538e5671b7f80ee200c3e08", + "new_UUIDs": false, + "runtime_changed": false, + "parent": "finished", + "all10tasks": "done" +} diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/normal-sync-result.json b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/normal-sync-result.json new file mode 100644 index 00000000..b4c334fc --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/normal-sync-result.json @@ -0,0 +1,224 @@ +{ + "ok": true, + "status": "applied", + "repo_slug": "state-hub", + "commit": "200cbd0a61a805d0fe29f9d6d1c49a6cf1f1703a", + "requests": 2, + "elapsed_ms": 15534.8, + "git": { + "upstream": "origin/main", + "behind": 0, + "ahead": 0 + }, + "identifiers": { + "schema": "repo-manager.identifier-assignment.v1", + "ok": true, + "executed": true, + "repo": "state-hub", + "namespace": "helixforge", + "assignments": [], + "historical_invalid_identifiers": [], + "historical_identity_collisions": [], + "files_changed": [] + }, + "receipt": { + "schema": "state-hub.repository-projection-reconcile.v1", + "instance_role": "primary", + "instance_label": "railiance01", + "expected_commit": "200cbd0a61a805d0fe29f9d6d1c49a6cf1f1703a", + "derived_commit": "200cbd0a61a805d0fe29f9d6d1c49a6cf1f1703a", + "outcome": { + "schema": "state-hub.projection-reset.v1", + "repo_slug": "state-hub", + "commit": "200cbd0a61a805d0fe29f9d6d1c49a6cf1f1703a", + "status": "applied", + "counts": { + "created": 0, + "updated": 70, + "retired": 0, + "refused": 0, + "released": 0, + "created_tasks": 0, + "updated_tasks": 2, + "cancelled_tasks": 0, + "created_edges": 0, + "removed_edges": 3, + "unresolved_edges": 0, + "external_edges": 6 + }, + "created": [], + "updated": [ + "CUST-WP-0003", + "CUST-WP-0011", + "CUST-WP-0012", + "CUST-WP-0038", + "CUST-WP-0042", + "STATE-WP-0043", + "STATE-WP-0044", + "STATE-WP-0045", + "STATE-WP-0046", + "STATE-WP-0047", + "STATE-WP-0048", + "STATE-WP-0049", + "STATE-WP-0050", + "STATE-WP-0051", + "STATE-WP-0052", + "STATE-WP-0053", + "STATE-WP-0054", + "STATE-WP-0055", + "STATE-WP-0056", + "STATE-WP-0057", + "STATE-WP-0058", + "STATE-WP-0059", + "STATE-WP-0060", + "STATE-WP-0061", + "STATE-WP-0062", + "STATE-WP-0063", + "STATE-WP-0064", + "STATE-WP-0065", + "STATE-WP-0066", + "STATE-WP-0067", + "STATE-WP-0068", + "STATE-WP-0069", + "STATE-WP-0070", + "STATE-WP-0071", + "STATE-WP-0072", + "STATE-WP-0073", + "STATE-WP-0074", + "STATE-WP-0075", + "STATE-WP-0076", + "STATE-WP-0077", + "STATE-WP-0078", + "STATE-WP-0079", + "STATE-WP-0080", + "STATE-WP-0081", + "STATE-WP-0082", + "STATE-WP-0083", + "STATE-WP-0084", + "STATE-WP-0085", + "STATE-WP-0086", + "STATE-WP-0087", + "STATE-WP-0088", + "STATE-WP-0089", + "STATE-WP-0090", + "STATE-WP-0091", + "STATE-WP-0092", + "STATE-WP-0093", + "STATE-WP-0094", + "STATE-WP-0095", + "STATE-WP-0096", + "STATE-WP-0097", + "STATE-WP-0098", + "STATE-WP-ADHOC-2026-06-04", + "STATE-WP-ADHOC-2026-07-01", + "STATE-WP-ADHOC-2026-08-08", + "STATE-WP-ADHOC-2026-08-23", + "STATE-WP-ADHOC-2026-08-30", + "STATE-WP-ADHOC-2026-09-04", + "STATE-WP-ADHOC-2026-09-22", + "STATE-WP-ADHOC-2026-10-06", + "STATE-WP-ADHOC-2026-10-13" + ], + "retired": [], + "released": [], + "created_tasks": [], + "updated_tasks": [ + "STATE-WP-0095-T07", + "STATE-WP-0095-T10" + ], + "cancelled_tasks": [], + "created_edges": [], + "removed_edges": [ + { + "id": "cc2867c3-4cb1-4b63-8c0d-02529a361a15", + "from_workplan_id": "79579572-8120-5d3b-aca5-dd4aff60d058", + "from_task_id": "cfd1ea99-e75b-542e-a434-acefb62f3c7d", + "to_workplan_id": null, + "to_task_id": "4d1675b6-f49f-5b01-8d76-bbf2030d1b34", + "relationship_type": "starts_after" + }, + { + "id": "e74ef85a-7884-4248-9496-7f1c3150313d", + "from_workplan_id": "79579572-8120-5d3b-aca5-dd4aff60d058", + "from_task_id": "cfd1ea99-e75b-542e-a434-acefb62f3c7d", + "to_workplan_id": null, + "to_task_id": "6af973b4-1964-5d4d-a2f8-8dbd4c01fda4", + "relationship_type": "starts_after" + }, + { + "id": "713e6db5-7574-4ade-a750-84bdde02f80a", + "from_workplan_id": "79579572-8120-5d3b-aca5-dd4aff60d058", + "from_task_id": "cfd1ea99-e75b-542e-a434-acefb62f3c7d", + "to_workplan_id": null, + "to_task_id": "88585c69-d9b9-5e1f-a256-6416ff71e824", + "relationship_type": "starts_after" + } + ], + "unresolved_edges": [], + "external_edges": [ + { + "workplan": "STATE-WP-0098", + "from": "STATE-WP-0098-T04", + "target": "HUB-WP-0011-T01", + "relationship_type": "starts_after", + "from_workplan_id": "0c68970e-295b-57fc-ac70-e472ee3a7bc4", + "from_task_id": "077797c8-289b-55ec-9597-28f223891b02", + "to_workplan_id": null, + "to_task_id": "588e4b58-4694-513c-8e7a-1fab38fb6ce3" + }, + { + "workplan": "CUST-WP-0038", + "from": "CUST-WP-0038", + "target": "CUST-WP-0011", + "relationship_type": "blocks", + "from_workplan_id": "ccb87fa4-6381-5f22-b097-6e026f56a9c1", + "from_task_id": null, + "to_workplan_id": "967baafb-d92d-405a-ba0b-0d00d37c4940", + "to_task_id": null + }, + { + "workplan": "STATE-WP-0079", + "from": "STATE-WP-0079-T04", + "target": "HUB-WP-0011-T03", + "relationship_type": "starts_after", + "from_workplan_id": "ed077b62-7048-5752-bf65-f90471f45854", + "from_task_id": "3a454438-1955-547f-a491-8eab7cd25710", + "to_workplan_id": null, + "to_task_id": "61c727a5-f4b1-54c0-b634-7d71a5416496" + }, + { + "workplan": "STATE-WP-0079", + "from": "STATE-WP-0079-T04", + "target": "HUB-WP-0006-T06", + "relationship_type": "starts_after", + "from_workplan_id": "ed077b62-7048-5752-bf65-f90471f45854", + "from_task_id": "3a454438-1955-547f-a491-8eab7cd25710", + "to_workplan_id": null, + "to_task_id": "7c8225f7-03dc-57b0-a668-c3d781bd828a" + }, + { + "workplan": "STATE-WP-0079", + "from": "STATE-WP-0079-T06", + "target": "STATE-WP-0079-T04", + "relationship_type": "starts_after", + "from_workplan_id": "ed077b62-7048-5752-bf65-f90471f45854", + "from_task_id": "9230c99b-af58-5794-9291-452e619a30fa", + "to_workplan_id": null, + "to_task_id": "3a454438-1955-547f-a491-8eab7cd25710" + }, + { + "workplan": "STATE-WP-0079", + "from": "STATE-WP-0079-T06", + "target": "STATE-WP-0079-T05", + "relationship_type": "starts_after", + "from_workplan_id": "ed077b62-7048-5752-bf65-f90471f45854", + "from_task_id": "9230c99b-af58-5794-9291-452e619a30fa", + "to_workplan_id": null, + "to_task_id": "f6cf8c01-eb59-5ca8-a541-23928d4a000c" + } + ], + "refused": [], + "notes": [] + } + } +} diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/publication-final-manifest.json b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/publication-final-manifest.json new file mode 100644 index 00000000..3fce112a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/publication-final-manifest.json @@ -0,0 +1,34 @@ +{ + "schema": "state95.final-closure-publication.v1", + "status": "verified", + "source_revision": "200cbd0a61a805d0fe29f9d6d1c49a6cf1f1703a", + "parent_status": "finished", + "task_count": 10, + "all_tasks_done": true, + "all_UUIDs_preserved": true, + "unrelated_dirty_and_index_preserved": true, + "native_security_target_gates_closed": 0, + "runtime_source": "c103cbf3c8bed5dba87a03e320595ddded9301a3", + "bound_receipts": [ + { + "path": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/verified-registrar-return.json", + "sha256": "sha256:6bf7fc2ee86688d6e15d584c9157bacf4fff6f87b887f28aa7ed8292df28d35e" + }, + { + "path": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/manifest.json", + "sha256": "sha256:634c816d73164a9d256b3e92205869d688dfe865edfd33fbc1be4052bb0f8795" + }, + { + "path": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/source-commit-result.json", + "sha256": "sha256:b1587c9e24befd1b4e1a4137c50a717c7c0f87d120105e8275da23d267042e8e" + }, + { + "path": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/normal-sync-result.json", + "sha256": "sha256:bd72497f8fbe6f3574b35308d788be88456f5d9905d320d14e9659a560395291" + }, + { + "path": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/source-publication-readback.json", + "sha256": "sha256:83878a62d3c6fdfc5f1568bc6cad16c7c14d2d257ecd59471473cb53da650836" + } + ] +} diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/publish_owner_source.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/publish_owner_source.py new file mode 100644 index 00000000..5e69e8e4 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/publish_owner_source.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Prepared exact-ten-path source publisher; requires final reviewed overlay. No deploy/sync.""" +from __future__ import annotations +import argparse +import hashlib +import json +from pathlib import Path +import subprocess + +BASE = Path(__file__).resolve().parent +OWNER = Path('/home/worsch/state-hub') +SCANNER = ['/usr/bin/python3', '/home/worsch/repo-manager/src/repo_manager/secret_scan.py', + '--path', str(OWNER), '--binary', '/home/worsch/.local/lib/repo-manager/gitleaks/8.30.1/gitleaks'] + +class Refusal(RuntimeError): + pass + +def sha(raw): + return hashlib.sha256(raw).hexdigest() + +def git(owner, *args, check=True): + p = subprocess.run(['git', '-C', str(owner), *args], capture_output=True, timeout=150) + if check and p.returncode: + raise Refusal('git_' + args[0] + '_refused_' + str(p.returncode)) + return p + +def index_entries(owner): + # Stage bytes remain private to this process. Receipts expose hashes only. + return dict((entry.split(b'\t', 1)[1].decode(), entry.split(b'\t', 1)[0]) + for entry in git(owner, 'ls-files', '--stage', '-z').stdout.split(b'\0') if entry) + +def dirty_files(owner): + tracked = git(owner, 'diff', '--name-only', 'HEAD', '-z').stdout.split(b'\0') + untracked = git(owner, 'ls-files', '--others', '--exclude-standard', '-z').stdout.split(b'\0') + return sorted({p.decode() for p in tracked + untracked if p}) + +def preserved_state(owner, owned): + indices = {p: sha(v) for p, v in index_entries(owner).items() if p not in owned} + worktree = {} + for p in dirty_files(owner): + if p in owned: + continue + file = owner / p + if file.is_symlink(): + raise Refusal('unrelated_symlink_requires_review') + worktree[p] = sha(file.read_bytes()) if file.exists() else None + return {'index_entries': indices, 'dirty_worktree': worktree} + +def validate(owner, expected): + if git(owner, 'rev-parse', 'HEAD').stdout.decode().strip() != expected['base_revision']: + raise Refusal('source_base_drift') + files = expected['files'] + names = {f['path'] for f in files} + if len(names) != len(files): + raise Refusal('duplicate_expected_path') + for f in files: + p = owner / f['path'] + if p.is_symlink() or not p.is_file() or sha(p.read_bytes()) != f['candidate_sha256']: + raise Refusal('reviewed_source_drift') + # Every unrelated worktree/index entry is retained. No stash/reset/clean. + return names, preserved_state(owner, names) + +def commit_source(owner, expected, scanner, execute=False, push=False): + names, before = validate(owner, expected) + result = {'schema': 'receiver-source-publication.v1', 'scope': 'source_only', + 'deployed': False, + 'base_revision': expected['base_revision'], 'files': sorted(names), + 'preserved_before': before, 'status': 'validated_not_executed'} + if not execute: + return result + # Stage exact reviewed paths first so the normal scanner inspects them. + # A refusal retains reviewed source/staging for inspection; unrelated state stays intact. + git(owner, 'add', '--', *sorted(names)) + scan = subprocess.run(scanner, cwd=owner, capture_output=True, timeout=150) + result['scanner_exit'] = scan.returncode + if scan.returncode: + raise Refusal('normal_source_scanner_refused') + # --only preserves unrelated staged content while committing exact paths. + git(owner, 'commit', '--only', '-m', + 'docs(workplan): close verified historical receiving', '--', *sorted(names)) + commit = git(owner, 'rev-parse', 'HEAD').stdout.decode().strip() + changed = set(git(owner, 'diff-tree', '--no-commit-id', '--name-only', '-r', commit).stdout.decode().splitlines()) + if changed != names: + raise Refusal('committed_path_set_mismatch_stop') + if preserved_state(owner, names) != before: + raise Refusal('unrelated_state_changed_stop') + result.update(status='committed_not_pushed', source_revision=commit, + preserved_after=before) + if push: + # Explicit ref and bounded push. A nonzero or timeout never triggers retry. + try: + p = git(owner, 'push', 'origin', 'HEAD:refs/heads/main', check=False) + result['push_exit'] = p.returncode + except subprocess.TimeoutExpired: + result.update(status='push_timeout_remote_unverified') + return result + remote = git(owner, 'ls-remote', '--exit-code', 'origin', 'refs/heads/main', check=False) + actual = remote.stdout.split()[0].decode() if remote.returncode == 0 and remote.stdout else None + result['observed_remote_revision'] = actual + result['status'] = 'source_published_verified' if actual == commit else 'push_remote_unverified' + return result + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--execute-source-commit', action='store_true') + ap.add_argument('--push-source', action='store_true') + args = ap.parse_args() + if args.push_source: + ap.error('direct push disabled; use normal repo-local make sync after exact source commit') + publication_manifest = BASE / 'publication-manifest.json' + if not publication_manifest.is_file(): + print(json.dumps({'status':'refused','reason':'reviewed_managed_UUID_T09_completion_manifest_not_ready','scope':'source_only'})) + return 1 + expected = json.loads(publication_manifest.read_text()) + if expected.get('publication_stage') != 'root_reviewed_UUIDs_and_T09_completion': + raise Refusal('publication_review_overlay_missing') + if len(expected['files']) != 10: + raise Refusal('expected_exact_ten_paths') + try: + result = commit_source(OWNER, expected, SCANNER, args.execute_source_commit, args.push_source) + except (Refusal, subprocess.TimeoutExpired) as exc: + result = {'schema': 'receiver-source-publication.v1', 'status': 'refused_or_unverified', + 'reason': str(exc) if isinstance(exc, Refusal) else 'bounded_command_timeout', + 'deployed': False} + print(json.dumps(result, indent=2)) + return 0 if result['status'] in {'validated_not_executed','committed_not_pushed','source_published_verified'} else 1 + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/publish_workplan.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/publish_workplan.py new file mode 100644 index 00000000..29ee9b31 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/publish_workplan.py @@ -0,0 +1,16 @@ +from pathlib import Path +import hashlib,json,importlib.util +p=Path(__file__).resolve().parent;owner=Path('/home/worsch/state-hub') +spec=importlib.util.spec_from_file_location('pub',p/'publish_owner_source.py');pub=importlib.util.module_from_spec(spec);spec.loader.exec_module(pub) +d=json.loads((p/'manifest.json').read_text());e=d['files'][0];assert len(d['files'])==1 +assert pub.git(owner,'rev-parse','HEAD').stdout.decode().strip()==d['base_revision'] +assert hashlib.sha256((p/'state-hub-workplan.patch').read_bytes()).hexdigest()=='cb29c018ba5ebe189e3bf40b3c4889484ed622f6f538e5671b7f80ee200c3e08' +f=owner/e['path'];s=p/'state-hub'/e['path'];assert not f.is_symlink() and not s.is_symlink() +assert hashlib.sha256(f.read_bytes()).hexdigest()==e['base_sha256'] +assert hashlib.sha256(s.read_bytes()).hexdigest()==e['candidate_sha256'] +assert not pub.git(owner,'diff','--cached','--',e['path']).stdout +before=pub.preserved_state(owner,{e['path']});f.write_bytes(s.read_bytes());assert pub.preserved_state(owner,{e['path']})==before +result=pub.commit_source(owner,d,pub.SCANNER,execute=True,push=False) +assert pub.preserved_state(owner,{e['path']})==before +(p/'source-commit-result.json').write_text(json.dumps(result,indent=2)+'\n') +print(json.dumps({k:v for k,v in result.items() if k not in {'preserved_before','preserved_after'}})) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/run_normal_sync.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/run_normal_sync.py new file mode 100644 index 00000000..3790505b --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/run_normal_sync.py @@ -0,0 +1,10 @@ +from pathlib import Path +import subprocess,json +p=Path(__file__).resolve().parent +r=subprocess.run(['make','sync','SYNC_PATH=.','SYNC_PUSH=1','STATE_HUB_API_BASE=http://127.0.0.1:8000'],cwd='/home/worsch/state-hub',capture_output=True,text=True,timeout=180) +try:d=json.loads(r.stdout[r.stdout.index('{'):]) +except Exception:d={'status':'unverified','reason':'normal_sync_receipt_unparseable','exit':r.returncode} +(p/'normal-sync-result.json').write_text(json.dumps(d,indent=2)+'\n') +receipt=d.get('receipt',{}) +print(json.dumps({'exit':r.returncode,'status':d.get('status'),'commit':d.get('commit'),'instance_role':receipt.get('instance_role'),'instance_label':receipt.get('instance_label'),'expected_commit':receipt.get('expected_commit'),'derived_commit':receipt.get('derived_commit'),'identifier_assignments':d.get('identifiers',{}).get('assignments')})) +raise SystemExit(r.returncode) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/source-publication-readback.json b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/source-publication-readback.json new file mode 100644 index 00000000..82b0ef9a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/source-publication-readback.json @@ -0,0 +1,80 @@ +{ + "schema": "state95.finished-owner-readback.v1", + "status": "applied", + "source_revision": "200cbd0a61a805d0fe29f9d6d1c49a6cf1f1703a", + "remote_revision": "200cbd0a61a805d0fe29f9d6d1c49a6cf1f1703a", + "instance_role": "primary", + "instance_label": "railiance01", + "expected_commit": "200cbd0a61a805d0fe29f9d6d1c49a6cf1f1703a", + "derived_commit": "200cbd0a61a805d0fe29f9d6d1c49a6cf1f1703a", + "parent_uuid": "79579572-8120-5d3b-aca5-dd4aff60d058", + "parent_status": "finished", + "tasks": [ + { + "uuid": "6a5f87bb-e63d-5e20-b9fc-c1b5cd403ff7", + "record_id": "STATE-WP-0095-T01", + "status": "done", + "needs_human": false + }, + { + "uuid": "ebb24194-e4cc-5130-aaa6-663f2ea2c822", + "record_id": "STATE-WP-0095-T02", + "status": "done", + "needs_human": false + }, + { + "uuid": "e8566341-cf6e-5b9c-ad80-8564d14cd52a", + "record_id": "STATE-WP-0095-T03", + "status": "done", + "needs_human": false + }, + { + "uuid": "3bbb311f-194e-5862-8aed-358a580f22b4", + "record_id": "STATE-WP-0095-T04", + "status": "done", + "needs_human": false + }, + { + "uuid": "339b9a37-181c-5599-9a89-0393e199bd4b", + "record_id": "STATE-WP-0095-T05", + "status": "done", + "needs_human": false + }, + { + "uuid": "6af973b4-1964-5d4d-a2f8-8dbd4c01fda4", + "record_id": "STATE-WP-0095-T06", + "status": "done", + "needs_human": false + }, + { + "uuid": "cfd1ea99-e75b-542e-a434-acefb62f3c7d", + "record_id": "STATE-WP-0095-T07", + "status": "done", + "needs_human": false + }, + { + "uuid": "2dd9b04f-2250-5a8a-b5bc-c8a009872858", + "record_id": "STATE-WP-0095-T08", + "status": "done", + "needs_human": false + }, + { + "uuid": "8be8f13b-6352-5295-9755-de6fbfb13593", + "record_id": "STATE-WP-0095-T09", + "status": "done", + "needs_human": false + }, + { + "uuid": "88585c69-d9b9-5e1f-a256-6416ff71e824", + "record_id": "STATE-WP-0095-T10", + "status": "done", + "needs_human": false + } + ], + "whole_unrelated_index_dirty_preserved": true, + "new_UUIDs": false, + "runtime_mutated_by_closure": false, + "declared_runtime_source": "c103cbf3c8bed5dba87a03e320595ddded9301a3", + "original_three_parent_failure": "retained_approved_historical_exception_no_guessed_restoration_no_backfill", + "residual_audit": "No own unfinished receiving/registrar-return work. Existing separate security-target/native/TLS/recovery/MFA work records remain outside this closure." +} diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/state-hub-workplan.patch b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/state-hub-workplan.patch new file mode 100644 index 00000000..71dd4626 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/state-hub-workplan.patch @@ -0,0 +1,47 @@ +--- a/workplans/STATE-WP-0095-historical-source-receiving.md ++++ b/workplans/STATE-WP-0095-historical-source-receiving.md +@@ -4,7 +4,7 @@ + title: "Preserve exact closed historical source bindings in Forge receiving" + domain: infotech + repo: state-hub +-status: active ++status: finished + owner: codex + created: "2026-10-04" + updated: "2026-10-17" +@@ -235,7 +235,7 @@ + + ```task + id: STATE-WP-0095-T07 +-status: wait ++status: done + priority: high + depends_on: [STATE-WP-0095-T06, RMGR-WP-0019-T06, STATE-WP-0095-T10] + state_hub_task_id: "cfd1ea99-e75b-542e-a434-acefb62f3c7d" +@@ -420,3 +420,26 @@ + security-target gate or broader autonomy is established by this infrastructure + return. Source appVersion and both maintained image pins now declare this same + verified image; the approved frozen rollout archive remains unchanged. ++ ++ ++T07 final owner return and STATE95 closure, 2026-10-17: Repo Manager published ++RMGR-WP-0019 finished with T06 done at source/remote ++3bcb1e1cf3e58b263f690534f7f28024d75cdbf6. Its normal authoritative projection ++is applied on primary/railiance01 at that exact expected/derived commit; all ++six source task UUIDs/statuses and finished parent were independently read back. ++This completes the remaining registrar-return dependency after T10's verified ++revision-2 delivery, eight rollback-only native cases and actual corrected ++ordinary Cluster pipeline preserved all 18 CURRENT full-row hashes, the 15 ++original task rows and three archived source byte digests. ++ ++The immutable runtime return remains ++sha256:09958bd69367cc6903e2a21a56eb81b6a8a46c2a1907afc93f787cd63732e92a. ++The founder-approved original three-parent mismatch remains a retained ++historical exception; no unavailable original preimages were reconstructed, ++no failed trial was relabeled, and no original hash restoration is claimed. ++All ten STATE95 tasks are now done. The residual audit found no unfinished ++work inside this receiving/registrar-return scope; separate security-target, ++TLS/recovery/MFA and owner-native admission work remains with its existing ++live records and is not accepted by this infrastructure workplan closure. ++The runtime remains the verified c103cbf/4cc4ce17 artifact; this later source-only ++workplan commit changes neither its source pin nor the frozen rollout archive. diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/state-hub/workplans/STATE-WP-0095-historical-source-receiving.md b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/state-hub/workplans/STATE-WP-0095-historical-source-receiving.md new file mode 100644 index 00000000..dce759a4 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/state-hub/workplans/STATE-WP-0095-historical-source-receiving.md @@ -0,0 +1,445 @@ +--- +id: STATE-WP-0095 +type: workplan +title: "Preserve exact closed historical source bindings in Forge receiving" +domain: infotech +repo: state-hub +status: finished +owner: codex +created: "2026-10-04" +updated: "2026-10-17" +flavor: implementation +depends_on: [] +state_hub_workstream_id: "79579572-8120-5d3b-aca5-dd4aff60d058" +--- + +## Goal + +Normal Forge receiving must preserve the seven bound Activity legacy source +views and the Custodian retired ADHOC source without new identities, source +rewrites, task movement, deletion, relabeling or unretirement. This implements +receiving support before RMGR-WP-0019 registrar admission. CUST-WP-0077 owns +cross-owner coordination; deployment and replay remain explicit owner returns. + +## Verify source bindings and shared views + +```task +id: STATE-WP-0095-T01 +status: done +priority: high +state_hub_task_id: "6a5f87bb-e63d-5e20-b9fc-c1b5cd403ff7" +``` + +Reviewed seven exact Activity files: four existing workplans and 64 UUID-bound +closed tasks. The archived custodian-WP-0003 parent and three split_from views +share one existing parent UUID; every repeated task binding/content agrees. +Custodian ADHOC-2026-08-25-retired retains parent +1c53d3db-6633-5e2d-987f-10706d73be9e and task +d8af3e6b-26ae-580c-bbeb-caa8ad419bec. No identity migration is authorized. + +## Implement preservation receiving and global collision refusal + +```task +id: STATE-WP-0095-T02 +status: done +priority: high +state_hub_task_id: "ebb24194-e4cc-5130-aaa6-663f2ea2c822" +``` + +Prepared exact source digest manifest, existing-parent/task verification, +immutable receiving and UUID-based historical diff. Ordinary canonical sources +continue deriving their identities. Missing/changed/symlinked source, absent +bound rows or nonterminal hub histories refuse. Check task UUID holders globally, +including retired and acknowledged-stale parent workplans, before INSERT. + +Offline preview tests and snapshot proof are evidence of source behavior only; +finish after applying reviewed source and its focused tests to the owning repo. + +## Deliver receiving through normal deployment and verify owner return + +```task +id: STATE-WP-0095-T03 +status: done +priority: high +depends_on: [STATE-WP-0095-T02] +state_hub_task_id: "e8566341-cf6e-5b9c-ad80-8564d14cd52a" +decision_id: "36772026-f43d-4331-b466-5f4b1afe4eca" +``` + +No direct live code patch. This task is a deployment return, not policy authority +or permission to resolve identity decisions. Exact receiving fixture acceptance +and global collision rejection must hold in the delivered version. + +## Verify normal historical replay preserves existing identities + +```task +id: STATE-WP-0095-T04 +status: done +priority: high +depends_on: [STATE-WP-0095-T03, RMGR-WP-0019-T03] +state_hub_task_id: "3bbb311f-194e-5862-8aed-358a580f22b4" +``` + +Verify Activity four historical parent UUIDs/64 task UUIDs and Custodian retired +parent/task unchanged; no historical row creation, movement, deletion, +relabeling or unretirement. Unrelated stale projections remain separate decisions. + +2026-10-05: Source exact-set retirement support now exists. Repository projection +reconcile accepts `expected_retirement_uuids`; that set must equal the stale +projections, and any task or dependency edge on a stale row refuses the call. +Live apply still needs a reviewed rollout. On 2026-10-05 the founder accepted +review `review-1ece4f3e-d0e3-4fd6-abc8-272f1b1876f4`, which names only the three +empty projections and preserves parent `b4eb45a9-69e3-4ab0-b00c-67a53c3117c5`. +That acceptance is not a live retirement and does not resolve +`91b7c633-a66f-5d43-9f4b-6565aefb9356`. + +## Offline evidence, 2026-10-04 + +Prepared receiver suite: Forge projection regression plus historical preservation, +missing/changed source, terminality, immutable UUID diff, and global holder query. +Real-source offline snapshot proof preserves four Activity parents/64 tasks and +one Custodian parent/task without mutations; no API writes or deployment occurred. +Exact original bindings are maintained in api/services/historical_projection.py. + +2026-10-04 actual owning-source validation: 155 focused receiver/qualifier/planner +cases passed; the independent offline current-source/current-hub-snapshot proof +retained five existing parents and 65 task UUIDs, no mutations, clean historical +diff. T02 is source-complete; T03/T04 remain wait for reviewed deployment and +normal registrar/replay receiving, respectively. Evidence: docs/evidence/2026-10-04-historical-receiving-source.json. + + +## Verified receiving rollout and remaining replay gate, 2026-10-04 + +T03 is done on actual founder approval of proposal +STATE-WP-0095-T03-historical-receiving revision 1, SHA256 +`d6789359b3fe7988a6201fff6f367cb46948aa197e1cc0ca66c31f45e1ea9981`, +approval receipt reference `user_reply:call_o87uEfFs3AtXfxO5YqUnSpcg:0`. +The normal owner Helm path delivered `main-5dd3df9`, commit +`5dd3df9c69eb564af10ea5ea01e349786212501a`, revision 72; API and MCP are Ready, +primary/railiance01 health and schema a0b1c2d3e4f5 verified, running source +hashes match. Rollback remains revision 71; no receipt-table downgrade is needed. +Actual historical preservation and global task-holder refusal were verified in +the delivered version. Durable receipt: +[verified historical receiving rollout](../docs/evidence/2026-10-04-historical-receiving-rollout.json). + +Custodian immutable replay applied while preserving the retired historical +parent/task UUIDs. Activity immutable replay retained all four historical parent +and 64 task UUIDs and historical row contents, but refused transactionally on +three unrelated stale projections: custodian-wp-0003 (archived), +custodian-wp-0003a (done), custodian-wp-0003b (done). No retirement acknowledgement +was issued and that refusal is not represented as normal replay success. + +Registrar source `c41d36c2` has 38 actual passing tests and Repo Manager's own +normal sync succeeded; registrar admission does not authorize retiring Activity +projections. T04 therefore remains wait, retaining RMGR-WP-0019-T03 and awaiting +Activity Core's precise reviewed disposition of those three stale rows. No +rename, identity migration, new paid native trial or further live action is +included in this source status update. + +## Activity Core return, 2026-10-06 + +Activity Core returned the exact disposition (message +`cf2d5847-8b3f-4653-9d9f-d5fcfbe3f1e4`, ACTIVITY-WP-0045, activity-core commit +`941fac0`). `expected_retirement_uuids`: +`683724d9-4249-5e0e-88e0-7baff4330d58` (custodian-wp-0003), +`644017c7-c5f2-5a64-81c3-d8ee806f8251` (custodian-wp-0003a), +`a5806df9-5b9a-50da-81c1-7e606076bed9` (custodian-wp-0003b). Each has zero +tasks and zero dependency edges. None is preserved parent +`b4eb45a9-69e3-4ab0-b00c-67a53c3117c5`. Repo Manager independently verified the +set (message `916841f6`, repo-manager commit `498682c`). Nothing was retired. +T04 now waits on decision `91b7c633` and a reviewed live rollout. After that, +Repo Manager re-runs the Activity owner sync and closes RMGR-WP-0019-T03. + +## Retirement rollout and T04 verification, 2026-10-06 + +The founder resolved decision `91b7c633` (retire exactly the three projections) +and approved the rollout. The founder ran the normal Helm upgrade to +`main-968a811` (revision 74; rollback revision 73, `main-fad9b3f`). API and MCP +are Ready, health is ok on primary/railiance01, and schema `b3c4d5e6f7a8` is +unchanged; no migrations. The activity-core reconcile at commit +`67a92d2ff8e033baf2c36998d24187fe83edec84` with `expected_retirement_uuids` set +to the three UUIDs applied: 0 created, 3 retired (tombstoned with an +`@retired-20261006` slug; UUIDs and statuses kept), 0 refused, 0 tasks +cancelled. All 7 bound Activity source files still match their digests. The four +historical parents and 64 task UUIDs are present under their bound parents with +unchanged fields. The Custodian retired parent and task are unchanged. Activity +had 54 workplans and 288 tasks before and after. Evidence: +[activity stale retirement rollout](../docs/evidence/2026-10-06-activity-stale-retirement-rollout.json). +Repo Manager's re-run of the Activity owner sync (RMGR-WP-0019-T03) and +ACTIVITY-WP-0045-T02 remain with their owners. + + +## Cluster exact historical receiving successor, 2026-10-17 + +This scoped extension retains the completed Activity/Custodian outcomes above. +Three frozen cluster archives contain three existing parent UUIDs and 15 existing +closed task UUIDs. Extend the existing exact historical receiving path before +registrar admission; never derive replacement identities or modify those +sources. Cross-owner observation attaches to existing SHR-WP-0001, with +RMGR-WP-0019 as obligor/beneficiary and NK-WP-0049-T35 benefiting indirectly. +The local derived case is waiting; no live worker injection is authorized. + +## Prepare exact cluster source preservation and refusal cases + +```task +id: STATE-WP-0095-T05 +status: done +priority: high +state_hub_task_id: "339b9a37-181c-5599-9a89-0393e199bd4b" +``` + +Prepared the same full-file SHA256 and 15 task binding manifest as Repo Manager. +Candidate receiving tests pass 26: existing rows remain entirely unchanged; +missing/foreign/nonterminal rows and unknown, changed, missing or symlinked +source refuse. Read-only public metadata confirms all 18 existing rows have the +expected ownership and terminal status. These are source and metadata checks, +not delivered receiving or a committed native trial. + +## Deliver reviewed cluster receiving successor before registrar admission + +```task +id: STATE-WP-0095-T06 +status: done +priority: high +depends_on: [STATE-WP-0095-T05] +needs_human: false +intervention_note: "Exact revision 4 approved and verified; no pending rollout intervention." +state_hub_task_id: "6af973b4-1964-5d4d-a2f8-8dbd4c01fda4" +``` + +Completed 2026-10-17 on the founder's approval of exact revision 4 of stable +proposal STATE-WP-0095-T06-cluster-frozen-receiving-r1 (decision + e3798419-8aa9-5425-8c08-20df6bd82a19; approved digest + sha256:63a02e8296d126c0158b7eb2c4ce79c7eeb6f8d6f61298d473c1065c7557ba32). +The ordinary owner Helm path delivered revision 76, runtime source 51bc70d and +exact image manifest sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015. +API/MCP Ready and primary/railiance01 health report expected schema +c4d5e6f7a8b9; delivered historical module sha256:e3c4bf965764b32a6df5d61c71272ee22569df9a9ddae2a3fc3df97ec554e0b2 matches. +All three parent and 15 task full DB row hashes/columns/UUIDs remained unchanged. +Five actual native probes passed: preserve, actual foreign-parent holder move, +nonterminal task, nonterminal parent and missing task. Every probe rolled back; +a fresh transaction confirmed complete row restoration. The foreign-holder +case precisely verified historical binding refusal, not the separate global +UUID-holder creation branch. No committed probe writes were made. + +Evidence: [verified receiving rollout](../docs/evidence/2026-10-17-cluster-historical-receiving-rollout.json). +This actual receiving return satisfies the prerequisite of RMGR-WP-0019-T05. +Normal cluster publication and its immutable-source/row return remain T07; +source readiness and this deployment do not count that later acceptance twice. +The additive inbox migration is retained; Helm rollback does not downgrade +schema or stop an active migration Job. Original failures and exact-UID recovery +limits remain in the referenced approved packet. No TLS recovery/native private +admission or new autonomy/cost grant is inferred. + +## Verify ordinary cluster receiving and immutable historical rows + +```task +id: STATE-WP-0095-T07 +status: done +priority: high +depends_on: [STATE-WP-0095-T06, RMGR-WP-0019-T06, STATE-WP-0095-T10] +state_hub_task_id: "cfd1ea99-e75b-542e-a434-acefb62f3c7d" +``` + +Capture normal cluster owner publication/receiving receipts and compare entire +existing row snapshots and exact source bytes. No creation, movement, relabel, +unretirement or retirement is authorized for these histories. A rollback first +fences cluster sync and restores registrar refusal before removing receiving +support; it never rewrites archived IDs or UUIDs. This work does not establish +original TLS recovery or owner-native admission. + +## Fix policy-compatible Secret-template server dry-run preparation + +```task +id: STATE-WP-0095-T08 +status: done +priority: high +state_hub_task_id: "2dd9b04f-2250-5a8a-b5bc-c8a009872858" +``` + +Prepared the narrow Makefile successor after the existing readiness command +refused its first Secret-template phase. Ordinary client-side apply adds the +last-applied annotation, correctly denied by the existing Platform +reject-secret-last-applied policy. Only the two Secret-template server +dry-run requests now use server-side apply with dry-run; no force-conflicts, +Secret value change, policy relaxation or actual Secret write is introduced. +Command-fixture tests verify both requests and preserve other dry-run phases. +Evidence and original refusal remain in the Custodian cluster historical +publication packet, revisions/r2 and r3-readiness. This is completed source +preparation; T06 separately records the verified actual rollout. The runtime image +continues to be main-51bc70d; Makefile/workplan changes do not create a new +application image or native acceptance. + + +T08 source repair refinement, 2026-10-17: the required normal Make render +correctly refused a pre-existing generated chart classification copy at v1.0 +after authoritative canon reached v1.1. Regenerated only through the existing +scripts/sync_classification_allowed.py, preserving canon and strict check. +The exact source diff is version 1.0→1.1 plus publication/history/participation. +Normal Make rendering and the focused classification check passed in an +isolated owner-layout copy. The original refusal is retained in the Custodian +owner-followthrough-r1 validation receipt. This is source repair only: no live +ConfigMap apply, new runtime image, policy relaxation or T07 completion. + + +T07 actual ordinary-publication return, 2026-10-17: cluster owner deep consistency +and normal fast projection completed, exact archived source bytes and all 15 +historical task full-row hashes remained unchanged. The later full-row check +FAILED for exactly the three parent rows. The earlier approved T06 rollout had +preserved all 18 rows at its verification stage; that success is not the later +whole-pipeline acceptance. The original snapshots retain hashes only, so exact +changed columns and original field preimages are not established. No guessed +DB repair or claim that all 18 original row hashes are restored is authorized. +T07 and RMGR-WP-0019-T06 remain waiting pending T09/T10 and a reviewed return. + +## Guard immutable historical index bindings in CLI and receiving API + +```task +id: STATE-WP-0095-T09 +status: done +priority: high +depends_on: [STATE-WP-0095-T06] +state_hub_task_id: "8be8f13b-6352-5295-9755-de6fbfb13593" +``` + +Prepare and publish the narrowly scoped ordinary-binding repair for the three +existing task-bound Cluster registry entries. The CLI verifies exact frozen +source metadata/bytes and omits those bindings, including on old receivers. +The API independently validates the complete batch before any mutation, checks +actual repo ownership and the existing terminal parent/task closure, and +preserves valid frozen rows without field or timestamp assignments. Invalid +metadata, missing/foreign/nonterminal rows refuse the batch. Unknown ordinary +bindings retain existing behavior; historical IDs, UUIDs and source bytes stay +unchanged. Isolated regression tests and normal owner rendering are required. +Reviewed source preparation and canonical regression proof completed on +2026-10-17: 102 isolated tests pass and normal Make render/classification +check passes (eight objects, three unchanged delivered runtime images). +This completion record accompanies the same source publication/projection; +source preparation does not establish delivered runtime or T07 acceptance. +Central check/fix engines also refuse the complete repair batch if any fixable +context touches an exact registered parent/task UUID or frozen source path. +This runs before index bindings and before reconcile/push/individual fixes; +ordinary earlier fixes cannot partially execute before a later frozen refusal. + +## Deliver and verify the ordinary-binding guard successor + +```task +id: STATE-WP-0095-T10 +status: done +priority: high +depends_on: [STATE-WP-0095-T09] +needs_human: false +intervention_note: "Exact revision 2 approved and verified; no pending rollout intervention." +state_hub_task_id: "88585c69-d9b9-5e1f-a256-6416ff71e824" +``` + +After source publication, resolve normal CI status and immutable image identity, +verify delivered guard source closure, prepare exact chart/reuse-values dry-run, +full closure/headroom/schema baseline and conditional recovery. Retain additive +migration and active-Job cancellation limitations; no Helm schema downgrade or +bounded-outage claim. Obtain scoped approval before live rollout. Then verify +new full-row baseline preservation and actual rollback-only refusal probes, +and only then return to T07 ordinary-pipeline acceptance. This prevents future +binding mutation; it does not reconstruct or restore original parent hashes. +No production changes, new autonomy or credential authority are granted here. + + +T09 source repair refinement, 2026-10-17: the attempted current-client regression +actually loaded an older packaged consistency checker from the owner virtualenv, +not the reviewed canonical source. That trial again failed parent-row hash +preservation; it is retained separately and does not count as R5 client proof. +Normal C-23 also legitimately normalized WP18 ready to active and created a +source commit, but the generic writeback commit included an unrelated staged +brief. A narrow successor scopes git add and git commit --only to the actual +writeback file, preserving unrelated index/worktree state. Real disposable Git +regression tests cover pre-existing staged changes, hooks, no-op and path names. +Verify the actually loaded CLI checker hash after ordinary owner package refresh +before any retry; capture legitimate source HEAD transitions rather than assume +a static revision. T07 and T10 remain wait. No original parent preimage recovery, +new runtime delivery or broader authority follows from this source repair. + + +T07 current-baseline client return, 2026-10-17: after the owner refreshed the +installed package and verified the actually loaded checker matched the reviewed +R6 source, ordinary Cluster deep consistency and fast projection passed. +Bindings synced 20/20, omitting the three frozen histories; all three parent +and 15 task CURRENT full-row hashes remained unchanged across +before-verified-client.json / after-verified-client.json. Unrelated staged brief +state was preserved. This proves the corrected installed client stage against +its fresh current baseline; earlier parent-hash failures remain retained +historical exceptions, not restored preimages or backfilled successes. + +T10 remains wait/needs_human for exact proposal +STATE-WP-0095-T10-frozen-bindings-guard-r1, now revision 2, metadata +sha256:5d0ddebc21bcb035809cc8b57f4c2e1ba8ed09204be5b965460e178a90913f1e, +pending decision df703266-6719-4c04-ae1b-ba9afd3bca1c. The inspected candidate +image is main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1, +from runtime source c103cbf3c8bed5dba87a03e320595ddded9301a3. Image-only +server dry-run passed eight identities with only three image-field changes. +The exact proposal and recovery limits are retained in the Custodian dated +2026-10-17 guard-successor-review-r2 packet. No new API runtime is approved or +delivered by this source-only note; its later workplan commit does not change +the image's source pin. T09 stays done, T07/T10 stay wait pending reviewed API +delivery and explicit successor/current-baseline acceptance disposition. + + +T10 proposal revision history: revision 1 metadata +sha256:81d365926fc585a186955d8c98ee83501fd3cec12135b2256d71b46a75097932 +and its original helper remain immutable local evidence, unapproved and +unexecuted. Revision 2 of the same stable proposal changes only the native +probe's EXPECTED hash metadata into module/sha256 records for normal publication +scanning; probe behavior, exact image, recovery and acceptance scope remain +unchanged. The same decision df703266-6719-4c04-ae1b-ba9afd3bca1c remains open +and pending. This source-only amendment grants no runtime authority or new +image/code change; T07/T10 remain wait and T09 remains done. + + +T10 verified receiving return, 2026-10-17: the founder approved and the owner +executed exact revision 2 of STATE-WP-0095-T10-frozen-bindings-guard-r1, +sha256:5d0ddebc21bcb035809cc8b57f4c2e1ba8ed09204be5b965460e178a90913f1e, +under decision df703266-6719-4c04-ae1b-ba9afd3bca1c. Helm revision 77 delivered +runtime source c103cbf3c8bed5dba87a03e320595ddded9301a3 and image +main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1. +API/MCP are Ready on expected schema c4d5e6f7a8b9. Eight actual native cases +passed with zero probe commits. All three parent and 15 task CURRENT full-row +hashes remained unchanged after delivery/probes and the actual normal Cluster +pipeline (20 ordinary bindings, three frozen bindings omitted by the verified +loaded checker). All three archived sources remained byte-identical. Exact +owned Job/pod success was observed, then their absence and removal of owned +chart staging were verified; no recovery was needed. + +The verified return is the Custodian dated 2026-10-17 + guard-successor-execution-r2/verified-receiving-return.json, +sha256:09958bd69367cc6903e2a21a56eb81b6a8a46c2a1907afc93f787cd63732e92a. +The original three-parent hash failures remain the founder-accepted historical +exception: unavailable original preimages were not guessed/restored, and failed +outcomes were not backfilled. The 15 original task rows and archived bytes stay +preserved. Earlier pending review notes are retained as proposal history; T10 +is now done and needs_human false. T07 and this parent remain open until the +actual RMGR-WP-0019-T06 owner closure is published and returned. No native +security-target gate or broader autonomy is established by this infrastructure +return. Source appVersion and both maintained image pins now declare this same +verified image; the approved frozen rollout archive remains unchanged. + + +T07 final owner return and STATE95 closure, 2026-10-17: Repo Manager published +RMGR-WP-0019 finished with T06 done at source/remote +3bcb1e1cf3e58b263f690534f7f28024d75cdbf6. Its normal authoritative projection +is applied on primary/railiance01 at that exact expected/derived commit; all +six source task UUIDs/statuses and finished parent were independently read back. +This completes the remaining registrar-return dependency after T10's verified +revision-2 delivery, eight rollback-only native cases and actual corrected +ordinary Cluster pipeline preserved all 18 CURRENT full-row hashes, the 15 +original task rows and three archived source byte digests. + +The immutable runtime return remains +sha256:09958bd69367cc6903e2a21a56eb81b6a8a46c2a1907afc93f787cd63732e92a. +The founder-approved original three-parent mismatch remains a retained +historical exception; no unavailable original preimages were reconstructed, +no failed trial was relabeled, and no original hash restoration is claimed. +All ten STATE95 tasks are now done. The residual audit found no unfinished +work inside this receiving/registrar-return scope; separate security-target, +TLS/recovery/MFA and owner-native admission work remains with its existing +live records and is not accepted by this infrastructure workplan closure. +The runtime remains the verified c103cbf/4cc4ce17 artifact; this later source-only +workplan commit changes neither its source pin nor the frozen rollout archive. diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/verified-registrar-return.json b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/verified-registrar-return.json new file mode 100644 index 00000000..d0a57d0d --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/owner-followthrough-r9/stage2/verified-registrar-return.json @@ -0,0 +1,45 @@ +{ + "schema": "state95.registrar-closure-return.v1", + "registrar_source": "3bcb1e1cf3e58b263f690534f7f28024d75cdbf6", + "registrar_remote": "3bcb1e1cf3e58b263f690534f7f28024d75cdbf6", + "parent_status": "finished", + "tasks": [ + { + "record_id": "RMGR-WP-0019-T01", + "uuid": "d78a622e-39f9-5923-b4e1-f70fdc17fb42", + "status": "done" + }, + { + "record_id": "RMGR-WP-0019-T02", + "uuid": "95240c81-1805-5365-9b35-d238621a16bf", + "status": "done" + }, + { + "record_id": "RMGR-WP-0019-T03", + "uuid": "682eea14-a4a8-5f33-9a9f-8f2f1ac8288d", + "status": "done" + }, + { + "record_id": "RMGR-WP-0019-T04", + "uuid": "1ccb06c6-ad30-5847-8704-e5628ba81f35", + "status": "done" + }, + { + "record_id": "RMGR-WP-0019-T05", + "uuid": "36f47e21-42d0-5aef-b065-4a5dd3af0726", + "status": "done" + }, + { + "record_id": "RMGR-WP-0019-T06", + "uuid": "4d1675b6-f49f-5b01-8d76-bbf2030d1b34", + "status": "done" + } + ], + "projection_status": "applied", + "instance_role": "primary", + "instance_label": "railiance01", + "expected_commit": "3bcb1e1cf3e58b263f690534f7f28024d75cdbf6", + "derived_commit": "3bcb1e1cf3e58b263f690534f7f28024d75cdbf6", + "verified_runtime_receipt_sha256": "sha256:09958bd69367cc6903e2a21a56eb81b6a8a46c2a1907afc93f787cd63732e92a", + "residual_audit": "STATE95 all other9tasks done; receiving/ordinarypipeline/registrar-return scope complete. Earlier3parent failure is approved retained exception, not unresolved repair. Security target/TLS/recovery/MFA/native admission remain existing separate live driver records outside STATE95; no new residual invented." +} diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager.patch b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager.patch new file mode 100644 index 00000000..e78fc5b6 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager.patch @@ -0,0 +1,972 @@ +--- a/src/repo_manager/historical_sources.py ++++ b/src/repo_manager/historical_sources.py +@@ -70,6 +70,111 @@ + "record_id": "CUST-WP-ADHOC-2026-08-25-RETIRED", + "uuid": "1c53d3db-6633-5e2d-987f-10706d73be9e", + "parent_record_id": "CUST-WP-ADHOC-2026-08-25-RETIRED" ++ }, ++ { ++ "repo": "railiance-cluster", ++ "path": "workplans/archived/260622-RAIL-BS-WP-0002-k3s-baseline.md", ++ "sha256": "8d6f8533c119117686bc0d5bb9ed5349726aa6c3189ff98b53409c68ac685ca9", ++ "record_id": "RAIL-BS-WP-0002", ++ "uuid": "4c63dfc6-9eac-4e79-9f77-8f644ad7147d", ++ "parent_record_id": "RAIL-BS-WP-0002", ++ "tasks": [ ++ { ++ "label": "T01", ++ "uuid": "3f042630-eab0-4c6a-9167-e2b28ff20e40", ++ "status": "done" ++ }, ++ { ++ "label": "T02", ++ "uuid": "e8510646-46ed-4697-a345-f3d3009eea78", ++ "status": "done" ++ }, ++ { ++ "label": "T03", ++ "uuid": "dab2c07f-8aa0-4635-8df6-857e87e93fc5", ++ "status": "done" ++ }, ++ { ++ "label": "T04", ++ "uuid": "5c3d40e4-239b-488e-9519-6f7a38d2325f", ++ "status": "done" ++ }, ++ { ++ "label": "T05", ++ "uuid": "7f9e0e58-a130-467a-a2d0-b3f2564e496f", ++ "status": "done" ++ } ++ ] ++ }, ++ { ++ "repo": "railiance-cluster", ++ "path": "workplans/archived/260622-RAIL-BS-WP-0003-pgpool-ha-failover-fix.md", ++ "sha256": "4dc2f61e15adc3c5c81f9ecf8b933512275b95e8f829fac52ae7cde1385cea4a", ++ "record_id": "RAIL-BS-WP-0003", ++ "uuid": "7ee9ee22-1fae-4567-9194-8d70a9e0f45b", ++ "parent_record_id": "RAIL-BS-WP-0003", ++ "tasks": [ ++ { ++ "label": "T01", ++ "uuid": "6841c93a-f146-47eb-9f7c-8fa0e02c1bbc", ++ "status": "done" ++ }, ++ { ++ "label": "T02", ++ "uuid": "ab166073-30a7-4702-a037-4091e8706e20", ++ "status": "done" ++ }, ++ { ++ "label": "T03", ++ "uuid": "140da396-8e30-4f4d-b88c-c42c0cd46c01", ++ "status": "done" ++ }, ++ { ++ "label": "T04", ++ "uuid": "d8a3ba40-fda0-4c1f-a9f1-ffcd621a5b3d", ++ "status": "done" ++ } ++ ] ++ }, ++ { ++ "repo": "railiance-cluster", ++ "path": "workplans/archived/260622-RAIL-BS-WP-0004-safety-net.md", ++ "sha256": "04f787078232395bd2d43861de16c521848559fa286f06e1caa9cb779aa71ac3", ++ "record_id": "RAIL-BS-WP-0004", ++ "uuid": "7e8b0c20-51eb-40c9-9e3b-85dd380d7625", ++ "parent_record_id": "RAIL-BS-WP-0004", ++ "tasks": [ ++ { ++ "label": "T01", ++ "uuid": "4526a842-ea31-4874-9231-92ab556cfe7b", ++ "status": "done" ++ }, ++ { ++ "label": "T02", ++ "uuid": "a6313e06-1976-46a7-8e31-df4eb2eca880", ++ "status": "done" ++ }, ++ { ++ "label": "T03", ++ "uuid": "05d42a55-921f-4aa7-bb76-e8af9c7e0ac3", ++ "status": "done" ++ }, ++ { ++ "label": "T04", ++ "uuid": "08233868-d522-4117-bc4e-6c0f52545665", ++ "status": "done" ++ }, ++ { ++ "label": "T05", ++ "uuid": "2d5acff7-4a4e-4ddd-ad06-08237ad3dac8", ++ "status": "done" ++ }, ++ { ++ "label": "T06", ++ "uuid": "f8e4a094-c367-40eb-b895-da17bc144b07", ++ "status": "done" ++ } ++ ] + } + ]''') + +@@ -83,3 +188,24 @@ + raise ValueError("Historical source binding requires owner review: " + entry["path"]) + paths.add(entry["path"]) + return paths ++ ++ ++def historical_source_occurrences(repo_root): ++ """Exact frozen closed task occurrences; never a general local-ID alias.""" ++ from repo_manager.parse.workplan import parse_workplan_file ++ from uuid import UUID ++ paths=historical_source_paths(repo_root) ++ occurrences=set(paths) ++ for entry in BINDINGS: ++ if entry['repo']!=repo_root.name or 'tasks' not in entry:continue ++ parsed=parse_workplan_file(repo_root/entry['path'],repo_root=repo_root) ++ actual=[{'label':task.id,'uuid':task.state_hub_task_id,'status':task.status} for task in parsed.tasks] ++ if (parsed.id!=entry['record_id'] or parsed.state_hub_workstream_id!=entry['uuid'] ++ or parsed.status not in {'finished','archived','completed'} or actual!=entry['tasks'] ++ or any(task['status'] not in {'done','cancel'} for task in actual)): ++ raise ValueError('Historical task binding requires owner review: '+entry['path']) ++ UUID(entry['uuid']) ++ for number,task in enumerate(actual,1): ++ UUID(task['uuid']) ++ occurrences.add(entry['path']+'#task-block-'+str(number)) ++ return occurrences +--- a/src/repo_manager/identifiers.py ++++ b/src/repo_manager/identifiers.py +@@ -854,8 +854,9 @@ + # allocation candidates. Keep conformance reporting strict while allowing + # ordinary registration to preserve those bindings. Local aliases still + # require the explicit parent allowlist in the kind registry. +- from repo_manager.historical_sources import historical_source_paths +- historical_sources: set[str] = historical_source_paths(repo_root) ++ from repo_manager.historical_sources import historical_source_occurrences ++ frozen_sources = historical_source_occurrences(repo_root) ++ historical_sources: set[str] = set(frozen_sources) + for path in iter_workplan_files(repo_root): + parsed = parse_workplan_file(path, repo_root=repo_root) + if parsed.status not in {"finished", "archived", "completed"} or not parsed.id: +@@ -889,11 +890,18 @@ + item for item in identity["invalid_identifiers"] + if item["source"] in historical_sources + ] +- if invalid or identity["identity_collisions"]: ++ # Strict scans keep legacy nonconformance. Only collisions wholly contained ++ # in exact hash-bound closed task occurrences can be preserved by receiving; ++ # any unknown/live source or qualified/workplan collision remains fatal. ++ historical_collisions=[item for item in identity['identity_collisions'] ++ if item['kind']=='task' and re.fullmatch(r'T[0-9]{2}',item['id']) ++ and all(source in frozen_sources for source in item['sources'])] ++ collisions=[item for item in identity['identity_collisions'] if item not in historical_collisions] ++ if invalid or collisions: + raise ValueError( + "work-record identities are not safe to derive: " + f"invalid={invalid!r}, " +- f"collisions={identity['identity_collisions']!r}" ++ f"collisions={collisions!r}" + ) + + rendered: dict[Path, str] = {} +@@ -966,6 +974,7 @@ + "namespace": namespace, + "assignments": assignments, + "historical_invalid_identifiers": historical, ++ "historical_identity_collisions": historical_collisions, + "files_changed": sorted(str(path.relative_to(repo_root)) for path in rendered), + } + +--- /dev/null ++++ b/tests/test_cluster_frozen_history.py +@@ -0,0 +1,52 @@ ++import shutil ++from pathlib import Path ++import pytest ++from repo_manager.identifiers import ensure_missing_work_record_identifiers as ensure ++from repo_manager.record_identity import scan_record_identities ++ ++FIXTURE=Path(__file__).parent/'fixtures/railiance-cluster' ++@pytest.fixture ++def repo(tmp_path): ++ root=tmp_path/'railiance-cluster';shutil.copytree(FIXTURE,root) ++ live=root/'workplans/RCLUSTER-WP-0018.md';live.write_text('---\nid: RCLUSTER-WP-0018\nstatus: ready\n---\n\n```task\nid: RCLUSTER-WP-0018-T01\nstatus: todo\n```\n') ++ return root,live ++ ++def test_exact_three_histories_preserve_15_bindings_and_strict_findings(repo): ++ root,live=repo;archive={p:p.read_bytes() for p in (root/'workplans/archived').glob('*.md')};before=scan_record_identities(root) ++ assert len(before['invalid_identifiers'])==15 and len(before['identity_collisions'])==5 ++ preview=ensure(root,execute=False);assert len(preview['assignments'])==2 ++ assert preview['historical_invalid_identifiers']==before['invalid_identifiers'] ++ assert preview['historical_identity_collisions']==before['identity_collisions'] ++ result=ensure(root,execute=True);assert len(result['assignments'])==2 ++ assert all(p.read_bytes()==raw for p,raw in archive.items()) ++ assert not scan_record_identities(root)['ok'] ++ assert ensure(root,execute=False)['assignments']==[] ++ ++@pytest.mark.parametrize('change',['bytes','missing','symlink','live','nonterminal','uuid']) ++def test_unknown_history_change_refuses_before_any_allocation(repo,change): ++ root,live=repo;p=next((root/'workplans/archived').glob('*.md'));before=live.read_bytes() ++ raw=p.read_bytes() ++ if change=='missing':p.unlink() ++ elif change=='symlink':p.unlink();target=root/'saved';target.write_bytes(raw);p.symlink_to(target) ++ elif change=='live':p.write_bytes(raw.replace(b'status: finished',b'status: active',1)) ++ elif change=='nonterminal':p.write_bytes(raw.replace(b'status: done',b'status: todo',1)) ++ elif change=='uuid':p.write_bytes(raw.replace(b'state_hub_task_id:',b'old_task_id:',1)) ++ else:p.write_bytes(raw+b'\nunreviewed change\n') ++ with pytest.raises(ValueError):ensure(root) ++ assert live.read_bytes()==before ++ ++def test_any_unknown_or_live_source_in_collision_still_refuses(repo): ++ root,live=repo;before=live.read_bytes();other=root/'workplans/unknown.md' ++ other.write_text('---\nid: RCLUSTER-WP-0099\nstatus: active\n---\n\n```task\nid: T01\nstatus: todo\nstate_hub_task_id: 11111111-1111-4111-8111-111111111111\n```\n') ++ with pytest.raises(ValueError,match='identities are not safe'):ensure(root) ++ assert live.read_bytes()==before ++ ++def test_other_repo_does_not_inherit_exact_frozen_exception(repo): ++ root,live=repo;other=root.with_name('other');root.rename(other) ++ with pytest.raises(ValueError,match='identities are not safe'):ensure(other) ++ ++def test_qualified_collision_stays_global(repo): ++ root,live=repo;before=live.read_bytes() ++ (root/'workplans/other.md').write_text('---\nid: RCLUSTER-WP-0019\nstatus: active\n---\n\n```task\nid: RCLUSTER-WP-0018-T01\nstatus: done\nstate_hub_task_id: 11111111-1111-4111-8111-111111111111\n```\n') ++ with pytest.raises(ValueError,match='collisions='):ensure(root) ++ assert live.read_bytes()==before +--- a/workplans/RMGR-WP-0019-historical-source-admission.md ++++ b/workplans/RMGR-WP-0019-historical-source-admission.md +@@ -4,10 +4,10 @@ + title: "Admit exact historical source views after receiving preservation support" + domain: infotech + repo: repo-manager +-status: finished ++status: active + owner: codex + created: "2026-10-04" +-updated: "2026-10-06" ++updated: "2026-10-17" + flavor: implementation + depends_on: [] + state_hub_workstream_id: "efe0ecb2-2f3f-5f49-baee-b90ad1d353ab" +@@ -146,3 +146,62 @@ + primary/railiance01, applied, 0 created / 45 updated (ACTIVITY-WP only) / 0 retired + / 0 refused, 0 task changes, historical UUIDs preserved. Evidence: + state-hub docs/evidence/2026-10-06-activity-stale-retirement-rollout.json. ++ ++ ++## Cluster exact historical source successor, 2026-10-17 ++ ++This scoped extension preserves the completed Activity/Custodian work above. ++The cluster publisher now refuses three closed RAIL-BS source files containing ++15 existing UUID-bound bare task labels. Preserve all three source bytes, IDs, ++and UUIDs. No new alias or general syntax exemption is introduced. Evidence and ++exact receiver-first proposal: cluster-historical-publication-r1 in the Custodian ++NetKingdom change packet. The existing SHR-WP-0001 project driver owns the ++Repo Manager / State Hub cross-owner wait; NK-WP-0049-T35 benefits from the ++blocked cluster publication. No worker or human gate is woken here. ++ ++## Prepare exact cluster registrar successor ++ ++```task ++id: RMGR-WP-0019-T04 ++status: done ++priority: high ++``` ++ ++Prepared full-file SHA256 bindings for three archives and 15 exact task ++occurrences; strict scans still return 15 invalid labels and five collisions. ++Unknown/live sources, changed/missing/symlinked histories, UUID drift and ++qualified/global collisions refuse before allocation. Candidate tests pass 47; ++canonical application remains a separate reviewed step. The read-only current ++cluster preview proposes seven ordinary UUID bindings in two files and changes ++no bytes. One pre-existing ADHOC field is included explicitly: task ++RCLUSTER-WP-ADHOC-2026-07-27-T01 in workplans/ADHOC-2026-07-27.md receives ++3e3f39d3-5070-5ba3-97db-19f5f361f264 through managed allocation only. ++ ++## Admit cluster registrar successor only after receiving verification ++ ++```task ++id: RMGR-WP-0019-T05 ++status: wait ++priority: high ++depends_on: [STATE-WP-0095-T06] ++``` ++ ++Retain the current guard until exact reviewed receiving source has been delivered ++and verified through State Hub's normal owner deployment. Match both manifests, ++then integrate reviewed registrar source and verify ordinary allocator preview. ++No direct API patch, history rename or bypass substitutes for this gate. ++ ++## Run ordinary cluster owner publication and verify preservation ++ ++```task ++id: RMGR-WP-0019-T06 ++status: wait ++priority: high ++depends_on: [RMGR-WP-0019-T05] ++``` ++ ++Only then run normal owner consistency/publication with the reviewed expected ++file set: RCLUSTER-WP-0018 and the one-field ADHOC managed backfill, plus ordinary ++generated indexes. Preserve all three archived files and all 18 existing ++parent/task rows. Return actual receiving/publication receipts to STATE-WP-0095-T07. ++Do not infer TLS recovery, credential custody or native acceptance from sync. +--- /dev/null ++++ b/tests/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0002-k3s-baseline.md +@@ -0,0 +1,177 @@ ++--- ++id: RAIL-BS-WP-0002 ++type: workplan ++title: "k3s and Kubernetes Platform Baseline" ++domain: financials ++repo: railiance-cluster ++status: finished ++owner: railiance ++topic_slug: railiance ++repo_goal_id: "70ab2379-fb9d-4fec-a09d-b2a717e4ace8" ++state_hub_workstream_id: "4c63dfc6-9eac-4e79-9f77-8f644ad7147d" ++created: "2026-03-09" ++updated: "2026-03-10" ++completed: "2026-03-10" ++--- ++ ++> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`). ++ ++# k3s and Kubernetes Platform Baseline ++ ++## Goal ++ ++Install k3s, Helm, and the baseline Kubernetes services on the converged ++HostEurope node. This workplan picks up exactly where `railiance-hosts` ++leaves off: a hardened, verified OS node that is ready for Kubernetes. ++ ++## Pre-condition ++ ++`railiance-infra` converge + Goss verify must pass before any task here ++is executed: ++ ++```bash ++# In railiance-infra/ ++make converge ++make verify # must exit 0 ++``` ++ ++## Boundary ++ ++This repo owns everything from k3s upward. It must not re-configure items ++defined in `railiance-infra/spec/server-baseline.yaml`. See ADR-003: ++`railiance-infra/docs/adr/ADR-003-railiance-5repo-stack-architecture.md`. ++ ++**Out of scope here:** platform services (PostgreSQL, storage, identity) ++→ `railiance-platform`. Application deployments (Gitea, coulomb services) ++→ `railiance-apps`. ++ ++--- ++ ++## Tasks ++ ++### T01 — Ansible playbook: install k3s (server mode) ++ ++```task ++id: T01 ++status: done ++priority: high ++state_hub_task_id: "3f042630-eab0-4c6a-9167-e2b28ff20e40" ++completed: "2026-03-10" ++``` ++ ++Harden `ansible/bootstrap.yml` to a production-ready k3s install: ++ ++- Use the official k3s install script pinned to a specific version ++ (`INSTALL_K3S_VERSION=v1.35.1+k3s1`) ++- `INSTALL_K3S_EXEC="server --cluster-init --write-kubeconfig-mode=644"` ++ (cluster-init enables embedded etcd for future HA expansion) ++- Wait for node `Ready` before proceeding: ++ ```bash ++ k3s kubectl wait node --all --for=condition=Ready --timeout=120s ++ ``` ++- Fetch kubeconfig to the control node as `~/.kube/config-hosteurope` ++ ++**Done when:** `k3s kubectl get nodes` returns `Ready` from both the server ++and the control node (via kubeconfig). ++ ++--- ++ ++### T02 — Helm installation ++ ++```task ++id: T02 ++status: done ++priority: high ++state_hub_task_id: "e8510646-46ed-4697-a345-f3d3009eea78" ++completed: "2026-03-10" ++``` ++ ++Add a task (or a role `roles/helm/`) that: ++ ++1. Downloads the Helm binary (pinned version) to `/usr/local/bin/helm` ++2. Verifies the checksum ++3. Confirms `helm version` succeeds ++ ++**Done when:** `helm version` succeeds on the HostEurope node. ++ ++--- ++ ++### T03 — Smoke test: k3s + Helm ++ ++```task ++id: T03 ++status: done ++priority: high ++state_hub_task_id: "dab2c07f-8aa0-4635-8df6-857e87e93fc5" ++completed: "2026-03-10" ++``` ++ ++Extend `tests/smoke_kube.sh` to assert: ++ ++- `k3s kubectl get nodes` → node in Ready state ++- `helm version` exits 0 ++- CoreDNS pod running in `kube-system` ++- Traefik ingress controller pod running (default in k3s) ++ ++Run via: ++```bash ++ansible-playbook -i ansible/hosts.ini ansible/smoke.yml ++``` ++or directly over SSH if the kubeconfig is available locally. ++ ++**Done when:** all assertions pass and the script exits 0. ++ ++--- ++ ++### T04 — Commit kubeconfig management notes ++ ++```task ++id: T04 ++status: done ++priority: medium ++state_hub_task_id: "5c3d40e4-239b-488e-9519-6f7a38d2325f" ++completed: "2026-03-10" ++``` ++ ++Document in `docs/kubeconfig.md`: ++ ++- Where the kubeconfig is fetched to (`~/.kube/config-hosteurope`) ++- How to merge it into `~/.kube/config` ++- How to switch context: `kubectl config use-context default` ++- Security note: kubeconfig is gitignored (contains cluster CA + client cert) ++ ++**Done when:** doc written and committed. ++ ++--- ++ ++### T05 — Add `make k3s-install` and `make smoke` targets ++ ++```task ++id: T05 ++status: done ++priority: medium ++state_hub_task_id: "7f9e0e58-a130-467a-a2d0-b3f2564e496f" ++completed: "2026-03-10" ++``` ++ ++Add to Makefile (create one if none exists): ++ ++```makefile ++k3s-install: ## Install k3s and Helm on all inventory hosts ++ ansible-playbook -i ansible/hosts.ini ansible/bootstrap.yml ++ ++smoke: ## Run Kubernetes smoke tests ++ bash tests/smoke_kube.sh ++``` ++ ++**Done when:** both targets work and are listed in `make help`. ++ ++--- ++ ++## References ++ ++- Repo goal: `70ab2379-fb9d-4fec-a09d-b2a717e4ace8` (Install k3s and Kubernetes Baseline) ++- Domain goal: `6f96c712-60e6-4ea9-ab06-168878eafbce` (Three-Phoenix Secure Kubernetes Infrastructure) ++- Pre-condition: railiance-infra WP-0001 (Secure Single-Server Bootstrap) — completed 2026-03-09 ++- Boundary ADR: `railiance-infra/docs/adr/ADR-003-railiance-5repo-stack-architecture.md` ++- k3s releases: https://github.com/k3s-io/k3s/releases +--- /dev/null ++++ b/tests/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0003-pgpool-ha-failover-fix.md +@@ -0,0 +1,196 @@ ++--- ++id: RAIL-BS-WP-0003 ++type: workplan ++title: "pgpool CrashLoopBackOff on PostgreSQL HA failover — missing secret key" ++domain: financials ++repo: railiance-cluster ++status: finished ++owner: tegwick ++created: "2026-03-10" ++updated: "2026-03-10" ++state_hub_workstream_id: "7ee9ee22-1fae-4567-9194-8d70a9e0f45b" ++--- ++ ++> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`). ++ ++# Bug Report: pgpool CrashLoopBackOff on PostgreSQL HA failover ++ ++## Summary ++ ++On 2026-03-10 a PostgreSQL HA failover caused all three postgresql pods to ++restart. pgpool — the connection pooler between Gitea and PostgreSQL — then ++entered CrashLoopBackOff and produced no logs. As a result Gitea's login ++and all write operations hung indefinitely. The root page was still served ++(from Valkey cache) which masked the failure. ++ ++The fix was to patch a missing key in a Kubernetes secret. The root cause is ++that the `gitea-12.2.0` Helm chart (postgresql-ha subchart v16.2.2) does not ++populate the `pgpool-password` key in the `gitea-postgresql-ha-postgresql` ++secret, even though the pgpool pod requires it at startup. ++ ++--- ++ ++## Timeline ++ ++| Time (UTC) | Event | ++|---|---| ++| ~09:45 | postgresql-0, postgresql-2 pods restarted (repmgr failover) | ++| ~09:45 | pgpool pod restarted and entered CrashLoopBackOff | ++| ~11:00 | User noticed Gitea login hanging; home page still loading | ++| ~13:00 | Root cause identified: missing `pgpool-password` secret key | ++| ~13:10 | Secret patched; pgpool pod deleted and restarted cleanly | ++| ~13:15 | Gitea fully operational | ++ ++--- ++ ++## Root Cause ++ ++The Bitnami `pgpool` container startup script reads the file ++`/opt/bitnami/pgpool/secrets/pgpool-password`, which is mounted from the ++`gitea-postgresql-ha-postgresql` Kubernetes Secret via a `subPath` volume ++mount. That secret key was never created by the Helm chart, so the file did ++not exist. The container exited immediately with no logs. ++ ++The pod had been running for 20 days without a restart, so this gap was ++never discovered during initial deployment. ++ ++--- ++ ++## Evidence ++ ++```bash ++# Secret was missing the pgpool-password key ++sudo k3s kubectl get secret -n default gitea-postgresql-ha-postgresql -o yaml ++# data: keys were password, postgres-password, repmgr-password only ++# pgpool-password was absent ++ ++# pgpool pod describe showed 824 back-off restarts over 173 minutes ++# No logs in either current or --previous output ++sudo k3s kubectl logs -n default --previous ++# (empty) ++ ++# Gitea process had zero TCP connections to PostgreSQL port 5432 ++# but many connections to Valkey port 6379 ++cat /proc//net/tcp | grep 1538 # 1538 = 5432 hex — no results ++``` ++ ++--- ++ ++## Immediate Fix Applied ++ ++```bash ++# Add the missing key (value = sr-check-password = changeme4 = base64: Y2hhbmdlbWU0) ++sudo k3s kubectl patch secret -n default gitea-postgresql-ha-postgresql \ ++ --type='json' \ ++ -p='[{"op":"add","path":"/data/pgpool-password","value":"Y2hhbmdlbWU0"}]' ++ ++# Restart pgpool ++sudo k3s kubectl delete pod -n default ++``` ++ ++--- ++ ++## Risk: Fix Will Be Lost on helm upgrade ++ ++The patched secret is managed by Helm (annotation: ++`meta.helm.sh/release-name: gitea`). A `helm upgrade` will regenerate the ++secret from the chart template, which does not include `pgpool-password`, ++and the bug will recur. ++ ++--- ++ ++## Tasks ++ ++### T01 — Add pgpool-password to Helm values ++ ++```task ++id: T01 ++status: done ++priority: high ++state_hub_task_id: "6841c93a-f146-47eb-9f7c-8fa0e02c1bbc" ++``` ++ ++Create or update `helm/gitea-values.yaml` (or equivalent) to permanently ++include the pgpool-password so it survives `helm upgrade`: ++ ++```yaml ++postgresql-ha: ++ postgresql: ++ pgpoolPassword: ++``` ++ ++**Done when:** `helm upgrade gitea` completes and pgpool starts cleanly ++without manual secret patching. ++ ++--- ++ ++### T02 — Add pgpool health check to smoke test ++ ++```task ++id: T02 ++status: done ++priority: high ++state_hub_task_id: "ab166073-30a7-4702-a037-4091e8706e20" ++``` ++ ++Extend `tests/smoke_kube.sh` to assert: ++ ++```bash ++# All postgresql-ha pods Running ++kubectl get pods -n default | grep gitea-postgresql-ha | grep -v Running && exit 1 ++ ++# pgpool specifically not in CrashLoopBackOff ++kubectl get pod -n default -l app.kubernetes.io/component=pgpool \ ++ -o jsonpath='{.items[0].status.containerStatuses[0].state}' | grep -v crash ++``` ++ ++**Done when:** the smoke test catches a pgpool failure within 5 minutes. ++ ++--- ++ ++### T03 — Add HA failover test ++ ++```task ++id: T03 ++status: done ++priority: high ++state_hub_task_id: "140da396-8e30-4f4d-b88c-c42c0cd46c01" ++``` ++ ++Create `tests/test_ha_failover.sh` that: ++ ++1. Records Gitea login response time (baseline) ++2. Kills the primary PostgreSQL pod: `kubectl delete pod gitea-postgresql-ha-postgresql-0 -n default` ++3. Waits for repmgr to promote a replica (max 60s) ++4. Asserts Gitea login POST still succeeds within 10s ++5. Asserts pgpool pod is Running (not CrashLoopBackOff) ++6. Asserts all postgresql pods return to Running ++ ++This test must pass before any PostgreSQL HA deployment is considered done. ++ ++**Done when:** script exits 0 against a live cluster. ++ ++--- ++ ++### T04 — Document the incident in docs/ ++ ++```task ++id: T04 ++status: done ++priority: medium ++state_hub_task_id: "d8a3ba40-fda0-4c1f-a9f1-ffcd621a5b3d" ++``` ++ ++Add `docs/incidents/2026-03-10-pgpool-missing-secret.md` with the full ++timeline, root cause, and fix, so future operators understand what happened ++and how to recover. ++ ++**Done when:** doc committed and linked from `docs/README.md`. ++ ++--- ++ ++## References ++ ++- Bitnami postgresql-ha chart v16.2.2 ++- Gitea Helm chart v12.2.0 ++- Related decision: D3 (HA testing policy) in `DECISIONS.md` +--- /dev/null ++++ b/tests/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0004-safety-net.md +@@ -0,0 +1,275 @@ ++--- ++id: RAIL-BS-WP-0004 ++type: workplan ++title: "Integrated Backup — S2 Kubernetes Runtime Layer" ++domain: financials ++repo: railiance-cluster ++status: finished ++owner: tegwick ++topic_slug: railiance ++state_hub_workstream_id: "7e8b0c20-51eb-40c9-9e3b-85dd380d7625" ++created: "2026-02-25" ++updated: "2026-03-26" ++--- ++ ++> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`). ++ ++# Integrated Backup — S2 Kubernetes Runtime Layer ++ ++## Goal ++ ++Implement the Q3 (Operability & Resilience) integrated backup for ++railiance-cluster (S2). Backs up what S2 owns — the Kubernetes runtime state — ++encrypted with age, written to a local directory on the server. No external ++dependencies required. ++ ++## Architecture (Decision D4) ++ ++Each railiance repo implements its own backup for what it owns. No central ++backup service. See `DECISIONS.md` D4 for full rationale. ++ ++**Standard interface every railiance repo must provide:** ++ ++```bash ++make backup # encrypt + write to /opt/backup/railiance// ++make restore # restore from most recent local backup ++``` ++ ++Encryption: age, same key pair as SOPS secrets (`.sops.yaml` public key). ++Output: `/opt/backup/railiance/cluster/` on the server. ++ ++## What S2 (railiance-cluster) owns and must back up ++ ++| Asset | Why it matters | ++|---|---| ++| k3s etcd snapshots | Full cluster state — all workloads, configs, secrets | ++| Helm release values | Runtime values not in git (any manually applied overrides) | ++| kubeconfig | Admin access to the cluster | ++ ++**Not S2's responsibility:** ++- Custodian State Hub DB → the-custodian owns this ++- Operator workstation config (`.claude/`, `.gitconfig`) → operator's own concern ++- Application data (Gitea repos, uploads) → S5 (railiance-apps) owns this ++- PostgreSQL data volumes → S3 (railiance-platform) owns this ++ ++## Encryption ++ ++Reuse the age public key from `.sops.yaml`: ++ ++```bash ++AGE_PUBLIC_KEY=$(grep 'age:' .sops.yaml | awk '{print $2}') ++tar -czf - | age -r "${AGE_PUBLIC_KEY}" -o backup.tar.gz.age ++``` ++ ++Decryption requires the private key at `~/.config/sops/age/keys.txt` ++(same key used for `sops -d`). No additional key management needed. ++ ++## Extension Point EP-RAIL-005 ++ ++Once all five OAS layers implement this interface, the custodian can ++orchestrate a full-stack backup with: ++ ++```bash ++for repo in railiance-infra railiance-cluster railiance-platform \ ++ railiance-enablement railiance-apps; do ++ make -C ~/$repo backup ++done ++``` ++ ++No special protocol needed — just the standard interface. ++ ++--- ++ ++## Tasks ++ ++### T01 — Define backup directory and encryption wrapper ++ ++```task ++id: T01 ++status: done ++priority: high ++state_hub_task_id: "4526a842-ea31-4874-9231-92ab556cfe7b" ++``` ++ ++Create `tools/cmd/railiance-backup-s2` (replacing the old `railiance-backup`): ++ ++- Backup dir: `/opt/backup/railiance/cluster/` (create with `mkdir -p`) ++- Encrypt each artifact with age using public key from `.sops.yaml` ++- Write timestamp-named files: `etcd-.snap.age`, `helm-values-.tar.gz.age`, `kubeconfig-.yaml.age` ++- Keep last 7 of each type ++- Write `.last-backup` stamp ++- Exit 0 on success, non-zero on any failure ++- No network required ++ ++Also remove the old `tools/cmd/railiance-backup` (backed up Docker-based ++custodian DB — wrong scope, not applicable to this server). ++ ++**Done when:** `make backup` runs on COULOMBCORE without error and files ++appear in `/opt/backup/railiance/cluster/`. ++ ++--- ++ ++### T02 — Back up k3s state (SQLite hot backup) ++ ++```task ++id: T02 ++status: done ++priority: high ++state_hub_task_id: "a6313e06-1976-46a7-8e31-df4eb2eca880" ++``` ++ ++k3s has built-in etcd snapshot support: ++ ++```bash ++sudo k3s etcd-snapshot save --name railiance-$(date -u +%Y%m%dT%H%M%SZ) ++# Default location: /var/lib/rancher/k3s/server/db/snapshots/ ++``` ++ ++Add to the backup script: take a fresh snapshot, encrypt with age, ++copy to `/opt/backup/railiance/cluster/`. ++ ++> **Note — verify etcd is in use before implementing:** ++> `k3s etcd-snapshot` only works if k3s was started with `--cluster-init`. ++> Without it, k3s uses SQLite and this command will fail. ++> Verify first: `sudo k3s etcd-snapshot ls 2>&1` ++ ++> **Note — sudo required:** etcd snapshot requires root. See T06 for how ++> this is resolved (backup runs under root's crontab). ++ ++**Done when:** backup includes a current etcd snapshot. ++ ++--- ++ ++### T03 — Back up Helm release values ++ ++```task ++id: T03 ++status: done ++priority: medium ++state_hub_task_id: "05d42a55-921f-4aa7-bb76-e8af9c7e0ac3" ++``` ++ ++Capture current runtime Helm values for all releases: ++ ++```bash ++KUBECONFIG=/etc/rancher/k3s/k3s.yaml helm list -A -o json | \ ++ jq -r '.[].name + " " + .namespace' | \ ++ while read name ns; do ++ KUBECONFIG=/etc/rancher/k3s/k3s.yaml helm get values "$name" -n "$ns" -o yaml ++ done ++``` ++ ++Tar and age-encrypt into `helm-values-.tar.gz.age`. ++ ++> **Note — kubeconfig permissions:** `/etc/rancher/k3s/k3s.yaml` is root-readable ++> only by default. The backup script must either run as root (see T06) or k3s ++> must be configured with `--write-kubeconfig-mode=644`. Running as root ++> (via root crontab) is the chosen approach — no config change needed. ++ ++**Done when:** backup includes a snapshot of all Helm release values. ++ ++--- ++ ++### T04 — Back up kubeconfig ++ ++```task ++id: T04 ++status: done ++priority: medium ++state_hub_task_id: "08233868-d522-4117-bc4e-6c0f52545665" ++``` ++ ++Age-encrypt `~/.kube/config-hosteurope` (or `/etc/rancher/k3s/k3s.yaml`) ++into `kubeconfig-.yaml.age` in the backup directory. ++ ++**Done when:** backup includes the encrypted kubeconfig. ++ ++--- ++ ++### T05 — make restore target ++ ++```task ++id: T05 ++status: done ++priority: medium ++state_hub_task_id: "2d5acff7-4a4e-4ddd-ad06-08237ad3dac8" ++``` ++ ++Add `tools/cmd/railiance-restore-s2` that decrypts and lists available ++backups, with guided restore for the etcd snapshot case. ++ ++Restore of etcd from snapshot: ++```bash ++sudo k3s server --cluster-reset \ ++ --cluster-reset-restore-path=/var/lib/rancher/k3s/server/db/snapshots/ ++``` ++ ++**Done when:** `make restore` prints available backups and a restore guide. ++ ++--- ++ ++### T06 — Install cron job and run restore drill ++ ++```task ++id: T06 ++status: done ++priority: medium ++state_hub_task_id: "f8e4a094-c367-40eb-b895-da17bc144b07" ++``` ++ ++#### Solving the sudo problem ++ ++The backup script needs root for two reasons: ++- `k3s etcd-snapshot save` requires root ++- `/etc/rancher/k3s/k3s.yaml` (kubeconfig) is root-readable only ++ ++**Solution: run the cron under root's crontab.** ++ ++This is the correct pattern for system-level backup jobs. It avoids a ++proliferating sudoers whitelist (one entry per command, brittle to maintain) ++and matches how tools like `rsnapshot`, `bacula`, and `borgbackup` work in ++production. The backup writes to `/opt/backup/` which is root-owned anyway. ++ ++Install the cron as root: ++ ++```bash ++sudo crontab -e ++# Add: ++0 2 * * * make -C /home/tegwick/railiance-cluster backup >> /opt/backup/railiance/cluster/backup.log 2>&1 ++``` ++ ++Note: use the absolute path to the repo — `~` does not expand reliably in ++root's crontab unless HOME is set. ++ ++Verify it is installed: ++```bash ++sudo crontab -l | grep railiance ++``` ++ ++#### Restore drill ++ ++Once T01–T04 are done, run a decrypt-and-verify drill: ++ ++```bash ++# Decrypt the etcd snapshot and verify it is a valid snapshot file ++sudo age -d -i ~/.config/sops/age/keys.txt \ ++ /opt/backup/railiance/cluster/etcd-$(ls /opt/backup/railiance/cluster/etcd-*.snap.age | sort -r | head -1 | xargs basename | sed 's/etcd-//;s/.snap.age//').snap.age \ ++ | file - ++ ++# Record the drill ++echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) restore drill OK" \ ++ >> /opt/backup/railiance/cluster/restore-drill.log ++``` ++ ++**Done when:** cron installed under root, drill completes without error, ++log entry written. ++ ++--- ++ ++## References ++ ++- Decision D4: Integrated backup per capability (`DECISIONS.md`) ++- Decision D2: Nextcloud as optional offsite extension (still valid, not a requirement) ++- OAS Q3: Operability & Resilience ++- Extension point EP-RAIL-005: Custodian full-stack backup orchestration ++- k3s etcd snapshots: https://docs.k3s.io/datastore/backup-restore diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/assistant-provenance.yaml b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/assistant-provenance.yaml new file mode 100644 index 00000000..30c14831 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/assistant-provenance.yaml @@ -0,0 +1,6 @@ +schema: repo-manager.assistant-provenance.v1 +cutover_commit: 329af60753b20896f30f098e8f75074652780032 +known_automation: + - name: custodian-sync + email: custodian-sync@railiance.local + trailer_required: false diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/dual-run.pilot.example.yaml b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/dual-run.pilot.example.yaml new file mode 100644 index 00000000..27a63119 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/dual-run.pilot.example.yaml @@ -0,0 +1,10 @@ +# Example dual-run pilot config (RMGR-WP-0003). +# Install: cp config/dual-run.pilot.example.yaml ~/.repo-manager/dual-run.yaml +# Env vars still override when set. Rollback: writeback/reconcile false or delete file. + +writeback: true +reconcile: true +writeback_push: false # set true only if pilot checkouts can push safely +pilot_repos: + - repo-manager +meter_path: ~/.repo-manager/mutation-meter.jsonl diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/fleet-namespace.yaml b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/fleet-namespace.yaml new file mode 100644 index 00000000..11785e3c --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/fleet-namespace.yaml @@ -0,0 +1,12 @@ +schema: repo-manager.fleet-namespace.v1 +namespace: helixforge +scope: HelixForge fleet +lineage: + kind: root + parent: null +posture: + current: {N: 2, U: 1, R: 1, S: 1} + target: {N: 3, U: 2, R: 2, S: 1} +declared_by: Bernd Worsch +declared: "2026-08-21" +decision_ref: RMGR-WP-0005-T03 diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/hub-record-authority.yaml b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/hub-record-authority.yaml new file mode 100644 index 00000000..cff1b8fa --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/hub-record-authority.yaml @@ -0,0 +1,71 @@ +schema: repo-manager.hub-record-authority.v1 +updated: "2026-08-21" +workplan_task: RMGR-WP-0005-T08 + +routing: + file-derived-key: "repository:/" + hub-native-key: "hub:" + retired-key: "archive:" + unknown-record-policy: reject + claimed-owner-mismatch-policy: reject + +classes: + file-derived: + authority: repository + offline: write-and-commit-source-file + hub-native-append: + authority: hub-core + offline: append-to-idempotent-outbox + hub-native-control: + authority: owning-central-hub + offline: no-generic-replay + derived-cache: + authority: upstream-source + offline: rebuild-or-refresh + retired: + authority: archived-provenance + offline: reject-writes + +records: + agent_messages: {class: hub-native-control, owner: hub-core} + capability_catalog: {class: file-derived, owner: repo-manager, transition: retire-direct-db-writes} + capability_requests: {class: file-derived, owner: repo-manager, transition: register-entry-files} + contributions: {class: file-derived, owner: repo-manager} + decisions: {class: file-derived, owner: repo-manager, transition: retire-direct-db-writes} + doi_cache: {class: derived-cache, owner: repo-manager} + domain_goals: {class: hub-native-control, owner: hub-core} + domains: {class: hub-native-control, owner: hub-core} + extension_points: {class: file-derived, owner: repo-manager} + fabric_graph_edges: {class: derived-cache, owner: fin-hub} + fabric_graph_imports: {class: derived-cache, owner: fin-hub} + fabric_graph_nodes: {class: derived-cache, owner: fin-hub} + intake_notes: {class: file-derived, owner: repo-manager, transition: retire-direct-db-writes} + intakes: {class: file-derived, owner: repo-manager, transition: retire-direct-db-writes} + interface_changes: {class: hub-native-control, owner: hub-core} + legacy_interface_usage_buckets: {class: derived-cache, owner: hub-core} + legacy_interfaces: {class: derived-cache, owner: hub-core} + managed_repos: {class: file-derived, owner: repo-manager, note: composite-repository-projection} + progress_events: {class: hub-native-append, owner: hub-core} + repo_goals: {class: file-derived, owner: repo-manager} + sbom_entries: {class: derived-cache, owner: repo-manager} + sbom_snapshots: {class: derived-cache, owner: repo-manager} + service_catalog: {class: hub-native-control, owner: ops-hub} + service_cloud: {class: hub-native-control, owner: ops-hub} + service_first_party: {class: hub-native-control, owner: ops-hub} + service_self_hosted: {class: hub-native-control, owner: ops-hub} + service_third_party: {class: hub-native-control, owner: ops-hub} + suggestion_notes: {class: retired, owner: archive} + suggestion_relevance_bumps: {class: retired, owner: archive} + suggestions: {class: retired, owner: archive} + tasks: {class: file-derived, owner: repo-manager} + td_notes: {class: file-derived, owner: repo-manager} + technical_debt: {class: file-derived, owner: repo-manager} + token_events: {class: hub-native-append, owner: hub-core} + topics: {class: hub-native-control, owner: hub-core, transition: retire-legacy-topic-spine} + tpsc_catalog: {class: hub-native-control, owner: ops-hub} + tpsc_entries: {class: derived-cache, owner: repo-manager} + tpsc_snapshots: {class: derived-cache, owner: repo-manager} + workplan_dependencies: {class: file-derived, owner: repo-manager} + workplan_launch_requests: {class: hub-native-control, owner: activity-core} + workplans: {class: file-derived, owner: repo-manager} + write_idempotency_keys: {class: derived-cache, owner: hub-core} diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/repository-registry.yaml b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/repository-registry.yaml new file mode 100644 index 00000000..6d1620f8 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/repository-registry.yaml @@ -0,0 +1,496 @@ +schema: repo-manager.repository-registry.v1 +registry_source: forgejo +identity_source: state-hub-primary-migration-2026-09-01 +repositories: +- repository_id: 01631c7a-f612-4799-892e-a96970d06063 + slug: prj-forgejo-org-refactor + lifecycle: active + forgejo_repository: coulomb/prj-forgejo-org-refactor +- repository_id: 03e6fe9f-ca84-4be5-ad5d-03c037bf6107 + slug: evidence-source + lifecycle: active + forgejo_repository: coulomb/evidence-source +- repository_id: 040b00d5-9c61-4a48-b21e-59c3f6ff4f18 + slug: tmux-amq + lifecycle: active + forgejo_repository: coulomb/tmux-amq +- repository_id: 077cf296-3879-4da5-ac97-46cb248c889d + slug: timeline-svg + lifecycle: active + forgejo_repository: coulomb/timeline-svg +- repository_id: 10f1c902-576d-4782-b1b2-643a6083ee36 + slug: config-atlas + lifecycle: active + forgejo_repository: coulomb/config-atlas +- repository_id: 1174ca73-c53d-4bb8-bfc3-fdeda13ef706 + slug: tegwick-control + lifecycle: active + forgejo_repository: coulomb/tegwick-control +- repository_id: 11c1ff2e-ad0d-4a1a-b649-7da7b98a07da + slug: sand-boxer + lifecycle: active + forgejo_repository: coulomb/sand-boxer +- repository_id: 13094d7a-b978-4aa3-8a1d-0d05b824698d + slug: llm-connect + lifecycle: active + forgejo_repository: coulomb/llm-connect +- repository_id: 14487b12-9a94-4d98-83e5-dcc0b58b650a + slug: whitehat-security + lifecycle: active + forgejo_repository: coulomb/whitehat-security +- repository_id: 146bcec9-59e8-481e-9996-f0b30ab28abb + slug: domain-tree + lifecycle: active + forgejo_repository: coulomb/domain-tree +- repository_id: 14a5657e-f875-4cd4-8801-3c7dcecb11f2 + slug: vantage-point + lifecycle: active + forgejo_repository: coulomb/vantage-point +- repository_id: 155fab93-6a76-415e-a57a-7e39b4428985 + slug: rapp-core-hub + lifecycle: active + forgejo_repository: coulomb/rapp-core-hub +- repository_id: 195e2991-79c3-4c61-981d-5de78f6fe5b8 + slug: resource-control + lifecycle: active + forgejo_repository: coulomb/resource-control +- repository_id: 1bf99f56-6e94-4379-a9ea-295a4c181889 + slug: ops-bridge + lifecycle: active + forgejo_repository: coulomb/ops-bridge +- repository_id: 1f5876f3-bc96-4f64-a9c8-f2e4578b8416 + slug: reef-railiance + lifecycle: active + forgejo_repository: coulomb/reef-railiance +- repository_id: 215c052f-88e7-4b8d-af84-01008cd606d3 + slug: rapp-tenant-engine + lifecycle: active + forgejo_repository: coulomb/rapp-tenant-engine +- repository_id: 217fe904-2e15-44a8-bdda-e8c6683f46c9 + slug: test-driver + lifecycle: active + forgejo_repository: coulomb/test-driver +- repository_id: 29593df6-89a6-4058-9e0b-863e986b86f8 + slug: policy-nexus + lifecycle: active + forgejo_repository: coulomb/policy-nexus +- repository_id: 2ad0ed58-4324-47e2-b695-d7128bc623bf + slug: binky-control + lifecycle: active + forgejo_repository: coulomb/binky-control +- repository_id: 2c0de614-e468-4eb6-8157-470649ac8c05 + slug: railiance-fabric + lifecycle: active + forgejo_repository: coulomb/railiance-fabric +- repository_id: 2c72f2ad-d940-4a21-a71e-d490bd51cff2 + slug: markitect-quarkdown + lifecycle: active + forgejo_repository: coulomb/markitect-quarkdown +- repository_id: 2cb4c14d-6f90-40a7-a9ee-14a663306cb5 + slug: risk-nexus + lifecycle: active + forgejo_repository: coulomb/risk-nexus +- repository_id: 2db668cf-6c52-4965-951f-0d5e617de9e3 + slug: railiance-hosts + lifecycle: archived + forgejo_repository: coulomb/railiance-hosts +- repository_id: 317300f6-a09b-4464-8a0e-798aad5627d0 + slug: rein-openweights + lifecycle: active + forgejo_repository: coulomb/rein-openweights +- repository_id: 32549b0d-b065-4294-b577-fad643fa38de + slug: can-you-assist + lifecycle: active + forgejo_repository: coulomb/can-you-assist +- repository_id: 33b4936f-aadd-42e2-a320-33c524a07e1d + slug: ralph-workplan + lifecycle: active + forgejo_repository: coulomb/ralph-workplan +- repository_id: 3786363f-94a5-43a0-889d-8e85993b2783 + slug: testdrive-jsui + lifecycle: active + forgejo_repository: coulomb/testdrive-jsui +- repository_id: 39b9170f-25d2-4728-bb68-bdd4791534ea + slug: ground-game + lifecycle: active + forgejo_repository: coulomb/ground-game +- repository_id: 3ce9ef37-9672-4096-9d28-5ef77e450c07 + slug: email-connect + lifecycle: active + forgejo_repository: coulomb/email-connect +- repository_id: 3d070446-80e0-4d51-a802-d037b8ac273a + slug: rapp-openbao + lifecycle: active + forgejo_repository: coulomb/rapp-openbao +- repository_id: 423794e3-a8a4-49f3-9f07-f743e10d5f67 + slug: binect-js + lifecycle: active + forgejo_repository: coulomb/binect-js +- repository_id: 4296e38c-4eb4-4710-8e35-fb37009add0b + slug: activity-core + lifecycle: active + forgejo_repository: coulomb/activity-core +- repository_id: 42f80fe5-e5bb-4765-8e4b-e369517fb36a + slug: railiance-bootstrap + lifecycle: archived + forgejo_repository: coulomb/railiance-bootstrap +- repository_id: 439a69fc-084a-4074-88fe-c99de627deeb + slug: disaster-control + lifecycle: active + forgejo_repository: coulomb/disaster-control +- repository_id: 4589488d-625b-4ffe-b8f0-48744d7b1498 + slug: net-kingdom + lifecycle: active + forgejo_repository: coulomb/net-kingdom +- repository_id: 46e9c85b-057d-41e0-9382-851bebcf2cc1 + slug: rapp-qonto + lifecycle: active + forgejo_repository: coulomb/rapp-qonto +- repository_id: 47f50756-aaf6-4897-9bc7-097fcd9daf46 + slug: python-snake + lifecycle: active + forgejo_repository: coulomb/python-snake +- repository_id: 485187c0-4fad-42f7-984c-5e317a66c5de + slug: railiance-infra + lifecycle: active + forgejo_repository: coulomb/railiance-infra +- repository_id: 487f321e-2e53-43d4-90a6-af421e438313 + slug: info-tech-canon + lifecycle: active + forgejo_repository: coulomb/info-tech-canon +- repository_id: 4a887c44-e301-4c1e-a4fe-1b4a842d8777 + slug: human-resources + lifecycle: active + forgejo_repository: coulomb/human-resources +- repository_id: 4fb4a5f3-08f9-49ba-958b-414adc565168 + slug: feature-control + lifecycle: active + forgejo_repository: coulomb/feature-control +- repository_id: 5115e0c5-009f-4168-b155-9943fe2ab9a7 + slug: railiance-platform + lifecycle: active + forgejo_repository: coulomb/railiance-platform +- repository_id: 56ae522c-b47e-4748-8239-f61b332fe69d + slug: the-custodian + lifecycle: active + forgejo_repository: coulomb/the-custodian +- repository_id: 5bac09ed-a1c9-42fb-bd16-75c8f9e01a1a + slug: glas-harness + lifecycle: active + forgejo_repository: coulomb/glas-harness +- repository_id: 5dd1708d-96e3-4737-96be-05485548ab04 + slug: prj-state-hub-retirement + lifecycle: active + forgejo_repository: coulomb/prj-state-hub-retirement +- repository_id: 60f2d8b6-f3fa-4bb0-b316-89de9b2a7d79 + slug: tele-mcp + lifecycle: active + forgejo_repository: coulomb/tele-mcp +- repository_id: 69b69830-af7f-496f-9af2-cbc23a83cd42 + slug: ihp-railiance-probe + lifecycle: active + forgejo_repository: coulomb/ihp-railiance-probe +- repository_id: 6b6e91cd-160f-4e62-9478-80d7588a35dd + slug: kings-guard + lifecycle: active + forgejo_repository: coulomb/kings-guard +- repository_id: 7007e599-6f2a-4fc6-b6c7-a86b6e9ccfaf + slug: reuse-surface + lifecycle: active + forgejo_repository: coulomb/reuse-surface +- repository_id: 719f49a6-63a0-47d4-9a9c-ea515be17d5a + slug: rapp-issue-core + lifecycle: active + forgejo_repository: coulomb/rapp-issue-core +- repository_id: 73be5a8e-8503-447f-87cb-de8c2c8c0b9e + slug: rein-aharness + lifecycle: active + forgejo_repository: coulomb/rein-aharness +- repository_id: 74df727e-a756-42de-b776-11928b03bd1f + slug: ops-warden + lifecycle: active + forgejo_repository: coulomb/ops-warden +- repository_id: 75d31180-acf5-4d47-aea8-2a5b1e71e6a9 + slug: marki-docx + lifecycle: active + forgejo_repository: coulomb/marki-docx +- repository_id: 761e6ac3-6483-4b7f-ac7a-5e5741a01118 + slug: user-engine + lifecycle: active + forgejo_repository: coulomb/user-engine +- repository_id: 76af2d82-9a4b-4797-b542-3f4f5545b6cc + slug: whynot-control + lifecycle: active + forgejo_repository: coulomb/whynot-control +- repository_id: 7945cc60-f228-4baa-879d-5aa515d004e3 + slug: qonto-assistant + lifecycle: active + forgejo_repository: coulomb/qonto-assistant +- repository_id: 7b46d0dd-b261-4b8f-82c6-fa26f24e42a0 + slug: sbom-nexus + lifecycle: active + forgejo_repository: coulomb/sbom-nexus +- repository_id: 80a9b0c4-f654-47fe-9721-33fc0558687e + slug: hub-core + lifecycle: active + forgejo_repository: coulomb/hub-core +- repository_id: 8142c713-7a09-4703-9771-0b9d4238fe33 + slug: citation-work + lifecycle: active + forgejo_repository: coulomb/citation-work +- repository_id: 87f43c8e-01bd-465a-96ce-c0eee2ae7bcf + slug: markitect-filter + lifecycle: active + forgejo_repository: coulomb/markitect-filter +- repository_id: 8a99bb74-1ec0-4478-ac70-35a7cddb0e3c + slug: key-cape + lifecycle: active + forgejo_repository: coulomb/key-cape +- repository_id: 8c82baea-bb40-435d-ac42-ec7a7c20dbb8 + slug: commerce-canon + lifecycle: active + forgejo_repository: coulomb/commerce-canon +- repository_id: 8df9b401-f2e4-4d12-b5ba-7fc191d4e606 + slug: rapp-sbom-nexus + lifecycle: active + forgejo_repository: coulomb/rapp-sbom-nexus +- repository_id: 8e9ffb56-7abc-47b5-84b2-5ad392b37dcd + slug: state-hub + lifecycle: active + forgejo_repository: coulomb/state-hub +- repository_id: 90918b55-6347-4e57-8086-db7b0454f73d + slug: guide-board + lifecycle: active + forgejo_repository: coulomb/guide-board +- repository_id: 90fc8c09-30e7-439d-a873-779bd2cd5ac5 + slug: coordination-engine + lifecycle: active + forgejo_repository: coulomb/coordination-engine +- repository_id: 95d05a4d-e276-4eea-a767-c78d3ac4b320 + slug: rail-knative + lifecycle: active + forgejo_repository: coulomb/rail-knative +- repository_id: 9b69c35b-f3e7-490c-bb9e-d78f96992513 + slug: polycode-sim + lifecycle: active + forgejo_repository: coulomb/polycode-sim +- repository_id: 9cfdd641-0c27-436f-ad3b-5d41a1353fcf + slug: rapp-user-engine + lifecycle: active + forgejo_repository: coulomb/rapp-user-engine +- repository_id: 9d7b100b-1538-483e-a873-cf70980c59e3 + slug: coulomb-loop + lifecycle: active + forgejo_repository: coulomb/coulomb-loop +- repository_id: a4c297f2-b38a-42d3-b1a5-ae4b29640276 + slug: markitect-main + lifecycle: active + forgejo_repository: coulomb/markitect-main +- repository_id: a4cac053-048b-453b-93aa-942260bd4e09 + slug: evidence-anchor + lifecycle: active + forgejo_repository: coulomb/evidence-anchor +- repository_id: a53a75fb-e9fa-41d9-8a96-12a4b924dc63 + slug: whynot-design + lifecycle: active + forgejo_repository: coulomb/whynot-design +- repository_id: a677c189-b4e2-4f2a-9e48-faa482c277e6 + slug: citation-evidence + lifecycle: active + forgejo_repository: coulomb/citation-evidence +- repository_id: a7cea934-e38c-467c-b2ea-64fd3424a845 + slug: kontextual-engine + lifecycle: active + forgejo_repository: coulomb/kontextual-engine +- repository_id: a9916da2-b4dd-4e12-8a30-47e47f6ed298 + slug: binect-chrome + lifecycle: active + forgejo_repository: coulomb/binect-chrome +- repository_id: a9d105c6-10fa-4cf9-8bc1-e248476698d3 + slug: repo-manager + lifecycle: active + forgejo_repository: coulomb/repo-manager +- repository_id: aa497d2f-d88d-4065-9ab2-e251df07ead4 + slug: railiance-master + lifecycle: active + forgejo_repository: coulomb/railiance-master +- repository_id: b1321f79-ccc7-4b0a-84bf-c7b9825ca427 + slug: reef-storage + lifecycle: active + forgejo_repository: coulomb/reef-storage +- repository_id: b51b2b12-dc53-4423-8d3c-78c96eee0362 + slug: shard-wiki + lifecycle: active + forgejo_repository: coulomb/shard-wiki +- repository_id: b6b8dacd-4100-475a-8390-c326e1cebdec + slug: open-cmis-tck + lifecycle: active + forgejo_repository: coulomb/open-cmis-tck +- repository_id: b7add1b9-b90f-4baf-acac-2f5ce3037b5f + slug: repo-seed + lifecycle: active + forgejo_repository: coulomb/repo-seed +- repository_id: b7b3e7cc-cdde-4430-be43-e82343fdf1b0 + slug: infospace-bench + lifecycle: active + forgejo_repository: coulomb/infospace-bench +- repository_id: b9066ab1-c6fe-409b-9145-64ba374654db + slug: ops-mason + lifecycle: active + forgejo_repository: coulomb/ops-mason +- repository_id: b96c952c-9993-4941-b75a-fe76eb7b2a57 + slug: citation-engine + lifecycle: active + forgejo_repository: coulomb/citation-engine +- repository_id: ba599cd4-e8aa-430b-bbbe-e49f88536d70 + slug: markitect-tool + lifecycle: active + forgejo_repository: coulomb/markitect-tool +- repository_id: bc978be9-ca72-42bb-a451-d4737b779c5b + slug: railiance-enablement + lifecycle: active + forgejo_repository: coulomb/railiance-enablement +- repository_id: c186e743-9ebb-425a-aecc-7e3ea7cdc2a5 + slug: railiance-telemetry + lifecycle: active + forgejo_repository: coulomb/railiance-telemetry +- repository_id: c29eab12-d8b8-46b3-851b-0e23f78f91f3 + slug: kaizen-agentic + lifecycle: active + forgejo_repository: coulomb/kaizen-agentic +- repository_id: c3fd0dd2-a0de-415c-8e3a-e37406f4b8f8 + slug: railiance-cluster + lifecycle: active + forgejo_repository: coulomb/railiance-cluster +- repository_id: c7a3dc03-d837-4577-a107-a190d6b00705 + slug: helix-forge + lifecycle: active + forgejo_repository: coulomb/helix-forge +- repository_id: c96dd8b3-3d15-4c50-8daf-0ec6a2c56ddf + slug: ops-hub + lifecycle: active + forgejo_repository: coulomb/ops-hub +- repository_id: cb06310e-4381-428b-b8f3-d3ea8ac2f70d + slug: railiance-apps + lifecycle: active + forgejo_repository: coulomb/railiance-apps +- repository_id: ce3d5e76-ee5c-40dc-acc8-c04e63a0e406 + slug: secrets-engine + lifecycle: active + forgejo_repository: coulomb/secrets-engine +- repository_id: d166babe-c3f0-4e46-8091-a2c44ab557fb + slug: direkt-vermittlung-de + lifecycle: active + forgejo_repository: coulomb/direkt-vermittlung-de +- repository_id: d3581e63-4002-4c83-8c03-93547823c8c7 + slug: freedom-intelligence + lifecycle: active + forgejo_repository: coulomb/freedom-intelligence +- repository_id: d416fd6f-3c9d-4ba7-a5ca-f7543940ee6a + slug: open-reuse + lifecycle: active + forgejo_repository: coulomb/open-reuse +- repository_id: d56e0504-044d-4021-ae43-e9d706dbd087 + slug: phase-memory + lifecycle: active + forgejo_repository: coulomb/phase-memory +- repository_id: d5c398ad-93a6-479c-bd5e-6078e717c819 + slug: rapp-policy-nexus + lifecycle: active + forgejo_repository: coulomb/rapp-policy-nexus +- repository_id: d63ab9fa-9bf2-4014-a5e8-d186c417552e + slug: target-revenue + lifecycle: active + forgejo_repository: coulomb/target-revenue +- repository_id: d70d228e-841d-42d9-a7e9-5d978b4008d8 + slug: tenant-engine + lifecycle: active + forgejo_repository: coulomb/tenant-engine +- repository_id: d7c12587-e70c-4d06-8843-e582c0d683c3 + slug: adaptive-pricing + lifecycle: active + forgejo_repository: coulomb/adaptive-pricing +- repository_id: da8ecb37-e1bf-4260-b853-beb40ac9235b + slug: soul-frame + lifecycle: active + forgejo_repository: coulomb/soul-frame +- repository_id: dc1866c7-08af-4117-8684-057331be20b5 + slug: rail-kubernetes + lifecycle: active + forgejo_repository: coulomb/rail-kubernetes +- repository_id: dc9e7e92-2c8c-4cec-9021-9182c46ab097 + slug: agentic-resources + lifecycle: active + forgejo_repository: coulomb/agentic-resources +- repository_id: de749ff1-a4a4-42f5-8ed4-6b8c05b18bd9 + slug: repo-scoping + lifecycle: active + forgejo_repository: coulomb/repo-scoping +- repository_id: e1575e7c-e1b0-4b46-98be-78bd49e06318 + slug: artifact-store + lifecycle: active + forgejo_repository: coulomb/artifact-store +- repository_id: e4614b15-7526-4787-bd6f-74aa3cf24c3d + slug: rapp-postgres + lifecycle: active + forgejo_repository: coulomb/rapp-postgres +- repository_id: e51fd6df-525e-457c-9ec0-924832b491bc + slug: issue-core + lifecycle: active + forgejo_repository: coulomb/issue-core +- repository_id: ea264d14-723b-48d9-b330-1e3c929934ec + slug: prj-unattended-progress-company + lifecycle: active + forgejo_repository: coulomb/prj-unattended-progress-company +- repository_id: ea797759-b691-4b5a-910e-3d77f19df0da + slug: gate-house + lifecycle: active + forgejo_repository: coulomb/gate-house +- repository_id: ec60633d-b055-43b7-a83e-7e0e9734ede1 + slug: approval-engine + lifecycle: active + forgejo_repository: coulomb/approval-engine +- repository_id: ec840a06-4e33-4fdc-9762-da7a5beec254 + slug: prj-canon-federation + lifecycle: active + forgejo_repository: coulomb/prj-canon-federation +- repository_id: edffc8b6-0360-471e-a6b1-df56bde1a27e + slug: railiance-forge + lifecycle: active + forgejo_repository: coulomb/railiance-forge +- repository_id: f14be058-a339-4357-a3ff-c8326f7748a7 + slug: evidence-binder + lifecycle: active + forgejo_repository: coulomb/evidence-binder +- repository_id: f4038d32-948d-49e2-9b84-4fd48109100f + slug: core-hub + lifecycle: active + forgejo_repository: coulomb/core-hub +- repository_id: f5d1db1e-e6f7-4299-a496-12dbc5e73b18 + slug: clay-borg + lifecycle: active + forgejo_repository: coulomb/clay-borg +- repository_id: fbf4e172-966a-4ec8-b135-2bff8951c550 + slug: fin-hub + lifecycle: active + forgejo_repository: coulomb/fin-hub +- repository_id: fc3296df-e3e0-49a4-b022-4701f3c71f9b + slug: coulomb-social + lifecycle: active + forgejo_repository: coulomb/coulomb-social +- repository_id: fda8ad85-a7d7-4055-8f21-902a533e59df + slug: access-engine + lifecycle: active + forgejo_repository: coulomb/access-engine +- repository_id: ff057178-b3ca-48f4-b7b5-2cda570ed12f + slug: zone-engine + lifecycle: active + forgejo_repository: coulomb/zone-engine +- repository_id: ff7b0754-9d54-4abf-a752-d342870f5993 + slug: vergabe-teilnahme + lifecycle: active + forgejo_repository: coulomb/vergabe-teilnahme +- repository_id: ffb264e4-5c5b-4c18-9c6f-df6dd5f1bb05 + slug: audit-core + lifecycle: active + forgejo_repository: coulomb/audit-core diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/state-hub-access.yaml b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/state-hub-access.yaml new file mode 100644 index 00000000..0e80a178 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/state-hub-access.yaml @@ -0,0 +1,19 @@ +schema: repo-manager.state-hub-access.v1 +updated: "2026-08-28" +workplan_task: CUST-WP-0067-T07 +notes: > + Authoritative State Hub access map for agent-facing AGENTS.md tables. + Edit this file, then `rmgr scaffold --path --refresh-hub-access`. + Local 127.0.0.1:8000 is central via ops-bridge state-hub-primary, not a cache. + +endpoints: + - context: Local workstation + url: http://127.0.0.1:8000 + - context: Remote (railiance01, in-cluster) + url: http://10.43.68.154:8000 + - context: Optional local edge relay + url: http://127.0.0.1:18080 + +retired: + - url: http://127.0.0.1:18000 + reason: reverse tunnel back to the workstation; the hub is in-cluster on railiance01 diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/work-record-types.yaml b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/work-record-types.yaml new file mode 100644 index 00000000..7fed01dd --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/work-record-types.yaml @@ -0,0 +1,83 @@ +# Implementation snapshot of the Custodian work-record kind registry. +# Canonical source: +# the-custodian/canon/standards/work-record-types.yaml (version 0.1) +version: "0.1" +status: active +kinds: + - kind: workplan + id_patterns: + - "^[A-Z][A-Z0-9]*(?:-[A-Z][A-Z0-9]*)*-WP-[0-9]{4}$" + - "^[A-Z][A-Z0-9]*(?:-[A-Z][A-Z0-9]*)*-WP-ADHOC-[0-9]{4}-[0-9]{2}-[0-9]{2}$" + legacy_patterns: + - pattern: "^CUST-WP-0000b$" + source: Custodian historical workplan suffix retained by source and hub + grandfathered: true + - pattern: "^CUST-WP-0000c$" + source: Custodian archived historical workplan suffix retained by source and hub + grandfathered: true + - pattern: "^CUST-WP-0010b$" + source: Custodian historical workplan suffix retained by source and hub + grandfathered: true + - pattern: "^CUST-WP-(0017|0053)-LEGACY$" + source: Custodian archived legacy workplan identity retained by source and hub + grandfathered: true + - pattern: "^ADHOC-[0-9]{4}-[0-9]{2}-[0-9]{2}$" + source: daily ad-hoc containers created before repository-qualified ad-hoc ids + grandfathered: true + - pattern: "^MASON-0001$" + source: ops-mason bootstrap workplan created before WP infix convention + grandfathered: true + - kind: task + id_patterns: + - "^[A-Z][A-Z0-9]*(?:-[A-Z][A-Z0-9]*)*-WP-[0-9]{4}-T[0-9]{2,3}$" + - "^[A-Z][A-Z0-9]*(?:-[A-Z][A-Z0-9]*)*-WP-ADHOC-[0-9]{4}-[0-9]{2}-[0-9]{2}-T[0-9]{2}$" + legacy_patterns: + - pattern: "^ADHOC-[0-9]{4}-[0-9]{2}-[0-9]{2}-T[0-9]{2}$" + source: daily ad-hoc tasks created before repository-qualified ad-hoc ids + grandfathered: true + - pattern: "^[A-Z]+-WP-[0-9]{4}-T[0-9]$" + source: early single-digit task suffixes (pre task-block convention) + grandfathered: true + - pattern: "^[A-Z]+-WP-[0-9]{4}-LEGACY-T[0-9]{2}$" + source: archived-workplan legacy task renumbering + grandfathered: true + - pattern: "^MASON-0001-T[0-9]{2,3}$" + source: ops-mason bootstrap tasks created before WP infix convention + grandfathered: true + - pattern: "^CUST-WP-(?:0000c-T0[1-4]|0010b-T0[1-2])$" + source: Custodian historical task identity retained by source and hub + grandfathered: true + scoped_legacy_patterns: + - pattern: "^(?:T01|T02|T03|T04|T05)$" + parent_pattern: "^CUST-WP-0006$" + source: Custodian historical task blocks with parent-qualified UUID bindings + grandfathered: true + - pattern: "^(?:T01|T02|T03|T04|T05|T06|T07|T08|T09|T10|T11|T12|T13|T14|T15|T16|T17)$" + parent_pattern: "^CUST-WP-0007$" + source: Custodian historical task blocks with parent-qualified UUID bindings + grandfathered: true + - pattern: "^(?:T01|T02|T03|T04|T05|T06|T07|T08|T09)$" + parent_pattern: "^CUST-WP-0030$" + source: Custodian historical task blocks with parent-qualified UUID bindings + grandfathered: true + - pattern: "^(?:T01|T02|T03|T04|T05|T06|T07)$" + parent_pattern: "^CUST-WP-0031$" + source: Custodian historical task blocks with parent-qualified UUID bindings + grandfathered: true + - kind: intake + id_patterns: + - "^[A-Z]+-IN-[0-9]{4}$" + legacy_patterns: + - pattern: "^AWQ-[0-9]{3}$" + source: binky-control AutopilotWorkQueue + grandfathered: true + - kind: decision + id_patterns: + - "^[A-Z]+-DEC-[0-9]{4}-[0-9]{3}$" + legacy_patterns: + - pattern: "^DEC-[0-9]{4}-[0-9]{3}$" + source: binky-control DecisionQueue + grandfathered: true +identity_reconciliation: + repeated_same_id_same_uuid: duplicate_source_occurrence + repeated_same_id_different_uuid: identity_collision diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/workplan-prefix-registry.yaml b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/workplan-prefix-registry.yaml new file mode 100644 index 00000000..abb666c8 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/config/workplan-prefix-registry.yaml @@ -0,0 +1,78 @@ +# RMGR-WP-0004-T08 / ADR-007 decision 1 +# One prefix, one repository. Prefixes derive from repo or project identity, +# never from a flavor marker or family name. Detection only — historical +# collisions stay until ADR-007 § Migration is ruled. + +standard: workplan-prefix-ownership +version: "0.1" +canon: + - the-custodian/canon/architecture/adr-007-workplan-identity-and-repo-worker-topology.md + - the-custodian/canon/standards/project-repository-flavor_v0.1.md + +retired: + - prefix: NET-WP + reason: second prefix in net-kingdom; NK-WP is the repository's form (229 ids vs 49) + successors: [NK-WP] + - prefix: PRJ-WP + reason: flavor marker; never a valid prefix + - prefix: RAIL-BS-WP + reason: shared family name, not a repository + successors: [RCLUSTER-WP, RBS-WP] + - prefix: RAILIANCE-WP + reason: family name, not a repository + successors: [RPF-WP, RAPPS-WP, RFORGE-WP, RTELE-WP] + +owners: + RMGR-WP: repo-manager + CUST-WP: the-custodian + STATE-WP: state-hub + CFED-WP: prj-canon-federation + SHR-WP: prj-state-hub-retirement + RCLUSTER-WP: railiance-cluster + RBS-WP: railiance-bootstrap + RPF-WP: railiance-platform + RAPPS-WP: railiance-apps + RFORGE-WP: railiance-forge + RTELE-WP: railiance-telemetry + RMASTER-WP: railiance-master + RCLK-WP: railiance-clock + RAIL-K8S-WP: rail-kubernetes + RAPP-OPENBAO-WP: rapp-openbao + RAPP-USER-ENGINE-WP: rapp-user-engine + RAPP-TENANT-ENGINE-WP: rapp-tenant-engine + RAPP-POLICY-NEXUS-WP: rapp-policy-nexus + USER-WP: user-engine + TEN-WP: tenant-engine + + ACTIVITY-WP: activity-core + ADAPTIVE-WP: adaptive-pricing + AGENTIC-WP: agentic-resources + GROUND-WP: ground-game + ITC-WP: info-tech-canon + KAIZEN-WP: kaizen-agentic + LLM-WP: llm-connect + NK-WP: net-kingdom + BRIDGE-WP: ops-bridge + WARDEN-WP: ops-warden + QONTO-WP: qonto-assistant + RAIL-HO-WP: railiance-infra + SECRETS-WP: secrets-engine + TAMQ-WP: tmux-amq + RAIL-FAB-WP: railiance-fabric + WHYNOT-WP: whynot-design + OPS-WP: ops-hub + WNCTL-WP: whynot-control + CORE-WP: core-hub + DISCTL-WP: disaster-control + REPO-WP: repo-seed +historical_collisions: + - id: RAILIANCE-WP-0015 + repos: [railiance-platform, railiance-apps] + - id: RAILIANCE-WP-0016 + repos: [railiance-platform, railiance-apps] + - id: CUST-WP-0000 + repos: [the-custodian] + - id: CUST-WP-0010 + repos: [the-custodian] + - id: CUST-WP-0045 + repos: [the-custodian] diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0002-k3s-baseline.md b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0002-k3s-baseline.md new file mode 100644 index 00000000..595cd5c1 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0002-k3s-baseline.md @@ -0,0 +1,177 @@ +--- +id: RAIL-BS-WP-0002 +type: workplan +title: "k3s and Kubernetes Platform Baseline" +domain: financials +repo: railiance-cluster +status: finished +owner: railiance +topic_slug: railiance +repo_goal_id: "70ab2379-fb9d-4fec-a09d-b2a717e4ace8" +state_hub_workstream_id: "4c63dfc6-9eac-4e79-9f77-8f644ad7147d" +created: "2026-03-09" +updated: "2026-03-10" +completed: "2026-03-10" +--- + +> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`). + +# k3s and Kubernetes Platform Baseline + +## Goal + +Install k3s, Helm, and the baseline Kubernetes services on the converged +HostEurope node. This workplan picks up exactly where `railiance-hosts` +leaves off: a hardened, verified OS node that is ready for Kubernetes. + +## Pre-condition + +`railiance-infra` converge + Goss verify must pass before any task here +is executed: + +```bash +# In railiance-infra/ +make converge +make verify # must exit 0 +``` + +## Boundary + +This repo owns everything from k3s upward. It must not re-configure items +defined in `railiance-infra/spec/server-baseline.yaml`. See ADR-003: +`railiance-infra/docs/adr/ADR-003-railiance-5repo-stack-architecture.md`. + +**Out of scope here:** platform services (PostgreSQL, storage, identity) +→ `railiance-platform`. Application deployments (Gitea, coulomb services) +→ `railiance-apps`. + +--- + +## Tasks + +### T01 — Ansible playbook: install k3s (server mode) + +```task +id: T01 +status: done +priority: high +state_hub_task_id: "3f042630-eab0-4c6a-9167-e2b28ff20e40" +completed: "2026-03-10" +``` + +Harden `ansible/bootstrap.yml` to a production-ready k3s install: + +- Use the official k3s install script pinned to a specific version + (`INSTALL_K3S_VERSION=v1.35.1+k3s1`) +- `INSTALL_K3S_EXEC="server --cluster-init --write-kubeconfig-mode=644"` + (cluster-init enables embedded etcd for future HA expansion) +- Wait for node `Ready` before proceeding: + ```bash + k3s kubectl wait node --all --for=condition=Ready --timeout=120s + ``` +- Fetch kubeconfig to the control node as `~/.kube/config-hosteurope` + +**Done when:** `k3s kubectl get nodes` returns `Ready` from both the server +and the control node (via kubeconfig). + +--- + +### T02 — Helm installation + +```task +id: T02 +status: done +priority: high +state_hub_task_id: "e8510646-46ed-4697-a345-f3d3009eea78" +completed: "2026-03-10" +``` + +Add a task (or a role `roles/helm/`) that: + +1. Downloads the Helm binary (pinned version) to `/usr/local/bin/helm` +2. Verifies the checksum +3. Confirms `helm version` succeeds + +**Done when:** `helm version` succeeds on the HostEurope node. + +--- + +### T03 — Smoke test: k3s + Helm + +```task +id: T03 +status: done +priority: high +state_hub_task_id: "dab2c07f-8aa0-4635-8df6-857e87e93fc5" +completed: "2026-03-10" +``` + +Extend `tests/smoke_kube.sh` to assert: + +- `k3s kubectl get nodes` → node in Ready state +- `helm version` exits 0 +- CoreDNS pod running in `kube-system` +- Traefik ingress controller pod running (default in k3s) + +Run via: +```bash +ansible-playbook -i ansible/hosts.ini ansible/smoke.yml +``` +or directly over SSH if the kubeconfig is available locally. + +**Done when:** all assertions pass and the script exits 0. + +--- + +### T04 — Commit kubeconfig management notes + +```task +id: T04 +status: done +priority: medium +state_hub_task_id: "5c3d40e4-239b-488e-9519-6f7a38d2325f" +completed: "2026-03-10" +``` + +Document in `docs/kubeconfig.md`: + +- Where the kubeconfig is fetched to (`~/.kube/config-hosteurope`) +- How to merge it into `~/.kube/config` +- How to switch context: `kubectl config use-context default` +- Security note: kubeconfig is gitignored (contains cluster CA + client cert) + +**Done when:** doc written and committed. + +--- + +### T05 — Add `make k3s-install` and `make smoke` targets + +```task +id: T05 +status: done +priority: medium +state_hub_task_id: "7f9e0e58-a130-467a-a2d0-b3f2564e496f" +completed: "2026-03-10" +``` + +Add to Makefile (create one if none exists): + +```makefile +k3s-install: ## Install k3s and Helm on all inventory hosts + ansible-playbook -i ansible/hosts.ini ansible/bootstrap.yml + +smoke: ## Run Kubernetes smoke tests + bash tests/smoke_kube.sh +``` + +**Done when:** both targets work and are listed in `make help`. + +--- + +## References + +- Repo goal: `70ab2379-fb9d-4fec-a09d-b2a717e4ace8` (Install k3s and Kubernetes Baseline) +- Domain goal: `6f96c712-60e6-4ea9-ab06-168878eafbce` (Three-Phoenix Secure Kubernetes Infrastructure) +- Pre-condition: railiance-infra WP-0001 (Secure Single-Server Bootstrap) — completed 2026-03-09 +- Boundary ADR: `railiance-infra/docs/adr/ADR-003-railiance-5repo-stack-architecture.md` +- k3s releases: https://github.com/k3s-io/k3s/releases diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0003-pgpool-ha-failover-fix.md b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0003-pgpool-ha-failover-fix.md new file mode 100644 index 00000000..fe7b21da --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0003-pgpool-ha-failover-fix.md @@ -0,0 +1,196 @@ +--- +id: RAIL-BS-WP-0003 +type: workplan +title: "pgpool CrashLoopBackOff on PostgreSQL HA failover — missing secret key" +domain: financials +repo: railiance-cluster +status: finished +owner: tegwick +created: "2026-03-10" +updated: "2026-03-10" +state_hub_workstream_id: "7ee9ee22-1fae-4567-9194-8d70a9e0f45b" +--- + +> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`). + +# Bug Report: pgpool CrashLoopBackOff on PostgreSQL HA failover + +## Summary + +On 2026-03-10 a PostgreSQL HA failover caused all three postgresql pods to +restart. pgpool — the connection pooler between Gitea and PostgreSQL — then +entered CrashLoopBackOff and produced no logs. As a result Gitea's login +and all write operations hung indefinitely. The root page was still served +(from Valkey cache) which masked the failure. + +The fix was to patch a missing key in a Kubernetes secret. The root cause is +that the `gitea-12.2.0` Helm chart (postgresql-ha subchart v16.2.2) does not +populate the `pgpool-password` key in the `gitea-postgresql-ha-postgresql` +secret, even though the pgpool pod requires it at startup. + +--- + +## Timeline + +| Time (UTC) | Event | +|---|---| +| ~09:45 | postgresql-0, postgresql-2 pods restarted (repmgr failover) | +| ~09:45 | pgpool pod restarted and entered CrashLoopBackOff | +| ~11:00 | User noticed Gitea login hanging; home page still loading | +| ~13:00 | Root cause identified: missing `pgpool-password` secret key | +| ~13:10 | Secret patched; pgpool pod deleted and restarted cleanly | +| ~13:15 | Gitea fully operational | + +--- + +## Root Cause + +The Bitnami `pgpool` container startup script reads the file +`/opt/bitnami/pgpool/secrets/pgpool-password`, which is mounted from the +`gitea-postgresql-ha-postgresql` Kubernetes Secret via a `subPath` volume +mount. That secret key was never created by the Helm chart, so the file did +not exist. The container exited immediately with no logs. + +The pod had been running for 20 days without a restart, so this gap was +never discovered during initial deployment. + +--- + +## Evidence + +```bash +# Secret was missing the pgpool-password key +sudo k3s kubectl get secret -n default gitea-postgresql-ha-postgresql -o yaml +# data: keys were password, postgres-password, repmgr-password only +# pgpool-password was absent + +# pgpool pod describe showed 824 back-off restarts over 173 minutes +# No logs in either current or --previous output +sudo k3s kubectl logs -n default --previous +# (empty) + +# Gitea process had zero TCP connections to PostgreSQL port 5432 +# but many connections to Valkey port 6379 +cat /proc//net/tcp | grep 1538 # 1538 = 5432 hex — no results +``` + +--- + +## Immediate Fix Applied + +```bash +# Add the missing key (value = sr-check-password = changeme4 = base64: Y2hhbmdlbWU0) +sudo k3s kubectl patch secret -n default gitea-postgresql-ha-postgresql \ + --type='json' \ + -p='[{"op":"add","path":"/data/pgpool-password","value":"Y2hhbmdlbWU0"}]' + +# Restart pgpool +sudo k3s kubectl delete pod -n default +``` + +--- + +## Risk: Fix Will Be Lost on helm upgrade + +The patched secret is managed by Helm (annotation: +`meta.helm.sh/release-name: gitea`). A `helm upgrade` will regenerate the +secret from the chart template, which does not include `pgpool-password`, +and the bug will recur. + +--- + +## Tasks + +### T01 — Add pgpool-password to Helm values + +```task +id: T01 +status: done +priority: high +state_hub_task_id: "6841c93a-f146-47eb-9f7c-8fa0e02c1bbc" +``` + +Create or update `helm/gitea-values.yaml` (or equivalent) to permanently +include the pgpool-password so it survives `helm upgrade`: + +```yaml +postgresql-ha: + postgresql: + pgpoolPassword: +``` + +**Done when:** `helm upgrade gitea` completes and pgpool starts cleanly +without manual secret patching. + +--- + +### T02 — Add pgpool health check to smoke test + +```task +id: T02 +status: done +priority: high +state_hub_task_id: "ab166073-30a7-4702-a037-4091e8706e20" +``` + +Extend `tests/smoke_kube.sh` to assert: + +```bash +# All postgresql-ha pods Running +kubectl get pods -n default | grep gitea-postgresql-ha | grep -v Running && exit 1 + +# pgpool specifically not in CrashLoopBackOff +kubectl get pod -n default -l app.kubernetes.io/component=pgpool \ + -o jsonpath='{.items[0].status.containerStatuses[0].state}' | grep -v crash +``` + +**Done when:** the smoke test catches a pgpool failure within 5 minutes. + +--- + +### T03 — Add HA failover test + +```task +id: T03 +status: done +priority: high +state_hub_task_id: "140da396-8e30-4f4d-b88c-c42c0cd46c01" +``` + +Create `tests/test_ha_failover.sh` that: + +1. Records Gitea login response time (baseline) +2. Kills the primary PostgreSQL pod: `kubectl delete pod gitea-postgresql-ha-postgresql-0 -n default` +3. Waits for repmgr to promote a replica (max 60s) +4. Asserts Gitea login POST still succeeds within 10s +5. Asserts pgpool pod is Running (not CrashLoopBackOff) +6. Asserts all postgresql pods return to Running + +This test must pass before any PostgreSQL HA deployment is considered done. + +**Done when:** script exits 0 against a live cluster. + +--- + +### T04 — Document the incident in docs/ + +```task +id: T04 +status: done +priority: medium +state_hub_task_id: "d8a3ba40-fda0-4c1f-a9f1-ffcd621a5b3d" +``` + +Add `docs/incidents/2026-03-10-pgpool-missing-secret.md` with the full +timeline, root cause, and fix, so future operators understand what happened +and how to recover. + +**Done when:** doc committed and linked from `docs/README.md`. + +--- + +## References + +- Bitnami postgresql-ha chart v16.2.2 +- Gitea Helm chart v12.2.0 +- Related decision: D3 (HA testing policy) in `DECISIONS.md` diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0004-safety-net.md b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0004-safety-net.md new file mode 100644 index 00000000..89d14fff --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0004-safety-net.md @@ -0,0 +1,275 @@ +--- +id: RAIL-BS-WP-0004 +type: workplan +title: "Integrated Backup — S2 Kubernetes Runtime Layer" +domain: financials +repo: railiance-cluster +status: finished +owner: tegwick +topic_slug: railiance +state_hub_workstream_id: "7e8b0c20-51eb-40c9-9e3b-85dd380d7625" +created: "2026-02-25" +updated: "2026-03-26" +--- + +> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`). + +# Integrated Backup — S2 Kubernetes Runtime Layer + +## Goal + +Implement the Q3 (Operability & Resilience) integrated backup for +railiance-cluster (S2). Backs up what S2 owns — the Kubernetes runtime state — +encrypted with age, written to a local directory on the server. No external +dependencies required. + +## Architecture (Decision D4) + +Each railiance repo implements its own backup for what it owns. No central +backup service. See `DECISIONS.md` D4 for full rationale. + +**Standard interface every railiance repo must provide:** + +```bash +make backup # encrypt + write to /opt/backup/railiance// +make restore # restore from most recent local backup +``` + +Encryption: age, same key pair as SOPS secrets (`.sops.yaml` public key). +Output: `/opt/backup/railiance/cluster/` on the server. + +## What S2 (railiance-cluster) owns and must back up + +| Asset | Why it matters | +|---|---| +| k3s etcd snapshots | Full cluster state — all workloads, configs, secrets | +| Helm release values | Runtime values not in git (any manually applied overrides) | +| kubeconfig | Admin access to the cluster | + +**Not S2's responsibility:** +- Custodian State Hub DB → the-custodian owns this +- Operator workstation config (`.claude/`, `.gitconfig`) → operator's own concern +- Application data (Gitea repos, uploads) → S5 (railiance-apps) owns this +- PostgreSQL data volumes → S3 (railiance-platform) owns this + +## Encryption + +Reuse the age public key from `.sops.yaml`: + +```bash +AGE_PUBLIC_KEY=$(grep 'age:' .sops.yaml | awk '{print $2}') +tar -czf - | age -r "${AGE_PUBLIC_KEY}" -o backup.tar.gz.age +``` + +Decryption requires the private key at `~/.config/sops/age/keys.txt` +(same key used for `sops -d`). No additional key management needed. + +## Extension Point EP-RAIL-005 + +Once all five OAS layers implement this interface, the custodian can +orchestrate a full-stack backup with: + +```bash +for repo in railiance-infra railiance-cluster railiance-platform \ + railiance-enablement railiance-apps; do + make -C ~/$repo backup +done +``` + +No special protocol needed — just the standard interface. + +--- + +## Tasks + +### T01 — Define backup directory and encryption wrapper + +```task +id: T01 +status: done +priority: high +state_hub_task_id: "4526a842-ea31-4874-9231-92ab556cfe7b" +``` + +Create `tools/cmd/railiance-backup-s2` (replacing the old `railiance-backup`): + +- Backup dir: `/opt/backup/railiance/cluster/` (create with `mkdir -p`) +- Encrypt each artifact with age using public key from `.sops.yaml` +- Write timestamp-named files: `etcd-.snap.age`, `helm-values-.tar.gz.age`, `kubeconfig-.yaml.age` +- Keep last 7 of each type +- Write `.last-backup` stamp +- Exit 0 on success, non-zero on any failure +- No network required + +Also remove the old `tools/cmd/railiance-backup` (backed up Docker-based +custodian DB — wrong scope, not applicable to this server). + +**Done when:** `make backup` runs on COULOMBCORE without error and files +appear in `/opt/backup/railiance/cluster/`. + +--- + +### T02 — Back up k3s state (SQLite hot backup) + +```task +id: T02 +status: done +priority: high +state_hub_task_id: "a6313e06-1976-46a7-8e31-df4eb2eca880" +``` + +k3s has built-in etcd snapshot support: + +```bash +sudo k3s etcd-snapshot save --name railiance-$(date -u +%Y%m%dT%H%M%SZ) +# Default location: /var/lib/rancher/k3s/server/db/snapshots/ +``` + +Add to the backup script: take a fresh snapshot, encrypt with age, +copy to `/opt/backup/railiance/cluster/`. + +> **Note — verify etcd is in use before implementing:** +> `k3s etcd-snapshot` only works if k3s was started with `--cluster-init`. +> Without it, k3s uses SQLite and this command will fail. +> Verify first: `sudo k3s etcd-snapshot ls 2>&1` + +> **Note — sudo required:** etcd snapshot requires root. See T06 for how +> this is resolved (backup runs under root's crontab). + +**Done when:** backup includes a current etcd snapshot. + +--- + +### T03 — Back up Helm release values + +```task +id: T03 +status: done +priority: medium +state_hub_task_id: "05d42a55-921f-4aa7-bb76-e8af9c7e0ac3" +``` + +Capture current runtime Helm values for all releases: + +```bash +KUBECONFIG=/etc/rancher/k3s/k3s.yaml helm list -A -o json | \ + jq -r '.[].name + " " + .namespace' | \ + while read name ns; do + KUBECONFIG=/etc/rancher/k3s/k3s.yaml helm get values "$name" -n "$ns" -o yaml + done +``` + +Tar and age-encrypt into `helm-values-.tar.gz.age`. + +> **Note — kubeconfig permissions:** `/etc/rancher/k3s/k3s.yaml` is root-readable +> only by default. The backup script must either run as root (see T06) or k3s +> must be configured with `--write-kubeconfig-mode=644`. Running as root +> (via root crontab) is the chosen approach — no config change needed. + +**Done when:** backup includes a snapshot of all Helm release values. + +--- + +### T04 — Back up kubeconfig + +```task +id: T04 +status: done +priority: medium +state_hub_task_id: "08233868-d522-4117-bc4e-6c0f52545665" +``` + +Age-encrypt `~/.kube/config-hosteurope` (or `/etc/rancher/k3s/k3s.yaml`) +into `kubeconfig-.yaml.age` in the backup directory. + +**Done when:** backup includes the encrypted kubeconfig. + +--- + +### T05 — make restore target + +```task +id: T05 +status: done +priority: medium +state_hub_task_id: "2d5acff7-4a4e-4ddd-ad06-08237ad3dac8" +``` + +Add `tools/cmd/railiance-restore-s2` that decrypts and lists available +backups, with guided restore for the etcd snapshot case. + +Restore of etcd from snapshot: +```bash +sudo k3s server --cluster-reset \ + --cluster-reset-restore-path=/var/lib/rancher/k3s/server/db/snapshots/ +``` + +**Done when:** `make restore` prints available backups and a restore guide. + +--- + +### T06 — Install cron job and run restore drill + +```task +id: T06 +status: done +priority: medium +state_hub_task_id: "f8e4a094-c367-40eb-b895-da17bc144b07" +``` + +#### Solving the sudo problem + +The backup script needs root for two reasons: +- `k3s etcd-snapshot save` requires root +- `/etc/rancher/k3s/k3s.yaml` (kubeconfig) is root-readable only + +**Solution: run the cron under root's crontab.** + +This is the correct pattern for system-level backup jobs. It avoids a +proliferating sudoers whitelist (one entry per command, brittle to maintain) +and matches how tools like `rsnapshot`, `bacula`, and `borgbackup` work in +production. The backup writes to `/opt/backup/` which is root-owned anyway. + +Install the cron as root: + +```bash +sudo crontab -e +# Add: +0 2 * * * make -C /home/tegwick/railiance-cluster backup >> /opt/backup/railiance/cluster/backup.log 2>&1 +``` + +Note: use the absolute path to the repo — `~` does not expand reliably in +root's crontab unless HOME is set. + +Verify it is installed: +```bash +sudo crontab -l | grep railiance +``` + +#### Restore drill + +Once T01–T04 are done, run a decrypt-and-verify drill: + +```bash +# Decrypt the etcd snapshot and verify it is a valid snapshot file +sudo age -d -i ~/.config/sops/age/keys.txt \ + /opt/backup/railiance/cluster/etcd-$(ls /opt/backup/railiance/cluster/etcd-*.snap.age | sort -r | head -1 | xargs basename | sed 's/etcd-//;s/.snap.age//').snap.age \ + | file - + +# Record the drill +echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) restore drill OK" \ + >> /opt/backup/railiance/cluster/restore-drill.log +``` + +**Done when:** cron installed under root, drill completes without error, +log entry written. + +--- + +## References + +- Decision D4: Integrated backup per capability (`DECISIONS.md`) +- Decision D2: Nextcloud as optional offsite extension (still valid, not a requirement) +- OAS Q3: Operability & Resilience +- Extension point EP-RAIL-005: Custodian full-stack backup orchestration +- k3s etcd snapshots: https://docs.k3s.io/datastore/backup-restore diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/__init__.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/__init__.py new file mode 100644 index 00000000..5bdc3a3d --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/__init__.py @@ -0,0 +1,3 @@ +"""Repo Manager — HelixForge repository integration boundary.""" + +__version__ = "0.1.0" diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/__main__.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/__main__.py new file mode 100644 index 00000000..b340d3e5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/__main__.py @@ -0,0 +1,3 @@ +from repo_manager.cli import main + +raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/authority.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/authority.py new file mode 100644 index 00000000..f813e148 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/authority.py @@ -0,0 +1,74 @@ +"""Resolve exactly one authoritative owner for a State Hub record type.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + +AUTHORITY_SCHEMA = "repo-manager.hub-record-authority.v1" +VALID_CLASSES = frozenset( + {"file-derived", "hub-native-append", "hub-native-control", "derived-cache", "retired"} +) +DEFAULT_CONTRACT = Path(__file__).resolve().parents[2] / "config" / "hub-record-authority.yaml" + + +class AuthorityError(ValueError): + """The requested route is missing or conflicts with the authority contract.""" + + +def load_authority_contract(path: Path = DEFAULT_CONTRACT) -> dict[str, Any]: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + if not isinstance(data, dict) or data.get("schema") != AUTHORITY_SCHEMA: + raise AuthorityError(f"unsupported authority contract: {path}") + records = data.get("records") + if not isinstance(records, dict) or not records: + raise AuthorityError("authority contract has no records") + for record_type, rule in records.items(): + if not isinstance(rule, dict): + raise AuthorityError(f"invalid rule for {record_type}") + if rule.get("class") not in VALID_CLASSES or not rule.get("owner"): + raise AuthorityError(f"incomplete authority rule for {record_type}") + return data + + +def resolve_record_authority( + record_type: str, + *, + repo_slug: str | None = None, + domain_slug: str | None = None, + claimed_owner: str | None = None, + contract_path: Path = DEFAULT_CONTRACT, +) -> dict[str, str]: + """Return the one authority route or reject an ambiguous/conflicting write.""" + contract = load_authority_contract(contract_path) + rule = contract["records"].get(record_type) + if not isinstance(rule, dict): + raise AuthorityError(f"unknown record type: {record_type}") + + record_class = str(rule["class"]) + owner = str(rule["owner"]) + if claimed_owner is not None and claimed_owner != owner: + raise AuthorityError( + f"authority mismatch for {record_type}: contract={owner}, claimed={claimed_owner}" + ) + + if record_class == "file-derived": + if not repo_slug or not domain_slug: + raise AuthorityError( + f"file-derived {record_type} requires repo_slug and domain_slug" + ) + authority_key = f"repository:{domain_slug}/{repo_slug}" + elif record_class == "retired": + authority_key = f"archive:{record_type}" + else: + authority_key = f"hub:{owner}" + + return { + "schema": AUTHORITY_SCHEMA, + "record_type": record_type, + "record_class": record_class, + "owner": owner, + "authority_key": authority_key, + } diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/cache.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/cache.py new file mode 100644 index 00000000..f6fe68e5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/cache.py @@ -0,0 +1,156 @@ +"""Rebuildable local-cache status and legacy provenance export.""" + +from __future__ import annotations + +import hashlib +import json +from datetime import datetime +from pathlib import Path +from typing import Any +from urllib.request import urlopen + +from repo_manager.gitops import head_sha +from repo_manager.index_store import default_index_path, load_index +from repo_manager.parse.record import iter_record_files +from repo_manager.parse.register import iter_register_files +from repo_manager.parse.workplan import iter_workplan_files +from repo_manager.time import utc_now, utc_now_text + +CACHE_STATUS_SCHEMA = "repo-manager.cache-status.v1" +CLOSED_EXPORT_SCHEMA = "repo-manager.closed-workplan-provenance.v1" + + +def authoritative_source_files(repo_root: Path) -> list[Path]: + repo_root = repo_root.resolve() + paths = { + *iter_workplan_files(repo_root), + *iter_record_files(repo_root), + *iter_register_files(repo_root), + } + for name in (".repo-classification.yaml", "INTENT.md", "GOAL.md"): + path = repo_root / name + if path.is_file(): + paths.add(path) + return sorted(paths, key=lambda path: str(path.relative_to(repo_root))) + + +def source_fingerprint(repo_root: Path) -> tuple[str, list[str]]: + """Hash the paths and bytes that feed the local repository projection.""" + repo_root = repo_root.resolve() + digest = hashlib.sha256() + relative_paths: list[str] = [] + for path in authoritative_source_files(repo_root): + relative = str(path.relative_to(repo_root)) + relative_paths.append(relative) + digest.update(relative.encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + return digest.hexdigest(), relative_paths + + +def _parse_timestamp(value: str) -> datetime: + parsed = datetime.fromisoformat(value) + if parsed.tzinfo is None: + raise ValueError("cache observed_at must be timezone-aware") + return parsed + + +def cache_status(repo_root: Path) -> dict[str, Any]: + repo_root = repo_root.resolve() + path = default_index_path(repo_root) + checked_at = utc_now() + if not path.is_file(): + return { + "schema": CACHE_STATUS_SCHEMA, + "ok": True, + "advisory": True, + "cache_present": False, + "stale": True, + "stale_reasons": ["cache_missing"], + "checked_at": checked_at.isoformat().replace("+00:00", "Z"), + "repo_root": str(repo_root), + "index_path": str(path), + } + + index = load_index(path) + fingerprint, source_files = source_fingerprint(repo_root) + stale_reasons: list[str] = [] + if not index.source_fingerprint: + stale_reasons.append("legacy_index_missing_source_fingerprint") + elif index.source_fingerprint != fingerprint: + stale_reasons.append("authoritative_source_changed") + try: + observed_at = _parse_timestamp(index.observed_at) + age_seconds = max(0, int((checked_at - observed_at).total_seconds())) + except (TypeError, ValueError): + observed_at = None + age_seconds = None + stale_reasons.append("invalid_observed_at") + + return { + "schema": CACHE_STATUS_SCHEMA, + "ok": True, + "advisory": True, + "cache_present": True, + "stale": bool(stale_reasons), + "stale_reasons": stale_reasons, + "checked_at": checked_at.isoformat().replace("+00:00", "Z"), + "observed_at": index.observed_at, + "age_seconds": age_seconds, + "repo_root": str(repo_root), + "index_path": str(path), + "indexed_head_sha": index.head_sha, + "current_head_sha": head_sha(repo_root), + "source_fingerprint": index.source_fingerprint, + "current_source_fingerprint": fingerprint, + "source_file_count": len(source_files), + "record_count": len(index.work_records), + } + + +def build_closed_provenance_export( + workplans: list[dict[str, Any]], + repos: list[dict[str, Any]], + *, + source: str, +) -> dict[str, Any]: + repo_slugs = {str(repo["id"]): repo.get("slug") for repo in repos if repo.get("id")} + rows = [ + { + "id": row.get("id"), + "repo_id": row.get("repo_id"), + "repo_slug": repo_slugs.get(str(row.get("repo_id"))), + "slug": row.get("slug"), + "title": row.get("title"), + "status": row.get("status"), + "owner": row.get("owner"), + "created_at": row.get("created_at"), + "updated_at": row.get("updated_at"), + } + for row in workplans + if row.get("status") in {"finished", "archived"} and not row.get("backing_filename") + ] + rows.sort(key=lambda row: (str(row["status"]), str(row["repo_slug"]), str(row["slug"]))) + canonical_rows = json.dumps(rows, sort_keys=True, separators=(",", ":")).encode("utf-8") + return { + "schema": CLOSED_EXPORT_SCHEMA, + "ok": True, + "generated_at": utc_now_text(), + "source": source, + "filter": "backing_filename is null and status in [finished, archived]", + "count": len(rows), + "rows_sha256": hashlib.sha256(canonical_rows).hexdigest(), + "rows": rows, + } + + +def fetch_closed_provenance(api_base: str) -> dict[str, Any]: + base = api_base.rstrip("/") + with urlopen(f"{base}/workplans/", timeout=30) as response: + workplans = json.load(response) + with urlopen(f"{base}/repos/?limit=500", timeout=30) as response: + repos = json.load(response) + if not isinstance(workplans, list) or not isinstance(repos, list): + raise TypeError("State Hub returned a non-list workplan or repository payload") + return build_closed_provenance_export(workplans, repos, source=base) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/classification.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/classification.py new file mode 100644 index 00000000..4a51538f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/classification.py @@ -0,0 +1,116 @@ +"""Versioned implementation of the Custodian repository-classification contract.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + +CONTRACT_VERSION = "1.0" +CATEGORIES = frozenset( + {"experimental", "research", "project", "tooling", "product", "business", "publication"} +) +DOMAINS = frozenset( + { + "infotech", + "financials", + "communication", + "consumer", + "health", + "industrials", + "energy", + "utilities", + "materials", + "realestate", + "crypto", + "agents", + "space", + "government", + } +) +BUSINESS_STAKE = frozenset( + { + "execution", + "intelligence", + "finance", + "legal", + "sales", + "experience", + "technology", + "operations", + "product", + "people", + "procurement", + "sustainability", + "automation", + } +) +BUSINESS_MECHANICS = frozenset({"intention", "control", "coordination", "operation", "adaptation"}) +_TAG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +@dataclass(frozen=True) +class ClassificationIssue: + field: str + message: str + + +class ClassificationError(ValueError): + def __init__(self, issues: list[ClassificationIssue]): + self.issues = issues + super().__init__("; ".join(f"{issue.field}: {issue.message}" for issue in issues)) + + +def _list(data: dict[str, Any], field: str, issues: list[ClassificationIssue]) -> list[str]: + value = data.get(field, []) + if value is None: + return [] + if not isinstance(value, list) or any(not isinstance(item, str) for item in value): + issues.append(ClassificationIssue(field, "must be a list of strings")) + return [] + if len(value) != len(set(value)): + issues.append(ClassificationIssue(field, "must not contain duplicates")) + return value + + +def validate_classification(data: dict[str, Any]) -> list[ClassificationIssue]: + """Validate required fields and controlled vocabularies from canon v1.0.""" + issues: list[ClassificationIssue] = [] + category = data.get("category") + domain = data.get("domain") + if category not in CATEGORIES: + issues.append(ClassificationIssue("category", f"must be one of {', '.join(sorted(CATEGORIES))}")) + if domain not in DOMAINS: + issues.append(ClassificationIssue("domain", f"must be one of {', '.join(sorted(DOMAINS))}")) + + secondary = _list(data, "secondary_domains", issues) + unknown_domains = sorted(set(secondary) - DOMAINS) + if unknown_domains: + issues.append(ClassificationIssue("secondary_domains", f"unknown values: {', '.join(unknown_domains)}")) + if domain in secondary: + issues.append(ClassificationIssue("secondary_domains", "must not repeat the primary domain")) + + tags = _list(data, "capability_tags", issues) + invalid_tags = sorted(tag for tag in tags if not _TAG_RE.fullmatch(tag)) + if invalid_tags: + issues.append(ClassificationIssue("capability_tags", f"not lowercase kebab-case: {', '.join(invalid_tags)}")) + + stake = _list(data, "business_stake", issues) + unknown_stake = sorted(set(stake) - BUSINESS_STAKE) + if unknown_stake: + issues.append(ClassificationIssue("business_stake", f"unknown values: {', '.join(unknown_stake)}")) + + mechanics = _list(data, "business_mechanics", issues) + unknown_mechanics = sorted(set(mechanics) - BUSINESS_MECHANICS) + if unknown_mechanics: + issues.append( + ClassificationIssue("business_mechanics", f"unknown values: {', '.join(unknown_mechanics)}") + ) + return issues + + +def require_valid_classification(data: dict[str, Any]) -> dict[str, Any]: + issues = validate_classification(data) + if issues: + raise ClassificationError(issues) + return data diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/cli.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/cli.py new file mode 100644 index 00000000..eab1807b --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/cli.py @@ -0,0 +1,1473 @@ +"""CLI entry point ``rmgr``.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from datetime import date +from pathlib import Path + +from repo_manager.commands.rapp import add_rapp_parser +from repo_manager.commands.rapp import init as rapp_init +from repo_manager.commands.rapp import pin_image as rapp_pin_image +from repo_manager.commands.rapp import place as rapp_place +from repo_manager.commands.rapp import skeleton as rapp_skeleton +from repo_manager.commands.rapp import validate as rapp_validate +from repo_manager.commands.rapp import wrap as rapp_wrap + + +def _json_object(raw: str) -> dict: + try: + value = json.loads(raw) + except json.JSONDecodeError as exc: + raise argparse.ArgumentTypeError(str(exc)) from exc + if not isinstance(value, dict): + raise argparse.ArgumentTypeError("value must be a JSON object") + return value + + +def _freshness_command(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: + from repo_manager import freshness_receipts as fr + + if args.freshness_command == "receipts": + path = Path(args.receipts_path).expanduser() if args.receipts_path else None + rows = fr.load_receipts(path, repo_slug=args.repo) + if args.latest: + rows = list(fr.index_receipts(rows).last.values()) + print(json.dumps(rows, indent=2)) + return 0 + if args.freshness_command != "run": + parser.print_help() + return 2 + + from repo_manager.freshness_runner import HubNotPrimary, RunConfig, run + from repo_manager.runtime import PublisherSettings + + try: + token = PublisherSettings.from_env().forgejo_token + except ValueError as exc: + print(json.dumps({"ok": False, "status": "config_error", "error": str(exc)})) + return 2 + cfg = RunConfig( + limit=args.limit, + max_seconds=args.max_seconds, + dry_run=args.dry_run, + api_base=args.api_base, + expected_instance_label=args.expected_instance_label, + forge_base=args.forge_base, + forge_owner=args.forge_owner, + forge_token=token, + receipts_path=Path(args.receipts_path).expanduser() if args.receipts_path else None, + definition_id=args.definition_id, + definition_version=args.definition_version, + fire_id=args.fire_id, + ) + try: + result = run(cfg) + except HubNotPrimary as exc: + print(json.dumps({"ok": False, "status": "refused", "reason": "hub_not_primary", + "hub": exc.identity}, indent=2)) + return 3 + except ValueError as exc: + print(json.dumps({"ok": False, "status": "invalid_arguments", "error": str(exc)})) + return 2 + except Exception as exc: # noqa: BLE001 - hub/forge unreachable before any attempt + print(json.dumps({"ok": False, "status": "unavailable", "error": str(exc)[:300]})) + return 4 + failed = result["counts"].get("failed", 0) + result["ok"] = not failed and not result["aborted"] + print(json.dumps(result, indent=2)) + return 0 if result["ok"] else 1 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="rmgr", + description="Repo Manager CLI (helixforge.repo-manager)", + ) + parser.add_argument("--version", action="store_true", help="Print package version") + sub = parser.add_subparsers(dest="command") + + sub.add_parser("version", help="Print version") + sub.add_parser("dual-run-status", help="Show dual-run flags and mutation meter") + + p_obs = sub.add_parser("observe", help="Observe repository + print snapshot JSON") + p_obs.add_argument("--path", default=".", help="Repository checkout path") + p_obs.add_argument("--slug", default=None, help="Override repo slug") + + p_rec = sub.add_parser("reconcile", help="Rebuild local work-record index from files") + p_rec.add_argument("--path", default=".", help="Repository checkout path") + p_rec.add_argument("--slug", default=None) + p_rec.add_argument("--no-write-index", action="store_true", help="Do not write index file") + + p_registrar = sub.add_parser( + "registrar-reconcile", + help="Assign missing State Hub UUIDs through a scoped on-demand registrar", + ) + p_registrar.add_argument("--path", default=".", help="Repository checkout path") + p_registrar.add_argument( + "--api-base", + default=os.environ.get("STATE_HUB_API_BASE", "http://127.0.0.1:8000"), + help="Authoritative State Hub API base", + ) + p_registrar.add_argument("--statehub-bin", default=None, help="Override statehub executable") + p_registrar.add_argument( + "--confirm-primary", + action="store_true", + help="Confirm that --api-base is the authoritative hub", + ) + p_registrar.add_argument("--push", action="store_true", help="Push the registrar commit") + p_registrar.add_argument( + "--allow-unverified-primary", + action="store_true", + help="Proceed when the hub declares no instance role. Explicit on purpose: " + "the alternative is silently trusting whatever answered.", + ) + registrar_mode = p_registrar.add_mutually_exclusive_group() + registrar_mode.add_argument( + "--repair-workplan", + default=None, + metavar="ID", + help="Rebuild and verify one already-identified workplan projection", + ) + registrar_mode.add_argument( + "--bootstrap-empty-projection", + action="store_true", + help="Rebuild all authoritative UUIDs after proving the repo projection is empty", + ) + + p_onboard = sub.add_parser( + "repo-onboard", + help="Onboard a repository into its authoritative State Hub from its classification file", + ) + p_onboard.add_argument("--path", default=".", help="Repository checkout path") + p_onboard.add_argument("--slug", default=None, help="Override the repo slug (default: directory name)") + p_onboard.add_argument( + "--api-base", + default=os.environ.get("STATE_HUB_API_BASE"), + help="Authoritative State Hub API base URL (or set STATE_HUB_API_BASE). " + "Deliberately has no default: a silent localhost default is how " + "onboarding reached a cache instead of central for seven weeks.", + ) + p_onboard.add_argument( + "--dry-run", + action="store_true", + help="Report what would change without writing to the hub", + ) + + p_cmd = sub.add_parser( + "update-task-status", + help="Command repo.work.update_task_status (file + git commit)", + ) + p_cmd.add_argument("--path", default=".", help="Repository checkout path") + p_cmd.add_argument( + "--task-id", + required=True, + help="Canonical task id or State Hub task UUID", + ) + p_cmd.add_argument( + "--status", + required=True, + choices=["wait", "todo", "progress", "done", "cancel"], + ) + p_cmd.add_argument("--reason", default="rmgr CLI") + p_cmd.add_argument("--correlation-id", default=None) + p_cmd.add_argument("--idempotency-key", default=None) + p_cmd.add_argument("--expected-head-sha", default=None) + p_cmd.add_argument("--slug", default=None) + p_cmd.add_argument("--push", action="store_true", help="git push after commit (push-seal)") + p_cmd.add_argument( + "--no-commit", + action="store_true", + help="Patch file only (invalid as full applied evidence; for tests)", + ) + + p_task = sub.add_parser("task", help="Governed file-backed task mutations") + task_sub = p_task.add_subparsers(dest="task_command") + p_task_add = task_sub.add_parser("add", help="Append a task to a workplan") + p_task_add.add_argument("--path", default=".") + p_task_add.add_argument("--workplan-id", required=True) + p_task_add.add_argument("--task-id", default=None) + p_task_add.add_argument("--title", required=True) + p_task_add.add_argument("--description", required=True) + p_task_add.add_argument("--status", choices=["wait", "todo", "progress", "done", "cancel"], default="todo") + p_task_add.add_argument("--priority", choices=["low", "medium", "high", "critical"], default="medium") + p_task_add.add_argument("--needs-human", action="store_true") + p_task_add.add_argument("--intervention-note", default=None) + p_task_add.add_argument("--blocking-reason", default=None) + p_task_update = task_sub.add_parser("update", help="Update task metadata and lifecycle") + p_task_update.add_argument("--path", default=".") + p_task_update.add_argument("--task-id", required=True) + p_task_update.add_argument("--title", default=None) + p_task_update.add_argument("--description", default=None) + p_task_update.add_argument("--status", choices=["wait", "todo", "progress", "done", "cancel"], default=None) + p_task_update.add_argument("--priority", choices=["low", "medium", "high", "critical"], default=None) + p_task_update.add_argument("--needs-human", action=argparse.BooleanOptionalAction, default=None) + p_task_update.add_argument("--intervention-note", default=None) + p_task_update.add_argument("--blocking-reason", default=None) + for task_parser in (p_task_add, p_task_update): + task_parser.add_argument("--reason", default="rmgr CLI") + task_parser.add_argument("--correlation-id", default=None) + task_parser.add_argument("--idempotency-key", default=None) + task_parser.add_argument("--expected-head-sha", default=None) + task_parser.add_argument("--slug", default=None) + task_parser.add_argument("--push", action="store_true") + task_parser.add_argument("--no-commit", action="store_true") + + p_adhoc = sub.add_parser("adhoc", help="Daily repository-qualified ad-hoc work") + adhoc_sub = p_adhoc.add_subparsers(dest="adhoc_command") + p_adhoc_add = adhoc_sub.add_parser("add", help="Create/reuse today's ad-hoc and append a task") + p_adhoc_add.add_argument("--path", default=".") + p_adhoc_add.add_argument("--title", required=True) + p_adhoc_add.add_argument("--description", required=True) + p_adhoc_add.add_argument("--prefix", default=None) + p_adhoc_add.add_argument("--date", type=date.fromisoformat, default=None) + p_adhoc_add.add_argument("--status", choices=["wait", "todo", "progress", "done", "cancel"], default="todo") + p_adhoc_add.add_argument("--priority", choices=["low", "medium", "high", "critical"], default="medium") + p_adhoc_add.add_argument("--owner", default="codex") + p_adhoc_add.add_argument("--reason", default="rmgr CLI") + p_adhoc_add.add_argument("--correlation-id", default=None) + p_adhoc_add.add_argument("--idempotency-key", default=None) + p_adhoc_add.add_argument("--expected-head-sha", default=None) + p_adhoc_add.add_argument("--slug", default=None) + p_adhoc_add.add_argument("--push", action="store_true") + p_adhoc_add.add_argument("--no-commit", action="store_true") + + p_sync = sub.add_parser("sync", help="Fast authoritative forge projection reconciliation") + p_sync.add_argument("--path", default=".") + p_sync.add_argument("--slug", default=None) + p_sync.add_argument("--api-base", default=os.environ.get("STATE_HUB_API_BASE", "http://127.0.0.1:8000")) + p_sync.add_argument("--expected-instance-label", default="railiance01") + p_sync.add_argument("--push", action="store_true", help="Push ahead commits before reconciliation") + p_sync.add_argument("--no-commit-identifiers", action="store_true") + p_sync.add_argument("--acknowledge-retirements", action="store_true") + p_sync.add_argument("--no-queue", action="store_true") + + p_fresh = sub.add_parser("freshness", help="Bounded Forge-sourced freshness runner") + fresh_sub = p_fresh.add_subparsers(dest="freshness_command") + p_fresh_run = fresh_sub.add_parser( + "run", help="Reconcile the N stalest repositories from their Forge HEAD commit" + ) + p_fresh_run.add_argument("--limit", type=int, required=True) + p_fresh_run.add_argument("--max-seconds", type=float, required=True) + p_fresh_run.add_argument("--dry-run", action="store_true", help="Emit the plan only") + p_fresh_run.add_argument( + "--api-base", default=os.environ.get("STATE_HUB_API_BASE", "http://127.0.0.1:8000") + ) + p_fresh_run.add_argument("--expected-instance-label", default="railiance01") + p_fresh_run.add_argument( + "--forge-base", + default=os.environ.get("REPO_MANAGER_FORGEJO_BASE_URL", "https://forgejo.coulomb.social"), + ) + p_fresh_run.add_argument("--forge-owner", default="coulomb") + p_fresh_run.add_argument("--receipts-path", default=None) + p_fresh_run.add_argument("--definition-id", default=None) + p_fresh_run.add_argument("--definition-version", default=None) + p_fresh_run.add_argument("--fire-id", default=None) + p_fresh_rcpt = fresh_sub.add_parser("receipts", help="List durable freshness receipts") + p_fresh_rcpt.add_argument("--repo", default=None) + p_fresh_rcpt.add_argument("--receipts-path", default=None) + p_fresh_rcpt.add_argument("--latest", action="store_true", help="Latest receipt per repo") + + p_wp = sub.add_parser("workplan", help="Governed file-backed workplan mutations") + wp_sub = p_wp.add_subparsers(dest="workplan_command") + p_wp_create = wp_sub.add_parser("create", help="Create a workplan file") + p_wp_create.add_argument("--path", default=".") + p_wp_create.add_argument("--workplan-id", required=True) + p_wp_create.add_argument("--title", required=True) + p_wp_create.add_argument("--goal", required=True) + p_wp_create.add_argument( + "--status", + choices=["proposed", "ready", "active", "blocked", "backlog", "finished", "archived"], + default="proposed", + ) + p_wp_create.add_argument("--owner", default="codex") + p_wp_create.add_argument("--domain", default=None) + p_wp_create.add_argument("--topic-slug", default=None) + p_wp_create.add_argument("--filename", default=None) + + p_wp_update = wp_sub.add_parser("update", help="Update workplan metadata or status") + p_wp_update.add_argument("--path", default=".") + p_wp_update.add_argument("--workplan-id", required=True) + p_wp_update.add_argument("--title", default=None) + p_wp_update.add_argument( + "--status", + choices=["proposed", "ready", "active", "blocked", "backlog", "finished", "archived"], + default=None, + ) + p_wp_update.add_argument("--owner", default=None) + p_wp_update.add_argument("--domain", default=None) + p_wp_update.add_argument("--topic-slug", default=None) + + p_wp_archive = wp_sub.add_parser( + "delete", + help="Recoverable delete: set archived and move to workplans/archived", + ) + p_wp_archive.add_argument("--path", default=".") + p_wp_archive.add_argument("--workplan-id", required=True) + p_wp_archive.add_argument( + "--confirm", + action="store_true", + help="Confirm moving the workplan to the dated archive", + ) + + for wp_parser in (p_wp_create, p_wp_update, p_wp_archive): + wp_parser.add_argument("--reason", default="rmgr CLI") + wp_parser.add_argument("--correlation-id", default=None) + wp_parser.add_argument("--idempotency-key", default=None) + wp_parser.add_argument("--expected-head-sha", default=None) + wp_parser.add_argument("--slug", default=None) + wp_parser.add_argument("--push", action="store_true") + wp_parser.add_argument("--no-commit", action="store_true") + + register_kinds = [ + "sbom-inventory", + "repo-goals", + "upstream-contributions", + "technical-debt", + "extension-points", + "register-entries", + ] + p_register = sub.add_parser("register", help="Repository-owned register spine") + register_sub = p_register.add_subparsers(dest="register_command") + p_reg_list = register_sub.add_parser("list", help="List indexed register entries") + p_reg_list.add_argument("--path", default=".") + p_reg_list.add_argument("--kind", choices=register_kinds, default=None) + p_reg_list.add_argument("--slug", default=None) + p_reg_put = register_sub.add_parser("put", help="Create or update a register entry") + p_reg_put.add_argument("--path", default=".") + p_reg_put.add_argument("--kind", required=True, choices=register_kinds) + p_reg_put.add_argument("--entry-id", required=True) + p_reg_put.add_argument("--title", default=None) + p_reg_put.add_argument("--status", default=None) + p_reg_put.add_argument("--data-json", type=_json_object, default=None) + p_reg_defer = register_sub.add_parser("defer", help="Defer a register entry") + p_reg_defer.add_argument("--path", default=".") + p_reg_defer.add_argument("--kind", required=True, choices=register_kinds) + p_reg_defer.add_argument("--entry-id", required=True) + p_reg_defer.add_argument("--status", default="deferred") + p_reg_note = register_sub.add_parser("note", help="Append a note to a register entry") + p_reg_note.add_argument("--path", default=".") + p_reg_note.add_argument("--kind", required=True, choices=register_kinds) + p_reg_note.add_argument("--entry-id", required=True) + p_reg_note.add_argument("--note", required=True) + p_reg_note.add_argument("--author", default="codex") + for register_parser in (p_reg_put, p_reg_defer, p_reg_note): + register_parser.add_argument("--reason", default="rmgr CLI") + register_parser.add_argument("--correlation-id", default=None) + register_parser.add_argument("--idempotency-key", default=None) + register_parser.add_argument("--expected-head-sha", default=None) + register_parser.add_argument("--slug", default=None) + register_parser.add_argument("--push", action="store_true") + register_parser.add_argument("--no-commit", action="store_true") + + p_intake = sub.add_parser("intake", help="Repository-owned intake records") + intake_sub = p_intake.add_subparsers(dest="record_command") + p_intake_create = intake_sub.add_parser("create", help="Create an intake") + p_intake_create.add_argument("--path", default=".") + p_intake_create.add_argument("--record-id", required=True) + p_intake_create.add_argument("--title", required=True) + p_intake_create.add_argument("--status", default="open") + p_intake_create.add_argument("--data-json", type=_json_object, default=None) + p_intake_route = intake_sub.add_parser("route", help="Route an intake") + p_intake_route.add_argument("--path", default=".") + p_intake_route.add_argument("--record-id", required=True) + p_intake_route.add_argument("--route-to", required=True) + p_intake_note = intake_sub.add_parser("note", help="Append an intake note") + p_intake_note.add_argument("--path", default=".") + p_intake_note.add_argument("--record-id", required=True) + p_intake_note.add_argument("--note", required=True) + p_intake_note.add_argument("--author", default="codex") + p_intake_close = intake_sub.add_parser("close", help="Close an intake") + p_intake_close.add_argument("--path", default=".") + p_intake_close.add_argument("--record-id", required=True) + p_intake_close.add_argument("--outcome", default=None) + + p_decision = sub.add_parser("decision", help="Repository-owned decision records") + decision_sub = p_decision.add_subparsers(dest="record_command") + p_decision_create = decision_sub.add_parser("create", help="Create a decision") + p_decision_create.add_argument("--path", default=".") + p_decision_create.add_argument("--record-id", required=True) + p_decision_create.add_argument("--title", required=True) + p_decision_create.add_argument("--status", default="open") + p_decision_create.add_argument("--data-json", type=_json_object, default=None) + p_decision_update = decision_sub.add_parser("update", help="Update a decision") + p_decision_update.add_argument("--path", default=".") + p_decision_update.add_argument("--record-id", required=True) + p_decision_update.add_argument("--title", default=None) + p_decision_update.add_argument("--status", default=None) + p_decision_update.add_argument("--data-json", type=_json_object, default=None) + p_decision_resolve = decision_sub.add_parser("resolve", help="Resolve a decision") + p_decision_resolve.add_argument("--path", default=".") + p_decision_resolve.add_argument("--record-id", required=True) + p_decision_resolve.add_argument("--rationale", required=True) + p_decision_resolve.add_argument("--decided-by", required=True) + + for record_parser in ( + p_intake_create, + p_intake_route, + p_intake_note, + p_intake_close, + p_decision_create, + p_decision_update, + p_decision_resolve, + ): + record_parser.add_argument("--reason", default="rmgr CLI") + record_parser.add_argument("--correlation-id", default=None) + record_parser.add_argument("--idempotency-key", default=None) + record_parser.add_argument("--expected-head-sha", default=None) + record_parser.add_argument("--slug", default=None) + record_parser.add_argument("--push", action="store_true") + record_parser.add_argument("--no-commit", action="store_true") + + add_rapp_parser(sub) + + p_conf = sub.add_parser("conform", help="Check a repository against flavor standards") + p_conf.add_argument("--path", default=".", help="Repository checkout path") + p_conf.add_argument("--slug", default=None) + + p_pref = sub.add_parser( + "prefix-uniqueness", + help="Detect shared workplan prefixes and reused identifiers (ADR-007)", + ) + p_pref.add_argument("--root", default=".", help="Fleet root or a single repository") + p_pref.add_argument("--registry", default=None, help="Override prefix registry YAML") + + p_scaf = sub.add_parser("scaffold", help="Create flavor-correct repository baseline files") + p_scaf.add_argument("--path", required=True) + p_scaf.add_argument( + "--flavor", + default=None, + choices=[ + "experimental", + "research", + "project", + "tooling", + "product", + "business", + "publication", + ], + ) + p_scaf.add_argument("--slug", default=None) + p_scaf.add_argument("--domain", default="infotech") + p_scaf.add_argument("--wp-prefix", default=None) + p_scaf.add_argument("--force", action="store_true") + p_scaf.add_argument("--no-commit", action="store_true") + p_scaf.add_argument( + "--refresh-hub-access", + action="store_true", + help="Replace the State Hub access table in AGENTS.md from config/state-hub-access.yaml", + ) + + p_secret_scan = sub.add_parser("secret-scan", help="Content-silent scan of changed staged blobs") + p_secret_scan.add_argument("--path", type=Path, default=Path(".")) + p_secret_scan.add_argument("--binary", type=Path, required=True) + + p_provenance = sub.add_parser( + "assistant-provenance", + help="Install or report coding-assistant commit provenance", + ) + provenance_sub = p_provenance.add_subparsers(dest="provenance_command") + p_prov_install = provenance_sub.add_parser("install", help="Set global core.hooksPath") + p_prov_install.add_argument( + "--hooks-path", + default=str(Path(__file__).resolve().parents[2] / ".githooks"), + ) + p_prov_report = provenance_sub.add_parser("report", help="Report trailers from Git history") + p_prov_report.add_argument("--path", default=".") + p_prov_report.add_argument("--rev", default="HEAD") + p_prov_report.add_argument("--max-count", type=int, default=None) + + p_identifier = sub.add_parser("identifier", help="Deterministic work-record identifiers") + identifier_sub = p_identifier.add_subparsers(dest="identifier_command") + p_id_derive = identifier_sub.add_parser("derive", help="Derive one UUIDv5") + p_id_derive.add_argument("--namespace", default=None, help="Override declared fleet namespace") + p_id_derive.add_argument("--record-id", required=True) + p_id_preflight = identifier_sub.add_parser("preflight", help="Scan live identifier collisions") + p_id_preflight.add_argument("--root", default=".") + p_id_plan = identifier_sub.add_parser( + "migration-plan", + help="Emit a non-mutating, per-repository old-to-derived UUID plan", + ) + p_id_plan.add_argument("--root", default=".") + p_id_plan.add_argument("--namespace", default=None, help="Override declared fleet namespace") + p_id_plan.add_argument("--output", default=None, help="Write the provenance mapping as JSON") + p_id_plan.add_argument("--force", action="store_true", help="Replace an existing --output file") + p_id_verify = identifier_sub.add_parser( + "migration-verify", + help="Verify a sealed migration plan against current repository sources", + ) + p_id_verify.add_argument("--plan", required=True) + p_id_verify.add_argument("--repo", default=None, help="Verify one repository atomic unit") + p_id_batch = identifier_sub.add_parser( + "migration-batch-plan", + help="Pin a clean synchronized repository batch for explicit approval", + ) + p_id_batch.add_argument("--plan", required=True) + p_id_batch.add_argument("--repo", action="append", required=True, dest="repos") + p_id_batch.add_argument( + "--projection-api-base", + action="append", + default=[], + dest="projection_api_bases", + help=( + "Classify legacy/derived UUID presence on this projection; both-present " + "and neither-present mappings are refused" + ), + ) + p_id_batch.add_argument("--output", default=None) + p_id_batch.add_argument("--force", action="store_true") + p_id_batch_verify = identifier_sub.add_parser( + "migration-batch-verify", + help="Verify a saved batch seal and repeat source/Git preflight", + ) + p_id_batch_verify.add_argument("--plan", required=True) + p_id_batch_verify.add_argument("--batch", required=True) + p_id_files = identifier_sub.add_parser( + "migration-files", + help="Validate or execute one repository's sealed UUID file rewrite", + ) + p_id_files.add_argument("--plan", required=True) + p_id_files.add_argument("--repo", required=True) + p_id_files.add_argument("--confirm-plan-sha256", required=True) + p_id_files.add_argument( + "--direction", choices=["forward", "reverse"], default="forward" + ) + p_id_files.add_argument( + "--execute", + action="store_true", + help="Write files; without this flag only validate and report", + ) + p_id_projection = identifier_sub.add_parser( + "migration-projection", + help="Apply or reverse one sealed repository migration on the primary hub", + ) + p_id_projection.add_argument("--plan", required=True) + p_id_projection.add_argument("--repo", required=True) + p_id_projection.add_argument("--confirm-plan-sha256", required=True) + p_id_projection.add_argument("--api-base", required=True) + p_id_projection.add_argument("--expected-instance-label", default="railiance01") + p_id_projection.add_argument( + "--direction", choices=["forward", "reverse"], default="forward" + ) + + p_sbom = sub.add_parser( + "sbom", + help="Deprecated compatibility commands delegated to SBOM Nexus", + ) + sbom_sub = p_sbom.add_subparsers(dest="sbom_command") + p_sbom_scan = sbom_sub.add_parser( + "scan", + help="Run a local non-authoritative preview through the SBOM Nexus CLI", + ) + p_sbom_scan.add_argument("--path", default=".") + p_sbom_scan.add_argument("--slug", default=None) + p_sbom_scan.add_argument("--output", default=None, help="Write the derived snapshot as JSON") + p_sbom_scan.add_argument("--force", action="store_true", help="Replace an existing --output file") + p_sbom_report = sbom_sub.add_parser( + "licence-report", + help="Preview licences locally without persisting or advancing Nexus state", + ) + p_sbom_report.add_argument("--path", default=".") + p_sbom_report.add_argument("--slug", default=None) + p_sbom_source = sbom_sub.add_parser( + "source-ref", + help="Resolve a controlled Forgejo source reference and optionally project it", + ) + p_sbom_source.add_argument("--path", default=".") + p_sbom_source.add_argument("--slug", default=None) + p_sbom_source.add_argument("--remote", default="origin") + p_sbom_source.add_argument( + "--forgejo-base", default="https://forgejo.coulomb.social" + ) + p_sbom_source.add_argument("--project", action="store_true") + p_sbom_source.add_argument("--confirm-authoritative", action="store_true") + + p_authority = sub.add_parser("authority", help="Resolve the one authoritative record owner") + authority_sub = p_authority.add_subparsers(dest="authority_command") + p_authority_route = authority_sub.add_parser("route", help="Resolve or verify an authority route") + p_authority_route.add_argument("--record-type", required=True) + p_authority_route.add_argument("--repo-slug", default=None) + p_authority_route.add_argument("--domain-slug", default=None) + p_authority_route.add_argument("--claimed-owner", default=None) + + p_cache = sub.add_parser("cache", help="Inspect and rebuild advisory repository caches") + cache_sub = p_cache.add_subparsers(dest="cache_command") + p_cache_status = cache_sub.add_parser("status", help="Report cache age and source drift") + p_cache_status.add_argument("--path", default=".") + p_cache_rebuild = cache_sub.add_parser("rebuild", help="Rebuild the local index from files") + p_cache_rebuild.add_argument("--path", default=".") + p_cache_rebuild.add_argument("--slug", default=None) + p_cache_export = cache_sub.add_parser( + "export-closed", + help="Export unbound closed State Hub records before cache replacement", + ) + p_cache_export.add_argument( + "--api-base", + default=os.environ.get("STATE_HUB_API_BASE", "http://127.0.0.1:8000"), + ) + p_cache_export.add_argument("--output", required=True) + p_cache_export.add_argument("--force", action="store_true") + + p_workload = sub.add_parser( + "workload", + help="Index or resolve authoritative rapp workload declarations", + ) + workload_sub = p_workload.add_subparsers(dest="workload_command") + p_workload_index = workload_sub.add_parser( + "index", help="Index workload identities from rapp declarations" + ) + p_workload_index.add_argument("--root", default=".", help="Fleet root or one rapp repo") + p_workload_resolve = workload_sub.add_parser( + "resolve", help="Resolve one explicit workload reference without inference" + ) + p_workload_resolve.add_argument("--root", default=".", help="Fleet root or one rapp repo") + p_workload_resolve.add_argument("--rapp-id", required=True) + p_workload_resolve.add_argument("--name", required=True) + p_workload_resolve.add_argument("--deployable", default=None) + + p_publisher = sub.add_parser( + "publisher", + help="Publish repository classifications through the read-only port.repo contract", + ) + publisher_sub = p_publisher.add_subparsers(dest="publisher_command") + p_publisher_snapshot = publisher_sub.add_parser( + "snapshot", help="Render the first page of a classification projection snapshot" + ) + p_publisher_snapshot.add_argument("--registry", required=True) + p_publisher_snapshot.add_argument( + "--cursor-secret", default=os.environ.get("REPO_MANAGER_CURSOR_SECRET") + ) + p_publisher_snapshot.add_argument("--page-size", type=int, default=100) + p_publisher_snapshot.add_argument( + "--forgejo-base-url", + default=os.environ.get("REPO_MANAGER_FORGEJO_BASE_URL", "https://forgejo.coulomb.social"), + ) + p_publisher_snapshot.add_argument("--forgejo-token-file", default=None) + p_publisher_snapshot.add_argument("--remote-workers", type=int, default=8) + p_publisher_import = publisher_sub.add_parser( + "import-registry", + help="Bootstrap the private local registry from primary State Hub identities", + ) + p_publisher_import.add_argument("--root", required=True, help="Local fleet checkout root") + p_publisher_import.add_argument( + "--api-base", + default=os.environ.get("STATE_HUB_API_BASE", "http://127.0.0.1:8000"), + ) + p_publisher_import.add_argument("--output", required=True) + p_publisher_import.add_argument("--timeout", type=float, default=10.0) + p_publisher_import.add_argument("--workers", type=int, default=4) + p_publisher_import.add_argument("--retries", type=int, default=2) + p_publisher_import.add_argument( + "--source", choices=("local", "forgejo"), default="local" + ) + p_publisher_import.add_argument("--force", action="store_true") + p_publisher_api = publisher_sub.add_parser( + "api", help="Run the read-only classification publisher API" + ) + p_publisher_api.add_argument("--host", default="127.0.0.1") + p_publisher_api.add_argument("--port", type=int, default=8020) + + p_owner_interface = sub.add_parser( + "owner-interface", + help="Inspect validated owner-consumable task interfaces", + ) + owner_interface_sub = p_owner_interface.add_subparsers(dest="owner_interface_command") + p_owner_interface_validate = owner_interface_sub.add_parser( + "validate", help="Validate and return owner task interfaces" + ) + p_owner_interface_validate.add_argument( + "--path", default="interfaces", help="One interface YAML or an interface directory" + ) + p_owner_interface_validate.add_argument( + "--owner", default=None, help="Return interfaces for this target repo or owner agent" + ) + + args = parser.parse_args(argv) + + if args.version or args.command in (None, "version"): + from repo_manager import __version__ + + print(__version__) + return 0 + + if args.command == "dual-run-status": + from repo_manager.dual_run import flags_status + + print(json.dumps(flags_status(), indent=2)) + return 0 + + if args.command == "observe": + from repo_manager.observe import observe_repository + + snap, _idx = observe_repository(Path(args.path), slug=args.slug) + print(json.dumps(snap, indent=2)) + return 0 + + if args.command == "reconcile": + from repo_manager.dual_run import record_mutation + from repo_manager.index_store import append_event, save_index + from repo_manager.observe import observe_repository + + root = Path(args.path) + snap, index = observe_repository(root, slug=args.slug) + append_event( + index, + { + "type": "repo.reconciled", + "workplan_count": snap["index"]["workplan_count"], + "task_count": snap["index"]["task_count"], + "source": "repo-manager", + }, + ) + record_mutation( + source="repo-manager", + kind="reconcile", + repo_slug=snap.get("slug"), + detail=snap["index"], + ) + if not args.no_write_index: + path = save_index(index) + print(json.dumps({"ok": True, "index_path": str(path), "snapshot": snap}, indent=2)) + else: + print(json.dumps({"ok": True, "snapshot": snap, "index": index.to_dict()}, indent=2)) + return 0 + + if args.command == "registrar-reconcile": + from repo_manager.commands.registrar_reconcile import registrar_reconcile + + result = registrar_reconcile( + Path(args.path), + api_base=args.api_base, + statehub_bin=args.statehub_bin, + confirm_primary=args.confirm_primary, + allow_unverified_primary=args.allow_unverified_primary, + push=args.push, + repair_workplan=args.repair_workplan, + bootstrap_empty_projection=args.bootstrap_empty_projection, + ) + print(json.dumps(result.to_dict(), indent=2)) + return 0 if result.status in {"applied", "noop"} else 1 + + if args.command == "repo-onboard": + from repo_manager.commands.repo_onboard import OnboardError, onboard_repo + + if not args.api_base: + print( + "ERROR: --api-base is required (or set STATE_HUB_API_BASE). " + "Point it at the authoritative hub, not a local cache.", + file=sys.stderr, + ) + return 2 + try: + report = onboard_repo( + Path(args.path), + api_base=args.api_base, + dry_run=args.dry_run, + slug=args.slug, + ) + except OnboardError as exc: + print(json.dumps({"status": "blocked", "error": str(exc)}, indent=2)) + return 1 + print(json.dumps(report, indent=2)) + return 0 + + if args.command == "update-task-status": + from repo_manager.commands.task_status import update_task_status + + result = update_task_status( + Path(args.path), + args.task_id, + args.status, + correlation_id=args.correlation_id, + reason=args.reason, + commit=not args.no_commit, + push=args.push, + expected_head_sha=args.expected_head_sha, + idempotency_key=args.idempotency_key, + repo_slug=args.slug, + ) + print(json.dumps(result.to_dict(), indent=2)) + return 0 if result.status == "applied" else 1 + + if args.command == "task": + if not args.task_command: + p_task.print_help() + return 2 + from repo_manager.commands.task import mutate_task + + result = mutate_task( + Path(args.path), + args.task_id, + operation=args.task_command, + workplan_id=getattr(args, "workplan_id", None), + title=args.title, + description=args.description, + status=args.status, + priority=args.priority, + needs_human=args.needs_human, + intervention_note=args.intervention_note, + blocking_reason=args.blocking_reason, + correlation_id=args.correlation_id, + reason=args.reason, + commit=not args.no_commit, + push=args.push, + expected_head_sha=args.expected_head_sha, + idempotency_key=args.idempotency_key, + repo_slug=args.slug, + ) + print(json.dumps(result.to_dict(), indent=2)) + return 0 if result.status == "applied" else 1 + + if args.command == "adhoc": + if args.adhoc_command != "add": + p_adhoc.print_help() + return 2 + from repo_manager.commands.task import add_adhoc_task + + result = add_adhoc_task( + Path(args.path), + args.title, + args.description, + prefix=args.prefix, + on_date=args.date, + status=args.status, + priority=args.priority, + owner=args.owner, + correlation_id=args.correlation_id, + reason=args.reason, + commit=not args.no_commit, + push=args.push, + expected_head_sha=args.expected_head_sha, + idempotency_key=args.idempotency_key, + repo_slug=args.slug, + ) + print(json.dumps(result.to_dict(), indent=2)) + return 0 if result.status == "applied" else 1 + + if args.command == "sync": + from repo_manager.projection_sync import sync_repository_projection + + result = sync_repository_projection( + Path(args.path), + api_base=args.api_base, + repo_slug=args.slug, + expected_instance_label=args.expected_instance_label, + commit_identifiers=not args.no_commit_identifiers, + push=args.push, + acknowledge_retirements=args.acknowledge_retirements, + queue_on_unavailable=not args.no_queue, + ) + print(json.dumps(result, indent=2)) + return 0 if result.get("ok") else 1 + + if args.command == "freshness": + return _freshness_command(args, p_fresh) + + if args.command == "workplan": + from repo_manager.commands.workplan import mutate_workplan + + if not args.workplan_command: + p_wp.print_help() + return 2 + operation = "archive" if args.workplan_command == "delete" else args.workplan_command + result = mutate_workplan( + Path(args.path), + args.workplan_id, + operation=operation, + title=getattr(args, "title", None), + goal=getattr(args, "goal", None), + status=getattr(args, "status", None), + owner=getattr(args, "owner", None), + topic_slug=getattr(args, "topic_slug", None), + domain=getattr(args, "domain", None), + filename=getattr(args, "filename", None), + confirm_archive=getattr(args, "confirm", False), + correlation_id=args.correlation_id, + reason=args.reason, + commit=not args.no_commit, + push=args.push, + expected_head_sha=args.expected_head_sha, + idempotency_key=args.idempotency_key, + repo_slug=args.slug, + ) + print(json.dumps(result.to_dict(), indent=2)) + return 0 if result.status == "applied" else 1 + + if args.command == "register": + if not args.register_command: + p_register.print_help() + return 2 + if args.register_command == "list": + from repo_manager.observe import observe_repository + + _snapshot, index = observe_repository(Path(args.path), slug=args.slug) + prefix = f"register:{args.kind}" if args.kind else "register:" + entries = [r.__dict__ for r in index.work_records if r.kind.startswith(prefix)] + print(json.dumps({"ok": True, "entries": entries}, indent=2)) + return 0 + + from repo_manager.commands.register import mutate_register_entry + + result = mutate_register_entry( + Path(args.path), + args.kind, + args.entry_id, + operation="upsert" if args.register_command == "put" else args.register_command, + title=getattr(args, "title", None), + status=getattr(args, "status", None), + data=getattr(args, "data_json", None), + note=getattr(args, "note", None), + note_author=getattr(args, "author", None), + correlation_id=args.correlation_id, + reason=args.reason, + commit=not args.no_commit, + push=args.push, + expected_head_sha=args.expected_head_sha, + idempotency_key=args.idempotency_key, + repo_slug=args.slug, + ) + print(json.dumps(result.to_dict(), indent=2)) + return 0 if result.status == "applied" else 1 + + if args.command in {"intake", "decision"}: + selected = p_intake if args.command == "intake" else p_decision + if not args.record_command: + selected.print_help() + return 2 + from repo_manager.commands.record import mutate_record + + result = mutate_record( + Path(args.path), + args.command, + args.record_id, + operation=args.record_command, + title=getattr(args, "title", None), + status=getattr(args, "status", None), + data=getattr(args, "data_json", None), + route_to=getattr(args, "route_to", None), + note=getattr(args, "note", None), + author=getattr(args, "author", None), + outcome=getattr(args, "outcome", None), + rationale=getattr(args, "rationale", None), + decided_by=getattr(args, "decided_by", None), + correlation_id=args.correlation_id, + reason=args.reason, + commit=not args.no_commit, + push=args.push, + expected_head_sha=args.expected_head_sha, + idempotency_key=args.idempotency_key, + repo_slug=args.slug, + ) + print(json.dumps(result.to_dict(), indent=2)) + return 0 if result.status == "applied" else 1 + + if args.command == "rapp": + if args.rapp_command == "init": + result = rapp_init( + Path(args.path), + app=args.app, + ownership_repo=args.ownership_repo, + rail=args.rail, + classification=args.classification, + criticality=args.criticality, + package_type=args.package_type, + purpose=args.purpose, + force=args.force, + ) + elif args.rapp_command == "validate": + result = rapp_validate( + Path(args.path), + family_root=Path(args.family_root) if args.family_root else None, + ) + elif args.rapp_command == "skeleton": + result = rapp_skeleton( + Path(args.path), + from_app=Path(args.from_app), + app=args.app, + package_type=args.package_type, + force=args.force, + dedicated_postgres=args.dedicated_postgres, + ) + elif args.rapp_command == "wrap": + result = rapp_wrap( + Path(args.path), + app=args.app, + ownership_repo=args.ownership_repo, + from_app=Path(args.from_app), + rail=args.rail, + classification=args.classification, + criticality=args.criticality, + package_type=args.package_type, + purpose=args.purpose, + force=args.force, + dedicated_postgres=args.dedicated_postgres, + family_root=Path(args.family_root) if args.family_root else None, + ) + elif args.rapp_command == "place": + result = rapp_place( + Path(args.path), + reef=args.reef, + family_root=Path(args.family_root) if args.family_root else None, + ) + elif args.rapp_command == "pin-image": + result = rapp_pin_image(Path(args.path), args.digest) + else: + parser.print_help() + return 2 + print(json.dumps(result, indent=2)) + return 0 if result.get("ok") else 1 + + if args.command == "conform": + from repo_manager.standards import check_repository + + report = check_repository(Path(args.path), slug=args.slug) + print(json.dumps(report.to_dict(), indent=2)) + return 0 if report.ok else 1 + + if args.command == "prefix-uniqueness": + from repo_manager.prefix_registry import scan_prefixes + + registry = Path(args.registry) if args.registry else None + report = scan_prefixes(Path(args.root), registry_path=registry) + print(json.dumps(report, indent=2)) + return 0 if report.get("ok") else 1 + + if args.command == "scaffold": + from repo_manager.commands.scaffold import refresh_hub_access, scaffold_repository + + if args.refresh_hub_access and not args.flavor: + result = refresh_hub_access(Path(args.path), commit=not args.no_commit) + else: + if not args.flavor: + print("scaffold: --flavor is required unless --refresh-hub-access is set", file=sys.stderr) + return 2 + result = scaffold_repository( + Path(args.path), + flavor=args.flavor, + slug=args.slug, + domain=args.domain, + wp_prefix=args.wp_prefix, + force=args.force, + commit=not args.no_commit, + ) + print(json.dumps(result.to_dict(), indent=2)) + return 0 if result.status == "applied" else 1 + + if args.command == "secret-scan": + from repo_manager.secret_scan import exit_code, scan_staged + + result = scan_staged(args.path, args.binary) + print(json.dumps(result, sort_keys=True)) + return exit_code(result) + + if args.command == "assistant-provenance": + if not args.provenance_command: + p_provenance.print_help() + return 2 + from repo_manager.provenance import assistant_report, install_hook + + if args.provenance_command == "install": + result = install_hook(Path(args.hooks_path)) + else: + result = assistant_report(Path(args.path), rev=args.rev, max_count=args.max_count) + print(json.dumps(result, indent=2)) + return 0 if result.get("ok") else 1 + + if args.command == "identifier": + if not args.identifier_command: + p_identifier.print_help() + return 2 + from repo_manager.identifiers import ( + derive_work_record_uuid, + load_fleet_namespace, + migrate_repository_identifier_files, + migrate_repository_projection, + plan_identifier_migration, + plan_identifier_migration_batch, + scan_live_identifier_collisions, + verify_identifier_migration_batch, + verify_identifier_migration_plan, + ) + + namespace = getattr(args, "namespace", None) or load_fleet_namespace() + + if args.identifier_command == "derive": + try: + derived = derive_work_record_uuid(namespace, args.record_id) + except ValueError as exc: + print(json.dumps({"ok": False, "error": str(exc)}, indent=2)) + return 1 + result = { + "ok": True, + "namespace": namespace, + "record_id": args.record_id, + "uuid": str(derived), + } + elif args.identifier_command == "preflight": + result = scan_live_identifier_collisions(Path(args.root)) + elif args.identifier_command == "migration-verify": + try: + plan = json.loads(Path(args.plan).read_text(encoding="utf-8")) + if not isinstance(plan, dict): + raise TypeError("migration plan must be a JSON object") + result = verify_identifier_migration_plan(plan, repo_slug=args.repo) + except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc: + print(json.dumps({"ok": False, "error": str(exc)}, indent=2)) + return 1 + elif args.identifier_command == "migration-files": + try: + plan = json.loads(Path(args.plan).read_text(encoding="utf-8")) + if not isinstance(plan, dict): + raise TypeError("migration plan must be a JSON object") + result = migrate_repository_identifier_files( + plan, + repo_slug=args.repo, + confirm_plan_sha256=args.confirm_plan_sha256, + direction=args.direction, + execute=args.execute, + ) + except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc: + print(json.dumps({"ok": False, "error": str(exc)}, indent=2)) + return 1 + elif args.identifier_command == "migration-projection": + try: + plan = json.loads(Path(args.plan).read_text(encoding="utf-8")) + if not isinstance(plan, dict): + raise TypeError("migration plan must be a JSON object") + result = migrate_repository_projection( + plan, + repo_slug=args.repo, + confirm_plan_sha256=args.confirm_plan_sha256, + api_base=args.api_base, + direction=args.direction, + expected_instance_label=args.expected_instance_label, + ) + except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc: + print(json.dumps({"ok": False, "error": str(exc)}, indent=2)) + return 1 + elif args.identifier_command == "migration-batch-plan": + try: + plan = json.loads(Path(args.plan).read_text(encoding="utf-8")) + if not isinstance(plan, dict): + raise TypeError("migration plan must be a JSON object") + result = plan_identifier_migration_batch( + plan, + repo_slugs=args.repos, + projection_api_bases=args.projection_api_bases, + ) + except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc: + print(json.dumps({"ok": False, "error": str(exc)}, indent=2)) + return 1 + if args.output: + output = Path(args.output) + if output.exists() and not args.force: + print( + json.dumps( + {"ok": False, "error": f"output exists: {output}; use --force to replace"}, + indent=2, + ) + ) + return 1 + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + elif args.identifier_command == "migration-batch-verify": + try: + plan = json.loads(Path(args.plan).read_text(encoding="utf-8")) + batch = json.loads(Path(args.batch).read_text(encoding="utf-8")) + if not isinstance(plan, dict) or not isinstance(batch, dict): + raise TypeError("migration plan and batch must be JSON objects") + result = verify_identifier_migration_batch(batch, plan=plan) + except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc: + print(json.dumps({"ok": False, "error": str(exc)}, indent=2)) + return 1 + else: + try: + result = plan_identifier_migration(Path(args.root), namespace) + except ValueError as exc: + print(json.dumps({"ok": False, "error": str(exc)}, indent=2)) + return 1 + if args.output: + output = Path(args.output) + if output.exists() and not args.force: + print( + json.dumps( + {"ok": False, "error": f"output exists: {output}; use --force to replace"}, + indent=2, + ) + ) + return 1 + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + print(json.dumps(result, indent=2)) + return 0 if result.get("ok") else 1 + + if args.command == "sbom": + if not args.sbom_command: + p_sbom.print_help() + return 2 + if args.sbom_command == "source-ref": + from repo_manager.sbom_client import ( + SBOMNexusClient, + SBOMNexusConfig, + SBOMServiceError, + ) + from repo_manager.source_ref import ForgejoSourceResolver + + resolution = ForgejoSourceResolver(base_url=args.forgejo_base).resolve( + Path(args.path), + repo_slug=args.slug, + remote_name=args.remote, + ) + if not args.project: + print(json.dumps(resolution, indent=2)) + return 0 if resolution.get("supported") else 1 + if not args.confirm_authoritative: + print( + json.dumps( + { + "ok": False, + "error": "--project requires --confirm-authoritative", + "resolution": resolution, + }, + indent=2, + ) + ) + return 2 + if not resolution.get("supported"): + print(json.dumps(resolution, indent=2)) + return 1 + try: + client = SBOMNexusClient(SBOMNexusConfig.from_environment()) + projection = client.upsert_repository( + resolution["repo_slug"], + nexus_checkout_path=None, + source_ref=resolution["source_ref"], + ) + except (ValueError, SBOMServiceError) as exc: + error = exc.to_dict() if isinstance(exc, SBOMServiceError) else {"code": "config_error"} + print( + json.dumps( + {"ok": False, "error": error, "resolution": resolution}, + indent=2, + ) + ) + return 1 + print( + json.dumps( + {"ok": True, "resolution": resolution, "projection": projection}, + indent=2, + ) + ) + return 0 + from repo_manager.sbom_client import ( + licence_report_from_snapshot, + scan_repository_via_nexus, + ) + + snapshot = scan_repository_via_nexus(Path(args.path), slug=args.slug) + result = ( + snapshot + if args.sbom_command == "scan" + else licence_report_from_snapshot(snapshot) + ) + if args.sbom_command == "scan" and args.output: + output = Path(args.output) + if output.exists() and not args.force: + print( + json.dumps( + {"ok": False, "error": f"output exists: {output}; use --force to replace"}, + indent=2, + ) + ) + return 1 + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(snapshot, indent=2) + "\n", encoding="utf-8") + print(json.dumps(result, indent=2)) + return 0 if result.get("ok") else 1 + + if args.command == "authority": + if not args.authority_command: + p_authority.print_help() + return 2 + from repo_manager.authority import AuthorityError, resolve_record_authority + + try: + result = resolve_record_authority( + args.record_type, + repo_slug=args.repo_slug, + domain_slug=args.domain_slug, + claimed_owner=args.claimed_owner, + ) + except AuthorityError as exc: + print(json.dumps({"ok": False, "error": str(exc)}, indent=2)) + return 1 + print(json.dumps({"ok": True, **result}, indent=2)) + return 0 + + if args.command == "cache": + if not args.cache_command: + p_cache.print_help() + return 2 + from repo_manager.cache import cache_status, fetch_closed_provenance + + if args.cache_command == "status": + result = cache_status(Path(args.path)) + elif args.cache_command == "rebuild": + from repo_manager.index_store import save_index + from repo_manager.observe import observe_repository + + snapshot, index = observe_repository(Path(args.path), slug=args.slug) + index_path = save_index(index) + result = { + "ok": True, + "rebuilt": True, + "index_path": str(index_path), + "record_count": snapshot["index"]["record_count"], + "cache": cache_status(Path(args.path)), + } + else: + output = Path(args.output) + if output.exists() and not args.force: + print( + json.dumps( + {"ok": False, "error": f"output exists: {output}; use --force to replace"}, + indent=2, + ) + ) + return 1 + try: + result = fetch_closed_provenance(args.api_base) + except (OSError, TypeError, ValueError) as exc: + print(json.dumps({"ok": False, "error": str(exc)}, indent=2)) + return 1 + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + result = {key: value for key, value in result.items() if key != "rows"} + result["output"] = str(output) + print(json.dumps(result, indent=2)) + return 0 if result.get("ok") else 1 + + if args.command == "workload": + if not args.workload_command: + p_workload.print_help() + return 2 + from repo_manager.workloads import index_workloads, resolve_workload + + if args.workload_command == "index": + result = index_workloads(Path(args.root)) + else: + result = resolve_workload( + Path(args.root), + rapp_id=args.rapp_id, + name=args.name, + deployable=args.deployable, + ) + print(json.dumps(result, indent=2)) + return 0 if result.get("ok") else 1 + + if args.command == "publisher": + if not args.publisher_command: + p_publisher.print_help() + return 2 + if args.publisher_command == "api": + import uvicorn + + uvicorn.run("repo_manager.runtime:app", host=args.host, port=args.port) + return 0 + if args.publisher_command == "import-registry": + import yaml + + from repo_manager.repository_publisher import import_state_hub_registry + + output = Path(args.output).expanduser() + if output.exists() and not args.force: + print( + json.dumps( + {"ok": False, "error": f"output exists: {output}; use --force to replace"}, + indent=2, + ) + ) + return 1 + try: + result = import_state_hub_registry( + Path(args.root), + api_base=args.api_base, + timeout_seconds=args.timeout, + workers=args.workers, + retries=args.retries, + source=args.source, + ) + except (OSError, ValueError) as exc: + print(json.dumps({"ok": False, "error": str(exc)}, indent=2)) + return 1 + if not result["ok"]: + print(json.dumps(result, indent=2)) + return 1 + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(yaml.safe_dump(result, sort_keys=False), encoding="utf-8") + print( + json.dumps( + { + "ok": True, + "output": str(output), + "repository_count": len(result["repositories"]), + "source": result["source"], + }, + indent=2, + ) + ) + return 0 + if not args.cursor_secret: + print( + json.dumps( + { + "ok": False, + "error": "--cursor-secret or REPO_MANAGER_CURSOR_SECRET is required", + }, + indent=2, + ) + ) + return 2 + from repo_manager.repository_publisher import ClassificationPublisher + + try: + forgejo_token = None + if args.forgejo_token_file: + forgejo_token = Path(args.forgejo_token_file).read_text(encoding="utf-8").strip() + if not forgejo_token: + raise ValueError("Forgejo token file is empty") + publisher = ClassificationPublisher( + Path(args.registry), + cursor_secret=args.cursor_secret, + page_size=args.page_size, + forgejo_base_url=args.forgejo_base_url, + forgejo_token=forgejo_token, + remote_workers=args.remote_workers, + ) + result = publisher.fetch_page() + publisher.close() + except ValueError as exc: + print(json.dumps({"ok": False, "error": str(exc)}, indent=2)) + return 1 + print(json.dumps(result, indent=2)) + return 0 + + if args.command == "owner-interface": + if not args.owner_interface_command: + p_owner_interface.print_help() + return 2 + from repo_manager.owner_interfaces import validate_owner_interfaces + + result = validate_owner_interfaces(Path(args.path), owner=args.owner) + print(json.dumps(result, indent=2)) + return 0 if result.get("ok") else 1 + + parser.print_help() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/__init__.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/__init__.py new file mode 100644 index 00000000..e8dd4af1 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/__init__.py @@ -0,0 +1,3 @@ +from repo_manager.commands.task_status import update_task_status + +__all__ = ["update_task_status"] diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/rapp.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/rapp.py new file mode 100644 index 00000000..8567702a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/rapp.py @@ -0,0 +1,946 @@ +"""Governed scaffolding for rapp-* managed workload packages.""" + +from __future__ import annotations + +import argparse +import re +import shutil +import subprocess +from pathlib import Path +from typing import Any + +PACKAGE_TYPES = ( + "helm-managed-platform-service", + "manifest-managed-platform-service", + "knative-managed-service", + "grouped-composition", +) +RAILS = ("rail-kubernetes", "rail-knative") +CLASSIFICATIONS = ("public", "internal", "confidential", "restricted") +CRITICALITIES = ("low", "medium", "high", "critical") +COMPUTE_REEFS = ("reef-railiance",) +SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +REEF_SLUG = re.compile(r"^reef-[a-z0-9]+(-[a-z0-9]+)*$") +DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") +EXPOSE_RE = re.compile(r"^EXPOSE\s+(\d+)", re.MULTILINE) +USER_RE = re.compile(r"^USER\s+(\d+)", re.MULTILINE) +HEALTH_PATH_RE = re.compile(r'path:\s*["\']?(/[A-Za-z0-9._/-]+)') +IMAGE_PIN_RE = re.compile( + r"(forgejo\.coulomb\.social/coulomb/[a-z0-9-]+@)sha256:[0-9a-f]{64}" +) +UNSET_DIGEST = "sha256:" + ("0" * 64) + +_FAMILY_VALIDATOR = Path.home() / "railiance-master" / "tools" / "validate-family-declarations.py" + + +def _refuse(message: str) -> dict[str, Any]: + return {"ok": False, "error": message} + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content if content.endswith("\n") else content + "\n") + + +def _underscore(app: str) -> str: + return app.replace("-", "_") + + +def init( + path: Path, + *, + app: str, + ownership_repo: str, + rail: str = "rail-kubernetes", + classification: str = "confidential", + criticality: str = "high", + package_type: str = "manifest-managed-platform-service", + purpose: str | None = None, + force: bool = False, +) -> dict[str, Any]: + check = _check_identity(app, ownership_repo, rail, package_type, classification, criticality) + if check: + return check + + rapp_id = f"rapp-{app}" + dest = path.expanduser().resolve() + dest.mkdir(parents=True, exist_ok=True) + declaration = dest / "declarations" / "rapp.yaml" + if declaration.is_file() and not force: + return _refuse(f"{declaration} already exists; refuse to overwrite a live contract") + + purpose_text = purpose or f"Package and operate the {app} service on Railiance." + files = { + ".repo-classification.yaml": ( + "repo_classification:\n" + ' standard: Repo Classification Standard\n' + ' version: "1.0"\n' + " classified_by: repo-manager\n" + " category: project\n" + " domain: infotech\n" + ), + "README.md": f"# {rapp_id}\n\nManaged runtime package for `{app}`.\n", + "INTENT.md": ( + f"# Intent\n\nProvide the managed Railiance runtime package for `{app}`.\n" + ), + "SCOPE.md": ( + "# Scope\n\n## In scope\n\n- packaging, smoke, rollback, and the family declaration\n\n" + "## Out of scope\n\n- application domain ownership\n- credential values\n" + ), + "AGENTS.md": ( + f"# Repository agent guide\n\nWorkplans use the prefix " + f"`{rapp_id.upper()}-WP-`.\n\n" + "Cross-repo waits: attach to a `prj-` driver " + "(`coordination-engine` `spec/cross-owner-wait-mode-v0.1.md`). " + "Do not strand a `wait` or implement residual flavor.\n" + ), + "CLAUDE.md": f"# {rapp_id}\n\n@SCOPE.md\n@AGENTS.md\n", + "Makefile": ( + "SHELL := /bin/bash\n\n.PHONY: check\n\ncheck:\n" + "\tpython3 -m unittest discover -s tests -v\n" + ), + ".gitignore": ".rendered/\n__pycache__/\n", + "declarations/rapp.yaml": _declaration( + rapp_id=rapp_id, + app=app, + ownership_repo=ownership_repo, + rail=rail, + classification=classification, + criticality=criticality, + package_type=package_type, + purpose=purpose_text, + ), + f"workplans/{rapp_id.upper()}-WP-0001-bootstrap.md": ( + f"---\nid: {rapp_id.upper()}-WP-0001\ntype: workplan\n" + f'title: "Bootstrap {rapp_id}"\nstatus: proposed\n' + f"flavor: planning\ndepends_on: []\n---\n\n" + f"# Bootstrap {rapp_id}\n" + ), + } + written = [] + for rel, content in files.items(): + target = dest / rel + if target.is_file() and not force and rel not in {"declarations/rapp.yaml"}: + continue + _write(target, content) + written.append(rel) + (dest / "workplans" / "archived").mkdir(exist_ok=True) + return {"ok": True, "path": str(dest), "rapp_id": rapp_id, "written": written} + + +def _check_identity( + app: str, + ownership_repo: str, + rail: str, + package_type: str, + classification: str, + criticality: str, +) -> dict[str, Any] | None: + if not SLUG.match(app) or app.startswith("rapp-"): + return _refuse("app must be a workload slug without the rapp- prefix") + if rail not in RAILS: + return _refuse(f"unknown rail {rail!r}; allowed: {', '.join(RAILS)}") + if package_type not in PACKAGE_TYPES: + return _refuse(f"unknown package_type {package_type!r}") + if classification not in CLASSIFICATIONS: + return _refuse(f"unknown classification {classification!r}") + if criticality not in CRITICALITIES: + return _refuse(f"unknown criticality {criticality!r}") + if not SLUG.match(ownership_repo) or ownership_repo.startswith("rapp-"): + return _refuse("ownership_repo must be an app or layer slug, not the rapp itself") + return None + + +def _declaration( + *, + rapp_id: str, + app: str, + ownership_repo: str, + rail: str, + classification: str, + criticality: str, + package_type: str, + purpose: str, +) -> str: + return ( + "kind: managed-workload-package\n" + "repo_family: rapp\n" + f"rapp_id: {rapp_id}\n" + f"repo: {rapp_id}\n" + f"ownership_repo: {ownership_repo}\n" + "contract_version: 1.0.0\n" + "readiness_state: draft\n" + "workload_identity:\n" + f" name: {app}\n" + f" package_type: {package_type}\n" + f"data_classification: {classification}\n" + f"criticality: {criticality}\n" + f"primary_rail: {rail}\n" + "supported_rails:\n" + f" - {rail}\n" + "bound_reefs: []\n" + "runtime_dependencies:\n" + " - kubernetes-api\n" + " - openbao-database-secrets-engine\n" + "composition:\n" + f" purpose: {purpose}\n" + " member_repos:\n" + f" - repo: {rapp_id}\n" + " role: managed runtime package\n" + " deployables:\n" + f" - {app}\n" + "rollout_contract:\n" + " default_mode: kubectl-server-side-apply\n" + "smoke_contract:\n" + " required:\n" + " - healthz-ok\n" + "rollback_contract:\n" + " order:\n" + " - previous-immutable-image-digest\n" + " - apply-reviewed-git-revision\n" + ) + + +def inspect_app(from_app: Path, app: str) -> dict[str, Any]: + root = from_app.expanduser().resolve() + container = "" + for name in ("Containerfile", "Dockerfile"): + candidate = root / name + if candidate.is_file(): + container = candidate.read_text() + break + expose = EXPOSE_RE.search(container) + user = USER_RE.search(container) + port = int(expose.group(1)) if expose else 8080 + uid = int(user.group(1)) if user else 10001 + deploy_dir = root / "deploy" + deploy_files = sorted(deploy_dir.glob("*.yaml")) if deploy_dir.is_dir() else [] + health = "/healthz" + ready = "/readyz" + blob = container + for path in deploy_files: + blob += "\n" + path.read_text() + for src in (root / "src").rglob("*.py") if (root / "src").is_dir() else []: + try: + blob += "\n" + src.read_text() + except OSError: + continue + if len(blob) > 400_000: + break + paths = HEALTH_PATH_RE.findall(blob) + if "/health" in paths and "/healthz" not in paths: + health = "/health" + ready = "/health" + if "/healthz" in paths: + health = "/healthz" + if "/readyz" in paths: + ready = "/readyz" + digest = UNSET_DIGEST + pin = IMAGE_PIN_RE.search(blob) + if pin: + digest = "sha256:" + pin.group(0).rsplit("sha256:", 1)[1] + return { + "app": app, + "root": str(root), + "port": port, + "uid": uid, + "health_path": health, + "ready_path": ready, + "image_repository": f"forgejo.coulomb.social/coulomb/{app}", + "image_digest": digest, + "deploy_files": [str(p) for p in deploy_files], + "has_chart": (root / "helm").is_dir() or (root / "charts").is_dir(), + "has_image_workflow": (root / ".forgejo" / "workflows" / "image.yaml").is_file(), + } + + +def skeleton( + path: Path, + *, + from_app: Path, + app: str | None = None, + package_type: str = "manifest-managed-platform-service", + force: bool = False, + dedicated_postgres: bool = False, +) -> dict[str, Any]: + dest = path.expanduser().resolve() + app_name = app or dest.name.removeprefix("rapp-") + if package_type == "helm-managed-platform-service": + return _refuse("Helm skeleton is not generated; pass an existing chart or use manifests") + if package_type not in PACKAGE_TYPES: + return _refuse(f"unknown package_type {package_type!r}") + facts = inspect_app(from_app, app_name) + written: list[str] = [] + notes: list[str] = [] + if facts["deploy_files"]: + manifests = dest / "manifests" + if manifests.exists() and any(manifests.glob("*.yaml")) and not force: + return _refuse(f"{manifests} already has manifests; pass --force to replace") + manifests.mkdir(parents=True, exist_ok=True) + for src in facts["deploy_files"]: + target = manifests / Path(src).name + shutil.copy2(src, target) + written.append(str(target.relative_to(dest))) + notes.append(f"absorbed {len(facts['deploy_files'])} file(s) from {from_app}/deploy") + else: + runtime = dest / "manifests" / "runtime.yaml" + if runtime.is_file() and not force: + return _refuse(f"{runtime} already exists; pass --force to replace") + _write(runtime, _runtime_manifest(app_name, facts)) + written.append("manifests/runtime.yaml") + notes.append("generated Deployment/Service/ServiceAccount/NetworkPolicy") + + if dedicated_postgres: + notes.append("dedicated Cluster not generated; pass reviewed CNPG YAML by hand") + + makefile = dest / "Makefile" + if not makefile.is_file() or force or makefile.read_text().count("\n") < 12: + _write(makefile, _makefile(app_name, facts, dest.name)) + written.append("Makefile") + render = dest / "tools" / "render.py" + if not render.is_file() or force: + _write(render, _render_py(app_name)) + render.chmod(0o755) + written.append("tools/render.py") + verify = dest / "tools" / "verify_live.sh" + if not verify.is_file() or force: + _write(verify, _verify_sh(app_name, facts)) + verify.chmod(0o755) + written.append("tools/verify_live.sh") + test = dest / "tests" / "test_packaging.py" + if not test.is_file() or force: + _write(test, _packaging_test(app_name)) + written.append("tests/test_packaging.py") + return {"ok": True, "path": str(dest), "facts": facts, "written": written, "notes": notes} + + +def ensure_image_workflow(from_app: Path, app: str) -> dict[str, Any]: + root = from_app.expanduser().resolve() + if not root.is_dir(): + return _refuse(f"app checkout missing: {root}") + target = root / ".forgejo" / "workflows" / "image.yaml" + if target.is_file(): + return {"ok": True, "path": str(target), "written": False, "note": "already present"} + _write(target, _image_workflow(app)) + return { + "ok": True, + "path": str(target), + "written": True, + "note": "first deploy waits on the CI digest; no workstation build", + } + + +def draft_postgres_consumer( + path: Path, + *, + app: str, + family_root: Path | None = None, +) -> dict[str, Any]: + dest = path.expanduser().resolve() + app_name = app or dest.name.removeprefix("rapp-") + slug = _underscore(app_name) + draft = ( + "apiVersion: rapp-postgres.railiance.io/v1alpha1\n" + "kind: PostgresConsumer\n" + "metadata:\n" + f" name: {app_name}\n" + "spec:\n" + f" database: {slug}\n" + f" schema: {slug}\n" + f" costAttributionKey: platform:{app_name}\n" + f" clientNamespaces: [{app_name}]\n" + " roles:\n" + f" owner: {slug}_owner\n" + f" migration: {slug}_migrate\n" + f" runtime: {slug}_app\n" + " tenantKeyingRequired: true\n" + " # Draft only. Do not apply from this package. Review in rapp-postgres.\n" + ) + handoff = dest / "handoffs" / "postgres-consumer.yaml" + search = (family_root or dest.parent).resolve() + live = search / "rapp-postgres" / "consumers" / f"{app_name}.yaml" + notes = [] + if live.is_file(): + _write( + dest / "handoffs" / "README.md", + f"# Handoffs\n\nPostgres consumer already lives at `{live}`.\n" + "This package does not apply it.\n", + ) + notes.append(f"existing consumer left in place: {live}") + return {"ok": True, "path": str(live), "written": False, "notes": notes} + _write(handoff, draft) + notes.append(str(handoff)) + if (search / "rapp-postgres" / "consumers").is_dir(): + target = search / "rapp-postgres" / "consumers" / f"{app_name}.yaml" + if not target.is_file(): + _write(target, draft) + notes.append(str(target)) + return {"ok": True, "path": str(handoff), "written": True, "notes": notes} + + +def wrap( + path: Path, + *, + app: str, + ownership_repo: str, + from_app: Path, + rail: str = "rail-kubernetes", + classification: str = "confidential", + criticality: str = "high", + package_type: str = "manifest-managed-platform-service", + purpose: str | None = None, + force: bool = False, + dedicated_postgres: bool = False, + family_root: Path | None = None, +) -> dict[str, Any]: + dest = path.expanduser().resolve() + created = init( + dest, + app=app, + ownership_repo=ownership_repo, + rail=rail, + classification=classification, + criticality=criticality, + package_type=package_type, + purpose=purpose, + force=force, + ) + if not created.get("ok"): + return created + skel = skeleton( + dest, + from_app=from_app, + app=app, + package_type=package_type, + force=force, + dedicated_postgres=dedicated_postgres, + ) + if not skel.get("ok"): + return skel + image = ensure_image_workflow(from_app, app) + if not image.get("ok"): + return image + consumer = {} if dedicated_postgres else draft_postgres_consumer( + dest, app=app, family_root=family_root or dest.parent + ) + if consumer and not consumer.get("ok"): + return consumer + checked = validate(dest, family_root=family_root or dest.parent) + return { + "ok": True, + "path": str(dest), + "init": created, + "skeleton": skel, + "image_workflow": image, + "postgres_consumer": consumer, + "validate": checked, + "placed": False, + "applied": False, + } + + +def place(path: Path, *, reef: str, family_root: Path | None = None) -> dict[str, Any]: + if not REEF_SLUG.match(reef): + return _refuse(f"unknown reef {reef!r}") + if reef == "reef-storage": + return _refuse("reef-storage hosts no rail; it is a consumed capability") + dest = path.expanduser().resolve() + declaration = dest / "declarations" / "rapp.yaml" + if not declaration.is_file(): + return _refuse(f"missing {declaration}") + search = (family_root or dest.parent).resolve() + if reef not in COMPUTE_REEFS and not (search / reef).is_dir(): + return _refuse(f"reef {reef!r} is not a known compute reef") + text = declaration.read_text() + if re.search(r"^bound_reefs:\n - ", text, re.MULTILINE): + text = re.sub( + r"^bound_reefs:\n(?: - .+\n)+", + f"bound_reefs:\n - {reef}\n", + text, + flags=re.MULTILINE, + ) + else: + text = re.sub( + r"^bound_reefs:\s*\[\]\s*$", + f"bound_reefs:\n - {reef}", + text, + flags=re.MULTILINE, + ) + if "exposure:" in text and "posture: public" in text: + return _refuse("place does not grant public exposure; edit exposure separately") + declaration.write_text(text) + return {"ok": True, "path": str(dest), "bound_reefs": [reef]} + + +def validate(path: Path, *, family_root: Path | None = None) -> dict[str, Any]: + dest = path.expanduser().resolve() + declaration = dest / "declarations" / "rapp.yaml" + if not declaration.is_file(): + return _refuse(f"missing {declaration}") + missing = [ + name + for name in ( + "README.md", + "INTENT.md", + "SCOPE.md", + "AGENTS.md", + ".repo-classification.yaml", + ) + if not (dest / name).is_file() + ] + if missing: + return _refuse(f"missing bootstrap files: {', '.join(missing)}") + + if _FAMILY_VALIDATOR.is_file(): + import tempfile + + search_root = (family_root or dest.parent).resolve() + with tempfile.TemporaryDirectory(prefix="rmgr-rapp-") as tmp: + tmp_root = Path(tmp) + (tmp_root / dest.name).symlink_to(dest) + for sibling in ("rail-kubernetes", "rail-knative", "reef-railiance"): + candidate = search_root / sibling + if candidate.is_dir(): + (tmp_root / sibling).symlink_to(candidate) + proc = subprocess.run( + ["python3", str(_FAMILY_VALIDATOR), "--root", str(tmp_root)], + check=False, + capture_output=True, + text=True, + ) + return { + "ok": proc.returncode == 0, + "path": str(dest), + "validator": str(_FAMILY_VALIDATOR), + "stdout": proc.stdout.strip(), + "stderr": proc.stderr.strip(), + "exit_code": proc.returncode, + } + + text = declaration.read_text() + required = ( + "kind: managed-workload-package", + "repo_family: rapp", + "ownership_repo:", + "primary_rail:", + "composition:", + ) + absent = [item for item in required if item not in text] + if absent: + return _refuse(f"declaration missing {absent}") + return {"ok": True, "path": str(dest), "validator": None} + + +def pin_image(path: Path, digest: str) -> dict[str, Any]: + if not DIGEST.fullmatch(digest): + return _refuse("digest must be sha256:<64 lowercase hex>") + dest = path.expanduser().resolve() + rewritten = 0 + for rel in ("manifests", "bindings", "declarations"): + root = dest / rel + if not root.exists(): + continue + files = [root] if root.is_file() else list(root.rglob("*")) + for file in files: + if not file.is_file(): + continue + text = file.read_text() + updated, n = IMAGE_PIN_RE.subn(rf"\g<1>{digest}", text) + if n: + file.write_text(updated) + rewritten += n + if rewritten == 0: + return _refuse("no forgejo.coulomb.social digest pin found") + makefile = dest / "Makefile" + if makefile.is_file(): + makefile.write_text( + re.sub(r"sha256:[0-9a-f]{64}", digest, makefile.read_text(), count=1) + ) + return {"ok": True, "path": str(dest), "digest": digest, "rewritten": rewritten} + + +def _runtime_manifest(app: str, facts: dict[str, Any]) -> str: + port = facts["port"] + uid = facts["uid"] + health = facts["health_path"] + ready = facts["ready_path"] + image = f"{facts['image_repository']}@{facts['image_digest']}" + return f"""apiVersion: v1 +kind: Namespace +metadata: + name: {app} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {app} + namespace: {app} + labels: + app.kubernetes.io/name: {app} +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: {app} + template: + metadata: + labels: + app.kubernetes.io/name: {app} + spec: + automountServiceAccountToken: false + serviceAccountName: {app} + securityContext: + runAsNonRoot: true + runAsUser: {uid} + seccompProfile: + type: RuntimeDefault + containers: + - name: {app} + image: {image} + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: {port} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + readOnlyRootFilesystem: true + readinessProbe: + httpGet: + path: {ready} + port: http + livenessProbe: + httpGet: + path: {health} + port: http +--- +apiVersion: v1 +kind: Service +metadata: + name: {app} + namespace: {app} +spec: + selector: + app.kubernetes.io/name: {app} + ports: + - name: http + port: {port} + targetPort: http +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {app} + namespace: {app} +automountServiceAccountToken: false +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {app}-default-deny + namespace: {app} +spec: + podSelector: {{}} + policyTypes: [Ingress, Egress] +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {app}-runtime + namespace: {app} +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: {app} + policyTypes: [Ingress, Egress] + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - {{protocol: TCP, port: {port}}} + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - {{protocol: UDP, port: 53}} + - {{protocol: TCP, port: 53}} +""" + + +def _makefile(app: str, facts: dict[str, Any], field_manager: str) -> str: + digest = facts["image_digest"] + repo = facts["image_repository"] + return f"""SHELL := /bin/bash + +TARGET ?= railiance01 +NAMESPACE := {app} +DEPLOYMENT := {app} +IMAGE_REPOSITORY := {repo} +IMAGE_DIGEST ?= {digest} +DIGEST ?= +RENDERED := .rendered +MANIFESTS := $(wildcard manifests/*.yaml) + +.PHONY: check test render validate-inputs server-dry-run deploy status verify-live rollback + +check: test + +test: + python3 -m unittest discover -s tests -v + +validate-inputs: + @echo "$(IMAGE_DIGEST)" | grep -Eq '^sha256:[0-9a-f]{{64}}$$' \\ + || (echo "IMAGE_DIGEST must be sha256:<64 hex>" >&2; exit 2) + +render: validate-inputs + @rm -rf $(RENDERED) + @mkdir -p $(RENDERED) + IMAGE_REPOSITORY=$(IMAGE_REPOSITORY) IMAGE_DIGEST=$(IMAGE_DIGEST) \\ + python3 tools/render.py $(MANIFESTS) --out $(RENDERED) + +server-dry-run: render + {{ for f in $(RENDERED)/*.yaml; do echo '---'; cat "$$f"; done; }} \\ + | ssh -o BatchMode=yes $(TARGET) \\ + kubectl apply --server-side --force-conflicts --dry-run=server -f - + +deploy: render + {{ for f in $(RENDERED)/*.yaml; do echo '---'; cat "$$f"; done; }} \\ + | ssh -o BatchMode=yes $(TARGET) \\ + kubectl apply --server-side --force-conflicts --field-manager={field_manager} -f - + ssh -o BatchMode=yes $(TARGET) \\ + kubectl -n $(NAMESPACE) rollout status deployment/$(DEPLOYMENT) --timeout=180s + +status: + ssh -o BatchMode=yes $(TARGET) kubectl -n $(NAMESPACE) get deploy,pods,svc + +verify-live: validate-inputs + EXPECTED_IMAGE="$(IMAGE_REPOSITORY)@$(IMAGE_DIGEST)" \\ + TARGET=$(TARGET) NAMESPACE=$(NAMESPACE) ./tools/verify_live.sh + +rollback: + @test -n "$(DIGEST)" || (echo "DIGEST=sha256:<64 hex> is required" >&2; exit 2) + $(MAKE) deploy IMAGE_DIGEST=$(DIGEST) +""" + + +def _render_py(app: str) -> str: + return f'''#!/usr/bin/env python3 +"""Rewrite digest pins into rendered manifests.""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +from pathlib import Path + +IMAGE_LINE = re.compile( + r"(image:\\s+)(forgejo\\.coulomb\\.social/coulomb/{app})(@sha256:[0-9a-f]{{64}})?" +) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("manifests", nargs="+", type=Path) + parser.add_argument("--out", required=True, type=Path) + args = parser.parse_args() + repo = os.environ.get("IMAGE_REPOSITORY", "forgejo.coulomb.social/coulomb/{app}") + digest = os.environ.get("IMAGE_DIGEST", "") + if not re.fullmatch(r"sha256:[0-9a-f]{{64}}", digest): + print("IMAGE_DIGEST must be sha256:<64 lowercase hex>", file=sys.stderr) + return 2 + replacement = rf"\\1{{repo}}@{{digest}}" + args.out.mkdir(parents=True, exist_ok=True) + for src in args.manifests: + (args.out / src.name).write_text(IMAGE_LINE.sub(replacement, src.read_text())) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +''' + + +def _verify_sh(app: str, facts: dict[str, Any]) -> str: + port = facts["port"] + health = facts["health_path"] + ready = facts["ready_path"] + return f"""#!/usr/bin/env bash +set -euo pipefail + +TARGET="${{TARGET:-railiance01}}" +NAMESPACE="${{NAMESPACE:-{app}}}" +DEPLOYMENT="${{DEPLOYMENT:-{app}}}" +EXPECTED_IMAGE="${{EXPECTED_IMAGE:?EXPECTED_IMAGE is required}}" + +remote() {{ + ssh -o BatchMode=yes "$TARGET" "$1" +}} + +live_image="$(remote "kubectl -n ${{NAMESPACE}} get deploy ${{DEPLOYMENT}} -o jsonpath='{{.spec.template.spec.containers[0].image}}'")" +if [[ "$live_image" != "$EXPECTED_IMAGE" ]]; then + echo "digest mismatch live=$live_image expected=$EXPECTED_IMAGE" >&2 + exit 1 +fi +remote "kubectl -n ${{NAMESPACE}} rollout status deployment/${{DEPLOYMENT}} --timeout=60s >/dev/null" +health="$(remote "kubectl -n ${{NAMESPACE}} exec deploy/${{DEPLOYMENT}} -- python3 -c \\"import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:{port}{health}').status)\\"")" +ready="$(remote "kubectl -n ${{NAMESPACE}} exec deploy/${{DEPLOYMENT}} -- python3 -c \\"import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:{port}{ready}').status)\\"")" +[[ "$health" == "200" ]] +[[ "$ready" == "200" ]] +python3 - "$live_image" "$health" "$ready" <<'PY' +import json, sys +print(json.dumps({{ + "health_ok": sys.argv[2] == "200", + "ready_ok": sys.argv[3] == "200", + "live_image": sys.argv[1], + "secret_values_observed": False, +}}, sort_keys=True)) +PY +""" + + +def _packaging_test(app: str) -> str: + return f'''import pathlib +import re +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +class PackagingTests(unittest.TestCase): + def test_declaration_names_the_workload(self): + decl = (ROOT / "declarations" / "rapp.yaml").read_text() + self.assertIn("ownership_repo:", decl) + self.assertIn("name: {app}", decl.split("workload_identity:", 1)[1][:200]) + self.assertNotIn("posture: public", decl) + + def test_package_has_no_floating_tag(self): + texts = [] + for folder in ("manifests", "declarations"): + root = ROOT / folder + if not root.exists(): + continue + for path in root.rglob("*"): + if path.is_file(): + texts.append(path.read_text()) + blob = "\\n".join(texts) + self.assertNotRegex(blob, re.compile(r"image:\\s+[^\\n]+:(latest|main)\\b")) + self.assertNotIn("sk-", blob) +''' + + +def _image_workflow(app: str) -> str: + return f"""name: Build and Publish Container Image + +# Images are built by CI from a tarball of the pushed commit, never from +# a workstation working tree. + +on: + push: + branches: + - main + paths: + - ".forgejo/workflows/image.yaml" + - "Containerfile" + - "src/**" + - "pyproject.toml" + - "README.md" + - "LICENSE" + workflow_dispatch: + +env: + REGISTRY: forgejo.coulomb.social + IMAGE_NAME: coulomb/{app} + DOCKER_HOST: tcp://127.0.0.1:2375 + +jobs: + build-and-push: + runs-on: container-build + steps: + - name: Build and push image + env: + REGISTRY_USER: ${{{{ secrets.REGISTRY_USER }}}} + REGISTRY_TOKEN: ${{{{ secrets.REGISTRY_TOKEN }}}} + run: | + set -eu + REF="${{GITHUB_SHA:-main}}" + SHORT="${{REF:0:7}}" + mkdir -p buildctx "${{HOME}}/bin" + wget -qO /tmp/repo.tar.gz \\ + "https://forgejo.coulomb.social/${{GITHUB_REPOSITORY}}/archive/${{SHORT}}.tar.gz" + tar xzf /tmp/repo.tar.gz -C buildctx --strip-components=1 + wget -qO- https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz \\ + | tar xz --strip-components=1 -C "${{HOME}}/bin" docker/docker + export PATH="${{HOME}}/bin:${{PATH}}" + echo "${{REGISTRY_TOKEN}}" | docker login "${{REGISTRY}}" -u "${{REGISTRY_USER}}" --password-stdin + IMAGE="${{REGISTRY}}/${{IMAGE_NAME}}" + docker build -f buildctx/Containerfile -t "${{IMAGE}}:latest" -t "${{IMAGE}}:main-${{SHORT}}" buildctx + docker push "${{IMAGE}}:latest" + docker push "${{IMAGE}}:main-${{SHORT}}" + echo "pushed ${{IMAGE}}:latest and ${{IMAGE}}:main-${{SHORT}}" + + - name: Report immutable digest + run: | + set -eu + export PATH="${{HOME}}/bin:${{PATH}}" + IMAGE="${{REGISTRY}}/${{IMAGE_NAME}}" + SHORT="${{GITHUB_SHA:0:7}}" + docker inspect --format='{{{{index .RepoDigests 0}}}}' "${{IMAGE}}:main-${{SHORT}}" +""" + + +def add_rapp_parser(sub: argparse._SubParsersAction) -> None: + rapp = sub.add_parser("rapp", help="Scaffold or validate a rapp-* package") + rapp_sub = rapp.add_subparsers(dest="rapp_command", required=True) + + p_init = rapp_sub.add_parser("init", help="Create a draft rapp-* baseline") + p_init.add_argument("--path", required=True, help="Destination checkout") + p_init.add_argument("--app", required=True, help="Workload name (no rapp- prefix)") + p_init.add_argument("--ownership-repo", required=True) + p_init.add_argument("--rail", default="rail-kubernetes", choices=RAILS) + p_init.add_argument("--classification", default="confidential", choices=CLASSIFICATIONS) + p_init.add_argument("--criticality", default="high", choices=CRITICALITIES) + p_init.add_argument("--package-type", default="manifest-managed-platform-service", choices=PACKAGE_TYPES) + p_init.add_argument("--purpose", default=None) + p_init.add_argument("--force", action="store_true") + + p_skel = rapp_sub.add_parser("skeleton", help="Generate or absorb runtime manifests") + p_skel.add_argument("--path", required=True) + p_skel.add_argument("--from-app", required=True) + p_skel.add_argument("--app", default=None) + p_skel.add_argument("--package-type", default="manifest-managed-platform-service", choices=PACKAGE_TYPES) + p_skel.add_argument("--force", action="store_true") + p_skel.add_argument("--dedicated-postgres", action="store_true") + + p_wrap = rapp_sub.add_parser("wrap", help="init + skeleton + image + consumer + validate") + p_wrap.add_argument("--path", required=True) + p_wrap.add_argument("--app", required=True) + p_wrap.add_argument("--ownership-repo", required=True) + p_wrap.add_argument("--from-app", required=True) + p_wrap.add_argument("--rail", default="rail-kubernetes", choices=RAILS) + p_wrap.add_argument("--classification", default="confidential", choices=CLASSIFICATIONS) + p_wrap.add_argument("--criticality", default="high", choices=CRITICALITIES) + p_wrap.add_argument("--package-type", default="manifest-managed-platform-service", choices=PACKAGE_TYPES) + p_wrap.add_argument("--purpose", default=None) + p_wrap.add_argument("--family-root", default=None) + p_wrap.add_argument("--force", action="store_true") + p_wrap.add_argument("--dedicated-postgres", action="store_true") + + p_place = rapp_sub.add_parser("place", help="Set bound_reefs only") + p_place.add_argument("--path", required=True) + p_place.add_argument("--reef", default="reef-railiance") + p_place.add_argument("--family-root", default=None) + + p_val = rapp_sub.add_parser("validate", help="Validate a rapp-* checkout") + p_val.add_argument("--path", required=True) + p_val.add_argument("--family-root", default=None) + + p_pin = rapp_sub.add_parser("pin-image", help="Rewrite digest pins") + p_pin.add_argument("--path", required=True) + p_pin.add_argument("--digest", required=True) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/record.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/record.py new file mode 100644 index 00000000..7d4b1dfd --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/record.py @@ -0,0 +1,313 @@ +"""Governed mutation commands for repository-owned intake and decision records.""" + +from __future__ import annotations + +import re +import uuid +from pathlib import Path +from typing import Any + +import yaml + +from repo_manager import dual_run, idempotency +from repo_manager.commands.workplan import CommandResult +from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff +from repo_manager.index_store import append_event, default_index_path, save_index +from repo_manager.observe import observe_repository +from repo_manager.parse.record import iter_record_files, parse_record_file +from repo_manager.time import utc_now_text + +_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$") +_PROTECTED = frozenset( + { + "id", + "kind", + "title", + "status", + "notes", + "created", + "updated", + "routed_at", + "closed_at", + "decided_at", + } +) +_OPERATIONS = { + "intake": frozenset({"create", "route", "note", "close"}), + "decision": frozenset({"create", "update", "resolve"}), +} + + +def _now() -> str: + return utc_now_text() + + +def _reject(command: str, correlation_id: str, code: str, message: str, **evidence: Any) -> CommandResult: + return CommandResult( + command=command, + status="rejected", + correlation_id=correlation_id, + evidence={"status": "rejected", **evidence}, + error={"code": code, "message": message, "correlation_id": correlation_id}, + ) + + +def _command(kind: str, operation: str) -> str: + verbs = { + ("intake", "create"): "repo.work.create_intake", + ("intake", "route"): "repo.work.route_intake", + ("intake", "note"): "repo.work.add_intake_note", + ("intake", "close"): "repo.work.close_intake", + ("decision", "create"): "repo.work.create_decision", + ("decision", "update"): "repo.work.update_decision", + ("decision", "resolve"): "repo.work.resolve_decision", + } + return verbs.get((kind, operation), f"repo.work.{operation}_{kind}") + + +def _default_path(repo_root: Path, kind: str) -> Path: + return repo_root / f"{kind}s" / f"{kind}s.md" + + +def _find(repo_root: Path, kind: str, record_id: str) -> list[tuple[Path, Any]]: + found: list[tuple[Path, Any]] = [] + for path in iter_record_files(repo_root): + for record in parse_record_file(path, repo_root=repo_root): + if record.kind == kind and (record.id == record_id or record.uuid == record_id): + found.append((path, record)) + return found + + +def _dump_block(record: dict[str, Any]) -> str: + return "```yaml\n" + yaml.safe_dump(record, sort_keys=False, allow_unicode=True).rstrip() + "\n```" + + +def _write_new(path: Path, kind: str, record: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + previous = path.read_text(encoding="utf-8") if path.is_file() else f"# {kind.title()} records\n" + text = previous.rstrip() + f"\n\n## {record['id']} — {record['title']}\n\n" + _dump_block(record) + "\n" + path.write_text(text, encoding="utf-8") + + +def _replace(path: Path, record: Any, data: dict[str, Any]) -> None: + text = path.read_text(encoding="utf-8") + updated = text[: record.block_start] + _dump_block(data) + text[record.block_end :] + path.write_text(updated, encoding="utf-8") + + +def mutate_record( + repo_root: Path, + kind: str, + record_id: str, + *, + operation: str, + title: str | None = None, + status: str | None = None, + data: dict[str, Any] | None = None, + route_to: str | None = None, + note: str | None = None, + author: str | None = None, + outcome: str | None = None, + rationale: str | None = None, + decided_by: str | None = None, + correlation_id: str | None = None, + reason: str = "rmgr command", + commit: bool = True, + push: bool = False, + expected_head_sha: str | None = None, + idempotency_key: str | None = None, + repo_slug: str | None = None, +) -> CommandResult: + """Mutate one intake or decision YAML block in its authoritative Markdown file.""" + correlation_id = correlation_id or str(uuid.uuid4()) + command = _command(kind, operation) + repo_root = repo_root.resolve() + payload = { + "repo_root": str(repo_root), + "kind": kind, + "record_id": record_id, + "operation": operation, + "title": title, + "status": status, + "data": data, + "route_to": route_to, + "note": note, + "author": author, + "outcome": outcome, + "rationale": rationale, + "decided_by": decided_by, + "expected_head_sha": expected_head_sha, + } + digest = idempotency.payload_hash(payload) + if idempotency_key: + prior = idempotency.get(idempotency_key) + if prior: + if prior.get("payload_hash") != digest: + return _reject(command, correlation_id, "conflict", "idempotency key reused") + previous = prior.get("result") or {} + return CommandResult( + command=previous.get("command", command), + status=previous.get("status", "applied"), + correlation_id=previous.get("correlation_id", correlation_id), + evidence=previous.get("evidence") or {"status": "applied", "replay": True}, + error=previous.get("error"), + ) + + if kind not in _OPERATIONS or operation not in _OPERATIONS[kind]: + return _reject(command, correlation_id, "validation_error", f"invalid {kind!r} operation {operation!r}") + if not _ID_RE.fullmatch(record_id): + return _reject(command, correlation_id, "validation_error", f"invalid record id {record_id!r}") + if data is not None and not isinstance(data, dict): + return _reject(command, correlation_id, "validation_error", "data must be an object") + protected = _PROTECTED.intersection(data or {}) + if protected: + return _reject( + command, + correlation_id, + "validation_error", + f"data contains protected fields: {', '.join(sorted(protected))}", + ) + + current_head = head_sha(repo_root) + if expected_head_sha and current_head and current_head != expected_head_sha: + result = _reject( + command, + correlation_id, + "precondition_failed", + f"expected_head_sha {expected_head_sha} != {current_head}", + head_sha=current_head, + ) + if result.error: + result.error["retryable"] = True + return result + + matches = _find(repo_root, kind, record_id) + if len(matches) > 1: + return _reject(command, correlation_id, "conflict", f"record {record_id!r} is duplicated") + match = matches[0] if matches else None + now = _now() + + if operation == "create": + if match: + return _reject(command, correlation_id, "conflict", f"record {record_id!r} exists") + if not title or not title.strip(): + return _reject(command, correlation_id, "validation_error", "create requires title") + record_data: dict[str, Any] = { + "id": record_id, + "kind": kind, + "title": title.strip(), + "status": status or "open", + **(data or {}), + "created": now, + "updated": now, + } + path = _default_path(repo_root, kind) + _write_new(path, kind, record_data) + else: + if not match: + return _reject(command, correlation_id, "not_found", f"record {record_id!r} not found") + path, parsed = match + record_data = dict(parsed.raw) + if kind == "intake" and operation == "route": + if not route_to or not route_to.strip(): + return _reject(command, correlation_id, "validation_error", "route requires route_to") + record_data.update(status="routed", routed_to=route_to.strip(), routed_at=now) + elif kind == "intake" and operation == "note": + if not note or not note.strip(): + return _reject(command, correlation_id, "validation_error", "note requires content") + record_data.setdefault("notes", []).append( + {"content": note.strip(), "author": author or "codex", "created": now} + ) + elif kind == "intake" and operation == "close": + record_data.update(status="closed", closed_at=now) + if outcome: + record_data["outcome"] = outcome.strip() + elif kind == "decision" and operation == "update": + if title is None and status is None and not data: + return _reject(command, correlation_id, "validation_error", "update requires a field") + if title is not None: + if not title.strip(): + return _reject(command, correlation_id, "validation_error", "title cannot be empty") + record_data["title"] = title.strip() + if status is not None: + if not status.strip(): + return _reject(command, correlation_id, "validation_error", "status cannot be empty") + record_data["status"] = status.strip() + record_data.update(data or {}) + else: + if not rationale or not rationale.strip() or not decided_by or not decided_by.strip(): + return _reject( + command, + correlation_id, + "validation_error", + "resolve requires rationale and decided_by", + ) + record_data.update( + status="resolved", + rationale=rationale.strip(), + decided_by=decided_by.strip(), + decided_at=now, + ) + record_data["updated"] = now + _replace(path, parsed, record_data) + + relative_path = str(path.relative_to(repo_root)) + git_sha: str | None = None + push_ok: bool | None = None + push_message: str | None = None + if commit: + try: + git_sha = commit_paths( + repo_root, + [relative_path], + message=( + f"{command} {record_id}\n\n" + f"correlation_id: {correlation_id}\nreason: {reason}\nsource: repo-manager\n" + ), + ) + except GitError as exc: + return CommandResult( + command=command, + status="failed", + correlation_id=correlation_id, + evidence={"status": "failed", "files_touched": [relative_path], "git_sha": None}, + error={"code": "internal", "message": str(exc), "correlation_id": correlation_id}, + ) + if push: + push_ok, push_message = push_ff(repo_root) + + snapshot, index = observe_repository(repo_root, slug=repo_slug) + append_event( + index, + { + "type": "repo.command.applied", + "command": command, + "operation": operation, + "correlation_id": correlation_id, + "kind": kind, + "id": record_id, + "git_sha": git_sha, + "files_touched": [relative_path], + "source": "repo-manager", + }, + ) + save_index(index) + dual_run.record_mutation( + source="repo-manager", + kind=f"{kind}_{operation}", + repo_slug=repo_slug or snapshot.get("slug"), + detail={"id": record_id, "git_sha": git_sha, "correlation_id": correlation_id}, + ) + evidence: dict[str, Any] = { + "status": "applied", + "git_sha": git_sha, + "files_touched": [relative_path], + "index_path": str(default_index_path(repo_root)), + "source": "repo-manager", + } + if push: + evidence.update(push_ok=push_ok, push_message=push_message) + result = CommandResult(command=command, status="applied", correlation_id=correlation_id, evidence=evidence) + if idempotency_key: + idempotency.put(idempotency_key, digest, result.to_dict()) + return result diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/register.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/register.py new file mode 100644 index 00000000..b49b7f26 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/register.py @@ -0,0 +1,311 @@ +"""Governed mutation spine for repository-owned registers.""" + +from __future__ import annotations + +import re +import uuid +from pathlib import Path +from typing import Any + +import yaml + +from repo_manager import dual_run, idempotency +from repo_manager.commands.workplan import CommandResult +from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff +from repo_manager.index_store import append_event, default_index_path, save_index +from repo_manager.observe import observe_repository +from repo_manager.parse.register import REGISTER_SCHEMA, SUPPORTED_REGISTER_KINDS, register_path +from repo_manager.time import utc_now_text + +_ENTRY_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$") +_PROTECTED_FIELDS = frozenset({"id", "title", "status", "notes", "created", "updated"}) +_REQUIRED_DATA = { + "sbom-inventory": frozenset({"package_name", "ecosystem"}), + "repo-goals": frozenset({"description"}), + "upstream-contributions": frozenset({"type"}), + "technical-debt": frozenset(), + "extension-points": frozenset(), + "register-entries": frozenset({"register_kind"}), +} + + +def _now() -> str: + return utc_now_text() + + +def _reject(command: str, correlation_id: str, code: str, message: str, **evidence: Any) -> CommandResult: + return CommandResult( + command=command, + status="rejected", + correlation_id=correlation_id, + evidence={"status": "rejected", **evidence}, + error={"code": code, "message": message, "correlation_id": correlation_id}, + ) + + +def _load(path: Path, kind: str) -> dict[str, Any]: + if not path.is_file(): + return {"schema": REGISTER_SCHEMA, "kind": kind, "entries": []} + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as exc: + raise ValueError(f"invalid register YAML: {exc}") from exc + if not isinstance(data, dict) or not isinstance(data.get("entries", []), list): + raise TypeError("register must be a mapping with an entries list") + if data.get("kind", kind) != kind: + raise ValueError(f"register kind does not match filename {kind!r}") + data.setdefault("schema", REGISTER_SCHEMA) + data.setdefault("kind", kind) + data.setdefault("entries", []) + return data + + +def _save(path: Path, document: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + yaml.safe_dump(document, sort_keys=False, allow_unicode=True), + encoding="utf-8", + ) + + +def mutate_register_entry( + repo_root: Path, + kind: str, + entry_id: str, + *, + operation: str, + title: str | None = None, + status: str | None = None, + data: dict[str, Any] | None = None, + note: str | None = None, + note_author: str | None = None, + correlation_id: str | None = None, + reason: str = "rmgr command", + commit: bool = True, + push: bool = False, + expected_head_sha: str | None = None, + idempotency_key: str | None = None, + repo_slug: str | None = None, +) -> CommandResult: + """Upsert, defer, or annotate an entry in one of the shared register kinds.""" + command_map = { + "upsert": "repo.register.upsert_entry", + "defer": "repo.register.defer_entry", + "note": "repo.register.add_note", + } + correlation_id = correlation_id or str(uuid.uuid4()) + command = command_map.get(operation, "repo.register.upsert_entry") + repo_root = repo_root.resolve() + payload = { + "repo_root": str(repo_root), + "kind": kind, + "entry_id": entry_id, + "operation": operation, + "title": title, + "status": status, + "data": data, + "note": note, + "note_author": note_author, + "expected_head_sha": expected_head_sha, + } + digest = idempotency.payload_hash(payload) + if idempotency_key: + prior = idempotency.get(idempotency_key) + if prior: + if prior.get("payload_hash") != digest: + return _reject(command, correlation_id, "conflict", "idempotency key reused") + previous = prior.get("result") or {} + return CommandResult( + command=previous.get("command", command), + status=previous.get("status", "applied"), + correlation_id=previous.get("correlation_id", correlation_id), + evidence=previous.get("evidence") or {"status": "applied", "replay": True}, + error=previous.get("error"), + ) + + if operation not in command_map: + return _reject(command, correlation_id, "validation_error", f"invalid operation {operation!r}") + if kind not in SUPPORTED_REGISTER_KINDS: + return _reject(command, correlation_id, "validation_error", f"unsupported register {kind!r}") + if not _ENTRY_ID_RE.fullmatch(entry_id): + return _reject(command, correlation_id, "validation_error", f"invalid entry id {entry_id!r}") + if status is not None and not status.strip(): + return _reject(command, correlation_id, "validation_error", "status cannot be empty") + if data is not None and not isinstance(data, dict): + return _reject(command, correlation_id, "validation_error", "data must be an object") + protected = _PROTECTED_FIELDS.intersection(data or {}) + if protected: + return _reject( + command, + correlation_id, + "validation_error", + f"data contains protected fields: {', '.join(sorted(protected))}", + ) + + current_head = head_sha(repo_root) + if expected_head_sha and current_head and expected_head_sha != current_head: + result = _reject( + command, + correlation_id, + "precondition_failed", + f"expected_head_sha {expected_head_sha} != {current_head}", + head_sha=current_head, + ) + if result.error: + result.error["retryable"] = True + return result + + snapshot, _index = observe_repository(repo_root, slug=repo_slug) + slug = repo_slug or snapshot.get("slug") + path = register_path(repo_root, kind) + try: + document = _load(path, kind) + except (TypeError, ValueError) as exc: + return _reject(command, correlation_id, "conflict", str(exc)) + entries = document["entries"] + matches = [item for item in entries if isinstance(item, dict) and str(item.get("id")) == entry_id] + if len(matches) > 1: + return _reject(command, correlation_id, "conflict", f"duplicate entry id {entry_id!r}") + entry = matches[0] if matches else None + before = yaml.safe_dump(entry, sort_keys=True) if entry is not None else None + now = _now() + + if operation == "upsert": + if entry is None: + if not title or not title.strip(): + return _reject(command, correlation_id, "validation_error", "new entry requires title") + missing = sorted(key for key in _REQUIRED_DATA[kind] if key not in (data or {})) + if missing: + return _reject( + command, + correlation_id, + "validation_error", + f"{kind} entry missing data fields: {', '.join(missing)}", + ) + entry = { + "id": entry_id, + "title": title.strip(), + "status": (status or "open").strip(), + **(data or {}), + "created": now, + "updated": now, + } + entries.append(entry) + else: + if title is not None: + if not title.strip(): + return _reject(command, correlation_id, "validation_error", "title cannot be empty") + entry["title"] = title.strip() + if status is not None: + entry["status"] = status.strip() + entry.update(data or {}) + entry["updated"] = now + elif operation == "defer": + if entry is None: + return _reject(command, correlation_id, "not_found", f"entry {entry_id!r} not found") + entry["status"] = (status or "deferred").strip() + entry["updated"] = now + else: + if entry is None: + return _reject(command, correlation_id, "not_found", f"entry {entry_id!r} not found") + if not note or not note.strip(): + return _reject(command, correlation_id, "validation_error", "note requires content") + entry.setdefault("notes", []).append( + {"content": note.strip(), "author": note_author or "codex", "created": now} + ) + entry["updated"] = now + + after = yaml.safe_dump(entry, sort_keys=True) + if before == after: + result = CommandResult( + command=command, + status="applied", + correlation_id=correlation_id, + evidence={"status": "applied", "git_sha": current_head, "files_touched": [], "noop": True}, + ) + if idempotency_key: + idempotency.put(idempotency_key, digest, result.to_dict()) + return result + + _save(path, document) + relative_path = str(path.relative_to(repo_root)) + git_sha: str | None = None + push_ok: bool | None = None + push_message: str | None = None + if commit: + try: + git_sha = commit_paths( + repo_root, + [relative_path], + message=( + f"{command} {kind}/{entry_id}\n\n" + f"correlation_id: {correlation_id}\nreason: {reason}\nsource: repo-manager\n" + ), + ) + except GitError as exc: + return CommandResult( + command=command, + status="failed", + correlation_id=correlation_id, + evidence={"status": "failed", "files_touched": [relative_path], "git_sha": None}, + error={"code": "internal", "message": str(exc), "correlation_id": correlation_id}, + ) + if push: + push_ok, push_message = push_ff(repo_root) + + _snapshot_after, index_after = observe_repository(repo_root, slug=slug) + append_event( + index_after, + { + "type": "repo.command.applied", + "command": command, + "operation": operation, + "correlation_id": correlation_id, + "register_kind": kind, + "entry_id": entry_id, + "git_sha": git_sha, + "files_touched": [relative_path], + "source": "repo-manager", + }, + ) + append_event( + index_after, + { + "type": "repo.work.indexed", + "correlation_id": correlation_id, + "kind": f"register:{kind}", + "id": entry_id, + "source_path": relative_path, + }, + ) + save_index(index_after) + dual_run.record_mutation( + source="repo-manager", + kind=f"register_{operation}", + repo_slug=slug, + detail={ + "register_kind": kind, + "entry_id": entry_id, + "git_sha": git_sha, + "correlation_id": correlation_id, + }, + ) + evidence: dict[str, Any] = { + "status": "applied", + "git_sha": git_sha, + "files_touched": [relative_path], + "index_path": str(default_index_path(repo_root)), + "source": "repo-manager", + } + if push: + evidence["push_ok"] = push_ok + evidence["push_message"] = push_message + result = CommandResult( + command=command, + status="applied", + correlation_id=correlation_id, + evidence=evidence, + ) + if idempotency_key: + idempotency.put(idempotency_key, digest, result.to_dict()) + return result diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/repo_onboard.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/repo_onboard.py new file mode 100644 index 00000000..265ce0ec --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/repo_onboard.py @@ -0,0 +1,229 @@ +"""Onboard a repository into its authoritative State Hub (CUST-WP-0067-T04). + +`hub-record-authority.yaml` assigns ``managed_repos`` to repo-manager as +``file-derived``: the repository's committed ``.repo-classification.yaml`` is +the source of truth and the hub holds a projection of it. Repo Manager owned +that record type without exposing any command for it, so the only working path +lived in the State Hub repo and defaulted to a local address — which is how +seven weeks of onboarding landed in a local cache instead of central. + +The order here is deliberate and follows ADR-010 decision 5: make the source +file correct and *reachable* first, then project it. A hub record whose backing +file is not pushed cannot be re-derived by anyone else. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import httpx +import yaml + +from repo_manager.classification import validate_classification +from repo_manager.gitops import head_sha, is_git_repo + +CLASSIFICATION_FILENAME = ".repo-classification.yaml" + + +class OnboardError(RuntimeError): + """Onboarding cannot proceed and the operator must decide what to do.""" + + +@dataclass +class OnboardResult: + slug: str + action: str = "unknown" + steps: list[dict[str, Any]] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + def step(self, name: str, ok: bool, **detail: Any) -> None: + self.steps.append({"step": name, "ok": ok, **detail}) + + def to_dict(self) -> dict[str, Any]: + return { + "schema": "repo-manager.repo-onboard.v1", + "slug": self.slug, + "action": self.action, + "steps": self.steps, + "warnings": self.warnings, + } + + +def _git(repo: Path, *args: str) -> str: + import subprocess + + proc = subprocess.run( + ["git", "-C", str(repo), *args], capture_output=True, text=True, check=False + ) + return proc.stdout.strip() if proc.returncode == 0 else "" + + +def _load_classification(repo: Path) -> dict[str, Any]: + path = repo / CLASSIFICATION_FILENAME + if not path.is_file(): + raise OnboardError( + f"{CLASSIFICATION_FILENAME} is missing in {repo}. Classification is not " + "mechanical — author it with the repository owner rather than guessing " + "(see CUST-WP-0065-T01)." + ) + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + data = raw.get("repo_classification", raw) + if not isinstance(data, dict): + raise OnboardError(f"{CLASSIFICATION_FILENAME} does not contain a mapping") + issues = validate_classification(data) + if issues: + detail = "; ".join(f"{i.field}: {i.message}" for i in issues) + raise OnboardError(f"{CLASSIFICATION_FILENAME} is invalid: {detail}") + return data + + +def _source_is_reachable(repo: Path, result: OnboardResult) -> None: + """The backing file must be committed and pushed, or nothing can re-derive it.""" + dirty = _git(repo, "status", "--porcelain", "--", CLASSIFICATION_FILENAME) + if dirty: + raise OnboardError( + f"{CLASSIFICATION_FILENAME} has uncommitted changes. Commit it first — " + "the commit is the write (ADR-010 decision 4)." + ) + + upstream = _git(repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") + if not upstream: + raise OnboardError( + "no upstream branch configured; central derives from what it can fetch" + ) + ahead = _git(repo, "rev-list", "--count", "@{u}..HEAD") + if ahead and ahead != "0": + raise OnboardError( + f"{ahead} commit(s) not pushed. Push before onboarding — a hub record " + "whose source is only local cannot be re-derived." + ) + result.step("source_reachable", True, upstream=upstream, head=head_sha(repo)) + + +def _hub(api_base: str, method: str, path: str, body: dict | None = None) -> tuple[int, Any]: + url = api_base.rstrip("/") + path + try: + with httpx.Client(timeout=30.0) as client: + response = client.request(method, url, json=body) + except httpx.HTTPError as exc: + raise OnboardError(f"cannot reach hub at {api_base}: {exc}") from exc + if response.status_code == 204: + return response.status_code, None + try: + return response.status_code, response.json() + except ValueError: + return response.status_code, {"_raw": response.text} + + +def _classification_body(data: dict[str, Any]) -> dict[str, Any]: + keys = ( + "category", + "secondary_domains", + "capability_tags", + "business_stake", + "business_mechanics", + "classified_at", + "classified_by", + "standard_version", + ) + return {k: data[k] for k in keys if data.get(k) is not None} + + +def onboard_repo( + repo_path: Path, + *, + api_base: str, + dry_run: bool = False, + slug: str | None = None, +) -> dict[str, Any]: + repo = Path(repo_path).expanduser().resolve() + if not is_git_repo(repo): + raise OnboardError(f"{repo} is not a git working copy") + + slug = slug or repo.name + result = OnboardResult(slug=slug) + + data = _load_classification(repo) + result.step("classification_valid", True, domain=data.get("domain"), category=data.get("category")) + + _source_is_reachable(repo, result) + + remote = _git(repo, "remote", "get-url", "origin") or None + + code, existing = _hub(api_base, "GET", f"/repos/{slug}") + exists = code == 200 + + if dry_run: + result.action = "would-update" if exists else "would-create" + result.step("hub_projection", True, dry_run=True, exists=exists, api_base=api_base) + return result.to_dict() + + if not exists: + code, created = _hub( + api_base, + "POST", + "/repos/", + { + "slug": slug, + "name": slug, + "domain_slug": data["domain"], + "local_path": str(repo), + "remote_url": remote, + }, + ) + if code not in (200, 201): + raise OnboardError(f"POST /repos/ failed ({code}): {created}") + result.action = "created" + result.step("hub_create", True, status=code) + else: + result.action = "updated" + + # Correct a stale remote before anything else depends on it. The forge + # migration moved every repository from Gitea to Forgejo but never updated + # the hub, leaving 50 records pointing at a retired forge — and + # `fix-consistency` matches a checkout to its record *by remote_url*, so it + # could not find them and refused to register any of their work records. + # The working copy's origin is the derivable truth (CUST-WP-0068-T06). + if remote and existing and existing.get("remote_url") != remote: + code, _ = _hub(api_base, "PATCH", f"/repos/{slug}", {"remote_url": remote}) + result.step( + "remote_url_corrected", code in (200, 201), + was=existing.get("remote_url"), now=remote, status=code, + ) + + code, patched = _hub(api_base, "PATCH", f"/repos/{slug}", _classification_body(data)) + if code in (200, 201): + result.step("hub_classification", True, status=code) + else: + # The authoritative record now exists, which is what stops the repository + # from being recoverable only through a discardable cache. Classification + # is a projection of a file that is committed and pushed, so it can be + # re-derived at any time — do not fail the whole onboarding for it. + result.step("hub_classification", False, status=code, detail=patched) + result.warnings.append( + f"classification not projected (PATCH returned {code}); the record exists " + "and the file remains the source of truth, so re-run once the hub can " + "validate classification" + ) + + # Verify against the hub rather than trusting our own write. + code, verified = _hub(api_base, "GET", f"/repos/{slug}") + if code != 200: + raise OnboardError(f"verification failed: GET /repos/{slug} returned {code}") + if verified.get("category") is None and not any( + st["step"] == "hub_classification" and st["ok"] for st in result.steps + ): + result.action = f"{result.action}-unclassified" + if verified.get("domain_slug") != data["domain"]: + result.warnings.append( + f"hub reports domain_slug={verified.get('domain_slug')!r}, file says {data['domain']!r}" + ) + result.step("verified", True, domain_slug=verified.get("domain_slug")) + return result.to_dict() + + +def render(report: dict[str, Any]) -> str: + return json.dumps(report, indent=2) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/scaffold.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/scaffold.py new file mode 100644 index 00000000..182a9450 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/scaffold.py @@ -0,0 +1,235 @@ +"""Governed command: create flavor-correct repository files (RMGR-WP-0004-T03).""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from repo_manager.gitops import GitError, commit_paths, is_git_repo +from repo_manager.hub_access import apply_hub_access_block, render_hub_access_block +from repo_manager.standards import FLAVOR_MARKER_PREFIX, expected_workplan_prefix +from repo_manager.time import utc_today + +DURABLE_FLAVORS = ("experimental", "research", "tooling", "product", "business") +FLAVORS = (*DURABLE_FLAVORS, "project") + + +@dataclass +class CommandResult: + status: str + evidence: dict[str, Any] + error: dict[str, Any] | None = None + correlation_id: str = "" + + def to_dict(self) -> dict[str, Any]: + out = { + "command": "repo.scaffold", + "status": self.status, + "correlation_id": self.correlation_id, + "evidence": self.evidence, + } + if self.error: + out["error"] = self.error + return out + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if not content.endswith("\n"): + content += "\n" + path.write_text(content) + + +def scaffold_repository( + path: Path, + *, + flavor: str, + slug: str | None = None, + domain: str = "infotech", + wp_prefix: str | None = None, + force: bool = False, + commit: bool = True, +) -> CommandResult: + cid = str(uuid.uuid4()) + dest = path.expanduser().resolve() + dest.mkdir(parents=True, exist_ok=True) + slug = slug or dest.name + flavor = flavor.strip().lower() + if flavor not in FLAVORS: + return CommandResult( + "rejected", + {"path": str(dest)}, + {"message": f"unknown flavor {flavor!r}"}, + cid, + ) + + prj = flavor == "project" or slug.startswith("prj-") + if prj: + flavor = "project" + prefix = (wp_prefix or "").strip().upper() + if prefix and not prefix.endswith("-WP"): + prefix = prefix.rstrip("-") + "-WP" + if not prefix: + derived = expected_workplan_prefix(dest, slug=slug) + return CommandResult( + "rejected", + {"path": str(dest), "suggested_prefix": derived}, + { + "message": ( + "project flavor requires --wp-prefix derived from the " + "project identity (never PRJ-WP-)" + ) + }, + cid, + ) + if prefix == FLAVOR_MARKER_PREFIX or prefix.startswith("PRJ-"): + return CommandResult( + "rejected", + {"path": str(dest)}, + {"message": "refusing flavor-derived prefix PRJ-WP-"}, + cid, + ) + else: + prefix = (wp_prefix or expected_workplan_prefix(dest, slug=slug) or "REPO-WP").upper() + if not prefix.endswith("-WP"): + prefix = prefix.rstrip("-") + "-WP" + + written: list[str] = [] + + def put(rel: str, content: str) -> None: + target = dest / rel + if target.exists() and not force: + return + _write(target, content) + written.append(rel) + + put( + ".repo-classification.yaml", + "repo_classification:\n" + f" category: {'project' if prj else flavor}\n" + f" domain: {domain}\n" + " secondary_domains: []\n" + " capability_tags: []\n", + ) + put("README.md", f"# {slug}\n\nSee {'GOAL.md' if prj else 'INTENT.md'} and SCOPE.md.\n") + put( + "SCOPE.md", + f"# Scope\n\n## In scope\n\n- {slug}\n\n## Out of scope\n\n- TBD\n", + ) + put( + "AGENTS.md", + f"# Agent instructions — {slug}\n\n" + f"Orient: {'GOAL.md' if prj else 'INTENT.md'} → SCOPE.md → workplans/.\n" + f"Workplan prefix: `{prefix}-`.\n\n" + "## State Hub Integration\n\n" + f"{render_hub_access_block()}\n\n" + "## Cross-repo waits\n\n" + "If blocked on another repo, look for a `prj-` driver workplan and " + "follow `coordination-engine` `spec/cross-owner-wait-mode-v0.1.md`. " + "Do not mint a stranded `wait`, implement `flavor: residual`, or " + "message `ops-warden` for secrets.\n", + ) + if prj: + today = utc_today().isoformat() + put( + "GOAL.md", + "---\n" + f"repo: {slug}\n" + "repo_flavor: project\n" + "project_status: draft\n" + f'started: "{today}"\n' + "---\n\n" + f"# Goal — {slug}\n\n" + "## Outcome\n\nWhat success looks like.\n\n" + "## Invariants\n\nRules that must hold during the project.\n\n" + "## Success gates\n\nCheckable conditions for goal achieved.\n\n" + "## Project retirement\n\n" + "Archive this repository when gates are met and residuals have live owners.\n", + ) + if (dest / "INTENT.md").exists() and not force: + pass + else: + put( + "INTENT.md", + f"# INTENT\n\n> Why `{slug}` exists.\n\n## One-liner\n\nTBD.\n", + ) + + (dest / "workplans" / "archived").mkdir(parents=True, exist_ok=True) + if not prj: + bootstrap = dest / "workplans" / f"{prefix}-0001-foundation.md" + if force or not bootstrap.exists(): + _write( + bootstrap, + "---\n" + f"id: {prefix}-0001\n" + "type: workplan\n" + f'title: "Foundation"\n' + f"domain: {domain}\n" + f"repo: {slug}\n" + "status: proposed\n" + "flavor: planning\n" + "depends_on: []\n" + "---\n\n" + "# Foundation\n\n" + "```task\n" + f"id: {prefix}-0001-T01\n" + "status: todo\n" + "priority: high\n" + "```\n\n" + "Establish the repository baseline.\n", + ) + written.append(str(bootstrap.relative_to(dest))) + + evidence: dict[str, Any] = { + "path": str(dest), + "flavor": flavor, + "slug": slug, + "wp_prefix": prefix, + "written": written, + "prj_layout": prj, + } + if commit and is_git_repo(dest) and written: + try: + sha = commit_paths(dest, written, f"chore: scaffold {flavor} repository baseline") + evidence["git_sha"] = sha + except GitError as exc: + return CommandResult("failed", evidence, {"message": str(exc)}, cid) + + if not written: + evidence["noop"] = True + return CommandResult("applied", evidence, None, cid) + + +def refresh_hub_access(path: Path, *, commit: bool = True) -> CommandResult: + """Replace the State Hub access table in AGENTS.md from the canonical map.""" + cid = str(uuid.uuid4()) + dest = path.expanduser().resolve() + agents = dest / "AGENTS.md" + evidence: dict[str, Any] = {"path": str(dest), "file": "AGENTS.md"} + if not agents.is_file(): + return CommandResult( + "rejected", + evidence, + {"message": "AGENTS.md is missing; scaffold the repository first"}, + cid, + ) + original = agents.read_text(encoding="utf-8") + updated, action = apply_hub_access_block(original) + evidence["action"] = action + if action == "noop": + evidence["noop"] = True + return CommandResult("applied", evidence, None, cid) + agents.write_text(updated, encoding="utf-8") + evidence["written"] = ["AGENTS.md"] + if commit and is_git_repo(dest): + try: + evidence["git_sha"] = commit_paths( + dest, + ["AGENTS.md"], + "docs(agents): refresh State Hub access map from repo-manager", + ) + except GitError as exc: + return CommandResult("failed", evidence, {"message": str(exc)}, cid) + return CommandResult("applied", evidence, None, cid) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/task.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/task.py new file mode 100644 index 00000000..053cf16a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/task.py @@ -0,0 +1,424 @@ +"""Governed file-backed task and daily ad-hoc mutations.""" + +from __future__ import annotations + +import json +import re +import uuid +from datetime import date +from pathlib import Path +from typing import Any + +from repo_manager import dual_run, idempotency +from repo_manager.commands.workplan import CommandResult, create_workplan +from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff +from repo_manager.identifiers import derive_work_record_uuid, load_fleet_namespace +from repo_manager.index_store import append_event, default_index_path, save_index +from repo_manager.observe import observe_repository +from repo_manager.parse.workplan import _TASK_BLOCK_RE, _parse_yaml_block +from repo_manager.record_identity import classify_record_id +from repo_manager.time import utc_today + +VALID_TASK_STATUSES = frozenset({"wait", "todo", "progress", "done", "cancel"}) +VALID_TASK_PRIORITIES = frozenset({"low", "medium", "high", "critical"}) + + +def _result( + command: str, + status: str, + correlation_id: str, + *, + evidence: dict[str, Any] | None = None, + code: str | None = None, + message: str | None = None, +) -> CommandResult: + error = None + if code and message: + error = {"code": code, "message": message, "correlation_id": correlation_id} + return CommandResult( + command=command, + status=status, + correlation_id=correlation_id, + evidence=evidence or {"status": status}, + error=error, + ) + + +def _is_uuid(value: str) -> bool: + try: + uuid.UUID(value) + return True + except ValueError: + return False + + +def _find_record(index: Any, kind: str, identifier: str) -> Any | None: + by_uuid = _is_uuid(identifier) + for record in index.work_records: + if record.kind != kind: + continue + if not by_uuid and record.id == identifier: + return record + if by_uuid and record.uuid and record.uuid.lower() == identifier.lower(): + return record + return None + + +def _render_scalar(key: str, value: Any) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if key in {"status", "priority"}: + return str(value) + return json.dumps(str(value), ensure_ascii=False) + + +def _patch_task_fields( + text: str, + *, + canonical_id: str, + updates: dict[str, Any], +) -> tuple[str, bool]: + matches = 0 + changed = False + + def replace(match: re.Match[str]) -> str: + nonlocal matches, changed + raw = match.group(1) + meta = _parse_yaml_block(raw.strip()) + if str(meta.get("id") or "") != canonical_id: + return match.group(0) + matches += 1 + patched = raw + for key, value in updates.items(): + rendered = _render_scalar(key, value) + pattern = re.compile(rf"^(?P{re.escape(key)}:\s*).*$", re.MULTILINE) + if pattern.search(patched): + candidate = pattern.sub(rf"\g{rendered}", patched, count=1) + else: + candidate = f"{patched.rstrip()}\n{key}: {rendered}\n" + if candidate != patched: + changed = True + patched = candidate + return f"```task\n{patched.strip()}\n```" + + result = _TASK_BLOCK_RE.sub(replace, text) + if matches != 1: + raise ValueError(f"{canonical_id}: expected exactly one task block, found {matches}") + return result, changed + + +def _next_task_id(parent_id: str, index: Any) -> str: + pattern = re.compile(rf"^{re.escape(parent_id)}-T(?P[0-9]{{2,3}})$") + numbers = [ + int(match.group("number")) + for record in index.work_records + if record.kind == "task" and record.id and (match := pattern.fullmatch(record.id)) + ] + number = max(numbers, default=0) + 1 + width = 2 if number < 100 else 3 + candidate = f"{parent_id}-T{number:0{width}d}" + if classify_record_id("task", candidate) != "canonical": + raise ValueError(f"no canonical task identifier remains under {parent_id}") + return candidate + + +def mutate_task( + repo_root: Path, + task_id: str | None, + *, + operation: str, + workplan_id: str | None = None, + title: str | None = None, + description: str | None = None, + status: str | None = None, + priority: str | None = None, + needs_human: bool | None = None, + intervention_note: str | None = None, + blocking_reason: str | None = None, + correlation_id: str | None = None, + reason: str = "rmgr command", + commit: bool = True, + push: bool = False, + expected_head_sha: str | None = None, + idempotency_key: str | None = None, + repo_slug: str | None = None, +) -> CommandResult: + command = f"repo.work.{operation}_task" + correlation_id = correlation_id or str(uuid.uuid4()) + repo_root = repo_root.resolve() + payload = { + "repo_root": str(repo_root), + "task_id": task_id, + "operation": operation, + "workplan_id": workplan_id, + "title": title, + "description": description, + "status": status, + "priority": priority, + "needs_human": needs_human, + "intervention_note": intervention_note, + "blocking_reason": blocking_reason, + "expected_head_sha": expected_head_sha, + } + payload_hash = idempotency.payload_hash(payload) + if idempotency_key and (prior := idempotency.get(idempotency_key)): + if prior.get("payload_hash") != payload_hash: + return _result( + command, "rejected", correlation_id, code="conflict", + message="idempotency key reused with different payload", + ) + previous = prior.get("result") or {} + return CommandResult( + command=previous.get("command", command), + status=previous.get("status", "applied"), + correlation_id=previous.get("correlation_id", correlation_id), + evidence=previous.get("evidence") or {"status": "applied", "replay": True}, + error=previous.get("error"), + ) + + if operation not in {"add", "update"}: + return _result(command, "rejected", correlation_id, code="validation_error", message="invalid task operation") + if status is not None and status not in VALID_TASK_STATUSES: + return _result(command, "rejected", correlation_id, code="validation_error", message=f"invalid task status {status!r}") + if priority is not None and priority not in VALID_TASK_PRIORITIES: + return _result(command, "rejected", correlation_id, code="validation_error", message=f"invalid task priority {priority!r}") + if needs_human is True and not intervention_note: + return _result(command, "rejected", correlation_id, code="validation_error", message="intervention_note is required when needs_human is true") + + current_head = head_sha(repo_root) + if expected_head_sha and current_head and expected_head_sha != current_head: + result = _result( + command, "rejected", correlation_id, + evidence={"status": "rejected", "head_sha": current_head}, + code="precondition_failed", + message=f"expected_head_sha {expected_head_sha} != {current_head}", + ) + if result.error: + result.error["retryable"] = True + return result + + snapshot, index = observe_repository(repo_root, slug=repo_slug) + slug = repo_slug or snapshot.get("slug") + if operation == "add": + if not workplan_id: + return _result(command, "rejected", correlation_id, code="validation_error", message="add requires workplan_id") + parent = _find_record(index, "workplan", workplan_id) + if parent is None or not parent.id: + return _result(command, "rejected", correlation_id, code="not_found", message=f"workplan {workplan_id!r} not found") + if not title or not title.strip(): + return _result(command, "rejected", correlation_id, code="validation_error", message="add requires a title") + canonical_id = task_id or _next_task_id(parent.id, index) + if classify_record_id("task", canonical_id) != "canonical": + return _result(command, "rejected", correlation_id, code="validation_error", message=f"invalid canonical task id {canonical_id!r}") + if _find_record(index, "task", canonical_id) is not None: + return _result(command, "rejected", correlation_id, code="conflict", message=f"task {canonical_id!r} exists") + if not canonical_id.startswith(parent.id + "-T"): + return _result(command, "rejected", correlation_id, code="validation_error", message="task id must be qualified by its workplan id") + path = repo_root / parent.source_path + task_uuid = derive_work_record_uuid(load_fleet_namespace(), canonical_id) + task_status = status or "todo" + task_priority = priority or "medium" + fields = [ + f"id: {canonical_id}", + f"status: {task_status}", + f"priority: {task_priority}", + f'state_hub_task_id: "{task_uuid}"', + ] + if needs_human is not None: + fields.append(f"needs_human: {'true' if needs_human else 'false'}") + if intervention_note: + fields.append(f"intervention_note: {json.dumps(intervention_note, ensure_ascii=False)}") + if blocking_reason: + fields.append(f"blocking_reason: {json.dumps(blocking_reason, ensure_ascii=False)}") + addition = ( + f"\n\n## {title.strip()}\n\n```task\n" + + "\n".join(fields) + + "\n```\n\n" + + ((description or "").strip() or "Task details to be completed.") + + "\n" + ) + path.write_text(path.read_text(encoding="utf-8").rstrip() + addition, encoding="utf-8") + changes = {"operation": "add", "task_id": canonical_id, "workplan_id": parent.id} + else: + if not task_id: + return _result(command, "rejected", correlation_id, code="validation_error", message="update requires task_id") + record = _find_record(index, "task", task_id) + if record is None or not record.id: + return _result(command, "rejected", correlation_id, code="not_found", message=f"task {task_id!r} not found") + updates = { + key: value + for key, value in { + "title": title.strip() if title else None, + "description": description.strip() if description else None, + "status": status, + "priority": priority, + "needs_human": needs_human, + "intervention_note": intervention_note, + "blocking_reason": blocking_reason, + }.items() + if value is not None + } + if not updates: + return _result(command, "rejected", correlation_id, code="validation_error", message="update requires at least one field") + existing_human = bool(record.extra.get("needs_human")) + resulting_human = updates.get("needs_human", existing_human) + resulting_note = updates.get("intervention_note", record.extra.get("intervention_note")) + if resulting_human and not resulting_note: + return _result(command, "rejected", correlation_id, code="validation_error", message="intervention_note is required when needs_human is true") + path = repo_root / record.source_path + patched, changed = _patch_task_fields( + path.read_text(encoding="utf-8"), canonical_id=record.id, updates=updates + ) + if not changed: + result = _result( + command, "applied", correlation_id, + evidence={"status": "applied", "git_sha": current_head, "files_touched": [], "noop": True}, + ) + if idempotency_key: + idempotency.put(idempotency_key, payload_hash, result.to_dict()) + return result + path.write_text(patched, encoding="utf-8") + canonical_id = record.id + changes = {"operation": "update", "task_id": canonical_id, "fields": sorted(updates)} + + relative_path = str(path.relative_to(repo_root)) + git_sha = None + push_ok = None + push_message = None + if commit: + try: + git_sha = commit_paths( + repo_root, + [relative_path], + message=( + f"{command} {canonical_id}\n\ncorrelation_id: {correlation_id}\n" + f"reason: {reason}\nsource: repo-manager\n" + ), + ) + except GitError as exc: + return _result(command, "failed", correlation_id, evidence={"status": "failed", "files_touched": [relative_path]}, code="internal", message=str(exc)) + if push: + push_ok, push_message = push_ff(repo_root) + + _snapshot_after, index_after = observe_repository(repo_root, slug=slug) + append_event( + index_after, + { + "type": "repo.command.applied", + "command": command, + "correlation_id": correlation_id, + "git_sha": git_sha, + **changes, + }, + ) + save_index(index_after) + dual_run.record_mutation( + source="repo-manager", + kind=f"task_{operation}", + repo_slug=slug, + detail={**changes, "git_sha": git_sha, "correlation_id": correlation_id}, + ) + evidence = { + "status": "applied", + "task_id": canonical_id, + "git_sha": git_sha, + "files_touched": [relative_path], + "index_path": str(default_index_path(repo_root)), + "source": "repo-manager", + } + if push: + evidence.update({"push_ok": push_ok, "push_message": push_message}) + result = _result(command, "applied", correlation_id, evidence=evidence) + if idempotency_key: + idempotency.put(idempotency_key, payload_hash, result.to_dict()) + return result + + +def add_adhoc_task( + repo_root: Path, + title: str, + description: str, + *, + prefix: str | None = None, + on_date: date | None = None, + status: str = "todo", + priority: str = "medium", + owner: str = "codex", + correlation_id: str | None = None, + reason: str = "rmgr adhoc add", + commit: bool = True, + push: bool = False, + expected_head_sha: str | None = None, + idempotency_key: str | None = None, + repo_slug: str | None = None, +) -> CommandResult: + """Create/reuse today's qualified ad-hoc workplan and append one task.""" + correlation_id = correlation_id or str(uuid.uuid4()) + repo_root = repo_root.resolve() + payload = { + "repo_root": str(repo_root), "title": title, "description": description, + "prefix": prefix, "on_date": str(on_date) if on_date else None, + "status": status, "priority": priority, "expected_head_sha": expected_head_sha, + } + payload_hash = idempotency.payload_hash(payload) + if idempotency_key and (prior := idempotency.get(idempotency_key)): + if prior.get("payload_hash") != payload_hash: + return _result("repo.work.add_adhoc_task", "rejected", correlation_id, code="conflict", message="idempotency key reused with different payload") + previous = prior.get("result") or {} + return CommandResult( + command=previous.get("command", "repo.work.add_adhoc_task"), + status=previous.get("status", "applied"), + correlation_id=previous.get("correlation_id", correlation_id), + evidence=previous.get("evidence") or {"status": "applied", "replay": True}, + error=previous.get("error"), + ) + + snapshot, index = observe_repository(repo_root, slug=repo_slug) + prefixes = { + record.id.split("-WP-", 1)[0] + for record in index.work_records + if record.kind == "workplan" and record.id and "-WP-" in record.id + } + selected = (prefix or "").strip().upper().removesuffix("-WP") + if not selected: + if len(prefixes) != 1: + return _result("repo.work.add_adhoc_task", "rejected", correlation_id, code="validation_error", message=f"could not infer one workplan prefix; candidates={sorted(prefixes)!r}; pass --prefix") + selected = next(iter(prefixes)) + day = on_date or utc_today() + workplan_id = f"{selected}-WP-ADHOC-{day.isoformat()}" + parent = _find_record(index, "workplan", workplan_id) + if parent is None: + created = create_workplan( + repo_root, + workplan_id, + f"Ad Hoc — {day.isoformat()}", + f"Small opportunistic fixes completed on {day.isoformat()}.", + owner=owner, + status="active", + commit=False, + expected_head_sha=expected_head_sha, + repo_slug=repo_slug or snapshot.get("slug"), + ) + if created.status != "applied": + return created + result = mutate_task( + repo_root, + None, + operation="add", + workplan_id=workplan_id, + title=title, + description=description, + status=status, + priority=priority, + correlation_id=correlation_id, + reason=reason, + commit=commit, + push=push, + expected_head_sha=expected_head_sha, + repo_slug=repo_slug or snapshot.get("slug"), + ) + result.command = "repo.work.add_adhoc_task" + result.evidence["workplan_id"] = workplan_id + if idempotency_key and result.status == "applied": + idempotency.put(idempotency_key, payload_hash, result.to_dict()) + return result diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/task_status.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/task_status.py new file mode 100644 index 00000000..8b68a665 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/task_status.py @@ -0,0 +1,345 @@ +"""Governed command: repo.work.update_task_status (dual-run hardened).""" + +from __future__ import annotations + +import re +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from repo_manager import dual_run, idempotency +from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff +from repo_manager.index_store import append_event, default_index_path, save_index +from repo_manager.observe import observe_repository +from repo_manager.parse.workplan import _TASK_BLOCK_RE, _parse_yaml_block + +VALID_TASK_STATUSES = frozenset({"wait", "todo", "progress", "done", "cancel"}) + + +@dataclass +class CommandResult: + status: str # applied | rejected | failed + evidence: dict[str, Any] + error: dict[str, Any] | None = None + correlation_id: str = "" + + def to_dict(self) -> dict[str, Any]: + out: dict[str, Any] = { + "command": "repo.work.update_task_status", + "status": self.status, + "correlation_id": self.correlation_id, + "evidence": self.evidence, + } + if self.error: + out["error"] = self.error + return out + + +def _is_uuid(value: str) -> bool: + try: + uuid.UUID(value) + return True + except ValueError: + return False + + +def _patch_task_status_in_file( + path: Path, + *, + canonical_id: str | None, + hub_uuid: str | None, + status: str, +) -> bool: + text = path.read_text(encoding="utf-8") + + def _replace(match: re.Match[str]) -> str: + block = match.group(0) + meta = _parse_yaml_block(match.group(1)) + tid = str(meta["id"]) if meta.get("id") is not None else None + hub = ( + str(meta["state_hub_task_id"]).strip().strip('"') + if meta.get("state_hub_task_id") is not None + else None + ) + matched = False + if canonical_id and tid == canonical_id: + matched = True + if hub_uuid and hub and hub.lower() == hub_uuid.lower(): + matched = True + if not matched: + return block + replaced = re.sub( + r"^(status:\s*)\S+", + rf"\g<1>{status}", + block, + count=1, + flags=re.MULTILINE, + ) + if replaced != block: + return replaced + return block.replace("\n```", f"\nstatus: {status}\n```", 1) + + new_text = _TASK_BLOCK_RE.sub(_replace, text) + if new_text == text: + return False + path.write_text(new_text, encoding="utf-8") + return True + + +def update_task_status( + repo_root: Path, + task_id: str, + status: str, + *, + correlation_id: str | None = None, + reason: str = "rmgr command", + commit: bool = True, + push: bool = False, + expected_head_sha: str | None = None, + idempotency_key: str | None = None, + repo_slug: str | None = None, +) -> CommandResult: + """Apply task status change to the workplan file and optionally git-commit. + + ``task_id`` may be a canonical id (e.g. RMGR-WP-0001-T05) or a State Hub + task UUID (matched against ``state_hub_task_id`` in the file). + """ + correlation_id = correlation_id or str(uuid.uuid4()) + repo_root = repo_root.resolve() + payload = { + "repo_root": str(repo_root), + "task_id": task_id, + "status": status, + "expected_head_sha": expected_head_sha, + } + ph = idempotency.payload_hash(payload) + + if idempotency_key: + prior = idempotency.get(idempotency_key) + if prior: + if prior.get("payload_hash") != ph: + return CommandResult( + status="rejected", + correlation_id=correlation_id, + evidence={"status": "rejected"}, + error={ + "code": "conflict", + "message": "idempotency key reused with different payload", + "correlation_id": correlation_id, + }, + ) + # Replay prior result + prev = prior.get("result") or {} + return CommandResult( + status=prev.get("status", "applied"), + correlation_id=prev.get("correlation_id", correlation_id), + evidence=prev.get("evidence") or {"status": "applied", "replay": True}, + error=prev.get("error"), + ) + + if status not in VALID_TASK_STATUSES: + return CommandResult( + status="rejected", + correlation_id=correlation_id, + evidence={"status": "rejected"}, + error={ + "code": "validation_error", + "message": f"invalid task status {status!r}", + "correlation_id": correlation_id, + }, + ) + + current_head = head_sha(repo_root) + if expected_head_sha and current_head and expected_head_sha != current_head: + return CommandResult( + status="rejected", + correlation_id=correlation_id, + evidence={"status": "rejected", "head_sha": current_head}, + error={ + "code": "precondition_failed", + "message": f"expected_head_sha {expected_head_sha} != {current_head}", + "correlation_id": correlation_id, + "retryable": True, + }, + ) + + snapshot, index = observe_repository(repo_root, slug=repo_slug) + slug = repo_slug or snapshot.get("slug") + + hub_uuid = task_id if _is_uuid(task_id) else None + canonical = None if hub_uuid else task_id + + match = None + for r in index.work_records: + if r.kind != "task": + continue + if canonical and r.id == canonical: + match = r + break + if hub_uuid and r.uuid and r.uuid.lower() == hub_uuid.lower(): + match = r + break + + if match is None: + return CommandResult( + status="rejected", + correlation_id=correlation_id, + evidence={"status": "rejected"}, + error={ + "code": "not_found", + "message": f"task {task_id!r} not found in workplans", + "correlation_id": correlation_id, + }, + ) + + path = repo_root / match.source_path + if not path.is_file(): + return CommandResult( + status="failed", + correlation_id=correlation_id, + evidence={"status": "failed"}, + error={ + "code": "not_found", + "message": f"source file missing: {match.source_path}", + "correlation_id": correlation_id, + }, + ) + + # Already at status → treat as successful no-op applied with current head + if match.status == status: + result = CommandResult( + status="applied", + correlation_id=correlation_id, + evidence={ + "status": "applied", + "git_sha": current_head, + "files_touched": [], + "noop": True, + "observed_at": index.observed_at, + }, + ) + if idempotency_key: + idempotency.put(idempotency_key, ph, result.to_dict()) + return result + + changed = _patch_task_status_in_file( + path, + canonical_id=match.id, + hub_uuid=match.uuid or hub_uuid, + status=status, + ) + if not changed: + return CommandResult( + status="rejected", + correlation_id=correlation_id, + evidence={"status": "rejected", "files_touched": []}, + error={ + "code": "conflict", + "message": "task block not patched (id mismatch)", + "correlation_id": correlation_id, + }, + ) + + git_sha: str | None = None + push_ok: bool | None = None + push_msg: str | None = None + if commit: + try: + git_sha = commit_paths( + repo_root, + [match.source_path], + message=( + f"repo.work.update_task_status {match.id or task_id} -> {status}\n\n" + f"correlation_id: {correlation_id}\nreason: {reason}\n" + f"source: repo-manager\n" + ), + ) + except GitError as exc: + return CommandResult( + status="failed", + correlation_id=correlation_id, + evidence={ + "status": "failed", + "files_touched": [match.source_path], + "git_sha": None, + }, + error={ + "code": "internal", + "message": str(exc), + "correlation_id": correlation_id, + }, + ) + if push: + push_ok, push_msg = push_ff(repo_root) + + _snap2, index2 = observe_repository(repo_root, slug=slug) + event = { + "type": "repo.command.applied", + "command": "repo.work.update_task_status", + "correlation_id": correlation_id, + "task_id": match.id, + "task_uuid": match.uuid, + "new_status": status, + "git_sha": git_sha, + "files_touched": [match.source_path], + "expected_head_sha_before": current_head, + "source": "repo-manager", + } + append_event(index2, event) + append_event( + index2, + { + "type": "repo.work.indexed", + "correlation_id": correlation_id, + "kind": "task", + "id": match.id, + "source_path": match.source_path, + }, + ) + save_index(index2) + + dual_run.record_mutation( + source="repo-manager", + kind="task_status_writeback", + repo_slug=slug, + detail={ + "task_id": match.id, + "status": status, + "git_sha": git_sha, + "correlation_id": correlation_id, + }, + ) + + evidence: dict[str, Any] = { + "status": "applied", + "git_sha": git_sha, + "files_touched": [match.source_path], + "observed_at": index2.observed_at, + "index_path": str(default_index_path(repo_root)), + "source": "repo-manager", + } + if push: + evidence["push_ok"] = push_ok + evidence["push_message"] = push_msg + + if commit and not git_sha: + return CommandResult( + status="failed", + correlation_id=correlation_id, + evidence=evidence, + error={ + "code": "internal", + "message": "applied file mutation without git_sha", + "correlation_id": correlation_id, + }, + ) + + result = CommandResult( + status="applied", + correlation_id=correlation_id, + evidence=evidence, + ) + if idempotency_key: + idempotency.put(idempotency_key, ph, result.to_dict()) + return result diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/workplan.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/workplan.py new file mode 100644 index 00000000..a21699b3 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/commands/workplan.py @@ -0,0 +1,519 @@ +"""Governed workplan create, update, and archive commands.""" + +from __future__ import annotations + +import json +import re +import uuid +from dataclasses import dataclass +from datetime import date +from pathlib import Path +from typing import Any + +from repo_manager import dual_run, idempotency +from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff +from repo_manager.identifiers import derive_work_record_uuid, load_fleet_namespace +from repo_manager.index_store import append_event, default_index_path, save_index +from repo_manager.observe import observe_repository +from repo_manager.record_identity import classify_record_id +from repo_manager.time import utc_today + +VALID_WORKPLAN_STATUSES = frozenset( + {"proposed", "ready", "active", "blocked", "backlog", "finished", "archived"} +) +_FRONTMATTER_RE = re.compile(r"\A---\r?\n(?P.*?)\r?\n---(?P\r?\n.*)?\Z", re.DOTALL) + + +@dataclass +class CommandResult: + command: str + status: str # applied | rejected | failed + evidence: dict[str, Any] + error: dict[str, Any] | None = None + correlation_id: str = "" + + def to_dict(self) -> dict[str, Any]: + out: dict[str, Any] = { + "command": self.command, + "status": self.status, + "correlation_id": self.correlation_id, + "evidence": self.evidence, + } + if self.error: + out["error"] = self.error + return out + + +def _result_from_prior(command: str, prior: dict[str, Any], correlation_id: str) -> CommandResult: + previous = prior.get("result") or {} + return CommandResult( + command=previous.get("command", command), + status=previous.get("status", "applied"), + correlation_id=previous.get("correlation_id", correlation_id), + evidence=previous.get("evidence") or {"status": "applied", "replay": True}, + error=previous.get("error"), + ) + + +def _rejected(command: str, correlation_id: str, code: str, message: str, **evidence: Any) -> CommandResult: + return CommandResult( + command=command, + status="rejected", + correlation_id=correlation_id, + evidence={"status": "rejected", **evidence}, + error={"code": code, "message": message, "correlation_id": correlation_id}, + ) + + +def _is_uuid(value: str) -> bool: + try: + uuid.UUID(value) + return True + except ValueError: + return False + + +def _slugify(value: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") or "workplan" + + +def _quoted(value: str) -> str: + return json.dumps(value, ensure_ascii=False) + + +def _today() -> date: + return utc_today() + + +def _patch_frontmatter(text: str, updates: dict[str, str]) -> str | None: + match = _FRONTMATTER_RE.match(text) + if not match: + return None + meta = match.group("meta") + for key, value in updates.items(): + rendered = value if key == "status" else _quoted(value) + pattern = re.compile(rf"^({re.escape(key)}:\s*).*$", re.MULTILINE) + if pattern.search(meta): + meta = pattern.sub(rf"\g<1>{rendered}", meta, count=1) + else: + meta = f"{meta.rstrip()}\n{key}: {rendered}" + return f"---\n{meta}\n---{match.group('body') or ''}" + + +def _find_workplan(index: Any, workplan_id: str) -> Any | None: + by_uuid = _is_uuid(workplan_id) + for record in index.work_records: + if record.kind != "workplan": + continue + if not by_uuid and record.id == workplan_id: + return record + if by_uuid and record.uuid and record.uuid.lower() == workplan_id.lower(): + return record + return None + + +def _create_text( + *, + workplan_id: str, + title: str, + goal: str, + status: str, + domain: str, + repo_slug: str, + owner: str, + topic_slug: str, +) -> str: + today = _today().isoformat() + workplan_uuid = derive_work_record_uuid(load_fleet_namespace(), workplan_id) + return ( + "---\n" + f"id: {workplan_id}\n" + "type: workplan\n" + f"title: {_quoted(title)}\n" + f"domain: {domain}\n" + f"repo: {repo_slug}\n" + f"status: {status}\n" + f"flavor: planning\n" + "depends_on: []\n" + f"owner: {owner}\n" + f"topic_slug: {topic_slug}\n" + f"created: {_quoted(today)}\n" + f"updated: {_quoted(today)}\n" + f"state_hub_workstream_id: {_quoted(str(workplan_uuid))}\n" + "---\n\n" + f"# {title}\n\n" + "## Goal\n\n" + f"{goal.strip()}\n" + ) + + +def mutate_workplan( + repo_root: Path, + workplan_id: str, + *, + operation: str, + title: str | None = None, + goal: str | None = None, + status: str | None = None, + owner: str | None = None, + topic_slug: str | None = None, + domain: str | None = None, + filename: str | None = None, + confirm_archive: bool = False, + correlation_id: str | None = None, + reason: str = "rmgr command", + commit: bool = True, + push: bool = False, + expected_head_sha: str | None = None, + idempotency_key: str | None = None, + repo_slug: str | None = None, +) -> CommandResult: + """Create, update, or archive a file-backed workplan. + + Update/archive accepts a canonical workplan id or State Hub UUID. ``archive`` + is the recoverable implementation of the legacy delete operation. + """ + commands = { + "create": "repo.work.create_workplan", + "update": "repo.work.update_workplan", + "archive": "repo.work.archive_workplan", + } + correlation_id = correlation_id or str(uuid.uuid4()) + command = commands.get(operation, "repo.work.update_workplan") + repo_root = repo_root.resolve() + payload = { + "repo_root": str(repo_root), + "workplan_id": workplan_id, + "operation": operation, + "title": title, + "goal": goal, + "status": status, + "owner": owner, + "topic_slug": topic_slug, + "domain": domain, + "filename": filename, + "confirm_archive": confirm_archive, + "expected_head_sha": expected_head_sha, + } + payload_digest = idempotency.payload_hash(payload) + + if idempotency_key: + prior = idempotency.get(idempotency_key) + if prior: + if prior.get("payload_hash") != payload_digest: + return _rejected( + command, + correlation_id, + "conflict", + "idempotency key reused with different payload", + ) + return _result_from_prior(command, prior, correlation_id) + + if operation not in commands: + return _rejected(command, correlation_id, "validation_error", f"invalid operation {operation!r}") + if status is not None and status not in VALID_WORKPLAN_STATUSES: + return _rejected( + command, + correlation_id, + "validation_error", + f"invalid workplan status {status!r}", + ) + if operation == "archive" and not confirm_archive: + return _rejected( + command, + correlation_id, + "confirmation_required", + "archive requires confirm_archive=True", + ) + + current_head = head_sha(repo_root) + if expected_head_sha and current_head and expected_head_sha != current_head: + result = _rejected( + command, + correlation_id, + "precondition_failed", + f"expected_head_sha {expected_head_sha} != {current_head}", + head_sha=current_head, + ) + if result.error: + result.error["retryable"] = True + return result + + snapshot, index = observe_repository(repo_root, slug=repo_slug) + slug = repo_slug or snapshot.get("slug") + record = _find_workplan(index, workplan_id) + touched: list[str] + changes: dict[str, Any] + + if operation == "create": + if classify_record_id("workplan", workplan_id) != "canonical": + return _rejected( + command, + correlation_id, + "validation_error", + f"invalid canonical workplan id {workplan_id!r}", + ) + if record is not None: + return _rejected(command, correlation_id, "conflict", f"workplan {workplan_id!r} exists") + if not title or not title.strip() or not goal or not goal.strip(): + return _rejected( + command, + correlation_id, + "validation_error", + "create requires non-empty title and goal", + ) + create_status = status or "proposed" + if create_status not in VALID_WORKPLAN_STATUSES: + return _rejected( + command, + correlation_id, + "validation_error", + f"invalid workplan status {create_status!r}", + ) + is_adhoc = "-WP-ADHOC-" in workplan_id + adhoc_date = workplan_id.rsplit("-WP-ADHOC-", 1)[-1] + name = filename or ( + f"ADHOC-{adhoc_date}.md" + if is_adhoc + else f"{workplan_id}-{_slugify(title)}.md" + ) + expected_adhoc_name = f"ADHOC-{adhoc_date}.md" + safe_name = ( + Path(name).name == name + and name.endswith(".md") + and ( + name.startswith(f"{workplan_id}-") + or (is_adhoc and name == expected_adhoc_name) + ) + ) + if not safe_name: + return _rejected(command, correlation_id, "validation_error", "unsafe workplan filename") + path = repo_root / "workplans" / name + if path.exists(): + return _rejected(command, correlation_id, "conflict", f"source file exists: workplans/{name}") + classification = snapshot.get("classification") or {} + create_domain = domain or classification.get("domain") or "infotech" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + _create_text( + workplan_id=workplan_id, + title=title.strip(), + goal=goal, + status=create_status, + domain=create_domain, + repo_slug=slug, + owner=owner or "codex", + topic_slug=topic_slug or create_domain, + ), + encoding="utf-8", + ) + touched = [str(path.relative_to(repo_root))] + changes = {"title": title.strip(), "status": create_status} + else: + if record is None: + return _rejected( + command, + correlation_id, + "not_found", + f"workplan {workplan_id!r} not found in workplans", + ) + path = repo_root / record.source_path + if not path.is_file(): + return CommandResult( + command=command, + status="failed", + correlation_id=correlation_id, + evidence={"status": "failed"}, + error={ + "code": "not_found", + "message": f"source file missing: {record.source_path}", + "correlation_id": correlation_id, + }, + ) + if operation == "update": + updates = { + key: value.strip() + for key, value in { + "title": title, + "status": status, + "owner": owner, + "topic_slug": topic_slug, + "domain": domain, + }.items() + if value is not None and value.strip() + } + if not updates: + return _rejected( + command, + correlation_id, + "validation_error", + "update requires at least one field", + ) + changes = { + key: value + for key, value in updates.items() + if key not in {"status", "title"} + or (key == "status" and value != record.status) + or (key == "title" and value != record.title) + } + if not changes: + result = CommandResult( + command=command, + status="applied", + correlation_id=correlation_id, + evidence={ + "status": "applied", + "git_sha": current_head, + "files_touched": [], + "noop": True, + "observed_at": index.observed_at, + }, + ) + if idempotency_key: + idempotency.put(idempotency_key, payload_digest, result.to_dict()) + return result + changes["updated"] = _today().isoformat() + new_text = _patch_frontmatter(path.read_text(encoding="utf-8"), changes) + if new_text is None: + return _rejected(command, correlation_id, "conflict", "workplan frontmatter is invalid") + path.write_text(new_text, encoding="utf-8") + touched = [record.source_path] + else: + if record.status == "archived" and record.source_path.startswith("workplans/archived/"): + result = CommandResult( + command=command, + status="applied", + correlation_id=correlation_id, + evidence={ + "status": "applied", + "git_sha": current_head, + "files_touched": [], + "noop": True, + "observed_at": index.observed_at, + }, + ) + if idempotency_key: + idempotency.put(idempotency_key, payload_digest, result.to_dict()) + return result + archived_dir = repo_root / "workplans" / "archived" + archived_dir.mkdir(parents=True, exist_ok=True) + archived_path = archived_dir / f"{_today():%y%m%d}-{path.name}" + if archived_path.exists(): + return _rejected( + command, + correlation_id, + "conflict", + f"archive target exists: {archived_path.relative_to(repo_root)}", + ) + new_text = _patch_frontmatter( + path.read_text(encoding="utf-8"), + {"status": "archived", "updated": _today().isoformat()}, + ) + if new_text is None: + return _rejected(command, correlation_id, "conflict", "workplan frontmatter is invalid") + archived_path.write_text(new_text, encoding="utf-8") + path.unlink() + touched = [record.source_path, str(archived_path.relative_to(repo_root))] + changes = {"status": "archived", "source_path": touched[-1]} + + git_sha: str | None = None + push_ok: bool | None = None + push_message: str | None = None + if commit: + try: + git_sha = commit_paths( + repo_root, + touched, + message=( + f"{command} {workplan_id} ({operation})\n\n" + f"correlation_id: {correlation_id}\nreason: {reason}\nsource: repo-manager\n" + ), + ) + except GitError as exc: + return CommandResult( + command=command, + status="failed", + correlation_id=correlation_id, + evidence={"status": "failed", "files_touched": touched, "git_sha": None}, + error={"code": "internal", "message": str(exc), "correlation_id": correlation_id}, + ) + if push: + push_ok, push_message = push_ff(repo_root) + + _snapshot_after, index_after = observe_repository(repo_root, slug=slug) + event = { + "type": "repo.command.applied", + "command": command, + "operation": operation, + "correlation_id": correlation_id, + "workplan_id": record.id if record else workplan_id, + "workplan_uuid": record.uuid if record else None, + "changes": changes, + "git_sha": git_sha, + "files_touched": touched, + "expected_head_sha_before": current_head, + "source": "repo-manager", + } + append_event(index_after, event) + append_event( + index_after, + { + "type": "repo.work.indexed", + "correlation_id": correlation_id, + "kind": "workplan", + "id": record.id if record else workplan_id, + "source_path": touched[-1], + }, + ) + save_index(index_after) + dual_run.record_mutation( + source="repo-manager", + kind=f"workplan_{operation}", + repo_slug=slug, + detail={ + "workplan_id": record.id if record else workplan_id, + "changes": changes, + "git_sha": git_sha, + "correlation_id": correlation_id, + }, + ) + + evidence: dict[str, Any] = { + "status": "applied", + "git_sha": git_sha, + "files_touched": touched, + "observed_at": index_after.observed_at, + "index_path": str(default_index_path(repo_root)), + "source": "repo-manager", + } + if push: + evidence["push_ok"] = push_ok + evidence["push_message"] = push_message + result = CommandResult( + command=command, + status="applied", + correlation_id=correlation_id, + evidence=evidence, + ) + if idempotency_key: + idempotency.put(idempotency_key, payload_digest, result.to_dict()) + return result + + +def create_workplan(repo_root: Path, workplan_id: str, title: str, goal: str, **kwargs: Any) -> CommandResult: + return mutate_workplan( + repo_root, + workplan_id, + operation="create", + title=title, + goal=goal, + **kwargs, + ) + + +def update_workplan(repo_root: Path, workplan_id: str, **kwargs: Any) -> CommandResult: + return mutate_workplan(repo_root, workplan_id, operation="update", **kwargs) + + +def archive_workplan(repo_root: Path, workplan_id: str, **kwargs: Any) -> CommandResult: + return mutate_workplan(repo_root, workplan_id, operation="archive", **kwargs) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/dual_run.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/dual_run.py new file mode 100644 index 00000000..d77bb085 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/dual_run.py @@ -0,0 +1,209 @@ +"""Dual-run flags and checkout-mutation meter (RMGR-WP-0002/0003). + +Precedence (highest first): + 1. Environment variables when set + 2. Config file (~/.repo-manager/dual-run.yaml or RM_DUAL_RUN_CONFIG) + 3. Defaults (all off) + +Environment variables: + RM_WRITEBACK, RM_RECONCILE, RM_WRITEBACK_PUSH, RM_PILOT_REPOS, RM_METER_PATH, + RM_DUAL_RUN_CONFIG + +Rollback: writeback/reconcile false in config and unset env → State Hub native. +""" + +from __future__ import annotations + +import json +import os +from functools import lru_cache +from pathlib import Path +from typing import Any, Literal + +import yaml + +from repo_manager.time import utc_now_text + +Source = Literal["state-hub", "repo-manager"] + +_TRUE = frozenset({"1", "true", "yes", "on"}) +_FALSE = frozenset({"0", "false", "no", "off"}) + + +def config_path() -> Path: + raw = os.environ.get("RM_DUAL_RUN_CONFIG", "~/.repo-manager/dual-run.yaml") + return Path(raw).expanduser() + + +@lru_cache(maxsize=1) +def _load_file_config() -> dict[str, Any]: + path = config_path() + if not path.is_file(): + return {} + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except (OSError, yaml.YAMLError): + return {} + return data if isinstance(data, dict) else {} + + +def reload_config() -> None: + """Clear cached config (tests / SIGHUP-style).""" + _load_file_config.cache_clear() + + +def _env_bool(name: str) -> bool | None: + raw = os.environ.get(name) + if raw is None or raw.strip() == "": + return None + v = raw.strip().lower() + if v in _TRUE: + return True + if v in _FALSE: + return False + return None + + +def _cfg_bool(key: str, default: bool = False) -> bool: + env_map = { + "writeback": "RM_WRITEBACK", + "reconcile": "RM_RECONCILE", + "writeback_push": "RM_WRITEBACK_PUSH", + } + env_name = env_map.get(key) + if env_name: + ev = _env_bool(env_name) + if ev is not None: + return ev + cfg = _load_file_config() + if key in cfg: + val = cfg[key] + if isinstance(val, bool): + return val + if isinstance(val, str): + return val.strip().lower() in _TRUE + return default + + +def writeback_enabled() -> bool: + return _cfg_bool("writeback", False) + + +def reconcile_enabled() -> bool: + return _cfg_bool("reconcile", False) + + +def writeback_push_enabled() -> bool: + return _cfg_bool("writeback_push", False) + + +def pilot_slugs() -> set[str] | None: + """None = all repos; empty set = none.""" + raw = os.environ.get("RM_PILOT_REPOS") + if raw is not None: + raw = raw.strip() + if not raw: + return set() + return {s.strip() for s in raw.split(",") if s.strip()} + cfg = _load_file_config() + if "pilot_repos" not in cfg: + return None + val = cfg["pilot_repos"] + if val is None: + return None + if isinstance(val, str): + if not val.strip(): + return set() + return {s.strip() for s in val.split(",") if s.strip()} + if isinstance(val, list): + return {str(s).strip() for s in val if str(s).strip()} + return None + + +def slug_allowed(slug: str | None) -> bool: + if not slug: + return False + pilots = pilot_slugs() + if pilots is None: + return True + return slug in pilots + + +def writeback_for_repo(slug: str | None) -> bool: + return writeback_enabled() and slug_allowed(slug) + + +def reconcile_for_repo(slug: str | None) -> bool: + return reconcile_enabled() and slug_allowed(slug) + + +def meter_path() -> Path: + raw = os.environ.get("RM_METER_PATH") + if raw: + return Path(raw).expanduser() + cfg = _load_file_config() + if cfg.get("meter_path"): + return Path(str(cfg["meter_path"])).expanduser() + return Path("~/.repo-manager/mutation-meter.jsonl").expanduser() + + +def record_mutation( + *, + source: Source, + kind: str, + repo_slug: str | None, + detail: dict[str, Any] | None = None, +) -> None: + try: + path = meter_path() + path.parent.mkdir(parents=True, exist_ok=True) + row = { + "ts": utc_now_text(), + "source": source, + "kind": kind, + "repo_slug": repo_slug, + "detail": detail or {}, + } + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(row, default=str) + "\n") + except OSError: + pass + + +def meter_summary(path: Path | None = None) -> dict[str, int]: + path = path or meter_path() + counts: dict[str, int] = {"state-hub": 0, "repo-manager": 0, "total": 0} + if not path.is_file(): + return counts + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except json.JSONDecodeError: + continue + src = row.get("source") + if src in counts: + counts[src] += 1 + counts["total"] += 1 + return counts + + +def flags_status() -> dict[str, Any]: + return { + "config_path": str(config_path()), + "config_exists": config_path().is_file(), + "writeback": writeback_enabled(), + "reconcile": reconcile_enabled(), + "writeback_push": writeback_push_enabled(), + "pilot_repos": sorted(pilot_slugs()) if pilot_slugs() is not None else None, + "meter_path": str(meter_path()), + "meter": meter_summary(), + # legacy env-style keys for operators + "RM_WRITEBACK": writeback_enabled(), + "RM_RECONCILE": reconcile_enabled(), + "RM_WRITEBACK_PUSH": writeback_push_enabled(), + "RM_PILOT_REPOS": sorted(pilot_slugs()) if pilot_slugs() is not None else None, + "RM_METER_PATH": str(meter_path()), + } diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/freshness.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/freshness.py new file mode 100644 index 00000000..d717a15a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/freshness.py @@ -0,0 +1,251 @@ +"""Read-only freshness-coverage measurement (STATE-WP-0094-T05 evidence). + +Gathers, from currently supported sources only: + +* State Hub REST (GET only): instance identity, ``/repos/``, per-repository + ``work-record-projection/snapshot`` (``backing_synced_at``). +* Forge (``git ls-remote``, read-only): per-repository source HEAD commit. +* Kubernetes (``kubectl get``, read-only): runner images and the live + Activity Core definition that would route C-rule runs to Repo Manager. +* Local State Hub edge outbox (``statehub outbox status``): queued receipts. + +It never writes to the hub, the cluster, Forge or any checkout, and it does not +read workstation checkouts (ADR-002). Fields that no supported source exposes are +reported under ``gaps`` instead of being inferred. + +Usage:: + + python -m repo_manager.freshness --out docs/evidence/-freshness-coverage.json +""" + +from __future__ import annotations + +import argparse +import json +import re +import statistics +import subprocess +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +DEFINITIONS_CONFIGMAP = ("activity-core", "actcore-external-activity-definitions") +C_RULE_QUERY = "consistency_sweep_remote_all" + + +def _get(api: str, path: str, timeout: float = 30) -> Any: + with urllib.request.urlopen(f"{api}{path}", timeout=timeout) as resp: + return json.loads(resp.read()) + + +def _run(cmd: list[str], timeout: float = 30) -> tuple[int, str]: + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False) + except (OSError, subprocess.TimeoutExpired) as exc: + return 1, str(exc) + return proc.returncode, (proc.stdout if proc.returncode == 0 else proc.stderr).strip() + + +def _parse_ts(value: str | None) -> datetime | None: + if not value: + return None + return datetime.fromisoformat(value) + + +def forge_head(remote_url: str | None) -> dict[str, Any]: + if not remote_url: + return {"commit": None, "error": "no remote_url"} + code, out = _run(["git", "ls-remote", remote_url, "HEAD"], timeout=30) + if code != 0 or not out: + return {"commit": None, "error": (out or "empty ls-remote")[-200:]} + return {"commit": out.split()[0], "error": None} + + +def projection_sync(api: str, slug: str) -> dict[str, Any]: + try: + snap = _get(api, f"/repos/{slug}/work-record-projection/snapshot") + except Exception as exc: # noqa: BLE001 - evidence collector records any failure + return {"error": str(exc)[-200:]} + stamps = [w.get("backing_synced_at") for w in snap.get("workplans", [])] + stamps = [s for s in stamps if s] + return { + "error": None, + "workplans": len(snap.get("workplans", [])), + "tasks": len(snap.get("tasks", [])), + "dependencies": len(snap.get("dependencies", [])), + "last_backing_synced_at": max(stamps) if stamps else None, + } + + +def live_definitions() -> dict[str, Any]: + ns, name = DEFINITIONS_CONFIGMAP + code, out = _run(["kubectl", "-n", ns, "get", "configmap", name, "-o", "json"]) + if code != 0: + return {"error": out[-200:], "c_rule_definitions": []} + data = json.loads(out).get("data", {}) + found = [] + for key, body in sorted(data.items()): + if C_RULE_QUERY not in body and "repo-manager" not in body.lower(): + continue + field = { + f: (m.group(1).strip().strip('"') if m else None) + for f, m in ( + ("id", re.search(r"^\s*id:\s*(.+)$", body, re.MULTILINE)), + ("version", re.search(r"^\s*version:\s*(.+)$", body, re.MULTILINE)), + ("enabled", re.search(r"^\s*enabled:\s*(\S+)", body, re.MULTILINE)), + ("status", re.search(r"^\s*status:\s*(\S+)", body, re.MULTILINE)), + ("cron", re.search(r"cron_expression:\s*\"?([^\"\n]+)", body)), + ) + } + found.append({"key": key, **field}) + return {"error": None, "configmap": f"{ns}/{name}", "c_rule_definitions": found} + + +def runner_revisions(repo_root: Path) -> dict[str, Any]: + code, head = _run(["git", "-C", str(repo_root), "rev-parse", "HEAD"]) + images = {} + for ns, deploy in (("state-hub", "state-hub"), ("activity-core", "actcore-worker")): + c, img = _run( + [ + "kubectl", + "-n", + ns, + "get", + "deploy", + deploy, + "-o", + "jsonpath={.spec.template.spec.containers[0].image}", + ] + ) + images[f"{ns}/{deploy}"] = img if c == 0 else f"unavailable: {img[-120:]}" + c, rm = _run(["kubectl", "get", "deploy", "-A", "-o", "name"]) + rm_deployed = [d for d in rm.splitlines() if "repo-manager" in d] if c == 0 else None + return { + "repo_manager_source_head": head if code == 0 else None, + "repo_manager_cluster_deployments": rm_deployed, + "images": images, + } + + +def summarize(rows: list[dict[str, Any]], now: datetime) -> dict[str, Any]: + """Pure summary of per-repository rows (unit-tested).""" + active = [r for r in rows if r.get("status") == "active"] + ages = [] + for r in active: + ts = _parse_ts(r.get("last_backing_synced_at")) + r["age_hours"] = round((now - ts).total_seconds() / 3600, 1) if ts else None + if ts: + ages.append(r["age_hours"]) + ages.sort() + + def within(h: float) -> int: + return sum(1 for a in ages if a <= h) + + def pct(p: float) -> float | None: + if not ages: + return None + return ages[min(len(ages) - 1, round(p * (len(ages) - 1)))] + + return { + "repos_total": len(rows), + "repos_active": len(active), + "active_with_projection_sync": len(ages), + "active_never_synced_or_no_workplans": len(active) - len(ages), + "active_forge_head_resolved": sum(1 for r in active if r.get("forge_commit")), + "synced_within_24h": within(24), + "synced_within_7d": within(24 * 7), + "synced_within_30d": within(24 * 30), + "age_hours": { + "min": ages[0] if ages else None, + "median": statistics.median(ages) if ages else None, + "p90": pct(0.9), + "max": ages[-1] if ages else None, + }, + } + + +def measure(api: str, repo_root: Path, workers: int = 8) -> dict[str, Any]: + now = datetime.now(UTC) + health = _get(api, "/state/health") + repos = _get(api, "/repos/?limit=1000") + + def row(repo: dict[str, Any]) -> dict[str, Any]: + forge = forge_head(repo.get("remote_url")) if repo.get("status") == "active" else {} + proj = projection_sync(api, repo["slug"]) + return { + "slug": repo["slug"], + "status": repo.get("status"), + "remote_url": repo.get("remote_url"), + "forge_commit": forge.get("commit"), + "forge_error": forge.get("error"), + "hub_last_state_synced_at": repo.get("last_state_synced_at"), + "last_backing_synced_at": proj.get("last_backing_synced_at"), + "projection_error": proj.get("error"), + "workplans": proj.get("workplans"), + "tasks": proj.get("tasks"), + "dependencies": proj.get("dependencies"), + } + + with ThreadPoolExecutor(max_workers=workers) as pool: + rows = sorted(pool.map(row, repos), key=lambda r: r["slug"]) + + code, outbox = _run(["statehub", "outbox", "status"]) + try: + outbox_status = json.loads(outbox) if code == 0 else {"error": outbox[-200:]} + except json.JSONDecodeError: + outbox_status = {"error": outbox[-200:]} + try: + ops_runs = _get(api, "/ops-runs/summary") + except Exception as exc: # noqa: BLE001 + ops_runs = {"error": str(exc)[-200:]} + + return { + "schema": "repo-manager.freshness-coverage.v1", + "measured_at": now.isoformat().replace("+00:00", "Z"), + "hub": {k: health.get(k) for k in ("status", "instance_role", "instance_label")} + | {"api_base": api, "schema": health.get("schema")}, + "runner": runner_revisions(repo_root), + "active_definition": live_definitions(), + "receipts": { + "edge_outbox": outbox_status, + "ops_runs_summary": ops_runs, + }, + "summary": summarize(rows, now), + "repositories": rows, + "gaps": [ + ( + "State Hub does not persist per-repository projection receipts or the " + "derived/expected source commit; `rmgr sync` returns the receipt to its " + "caller only. last_backing_synced_at is the closest supported proxy for " + "'last successful receipt' and proves no commit equality with forge_commit." + ), + ( + "Failed receipts are not persisted anywhere queryable (mutation meter has " + "no sync/projection kinds; ops-runs summary depends on ACTIVITY_CORE_URL)." + ), + ( + "No supported scheduled C-rule runner exists; freshness is driven only by " + "manual/agent-initiated syncs, so the window below is observed, not an SLA." + ), + ], + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--api-base", default="http://127.0.0.1:8000") + parser.add_argument("--repo-root", default=str(Path(__file__).resolve().parents[2])) + parser.add_argument("--out", default=None) + args = parser.parse_args(argv) + result = measure(args.api_base.rstrip("/"), Path(args.repo_root)) + text = json.dumps(result, indent=2, sort_keys=False) + "\n" + if args.out: + Path(args.out).write_text(text) + print(json.dumps(result["summary"], indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/freshness_receipts.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/freshness_receipts.py new file mode 100644 index 00000000..ba76285f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/freshness_receipts.py @@ -0,0 +1,154 @@ +"""Durable freshness receipts (RMGR-WP-0020-T01). + +Contract ``repo-manager.freshness-receipt.v1``: one immutable JSON object per +repository attempt of the bounded freshness runner. It records the repository, +the Forge commit the runner read, the expected/derived/projected commits bound by +State Hub's ``state-hub.repository-projection-reconcile.v1`` receipt, the +outcome, the reason, the runner revision, the Activity Core definition and fire, +timings, and the verified primary-hub identity. + +Store: an append-only JSON Lines file in the Repo Manager evidence sink, next to +the mutation meter (``~/.repo-manager/mutation-meter.jsonl``) and the +idempotency store. This is repo-manager's existing persistence pattern. The +State Hub DB is deliberately not used, because the hub is the system under +measurement and is a read model. The file needs no new service, it is readable +with ``jq``/``rmgr freshness receipts``, and appends are serialised with an +advisory lock and fsynced. A deployed runner must mount this path on a +persistent volume (T04). + +Path resolution: ``RM_FRESHNESS_RECEIPTS_PATH`` env, then +``freshness_receipts_path`` in the dual-run config file, then the default. +""" + +from __future__ import annotations + +import fcntl +import json +import os +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + +from repo_manager.dual_run import _load_file_config + +SCHEMA = "repo-manager.freshness-receipt.v1" +DEFAULT_PATH = "~/.repo-manager/freshness-receipts.jsonl" + +# ``noop`` covers both "hub reported nothing to change" and "Forge HEAD already +# equals the last successful receipt" (then ``reconciled`` is False). +# ``queued`` and ``skipped`` are reserved by the contract; the bounded runner +# never queues and does not write receipts for repositories it did not attempt. +OUTCOMES = ("applied", "noop", "queued", "failed", "refused", "skipped") +SUCCESS_OUTCOMES = frozenset({"applied", "noop"}) + + +@dataclass +class HubIdentity: + api_base: str + instance_role: str | None + instance_label: str | None + expected_instance_label: str | None + verified_primary: bool + + +@dataclass +class FreshnessReceipt: + receipt_id: str + repo_slug: str + outcome: str + reason: str | None + forge_commit: str | None + forge_ref: str | None + expected_commit: str | None + derived_commit: str | None + projected_commit: str | None + reconciled: bool + http_status: int | None + hub: HubIdentity + runner_revision: str + definition_id: str | None + definition_version: str | None + fire_id: str + started_at: str + finished_at: str + elapsed_ms: float + detail: dict[str, Any] = field(default_factory=dict) + schema: str = SCHEMA + + def __post_init__(self) -> None: + if self.outcome not in OUTCOMES: + raise ValueError(f"invalid freshness outcome: {self.outcome!r}") + if not self.hub.verified_primary: + raise ValueError("a freshness receipt requires a verified primary hub") + + @property + def succeeded(self) -> bool: + return self.outcome in SUCCESS_OUTCOMES + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def receipts_path() -> Path: + raw = os.environ.get("RM_FRESHNESS_RECEIPTS_PATH") + if raw: + return Path(raw).expanduser() + cfg = _load_file_config() + if cfg.get("freshness_receipts_path"): + return Path(str(cfg["freshness_receipts_path"])).expanduser() + return Path(DEFAULT_PATH).expanduser() + + +def append_receipt(receipt: FreshnessReceipt, path: Path | None = None) -> Path: + """Append one receipt durably (locked, flushed and fsynced).""" + path = path or receipts_path() + path.parent.mkdir(parents=True, exist_ok=True) + line = json.dumps(receipt.to_dict(), sort_keys=True, default=str) + "\n" + with path.open("a", encoding="utf-8") as handle: + fcntl.flock(handle, fcntl.LOCK_EX) + try: + handle.write(line) + handle.flush() + os.fsync(handle.fileno()) + finally: + fcntl.flock(handle, fcntl.LOCK_UN) + return path + + +def load_receipts(path: Path | None = None, repo_slug: str | None = None) -> list[dict[str, Any]]: + """Read receipts in append order; malformed or foreign lines are ignored.""" + path = path or receipts_path() + if not path.is_file(): + return [] + rows: list[dict[str, Any]] = [] + with path.open(encoding="utf-8") as handle: + for raw in handle: + try: + row = json.loads(raw) + except json.JSONDecodeError: + continue + if not isinstance(row, dict) or row.get("schema") != SCHEMA: + continue + if repo_slug and row.get("repo_slug") != repo_slug: + continue + rows.append(row) + return rows + + +@dataclass +class ReceiptIndex: + last: dict[str, dict[str, Any]] = field(default_factory=dict) + last_success: dict[str, dict[str, Any]] = field(default_factory=dict) + + +def index_receipts(rows: list[dict[str, Any]]) -> ReceiptIndex: + """Latest receipt and latest successful receipt per repository.""" + index = ReceiptIndex() + for row in rows: + slug = row.get("repo_slug") + if not slug: + continue + index.last[slug] = row + if row.get("outcome") in SUCCESS_OUTCOMES and row.get("forge_commit"): + index.last_success[slug] = row + return index diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/freshness_runner.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/freshness_runner.py new file mode 100644 index 00000000..0527bb0f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/freshness_runner.py @@ -0,0 +1,447 @@ +"""Bounded Forge-sourced freshness runner (RMGR-WP-0020-T02). + +``rmgr freshness run --limit N --max-seconds S [--dry-run]`` + +1. Verify that the answering State Hub is the primary (``instance_role`` and + ``instance_label``). Otherwise refuse the whole run and write no receipts. +2. Rank active repositories stalest first. Repositories without a receipt come + first, ordered by ``backing_synced_at`` (missing first). Then come the rest, + ordered by the time of their latest receipt of any outcome, so that a + permanently refused hole cannot starve the rotation. +3. For at most N repositories, read the Forge default-branch HEAD over the + Forgejo HTTP API. Skip with ``noop`` (no hub request) when it equals the + commit of the last successful receipt. +4. Otherwise ask State Hub to derive the projection from that immutable Forge + commit (``POST /repos/{slug}/work-record-projection/reconcile``, + STATE-WP-0086). There is no checkout, no ``--all --remote`` and no + ``/consistency/sweep/remote-all``. ``acknowledge_retirements`` is always + false, so a hub refusal is recorded as ``refused`` and never forced. +5. Verify the hub receipt like ``projection_sync``: schema, primary, instance + label, and expected == derived == projected == Forge commit. +6. Persist one ``repo-manager.freshness-receipt.v1`` per attempted + repository. Stop starting work at ``max_seconds``. Each request timeout is + clipped to the remaining budget. +""" + +from __future__ import annotations + +import subprocess +import time +import uuid +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor, wait +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import httpx + +from repo_manager import __version__ +from repo_manager.freshness_receipts import ( + FreshnessReceipt, + HubIdentity, + ReceiptIndex, + append_receipt, + index_receipts, + load_receipts, + receipts_path, +) +from repo_manager.time import utc_now_text + +RUN_SCHEMA = "repo-manager.freshness-run.v1" +RECONCILE_SCHEMA = "state-hub.repository-projection-reconcile.v1" +DEFAULT_FORGE_BASE = "https://forgejo.coulomb.social" +DEFAULT_FORGE_OWNER = "coulomb" +# Never start a reconcile with less budget than this; the request would only +# time out with an unknown server-side outcome. +MIN_RECONCILE_SECONDS = 5.0 +# Share of max_seconds (capped at 60 s) for the pre-receipt backing_synced_at fallback. +SNAPSHOT_BUDGET_FRACTION = 0.25 + + +class HubNotPrimary(RuntimeError): + def __init__(self, identity: dict[str, Any]): + super().__init__("answering State Hub is not the verified primary") + self.identity = identity + + +@dataclass +class RunConfig: + limit: int + max_seconds: float + dry_run: bool = False + api_base: str = "http://127.0.0.1:8000" + expected_instance_label: str = "railiance01" + forge_base: str = DEFAULT_FORGE_BASE + forge_owner: str = DEFAULT_FORGE_OWNER + forge_token: str | None = None + receipts_path: Path | None = None + runner_revision: str | None = None + definition_id: str | None = None + definition_version: str | None = None + fire_id: str | None = None + snapshot_workers: int = 8 + + +@dataclass +class Candidate: + slug: str + has_receipt: bool + rank_ts: str | None + last_success_commit: str | None = None + detail: dict[str, Any] = field(default_factory=dict) + + +def runner_revision() -> str: + """Source revision of this runner: git HEAD of the package checkout or version.""" + root = Path(__file__).resolve().parents[2] + try: + proc = subprocess.run( + ["git", "-C", str(root), "rev-parse", "HEAD"], + capture_output=True, text=True, timeout=5, check=False, + ) + except (OSError, subprocess.TimeoutExpired): + proc = None + if proc is not None and proc.returncode == 0 and proc.stdout.strip(): + return proc.stdout.strip() + return f"repo-manager=={__version__}" + + +def rank_candidates( + active_slugs: list[str], + index: ReceiptIndex, + backing_synced_at: dict[str, str | None], +) -> list[Candidate]: + """Pure stalest-first ranking (unit-tested).""" + never: list[Candidate] = [] + seen: list[Candidate] = [] + for slug in active_slugs: + last = index.last.get(slug) + success = index.last_success.get(slug) + commit = success.get("forge_commit") if success else None + if last is None: + never.append(Candidate(slug, False, backing_synced_at.get(slug), commit)) + else: + seen.append(Candidate(slug, True, last.get("finished_at"), commit)) + # ISO-8601 UTC strings sort chronologically; None (never synced) sorts first. + never.sort(key=lambda c: (c.rank_ts is not None, c.rank_ts or "", c.slug)) + seen.sort(key=lambda c: (c.rank_ts or "", c.slug)) + return never + seen + + +class _Budget: + def __init__(self, max_seconds: float, clock: Callable[[], float]): + self.clock = clock + self.deadline = clock() + max_seconds + + def remaining(self) -> float: + return self.deadline - self.clock() + + +def _identity(health: dict[str, Any], cfg: RunConfig) -> HubIdentity: + role = health.get("instance_role") + label = health.get("instance_label") + return HubIdentity( + api_base=cfg.api_base.rstrip("/"), + instance_role=role, + instance_label=label, + expected_instance_label=cfg.expected_instance_label, + verified_primary=role == "primary" and label == cfg.expected_instance_label, + ) + + +def _forge_head(forge: httpx.Client, owner: str, slug: str, timeout: float) -> dict[str, Any]: + try: + repo = forge.get(f"/api/v1/repos/{owner}/{slug}", timeout=timeout) + repo.raise_for_status() + branch = repo.json().get("default_branch") or "main" + resp = forge.get(f"/api/v1/repos/{owner}/{slug}/branches/{branch}", timeout=timeout) + resp.raise_for_status() + commit = (resp.json().get("commit") or {}).get("id") + except httpx.HTTPStatusError as exc: + return {"commit": None, "ref": None, + "error": f"forge HTTP {exc.response.status_code}"} + except (httpx.HTTPError, ValueError) as exc: + return {"commit": None, "ref": None, "error": f"forge unavailable: {exc}"[:300]} + if not isinstance(commit, str) or len(commit) != 40: + return {"commit": None, "ref": branch, "error": "forge returned no full commit id"} + return {"commit": commit.lower(), "ref": branch, "error": None} + + +def _backing_synced(hub: httpx.Client, slugs: list[str], workers: int, + budget: _Budget) -> dict[str, str | None]: + """Fallback ranking key, bounded to a fraction of the run budget. + + Repositories whose snapshot fails or does not answer in time rank as never + synced (first), which is the conservative choice. + """ + if not slugs: + return {} + phase = max(min(budget.remaining() * SNAPSHOT_BUDGET_FRACTION, 60.0), 0.5) + request_timeout = min(20.0, phase) + + def one(slug: str) -> str | None: + try: + resp = hub.get(f"/repos/{slug}/work-record-projection/snapshot", + timeout=request_timeout) + resp.raise_for_status() + stamps = [w.get("backing_synced_at") for w in resp.json().get("workplans", [])] + except (httpx.HTTPError, ValueError, AttributeError): + return None + stamps = [s for s in stamps if s] + return max(stamps) if stamps else None + + pool = ThreadPoolExecutor(max_workers=max(1, workers)) + futures = {pool.submit(one, slug): slug for slug in slugs} + done, _ = wait(futures, timeout=phase) + # Do not wait for stragglers; in-flight requests end within request_timeout. + pool.shutdown(wait=False, cancel_futures=True) + return {slug: (f.result() if f in done else None) for f, slug in futures.items()} + + +def _verify_reconcile(receipt: Any, identity: HubIdentity, slug: str, + commit: str) -> str | None: + """Return why a hub response is not a valid exact-commit primary receipt.""" + if not isinstance(receipt, dict) or receipt.get("schema") != RECONCILE_SCHEMA: + return "unexpected receipt schema" + if receipt.get("instance_role") != "primary": + return "receipt not issued by a primary hub" + if receipt.get("instance_label") != identity.instance_label: + return "receipt instance label differs from the verified primary" + outcome = receipt.get("outcome") + if not isinstance(outcome, dict): + return "receipt has no outcome" + if not (receipt.get("expected_commit") == commit + and receipt.get("derived_commit") == commit + and outcome.get("commit") == commit): + return "expected/derived/projected commit does not equal the Forge commit" + if outcome.get("repo_slug") != slug: + return "receipt binds a different repository" + if outcome.get("status") not in {"applied", "noop", "refused"}: + return f"unexpected outcome status {outcome.get('status')!r}" + return None + + +def _http_error_detail(exc: httpx.HTTPStatusError) -> Any: + try: + body = exc.response.json() + except ValueError: + return exc.response.text[:300] + return body.get("detail", body) if isinstance(body, dict) else body + + +def _reconcile(hub: httpx.Client, identity: HubIdentity, slug: str, commit: str, + timeout: float) -> dict[str, Any]: + """One immutable-commit reconcile. Returns receipt fields (no store write).""" + path = f"/repos/{slug}/work-record-projection/reconcile" + headers = { + "Idempotency-Key": f"rmgr-freshness:v1:{slug}:{commit}", + "X-StateHub-Source-Agent": "repo-manager", + } + try: + resp = hub.post(path, json={"expected_commit": commit, "acknowledge_retirements": False}, + headers=headers, timeout=timeout) + resp.raise_for_status() + body = resp.json() + except httpx.TimeoutException: + return {"outcome": "failed", "reason": "hub_timeout_outcome_unknown", + "http_status": None} + except httpx.HTTPStatusError as exc: + status = exc.response.status_code + detail = _http_error_detail(exc) + fields: dict[str, Any] = {"outcome": "failed", "http_status": status, + "detail": {"hub_error": detail}} + if status == 409 and isinstance(detail, dict) and "derived_commit" in detail: + fields["reason"] = "forge_moved_during_run" + fields["derived_commit"] = detail.get("derived_commit") + elif status == 409 and isinstance(detail, dict) and "instance_role" in detail: + fields["reason"] = "hub_not_primary" + fields["abort"] = True + fields["no_receipt"] = True + elif status == 424: + fields["reason"] = "forge_unreadable_by_hub" + elif status == 502: + fields["reason"] = "hub_derivation_failed" + else: + fields["reason"] = f"hub_http_{status}" + return fields + except (httpx.HTTPError, ValueError) as exc: + return {"outcome": "failed", "reason": "hub_unavailable", "http_status": None, + "detail": {"error": str(exc)[:300]}, "abort": True} + + invalid = _verify_reconcile(body, identity, slug, commit) + if invalid: + # Kill criterion: never persist an unverified primary receipt as success. + identity_fault = "primary" in invalid or "instance label" in invalid + return {"outcome": "failed", "reason": f"invalid_receipt: {invalid}", + "http_status": resp.status_code, "abort": True, + "no_receipt": identity_fault, + "derived_commit": body.get("derived_commit") if isinstance(body, dict) else None} + outcome = body["outcome"] + status = outcome["status"] + fields = { + "outcome": status, + "reason": None, + "http_status": resp.status_code, + "expected_commit": body["expected_commit"], + "derived_commit": body["derived_commit"], + "projected_commit": outcome["commit"], + "detail": {"counts": outcome.get("counts"), "notes": outcome.get("notes")}, + } + if status == "refused": + refused = outcome.get("refused") or [] + fields["reason"] = "hub_refused" + fields["detail"]["refused"] = refused[:20] + return fields + + +def run( + cfg: RunConfig, + *, + hub_transport: httpx.BaseTransport | None = None, + forge_transport: httpx.BaseTransport | None = None, + clock: Callable[[], float] = time.monotonic, +) -> dict[str, Any]: + if cfg.limit < 1: + raise ValueError("--limit must be at least 1") + if cfg.max_seconds <= 0: + raise ValueError("--max-seconds must be positive") + budget = _Budget(cfg.max_seconds, clock) + started_at = utc_now_text() + fire_id = cfg.fire_id or str(uuid.uuid4()) + revision = cfg.runner_revision or runner_revision() + store = cfg.receipts_path or receipts_path() + forge_headers = {"Authorization": f"token {cfg.forge_token}"} if cfg.forge_token else {} + + summary: dict[str, Any] = { + "schema": RUN_SCHEMA, + "fire_id": fire_id, + "dry_run": cfg.dry_run, + "limit": cfg.limit, + "max_seconds": cfg.max_seconds, + "runner_revision": revision, + "definition_id": cfg.definition_id, + "definition_version": cfg.definition_version, + "receipts_path": str(store), + "started_at": started_at, + } + + with httpx.Client(base_url=cfg.api_base.rstrip("/"), follow_redirects=True, + timeout=httpx.Timeout(30.0, connect=5.0), + transport=hub_transport) as hub, \ + httpx.Client(base_url=cfg.forge_base.rstrip("/"), follow_redirects=True, + timeout=httpx.Timeout(15.0, connect=5.0), headers=forge_headers, + transport=forge_transport) as forge: + health = hub.get("/state/health") + health.raise_for_status() + identity = _identity(health.json(), cfg) + summary["hub"] = vars(identity) + if not identity.verified_primary: + raise HubNotPrimary(vars(identity)) + + repos = hub.get("/repos/", params={"limit": 1000}) + repos.raise_for_status() + rows = {r["slug"]: r for r in repos.json() + if isinstance(r, dict) and r.get("status") == "active" and r.get("slug")} + active = sorted(rows) + index = index_receipts(load_receipts(store)) + never = [s for s in active if s not in index.last] + synced = _backing_synced(hub, never, cfg.snapshot_workers, budget) + # A slow or failing snapshot falls back to the repository row's own + # last_state_synced_at, which costs no extra request. + fallback_used = 0 + for slug in never: + if synced.get(slug) is None and rows[slug].get("last_state_synced_at"): + synced[slug] = rows[slug]["last_state_synced_at"] + fallback_used += 1 + ranked = rank_candidates(active, index, synced) + selected = ranked[: cfg.limit] + summary["active_repositories"] = len(active) + summary["never_received"] = len(never) + summary["rank_fallback_last_state_synced_at"] = fallback_used + + results: list[dict[str, Any]] = [] + deferred: list[str] = [] + aborted: str | None = None + for position, cand in enumerate(selected): + if aborted or budget.remaining() <= 0: + deferred.extend(c.slug for c in selected[position:]) + break + t0 = clock() + attempt_started = utc_now_text() + head = _forge_head(forge, cfg.forge_owner, cand.slug, + timeout=min(15.0, max(budget.remaining(), 0.5))) + row: dict[str, Any] = { + "repo_slug": cand.slug, + "rank_key": {"has_receipt": cand.has_receipt, "ts": cand.rank_ts}, + "forge_commit": head["commit"], + "forge_ref": head["ref"], + "last_success_commit": cand.last_success_commit, + } + fields: dict[str, Any] + if head["commit"] is None: + fields = {"outcome": "failed", "reason": "forge_head_unresolved", + "http_status": None, "detail": {"forge_error": head["error"]}} + row["action"] = "fail" + elif head["commit"] == cand.last_success_commit: + fields = {"outcome": "noop", "reason": "forge_head_matches_last_success", + "http_status": None} + row["action"] = "skip_noop" + elif cfg.dry_run: + row["action"] = "reconcile" + results.append(row) + continue + elif budget.remaining() < MIN_RECONCILE_SECONDS: + deferred.extend(c.slug for c in selected[position:]) + break + else: + row["action"] = "reconcile" + fields = _reconcile(hub, identity, cand.slug, head["commit"], + timeout=min(120.0, budget.remaining())) + row.update({k: v for k, v in fields.items() if k not in {"abort", "no_receipt"}}) + if fields.get("abort"): + aborted = fields["reason"] + # Never persist a receipt whose primary identity the hub did not confirm. + if fields.get("no_receipt"): + row["receipt_withheld"] = True + elif not cfg.dry_run: + receipt = FreshnessReceipt( + receipt_id=str(uuid.uuid4()), + repo_slug=cand.slug, + outcome=fields["outcome"], + reason=fields.get("reason"), + forge_commit=head["commit"], + forge_ref=head["ref"], + expected_commit=fields.get("expected_commit"), + derived_commit=fields.get("derived_commit"), + projected_commit=fields.get("projected_commit"), + reconciled=row["action"] == "reconcile", + http_status=fields.get("http_status"), + hub=identity, + runner_revision=revision, + definition_id=cfg.definition_id, + definition_version=cfg.definition_version, + fire_id=fire_id, + started_at=attempt_started, + finished_at=utc_now_text(), + elapsed_ms=round((clock() - t0) * 1000, 1), + detail=fields.get("detail") or {}, + ) + append_receipt(receipt, store) + row["receipt_id"] = receipt.receipt_id + results.append(row) + + counts: dict[str, int] = {} + for row in results: + key = row.get("outcome") or f"plan:{row.get('action')}" + counts[key] = counts.get(key, 0) + 1 + summary.update({ + "selected": [c.slug for c in selected], + "results": results, + "deferred": deferred, + "aborted": aborted, + "counts": counts, + "finished_at": utc_now_text(), + "elapsed_seconds": round(cfg.max_seconds - budget.remaining(), 2), + }) + return summary diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/gitops.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/gitops.py new file mode 100644 index 00000000..6b2a625c --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/gitops.py @@ -0,0 +1,91 @@ +"""Git helpers via subprocess (ADR-001).""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +# The admitted receive scanner has a 90-second total budget. Leave bounded +# headroom for transport and Git hooks; a timeout never proves remote refusal. +PUSH_TIMEOUT_SECONDS = 120 + + +class GitError(RuntimeError): + pass + + +def _run(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run( + ["git", *args], + cwd=repo, + capture_output=True, + text=True, + check=False, + ) + if check and proc.returncode != 0: + raise GitError(proc.stderr.strip() or proc.stdout.strip() or f"git {' '.join(args)} failed") + return proc + + +def head_sha(repo: Path) -> str | None: + proc = _run(repo, "rev-parse", "HEAD", check=False) + if proc.returncode != 0: + return None + return proc.stdout.strip() or None + + +def is_git_repo(repo: Path) -> bool: + return (repo / ".git").exists() or _run(repo, "rev-parse", "--git-dir", check=False).returncode == 0 + + +def commit_paths( + repo: Path, + paths: list[str], + message: str, + *, + author_name: str = "repo-manager", + author_email: str = "repo-manager@local", +) -> str: + """Stage paths and commit. Returns new HEAD sha.""" + env_author = [ + "-c", + f"user.name={author_name}", + "-c", + f"user.email={author_email}", + ] + for path in paths: + _run(repo, "add", "--", path) + proc = subprocess.run( + ["git", *env_author, "commit", "-m", message], + cwd=repo, + capture_output=True, + text=True, + check=False, + ) + if proc.returncode != 0: + raise GitError(proc.stderr.strip() or proc.stdout.strip() or "git commit failed") + sha = head_sha(repo) + if not sha: + raise GitError("commit succeeded but HEAD missing") + return sha + + +def push_ff(repo: Path) -> tuple[bool, str]: + """Best-effort push (push-seal compatible). Never force-pushes.""" + try: + proc = subprocess.run( + ["git", "push"], + cwd=repo, + capture_output=True, + text=True, + timeout=PUSH_TIMEOUT_SECONDS, + check=False, + ) + if proc.returncode == 0: + return True, (proc.stdout.strip() or "pushed") + return False, (proc.stderr.strip() or proc.stdout.strip() or "push failed") + except subprocess.TimeoutExpired: + return False, "push timed out; remote outcome unverified; inspect the remote ref before retrying" + except Exception as exc: # noqa: BLE001 + return False, str(exc) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/historical_sources.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/historical_sources.py new file mode 100644 index 00000000..5ff0fa3d --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/historical_sources.py @@ -0,0 +1,211 @@ +"""Reviewed closed-source exceptions; enable only after receiving deployment. + +No aliases, new IDs, binding replacement or source edits are authorized. +Strict identity scans retain nonconformance findings for these legacy labels. +""" +from hashlib import sha256 +import json + +BINDINGS = json.loads(r'''[ + { + "repo": "activity-core", + "path": "workplans/archived/260707-custodian-WP-0003-event-bridge.md", + "sha256": "83ea8bcb151e003419b73c59dbd028f4a1ce3d1120a82e4a128a772503ff3b76", + "record_id": "custodian-WP-0003", + "uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5", + "parent_record_id": "custodian-WP-0003" + }, + { + "repo": "activity-core", + "path": "workplans/custodian-WP-0001-temporal-backbone.md", + "sha256": "020e2eac973f2daa41c1687f8e867da9d521f945547e6aa82c87993b3ff6fc64", + "record_id": "custodian-WP-0001", + "uuid": "e22516cc-4fdf-4dad-a928-a9e04a9fa7c7", + "parent_record_id": "custodian-WP-0001" + }, + { + "repo": "activity-core", + "path": "workplans/custodian-WP-0002-triggers-ops.md", + "sha256": "d8cbc61631c01b6f11a03a63f2fb6275fc853ff7f7459ce65973ed1a233daa01", + "record_id": "custodian-WP-0002", + "uuid": "3a4f47d9-8bc1-434e-acb4-bed5d4dacda0", + "parent_record_id": "custodian-WP-0002" + }, + { + "repo": "activity-core", + "path": "workplans/custodian-WP-0003a-event-bridge-model-rules-registry.md", + "sha256": "ffa6682e9ba3aaf559bd44203d561c90dea69bcf1b1e778209a7b209e6e5cdd3", + "record_id": "custodian-WP-0003a", + "uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5", + "parent_record_id": "custodian-WP-0003" + }, + { + "repo": "activity-core", + "path": "workplans/custodian-WP-0003b-event-bridge-parser-workflow-triggers.md", + "sha256": "4fc34e0f821e2df51e5c08c7ab34da35b65690381d01f0cfdf55d00c970b414c", + "record_id": "custodian-WP-0003b", + "uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5", + "parent_record_id": "custodian-WP-0003" + }, + { + "repo": "activity-core", + "path": "workplans/custodian-WP-0003c-event-bridge-context-integration.md", + "sha256": "b6cd62b26515e509670ca39736b6bcec9633beedd46c9bd8f7598c06bac7276b", + "record_id": "custodian-WP-0003c", + "uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5", + "parent_record_id": "custodian-WP-0003" + }, + { + "repo": "activity-core", + "path": "workplans/custodian-WP-0004-railiance-ops.md", + "sha256": "20df02eea740e109c937130e7f8801a636fc0990ed8c6d2ced55659f8957adb1", + "record_id": "custodian-WP-0004", + "uuid": "759b1255-aa78-42b5-8fab-c5fcf168f5f4", + "parent_record_id": "custodian-WP-0004" + }, + { + "repo": "the-custodian", + "path": "workplans/ADHOC-2026-08-25-retired.md", + "sha256": "344a1293dbb3a67a944eb34ca4a0337487d8f047226027a2107922346fbab2dd", + "record_id": "CUST-WP-ADHOC-2026-08-25-RETIRED", + "uuid": "1c53d3db-6633-5e2d-987f-10706d73be9e", + "parent_record_id": "CUST-WP-ADHOC-2026-08-25-RETIRED" + }, + { + "repo": "railiance-cluster", + "path": "workplans/archived/260622-RAIL-BS-WP-0002-k3s-baseline.md", + "sha256": "8d6f8533c119117686bc0d5bb9ed5349726aa6c3189ff98b53409c68ac685ca9", + "record_id": "RAIL-BS-WP-0002", + "uuid": "4c63dfc6-9eac-4e79-9f77-8f644ad7147d", + "parent_record_id": "RAIL-BS-WP-0002", + "tasks": [ + { + "label": "T01", + "uuid": "3f042630-eab0-4c6a-9167-e2b28ff20e40", + "status": "done" + }, + { + "label": "T02", + "uuid": "e8510646-46ed-4697-a345-f3d3009eea78", + "status": "done" + }, + { + "label": "T03", + "uuid": "dab2c07f-8aa0-4635-8df6-857e87e93fc5", + "status": "done" + }, + { + "label": "T04", + "uuid": "5c3d40e4-239b-488e-9519-6f7a38d2325f", + "status": "done" + }, + { + "label": "T05", + "uuid": "7f9e0e58-a130-467a-a2d0-b3f2564e496f", + "status": "done" + } + ] + }, + { + "repo": "railiance-cluster", + "path": "workplans/archived/260622-RAIL-BS-WP-0003-pgpool-ha-failover-fix.md", + "sha256": "4dc2f61e15adc3c5c81f9ecf8b933512275b95e8f829fac52ae7cde1385cea4a", + "record_id": "RAIL-BS-WP-0003", + "uuid": "7ee9ee22-1fae-4567-9194-8d70a9e0f45b", + "parent_record_id": "RAIL-BS-WP-0003", + "tasks": [ + { + "label": "T01", + "uuid": "6841c93a-f146-47eb-9f7c-8fa0e02c1bbc", + "status": "done" + }, + { + "label": "T02", + "uuid": "ab166073-30a7-4702-a037-4091e8706e20", + "status": "done" + }, + { + "label": "T03", + "uuid": "140da396-8e30-4f4d-b88c-c42c0cd46c01", + "status": "done" + }, + { + "label": "T04", + "uuid": "d8a3ba40-fda0-4c1f-a9f1-ffcd621a5b3d", + "status": "done" + } + ] + }, + { + "repo": "railiance-cluster", + "path": "workplans/archived/260622-RAIL-BS-WP-0004-safety-net.md", + "sha256": "04f787078232395bd2d43861de16c521848559fa286f06e1caa9cb779aa71ac3", + "record_id": "RAIL-BS-WP-0004", + "uuid": "7e8b0c20-51eb-40c9-9e3b-85dd380d7625", + "parent_record_id": "RAIL-BS-WP-0004", + "tasks": [ + { + "label": "T01", + "uuid": "4526a842-ea31-4874-9231-92ab556cfe7b", + "status": "done" + }, + { + "label": "T02", + "uuid": "a6313e06-1976-46a7-8e31-df4eb2eca880", + "status": "done" + }, + { + "label": "T03", + "uuid": "05d42a55-921f-4aa7-bb76-e8af9c7e0ac3", + "status": "done" + }, + { + "label": "T04", + "uuid": "08233868-d522-4117-bc4e-6c0f52545665", + "status": "done" + }, + { + "label": "T05", + "uuid": "2d5acff7-4a4e-4ddd-ad06-08237ad3dac8", + "status": "done" + }, + { + "label": "T06", + "uuid": "f8e4a094-c367-40eb-b895-da17bc144b07", + "status": "done" + } + ] + } +]''') + + +def historical_source_paths(repo_root): + entries = [entry for entry in BINDINGS if entry["repo"] == repo_root.name] + paths = set() + for entry in entries: + path = repo_root / entry["path"] + if path.is_symlink() or not path.is_file() or sha256(path.read_bytes()).hexdigest() != entry["sha256"]: + raise ValueError("Historical source binding requires owner review: " + entry["path"]) + paths.add(entry["path"]) + return paths + + +def historical_source_occurrences(repo_root): + """Exact frozen closed task occurrences; never a general local-ID alias.""" + from repo_manager.parse.workplan import parse_workplan_file + from uuid import UUID + paths=historical_source_paths(repo_root) + occurrences=set(paths) + for entry in BINDINGS: + if entry['repo']!=repo_root.name or 'tasks' not in entry:continue + parsed=parse_workplan_file(repo_root/entry['path'],repo_root=repo_root) + actual=[{'label':task.id,'uuid':task.state_hub_task_id,'status':task.status} for task in parsed.tasks] + if (parsed.id!=entry['record_id'] or parsed.state_hub_workstream_id!=entry['uuid'] + or parsed.status not in {'finished','archived','completed'} or actual!=entry['tasks'] + or any(task['status'] not in {'done','cancel'} for task in actual)): + raise ValueError('Historical task binding requires owner review: '+entry['path']) + UUID(entry['uuid']) + for number,task in enumerate(actual,1): + UUID(task['uuid']) + occurrences.add(entry['path']+'#task-block-'+str(number)) + return occurrences diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/hub_access.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/hub_access.py new file mode 100644 index 00000000..307ebf9e --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/repo-manager/src/repo_manager/hub_access.py @@ -0,0 +1,111 @@ +"""Canonical State Hub access map for agent-facing AGENTS.md (CUST-WP-0067-T07). + +The port map used to be prose copied into ~180 AGENTS.md files. Topology +changes then required a fleet-wide rewrite. This module is the single source: +edit ``config/state-hub-access.yaml`` and refresh with +``rmgr scaffold --refresh-hub-access``. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import yaml + +BEGIN = "" +END = "" +DEFAULT_CONTRACT = Path(__file__).resolve().parents[2] / "config" / "state-hub-access.yaml" + +_FALLBACK: dict[str, Any] = { + "endpoints": [ + {"context": "Local workstation", "url": "http://127.0.0.1:8000"}, + {"context": "Remote (railiance01, in-cluster)", "url": "http://10.43.68.154:8000"}, + {"context": "Optional local edge relay", "url": "http://127.0.0.1:18080"}, + ], + "retired": [ + { + "url": "http://127.0.0.1:18000", + "reason": "reverse tunnel back to the workstation; the hub is in-cluster on railiance01", + } + ], +} + +_TABLE_RE = re.compile( + r"\| Context \| URL \|\r?\n\|[-:| ]+\|\r?\n(?:\|[^\n]*\|\r?\n)+", + re.MULTILINE, +) +_MARKED_RE = re.compile( + re.escape(BEGIN) + r".*?" + re.escape(END), + re.DOTALL, +) + + +def load_access(path: Path | None = None) -> dict[str, Any]: + target = path or DEFAULT_CONTRACT + if target.is_file(): + payload = yaml.safe_load(target.read_text(encoding="utf-8")) or {} + if isinstance(payload, dict) and payload.get("endpoints"): + return payload + return _FALLBACK + + +def render_hub_access_block(access: dict[str, Any] | None = None) -> str: + data = access or load_access() + rows = ["| Context | URL |", "|---------|-----|"] + for item in data.get("endpoints") or []: + context = str(item.get("context") or "").strip() + url = str(item.get("url") or "").strip() + rows.append(f"| {context} | `{url}` |") + retired = data.get("retired") or [] + note_lines: list[str] = [] + for item in retired: + url = str(item.get("url") or "").strip() + if not url: + continue + note_lines.append( + f"Do not instruct a remote agent to use `{url}`. That reverse tunnel " + "is retired; on railiance01 reach the hub at the in-cluster address above." + ) + body = "\n".join(rows) + if note_lines: + body = body + "\n\n" + "\n".join(note_lines) + return f"{BEGIN}\n{body}\n{END}" + + +def _table_is_hub_access(table: str) -> bool: + lowered = table.lower() + return "local workstation" in lowered and ( + "railiance01" in lowered or "10.43.68.154" in lowered or "127.0.0.1:18000" in lowered + ) + + +def apply_hub_access_block(text: str, block: str | None = None) -> tuple[str, str]: + """Insert or replace the canonical access table. Returns (new_text, action).""" + block = block or render_hub_access_block() + if block in text: + return text, "noop" + marked = _MARKED_RE.search(text) + if marked: + return text[: marked.start()] + block + text[marked.end() :], "replaced-marked" + for match in _TABLE_RE.finditer(text): + if _table_is_hub_access(match.group(0)): + return text[: match.start()] + block + "\n" + text[match.end() :], "replaced-table" + heading = re.search(r"^## State Hub Integration\s*$", text, re.MULTILINE) + if heading: + insert_at = heading.end() + return text[:insert_at] + "\n\n" + block + text[insert_at:], "inserted" + if text and not text.endswith("\n"): + text += "\n" + return text + "\n## State Hub Integration\n\n" + block + "\n", "appended" + + +def live_urls(access: dict[str, Any] | None = None) -> list[str]: + data = access or load_access() + return [str(item.get("url") or "").strip() for item in data.get("endpoints") or []] + + +def retired_urls(access: dict[str, Any] | None = None) -> list[str]: + data = access or load_access() + return [str(item.get("url") or "").strip() for item in data.get("retired") or []]