From 014bbd28931589ae9916b6822fa74f458a823b0c Mon Sep 17 00:00:00 2001 From: codex Date: Sun, 1 Nov 2026 22:58:27 +0100 Subject: [PATCH] docs: preserve reviewed NetKingdom session artifacts --- .../260622-RAIL-BS-WP-0004-safety-net.md | 275 +++ .../state-hub/tests/__init__.py | 0 .../260622-RAIL-BS-WP-0002-k3s-baseline.md | 177 ++ ...-RAIL-BS-WP-0003-pgpool-ha-failover-fix.md | 196 ++ .../260622-RAIL-BS-WP-0004-safety-net.md | 275 +++ .../test_cluster_historical_projection.py | 67 + .../state-hub/tests/test_forge_projection.py | 1660 +++++++++++++++++ .../tests/test_historical_projection.py | 135 ++ ...ATE-WP-0095-historical-source-receiving.md | 231 +++ .../validation.json | 27 + .../decision-auth/README.md | 28 + .../access-engine-original-dirty.patch | 77 + .../integration-manifest-r1.json | 57 + .../integration-manifest-r3.json | 43 + .../decision-auth/integration-manifest.json | 68 + .../secrets-decision-r3-scanner.json | 1 + .../secrets-engine-original-dirty.patch | 92 + .../decision-auth/source-provenance.json | 12 + .../decision-auth/verification-r2.json | 42 + .../decision-handoff/README.md | 29 + .../decision-handoff/implementation-r1.patch | 600 ++++++ .../decision-handoff/implementation-r2.patch | 622 ++++++ .../integration-manifest-r1.json | 71 + .../original-owner-dirty.patch | 96 + .../decision-handoff/r1-to-r2.patch | 40 + .../secrets-task-handoff-r2-scanner.json | 1 + .../secrets-task-handoff-scanner.json | 1 + .../decision-handoff/source-provenance.json | 6 + .../decision-native-preparation/README.md | 25 + .../canonical-bundle-build-r1.json | 1 + .../consumer-admission-r1/README.md | 19 + .../activity-core-metered-worker-token.yaml | 63 + .../catalog/email-connect-transactional.yaml | 78 + .../catalog/example-build-test-token.yaml | 44 + .../catalog/forgejo-admin-api-token.yaml | 77 + .../glas-claude-agent-dev-anthropic.yaml | 104 ++ .../catalog/issue-core-ingestion-api-key.yaml | 78 + .../catalog/openrouter-llm-connect.yaml | 74 + .../reuse-surface-hub-write-token.yaml | 77 + .../configuration/catalog/warden-sign.yaml | 102 + .../catalog/whynot-design-npm-publish.yaml | 78 + .../configuration/environment.proposed.json | 21 + .../configuration/pep-stance.yaml | 81 + .../netkingdom-publication-scope.yaml | 46 + .../policies/secrets-engine-build.hcl | 36 + .../policies/secrets-engine-prod.hcl | 63 + .../policies/secrets-engine-test.hcl | 33 + .../trust/lifecycle-pre-key.json | 9 + .../consumer-baseline.json | 95 + .../disposable-baseline-refusal.txt | 1 + 50 files changed, 6134 insertions(+) create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0004-safety-net.md create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/tests/__init__.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/tests/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0002-k3s-baseline.md create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/tests/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0003-pgpool-ha-failover-fix.md create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/tests/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0004-safety-net.md create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/tests/test_cluster_historical_projection.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/tests/test_forge_projection.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/tests/test_historical_projection.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/workplans/STATE-WP-0095-historical-source-receiving.md create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/validation.json create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-auth/README.md create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-auth/access-engine-original-dirty.patch create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-auth/integration-manifest-r1.json create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-auth/integration-manifest-r3.json create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-auth/integration-manifest.json create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-auth/secrets-decision-r3-scanner.json create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-auth/secrets-engine-original-dirty.patch create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-auth/source-provenance.json create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-auth/verification-r2.json create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-handoff/README.md create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-handoff/implementation-r1.patch create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-handoff/implementation-r2.patch create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-handoff/integration-manifest-r1.json create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-handoff/original-owner-dirty.patch create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-handoff/r1-to-r2.patch create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-handoff/secrets-task-handoff-r2-scanner.json create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-handoff/secrets-task-handoff-scanner.json create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-handoff/source-provenance.json create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/README.md create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/canonical-bundle-build-r1.json create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/README.md create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/activity-core-metered-worker-token.yaml create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/email-connect-transactional.yaml create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/example-build-test-token.yaml create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/forgejo-admin-api-token.yaml create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/glas-claude-agent-dev-anthropic.yaml create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/issue-core-ingestion-api-key.yaml create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/openrouter-llm-connect.yaml create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/reuse-surface-hub-write-token.yaml create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/warden-sign.yaml create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/whynot-design-npm-publish.yaml create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/environment.proposed.json create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/pep-stance.yaml create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/policies/netkingdom-publication-scope.yaml create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/policies/secrets-engine-build.hcl create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/policies/secrets-engine-prod.hcl create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/policies/secrets-engine-test.hcl create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/trust/lifecycle-pre-key.json create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/consumer-baseline.json create mode 100644 docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/disposable-baseline-refusal.txt diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/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/state-hub/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/state-hub/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/state-hub/tests/__init__.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/tests/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/state-hub/tests/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/state-hub/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 diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/tests/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/state-hub/tests/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/state-hub/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` diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/tests/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/state-hub/tests/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/state-hub/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/state-hub/tests/test_cluster_historical_projection.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/tests/test_cluster_historical_projection.py new file mode 100644 index 00000000..36db2dfa --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/tests/test_cluster_historical_projection.py @@ -0,0 +1,67 @@ +import shutil +import uuid +from pathlib import Path +import pytest +from api.services import forge_projection as fp +from api.services import historical_projection as hp +from tests.test_forge_projection import _FakeSession,_Repo,_Row,_TaskRow + +FIXTURE=Path(__file__).parent/'fixtures/railiance-cluster' +@pytest.fixture +def bound(tmp_path): + root=tmp_path/'railiance-cluster';shutil.copytree(FIXTURE,root) + derived=fp.derive_from_checkout(root,'railiance-cluster','candidate') + assert len(derived.workplans)==3 + rows=[];tasks=[] + for parent in derived.workplans: + row=_Row(parent.record_id.lower(),'finished',parent.relative_path);row.id=uuid.UUID(parent.uuid);rows.append(row) + for source in parent.tasks: + task=_TaskRow(source.record_id,status='done',workplan_id=row.id);task.id=uuid.UUID(source.uuid);tasks.append(task) + assert len(tasks)==15 + return root,derived,rows,tasks + +def test_exact_three_sources_derive_only_frozen_15_task_uuids(bound): + root,derived,rows,tasks=bound + assert all(parent.historical_preservation for parent in derived.workplans) + assert all(parent.uuid!=fp.derived_record_uuid(parent.record_id) for parent in derived.workplans) + expected={item['uuid'] for entry in hp.BINDINGS if entry['repo']=='railiance-cluster' for item in entry['tasks']} + assert {str(t.id) for t in tasks}==expected + +@pytest.mark.asyncio +async def test_existing_rows_are_entirely_unchanged_on_normal_receiving(bound): + root,derived,rows,tasks=bound;before=[dict(x.__dict__) for x in rows+tasks] + session=_FakeSession(_Repo(),rows,task_rows=tasks) + outcome=await fp.reset_repository_projection(session,'railiance-cluster',derived=derived) + assert outcome.status=='noop' and not outcome.refused + assert [dict(x.__dict__) for x in rows+tasks]==before + assert not session.added and not session.deleted + +@pytest.mark.parametrize('defect',['missing-parent','missing-task','foreign-holder','nonterminal-task','nonterminal-parent','duplicate-current-label']) +@pytest.mark.asyncio +async def test_unsupported_actual_binding_refuses_before_any_mutation(bound,defect): + root,derived,rows,tasks=bound + if defect=='missing-parent':rows.pop() + elif defect=='missing-task':tasks.pop() + elif defect=='foreign-holder':tasks[0].workplan_id=uuid.uuid4() + elif defect=='nonterminal-task':tasks[0].status='progress' + elif defect=='duplicate-current-label':tasks.append(_TaskRow(tasks[0].record_id,status='done',workplan_id=tasks[0].workplan_id)) + else:rows[0].status='active' + before=[dict(x.__dict__) for x in rows+tasks];session=_FakeSession(_Repo(),rows,task_rows=tasks) + outcome=await fp.reset_repository_projection(session,'railiance-cluster',derived=derived) + assert outcome.status=='refused';assert [dict(x.__dict__) for x in rows+tasks]==before + assert not session.added and not session.deleted + +@pytest.mark.parametrize('change',['hash-drift','missing','symlink','live-source','missing-source-uuid']) +def test_frozen_source_drift_refuses_derivation(bound,change): + root,*_=bound;p=next((root/'workplans/archived').glob('*.md'));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-source':p.write_bytes(raw.replace(b'status: finished',b'status: active',1)) + elif change=='missing-source-uuid':p.write_bytes(raw.replace(b'state_hub_task_id:',b'old_task_id:',1)) + else:p.write_bytes(raw+b'\nunreviewed\n') + with pytest.raises(fp.ForgeDeriveError,match='Historical source binding'):fp.derive_from_checkout(root,'railiance-cluster','candidate') + +def test_unknown_repository_never_trusts_frozen_source_uuids(bound): + root,derived,*_=bound;other=fp.derive_from_checkout(root,'different-repo','candidate') + assert not any(parent.historical_preservation for parent in other.workplans) + assert not {parent.uuid for parent in derived.workplans}&{parent.uuid for parent in other.workplans} diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/tests/test_forge_projection.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/tests/test_forge_projection.py new file mode 100644 index 00000000..d1f7e889 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/tests/test_forge_projection.py @@ -0,0 +1,1660 @@ +"""Deriving a projection from the forge (STATE-WP-0083-T01). + +The properties that matter are identity and determinism: a forge-derived +projection must compute the same record identities as repo-manager, and the same +commit must always yield the same projection. Without both, the reset in T03 +cannot be verified against anything. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from api.services import forge_credential as fc + +from api.services import forge_projection as fp + + +def test_task_description_retains_nested_sections_and_matches_consistency(monkeypatch): + monkeypatch.syspath_prepend(str(Path(__file__).parents[1] / "scripts")) + from scripts.consistency_check import parse_task_blocks + + body = '''## Factory admission + +```task +id: HFACT-WP-0001-T01 +status: progress +description: Earlier inline summary. +intervention_note: "" +blocking_reason: "" +``` + +--- +Current native admission requirements. + +### Local proof + +Retain the source evidence and limitations. + +#### Remaining return + +Exact operator binding remains open. +--- + +## Next task + +```task +id: HFACT-WP-0001-T02 +status: wait +``` + +Separate next-task description. +''' + derived = fp._parse_tasks(body, "HFACT-WP-0001") + authoritative = parse_task_blocks(body) + assert derived[0].description == authoritative[0]["description"] + assert "### Local proof" in derived[0].description + assert "#### Remaining return" in derived[0].description + assert "Next task" not in derived[0].description + assert not derived[0].description.startswith("---") + assert derived[0].intervention_note == "" + assert derived[0].blocking_reason == "" + assert derived[1].description == authoritative[1]["description"] + assert derived[1].intervention_note is None + + +@pytest.mark.parametrize("heading", ["", "# Workplan\n", "## Task\n", "### Task\n"]) +def test_task_description_stops_before_next_task_block(heading, monkeypatch): + monkeypatch.syspath_prepend(str(Path(__file__).parents[1] / "scripts")) + from scripts.consistency_check import parse_task_blocks + + body = heading + '''```task +id: DEMO-WP-0001-T01 +status: todo +``` + +First description. + +```task +id: DEMO-WP-0001-T02 +status: wait +description: Inline fallback. +intervention_note: null +blocking_reason: null +``` +''' + derived = fp._parse_tasks(body, "DEMO-WP-0001") + authoritative = parse_task_blocks(body) + assert derived[0].description == authoritative[0]["description"] == "First description." + assert derived[1].description == authoritative[1]["description"] == "Inline fallback." + assert derived[1].intervention_note is derived[1].blocking_reason is None + + +def test_identity_matches_the_fleet_derivation(): + """Same namespace as ADR-007, so overlay and forge agree on identity.""" + assert fp.derived_record_uuid("CUST-WP-0067") == "16249302-2767-55df-aec0-d92c2751c225" + assert fp.derived_record_uuid("CUST-WP-0067-T01") == "f3608db4-20a5-58fb-a965-885eb14858af" + + +def _repo(tmp_path: Path) -> Path: + root = tmp_path / "demo" + (root / "workplans" / "archived").mkdir(parents=True) + (root / "workplans" / "DEMO-WP-0001-a.md").write_text( + "---\nid: DEMO-WP-0001\ntype: workplan\ntitle: \"First\"\nstatus: active\n---\n\n" + "## Do the thing\n\n```task\nid: DEMO-WP-0001-T01\nstatus: todo\npriority: high\n```\n\n" + "## Do the other\n\n```task\nid: DEMO-WP-0001-T02\nstatus: done\npriority: low\n```\n", + encoding="utf-8", + ) + (root / "workplans" / "archived" / "260101-DEMO-WP-0002-b.md").write_text( + "---\nid: DEMO-WP-0002\ntype: workplan\ntitle: \"Second\"\nstatus: finished\n---\n\n# b\n", + encoding="utf-8", + ) + (root / "workplans" / "NOTES.md").write_text( + "---\nid: NOT-A-WORKPLAN\ntype: note\n---\n\n# not a workplan\n", encoding="utf-8" + ) + return root + + +def test_derives_workplans_tasks_and_archived_flag(tmp_path): + p = fp.derive_from_checkout(_repo(tmp_path), "demo", "abc123") + assert [w.record_id for w in p.workplans] == ["DEMO-WP-0001", "DEMO-WP-0002"] + first, second = p.workplans + assert first.status == "active" and first.archived is False + assert second.archived is True + assert p.task_count == 2 + assert [t.record_id for t in first.tasks] == ["DEMO-WP-0001-T01", "DEMO-WP-0001-T02"] + + +def test_ignores_files_that_are_not_workplans(tmp_path): + """Selection is by `type: workplan`; anything else is not this hub's business.""" + p = fp.derive_from_checkout(_repo(tmp_path), "demo", "abc123") + assert all(w.record_id != "NOT-A-WORKPLAN" for w in p.workplans) + + +def test_task_titles_fall_back_to_the_preceding_heading(tmp_path): + p = fp.derive_from_checkout(_repo(tmp_path), "demo", "abc123") + titles = [t.title for t in p.workplans[0].tasks] + assert titles == ["Do the thing", "Do the other"] + + +def test_identifiers_are_derived_not_read_from_the_file(tmp_path): + """A forge projection must not inherit whatever id a file happens to carry.""" + root = _repo(tmp_path) + f = root / "workplans" / "DEMO-WP-0001-a.md" + f.write_text( + f.read_text(encoding="utf-8").replace( + "status: active", + 'status: active\nstate_hub_workstream_id: "00000000-0000-4000-8000-000000000000"', + ), + encoding="utf-8", + ) + p = fp.derive_from_checkout(root, "demo", "abc123") + assert p.workplans[0].uuid == fp.derived_record_uuid("DEMO-WP-0001") + assert p.workplans[0].uuid != "00000000-0000-4000-8000-000000000000" + + +def test_same_input_yields_identical_output(tmp_path): + root = _repo(tmp_path) + assert fp.derive_from_checkout(root, "demo", "abc").to_dict() == \ + fp.derive_from_checkout(root, "demo", "abc").to_dict() + + +def test_missing_workplans_directory_is_empty_not_an_error(tmp_path): + (tmp_path / "bare").mkdir() + p = fp.derive_from_checkout(tmp_path / "bare", "bare", "abc") + assert p.workplans == [] and p.commit == "abc" + + +def test_clone_failure_is_reported_not_swallowed(monkeypatch): + def boom(*a, **k): + raise fp.ForgeDeriveError("repository not found") + monkeypatch.setattr(fp, "_run_git", boom) + with pytest.raises(fp.ForgeDeriveError, match="not found"): + fp.derive_from_forge("nope") + + +class TestReset: + """Applying the reset (STATE-WP-0083-T03). + + The properties worth guarding are the refusals, not the happy path. A reset + that quietly retires a record someone still needs is worse than one that + does nothing. + """ + + @staticmethod + def _derived(*records): + wps = [] + for rid, status, path in records: + wps.append( + fp.DerivedWorkplan( + record_id=rid, uuid=fp.derived_record_uuid(rid), title=rid, + status=status, relative_path=path, archived=False, tasks=[], + ) + ) + return fp.DerivedProjection(repo_slug="demo", commit="c0ffee", workplans=wps) + + @pytest.mark.asyncio + async def test_refuses_retirement_unless_acknowledged(self, monkeypatch): + """A record that stops deriving may mean a deleted file — or a wrong branch.""" + session = _FakeSession( + repo=_Repo(), + rows=[_Row(slug="demo-wp-0001", status="active", path="workplans/a.md")], + ) + out = await fp.reset_repository_projection( + session, "demo", derived=self._derived() # forge has nothing + ) + assert out.status == "refused" + assert out.refused and out.refused[0]["slug"] == "demo-wp-0001" + assert out.retired == [] + assert session.committed is False + + @pytest.mark.asyncio + async def test_retires_when_acknowledged(self): + row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md") + session = _FakeSession(repo=_Repo(), rows=[row]) + out = await fp.reset_repository_projection( + session, "demo", derived=self._derived(), acknowledge_retirements=True + ) + assert out.status == "applied" and out.retired == ["demo-wp-0001"] + assert row.projection_retired_at is not None + assert row.projection_retired_reason == fp.RETIRE_REASON + + @pytest.mark.asyncio + async def test_retirement_is_not_deletion(self): + """Hub-native records reference workplans with RESTRICT; the row survives.""" + row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md") + session = _FakeSession(repo=_Repo(), rows=[row]) + await fp.reset_repository_projection( + session, "demo", derived=self._derived(), acknowledge_retirements=True + ) + assert row in session.rows + assert session.deleted == [] + + @pytest.mark.asyncio + async def test_unretires_a_record_that_derives_again(self): + from datetime import datetime, timezone + row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md") + row.projection_retired_at = datetime.now(tz=timezone.utc) + row.projection_retired_reason = fp.RETIRE_REASON + session = _FakeSession(repo=_Repo(), rows=[row]) + out = await fp.reset_repository_projection( + session, "demo", + derived=self._derived(("DEMO-WP-0001", "active", "workplans/a.md")), + ) + assert out.status == "applied" + assert row.projection_retired_at is None + + @pytest.mark.asyncio + async def test_unregistered_repository_is_refused_not_created(self): + session = _FakeSession(repo=None, rows=[]) + out = await fp.reset_repository_projection(session, "nope", derived=self._derived()) + assert out.status == "refused" + assert "not registered" in out.refused[0]["reason"] + + @pytest.mark.asyncio + async def test_records_the_commit_it_derived_from(self): + row = _Row(slug="demo-wp-0001", status="proposed", path="workplans/a.md") + session = _FakeSession(repo=_Repo(), rows=[row]) + await fp.reset_repository_projection( + session, "demo", + derived=self._derived(("DEMO-WP-0001", "active", "workplans/a.md")), + ) + assert row.derived_from_commit == "c0ffee" + assert row.status == "active" + + +class _Repo: + def __init__(self): + import uuid as _u + self.id = _u.uuid4() + self.topic_id = None + + +class _Row: + def __init__(self, slug, status, path, title=None): + import uuid as _u + self.id = _u.uuid4() + self.slug = slug + self.status = status + # The title is a derived field the reset syncs; the fixture carried no + # `title` at all, so it could not have caught the drift that left + # cust-wp-0010 describing another workplan's work. + self.title = title + self.backing_relative_path = path + self.backing_filename = path.rsplit("/", 1)[-1] + self.backing_archived = False + self.projection_retired_at = None + self.projection_retired_reason = None + self.derived_from_commit = None + + +class _FakeSession: + """Stands in for AsyncSession: enough to prove intent without a database.""" + + def __init__( + self, + repo, + rows, + foreign=None, + slug_clash=None, + task_rows=None, + task_collision_rows=None, + retirement_tasks=None, + retirement_edges=None, + ): + self._repo = repo + self.rows = list(rows) + self._foreign = list(foreign or []) + self._slug_clash = list(slug_clash or []) + self._task_rows = task_rows + self._task_collision_rows = list(task_collision_rows or []) + # Exact-set guard only. None leaves the call sequence used by older tests. + self._retirement_tasks = retirement_tasks + self._retirement_edges = retirement_edges + self._served_retirement_tasks = False + self._served_retirement_edges = False + self.added = [] + self.deleted = [] + self.committed = False + self._calls = 0 + + async def execute(self, statement=None, *_a, **_k): + try: + text = "" if statement is None else str(statement).lower() + except Exception: + text = "" + # These queries sit ahead of the older call sequence. Answer them + # without consuming that sequence, and only the first of each: later + # task and edge loads still follow the call counter. + if ( + self._retirement_tasks is not None + and not self._served_retirement_tasks + and "from tasks" in text + and "workplan_id" in text + ): + self._served_retirement_tasks = True + return self._result(self._retirement_tasks) + if ( + self._retirement_edges is not None + and not self._served_retirement_edges + and "from workplan_dependencies" in text + ): + self._served_retirement_edges = True + return self._result(self._retirement_edges) + + self._calls += 1 + rows = self.rows + # 1 resolves the repo, 2 loads its workplans, 3 is the identifier + # lookup, 4 the slug lookup. On the update-only path (no creates) + # the 3rd call is the existing-task load instead. + update_only = ( + self._task_rows is not None + and not self._foreign + and not self._slug_clash + ) + if self._calls == 2: + payload = rows + elif update_only and self._calls == 3: + payload = self._task_rows + elif update_only and self._calls == 4: + payload = self._task_collision_rows + elif self._calls == 3: + payload = self._foreign + elif self._calls == 4: + payload = self._slug_clash + elif self._calls == 5: + payload = self._task_collision_rows + else: + payload = [] + + return self._result(payload) + + def _result(self, payload): + repo = self._repo + + class R: + def scalar_one_or_none(self_inner): + return repo + + def scalars(self_inner): + return iter(payload) + + return R() + + def add(self, obj): + self.added.append(obj) + + def delete(self, obj): + self.deleted.append(obj) + + async def flush(self): + return None + + async def commit(self): + self.committed = True + + async def rollback(self): + self.rolled_back = True + + +class TestCollisionRefusal: + """A colliding identifier must refuse, not raise (STATE-WP-0083-T03). + + net-kingdom's ADHOC-2026-08-23 derives to an identifier another repository + already holds — the case CUST-WP-0066 documents. The reset previously failed + on a database constraint, which tells the caller nothing they can act on. + """ + + @staticmethod + def _derived(rid="DEMO-WP-0001"): + return fp.DerivedProjection( + repo_slug="demo", commit="c0ffee", + workplans=[fp.DerivedWorkplan( + record_id=rid, uuid=fp.derived_record_uuid(rid), title=rid, + status="active", relative_path="workplans/a.md", + archived=False, tasks=[])], + ) + + @pytest.mark.asyncio + async def test_refuses_when_the_identifier_belongs_elsewhere(self): + derived = self._derived() + foreign = _Row(slug="held-by-someone-else", status="finished", path="workplans/x.md") + foreign.id = __import__("uuid").UUID(derived.workplans[0].uuid) + session = _FakeSession(repo=_Repo(), rows=[], foreign=[foreign]) + out = await fp.reset_repository_projection(session, "demo", derived=derived) + assert out.status == "refused" + assert out.refused[0]["reason"].startswith("derived identifier already belongs") + assert out.refused[0]["held_by_slug"] == "held-by-someone-else" + assert out.created == [] and session.added == [] + + @pytest.mark.asyncio + async def test_acknowledging_retirements_does_not_authorise_a_collision(self): + """Different decision, different authorisation.""" + derived = self._derived() + foreign = _Row(slug="held-by-someone-else", status="finished", path="workplans/x.md") + foreign.id = __import__("uuid").UUID(derived.workplans[0].uuid) + session = _FakeSession(repo=_Repo(), rows=[], foreign=[foreign]) + out = await fp.reset_repository_projection( + session, "demo", derived=derived, acknowledge_retirements=True + ) + assert out.status == "refused" + assert session.added == [] + + @pytest.mark.asyncio + async def test_no_collision_still_creates(self): + derived = self._derived() + session = _FakeSession(repo=_Repo(), rows=[], foreign=[]) + out = await fp.reset_repository_projection(session, "demo", derived=derived) + assert out.status == "applied" and out.created == ["DEMO-WP-0001"] + + +class TestFleetReset: + """The fleet form must be a loop over the repository form (T04). + + Its value is entirely in what it does with failure: a wide reset that stops + at the first refusal is one nobody can run, because there is always one + unresolved repository somewhere. + """ + + class _Factory: + def __init__(self, sessions): + self._sessions = list(sessions) + + def __call__(self): + session = self._sessions.pop(0) + class Ctx: + async def __aenter__(self_inner): + return session + async def __aexit__(self_inner, *a): + return False + return Ctx() + + @staticmethod + def _derived(rid): + return fp.DerivedProjection( + repo_slug="x", commit="c0ffee", + workplans=[fp.DerivedWorkplan( + record_id=rid, uuid=fp.derived_record_uuid(rid), title=rid, + status="active", relative_path="workplans/a.md", archived=False, tasks=[])]) + + @pytest.mark.asyncio + async def test_a_refusal_does_not_stop_the_pass(self, monkeypatch): + calls = [] + + async def fake(session, slug, **kw): + calls.append(slug) + out = fp.ResetOutcome(repo_slug=slug, commit="c0ffee", status="applied") + if slug == "bad": + out.status = "refused" + out.refused.append({"reason": "would be retired", "slug": "x"}) + else: + out.updated.append("A-WP-0001") + return out + + monkeypatch.setattr(fp, "reset_repository_projection", fake) + s = [_FakeSession(repo=_Repo(), rows=[]) for _ in range(3)] + res = await fp.reset_fleet_projection(self._Factory(s), ["good", "bad", "also-good"]) + assert calls == ["good", "bad", "also-good"] + d = res.to_dict() + assert d["by_status"] == {"applied": 2, "refused": 1} + assert d["totals"]["updated"] == 2 + + @pytest.mark.asyncio + async def test_an_error_does_not_stop_the_pass(self, monkeypatch): + async def fake(session, slug, **kw): + if slug == "boom": + raise RuntimeError("clone failed") + return fp.ResetOutcome(repo_slug=slug, commit="c", status="noop") + + monkeypatch.setattr(fp, "reset_repository_projection", fake) + s = [_FakeSession(repo=_Repo(), rows=[]) for _ in range(3)] + res = await fp.reset_fleet_projection(self._Factory(s), ["a", "boom", "b"]) + d = res.to_dict() + assert d["errored"] == 1 and "clone failed" in res.errors["boom"] + assert set(res.results) == {"a", "b"} + + @pytest.mark.asyncio + async def test_only_applied_repositories_are_committed(self, monkeypatch): + async def fake(session, slug, **kw): + out = fp.ResetOutcome(repo_slug=slug, commit="c", status="applied") + if slug == "refuser": + out.status = "refused" + else: + out.updated.append("A-WP-0001") + return out + + monkeypatch.setattr(fp, "reset_repository_projection", fake) + s = [_FakeSession(repo=_Repo(), rows=[]) for _ in range(2)] + await fp.reset_fleet_projection(self._Factory(s), ["applier", "refuser"]) + assert s[0].committed is True + assert s[1].committed is False + + +class TestSlugCollisionRefusal: + """slug carries its own unique constraint (STATE-WP-0083-T04). + + Checking the identifier alone left disaster-control raising IntegrityError: + two repositories can derive different identifiers whose slugs still collide. + """ + + @pytest.mark.asyncio + async def test_refuses_when_the_slug_belongs_elsewhere(self): + derived = fp.DerivedProjection( + repo_slug="demo", commit="c0ffee", + workplans=[fp.DerivedWorkplan( + record_id="REPO-WP-0001", uuid=fp.derived_record_uuid("REPO-WP-0001"), + title="x", status="active", relative_path="workplans/a.md", + archived=False, tasks=[])]) + clash = _Row(slug="repo-wp-0001", status="finished", path="workplans/other.md") + session = _FakeSession(repo=_Repo(), rows=[], foreign=[], slug_clash=[clash]) + out = await fp.reset_repository_projection(session, "demo", derived=derived) + assert out.status == "refused" + assert out.refused[0]["reason"].startswith("slug already belongs") + assert session.added == [] + + +class TestUnreadableIsNotMissing: + """STATE-WP-0084-T01. + + A repository central is not permitted to read and a repository whose + records no longer derive authorise opposite things. Every test here exists + to keep the retirement path unreachable from an answer that cannot support + it — by construction, not by the reset happening to fail first. + """ + + @pytest.mark.parametrize( + "stderr", + [ + "fatal: could not read Username for 'https://forgejo.coulomb.social'", + "remote: Invalid username or password.\nfatal: Authentication failed", + "fatal: could not read Username for 'https://f': terminal prompts disabled", + "fatal: repository 'https://forgejo.coulomb.social/rapp-openbao.git' not found", + "fatal: unable to access '...': The requested URL returned error: 403", + ], + ) + def test_permission_shaped_failures_are_classified_unreadable(self, stderr, monkeypatch): + def boom(*a, **k): + raise fp.ForgeDeriveError(stderr) + + monkeypatch.setattr(fp, "_run_git", boom) + with pytest.raises(fp.ForgeUnreadableError): + fp.derive_from_forge("rapp-openbao") + + def test_a_genuine_fault_stays_a_plain_error(self, monkeypatch): + def boom(*a, **k): + raise fp.ForgeDeriveError("fatal: early EOF\nfatal: index-pack failed") + + monkeypatch.setattr(fp, "_run_git", boom) + with pytest.raises(fp.ForgeDeriveError) as exc: + fp.derive_from_forge("demo") + assert not isinstance(exc.value, fp.ForgeUnreadableError) + + def test_unreadable_is_a_derive_error_so_old_callers_still_catch_it(self): + assert issubclass(fp.ForgeUnreadableError, fp.ForgeDeriveError) + + def test_a_checkout_without_workplans_cannot_evidence_absence(self, tmp_path): + (tmp_path / "bare").mkdir() + p = fp.derive_from_checkout(tmp_path / "bare", "bare", "abc") + assert p.workplans == [] + assert p.records_source_present is False + assert p.retirement_eligible is False + + def test_a_real_checkout_can(self, tmp_path): + p = fp.derive_from_checkout(_repo(tmp_path), "demo", "abc") + assert p.records_source_present is True + assert p.retirement_eligible is True + + def test_the_diff_withholds_stale_rather_than_computing_it(self): + derived = fp.DerivedProjection( + repo_slug="demo", commit="c0ffee", records_source_present=False + ) + hub = [{"id": "11111111-1111-1111-1111-111111111111", "slug": "demo-wp-0001", + "status": "active", "backing_relative_path": "workplans/a.md"}] + d = fp.diff_against_hub(derived, hub, {}) + assert d.stale == [] + assert d.would_remove == 0 + assert d.stale_withheld + + @pytest.mark.asyncio + async def test_an_empty_source_cannot_retire_even_when_acknowledged(self): + derived = fp.DerivedProjection( + repo_slug="demo", commit="c0ffee", records_source_present=False + ) + row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md") + session = _FakeSession(repo=_Repo(), rows=[row]) + out = await fp.reset_repository_projection( + session, "demo", derived=derived, acknowledge_retirements=True + ) + assert out.status == "refused" + assert out.retired == [] + assert row.projection_retired_at is None + assert out.refused[0]["reason"].startswith("source produced no records") + + @pytest.mark.asyncio + async def test_an_unreadable_repository_reports_unreadable_not_error(self, monkeypatch): + def boom(*a, **k): + raise fp.ForgeUnreadableError("rapp-openbao could not be read from the forge") + + monkeypatch.setattr(fp, "derive_from_forge", boom) + session = _FakeSession(repo=_Repo(), rows=[]) + out = await fp.reset_repository_projection(session, "rapp-openbao") + assert out.status == "unreadable" + assert out.retired == [] and out.created == [] and out.updated == [] + + @pytest.mark.asyncio + async def test_the_fleet_keeps_unreadable_out_of_the_error_bucket(self, monkeypatch): + async def fake(session, slug, **kw): + if slug == "private": + out = fp.ResetOutcome(repo_slug=slug, commit="", status="unreadable") + out.refused.append({"reason": "repository could not be read from the forge", + "slug": slug, "detail": "could not read Username"}) + return out + if slug == "broken": + raise RuntimeError("index-pack failed") + out = fp.ResetOutcome(repo_slug=slug, commit="c0ffee", status="applied") + out.updated.append("A-WP-0001") + return out + + monkeypatch.setattr(fp, "reset_repository_projection", fake) + sessions = [_FakeSession(repo=_Repo(), rows=[]) for _ in range(3)] + outcome = await fp.reset_fleet_projection( + TestFleetReset._Factory(sessions), ["ok", "private", "broken"] + ) + assert list(outcome.unreadable) == ["private"] + assert list(outcome.errors) == ["broken"] + assert "private" not in outcome.results + d = outcome.to_dict() + assert d["unreadable_count"] == 1 and d["errored"] == 1 + assert d["repositories"] == 3 + + +class TestForgeCredential: + """Optional forge read credential (STATE-WP-0084-T03). + + Absent is a valid state: a hub with no credential must still derive every + public repository. The credential must never reach argv, a log, or an + exception — the places a secret leaks without anyone deciding to leak it. + """ + + @pytest.fixture(autouse=True) + def _clear_cache(self): + """The resolved credential is cached for 5 minutes in production. + + That cache is deliberate — a fleet reset of 121 repositories must not + authenticate to OpenBao 121 times — so tests clear it rather than + disable it, and exercise the same code path production uses. + """ + fc.reset_cache() + yield + fc.reset_cache() + + def test_absent_credential_is_none_not_empty_string(self, monkeypatch): + monkeypatch.delenv(fc.TOKEN_ENV, raising=False) + monkeypatch.delenv(fc.TOKEN_FILE_ENV, raising=False) + assert fc.forge_read_token() is None + + def test_a_file_is_preferred_over_the_environment(self, tmp_path, monkeypatch): + """Kubernetes rotates a mounted file without a redeploy.""" + f = tmp_path / "token" + f.write_text("from-file\n", encoding="utf-8") + monkeypatch.setenv(fc.TOKEN_ENV, "from-env") + monkeypatch.setenv(fc.TOKEN_FILE_ENV, str(f)) + assert fc.forge_read_token() == "from-file" + + def test_an_unreadable_token_file_does_not_fall_back_silently(self, monkeypatch): + """Falling back to a stale env value would hide a broken mount.""" + monkeypatch.setenv(fc.TOKEN_FILE_ENV, "/nonexistent/token") + monkeypatch.setenv(fc.TOKEN_ENV, "from-env") + assert fc.forge_read_token() is None + + def test_openbao_is_the_last_resort_not_the_first(self, tmp_path, monkeypatch): + """A file or env value must not trigger a network call.""" + f = tmp_path / "token" + f.write_text("local", encoding="utf-8") + monkeypatch.setenv(fc.TOKEN_FILE_ENV, str(f)) + monkeypatch.setattr( + fc, "_from_openbao", lambda: pytest.fail("OpenBao consulted unnecessarily") + ) + assert fc.forge_read_token() == "local" + + def test_openbao_failure_resolves_to_absent_not_an_exception(self, monkeypatch): + """A hub that cannot reach OpenBao must still derive public repos. + + Raising here would turn "nine repositories are unreadable" into "the + whole derivation pass failed" — the outcome STATE-WP-0084-T01 exists to + prevent. + """ + monkeypatch.delenv(fc.TOKEN_FILE_ENV, raising=False) + monkeypatch.delenv(fc.TOKEN_ENV, raising=False) + monkeypatch.setenv(fc.OPENBAO_ADDR_ENV, "https://openbao.invalid") + monkeypatch.setenv(fc.SECRET_PATH_ENV, "kv/data/forge") + monkeypatch.setenv(fc.OPENBAO_ROLE_ENV, "state-hub-forge-derivation") + monkeypatch.setenv(fc.OPENBAO_JWT_PATH_ENV, "/nonexistent/sa-token") + assert fc.forge_read_token() is None + + def test_openbao_unwraps_kv_v2(self, tmp_path, monkeypatch): + jwt = tmp_path / "sa" + jwt.write_text("jwt-value", encoding="utf-8") + monkeypatch.delenv(fc.TOKEN_FILE_ENV, raising=False) + monkeypatch.delenv(fc.TOKEN_ENV, raising=False) + monkeypatch.setenv(fc.OPENBAO_ADDR_ENV, "https://openbao.test") + monkeypatch.setenv(fc.SECRET_PATH_ENV, "kv/data/forge") + monkeypatch.setenv(fc.OPENBAO_ROLE_ENV, "state-hub-forge-derivation") + monkeypatch.setenv(fc.OPENBAO_JWT_PATH_ENV, str(jwt)) + + class R: + def __init__(self, payload): + self._p = payload + + def raise_for_status(self): + return None + + def json(self): + return self._p + + class C: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def post(self, url, json): + assert json["jwt"] == "jwt-value" + assert json["role"] == "state-hub-forge-derivation" + return R({"auth": {"client_token": "bao-token"}}) + + def get(self, url, headers): + assert headers["X-Vault-Token"] == "bao-token" + return R({"data": {"data": {"token": "forge-secret"}}}) + + monkeypatch.setattr(fc.httpx, "Client", lambda **kw: C()) + assert fc.forge_read_token() == "forge-secret" + + def test_credential_never_appears_in_argv(self, monkeypatch): + """`-c http.extraHeader=` would put the token in every ps listing.""" + seen = {} + + class P: + returncode = 0 + stdout = "" + stderr = "" + + def fake_run(cmd, **kw): + seen["cmd"] = cmd + seen["env"] = kw.get("env") or {} + return P() + + monkeypatch.setattr(fp.subprocess, "run", fake_run) + fp._run_git("clone", "url", "dir", token="s3cret") + assert not any("s3cret" in part for part in seen["cmd"]) + assert seen["env"]["GIT_CONFIG_COUNT"] == "1" + assert "s3cret" not in seen["env"]["GIT_CONFIG_KEY_0"] + + def test_credential_is_redacted_from_failures(self, monkeypatch): + class P: + returncode = 128 + stdout = "" + stderr = "fatal: auth failed using s3cret" + + monkeypatch.setattr(fp.subprocess, "run", lambda *a, **k: P()) + with pytest.raises(fp.ForgeDeriveError) as exc: + fp._run_git("clone", token="s3cret") + assert "s3cret" not in str(exc.value) and "***" in str(exc.value) + + def test_no_credential_still_runs(self, monkeypatch): + class P: + returncode = 0 + stdout = "ok" + stderr = "" + seen = {} + + def fake_run(cmd, **kw): + seen["env"] = kw.get("env") or {} + return P() + + monkeypatch.setattr(fp.subprocess, "run", fake_run) + assert fp._run_git("status") == "ok" + assert "GIT_CONFIG_COUNT" not in seen["env"] + + +class TestRekeyIsNotRename: + """A changed identifier is a new record, not a moved file. + + The ad-hoc requalification (CUST-WP-0066) keeps the filename by design, so + for those records a re-key never changes the path. Matching on path there + cannot distinguish the two cases, and choosing rename updates a row whose + UUID still encodes the old identifier — leaving the file and the hub + disagreeing about what the record is called. + """ + + class _Row: + def __init__(self, id, slug, path): + self.id = id + self.slug = slug + self.backing_relative_path = path + self.projection_retired_at = None + self.status = "finished" + + def test_a_derived_row_is_not_matched_by_path(self): + """uuid5 means identity is a function of the identifier.""" + import uuid as _u + old = self._Row( + _u.UUID(fp.derived_record_uuid("ADHOC-2026-08-25")), + "adhoc-2026-08-25", + "workplans/ADHOC-2026-08-25.md", + ) + assert fp._identity_is_derived(old) + + def test_a_legacy_row_keeps_path_matching(self): + """v4 rows predate derived identity; there the identifier is a label.""" + import uuid as _u + legacy = self._Row(_u.uuid4(), "repo-wp-0001", "workplans/REPO-WP-0001.md") + assert not fp._identity_is_derived(legacy) + + def test_the_two_uuids_actually_differ(self): + """Guards the premise: if they were equal the distinction is moot.""" + assert fp.derived_record_uuid("ADHOC-2026-08-25") != fp.derived_record_uuid( + "CUST-WP-ADHOC-2026-08-25" + ) + + @pytest.mark.parametrize( + "slug", + [ + "adhoc-2026-07-02", # grandfathered daily ad-hoc + "cust-wp-adhoc-2026-08-25", # qualified daily ad-hoc + "net-kingdom-adhoc-2026-07-02", # repo-name-qualified legacy form + "adhoc-llmc-2026-06-02", # another legacy qualification + "rcluster-wp-0007", # ordinary workplan + "cust-wp-0066-t01", # task + ], + ) + def test_identifier_slugs_are_recognised(self, slug): + """A slug that claims to be an identifier is one, whatever its UUID. + + 25 of 44 ad-hoc rows are legacy v4, so a UUID-version test alone would + leave them path-matched and silently diverged from their files. + """ + assert fp._slug_is_identifier(slug) + + @pytest.mark.parametrize( + "slug", + ["three-phoenix-ha-cluster", "testdrive-jsui-publication", "state-hub-v0.1", ""], + ) + def test_title_slugs_keep_path_matching(self, slug): + """Hub-first rows never claimed an identifier; the path is their only link.""" + assert not fp._slug_is_identifier(slug) + + +class TestRetirementReleasesTheIdentifier: + """`slug` is unique table-wide, so retirement must free it. + + Retirement that only sets a timestamp leaves the identifier locked to a + record nothing derives, and the repository that owns it can never claim it. + """ + + def test_the_identifier_is_released(self): + from datetime import datetime, timezone + when = datetime(2026, 8, 28, tzinfo=timezone.utc) + assert fp._tombstone_slug("repo-wp-0001", when) == "repo-wp-0001@retired-20260828" + + def test_re_retiring_does_not_stack_marks(self): + """Otherwise the 100-char column overflows after a few passes.""" + from datetime import datetime, timezone + a = fp._tombstone_slug("repo-wp-0001", datetime(2026, 8, 28, tzinfo=timezone.utc)) + b = fp._tombstone_slug(a, datetime(2026, 9, 1, tzinfo=timezone.utc)) + assert b == "repo-wp-0001@retired-20260901" + assert b.count(fp.RETIRED_SLUG_MARK) == 1 + + def test_a_long_slug_stays_within_the_column(self): + from datetime import datetime, timezone + out = fp._tombstone_slug("x" * 140, datetime(2026, 8, 28, tzinfo=timezone.utc)) + assert len(out) <= 100 + + class _Retired: + def __init__(self, slug, when, id=None): + import uuid as _u + self.id = id or _u.uuid4() + self.slug = slug + self.projection_retired_at = when + self.projection_retired_reason = "x" + self.backing_relative_path = None + self.status = "finished" + + def test_rows_retired_before_the_stamp_existed_are_repaired(self): + """`stale` excludes already-retired rows, so nothing else revisits them. + + The 32 rows retired earlier today still hold their identifiers; without + this they would block the owning repository forever. + """ + from datetime import datetime, timezone + when = datetime(2026, 8, 28, tzinfo=timezone.utc) + row = self._Retired("repo-wp-0001", when) + assert fp.RETIRED_SLUG_MARK not in row.slug + row.slug = fp._tombstone_slug(row.slug, row.projection_retired_at) + assert row.slug == "repo-wp-0001@retired-20260828" + + def test_an_already_stamped_row_is_left_alone(self): + """Otherwise every reset rewrites the date and churns the row.""" + from datetime import datetime, timezone + row = self._Retired( + "repo-wp-0001@retired-20260828", datetime(2026, 8, 28, tzinfo=timezone.utc) + ) + assert fp.RETIRED_SLUG_MARK in row.slug + + def test_a_release_is_committed_even_when_the_repo_refuses(self): + """The fleet driver commits only on `applied` and rolls back otherwise. + + Moving the release ahead of the refusal returns achieves nothing if the + rollback then discards it — which is the state railiance-cluster and + inter-hub were in. + """ + import inspect + src = inspect.getsource(fp.reset_fleet_projection) + assert 'result.status == "applied" or result.released' in src + + +class TestUuidMatchWins: + """A row whose UUID is uuid5 of the identifier IS that record. + + `testdrive-jsui-publication` and `three-phoenix-ha-cluster` carry legacy + title slugs while their UUIDs are derived from MARKITECT-WP-0002 and + RCLUSTER-WP-0007. The slug-identifier rule alone reads them as re-keys and + proposes retiring records that were correct all along. + """ + + def test_a_title_slugged_row_is_still_its_derived_record(self): + import uuid as _u + rid = "MARKITECT-WP-0002" + row_id = _u.UUID(fp.derived_record_uuid(rid)) + # The slug says one thing, the UUID says another; the UUID is identity. + assert not fp._slug_is_identifier("testdrive-jsui-publication") + assert str(row_id) == fp.derived_record_uuid(rid) + + def test_uuid_match_is_checked_before_the_rekey_rules(self): + import inspect + src = inspect.getsource(fp.reset_repository_projection) + uuid_at = src.index("want_by_uuid.get(str(row.id))") + rekey_at = src.index("_identity_is_derived(row) or _slug_is_identifier") + assert uuid_at < rekey_at, "UUID match must precede the re-key heuristics" + + +class TestTaskIdentityIsQualified: + """A bare `T01` is unique within its workplan, not in the fleet. + + Unqualified, `uuid5("T01")` is the same UUID for every workplan that has a + T01 — llm-connect's 91 task blocks derive 49 distinct UUIDs, and creating + its workplans dies on `duplicate key value violates unique constraint + "tasks_pkey"`. + """ + + BODY = """ +```task +id: T01 +status: done +``` + +```task +id: T02 +status: todo +``` +""" + + def test_short_ids_are_qualified_by_their_workplan(self): + a = fp._parse_tasks(self.BODY, "LLM-WP-0001") + b = fp._parse_tasks(self.BODY, "LLM-WP-0002") + assert [t.record_id for t in a] == ["LLM-WP-0001-T01", "LLM-WP-0001-T02"] + assert {t.uuid for t in a}.isdisjoint({t.uuid for t in b}) + + def test_an_already_qualified_id_is_left_alone(self): + body = "```task\nid: LLM-WP-0009-T03\nstatus: todo\n```" + assert fp._parse_tasks(body, "LLM-WP-0001")[0].record_id == "LLM-WP-0009-T03" + + def test_it_agrees_with_the_backfill(self): + """Both must apply the same rule or they disagree on a task's name.""" + from api.services.task_record_id_backfill import qualify_task_id + got = fp._parse_tasks(self.BODY, "LLM-WP-0001")[0].record_id + assert got == qualify_task_id("T01", "LLM-WP-0001") + + def test_uuid_follows_the_qualified_id(self): + t = fp._parse_tasks(self.BODY, "LLM-WP-0001")[0] + assert t.uuid == fp.derived_record_uuid("LLM-WP-0001-T01") + + +class TestRetirementIsNotUndoneByPath: + """A retired row is matched by UUID or not at all. + + Releasing the identifier removes the evidence that made the row a re-key: + the tombstoned slug is no longer an identifier, and a legacy row is not + UUID-derived, so both guards fall through to path matching — which matches + the very file the row was retired for and resurrects it beside the correct + record created from that same file. + """ + + def test_a_tombstoned_slug_is_not_an_identifier(self): + """This is the signal the tombstone destroys.""" + assert fp._slug_is_identifier("adhoc-2026-06-01") + assert not fp._slug_is_identifier("adhoc-2026-06-01@retired-20260827") + + def test_retired_rows_are_skipped_before_path_matching(self): + import inspect + src = inspect.getsource(fp.reset_repository_projection) + skip_at = src.index("if row.projection_retired_at is not None:") + path_at = src.index("if bp and _path_key(bp) in want_paths:") + assert skip_at < path_at, "retired rows must be skipped before path matching" + + def test_uuid_match_still_precedes_the_skip(self): + """A record that genuinely returns must still be un-retired.""" + import inspect + src = inspect.getsource(fp.reset_repository_projection) + assert src.index("want_by_uuid.get(str(row.id))") < src.index( + "if row.projection_retired_at is not None:" + ) + + def test_a_row_that_derives_again_is_not_tombstoned(self): + """Releasing it would change the slug out from under the matching. + + The row would then fail to match its own file and never be un-retired — + which is how five rows ended up tombstoned but not retired. + """ + import inspect + src = inspect.getsource(fp.reset_repository_projection) + rel = src.index("outcome.released.append(before)") + guard = src.index('in want or str(r.id) in want_by_uuid') + assert guard < rel, "the release must exempt records the forge still derives" + + def test_a_stamped_slug_counts_as_retired_even_if_the_flag_is_clear(self): + """Otherwise a resurrected row path-matches its file and reports noop forever.""" + import inspect + src = inspect.getsource(fp.reset_repository_projection) + assert "projection_retired_at is None and RETIRED_SLUG_MARK in" in src + + def test_a_resurrected_row_becomes_stale_rather_than_being_skipped(self): + """Skipping it outright is what left six rows reporting noop forever. + + `stale` is computed from `matched`, so a row that never enters `matched` + is invisible: not retired, so not stale; path-matching its own file, so + never reported. + """ + import inspect + src = inspect.getsource(fp.reset_repository_projection) + zombie = src.index("projection_retired_at is None and RETIRED_SLUG_MARK in") + tail = src[zombie:zombie + 700] + assert "matched[key] = row" in tail, "a resurrected row must enter `matched`" + + +class TestCompetingClaims: + """Two rows can claim one record; the weaker must not vanish. + + `cust-wp-0010` claimed CUST-WP-0010 by its own identifier while + `workstream-lifecycle-documentation` claimed it by path. Assigning into + `matched` unconditionally let the later one win and dropped the other + silently — never matched, so never stale, so never reported by any pass. + """ + + def test_claims_are_ranked_not_overwritten(self): + import inspect + src = inspect.getsource(fp.reset_repository_projection) + assert "def _claim(" in src + assert "displaced.append" in src + + def test_a_displaced_row_becomes_stale(self): + import inspect + src = inspect.getsource(fp.reset_repository_projection) + stale_at = src.index("stale = [") + assert "displaced" in src[stale_at:stale_at + 400] + + def test_identifier_beats_path(self): + """A row naming the record outranks one that merely shares its file.""" + import inspect + src = inspect.getsource(fp.reset_repository_projection) + assert src.index("UUID_MATCH, SLUG_MATCH, PATH_MATCH, PREFIX_MATCH = 0, 1, 2, 3") > 0 + # Strength is ordered strongest-first, so a lower number wins. + assert "if strength < held:" in src + + +class TestTitleIsDerivedToo: + """The title is a derived field and must follow the file. + + Not syncing it left `cust-wp-0010` correctly identified and correctly backed + while describing a different workplan's work. + """ + + def test_title_is_synced(self): + import inspect + src = inspect.getsource(fp.reset_repository_projection) + assert "row.title = w.title.strip()" in src + + def test_an_empty_derived_title_does_not_blank_a_real_one(self): + """Three activity-core files parse to no title; blanking is worse than stale.""" + import inspect + src = inspect.getsource(fp.reset_repository_projection) + i = src.index("row.title = w.title.strip()") + guard = src[max(0, i - 200):i] + assert "w.title and w.title.strip()" in guard + + +class TestTitleSyncBehaviour: + """Exercised against the fake session rather than asserted on source.""" + + @pytest.mark.asyncio + async def test_a_drifted_title_is_corrected(self): + row = _Row(slug="demo-wp-0001", status="active", + path="workplans/a.md", title="Some Other Workplan") + session = _FakeSession(repo=_Repo(), rows=[row]) + out = await fp.reset_repository_projection( + session, "demo", + derived=TestReset()._derived(("DEMO-WP-0001", "active", "workplans/a.md")), + ) + assert out.status == "applied" + assert row.title != "Some Other Workplan" + + @pytest.mark.asyncio + async def test_an_empty_derived_title_leaves_the_existing_one(self): + row = _Row(slug="demo-wp-0001", status="active", + path="workplans/a.md", title="Real Title") + d = TestReset()._derived(("DEMO-WP-0001", "active", "workplans/a.md")) + d.workplans[0].title = None + session = _FakeSession(repo=_Repo(), rows=[row]) + await fp.reset_repository_projection(session, "demo", derived=d) + assert row.title == "Real Title" + + +class _TaskRow: + def __init__(self, record_id, status="todo", title="t", workplan_id=None): + import uuid as _u + self.id = _u.uuid4() + self.record_id = record_id + self.status = status + self.title = title + self.priority = "medium" + self.workplan_id = workplan_id + + +class TestExistingWorkplanTasks: + """Tasks of an existing workplan are matched by record_id (CUST-WP-0068-T09).""" + + def _derived(self, *tasks): + dts = [ + fp.DerivedTask( + record_id=tid, + uuid=fp.derived_record_uuid(tid), + title=title, + status=st, + priority="medium", + ) + for tid, title, st in tasks + ] + return fp.DerivedProjection( + repo_slug="demo", + commit="c0ffee", + workplans=[ + fp.DerivedWorkplan( + record_id="DEMO-WP-0001", + uuid=fp.derived_record_uuid("DEMO-WP-0001"), + title="DEMO-WP-0001", + status="active", + relative_path="workplans/a.md", + archived=False, + tasks=dts, + ) + ], + ) + + @pytest.mark.asyncio + async def test_creates_a_missing_identified_task(self): + row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md") + session = _FakeSession(repo=_Repo(), rows=[row], task_rows=[]) + out = await fp.reset_repository_projection( + session, "demo", + derived=self._derived(("DEMO-WP-0001-T01", "Do it", "todo")), + ) + assert out.created_tasks == ["DEMO-WP-0001-T01"] + assert session.added + assert session.added[0].record_id == "DEMO-WP-0001-T01" + + @pytest.mark.asyncio + async def test_cancels_an_open_task_the_file_no_longer_derives(self): + row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md") + stale = _TaskRow("DEMO-WP-0001-T09", status="todo", workplan_id=row.id) + session = _FakeSession(repo=_Repo(), rows=[row], task_rows=[stale]) + out = await fp.reset_repository_projection( + session, "demo", + derived=self._derived(("DEMO-WP-0001-T01", "Do it", "todo")), + ) + assert stale.status.value == "cancel" or stale.status == "cancel" or str(stale.status).endswith("cancel") + assert "DEMO-WP-0001-T09" in out.cancelled_tasks + assert session.deleted == [] + + @pytest.mark.asyncio + async def test_sets_record_id_when_the_derived_uuid_already_exists(self): + row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md") + existing = _TaskRow(None, status="done", title="Clear the stale hub rows", workplan_id=row.id) + derived = self._derived(("DEMO-WP-0001-T01", "Clear the stale hub rows", "done")) + existing.id = __import__("uuid").UUID(derived.workplans[0].tasks[0].uuid) + session = _FakeSession(repo=_Repo(), rows=[row], task_rows=[existing]) + out = await fp.reset_repository_projection(session, "demo", derived=derived) + assert out.created_tasks == [] + assert existing.record_id == "DEMO-WP-0001-T01" + assert session.added == [] + + @pytest.mark.asyncio + async def test_leaves_unidentified_tasks_alone(self): + row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md") + orphan = _TaskRow(None, status="todo", title="legacy", workplan_id=row.id) + session = _FakeSession(repo=_Repo(), rows=[row], task_rows=[orphan]) + await fp.reset_repository_projection( + session, "demo", + derived=self._derived(("DEMO-WP-0001-T01", "Do it", "todo")), + ) + assert orphan.status == "todo" + + @pytest.mark.asyncio + async def test_duplicate_identity_inside_current_workplan_is_refused(self): + row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md") + first = _TaskRow("DEMO-WP-0001-T01", workplan_id=row.id) + second = _TaskRow("demo-wp-0001-t01", workplan_id=row.id) + session = _FakeSession(repo=_Repo(), rows=[row], task_rows=[first, second]) + out = await fp.reset_repository_projection( + session, + "demo", + derived=self._derived(("DEMO-WP-0001-T01", "Do it", "todo")), + ) + assert out.status == "refused" + assert out.refused[0]["reason"].startswith("current workplan contains duplicate") + assert session.added == [] + + @pytest.mark.asyncio + async def test_duplicate_stale_history_inside_current_workplan_does_not_block(self): + row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md") + desired = _TaskRow("DEMO-WP-0001-T01", status="todo", workplan_id=row.id) + stale_a = _TaskRow("DEMO-WP-0001-T09", status="done", workplan_id=row.id) + stale_b = _TaskRow("demo-wp-0001-t09", status="done", workplan_id=row.id) + session = _FakeSession( + repo=_Repo(), rows=[row], task_rows=[desired, stale_a, stale_b] + ) + out = await fp.reset_repository_projection( + session, + "demo", + derived=self._derived(("DEMO-WP-0001-T01", "Do it", "todo")), + ) + assert out.status in {"applied", "noop"} + assert out.refused == [] + + @pytest.mark.asyncio + async def test_derived_task_uuid_held_elsewhere_is_refused_before_insert(self): + row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md") + derived = self._derived(("DEMO-WP-0001-T01", "Do it", "todo")) + holder = _TaskRow("DEMO-WP-0001-T01", workplan_id=__import__("uuid").uuid4()) + holder.id = __import__("uuid").UUID(derived.workplans[0].tasks[0].uuid) + session = _FakeSession( + repo=_Repo(), + rows=[row], + task_rows=[], + task_collision_rows=[holder], + ) + out = await fp.reset_repository_projection(session, "demo", derived=derived) + assert out.status == "refused" + assert out.refused[0]["reason"].startswith("derived task identifier already belongs") + assert session.added == [] + + @pytest.mark.asyncio + async def test_tasks_on_retired_copy_do_not_block_current_projection(self): + from datetime import datetime, timezone + + current = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md") + current.id = __import__("uuid").UUID(fp.derived_record_uuid("DEMO-WP-0001")) + retired = _Row( + slug="demo-wp-0001@retired-20260831", + status="finished", + path="workplans/a.md", + ) + retired.projection_retired_at = datetime.now(tz=timezone.utc) + task = _TaskRow("DEMO-WP-0001-T01", status="todo", workplan_id=current.id) + session = _FakeSession(repo=_Repo(), rows=[current, retired], task_rows=[task]) + out = await fp.reset_repository_projection( + session, + "demo", + derived=self._derived(("DEMO-WP-0001-T01", "Do it", "todo")), + ) + assert out.status in {"applied", "noop"} + assert out.refused == [] + + +def test_diff_reports_duplicate_current_task_identity_as_ambiguous(): + derived = TestExistingWorkplanTasks()._derived( + ("DEMO-WP-0001-T01", "Do it", "todo") + ) + workplan_id = derived.workplans[0].uuid + hub_workplans = [ + { + "id": workplan_id, + "slug": "demo-wp-0001", + "status": "active", + "backing_relative_path": "workplans/a.md", + } + ] + hub_tasks = { + workplan_id: [ + { + "id": "11111111-1111-4111-8111-111111111111", + "record_id": "DEMO-WP-0001-T01", + "title": "a", + "status": "todo", + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "record_id": "demo-wp-0001-t01", + "title": "b", + "status": "todo", + }, + ] + } + diff = fp.diff_against_hub(derived, hub_workplans, hub_tasks) + assert diff.clean is False + assert diff.to_dict()["counts"]["ambiguous"] == 1 + assert diff.ambiguous[0]["source"] == "hub" + + +@pytest.mark.asyncio +async def test_reset_refuses_duplicate_task_identity_in_forge_projection(): + first = TestExistingWorkplanTasks()._derived( + ("SHARED-WP-0001-T01", "First", "todo") + ).workplans[0] + second = fp.DerivedWorkplan( + record_id="DEMO-WP-0002", + uuid=fp.derived_record_uuid("DEMO-WP-0002"), + title="Second", + status="active", + relative_path="workplans/b.md", + archived=False, + tasks=[ + fp.DerivedTask( + record_id="SHARED-WP-0001-T01", + uuid=fp.derived_record_uuid("SHARED-WP-0001-T01"), + title="Second claim", + status="todo", + priority="medium", + ) + ], + ) + derived = fp.DerivedProjection( + repo_slug="demo", commit="c0ffee", workplans=[first, second] + ) + session = _FakeSession(repo=_Repo(), rows=[]) + out = await fp.reset_repository_projection(session, "demo", derived=derived) + assert out.status == "refused" + assert any(item.get("source") == "forge" for item in out.refused) + assert session.added == [] + + +# The preserved Activity parent. A still-deriving row with this id is not stale. +_HISTORICAL_PARENT = "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5" + + +def _historical_parent_derived(): + return fp.DerivedProjection( + repo_slug="demo", + commit="c0ffee", + workplans=[ + fp.DerivedWorkplan( + record_id="custodian-WP-0003", + uuid=_HISTORICAL_PARENT, + title="Preserved parent", + status="archived", + relative_path="workplans/archived/custodian-wp-0003.md", + archived=True, + historical_preservation=True, + tasks=[], + ) + ], + ) + + +def _bound_rows(): + """One still-deriving parent, one path-displaced copy, one unmatched live row.""" + import uuid as _u + + path = "workplans/archived/custodian-wp-0003.md" + parent = _Row(slug="custodian-wp-0003", status="archived", path=path, title="Preserved parent") + parent.id = _u.UUID(_HISTORICAL_PARENT) + displaced = _Row(slug="empty-duplicate-copy", status="done", path=path, title="Displaced copy") + unmatched = _Row( + slug="custodian-wp-0099", status="active", path="workplans/other.md", title="Unmatched" + ) + return parent, displaced, unmatched + + +class _Dependency: + def __init__(self, from_workplan_id, to_workplan_id): + import uuid as _u + + self.id = _u.uuid4() + self.from_workplan_id = from_workplan_id + self.to_workplan_id = to_workplan_id + self.from_task_id = None + self.to_task_id = None + + +class TestExactRetirementSet: + """Exact UUID authorization for Forge retirement. Nothing is deleted.""" + + @staticmethod + def _apply(session, derived, **kwargs): + return fp.reset_repository_projection(session, "demo", derived=derived, **kwargs) + + @pytest.mark.asyncio + async def test_exact_uuid_set_retires_only_those_stale_and_displaced_rows(self): + parent, displaced, unmatched = _bound_rows() + session = _FakeSession(repo=_Repo(), rows=[parent, displaced, unmatched]) + out = await self._apply( + session, + _historical_parent_derived(), + expected_retirement_uuids=[displaced.id, unmatched.id], + ) + assert out.status == "applied" + assert out.refused == [] + assert set(out.retired) == {"empty-duplicate-copy", "custodian-wp-0099"} + assert displaced.projection_retired_at is not None + assert unmatched.projection_retired_at is not None + assert displaced.projection_retired_reason == fp.RETIRE_REASON + assert unmatched.projection_retired_reason == fp.RETIRE_REASON + assert fp.RETIRED_SLUG_MARK in displaced.slug + assert fp.RETIRED_SLUG_MARK in unmatched.slug + assert parent.projection_retired_at is None + assert parent.slug == "custodian-wp-0003" + assert parent.derived_from_commit is None + assert parent.id.hex == __import__("uuid").UUID(_HISTORICAL_PARENT).hex + assert session.deleted == [] + assert {parent, displaced, unmatched} <= set(session.rows) + + @pytest.mark.asyncio + async def test_missing_or_extra_uuid_refuses_and_retires_nothing(self): + parent, displaced, unmatched = _bound_rows() + derived = _historical_parent_derived() + + missing = _FakeSession(repo=_Repo(), rows=[parent, displaced, unmatched]) + out = await self._apply( + missing, derived, expected_retirement_uuids=[displaced.id] + ) + assert out.status == "refused" and out.retired == [] + assert out.refused[0]["missing"] == [str(unmatched.id)] + assert out.refused[0]["extra"] == [] + assert displaced.projection_retired_at is None + assert unmatched.projection_retired_at is None + assert parent.projection_retired_at is None + + extra = _FakeSession(repo=_Repo(), rows=[parent, displaced, unmatched]) + out = await self._apply( + extra, + derived, + expected_retirement_uuids=[displaced.id, unmatched.id, parent.id], + ) + assert out.status == "refused" and out.retired == [] + assert out.refused[0]["missing"] == [] + assert out.refused[0]["extra"] == [str(parent.id)] + assert displaced.projection_retired_at is None + assert unmatched.projection_retired_at is None + assert parent.slug == "custodian-wp-0003" + + # An empty list is present and expects zero retirements. + empty = _FakeSession(repo=_Repo(), rows=[parent, displaced, unmatched]) + out = await self._apply(empty, derived, expected_retirement_uuids=[]) + assert out.status == "refused" and out.retired == [] + assert out.refused[0]["extra"] == [] + assert set(out.refused[0]["missing"]) == {str(displaced.id), str(unmatched.id)} + assert displaced.projection_retired_at is None + assert extra.deleted == [] and missing.deleted == [] and empty.deleted == [] + + @pytest.mark.asyncio + async def test_stale_row_with_a_task_refuses(self): + """Terminal tasks count. A clean sibling in the same set is not retired.""" + clean = _Row(slug="demo-wp-0002", status="done", path="workplans/b.md") + dirty = _Row(slug="demo-wp-0001", status="done", path="workplans/a.md") + task = _TaskRow("DEMO-WP-0001-T01", status="done", workplan_id=dirty.id) + session = _FakeSession( + repo=_Repo(), + rows=[clean, dirty], + retirement_tasks=[task], + retirement_edges=[], + ) + out = await self._apply( + session, + TestReset()._derived(), + expected_retirement_uuids=[clean.id, dirty.id], + ) + assert out.status == "refused" + assert out.retired == [] + assert clean.projection_retired_at is None + assert dirty.projection_retired_at is None + assert session.deleted == [] + assert out.refused[0]["reason"] == "stale projection has tasks" + assert out.refused[0]["uuid"] == str(dirty.id) + assert task.status == "done" + + @pytest.mark.asyncio + @pytest.mark.parametrize("direction", ["from", "to"]) + async def test_stale_row_with_a_dependency_edge_refuses(self, direction): + clean = _Row(slug="demo-wp-0002", status="done", path="workplans/b.md") + dirty = _Row(slug="demo-wp-0001", status="done", path="workplans/a.md") + other = __import__("uuid").uuid4() + if direction == "from": + edge = _Dependency(dirty.id, other) + else: + edge = _Dependency(other, dirty.id) + session = _FakeSession( + repo=_Repo(), + rows=[clean, dirty], + retirement_tasks=[], + retirement_edges=[edge], + ) + out = await self._apply( + session, + TestReset()._derived(), + expected_retirement_uuids=[clean.id, dirty.id], + ) + assert out.status == "refused" + assert out.retired == [] + assert clean.projection_retired_at is None + assert dirty.projection_retired_at is None + assert session.deleted == [] + assert out.refused[0]["reason"] == "stale projection has dependency edges" + assert out.refused[0]["uuid"] == str(dirty.id) + assert str(edge.id) in out.refused[0]["dependency_ids"] + + @pytest.mark.asyncio + async def test_boolean_acknowledgement_still_retires_a_simple_stale_row(self): + row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md") + session = _FakeSession(repo=_Repo(), rows=[row]) + out = await fp.reset_repository_projection( + session, "demo", derived=TestReset()._derived(), acknowledge_retirements=True + ) + assert out.status == "applied" and out.retired == ["demo-wp-0001"] + assert row.projection_retired_at is not None + assert row.projection_retired_reason == fp.RETIRE_REASON + assert session.deleted == [] + assert row in session.rows + + @pytest.mark.asyncio + async def test_both_flags_together_refuse(self): + row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md") + session = _FakeSession(repo=_Repo(), rows=[row]) + out = await fp.reset_repository_projection( + session, + "demo", + derived=TestReset()._derived(), + acknowledge_retirements=True, + expected_retirement_uuids=[row.id], + ) + assert out.status == "refused" + assert out.retired == [] + assert out.refused[0]["reason"] == "ambiguous authorization" + assert row.projection_retired_at is None + assert fp.RETIRED_SLUG_MARK not in (row.slug or "") + assert session.deleted == [] + + @pytest.mark.asyncio + async def test_still_deriving_row_is_kept_unless_the_displaced_row_is_named(self): + parent, displaced, _unmatched = _bound_rows() + derived = _historical_parent_derived() + + unnamed = _FakeSession(repo=_Repo(), rows=[parent, displaced]) + out = await self._apply(unnamed, derived, expected_retirement_uuids=[parent.id]) + assert out.status == "refused" and out.retired == [] + assert str(displaced.id) in out.refused[0]["missing"] + assert str(parent.id) in out.refused[0]["extra"] + assert parent.projection_retired_at is None + assert displaced.projection_retired_at is None + + named = _FakeSession(repo=_Repo(), rows=[parent, displaced]) + out = await self._apply( + named, derived, expected_retirement_uuids=[displaced.id] + ) + assert out.status == "applied" + assert out.retired == ["empty-duplicate-copy"] + assert displaced.projection_retired_at is not None + assert displaced.projection_retired_reason == fp.RETIRE_REASON + assert fp.RETIRED_SLUG_MARK in displaced.slug + assert parent.projection_retired_at is None + assert parent.slug == "custodian-wp-0003" + assert parent.derived_from_commit is None + assert str(parent.id) == _HISTORICAL_PARENT + assert named.deleted == [] + assert parent in named.rows and displaced in named.rows + + @pytest.mark.asyncio + async def test_exact_set_cannot_authorize_an_empty_source(self): + derived = fp.DerivedProjection( + repo_slug="demo", commit="c0ffee", records_source_present=False + ) + row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md") + session = _FakeSession(repo=_Repo(), rows=[row]) + out = await fp.reset_repository_projection( + session, "demo", derived=derived, expected_retirement_uuids=[row.id] + ) + assert out.status == "refused" + assert out.retired == [] + assert row.projection_retired_at is None + assert out.refused[0]["reason"].startswith("source produced no records") + + def test_schema_distinguishes_omitted_and_empty_expected_set(self): + from api.schemas.work_record_projection import RepositoryProjectionReconcile + import inspect + from api.routers import work_record_projection as route + + omitted = RepositoryProjectionReconcile(expected_commit="a" * 40) + assert omitted.expected_retirement_uuids is None + assert omitted.acknowledge_retirements is False + empty = RepositoryProjectionReconcile( + expected_commit="a" * 40, expected_retirement_uuids=[] + ) + assert empty.expected_retirement_uuids == [] + named = RepositoryProjectionReconcile( + expected_commit="b" * 40, + expected_retirement_uuids=[_HISTORICAL_PARENT], + ) + assert str(named.expected_retirement_uuids[0]) == _HISTORICAL_PARENT + src = inspect.getsource(route.reconcile_repository_work_records) + assert "expected_retirement_uuids=body.expected_retirement_uuids" in src diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/tests/test_historical_projection.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/tests/test_historical_projection.py new file mode 100644 index 00000000..7b24cdf8 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/tests/test_historical_projection.py @@ -0,0 +1,135 @@ +"""Closed historical views preserve identity; unknown sources never gain authority.""" +import hashlib +import uuid +from pathlib import Path + +import pytest + +from api.services import forge_projection as fp +from api.services import historical_projection as hp +from tests.test_forge_projection import _FakeSession, _Repo, _Row, _TaskRow, TestExistingWorkplanTasks as ExistingTaskTests + + +def _fixture(tmp_path, monkeypatch): + parent = str(uuid.uuid4()); task = str(uuid.uuid4()) + paths = ['workplans/archived/old.md', 'workplans/split.md'] + entries = [] + for index, path in enumerate(paths): + rid = 'custodian-WP-0003' + ('a' if index else '') + data = (f'---\nid: {rid}\ntype: workplan\nrepo: activity-core\nstatus: done\n' + f'state_hub_workstream_id: {parent}\ntasks:\n' + f'- id: T34\n title: Closed task\n status: done\n state_hub_task_id: {task}\n---\n').encode() + p = tmp_path/path;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(data) + entries.append(dict(repo='activity-core',path=path,sha256=hashlib.sha256(data).hexdigest(), + record_id=rid,uuid=parent,parent_record_id='custodian-WP-0003')) + monkeypatch.setattr(hp,'BINDINGS',entries) + return parent,task,paths + + +def test_exact_shared_views_derive_one_preserved_parent(tmp_path, monkeypatch): + parent,task,_ = _fixture(tmp_path,monkeypatch) + derived = fp.derive_from_checkout(tmp_path,'activity-core','abc') + assert len(derived.workplans)==1 + w = derived.workplans[0] + assert w.uuid == parent and w.historical_preservation + assert len(w.tasks)==1 and w.tasks[0].uuid==task + assert w.tasks[0].record_id=='T34' + assert w.uuid != fp.derived_record_uuid(w.record_id) + + +@pytest.mark.parametrize('change',['bytes','missing','symlink']) +def test_changed_bound_history_refuses_derive(tmp_path,monkeypatch,change): + _,_,paths = _fixture(tmp_path,monkeypatch) + p=tmp_path/paths[1] + if change=='bytes':p.write_text(p.read_text().replace('status: done','status: active')) + elif change=='missing':p.unlink() + else: + data=p.read_bytes();p.unlink();other=tmp_path/'other.md';other.write_bytes(data);p.symlink_to(other) + with pytest.raises(fp.ForgeDeriveError,match='Historical source binding'): + fp.derive_from_checkout(tmp_path,'activity-core','abc') + + +def test_unknown_repo_cannot_trust_existing_binding(tmp_path,monkeypatch): + parent,_,_ = _fixture(tmp_path,monkeypatch) + derived=fp.derive_from_checkout(tmp_path,'other-repo','abc') + assert all(not w.historical_preservation and w.uuid != parent for w in derived.workplans) + + +@pytest.mark.asyncio +async def test_historical_receiving_preserves_terminal_tombstone_and_null_task_label(tmp_path,monkeypatch): + parent,task,_=_fixture(tmp_path,monkeypatch) + derived=fp.derive_from_checkout(tmp_path,'activity-core','abc') + row=_Row(slug='old@retired-20260827',status='archived',path='workplans/archived/old.md') + row.id=uuid.UUID(parent) + from datetime import datetime,timezone + row.projection_retired_at=datetime.now(timezone.utc) + t=_TaskRow(None,status='done',workplan_id=row.id);t.id=uuid.UUID(task) + before_row=dict(row.__dict__);before_task=dict(t.__dict__) + session=_FakeSession(_Repo(),[row],task_rows=[t]) + outcome=await fp.reset_repository_projection(session,'activity-core',derived=derived) + assert outcome.status=='noop' and outcome.refused==[] + assert row.__dict__==before_row and t.__dict__==before_task + assert not session.added and not session.deleted + + +@pytest.mark.asyncio +async def test_missing_historical_task_refuses_before_mutation(tmp_path,monkeypatch): + parent,_,_=_fixture(tmp_path,monkeypatch) + derived=fp.derive_from_checkout(tmp_path,'activity-core','abc') + row=_Row(slug='old',status='archived',path='workplans/archived/old.md');row.id=uuid.UUID(parent) + session=_FakeSession(_Repo(),[row],task_rows=[]) + outcome=await fp.reset_repository_projection(session,'activity-core',derived=derived) + assert outcome.status=='refused' + assert outcome.refused[0]['reason'].startswith('historical binding') + assert not session.added and not session.deleted + + +@pytest.mark.asyncio +async def test_task_holder_query_includes_retired_and_stale_parents(): + derived=ExistingTaskTests()._derived(('DEMO-WP-0001-T01','Task','todo')) + current=_Row(slug='demo-wp-0001',status='active',path='workplans/a.md') + holder=_TaskRow('DEMO-WP-0001-T01',workplan_id=uuid.uuid4()) + holder.id=uuid.UUID(derived.workplans[0].tasks[0].uuid) + class Session(_FakeSession): + statements=[] + async def execute(self,statement,*args,**kwargs): + self.statements.append(str(statement)) + return await super().execute(statement,*args,**kwargs) + session=Session(_Repo(),[current],task_rows=[],task_collision_rows=[holder]) + outcome=await fp.reset_repository_projection(session,'demo',derived=derived) + assert outcome.status=='refused' and not session.added + query=session.statements[-1] + assert 'tasks.id IN' in query + assert 'projection_retired_at' not in query and 'NOT IN' not in query and 'JOIN' not in query + + +@pytest.mark.asyncio +async def test_missing_historical_parent_never_recreates(tmp_path,monkeypatch): + _fixture(tmp_path,monkeypatch) + derived=fp.derive_from_checkout(tmp_path,'activity-core','abc') + session=_FakeSession(_Repo(),[]) + outcome=await fp.reset_repository_projection(session,'activity-core',derived=derived) + assert outcome.status=='refused' and not session.added and not session.deleted + + +@pytest.mark.asyncio +@pytest.mark.parametrize('field',['parent','task']) +async def test_historical_nonterminal_hub_record_requires_owner_review(tmp_path,monkeypatch,field): + parent,task,_=_fixture(tmp_path,monkeypatch) + derived=fp.derive_from_checkout(tmp_path,'activity-core','abc') + row=_Row(slug='old',status='active' if field=='parent' else 'finished',path='workplans/archived/old.md') + row.id=uuid.UUID(parent) + t=_TaskRow('preserved-old-label',status='wait' if field=='task' else 'done',workplan_id=row.id) + t.id=uuid.UUID(task) + session=_FakeSession(_Repo(),[row],task_rows=[t]) + outcome=await fp.reset_repository_projection(session,'activity-core',derived=derived) + assert outcome.status=='refused' and not session.added and not session.deleted + + +def test_historical_diff_uses_uuid_without_relabeling(tmp_path,monkeypatch): + parent,task,_=_fixture(tmp_path,monkeypatch) + derived=fp.derive_from_checkout(tmp_path,'activity-core','abc') + diff=fp.diff_against_hub(derived,[{'id':parent,'slug':'historic@retired-20260827', + 'status':'archived','backing_relative_path':'old-path.md'}], + {parent:[{'id':task,'record_id':None,'title':'Old title','status':'done'}]}) + assert not diff.missing and not diff.stale and not diff.differing diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/workplans/STATE-WP-0095-historical-source-receiving.md b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/workplans/STATE-WP-0095-historical-source-receiving.md new file mode 100644 index 00000000..d7c09263 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/workplans/STATE-WP-0095-historical-source-receiving.md @@ -0,0 +1,231 @@ +--- +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 +``` + +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: wait +priority: high +depends_on: [STATE-WP-0095-T05] +needs_human: true +intervention_note: "Review exact committed source, CI image and owner deployment proposal before rollout." +``` + +Use the normal CI image and Helm owner path after exact revision/image pin, +fresh headroom and reviewed dry-run/diff. API/MCP currently run main-7d99282; +Helm revision75 is the observed rollback baseline, requiring freshness before +execution. No deployment assent exists for this successor. Verify delivered +module hash, health, both readiness checks and unchanged schema; verify exact +three parents/15 tasks and rejection behavior without committed probe changes. +Only the verified return unblocks RMGR-WP-0019-T05. Costs and monetary risk are +unknown pending exact rollout proposal; no zero-cost or autonomy 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] +``` + +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. diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/validation.json b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/validation.json new file mode 100644 index 00000000..0c039389 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/validation.json @@ -0,0 +1,27 @@ +{ + "schema": "cluster-historical-source-validation.v1", + "canonical_mutations": false, + "production_mutations": false, + "repos": { + "repo-manager": { + "tests": 47, + "result": "pass", + "command": "PYTHONPATH=repo-manager/src /home/worsch/repo-manager/.venv/bin/python -m pytest -q --tb=short repo-manager/tests" + }, + "state-hub": { + "tests": 26, + "result": "pass", + "command": "PYTHONPATH=state-hub /home/worsch/state-hub/.venv/bin/python -m pytest -q --tb=short state-hub/tests/test_cluster_historical_projection.py state-hub/tests/test_historical_projection.py" + } + }, + "checks": { + "receiving_registrar_manifests_equal": true, + "frozen_archives": 3, + "existing_task_uuids": 15, + "read_only_existing_terminal_rows": 18, + "actual_allocator_assignments": 7, + "actual_allocator_expected_files": 2, + "strict_invalid_labels_retained": 15, + "strict_collisions_retained": 5 + } +} diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-auth/README.md b/docs/changes/netkingdom-target-2026-10-16/decision-auth/README.md new file mode 100644 index 00000000..03de2c95 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-auth/README.md @@ -0,0 +1,28 @@ +# Secrets Engine responder authentication source handoff + +Apply/review `implementation-r2.patch` using `integration-manifest.json` and +`source-provenance.json`. R1 is retained unchanged for audit; its initial fixture +failures and subsequent fixes are in the retained logs. Original dirty patches +are provenance only and must not be applied as implementation. + +R2 makes the actual production Check/authorization path require independently +admitted Ed25519 keys, exact signed evaluator/mode and policy pin/artifact digest. +It processes original response bytes using unchanged pinned Access Engine Go +producer types/verifier, then consumes only the authenticated typed output. +Unsigned/forged/tampered/downgraded responses refuse. The default Check API is +also authenticated; synthetic transport tests explicitly name their opt-out. +The subprocess receives an empty environment. Existing request/claim/lifetime/CAS +bindings stay enforced; native Go signing drives the migrated consumer tests. + +Verification: 158 focused pass; full suite 685 pass/16 fail/1 skip. All sixteen +failure ids exactly match untouched canonical baseline failures caused by the +sandbox temporary-path ownership check. `verification-r2.json` retains the exact +comparison. No source guard was weakened to make that environment pass. + +Deployment must separately install the reviewed Go helper; a Python wheel alone +is insufficient. Key admission/rotation and native rollout still require exact +owner review and authorization. No production keys, credentials, backend rights, +CCR38 scope or trust settings were changed. WP14 T02 remains progress and T03 +wait. WP13 and its native acceptance are unchanged. Root performs canonical +integration and consistency sync; this worker changed only isolated source and +the Custodian handoff artifacts. diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-auth/access-engine-original-dirty.patch b/docs/changes/netkingdom-target-2026-10-16/decision-auth/access-engine-original-dirty.patch new file mode 100644 index 00000000..0dc81fe9 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-auth/access-engine-original-dirty.patch @@ -0,0 +1,77 @@ +diff --git a/.custodian-brief.md b/.custodian-brief.md +index d3d8eba..b392557 100644 +--- a/.custodian-brief.md ++++ b/.custodian-brief.md +@@ -2,7 +2,7 @@ + # Custodian Brief — access-engine + + **Domain:** infotech +-**Last synced:** 2026-10-11 18:38 UTC ++**Last synced:** 2026-10-11 21:31 UTC + **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* + + ## Active Workstreams +@@ -12,7 +12,7 @@ Progress: 5/9 done | workplan_id: `84f5d9fe-b4c9-584a-b964-efe3e48af095` + + **Open tasks:** + - ! 2. Register flex-auth as an audit-core sender `89661908` +- *(wait: AUDIT-IN-0006 sender rows and governed token delivery remain pending; gate-house GH-WP-0004-T12 must rule on the load-bearing failure-path exception.)* ++ *(wait: GH-WP-0004-T12 refused the load-bearing failure-path exception (GH-DEC-2026-024). AUDIT-IN-0006 sender rows and governed token delivery remain pending.)* + - ! 4. Close G2 `423b3090` + *(wait: G2 needs T08 native heartbeat/drain and silence-finding evidence plus T06 reconciliation; local tests cannot close the delivery gap.)* + - ! 6. Reconciliation, profile check, and storage `bf92a951` +@@ -24,12 +24,12 @@ Progress: 5/9 done | workplan_id: `84f5d9fe-b4c9-584a-b964-efe3e48af095` + Progress: 0/8 done | workplan_id: `af0f8d77-af04-569b-8e79-6c47eb8f1087` + + **Open tasks:** ++- ! Make the PDP accept both names `487525e5` ++- ! Stand up the access-engine namespace next to flex-auth `13b93d7a` ++- ! Consumers switch over, one at a time `f0470e5c` + - ► Decide the target names and supersede the retain table `4f9a796d` + - ► Inventory every consumer reference and open handoffs `8f597899` + - ► Rename the source `ff7fd369` +-- · Make the PDP accept both names `487525e5` +-- · Stand up the access-engine namespace next to flex-auth `13b93d7a` +-- · Consumers switch over, one at a time `f0470e5c` + - · Retire the flex-auth runtime `e46c9c8e` + - … and 1 more open tasks + +@@ -38,14 +38,14 @@ Progress: 10/11 done | workplan_id: `99a661a8-b36c-5c1c-b78b-1e8930bcd0a9` + + **Open tasks:** + - ! 11. Soak, hand off residuals, and clean up the old checkout `46d8318b` +- *(wait: Needs SBOM-IN-0001 catch-up receipt, secrets-engine coordinate correction and railiance-fabric residual disposition, observed soak evidence, and exact checkout cleanup approval under e071b34e-c4d4-49d0-824d-2c58bb0ae496.)* ++ *(wait: Cleanup approved conditionally (e071b34e): runs once the listed soak evidence, SBOM-IN-0001 receipt and residual handoffs exist.)* + + ### 2026-10-03 secret-read evaluator for ops-warden fetch checks + Progress: 1/2 done | workplan_id: `60941288-d1f8-58fe-ad38-d508b7d5eac2` + + **Open tasks:** + - ! Deploy the pin (founder go required) `aa00b29f` +- *(wait: Live railiance01 change; founder go needed for CI image digest, helm install and tunnel.)* ++ *(wait: Install accepted retroactively (877c268b); steps 6-7 follow WARDEN-WP-0039-T03 token receipts and fetch URL.)* + + ## Inbox Hygiene + +diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md +index 62ffebf..2268973 100644 +--- a/WORK-RECORDS.md ++++ b/WORK-RECORDS.md +@@ -214,12 +214,12 @@ + | task | FLEX-WP-0035-T03 | done | — | workplans/FLEX-WP-0035-per-identity-application-readback.md | + | task | FLEX-WP-0035-T04 | done | — | workplans/FLEX-WP-0035-per-identity-application-readback.md | + | task | FLEX-WP-0035-T05 | done | — | workplans/FLEX-WP-0035-per-identity-application-readback.md | +-| task | FLEX-WP-0036-T01 | progress | — | workplans/FLEX-WP-0036-runtime-rename-access-engine.md | ++| task | FLEX-WP-0036-T01 | wait | — | workplans/FLEX-WP-0036-runtime-rename-access-engine.md | + | task | FLEX-WP-0036-T02 | progress | — | workplans/FLEX-WP-0036-runtime-rename-access-engine.md | +-| task | FLEX-WP-0036-T03 | todo | — | workplans/FLEX-WP-0036-runtime-rename-access-engine.md | +-| task | FLEX-WP-0036-T04 | progress | — | workplans/FLEX-WP-0036-runtime-rename-access-engine.md | +-| task | FLEX-WP-0036-T05 | todo | — | workplans/FLEX-WP-0036-runtime-rename-access-engine.md | +-| task | FLEX-WP-0036-T06 | todo | — | workplans/FLEX-WP-0036-runtime-rename-access-engine.md | ++| task | FLEX-WP-0036-T03 | wait | — | workplans/FLEX-WP-0036-runtime-rename-access-engine.md | ++| task | FLEX-WP-0036-T04 | wait | — | workplans/FLEX-WP-0036-runtime-rename-access-engine.md | ++| task | FLEX-WP-0036-T05 | wait | — | workplans/FLEX-WP-0036-runtime-rename-access-engine.md | ++| task | FLEX-WP-0036-T06 | wait | — | workplans/FLEX-WP-0036-runtime-rename-access-engine.md | + | task | FLEX-WP-0036-T07 | todo | — | workplans/FLEX-WP-0036-runtime-rename-access-engine.md | + | task | FLEX-WP-0036-T08 | todo | — | workplans/FLEX-WP-0036-runtime-rename-access-engine.md | + | intake | FLEX-IN-0001 | closed | — | intakes/intakes.md | diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-auth/integration-manifest-r1.json b/docs/changes/netkingdom-target-2026-10-16/decision-auth/integration-manifest-r1.json new file mode 100644 index 00000000..38de17ad --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-auth/integration-manifest-r1.json @@ -0,0 +1,57 @@ +{ + "schema": "netkingdom.source-integration.v1", + "owner": "secrets-engine", + "checkout": "/tmp/netkingdom-secrets-decision-auth", + "source_base": "6bd7129eac94131b4cf689e47cbdc9899418beb3", + "patch": "implementation.patch", + "patch_sha256": "1cd7adab482c1e359786ed403e1bb209e76956c62ee045d85bb8a59a13fe55b7", + "paths": [ + "SCOPE.md", + "docs/pdp-access-path.md", + "scripts/check_pdp_producer_source.py", + "src/secrets_engine/approval_consume.py", + "src/secrets_engine/config.py", + "src/secrets_engine/decision_check.py", + "src/secrets_engine/decision_signature.py", + "tests/fixtures/pdp-signature/decision_rotate_signed.json", + "tests/fixtures/pdp-signature/decision_rotate_signed_tampered.json", + "tests/signature_support.py", + "tests/test_approval_auth.py", + "tests/test_consume_binding_join.py", + "tests/test_decision_signature.py", + "tests/test_decision_transport.py", + "verifier/LICENSE.access-engine", + "verifier/README.md", + "verifier/go.mod", + "verifier/internal/sign/sign.go", + "verifier/main.go", + "verifier/pkg/api/canonical.go", + "verifier/pkg/api/caring.go", + "verifier/pkg/api/doc.go", + "verifier/pkg/api/resource_manifest.go", + "verifier/producer-source.json", + "verifier/testsupport/signer/main.go", + "workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md" + ], + "workplan": "SECRETS-WP-0014", + "task_status": { + "T02": "progress: implementation prepared; independent review/key admission pending", + "T03": "wait: no native rollout/trust admission" + }, + "verification": [ + "89 focused exact-source tests passed", + "20 initial native producer/consumer tests passed", + "Access producer go test ./internal/sign ./pkg/api passed", + "producer source/fixture hash check against current Access owner passed" + ], + "native_acceptance": false, + "canonical_owner_modified": false, + "secrets_read": false, + "integration_instructions": "Review/apply implementation.patch to current source after base/dirty provenance check; do not apply original dirty provenance patches. Install reviewed verifier separately; do not deploy/admit keys automatically. Root handles canonical integration/fix-consistency.", + "limitations": [ + "Python wheel alone lacks installed Go helper; deployment package must install it", + "Signer plus signed evaluator/mode and policy artifact digest binds issuer/pin; producer has no distinct instance issuer field", + "Trust file must be independently admitted; no production keys/config changed", + "Full suite initial collection lacked optional clock import; broader rerun remains under investigation, focused suite green" + ] +} diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-auth/integration-manifest-r3.json b/docs/changes/netkingdom-target-2026-10-16/decision-auth/integration-manifest-r3.json new file mode 100644 index 00000000..905e3283 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-auth/integration-manifest-r3.json @@ -0,0 +1,43 @@ +{ + "schema": "netkingdom.source-integration.v1", + "revision": "r3", + "base_patch": "implementation-r2.patch", + "base_patch_sha256": "98c4300c42fe93759a55297fe6629f1d4aeed249c3625f7fd37c8ab523a08ca9", + "delta": "implementation-r3-delta.patch", + "delta_sha256": "b6c944fee615298a29dbd50ba0ad437ff76e6eab190f6168427e4ae5dbbfc59c", + "files": { + "src/secrets_engine/config.py": { + "r2_sha256": "be74993577237d52523ead470fad167e0c5834df3b064a3d0fca95235cc6ddb1", + "r3_sha256": "228f548822620d538ad0c025eb68d88091be542819ed571be06c9e69c5e912c6" + }, + "tests/test_consume_binding_join.py": { + "r2_sha256": "c3fed0d17cd42acd23798eec37adc20287b4b285fd0e001eb75769e9acb05b78", + "r3_sha256": "eb5f033057caebc470a6fad4fcfa2b30fbc92ff41217ad7ace6f329dae7e9af3" + }, + "tests/test_execution_context.py": { + "r2_sha256": "7a173c50a58bc8fe95decb936ba7c181b5c0279e51ecea937588d76c84cdc88a", + "r3_sha256": "7b405dd87a796b142cf0a4bbf48b9df074e99be1c985fe622386796e6ff491a1" + }, + "tests/test_human_control.py": { + "r2_sha256": "90c27818e17b78772d7699064c52759b3709ff4bf8fed21b1f63647905e46c84", + "r3_sha256": "edc9b464fe0aff4b05a4ce5123ff715b6ae53898f0f001c28c3815aaa462b301" + } + }, + "purpose": "Name public coordination/approval IDs explicitly; values and scanner policy unchanged", + "canonical_owner_modified": false, + "verification": { + "scanner": { + "blocked_blob_ids": [], + "scanned_blobs": 21, + "scanner_sha256": "88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509", + "scanner_version": "8.30.1", + "scope": "changed-staged-blobs", + "status": "clean" + }, + "scanned_files_match_r3_hashes": true, + "focused_tests": "84 passed in 9.07s", + "config": "default topic and explicit SECRETS_ENGINE_TOPIC_ID override preserved", + "diff_whitespace": "pass", + "dependent_import_tests": "2 passed in 3.41s" + } +} diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-auth/integration-manifest.json b/docs/changes/netkingdom-target-2026-10-16/decision-auth/integration-manifest.json new file mode 100644 index 00000000..8559820c --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-auth/integration-manifest.json @@ -0,0 +1,68 @@ +{ + "schema": "netkingdom.source-integration.v1", + "owner": "secrets-engine", + "checkout": "/tmp/netkingdom-secrets-decision-auth", + "source_base": "6bd7129eac94131b4cf689e47cbdc9899418beb3", + "patch": "implementation-r2.patch", + "patch_sha256": "98c4300c42fe93759a55297fe6629f1d4aeed249c3625f7fd37c8ab523a08ca9", + "paths": [ + "SCOPE.md", + "docs/pdp-access-path.md", + "scripts/check_pdp_producer_source.py", + "src/secrets_engine/approval_consume.py", + "src/secrets_engine/config.py", + "src/secrets_engine/decision_check.py", + "src/secrets_engine/decision_signature.py", + "tests/fixtures/pdp-signature/decision_rotate_signed.json", + "tests/fixtures/pdp-signature/decision_rotate_signed_tampered.json", + "tests/signature_support.py", + "tests/test_approval_auth.py", + "tests/test_consume_binding_join.py", + "tests/test_decision_replay.py", + "tests/test_decision_signature.py", + "tests/test_decision_transport.py", + "tests/test_execution_context.py", + "tests/test_human_control.py", + "tests/test_integration_authorization.py", + "verifier/LICENSE.access-engine", + "verifier/README.md", + "verifier/go.mod", + "verifier/internal/sign/sign.go", + "verifier/main.go", + "verifier/pkg/api/canonical.go", + "verifier/pkg/api/caring.go", + "verifier/pkg/api/doc.go", + "verifier/pkg/api/resource_manifest.go", + "verifier/producer-source.json", + "verifier/testsupport/signer/main.go", + "workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md" + ], + "workplan": "SECRETS-WP-0014", + "task_status": { + "T02": "progress: implementation prepared; independent review/key admission pending", + "T03": "wait: no native rollout/trust admission" + }, + "verification": [ + "158 focused tests passed in 12.42s", + "Full candidate: 685 passed, 16 failed, 1 skipped in 51.58s", + "All 16 candidate failures exactly match unchanged canonical baseline failed-file run (120 passed/16 failed); zero new failure ids", + "Expanded execution/human/disposable OpenBao: 71 passed, 1 matching baseline failure", + "Real Access producer go test ./internal/sign ./pkg/api passed", + "Exact producer/fixture hashes and git diff --check pass" + ], + "native_acceptance": false, + "canonical_owner_modified": false, + "secrets_read": false, + "integration_instructions": "Review/apply implementation.patch to current source after base/dirty provenance check; do not apply original dirty provenance patches. Install reviewed verifier separately; do not deploy/admit keys automatically. Root handles canonical integration/fix-consistency.", + "limitations": [ + "Python wheel alone lacks installed Go helper; deployment package must install it", + "Signer plus signed evaluator/mode and policy artifact digest binds issuer/pin; producer has no distinct instance issuer field", + "Trust file must be independently admitted; no production keys/config changed", + "Sixteen pre-existing sandbox tmp ancestry ownership failures prevent claiming full green; exact baseline/candidate ids retained in verification-r2.json. No new failure ids remain." + ], + "revision": "r2", + "supersedes": { + "patch": "implementation.patch", + "sha256": "1cd7adab482c1e359786ed403e1bb209e76956c62ee045d85bb8a59a13fe55b7" + } +} diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-auth/secrets-decision-r3-scanner.json b/docs/changes/netkingdom-target-2026-10-16/decision-auth/secrets-decision-r3-scanner.json new file mode 100644 index 00000000..3aacdba4 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-auth/secrets-decision-r3-scanner.json @@ -0,0 +1 @@ +{"blocked_blob_ids": [], "scanned_blobs": 21, "scanner_sha256": "88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509", "scanner_version": "8.30.1", "scope": "changed-staged-blobs", "status": "clean"} diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-auth/secrets-engine-original-dirty.patch b/docs/changes/netkingdom-target-2026-10-16/decision-auth/secrets-engine-original-dirty.patch new file mode 100644 index 00000000..e24b6469 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-auth/secrets-engine-original-dirty.patch @@ -0,0 +1,92 @@ +diff --git a/.custodian-brief.md b/.custodian-brief.md +index 0cb912a..122e79b 100644 +--- a/.custodian-brief.md ++++ b/.custodian-brief.md +@@ -2,17 +2,33 @@ + # Custodian Brief — secrets-engine + + **Domain:** infotech +-**Last synced:** 2026-10-11 17:52 UTC ++**Last synced:** 2026-10-12 06:27 UTC + **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* + + ## Active Workstreams + ++### Constrain PDP transport and authenticate decision responses ++Progress: 1/3 done | workplan_id: `7252469d-d7a5-5c18-8911-b511b8c6a736` ++ ++**Open tasks:** ++- ! Verify the admitted native consumer and rotation path `85aa4c78` ++ *(wait: Requires reviewed verification-key admission, source enforcement and exact native rollout receipt; no production trust change is proposed yet.)* ++- · Implement independently anchored responder authentication `877782e5` ++ + ### Design and prove task-scoped protected OpenBao execution + Progress: 1/3 done | workplan_id: `5e38aecc-85ca-5f55-94c6-9fbb08604819` + + **Open tasks:** ++- ! Define the exact mediated execution contract `b2f0d462` ++ *(wait: Independent Platform mediated-contract review and identity/approval/PDP owner returns remain required; D3 wrapped primitive source is implemented, no native entitlement is inferred.)* + - ! Implement and prove one native metadata action `5f8f7c41` +-- ► Define the exact mediated execution contract `b2f0d462` ++ ++### Production-safe provisioning, authorization, and lifecycle hardening ++Progress: 6/7 done | workplan_id: `68a39be1-bd9c-5133-ad64-e7bca892aaf3` ++ ++**Open tasks:** ++- ! Resume native production lane adoption `a0a1dd92` ++ *(wait: Engine approval hardening is complete. Remaining per-lane cutover includes native routing/proxy retirement with ops-warden under SECRETS-WP-0006-T05/T06; the exact OpenRouter key-check receipt does not establish broader recipient readiness.)* + + ### Activate native Claude credential delivery for Glas + Progress: 2/3 done | workplan_id: `40ccc3b4-d046-5a58-8649-e7935f45c974` +@@ -26,23 +42,16 @@ Progress: 4/6 done | workplan_id: `31f7f8ea-7f73-516c-8877-f03a13f1db82` + + **Open tasks:** + - ! Stage live apply and verification `fb103f1e` +- *(wait: OpenRouter key-check native acceptance is recorded in SECRETS-WP-0010-T03. Other lanes still need exact approvals, scoped attended apply and per-lane positive/negative/health/revocation evidence.)* ++ *(wait: Owner work first: publish a reviewed scoped-apply command per lane. Per-lane approvals are no longer needed (D1 standing rule); each lane with a command enters the attended window.)* + - ! Reconcile routing ownership and retire interim proxies `1431edae` + *(wait: Needs owner-agreed routing/proxy retirement for each verified lane and custody disposition of the legacy npm pointer; a single approved OpenRouter key-check does not authorize broader routing cutover.)* + +-### Production-safe provisioning, authorization, and lifecycle hardening +-Progress: 6/7 done | workplan_id: `68a39be1-bd9c-5133-ad64-e7bca892aaf3` +- +-**Open tasks:** +-- ! Resume native production lane adoption `a0a1dd92` +- *(wait: Engine approval hardening is complete. Remaining per-lane cutover includes native routing/proxy retirement with ops-warden under SECRETS-WP-0006-T05/T06; the exact OpenRouter key-check receipt does not establish broader recipient readiness.)* +- + ### Evolve the Lifecycle engine to the accepted security layer model + Progress: 5/6 done | workplan_id: `9c9e5164-b2f5-5ea2-a557-5368d65e9fe0` + + **Open tasks:** + - ! No standing engine credential `d7bc8bdc` +- *(wait: Positive service-JWT login proof passed 2026-10-10 (SECRETS-WP-0012-T03, docs/evidence/2026-10-10-keycape-openbao-probe.json). Waits on railiance-platform closing RPF-WP-0035-T02 with the CCR-2026-0038 negative checks, then on routing live engine actions through service-jwt instead of bootstrap/env.)* ++ *(wait: Published JWT contract and positive login/revocation are delivered. Native KEY-WP-0039-T02 claim negatives, CCR-2026-0038 effective-policy/capability/expiry/use checks and MASON-WP-0009-T05 remain. Login-self-only grants no protected-operation authority: agree and implement separately reviewed task-scoped backend execution entitlement, then prove functional steady-state service-identity adoption without bootstrap/env fallback.)* + + ## Inbox Hygiene + +diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md +index 70e5831..a467a0e 100644 +--- a/WORK-RECORDS.md ++++ b/WORK-RECORDS.md +@@ -22,6 +22,7 @@ + | workplan | SECRETS-WP-0011 | finished | — | workplans/SECRETS-WP-0011-multi-lane-exec-owner-delivery.md | + | workplan | SECRETS-WP-0012 | finished | — | workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md | + | workplan | SECRETS-WP-0013 | proposed | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md | ++| workplan | SECRETS-WP-0014 | active | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md | + | task | SECRETS-WP-ADHOC-2026-08-21-T01 | done | — | workplans/ADHOC-2026-08-21.md | + | task | SECRETS-WP-ADHOC-2026-08-23-T01 | done | — | workplans/ADHOC-2026-08-23.md | + | task | SECRETS-WP-0001-T01 | done | — | workplans/SECRETS-WP-0001-statehub-bootstrap.md | +@@ -83,9 +84,12 @@ + | task | SECRETS-WP-0012-T02 | cancel | — | workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md | + | task | SECRETS-WP-0012-T03 | done | — | workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md | + | task | SECRETS-WP-0012-T04 | done | — | workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md | +-| task | SECRETS-WP-0013-T01 | progress | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md | ++| task | SECRETS-WP-0013-T01 | wait | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md | + | task | SECRETS-WP-0013-T02 | done | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md | + | task | SECRETS-WP-0013-T03 | wait | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md | ++| task | SECRETS-WP-0014-T01 | done | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md | ++| task | SECRETS-WP-0014-T02 | todo | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md | ++| task | SECRETS-WP-0014-T03 | wait | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md | + | intake | SECRETS-IN-0001 | closed | — | intakes/intakes.md | + | intake | SECRETS-IN-0002 | closed | — | intakes/intakes.md | + | intake | SECRETS-IN-0003 | closed | — | intakes/intakes.md | diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-auth/source-provenance.json b/docs/changes/netkingdom-target-2026-10-16/decision-auth/source-provenance.json new file mode 100644 index 00000000..db3601cc --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-auth/source-provenance.json @@ -0,0 +1,12 @@ +{ + "secrets-engine": { + "head": "6bd7129eac94131b4cf689e47cbdc9899418beb3", + "dirty_patch_sha256": "499b8b4cd583b097d225dfe6e33dde9cbf72329054796a8762a535908c7af3dc", + "status": "M .custodian-brief.md\n M WORK-RECORDS.md\n" + }, + "access-engine": { + "head": "62ac2b4e99dec2005c7c407fe633612578a7907d", + "dirty_patch_sha256": "a20bc75c88e2861e37df6b793c2346efbd8c3fc4a46c7fa2ad0e5aa8c0144130", + "status": " M .custodian-brief.md\n M WORK-RECORDS.md\n" + } +} diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-auth/verification-r2.json b/docs/changes/netkingdom-target-2026-10-16/decision-auth/verification-r2.json new file mode 100644 index 00000000..ed000678 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-auth/verification-r2.json @@ -0,0 +1,42 @@ +{ + "schema": "netkingdom.verification.v1", + "patch": "implementation-r2.patch", + "patch_sha256": "98c4300c42fe93759a55297fe6629f1d4aeed249c3625f7fd37c8ab523a08ca9", + "full_candidate": { + "passed": 685, + "failed": 16, + "skipped": 1, + "seconds": 51.58 + }, + "focused_candidate": { + "passed": 158, + "failed": 0, + "seconds": 12.42 + }, + "unchanged_owner_baseline_failed_files": { + "passed": 120, + "failed": 16, + "seconds": 14.08 + }, + "new_failures": [], + "baseline_failure_test_ids": [ + "tests/test_exec_owner.py::test_actual_bound_child_gets_only_selected_secret_and_fixed_environment", + "tests/test_exec_owner.py::test_binding_changed_after_approval_refuses_before_fetch", + "tests/test_exec_owner.py::test_config_changed_during_fetch_does_not_launch", + "tests/test_exec_owner_companions.py::test_companion_fetch_failure_starts_no_child", + "tests/test_exec_owner_companions.py::test_companion_value_reaches_bound_child_and_is_redacted", + "tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[consent]", + "tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[env]", + "tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[extra]", + "tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[field]", + "tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[lane]", + "tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[missing]", + "tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[stage]", + "tests/test_exec_owner_companions.py::test_each_lane_is_gated_separately_before_backend", + "tests/test_exec_owner_companions.py::test_unresolvable_companion_refuses_before_any_gate", + "tests/test_human_control.py::test_real_exec_handler_rejects_undeclared_claim_before_consume_backend_child", + "tests/test_integration_companions.py::test_two_lanes_reach_one_bound_owner" + ], + "reason": "Every candidate failure exactly matches a failure reproduced against unchanged canonical owner source; all refuse on exec_owner._check_path sandbox /tmp ancestry ownership. No guard bypass or ownership relaxation was added.", + "native_acceptance": false +} diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-handoff/README.md b/docs/changes/netkingdom-target-2026-10-16/decision-handoff/README.md new file mode 100644 index 00000000..f4a274be --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-handoff/README.md @@ -0,0 +1,29 @@ +# Strict Secrets task handoff source package + +Review `implementation-r1.patch` on c80a944 independently of the CLI packaging +revision. Exact source hashes, public API and tests are in the integration manifest. + +The module calls the real existing serving claim/signed Check validators, retains +immutable served artifacts, verifies exact descriptor/request/native approval and +enforced caller joins, then exposes a process-local one-shot strict CAS handoff. +It refuses missing/mismatched/coerced response fields and historical idempotence. +No raw mapping/boolean/authorizer callback supplies authorization. Platform must +bind its owner Plan, reserve durable journal, call this consume, independently +GET-confirm actual original Approval consumption and journal it before native. + +Focused tests: 246 pass. Full: 736 pass/16 fail/1 skip; exact16 failure IDs match the +unchanged canonical baseline's temporary-path ownership refusals, with zero new +failure IDs. All failing initial observations are retained; no guard was weakened. +Pinned scanner is clean5 blobs and whitespace checks pass. Real producer Go +signatures and local HTTP are exercised; the fixture caller/approval are synthetic. + +No live key/trust, policy admission, server mapping, service binding propagation, +backend entitlement or protected effect is claimed. WP13 native work remains open. +Platform owns the independent actual-provider/Access-serve whole-stack proof. + +R2 supersedes R1 with an exactness repair: independently recompute the served +native act digest, because Python dict equality can equate True and integer1 in +registry enrichment. The retained R1 counterexample accepted that malformed fact; +R2 refuses it. Public interface and all source gates are unchanged. Review full +`implementation-r2.patch` or tiny `r1-to-r2.patch`; R1 is retained unchanged. +R2:247 focused pass; full737 pass/16 baseline failures/1skip; scanner clean5. diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-handoff/implementation-r1.patch b/docs/changes/netkingdom-target-2026-10-16/decision-handoff/implementation-r1.patch new file mode 100644 index 00000000..31792ec3 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-handoff/implementation-r1.patch @@ -0,0 +1,600 @@ +diff --git a/docs/task-execution-handoff.md b/docs/task-execution-handoff.md +new file mode 100644 +index 0000000..5821175 +--- /dev/null ++++ b/docs/task-execution-handoff.md +@@ -0,0 +1,78 @@ ++# Strict Secrets service task-execution handoff ++ ++This is a narrow source seam for existing SECRETS-WP-0013, not a native grant or ++an execute-ccr CLI. Both Access runtime-deny policy variants remain unchanged. ++The proposed service subject/transport mapping and the complete native executor ++still need independent owner admission. Positive source fixtures do not admit ++that mapping, a policy, a caller, a production key or an OpenBao entitlement. ++ ++Platform's trusted owner process calls: ++ ++```python ++from secrets_engine.task_execution_handoff import ( ++ authorize_task_execution, consume_task_execution, ++) ++authorization = authorize_task_execution(cfg, entry, execution_contract=contract) ++record = authorization.as_owner_handoff() ++# Independently bind exact owner Plan/descriptor; reserve durable one-shot journal. ++# Persist pending strict-CAS intent before calling: ++consumption = consume_task_execution(cfg, authorization) ++# Independently GET-confirm exact original consumption with Approval Engine's ++# ExecutionConfirmationClient before journaling consume-confirmed/native work. ++``` ++ ++Preparation uses the existing empty fields/policy_targets/auth_targets defaults ++and existing build_action_request/claim/Check validators. No caller-selected ++transport, authorizer callback, policy result or raw handoff mapping is accepted. ++The normal authorizer retains detached serialized request, claim and authenticated ++Go-typed envelope snapshots; its existing evidence output does not expose them. ++The narrow adapter reuses the validators, rather than evaluating a policy. ++ ++The adapter checks the exact descriptor in the signed context, full evaluated and ++submitted request digests, approval-free digest, exact five-field native act and ++native binding digest, service subject/resource/tenant correspondence and original ++decision/approval/deadline bounds. Initiating/represented subject is strictly ++`secrets-engine` of type `service`; the execution actor is `service:secrets-engine`. ++Signed caller provenance must report `enforce`, the proposed exact Secrets ++ServiceAccount principal, `flex-auth` audience and live expiry bounding the ++operation deadline. This consumes the signed PDP's caller attestation; it does ++not independently perform TokenReview or promote the proposed server mapping. ++Human/delegation/projected-identity flags cannot substitute for this service seam. ++No service_actor_binding request propagation or source-policy activation is added. ++ ++`as_owner_handoff()` returns a detached non-secret mapping with: ++ ++- execution_contract ++- approval_id, request_digest, decision_id ++- native_binding, approval_binding_digest, submitted_request_digest ++- decision_not_before, decision_expires_at, approval_expires_at ++ ++The exact native binding is derived from the signed evaluated request using the ++published Approval five-field mapping, and is compared to the served claim. ++Descriptor/CCR/grant/source/catalog/target correspondence is retained end to end; ++Platform still independently compiles its owner Plan and grant from owner bytes. ++The mapping is evidence for that join, never a transferable bearer authorization. ++ ++Consumption accepts only the module-created opaque prepared object in that same ++trusted owner process. It fixes source/config coordinates, reauthenticates using ++currently admitted responder keys, and rechecks claim/decision/descriptor/caller ++freshness before reading the CAS credential. Each object is reserved once before ++CAS/revalidation and cannot retry after any failure or unknown outcome. Its ++process-local guard complements Platform's durable cross-process journal; it is ++not a replacement for that journal or process isolation. ++ ++The strict response path uses the existing CAS transport but rejects duplicate, ++nonfinite or excessively deep JSON, missing/mismatched approval and decision IDs, ++wrong digest/status, missing or nonboolean idempotence, historical idempotent ++success, verdict fields and absent/naive/invalid consumption timestamps. The ++consumption instant and post-CAS freshness must remain inside the original ++bounds. No field is coerced or filled from the submitted binding. A late/uncertain ++CAS may have occurred; it never licenses retry or protected native work. ++ ++The returned immutable TaskExecutionConsumption contains exact approval_id, ++request_digest, decision_id, consumed_at and explicit false idempotent. It proves ++only the strict CAS response observed by Secrets. Platform must independently ++confirm original durable consumption using Approval's real provider client, ++journal it, then recheck its exact Plan, owner sources, grant and deadlines before ++any native operation. Neither a dataclass nor a caller-supplied boolean admits ++execution. Existing generic CLI CAS behavior is otherwise unchanged. +diff --git a/src/secrets_engine/approval_consume.py b/src/secrets_engine/approval_consume.py +index bb1e766..1cf4140 100644 +--- a/src/secrets_engine/approval_consume.py ++++ b/src/secrets_engine/approval_consume.py +@@ -23,7 +23,7 @@ from secrets_engine.execution_contract import ExecutionContract, require_executi + from secrets_engine.approval_claim import observe_pdp_approval_claim, validate_approval_claim + from secrets_engine.catalog import human_control_required + from secrets_engine.decision_check import check_decision, require_supported_pdp_address +-from secrets_engine.decision_signature import load_trust ++from secrets_engine.decision_signature import load_trust, strict_json + from secrets_engine.authorization import ( + build_action_request, + validate_decision_envelope, +@@ -70,6 +70,8 @@ class AuthorizedAction: + binding: ConsumeBinding + decision_id: str + expires_at: str ++ # Detached original served artifacts for the narrow task-execution handoff. ++ artifacts: str = field(default="", repr=False, compare=False) + + @property + def id(self) -> str: +@@ -380,6 +382,8 @@ def authorize_action( + ), + decision_id=validated.decision_id, + expires_at=validated.expires_at, ++ artifacts=json.dumps({"request":expected_request,"claim":observation.claim,"envelope":envelope}, ++ sort_keys=True,separators=(",",":"),allow_nan=False), + ) + + +@@ -391,6 +395,7 @@ def consume_approval( + binding: ConsumeBinding, + timeout_seconds: float = 3, + opener: Callable[..., Any] = credential_urlopen, ++ strict_response: bool = False, + ) -> ConsumedApproval: + """POST /v1/approvals/{id}/consume. Fail closed on anything but confirmed use.""" + if not base_url or not base_url.startswith(("http://", "https://")): +@@ -438,8 +443,8 @@ def consume_approval( + if status != 200 or len(raw) > _MAX_BODY: + raise DecisionError(_status_message(status if status != 200 else 502)) + try: +- payload = json.loads(raw.decode("utf-8")) +- except (UnicodeDecodeError, json.JSONDecodeError) as exc: ++ payload = (strict_json if strict_response else json.loads)(raw.decode("utf-8")) ++ except (UnicodeDecodeError, ValueError, RecursionError) as exc: + raise DecisionError("approval consume returned invalid JSON") from exc + if not isinstance(payload, dict): + raise DecisionError("approval consume returned invalid payload") +@@ -447,6 +452,19 @@ def consume_approval( + raise DecisionError("approval consumption was not confirmed") + if payload.get("request_digest") != binding.request_digest: + raise DecisionError("approval consume digest does not match the request") ++ if strict_response: ++ from datetime import datetime ++ if (payload.get("approval_id") != approval_id ++ or payload.get("decision_id") != binding.decision_id ++ or payload.get("idempotent") is not False ++ or any(key in payload for key in ("effect","allow","deny","decision"))): ++ raise DecisionError("strict task consumption correspondence refused") ++ try: ++ stamp = datetime.fromisoformat(payload["consumed_at"].replace("Z","+00:00")) ++ if stamp.tzinfo is None or stamp.utcoffset() is None: ++ raise ValueError("timezone required") ++ except (KeyError,TypeError,AttributeError,ValueError): ++ raise DecisionError("strict task consumption timestamp refused") from None + decision_id = payload.get("decision_id") + if decision_id is not None and ( + not isinstance(decision_id, str) or not decision_id +diff --git a/src/secrets_engine/task_execution_handoff.py b/src/secrets_engine/task_execution_handoff.py +new file mode 100644 +index 0000000..deab6e2 +--- /dev/null ++++ b/src/secrets_engine/task_execution_handoff.py +@@ -0,0 +1,191 @@ ++"""Narrow service-task PEP handoff; records never grant native execution. ++ ++Platform calls preparation itself, binds its independently compiled owner Plan, ++reserves/journals once, then calls strict consumption and independent Approval ++GET confirmation. No inbound mapping/boolean or constructor creates authority. ++Actual policy/server input mapping and runtime entitlement remain unadmitted. ++""" ++from __future__ import annotations ++ ++from dataclasses import dataclass ++from datetime import datetime ++import json ++from threading import Lock ++ ++from secrets_engine.approval_auth import approval_token, credential_urlopen ++from secrets_engine.approval_claim import binding_from_check_request, claim_binding_digest, validate_approval_claim ++from secrets_engine.approval_consume import authorize_action, consume_approval ++from secrets_engine.application_time import read_window, validity_bounds ++from secrets_engine.authorization import validate_decision_envelope, request_digest, approval_binding_digest ++from secrets_engine.decision_signature import authenticate_response, load_trust, strict_json ++from secrets_engine.errors import DecisionError ++from secrets_engine.execution_contract import ExecutionContract, require_execution_deadline ++ ++_CALLER = 'system:serviceaccount:secrets-engine:secrets-engine' ++_NATIVE_FIELDS = ('action','actor','principal','purpose','target') ++_COORDINATES = ('approval_url','approval_token_file','approval_client_secret_file','keycape_token_url','keycape_issuer', ++ 'pdp_url','pdp_token_file','pdp_trust_file','pdp_verifier','clock_trust_file', ++ 'authorization_subject_id','authorization_subject_type','authorization_policy_package','authorization_policy_version') ++_ISSUED = object() ++ ++ ++def _refuse(): ++ raise DecisionError('strict task execution handoff correspondence refused') ++ ++ ++def _coordinates(cfg): ++ return tuple(str(getattr(cfg,name,'') or '') for name in _COORDINATES) ++ ++ ++def _stamp(value): ++ try: ++ parsed=datetime.fromisoformat(value.replace('Z','+00:00')) ++ if parsed.tzinfo is None or parsed.utcoffset() is None: ++ _refuse() ++ return parsed ++ except (AttributeError,TypeError,ValueError): ++ _refuse() ++ ++ ++class TaskExecutionAuthorization: ++ """Opaque process-local prepared state; not a bearer credential or allow.""" ++ __slots__=('_action','_contract','_coordinates','_handoff','_used','_lock') ++ ++ def __init__(self, issuer=None, *, action=None, contract=None, coordinates=None, handoff=None): ++ if issuer is not _ISSUED: ++ _refuse() ++ object.__setattr__(self,'_action',action) ++ object.__setattr__(self,'_contract',contract) ++ object.__setattr__(self,'_coordinates',coordinates) ++ object.__setattr__(self,'_handoff',json.dumps(handoff,sort_keys=True,separators=(',',':'),allow_nan=False)) ++ object.__setattr__(self,'_used',False) ++ object.__setattr__(self,'_lock',Lock()) ++ ++ def __setattr__(self,name,value): ++ raise AttributeError('prepared task handoff is immutable') ++ ++ def as_owner_handoff(self): ++ return strict_json(self._handoff) ++ ++ ++@dataclass(frozen=True) ++class TaskExecutionConsumption: ++ approval_id: str ++ request_digest: str ++ decision_id: str ++ consumed_at: str ++ idempotent: bool ++ ++ ++def _validate(cfg, action, contract): ++ if type(contract) is not ExecutionContract or not action.artifacts: ++ _refuse() ++ descriptor=contract.as_dict() ++ artifacts=strict_json(action.artifacts) ++ request,claim,envelope=(artifacts[name] for name in ('request','claim','envelope')) ++ # Rotation/removal and installed-helper changes cannot hide behind a cached ++ # prepared label. Reauthenticate before each strict CAS, without networking. ++ authenticated=authenticate_response(json.dumps(envelope,allow_nan=False).encode(), ++ trust=load_trust(getattr(cfg,'pdp_trust_file',None)), ++ verifier=getattr(cfg,'pdp_verifier',None)) ++ if authenticated != envelope: ++ _refuse() ++ window=read_window(cfg) ++ lower,upper=validity_bounds(window=window) ++ validated=validate_decision_envelope(envelope,request, ++ accepted_policy_packages={str(cfg.authorization_policy_package)}, ++ accepted_policy_versions={str(cfg.authorization_policy_version)}, ++ expected_approval_binding_digest=claim['binding']['pdp_digest'],time_window=window) ++ validate_approval_claim(claim,approval_id=action.binding.approval_id, ++ expected_pdp_digest=envelope['binding']['approval_binding_digest'], ++ require_human_control=True,time_window=window) ++ require_execution_deadline(request,time_window=window, ++ bounds=(claim['validity']['expires_at'],envelope['lifetime']['expires_at'])) ++ binding=envelope['binding'] ++ if (envelope['lifetime'].get('kind')!='ttl' ++ or _stamp(envelope['lifetime'].get('not_before'))>lower): ++ _refuse() ++ if (request['action']!='execute-ccr' or binding.get('action')!='execute-ccr' ++ or request['tenant']!='tenant:platform' or binding.get('tenant')!='tenant:platform' ++ or binding.get('context')!=request['context'] ++ or request['context'].get('execution_contract')!=descriptor ++ or binding.get('request_digest')!=request_digest(binding) ++ or binding.get('approval_binding_digest')!=approval_binding_digest(binding) ++ or action.binding.request_digest!=validated.request_digest ++ or action.binding.decision_id!=validated.decision_id): ++ _refuse() ++ subject=binding['subject']; resource=binding['resource'] ++ if (request['subject']!={'id':'secrets-engine','type':'service'} ++ or subject.get('id')!='secrets-engine' or subject.get('type')!='service' ++ or subject.get('tenant') not in (None,'tenant:platform') ++ or 'principal' in subject.get('attributes',{}) ++ or envelope['subject']!=subject or envelope['resource']!=resource ++ or descriptor['execution_actor']!='service:secrets-engine' ++ or any(resource.get(key)!=request['resource'].get(key) for key in ('id','type','system')) ++ or resource.get('tenant') not in (None,'tenant:platform') ++ or any(resource.get('attributes',{}).get(key)!=value for key,value in request['resource']['attributes'].items()) ++ or any(key in binding['context'] for key in ('task_execution_projection','initiator','represented_principal','delegation','verified','owner_admission'))): ++ _refuse() ++ caller=envelope['provenance'].get('caller') ++ if (not isinstance(caller,dict) or caller.get('mode')!='enforce' ++ or caller.get('principal')!=_CALLER or caller.get('audience')!='flex-auth' ++ or _stamp(caller.get('not_after')) < _stamp(descriptor['operation_deadline']) ++ or upper >= _stamp(caller.get('not_after'))): ++ _refuse() ++ native=binding_from_check_request(binding) ++ supplied=claim['binding'] ++ if (any(supplied.get(key)!=native[key] for key in _NATIVE_FIELDS) ++ or supplied.get('digest')!=claim_binding_digest(**native)): ++ _refuse() ++ return {'execution_contract':descriptor,'approval_id':action.binding.approval_id, ++ 'request_digest':validated.request_digest,'decision_id':validated.decision_id, ++ 'native_binding':native,'approval_binding_digest':binding['approval_binding_digest'], ++ 'submitted_request_digest':validated.submitted_request_digest, ++ 'decision_not_before':envelope['lifetime'].get('not_before') or '', ++ 'decision_expires_at':envelope['lifetime']['expires_at'], ++ 'approval_expires_at':claim['validity']['expires_at']} ++ ++ ++def authorize_task_execution(cfg, entry, *, execution_contract): ++ if type(execution_contract) is not ExecutionContract: ++ _refuse() ++ # No injected transports or policy callbacks: use the actual existing PIP/PDP ++ # clients and mandatory signature validators in the owner process. ++ action=authorize_action(cfg,entry,'execute-ccr',execution_contract=execution_contract) ++ if action is None: ++ _refuse() ++ try: ++ handoff=_validate(cfg,action,execution_contract) ++ return TaskExecutionAuthorization(_ISSUED,action=action,contract=ExecutionContract.parse(execution_contract.as_dict()), ++ coordinates=_coordinates(cfg),handoff=handoff) ++ except (ValueError,KeyError,TypeError,RecursionError) as exc: ++ raise DecisionError('strict task execution handoff malformed') from exc ++ ++ ++def consume_task_execution(cfg, authorization): ++ if type(authorization) is not TaskExecutionAuthorization: ++ _refuse() ++ with authorization._lock: ++ if authorization._used or _coordinates(cfg)!=authorization._coordinates: ++ _refuse() ++ # Reserve once before all work; failures/unknown outcomes never retry this ++ # prepared object. Platform's durable journal is the cross-process guard. ++ object.__setattr__(authorization,'_used',True) ++ try: ++ handoff=_validate(cfg,authorization._action,authorization._contract) ++ if handoff!=authorization.as_owner_handoff(): ++ _refuse() ++ result=consume_approval(base_url=str(cfg.approval_url), ++ token_provider=lambda:approval_token(cfg,scope='approval:consume'), ++ binding=authorization._action.binding,opener=credential_urlopen,strict_response=True) ++ stamp=_stamp(result.consumed_at) ++ window=read_window(cfg);_,upper=validity_bounds(window=window) ++ if (stamp>upper or (handoff['decision_not_before'] and stamp<_stamp(handoff['decision_not_before'])) ++ or any(stamp>=_stamp(value) for value in (handoff['decision_expires_at'],handoff['approval_expires_at'], ++ handoff['execution_contract']['operation_deadline']))): ++ _refuse() ++ # Late CAS completion is historical evidence only; refuse native dispatch. ++ _validate(cfg,authorization._action,authorization._contract) ++ return TaskExecutionConsumption(result.approval_id,result.request_digest,result.decision_id,result.consumed_at,result.idempotent) ++ except (ValueError,KeyError,TypeError,RecursionError) as exc: ++ raise DecisionError('strict task execution consumption malformed') from exc +diff --git a/tests/test_task_execution_handoff.py b/tests/test_task_execution_handoff.py +new file mode 100644 +index 0000000..b0961fc +--- /dev/null ++++ b/tests/test_task_execution_handoff.py +@@ -0,0 +1,212 @@ ++"""Real local HTTP/producer signatures, synthetic approval/caller only. ++ ++These source fixtures never enable Access's runtime-deny policy or admit a real ++caller, key, owner grant or native backend. Whole-provider integration is separate. ++""" ++import copy ++from datetime import datetime, timedelta, timezone ++from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer ++import json ++from pathlib import Path ++from threading import Thread ++ ++import pytest ++ ++from secrets_engine.approval_claim import binding_from_check_request, claim_binding_digest ++from secrets_engine.approval_consume import _expected_request ++from secrets_engine.authorization import request_digest, approval_binding_digest ++from secrets_engine.errors import DecisionError ++from secrets_engine.execution_contract import ExecutionContract ++from secrets_engine.task_execution_handoff import (authorize_task_execution,consume_task_execution, ++ TaskExecutionAuthorization) ++from tests.authorization_stub import AuthorizationStub ++from tests.signature_support import signature_tools, admit_tools, sign_envelope ++from tests.test_consume_binding_join import _Cfg,_token,APPROVAL_ID ++from tests.test_execution_context import entry,descriptor ++ ++ ++def stamp(moment): ++ return moment.isoformat() ++ ++ ++@pytest.fixture ++def lane(tmp_path,signature_tools): ++ e=entry();contract=ExecutionContract.parse(descriptor(e)) ++ cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service') ++ admit_tools(cfg,tmp_path,signature_tools) ++ original=_expected_request(cfg,e,'execute-ccr',execution_contract=contract) ++ stub=AuthorizationStub(approval_id=APPROVAL_ID,package=cfg.authorization_policy_package,version=cfg.authorization_policy_version) ++ stub.bind_request(original) ++ state={'calls':[],'claim_change':None,'decision_change':None,'response_change':None,'raw_response':None} ++ claim=stub.claim();native=binding_from_check_request(original) ++ claim['binding'].update(native,digest=claim_binding_digest(**native),human_control=True) ++ state['claim']=claim ++ ++ class Handler(BaseHTTPRequestHandler): ++ def log_message(self,*_args):pass ++ def reply(self,payload): ++ data=json.dumps(payload).encode() if isinstance(payload,dict) else payload ++ self.send_response(200);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data) ++ def do_GET(self): ++ state['calls'].append('claim') ++ value=copy.deepcopy(state['claim']) ++ if state['claim_change']:state['claim_change'](value) ++ self.reply(value) ++ def do_POST(self): ++ submitted=json.loads(self.rfile.read(int(self.headers['Content-Length']))) ++ if self.path=='/v1/check': ++ state['calls'].append('check') ++ envelope=stub.decision(submitted) ++ envelope['provenance']['caller']={'mode':'enforce','principal':'system:serviceaccount:secrets-engine:secrets-engine', ++ 'audience':'flex-auth','not_after':stamp(datetime.now(timezone.utc)+timedelta(minutes=20))} ++ if state['decision_change']:state['decision_change'](envelope) ++ self.reply(sign_envelope(envelope,signature_tools)) ++ else: ++ state['calls'].append('consume') ++ payload={'approval_id':APPROVAL_ID,'status':'consumed','request_digest':submitted['request_digest'], ++ 'decision_id':submitted['decision_id'],'consumed_at':stamp(datetime.now(timezone.utc)),'idempotent':False} ++ if state['response_change']:state['response_change'](payload) ++ self.reply(state['raw_response'] if state['raw_response'] is not None else payload) ++ httpd=ThreadingHTTPServer(('127.0.0.1',0),Handler) ++ thread=Thread(target=httpd.serve_forever,kwargs={'poll_interval':0.01},daemon=True);thread.start() ++ cfg.approval_url=cfg.pdp_url=f'http://127.0.0.1:{httpd.server_port}' ++ try:yield cfg,e,contract,state ++ finally:httpd.shutdown();httpd.server_close();thread.join(2) ++ ++ ++def prepared(lane): ++ cfg,e,contract,state=lane ++ return authorize_task_execution(cfg,e,execution_contract=contract) ++ ++ ++def test_exact_preparation_then_one_strict_cas(lane): ++ cfg,e,contract,state=lane ++ result=prepared(lane) ++ assert state['calls']==['claim','check'] ++ handoff=result.as_owner_handoff() ++ assert set(handoff)=={'execution_contract','approval_id','request_digest','decision_id','native_binding','approval_binding_digest', ++ 'submitted_request_digest','decision_not_before','decision_expires_at','approval_expires_at'} ++ assert handoff['execution_contract']==contract.as_dict() ++ assert handoff['native_binding']==binding_from_check_request(_expected_request(cfg,e,'execute-ccr',execution_contract=contract)) ++ changed=result.as_owner_handoff();changed['execution_contract']['ccr_id']='changed' ++ assert result.as_owner_handoff()==handoff ++ with pytest.raises(AttributeError):result._handoff='changed' ++ receipt=consume_task_execution(cfg,result) ++ assert receipt.approval_id==handoff['approval_id'] and receipt.request_digest==handoff['request_digest'] ++ assert receipt.decision_id==handoff['decision_id'] and receipt.idempotent is False ++ assert state['calls']==['claim','check','consume'] ++ with pytest.raises(DecisionError):consume_task_execution(cfg,result) ++ assert state['calls'].count('consume')==1 ++ ++ ++@pytest.mark.parametrize('field',['action','actor','principal','purpose','target','digest']) ++def test_changed_native_claim_refuses_before_cas(lane,field): ++ cfg,e,contract,state=lane ++ def change(claim):claim['binding'][field]={'id':'other'} if field=='target' else 'other' ++ state['claim_change']=change ++ with pytest.raises(DecisionError):prepared(lane) ++ assert 'consume' not in state['calls'] ++ ++ ++@pytest.mark.parametrize('field',['mode','principal','audience','not_after']) ++def test_signed_wrong_caller_refuses_before_cas(lane,field): ++ cfg,e,contract,state=lane ++ state['decision_change']=lambda envelope:envelope['provenance']['caller'].__setitem__(field,'wrong') ++ with pytest.raises(DecisionError):prepared(lane) ++ assert 'consume' not in state['calls'] ++ ++ ++@pytest.mark.parametrize('field',['execution_actor','execution_grant_ccr_id','grant_immutable_sha256','ccr_id','backend_origin']) ++def test_signed_changed_descriptor_refuses_before_cas(lane,field): ++ cfg,e,contract,state=lane ++ def change(envelope):envelope['binding']['context']['execution_contract'][field]='wrong' ++ state['decision_change']=change ++ with pytest.raises(DecisionError):prepared(lane) ++ assert 'consume' not in state['calls'] ++ ++ ++@pytest.mark.parametrize('field',['subject','resource','request_digest','submitted_request_digest','approval_binding_digest']) ++def test_signed_changed_request_correspondence_refuses_before_cas(lane,field): ++ cfg,e,contract,state=lane ++ def change(envelope): ++ envelope['binding'][field]={'id':'other'} if field in ('subject','resource') else 'sha256:'+'0'*64 ++ state['decision_change']=change ++ with pytest.raises(DecisionError):prepared(lane) ++ assert 'consume' not in state['calls'] ++ ++ ++@pytest.mark.parametrize('field',['approval_id','decision_id','request_digest','status','consumed_at','idempotent']) ++def test_cas_correspondence_never_coerces_or_falls_back(lane,field): ++ cfg,e,contract,state=lane;result=prepared(lane) ++ state['response_change']=lambda payload:payload.__setitem__(field,'wrong') ++ with pytest.raises(DecisionError):consume_task_execution(cfg,result) ++ with pytest.raises(DecisionError):consume_task_execution(cfg,result) ++ assert state['calls'].count('consume')==1 ++ ++ ++@pytest.mark.parametrize('value',[True,1,0,'false',None]) ++def test_nonfresh_or_nonboolean_idempotence_refuses(lane,value): ++ cfg,e,contract,state=lane;result=prepared(lane) ++ state['response_change']=lambda payload:payload.__setitem__('idempotent',value) ++ with pytest.raises(DecisionError):consume_task_execution(cfg,result) ++ ++ ++@pytest.mark.parametrize('body',[b'{"status":"consumed","status":"consumed"}',b'{"status":NaN}',b'['*2000+b'0'+b']'*2000]) ++def test_duplicate_nonfinite_or_deep_consume_body_refuses(lane,body): ++ cfg,e,contract,state=lane;result=prepared(lane) ++ state['raw_response']=body ++ with pytest.raises(DecisionError):consume_task_execution(cfg,result) ++ ++ ++@pytest.mark.parametrize('field',['pdp_url','approval_url','authorization_subject_id','authorization_policy_version','pdp_verifier']) ++def test_changed_owner_coordinates_refuse_before_cas(lane,field): ++ cfg,e,contract,state=lane;result=prepared(lane);setattr(cfg,field,'changed') ++ with pytest.raises(DecisionError):consume_task_execution(cfg,result) ++ assert 'consume' not in state['calls'] ++ ++ ++def test_removed_signer_refuses_before_cas(lane): ++ cfg,e,contract,state=lane;result=prepared(lane) ++ trust=json.loads(cfg.pdp_trust_file.read_text());trust['keys']={'other':next(iter(trust['keys'].values()))} ++ cfg.pdp_trust_file.write_text(json.dumps(trust)) ++ with pytest.raises(DecisionError):consume_task_execution(cfg,result) ++ assert 'consume' not in state['calls'] ++ ++ ++def test_mapping_boolean_and_public_constructor_are_not_authority(lane): ++ cfg,e,contract,state=lane ++ for value in [True,{},object()]: ++ with pytest.raises(DecisionError):consume_task_execution(cfg,value) ++ with pytest.raises(DecisionError):TaskExecutionAuthorization() ++ assert not state['calls'] ++ ++ ++@pytest.mark.parametrize('field',['approval_id','decision_id','consumed_at','idempotent']) ++def test_absent_cas_fields_cannot_fall_back(lane,field): ++ cfg,e,contract,state=lane;result=prepared(lane) ++ state['response_change']=lambda payload:payload.pop(field) ++ with pytest.raises(DecisionError):consume_task_execution(cfg,result) ++ assert state['calls'].count('consume')==1 ++ ++ ++def test_expired_preparation_refuses_before_cas(lane,monkeypatch): ++ from types import SimpleNamespace ++ import secrets_engine.task_execution_handoff as module ++ cfg,e,contract,state=lane;result=prepared(lane) ++ later=int((datetime.now(timezone.utc)+timedelta(minutes=2)).timestamp()*1_000_000_000) ++ monkeypatch.setattr(module,'read_window',lambda cfg:SimpleNamespace(lower_ns=later,upper_ns=later)) ++ with pytest.raises(DecisionError):consume_task_execution(cfg,result) ++ assert 'consume' not in state['calls'] ++ ++ ++@pytest.mark.parametrize('change',['deny','expired','missing-caller','missing-start']) ++def test_signed_decision_missing_authority_refuses_before_cas(lane,change): ++ cfg,e,contract,state=lane ++ def alter(envelope): ++ if change=='deny':envelope['effect']='deny' ++ elif change=='expired':envelope['lifetime']['expires_at']=stamp(datetime.now(timezone.utc)-timedelta(seconds=1)) ++ elif change=='missing-start':envelope['lifetime'].pop('not_before') ++ else:envelope['provenance'].pop('caller') ++ state['decision_change']=alter ++ with pytest.raises(DecisionError):prepared(lane) ++ assert 'consume' not in state['calls'] +diff --git a/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md b/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md +index 01be511..a8a3845 100644 +--- a/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md ++++ b/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md +@@ -9,7 +9,7 @@ flavor: planning + owner: codex + topic_slug: netkingdom + created: "2026-10-11" +-updated: "2026-10-11" ++updated: "2026-10-16" + depends_on: [] + state_hub_workstream_id: "5e38aecc-85ca-5f55-94c6-9fbb08604819" + --- +@@ -110,3 +110,17 @@ This is source preparation awaiting independent technical review/publication, + not a formal owner assent, policy deployment or native execution milestone. T01 + remains progress; T03 and SECRETS-WP-0008-T06 remain open. Detailed limitations + and retained failed test observations are in the source preparation receipt. ++ ++ ++2026-10-16 bounded source handoff preparation: `task_execution_handoff.py` uses ++the actual serving approval/mandatory signed Check path and retained detached ++artifacts to check exact descriptor/request/native approval/caller/lifetime joins, ++then performs one strict exact-response CAS only after Platform journal admission. ++It accepts no inbound boolean/mapping/authorizer callback and grants no native ++execution. Platform independently confirms durable consumption with the actual ++Approval provider client before native dispatch. Focused local HTTP/native Go ++producer-signature source checks cover altered joins/caller/grant, missing fields, ++coercion/idempotence, expiry, signer removal and replay refusal. No service binding ++propagation, policy gate flip, identity mapping admission or production trust/key ++change was made. Independent source integration/review and actual whole-provider ++proof remain required. T01/T03 and WP8-T06 retain their open native obligations. diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-handoff/implementation-r2.patch b/docs/changes/netkingdom-target-2026-10-16/decision-handoff/implementation-r2.patch new file mode 100644 index 00000000..650fb914 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-handoff/implementation-r2.patch @@ -0,0 +1,622 @@ +diff --git a/docs/task-execution-handoff.md b/docs/task-execution-handoff.md +new file mode 100644 +index 0000000..5821175 +--- /dev/null ++++ b/docs/task-execution-handoff.md +@@ -0,0 +1,78 @@ ++# Strict Secrets service task-execution handoff ++ ++This is a narrow source seam for existing SECRETS-WP-0013, not a native grant or ++an execute-ccr CLI. Both Access runtime-deny policy variants remain unchanged. ++The proposed service subject/transport mapping and the complete native executor ++still need independent owner admission. Positive source fixtures do not admit ++that mapping, a policy, a caller, a production key or an OpenBao entitlement. ++ ++Platform's trusted owner process calls: ++ ++```python ++from secrets_engine.task_execution_handoff import ( ++ authorize_task_execution, consume_task_execution, ++) ++authorization = authorize_task_execution(cfg, entry, execution_contract=contract) ++record = authorization.as_owner_handoff() ++# Independently bind exact owner Plan/descriptor; reserve durable one-shot journal. ++# Persist pending strict-CAS intent before calling: ++consumption = consume_task_execution(cfg, authorization) ++# Independently GET-confirm exact original consumption with Approval Engine's ++# ExecutionConfirmationClient before journaling consume-confirmed/native work. ++``` ++ ++Preparation uses the existing empty fields/policy_targets/auth_targets defaults ++and existing build_action_request/claim/Check validators. No caller-selected ++transport, authorizer callback, policy result or raw handoff mapping is accepted. ++The normal authorizer retains detached serialized request, claim and authenticated ++Go-typed envelope snapshots; its existing evidence output does not expose them. ++The narrow adapter reuses the validators, rather than evaluating a policy. ++ ++The adapter checks the exact descriptor in the signed context, full evaluated and ++submitted request digests, approval-free digest, exact five-field native act and ++native binding digest, service subject/resource/tenant correspondence and original ++decision/approval/deadline bounds. Initiating/represented subject is strictly ++`secrets-engine` of type `service`; the execution actor is `service:secrets-engine`. ++Signed caller provenance must report `enforce`, the proposed exact Secrets ++ServiceAccount principal, `flex-auth` audience and live expiry bounding the ++operation deadline. This consumes the signed PDP's caller attestation; it does ++not independently perform TokenReview or promote the proposed server mapping. ++Human/delegation/projected-identity flags cannot substitute for this service seam. ++No service_actor_binding request propagation or source-policy activation is added. ++ ++`as_owner_handoff()` returns a detached non-secret mapping with: ++ ++- execution_contract ++- approval_id, request_digest, decision_id ++- native_binding, approval_binding_digest, submitted_request_digest ++- decision_not_before, decision_expires_at, approval_expires_at ++ ++The exact native binding is derived from the signed evaluated request using the ++published Approval five-field mapping, and is compared to the served claim. ++Descriptor/CCR/grant/source/catalog/target correspondence is retained end to end; ++Platform still independently compiles its owner Plan and grant from owner bytes. ++The mapping is evidence for that join, never a transferable bearer authorization. ++ ++Consumption accepts only the module-created opaque prepared object in that same ++trusted owner process. It fixes source/config coordinates, reauthenticates using ++currently admitted responder keys, and rechecks claim/decision/descriptor/caller ++freshness before reading the CAS credential. Each object is reserved once before ++CAS/revalidation and cannot retry after any failure or unknown outcome. Its ++process-local guard complements Platform's durable cross-process journal; it is ++not a replacement for that journal or process isolation. ++ ++The strict response path uses the existing CAS transport but rejects duplicate, ++nonfinite or excessively deep JSON, missing/mismatched approval and decision IDs, ++wrong digest/status, missing or nonboolean idempotence, historical idempotent ++success, verdict fields and absent/naive/invalid consumption timestamps. The ++consumption instant and post-CAS freshness must remain inside the original ++bounds. No field is coerced or filled from the submitted binding. A late/uncertain ++CAS may have occurred; it never licenses retry or protected native work. ++ ++The returned immutable TaskExecutionConsumption contains exact approval_id, ++request_digest, decision_id, consumed_at and explicit false idempotent. It proves ++only the strict CAS response observed by Secrets. Platform must independently ++confirm original durable consumption using Approval's real provider client, ++journal it, then recheck its exact Plan, owner sources, grant and deadlines before ++any native operation. Neither a dataclass nor a caller-supplied boolean admits ++execution. Existing generic CLI CAS behavior is otherwise unchanged. +diff --git a/src/secrets_engine/approval_consume.py b/src/secrets_engine/approval_consume.py +index bb1e766..1cf4140 100644 +--- a/src/secrets_engine/approval_consume.py ++++ b/src/secrets_engine/approval_consume.py +@@ -23,7 +23,7 @@ from secrets_engine.execution_contract import ExecutionContract, require_executi + from secrets_engine.approval_claim import observe_pdp_approval_claim, validate_approval_claim + from secrets_engine.catalog import human_control_required + from secrets_engine.decision_check import check_decision, require_supported_pdp_address +-from secrets_engine.decision_signature import load_trust ++from secrets_engine.decision_signature import load_trust, strict_json + from secrets_engine.authorization import ( + build_action_request, + validate_decision_envelope, +@@ -70,6 +70,8 @@ class AuthorizedAction: + binding: ConsumeBinding + decision_id: str + expires_at: str ++ # Detached original served artifacts for the narrow task-execution handoff. ++ artifacts: str = field(default="", repr=False, compare=False) + + @property + def id(self) -> str: +@@ -380,6 +382,8 @@ def authorize_action( + ), + decision_id=validated.decision_id, + expires_at=validated.expires_at, ++ artifacts=json.dumps({"request":expected_request,"claim":observation.claim,"envelope":envelope}, ++ sort_keys=True,separators=(",",":"),allow_nan=False), + ) + + +@@ -391,6 +395,7 @@ def consume_approval( + binding: ConsumeBinding, + timeout_seconds: float = 3, + opener: Callable[..., Any] = credential_urlopen, ++ strict_response: bool = False, + ) -> ConsumedApproval: + """POST /v1/approvals/{id}/consume. Fail closed on anything but confirmed use.""" + if not base_url or not base_url.startswith(("http://", "https://")): +@@ -438,8 +443,8 @@ def consume_approval( + if status != 200 or len(raw) > _MAX_BODY: + raise DecisionError(_status_message(status if status != 200 else 502)) + try: +- payload = json.loads(raw.decode("utf-8")) +- except (UnicodeDecodeError, json.JSONDecodeError) as exc: ++ payload = (strict_json if strict_response else json.loads)(raw.decode("utf-8")) ++ except (UnicodeDecodeError, ValueError, RecursionError) as exc: + raise DecisionError("approval consume returned invalid JSON") from exc + if not isinstance(payload, dict): + raise DecisionError("approval consume returned invalid payload") +@@ -447,6 +452,19 @@ def consume_approval( + raise DecisionError("approval consumption was not confirmed") + if payload.get("request_digest") != binding.request_digest: + raise DecisionError("approval consume digest does not match the request") ++ if strict_response: ++ from datetime import datetime ++ if (payload.get("approval_id") != approval_id ++ or payload.get("decision_id") != binding.decision_id ++ or payload.get("idempotent") is not False ++ or any(key in payload for key in ("effect","allow","deny","decision"))): ++ raise DecisionError("strict task consumption correspondence refused") ++ try: ++ stamp = datetime.fromisoformat(payload["consumed_at"].replace("Z","+00:00")) ++ if stamp.tzinfo is None or stamp.utcoffset() is None: ++ raise ValueError("timezone required") ++ except (KeyError,TypeError,AttributeError,ValueError): ++ raise DecisionError("strict task consumption timestamp refused") from None + decision_id = payload.get("decision_id") + if decision_id is not None and ( + not isinstance(decision_id, str) or not decision_id +diff --git a/src/secrets_engine/task_execution_handoff.py b/src/secrets_engine/task_execution_handoff.py +new file mode 100644 +index 0000000..9772861 +--- /dev/null ++++ b/src/secrets_engine/task_execution_handoff.py +@@ -0,0 +1,194 @@ ++"""Narrow service-task PEP handoff; records never grant native execution. ++ ++Platform calls preparation itself, binds its independently compiled owner Plan, ++reserves/journals once, then calls strict consumption and independent Approval ++GET confirmation. No inbound mapping/boolean or constructor creates authority. ++Actual policy/server input mapping and runtime entitlement remain unadmitted. ++""" ++from __future__ import annotations ++ ++from dataclasses import dataclass ++from datetime import datetime ++import json ++from threading import Lock ++ ++from secrets_engine.approval_auth import approval_token, credential_urlopen ++from secrets_engine.approval_claim import binding_from_check_request, claim_binding_digest, validate_approval_claim ++from secrets_engine.approval_consume import authorize_action, consume_approval ++from secrets_engine.application_time import read_window, validity_bounds ++from secrets_engine.authorization import validate_decision_envelope, request_digest, approval_binding_digest ++from secrets_engine.decision_signature import authenticate_response, load_trust, strict_json ++from secrets_engine.errors import DecisionError ++from secrets_engine.execution_contract import ExecutionContract, require_execution_deadline ++ ++_CALLER = 'system:serviceaccount:secrets-engine:secrets-engine' ++_NATIVE_FIELDS = ('action','actor','principal','purpose','target') ++_COORDINATES = ('approval_url','approval_token_file','approval_client_secret_file','keycape_token_url','keycape_issuer', ++ 'pdp_url','pdp_token_file','pdp_trust_file','pdp_verifier','clock_trust_file', ++ 'authorization_subject_id','authorization_subject_type','authorization_policy_package','authorization_policy_version') ++_ISSUED = object() ++ ++ ++def _refuse(): ++ raise DecisionError('strict task execution handoff correspondence refused') ++ ++ ++def _coordinates(cfg): ++ return tuple(str(getattr(cfg,name,'') or '') for name in _COORDINATES) ++ ++ ++def _stamp(value): ++ try: ++ parsed=datetime.fromisoformat(value.replace('Z','+00:00')) ++ if parsed.tzinfo is None or parsed.utcoffset() is None: ++ _refuse() ++ return parsed ++ except (AttributeError,TypeError,ValueError): ++ _refuse() ++ ++ ++class TaskExecutionAuthorization: ++ """Opaque process-local prepared state; not a bearer credential or allow.""" ++ __slots__=('_action','_contract','_coordinates','_handoff','_used','_lock') ++ ++ def __init__(self, issuer=None, *, action=None, contract=None, coordinates=None, handoff=None): ++ if issuer is not _ISSUED: ++ _refuse() ++ object.__setattr__(self,'_action',action) ++ object.__setattr__(self,'_contract',contract) ++ object.__setattr__(self,'_coordinates',coordinates) ++ object.__setattr__(self,'_handoff',json.dumps(handoff,sort_keys=True,separators=(',',':'),allow_nan=False)) ++ object.__setattr__(self,'_used',False) ++ object.__setattr__(self,'_lock',Lock()) ++ ++ def __setattr__(self,name,value): ++ raise AttributeError('prepared task handoff is immutable') ++ ++ def as_owner_handoff(self): ++ return strict_json(self._handoff) ++ ++ ++@dataclass(frozen=True) ++class TaskExecutionConsumption: ++ approval_id: str ++ request_digest: str ++ decision_id: str ++ consumed_at: str ++ idempotent: bool ++ ++ ++def _validate(cfg, action, contract): ++ if type(contract) is not ExecutionContract or not action.artifacts: ++ _refuse() ++ descriptor=contract.as_dict() ++ artifacts=strict_json(action.artifacts) ++ request,claim,envelope=(artifacts[name] for name in ('request','claim','envelope')) ++ # Rotation/removal and installed-helper changes cannot hide behind a cached ++ # prepared label. Reauthenticate before each strict CAS, without networking. ++ authenticated=authenticate_response(json.dumps(envelope,allow_nan=False).encode(), ++ trust=load_trust(getattr(cfg,'pdp_trust_file',None)), ++ verifier=getattr(cfg,'pdp_verifier',None)) ++ if authenticated != envelope: ++ _refuse() ++ window=read_window(cfg) ++ lower,upper=validity_bounds(window=window) ++ validated=validate_decision_envelope(envelope,request, ++ accepted_policy_packages={str(cfg.authorization_policy_package)}, ++ accepted_policy_versions={str(cfg.authorization_policy_version)}, ++ expected_approval_binding_digest=claim['binding']['pdp_digest'],time_window=window) ++ validate_approval_claim(claim,approval_id=action.binding.approval_id, ++ expected_pdp_digest=envelope['binding']['approval_binding_digest'], ++ require_human_control=True,time_window=window) ++ require_execution_deadline(request,time_window=window, ++ bounds=(claim['validity']['expires_at'],envelope['lifetime']['expires_at'])) ++ binding=envelope['binding'] ++ if (envelope['lifetime'].get('kind')!='ttl' ++ or _stamp(envelope['lifetime'].get('not_before'))>lower): ++ _refuse() ++ if (request['action']!='execute-ccr' or binding.get('action')!='execute-ccr' ++ or request['tenant']!='tenant:platform' or binding.get('tenant')!='tenant:platform' ++ or binding.get('context')!=request['context'] ++ or request['context'].get('execution_contract')!=descriptor ++ or binding.get('request_digest')!=request_digest(binding) ++ or binding.get('approval_binding_digest')!=approval_binding_digest(binding) ++ or action.binding.request_digest!=validated.request_digest ++ or action.binding.decision_id!=validated.decision_id): ++ _refuse() ++ subject=binding['subject']; resource=binding['resource'] ++ if (request['subject']!={'id':'secrets-engine','type':'service'} ++ or subject.get('id')!='secrets-engine' or subject.get('type')!='service' ++ or subject.get('tenant') not in (None,'tenant:platform') ++ or 'principal' in subject.get('attributes',{}) ++ or envelope['subject']!=subject or envelope['resource']!=resource ++ or descriptor['execution_actor']!='service:secrets-engine' ++ or any(resource.get(key)!=request['resource'].get(key) for key in ('id','type','system')) ++ or resource.get('tenant') not in (None,'tenant:platform') ++ or any(resource.get('attributes',{}).get(key)!=value for key,value in request['resource']['attributes'].items()) ++ or any(key in binding['context'] for key in ('task_execution_projection','initiator','represented_principal','delegation','verified','owner_admission'))): ++ _refuse() ++ caller=envelope['provenance'].get('caller') ++ if (not isinstance(caller,dict) or caller.get('mode')!='enforce' ++ or caller.get('principal')!=_CALLER or caller.get('audience')!='flex-auth' ++ or _stamp(caller.get('not_after')) < _stamp(descriptor['operation_deadline']) ++ or upper >= _stamp(caller.get('not_after'))): ++ _refuse() ++ native=binding_from_check_request(binding) ++ supplied=claim['binding'] ++ # Recompute the served native act too: Python equality alone conflates ++ # boolean/integer enrichment and cannot establish canonical correspondence. ++ if (any(supplied.get(key)!=native[key] for key in _NATIVE_FIELDS) ++ or supplied.get('digest')!=claim_binding_digest(**native) ++ or supplied.get('digest')!=claim_binding_digest(**{key:supplied.get(key) for key in _NATIVE_FIELDS})): ++ _refuse() ++ return {'execution_contract':descriptor,'approval_id':action.binding.approval_id, ++ 'request_digest':validated.request_digest,'decision_id':validated.decision_id, ++ 'native_binding':native,'approval_binding_digest':binding['approval_binding_digest'], ++ 'submitted_request_digest':validated.submitted_request_digest, ++ 'decision_not_before':envelope['lifetime'].get('not_before') or '', ++ 'decision_expires_at':envelope['lifetime']['expires_at'], ++ 'approval_expires_at':claim['validity']['expires_at']} ++ ++ ++def authorize_task_execution(cfg, entry, *, execution_contract): ++ if type(execution_contract) is not ExecutionContract: ++ _refuse() ++ # No injected transports or policy callbacks: use the actual existing PIP/PDP ++ # clients and mandatory signature validators in the owner process. ++ action=authorize_action(cfg,entry,'execute-ccr',execution_contract=execution_contract) ++ if action is None: ++ _refuse() ++ try: ++ handoff=_validate(cfg,action,execution_contract) ++ return TaskExecutionAuthorization(_ISSUED,action=action,contract=ExecutionContract.parse(execution_contract.as_dict()), ++ coordinates=_coordinates(cfg),handoff=handoff) ++ except (ValueError,KeyError,TypeError,RecursionError) as exc: ++ raise DecisionError('strict task execution handoff malformed') from exc ++ ++ ++def consume_task_execution(cfg, authorization): ++ if type(authorization) is not TaskExecutionAuthorization: ++ _refuse() ++ with authorization._lock: ++ if authorization._used or _coordinates(cfg)!=authorization._coordinates: ++ _refuse() ++ # Reserve once before all work; failures/unknown outcomes never retry this ++ # prepared object. Platform's durable journal is the cross-process guard. ++ object.__setattr__(authorization,'_used',True) ++ try: ++ handoff=_validate(cfg,authorization._action,authorization._contract) ++ if handoff!=authorization.as_owner_handoff(): ++ _refuse() ++ result=consume_approval(base_url=str(cfg.approval_url), ++ token_provider=lambda:approval_token(cfg,scope='approval:consume'), ++ binding=authorization._action.binding,opener=credential_urlopen,strict_response=True) ++ stamp=_stamp(result.consumed_at) ++ window=read_window(cfg);_,upper=validity_bounds(window=window) ++ if (stamp>upper or (handoff['decision_not_before'] and stamp<_stamp(handoff['decision_not_before'])) ++ or any(stamp>=_stamp(value) for value in (handoff['decision_expires_at'],handoff['approval_expires_at'], ++ handoff['execution_contract']['operation_deadline']))): ++ _refuse() ++ # Late CAS completion is historical evidence only; refuse native dispatch. ++ _validate(cfg,authorization._action,authorization._contract) ++ return TaskExecutionConsumption(result.approval_id,result.request_digest,result.decision_id,result.consumed_at,result.idempotent) ++ except (ValueError,KeyError,TypeError,RecursionError) as exc: ++ raise DecisionError('strict task execution consumption malformed') from exc +diff --git a/tests/test_task_execution_handoff.py b/tests/test_task_execution_handoff.py +new file mode 100644 +index 0000000..2204da2 +--- /dev/null ++++ b/tests/test_task_execution_handoff.py +@@ -0,0 +1,231 @@ ++"""Real local HTTP/producer signatures, synthetic approval/caller only. ++ ++These source fixtures never enable Access's runtime-deny policy or admit a real ++caller, key, owner grant or native backend. Whole-provider integration is separate. ++""" ++import copy ++from datetime import datetime, timedelta, timezone ++from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer ++import json ++from pathlib import Path ++from threading import Thread ++ ++import pytest ++ ++from secrets_engine.approval_claim import binding_from_check_request, claim_binding_digest ++from secrets_engine.approval_consume import _expected_request ++from secrets_engine.authorization import request_digest, approval_binding_digest ++from secrets_engine.errors import DecisionError ++from secrets_engine.execution_contract import ExecutionContract ++from secrets_engine.task_execution_handoff import (authorize_task_execution,consume_task_execution, ++ TaskExecutionAuthorization) ++from tests.authorization_stub import AuthorizationStub ++from tests.signature_support import signature_tools, admit_tools, sign_envelope ++from tests.test_consume_binding_join import _Cfg,_token,APPROVAL_ID ++from tests.test_execution_context import entry,descriptor ++ ++ ++def stamp(moment): ++ return moment.isoformat() ++ ++ ++@pytest.fixture ++def lane(tmp_path,signature_tools): ++ e=entry();contract=ExecutionContract.parse(descriptor(e)) ++ cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service') ++ admit_tools(cfg,tmp_path,signature_tools) ++ original=_expected_request(cfg,e,'execute-ccr',execution_contract=contract) ++ stub=AuthorizationStub(approval_id=APPROVAL_ID,package=cfg.authorization_policy_package,version=cfg.authorization_policy_version) ++ stub.bind_request(original) ++ state={'calls':[],'claim_change':None,'decision_change':None,'response_change':None,'raw_response':None} ++ claim=stub.claim();native=binding_from_check_request(original) ++ claim['binding'].update(native,digest=claim_binding_digest(**native),human_control=True) ++ state['claim']=claim ++ ++ class Handler(BaseHTTPRequestHandler): ++ def log_message(self,*_args):pass ++ def reply(self,payload): ++ data=json.dumps(payload).encode() if isinstance(payload,dict) else payload ++ self.send_response(200);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data) ++ def do_GET(self): ++ state['calls'].append('claim') ++ value=copy.deepcopy(state['claim']) ++ if state['claim_change']:state['claim_change'](value) ++ self.reply(value) ++ def do_POST(self): ++ submitted=json.loads(self.rfile.read(int(self.headers['Content-Length']))) ++ if self.path=='/v1/check': ++ state['calls'].append('check') ++ envelope=stub.decision(submitted) ++ envelope['provenance']['caller']={'mode':'enforce','principal':'system:serviceaccount:secrets-engine:secrets-engine', ++ 'audience':'flex-auth','not_after':stamp(datetime.now(timezone.utc)+timedelta(minutes=20))} ++ if state['decision_change']:state['decision_change'](envelope) ++ self.reply(sign_envelope(envelope,signature_tools)) ++ else: ++ state['calls'].append('consume') ++ payload={'approval_id':APPROVAL_ID,'status':'consumed','request_digest':submitted['request_digest'], ++ 'decision_id':submitted['decision_id'],'consumed_at':stamp(datetime.now(timezone.utc)),'idempotent':False} ++ if state['response_change']:state['response_change'](payload) ++ self.reply(state['raw_response'] if state['raw_response'] is not None else payload) ++ httpd=ThreadingHTTPServer(('127.0.0.1',0),Handler) ++ thread=Thread(target=httpd.serve_forever,kwargs={'poll_interval':0.01},daemon=True);thread.start() ++ cfg.approval_url=cfg.pdp_url=f'http://127.0.0.1:{httpd.server_port}' ++ try:yield cfg,e,contract,state ++ finally:httpd.shutdown();httpd.server_close();thread.join(2) ++ ++ ++def prepared(lane): ++ cfg,e,contract,state=lane ++ return authorize_task_execution(cfg,e,execution_contract=contract) ++ ++ ++def test_exact_preparation_then_one_strict_cas(lane): ++ cfg,e,contract,state=lane ++ result=prepared(lane) ++ assert state['calls']==['claim','check'] ++ handoff=result.as_owner_handoff() ++ assert set(handoff)=={'execution_contract','approval_id','request_digest','decision_id','native_binding','approval_binding_digest', ++ 'submitted_request_digest','decision_not_before','decision_expires_at','approval_expires_at'} ++ assert handoff['execution_contract']==contract.as_dict() ++ assert handoff['native_binding']==binding_from_check_request(_expected_request(cfg,e,'execute-ccr',execution_contract=contract)) ++ changed=result.as_owner_handoff();changed['execution_contract']['ccr_id']='changed' ++ assert result.as_owner_handoff()==handoff ++ with pytest.raises(AttributeError):result._handoff='changed' ++ receipt=consume_task_execution(cfg,result) ++ assert receipt.approval_id==handoff['approval_id'] and receipt.request_digest==handoff['request_digest'] ++ assert receipt.decision_id==handoff['decision_id'] and receipt.idempotent is False ++ assert state['calls']==['claim','check','consume'] ++ with pytest.raises(DecisionError):consume_task_execution(cfg,result) ++ assert state['calls'].count('consume')==1 ++ ++ ++@pytest.mark.parametrize('field',['action','actor','principal','purpose','target','digest']) ++def test_changed_native_claim_refuses_before_cas(lane,field): ++ cfg,e,contract,state=lane ++ def change(claim):claim['binding'][field]={'id':'other'} if field=='target' else 'other' ++ state['claim_change']=change ++ with pytest.raises(DecisionError):prepared(lane) ++ assert 'consume' not in state['calls'] ++ ++ ++@pytest.mark.parametrize('field',['mode','principal','audience','not_after']) ++def test_signed_wrong_caller_refuses_before_cas(lane,field): ++ cfg,e,contract,state=lane ++ state['decision_change']=lambda envelope:envelope['provenance']['caller'].__setitem__(field,'wrong') ++ with pytest.raises(DecisionError):prepared(lane) ++ assert 'consume' not in state['calls'] ++ ++ ++@pytest.mark.parametrize('field',['execution_actor','execution_grant_ccr_id','grant_immutable_sha256','ccr_id','backend_origin']) ++def test_signed_changed_descriptor_refuses_before_cas(lane,field): ++ cfg,e,contract,state=lane ++ def change(envelope):envelope['binding']['context']['execution_contract'][field]='wrong' ++ state['decision_change']=change ++ with pytest.raises(DecisionError):prepared(lane) ++ assert 'consume' not in state['calls'] ++ ++ ++@pytest.mark.parametrize('field',['subject','resource','request_digest','submitted_request_digest','approval_binding_digest']) ++def test_signed_changed_request_correspondence_refuses_before_cas(lane,field): ++ cfg,e,contract,state=lane ++ def change(envelope): ++ envelope['binding'][field]={'id':'other'} if field in ('subject','resource') else 'sha256:'+'0'*64 ++ state['decision_change']=change ++ with pytest.raises(DecisionError):prepared(lane) ++ assert 'consume' not in state['calls'] ++ ++ ++@pytest.mark.parametrize('field',['approval_id','decision_id','request_digest','status','consumed_at','idempotent']) ++def test_cas_correspondence_never_coerces_or_falls_back(lane,field): ++ cfg,e,contract,state=lane;result=prepared(lane) ++ state['response_change']=lambda payload:payload.__setitem__(field,'wrong') ++ with pytest.raises(DecisionError):consume_task_execution(cfg,result) ++ with pytest.raises(DecisionError):consume_task_execution(cfg,result) ++ assert state['calls'].count('consume')==1 ++ ++ ++@pytest.mark.parametrize('value',[True,1,0,'false',None]) ++def test_nonfresh_or_nonboolean_idempotence_refuses(lane,value): ++ cfg,e,contract,state=lane;result=prepared(lane) ++ state['response_change']=lambda payload:payload.__setitem__('idempotent',value) ++ with pytest.raises(DecisionError):consume_task_execution(cfg,result) ++ ++ ++@pytest.mark.parametrize('body',[b'{"status":"consumed","status":"consumed"}',b'{"status":NaN}',b'['*2000+b'0'+b']'*2000]) ++def test_duplicate_nonfinite_or_deep_consume_body_refuses(lane,body): ++ cfg,e,contract,state=lane;result=prepared(lane) ++ state['raw_response']=body ++ with pytest.raises(DecisionError):consume_task_execution(cfg,result) ++ ++ ++@pytest.mark.parametrize('field',['pdp_url','approval_url','authorization_subject_id','authorization_policy_version','pdp_verifier']) ++def test_changed_owner_coordinates_refuse_before_cas(lane,field): ++ cfg,e,contract,state=lane;result=prepared(lane);setattr(cfg,field,'changed') ++ with pytest.raises(DecisionError):consume_task_execution(cfg,result) ++ assert 'consume' not in state['calls'] ++ ++ ++def test_removed_signer_refuses_before_cas(lane): ++ cfg,e,contract,state=lane;result=prepared(lane) ++ trust=json.loads(cfg.pdp_trust_file.read_text());trust['keys']={'other':next(iter(trust['keys'].values()))} ++ cfg.pdp_trust_file.write_text(json.dumps(trust)) ++ with pytest.raises(DecisionError):consume_task_execution(cfg,result) ++ assert 'consume' not in state['calls'] ++ ++ ++def test_mapping_boolean_and_public_constructor_are_not_authority(lane): ++ cfg,e,contract,state=lane ++ for value in [True,{},object()]: ++ with pytest.raises(DecisionError):consume_task_execution(cfg,value) ++ with pytest.raises(DecisionError):TaskExecutionAuthorization() ++ assert not state['calls'] ++ ++ ++@pytest.mark.parametrize('field',['approval_id','decision_id','consumed_at','idempotent']) ++def test_absent_cas_fields_cannot_fall_back(lane,field): ++ cfg,e,contract,state=lane;result=prepared(lane) ++ state['response_change']=lambda payload:payload.pop(field) ++ with pytest.raises(DecisionError):consume_task_execution(cfg,result) ++ assert state['calls'].count('consume')==1 ++ ++ ++def test_expired_preparation_refuses_before_cas(lane,monkeypatch): ++ from types import SimpleNamespace ++ import secrets_engine.task_execution_handoff as module ++ cfg,e,contract,state=lane;result=prepared(lane) ++ later=int((datetime.now(timezone.utc)+timedelta(minutes=2)).timestamp()*1_000_000_000) ++ monkeypatch.setattr(module,'read_window',lambda cfg:SimpleNamespace(lower_ns=later,upper_ns=later)) ++ with pytest.raises(DecisionError):consume_task_execution(cfg,result) ++ assert 'consume' not in state['calls'] ++ ++ ++@pytest.mark.parametrize('change',['deny','expired','missing-caller','missing-start']) ++def test_signed_decision_missing_authority_refuses_before_cas(lane,change): ++ cfg,e,contract,state=lane ++ def alter(envelope): ++ if change=='deny':envelope['effect']='deny' ++ elif change=='expired':envelope['lifetime']['expires_at']=stamp(datetime.now(timezone.utc)-timedelta(seconds=1)) ++ elif change=='missing-start':envelope['lifetime'].pop('not_before') ++ else:envelope['provenance'].pop('caller') ++ state['decision_change']=alter ++ with pytest.raises(DecisionError):prepared(lane) ++ assert 'consume' not in state['calls'] ++ ++ ++def test_native_binding_equality_does_not_coerce_boolean_enrichment(lane): ++ cfg,e,contract,state=lane ++ evaluated=_expected_request(cfg,e,'execute-ccr',execution_contract=contract) ++ evaluated['resource']['attributes']['synthetic_rank']=1 ++ native=binding_from_check_request(evaluated) ++ state['claim']['binding'].update(copy.deepcopy(native),digest=claim_binding_digest(**native), ++ pdp_digest=approval_binding_digest(evaluated)) ++ # Python dict equality treats True == 1. This malformed provider fact must ++ # refuse even when its reported native digest names the integer counterpart. ++ state['claim']['binding']['target']['attributes']['synthetic_rank']=True ++ def enrich(envelope): ++ envelope['resource']['attributes']['synthetic_rank']=1 ++ envelope['binding']['request_digest']=request_digest(envelope['binding']) ++ envelope['binding']['approval_binding_digest']=approval_binding_digest(envelope['binding']) ++ state['decision_change']=enrich ++ with pytest.raises(DecisionError):prepared(lane) ++ assert 'consume' not in state['calls'] +diff --git a/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md b/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md +index 01be511..a8a3845 100644 +--- a/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md ++++ b/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md +@@ -9,7 +9,7 @@ flavor: planning + owner: codex + topic_slug: netkingdom + created: "2026-10-11" +-updated: "2026-10-11" ++updated: "2026-10-16" + depends_on: [] + state_hub_workstream_id: "5e38aecc-85ca-5f55-94c6-9fbb08604819" + --- +@@ -110,3 +110,17 @@ This is source preparation awaiting independent technical review/publication, + not a formal owner assent, policy deployment or native execution milestone. T01 + remains progress; T03 and SECRETS-WP-0008-T06 remain open. Detailed limitations + and retained failed test observations are in the source preparation receipt. ++ ++ ++2026-10-16 bounded source handoff preparation: `task_execution_handoff.py` uses ++the actual serving approval/mandatory signed Check path and retained detached ++artifacts to check exact descriptor/request/native approval/caller/lifetime joins, ++then performs one strict exact-response CAS only after Platform journal admission. ++It accepts no inbound boolean/mapping/authorizer callback and grants no native ++execution. Platform independently confirms durable consumption with the actual ++Approval provider client before native dispatch. Focused local HTTP/native Go ++producer-signature source checks cover altered joins/caller/grant, missing fields, ++coercion/idempotence, expiry, signer removal and replay refusal. No service binding ++propagation, policy gate flip, identity mapping admission or production trust/key ++change was made. Independent source integration/review and actual whole-provider ++proof remain required. T01/T03 and WP8-T06 retain their open native obligations. diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-handoff/integration-manifest-r1.json b/docs/changes/netkingdom-target-2026-10-16/decision-handoff/integration-manifest-r1.json new file mode 100644 index 00000000..78cd93ef --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-handoff/integration-manifest-r1.json @@ -0,0 +1,71 @@ +{ + "schema": "netkingdom.source-integration.v1", + "owner": "secrets-engine", + "scope": "strict service-task authorization and consumption owner handoff; no native dispatch", + "base_commit": "c80a944105a00220cd3c1a007709e72d830dd08d", + "patch": "implementation-r1.patch", + "patch_sha256": "90e37bf0e53abd361f210b5b4a56e7ab6c507f96aba5a2e4147c61c583ece810", + "source_checkout": "/tmp/netkingdom-secrets-task-handoff", + "files": { + "docs/task-execution-handoff.md": "d60949668c74ef6b317f44c16e22336baf76989b4a063fe10e9831c2f9c127a4", + "src/secrets_engine/approval_consume.py": "bea80a503944b1b9796819cfea5f514c3184a724115130d16c3bc0c675f09a6b", + "src/secrets_engine/task_execution_handoff.py": "671a393aa2b9355e1c81c502a74f9ce8adad75fe7eb03492d11843a94b24d758", + "tests/test_task_execution_handoff.py": "20e7929a67dcfeb5283f5fcad60e4c972c170b82678c32aaa991cd4dcb3033be", + "workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md": "c061dbc1c2c23dc3c79c3de78d1eed6fd57af05b078c8eb42b645813552a129b" + }, + "verification": { + "focused": "246 passed in 19.18s", + "full": "736 passed, 16 failed, 1 skipped in 47.37s", + "baseline_comparison": "all16 exact failure IDs match unchanged owner baseline; zero new failure IDs", + "baseline_failure_ids": [ + "tests/test_exec_owner.py::test_actual_bound_child_gets_only_selected_secret_and_fixed_environment", + "tests/test_exec_owner.py::test_binding_changed_after_approval_refuses_before_fetch", + "tests/test_exec_owner.py::test_config_changed_during_fetch_does_not_launch", + "tests/test_exec_owner_companions.py::test_companion_fetch_failure_starts_no_child", + "tests/test_exec_owner_companions.py::test_companion_value_reaches_bound_child_and_is_redacted", + "tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[consent]", + "tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[env]", + "tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[extra]", + "tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[field]", + "tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[lane]", + "tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[missing]", + "tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[stage]", + "tests/test_exec_owner_companions.py::test_each_lane_is_gated_separately_before_backend", + "tests/test_exec_owner_companions.py::test_unresolvable_companion_refuses_before_any_gate", + "tests/test_human_control.py::test_real_exec_handler_rejects_undeclared_claim_before_consume_backend_child", + "tests/test_integration_companions.py::test_two_lanes_reach_one_bound_owner" + ], + "scanner": { + "blocked_blob_ids": [], + "scanned_blobs": 5, + "scanner_sha256": "88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509", + "scanner_version": "8.30.1", + "scope": "changed-staged-blobs", + "status": "clean" + }, + "whitespace": "pass" + }, + "interface": { + "module": "secrets_engine.task_execution_handoff", + "prepare": "authorize_task_execution(cfg, entry, *, execution_contract)", + "owner_record": "authorization.as_owner_handoff()", + "consume": "consume_task_execution(cfg, authorization)", + "result_fields": [ + "approval_id", + "request_digest", + "decision_id", + "consumed_at", + "idempotent" + ] + }, + "native_acceptance": false, + "policy_or_mapping_admission": false, + "production_credentials_or_trust_change": false, + "canonical_owner_modified": false, + "limitations": [ + "Source-only exact proposed service caller mapping; Access runtime deny gates unchanged", + "Opaque prepared object is process-local; Platform durable journal and independent real Approval GET confirmation remain mandatory", + "Actual Access-serve/Approval-provider whole-stack fixture is separately owned by Platform worker", + "No service_actor_binding request propagation or new live trust/grant materialization" + ] +} diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-handoff/original-owner-dirty.patch b/docs/changes/netkingdom-target-2026-10-16/decision-handoff/original-owner-dirty.patch new file mode 100644 index 00000000..acf1eeaa --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-handoff/original-owner-dirty.patch @@ -0,0 +1,96 @@ +diff --git a/.custodian-brief.md b/.custodian-brief.md +index 0cb912a..ba794c8 100644 +--- a/.custodian-brief.md ++++ b/.custodian-brief.md +@@ -2,7 +2,7 @@ + # Custodian Brief — secrets-engine + + **Domain:** infotech +-**Last synced:** 2026-10-11 17:52 UTC ++**Last synced:** 2026-10-16 05:55 UTC + **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* + + ## Active Workstreams +@@ -11,8 +11,24 @@ + Progress: 1/3 done | workplan_id: `5e38aecc-85ca-5f55-94c6-9fbb08604819` + + **Open tasks:** ++- ! Define the exact mediated execution contract `b2f0d462` ++ *(wait: Independent Platform mediated-contract review and identity/approval/PDP owner returns remain required; D3 wrapped primitive source is implemented, no native entitlement is inferred.)* + - ! Implement and prove one native metadata action `5f8f7c41` +-- ► Define the exact mediated execution contract `b2f0d462` ++ ++### Constrain PDP transport and authenticate decision responses ++Progress: 1/3 done | workplan_id: `7252469d-d7a5-5c18-8911-b511b8c6a736` ++ ++**Open tasks:** ++- ! Verify the admitted native consumer and rotation path `85aa4c78` ++ *(wait: Requires reviewed verification-key admission, source enforcement and exact native rollout receipt; no production trust change is proposed yet.)* ++- ► Implement independently anchored responder authentication `877782e5` ++ ++### Production-safe provisioning, authorization, and lifecycle hardening ++Progress: 6/7 done | workplan_id: `68a39be1-bd9c-5133-ad64-e7bca892aaf3` ++ ++**Open tasks:** ++- ! Resume native production lane adoption `a0a1dd92` ++ *(wait: Engine approval hardening is complete. Remaining per-lane cutover includes native routing/proxy retirement with ops-warden under SECRETS-WP-0006-T05/T06; the exact OpenRouter key-check receipt does not establish broader recipient readiness.)* + + ### Activate native Claude credential delivery for Glas + Progress: 2/3 done | workplan_id: `40ccc3b4-d046-5a58-8649-e7935f45c974` +@@ -26,27 +42,20 @@ Progress: 4/6 done | workplan_id: `31f7f8ea-7f73-516c-8877-f03a13f1db82` + + **Open tasks:** + - ! Stage live apply and verification `fb103f1e` +- *(wait: OpenRouter key-check native acceptance is recorded in SECRETS-WP-0010-T03. Other lanes still need exact approvals, scoped attended apply and per-lane positive/negative/health/revocation evidence.)* ++ *(wait: Owner work first: publish a reviewed scoped-apply command per lane. Per-lane approvals are no longer needed (D1 standing rule); each lane with a command enters the attended window.)* + - ! Reconcile routing ownership and retire interim proxies `1431edae` + *(wait: Needs owner-agreed routing/proxy retirement for each verified lane and custody disposition of the legacy npm pointer; a single approved OpenRouter key-check does not authorize broader routing cutover.)* + +-### Production-safe provisioning, authorization, and lifecycle hardening +-Progress: 6/7 done | workplan_id: `68a39be1-bd9c-5133-ad64-e7bca892aaf3` +- +-**Open tasks:** +-- ! Resume native production lane adoption `a0a1dd92` +- *(wait: Engine approval hardening is complete. Remaining per-lane cutover includes native routing/proxy retirement with ops-warden under SECRETS-WP-0006-T05/T06; the exact OpenRouter key-check receipt does not establish broader recipient readiness.)* +- + ### Evolve the Lifecycle engine to the accepted security layer model + Progress: 5/6 done | workplan_id: `9c9e5164-b2f5-5ea2-a557-5368d65e9fe0` + + **Open tasks:** + - ! No standing engine credential `d7bc8bdc` +- *(wait: Positive service-JWT login proof passed 2026-10-10 (SECRETS-WP-0012-T03, docs/evidence/2026-10-10-keycape-openbao-probe.json). Waits on railiance-platform closing RPF-WP-0035-T02 with the CCR-2026-0038 negative checks, then on routing live engine actions through service-jwt instead of bootstrap/env.)* ++ *(wait: Published JWT contract and positive login/revocation are delivered. Native KEY-WP-0039-T02 claim negatives, CCR-2026-0038 effective-policy/capability/expiry/use checks and MASON-WP-0009-T05 remain. Login-self-only grants no protected-operation authority: agree and implement separately reviewed task-scoped backend execution entitlement, then prove functional steady-state service-identity adoption without bootstrap/env fallback.)* + + ## Inbox Hygiene + +-**Stale unread:** 2 message(s) older than 3 day(s) — triage at session start. ++**Stale unread:** 3 message(s) older than 3 day(s) — triage at session start. + **Missing thread_id:** 2 unread message(s) lack supersession chains. + + --- +diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md +index 70e5831..4d1cfb6 100644 +--- a/WORK-RECORDS.md ++++ b/WORK-RECORDS.md +@@ -22,6 +22,7 @@ + | workplan | SECRETS-WP-0011 | finished | — | workplans/SECRETS-WP-0011-multi-lane-exec-owner-delivery.md | + | workplan | SECRETS-WP-0012 | finished | — | workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md | + | workplan | SECRETS-WP-0013 | proposed | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md | ++| workplan | SECRETS-WP-0014 | active | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md | + | task | SECRETS-WP-ADHOC-2026-08-21-T01 | done | — | workplans/ADHOC-2026-08-21.md | + | task | SECRETS-WP-ADHOC-2026-08-23-T01 | done | — | workplans/ADHOC-2026-08-23.md | + | task | SECRETS-WP-0001-T01 | done | — | workplans/SECRETS-WP-0001-statehub-bootstrap.md | +@@ -83,9 +84,12 @@ + | task | SECRETS-WP-0012-T02 | cancel | — | workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md | + | task | SECRETS-WP-0012-T03 | done | — | workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md | + | task | SECRETS-WP-0012-T04 | done | — | workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md | +-| task | SECRETS-WP-0013-T01 | progress | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md | ++| task | SECRETS-WP-0013-T01 | wait | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md | + | task | SECRETS-WP-0013-T02 | done | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md | + | task | SECRETS-WP-0013-T03 | wait | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md | ++| task | SECRETS-WP-0014-T01 | done | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md | ++| task | SECRETS-WP-0014-T02 | progress | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md | ++| task | SECRETS-WP-0014-T03 | wait | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md | + | intake | SECRETS-IN-0001 | closed | — | intakes/intakes.md | + | intake | SECRETS-IN-0002 | closed | — | intakes/intakes.md | + | intake | SECRETS-IN-0003 | closed | — | intakes/intakes.md | diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-handoff/r1-to-r2.patch b/docs/changes/netkingdom-target-2026-10-16/decision-handoff/r1-to-r2.patch new file mode 100644 index 00000000..9fb0e3dd --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-handoff/r1-to-r2.patch @@ -0,0 +1,40 @@ +--- a/src/secrets_engine/task_execution_handoff.py ++++ b/src/secrets_engine/task_execution_handoff.py +@@ -134,8 +134,11 @@ + _refuse() + native=binding_from_check_request(binding) + supplied=claim['binding'] ++ # Recompute the served native act too: Python equality alone conflates ++ # boolean/integer enrichment and cannot establish canonical correspondence. + if (any(supplied.get(key)!=native[key] for key in _NATIVE_FIELDS) +- or supplied.get('digest')!=claim_binding_digest(**native)): ++ or supplied.get('digest')!=claim_binding_digest(**native) ++ or supplied.get('digest')!=claim_binding_digest(**{key:supplied.get(key) for key in _NATIVE_FIELDS})): + _refuse() + return {'execution_contract':descriptor,'approval_id':action.binding.approval_id, + 'request_digest':validated.request_digest,'decision_id':validated.decision_id, +--- a/tests/test_task_execution_handoff.py ++++ b/tests/test_task_execution_handoff.py +@@ -210,3 +210,22 @@ + state['decision_change']=alter + with pytest.raises(DecisionError):prepared(lane) + assert 'consume' not in state['calls'] ++ ++ ++def test_native_binding_equality_does_not_coerce_boolean_enrichment(lane): ++ cfg,e,contract,state=lane ++ evaluated=_expected_request(cfg,e,'execute-ccr',execution_contract=contract) ++ evaluated['resource']['attributes']['synthetic_rank']=1 ++ native=binding_from_check_request(evaluated) ++ state['claim']['binding'].update(copy.deepcopy(native),digest=claim_binding_digest(**native), ++ pdp_digest=approval_binding_digest(evaluated)) ++ # Python dict equality treats True == 1. This malformed provider fact must ++ # refuse even when its reported native digest names the integer counterpart. ++ state['claim']['binding']['target']['attributes']['synthetic_rank']=True ++ def enrich(envelope): ++ envelope['resource']['attributes']['synthetic_rank']=1 ++ envelope['binding']['request_digest']=request_digest(envelope['binding']) ++ envelope['binding']['approval_binding_digest']=approval_binding_digest(envelope['binding']) ++ state['decision_change']=enrich ++ with pytest.raises(DecisionError):prepared(lane) ++ assert 'consume' not in state['calls'] diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-handoff/secrets-task-handoff-r2-scanner.json b/docs/changes/netkingdom-target-2026-10-16/decision-handoff/secrets-task-handoff-r2-scanner.json new file mode 100644 index 00000000..00ce826b --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-handoff/secrets-task-handoff-r2-scanner.json @@ -0,0 +1 @@ +{"blocked_blob_ids": [], "scanned_blobs": 5, "scanner_sha256": "88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509", "scanner_version": "8.30.1", "scope": "changed-staged-blobs", "status": "clean"} diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-handoff/secrets-task-handoff-scanner.json b/docs/changes/netkingdom-target-2026-10-16/decision-handoff/secrets-task-handoff-scanner.json new file mode 100644 index 00000000..00ce826b --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-handoff/secrets-task-handoff-scanner.json @@ -0,0 +1 @@ +{"blocked_blob_ids": [], "scanned_blobs": 5, "scanner_sha256": "88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509", "scanner_version": "8.30.1", "scope": "changed-staged-blobs", "status": "clean"} diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-handoff/source-provenance.json b/docs/changes/netkingdom-target-2026-10-16/decision-handoff/source-provenance.json new file mode 100644 index 00000000..cb8ff558 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-handoff/source-provenance.json @@ -0,0 +1,6 @@ +{ + "canonical_base": "c80a944105a00220cd3c1a007709e72d830dd08d", + "canonical_status": "M .custodian-brief.md\n M WORK-RECORDS.md\n", + "dirty_metadata_patch_sha256": "a5ef64f28f7c30e7b71a8f26160e820dfa809407d8cc21336032c61fed70c4c5", + "dirty_metadata_excluded_from_implementation": true +} diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/README.md b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/README.md new file mode 100644 index 00000000..9bc5808e --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/README.md @@ -0,0 +1,25 @@ +# Secrets native CLI admission preparation + +The six-file source patch is preparation and documentation only. Review exact +base/files/hash in `integration-manifest-r1.json`; canonical owner is unchanged. +The CLI runtime AST differs only by a corrected explanatory docstring. + +The proposal names11 serving authorization call sites across10handlers (including +exec companion delegation), one shared signed Check client, and0/11 current native +effect acceptance. Session-revoke, service-auth identity and route ambient-readiness +paths are explicit exclusions. Generic CLI CAS is not silently relabeled strict +R2 task consumption. There is no execute-ccr CLI command or admitted task placement. + +A fresh bundle was built offline from a separate CLEAN canonical ff1a13d checkout, +which includes packaging and strictR2. Its source/artifact hashes and exact manifest +are retained; c80 proof bundles remain intermediate evidence only. No installation +occurred. Actual signer kid/public key/fingerprint/custody inputs stay null; no +synthetic key filled them. The proposed lifecycle trust template's empty key map +was proved to refuse before credential/network access. Scanner is clean6 blobs. + +Access/Platform/Approval owner-return artifacts are referenced with hashes. Native +caller/key/pin/package placement, source/provider/audit receipts and actual approved +CCR/catalog/grant/expectation inputs remain explicit. Future B2 out-of-band fresh +Approval facts require owner-reviewed correspondence while preserving original +context; no equality relaxation, digest translation or new schema/helper was added. +Root owns integration/consistency and any separately authorized later admission. diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/canonical-bundle-build-r1.json b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/canonical-bundle-build-r1.json new file mode 100644 index 00000000..c33171e4 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/canonical-bundle-build-r1.json @@ -0,0 +1 @@ +{"bundle": "/tmp/secrets-cli-ff1-native-proof", "helper_sha256": "a6e4eeb5361c61a2db5526ba5648ef5e48143c1a453e158d2f62991ba7ba3a33", "manifest_sha256": "ee07c0b8755788ad0894c679ee2ca125a1c24df3a895013755d4a965a879a1ec"} diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/README.md b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/README.md new file mode 100644 index 00000000..b5b2df98 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/README.md @@ -0,0 +1,19 @@ +# Secrets consumer admission proposal R1 + +This packet recommends exact consumer choices under SECRETS-WP-0014-T02/T03. It prepares the existing workstation CLI; it creates no workload, service, launcher or credential provider. Nothing here is installed or admitted. Protected effect acceptance remains **0/11 authorization sites across10 handlers**. Existing WP13/task/native Plan/grant obligations remain separate. + +Recommend `worsch`, UID/GID1000, on the existing local workstation. `id worsch` confirms that account. Use the fresh immutable release prefix `/home/worsch/.local/share/secrets-engine/releases/4dd004a22501-105dcebe6f03` and only its `bin/secrets-engine` CLI entrypoint. Install only after owner selection of the existing offline bundle/manifest and new parent/prefix; the reviewed installer refuses an existing prefix. The fixed helper is `libexec/secrets-engine-pdp-verify`, the offline receipt interpreter is `venv/bin/python`. The current editable checkout, internal venv CLI and rolling aliases are not admitted effect entrypoints. Same-UID code isolation is not established by0700 directories or checksums. + +Recommend the exact pre-key config parent `/home/worsch/.config/secrets-engine/native-lifecycle/lifecycle-20261016-pre-key-r1`, evidence parent `/home/worsch/.local/state/secrets-engine/native-lifecycle/lifecycle-20261016-r1`, and per-session caller directory `/run/user/1000/secrets-engine/lifecycle-20261016-r1`. Runtime/config/state directories are owner-controlled0700, files0600. No such proposed path was created by this preparation. `configuration/` contains public metadata copied from exact published runtime4dd004a, the existing total stance, empty-key trust and `environment.proposed.json`. That JSON documents the existing Config.load environment; it is not a new loader. No cwd fallback, ambient secrets, optional clock extra, Approval credential, unsafe-demo or backend provider is selected. Empty BAO/VAULT and Approval endpoints deliberately leave backend/consume unavailable in this stage. No catalog row action is granted by pinning its public metadata. + +`consumer-baseline.json` is the exact pre-generation public bundle/config/verifier/policy baseline. Its SHA256 is the proposed consumer_trust_sha256 and bindings.trust_sha256 in a future owner-reviewed Platform packet. It pins the public empty-key trust file, not a future key or online trust. Artifact integrity is not independent package admission: the Secrets owner/founder must independently select these exact bytes and control their delivery/prefix. The producer OCI image is deliberately a separate Platform/Access packet binding. P1 currently records old candidate0a/failed CI400; root's successor image needs a separately frozen packet revision and exact review, not reuse of old approvals. + +The installed4dd bundle's real authorize_action rejected this exact empty-key trust before credential/backend access in a disposable mapping with a minimal environment. No custom opener/authorizer or synthetic signer was used. `validation.json` preserves the initial harness assertion correction. Existing root-verified reproducible bundle/install tests remain independent evidence; no new build or dependency download was needed. + +After custody, use a new configuration revision at `/home/worsch/.config/secrets-engine/native-lifecycle/lifecycle-20261016-post-custody-r1` with `trust/lifecycle.json`. Authenticate the Platform public return independently, match exact source/target/receipt lineage, proposed kid, canonical public key and fingerprint, then independently admit the final public trust/config bytes and their new digest. Fingerprint is SHA256 of canonical43ASCII base64url public key, not raw32 and not owner authentication. Null-public/metadata-only/current-absence/unstored-seed recovery cannot admit a key. Do not overwrite the pre-key revision or use a responder key endpoint/private custody read to fill it. + +`transport-proposal.json` makes the existing local transport choice concrete: primary/railiance01 owner-verified cluster, exact owner-returned named pod in flex-auth, literal127.0.0.1:18081 to8080, no Service/label-first selection or automatic port substitution. The cluster/Access owner must independently verify pod UID/image/startup signer projection after separately admitted rollout. Stop only the owned port-forward PID when capture ends. Local UID1000 is not the ServiceAccount: a separately authorized existing TokenRequest delivery for secrets-engine/secrets-engine, audienceflex-auth and proposed600second lifetime must place the bounded caller file0600 in the private runtime directory. No caller file was inspected/minted, no token delivery actor selected, no RBAC grant proposed. Record public custody/expiry metadata only. The capture operation and source-record export still need exact Access-owner scope; the prior apply/destroy whynot request pair remains a proposal requiring disposition. + +Stage1 uses Access's existing `tools/verify_native_signer_receipt.py` at the exact pinned source/hash with the installed bundle Python/helper. It consumes captured public requests/responses/source records and admitted trust offline; it has no token/network/backend inputs. Do not turn that Python tool into a second effect entrypoint. Approval/client-secret/backend/caller effect grants are unnecessary for this offline proof and remain outside scope. Before any eleven-site effect acceptance, independently select actual handler/catalog scope, reviewed Approval delivery/CAS contract, supported backend and effect/readback/cleanup acceptance; service-jwt protected execution still refuses, and switching to bootstrap/env is no workaround. + +Remaining owner decisions are bounded: select this exact baseline/release/config and install custody; supply independently authenticated public signer return and final trust successor; select exact successful producer image/pod/projection/runtime evidence; authorize bounded caller delivery and signer-only request/source-record capture. None is replaced by this source review. Native effect grants/Approval/backend/Plan remain separate. Preserve signed refusal on outage, unknown/removed key, changed helper, wrong pin and unsigned rollback; never blind retry a consume. diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/activity-core-metered-worker-token.yaml b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/activity-core-metered-worker-token.yaml new file mode 100644 index 00000000..74a011e2 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/activity-core-metered-worker-token.yaml @@ -0,0 +1,63 @@ +id: activity-core-metered-worker-token +kind: kv +org: coulomb +repo: activity-core +stage: prod +description: Activity Core queue token for rein-aharness-metered@railiance01, + delivered only as a companion of the Glas metered owner. Custody and issuance + are activity-core's (ACTIVITY-WP-0039); secrets-engine only reads. +mount: platform +path: workloads/activity-core/ops-run-workers/rein-aharness-metered-railiance01 +mount_management: existing +fields: +- token +consumers: +- name: rein-aharness-metered-railiance01 + auth: approle + claim: catalog:activity-core-metered-worker-token + purpose: Claim the admitted hfact-metered ops_run as rein-aharness-metered@railiance01 + inside the catalog-bound Glas metered owner +workload_delivery: [] +delivery_config: + companion_of: + - glas-claude-agent-dev-anthropic +delivery_modes: +- exec-env +delivery_auth: + method: approle + management: engine + policy_name: se-prod-activity-core-metered-worker-token + role_name: se-prod-activity-core-metered-worker-token + metadata_read: false + token_ttl: 5m + token_max_ttl: 15m + secret_id_ttl: 5m + secret_id_num_uses: 1 + token_num_uses: 8 +approval: + model: decision + decision_ref: ACTIVITY-WP-0039 + notes: Ordinary lane approval, no human control (operator decision 2026-09-23, + SECRETS-WP-0011). Native apply and exec still need per-lane claim, PDP decision + and consume. This entry is not authorization. +verification: + positive: Exact scoped AppRole reads only token, delivered as ACTIVITY_CORE_WORKER_TOKEN + into the bound Glas metered owner. + negative: The claim-loop worker path, sibling KV, metadata, listing and writes denied; + the Glas lane AppRole cannot read this path. +risk: + classification: standard + notes: Lets the holder claim, heartbeat and close ops_runs as the metered identity + only. No provider spend by itself. +rotation: + owner: activity-core + expectation: Mint a new value at the same path via ACTIVITY-WP-0039 procedure; ESO + resyncs actcore-runtime-secret; next exec reads the new value. + ttl: owner-defined +deactivation: + owner: activity-core + expectation: Remove the identity from ACTIVITY_CORE_WORKERS and the path; revoke + the se-prod AppRole and policy. +audit: + evidence: Lane id, companion primary, actor, exact path, field name, timestamps, + and pass/fail only diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/email-connect-transactional.yaml b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/email-connect-transactional.yaml new file mode 100644 index 00000000..58d41d63 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/email-connect-transactional.yaml @@ -0,0 +1,78 @@ +# NON-SECRET catalog metadata derived from railiance-platform CCR-2026-0010. +id: email-connect-transactional +kind: kv +org: coulomb +repo: email-connect +stage: prod +description: >- + IONOS transactional SMTP identity and the shared user-engine ingest bearer for + email-connect. user-engine must never receive the SMTP fields. + +mount: platform +path: workloads/email-connect/transactional +mount_management: existing +fields: + - EMAIL_CONNECT_INGEST_TOKEN + - EMAIL_CONNECT_SMTP_USERNAME + - EMAIL_CONNECT_SMTP_PASSWORD + +consumers: + - name: email-connect-runtime + auth: token + claim: clustersecretstore:openbao-email-connect + purpose: sync email-connect-runtime through the namespace-limited ESO store + - name: user-engine-ingest-caller + auth: shared-bearer-copy + claim: service:user-engine + purpose: consume only EMAIL_CONNECT_INGEST_TOKEN from its separately managed runtime Secret + - name: secrets-engine-operator-exec + auth: approle + claim: catalog:email-connect-transactional + purpose: approved SMTP/ingest smoke in a child process and non-secret verification + +workload_delivery: + - mode: external-secrets + owner: railiance-platform + email-connect + +delivery_modes: [exec-env, read-check] +delivery_auth: + method: approle + management: engine + policy_name: se-prod-email-connect-transactional + role_name: se-prod-email-connect-transactional + token_ttl: 15m + token_max_ttl: 30m + secret_id_ttl: 15m + secret_id_num_uses: 1 + token_num_uses: 8 + +approval: + model: ccr + decision_ref: CCR-2026-0010 + notes: >- + CCR-2026-0010 approves the existing workload lane. A live native AppRole + apply remains gated by SECRETS-WP-0006-T05 review and operator approval. + +verification: + positive: approved delivery AppRole can read each declared field without printing it + negative: unrelated token is denied on platform/data/workloads/email-connect/transactional + +risk: + classification: high + notes: SMTP credentials can send as the platform identity and the ingest bearer authorizes transactional requests + +rotation: + owner: railiance-platform + email-connect + user-engine + expectation: >- + Rotate overlap-first, wait for ESO refresh, roll email-connect and user-engine + when the bearer changes, verify health, then revoke the old provider password/bearer. + ttl: provider-defined + +deactivation: + owner: railiance-platform + email-connect + expectation: >- + Disable the front door, detach ESO/native read access, remove materialized + Secrets, stop email-connect, and revoke the IONOS app password if applicable. + +audit: + evidence: CCR id, actor, exact path, field names, ESO refresh timestamp, request ids, and pass/fail only diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/example-build-test-token.yaml b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/example-build-test-token.yaml new file mode 100644 index 00000000..7e3ae372 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/example-build-test-token.yaml @@ -0,0 +1,44 @@ +# Example BUILD-stage lane. Demonstrates that build entries can be looser: +# generated test values are allowed and no production decision is required. +id: example-build-test-token +org: coulomb +repo: platform-ci +stage: build +description: >- + Throwaway generated credential for build-stage integration tests. May be + generated locally; must never be reused in test or prod. + +mount: secret +path: build/example/test-token + +fields: + - api_token + +consumers: + - name: build-runner + auth: approle + claim: "role:build-runner" + purpose: "exercise build-stage integration tests" + +delivery_modes: + - exec-env + - read-check + +# Build stage permits bootstrap-only / generated values without a prod decision. +approval: + model: bootstrap-only + notes: "Build stage: generated test secret, no production decision required." + +verification: + positive: "build-runner token can read the generated value" + negative: "prod consumers cannot read build paths" + +rotation: + expectation: "regenerate per run" + ttl: "1h" + +deactivation: + expectation: "delete on build teardown" + +audit: + evidence: "actor, path, timestamp, result — no secret value" diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/forgejo-admin-api-token.yaml b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/forgejo-admin-api-token.yaml new file mode 100644 index 00000000..ac6e0fe2 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/forgejo-admin-api-token.yaml @@ -0,0 +1,77 @@ +# NON-SECRET catalog metadata derived from railiance-platform CCR-2026-0006. +id: forgejo-admin-api-token +kind: kv +org: coulomb +repo: railiance-platform +stage: prod +description: >- + Forgejo site-admin PAT and non-secret companion metadata for attended operator + and automation tooling. This lane is distinct from Forgejo SMTP credentials. + +mount: platform +path: workloads/forgejo/forgejo-admin +mount_management: existing +fields: + - API_TOKEN + - API_USER + - API_BASE_URL + - TOKEN_SCOPES + - GENERATED_AT + +consumers: + - name: forgejo-platform-operators + auth: oidc + claim: group:net-kingdom-admins + purpose: existing attended reads through forgejo-admin-workload-kv-read + - name: forgejo-automation + auth: approle + claim: catalog:forgejo-admin-api-token + purpose: approved package, repository, webhook, and bootstrap commands in a child process + +workload_delivery: + - mode: operator-oidc + owner: railiance-platform + +delivery_modes: [exec-env, read-check] +delivery_auth: + method: approle + management: engine + # Avoid broad-admin name guards while retaining an exact-path read policy. + policy_name: se-prod-forgejo-operator-api-token + role_name: se-prod-forgejo-operator-api-token + token_ttl: 15m + token_max_ttl: 30m + secret_id_ttl: 15m + secret_id_num_uses: 1 + token_num_uses: 8 + +approval: + model: ccr + decision_ref: CCR-2026-0006 + notes: >- + CCR-2026-0006 approves the existing OIDC/operator lane. A live native + AppRole apply remains gated by SECRETS-WP-0006-T05 review and operator approval. + +verification: + positive: approved delivery AppRole can read declared fields and an exec smoke authenticates to Forgejo + negative: unrelated token is denied on platform/data/workloads/forgejo/forgejo-admin + +risk: + classification: high + notes: API_TOKEN is a Forgejo site-admin PAT with package, repository, webhook, and operator impact + +rotation: + owner: railiance-platform + Forgejo operator + expectation: >- + Mint a replacement PAT as the site-admin account, update OpenBao, verify all + consumers through exec delivery, then delete the old PAT in Forgejo. + ttl: provider-defined + +deactivation: + owner: railiance-platform + Forgejo operator + expectation: >- + Disable the front door, detach native/OIDC read access, revoke the PAT in + Forgejo, and preserve package/webhook audit evidence. + +audit: + evidence: CCR id, actor, exact path, field names, Forgejo token identifier/scopes if non-secret, timestamps, and pass/fail only diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/glas-claude-agent-dev-anthropic.yaml b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/glas-claude-agent-dev-anthropic.yaml new file mode 100644 index 00000000..f214b0e6 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/glas-claude-agent-dev-anthropic.yaml @@ -0,0 +1,104 @@ +id: glas-claude-agent-dev-anthropic +kind: kv +org: coulomb +repo: sand-boxer +stage: prod +description: Catalog-bound metered owner with Activity Core worker-token companion. + The 2026-09-27 corrected owner pin requires reviewed host installation; the old + 200k/32k owner is not admitted by this catalog. Native activation remains SECRETS-WP-0009-T03. +mount: platform +path: workloads/glas-harness/claude-agent-dev +mount_management: existing +fields: +- ANTHROPIC_API_KEY +consumers: +- name: sand-boxer-glas-agent-dev + auth: approle + claim: catalog:glas-claude-agent-dev-anthropic + purpose: Owner-admitted glas-harness agt run through the reviewed local profile; + no caller-facing key fetch +workload_delivery: [] +delivery_config: + exec_owner: + status: configured + owner: rein-aharness MessagesOwner (metered-once) with sand-boxer runtime boundary + command: + - /home/tegwick/.helixforge-factory/runtimes/b6e4e8a429393d68831c996a65c0489664205df969ac9882e581983ec2da4969/bin/python3 + - -I + - -B + - -m + - rein_aharness.cli + - metered-once + - --owner-config + - /home/tegwick/hfact/owner-metered/owner.json + cwd: /home/tegwick/hfact/owner-metered + environment: + PATH: /usr/bin:/bin + LANG: C.UTF-8 + HOME: /home/tegwick + ACTIVITY_CORE_URL: http://127.0.0.1:8010 + AGENT_HARNESS_WORKER_ID: rein-aharness-metered@railiance01 + AGENT_HARNESS_OPS_LABELS: hfact-metered + AGENT_HARNESS_OPS_LABELS_MODE: all + AGENT_HARNESS_EXECUTION_PROJECT: prj-helixforge-factory + AGENT_HARNESS_REQUIRE_SPEND_ADMISSION: '1' + AGENT_HARNESS_REQUIRE_REQUEST_ADMISSION: '1' + AGENT_HARNESS_SPEND_POLICY: /home/tegwick/hfact/owner-metered/spend-policy.json + AGENT_HARNESS_SPEND_LEDGER: /home/tegwick/hfact/owner-metered/spend.sqlite3 + AGENT_HARNESS_REPO_MAP: '{"hfact-glas-proof":"/home/tegwick/hfact/targets/hfact-glas-proof"}' + files: + /home/tegwick/.helixforge-factory/runtimes/b6e4e8a429393d68831c996a65c0489664205df969ac9882e581983ec2da4969/bin/python3: + sha256: e50d468e8b0adfb05733f5b87b3cff34829c4a8c1aea50c865aa8bdfe4bb150f + private: false + /home/tegwick/hfact/owner-metered/owner.json: + sha256: e0d3fb84649fdca302eccd9415f3cc2beaee84bf07bd83205cdffbe93e5573bc + private: true + /home/tegwick/hfact/owner-metered/spend-policy.json: + sha256: f31c585916de1ea8bd8e48c72421803dfde1015e6201704b9320f77d2c545d9c + private: true + companions: + - catalog: activity-core-metered-worker-token + field: token + env: ACTIVITY_CORE_WORKER_TOKEN +delivery_modes: +- exec-env +- read-check +delivery_auth: + method: approle + management: engine + policy_name: se-prod-glas-claude-agent-dev-anthropic + role_name: se-prod-glas-claude-agent-dev-anthropic + metadata_read: false + token_ttl: 5m + token_max_ttl: 15m + secret_id_ttl: 5m + secret_id_num_uses: 1 + token_num_uses: 8 +approval: + model: ccr + human_control: true + decision_ref: CCR-2026-0016 + notes: Custody only has been completed. Native apply and exec require durable exact-action + authorization, engine consume, scoped backend authority and verified delivery + state. This entry is not authorization. +verification: + positive: Exact scoped AppRole reads only ANTHROPIC_API_KEY into the approved child; + owner binding and redaction pass. + negative: Wrong owner profile/project/actor, direct caller fetch, sibling KV, metadata, + listing and writes denied. +risk: + classification: high + notes: API spend; provider expiry 2027-01-31T21:00:00Z is not enforced by Bao token + TTL. Workspace scope and budget unverified. +rotation: + owner: railiance-platform + sand-boxer + expectation: Provider replacement, versioned CAS custody, stop old runs, verify + replacement then revoke predecessor at Anthropic and prove denial. + ttl: provider-defined +deactivation: + owner: railiance-platform + sand-boxer + expectation: Disable lane, stop affected runs, revoke Bao sessions and provider + key. Preserve custody history. +audit: + evidence: CCR id, actor, exact path, field name, provider key identifier if non-secret, + timestamps, and pass/fail only diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/issue-core-ingestion-api-key.yaml b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/issue-core-ingestion-api-key.yaml new file mode 100644 index 00000000..c2e4bacb --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/issue-core-ingestion-api-key.yaml @@ -0,0 +1,78 @@ +# NON-SECRET catalog metadata derived from railiance-platform CCR-2026-0002. +id: issue-core-ingestion-api-key +kind: kv +org: coulomb +repo: issue-core +stage: prod +description: >- + Runtime credentials for issue-core ingestion and its Forgejo backend. The + existing workload path is delivered by ESO; native secrets-engine exec is a + separate exact-path AppRole proposed for attended operator/agent use. + +mount: platform +path: workloads/issue-core/issue-core/issue-core-runtime +mount_management: existing +fields: + - ISSUE_CORE_API_KEY + - GITEA_BACKEND_TOKEN + +consumers: + - name: issue-core-runtime + auth: kubernetes + claim: serviceaccount:external-secrets/external-secrets + purpose: sync the issue-core runtime Secret through the namespace-limited ESO store + - name: activity-core-issue-sink + auth: kubernetes + claim: serviceaccount:external-secrets/external-secrets + purpose: sync the shared ingestion key for activity-core issue emission + - name: secrets-engine-operator-exec + auth: approle + claim: catalog:issue-core-ingestion-api-key + purpose: approved child-process delivery and non-secret verification only + +workload_delivery: + - mode: external-secrets + owner: railiance-platform + issue-core + activity-core + +delivery_modes: [exec-env, read-check] +delivery_auth: + method: approle + management: engine + policy_name: se-prod-issue-core-ingestion-api-key + role_name: se-prod-issue-core-ingestion-api-key + token_ttl: 15m + token_max_ttl: 30m + secret_id_ttl: 15m + secret_id_num_uses: 1 + token_num_uses: 8 + +approval: + model: ccr + decision_ref: CCR-2026-0002 + notes: >- + CCR-2026-0002 approves the existing workload lane. A live native AppRole + apply remains gated by SECRETS-WP-0006-T05 review and operator approval. + +verification: + positive: approved delivery AppRole can read both declared fields without printing them + negative: unrelated token is denied on platform/data/workloads/issue-core/issue-core/issue-core-runtime + +risk: + classification: high + notes: path includes the shared ingestion bearer and the issue-core Forgejo backend token + +rotation: + owner: railiance-platform + issue-core + activity-core + expectation: >- + Update OpenBao first, wait for both ESO consumers, roll issue-core and the + activity-core workers, then revoke the replaced provider credential. + ttl: provider-defined + +deactivation: + owner: railiance-platform + issue-core + expectation: >- + Disable the front door, detach read access, remove materialized workload + Secrets, restart consumers, and preserve non-secret audit evidence. + +audit: + evidence: CCR id, actor, exact path, field names, audit request ids, timestamps, and pass/fail only diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/openrouter-llm-connect.yaml b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/openrouter-llm-connect.yaml new file mode 100644 index 00000000..2ae3a7f6 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/openrouter-llm-connect.yaml @@ -0,0 +1,74 @@ +# NON-SECRET catalog metadata derived from railiance-platform CCR-2026-0003. +id: openrouter-llm-connect +kind: kv +org: coulomb +repo: llm-connect +stage: prod +description: >- + OpenRouter provider credential used by llm-connect for activity-core. The + catalog does not generalize this key to other OpenRouter consumers. + +mount: platform +path: workloads/activity-core/llm-connect/llm-connect-provider-secrets +mount_management: existing +fields: + - OPENROUTER_API_KEY + +consumers: + - name: llm-connect-activity-core + auth: kubernetes + claim: serviceaccount:external-secrets/external-secrets + purpose: sync llm-connect-provider-secrets for the activity-core deployment + - name: secrets-engine-operator-exec + auth: approle + claim: catalog:openrouter-llm-connect + purpose: approved llm-connect smoke in a child process and non-secret verification + +workload_delivery: + - mode: external-secrets + owner: railiance-platform + llm-connect + +delivery_modes: [exec-env, read-check] +delivery_auth: + method: approle + management: engine + policy_name: se-prod-openrouter-llm-connect + role_name: se-prod-openrouter-llm-connect + token_ttl: 15m + token_max_ttl: 30m + secret_id_ttl: 15m + secret_id_num_uses: 1 + token_num_uses: 8 + +approval: + model: ccr + decision_ref: CCR-2026-0003 + notes: >- + CCR-2026-0003 approves the existing workload lane. A live native AppRole + apply remains gated by SECRETS-WP-0006-T05 review and operator approval. + First native-cutover candidate (docs/native-lane-cutover.md); not an + authorization to apply. + +verification: + positive: approved delivery AppRole can read OPENROUTER_API_KEY without printing it + negative: unrelated token is denied on platform/data/workloads/activity-core/llm-connect/llm-connect-provider-secrets + +risk: + classification: high + notes: provider key can incur spend and remains valid outside OpenBao until revoked at OpenRouter + +rotation: + owner: railiance-platform + llm-connect provider operator + expectation: >- + Mint a replacement at OpenRouter, update every live cluster copy, wait for + ESO refresh, restart llm-connect, verify, and revoke the old provider key. + ttl: provider-defined + +deactivation: + owner: railiance-platform + llm-connect + expectation: >- + Disable the front door and workload read path, remove materialized Secrets, + stop consumers, and revoke the provider key at OpenRouter. + +audit: + evidence: CCR id, actor, exact path, field name, provider key identifier if non-secret, timestamps, and pass/fail only diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/reuse-surface-hub-write-token.yaml b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/reuse-surface-hub-write-token.yaml new file mode 100644 index 00000000..c3e68582 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/reuse-surface-hub-write-token.yaml @@ -0,0 +1,77 @@ +# NON-SECRET catalog metadata derived from railiance-platform CCR-2026-0005. +id: reuse-surface-hub-write-token +kind: kv +org: coulomb +repo: reuse-surface +stage: prod +description: >- + Production federation-hub write bearer and Forgejo webhook HMAC. ESO remains + the workload delivery path; native exec is a separate exact-path AppRole. + +mount: platform +path: workloads/reuse/reuse-surface/runtime-secrets +mount_management: existing +fields: + - REUSE_SURFACE_TOKEN + - REUSE_SURFACE_FORGEJO_WEBHOOK_SECRET + +consumers: + - name: reuse-surface-runtime + auth: kubernetes + claim: serviceaccount:external-secrets/external-secrets + purpose: sync reuse-surface-env through the namespace-limited ESO store + - name: forgejo-coulomb-org-webhook + auth: shared-hmac + claim: forgejo-org:coulomb + purpose: share the webhook HMAC with the reuse-surface receiver + - name: secrets-engine-operator-exec + auth: approle + claim: catalog:reuse-surface-hub-write-token + purpose: approved hub tooling in a child process and non-secret verification + +workload_delivery: + - mode: external-secrets + owner: railiance-platform + reuse-surface + railiance-apps + +delivery_modes: [exec-env, read-check] +delivery_auth: + method: approle + management: engine + policy_name: se-prod-reuse-surface-hub-write-token + role_name: se-prod-reuse-surface-hub-write-token + token_ttl: 15m + token_max_ttl: 30m + secret_id_ttl: 15m + secret_id_num_uses: 1 + token_num_uses: 8 + +approval: + model: ccr + decision_ref: CCR-2026-0005 + notes: >- + CCR-2026-0005 approves the existing workload lane. A live native AppRole + apply remains gated by SECRETS-WP-0006-T05 review and operator approval. + +verification: + positive: approved delivery AppRole can read both declared fields without printing them + negative: unrelated token is denied on platform/data/workloads/reuse/reuse-surface/runtime-secrets + +risk: + classification: high + notes: hub writes affect the production federation index and the HMAC has two coordinated consumers + +rotation: + owner: railiance-platform + reuse-surface + railiance-apps + expectation: >- + Patch OpenBao, force ESO refresh, roll reuse-surface, and reconfigure the + Forgejo org webhook whenever its HMAC changes. + ttl: rotate-on-compromise + +deactivation: + owner: railiance-platform + reuse-surface + expectation: >- + Disable the front door, detach ESO access, and remove the materialized + Secret only after confirming decommission or break-glass posture. + +audit: + evidence: CCR id, actor, exact path, field names, ESO refresh timestamp, request ids, and pass/fail only diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/warden-sign.yaml b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/warden-sign.yaml new file mode 100644 index 00000000..dbff06b4 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/warden-sign.yaml @@ -0,0 +1,102 @@ +# warden-sign auth-capability lane. +# NON-SECRET: this file contains only OpenBao policy/AppRole metadata and +# operational pointers. AppRole secret_id values and tokens must never appear in +# Git, State Hub, chat, prompts, workplans, or normal logs. +id: warden-sign +kind: auth-capability +org: netkingdom +repo: ops-warden +stage: prod +description: >- + Scoped OpenBao AppRole used by ops-warden production smoke tests to call the + SSH secrets engine signing API over HTTP. This lane grants update only on the + exact SSH signing roles needed for FLEX-WP-0007 T4; it stores no KV value. + +# Existing production OpenBao SSH secrets-engine mount and logical operation. +mount: ssh +path: sign + +consumers: + - name: ops-warden-policy-smoke + auth: approle + claim: "agent:agt-state-hub-bridge" + purpose: >- + Run warden sign and the flex-auth policy-gate production smoke without + giving ops-warden standing custody of an OpenBao token. + allowed_actors: + - agt-state-hub-bridge + - agt-codex-interhub-bootstrap + - adm-example + - atm-backup-daily + +# The deliverable is a freshly minted AppRole secret_id plus role_id handed to +# the operator out-of-band. There is no KV value and no from-file provisioning. +delivery_modes: + - approle-login + +approval: + model: decision + decision_ref: "SECRETS-WP-0004" + notes: >- + Production auth-capability lane. Live apply and handoff require an approved + decision/workplan plus a mode-0600 bootstrap token outside any repo. + +verification: + positive: "the warden-sign AppRole token has update on every allowlisted ssh/sign role" + negative: "the warden-sign AppRole token lacks update on ssh/admin/sys/auth/identity probes" + +auth_capability: + policy_name: warden-sign + role_name: warden-sign + token_ttl: 15m + token_max_ttl: 15m + token_num_uses: 0 + secret_id_ttl: 30m + secret_id_num_uses: 1 + oidc: + auth_mount: netkingdom + role_name: warden-sign + role_path: auth/netkingdom/role/warden-sign + login_command: bao login -method=oidc -path=netkingdom role=warden-sign + policies: [warden-sign] + ttl: 15m + user_claim: sub + groups_claim: groups + bound_claims: + groups: [net-kingdom-admins] + oidc_scopes: [openid, profile, email, groups] + allowed_redirect_uris: + - https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback + - http://localhost:8250/oidc/callback + - http://127.0.0.1:8250/oidc/callback + allowed_paths: + - path: ssh/sign/agt-role + capabilities: [update] + - path: ssh/sign/adm-role + capabilities: [update] + - path: ssh/sign/atm-role + capabilities: [update] + denied_probe_paths: + - ssh/sign/unlisted-role + - ssh/roles/agt-role + - ssh/config/ca + - sys/policies/acl/warden-sign + - auth/token/create + - identity/entity/id + +rotation: + expectation: >- + Mint a fresh single-use secret_id for each attended handoff; revoke the + resulting token by accessor after the FLEX-WP-0007 T4 smoke or when the 15m + TTL expires, whichever comes first. + ttl: 15m + +deactivation: + expectation: >- + Delete the warden-sign AppRole and ACL policy, revoke outstanding token + accessors, and record non-secret evidence. + +audit: + evidence: >- + policy name, AppRole name, mount, allowed paths, token TTL, role_id file path, + secret_id file path, timestamp, result — no token value or secret_id. diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/whynot-design-npm-publish.yaml b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/whynot-design-npm-publish.yaml new file mode 100644 index 00000000..d1f7ce6e --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/catalog/whynot-design-npm-publish.yaml @@ -0,0 +1,78 @@ +# whynot-design npm publish token — the MVP pilot lane. +# This file is NON-SECRET. It describes where the token lives in OpenBao and how +# it may be consumed. The token VALUE never appears here. +# +# Terminology (Gitea is overloaded — we use the most explicit words): +# org = coulomb the Gitea organisation +# repo = whynot-design the Gitea repository / product (NOT an org, NOT a scope) +# npm package = @whynot/design published to the coulomb Gitea npm registry +# "@whynot" is the npm *scope*; it is neither the org nor the repo name. +id: whynot-design-npm-publish +org: coulomb +repo: whynot-design +stage: prod +description: >- + npm automation token used to publish the @whynot/design package from the + coulomb/whynot-design repo to the coulomb Gitea npm registry. Delivered to + `npm publish` via an exec-time temporary npm config; never printed or exported + into the parent shell. + +# OpenBao KV v2 location of the secret material (org/repo-scoped path). +mount: secret +path: coulomb/whynot-design/npm/publish + +# Field(s) inside the KV entry. The publish token is stored under this key. +# NOTE: this is the KV field name, NOT the env var the value is injected as. +# The injected name is resolved separately by the publication-scope policy and +# is currently `NPM_AUTH_TOKEN` (see delivery_config.npm.maturity below and +# docs/publication-scope-policy.md). Do not conflate the two. +fields: + - npm_token + +# Who may consume this lane and the identity claim that binds them. +consumers: + - name: whynot-design-ci + auth: approle # bound OpenBao auth method + claim: "repo:coulomb/whynot-design" + purpose: "publish @whynot/design to the coulomb Gitea npm registry from CI" + +# How the value may leave OpenBao. npm-config = temp .npmrc for the child only. +delivery_modes: + - npm-config + - read-check + +# npm-specific delivery target. The registry/scope live here as catalog DATA so +# the engine never hardcodes a registry. Matches coulomb/whynot-design/.npmrc. +delivery_config: + npm: + registry: "https://forgejo.coulomb.social/api/packages/coulomb/npm/" + scope: "@whynot" + package: "@whynot/design" + # Package maturity tag. Under the netkingdom publication-scope policy this + # would map to a publication scope (build->gitea, test->org, prod->repo) once + # netkingdom is production grade. netkingdom is at maturity-build today, so the + # policy is dormant and this lane clamps to repo-scope, injected as + # NPM_AUTH_TOKEN. Set `token_env` to override the resolved name. + maturity: maturity-build + # token_env: NPM_AUTH_WHYNOTDESIGN # explicit per-repo form (optional) + +# Privileged actions on this lane require an approved decision/CCR. +approval: + model: decision + decision_ref: "e6381a56-6b04-4fd5-b2de-f3ef59cde888" # Canonical State Hub decision UUID; local fixture is offline fallback + notes: "Production lane: apply requires an approved decision." + +# Verification expectations (no value is ever printed). +verification: + positive: "approved consumer token can read the lane field" + negative: "an unrelated token is denied read on the lane path" + +rotation: + expectation: "rotate on compromise or every 90 days" + ttl: "90d" + +deactivation: + expectation: "revoke approle + delete KV metadata; record evidence" + +audit: + evidence: "decision id, actor, path, timestamp, result — no secret value" diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/environment.proposed.json b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/environment.proposed.json new file mode 100644 index 00000000..c5edf366 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/environment.proposed.json @@ -0,0 +1,21 @@ +{ + "SECRETS_ENGINE_CATALOG": "/home/worsch/.config/secrets-engine/native-lifecycle/lifecycle-20261016-pre-key-r1/catalog", + "SECRETS_ENGINE_POLICIES": "/home/worsch/.config/secrets-engine/native-lifecycle/lifecycle-20261016-pre-key-r1/policies", + "SECRETS_ENGINE_PEP_STANCE": "/home/worsch/.config/secrets-engine/native-lifecycle/lifecycle-20261016-pre-key-r1/pep-stance.yaml", + "SECRETS_ENGINE_EVIDENCE": "/home/worsch/.local/state/secrets-engine/native-lifecycle/lifecycle-20261016-r1/evidence", + "SECRETS_ENGINE_HUB_URL": "http://127.0.0.1:8000", + "SECRETS_ENGINE_PDP_URL": "http://127.0.0.1:18081", + "SECRETS_ENGINE_PDP_TOKEN_FILE": "/run/user/1000/secrets-engine/lifecycle-20261016-r1/pdp-caller.token", + "SECRETS_ENGINE_PDP_TRUST_FILE": "/home/worsch/.config/secrets-engine/native-lifecycle/lifecycle-20261016-pre-key-r1/trust/lifecycle-pre-key.json", + "SECRETS_ENGINE_PDP_VERIFIER": "/home/worsch/.local/share/secrets-engine/releases/4dd004a22501-105dcebe6f03/libexec/secrets-engine-pdp-verify", + "SECRETS_ENGINE_AUTHORIZATION_SUBJECT_ID": "secrets-engine", + "SECRETS_ENGINE_AUTHORIZATION_SUBJECT_TYPE": "service", + "SECRETS_ENGINE_AUTHORIZATION_POLICY_PACKAGE": "secrets-engine.catalog-lane.lifecycle", + "SECRETS_ENGINE_AUTHORIZATION_POLICY_VERSION": "v2", + "SECRETS_ENGINE_AUTHORIZATION_MIN_APPROVALS": "1", + "SECRETS_ENGINE_APPROVAL_URL": "", + "SECRETS_ENGINE_KEYCAPE_TOKEN_URL": "", + "SECRETS_ENGINE_KEYCAPE_ISSUER": "", + "BAO_ADDR": "", + "VAULT_ADDR": "" +} diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/pep-stance.yaml b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/pep-stance.yaml new file mode 100644 index 00000000..e14888c9 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/pep-stance.yaml @@ -0,0 +1,81 @@ +# secrets-engine — PEP unreachable-engine stance map +# +# Framework: net-kingdom/canon/standards/security-layer-model_v0.7.md §6.4, §9.3 +# Companion: net-kingdom/SECURITY-COMPANION.md §5 +# Voice: INTENT.md / layer.yaml +# Validate: pytest tests/test_layer_conformance.py +# +# §6.4 obligation 3 requires a declared unreachable-engine stance that is +# total, scoped per zone or equivalent, carries no implicit default and no +# per-call discretion, and is published rather than held in code comments. +# Catalog stage is the equivalent scope until security-zone membership arrives +# as a claim on the decision. +# +# Runtime reads this file. tests/test_layer_conformance.py also pins +# secrets_engine.pep_stance.SHIPPED_STANCE to this map. A published map free +# to drift from that pin is worse than none. + +schema_version: "0.1" +framework: netkingdom-security-layer-model +standard_version: "0.7" +repository: secrets-engine +pep_shape: true +declared_by: INTENT.md +decision_engine: access-engine # flex-auth until the governed rename +scope: catalog-stage # equivalent scope; not security-zones yet + +protected_action: >- + OpenBao metadata apply, KV provision, verification, exec delivery, + auth-capability handoff, native revoke, and lifecycle + suspend/deactivate/destroy + +# Total by construction over every catalog stage plus unknown. +# No implicit default — an unlisted value is a config error, not a fallback. +# "fail_closed" means no protected side effect without a durable +# access-engine / ActionAuthorization record. +# "fail_open" here means the engine-unreachable residue: proceed only under +# the existing lane-approval check (State Hub or local fixture), which is +# itself a tracked gap relative to access-engine, not a manufactured allow. +stance: + build: fail_open + test: fail_open + prod: fail_closed + unknown: fail_closed + +# Explicit, three-factor exception. Not a stance row and not a default. +# All three must hold or the production gate remains closed. +demo_exception: + id: unsafe-local-demo + requires: + - "SECRETS_ENGINE_UNSAFE_DEMO=1" + - "hub URL empty" + - "OpenBao target is loopback" + applies_to: prod + note: >- + Throwaway local demonstration only. Not a production residue and not + available when any one factor is missing. + +on_apply: + recorded_fields: + - stance_stage + - stance_failure_mode + - stance_demo_exception + - stance_decision_id # present only where a decision was resolved + - approval_status # rejected when the stance refuses + - error_type # DecisionError when fail_closed fires + written_to: + - "local JSONL (.evidence/evidence-YYYY-MM-DD.jsonl)" + - "evidence_dir/outbox/ for load-bearing classes" + - "best-effort State Hub progress note (attributive only)" + never_recorded: "any secret material, token, password, or raw value" + +# §6.4 obligation 2 — the verdict is never cached. A retry of the same +# canonical request digest inside the decision lifetime is the decision +# being used as issued; a different resource is not. +verdict_caching: none + +# §6.4 obligation 4 / §9.6 — attributive today. No control branches on the +# presence of a local evidence record. Completeness is not claimed. +reconstructability: + bound: "§9.6 attributive — completeness is not claimed" + declared_at: layer.yaml#evidence_bound diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/policies/netkingdom-publication-scope.yaml b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/policies/netkingdom-publication-scope.yaml new file mode 100644 index 00000000..93c6117a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/policies/netkingdom-publication-scope.yaml @@ -0,0 +1,46 @@ +# Publication-scope policy — owned by the netkingdom security-infrastructure domain, +# enforced by secrets-engine. +# +# Binds npm package *publication scope* to package *maturity*, and GATES the +# graduated (relaxed) scoping behind netkingdom itself reaching production grade. +# +# Design intent (proves policy-bound secret handling): +# - A repo's maturity translates into the maturity tag of the package it ships +# (maturity-build / maturity-test / maturity-prod). +# - Each maturity tag is restricted by the publication scope its token carries. +# - Low-stakes build artifacts may publish with a BROAD shared token; high-stakes +# production publishes require a TIGHT, repo-scoped token. Scope narrows as +# stakes rise. +# - The graduated table is DORMANT until netkingdom is production grade. While +# dormant, every lane clamps to the safest scope (repo) — fail-safe, never +# fail-open. +domain: netkingdom +policy: publication-scope +version: "0.1.0" + +# netkingdom's own maturity. `production_grade` is derived: it is true only when +# this equals "maturity-prod". Today netkingdom is at maturity-build. +netkingdom_maturity: maturity-build + +# When ACTIVE (production_grade == true): package maturity tag -> required scope. +maturity_scope: + maturity-build: gitea # gitea-wide publication rights + maturity-test: org # org-wide publication rights + maturity-prod: repo # repo-scoped publication rights + +# Safe scope applied while DORMANT, and the floor that cannot be exceeded before +# netkingdom is production grade. +dormant_scope: repo + +# Token env-var naming convention by effective scope. {ORG}/{SCOPE}/{REPO} are +# upper-cased, de-hyphenated forms of the lane's org / npm-scope / repo. +# org coulomb -> COULOMB +# scope @whynot -> WHYNOT (npm scope = "project"; defined, NOT used by policy) +# repo whynot-design -> WHYNOTDESIGN +# The default repo-scope name is the bare NPM_AUTH_TOKEN; the explicit per-repo +# form (NPM_AUTH_{REPO}) is available when a lane overrides it. +token_env: + gitea: "NPM_AUTH_GITEA_TOKEN" + org: "NPM_AUTH_{ORG}_TOKEN" + scope: "NPM_AUTH_{SCOPE}_TOKEN" + repo: "NPM_AUTH_TOKEN" diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/policies/secrets-engine-build.hcl b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/policies/secrets-engine-build.hcl new file mode 100644 index 00000000..94769b7a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/policies/secrets-engine-build.hcl @@ -0,0 +1,36 @@ +# secrets-engine-build — build/dev stage role policy. +# +# Scope: manage approved BUILD-stage metadata and values under the build/ prefix +# only. May generate throwaway test values. Cannot touch test/ or prod paths, +# cannot administer sys/, auth/, or identity/, cannot act as root. + +# KV v2 data + metadata under the build prefix. +path "secret/data/build/*" { + capabilities = ["create", "read", "update", "patch", "delete"] +} +path "secret/metadata/build/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +# Manage only the build consumer policies this role owns. +path "sys/policies/acl/se-build-*" { + capabilities = ["create", "read", "update", "delete"] +} + +# Manage build approle roles only. +path "auth/approle/role/se-build-*" { + capabilities = ["create", "read", "update", "delete"] +} +path "auth/approle/role/se-build-*/role-id" { + capabilities = ["read"] +} +path "auth/approle/role/se-build-*/secret-id" { + capabilities = ["create", "update"] +} + +# DENY everything outside the build lane (explicit, for reviewer clarity). +path "secret/data/test/*" { capabilities = ["deny"] } +path "secret/data/prod/*" { capabilities = ["deny"] } +path "sys/*" { capabilities = ["deny"] } +path "auth/token/*" { capabilities = ["deny"] } +path "identity/*" { capabilities = ["deny"] } diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/policies/secrets-engine-prod.hcl b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/policies/secrets-engine-prod.hcl new file mode 100644 index 00000000..cf11be94 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/policies/secrets-engine-prod.hcl @@ -0,0 +1,63 @@ +# secrets-engine-prod — production stage role policy. +# +# Scope: apply approved PRODUCTION metadata (ACL policies + approle roles) and +# write approved lane values for provisioning. Deliberately NARROW: +# - no root / sudo +# - no sys/* administration beyond its own se-prod-* ACL policies +# - no identity/* administration +# - no auth/token administration +# - cannot reach into build/ or test/ prefixes +# +# Production raw-value READ stays gated: this role can write a value during an +# approved provisioning step, but broad read of arbitrary prod paths is denied. +# Consumers read their own lane through the narrow se-prod- consumer policy. + +# Apply approved production lane values (provisioning) and metadata. +# Owner-scoped prod lanes (e.g. whynot-design/...) — NOT under build/ or test/. +path "secret/data/+/*" { + capabilities = ["create", "update", "patch"] +} +path "secret/metadata/+/*" { + capabilities = ["create", "read", "update", "list"] +} + +# Manage only the prod consumer policies this role owns. +path "sys/policies/acl/se-prod-*" { + capabilities = ["create", "read", "update", "delete"] +} + +# Exact exception for SECRETS-WP-0004 auth-capability lane. +path "sys/policies/acl/warden-sign" { + capabilities = ["create", "read", "update", "delete"] +} + +# Manage prod approle roles only. +path "auth/approle/role/se-prod-*" { + capabilities = ["create", "read", "update", "delete"] +} +path "auth/approle/role/se-prod-*/role-id" { + capabilities = ["read"] +} +path "auth/approle/role/se-prod-*/secret-id" { + capabilities = ["create", "update"] +} + +# Exact exception for SECRETS-WP-0004 auth-capability lane. +path "auth/approle/role/warden-sign" { + capabilities = ["create", "read", "update", "delete"] +} +path "auth/approle/role/warden-sign/role-id" { + capabilities = ["read"] +} +path "auth/approle/role/warden-sign/secret-id" { + capabilities = ["create", "update"] +} + +# Hard denials — production appliers must not become broad admins. +path "secret/data/build/*" { capabilities = ["deny"] } +path "secret/data/test/*" { capabilities = ["deny"] } +path "sys/policies/acl/secrets-engine-*" { capabilities = ["deny"] } # can't edit stage roles +path "auth/token/*" { capabilities = ["deny"] } +path "identity/*" { capabilities = ["deny"] } +path "sys/auth/*" { capabilities = ["deny"] } +path "sys/mounts/*" { capabilities = ["deny"] } diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/policies/secrets-engine-test.hcl b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/policies/secrets-engine-test.hcl new file mode 100644 index 00000000..8cc367a0 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/policies/secrets-engine-test.hcl @@ -0,0 +1,33 @@ +# secrets-engine-test — test/staging stage role policy. +# +# Scope: manage approved TEST-stage metadata and values under the test/ prefix, +# and run positive/negative verification. Cannot touch build/ or prod paths, +# cannot administer sys/, auth/, or identity/, cannot act as root. + +path "secret/data/test/*" { + capabilities = ["create", "read", "update", "patch", "delete"] +} +path "secret/metadata/test/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "sys/policies/acl/se-test-*" { + capabilities = ["create", "read", "update", "delete"] +} + +path "auth/approle/role/se-test-*" { + capabilities = ["create", "read", "update", "delete"] +} +path "auth/approle/role/se-test-*/role-id" { + capabilities = ["read"] +} +path "auth/approle/role/se-test-*/secret-id" { + capabilities = ["create", "update"] +} + +# DENY everything outside the test lane. +path "secret/data/build/*" { capabilities = ["deny"] } +path "secret/data/prod/*" { capabilities = ["deny"] } +path "sys/*" { capabilities = ["deny"] } +path "auth/token/*" { capabilities = ["deny"] } +path "identity/*" { capabilities = ["deny"] } diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/trust/lifecycle-pre-key.json b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/trust/lifecycle-pre-key.json new file mode 100644 index 00000000..22fc99a3 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/configuration/trust/lifecycle-pre-key.json @@ -0,0 +1,9 @@ +{ + "schema": "secrets-engine.pdp-trust.v1", + "evaluator": "flex-auth/local", + "mode": "standalone", + "policy_package": "secrets-engine.catalog-lane.lifecycle", + "policy_version": "v2", + "policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4", + "keys": {} +} diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/consumer-baseline.json b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/consumer-baseline.json new file mode 100644 index 00000000..aa204954 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/consumer-baseline.json @@ -0,0 +1,95 @@ +{ + "schema": "secrets-engine.consumer-baseline-proposal.v1", + "proposal_id": "SECRETS14-CONSUMER-ADMISSION-20261016-R1", + "state": "owner-selectable source preparation; not installed or admitted", + "execution_identity": { + "host": "existing local workstation", + "user": "worsch", + "uid": 1000, + "gid": 1000, + "observation": "id worsch; no credential/process environment read", + "same_uid_code_isolation_established": false + }, + "release": { + "runtime_source_revision": "4dd004a2250104ba56293b2d7b0d96f4e076ea99", + "documentation_source_revision": "3718e60d7385db091d3423352f44e922863087de", + "bundle_manifest_sha256": "sha256:105dcebe6f03e9799f91ea75175e20500df2aa7f08b5e32d39c1c367593ca47e", + "wheel_sha256": "sha256:198dca5c025fe22d60b3dead8244d112a554bf837c28f6cd2877aafa05924ae7", + "helper_sha256": "sha256:ed922b62533e4d33ae2d5f11175111f6f28e684a19e63ac704d43b63f0e0e12e", + "prefix": "/home/worsch/.local/share/secrets-engine/releases/4dd004a22501-105dcebe6f03", + "entrypoint": "/home/worsch/.local/share/secrets-engine/releases/4dd004a22501-105dcebe6f03/bin/secrets-engine", + "helper": "/home/worsch/.local/share/secrets-engine/releases/4dd004a22501-105dcebe6f03/libexec/secrets-engine-pdp-verify", + "python": "/home/worsch/.local/share/secrets-engine/releases/4dd004a22501-105dcebe6f03/venv/bin/python", + "install_contract": "new prefix only; existing reviewed offline installer with independently admitted manifest; never overwrite or use ambient editable/venv CLI" + }, + "configuration": { + "parent": "/home/worsch/.config/secrets-engine/native-lifecycle/lifecycle-20261016-pre-key-r1", + "files_sha256": { + "catalog/activity-core-metered-worker-token.yaml": "sha256:67d6235bbf6e28b7d1392ff26fd13ce4933843d11407a8fe2b1c793b3748a1f5", + "catalog/email-connect-transactional.yaml": "sha256:7869e96a7fca4c508f8a2a11a9b614db8c8a4100c2402597bde48a06aef77f97", + "catalog/example-build-test-token.yaml": "sha256:6e2396af8c711c10a5af83edb3925227c251e82d16a5d5a7b7a87948e2229f54", + "catalog/forgejo-admin-api-token.yaml": "sha256:193e231c5dbc175e82614fe35092ab649cf4152878f2679332b37888f176a991", + "catalog/glas-claude-agent-dev-anthropic.yaml": "sha256:61f40b124d937516b18b77b3ae3253079f024f4522191e22a81fae3d859abb63", + "catalog/issue-core-ingestion-api-key.yaml": "sha256:401b8c7a57d3e9a54e98711387577ecc6fc0674f2e32759a5c675e1d4adf176f", + "catalog/openrouter-llm-connect.yaml": "sha256:3d46e4896aa3b2a9838f72d4f2e3b1afc9deb2c7b1be0c62ae1900eb9cb1713b", + "catalog/reuse-surface-hub-write-token.yaml": "sha256:6f2ab0e31ba71bffb5d48b8ac787dee52b73f865af1c9f155d5046067a841111", + "catalog/warden-sign.yaml": "sha256:54a9ec01bf87fa3cc6ab75fa6c818c4977f2f70800a16890a836a509ad5c73c9", + "catalog/whynot-design-npm-publish.yaml": "sha256:18ada66f109a5532462b70b4d213bb73c7ab2e0ef9a4a2a149b7f9fcd77b034f", + "environment.proposed.json": "sha256:78a19a1a33f1faf61353ce674048826c89c45e84f15467992fca347679c4908d", + "pep-stance.yaml": "sha256:969ffd12506e2954c273e19ed6505692c1bacbe97a262a85862793254263e737", + "policies/netkingdom-publication-scope.yaml": "sha256:018000cb29edf7707f3885bec2f54285adef1e5a71c749b6b7ab6044203de89b", + "policies/secrets-engine-build.hcl": "sha256:ec5ce1987fbf2818f1ecd612d280cc419990dce12f85d7f5f38b621f4f9dbf69", + "policies/secrets-engine-prod.hcl": "sha256:1d12ca47e4f3ab234370ddfadad2aca8ae0943d95833798be5af0ea09860c6ce", + "policies/secrets-engine-test.hcl": "sha256:53c1d4146116784ba0024cc2c9f318e6443471ee97b7b1d2056ea840eb4ff6f6", + "trust/lifecycle-pre-key.json": "sha256:5f01b418cccef03326c424a1de649a4bde42811aa1e2041a6d0417c8d65a3f6a" + }, + "format": "environment.proposed.json describes existing Config.load environment only; not a new runtime loader", + "owner_directory_mode": "0700", + "metadata_file_mode": "0600", + "evidence_root": "/home/worsch/.local/state/secrets-engine/native-lifecycle/lifecycle-20261016-r1", + "runtime_credential_directory": "/run/user/1000/secrets-engine/lifecycle-20261016-r1", + "runtime_directory_mode": "0700", + "credential_file_mode": "0600", + "credential_owner_uid": 1000 + }, + "trust": { + "pre_key_file": "trust/lifecycle-pre-key.json", + "pre_key_file_sha256": "sha256:5f01b418cccef03326c424a1de649a4bde42811aa1e2041a6d0417c8d65a3f6a", + "keys": {}, + "expected_behavior": "empty-key trust must refuse before caller credential access; no pre-generation online trust", + "post_custody_successor_config_parent": "/home/worsch/.config/secrets-engine/native-lifecycle/lifecycle-20261016-post-custody-r1", + "post_custody_successor_trust_relative_path": "trust/lifecycle.json", + "post_custody_successor_digest": null, + "independent_key_admission_required": true + }, + "policy": { + "evaluator": "flex-auth/local", + "mode": "standalone", + "policy_package": "secrets-engine.catalog-lane.lifecycle", + "policy_version": "v2", + "policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4" + }, + "unavailable_for_this_scope": { + "approval_delivery": "not configured; stage1 offline receipt proof only", + "backend_entitlement": "not configured; no backend invocation", + "clock_extra": "not configured; no optional extra admitted", + "native_task_runtime": "not selected; separate exact Plan/grant/source required" + }, + "environment_to_be_absent": [ + "BAO_TOKEN", + "VAULT_TOKEN", + "SECRETS_ENGINE_UNSAFE_DEMO", + "SECRETS_ENGINE_APPROVAL_TOKEN_FILE", + "SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE", + "SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE", + "SECRETS_ENGINE_OPENBAO_JWT_LOGIN", + "SECRETS_ENGINE_CLOCK_TRUST_FILE" + ], + "scope": "retained lifecycle signer authentication only; catalog snapshot pins data, grants no catalog action", + "producer_image": "separate native packet input; intentionally not a consumer baseline hash input", + "execution_authorized": false, + "native_trust_admitted": false, + "protected_effect_sites_accepted": 0, + "protected_effect_sites": 11, + "protected_effect_handlers": 10 +} diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/disposable-baseline-refusal.txt b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/disposable-baseline-refusal.txt new file mode 100644 index 00000000..8a076edd --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-native-preparation/consumer-admission-r1/disposable-baseline-refusal.txt @@ -0,0 +1 @@ +PASS: actual installed authorize_action refused empty-key trust before credential/backend access