From 02e69269c897fbb471aa067ad76129b936b2f289 Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 19 Oct 2026 12:42:11 +0200 Subject: [PATCH] Adopt staged generation and prepare weekly sizing and credential isolation --- .generated-work-records.json | 1 + .generated-work-records.mk | 8 + Makefile | 5 +- SCOPE.md | 9 +- WORK-RECORDS.md | 82 ++++- config/weekly-allocation-request.json | 13 + .../credential-boundary-r1/PROPOSAL.md | 92 +++++ .../credential-boundary-r1/SHA256SUMS | 9 + .../cluster-bootstrap.patch | 45 +++ .../host-identity.candidate.yaml | 25 ++ .../credential-boundary-r1/identity-rbac.yaml | 34 ++ .../original-revision-1.SHA256SUMS | 8 + .../credential-boundary-r1/source-pins.json | 27 ++ .../test_negative_verifier.py | 71 ++++ .../credential-boundary-r1/validation.json | 9 + .../verify_agent_denials.py | 110 ++++++ docs/staged-work-record-generation.md | 33 +- docs/weekly-allocation-report.md | 110 ++++++ scripts/check_generated_only_commit.py | 5 +- scripts/generate_staged_work_records.py | 9 + scripts/generated-pre-commit.sh | 3 +- scripts/install_shared_generated_guard.py | 30 +- scripts/run_generated_work_records_hook.py | 48 +++ scripts/weekly_allocation_report.py | 321 ++++++++++++++++++ tests/test_generated_commit_guard.py | 9 + tests/test_shared_generated_guard.py | 15 + tests/test_staged_work_records.py | 66 ++++ tests/test_weekly_allocation_report.py | 150 ++++++++ ...sured-workload-sizing-and-weekly-review.md | 40 +++ ...UST-WP-0073-agent-credential-separation.md | 37 +- ...UST-WP-0079-deterministic-security-flow.md | 41 ++- 31 files changed, 1439 insertions(+), 26 deletions(-) create mode 100644 .generated-work-records.json create mode 100644 .generated-work-records.mk create mode 100644 config/weekly-allocation-request.json create mode 100644 docs/changes/CUST-WP-0073/credential-boundary-r1/PROPOSAL.md create mode 100644 docs/changes/CUST-WP-0073/credential-boundary-r1/SHA256SUMS create mode 100644 docs/changes/CUST-WP-0073/credential-boundary-r1/cluster-bootstrap.patch create mode 100644 docs/changes/CUST-WP-0073/credential-boundary-r1/host-identity.candidate.yaml create mode 100644 docs/changes/CUST-WP-0073/credential-boundary-r1/identity-rbac.yaml create mode 100644 docs/changes/CUST-WP-0073/credential-boundary-r1/original-revision-1.SHA256SUMS create mode 100644 docs/changes/CUST-WP-0073/credential-boundary-r1/source-pins.json create mode 100644 docs/changes/CUST-WP-0073/credential-boundary-r1/test_negative_verifier.py create mode 100644 docs/changes/CUST-WP-0073/credential-boundary-r1/validation.json create mode 100644 docs/changes/CUST-WP-0073/credential-boundary-r1/verify_agent_denials.py create mode 100644 docs/weekly-allocation-report.md create mode 100644 scripts/run_generated_work_records_hook.py create mode 100644 scripts/weekly_allocation_report.py create mode 100644 tests/test_weekly_allocation_report.py diff --git a/.generated-work-records.json b/.generated-work-records.json new file mode 100644 index 0000000..06a7059 --- /dev/null +++ b/.generated-work-records.json @@ -0,0 +1 @@ +{"version": 1, "repo_slug": "the-custodian"} diff --git a/.generated-work-records.mk b/.generated-work-records.mk new file mode 100644 index 0000000..cacc957 --- /dev/null +++ b/.generated-work-records.mk @@ -0,0 +1,8 @@ +# Explicit opt-in recipe; no environment files or external make includes. +CUSTODIAN_SOURCE ?= /home/worsch/the-custodian +GENERATED_REPO_SLUG ?= the-custodian +.PHONY: generated-work-records generated-work-records-check +generated-work-records: + python3 "$(CUSTODIAN_SOURCE)/scripts/generate_staged_work_records.py" --repo . --repo-slug "$(GENERATED_REPO_SLUG)" +generated-work-records-check: + python3 "$(CUSTODIAN_SOURCE)/scripts/generate_staged_work_records.py" --repo . --repo-slug "$(GENERATED_REPO_SLUG)" --check diff --git a/Makefile b/Makefile index f42a193..d0474b3 100644 --- a/Makefile +++ b/Makefile @@ -232,8 +232,9 @@ artifact-adr008: --subtitle "A framework for describing, holding and improving multi-tenancy — including where we are not there yet." .PHONY: generated-work-records generated-work-records-check +GENERATED_REPO_SLUG ?= the-custodian generated-work-records: ## Regenerate and stage index from staged sources; preserve unstaged edits - python3 scripts/generate_staged_work_records.py --repo . --repo-slug the-custodian + $(MAKE) --no-print-directory -f .generated-work-records.mk generated-work-records GENERATED_REPO_SLUG="$(GENERATED_REPO_SLUG)" generated-work-records-check: ## Check staged index without committing, pushing or changing files - python3 scripts/generate_staged_work_records.py --repo . --repo-slug the-custodian --check + $(MAKE) --no-print-directory -f .generated-work-records.mk generated-work-records-check GENERATED_REPO_SLUG="$(GENERATED_REPO_SLUG)" diff --git a/SCOPE.md b/SCOPE.md index c7a9e9c..2389275 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -218,7 +218,7 @@ keywords: [git, generated, hooks, work-records, validation] ```capability type: tool title: Staged work-record index generation -description: make generated-work-records reuses the State Hub renderer on staged Markdown, with explicit repository identity, and stages only WORK-RECORDS.md while refusing unstaged generated-file edits. make generated-work-records-check is read-only. Source adapter and tests are implemented; shared-hook activation and fleet adoption are not established. See docs/staged-work-record-generation.md. +description: make generated-work-records reuses the State Hub renderer on staged Markdown, with explicit repository identity, and stages only WORK-RECORDS.md while refusing unstaged generated-file edits. make generated-work-records-check is read-only. Source adapter and tests are implemented; the shared/local hook supports explicit per-repo adoption, with activity-core, policy-nexus and repo-manager committed. Remaining fleet and other index adoption are still open. See docs/staged-work-record-generation.md. keywords: [git, generated, work-records, staged-sources, validation] ``` @@ -241,6 +241,13 @@ These local discovery descriptions are not publication grants. The federated and repository classification tooling. Confidential canon, memory, operational records and estate-specific execution configuration stay outside that boundary. +```capability +type: tool +title: Retained weekly allocation evidence report +description: scripts/weekly_allocation_report.py joins existing cluster, allocation, telemetry and resource-control evidence into an immutable weekly report. It preserves missing measurements, namespace-only aggregates, comparable metric windows and stable recommendation identities. The disabled activity definition still requires accepted input collection, durable executor storage, delivery and recovery before unattended operation. +keywords: [capacity, weekly, telemetry, retention, recommendations, resource-control] +``` + --- ## Notes diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index d535e0f..2a6015b 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -79,8 +79,10 @@ | workplan | CUST-WP-0076 | finished | — | workplans/CUST-WP-0076-provisioning-window-cadence.md | | workplan | CUST-WP-0077 | finished | — | workplans/CUST-WP-0077-dependency-root-execution-wave.md | | workplan | CUST-WP-0078 | finished | — | workplans/CUST-WP-0078-security-collaboration-consolidation.md | -| workplan | CUST-WP-0079 | blocked | — | workplans/CUST-WP-0079-deterministic-security-flow.md | -| workplan | CUST-WP-0080 | active | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| workplan | CUST-WP-0079 | active | — | workplans/CUST-WP-0079-deterministic-security-flow.md | +| workplan | CUST-WP-0080 | finished | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| workplan | CUST-WP-0081 | finished | — | workplans/CUST-WP-0081-netkingdom-architecture-review-and-reuse.md | +| workplan | CUST-WP-0082 | backlog | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | | workplan | THE-WP-0001 | finished | — | workplans/THE-WP-0001-federation-interface.md | | task | CUST-WP-ADHOC-2026-05-02-T01 | done | — | workplans/ADHOC-2026-05-02.md | | task | CUST-WP-ADHOC-2026-07-02-T01 | done | — | workplans/ADHOC-2026-07-02.md | @@ -501,13 +503,82 @@ | task | CUST-WP-0079-T02 | done | — | workplans/CUST-WP-0079-deterministic-security-flow.md | | task | CUST-WP-0079-T03 | wait | — | workplans/CUST-WP-0079-deterministic-security-flow.md | | task | CUST-WP-0079-T04 | wait | — | workplans/CUST-WP-0079-deterministic-security-flow.md | -| task | CUST-WP-0079-T05 | wait | — | workplans/CUST-WP-0079-deterministic-security-flow.md | +| task | CUST-WP-0079-T05 | progress | — | workplans/CUST-WP-0079-deterministic-security-flow.md | | task | CUST-WP-0079-T06 | wait | — | workplans/CUST-WP-0079-deterministic-security-flow.md | | task | CUST-WP-0079-T07 | done | — | workplans/CUST-WP-0079-deterministic-security-flow.md | | task | CUST-WP-0080-T01 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | | task | CUST-WP-0080-T02 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | -| task | CUST-WP-0080-T03 | progress | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | -| task | CUST-WP-0080-T04 | todo | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T03 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T04 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T05 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T06 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T07 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T08 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T09 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T10 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T11 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T12 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T13 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T14 | cancel | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T15 | cancel | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T16 | cancel | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T17 | cancel | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T18 | cancel | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T19 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T20 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T21 | cancel | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T22 | cancel | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T23 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T24 | cancel | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T25 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T26 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T27 | cancel | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T28 | cancel | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T29 | cancel | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T30 | cancel | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T31 | cancel | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T32 | cancel | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T33 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T34 | cancel | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T35 | cancel | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T36 | cancel | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T37 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T38 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T39 | cancel | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T40 | cancel | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T41 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T42 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T43 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T44 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0080-T45 | done | — | workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md | +| task | CUST-WP-0081-T01 | done | — | workplans/CUST-WP-0081-netkingdom-architecture-review-and-reuse.md | +| task | CUST-WP-0081-T02 | done | — | workplans/CUST-WP-0081-netkingdom-architecture-review-and-reuse.md | +| task | CUST-WP-0081-T03 | done | — | workplans/CUST-WP-0081-netkingdom-architecture-review-and-reuse.md | +| task | CUST-WP-0082-T01 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T02 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T03 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T04 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T05 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T06 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T07 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T08 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T09 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T10 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T11 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T12 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T13 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T14 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T15 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T16 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T17 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T18 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T19 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T20 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T21 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T22 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T23 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T24 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | +| task | CUST-WP-0082-T25 | todo | — | workplans/CUST-WP-0082-netkingdom-security-hardening.md | | task | THE-WP-0001-T01 | done | — | workplans/THE-WP-0001-federation-interface.md | | task | THE-WP-0001-T02 | done | — | workplans/THE-WP-0001-federation-interface.md | | intake | CUST-IN-0001 | closed | green | intake-legacy-suggestions-migration.md | @@ -527,3 +598,4 @@ | intake | CUST-IN-0015 | open | blue | intakes.md | | intake | CUST-IN-0016 | open | green | intakes.md | | intake | CUST-IN-0017 | open | green | intakes.md | +| intake | CUST-IN-0018 | open | green | intakes.md | diff --git a/config/weekly-allocation-request.json b/config/weekly-allocation-request.json new file mode 100644 index 0000000..b342f3c --- /dev/null +++ b/config/weekly-allocation-request.json @@ -0,0 +1,13 @@ +{ + "schema": "custodian.weekly-allocation-request.v1", + "binding_status": "unbound; CUST-WP-0071-T05 executor owner must provide real retained inputs before enabling", + "cluster": null, + "allocation": null, + "telemetry": null, + "inventory": null, + "provenance": null, + "previous": null, + "reviews": null, + "as_of": null, + "output": null +} diff --git a/docs/changes/CUST-WP-0073/credential-boundary-r1/PROPOSAL.md b/docs/changes/CUST-WP-0073/credential-boundary-r1/PROPOSAL.md new file mode 100644 index 0000000..59e30b7 --- /dev/null +++ b/docs/changes/CUST-WP-0073/credential-boundary-r1/PROPOSAL.md @@ -0,0 +1,92 @@ +# CUST-WP-0073 credential boundary — source proposal revision 2 + +Status: preparation; no execution authorization. Supervisor: Bernd Worsch. +Assignment: custodian-codex; supervised, autopilot disabled. +Stable proposal ID: CUST73-CREDENTIAL-BOUNDARY-R1. Original source inputs are +pinned in source-pins.json. SHA256SUMS binds the current review packet; original-revision-1.SHA256SUMS +retains the original digest inventory. Revision 2 fixes the negative verifier +after review found aggregate namespace denial insufficient. This is refinement +of the same proposal, not another success or any execution approval. + +This packet narrows the remaining implementation choices. It is not executable +as a complete cutover: exact current host configuration, workstation assignment +launch and approved privileged executor inputs remain owner review gates. + +## Prepared source and owners + +- Platform: identity-rbac.yaml proposes a dedicated ServiceAccount and explicit + GET-only version/health observation allowlist. No raw resource observation is + granted. Workload observations must use sanitized supervisor-returned results. + Issue short-lived credentials through the existing owner custody path; do not + create legacy static ServiceAccount token Secrets. Other existing RBAC bindings + must be tested; this manifest alone cannot prove effective denial. +- Cluster: cluster-bootstrap.patch removes mode 644 and unconditional transfer + of the admin kubeconfig into the controller account. It changes future install + flags to 600 and converges file ownership/mode. The actual source owner is + railiance-cluster, not railiance-enablement. The existing installer has a + creates guard: on an installed node it does NOT update the systemd launch + flags. The owner must capture sanitized effective mode/flag evidence and supply + an exact persistent override/restart diff; applying this patch alone would + permit the old 644 unit flag to restore exposure on restart. Do not run the + broad bootstrap playbook for this narrow cutover. +- Infra: host-identity.candidate.yaml specifies a separate locked-password + custodian-agt account and agt-custodian-codex principal. Use the existing + ssh_ca_host inventory mechanism, never map this principal to tegwick. No sudo, + privileged groups, agent forwarding, port forwarding, user RC/env or raw keys. + Existing agt-task-bridge and agt-interhub-bootstrap mappings have other consumers; + do not remove them blindly. Prove the assignment cannot use their identities. +- Warden: issue only the scoped new SSH principal under its existing issuance + contract after host mapping review. No secret retrieval through Warden. +- Harness: reuse installed f599 and successful revision-3 offline rehearsal; + finish existing native admission gates. The actual interactive session must + enter the restricted profile before any model/tool process can observe the + host account, external connectors or inherited credentials. A child sandbox + inside the current admin-capable parent does not migrate this assignment. + +## Exact cutover packet still required before approval + +Record the concrete host and workstation account IDs; resolved SSH policy; +public principal mapping; sanitized effective sudo/group/ACL decisions; pinned +restricted kubeconfig context and API endpoint; trusted executor identity and +allowlisted exact command digest; issuer TTL/audience; existing credential/file, +agent socket, GitOps and external-tool inventory; safe staging and retirement +order. Bind all of these plus owner reviews and verification/rollback digests +into a new exact revision, preserving this original packet. Do not ask for a +blanket approval of placeholders. + +Kubernetes and SSH identity issuance do not solve workstation credential reach: +old admin kubeconfigs, SSH agent sockets, writable trusted executor/runtime +files, GitOps write credentials, container sockets, OpenBao tokens and inherited +connector authority must all be absent from the actual agent account/profile. +Privileged executor must live outside the same writable account and bind exact +approved action/target/revision; a readable approval JSON is not enforcement. + +## Verification and rollback + +1. Owner renders/reviews manifests and source diff without activation. Check + effective existing RBAC and host override sources before finalizing. +2. Supervisor retains separate attended recovery access, then stages the new + identity. Never remove the last working human admin path. +3. Run verify_agent_denials.py inside the actual new account/profile with its + explicit context and supervisor-supplied complete current namespace inventory. + Inventory JSON requires cluster="railiance01", complete=true, a nonempty unique + namespaces list and supervisor_receipt_ref; its canonical SHA256 is bound in + the result. Its authenticity/completeness must be checked by the supervisor. + Aggregate --all-namespaces checks are retained, with every namespaced permission + also checked in every listed namespace; cluster-scoped privileges are checked + separately. Empty inventory refuses before any command. It refuses the old account/identity, uses only auth reviews + and access checks, suppresses raw command output, and reports partial proof. +4. Supervisor independently verifies sudoers/ACLs, old admin SSH refusal, complete + workstation/external-tool isolation, and positive sanitized execution of one + approved action plus refusal of changed, unapproved, expired and replayed + actions. Bind results to the actual runtime and assignment, not a fixture. +5. Only then document the admitted T02/T04 path. On any incomplete/failed probe, + stop assignment admission, revoke only newly issued identities, and use the + retained human path for recovery. Restore exact predecessor configuration + only under the scoped recovery decision; never silently restore world-readable + admin credentials. Preserve failed receipts. No automatic privilege fallback. + +Cost estimate: no new infrastructure allocation; owner review and one attended +window required. Risk estimate: potential loss of administrative access and +single-node interruption during k3s restart; monetary loss is not quantified. +This is not an autopilot grant. T05 rotation deferral remains unchanged. diff --git a/docs/changes/CUST-WP-0073/credential-boundary-r1/SHA256SUMS b/docs/changes/CUST-WP-0073/credential-boundary-r1/SHA256SUMS new file mode 100644 index 0000000..40a6e05 --- /dev/null +++ b/docs/changes/CUST-WP-0073/credential-boundary-r1/SHA256SUMS @@ -0,0 +1,9 @@ +1d60bf9acf066baff954348159c2213bba14d51131bf17e98d5fe4db165dbd7f PROPOSAL.md +67f71a470f9a877d4f8415af3d627bef16d44c20e5add5d17e30518e327d4ff8 cluster-bootstrap.patch +a8f83d8e1bb5ae65cbdfdab3e710a02f47f395b157c278768700b515ed343f9f host-identity.candidate.yaml +591d893a8f0b1d1b30235900b1a5bd828e01e2163a7bb48d8ba4de963dba80d1 identity-rbac.yaml +aa99050e3c446faf773a60701e5d1215f90ebca66a20edcb95975611e8683a5f original-revision-1.SHA256SUMS +c3815aea14fe0b1ba6e8b5e4b339a82496adb511a2ea587d38c797c9513e86f4 source-pins.json +3077831ba2efdfe53ec643c4a925801850edeecf9f1f52a43d12c3ae923da4e5 test_negative_verifier.py +88da0faf33b532acbec16e5f28acfb87278ec66328effb97ff9404891a818b65 validation.json +7f8cfb9340873acf508072f19e8476603a61a329041e381f0464000f2e5d795f verify_agent_denials.py diff --git a/docs/changes/CUST-WP-0073/credential-boundary-r1/cluster-bootstrap.patch b/docs/changes/CUST-WP-0073/credential-boundary-r1/cluster-bootstrap.patch new file mode 100644 index 0000000..92234c7 --- /dev/null +++ b/docs/changes/CUST-WP-0073/credential-boundary-r1/cluster-bootstrap.patch @@ -0,0 +1,45 @@ +--- a/ansible/bootstrap.yml ++++ b/ansible/bootstrap.yml +@@ -9,7 +9,6 @@ + vars: + k3s_version: "v1.35.1+k3s1" + helm_version: "v3.17.3" +- kubeconfig_local_path: "~/.kube/config-hosteurope" + + tasks: + # ── Base packages ──────────────────────────────────────────────────────── +@@ -27,7 +26,7 @@ + shell: | + curl -sfL https://get.k3s.io | \ + INSTALL_K3S_VERSION="{{ k3s_version }}" \ +- INSTALL_K3S_EXEC="server --cluster-init --write-kubeconfig-mode=644" \ ++ INSTALL_K3S_EXEC="server --cluster-init --write-kubeconfig-mode=600" \ + sh - + args: + creates: /usr/local/bin/k3s +@@ -77,17 +76,11 @@ + + - debug: var=helm_ver.stdout + +- # ── Kubeconfig ─────────────────────────────────────────────────────────── +- - name: Fetch kubeconfig to control node +- fetch: +- src: /etc/rancher/k3s/k3s.yaml +- dest: "{{ kubeconfig_local_path }}" +- flat: yes +- +- - name: Fix server address in fetched kubeconfig +- delegate_to: localhost +- become: false +- replace: +- path: "{{ kubeconfig_local_path }}" +- regexp: 'server: https://127\.0\.0\.1:6443' +- replace: "server: https://{{ ansible_host }}:6443" ++ # Admin kubeconfig stays on the host. Attended administration uses ++ # sudo k3s kubectl; agent identity issuance belongs to railiance-platform. ++ - name: Restrict existing admin kubeconfig permissions ++ ansible.builtin.file: ++ path: /etc/rancher/k3s/k3s.yaml ++ owner: root ++ group: root ++ mode: "0600" diff --git a/docs/changes/CUST-WP-0073/credential-boundary-r1/host-identity.candidate.yaml b/docs/changes/CUST-WP-0073/credential-boundary-r1/host-identity.candidate.yaml new file mode 100644 index 0000000..ac28e7c --- /dev/null +++ b/docs/changes/CUST-WP-0073/credential-boundary-r1/host-identity.candidate.yaml @@ -0,0 +1,25 @@ +# Proposed additions only, not a runnable estate playbook. +account: + name: custodian-agt + password_locked: true + home_mode: '0700' + supplementary_groups: [] + sudo_rules: [] + ssh_certificate_principal: agt-custodian-codex + authorized_keys: [] +ssh_match: + User: custodian-agt + PasswordAuthentication: 'no' + KbdInteractiveAuthentication: 'no' + AuthenticationMethods: publickey + AuthorizedKeysFile: none + AllowAgentForwarding: 'no' + AllowTcpForwarding: 'no' + X11Forwarding: 'no' + PermitTunnel: 'no' + PermitUserRC: 'no' + PermitUserEnvironment: 'no' +principals: + # Add this user to Railiance01 only; never map the new principal to tegwick. + Railiance01: + custodian-agt: [agt-custodian-codex] diff --git a/docs/changes/CUST-WP-0073/credential-boundary-r1/identity-rbac.yaml b/docs/changes/CUST-WP-0073/credential-boundary-r1/identity-rbac.yaml new file mode 100644 index 0000000..9802ef4 --- /dev/null +++ b/docs/changes/CUST-WP-0073/credential-boundary-r1/identity-rbac.yaml @@ -0,0 +1,34 @@ +# REVIEW CANDIDATE ONLY. No credential issuance or activation. +apiVersion: v1 +kind: Namespace +metadata: + name: custodian-agents +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: custodian-codex + namespace: custodian-agents +automountServiceAccountToken: false +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: custodian-supervised-observer +rules: + # Health/version only. No raw workload specs, logs, ConfigMaps or Secrets. + - nonResourceURLs: ["/version", "/healthz", "/livez", "/readyz"] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: custodian-codex-supervised-observer +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: custodian-supervised-observer +subjects: + - kind: ServiceAccount + name: custodian-codex + namespace: custodian-agents diff --git a/docs/changes/CUST-WP-0073/credential-boundary-r1/original-revision-1.SHA256SUMS b/docs/changes/CUST-WP-0073/credential-boundary-r1/original-revision-1.SHA256SUMS new file mode 100644 index 0000000..443489a --- /dev/null +++ b/docs/changes/CUST-WP-0073/credential-boundary-r1/original-revision-1.SHA256SUMS @@ -0,0 +1,8 @@ +e981c883a0d9f0618717d5da9c1c9016845643d8a3fb96f64894877d69e0d2e7 PROPOSAL.md +67f71a470f9a877d4f8415af3d627bef16d44c20e5add5d17e30518e327d4ff8 cluster-bootstrap.patch +a8f83d8e1bb5ae65cbdfdab3e710a02f47f395b157c278768700b515ed343f9f host-identity.candidate.yaml +591d893a8f0b1d1b30235900b1a5bd828e01e2163a7bb48d8ba4de963dba80d1 identity-rbac.yaml +c3815aea14fe0b1ba6e8b5e4b339a82496adb511a2ea587d38c797c9513e86f4 source-pins.json +11a4f287cbe2148db511704f88a04d60416e6b7ece1ee6c4fe02e8e7b39eb155 test_negative_verifier.py +868551696f425366b3053ba16ec505c98ec60dc899b1187b4b9f6ed2f86b51af validation.json +aae332f01e1e788f19fd653b6ed9c850d1bc62e5721cd11cd750c79c68be5e62 verify_agent_denials.py diff --git a/docs/changes/CUST-WP-0073/credential-boundary-r1/source-pins.json b/docs/changes/CUST-WP-0073/credential-boundary-r1/source-pins.json new file mode 100644 index 0000000..f4c62d7 --- /dev/null +++ b/docs/changes/CUST-WP-0073/credential-boundary-r1/source-pins.json @@ -0,0 +1,27 @@ +{ + "railiance-cluster": { + "head": "07ce43f33c6128f4caf1f3251a18b3bf10bab061", + "files": { + "ansible/bootstrap.yml": "82d58148582e33cc195bf4041ce7cb42f7a6a1a562e383d7a7e4b93e0756071d" + } + }, + "railiance-infra": { + "head": "32f64b54fd94a5e6a145ef7c2c8faf051f721a71", + "files": { + "ansible/inventory/ssh_principals.yaml": "4d8577d9c91ee2a0c1fc94944ef633f1866fd0b70c6967def210df991d0bcdd0", + "ansible/roles/ssh_ca_host/tasks/main.yml": "c840ccb53d351ca6a1bb8b7a8406efae4451bb651707b03353f5145aa8f21fd3" + } + }, + "glas-harness": { + "head": "136d532a02b87adcf36213a29478954d9dc5ff7d", + "files": { + "SCOPE.md": "882c972bec13f04d91a9a0cd3e38cc496a491a2fde1b800e617e1fd250ff643c" + } + }, + "rein-aharness": { + "head": "eca0a5891c78fbda2422b39d4a594cec1704c860", + "files": { + "workplans/REINAH-WP-0008-simpler-metered-retry.md": "1c7a9f3ef10fb0d945e7ca8c473377bcf778fe08a404d7b87db90e529fc541a3" + } + } +} diff --git a/docs/changes/CUST-WP-0073/credential-boundary-r1/test_negative_verifier.py b/docs/changes/CUST-WP-0073/credential-boundary-r1/test_negative_verifier.py new file mode 100644 index 0000000..c8ce82d --- /dev/null +++ b/docs/changes/CUST-WP-0073/credential-boundary-r1/test_negative_verifier.py @@ -0,0 +1,71 @@ +import importlib.util +import json +from pathlib import Path +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('verifier', Path(__file__).with_name('verify_agent_denials.py')) +v = importlib.util.module_from_spec(spec) +spec.loader.exec_module(v) + + +INVENTORY = {'cluster': 'railiance01', 'namespaces': ['default', 'sso'], 'complete': True, 'supervisor_receipt_ref': 'synthetic-test-only'} + + +class VerificationTest(unittest.TestCase): + def test_old_account_refuses_before_any_command(self): + with patch.object(v.os, 'geteuid', return_value=0), patch.object(v, 'run') as run: + self.assertFalse(v.probe('/kubectl', 'agent', 'custodian-agt', INVENTORY)['passed']) + run.assert_not_called() + + def simulate(self, identity, denied_code=1, denied_output=b'no', allow_sso=False): + payload = json.dumps({'status': {'userInfo': identity}}).encode() + def response(argv): + if 'whoami' in argv: + return 0, payload + if allow_sso and '--namespace' in argv and argv[-1] == 'sso' and 'secrets' in argv: + return 0, b'yes' + return denied_code, denied_output + with patch.object(v.os, 'geteuid', return_value=1234), \ + patch.object(v.pwd, 'getpwuid', return_value=SimpleNamespace(pw_name='custodian-agt')), \ + patch.object(v.os, 'getgroups', return_value=[1234]), \ + patch.object(v.os, 'getegid', return_value=1234), \ + patch.object(v.grp, 'getgrgid', return_value=SimpleNamespace(gr_name='custodian-agt')), \ + patch.object(v.os, 'access', return_value=False), \ + patch.object(v, 'run', side_effect=response): + return v.probe('/kubectl', 'agent', 'custodian-agt', INVENTORY) + + def test_admin_membership_refuses(self): + result = self.simulate({'username': v.IDENTITY, 'groups': ['system:masters']}) + self.assertEqual(result['status'], 'refused_wrong_identity') + + def test_timeout_is_not_denial(self): + result = self.simulate({'username': v.IDENTITY}, None, b'') + self.assertFalse(result['passed']) + + def test_forbidden_text_is_not_valid_denial(self): + result = self.simulate({'username': v.IDENTITY}, 1, b'Forbidden secret material') + self.assertFalse(result['passed']) + self.assertNotIn('secret material', json.dumps(result)) + + def test_namespaced_allow_fails_even_when_all_namespace_query_would_deny(self): + result = self.simulate({'username': v.IDENTITY}, allow_sso=True) + self.assertFalse(result['passed']) + self.assertFalse(result['checks']['sso:get:secrets']) + + def test_empty_inventory_refuses_before_commands(self): + with patch.object(v, 'run') as run: + result = v.probe('/kubectl', 'agent', 'custodian-agt', {**INVENTORY, 'namespaces': []}) + self.assertFalse(result['passed']) + run.assert_not_called() + + def test_negative_success_cannot_admit_assignment(self): + result = self.simulate({'username': v.IDENTITY}) + self.assertTrue(result['passed']) + self.assertFalse(result['assignment_admitted']) + self.assertTrue(result['remaining']) + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/changes/CUST-WP-0073/credential-boundary-r1/validation.json b/docs/changes/CUST-WP-0073/credential-boundary-r1/validation.json new file mode 100644 index 0000000..508d9e4 --- /dev/null +++ b/docs/changes/CUST-WP-0073/credential-boundary-r1/validation.json @@ -0,0 +1,9 @@ +{ + "source_patch_applies": true, + "negative_verifier_tests": 7, + "live_probes": false, + "live_mutations": false, + "assignment_admitted": false, + "proposal_revision": 2, + "retained_refinement": "Revision1 aggregate denial insufficient for namespace RoleBinding; no live use; revision2 adds complete inventoried per-namespace and separate cluster probes." +} diff --git a/docs/changes/CUST-WP-0073/credential-boundary-r1/verify_agent_denials.py b/docs/changes/CUST-WP-0073/credential-boundary-r1/verify_agent_denials.py new file mode 100644 index 0000000..f3c5c89 --- /dev/null +++ b/docs/changes/CUST-WP-0073/credential-boundary-r1/verify_agent_denials.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Read-only actual-account probes. Never prints command output or credentials.""" +import argparse +import grp +import hashlib +import re +import json +import os +import pwd +import subprocess + +IDENTITY = 'system:serviceaccount:custodian-agents:custodian-codex' +DENIALS = [ + ('get', 'secrets'), ('list', 'secrets'), ('watch', 'secrets'), + ('get', 'configmaps'), ('get', 'pods'), ('get', 'pods/log'), + ('create', 'pods/exec'), ('create', 'pods/attach'), ('create', 'pods'), + ('patch', 'deployments.apps'), ('create', 'serviceaccounts/token'), + ('patch', 'applications.argoproj.io'), +] + +CLUSTER_DENIALS = [('impersonate', 'users'), + ('bind', 'clusterroles.rbac.authorization.k8s.io'), + ('escalate', 'clusterroles.rbac.authorization.k8s.io')] + + +def inventory_valid(inventory): + if not isinstance(inventory, dict): + return False + namespaces = inventory.get('namespaces') + return (inventory.get('cluster') == 'railiance01' + and inventory.get('complete') is True + and isinstance(inventory.get('supervisor_receipt_ref'), str) + and bool(inventory['supervisor_receipt_ref'].strip()) + and isinstance(namespaces, list) and bool(namespaces) + and all(isinstance(n, str) and len(n) <= 63 + and re.fullmatch(r'[a-z0-9](?:[a-z0-9-]*[a-z0-9])?', n) + for n in namespaces) + and len(set(namespaces)) == len(namespaces)) + + +def run(argv): + try: + p = subprocess.run(argv, capture_output=True, timeout=20, check=False) + return p.returncode, p.stdout + except (OSError, subprocess.TimeoutExpired): + return None, b'' + + +def probe(kubectl, context, account, inventory): + if not inventory_valid(inventory): + return {"status": "refused_namespace_inventory", "passed": False} + # Abort before even auth-only Kubernetes requests under the old account. + if os.geteuid() == 0 or pwd.getpwuid(os.geteuid()).pw_name != account: + return {'status': 'refused_wrong_account', 'passed': False} + base = [kubectl, '--context', context, '--request-timeout=10s', 'auth'] + code, output = run(base + ['whoami', '-o', 'json']) + try: + info = json.loads(output)['status']['userInfo'] + identity = code == 0 and info['username'] == IDENTITY and 'system:masters' not in info.get('groups', []) + except (KeyError, TypeError, ValueError): + identity = False + if not identity: + return {'status': 'refused_wrong_identity', 'passed': False} + checks = {} + for verb, resource in DENIALS: + code, output = run(base + ['can-i', verb, resource, '--all-namespaces']) + checks[f'all-namespaces:{verb}:{resource}'] = code == 1 and output.strip() == b'no' + # An all-namespaces denial can coexist with a namespaced RoleBinding grant. + # Probe each namespace supplied in the separately supervised complete inventory. + for namespace in inventory['namespaces']: + for verb, resource in DENIALS: + code, output = run(base + ['can-i', verb, resource, '--namespace', namespace]) + checks[f'{namespace}:{verb}:{resource}'] = code == 1 and output.strip() == b'no' + for verb, resource in CLUSTER_DENIALS: + code, output = run(base + ['can-i', verb, resource]) + checks[f'cluster:{verb}:{resource}'] = code == 1 and output.strip() == b'no' + dangerous = {'sudo', 'wheel', 'docker', 'lxd', 'incus-admin', 'disk', 'libvirt'} + groups = {grp.getgrgid(gid).gr_name for gid in set(os.getgroups()) | {os.getegid()}} + checks['no_privileged_groups'] = not (groups & dangerous) + checks['host_admin_kubeconfig_unreadable'] = not os.access('/etc/rancher/k3s/k3s.yaml', os.R_OK) + checks['legacy_admin_home_unsearchable'] = not os.access('/home/tegwick', os.X_OK) + checks['root_home_unsearchable'] = not os.access('/root', os.X_OK) + for label, path in [('docker', '/var/run/docker.sock'), ('containerd', '/run/k3s/containerd/containerd.sock')]: + checks[f'{label}_socket_not_writable'] = not os.access(path, os.W_OK) + # This is a partial negative proof, never assignment admission on its own. + return {'status': 'negative_probes_passed' if all(checks.values()) else 'negative_probes_failed', + 'passed': all(checks.values()), 'checks': checks, 'assignment_admitted': False, + 'namespace_inventory_sha256': hashlib.sha256(json.dumps(inventory, sort_keys=True, separators=(',', ':')).encode()).hexdigest(), + 'namespace_count': len(inventory['namespaces']), + 'supervisor_receipt_ref': inventory['supervisor_receipt_ref'], + 'remaining': ['supervisor_verified_complete_current_namespace_inventory', 'supervisor_verified_sudoers_and_ACLs', 'all_external_tool_and_credential_paths', + 'old_admin_SSH_denial', 'exact_approved_action_and_changed_action_refusal', + 'actual_interactive_runtime_admission']} + + +if __name__ == '__main__': + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--kubectl', default='/usr/local/bin/kubectl') + p.add_argument('--context', required=True) + p.add_argument('--namespace-inventory', required=True, help='Supervisor-supplied complete current namespace inventory JSON') + p.add_argument('--account', default='custodian-agt', choices=['custodian-agt']) + a = p.parse_args() + try: + with open(a.namespace_inventory, encoding='utf-8') as stream: + inventory = json.load(stream) + except (OSError, ValueError): + inventory = None + receipt = probe(a.kubectl, a.context, a.account, inventory) + print(json.dumps(receipt, sort_keys=True)) + raise SystemExit(0 if receipt['passed'] else 1) diff --git a/docs/staged-work-record-generation.md b/docs/staged-work-record-generation.md index e6ef735..fccd72b 100644 --- a/docs/staged-work-record-generation.md +++ b/docs/staged-work-record-generation.md @@ -33,10 +33,33 @@ reuse a different owner checkout. The adapter does not duplicate the parser or call the auto-publishing consistency CLI. It retains the owner's generated-file format, including its existing regeneration attribution. -This implementation has not been activated in shared hooks or adopted across -the fleet. A future reviewed pre-commit integration must run generation beside -the existing generated-only guard and secret scan, retain their rejection -behavior, and preserve unrelated hooks and changes. The existing guard still -rejects generated-only commits. Source inventories and publication indexes +The shared repo-manager hook and Custodian local hook now run the existing +generated-only guard first, then the opt-in generator, then their existing secret +scan. Activity Core, Policy Nexus and Repo Manager have committed opt-ins; +Custodian source includes its opt-in. The guard still rejects generated-only +commits. Other repositories without a staged opt-in keep their prior behavior. + +The tracked `.generated-work-records.json` supplies version 1 and canonical +`repo_slug`. Its dedicated `.generated-work-records.mk` avoids loading unrelated +Makefiles or environment-file includes. The hook verifies that both files match +the staged blobs before invoking the fixed target, with Make override variables +removed from its environment. The recipe is reviewed, trusted repository source; +this is not a sandbox for arbitrary untrusted Make code. + +`git commit --only` and other alternate-index commits must not need generation: +Git otherwise retains a stale normal index after a hook changes the temporary +commit index. The adapter refuses such writes before mutation. Stage the selected +sources and run `make generated-work-records` first, then include both those +source paths and `WORK-RECORDS.md` in `git commit --only`. A regression test proves +that unrelated staged briefs remain staged and the generated index is coherent. + +Upgrade the exact reviewed secret-scan hook composition reproducibly with: + +```bash +python3 scripts/install_shared_generated_guard.py --repo . --install --enable-generation +``` + +Unknown hook compositions are refused; existing other hooks and routing remain +unchanged. The option does not opt any repository into generation on its own. Source inventories and publication indexes retain their respective owner generators; this adapter generates only the work-record index. Fleet adoption remains an open CUST-WP-0079-T05 obligation. diff --git a/docs/weekly-allocation-report.md b/docs/weekly-allocation-report.md new file mode 100644 index 0000000..bb232e6 --- /dev/null +++ b/docs/weekly-allocation-report.md @@ -0,0 +1,110 @@ +# Retained weekly allocation assessment + +`scripts/weekly_allocation_report.py` implements the offline evidence join for +CUST-WP-0071-T05. It reads the existing cluster collector, Custodian allocation +reconcile, Prometheus query-response capture and resource-control inventory. +It performs no collection, deployment, network calls, task writes or delivery. +It is suitable for an accepted durable executor; it does not install one. + +## Inputs and retained evidence + +The required inputs are: + +- `--cluster`: `railiance.cluster-resource-observation.v1`, produced by + railiance-cluster's `make cluster-observe REMOTE=railiance01`. +- `--allocation`: `custodian.allocation-reconcile.v1` from + `scripts/reconcile_railiance_allocation.py`, matching the same capture and + workload inventory. Run both against the same retained observation. +- `--telemetry`: the existing September 28 query-capture shape: `captured_at`, + `window: 7d`, `step: 5m`, and `queries` mapping each metric name to its exact + `promql` and Prometheus `response`. Preserve original sample evaluation times; + do not restamp historical query results. These describe the query window, + not independently verified raw scrape completeness. +- `--inventory`: resource-control's `data/resources/reef-railiance-k3s.json`. + Dated portfolio capacity is retained as portfolio context, never summed with + the live cluster or treated as current scheduler evidence. +- `--provenance`: exact source collector revisions and `source_observation` + matching the cluster capture, as in the September 28 allocation provenance. +- `--as-of`: explicit evaluation timestamp; identical retries use the same value. +- `--output`: a new retained JSON path. Exact identical retries succeed; changed + content at that path fails and requires an explicit revision path. Writes use + a complete temporary file and atomic create-without-replacement publication. + +Optional `--previous` selects the previous retained report. Deltas require the +same resource, exact query texts, sample step, seven-day window and consecutive +capture endpoints within one sample step. Other comparisons stay unknown; the +report still records disappeared workloads. Recommendation identity depends on +resource/namespace/workload/review purpose, not the date or recommendation text. +Existing receiving-task links survive retries and subsequent reports. + +Metric names are `cpu_p95_m`, `cpu_peak_m`, `memory_p95_bytes`, +`memory_peak_bytes`, `cpu_throttle_ratio`, `latency_p95_seconds`, `errors`, +`request_count`, `restarts`, `oom_events`, `cpu_sample_count` and +`memory_sample_count`. Metrics are nonnegative, finite instant-vector aggregates. +Workload queries must carry exactly `namespace` and `workload` identity labels +(an optional Prometheus `__name__` is allowed). Extra labels or duplicate series +are not silently aggregated. Namespace-only results are retained separately; +they never become per-workload values, even for a currently single-workload +namespace. Namespace peaks and percentiles are not additive. + +Missing series, failed queries and non-finite values remain null with explicit +reasons. Observed zero remains zero. Evaluation counts never prove raw scrape +coverage. CPU/memory limits and instantaneous observations come from the cluster +snapshot; historical latency, request/errors, OOM or throttling are not inferred +from that snapshot. Unknown operating margin remains null; reservation residual +does not establish processing capacity or release acceptance. + +## Owner decisions + +Without a complete current owner review the result is `investigate`, with low +confidence and explicit evidence gaps. `--reviews` accepts a JSON mapping keyed +by the stable recommendation ID. A review provides `decision` (keep, increase, +decrease or investigate), `owner`, `period_end` matching telemetry capture, +`reviewed_at`, `evidence_refs`, `receiving_task`, `configuration`, `rollback_ref`, +`coverage_ref`, `service_acceptance_ref`, and `margin_ref`. Optional `outcome` +records what happened to the preceding recommendation. These references are +owner assertions to be checked during acceptance, not proof generated by this +adapter. Wrong-owner/window reviews, missing workload metrics, zero CPU requests, +unknown owners and stale/future evidence keep the result at investigate and +retain the proposal for review. No idle sample automatically recommends a cut. + +Only new observation evidence and verified owner disposition can complete T02–T04. +In particular, the historical Vergabe sample does not establish the attended +two-user p95/error acceptance target or incremental database attribution. + +## Disabled execution contract + +The owner-source candidate is +`activity-core/activity-definitions/weekly-railiance-allocation-review.md`. +Its fixed command is: + +```sh +python3 scripts/weekly_allocation_report.py --request config/weekly-allocation-request.json +``` + +The request is intentionally unbound and the command fails until the accepted +executor supplies real input paths, a fixed `as_of`, and a durable output path. +Paths resolve relative to the request file. The executor must snapshot this +request per run, keep it stable for retries, bind a real previous report, and +retain reports beyond the seven-day raw-metric retention. The committed null +template is not a production storage location or collection implementation. + +Activity-core must select a scoped durable Railiance executor/profile before +projection or activation; no workstation profile is asserted here. It owns the +Monday 08:00 Europe/Berlin schedule and operations-run retry/lease evidence. +The adapter's matching recommendation IDs are the downstream upsert keys for +existing owner work records; it does not itself create those records. Delivery, +explicit owner acknowledgment, independent missed/failed-run detection and +recovery remain unbound. A report file or successful ops_run alone proves none +of them. T05 needs the first unattended schedule receipt and exercised recovery. + +## Verification + +```sh +python3 -m unittest discover -s tests -p test_weekly_allocation_report.py -v +``` + +The tests cover historical namespace-only input, missing/zero/NaN, invalid +aggregation, query failure, compatible/incompatible periods, stable identities +and owner-task links, stale/future evidence, owner review, resource/capture +mismatch and retained retry/collision behavior. diff --git a/scripts/check_generated_only_commit.py b/scripts/check_generated_only_commit.py index f92c38b..5ba32ec 100644 --- a/scripts/check_generated_only_commit.py +++ b/scripts/check_generated_only_commit.py @@ -14,7 +14,10 @@ def generated(path): name = parsed.name return (name in {"work-records.md", ".custodian-brief.md", "source-index.md", "publication-index.md", "index.generated.md"} - or name.startswith("source-inventory") + # The root config is authored generator input, not its output. + # Keep similarly named evidence and generated-directory copies gated. + or (name.startswith("source-inventory") + and str(parsed) != "source-inventory.config.json") or name.startswith("decision-packets") or name.startswith("publication-index") or name.startswith("projection-refresh") or str(parsed).startswith("generated/") diff --git a/scripts/generate_staged_work_records.py b/scripts/generate_staged_work_records.py index e20714d..0475a4f 100644 --- a/scripts/generate_staged_work_records.py +++ b/scripts/generate_staged_work_records.py @@ -5,6 +5,7 @@ Uses the existing State Hub renderer. No hub access, commit or push. Intended for a Make target invoked by pre-commit, before the unchanged generated guard. """ import argparse +import os from pathlib import Path import re import subprocess @@ -75,6 +76,14 @@ def generate(repo, renderer_root, *, repo_slug, check=False): return False if check: raise ValueError('staged WORK-RECORDS.md is stale') + index_env = os.environ.get('GIT_INDEX_FILE') + if index_env: + selected = Path(index_env) + if not selected.is_absolute(): + selected = repo / selected + normal = Path(git(repo, 'rev-parse', '--absolute-git-dir').decode().strip()) / 'index' + if selected.resolve() != normal.resolve(): + raise ValueError('alternate index/partial commit needs regeneration; stage selected sources, run make generated-work-records, then include WORK-RECORDS.md in the partial commit') if staged_entries != git(repo, 'ls-files', '--stage', '-z'): raise ValueError('Git index changed during rendering; retry') if (destination.read_bytes() if destination.exists() else None) != existing: diff --git a/scripts/generated-pre-commit.sh b/scripts/generated-pre-commit.sh index a8266a8..25e31c0 100644 --- a/scripts/generated-pre-commit.sh +++ b/scripts/generated-pre-commit.sh @@ -2,4 +2,5 @@ # Install as .git/hooks/pre-commit; rejects generated/evidence-only auto commits too. set -eu REPO_ROOT="$(git rev-parse --show-toplevel)" -exec python3 "$REPO_ROOT/scripts/check_generated_only_commit.py" --staged +python3 "$REPO_ROOT/scripts/check_generated_only_commit.py" --staged +exec python3 "$REPO_ROOT/scripts/run_generated_work_records_hook.py" diff --git a/scripts/install_shared_generated_guard.py b/scripts/install_shared_generated_guard.py index 4f9812a..0d9c1bb 100644 --- a/scripts/install_shared_generated_guard.py +++ b/scripts/install_shared_generated_guard.py @@ -17,10 +17,12 @@ import subprocess # CUST-WP-0080 / RMGR-WP-0021; unknown hooks still require owner integration. SCANNED_HOOK_SHA256 = "d50cf763dc9814383c6886e30b6650bbbdcb49ddd78889082e3fda3626a2cbfd" +ADOPTED_HOOK_SHA256 = "1a8a8b2a54092311a355a3a51cf6a696bd05e146f2f4a25455ae5c30c502929d" + CHECKER = Path(__file__).with_name("check_generated_only_commit.py").resolve() -def install(repo, execute=False): +def install(repo, execute=False, enable_generation=False): repo = Path(repo).resolve() configured = subprocess.check_output( ["git", "-C", str(repo), "config", "--get", "core.hooksPath"], text=True).strip() @@ -36,9 +38,25 @@ def install(repo, execute=False): raise ValueError("refuse redirected existing pre-commit hook") if target.exists(): if (target.read_text() != content - and hashlib.sha256(target.read_bytes()).hexdigest() != SCANNED_HOOK_SHA256): + and hashlib.sha256(target.read_bytes()).hexdigest() not in {SCANNED_HOOK_SHA256, ADOPTED_HOOK_SHA256}): raise ValueError("existing pre-commit hook differs; owner integration required") - return {"repo": str(repo), "hook": str(target), "status": "installed", "changed": False} + changed = False + if enable_generation and hashlib.sha256(target.read_bytes()).hexdigest() == SCANNED_HOOK_SHA256: + marker = "python3 " + shlex.quote(str(CHECKER)) + " --staged\n" + existing = target.read_text() + if existing.count(marker) != 1: + raise ValueError("reviewed hook checker path differs") + revised = existing.replace(marker, marker + "python3 " + shlex.quote(str(CHECKER.with_name("run_generated_work_records_hook.py"))) + "\n") + if hashlib.sha256(revised.encode()).hexdigest() != ADOPTED_HOOK_SHA256: + raise ValueError("adopted composition differs from reviewed hook") + if execute: + fd = os.open(target, os.O_WRONLY | os.O_TRUNC | os.O_NOFOLLOW) + with os.fdopen(fd, "w") as handle: + handle.write(revised) + handle.flush() + os.fsync(handle.fileno()) + changed = True + return {"repo": str(repo), "hook": str(target), "status": "installed", "changed": changed} if execute: fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o755) with os.fdopen(fd, "w") as handle: @@ -53,10 +71,14 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--repo", type=Path, required=True) parser.add_argument("--install", action="store_true") + parser.add_argument("--enable-generation", action="store_true", + help="upgrade only the reviewed scanner composition with opt-in generation") args = parser.parse_args() import json try: - print(json.dumps(install(args.repo, args.install), sort_keys=True)) + print(json.dumps(install(args.repo, args.install, args.enable_generation), sort_keys=True)) + except PermissionError: + parser.exit(1, "Permission denied updating the reviewed hook; rerun through the permitted filesystem path.\n") except (ValueError, OSError, subprocess.SubprocessError): parser.exit(1, "Shared hook unverified; existing files and routing are not replaced.\n") diff --git a/scripts/run_generated_work_records_hook.py b/scripts/run_generated_work_records_hook.py new file mode 100644 index 0000000..3e9db3a --- /dev/null +++ b/scripts/run_generated_work_records_hook.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Run explicitly adopted staged generation without executing unstaged Make input.""" +import json +from pathlib import Path +import re +import subprocess +import sys + +CONFIG = '.generated-work-records.json' + + +def run(repo): + repo = Path(repo) + config = subprocess.run(['git', '-C', str(repo), 'show', ':' + CONFIG], capture_output=True) + if config.returncode: + # An unstaged opt-in never changes hook behavior. + return False + record = json.loads(config.stdout) + if (set(record) != {'version', 'repo_slug'} or record['version'] != 1 + or not isinstance(record['repo_slug'], str) + or not re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*', record['repo_slug'])): + raise ValueError('invalid generated-work-record opt-in') + for name in (CONFIG, '.generated-work-records.mk'): + path = repo / name + staged = subprocess.check_output(['git', '-C', str(repo), 'show', ':' + name]) + if path.is_symlink() or path.read_bytes() != staged: + raise ValueError(f'{name} has unstaged/redirected content; refusing hook execution') + # Make includes and environment overrides could run unstaged recipe inputs. + # Only invoke the reviewed target without implicit makefiles or user MAKEFLAGS. + import os + env = {k: v for k, v in os.environ.items() if k not in ('MAKEFILES', 'MAKEFLAGS', 'MFLAGS', 'GNUMAKEFLAGS')} + subprocess.run(['make', '--no-print-directory', '-f', '.generated-work-records.mk', + 'generated-work-records', 'GENERATED_REPO_SLUG=' + record['repo_slug'], + 'CUSTODIAN_SOURCE=' + str(Path(__file__).resolve().parents[1])], + cwd=repo, env=env, check=True) + return True + + +def main(): + try: + root = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], text=True).strip() + run(root) + except (ValueError, OSError, subprocess.SubprocessError) as exc: + sys.exit(str(exc)) + + +if __name__ == '__main__': + main() diff --git a/scripts/weekly_allocation_report.py b/scripts/weekly_allocation_report.py new file mode 100644 index 0000000..4917a23 --- /dev/null +++ b/scripts/weekly_allocation_report.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""Retain CUST-WP-0071 sizing evidence; no network, resizing or task emission.""" +from __future__ import annotations + +import argparse +from datetime import datetime, timedelta, timezone +import hashlib +import json +import math +import os +from pathlib import Path +import re +import tempfile + +SCHEMA = "custodian.weekly-allocation.v1" +METRICS = ( + "cpu_p95_m", "cpu_peak_m", "memory_p95_bytes", "memory_peak_bytes", + "cpu_throttle_ratio", "latency_p95_seconds", "errors", "request_count", + "restarts", "oom_events", "cpu_sample_count", "memory_sample_count", +) +REQUIRED = tuple(m for m in METRICS if not m.endswith("sample_count")) + + +def timestamp(value): + result = datetime.fromisoformat(value.replace("Z", "+00:00")) + if result.tzinfo is None: + raise ValueError("timestamps must include timezone") + return result.astimezone(timezone.utc) + + +def duration(value): + match = re.fullmatch(r"([1-9][0-9]*)([smhdw])", value) + if not match: + raise ValueError(f"unsupported duration: {value}") + return int(match[1]) * {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}[match[2]] + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, allow_nan=False).encode()).hexdigest() + + +def finite(value): + number = float(value) + if not math.isfinite(number) or number < 0: + raise ValueError("metric must be finite and nonnegative") + return number + + +def metric_series(telemetry): + """Do not distribute namespace aggregates over workloads, or sum percentiles.""" + series, queries = {}, {} + seconds = duration(telemetry["window"]) + for name, query in sorted(telemetry.get("queries", {}).items()): + response = query.get("response", {}) + data = response.get("data", {}) + queries[name] = { + "promql": query.get("promql"), "response_status": response.get("status"), + "result_type": data.get("resultType"), "warnings": response.get("warnings", []), + "error": response.get("error"), "response_sha256": digest(response), + "evaluation_times": [], + } + if name not in METRICS or response.get("status") != "success" or data.get("resultType") != "vector": + continue + for sample in data.get("result", []): + labels = sample.get("metric", {}) + namespace, workload = labels.get("namespace"), labels.get("workload") + # Additional dimensions are ambiguous: do not silently collapse pod/job series. + if not namespace or set(labels) - {"namespace", "workload", "__name__"}: + queries[name].setdefault("rejected", []).append("unaggregated or absent identity labels") + continue + key = (namespace, workload) + values = series.setdefault(key, {}) + if name in values: + values[name] = {"value": None, "reason": "ambiguous duplicate series"} + continue + try: + epoch, raw = sample["value"] + end = datetime.fromtimestamp(finite(epoch), timezone.utc) + number = finite(raw) + except (KeyError, ValueError, TypeError, OverflowError): + values[name] = {"value": None, "reason": "invalid sample"} + continue + queries[name]["evaluation_times"].append(end.isoformat()) + values[name] = { + "value": number, "window_start": (end - timedelta(seconds=seconds)).isoformat(), + "window_end": end.isoformat(), "query": name, + "capture_alignment_seconds": (end - timestamp(telemetry["captured_at"])).total_seconds(), + } + return series, queries + + +def recommendation_id(resource, namespace, workload): + return "allocation-" + digest([resource, namespace, workload, "sizing-review"])[:24] + + +def build_report(cluster, allocation, telemetry, inventory, provenance, *, as_of, + previous=None, reviews=None, max_age_hours=48): + now = timestamp(as_of) + if cluster.get("schema_version") != "railiance.cluster-resource-observation.v1": + raise ValueError("unsupported cluster schema") + if allocation.get("schema") != "custodian.allocation-reconcile.v1": + raise ValueError("unsupported allocation schema") + resource = cluster["resource_id"] + if inventory.get("id") != resource or allocation.get("host", {}).get("cluster_resource_id") != resource: + raise ValueError("resource identity mismatch") + if allocation.get("count_host_and_cluster_cpus_once") is not True: + raise ValueError("allocation must deduplicate host and cluster CPUs") + if cluster["captured_at"] != allocation["captured_at"]: + raise ValueError("cluster and reconcile snapshots differ") + if provenance.get("source_observation") != cluster["captured_at"]: + raise ValueError("provenance does not identify cluster snapshot") + seconds = duration(telemetry["window"]) + if seconds != 604800: + raise ValueError("weekly report requires a seven-day telemetry window") + step = duration(telemetry["step"]) + series, queries = metric_series(telemetry) + captured = {"cluster": cluster["captured_at"], "telemetry": telemetry["captured_at"]} + freshness = {} + for name, value in captured.items(): + age = (now - timestamp(value)).total_seconds() + freshness[name] = {"age_seconds": age, "status": "future" if age < 0 else "stale" if age > max_age_hours * 3600 else "fresh"} + query_signature = digest({name: q["promql"] for name, q in queries.items()}) + end = timestamp(telemetry["captured_at"]) + comparison_reason = "no previous retained report" + compatible = False + if previous: + prev_end = timestamp(previous["period"]["capture_end"]) + compatible = ( + previous.get("schema") == SCHEMA and previous.get("resource_id") == resource + and previous["period"].get("seconds") == seconds + and previous["period"].get("step_seconds") == step + and previous.get("query_signature") == query_signature + and abs((end - prev_end).total_seconds() - seconds) <= step + ) + comparison_reason = "comparable consecutive capture windows" if compatible else "nonconsecutive windows or changed query/resource/step contract" + previous_rows = {(r["namespace"], r["workload"]): r for r in (previous or {}).get("workloads", [])} + owners = {(r["namespace"], r["workload"]): r for r in allocation.get("workloads", [])} + if len(owners) != len(allocation.get("workloads", [])): + raise ValueError("duplicate allocation workload identity") + reviews = reviews or {} + rows, recommendations = [], [] + seen = set() + for workload in sorted(cluster.get("utilization", {}).get("workloads", []), key=lambda x: (x["namespace"], x["workload"])): + key = workload["namespace"], workload["workload"] + if key in seen: + raise ValueError("duplicate cluster workload identity") + seen.add(key) + owner = owners.get(key, {}) + measurements = {m: series.get(key, {}).get(m, {"value": None, "reason": "no workload series"}) for m in METRICS} + gaps = [m for m in REQUIRED if measurements[m]["value"] is None] + metric_stale = [m for m, v in measurements.items() if v["value"] is not None and not 0 <= (now - timestamp(v["window_end"])).total_seconds() <= max_age_hours * 3600] + metric_unaligned = [m for m, v in measurements.items() if v["value"] is not None and abs(v["capture_alignment_seconds"]) > step] + rid = recommendation_id(resource, *key) + reasons = [] + if gaps: + reasons.append("missing workload metrics: " + ", ".join(gaps)) + if metric_stale or any(v["status"] != "fresh" for v in freshness.values()): + reasons.append("stale or future observation") + if metric_unaligned: + reasons.append("metric evaluation windows differ from capture by more than one step") + if owner.get("owner", "unknown") == "unknown": + reasons.append("unknown owner") + if workload.get("requests", {}).get("cpu_millicores") == 0: + reasons.append("zero CPU request requires owner review") + review = reviews.get(rid) + decision, confidence, evidence = "investigate", "low", [] + if review: + if review.get("decision") not in {"keep", "increase", "decrease", "investigate"}: + raise ValueError("invalid owner review decision") + required = ("owner", "evidence_refs", "receiving_task", "configuration", "rollback_ref", "reviewed_at", "period_end", "coverage_ref", "service_acceptance_ref", "margin_ref") + if any(not review.get(k) for k in required): + raise ValueError("owner review missing evidence, receiving task or configuration/rollback") + if review["owner"] != owner.get("owner") or review["period_end"] != telemetry["captured_at"]: + reasons.append("owner review does not match this owner/window") + elif timestamp(review["reviewed_at"]) > now: + reasons.append("owner review is future-dated") + elif not reasons: + decision, confidence, evidence = review["decision"], "owner-reviewed", review["evidence_refs"] + else: + reasons.append("owner proposal retained; incomplete evidence prevents accepted sizing recommendation") + else: + reasons.append("no owner-reviewed sizing decision") + row = { + "namespace": key[0], "workload": key[1], "owner": owner.get("owner", "unknown"), + "service": owner.get("service", "unknown"), "tenant": owner.get("tenant", "unknown"), + "pods": workload.get("pods"), "requests": workload.get("requests"), + "limits": workload.get("limits"), "instantaneous_observed": workload.get("observed"), + "metrics": measurements, "missing_metrics": gaps, + "namespace_context_ref": key[0] if (key[0], None) in series else None, + "raw_scrape_coverage": "unknown; evaluation counts do not prove source coverage", + "recommendation_id": rid, "week_on_week": {}, "comparison_reasons": {}, + } + prior = previous_rows.get(key) + for metric in METRICS: + current = measurements[metric]["value"] + old_measurement = (prior or {}).get("metrics", {}).get(metric, {}) + old = old_measurement.get("value") + metric_compatible = False + reason = comparison_reason + if compatible and current is not None and old is not None: + try: + old_end, old_start = timestamp(old_measurement["window_end"]), timestamp(old_measurement["window_start"]) + new_end, new_start = timestamp(measurements[metric]["window_end"]), timestamp(measurements[metric]["window_start"]) + metric_compatible = ( + (old_end - old_start).total_seconds() == seconds + and (new_end - new_start).total_seconds() == seconds + and abs((new_end - old_end).total_seconds() - seconds) <= step + and abs((new_start - old_end).total_seconds()) <= step + and abs(measurements[metric]["capture_alignment_seconds"]) <= step + and abs((old_end - timestamp(previous["period"]["capture_end"])).total_seconds()) <= step + ) + reason = "comparable consecutive metric windows" if metric_compatible else "metric evaluation windows are not consecutive or capture-aligned" + except (KeyError, ValueError, TypeError): + reason = "missing or invalid previous metric window" + elif compatible: + reason = "missing current or previous metric" + row["week_on_week"][metric] = current - old if metric_compatible else None + row["comparison_reasons"][metric] = reason + rows.append(row) + prior_recommendation = next((r for r in (previous or {}).get("recommendations", []) if r["id"] == rid), {}) + recommendations.append({ + "id": rid, "decision": decision, "confidence": confidence, "owner": row["owner"], + "reasons": reasons, "evidence_refs": evidence, "owner_proposal": review, + "receiving_task": review.get("receiving_task") if review else prior_recommendation.get("receiving_task"), + "previous_decision": prior_recommendation.get("decision"), + "owner_outcome": review.get("outcome") if review else None, + "delivery": "not emitted", "acknowledgment": None, + }) + if seen != set(owners): + raise ValueError("cluster and allocation workload inventories differ") + return { + "schema": SCHEMA, "workplan": "CUST-WP-0071-T05", "as_of": as_of, + "adapter_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + "resource_id": resource, "period": {"capture_end": telemetry["captured_at"], "seconds": seconds, "step_seconds": step, + "note": "Actual per-query windows are retained on samples; capture time is not a common query evaluation time."}, + "freshness": freshness, "max_age_hours": max_age_hours, "query_signature": query_signature, + "queries": queries, "collector_provenance": provenance, + "source_sha256": {"cluster": digest(cluster), "allocation": digest(allocation), "telemetry": digest(telemetry), "inventory": digest(inventory)}, + "node": {"capacity": cluster.get("capacity"), "allocation": cluster.get("allocation"), + "scheduled_request_cpu_m": allocation.get("scheduled_request_cpu_m"), + "pending_unscheduled_cpu_m": allocation.get("pending_unscheduled_cpu_m"), + "reservation_residual_cpu_m": allocation.get("residual_cpu_m"), + "release_preflight": allocation.get("state_hub_preflight_observation"), + "operating_margin": None, "release_margin_acceptance": "not established by reservation residual", + "host_cluster_counted_once": True}, + "portfolio": {k: inventory.get(k) for k in ("id", "schema_version", "ownership", "capacity", "relationships", "evidence")}, + "workloads": rows, + "namespace_context": {ns: values for (ns, workload), values in sorted(series.items(), key=lambda x: str(x[0])) if workload is None}, + "comparison": {"compatible": compatible, "reason": comparison_reason, + "previous_report_sha256": digest(previous) if previous else None, + "disappeared_workloads": [list(key) for key in sorted(set(previous_rows) - seen)]}, + "measurement_gaps": cluster.get("measurement_gaps", []) + allocation.get("measurement_gaps", []), + "recommendations": recommendations, + "limitations": ["No representative pilot acceptance inferred from telemetry.", + "Namespace percentiles and peaks are not workload metrics or additive node peaks.", + "Portfolio capacity is dated evidence, not additional host capacity.", + "No procurement, sizing changes, owner-task creation, delivery or acknowledgment performed.", + "Installed executor, scoped reads, independent missed-run handling and first unattended receipt remain T05 gates."], + } + + +def retain(path, report): + """Never overwrite a retained week; identical retries succeed.""" + encoded = json.dumps(report, indent=2, sort_keys=True, allow_nan=False) + "\n" + path.parent.mkdir(parents=True, exist_ok=True) + temporary = None + try: + with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as stream: + temporary = Path(stream.name) + stream.write(encoded) + stream.flush() + os.fsync(stream.fileno()) + try: + os.link(temporary, path) + except FileExistsError: + if path.is_symlink() or path.read_text() != encoded: + raise ValueError("retained report differs; choose a new explicit revision path") + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--request", type=Path, help="Executor-owned JSON request; paths relative to its directory") + for name in ("cluster", "allocation", "telemetry", "inventory", "provenance"): + parser.add_argument("--" + name, type=Path) + parser.add_argument("--previous", type=Path) + parser.add_argument("--reviews", type=Path) + parser.add_argument("--as-of") + parser.add_argument("--max-age-hours", type=float, default=48) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + try: + if args.request: + request = json.loads(args.request.read_text()) + if request.get("schema") != "custodian.weekly-allocation-request.v1": + raise ValueError("unsupported executor request schema") + if any(getattr(args, key) for key in ("cluster", "allocation", "telemetry", "inventory", "provenance", "previous", "reviews", "as_of", "output")): + raise ValueError("request cannot be mixed with input/output overrides") + for key in ("cluster", "allocation", "telemetry", "inventory", "provenance", "previous", "reviews", "output"): + value = request.get(key) + setattr(args, key, args.request.parent / value if value else None) + args.as_of = request.get("as_of") + if any(not getattr(args, key) for key in ("cluster", "allocation", "telemetry", "inventory", "provenance", "as_of", "output")): + raise ValueError("executor input paths, as_of and retention output must be explicitly bound") + if not math.isfinite(args.max_age_hours) or args.max_age_hours <= 0: + raise ValueError("max-age-hours must be positive and finite") + inputs = {name: json.loads(getattr(args, name).read_text()) for name in ("cluster", "allocation", "telemetry", "inventory", "provenance")} + report = build_report(**inputs, as_of=args.as_of, max_age_hours=args.max_age_hours, + previous=json.loads(args.previous.read_text()) if args.previous else None, + reviews=json.loads(args.reviews.read_text()) if args.reviews else None) + retain(args.output, report) + except (OSError, ValueError, KeyError, TypeError) as exc: + parser.exit(2, f"weekly allocation report refused: {exc}\n") + print(json.dumps({"status": "retained", "path": str(args.output), "sha256": digest(report), + "workloads": len(report["workloads"]), "delivery": "not emitted"})) + + +if __name__ == "__main__": + main() diff --git a/tests/test_generated_commit_guard.py b/tests/test_generated_commit_guard.py index 728a12d..d72c67a 100644 --- a/tests/test_generated_commit_guard.py +++ b/tests/test_generated_commit_guard.py @@ -27,6 +27,15 @@ def test_evidence_packets_and_generated_brief_need_source_change(): assert not guard.standalone(["docs/decisions/authorization.md"]) +def test_source_inventory_configuration_is_authored_input(): + assert not guard.standalone(["source-inventory.config.json"]) + assert not guard.standalone(["source-inventory.config.json", "source-inventory.json"]) + assert guard.standalone(["source-inventory.json"]) + assert guard.standalone(["source-inventory.config.json.generated"]) + assert guard.standalone(["docs/evidence/source-inventory.config.json"]) + assert guard.standalone(["generated/source-inventory.config.json"]) + + def test_ci_checks_each_commit_in_range(tmp_path): def git(*args): return subprocess.check_output(["git", *args], cwd=tmp_path).decode().strip() diff --git a/tests/test_shared_generated_guard.py b/tests/test_shared_generated_guard.py index f35ea9c..9d1f2c6 100644 --- a/tests/test_shared_generated_guard.py +++ b/tests/test_shared_generated_guard.py @@ -95,3 +95,18 @@ def test_make_install_recognizes_existing_shared_guard(tmp_path, existing_local) subprocess.run(["git", "-C", str(repo), "add", "WORK-RECORDS.md"], check=True) assert subprocess.run([str(hooks / "pre-commit")], cwd=repo, capture_output=True).returncode == 1 + + +def test_reviewed_hook_generation_upgrade_preserves_scanner(tmp_path): + repo, hooks = repository(tmp_path) + original = (SCRIPT.parents[1] / 'docs/changes/CUST-WP-0080/secret-hooks-pre-commit').read_bytes() + hook = hooks / 'pre-commit' + hook.write_bytes(original) + hook.chmod(0o755) + assert guard.install(repo, True, enable_generation=True)['changed'] + marker = b'python3 /home/worsch/the-custodian/scripts/run_generated_work_records_hook.py\n' + assert hook.read_bytes().replace(marker, b'') == original + assert not guard.install(repo, True, enable_generation=True)['changed'] + (repo / 'WORK-RECORDS.md').write_text('generated only') + subprocess.run(['git', '-C', str(repo), 'add', 'WORK-RECORDS.md'], check=True) + assert subprocess.run([str(hook)], cwd=repo, capture_output=True).returncode == 1 diff --git a/tests/test_staged_work_records.py b/tests/test_staged_work_records.py index 80a3d48..8ea9066 100644 --- a/tests/test_staged_work_records.py +++ b/tests/test_staged_work_records.py @@ -101,3 +101,69 @@ def test_explicit_renderer_roots_do_not_share_import_cache(tmp_path): 'def _work_record_index_document(slug, rows): return "unused"\n' ) assert module.render(tmp_path, 'canonical-repo', root) == (label + 'canonical-repo').encode() + + +def hook_module(): + spec = importlib.util.spec_from_file_location('record_hook', Path(__file__).parents[1] / 'scripts/run_generated_work_records_hook.py') + loaded = importlib.util.module_from_spec(spec) + spec.loader.exec_module(loaded) + return loaded + + +def test_opt_in_hook_ignores_unstaged_opt_in(repo): + (repo / '.generated-work-records.json').write_text('{"version":1,"repo_slug":"canonical-repo"}') + assert not hook_module().run(repo) + + +def test_opt_in_hook_rejects_unstaged_recipe(repo): + (repo / '.generated-work-records.json').write_text('{"version":1,"repo_slug":"canonical-repo"}') + (repo / '.generated-work-records.mk').write_text('generated-work-records:\n\t@true\n') + git(repo, 'add', '.generated-work-records.json', '.generated-work-records.mk') + (repo / '.generated-work-records.mk').write_text('unstaged change') + with pytest.raises(ValueError, match='unstaged/redirected'): + hook_module().run(repo) + + +def test_adopted_real_hook_preserves_partial_staging(repo): + root = Path(__file__).parents[1] + (repo / '.generated-work-records.json').write_text('{"version":1,"repo_slug":"canonical-repo"}') + (repo / '.generated-work-records.mk').write_bytes((root / '.generated-work-records.mk').read_bytes()) + plan = repo / 'workplans/plan.md' + plan.write_text('---\nid: TEST-WP-0001\nstatus: active\n---\n') + git(repo, 'add', '.generated-work-records.json', '.generated-work-records.mk', 'workplans/plan.md') + plan.write_text('unstaged source') + # The ordinary Makefile must not be parsed by this fixed recipe invocation. + (repo / 'Makefile').write_text('$(error should never execute)\n') + assert hook_module().run(repo) + assert b'TEST-WP-0001 | active' in git(repo, 'show', ':WORK-RECORDS.md') + assert plan.read_text() == 'unstaged source' + + +def test_real_commit_only_includes_derived_index_and_preserves_other_staging(repo): + root = Path(__file__).parents[1] + git(repo, 'config', 'user.name', 'Test') + git(repo, 'config', 'user.email', 'test@example.invalid') + git(repo, 'config', 'core.hooksPath', str(repo / '.git/hooks')) + (repo / '.generated-work-records.json').write_text('{"version":1,"repo_slug":"canonical-repo"}') + (repo / '.generated-work-records.mk').write_bytes((root / '.generated-work-records.mk').read_bytes()) + git(repo, 'add', '.') + git(repo, 'commit', '-qm', 'initial') + (repo / '.custodian-brief.md').write_text('unrelated staged brief') + git(repo, 'add', '.custodian-brief.md') + plan = repo / 'workplans/plan.md' + plan.write_text('---\nid: TEST-WP-0001\nstatus: active\n---\n') + hook = repo / '.git/hooks/pre-commit' + hook.write_text('#!/bin/sh\nset -eu\npython3 "' + str(root / 'scripts/check_generated_only_commit.py') + '" --staged\npython3 "' + str(root / 'scripts/run_generated_work_records_hook.py') + '"\n') + hook.chmod(0o755) + before = git(repo, 'ls-files', '--stage') + failed = subprocess.run(['git', '-C', str(repo), 'commit', '--only', 'workplans/plan.md', '-qm', 'blocked partial'], capture_output=True) + assert failed.returncode != 0 + assert b'alternate index/partial commit' in failed.stderr + assert git(repo, 'ls-files', '--stage') == before + assert (repo / 'WORK-RECORDS.md').read_text() == 'old index' + git(repo, 'add', 'workplans/plan.md') + assert hook_module().run(repo) + git(repo, 'commit', '--only', 'workplans/plan.md', 'WORK-RECORDS.md', '-qm', 'source change') + assert b'TEST-WP-0001 | active' in git(repo, 'show', 'HEAD:WORK-RECORDS.md') + assert git(repo, 'diff', '--cached', '--name-only').decode().splitlines() == ['.custodian-brief.md'] + assert (repo / 'WORK-RECORDS.md').read_bytes() == git(repo, 'show', 'HEAD:WORK-RECORDS.md') diff --git a/tests/test_weekly_allocation_report.py b/tests/test_weekly_allocation_report.py new file mode 100644 index 0000000..d1d4ac0 --- /dev/null +++ b/tests/test_weekly_allocation_report.py @@ -0,0 +1,150 @@ +import copy +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("weekly", ROOT / "scripts/weekly_allocation_report.py") +weekly = importlib.util.module_from_spec(spec) +spec.loader.exec_module(weekly) + + +class WeeklyAllocationTests(unittest.TestCase): + def setUp(self): + self.end = "2026-10-19T06:00:00+00:00" + self.resource = "resource:railiance:reef-railiance:k3s" + self.cluster = { + "schema_version": "railiance.cluster-resource-observation.v1", + "resource_id": self.resource, "captured_at": self.end, + "utilization": {"workloads": [{"namespace": "pilot", "workload": "app", "pods": 1, + "requests": {"cpu_millicores": 60, "memory_bytes": 256}, + "limits": {"cpu_millicores": 1000, "memory_bytes": 1024}}]}, + } + self.allocation = { + "schema": "custodian.allocation-reconcile.v1", "captured_at": self.end, + "host": {"cluster_resource_id": self.resource}, "count_host_and_cluster_cpus_once": True, + "workloads": [{"namespace": "pilot", "workload": "app", "owner": "railiance-apps"}], + } + self.telemetry = {"captured_at": self.end, "window": "7d", "step": "5m", "queries": {}} + self.inventory = {"id": self.resource} + self.provenance = {"source_observation": self.end, "revisions": {"collector": "a" * 40}} + + def add_metric(self, name="cpu_p95_m", value="1.5", workload=True): + labels = {"namespace": "pilot"} + if workload: + labels["workload"] = "app" + self.telemetry["queries"][name] = {"promql": f"aggregate_{name}[7d:5m]", + "response": {"status": "success", "data": {"resultType": "vector", "result": [ + {"metric": labels, "value": [weekly.timestamp(self.end).timestamp(), value]}]}}} + + def report(self, **kwargs): + return weekly.build_report(self.cluster, self.allocation, self.telemetry, self.inventory, + self.provenance, as_of=kwargs.pop("as_of", self.end), **kwargs) + + def test_namespace_is_not_workload_demand(self): + self.add_metric(workload=False) + report = self.report() + self.assertEqual(report["namespace_context"]["pilot"]["cpu_p95_m"]["value"], 1.5) + self.assertIsNone(report["workloads"][0]["metrics"]["cpu_p95_m"]["value"]) + self.assertEqual(report["recommendations"][0]["decision"], "investigate") + + def test_missing_zero_and_nonfinite_are_distinct(self): + self.add_metric(value="0") + self.add_metric("memory_peak_bytes", value="NaN") + row = self.report()["workloads"][0] + self.assertEqual(row["metrics"]["cpu_p95_m"]["value"], 0) + self.assertIsNone(row["metrics"]["memory_peak_bytes"]["value"]) + self.assertIsNone(row["metrics"]["errors"]["value"]) + + def test_duplicate_or_extra_labels_do_not_aggregate(self): + self.add_metric() + samples = self.telemetry["queries"]["cpu_p95_m"]["response"]["data"]["result"] + samples.append(copy.deepcopy(samples[0])) + self.assertIsNone(self.report()["workloads"][0]["metrics"]["cpu_p95_m"]["value"]) + samples.pop() + samples[0]["metric"]["pod"] = "app-123" + self.assertIsNone(self.report()["workloads"][0]["metrics"]["cpu_p95_m"]["value"]) + + def test_failure_response_is_missing(self): + self.add_metric() + self.telemetry["queries"]["cpu_p95_m"]["response"]["status"] = "error" + self.assertIsNone(self.report()["workloads"][0]["metrics"]["cpu_p95_m"]["value"]) + + def test_stable_recommendation_and_only_comparable_weeks(self): + self.add_metric(value="2") + current = self.report() + previous = copy.deepcopy(current) + previous["period"]["capture_end"] = "2026-10-12T06:00:00+00:00" + previous["workloads"][0]["metrics"]["cpu_p95_m"]["value"] = 1 + previous["workloads"][0]["metrics"]["cpu_p95_m"].update( + window_end="2026-10-12T06:00:00+00:00", window_start="2026-10-05T06:00:00+00:00") + previous["recommendations"][0]["receiving_task"] = "OWNER-WP-0001-T01" + compared = self.report(previous=previous) + self.assertEqual(compared["workloads"][0]["week_on_week"]["cpu_p95_m"], 1) + self.assertEqual(compared["recommendations"][0]["id"], previous["recommendations"][0]["id"]) + self.assertEqual(compared["recommendations"][0]["receiving_task"], "OWNER-WP-0001-T01") + previous["query_signature"] = "changed" + self.assertIsNone(self.report(previous=previous)["workloads"][0]["week_on_week"]["cpu_p95_m"]) + + def test_capture_consecutive_but_metric_windows_not_consecutive(self): + self.add_metric(value="2") + previous = copy.deepcopy(self.report()) + previous["period"]["capture_end"] = "2026-10-12T06:00:00+00:00" + previous["workloads"][0]["metrics"]["cpu_p95_m"].update( + window_end="2026-10-14T06:00:00+00:00", window_start="2026-10-07T06:00:00+00:00") + row = self.report(previous=previous)["workloads"][0] + self.assertIsNone(row["week_on_week"]["cpu_p95_m"]) + self.assertIn("not consecutive", row["comparison_reasons"]["cpu_p95_m"]) + + def test_future_and_stale_observations_stay_unaccepted(self): + for time, state in [("2026-10-18T06:00:00+00:00", "future"), ("2026-10-22T06:00:00+00:00", "stale")]: + report = self.report(as_of=time) + self.assertEqual(report["freshness"]["telemetry"]["status"], state) + self.assertEqual(report["recommendations"][0]["decision"], "investigate") + + def test_matching_owner_review_required_for_keep(self): + for metric in weekly.METRICS: + self.add_metric(metric) + rid = self.report()["recommendations"][0]["id"] + review = {"decision": "keep", "owner": "railiance-apps", "evidence_refs": ["run:pilot"], + "receiving_task": "CUST-WP-0071-T04", "configuration": {"cpu_request_m": 60}, + "rollback_ref": "unchanged-config", "reviewed_at": self.end, "period_end": self.end, + "coverage_ref": "coverage:pilot", "service_acceptance_ref": "acceptance:pilot", "margin_ref": "budget:pilot"} + self.assertEqual(self.report(reviews={rid: review})["recommendations"][0]["decision"], "keep") + review["owner"] = "other" + self.assertEqual(self.report(reviews={rid: review})["recommendations"][0]["decision"], "investigate") + + def test_identity_and_snapshot_mismatch_refused(self): + self.inventory["id"] = "another-cluster" + with self.assertRaises(ValueError): + self.report() + self.inventory["id"] = self.resource + self.allocation["captured_at"] = "2026-10-12T06:00:00+00:00" + with self.assertRaises(ValueError): + self.report() + + def test_retention_retries_and_revisions(self): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "week.json" + report = self.report() + weekly.retain(output, report) + weekly.retain(output, report) + report["as_of"] = "different" + with self.assertRaises(ValueError): + weekly.retain(output, report) + self.assertEqual(json.loads(output.read_text())["as_of"], self.end) + self.assertEqual(list(Path(directory).iterdir()), [output]) + + def test_historical_fixture_preserves_known_limits(self): + load = lambda suffix: json.loads((ROOT / f"docs/evidence/2026-09-28-{suffix}.json").read_text()) + report = weekly.build_report(load("cluster-observation"), load("allocation-reconcile"), + load("allocation-telemetry"), self.inventory, load("allocation-provenance"), as_of=self.end) + self.assertEqual(report["node"]["reservation_residual_cpu_m"], 580) + self.assertEqual(report["namespace_context"]["forgejo"]["cpu_p95_m"]["value"], 1454.3502378724597) + self.assertTrue(all(r["decision"] == "investigate" for r in report["recommendations"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/workplans/CUST-WP-0071-measured-workload-sizing-and-weekly-review.md b/workplans/CUST-WP-0071-measured-workload-sizing-and-weekly-review.md index bc66d56..b53a802 100644 --- a/workplans/CUST-WP-0071-measured-workload-sizing-and-weekly-review.md +++ b/workplans/CUST-WP-0071-measured-workload-sizing-and-weekly-review.md @@ -352,3 +352,43 @@ validation is not green. The required `rmgr sync --path . --push` returned an applied primary receipt with expected and derived commit both `2b2c6f08005905a50dd394b137c4c010251b13ba`. This is repository projection evidence, not live telemetry or reuse-surface ingestion proof. + + +## Offline assessment implementation — 2026-10-19 + +T05 preparation now includes `scripts/weekly_allocation_report.py`, the input +contract in `docs/weekly-allocation-report.md`, and an explicitly unbound +`config/weekly-allocation-request.json`. The adapter consumes retained cluster, +allocation, exact PromQL responses, provenance and resource-control inventory; +it never collects credentials or changes allocations. It retains collector/query +provenance, unknown coverage, stale/future flags, workload request/limit snapshots, +namespace context without false workload attribution, stable recommendation IDs +and previous owner-task links. Week-on-week deltas require compatible query, +resource and sample contracts plus consecutive actual metric evaluation windows; +capture timestamps alone do not suffice. Historical namespace-only measurements +therefore remain insufficient for an accepted workload recommendation. + +Report publication uses atomic create-without-replacement and exact idempotent +retries; changed content requires an explicit revision. Owner proposals require +named evidence, coverage, service acceptance, margin, configuration and rollback +references; missing/stale evidence keeps the recommendation at investigate. +The adapter does not claim those references were independently accepted, create +owner tasks, deliver messages or acknowledge reports. + +The actual disabled owner definition is committed in activity-core at `7490eb8`: +`activity-definitions/weekly-railiance-allocation-review.md`. Its native parser +accepts Monday 08:00 Europe/Berlin with skip misfires, `enabled: false`. +The fixed command uses the null request template and refuses execution until +real collector/retention inputs are bound. No workstation profile, production +storage path or durable-executor acceptance is asserted. No schedule was +projected, installed or enabled. The first unattended run, durable retention, +owner delivery/acknowledgment and independent missed/failed-run recovery remain +T05 acceptance gates, after T04 and executor-owner acceptance. + +Validation: eleven unit tests pass, covering the historical September 28 input, +namespace/workload isolation, zero/missing/NaN and duplicate series, query +failure, stale/future evidence, owner decisions, snapshot identity, exact retry +retention, stable task links, and misleading consecutive capture timestamps +whose actual metric windows are only five days apart. The null executor request +fails closed as intended. T02–T05 remain waiting for their existing acceptance +returns; this source implementation does not close them. diff --git a/workplans/CUST-WP-0073-agent-credential-separation.md b/workplans/CUST-WP-0073-agent-credential-separation.md index d5756e8..1f6c217 100644 --- a/workplans/CUST-WP-0073-agent-credential-separation.md +++ b/workplans/CUST-WP-0073-agent-credential-separation.md @@ -87,11 +87,11 @@ id: CUST-WP-0073-T02 status: wait priority: high depends_on: [GLAS-WP-0012] -blocking_reason: "Await GLAS-WP-0012 (glas-harness) production acceptance of the local supervised profile; then railiance-platform RBAC, railiance-enablement k3s config, railiance-infra principal mapping and ops-warden certificate issuance." +blocking_reason: "Await GLAS-WP-0012 (glas-harness) production acceptance of the local supervised profile; then railiance-platform RBAC, railiance-cluster persistent k3s config, railiance-infra principal/account mapping and ops-warden certificate issuance." state_hub_task_id: "4b88b0b7-dc7e-5612-aa5d-1a08f0530c35" ``` -Owner: railiance-platform (RBAC), railiance-enablement (k3s install), +Owner: railiance-platform (RBAC), railiance-cluster (k3s install; ownership corrected 2026-10-19), railiance-infra (host principal mapping), ops-warden (certificate issuance). - Bind the agent's stable identity and `supervised-mode` to its existing @@ -107,7 +107,10 @@ railiance-infra (host principal mapping), ops-warden (certificate issuance). issuance through an attended login). - Proof: as the agent identity, `kubectl auth can-i get secrets -A` and `can-i create pods/exec -A` both answer `no`, and `kubectl auth whoami` - shows no `system:masters`. Record the output as evidence. Also verify that + shows no `system:masters`. Retain these aggregate checks and additionally + test every namespaced forbidden permission in each namespace from a supervisor-verified + complete current inventory; aggregate denial does not exclude a namespaced grant. + Test cluster-scoped privileges separately. Record sanitized output as evidence. Also verify that an exact supervisor-approved action can execute through the selected privileged path and return sanitized outcome evidence, while an unapproved/revised action cannot use that approval. Test from the actual agent account, including denial @@ -356,3 +359,31 @@ and enablement clean. Glas's generated change matches GLAS-WP-0016-T02 already done in its source workplan; use the owning generator workflow to commit those projections. No privileged change, runtime retry, rotation or autonomy grant was performed by this review. + +## Concrete boundary source packet — 2026-10-19 + +Prepared `docs/changes/CUST-WP-0073/credential-boundary-r1/PROPOSAL.md` with +SHA256SUMS-bound RBAC/identity candidate, host principal/account specification, +cluster installer diff, source pins and a safe actual-account negative verifier. +Seven counterexample/partial-proof tests pass after the same proposal revision-2 +namespace-inventory correction; revision-1 digest inventory and refinement are +retained. Installer patch applies to its +pinned source. No live probe, identity issuance, account change or cutover ran. + +Source ownership correction: `railiance-cluster/ansible/bootstrap.yml` owns the +k3s installer and currently combines mode644 with unconditional admin kubeconfig +fetch to the controller. `railiance-enablement` is S4, not this S2 installer +owner. Route the persistent mode600 change to cluster; enablement is consulted +only if distribution tooling requires it. The proposed installer diff also +removes admin kubeconfig export. Existing hosts need an exact reviewed effective +unit/config override because the installer `creates` guard skips their flags; +chmod alone can be reverted by the old flag at restart. + +Infra's current SSH inventory maps generic agent principals into tegwick. The +packet proposes a new dedicated account/principal without altering existing +bridge consumers. Actual assignment isolation must additionally exclude old +principal credentials, external tools/connectors and writable privileged paths. +The negative verifier uses auth reviews and access checks only, refuses the +old account/identity and never upgrades a passing subset to assignment admission. +Exact effective host/workstation inputs and privileged execution binding remain +required before an execution approval packet is complete. diff --git a/workplans/CUST-WP-0079-deterministic-security-flow.md b/workplans/CUST-WP-0079-deterministic-security-flow.md index 6d6f98e..52700cd 100644 --- a/workplans/CUST-WP-0079-deterministic-security-flow.md +++ b/workplans/CUST-WP-0079-deterministic-security-flow.md @@ -4,7 +4,7 @@ type: workplan title: "Deterministic security flow: standing authorizations, flow report, and agent re-engagement" domain: infotech repo: the-custodian -status: blocked +status: active owner: claude-code topic_slug: custodian created: "2026-10-11" @@ -113,9 +113,8 @@ Canon source written 2026-10-11: `canon/standards/build-mode-change-authorizatio ```task id: CUST-WP-0079-T05 -status: wait -blocked_on: message-from:repo-manager -blocking_reason: "Local/shared guards are implemented and the 2026-10-12 to 2026-10-19 local history audit found zero generated-only commits across 15 security repos (498 commits). Fleet generator adoption remains unverified; shared hooks reject but do not regenerate indexes." +status: progress +blocking_reason: "Staged WORK-RECORDS generation adopted in activity-core, policy-nexus and repo-manager with Custodian activation underway. Remaining security fleet adoption and other owner index generators are still open; the 498-commit local seven-day history check passed." priority: medium state_hub_task_id: "8a3dc219-8982-5903-9407-e1b549d4544c" ``` @@ -259,3 +258,37 @@ in a fresh subprocess, preventing cross-checkout module-cache reuse. Tests prove both boundaries. Usage and adoption limitations are documented in `docs/staged-work-record-generation.md` and the local SCOPE capability; no shared hook routing was changed. + + +### T05 generator adoption — 2026-10-19 + +T05 is progress and the workplan active because authorized source adoption is +underway. The shared repo-manager hook now composes the original generated-only +guard, opt-in staged generator and unchanged exact-scope secret scanner. The +Custodian's separate local scanner hook was upgraded with the reviewed installer +(`changed: true`); its routing and other hooks were preserved. Opt-in is explicit +in `.generated-work-records.json`, with a dedicated staged Make recipe. Ordinary +Makefiles and `.env` includes are not loaded by the hook. Recipe content remains +trusted source, not an arbitrary-code sandbox. + +Actual owner commits exercised the hook: activity-core `8a7275c` found its index +current; policy-nexus `c89364d` and repo-manager `05046fb` regenerated and included +WORK-RECORDS with the adoption source. All three checkouts were clean after their +commits, and the latter two retained successful secret scans. Subsequent owner +work may change those checkouts independently. + +Twenty-one adapter/shared-hook/guard tests pass, including preservation of +partially staged source and unrelated staged briefs. Real `git commit --only` +exposed Git's temporary-index behavior; the adapter now refuses regeneration in +an alternate index before mutation. Pre-generating against normally staged +sources and including WORK-RECORDS in the partial commit passes with unrelated +staging intact. Instructions are in `docs/staged-work-record-generation.md`. + +This is WORK-RECORDS adoption in three provider repos plus Custodian preparation, +not fleet completion. Remaining surfaces include the other security repositories, +hub-derived `.custodian-brief.md`, policy-nexus source inventory (whose authored +configuration/dispositions must be retained), and net-kingdom's generated +coordination-frame receiver through `tools/coordination-frame/render.py`. +Historic evidence inventories are not recurring generator outputs. T05 stays +open until those applicable owner generation paths and fleet acceptance are +verified; the independent schedule, publication and attended gates remain open.