From f713aa90ef6c6f2733380491c0f6d7f1b48522b7 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 6 Oct 2026 17:02:15 +0200 Subject: [PATCH 01/87] Record approved deployment, backup and personal login blocker Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a10e2d-a5ef-7e92-b03d-b1694e915680 --- ...P-0015-coulomb-personal-content-release.md | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index 69032ed..8191787 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -53,7 +53,7 @@ that coulomb-social is not currently ArgoCD-managed. Publish the review branch. ```task id: RAPPS-WP-0015-T03 -status: wait +status: done priority: high state_hub_task_id: "2ce05439-70dc-53cf-b704-5118687857cb" ``` @@ -94,3 +94,37 @@ confirms the public coulomb-social client is absent. CSOC-WP-0008-T11 prepares its restoration and shared KeyCape restart; no live change has been made. Existing completed apps-pg daily backup is not a paired application restore. T03/T04 remain waiting for exact live approval and personal acceptance. + +## Approved deployment receipt — 2026-10-06 + +Founder approved explicit proposal; decision 8bbce691-a787-4140-9cd4-df06e8635a27, +ADMINISTER @ realm:kubernetes/railiance01, activation=APPROVED. +Fresh apps-pg Backup csoc-preupgrade-20261006145351 completed 14:54:31Z before +app upgrade. Chart changes pushed to main at3f5a909. Helm atomic upgrade from +railiance01 succeeded: revision10, chart0.2.0, pinned digest above. App1/1 ready, +2Gi PVC Bound, UID/GID10001 write succeeds, zero pending migrations. Dedicated +synthetic content marker survived a fresh pod restart and was removed afterward. +Public health/landing/clipboard asset200; login302 correct issuer/client/PKCE. + +Missing public OIDC client restored and KeyCape restart succeeded under same go; +authorization hands off to Authelia. Interactive tegwick sign-in remains pending +user confirmation. T03 done; T04 wait for interactive/product acceptance and +paired recovery. No productive import or apex cutover. Application receipt: +coulomb-social/docs/release/personal-account-deployed-2026-10-06.md. + +## User sign-in result — 2026-10-06 + +The user reaches auth.coulomb.social but tegwick is rejected as wrong username +or password. Read-only directory query through the existing provisioner confirms +tegwick is present. Live Authelia username attribute is uid; its lookup supports +uid or mail. Contained recent-log inspection records a tegwick LDAP invalid- +credentials failure, without backend connection error or regulation-ban evidence +in that entry. This does not establish why the entered credential is rejected. + +The app and client restoration are deployed, but personal sign-in is not accepted. +CSOC-WP-0008-T11 remains wait for human credential confirmation/recovery and a +successful fresh sign-in. The user was asked whether the same password works on +another service. No password was requested/read/exported or reset. Authelia's +current notifier is filesystem rather than SMTP: do not promise emailed recovery +or expose reset tokens from notification files. Any credential reset must use +the owning attended recovery/setup lane under a separate exact approved change. From df7b69ff3bc05e274ac8e9bf4080509e2b765882 Mon Sep 17 00:00:00 2001 From: custodian-sync Date: Tue, 6 Oct 2026 17:03:10 +0200 Subject: [PATCH 02/87] chore(consistency): sync task status from DB [auto] Updated by fix-consistency on 2026-10-06: - update .custodian-brief.md for railiance-apps Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a10e2d-a5ef-7e92-b03d-b1694e915680 --- .custodian-brief.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.custodian-brief.md b/.custodian-brief.md index 19281c9..de8c2b3 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,16 +2,15 @@ # Custodian Brief — railiance-apps **Domain:** financials -**Last synced:** 2026-10-06 14:44 UTC +**Last synced:** 2026-10-06 15:03 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams ### Coulomb-social persistent personal workspace release -Progress: 2/4 done | workplan_id: `79abacf8-361e-51ab-829d-40dec8e142d1` +Progress: 3/4 done | workplan_id: `79abacf8-361e-51ab-829d-40dec8e142d1` **Open tasks:** -- ! T03 — Approved upgrade and recovery point `2ce05439` - ! T04 — Personal sign-in and content acceptance `8bc4962a` ### Deploy and recover the first invited Vergabe company pilot From 8a57e37dedf075adab317ec0b88ac462bc017df9 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 6 Oct 2026 17:03:39 +0200 Subject: [PATCH 03/87] Refresh work-record index after Coulomb deployment Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a10e2d-a5ef-7e92-b03d-b1694e915680 --- WORK-RECORDS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 5fad69d..ff09821 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -34,5 +34,5 @@ | task | RAPPS-WP-0014-T03 | wait | — | workplans/RAPPS-WP-0014-vergabe-invited-pilot.md | | task | RAPPS-WP-0015-T01 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T02 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | -| task | RAPPS-WP-0015-T03 | wait | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | +| task | RAPPS-WP-0015-T03 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T04 | wait | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | From 3f8276e0eada1cfb033b3104dc82f00a866a2ddb Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 6 Oct 2026 20:50:05 +0200 Subject: [PATCH 04/87] fix(coulomb): use canonical user service during personal login Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a10e2d-a5ef-7e92-b03d-b1694e915680 --- charts/coulomb-social/values.yaml | 2 +- helm/coulomb-social-values.yaml | 2 +- ...-WP-0015-coulomb-personal-content-release.md | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/charts/coulomb-social/values.yaml b/charts/coulomb-social/values.yaml index f8e3c20..034e435 100644 --- a/charts/coulomb-social/values.yaml +++ b/charts/coulomb-social/values.yaml @@ -38,7 +38,7 @@ env: USER_ENGINE_APPLICATION_ID: coulomb-social USER_ENGINE_EXPECTED_AUDIENCE: user-engine-portal # USER_ENGINE_BASE_URL set via secret or here when stable - USER_ENGINE_BASE_URL: https://users.92-205-62-239.nip.io + USER_ENGINE_BASE_URL: https://users.coulomb.social NETKINGDOM_REGISTRATION_URL: https://users.92-205-62-239.nip.io/register probes: diff --git a/helm/coulomb-social-values.yaml b/helm/coulomb-social-values.yaml index 2cb0846..dc345ce 100644 --- a/helm/coulomb-social-values.yaml +++ b/helm/coulomb-social-values.yaml @@ -19,7 +19,7 @@ env: OIDC_CLIENT_ID: coulomb-social OIDC_REDIRECT_URI: https://app.coulomb.social/auth/callback/ OIDC_SCOPES: "openid profile email groups" - USER_ENGINE_BASE_URL: https://users.92-205-62-239.nip.io + USER_ENGINE_BASE_URL: https://users.coulomb.social USER_ENGINE_APPLICATION_ID: coulomb-social USER_ENGINE_EXPECTED_AUDIENCE: user-engine-portal NETKINGDOM_REGISTRATION_URL: https://users.92-205-62-239.nip.io/register diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index 8191787..3d19cd5 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -128,3 +128,20 @@ another service. No password was requested/read/exported or reset. Authelia's current notifier is filesystem rather than SMTP: do not promise emailed recovery or expose reset tokens from notification files. Any credential reset must use the owning attended recovery/setup lane under a separate exact approved change. + +## Personal login 500 correction — 2026-10-06 + +User confirmed fresh password-reset email receipt, then reported HTTP500 after +authentication. In-pod credential-safe read-only probes with empty claims show +old USER_ENGINE_BASE_URL redirects302 to users.coulomb.social/api/v1/me; canonical +endpoint responds400 to empty claims using the existing service credential. No +identity was created. The application's httpx client does not follow redirects +and raise_for_status rejects302 before establishing its local session. + +Correct USER_ENGINE_BASE_URL in chart defaults and production overrides to +https://users.coulomb.social. Preserve immutable image, Secret references, +identity issuer/subject/tenant, PVC and access rules. This completes the already +approved personal app deployment; founder go/interruption approval is recorded +in decision8bbce691-a787-4140-9cd4-df06e8635a27. Use node-side Helm atomic upgrade, +server dry-run and exact manifest diff; rollback revision10 if necessary. T04 +remains waiting for successful interactive personal workspace acceptance. From ae2885728a9b33211fc1071d7430cb5b409f139c Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 6 Oct 2026 20:52:28 +0200 Subject: [PATCH 05/87] docs: record canonical user service correction deployment Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a10e2d-a5ef-7e92-b03d-b1694e915680 --- workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index 3d19cd5..168252a 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -145,3 +145,8 @@ approved personal app deployment; founder go/interruption approval is recorded in decision8bbce691-a787-4140-9cd4-df06e8635a27. Use node-side Helm atomic upgrade, server dry-run and exact manifest diff; rollback revision10 if necessary. T04 remains waiting for successful interactive personal workspace acceptance. + +Correction deployed as Helm revision11; parsed server dry-run manifest differs +only in USER_ENGINE_BASE_URL. Atomic/wait upgrade succeeded, app1/1 and public +health/landing200, login302. Root receipt docs/release/personal-login-500-2026-10-06.md +in coulomb-social records evidence. Fresh user workspace acceptance remains open. From 62dc523d7e81d7040c4f8dfbdd4015dd0f456b4a Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 6 Oct 2026 20:54:42 +0200 Subject: [PATCH 06/87] docs: record user workspace acceptance after login repair Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a10e2d-a5ef-7e92-b03d-b1694e915680 --- workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index 168252a..5b9fc5c 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -150,3 +150,8 @@ Correction deployed as Helm revision11; parsed server dry-run manifest differs only in USER_ENGINE_BASE_URL. Atomic/wait upgrade succeeded, app1/1 and public health/landing200, login302. Root receipt docs/release/personal-login-500-2026-10-06.md in coulomb-social records evidence. Fresh user workspace acceptance remains open. + +User confirmed “I reach my workspace” after revision11 correction. Personal +sign-in accepted. T04 remains open for content creation/reload, copy/edit/native +clipboard and explicit access-denial/product acceptance; login alone does not +close that broader product task. From f42f8024c0ba2c9bcd63b20811a235dcb5c43c8f Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 6 Oct 2026 22:11:42 +0200 Subject: [PATCH 07/87] docs: record paired recovery before first ReichelAg transfer Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a10e2d-a5ef-7e92-b03d-b1694e915680 --- ...P-0015-coulomb-personal-content-release.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index 5b9fc5c..aca98da 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -155,3 +155,24 @@ User confirmed “I reach my workspace” after revision11 correction. Personal sign-in accepted. T04 remains open for content creation/reload, copy/edit/native clipboard and explicit access-denial/product acceptance; login alone does not close that broader product task. + +## First content-transfer recovery preparation — 2026-10-06 + +Founder instructed current ReichelAg capture/first transfer (“yes go on”), then +prioritized content over UX and a separate UI framework architecture phase. +Bounded snapshot operation continued personal-app interruption approval: root +scripts/snapshot-content-and-db.py server dry-run passed; app paused, app-scoped +coulomb_social_db pg_dump and read-only retained PVC archive captured privately, +helper pod removed and app1replica resumed. Operation53.73seconds; app health200. +No persistent chart/deployment, data, grants or credential settings changed. + +Private pair restored in isolated Docker PostgreSQL:13tables/102rows with empty +content, logical rows/sequences match. A separate synthetic populated pair +restored13tables/111rows/4files; protocol original/edited copy, assets/render, +viewer validCSRF write denial and outsider read denial pass. Ephemeral restore +containers/internal networks removed;96repo tests passed. Receipt in product +repo docs/release/reichelag-transfer-2026-10-06.md. + +Fresh private Bubble protocols require a renewed session; user-attended local +browser opened. T04 remains wait for real content/product acceptance. Temporary +snapshot custody is not scheduled durable backup acceptance (CSOC-WP-0008-T02). From 5f6244ec625dc54203b86199fee334b900467fdd Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 6 Oct 2026 22:54:35 +0200 Subject: [PATCH 08/87] docs: record verified real ReichelAg content import Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a10e2d-a5ef-7e92-b03d-b1694e915680 --- ...WP-0015-coulomb-personal-content-release.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index aca98da..962465d 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -176,3 +176,21 @@ repo docs/release/reichelag-transfer-2026-10-06.md. Fresh private Bubble protocols require a renewed session; user-attended local browser opened. T04 remains wait for real content/product acceptance. Temporary snapshot custody is not scheduled durable backup acceptance (CSOC-WP-0008-T02). + +## Real first-cohort import executed + +Founder completed attended Bubble web login. First ReichelAG cohort captured +without old seed:36articles/36assets (37markdown files with space index), latest +dated protocol2026-10-02; all29August IDs plus7new. Fresh20:21:30Z paired point +captured privately. Node import helper server dry-run/source hashes/snapshot +hashes passed; after pausing writers current DB/content compared equal to +paired point. One new space, owner grant only for existing tegwick identity; +no replacement. All73source files hash-match and37pages render; app resumed +after52.97seconds. New pod verifies visible space/pages/assets/grant; health200. +Same revision11 image, credentials and chart configuration retained. + +Separate real-content paired rehearsal restored13tables/104rows/73files with +all hashes/logical rows/sequences matching, all pages rendered, owner read and +ungranted same-tenant read denial.96tests pass. T04 user/clipboard/product +acceptance remains open; retained backup custody/rollback remains live +CSOC-WP-0008-T02. Receipt: product docs/release/reichelag-imported-2026-10-06.md. From 9499c39676a34397036d2522b063f4b30e129df9 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 7 Oct 2026 00:29:16 +0200 Subject: [PATCH 09/87] Pin full-protocol clipboard corrective release Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a10e2d-a5ef-7e92-b03d-b1694e915680 --- helm/coulomb-social-values.yaml | 4 ++-- ...PPS-WP-0015-coulomb-personal-content-release.md | 14 +++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/helm/coulomb-social-values.yaml b/helm/coulomb-social-values.yaml index dc345ce..53770de 100644 --- a/helm/coulomb-social-values.yaml +++ b/helm/coulomb-social-values.yaml @@ -3,8 +3,8 @@ image: repository: forgejo.coulomb.social/coulomb/coulomb-social - tag: "ac7bac1852c9" - digest: "sha256:bf975939e22b9b0a5d9ac05584ae6265ba201169df6dc48e506ec684b30a9c58" + tag: "d521ddc" + digest: "sha256:9e7d1c2cc5248a3fd92ab48b4c4edf077c3ee7fb9149e7adb78c29101c60b745" # Public host: app.coulomb.social (parallel to Bubble on apex coulomb.social). # Apex cutover is deferred until data + self-registration are ready. diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index 962465d..604bbe6 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: railiance-apps created: "2026-10-06" -updated: "2026-10-06" +updated: "2026-10-07" related: - CSOC-WP-0008 state_hub_workstream_id: "79abacf8-361e-51ab-829d-40dec8e142d1" @@ -194,3 +194,15 @@ all hashes/logical rows/sequences matching, all pages rendered, owner read and ungranted same-tenant read denial.96tests pass. T04 user/clipboard/product acceptance remains open; retained backup custody/rollback remains live CSOC-WP-0008-T02. Receipt: product docs/release/reichelag-imported-2026-10-06.md. + +## Clipboard fidelity corrective release — 2026-10-07 + +Founder review found the ReichelAg clipboard differs from Bubble. Root source +d521ddc fixes full title, italic abstractor, raw Markdown and legacy footer; +private authenticated comparison is exact, and make check passes97tests. +Production values pin the new immutable registry digest. Continue the approved +personal-app upgrade/interruption scope (decision8bbce691-a787-4140-9cd4-df06e8635a27); +only the app image may differ in parsed manifests. Preserve PVC, environment, +Secret references, identity and grants. Fresh paired DB/content recovery point +captured before upgrade. T04 remains waiting for actual user clipboard/mail +review; no protocol contents are changed by this image release. From bca75d70a0eb7c17bb9b92b8deba636a8def5d8b Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 7 Oct 2026 01:53:29 +0200 Subject: [PATCH 10/87] Record revision12 clipboard and content preservation proof Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a10e2d-a5ef-7e92-b03d-b1694e915680 --- .../RAPPS-WP-0015-coulomb-personal-content-release.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index 604bbe6..5fc2947 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -206,3 +206,10 @@ only the app image may differ in parsed manifests. Preserve PVC, environment, Secret references, identity and grants. Fresh paired DB/content recovery point captured before upgrade. T04 remains waiting for actual user clipboard/mail review; no protocol contents are changed by this image release. + +Corrective release deployed as Helm revision12 with atomic/wait. Server dry-run +parsed manifests differ only in the pinned app image. Live loader/template +clipboard text equals the original; all74content file hashes unchanged from +paired pre-upgrade snapshot; owner access valid. Public health/static200 and +served clipboard script equals tested source. T04 remains user product review. +Receipt: coulomb-social/docs/release/reichelag-clipboard-2026-10-07.md. From 22c5695a211e5de61a02678cb3a7a284cbdaa938 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 7 Oct 2026 02:08:00 +0200 Subject: [PATCH 11/87] Pin tested protocol naming corrective release Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a10e2d-a5ef-7e92-b03d-b1694e915680 --- helm/coulomb-social-values.yaml | 4 ++-- ...APPS-WP-0015-coulomb-personal-content-release.md | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/helm/coulomb-social-values.yaml b/helm/coulomb-social-values.yaml index 53770de..b16ed77 100644 --- a/helm/coulomb-social-values.yaml +++ b/helm/coulomb-social-values.yaml @@ -3,8 +3,8 @@ image: repository: forgejo.coulomb.social/coulomb/coulomb-social - tag: "d521ddc" - digest: "sha256:9e7d1c2cc5248a3fd92ab48b4c4edf077c3ee7fb9149e7adb78c29101c60b745" + tag: "b13ba9c5815e" + digest: "sha256:dbeaeda1a03622a91683f8db3c1f498e8bf43417cdb077ec740c56f0489baff6" # Public host: app.coulomb.social (parallel to Bubble on apex coulomb.social). # Apex cutover is deferred until data + self-registration are ready. diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index 5fc2947..6c5d2ac 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -213,3 +213,16 @@ clipboard text equals the original; all74content file hashes unchanged from paired pre-upgrade snapshot; owner access valid. Public health/static200 and served clipboard script equals tested source. T04 remains user product review. Receipt: coulomb-social/docs/release/reichelag-clipboard-2026-10-07.md. + +## Protocol naming corrective release — 2026-10-07 + +Founder confirms edit and clipboard behavior, reports default filenames retained +after duplicating with a new title and undiscoverable rename controls. Root +CSOC-WP-0008-T12 implements server-derived filenames on title-only duplication, +explicit override, visible Rename page controls and metadata preservation during +rename/edit. Source b13ba9c5815e, full gate98tests passed, pinned immutable digest +sha256:dbeaeda1a03622a91683f8db3c1f498e8bf43417cdb077ec740c56f0489baff6. +Apply an image-only node Helm atomic/wait upgrade under existing founder app +upgrade/interruption go, decision8bbce691-a787-4140-9cd4-df06e8635a27. Fresh paired +database/content recovery point captured56.93seconds, app resumed; content and +grants must remain unchanged. T04 continues user naming/workflow review. From 4a807932faf1cda04c44e8e1af4eeb7f540136fc Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 7 Oct 2026 02:13:00 +0200 Subject: [PATCH 12/87] Record revision13 protocol naming verification Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a10e2d-a5ef-7e92-b03d-b1694e915680 --- .../RAPPS-WP-0015-coulomb-personal-content-release.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index 6c5d2ac..75ade2f 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -226,3 +226,11 @@ Apply an image-only node Helm atomic/wait upgrade under existing founder app upgrade/interruption go, decision8bbce691-a787-4140-9cd4-df06e8635a27. Fresh paired database/content recovery point captured56.93seconds, app resumed; content and grants must remain unchanged. T04 continues user naming/workflow review. + +Naming correction deployed as revision13; node Helm atomic/wait succeeded with +app-image-only parsed manifest diff. Live Rename page/title/file controls and +new copy-filename logic verified; original clipboard payload still matches. +All75content files unchanged from fresh paired snapshot; owner access remains +valid. Health/static200. Owning pin commit22c5695; sourceb13ba9c5815e. +T04 remains founder naming/workflow review; edit and clipboard copy accepted. +Receipt: coulomb-social/docs/release/reichelag-naming-2026-10-07.md. From 2e95944ba3841959f8fbb384eeddc601d8589575 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 7 Oct 2026 12:43:40 +0200 Subject: [PATCH 13/87] Pin approved private Visual release for coulomb-social Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a11538-05ea-7b63-b3c7-a2df5f38898d --- helm/coulomb-social-values.yaml | 4 ++-- ...WP-0015-coulomb-personal-content-release.md | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/helm/coulomb-social-values.yaml b/helm/coulomb-social-values.yaml index b16ed77..b28fe0c 100644 --- a/helm/coulomb-social-values.yaml +++ b/helm/coulomb-social-values.yaml @@ -3,8 +3,8 @@ image: repository: forgejo.coulomb.social/coulomb/coulomb-social - tag: "b13ba9c5815e" - digest: "sha256:dbeaeda1a03622a91683f8db3c1f498e8bf43417cdb077ec740c56f0489baff6" + tag: "017b917e2158" + digest: "sha256:0e235abc33a1c5613c0e2c9a0f767c871d0fbed6695973ae17cfdcb2dedaaafa" # Public host: app.coulomb.social (parallel to Bubble on apex coulomb.social). # Apex cutover is deferred until data + self-registration are ready. diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index 75ade2f..b01a6d8 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -234,3 +234,21 @@ All75content files unchanged from fresh paired snapshot; owner access remains valid. Health/static200. Owning pin commit22c5695; sourceb13ba9c5815e. T04 remains founder naming/workflow review; edit and clipboard copy accepted. Receipt: coulomb-social/docs/release/reichelag-naming-2026-10-07.md. + + +## Private Visual release — 2026-10-07 + +Founder explicitly approves exact image-only rollout, decision +3f959ed2-4163-4fa5-9f88-f80b2a3d1b89, ADMINISTER @ +realm:kubernetes/railiance01, activation=APPROVED. Source017b917e2158, published +digest sha256:0e235abc33a1c5613c0e2c9a0f767c871d0fbed6695973ae17cfdcb2dedaaafa. +Root make check129passed; private raster covers/body-image routing and bounded +Visual copies tested, including actual Chromium writer/viewer/outsider behavior. +Packaged UID10001/network-disabled smoke passes. Live-manifest comparison and +server dry-run verified image only; current37JPEG signatures all match. + +Prepare fresh quiesced paired DB/content backup and verify isolated recovery +before node Helm atomic/wait upgrade. Preserve issuer, mailbox, credentials, +PVC, metadata/content and grants. Root receipt: +coulomb-social/docs/release/private-visuals-2026-10-07.md. T04 and product T05/T09 +remain open for actual user Visual/workflow acceptance. No Bubble retirement. From 7fb7a1c389207ac0f28c7ccec4243353e0e634c0 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 7 Oct 2026 13:44:02 +0200 Subject: [PATCH 14/87] Record private Visual revision14 deployment receipt Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a11538-05ea-7b63-b3c7-a2df5f38898d --- ...P-0015-coulomb-personal-content-release.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index b01a6d8..02b3989 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -252,3 +252,25 @@ before node Helm atomic/wait upgrade. Preserve issuer, mailbox, credentials, PVC, metadata/content and grants. Root receipt: coulomb-social/docs/release/private-visuals-2026-10-07.md. T04 and product T05/T09 remain open for actual user Visual/workflow acceptance. No Bubble retirement. + + +Private Visual release deployed as revision14 under exact founder decision +3f959ed2-4163-4fa5-9f88-f80b2a3d1b89. Fresh quiesced paired capture57.74seconds, +app resumed; actual production pair restores13tables/104rows/sequences and +75files with all hashes matching. New-image isolated checks render38pages and +38cover references; viewer read/write denial and outsider page/image denial +pass. Rehearsal DB/network removed. Private mode0640 restored files use the +workstation non-root owner; production UID10001 and ownership remain intact. + +Owning pin2e95944 reviewed source archive deployed from the node. Fresh parsed +manifest comparison proves app image only; cluster identity and kubectl/Helm +server dry-run pass. All75files match paired point before upgrade. Atomic/wait +upgrade20.66seconds; app1/1, exact source017b917/digest0e235abc. Read-only live +owner views verify38covers, template and single owner grant; unsaved outsider +image404. All75stored file hashes unchanged. Anonymous image login302; public +health/static200; served clipboard source matches. Root snapshot helper pin +follows release;7focused recovery tests/new server dry-run pass. + +T04 user Visual/complete workflow acceptance remains open. Root T02 retained +backup custody/retention and production rollback remain open; no durable backup +or Bubble retirement claim. Receipt: product docs/release/private-visuals-2026-10-07.md. From f5fc9eaa15429285a46a64024fb2fbb832290d1c Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 7 Oct 2026 15:10:02 +0200 Subject: [PATCH 15/87] Pin Spaces-list Visual correction after founder review Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a11538-05ea-7b63-b3c7-a2df5f38898d --- helm/coulomb-social-values.yaml | 4 ++-- ...S-WP-0015-coulomb-personal-content-release.md | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/helm/coulomb-social-values.yaml b/helm/coulomb-social-values.yaml index b28fe0c..6eb6b38 100644 --- a/helm/coulomb-social-values.yaml +++ b/helm/coulomb-social-values.yaml @@ -3,8 +3,8 @@ image: repository: forgejo.coulomb.social/coulomb/coulomb-social - tag: "017b917e2158" - digest: "sha256:0e235abc33a1c5613c0e2c9a0f767c871d0fbed6695973ae17cfdcb2dedaaafa" + tag: "881a61d10798" + digest: "sha256:91673ba7625f905c7a4d0c3d52b45ccc7e7746f387d1e84aab331d68994c5df9" # Public host: app.coulomb.social (parallel to Bubble on apex coulomb.social). # Apex cutover is deferred until data + self-registration are ready. diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index 02b3989..c3e311e 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -274,3 +274,19 @@ follows release;7focused recovery tests/new server dry-run pass. T04 user Visual/complete workflow acceptance remains open. Root T02 retained backup custody/retention and production rollback remain open; no durable backup or Bubble retirement claim. Receipt: product docs/release/private-visuals-2026-10-07.md. + + +## Spaces-list Visual correction — 2026-10-07 + +Founder review confirms page Visual but reports the missing Spaces-list preview. +Complete the already approved private-Visual image-only upgrade scope by adding +that preview with the existing authenticated asset route and membership-filtered +query. Source881a61d10798; full make check129passed including actual writer/viewer +list image loading and outsider exclusion. Browser harness now uses a temporary +file DB to permit concurrent image request connections; streaming fast tests +follow Django client close lifecycle. No checks skipped/serialized. + +Pin sha256:91673ba7625f905c7a4d0c3d52b45ccc7e7746f387d1e84aab331d68994c5df9. +Fresh paired capture56.03seconds; app resumed. Verify isolated recovery, fresh +image-only node dry-run and rollout within decision3f959ed2's approved Visual +scope and the founder's direct gap review. T04/T05/T09 remain user acceptance. From de2457cab2b6c9cf322e1b16945edc8d08a371ec Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 7 Oct 2026 16:01:22 +0200 Subject: [PATCH 16/87] Record Spaces-list cover revision15 rollout receipt Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a11538-05ea-7b63-b3c7-a2df5f38898d --- ...P-0015-coulomb-personal-content-release.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index c3e311e..7894540 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -290,3 +290,24 @@ Pin sha256:91673ba7625f905c7a4d0c3d52b45ccc7e7746f387d1e84aab331d68994c5df9. Fresh paired capture56.03seconds; app resumed. Verify isolated recovery, fresh image-only node dry-run and rollout within decision3f959ed2's approved Visual scope and the founder's direct gap review. T04/T05/T09 remain user acceptance. + + +Spaces-list correction deployed as revision15: source881a61d10798, owner pin +f5fc9ea, digest91673ba7625f905c7a4d0c3d52b45ccc7e7746f387d1e84aab331d68994c5df9. +Fresh paired capture56.03seconds; app resumed; actual pair restored13tables/ +104rows/sequences/75files matching,4.14seconds. New-image owner/viewer list +cover rendering/reads and outsider list exclusion pass in isolated recovery; +rehearsal DB/network removed. Fresh exact image-only/cluster/content guards and +server/Helm dry-run pass. Atomic/wait upgrade20.58seconds, app1/1. + +Read-only live core view verifies owner list preview/private image bytes and +unsaved outsider exclusion; single owner grant/all75file hashes unchanged. +Public health/static200; clipboard unchanged. Root snapshot helper pin follows +release;7tests/new server dry-run pass. Original release gate129passed, including +actual writer/viewer list-image loading and outsider exclusion with independent +test DB request connections; no skipped/serialized browser traffic. + +This completes the omitted list preview identified in direct founder review, +within the approved private-Visual image-only scope. T04 and root T09 wait for +founder list/full workflow acceptance; retained backup operations remain T02. +Product receipt docs/release/private-visuals-2026-10-07.md includes both revisions. From 2202e690ccf178a7f31f0185fe02f665640061a6 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 8 Oct 2026 09:26:40 +0200 Subject: [PATCH 17/87] Pin Coulomb sharing and portability release dd83c90 Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a119cf-56d0-7fa3-840b-eb8732e1db0b --- helm/coulomb-social-values.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helm/coulomb-social-values.yaml b/helm/coulomb-social-values.yaml index 6eb6b38..f0a6cea 100644 --- a/helm/coulomb-social-values.yaml +++ b/helm/coulomb-social-values.yaml @@ -3,8 +3,8 @@ image: repository: forgejo.coulomb.social/coulomb/coulomb-social - tag: "881a61d10798" - digest: "sha256:91673ba7625f905c7a4d0c3d52b45ccc7e7746f387d1e84aab331d68994c5df9" + tag: "dd83c90" + digest: "sha256:9a646d8e01a61ba5316d3c2eb0273064e17f2d0300ad07af766ccedda65ecb18" # Public host: app.coulomb.social (parallel to Bubble on apex coulomb.social). # Apex cutover is deferred until data + self-registration are ready. From 815ba1f70ca2ba2db05012681ddbd62e5b74a46f Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 8 Oct 2026 09:38:11 +0200 Subject: [PATCH 18/87] Record verified Coulomb revision 16 deployment and paired recovery Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a119cf-56d0-7fa3-840b-eb8732e1db0b --- ...P-0015-coulomb-personal-content-release.md | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index 7894540..fd4cac8 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: railiance-apps created: "2026-10-06" -updated: "2026-10-07" +updated: "2026-10-08" related: - CSOC-WP-0008 state_hub_workstream_id: "79abacf8-361e-51ab-829d-40dec8e142d1" @@ -311,3 +311,32 @@ This completes the omitted list preview identified in direct founder review, within the approved private-Visual image-only scope. T04 and root T09 wait for founder list/full workflow acceptance; retained backup operations remain T02. Product receipt docs/release/private-visuals-2026-10-07.md includes both revisions. + + +## Sharing/portability image release — 2026-10-08 + +Founder instruction: Please deploy commit and sync. Exact image-only authority +is recorded by product decision8e692284-6289-4594-bb21-88346f174806: +ADMINISTER @ realm:kubernetes/railiance01, activation=APPROVED. Source dd83c90; +owner pin2202e69; immutable digest9a646d8e01a61ba5316d3c2eb0273064e17f2d0300ad07af766ccedda65ecb18. +Product182-test Ruff/real-browser gate and packaged non-root/no-network smoke pass. +Fresh paired capture56.21s/app resumed; actual isolated13tables/104rows/sequences/ +75files match, recovery4.31s. New image renders38pages/38Visuals and verifies +sharing/export/restored viewer write denial and outsider page/image denial; +isolated DB/network removed. Private pair retained for existing custody handoff. + +Parsed manifests prove three unchanged resources with only the app image changed. +Node cluster/PVC/image/pressure and live specs match; CPU1/1000m semantic equality +was corrected after the preflight stopped, with no upgrade on failed guards. +Namespace-correct kubectl/Helm server dry-run and pre-upgrade75file hashes pass. +Atomic/wait upgrade18.64s deploys revision16, one Ready replica at exact digest. +Postflight live specs and all75stored file hashes match; existing owner grant +unchanged. Read-only UID10001 owner controls/export and38pages/Visuals, unsaved +outsider denial, public health/static200 and clipboard source match all pass. +No production identity/session/grant or content change is used for tests. + +Product snapshot helper pin follows release;9focused safety tests and fresh +server dry-run pass. No Secret, issuer, mail, caller, PVC, offsite schedule or +Bubble/apex change. T04 remains wait for productive complete user acceptance; +product WP-0008/WP-0010 retain their broader open tasks. Product receipt: +docs/release/sharing-portability-review-2026-10-08.md. From 7ea9e1c9110bd2e4e7697e470d7c47ce3e2cd352 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 8 Oct 2026 19:12:45 +0200 Subject: [PATCH 19/87] Add RAPPS-WP-0016: Forgejo sign-in through NetKingdom ORGREF-WP-0002-T05; founder-approved 2026-10-08. Break-glass first, no visibility change, depends on key-cape KEY-WP-0038. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 5457@bnt-lap001 Assistant-Session: 792bf84a-3783-4f5d-a811-cded61dd2012 --- .../RAPPS-WP-0016-forgejo-netkingdom-login.md | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md diff --git a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md new file mode 100644 index 0000000..65f2cd8 --- /dev/null +++ b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md @@ -0,0 +1,132 @@ +--- +id: RAPPS-WP-0016 +type: workplan +title: "Forgejo sign-in through NetKingdom, before the forge closes" +domain: infotech +repo: railiance-apps +status: ready +flavor: implementation +owner: claude-code +topic_slug: railiance +created: "2026-10-08" +updated: "2026-10-08" +depends_on: + - KEY-WP-0038 +related_repos: + - prj-forgejo-org-refactor + - key-cape + - railiance-platform +--- + +# RAPPS-WP-0016 - Forgejo sign-in through NetKingdom, before the forge closes + +## Why + +`forgejo.coulomb.social` is closing: private by default, no anonymous access +(prj-forgejo-org-refactor ORGREF-WP-0002, decision +`decisions/forge-visibility-policy.md`). The founder's condition is that sign-in +runs through NetKingdom first. railiance-apps owns the Forgejo Helm release +(`docs/forgejo-on-railiance01.md`: S5 Helm release, ingress, operator Makefile), +so the Forgejo side lands here. This plan is ORGREF-WP-0002-T05. The NetKingdom +side is key-cape `KEY-WP-0038`. + +## Boundaries + +- **Founder guardrail:** tighten gradually; never lock anyone out; never risk + assets; never reach a state that is hard to repair. Every step keeps the + current sign-in path working until the new one is proven. +- **No visibility change** in this plan. Repositories, packages and + `REQUIRE_SIGNIN_VIEW` are ORGREF-WP-0002-T08. +- **Configuration in git.** The authentication source is declared in + `helm/forgejo-values.yaml` (`gitea.oauth`), with its secret drawn from an + ExternalSecret over OpenBao. No click-ops, and no secret value in git. +- Open registration is already disabled (`service.DISABLE_REGISTRATION: true`). + This plan keeps it that way. + +## Tasks + +```task +id: RAPPS-WP-0016-T01 +status: todo +priority: high +``` + +### T01 — Make the local administrator a sealed break-glass + +Before anything about sign-in changes: confirm the local administrator account +works, keep its credential only in OpenBao, and document recovery (who, how, +where it is logged) in `docs/forgejo-on-railiance01.md`. Verify one break-glass +sign-in and record it without the credential. + +Acceptance: a recorded break-glass sign-in, with the recovery path written down. +This is the "never lock ourselves out" floor for every later step. + +```task +id: RAPPS-WP-0016-T02 +status: wait +depends_on: [RAPPS-WP-0016-T01, KEY-WP-0038-T02] +blocking_reason: "Needs break-glass in place and the NetKingdom client registered with its secret in custody." +priority: high +``` + +### T02 — Add the NetKingdom authentication source + +- an ExternalSecret delivering the `forgejo` client secret from OpenBao (path + from KEY-WP-0038-T02) to a Secret in the `forgejo` namespace +- `gitea.oauth`: name `netkingdom` (must match the redirect URI registered in + key-cape), provider `openidConnect`, auto-discovery from + `https://kc.coulomb.social/.well-known/openid-configuration`, scopes + `openid profile email groups`, group-claim name `groups` +- `oauth2_client` settings: no automatic account creation for unknown subjects; + account linking to existing local accounts by username after a successful + local sign-in (so the founder and agent accounts keep their history) +- Helm upgrade with the previous revision noted for rollback + +Local password sign-in stays enabled. + +Acceptance: the "Sign in with netkingdom" option appears. Local sign-in still +works. Rollback is one `helm rollback` to the noted revision. + +```task +id: RAPPS-WP-0016-T03 +status: wait +depends_on: [RAPPS-WP-0016-T02] +blocking_reason: "Linking and team mapping follow a working authentication source." +priority: high +``` + +### T03 — Link accounts and map groups to teams + +- The founder links their account through NetKingdom (MFA), then signs in again + through NetKingdom only and sees the same repositories, organisations and + admin rights. +- Map NetKingdom groups (names agreed in KEY-WP-0038-T01) to organisation teams: + owners and readers at least. Create the read-only teams that + ORGREF-WP-0002-T06 consumers will use. +- Apply KEY-WP-0038-T03's decision on agents and services: NetKingdom sign-in, or + scoped tokens bound to service identities. + +Acceptance: founder and one agent work through NetKingdom; group-to-team mapping +verified by adding and removing one test membership; break-glass still works. + +```task +id: RAPPS-WP-0016-T04 +status: wait +depends_on: [RAPPS-WP-0016-T03] +needs_human: true +blocking_reason: "Restricting local password sign-in is a founder decision once NetKingdom sign-in is proven." +priority: medium +``` + +### T04 — Decide on local password sign-in + +Once NetKingdom sign-in has been used for a while without problems, the founder +decides whether local password sign-in is turned off for everyone except +break-glass. This is not required to close the forge; it is the next tightening +step, taken separately. + +## Also in scope: stale node credential + +The k3s `registries.yaml` on railiance01 still holds a login for the frozen Gitea +host `92.205.130.254` (ORGREF-WP-0002-T02). Coordinate its removal with the node +owner when ORGREF-WP-0002-T06 adds the forge registry login, in the same change. From 21022a6e1c5d2b1d55365282c9a83d789bb26027 Mon Sep 17 00:00:00 2001 From: custodian-sync Date: Thu, 8 Oct 2026 19:13:02 +0200 Subject: [PATCH 20/87] chore(consistency): sync task status from DB [auto] Updated by fix-consistency on 2026-10-08: - update .custodian-brief.md for railiance-apps Assistant: claude-code Assistant-Model: opus Assistant-Process: 5457@bnt-lap001 Assistant-Session: 792bf84a-3783-4f5d-a811-cded61dd2012 --- .custodian-brief.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.custodian-brief.md b/.custodian-brief.md index de8c2b3..d2cc7cf 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,11 +2,20 @@ # Custodian Brief — railiance-apps **Domain:** financials -**Last synced:** 2026-10-06 15:03 UTC +**Last synced:** 2026-10-08 17:13 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams +### Forgejo sign-in through NetKingdom, before the forge closes +Progress: 0/4 done | workplan_id: `f6b1d6f6-0abb-5774-87b9-bf3cfb640a3b` + +**Open tasks:** +- ! T01 — Make the local administrator a sealed break-glass `13cd3aec` +- ! T02 — Add the NetKingdom authentication source `81f072aa` +- ! T03 — Link accounts and map groups to teams `32c54737` +- · Tasks `98004ed0` + ### Coulomb-social persistent personal workspace release Progress: 3/4 done | workplan_id: `79abacf8-361e-51ab-829d-40dec8e142d1` From b6be18b7274964b9da73c4612c707be65fef02a1 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 8 Oct 2026 19:13:32 +0200 Subject: [PATCH 21/87] chore(consistency): refresh the work-record index Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 5457@bnt-lap001 Assistant-Session: 792bf84a-3783-4f5d-a811-cded61dd2012 --- WORK-RECORDS.md | 5 +++++ workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index ff09821..dba8a65 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -13,6 +13,7 @@ | workplan | RAPPS-WP-0003 | finished | — | workplans/RAPPS-WP-0003-policy-nexus-production-binding.md | | workplan | RAPPS-WP-0014 | blocked | — | workplans/RAPPS-WP-0014-vergabe-invited-pilot.md | | workplan | RAPPS-WP-0015 | active | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | +| workplan | RAPPS-WP-0016 | ready | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0001-T01 | done | — | workplans/RAPPS-WP-0001-cnpg-backup-scheduledbackup-coverage.md | | task | RAPPS-WP-0001-T02 | done | — | workplans/RAPPS-WP-0001-cnpg-backup-scheduledbackup-coverage.md | | task | RAPPS-WP-0001-T03 | done | — | workplans/RAPPS-WP-0001-cnpg-backup-scheduledbackup-coverage.md | @@ -36,3 +37,7 @@ | task | RAPPS-WP-0015-T02 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T03 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T04 | wait | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | +| task | RAPPS-WP-0016-T01 | todo | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | +| task | RAPPS-WP-0016-T02 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | +| task | RAPPS-WP-0016-T03 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | +| task | RAPPS-WP-0016-T04 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | diff --git a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md index 65f2cd8..d02aae2 100644 --- a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md +++ b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md @@ -16,6 +16,7 @@ related_repos: - prj-forgejo-org-refactor - key-cape - railiance-platform +state_hub_workstream_id: "f6b1d6f6-0abb-5774-87b9-bf3cfb640a3b" --- # RAPPS-WP-0016 - Forgejo sign-in through NetKingdom, before the forge closes @@ -49,6 +50,7 @@ side is key-cape `KEY-WP-0038`. id: RAPPS-WP-0016-T01 status: todo priority: high +state_hub_task_id: "98004ed0-83e6-5f6e-9b18-15b8b2aec2e7" ``` ### T01 — Make the local administrator a sealed break-glass @@ -67,6 +69,7 @@ status: wait depends_on: [RAPPS-WP-0016-T01, KEY-WP-0038-T02] blocking_reason: "Needs break-glass in place and the NetKingdom client registered with its secret in custody." priority: high +state_hub_task_id: "13cd3aec-c87d-5d3f-a421-39d8ff131edd" ``` ### T02 — Add the NetKingdom authentication source @@ -93,6 +96,7 @@ status: wait depends_on: [RAPPS-WP-0016-T02] blocking_reason: "Linking and team mapping follow a working authentication source." priority: high +state_hub_task_id: "81f072aa-c316-511e-a2cd-21dd1b1d0a00" ``` ### T03 — Link accounts and map groups to teams @@ -116,6 +120,7 @@ depends_on: [RAPPS-WP-0016-T03] needs_human: true blocking_reason: "Restricting local password sign-in is a founder decision once NetKingdom sign-in is proven." priority: medium +state_hub_task_id: "32c54737-c40f-5fde-9b91-163f8dcb0068" ``` ### T04 — Decide on local password sign-in From 6953a23e6257432ef76b38109fbd4dbab7fc91a1 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 11 Oct 2026 03:12:03 +0200 Subject: [PATCH 22/87] Prepare unapproved exact Policy Nexus image and provenance pins Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade --- .../policy-nexus-production-candidate.json | 20 +++++++++++++++++++ values/policy-nexus-production-candidate.yaml | 6 ++++++ 2 files changed, 26 insertions(+) create mode 100644 bindings/policy-nexus-production-candidate.json create mode 100644 values/policy-nexus-production-candidate.yaml diff --git a/bindings/policy-nexus-production-candidate.json b/bindings/policy-nexus-production-candidate.json new file mode 100644 index 0000000..13ec384 --- /dev/null +++ b/bindings/policy-nexus-production-candidate.json @@ -0,0 +1,20 @@ +{ + "schema_version": "policy-nexus-production-binding/v1", + "status": "prepared-unapproved", + "approved_on": null, + "rapp_repo": "rapp-policy-nexus", + "reef": "reef-railiance", + "rail": "rail-kubernetes", + "namespace": "policy-nexus", + "release": "policy-nexus", + "hostname": "policy.coulomb.social", + "image_repository": "forgejo.coulomb.social/coulomb/policy-nexus", + "image_digest": "sha256:4e0db6a57eb00f577159aa6ee10f910dd67b03fd0541c6ce849f5426fdb99d06", + "publication_manifest_digest": "ba53cc4900e6bb9ab8aa4bac4e58cb5fe7a03d656bafb3f9cb3d9c8ebff7843a", + "source_inventory_digest": "40d9e8852fe4c9e75b71f371d0fdae38664b068e7498711b0ac5671d0f41efb5", + "source_set_digest": "15bda2e332285afc862df8974cf29de403737df5073fa362adfc6d203cf2f256", + "residual_risk_owner": "the-custodian", + "proposal_id": "POLICY-PUB-20261011-SECURITY-LAYER-V08-R1", + "publication_source_revision": "b64a0c52ec1ea8d1efa99dd7f870e580d7c60df8", + "source_lock_digest": "99c695f46a54ead4cfe8f560f3648bf4db19404fe198b156c740115c97d3ed40" +} diff --git a/values/policy-nexus-production-candidate.yaml b/values/policy-nexus-production-candidate.yaml new file mode 100644 index 0000000..7261bcc --- /dev/null +++ b/values/policy-nexus-production-candidate.yaml @@ -0,0 +1,6 @@ +image: + repository: forgejo.coulomb.social/coulomb/policy-nexus + digest: sha256:4e0db6a57eb00f577159aa6ee10f910dd67b03fd0541c6ce849f5426fdb99d06 +publicationManifestDigest: ba53cc4900e6bb9ab8aa4bac4e58cb5fe7a03d656bafb3f9cb3d9c8ebff7843a +sourceInventoryDigest: 40d9e8852fe4c9e75b71f371d0fdae38664b068e7498711b0ac5671d0f41efb5 +sourceSetDigest: 15bda2e332285afc862df8974cf29de403737df5073fa362adfc6d203cf2f256 From 602c9dcc1cf590201eb8bf57925d287f9b6af19a Mon Sep 17 00:00:00 2001 From: custodian-sync Date: Sun, 11 Oct 2026 03:12:44 +0200 Subject: [PATCH 23/87] chore(consistency): sync task status from DB [auto] Updated by fix-consistency on 2026-10-11: - update .custodian-brief.md for railiance-apps Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade --- .custodian-brief.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.custodian-brief.md b/.custodian-brief.md index d2cc7cf..c76ea12 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,7 +2,7 @@ # Custodian Brief — railiance-apps **Domain:** financials -**Last synced:** 2026-10-08 17:13 UTC +**Last synced:** 2026-10-11 01:12 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams @@ -32,7 +32,7 @@ Progress: 2/3 done | workplan_id: `c7fdaa7e-cab8-5d1d-86c2-f1aad7927c57` ## Inbox Hygiene **Stale unread:** 2 message(s) older than 3 day(s) — triage at session start. -**Missing thread_id:** 2 unread message(s) lack supersession chains. +**Missing thread_id:** 4 unread message(s) lack supersession chains. --- ## MCP Orientation (when available) From c4359794e8746ab31b24a7c5d547a9f926548025 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 11 Oct 2026 03:27:48 +0200 Subject: [PATCH 24/87] Reconcile verified Policy Nexus release nine to approved source pins Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade --- bindings/policy-nexus-production.json | 18 ++++++++++++------ values/policy-nexus-production.yaml | 6 ++++++ 2 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 values/policy-nexus-production.yaml diff --git a/bindings/policy-nexus-production.json b/bindings/policy-nexus-production.json index 0ebbe1d..3fddec0 100644 --- a/bindings/policy-nexus-production.json +++ b/bindings/policy-nexus-production.json @@ -1,7 +1,7 @@ { "schema_version": "policy-nexus-production-binding/v1", "status": "release-approved", - "approved_on": "2026-09-01", + "approved_on": "2026-10-11", "rapp_repo": "rapp-policy-nexus", "reef": "reef-railiance", "rail": "rail-kubernetes", @@ -9,9 +9,15 @@ "release": "policy-nexus", "hostname": "policy.coulomb.social", "image_repository": "forgejo.coulomb.social/coulomb/policy-nexus", - "image_digest": "sha256:23df0ab3dc80d2a92b8058eeb572c222ceb05d17434f173a24142b2450ab3cea", - "publication_manifest_digest": "09f9f7edcc46e31d4b3f73b62d16fdf17808ae1f893f312242b7b9e2037319e0", - "source_inventory_digest": "9658803bc79d19cef1a9c842d1a8c161f1998d59ce6e4bc4df3c88aba2f4c9c4", - "source_set_digest": "03f04902a3559099433795453829d75470aa1490dd243b42e7703b6eab87772f", - "residual_risk_owner": "the-custodian" + "image_digest": "sha256:4e0db6a57eb00f577159aa6ee10f910dd67b03fd0541c6ce849f5426fdb99d06", + "publication_manifest_digest": "ba53cc4900e6bb9ab8aa4bac4e58cb5fe7a03d656bafb3f9cb3d9c8ebff7843a", + "source_inventory_digest": "40d9e8852fe4c9e75b71f371d0fdae38664b068e7498711b0ac5671d0f41efb5", + "source_set_digest": "15bda2e332285afc862df8974cf29de403737df5073fa362adfc6d203cf2f256", + "residual_risk_owner": "the-custodian", + "proposal_id": "POLICY-PUB-20261011-SECURITY-LAYER-V08-R1", + "publication_source_revision": "b64a0c52ec1ea8d1efa99dd7f870e580d7c60df8", + "source_lock_digest": "99c695f46a54ead4cfe8f560f3648bf4db19404fe198b156c740115c97d3ed40", + "approval_ref": "informed-decision:infd-20261011-policy-nexus-hosting-r1@1; approval:52a3c479-2cb1-4074-abec-6a88117ad596", + "verified_helm_revision": 9, + "native_verification_ref": "rapp-policy-nexus/docs/evidence/2026-10-11-security-layer-v08-native-hosting/" } diff --git a/values/policy-nexus-production.yaml b/values/policy-nexus-production.yaml new file mode 100644 index 0000000..7261bcc --- /dev/null +++ b/values/policy-nexus-production.yaml @@ -0,0 +1,6 @@ +image: + repository: forgejo.coulomb.social/coulomb/policy-nexus + digest: sha256:4e0db6a57eb00f577159aa6ee10f910dd67b03fd0541c6ce849f5426fdb99d06 +publicationManifestDigest: ba53cc4900e6bb9ab8aa4bac4e58cb5fe7a03d656bafb3f9cb3d9c8ebff7843a +sourceInventoryDigest: 40d9e8852fe4c9e75b71f371d0fdae38664b068e7498711b0ac5671d0f41efb5 +sourceSetDigest: 15bda2e332285afc862df8974cf29de403737df5073fa362adfc6d203cf2f256 From 367f01cb4b5859ce0b4598a1ab652904b83f7f84 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 11 Oct 2026 04:09:45 +0200 Subject: [PATCH 25/87] Prepare reviewed Forgejo consumer ESO scope and truthful recovery records Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade --- .../consumer-delivery.yaml | 57 ++ .../native-metadata.json | 666 ++++++++++++++++++ .../observe-native-metadata.py | 31 + .../owner-design.md | 39 + .../owner-source-pins.json | 37 + .../primary-source-provenance.json | 11 + .../validation.json | 89 +++ 7 files changed, 930 insertions(+) create mode 100644 docs/proposals/2026-10-11-forgejo-consumer-eso/consumer-delivery.yaml create mode 100644 docs/proposals/2026-10-11-forgejo-consumer-eso/native-metadata.json create mode 100644 docs/proposals/2026-10-11-forgejo-consumer-eso/observe-native-metadata.py create mode 100644 docs/proposals/2026-10-11-forgejo-consumer-eso/owner-design.md create mode 100644 docs/proposals/2026-10-11-forgejo-consumer-eso/owner-source-pins.json create mode 100644 docs/proposals/2026-10-11-forgejo-consumer-eso/primary-source-provenance.json create mode 100644 docs/proposals/2026-10-11-forgejo-consumer-eso/validation.json diff --git a/docs/proposals/2026-10-11-forgejo-consumer-eso/consumer-delivery.yaml b/docs/proposals/2026-10-11-forgejo-consumer-eso/consumer-delivery.yaml new file mode 100644 index 0000000..01e2a98 --- /dev/null +++ b/docs/proposals/2026-10-11-forgejo-consumer-eso/consumer-delivery.yaml @@ -0,0 +1,57 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: forgejo-keycape-forgejo-eso + namespace: forgejo + labels: + app.kubernetes.io/name: forgejo + app.kubernetes.io/part-of: railiance-apps +automountServiceAccountToken: false +--- +apiVersion: external-secrets.io/v1 +kind: SecretStore +metadata: + name: openbao-keycape-forgejo-consumer + namespace: forgejo +spec: + provider: + vault: + server: http://openbao.openbao.svc:8200 + path: platform + version: v2 + auth: + kubernetes: + mountPath: kubernetes + role: external-secrets-keycape-forgejo-forgejo + serviceAccountRef: + name: forgejo-keycape-forgejo-eso + audiences: + - openbao +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: forgejo-netkingdom-oidc-client + namespace: forgejo + labels: + app.kubernetes.io/name: forgejo + app.kubernetes.io/part-of: railiance-apps +spec: + refreshInterval: 15m + secretStoreRef: + name: openbao-keycape-forgejo-consumer + kind: SecretStore + target: + name: forgejo-netkingdom-oidc-client + creationPolicy: Owner + deletionPolicy: Retain + template: + metadata: + labels: + app.kubernetes.io/name: forgejo + app.kubernetes.io/part-of: railiance-apps + data: + - secretKey: CLIENT_SECRET + remoteRef: + key: workloads/keycape/forgejo + property: CLIENT_SECRET diff --git a/docs/proposals/2026-10-11-forgejo-consumer-eso/native-metadata.json b/docs/proposals/2026-10-11-forgejo-consumer-eso/native-metadata.json new file mode 100644 index 0000000..298d885 --- /dev/null +++ b/docs/proposals/2026-10-11-forgejo-consumer-eso/native-metadata.json @@ -0,0 +1,666 @@ +{ + "eso_rbac_bindings": [ + { + "kind": "ClusterRoleBinding", + "name": "external-secrets-cert-controller", + "namespace": null, + "roleRef": { + "apiGroup": "rbac.authorization.k8s.io", + "kind": "ClusterRole", + "name": "external-secrets-cert-controller" + }, + "rules": [ + { + "apiGroups": [ + "apiextensions.k8s.io" + ], + "resources": [ + "customresourcedefinitions" + ], + "verbs": [ + "get", + "list", + "watch", + "update", + "patch" + ] + }, + { + "apiGroups": [ + "admissionregistration.k8s.io" + ], + "resources": [ + "validatingwebhookconfigurations" + ], + "verbs": [ + "list", + "watch", + "get" + ] + }, + { + "apiGroups": [ + "admissionregistration.k8s.io" + ], + "resourceNames": [ + "secretstore-validate", + "externalsecret-validate" + ], + "resources": [ + "validatingwebhookconfigurations" + ], + "verbs": [ + "update", + "patch" + ] + }, + { + "apiGroups": [ + "" + ], + "resources": [ + "endpoints" + ], + "verbs": [ + "list", + "get", + "watch" + ] + }, + { + "apiGroups": [ + "" + ], + "resources": [ + "events" + ], + "verbs": [ + "create", + "patch" + ] + }, + { + "apiGroups": [ + "" + ], + "resources": [ + "secrets" + ], + "verbs": [ + "get", + "list", + "watch", + "update", + "patch" + ] + }, + { + "apiGroups": [ + "coordination.k8s.io" + ], + "resources": [ + "leases" + ], + "verbs": [ + "get", + "create", + "update", + "patch" + ] + } + ], + "subjects": [ + { + "kind": "ServiceAccount", + "name": "external-secrets-cert-controller", + "namespace": "external-secrets" + } + ] + }, + { + "kind": "ClusterRoleBinding", + "name": "external-secrets-controller", + "namespace": null, + "roleRef": { + "apiGroup": "rbac.authorization.k8s.io", + "kind": "ClusterRole", + "name": "external-secrets-controller" + }, + "rules": [ + { + "apiGroups": [ + "external-secrets.io" + ], + "resources": [ + "secretstores", + "clustersecretstores", + "externalsecrets", + "clusterexternalsecrets", + "pushsecrets", + "clusterpushsecrets" + ], + "verbs": [ + "get", + "list", + "watch" + ] + }, + { + "apiGroups": [ + "external-secrets.io" + ], + "resources": [ + "externalsecrets", + "externalsecrets/status", + "externalsecrets/finalizers", + "secretstores", + "secretstores/status", + "secretstores/finalizers", + "clustersecretstores", + "clustersecretstores/status", + "clustersecretstores/finalizers", + "clusterexternalsecrets", + "clusterexternalsecrets/status", + "clusterexternalsecrets/finalizers", + "pushsecrets", + "pushsecrets/status", + "pushsecrets/finalizers", + "clusterpushsecrets", + "clusterpushsecrets/status", + "clusterpushsecrets/finalizers" + ], + "verbs": [ + "get", + "update", + "patch" + ] + }, + { + "apiGroups": [ + "generators.external-secrets.io" + ], + "resources": [ + "generatorstates" + ], + "verbs": [ + "get", + "list", + "watch", + "create", + "update", + "patch", + "delete", + "deletecollection" + ] + }, + { + "apiGroups": [ + "generators.external-secrets.io" + ], + "resources": [ + "acraccesstokens", + "clustergenerators", + "ecrauthorizationtokens", + "fakes", + "gcraccesstokens", + "githubaccesstokens", + "quayaccesstokens", + "passwords", + "stssessiontokens", + "uuids", + "vaultdynamicsecrets", + "webhooks", + "grafanas" + ], + "verbs": [ + "get", + "list", + "watch" + ] + }, + { + "apiGroups": [ + "" + ], + "resources": [ + "serviceaccounts", + "namespaces" + ], + "verbs": [ + "get", + "list", + "watch" + ] + }, + { + "apiGroups": [ + "" + ], + "resources": [ + "configmaps" + ], + "verbs": [ + "get", + "list", + "watch" + ] + }, + { + "apiGroups": [ + "" + ], + "resources": [ + "secrets" + ], + "verbs": [ + "get", + "list", + "watch", + "create", + "update", + "delete", + "patch" + ] + }, + { + "apiGroups": [ + "" + ], + "resources": [ + "serviceaccounts/token" + ], + "verbs": [ + "create" + ] + }, + { + "apiGroups": [ + "" + ], + "resources": [ + "events" + ], + "verbs": [ + "create", + "patch" + ] + }, + { + "apiGroups": [ + "external-secrets.io" + ], + "resources": [ + "externalsecrets" + ], + "verbs": [ + "create", + "update", + "delete" + ] + }, + { + "apiGroups": [ + "external-secrets.io" + ], + "resources": [ + "pushsecrets" + ], + "verbs": [ + "create", + "update", + "delete" + ] + } + ], + "subjects": [ + { + "kind": "ServiceAccount", + "name": "external-secrets", + "namespace": "external-secrets" + } + ] + }, + { + "kind": "RoleBinding", + "name": "external-secrets-leaderelection", + "namespace": "external-secrets", + "roleRef": { + "apiGroup": "rbac.authorization.k8s.io", + "kind": "Role", + "name": "external-secrets-leaderelection" + }, + "rules": [ + { + "apiGroups": [ + "" + ], + "resourceNames": [ + "external-secrets-controller" + ], + "resources": [ + "configmaps" + ], + "verbs": [ + "get", + "update", + "patch" + ] + }, + { + "apiGroups": [ + "" + ], + "resources": [ + "configmaps" + ], + "verbs": [ + "create" + ] + }, + { + "apiGroups": [ + "coordination.k8s.io" + ], + "resources": [ + "leases" + ], + "verbs": [ + "get", + "create", + "update", + "patch" + ] + } + ], + "subjects": [ + { + "kind": "ServiceAccount", + "name": "external-secrets", + "namespace": "external-secrets" + } + ] + } + ], + "external-secrets_deployments": [ + { + "images": [ + "oci.external-secrets.io/external-secrets/external-secrets:v0.16.1" + ], + "name": "external-secrets", + "serviceAccount": "external-secrets", + "uid": "e6017556-523a-4b26-b9d1-4c0c90cef483" + }, + { + "images": [ + "oci.external-secrets.io/external-secrets/external-secrets:v0.16.1" + ], + "name": "external-secrets-cert-controller", + "serviceAccount": "external-secrets-cert-controller", + "uid": "5134a56f-aa05-4764-9b27-c56e92203c7d" + }, + { + "images": [ + "oci.external-secrets.io/external-secrets/external-secrets:v0.16.1" + ], + "name": "external-secrets-webhook", + "serviceAccount": "external-secrets-webhook", + "uid": "6e6e4a6e-4c2c-4659-af56-d4281f2fe229" + } + ], + "external-secrets_externalsecrets": [], + "external-secrets_secretstores": [], + "external-secrets_serviceaccounts": [ + { + "automountServiceAccountToken": null, + "name": "default", + "uid": "cc29abe8-5ace-4cc9-b479-cf8db84df42d" + }, + { + "automountServiceAccountToken": false, + "name": "eso-token-renewer", + "uid": "4d44afd7-59e9-44de-9e99-8cd620be41de" + }, + { + "automountServiceAccountToken": null, + "name": "external-secrets", + "uid": "c918dc22-b1ee-4e1f-a8f8-1d15ac0d5a10" + }, + { + "automountServiceAccountToken": null, + "name": "external-secrets-cert-controller", + "uid": "3c78bb78-a3c5-4953-93d4-cf6621e1200a" + }, + { + "automountServiceAccountToken": null, + "name": "external-secrets-webhook", + "uid": "c75dc3ba-42db-4a59-8952-6c94d85d054c" + } + ], + "forgejo_deployments": [ + { + "images": [ + "code.forgejo.org/forgejo/forgejo:11.0.3-rootless" + ], + "name": "forgejo-gitea", + "serviceAccount": "default", + "uid": "e8eff0aa-64bb-4b44-9a80-a66612986355" + }, + { + "images": [ + "docker:27-dind", + "code.forgejo.org/forgejo/runner:6.3.1" + ], + "name": "forgejo-runner", + "serviceAccount": "default", + "uid": "89c8a056-bcae-4940-acdd-2eb2768e108a" + } + ], + "forgejo_externalsecrets": [ + { + "conditions": [ + { + "type": "Ready", + "status": "True", + "reason": "SecretSynced" + } + ], + "data": [ + { + "key": "workloads/forgejo/forgejo-mailer", + "property": "PASSWD", + "secretKey": "MAILER_PASSWD" + } + ], + "name": "forgejo-mailer", + "store": { + "kind": "ClusterSecretStore", + "name": "openbao-forgejo" + }, + "target": "forgejo-mailer", + "uid": "cbbea71e-70a8-4010-aca9-9136920f2f58" + } + ], + "forgejo_secretstores": [], + "forgejo_serviceaccounts": [ + { + "automountServiceAccountToken": null, + "name": "default", + "uid": "2ae15248-4700-46a6-84c6-e3bb3cd33323" + }, + { + "automountServiceAccountToken": false, + "name": "forgejo-mailer-eso", + "uid": "e2214835-32dd-4333-a9c2-6d248ab8bafe" + } + ], + "native_mutation": false, + "observed_at": "2026-10-11T01:20:37.519031+00:00", + "secret_value_reads": false, + "secretstore_crd": { + "uid": "1eefa5c9-6c2a-4a3b-84c6-3941488201c2", + "versions": [ + { + "kubernetes_auth_schema": { + "description": "Kubernetes authenticates with Vault by passing the ServiceAccount\ntoken stored in the named Secret resource to the Vault server.", + "properties": { + "mountPath": { + "default": "kubernetes", + "description": "Path where the Kubernetes authentication backend is mounted in Vault, e.g:\n\"kubernetes\"", + "type": "string" + }, + "role": { + "description": "A required field containing the Vault Role to assume. A Role binds a\nKubernetes ServiceAccount with a set of Vault policies.", + "type": "string" + }, + "secretRef": { + "description": "Optional secret field containing a Kubernetes ServiceAccount JWT used\nfor authenticating with Vault. If a name is specified without a key,\n`token` is the default. If one is not specified, the one bound to\nthe controller will be used.", + "properties": { + "key": { + "description": "A key in the referenced Secret.\nSome instances of this field may be defaulted, in others it may be required.", + "maxLength": 253, + "minLength": 1, + "pattern": "^[-._a-zA-Z0-9]+$", + "type": "string" + }, + "name": { + "description": "The name of the Secret resource being referred to.", + "maxLength": 253, + "minLength": 1, + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$", + "type": "string" + }, + "namespace": { + "description": "The namespace of the Secret resource being referred to.\nIgnored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.", + "maxLength": 63, + "minLength": 1, + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", + "type": "string" + } + }, + "type": "object" + }, + "serviceAccountRef": { + "description": "Optional service account field containing the name of a kubernetes ServiceAccount.\nIf the service account is specified, the service account secret token JWT will be used\nfor authenticating with Vault. If the service account selector is not supplied,\nthe secretRef will be used instead.", + "properties": { + "audiences": { + "description": "Audience specifies the `aud` claim for the service account token\nIf the service account uses a well-known annotation for e.g. IRSA or GCP Workload Identity\nthen this audiences will be appended to the list", + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "description": "The name of the ServiceAccount resource being referred to.", + "maxLength": 253, + "minLength": 1, + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$", + "type": "string" + }, + "namespace": { + "description": "Namespace of the resource being referred to.\nIgnored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.", + "maxLength": 63, + "minLength": 1, + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + }, + "required": [ + "mountPath", + "role" + ], + "type": "object" + }, + "name": "v1", + "served": true, + "storage": true + }, + { + "kubernetes_auth_schema": { + "description": "Kubernetes authenticates with Vault by passing the ServiceAccount\ntoken stored in the named Secret resource to the Vault server.", + "properties": { + "mountPath": { + "default": "kubernetes", + "description": "Path where the Kubernetes authentication backend is mounted in Vault, e.g:\n\"kubernetes\"", + "type": "string" + }, + "role": { + "description": "A required field containing the Vault Role to assume. A Role binds a\nKubernetes ServiceAccount with a set of Vault policies.", + "type": "string" + }, + "secretRef": { + "description": "Optional secret field containing a Kubernetes ServiceAccount JWT used\nfor authenticating with Vault. If a name is specified without a key,\n`token` is the default. If one is not specified, the one bound to\nthe controller will be used.", + "properties": { + "key": { + "description": "A key in the referenced Secret.\nSome instances of this field may be defaulted, in others it may be required.", + "maxLength": 253, + "minLength": 1, + "pattern": "^[-._a-zA-Z0-9]+$", + "type": "string" + }, + "name": { + "description": "The name of the Secret resource being referred to.", + "maxLength": 253, + "minLength": 1, + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$", + "type": "string" + }, + "namespace": { + "description": "The namespace of the Secret resource being referred to.\nIgnored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.", + "maxLength": 63, + "minLength": 1, + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", + "type": "string" + } + }, + "type": "object" + }, + "serviceAccountRef": { + "description": "Optional service account field containing the name of a kubernetes ServiceAccount.\nIf the service account is specified, the service account secret token JWT will be used\nfor authenticating with Vault. If the service account selector is not supplied,\nthe secretRef will be used instead.", + "properties": { + "audiences": { + "description": "Audience specifies the `aud` claim for the service account token\nIf the service account uses a well-known annotation for e.g. IRSA or GCP Workload Identity\nthen this audiences will be appended to the list", + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "description": "The name of the ServiceAccount resource being referred to.", + "maxLength": 253, + "minLength": 1, + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$", + "type": "string" + }, + "namespace": { + "description": "Namespace of the resource being referred to.\nIgnored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.", + "maxLength": 63, + "minLength": 1, + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + }, + "required": [ + "mountPath", + "role" + ], + "type": "object" + }, + "name": "v1beta1", + "served": true, + "storage": false + } + ] + } +} diff --git a/docs/proposals/2026-10-11-forgejo-consumer-eso/observe-native-metadata.py b/docs/proposals/2026-10-11-forgejo-consumer-eso/observe-native-metadata.py new file mode 100644 index 0000000..eb1cd78 --- /dev/null +++ b/docs/proposals/2026-10-11-forgejo-consumer-eso/observe-native-metadata.py @@ -0,0 +1,31 @@ +import json,subprocess,sys,hashlib,datetime +out={'observed_at':datetime.datetime.now(datetime.timezone.utc).isoformat(),'native_mutation':False,'secret_value_reads':False} +def get(args): + r=subprocess.run(['kubectl',*args,'-o','json'],capture_output=True,text=True) + if r.returncode:return None + try:return json.loads(r.stdout) + except Exception:return None +for ns in ['external-secrets','forgejo']: + deps=get(['get','deployments','-n',ns]);out[ns+'_deployments']=None if deps is None else [{'name':d['metadata']['name'],'uid':d['metadata']['uid'],'serviceAccount':d['spec']['template']['spec'].get('serviceAccountName','default'),'images':[c['image'] for c in d['spec']['template']['spec']['containers']]} for d in deps['items']] + sas=get(['get','serviceaccounts','-n',ns]);out[ns+'_serviceaccounts']=None if sas is None else [{'name':s['metadata']['name'],'uid':s['metadata']['uid'],'automountServiceAccountToken':s.get('automountServiceAccountToken')} for s in sas['items']] + stores=get(['get','secretstores','-n',ns]);out[ns+'_secretstores']=None if stores is None else [{'name':s['metadata']['name'],'uid':s['metadata']['uid'],'vault':{k:v for k,v in s['spec'].get('provider',{}).get('vault',{}).items() if k in ['server','path','version','auth']},'conditions':[{k:c[k] for k in ['type','status','reason'] if k in c} for c in s.get('status',{}).get('conditions',[])]} for s in stores['items']] + es=get(['get','externalsecrets','-n',ns]);out[ns+'_externalsecrets']=None if es is None else [{'name':s['metadata']['name'],'uid':s['metadata']['uid'],'store':s['spec']['secretStoreRef'],'target':s['spec'].get('target',{}).get('name'),'data':[{'secretKey':x.get('secretKey'),'key':x.get('remoteRef',{}).get('key'),'property':x.get('remoteRef',{}).get('property')} for x in s['spec'].get('data',[])],'conditions':[{k:c[k] for k in ['type','status','reason'] if k in c} for c in s.get('status',{}).get('conditions',[])]} for s in es['items']] +crd=get(['get','crd','secretstores.external-secrets.io']) +if crd: + versions=[] + for v in crd['spec']['versions']: + a=v['schema']['openAPIV3Schema']['properties']['spec']['properties']['provider']['properties']['vault']['properties']['auth']['properties']['kubernetes'] + versions.append({'name':v['name'],'served':v['served'],'storage':v['storage'],'kubernetes_auth_schema':a}) + out['secretstore_crd']={'uid':crd['metadata']['uid'],'versions':versions} +bindings=[] +for resource in ['clusterrolebindings','rolebindings']: + obj=get(['get',resource,'-A']) + if obj is None:continue + for b in obj['items']: + if any(s.get('kind')=='ServiceAccount' and s.get('namespace')=='external-secrets' for s in b.get('subjects',[])): + ref=b['roleRef'];args=['get',ref['kind'].lower(),ref['name']] + if ref['kind']=='Role':args+=['-n',b['metadata']['namespace']] + role=get(args) + bindings.append({'kind':b['kind'],'name':b['metadata']['name'],'namespace':b['metadata'].get('namespace'),'subjects':b.get('subjects',[]),'roleRef':ref,'rules':None if role is None else role.get('rules',[])}) +out['eso_rbac_bindings']=bindings +print(json.dumps(out,sort_keys=True)) diff --git a/docs/proposals/2026-10-11-forgejo-consumer-eso/owner-design.md b/docs/proposals/2026-10-11-forgejo-consumer-eso/owner-design.md new file mode 100644 index 0000000..a267302 --- /dev/null +++ b/docs/proposals/2026-10-11-forgejo-consumer-eso/owner-design.md @@ -0,0 +1,39 @@ +# Forgejo consumer ESO reader — concrete source proposal + +RAPPS-WP-0016 already chooses ESO delivery for the OIDC client secret. This proposal fills its missing reader identity without asking the founder to make that same source choice again. It is not a live apply, a new credential grant, or first-case success. Existing ORGREF-WP-0002 remains the project driver; CCR42, MASON-WP-0010-T03 and NK-WP-0048-T06 remain the owner work, rather than another workplan. + +Actual read-only railiance01 metadata shows ESO v0.16.1 running as external-secrets/external-secrets, served v1/v1beta1 SecretStore CRDs supporting serviceAccountRef.audiences, and controller RBAC permitting serviceaccounts/token create. Forgejo and its runner currently share forgejo/default; they must not become the OpenBao reader identity. No consumer namespace SecretStore or dedicated OIDC-delivery SA exists in the observed metadata. Existing forgejo-mailer-eso is a separate SMTP lane and remains unchanged. No OpenBao login, capability check, auth operation, Secret value, Secret object retrieval, or native materialization was performed. + +## Exact supported reader + +`consumer-delivery.yaml` prepares exactly three namespaced objects: ServiceAccount forgejo/forgejo-keycape-forgejo-eso with automount=false and no legacy token refs; SecretStore forgejo/openbao-keycape-forgejo-consumer; ExternalSecret forgejo/forgejo-netkingdom-oidc-client. The store requests a token for that dedicated serviceAccountRef, audiences=[openbao], and authenticates to OpenBao Kubernetes role external-secrets-keycape-forgejo-forgejo. The role binds exactly that SA and namespace, only workload-kv-read-keycape-forgejo-forgejo, no default policy, and a 900-second maximum OpenBao token lifetime. ESO's installed source requests a 600-second Kubernetes token and can fall back to legacy SA tokens; owner admission must ensure this new SA has no legacy tokens, never create one to bypass a failed TokenRequest. + +The namespace SecretStore cannot select another namespace's SA; serviceAccountRef.namespace is omitted. No extra RBAC, projected workload token, Deployment SA replacement or controller identity change is proposed. Namespace/default is not an OpenBao-bound reader. ESO remains the shared trusted controller: it already has clusterwide TokenRequest and Secret permissions and can request this SA's token. This separates reader binding/revocation and avoids default identity reuse; it does not isolate this secret from controller compromise or grant new cryptographic isolation. + +The exact KV data ACL is platform/data/workloads/keycape/forgejo, read only, plus existing self-token lookup/revoke stanzas. No metadata/list/parent/sibling access is added. Delivery maps only remote workloads/keycape/forgejo field CLIENT_SECRET to target key CLIENT_SECRET. Target creationPolicy=Owner and deletionPolicy=Retain preserve fail-safe remote-deletion behavior. Refresh is 15m; the value is never present in this source. Existing internal OpenBao HTTP endpoint and controller custody behavior are retained, not upgraded into a TLS/isolation claim. + +## Type and structural boundary + +NetKingdom's proposed successor type 1.2 adds explicit case parameter consumer_namespace, one sixth structural object consumer-eso-role, and separate owner deliver-consumer-secret follow-through/readiness/rollback. All original five rendered structural bodies compare equal to current1.1. Existing acknowledged type 1.1 and accepted case 1.1 remain byte-preserved; the proposed 1.2 case has status proposed and no copied acceptance. Full proposed render is c0afd5576cdae0cbc807eccb2b9fa87ac668f15e32ec7cf79d295bdb23c75b35. Existing acceptance does not silently extend to the new role/runtime delivery. Owner review and exact type/case acknowledgment precede any build, metadata apply or materialized-status receipt. + +The current Mason builder gives every role-backed reader the ESO draft suffix. With two reader roles it silently overwrites one CCR. The new regression proves that defect against current source; the minimal staged fix retains ESO for the KeyCape reader and Forgejo for consumer-reader-policy, plus refuses duplicate output identities.42 actual focused builder/plan/structure tests pass with corrected source, including both independent CCRs, platform validation and a complete six-object structural request. Existing1.1 tests remain passing. No fake proposed 1.2 acceptance is used: Mason correctly refuses to build a merely proposed successor. Test fixtures alone temporarily acknowledge synthetic roles to exercise builder semantics. + +The proposed CCR42 body uses proposed status, bound_claims_confirmed=false, explicit pending1.2 acknowledgment and old CCR's exact historical hash/provenance. Owner validation passes with the expected apply-blocking warning. It is stored separately from the actual in-flight CCR42; no historical review or applied CCR41 is rewritten. Folding this reviewed body into CCR42 remains an owner reconciliation with retained original revision, not a fresh parallel request. + +## Admission, native proof and recovery + +Before an exact scoped native proposal, refresh ESO image/CRD/RBAC, Forgejo/default and runner identity, the three proposed object identities and target metadata through Kubernetes PartialObjectMetadata only. Classify absent, exact preexisting-matched or conflicting; refuse a legacy-token SA, unknown ownership, changed role/policy, audience mismatch or shared target. Never read Secret data. Preserve current UIDs/resourceVersions, authenticated owner change receipts and the exact type/case/render/source pins. No placeholder SA claim substitutes for actual owner admission. + +Platform materialization applies only the reviewed exact-path policy and dedicated Kubernetes-auth role after its own approved CCR path. Fresh owner metadata/capability receipts must prove exact binding and distinguish case-created from preexisting; no minted value or successful delivery is required to prove structure. Mason's existing typed structural contract then covers all six objects, including source layout and catalog pointer; source-only bodies are not authenticated native evidence. Warden native dispatch/descriptors/session receipt and per-owner provenance still need their existing accepted integration review. + +Consumer follow-through applies the three admitted objects, then checks SecretStore/ExternalSecret Ready status and exact target UID/ownerReference through metadata only. Shared/matched objects never acquire delete authority. If ESO reports failure, stop; no manual Secret seeding or controller/static-token fallback is admitted. Minting, KeyCape client/env/groups, and Forgejo Helm OIDC/group enforcement stay distinct existing first-case steps. Forgejo gitea.oauth secret-consumption settings were not changed or Helm-render-verified here; they must be prepared/reviewed before T02 handover, with current local login intact and T01 break-glass acceptance still a prerequisite. Delivery Ready alone does not complete browser/MFA/group checks or forge closure. + +Created-only rollback first reverts the consumer secret reference/auth source through its owner Helm revision. Remove only the admitted trial-created ExternalSecret and independently proven case-created target UID/ownerReference; preserve matched/shared targets. Then remove only created namespace SecretStore and SA. Remove only the created OpenBao role after revoking its issued tokens or proving expiry, then created policy. Role/SA/store deletion does not revoke already issued OpenBao tokens; deletion alone is not deactivation proof. Retain native/audit/source receipts and stop if references, UIDs or ownership changed. Do not infer delete authority from Mason's legacy planned rollback inventory. + +RAPPS-WP-0016-T02, current CCR42, MASON-WP-0010-T03 and first-case acceptance remain unfinished. ORGREF-WP-0002 holds this owner return. Human-only break-glass/account-link/MFA/first-case acknowledgment gates remain separate from source preparation; no new human login is requested by this packet. + +## Supplementary generated-CCR refinement after Platform conditional review + +Exact prior 271 packet is preserved at /tmp/forgejo-consumer-eso-refinement-original-271, alongside original c48. Platform found two additional generator gaps: hardcoded keycape target for the consumer, and delete-only consumer-role lifecycle. Both meaningful counterexamples fail against 271 source before the fix. Current staged generator derives target.workload from consuming_service only for consumer-reader-policy, preserving all KeyCape ESO output fields. A role-backed consumer CCR requires reverting consumer reference, authenticated case-created ownership, preservation of preexisting/shared objects, issued-token owner revocation or independently verified expiry before deactivation, and no new login/remint during recovery. Actual owner native authority remains required; generated wording is not an implemented revocation adapter. + +Forty-four focused builder/plan/structure tests now pass. Earlier 42 and original counterexample/failure history remain retained. The old committed 1.1 bundle still mislabels the consumer workload keycape and therefore correctly fails fresh exact target matching after this correction. That historical bundle is preserved; the checker is not weakened or grandfathered. Local fixtures alone use corrected/rehashed consumer metadata, with a separate regression proving old bundle refusal. Current 1.1 unbound consumer generation changes only its previously wrong target.workload; KeyCape reader output remains unchanged. The proposed 1.2 render/type/case/source remains pending exact acknowledgment, with identical six-object structural body and no native completion claim. Fresh source build/bundle owner reconciliation must occur after exact acceptance before any native apply/materialization; old CCR or bundle approval is not silently reassigned. New source/test hashes are in validation.json and generated projection receipt. diff --git a/docs/proposals/2026-10-11-forgejo-consumer-eso/owner-source-pins.json b/docs/proposals/2026-10-11-forgejo-consumer-eso/owner-source-pins.json new file mode 100644 index 0000000..d64136b --- /dev/null +++ b/docs/proposals/2026-10-11-forgejo-consumer-eso/owner-source-pins.json @@ -0,0 +1,37 @@ +[ + { + "repo": "net-kingdom", + "path": "security-case-types/oidc-browser-relying-party/type.yaml", + "sha256": "f705c64e4875e9b8e18a912fb51c135e77898926d6a451f5cb74ab3c0cbe4d1c" + }, + { + "repo": "net-kingdom", + "path": "security-cases/forgejo-netkingdom-oidc.yaml", + "sha256": "bcc2c6d5bae0d28242b3cdb71e74d8ca297c2afe04766b55d53597f86893351d" + }, + { + "repo": "ops-mason", + "path": "src/ops_mason/case_build.py", + "sha256": "249cc8574939d2c3193f968309781c472c96c199e04206c336463b4d65ea6659" + }, + { + "repo": "ops-mason", + "path": "src/ops_mason/case_structure.py", + "sha256": "71032a5ea613f5226f552f796656599c1a7447a38428f890595d8f252831ce80" + }, + { + "repo": "railiance-platform", + "path": "credential-change-requests/CCR-2026-0042-forgejo-netkingdom-oidc-consumer-reader.yaml", + "sha256": "a36ab567839c12660c8ca468c8e4c6a67e367c92ae2ae51851b8e34c35a277f3" + }, + { + "repo": "railiance-platform", + "path": "credential-change-requests/CCR-2026-0041-forgejo-netkingdom-oidc-keycape-eso-reader.yaml", + "sha256": "e69077ad29166770e4a57b4c4105615e272822d8fde11df591d0c127b158ac67" + }, + { + "repo": "railiance-apps", + "path": "workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md", + "sha256": "a74f9fbc3300739bc0d8e31f1de88c0b7226ff2f6990e7cbb3ae433ac84fdb5e" + } +] diff --git a/docs/proposals/2026-10-11-forgejo-consumer-eso/primary-source-provenance.json b/docs/proposals/2026-10-11-forgejo-consumer-eso/primary-source-provenance.json new file mode 100644 index 0000000..58952b4 --- /dev/null +++ b/docs/proposals/2026-10-11-forgejo-consumer-eso/primary-source-provenance.json @@ -0,0 +1,11 @@ +[ + { + "url": "https://github.com/external-secrets/external-secrets/blob/v0.16.1/pkg/provider/vault/auth_kubernetes.go", + "sha256": "e53f43c0748e43f408b5a7c5323c12c0fb5fb4299a33718181f6d70945112071", + "finding": "serviceAccountRef requests TokenRequest with600second lifetime, then legacy-token fallback if unavailable; namespace local for SecretStore; no serviceAccountRef means mounted controller token path" + }, + { + "url": "https://external-secrets.io/latest/provider/hashicorp-vault/", + "finding": "Official current documentation cross-check; actual runtime compatibility established by v0.16.1 source and installed CRD/RBAC instead of assuming latest behavior." + } +] diff --git a/docs/proposals/2026-10-11-forgejo-consumer-eso/validation.json b/docs/proposals/2026-10-11-forgejo-consumer-eso/validation.json new file mode 100644 index 0000000..156d4ed --- /dev/null +++ b/docs/proposals/2026-10-11-forgejo-consumer-eso/validation.json @@ -0,0 +1,89 @@ +{ + "recorded_at": "2026-10-11T01:34:15.343635+00:00", + "native_metadata_only": true, + "native_auth_or_mutation": false, + "secret_value_reads": false, + "native_eso_image": "oci.external-secrets.io/external-secrets/external-secrets:v0.16.1", + "served_v1_crd_auth_schema_validation": true, + "actual_common_controller_tokenrequest_rbac": true, + "new_rbac_required": false, + "candidate_exact_kubernetes_objects": 3, + "old_materialization_objects": 5, + "new_materialization_objects": 6, + "original_five_object_bodies_unchanged": true, + "added_object_ids": [ + "consumer-eso-role" + ], + "old_full_render_digest": "sha256:e64b56e4590b57b858303fcda1a474693e3dd821bc83df26646fe78fc345498a", + "proposed_full_render_digest": "sha256:c0afd5576cdae0cbc807eccb2b9fa87ac668f15e32ec7cf79d295bdb23c75b35", + "net_type_and_proposed_case_validation_errors": 0, + "mason_tests": { + "current_source_negative_reproduced": { + "passed": 40, + "failed": 1, + "node": "test_two_role_backed_readers_keep_distinct_ccrs_and_complete_structure", + "cause": "Both role-backed readers used same CCR-ESO output identity, overwriting KeyCape request" + }, + "corrected_stage": { + "passed": 44, + "failed": 0, + "seconds": 5.85 + }, + "duplicate_overwrite_guard_verified": true, + "prior_corrected_stage": { + "passed": 42, + "failed": 0, + "seconds": 6.66 + } + }, + "mason_proposed_type_build_refused": true, + "mason_refusal_reason": "allow-unimplemented only applies to acknowledged type; proposed1.2 has no acceptance", + "platform_draft_validation_exit_code": 0, + "platform_draft_warning": "Bound claim is unconfirmed; apply blocked", + "draft_validation_preparation_history": "Initial in_flight draft used unsupported missing-field names. Corrected to ordinary proposed status with explicit pending owner/case review and bound_claims_confirmed=false.", + "source_statuses_unchanged": [ + "acknowledged type1.1", + "accepted case1.1", + "in_flight current CCR42", + "applied historical CCR41" + ], + "runtime_helm_handover_verified": false, + "native_materialized": false, + "native_followthrough_complete": false, + "founder_acknowledgment": null, + "driver": "ORGREF-WP-0002", + "kubernetes_names_bounded_by_type_parameters": true, + "consumer_role_rollback_precedes_policy_rollback": true, + "generated_ccr_refinement": { + "prior_packet_sha256": "271afd04512b3dac642b3385013cac03d28afde22ff97bcca8d0e262ab3d9279", + "prior_packet_preserved_at": "/tmp/forgejo-consumer-eso-refinement-original-271", + "platform_conditional_manifest_sha256": "9e4967bfff57694ceb12883f522edc405ef3fa316e46b88c9bdad47205a180b4", + "before_fix_counterexamples": [ + { + "node": "test_two_role_backed_readers_keep_distinct_ccrs_and_complete_structure", + "result": "failed wrong consumer target keycape vs forgejo" + }, + { + "node": "test_generated_consumer_lifecycle_requires_created_only_token_recovery", + "result": "failed missing created-only/token-revocation-or-expiry lifecycle" + } + ], + "intermediate_check_history": { + "tests": 9, + "passed": 7, + "failed": 2, + "cause": "case-sensitive test string matched lowercase revoke against capitalized sentence; assertions normalized case without removing required semantics" + }, + "candidate_builder_sha256": "c2b140f380407779ce5478ca57c97eb0e6375d6a9cad4591b0b29ddd177db686", + "candidate_build_tests_sha256": "aea22297178b5bb1eaaf5d59bbd1c71112dfc32bed2075b33ccc0598c8fc5be5", + "candidate_structure_tests_sha256": "2e7446eaf01d4bb59d7d8fb3c3cdc5e6e3007b3ae84d8ed0a49f11f745f2e386", + "keycape1.1_generated_output_unchanged": true, + "consumer1.1_generated_output_changes_only_target_workload": true, + "historical1.1_bundle_unchanged_and_now_refused_for_wrong_target": true, + "structural_checker_weakened": false, + "role_backed_consumer_created_only_token_recovery_required": true, + "proposed_type_or_case_new_acceptance": false, + "ruff_check": "not run; ruff executable absent in owner venv and PATH", + "syntax_checks": "all candidate Python AST parse passed" + } +} From d349815195a373da1b6b86f64081c84ee7ccd232 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 11 Oct 2026 08:56:45 +0200 Subject: [PATCH 26/87] Record source-only Forgejo reference migration decision Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade --- ...ence-migration-source-design-approval.json | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/evidence/2026-10-11-forgejo-reference-migration-source-design-approval.json diff --git a/docs/evidence/2026-10-11-forgejo-reference-migration-source-design-approval.json b/docs/evidence/2026-10-11-forgejo-reference-migration-source-design-approval.json new file mode 100644 index 0000000..575ac39 --- /dev/null +++ b/docs/evidence/2026-10-11-forgejo-reference-migration-source-design-approval.json @@ -0,0 +1,44 @@ +{ + "stable_proposal": "NK-FORGEJO-TYPE12-SOURCE-20261011-R1", + "memo_id": "infd-20261011-forgejo-type12-source-r1", + "memo_version": 1, + "review_id": "review-126c6c90-11ba-4609-b8d1-75e054c246cd", + "approval_id": "8e9332c8-754a-420c-b8b7-445cb4fe6765", + "disposition_id": "disp-315d54ad-20f1-40e6-862c-6c7d2e8f0ea8", + "presentation_id": "pres-5a52f932-8c65-46cc-8054-0264f47f934a", + "actual_human_actor": { + "kind": "person", + "sub": "uid=platform-root,ou=people,dc=netkingdom,dc=local" + }, + "actual_disposition_at": "2026-10-11T06:47:39Z", + "confirmed_nonstale": true, + "exact_human_packet_manifest_sha256": "0b377d647ca748cd03c92f9792396412048055d4bde9f7bb06ff6d413f7acd68", + "submission_digest": "sha256:585cee731de30d2b37883a57abfd9eb32f023ee694e8d911989109d5caf80569", + "canonical_case12_source_sha256": "1c868c1da7d9077fae97bc6bc029706ec3dc418cfeaae30dc0f92b5b4ba6f2ab", + "full_render_digest": "sha256:c0afd5576cdae0cbc807eccb2b9fa87ac668f15e32ec7cf79d295bdb23c75b35", + "kind": "human-reference-migration-design-source-receipt", + "case": "forgejo-netkingdom-oidc", + "type": "oidc-browser-relying-party@1.2", + "declared_case_owner": { + "repo": "railiance-apps", + "person": "founder" + }, + "actual_human_design_approval_roles_requested_in_exact_memo": [ + "declared-consumer-owner", + "founder" + ], + "scope": "Separate owner design receipt of exact human source approval; not an agent Apps-owner review and not canonical case acceptance. The requesting coordinator transcribes only the actual founder act matching exact memo terms.", + "canonical_case12_status": "proposed", + "canonical_case_acceptance_recorded": false, + "type_implemented": false, + "bound_claims_confirmed": false, + "execution_permitted": false, + "native_or_credential_or_session_authority": false, + "existing_driver": "ORGREF-WP-0002", + "consumer_task": "RAPPS-WP-0016-T02", + "type_source_receipt_repo": "net-kingdom", + "type_source_receipt_path": "security-case-type-revisions/1.2/oidc-browser-relying-party/decision/acknowledgment.json", + "recording_act": "Transcribe confirmed exact human reference-migration design approval only; requesting coordinator records actual fulfilled roles. No agent Apps-owner review.", + "recorded_status": "source-design-approved", + "type_source_receipt_sha256": "2dcc220febf125a17b3769f7014f18b63979d7121e0f7056a2b0d8b1ae6031b0" +} From c94b301c2a1d8b62893c727357d371d3dfdee5fd Mon Sep 17 00:00:00 2001 From: custodian-sync Date: Sun, 11 Oct 2026 08:58:12 +0200 Subject: [PATCH 27/87] chore(consistency): renormalize lifecycle state [auto] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated by fix-consistency on 2026-10-11: - workplan status: ready → active Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade --- workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md index d02aae2..2b2bd70 100644 --- a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md +++ b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md @@ -4,7 +4,7 @@ type: workplan title: "Forgejo sign-in through NetKingdom, before the forge closes" domain: infotech repo: railiance-apps -status: ready +status: active flavor: implementation owner: claude-code topic_slug: railiance From 390a30a5c1a97eef18a31c755b7b75fc91702bb0 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 13 Oct 2026 13:59:43 +0200 Subject: [PATCH 28/87] security: persist Forgejo receive hook inheritance for new repositories Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2 --- Makefile | 8 +++++++- helm/forgejo-values.yaml | 17 +++++++++++++++++ manifests/forgejo-secret-scan-template.yaml | 20 ++++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 manifests/forgejo-secret-scan-template.yaml diff --git a/Makefile b/Makefile index 7b24eb8..c02679d 100644 --- a/Makefile +++ b/Makefile @@ -63,6 +63,7 @@ FORGEJO_CHART_VERSION ?= 12.5.0 FORGEJO_VALUES ?= helm/forgejo-values.yaml FORGEJO_REGISTRY_VALUES ?= helm/forgejo-registry-values.yaml FORGEJO_SECRETS ?= helm/forgejo-secrets.sops.yaml +FORGEJO_SECRET_SCAN_TEMPLATE ?= manifests/forgejo-secret-scan-template.yaml FORGEJO_INGRESS ?= manifests/forgejo-ingress.yaml FORGEJO_SSH_NODEPORT ?= manifests/forgejo-ssh-nodeport.yaml FORGEJO_BASE_URL ?= https://forgejo.coulomb.social @@ -469,7 +470,10 @@ forgejo-server-dry-run: check-railiance01-kubeconfig ## Helm server dry-run Forg -f <(KUBECONFIG="$(FORGEJO_KUBECONFIG)" tools/forgejo-helm-secrets.sh) \ --dry-run=server --timeout 5m -forgejo-deploy: check-railiance01-kubeconfig forgejo-mailer-es-deploy ## Deploy / upgrade Forgejo on railiance01 +forgejo-secret-scan-template-deploy: check-railiance01-kubeconfig ## Install the immutable, non-secret receive-hook template + KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl apply --server-side -f $(FORGEJO_SECRET_SCAN_TEMPLATE) + +forgejo-deploy: check-railiance01-kubeconfig forgejo-mailer-es-deploy forgejo-secret-scan-template-deploy ## Deploy / upgrade Forgejo on railiance01 @test -n "$$(KUBECONFIG="$(FORGEJO_KUBECONFIG)" kubectl get secret forgejo-mailer -n $(FORGEJO_NAMESPACE) -o name 2>/dev/null)" || { \ echo "Missing secret forgejo-mailer in $(FORGEJO_NAMESPACE)." >&2; \ echo "Ensure ExternalSecret is Ready and OpenBao role external-secrets-forgejo is configured." >&2; \ @@ -643,3 +647,5 @@ help: ## Show this help /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST) .PHONY: check-tools check-sops k8s-server-dry-run apps-pg-status apps-pg-restore-drill check-railiance01-kubeconfig check-inter-hub-image-tag check-inter-hub-image check-vergabe-image-tag check-vergabe-image vergabe-dry-run vergabe-deploy vergabe-ingress-deploy vergabe-status vergabe-migrate vergabe-seed vergabe-superuser vergabe-logs vergabe-db-url-secret check-coulomb-social-image-tag coulomb-social-dry-run coulomb-social-deploy coulomb-social-ingress-deploy coulomb-social-status coulomb-social-logs coulomb-social-env-secret coulomb-social-env-secret-dry-run eso-deploy forgejo-openbao-eso-token-apply forgejo-openbao-store-deploy forgejo-mailer-es-deploy forgejo-mailer-es-status forgejo-dry-run forgejo-server-dry-run forgejo-deploy forgejo-ingress-deploy forgejo-ssh-nodeport-deploy forgejo-status forgejo-smoke forgejo-npm-smoke forgejo-verify forgejo-operator-bootstrap forgejo-runner-registration-sops-bootstrap forgejo-secrets-check forgejo-logs forgejo-runner-registration-deploy forgejo-runner-deploy forgejo-runner-status forgejo-runner-logs inter-hub-render-baseline inter-hub-dry-run inter-hub-server-dry-run inter-hub-deploy inter-hub-status inter-hub-release-info inter-hub-smoke inter-hub-logs reuse-dry-run reuse-deploy reuse-status reuse-smoke reuse-logs reuse-forgejo-webhook reuse-openbao-eso-token-apply reuse-openbao-store-deploy reuse-runtime-es-deploy reuse-runtime-es-status reuse-webhook-smoke policy-nexus-check-binding policy-nexus-check-image policy-nexus-render policy-nexus-server-dry-run policy-nexus-deploy policy-nexus-status policy-nexus-smoke policy-nexus-rollback help + +.PHONY: forgejo-secret-scan-template-deploy diff --git a/helm/forgejo-values.yaml b/helm/forgejo-values.yaml index 44861c9..f5e479a 100644 --- a/helm/forgejo-values.yaml +++ b/helm/forgejo-values.yaml @@ -30,6 +30,21 @@ redis-cluster: valkey-cluster: enabled: false +# File subPath keeps Git from copying ConfigMap projection symlinks. +extraVolumes: +- name: secret-scan-template + configMap: + name: forgejo-secret-template-b1d7cf8cd13f + defaultMode: 365 +extraContainerVolumeMounts: +- &id001 + name: secret-scan-template + mountPath: /opt/forge-secret-template/hooks/pre-receive.d/10-custodian-secret-scan + subPath: hook + readOnly: true +extraInitVolumeMounts: +- *id001 + gitea: admin: username: forgejo_admin @@ -37,6 +52,8 @@ gitea: passwordMode: initialOnlyRequireReset # Mailer password from OpenBao via ExternalSecret forgejo-mailer (not Helm/SOPS). additionalConfigFromEnvs: + - name: GITEA__GIT_0X2E_CONFIG__INIT_0X2E_TEMPLATEDIR + value: /opt/forge-secret-template - name: GITEA__mailer__PASSWD valueFrom: secretKeyRef: diff --git a/manifests/forgejo-secret-scan-template.yaml b/manifests/forgejo-secret-scan-template.yaml new file mode 100644 index 0000000..7142fec --- /dev/null +++ b/manifests/forgejo-secret-scan-template.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: forgejo-secret-template-b1d7cf8cd13f + namespace: forgejo + labels: + app.kubernetes.io/part-of: forgejo + custodian.coulomb.social/workplan: FORGE-WP-0006 +immutable: true +data: + hook: '#!/bin/sh + + set +vx + + set -eu + + exec /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan + --scanner /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks + + ' From 50b66c4e44d78c1fb1e89991439eda14722103b3 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 13 Oct 2026 14:23:00 +0200 Subject: [PATCH 29/87] security: restore pinned scanner custody before Forgejo startup Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2 --- helm/forgejo-values.yaml | 49 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/helm/forgejo-values.yaml b/helm/forgejo-values.yaml index f5e479a..8d3b8ee 100644 --- a/helm/forgejo-values.yaml +++ b/helm/forgejo-values.yaml @@ -45,6 +45,55 @@ extraContainerVolumeMounts: extraInitVolumeMounts: - *id001 +# Verify admitted scanner custody after Kubernetes fsGroup volume processing. +# Only the existing security subtree is mounted; no credentials are needed. +preExtraInitContainers: +- name: verify-secret-scanner + image: code.forgejo.org/forgejo/forgejo@sha256:691657c7e64af7730a2e885e6d022cf16ae60724931c5392bd61838afadcaeba + imagePullPolicy: IfNotPresent + command: + - sh + - -c + - "#!/bin/sh\nset +vx\nset -eu\ntest \"$(id -u)\" = 1000\ntest -d /data && test ! -L /data\ntest -d /data/security\ + \ && test ! -L /data/security\ntest -d /data/security/receive-secret-scan && test ! -L /data/security/receive-secret-scan\n\ + test -d /data/security/receive-secret-scan/20261012-57a0642b6251 && test ! -L /data/security/receive-secret-scan/20261012-57a0642b6251\n\ + test -f /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan && test ! -L /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan\n\ + test \"$(stat -c %u /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan)\" = 1000\n\ + test \"$(sha256sum /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan | cut -d \"\ + \ \" -f 1)\" = 57a0642b625105440fe146fceb4d244fd2dd08a75beb316831675643e6cc27ec\ncase \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan)\"\ + \ in 755|775) ;; *) exit 21;; esac\ntest -f /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks\ + \ && test ! -L /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks\ntest \"$(stat -c %u /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks)\"\ + \ = 1000\ntest \"$(sha256sum /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks | cut -d \" \"\ + \ -f 1)\" = 88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509\ncase \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks)\"\ + \ in 755|775) ;; *) exit 21;; esac\nif [ \"${1:-check}\" = apply ]; then\n chmod 755 /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan\n\ + \ test \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan)\" = 755\n\ + \ chmod 755 /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks\n test \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks)\"\ + \ = 755\nfi\n" + - sh + - apply + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + runAsNonRoot: true + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + cpu: 100m + memory: 64Mi + volumeMounts: + - name: data + mountPath: /data/security + subPath: security + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + gitea: admin: username: forgejo_admin From 706461b845086ee2df8ce9acae3dae7feebfffda Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 13 Oct 2026 18:41:10 +0200 Subject: [PATCH 30/87] security: restrict runner Docker API to pod loopback Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2 --- manifests/forgejo-runner.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/manifests/forgejo-runner.yaml b/manifests/forgejo-runner.yaml index 3db38ea..ccbb94b 100644 --- a/manifests/forgejo-runner.yaml +++ b/manifests/forgejo-runner.yaml @@ -65,7 +65,7 @@ spec: fsGroup: 1000 initContainers: - name: register - image: code.forgejo.org/forgejo/runner:6.3.1 + image: code.forgejo.org/forgejo/runner@sha256:5071e6832313bafe71577e05631bece88caff08fcfb372193e4a21941f4ed54b imagePullPolicy: IfNotPresent command: ["/bin/sh", "/scripts/register.sh"] env: @@ -88,7 +88,7 @@ spec: readOnly: true containers: - name: dind - image: docker:27-dind + image: docker.io/library/docker@sha256:aa3df78ecf320f5fafdce71c659f1629e96e9de0968305fe1de670e0ca9176ce imagePullPolicy: IfNotPresent securityContext: privileged: true @@ -98,15 +98,15 @@ spec: args: - dockerd - -H - - tcp://0.0.0.0:2375 + - tcp://127.0.0.1:2375 - --tls=false readinessProbe: - tcpSocket: - port: 2375 + exec: + command: ["sh", "-c", "wget -q -O- -T 3 http://127.0.0.1:2375/_ping | grep -qx OK"] initialDelaySeconds: 10 periodSeconds: 10 - name: runner - image: code.forgejo.org/forgejo/runner:6.3.1 + image: code.forgejo.org/forgejo/runner@sha256:5071e6832313bafe71577e05631bece88caff08fcfb372193e4a21941f4ed54b imagePullPolicy: IfNotPresent env: - name: DOCKER_HOST From 72bcad86d7531bcc27f0e465a9385be559ba0c9f Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 14 Oct 2026 06:41:38 +0200 Subject: [PATCH 31/87] docs(reuse): establish reviewed NetKingdom security capabilities Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a137ca-5c77-7a71-b9ae-23ad9b0b5684 --- ...y.railiance.workload-deployment-tooling.md | 99 +++++++++---------- registry/indexes/capabilities.yaml | 19 ++-- 2 files changed, 55 insertions(+), 63 deletions(-) diff --git a/registry/capabilities/capability.railiance.workload-deployment-tooling.md b/registry/capabilities/capability.railiance.workload-deployment-tooling.md index 4e64f96..063141c 100644 --- a/registry/capabilities/capability.railiance.workload-deployment-tooling.md +++ b/registry/capabilities/capability.railiance.workload-deployment-tooling.md @@ -1,8 +1,7 @@ --- id: capability.railiance.workload-deployment-tooling -name: Railiance Application Workload Deployment Tooling -summary: S5 Workloads and Experience Endpoints layer of the Railiance OAS Stack — application Helm releases, - Kubernetes workload manifests, deployment guardrails, and smoke-test/check tooling for user-facing services. +name: Workload release configuration and guards +summary: Helm/manifests, exact image pins, application runbooks and rollout/rollback. owner: railiance-apps status: draft domain: financials @@ -11,21 +10,25 @@ tags: - kubernetes - helm - deployment +- security +- netkingdom +- framework-reuse maturity: discovery: current: D3 target: D4 confidence: medium - rationale: SCOPE.md places this repo precisely in the Railiance OAS Stack layering (S5), scoped to - application Helm releases, workload manifests, and operator runbooks for user-facing services such - as vergabe-teilnahme. + rationale: SCOPE.md places this repo precisely in the Railiance OAS Stack layering + (S5), scoped to application Helm releases, workload manifests, and operator + runbooks for user-facing services such as vergabe-teilnahme. availability: current: A2 target: A3 confidence: medium - rationale: No top-level package manifest, but ships real, runnable tooling under tools/ (build-database-url-secret.sh, - check-oci-image.sh, check-sops.sh, forgejo-smoke.sh, inter-hub-smoke.sh, k8s-server-dry-run.sh, - smoke-service.sh) plus charts/ and manifests/; has both .gitea and .forgejo CI already. + rationale: 'Source interface assessed 2026-10-14: Helm/manifests, exact image + pins, application runbooks and rollout/rollback. Availability describes the + delivery form, not production acceptance. Release settings depend on underlying + platform and workload owners.' external_evidence: completeness: level: C1 @@ -33,8 +36,8 @@ external_evidence: basis: scope_vs_intent_and_consumer_expectations satisfied_expectations: - Helm charts and Kubernetes manifests for application workloads - - smoke-test and dry-run check scripts, including a forgejo-smoke.sh already ahead of the platform-wide - Forgejo transition + - smoke-test and dry-run check scripts, including a forgejo-smoke.sh already ahead + of the platform-wide Forgejo transition broken_expectations: [] out_of_scope_expectations: [] reliability: @@ -44,33 +47,32 @@ external_evidence: known_reliability_risks: - no top-level packaging; tooling is a collection of shell scripts under tools/ discovery: - intent: 'Own the S5 layer of the Railiance OAS Stack: deploying and verifying user-facing application - workloads via Helm/Kubernetes with guardrails and operator runbooks.' + intent: Helm/manifests, exact image pins, application runbooks and rollout/rollback includes: - - application Helm releases and Kubernetes workload manifests - - deployment guardrails - - smoke-test and dry-run tooling (including forgejo-smoke.sh) + - Helm/manifests, exact image pins, application runbooks and rollout/rollback excludes: - - cluster/platform substrate (see railiance-cluster, railiance-platform) - - CI/CD enablement templates (see railiance-enablement) + - Release settings depend on underlying platform and workload owners assumptions: [] - use_cases: [] + use_cases: + - 'U12: Developer builds and publishes through controlled source, dependency and + runner paths' + - 'U13: Platform owner deploys and rolls back a workload with explicit substrate + and custody bindings' research_memos: [] availability: current_level: A2 target_level: A3 current_artifacts: - - Helm charts - - Kubernetes manifests - - tools/*.sh smoke and check scripts + - SCOPE.md target_artifacts: [] consumption_modes: - - cli (shell scripts) - - helm/kubernetes deployment + - CLI + - source relations: depends_on: [] supports: [] - related_to: [] + related_to: + - capability.security.iam-tooling-suite evidence: documentation: - SCOPE.md @@ -83,45 +85,34 @@ evidence: incidents: [] consumer_guidance: recommended_for: - - operators deploying or verifying Railiance user-facing application workloads + - Developer builds and publishes through controlled source, dependency and runner + paths + - Platform owner deploys and rolls back a workload with explicit substrate and custody + bindings not_recommended_for: - - needs at the cluster/platform substrate layer (see railiance-cluster, railiance-platform) + - Release settings depend on underlying platform and workload owners known_limitations: - - no unified packaging; tooling is a set of independent shell scripts + - Release settings depend on underlying platform and workload owners + - 2026-10-14 architecture review estimates operational maturity separately; a registry + entry does not authorize execution or establish deployment. promotion_history: [] --- -# Railiance Application Workload Deployment Tooling +# Workload release configuration and guards -## Overview +Helm/manifests, exact image pins, application runbooks and rollout/rollback. -`railiance-apps` is the S5 Workloads and Experience Endpoints layer of the Railiance OAS Stack: Helm releases, Kubernetes workload manifests, deployment guardrails, and operator runbooks for user-facing services such as `vergabe-teilnahme`, including a `forgejo-smoke.sh` check already ahead of the platform-wide Gitea-to-Forgejo transition. +## Reuse boundary -## Assessment notes +Release settings depend on underlying platform and workload owners. -### Discovery +## Supported use cases -SCOPE.md places this repo precisely in the Railiance OAS Stack layering (S5), scoped to application Helm releases, workload manifests, and operator runbooks for user-facing services such as vergabe-teilnahme. +- U12: Developer builds and publishes through controlled source, dependency and runner paths +- U13: Platform owner deploys and rolls back a workload with explicit substrate and custody bindings -### Availability +## Evidence and integration -No top-level package manifest, but ships real, runnable tooling under tools/ (build-database-url-secret.sh, check-oci-image.sh, check-sops.sh, forgejo-smoke.sh, inter-hub-smoke.sh, k8s-server-dry-run.sh, smoke-service.sh) plus charts/ and manifests/; has both .gitea and .forgejo CI already. +- [SCOPE.md](../../SCOPE.md) -### Completeness - -First-pass honest assessment from the REUSE-WP-0017 coverage campaign -(reuse-surface). No external consumer feedback exists yet; levels reflect -scope-vs-intent documentation quality, not internal code quality. - -### Reliability - -No production consumer telemetry exists yet; reliability level is -intentionally conservative pending REUSE-WP-0019 reuse-telemetry evidence. - -## Promotion checklist - -- [x] ID follows `capability..` pattern -- [x] Maturity enums match `specs/CapabilityMaturityStandard.md` -- [x] `external_evidence` is populated separately from `maturity` -- [ ] Relations reference valid capability IDs (none yet) -- [x] Index entry added in `registry/indexes/capabilities.yaml` +Assessment: CUST-WP-0081, 2026-10-14. Relations identify interfaces to compose; they do not certify native integration. Existing external evidence levels are retained where present. New entries use conservative C1/R0 until consumer assessment exists. diff --git a/registry/indexes/capabilities.yaml b/registry/indexes/capabilities.yaml index 38b1f91..a311af5 100644 --- a/registry/indexes/capabilities.yaml +++ b/registry/indexes/capabilities.yaml @@ -1,22 +1,23 @@ version: 1 -updated: '2026-07-06' +updated: '2026-10-14' domain: helix_forge capabilities: - id: capability.railiance.workload-deployment-tooling - name: Railiance Application Workload Deployment Tooling - summary: S5 Workloads and Experience Endpoints layer of the Railiance OAS Stack — application Helm releases, - Kubernetes workload manifests, deployment guardrails, and smoke-test/check tooling for user-facing - services. - vector: D3 / A2 / C1 / R1 + name: Workload release configuration and guards + summary: Helm/manifests, exact image pins, application runbooks and rollout/rollback. domain: financials status: draft owner: railiance-apps - path: registry/capabilities/capability.railiance.workload-deployment-tooling.md tags: - railiance - kubernetes - helm - deployment + - security + - netkingdom + - framework-reuse + vector: D3 / A2 / C1 / R1 + path: registry/capabilities/capability.railiance.workload-deployment-tooling.md consumption_modes: - - cli (shell scripts) - - helm/kubernetes deployment + - CLI + - source From fb7d56d3dcafad744080e0c969a704e8306ea796 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 15 Oct 2026 01:47:29 +0200 Subject: [PATCH 32/87] Prepare scoped reuse reader release with qualified content digests Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a137ca-5c77-7a71-b9ae-23ad9b0b5684 --- charts/reuse-surface/templates/_helpers.tpl | 10 + .../reuse-surface/templates/deployment.yaml | 2 + charts/reuse-surface/values.schema.json | 30 + charts/reuse-surface/values.yaml | 5 + .../apps-owner-review-r2.md | 119 ++++ .../local-verification-r2.json | 96 +++ .../owner-review-receipt-r2.json | 30 + .../apps-owner-release-review.md | 57 ++ .../phase-a-local-image-r1/manifest.json | 12 + .../phase-a-full-chart.proposed.yaml | 478 +++++++++++++ .../phase-a-preparation-receipt.json | 67 ++ .../phase-a-supplement.patch | 665 ++++++++++++++++++ .../phase-a-values.proposed.yaml | 21 + .../preparation-manifest-r2.json | 29 + .../private-reader-values.r1.yaml | 5 + ...use-surface-runtime-externalsecret.r1.yaml | 46 ++ .../runtime-auth-source-allowlist-r1.json | 18 + .../source-repair-owner-refinement-r3.json | 24 + .../supervision-preparation-r1.json | 10 + .../verify_preparation.py | 94 +++ .../reuse_authenticated_source_inventory.py | 62 ++ ...PS-WP-0017-reuse-private-reader-release.md | 57 ++ 22 files changed, 1937 insertions(+) create mode 100644 charts/reuse-surface/values.schema.json create mode 100644 docs/assessments/cust81-private-reader/apps-owner-review-r2.md create mode 100644 docs/assessments/cust81-private-reader/local-verification-r2.json create mode 100644 docs/assessments/cust81-private-reader/owner-review-receipt-r2.json create mode 100644 docs/assessments/cust81-private-reader/phase-a-local-image-r1/apps-owner-release-review.md create mode 100644 docs/assessments/cust81-private-reader/phase-a-local-image-r1/manifest.json create mode 100644 docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-full-chart.proposed.yaml create mode 100644 docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-preparation-receipt.json create mode 100644 docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-supplement.patch create mode 100644 docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-values.proposed.yaml create mode 100644 docs/assessments/cust81-private-reader/preparation-manifest-r2.json create mode 100644 docs/assessments/cust81-private-reader/private-reader-values.r1.yaml create mode 100644 docs/assessments/cust81-private-reader/reuse-surface-runtime-externalsecret.r1.yaml create mode 100644 docs/assessments/cust81-private-reader/runtime-auth-source-allowlist-r1.json create mode 100644 docs/assessments/cust81-private-reader/source-repair-owner-refinement-r3.json create mode 100644 docs/assessments/cust81-private-reader/supervision-preparation-r1.json create mode 100644 docs/assessments/cust81-private-reader/verify_preparation.py create mode 100644 scripts/reuse_authenticated_source_inventory.py create mode 100644 workplans/RAPPS-WP-0017-reuse-private-reader-release.md diff --git a/charts/reuse-surface/templates/_helpers.tpl b/charts/reuse-surface/templates/_helpers.tpl index 1c46bdb..cdcae93 100644 --- a/charts/reuse-surface/templates/_helpers.tpl +++ b/charts/reuse-surface/templates/_helpers.tpl @@ -32,11 +32,21 @@ app.kubernetes.io/component: landing {{- end -}} {{- define "reuse.image" -}} +{{- if and .Values.authSourceAllowlist (not .Values.image.digest) -}} +{{- fail "authenticated source allowlist requires reviewed hardened image.digest" -}} +{{- end -}} +{{- if .Values.image.digest -}} +{{- if not (regexMatch "^sha256:[a-f0-9]{64}$" .Values.image.digest) -}} +{{- fail "image.digest must be sha256 plus 64 lowercase hex characters" -}} +{{- end -}} +{{- printf "%s@%s" .Values.image.repository .Values.image.digest -}} +{{- else -}} {{- if not .Values.image.tag -}} {{- fail "image.tag is required - pin it in helm/reuse-surface-values.yaml" -}} {{- end -}} {{- printf "%s:%s" .Values.image.repository .Values.image.tag -}} {{- end -}} +{{- end -}} {{- define "reuse.landingImage" -}} {{- if not .Values.landing.image.tag -}} diff --git a/charts/reuse-surface/templates/deployment.yaml b/charts/reuse-surface/templates/deployment.yaml index b1c47e7..06b815d 100644 --- a/charts/reuse-surface/templates/deployment.yaml +++ b/charts/reuse-surface/templates/deployment.yaml @@ -36,6 +36,8 @@ spec: - secretRef: name: {{ .Values.envSecretName | quote }} env: + - name: REUSE_SURFACE_AUTH_SOURCE_ALLOWLIST + value: {{ .Values.authSourceAllowlist | toJson | quote }} - name: REUSE_SURFACE_LISTEN_HOST value: "0.0.0.0" - name: REUSE_SURFACE_LISTEN_PORT diff --git a/charts/reuse-surface/values.schema.json b/charts/reuse-surface/values.schema.json new file mode 100644 index 0000000..7367c10 --- /dev/null +++ b/charts/reuse-surface/values.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "image": { + "type": "object", + "properties": { + "digest": { + "type": "string", + "pattern": "^(|sha256:[a-f0-9]{64})$" + } + } + }, + "authSourceAllowlist": { + "type": "object", + "propertyNames": { + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "additionalProperties": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^https://[^\\s]+$" + }, + "minItems": 1 + } + } + } +} diff --git a/charts/reuse-surface/values.yaml b/charts/reuse-surface/values.yaml index fb54e76..0e520d2 100644 --- a/charts/reuse-surface/values.yaml +++ b/charts/reuse-surface/values.yaml @@ -1,6 +1,7 @@ image: repository: forgejo.coulomb.social/coulomb/reuse-surface tag: "" + digest: "" pullPolicy: IfNotPresent replicaCount: 1 @@ -26,6 +27,10 @@ resources: envSecretName: reuse-surface-env +# Non-secret exact URL bindings for authenticated remote metadata fetches. +# Nonempty bindings require an immutable digest; use only a reviewed hardened image. +authSourceAllowlist: {} + landing: enabled: false image: diff --git a/docs/assessments/cust81-private-reader/apps-owner-review-r2.md b/docs/assessments/cust81-private-reader/apps-owner-review-r2.md new file mode 100644 index 0000000..a7f7b1b --- /dev/null +++ b/docs/assessments/cust81-private-reader/apps-owner-review-r2.md @@ -0,0 +1,119 @@ +# Railiance apps preparation review — CUST81 private metadata reader R2 + +Reviewer: delegated railiance-apps preparation worker, supervised local preparation +assignment. No promotion evidence or existing own-scope supervision record was +found; the descriptive preparation record grants no runtime authority. This is +an S5 release-contract review grounded in INTENT principles 1–5 (workloads, +release contracts, repeatable operations, explicit S3 handoff, verified health). +It is not a founder decision, independent platform credential review or reuse +application source review. No live changes, commits, pushes, hub writes, image +publishes, login or credential retrieval occurred. + +Disposition: **approved for integration as local preparation only; live promotion blocked**. +Canonical review disposition: `approved`. This accepts the bounded S5 proposal +and its explicit runtime gates, not live execution or another owner’s review. + +## Proposed changes and owner boundaries + +The chart accepts an optional immutable image digest, adds a non-secret exact +`REUSE_SURFACE_AUTH_SOURCE_ALLOWLIST` environment binding, and fails rendering +when a nonempty allowlist lacks an immutable digest. Schema checks enforce +HTTPS allowlist entries and valid digest syntax. Production values continue +using their existing `main-6cbc862` tag; production ExternalSecret is unchanged. +The preparation override contains exactly 14 reviewed pinned metadata URLs and +an empty digest deliberately preventing deployment. A synthetic all-zero digest +in local tests is not an image and must never be promoted. + +The proposed phase-B ExternalSecret preserves existing two keys, target metadata +and existing `openbao-reuse` ClusterSecretStore, adding only +`REUSE_SURFACE_FORGEJO_SOURCE_READ_TOKEN` from +`workloads/reuse/reuse-surface/forgejo-source-read:FORGEJO_SOURCE_TOKEN`. +Platform owns the dedicated KV path/policy/store read scope, CCR-2026-0046 and +credential issuance; apps neither provisions nor consumes an admin credential. + +Digest presence cannot prove the hardened application is included. The reuse +owner must provide published build digest, source revision, tests and review +binding *both* authenticated redirect rejection and exact environment-name/URL +credential routing. A reader must not reach the existing vulnerable runtime. + +## Ordered promotion gates — not executed + +1. Confirm own-scope supervision and founder change approval, reviewed CCR, + exact seven-source owner consent packet and scoped Forgejo read-only issuer + receipt. Integrate provisional RAPPS-WP-0017 through root owner coordination. +2. Run metadata inventory helper with an explicitly verified railiance01 + kubeconfig; compare existing authenticated registrations with newly + fail-closed image behavior. The helper reads only SQLite registration + metadata, never env/Secrets, captures command errors and refuses unsafe + URLs. If it refuses, resolve via the source owner without emitting raw + objects. Any existing authenticated source requires separately reviewed + exact URL bindings including capability entry URLs before promotion; do not + silently orphan sources or broaden the 14-URL map. Missing inventory blocks + phase A. Store only safe names, counts and validated metadata URLs. +3. Read live release image/revision, replica state, PVC storage/access modes and + readiness. Confirm node external IP `92.205.62.239`, not retired coulombcore. + Record requested CPU/memory headroom and pending pods before rollout. Existing + API requests 100m CPU/256Mi, surge1 requires that much additional allocatable + capacity; landing unchanged. Confirm the existing PVC supports simultaneous + pod scheduling/mount on this single node; stop if RWO/RWOP or mount conflicts + prevent it. No rollout strategy/resource/PVC changes are proposed. Do not + change other workloads merely to obtain capacity. +4. Once a real reviewed digest exists, fill the preparation values; render + exact chart with production values plus proposal overrides. Server dry-run + and client diff must cover every rendered chart object and the phase-B ESO + manifest. Preserve stderr in memory and emit sanitized status only; never + print live Secrets, Helm secret values, annotations or raw API failures. + Existing release owner and any Argo adoption must be verified before choosing + owner Helm upgrade versus GitOps sync; neither is executed here. +5. Phase A: approved release of hardened digest plus approved allowlist, with + existing ESO fields only. Owner Helm path must use atomic upgrade, timeout + and wait; verify actual running imageID matches digest, API health/probes, + public federation baseline63 and credential-routing refusal/redirect tests. + Deployment failure blocks phase B. Database/PVC remains unchanged. Collect a + separately approved safe backup/recovery receipt before any risky live release. +6. Phase B: after verified phase A, execute reviewed platform credential action + and ESO reader mapping. Verify only ESO condition/status and key-presence + booleans via approved suppressing helpers, then restart/sync the hardened + workload so envFrom obtains the reader. Never dump Secret or process env. +7. Reuse owner runs native exact-byte authenticated proof for all14 URLs and + seven capability entries using pinned consent hashes. Only then register + seven sources through the scoped write route and verify hosted70/70, + no duplicates/conflicting IDs and public federation preserves all63. + +## Rollback contract + +Before execution capture previous approved release revision, digest/tag and +safe configuration, public discovery baseline and database recovery receipt. +If phase A fails before credential delivery, atomic restore of previous release +is safe because no reader has been supplied. After phase B, **never downgrade to +vulnerable runtime with reader still available**: revoke the dedicated reader +at Forgejo/OpenBao via credential owner, remove phase-B ESO mapping and materialized +reader key through reviewed silent maintenance, restart current hardened pods, +verify absence/revocation, then restore prior approved chart/image only if needed. +Metadata registrations may remain inaccessible until re-established; record the +regression explicitly. Do not roll back the PVC or use stale tokens as recovery. + +## Local evidence and limitations + +`helm lint charts/reuse-surface -f helm/reuse-surface-values.yaml` passed. +`python3 docs/assessments/cust81-private-reader/verify_preparation.py` passed ten +checks, including exact14 roundtrip, digest/schema refusal, retention of current +strategy/resources/PVC, correct isolated ESO mapping, metadata URL sanitization +and subprocess error suppression. `local-verification-r2.json` contains command +statuses without raw output. No cluster inventory, resource headroom, +server dry-run/diff, image publish, live approval or native fetch has been proven. + +## Revision history — same proposal, no second trial + +R1 root review found the inventory SQL incorrectly selected `$.index_url` rather +than the native HubStore registration field `$.url`. R1's local checks did not +exercise the pod SQL and therefore missed this defect. R1 must not be executed; +it remains preserved in the root assembly archive. R2 selects `$.url` and tests +the exact read-only pod SQL against a real SQLite registration payload containing +`repo`, `url`, `auth_env`, timestamps and an unrelated sentinel description, plus +an anonymous registration. Only expected names/URL emerge; rows remain unchanged. +R2 also accepts canonical Forgejo raw branch/commit/tag index routes while still +refusing userinfo, non-HTTPS, credentials in queries and fragments. Untrusted +successful subprocess output is refused without raw echo, as are failures. +No live inventory command has run. This corrective refinement is the same +CUST81-APPS-PRIVATE-READER-RELEASE proposal revision2, not an unchanged success. diff --git a/docs/assessments/cust81-private-reader/local-verification-r2.json b/docs/assessments/cust81-private-reader/local-verification-r2.json new file mode 100644 index 0000000..dbd1366 --- /dev/null +++ b/docs/assessments/cust81-private-reader/local-verification-r2.json @@ -0,0 +1,96 @@ +{ + "status": "passed", + "local_only": true, + "real_image_digest": null, + "synthetic_digest_used_only_to_check_rendering": true, + "checks": [ + "production rendering unchanged except empty fail-closed allowlist", + "unknown/malformed digest fails", + "HTTP allowlist fails schema", + "exact14 URLs roundtrip", + "strategy resources PVC retained", + "dedicated ESO mapping preserves current fields/store", + "metadata sanitizer excludes userinfo/query secrets/fragments", + "kubectl failure output discarded", + "exact pod SQL real payload URL keys readonly fixture and anonymous filtering", + "legacy Forgejo branch URL accepted without weakening credential query refusal" + ], + "helm_calls": [ + { + "command": [ + "helm", + "template", + "reuse", + "/tmp/cust81-apps-reader-owner/charts/reuse-surface", + "-f", + "/tmp/cust81-apps-reader-owner/helm/reuse-surface-values.yaml" + ], + "exit_code": 0, + "expected_success": true + }, + { + "command": [ + "helm", + "template", + "reuse", + "/tmp/cust81-apps-reader-owner/charts/reuse-surface", + "-f", + "/tmp/cust81-apps-reader-owner/helm/reuse-surface-values.yaml", + "-f", + "/tmp/cust81-apps-reader-owner/docs/assessments/cust81-private-reader/private-reader-values.r1.yaml" + ], + "exit_code": 1, + "expected_success": false + }, + { + "command": [ + "helm", + "template", + "reuse", + "/tmp/cust81-apps-reader-owner/charts/reuse-surface", + "-f", + "/tmp/cust81-apps-reader-owner/helm/reuse-surface-values.yaml", + "-f", + "/tmp/cust81-apps-reader-owner/docs/assessments/cust81-private-reader/private-reader-values.r1.yaml", + "--set", + "image.digest=sha256:bad" + ], + "exit_code": 1, + "expected_success": false + }, + { + "command": [ + "helm", + "template", + "reuse", + "/tmp/cust81-apps-reader-owner/charts/reuse-surface", + "-f", + "/tmp/cust81-apps-reader-owner/helm/reuse-surface-values.yaml", + "-f", + "/tmp/cust81-apps-reader-owner/docs/assessments/cust81-private-reader/private-reader-values.r1.yaml", + "--set-json", + "authSourceAllowlist={\"BAD\":[\"http://attacker.invalid\"]}", + "--set", + "image.digest=sha256:0000000000000000000000000000000000000000000000000000000000000000" + ], + "exit_code": 1, + "expected_success": false + }, + { + "command": [ + "helm", + "template", + "reuse", + "/tmp/cust81-apps-reader-owner/charts/reuse-surface", + "-f", + "/tmp/cust81-apps-reader-owner/helm/reuse-surface-values.yaml", + "-f", + "/tmp/cust81-apps-reader-owner/docs/assessments/cust81-private-reader/private-reader-values.r1.yaml", + "--set", + "image.digest=sha256:0000000000000000000000000000000000000000000000000000000000000000" + ], + "exit_code": 0, + "expected_success": true + } + ] +} diff --git a/docs/assessments/cust81-private-reader/owner-review-receipt-r2.json b/docs/assessments/cust81-private-reader/owner-review-receipt-r2.json new file mode 100644 index 0000000..c81c89b --- /dev/null +++ b/docs/assessments/cust81-private-reader/owner-review-receipt-r2.json @@ -0,0 +1,30 @@ +{ + "reviewer_owner": "railiance-apps", + "reviewer_assignment": "delegated-railiance-apps-cust81-preparation", + "disposition": "approved", + "approved_scope": "isolated S5 chart/ESO preparation integration only", + "live_execution_approval": false, + "workitem": "RAPPS-WP-0017", + "intent_basis": [ + "Release Contracts over Source Internals", + "Repeatable Operations", + "Clear Lower-Layer Handoffs", + "Evidence Before Confidence" + ], + "review_artifact_sha256": "a3a2f2db6f0a8461a0d8299825c842c8ce427f59c86238884609d463837b3b75", + "reviewed_manifest_sha256": "45d467c2bb48462a388f5df4ba5188492d406bdddbddfbf3487bd2077486dec8", + "reviewed_patch_sha256": "39ebaa7f4050f9204fe8ee38364fbbce874903447ac45ccd408cf434aa6e4a73", + "gates": [ + "unknown reviewed hardened image digest", + "metadata-only existing authenticated source inventory/migration", + "live node headroom/PVC/readiness survey", + "server dry-run and client diff", + "release-specific EUR estimates and founder live-change approval", + "phase A verified before phase B credential delivery", + "approved executed platform credential CCR", + "native14 URL/7 metadata hash proof then70/70 hosted" + ], + "proposal_id": "CUST81-APPS-PRIVATE-READER-RELEASE-R1", + "revision": 2, + "refinement_or_rescue": true +} diff --git a/docs/assessments/cust81-private-reader/phase-a-local-image-r1/apps-owner-release-review.md b/docs/assessments/cust81-private-reader/phase-a-local-image-r1/apps-owner-release-review.md new file mode 100644 index 0000000..0079fe5 --- /dev/null +++ b/docs/assessments/cust81-private-reader/phase-a-local-image-r1/apps-owner-release-review.md @@ -0,0 +1,57 @@ +# Apps concrete phase A proposal review + +Disposition: `approved` for proposal preparation and local render only. +This supplement preserves the reviewed core R2 patch unchanged. It supplies +actual `reuse` Helm release / `reuse` namespace chart rendering with the locally +built hardened image digest. It does not authorize publication, execute a live +change or approve platform's CCR. INTENT basis: source-controlled release +contracts, explicit S3 handoff and evidence before confidence. + +The proposed image is +`forgejo.coulomb.social/coulomb/reuse-surface@sha256:2364b7e7859daccf9340a063654e2b09eed1bc27e0adf7b23cbb4b30135a7db0`. +The local build receipt confirms no-network/read-only health and native source +hash smoke. Registry publication is false. Before apply the exact reviewed +artifact must be published and independently verified by registry manifest +digest. If publication changes manifest representation/digest, revise and review +the bound proposal rather than silently replacing the digest. + +Current observations are node `92.205.62.239`, CPU requests3560m of4000m (440m +free), API100m/256Mi, replicas1 with surge1/unavailable0, PVC +`reuse-surface-data` Bound RWO1Gi, and Helm ownership without Argo tracking. +The proposed chart retains resources, strategy and PVC. One100m/256Mi surge fits +observed CPU headroom; recheck memory, pending pods and node headroom immediately +before rollout. RWO single-node does not prohibit two pods on that same node, +but driver mount behavior and SQLite concurrent-access readiness remain +verification gates. Do not change deployment strategy or PVC to bypass a failure. + +Exact readonly authenticated-source inventory is0 at its receipt timestamp. +This clears the observed migration gate, but recheck before apply; no credential +or Secret/env was read. Existing Kubernetes-auth `openbao-reuse` store is Ready +with namespace/ServiceAccount/role matching the consumer; its current +ExternalSecret contains only the two old fields and remains unchanged in phase A. + +Local full-chart render and assertions passed: exact proposed image and14 URL +allowlist; same100m/256Mi requests; same1surge/0unavailable/replicas1; no Secret, +ExternalSecret or store objects added. Full chart includes existing landing, +ingress, services and PVC and must receive normal full-chart server dry-run and +client diff, not a Deployment-only narrow review. + +Incremental infrastructure cost is EUR0 because node, replica steady state, +requests, services and PVC capacity remain unchanged. Build/registry execution +cost is not independently accounted. Credible operational loss is qualitative +and not priced in EUR: rollout failure or concurrent SQLite/PVC access may cause +service interruption or recovery work. No autopilot grant is inferred. Exact +human review must accept these estimates and remaining readiness gates. + +Before founder-authorized deploy, capture current Helm revision and safe rollback +reference (old running digest +`sha256:2c1514cb3f5e1297ffeeac912ba36a70f8a2591588a5a4643bc58293e97132de`), +known recovery receipt and63 public capability baseline. Use actual Helm release +`reuse`, namespace`reuse`, owner atomic/wait/timeout path after safe dry-run/diff. +No exact deploy command is executed by this proposal. Phase A retains the current +runtime Secret fields and supplies no reader. Verify running imageID, readiness, +public federation and credential refusal/redirect behavior before phase B. +If phase A fails with no reader delivered, restore the approved previous release. +After phase B, revoke/remove reader and verify its absence before any vulnerable +image restoration. Phase B is independently gated by approved/executed CCR and +native14 URL/7 entry-hash proof before70/70 hosted discovery. diff --git a/docs/assessments/cust81-private-reader/phase-a-local-image-r1/manifest.json b/docs/assessments/cust81-private-reader/phase-a-local-image-r1/manifest.json new file mode 100644 index 0000000..15cc477 --- /dev/null +++ b/docs/assessments/cust81-private-reader/phase-a-local-image-r1/manifest.json @@ -0,0 +1,12 @@ +{ + "proposal_id": "CUST81-APPS-PHASE-A-LOCAL-IMAGE-R1", + "reviewer_owner": "railiance-apps", + "disposition": "approved", + "approved_scope": "preparation only, local full-chart render, supervised founder review required", + "files": { + "docs/assessments/cust81-private-reader/phase-a-local-image-r1/apps-owner-release-review.md": "a74e96986798a727fc87c0d3c578601579778dfd8521796d0bb81df8451c751b", + "docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-full-chart.proposed.yaml": "36c20c3e16757d3e888a3db02853032b33e791aa04a6055fe001121254345b81", + "docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-preparation-receipt.json": "3282a17ae3724619e40dd1f34846d83b2297d9ae00030618dbd269e8e2fd7d39", + "docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-values.proposed.yaml": "35284857ec987fc7a929f5c2eecdb44c7d3367f7835904692947de09918ab8f1" + } +} diff --git a/docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-full-chart.proposed.yaml b/docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-full-chart.proposed.yaml new file mode 100644 index 0000000..4059c3a --- /dev/null +++ b/docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-full-chart.proposed.yaml @@ -0,0 +1,478 @@ +# PREPARATION RENDER ONLY; no registry publication or server validation proved. +--- +# Source: reuse-surface/templates/landing-configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: reuse-surface-landing + labels: + app.kubernetes.io/name: reuse-surface + app.kubernetes.io/instance: reuse + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/part-of: railiance-apps + helm.sh/chart: reuse-surface-0.1.0 + app.kubernetes.io/component: landing +data: + index.html: | + + + + + + + + REUSE federation endpoint + + + +
+

reuse.coulomb.social

+

REUSE federation endpoint

+

This is the Railiance REUSE capability federation service. It is an API endpoint for automated clients and operators; there is no browser login for this service.

+ + +
+ + +--- +# Source: reuse-surface/templates/pvc.yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: reuse-surface-data + labels: + app.kubernetes.io/name: reuse-surface + app.kubernetes.io/instance: reuse + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/part-of: railiance-apps + helm.sh/chart: reuse-surface-0.1.0 +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +# Source: reuse-surface/templates/landing-service.yaml +apiVersion: v1 +kind: Service +metadata: + name: reuse-surface-landing + labels: + app.kubernetes.io/name: reuse-surface + app.kubernetes.io/instance: reuse + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/part-of: railiance-apps + helm.sh/chart: reuse-surface-0.1.0 + app.kubernetes.io/component: landing +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: reuse-surface + app.kubernetes.io/instance: reuse + app.kubernetes.io/component: landing + ports: + - name: http + port: 8080 + targetPort: http + protocol: TCP +--- +# Source: reuse-surface/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: reuse-surface + labels: + app.kubernetes.io/name: reuse-surface + app.kubernetes.io/instance: reuse + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/part-of: railiance-apps + helm.sh/chart: reuse-surface-0.1.0 +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: reuse-surface + app.kubernetes.io/instance: reuse + app.kubernetes.io/component: api + ports: + - name: http + port: 8000 + targetPort: http + protocol: TCP +--- +# Source: reuse-surface/templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: reuse-surface + labels: + app.kubernetes.io/name: reuse-surface + app.kubernetes.io/instance: reuse + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/part-of: railiance-apps + helm.sh/chart: reuse-surface-0.1.0 + app.kubernetes.io/component: api +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: reuse-surface + app.kubernetes.io/instance: reuse + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + app.kubernetes.io/name: reuse-surface + app.kubernetes.io/instance: reuse + app.kubernetes.io/component: api + spec: + enableServiceLinks: false + securityContext: + {} + containers: + - name: reuse-surface + image: "forgejo.coulomb.social/coulomb/reuse-surface@sha256:2364b7e7859daccf9340a063654e2b09eed1bc27e0adf7b23cbb4b30135a7db0" + imagePullPolicy: IfNotPresent + command: ["reuse-surface", "serve"] + securityContext: + {} + ports: + - name: http + containerPort: 8000 + protocol: TCP + envFrom: + - secretRef: + name: "reuse-surface-env" + env: + - name: REUSE_SURFACE_AUTH_SOURCE_ALLOWLIST + value: "{\"REUSE_SURFACE_FORGEJO_SOURCE_READ_TOKEN\":[\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-openbao/raw/registry/capabilities/capability.railiance.openbao-package.md?ref=35672be138036c8433a5ac5de91e068c03c2b124\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-openbao/raw/registry/indexes/capabilities.yaml?ref=35672be138036c8433a5ac5de91e068c03c2b124\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-policy-nexus/raw/registry/capabilities/capability.railiance.policy-publication-package.md?ref=088b78ac03b0cebeec34a69aae3569eb7ec2f9c3\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-policy-nexus/raw/registry/indexes/capabilities.yaml?ref=088b78ac03b0cebeec34a69aae3569eb7ec2f9c3\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-postgres/raw/registry/capabilities/capability.railiance.postgres-isolation-recovery.md?ref=a9f569c96d74aa384ac8d3ac4aa960e4061550b5\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-postgres/raw/registry/indexes/capabilities.yaml?ref=a9f569c96d74aa384ac8d3ac4aa960e4061550b5\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-sbom-nexus/raw/registry/capabilities/capability.railiance.sbom-runtime-package.md?ref=adbedd349e8df97be43a1f57db7095af45ff29d1\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-sbom-nexus/raw/registry/indexes/capabilities.yaml?ref=adbedd349e8df97be43a1f57db7095af45ff29d1\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-telemetry/raw/registry/capabilities/capability.railiance.telemetry-package.md?ref=f158307e24b86bab88a1af4830ff6b4f85afdeb5\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-telemetry/raw/registry/indexes/capabilities.yaml?ref=f158307e24b86bab88a1af4830ff6b4f85afdeb5\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-tenant-engine/raw/registry/capabilities/capability.railiance.tenant-engine-package.md?ref=9bdc86572728783a5ed94266f04f7b12d414c557\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-tenant-engine/raw/registry/indexes/capabilities.yaml?ref=9bdc86572728783a5ed94266f04f7b12d414c557\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-user-engine/raw/registry/capabilities/capability.railiance.user-engine-package.md?ref=f35108f9e1b8702fb6c526fe42b2fec5ca032738\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-user-engine/raw/registry/indexes/capabilities.yaml?ref=f35108f9e1b8702fb6c526fe42b2fec5ca032738\"]}" + - name: REUSE_SURFACE_LISTEN_HOST + value: "0.0.0.0" + - name: REUSE_SURFACE_LISTEN_PORT + value: "8000" + - name: REUSE_SURFACE_DB + value: "/data/reuse.db" + - name: REUSE_SURFACE_CACHE_DIR + value: "/data/cache" + volumeMounts: + - name: data + mountPath: /data + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 15 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 256Mi + volumes: + - name: data + persistentVolumeClaim: + claimName: reuse-surface-data +--- +# Source: reuse-surface/templates/landing-deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: reuse-surface-landing + labels: + app.kubernetes.io/name: reuse-surface + app.kubernetes.io/instance: reuse + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/part-of: railiance-apps + helm.sh/chart: reuse-surface-0.1.0 + app.kubernetes.io/component: landing +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: reuse-surface + app.kubernetes.io/instance: reuse + app.kubernetes.io/component: landing + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + app.kubernetes.io/name: reuse-surface + app.kubernetes.io/instance: reuse + app.kubernetes.io/component: landing + spec: + enableServiceLinks: false + containers: + - name: landing + image: "nginxinc/nginx-unprivileged:1.27-alpine" + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8080 + protocol: TCP + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 3 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 3 + failureThreshold: 3 + resources: + limits: + cpu: 50m + memory: 64Mi + requests: + cpu: 10m + memory: 32Mi + volumeMounts: + - name: landing-page + mountPath: /usr/share/nginx/html/index.html + subPath: index.html + readOnly: true + volumes: + - name: landing-page + configMap: + name: reuse-surface-landing +--- +# Source: reuse-surface/templates/ingress.yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: reuse-surface + labels: + app.kubernetes.io/name: reuse-surface + app.kubernetes.io/instance: reuse + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/part-of: railiance-apps + helm.sh/chart: reuse-surface-0.1.0 + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" +spec: + ingressClassName: traefik + tls: + - hosts: + - reuse.coulomb.social + secretName: reuse-surface-tls + rules: + - host: reuse.coulomb.social + http: + paths: + - path: /health + pathType: Exact + backend: + service: + name: reuse-surface + port: + number: 8000 + - path: /v1 + pathType: Prefix + backend: + service: + name: reuse-surface + port: + number: 8000 +--- +# Source: reuse-surface/templates/landing-ingress.yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: reuse-surface-landing + labels: + app.kubernetes.io/name: reuse-surface + app.kubernetes.io/instance: reuse + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/part-of: railiance-apps + helm.sh/chart: reuse-surface-0.1.0 + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" + traefik.ingress.kubernetes.io/router.priority: "1" +spec: + ingressClassName: traefik + tls: + - hosts: + - reuse.coulomb.social + secretName: reuse-surface-tls + rules: + - host: reuse.coulomb.social + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: reuse-surface-landing + port: + number: 8080 +--- +# Source: reuse-surface/templates/redirect-ingress.yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: reuse-surface-http-redirect + labels: + app.kubernetes.io/name: reuse-surface + app.kubernetes.io/instance: reuse + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/part-of: railiance-apps + helm.sh/chart: reuse-surface-0.1.0 + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web + traefik.ingress.kubernetes.io/router.middlewares: "reuse-reuse-surface-redirect-https@kubernetescrd" +spec: + ingressClassName: traefik + rules: + - host: reuse.coulomb.social + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: reuse-surface + port: + number: 8000 +--- +# Source: reuse-surface/templates/redirect-middleware.yaml +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: reuse-surface-redirect-https + labels: + app.kubernetes.io/name: reuse-surface + app.kubernetes.io/instance: reuse + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/part-of: railiance-apps + helm.sh/chart: reuse-surface-0.1.0 +spec: + redirectScheme: + scheme: https + permanent: true diff --git a/docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-preparation-receipt.json b/docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-preparation-receipt.json new file mode 100644 index 0000000..c58f4b4 --- /dev/null +++ b/docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-preparation-receipt.json @@ -0,0 +1,67 @@ +{ + "proposal_id": "CUST81-APPS-PHASE-A-LOCAL-IMAGE-R1", + "disposition": "approved", + "approved_scope": "concrete proposal preparation and local render only; not publish or production deploy", + "workitem": "RAPPS-WP-0017-T02", + "release": "reuse", + "namespace": "reuse", + "deployment": "reuse-surface", + "image_reference": "forgejo.coulomb.social/coulomb/reuse-surface@sha256:2364b7e7859daccf9340a063654e2b09eed1bc27e0adf7b23cbb4b30135a7db0", + "registry_publication_verified": false, + "phase_b_credential_delivery_included": false, + "incremental_infrastructure_cost_eur": 0, + "cost_basis": "existing node, same requests and replicas, same PVC, no new service or purchased capacity; build/registry execution cost not independently accounted", + "credible_loss_bound_eur": null, + "risk_basis": "qualitative supervised change: transient rollout or SQLite concurrent-access outage; RWO PVC single-node mounts need preflight; no customer production admitted; data recovery and exact atomic rollback required before execution", + "autopilot_authority": false, + "local_render_checks": "passed", + "evidence": { + "hardened-reader-local-image-r1.json": { + "path": "/home/worsch/the-custodian/docs/assessments/netkingdom-2026-10-14/hardened-reader-local-image-r1.json", + "sha256": "ed7c2516925d7f16b573a70b5e48d5656a4875e9eb94bcc8760a8bdcbe07f59c" + }, + "live-authenticated-source-inventory-r1.json": { + "path": "/home/worsch/the-custodian/docs/assessments/netkingdom-2026-10-14/live-authenticated-source-inventory-r1.json", + "sha256": "ba669d6231c8c611191c7ad28f5914d6c47332032b23b0ea40a13f753e883073" + }, + "reuse-release-runtime-metadata-r1.json": { + "path": "/home/worsch/the-custodian/docs/assessments/netkingdom-2026-10-14/reuse-release-runtime-metadata-r1.json", + "sha256": "5ec901cf41b9f52628ff636c23b5bbc4789df6a9118f5cff0ff8518b287357e9" + }, + "reuse-release-owner-eso-metadata-r1.json": { + "path": "/home/worsch/the-custodian/docs/assessments/netkingdom-2026-10-14/reuse-release-owner-eso-metadata-r1.json", + "sha256": "ff9aa207ae081d27d86820747a993d4bbc290ba2c354eef72388233d6733adb3" + } + }, + "observed_runtime": { + "node_ip": "92.205.62.239", + "cpu_free_m": 440, + "api_surge_cpu_m": 100, + "api_surge_memory_mi": 256, + "pvc": "reuse-surface-data", + "pvc_access": "ReadWriteOnce", + "pvc_capacity": "1Gi", + "previous_image_digest": "forgejo.coulomb.social/coulomb/reuse-surface@sha256:2c1514cb3f5e1297ffeeac912ba36a70f8a2591588a5a4643bc58293e97132de", + "authenticated_sources": 0 + }, + "gates": [ + "publish exact reviewed image and verify registry digest; local digest may require reviewed update if registry transforms manifest", + "founder approval for exact phase A and later credential CCR/phase B", + "recheck target headroom memory node/PVC mount scheduling and authenticated inventory immediately before apply", + "bounded safe server dry-run/client diff and current release revision/rollback capture", + "no ESO reader field during phase A; verify actual imageID health and63public baseline before phase B", + "revoke/remove delivered reader before any downgrade to old vulnerable image" + ], + "render_command": [ + "helm", + "template", + "reuse", + "charts/reuse-surface", + "--namespace", + "reuse", + "-f", + "helm/reuse-surface-values.yaml", + "-f", + "docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-values.proposed.yaml" + ] +} diff --git a/docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-supplement.patch b/docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-supplement.patch new file mode 100644 index 0000000..bdedf68 --- /dev/null +++ b/docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-supplement.patch @@ -0,0 +1,665 @@ +diff --git a/docs/assessments/cust81-private-reader/phase-a-local-image-r1/apps-owner-release-review.md b/docs/assessments/cust81-private-reader/phase-a-local-image-r1/apps-owner-release-review.md +new file mode 100644 +index 0000000..0079fe5 +--- /dev/null ++++ b/docs/assessments/cust81-private-reader/phase-a-local-image-r1/apps-owner-release-review.md +@@ -0,0 +1,57 @@ ++# Apps concrete phase A proposal review ++ ++Disposition: `approved` for proposal preparation and local render only. ++This supplement preserves the reviewed core R2 patch unchanged. It supplies ++actual `reuse` Helm release / `reuse` namespace chart rendering with the locally ++built hardened image digest. It does not authorize publication, execute a live ++change or approve platform's CCR. INTENT basis: source-controlled release ++contracts, explicit S3 handoff and evidence before confidence. ++ ++The proposed image is ++`forgejo.coulomb.social/coulomb/reuse-surface@sha256:2364b7e7859daccf9340a063654e2b09eed1bc27e0adf7b23cbb4b30135a7db0`. ++The local build receipt confirms no-network/read-only health and native source ++hash smoke. Registry publication is false. Before apply the exact reviewed ++artifact must be published and independently verified by registry manifest ++digest. If publication changes manifest representation/digest, revise and review ++the bound proposal rather than silently replacing the digest. ++ ++Current observations are node `92.205.62.239`, CPU requests3560m of4000m (440m ++free), API100m/256Mi, replicas1 with surge1/unavailable0, PVC ++`reuse-surface-data` Bound RWO1Gi, and Helm ownership without Argo tracking. ++The proposed chart retains resources, strategy and PVC. One100m/256Mi surge fits ++observed CPU headroom; recheck memory, pending pods and node headroom immediately ++before rollout. RWO single-node does not prohibit two pods on that same node, ++but driver mount behavior and SQLite concurrent-access readiness remain ++verification gates. Do not change deployment strategy or PVC to bypass a failure. ++ ++Exact readonly authenticated-source inventory is0 at its receipt timestamp. ++This clears the observed migration gate, but recheck before apply; no credential ++or Secret/env was read. Existing Kubernetes-auth `openbao-reuse` store is Ready ++with namespace/ServiceAccount/role matching the consumer; its current ++ExternalSecret contains only the two old fields and remains unchanged in phase A. ++ ++Local full-chart render and assertions passed: exact proposed image and14 URL ++allowlist; same100m/256Mi requests; same1surge/0unavailable/replicas1; no Secret, ++ExternalSecret or store objects added. Full chart includes existing landing, ++ingress, services and PVC and must receive normal full-chart server dry-run and ++client diff, not a Deployment-only narrow review. ++ ++Incremental infrastructure cost is EUR0 because node, replica steady state, ++requests, services and PVC capacity remain unchanged. Build/registry execution ++cost is not independently accounted. Credible operational loss is qualitative ++and not priced in EUR: rollout failure or concurrent SQLite/PVC access may cause ++service interruption or recovery work. No autopilot grant is inferred. Exact ++human review must accept these estimates and remaining readiness gates. ++ ++Before founder-authorized deploy, capture current Helm revision and safe rollback ++reference (old running digest ++`sha256:2c1514cb3f5e1297ffeeac912ba36a70f8a2591588a5a4643bc58293e97132de`), ++known recovery receipt and63 public capability baseline. Use actual Helm release ++`reuse`, namespace`reuse`, owner atomic/wait/timeout path after safe dry-run/diff. ++No exact deploy command is executed by this proposal. Phase A retains the current ++runtime Secret fields and supplies no reader. Verify running imageID, readiness, ++public federation and credential refusal/redirect behavior before phase B. ++If phase A fails with no reader delivered, restore the approved previous release. ++After phase B, revoke/remove reader and verify its absence before any vulnerable ++image restoration. Phase B is independently gated by approved/executed CCR and ++native14 URL/7 entry-hash proof before70/70 hosted discovery. +diff --git a/docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-full-chart.proposed.yaml b/docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-full-chart.proposed.yaml +new file mode 100644 +index 0000000..4059c3a +--- /dev/null ++++ b/docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-full-chart.proposed.yaml +@@ -0,0 +1,478 @@ ++# PREPARATION RENDER ONLY; no registry publication or server validation proved. ++--- ++# Source: reuse-surface/templates/landing-configmap.yaml ++apiVersion: v1 ++kind: ConfigMap ++metadata: ++ name: reuse-surface-landing ++ labels: ++ app.kubernetes.io/name: reuse-surface ++ app.kubernetes.io/instance: reuse ++ app.kubernetes.io/managed-by: Helm ++ app.kubernetes.io/version: "0.1.0" ++ app.kubernetes.io/part-of: railiance-apps ++ helm.sh/chart: reuse-surface-0.1.0 ++ app.kubernetes.io/component: landing ++data: ++ index.html: | ++ ++ ++ ++ ++ ++ ++ ++ REUSE federation endpoint ++ ++ ++ ++
++

reuse.coulomb.social

++

REUSE federation endpoint

++

This is the Railiance REUSE capability federation service. It is an API endpoint for automated clients and operators; there is no browser login for this service.

++ ++ ++
++ ++ ++--- ++# Source: reuse-surface/templates/pvc.yaml ++apiVersion: v1 ++kind: PersistentVolumeClaim ++metadata: ++ name: reuse-surface-data ++ labels: ++ app.kubernetes.io/name: reuse-surface ++ app.kubernetes.io/instance: reuse ++ app.kubernetes.io/managed-by: Helm ++ app.kubernetes.io/version: "0.1.0" ++ app.kubernetes.io/part-of: railiance-apps ++ helm.sh/chart: reuse-surface-0.1.0 ++spec: ++ accessModes: ++ - ReadWriteOnce ++ resources: ++ requests: ++ storage: 1Gi ++--- ++# Source: reuse-surface/templates/landing-service.yaml ++apiVersion: v1 ++kind: Service ++metadata: ++ name: reuse-surface-landing ++ labels: ++ app.kubernetes.io/name: reuse-surface ++ app.kubernetes.io/instance: reuse ++ app.kubernetes.io/managed-by: Helm ++ app.kubernetes.io/version: "0.1.0" ++ app.kubernetes.io/part-of: railiance-apps ++ helm.sh/chart: reuse-surface-0.1.0 ++ app.kubernetes.io/component: landing ++spec: ++ type: ClusterIP ++ selector: ++ app.kubernetes.io/name: reuse-surface ++ app.kubernetes.io/instance: reuse ++ app.kubernetes.io/component: landing ++ ports: ++ - name: http ++ port: 8080 ++ targetPort: http ++ protocol: TCP ++--- ++# Source: reuse-surface/templates/service.yaml ++apiVersion: v1 ++kind: Service ++metadata: ++ name: reuse-surface ++ labels: ++ app.kubernetes.io/name: reuse-surface ++ app.kubernetes.io/instance: reuse ++ app.kubernetes.io/managed-by: Helm ++ app.kubernetes.io/version: "0.1.0" ++ app.kubernetes.io/part-of: railiance-apps ++ helm.sh/chart: reuse-surface-0.1.0 ++spec: ++ type: ClusterIP ++ selector: ++ app.kubernetes.io/name: reuse-surface ++ app.kubernetes.io/instance: reuse ++ app.kubernetes.io/component: api ++ ports: ++ - name: http ++ port: 8000 ++ targetPort: http ++ protocol: TCP ++--- ++# Source: reuse-surface/templates/deployment.yaml ++apiVersion: apps/v1 ++kind: Deployment ++metadata: ++ name: reuse-surface ++ labels: ++ app.kubernetes.io/name: reuse-surface ++ app.kubernetes.io/instance: reuse ++ app.kubernetes.io/managed-by: Helm ++ app.kubernetes.io/version: "0.1.0" ++ app.kubernetes.io/part-of: railiance-apps ++ helm.sh/chart: reuse-surface-0.1.0 ++ app.kubernetes.io/component: api ++spec: ++ replicas: 1 ++ selector: ++ matchLabels: ++ app.kubernetes.io/name: reuse-surface ++ app.kubernetes.io/instance: reuse ++ strategy: ++ type: RollingUpdate ++ rollingUpdate: ++ maxSurge: 1 ++ maxUnavailable: 0 ++ template: ++ metadata: ++ labels: ++ app.kubernetes.io/name: reuse-surface ++ app.kubernetes.io/instance: reuse ++ app.kubernetes.io/component: api ++ spec: ++ enableServiceLinks: false ++ securityContext: ++ {} ++ containers: ++ - name: reuse-surface ++ image: "forgejo.coulomb.social/coulomb/reuse-surface@sha256:2364b7e7859daccf9340a063654e2b09eed1bc27e0adf7b23cbb4b30135a7db0" ++ imagePullPolicy: IfNotPresent ++ command: ["reuse-surface", "serve"] ++ securityContext: ++ {} ++ ports: ++ - name: http ++ containerPort: 8000 ++ protocol: TCP ++ envFrom: ++ - secretRef: ++ name: "reuse-surface-env" ++ env: ++ - name: REUSE_SURFACE_AUTH_SOURCE_ALLOWLIST ++ value: "{\"REUSE_SURFACE_FORGEJO_SOURCE_READ_TOKEN\":[\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-openbao/raw/registry/capabilities/capability.railiance.openbao-package.md?ref=35672be138036c8433a5ac5de91e068c03c2b124\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-openbao/raw/registry/indexes/capabilities.yaml?ref=35672be138036c8433a5ac5de91e068c03c2b124\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-policy-nexus/raw/registry/capabilities/capability.railiance.policy-publication-package.md?ref=088b78ac03b0cebeec34a69aae3569eb7ec2f9c3\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-policy-nexus/raw/registry/indexes/capabilities.yaml?ref=088b78ac03b0cebeec34a69aae3569eb7ec2f9c3\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-postgres/raw/registry/capabilities/capability.railiance.postgres-isolation-recovery.md?ref=a9f569c96d74aa384ac8d3ac4aa960e4061550b5\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-postgres/raw/registry/indexes/capabilities.yaml?ref=a9f569c96d74aa384ac8d3ac4aa960e4061550b5\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-sbom-nexus/raw/registry/capabilities/capability.railiance.sbom-runtime-package.md?ref=adbedd349e8df97be43a1f57db7095af45ff29d1\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-sbom-nexus/raw/registry/indexes/capabilities.yaml?ref=adbedd349e8df97be43a1f57db7095af45ff29d1\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-telemetry/raw/registry/capabilities/capability.railiance.telemetry-package.md?ref=f158307e24b86bab88a1af4830ff6b4f85afdeb5\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-telemetry/raw/registry/indexes/capabilities.yaml?ref=f158307e24b86bab88a1af4830ff6b4f85afdeb5\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-tenant-engine/raw/registry/capabilities/capability.railiance.tenant-engine-package.md?ref=9bdc86572728783a5ed94266f04f7b12d414c557\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-tenant-engine/raw/registry/indexes/capabilities.yaml?ref=9bdc86572728783a5ed94266f04f7b12d414c557\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-user-engine/raw/registry/capabilities/capability.railiance.user-engine-package.md?ref=f35108f9e1b8702fb6c526fe42b2fec5ca032738\",\"https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-user-engine/raw/registry/indexes/capabilities.yaml?ref=f35108f9e1b8702fb6c526fe42b2fec5ca032738\"]}" ++ - name: REUSE_SURFACE_LISTEN_HOST ++ value: "0.0.0.0" ++ - name: REUSE_SURFACE_LISTEN_PORT ++ value: "8000" ++ - name: REUSE_SURFACE_DB ++ value: "/data/reuse.db" ++ - name: REUSE_SURFACE_CACHE_DIR ++ value: "/data/cache" ++ volumeMounts: ++ - name: data ++ mountPath: /data ++ readinessProbe: ++ httpGet: ++ path: /health ++ port: 8000 ++ initialDelaySeconds: 10 ++ periodSeconds: 10 ++ timeoutSeconds: 5 ++ failureThreshold: 3 ++ livenessProbe: ++ httpGet: ++ path: /health ++ port: 8000 ++ initialDelaySeconds: 15 ++ periodSeconds: 30 ++ timeoutSeconds: 5 ++ failureThreshold: 3 ++ resources: ++ limits: ++ cpu: 500m ++ memory: 512Mi ++ requests: ++ cpu: 100m ++ memory: 256Mi ++ volumes: ++ - name: data ++ persistentVolumeClaim: ++ claimName: reuse-surface-data ++--- ++# Source: reuse-surface/templates/landing-deployment.yaml ++apiVersion: apps/v1 ++kind: Deployment ++metadata: ++ name: reuse-surface-landing ++ labels: ++ app.kubernetes.io/name: reuse-surface ++ app.kubernetes.io/instance: reuse ++ app.kubernetes.io/managed-by: Helm ++ app.kubernetes.io/version: "0.1.0" ++ app.kubernetes.io/part-of: railiance-apps ++ helm.sh/chart: reuse-surface-0.1.0 ++ app.kubernetes.io/component: landing ++spec: ++ replicas: 1 ++ selector: ++ matchLabels: ++ app.kubernetes.io/name: reuse-surface ++ app.kubernetes.io/instance: reuse ++ app.kubernetes.io/component: landing ++ strategy: ++ type: RollingUpdate ++ rollingUpdate: ++ maxSurge: 1 ++ maxUnavailable: 0 ++ template: ++ metadata: ++ labels: ++ app.kubernetes.io/name: reuse-surface ++ app.kubernetes.io/instance: reuse ++ app.kubernetes.io/component: landing ++ spec: ++ enableServiceLinks: false ++ containers: ++ - name: landing ++ image: "nginxinc/nginx-unprivileged:1.27-alpine" ++ imagePullPolicy: IfNotPresent ++ ports: ++ - name: http ++ containerPort: 8080 ++ protocol: TCP ++ readinessProbe: ++ httpGet: ++ path: / ++ port: http ++ initialDelaySeconds: 3 ++ periodSeconds: 10 ++ timeoutSeconds: 3 ++ failureThreshold: 3 ++ livenessProbe: ++ httpGet: ++ path: / ++ port: http ++ initialDelaySeconds: 10 ++ periodSeconds: 30 ++ timeoutSeconds: 3 ++ failureThreshold: 3 ++ resources: ++ limits: ++ cpu: 50m ++ memory: 64Mi ++ requests: ++ cpu: 10m ++ memory: 32Mi ++ volumeMounts: ++ - name: landing-page ++ mountPath: /usr/share/nginx/html/index.html ++ subPath: index.html ++ readOnly: true ++ volumes: ++ - name: landing-page ++ configMap: ++ name: reuse-surface-landing ++--- ++# Source: reuse-surface/templates/ingress.yaml ++apiVersion: networking.k8s.io/v1 ++kind: Ingress ++metadata: ++ name: reuse-surface ++ labels: ++ app.kubernetes.io/name: reuse-surface ++ app.kubernetes.io/instance: reuse ++ app.kubernetes.io/managed-by: Helm ++ app.kubernetes.io/version: "0.1.0" ++ app.kubernetes.io/part-of: railiance-apps ++ helm.sh/chart: reuse-surface-0.1.0 ++ annotations: ++ cert-manager.io/cluster-issuer: letsencrypt-prod ++ traefik.ingress.kubernetes.io/router.entrypoints: websecure ++ traefik.ingress.kubernetes.io/router.tls: "true" ++spec: ++ ingressClassName: traefik ++ tls: ++ - hosts: ++ - reuse.coulomb.social ++ secretName: reuse-surface-tls ++ rules: ++ - host: reuse.coulomb.social ++ http: ++ paths: ++ - path: /health ++ pathType: Exact ++ backend: ++ service: ++ name: reuse-surface ++ port: ++ number: 8000 ++ - path: /v1 ++ pathType: Prefix ++ backend: ++ service: ++ name: reuse-surface ++ port: ++ number: 8000 ++--- ++# Source: reuse-surface/templates/landing-ingress.yaml ++apiVersion: networking.k8s.io/v1 ++kind: Ingress ++metadata: ++ name: reuse-surface-landing ++ labels: ++ app.kubernetes.io/name: reuse-surface ++ app.kubernetes.io/instance: reuse ++ app.kubernetes.io/managed-by: Helm ++ app.kubernetes.io/version: "0.1.0" ++ app.kubernetes.io/part-of: railiance-apps ++ helm.sh/chart: reuse-surface-0.1.0 ++ annotations: ++ traefik.ingress.kubernetes.io/router.entrypoints: websecure ++ traefik.ingress.kubernetes.io/router.tls: "true" ++ traefik.ingress.kubernetes.io/router.priority: "1" ++spec: ++ ingressClassName: traefik ++ tls: ++ - hosts: ++ - reuse.coulomb.social ++ secretName: reuse-surface-tls ++ rules: ++ - host: reuse.coulomb.social ++ http: ++ paths: ++ - path: / ++ pathType: Prefix ++ backend: ++ service: ++ name: reuse-surface-landing ++ port: ++ number: 8080 ++--- ++# Source: reuse-surface/templates/redirect-ingress.yaml ++apiVersion: networking.k8s.io/v1 ++kind: Ingress ++metadata: ++ name: reuse-surface-http-redirect ++ labels: ++ app.kubernetes.io/name: reuse-surface ++ app.kubernetes.io/instance: reuse ++ app.kubernetes.io/managed-by: Helm ++ app.kubernetes.io/version: "0.1.0" ++ app.kubernetes.io/part-of: railiance-apps ++ helm.sh/chart: reuse-surface-0.1.0 ++ annotations: ++ traefik.ingress.kubernetes.io/router.entrypoints: web ++ traefik.ingress.kubernetes.io/router.middlewares: "reuse-reuse-surface-redirect-https@kubernetescrd" ++spec: ++ ingressClassName: traefik ++ rules: ++ - host: reuse.coulomb.social ++ http: ++ paths: ++ - path: / ++ pathType: Prefix ++ backend: ++ service: ++ name: reuse-surface ++ port: ++ number: 8000 ++--- ++# Source: reuse-surface/templates/redirect-middleware.yaml ++apiVersion: traefik.io/v1alpha1 ++kind: Middleware ++metadata: ++ name: reuse-surface-redirect-https ++ labels: ++ app.kubernetes.io/name: reuse-surface ++ app.kubernetes.io/instance: reuse ++ app.kubernetes.io/managed-by: Helm ++ app.kubernetes.io/version: "0.1.0" ++ app.kubernetes.io/part-of: railiance-apps ++ helm.sh/chart: reuse-surface-0.1.0 ++spec: ++ redirectScheme: ++ scheme: https ++ permanent: true +diff --git a/docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-preparation-receipt.json b/docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-preparation-receipt.json +new file mode 100644 +index 0000000..c58f4b4 +--- /dev/null ++++ b/docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-preparation-receipt.json +@@ -0,0 +1,67 @@ ++{ ++ "proposal_id": "CUST81-APPS-PHASE-A-LOCAL-IMAGE-R1", ++ "disposition": "approved", ++ "approved_scope": "concrete proposal preparation and local render only; not publish or production deploy", ++ "workitem": "RAPPS-WP-0017-T02", ++ "release": "reuse", ++ "namespace": "reuse", ++ "deployment": "reuse-surface", ++ "image_reference": "forgejo.coulomb.social/coulomb/reuse-surface@sha256:2364b7e7859daccf9340a063654e2b09eed1bc27e0adf7b23cbb4b30135a7db0", ++ "registry_publication_verified": false, ++ "phase_b_credential_delivery_included": false, ++ "incremental_infrastructure_cost_eur": 0, ++ "cost_basis": "existing node, same requests and replicas, same PVC, no new service or purchased capacity; build/registry execution cost not independently accounted", ++ "credible_loss_bound_eur": null, ++ "risk_basis": "qualitative supervised change: transient rollout or SQLite concurrent-access outage; RWO PVC single-node mounts need preflight; no customer production admitted; data recovery and exact atomic rollback required before execution", ++ "autopilot_authority": false, ++ "local_render_checks": "passed", ++ "evidence": { ++ "hardened-reader-local-image-r1.json": { ++ "path": "/home/worsch/the-custodian/docs/assessments/netkingdom-2026-10-14/hardened-reader-local-image-r1.json", ++ "sha256": "ed7c2516925d7f16b573a70b5e48d5656a4875e9eb94bcc8760a8bdcbe07f59c" ++ }, ++ "live-authenticated-source-inventory-r1.json": { ++ "path": "/home/worsch/the-custodian/docs/assessments/netkingdom-2026-10-14/live-authenticated-source-inventory-r1.json", ++ "sha256": "ba669d6231c8c611191c7ad28f5914d6c47332032b23b0ea40a13f753e883073" ++ }, ++ "reuse-release-runtime-metadata-r1.json": { ++ "path": "/home/worsch/the-custodian/docs/assessments/netkingdom-2026-10-14/reuse-release-runtime-metadata-r1.json", ++ "sha256": "5ec901cf41b9f52628ff636c23b5bbc4789df6a9118f5cff0ff8518b287357e9" ++ }, ++ "reuse-release-owner-eso-metadata-r1.json": { ++ "path": "/home/worsch/the-custodian/docs/assessments/netkingdom-2026-10-14/reuse-release-owner-eso-metadata-r1.json", ++ "sha256": "ff9aa207ae081d27d86820747a993d4bbc290ba2c354eef72388233d6733adb3" ++ } ++ }, ++ "observed_runtime": { ++ "node_ip": "92.205.62.239", ++ "cpu_free_m": 440, ++ "api_surge_cpu_m": 100, ++ "api_surge_memory_mi": 256, ++ "pvc": "reuse-surface-data", ++ "pvc_access": "ReadWriteOnce", ++ "pvc_capacity": "1Gi", ++ "previous_image_digest": "forgejo.coulomb.social/coulomb/reuse-surface@sha256:2c1514cb3f5e1297ffeeac912ba36a70f8a2591588a5a4643bc58293e97132de", ++ "authenticated_sources": 0 ++ }, ++ "gates": [ ++ "publish exact reviewed image and verify registry digest; local digest may require reviewed update if registry transforms manifest", ++ "founder approval for exact phase A and later credential CCR/phase B", ++ "recheck target headroom memory node/PVC mount scheduling and authenticated inventory immediately before apply", ++ "bounded safe server dry-run/client diff and current release revision/rollback capture", ++ "no ESO reader field during phase A; verify actual imageID health and63public baseline before phase B", ++ "revoke/remove delivered reader before any downgrade to old vulnerable image" ++ ], ++ "render_command": [ ++ "helm", ++ "template", ++ "reuse", ++ "charts/reuse-surface", ++ "--namespace", ++ "reuse", ++ "-f", ++ "helm/reuse-surface-values.yaml", ++ "-f", ++ "docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-values.proposed.yaml" ++ ] ++} +diff --git a/docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-values.proposed.yaml b/docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-values.proposed.yaml +new file mode 100644 +index 0000000..6e6f81e +--- /dev/null ++++ b/docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-values.proposed.yaml +@@ -0,0 +1,21 @@ ++# PROPOSAL ONLY; digest is locally built, NOT published. ++# Publish exact reviewed artifact and verify registry digest before any apply. ++image: ++ repository: forgejo.coulomb.social/coulomb/reuse-surface ++ digest: sha256:2364b7e7859daccf9340a063654e2b09eed1bc27e0adf7b23cbb4b30135a7db0 ++authSourceAllowlist: ++ REUSE_SURFACE_FORGEJO_SOURCE_READ_TOKEN: ++ - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-openbao/raw/registry/capabilities/capability.railiance.openbao-package.md?ref=35672be138036c8433a5ac5de91e068c03c2b124 ++ - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-openbao/raw/registry/indexes/capabilities.yaml?ref=35672be138036c8433a5ac5de91e068c03c2b124 ++ - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-policy-nexus/raw/registry/capabilities/capability.railiance.policy-publication-package.md?ref=088b78ac03b0cebeec34a69aae3569eb7ec2f9c3 ++ - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-policy-nexus/raw/registry/indexes/capabilities.yaml?ref=088b78ac03b0cebeec34a69aae3569eb7ec2f9c3 ++ - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-postgres/raw/registry/capabilities/capability.railiance.postgres-isolation-recovery.md?ref=a9f569c96d74aa384ac8d3ac4aa960e4061550b5 ++ - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-postgres/raw/registry/indexes/capabilities.yaml?ref=a9f569c96d74aa384ac8d3ac4aa960e4061550b5 ++ - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-sbom-nexus/raw/registry/capabilities/capability.railiance.sbom-runtime-package.md?ref=adbedd349e8df97be43a1f57db7095af45ff29d1 ++ - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-sbom-nexus/raw/registry/indexes/capabilities.yaml?ref=adbedd349e8df97be43a1f57db7095af45ff29d1 ++ - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-telemetry/raw/registry/capabilities/capability.railiance.telemetry-package.md?ref=f158307e24b86bab88a1af4830ff6b4f85afdeb5 ++ - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-telemetry/raw/registry/indexes/capabilities.yaml?ref=f158307e24b86bab88a1af4830ff6b4f85afdeb5 ++ - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-tenant-engine/raw/registry/capabilities/capability.railiance.tenant-engine-package.md?ref=9bdc86572728783a5ed94266f04f7b12d414c557 ++ - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-tenant-engine/raw/registry/indexes/capabilities.yaml?ref=9bdc86572728783a5ed94266f04f7b12d414c557 ++ - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-user-engine/raw/registry/capabilities/capability.railiance.user-engine-package.md?ref=f35108f9e1b8702fb6c526fe42b2fec5ca032738 ++ - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-user-engine/raw/registry/indexes/capabilities.yaml?ref=f35108f9e1b8702fb6c526fe42b2fec5ca032738 +diff --git a/docs/assessments/cust81-private-reader/phase-a-local-image-r1/manifest.json b/docs/assessments/cust81-private-reader/phase-a-local-image-r1/manifest.json +new file mode 100644 +index 0000000..15cc477 +--- /dev/null ++++ b/docs/assessments/cust81-private-reader/phase-a-local-image-r1/manifest.json +@@ -0,0 +1,12 @@ ++{ ++ "proposal_id": "CUST81-APPS-PHASE-A-LOCAL-IMAGE-R1", ++ "reviewer_owner": "railiance-apps", ++ "disposition": "approved", ++ "approved_scope": "preparation only, local full-chart render, supervised founder review required", ++ "files": { ++ "docs/assessments/cust81-private-reader/phase-a-local-image-r1/apps-owner-release-review.md": "a74e96986798a727fc87c0d3c578601579778dfd8521796d0bb81df8451c751b", ++ "docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-full-chart.proposed.yaml": "36c20c3e16757d3e888a3db02853032b33e791aa04a6055fe001121254345b81", ++ "docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-preparation-receipt.json": "3282a17ae3724619e40dd1f34846d83b2297d9ae00030618dbd269e8e2fd7d39", ++ "docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-values.proposed.yaml": "35284857ec987fc7a929f5c2eecdb44c7d3367f7835904692947de09918ab8f1" ++ } ++} diff --git a/docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-values.proposed.yaml b/docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-values.proposed.yaml new file mode 100644 index 0000000..6e6f81e --- /dev/null +++ b/docs/assessments/cust81-private-reader/phase-a-local-image-r1/phase-a-values.proposed.yaml @@ -0,0 +1,21 @@ +# PROPOSAL ONLY; digest is locally built, NOT published. +# Publish exact reviewed artifact and verify registry digest before any apply. +image: + repository: forgejo.coulomb.social/coulomb/reuse-surface + digest: sha256:2364b7e7859daccf9340a063654e2b09eed1bc27e0adf7b23cbb4b30135a7db0 +authSourceAllowlist: + REUSE_SURFACE_FORGEJO_SOURCE_READ_TOKEN: + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-openbao/raw/registry/capabilities/capability.railiance.openbao-package.md?ref=35672be138036c8433a5ac5de91e068c03c2b124 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-openbao/raw/registry/indexes/capabilities.yaml?ref=35672be138036c8433a5ac5de91e068c03c2b124 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-policy-nexus/raw/registry/capabilities/capability.railiance.policy-publication-package.md?ref=088b78ac03b0cebeec34a69aae3569eb7ec2f9c3 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-policy-nexus/raw/registry/indexes/capabilities.yaml?ref=088b78ac03b0cebeec34a69aae3569eb7ec2f9c3 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-postgres/raw/registry/capabilities/capability.railiance.postgres-isolation-recovery.md?ref=a9f569c96d74aa384ac8d3ac4aa960e4061550b5 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-postgres/raw/registry/indexes/capabilities.yaml?ref=a9f569c96d74aa384ac8d3ac4aa960e4061550b5 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-sbom-nexus/raw/registry/capabilities/capability.railiance.sbom-runtime-package.md?ref=adbedd349e8df97be43a1f57db7095af45ff29d1 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-sbom-nexus/raw/registry/indexes/capabilities.yaml?ref=adbedd349e8df97be43a1f57db7095af45ff29d1 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-telemetry/raw/registry/capabilities/capability.railiance.telemetry-package.md?ref=f158307e24b86bab88a1af4830ff6b4f85afdeb5 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-telemetry/raw/registry/indexes/capabilities.yaml?ref=f158307e24b86bab88a1af4830ff6b4f85afdeb5 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-tenant-engine/raw/registry/capabilities/capability.railiance.tenant-engine-package.md?ref=9bdc86572728783a5ed94266f04f7b12d414c557 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-tenant-engine/raw/registry/indexes/capabilities.yaml?ref=9bdc86572728783a5ed94266f04f7b12d414c557 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-user-engine/raw/registry/capabilities/capability.railiance.user-engine-package.md?ref=f35108f9e1b8702fb6c526fe42b2fec5ca032738 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-user-engine/raw/registry/indexes/capabilities.yaml?ref=f35108f9e1b8702fb6c526fe42b2fec5ca032738 diff --git a/docs/assessments/cust81-private-reader/preparation-manifest-r2.json b/docs/assessments/cust81-private-reader/preparation-manifest-r2.json new file mode 100644 index 0000000..e60e14c --- /dev/null +++ b/docs/assessments/cust81-private-reader/preparation-manifest-r2.json @@ -0,0 +1,29 @@ +{ + "proposal_id": "CUST81-APPS-PRIVATE-READER-RELEASE-R1", + "status": "local_verified_live_blocked", + "real_image_digest": null, + "live_execution": false, + "cost_estimate_eur": null, + "risk_estimate_eur": null, + "estimate_status": "live cost/loss bounds require release-specific runtime headroom, recovery and image evidence; not executable before supervisor-approved estimates", + "paths": { + "charts/reuse-surface/templates/_helpers.tpl": "sha256:789606a31ac8da6dfb7a5f6ace6a77269e3be3867d2e9a11e52b19aadf6977df", + "charts/reuse-surface/templates/deployment.yaml": "sha256:2ec0be6d9aa24ed026b0c6629ab8ba003669eb0460d722fe413cd4934111540c", + "charts/reuse-surface/values.schema.json": "sha256:ea73e027e4dcd832cc0bd48fc7ccfa29158153033ca02a8dc7b25b7df0d6c2b5", + "charts/reuse-surface/values.yaml": "sha256:d1aeff71df0c53e4ecb3f4ebaac50ced7691b52c673fe9b49fbaeee6ca02c226", + "docs/assessments/cust81-private-reader/apps-owner-review-r2.md": "sha256:a3a2f2db6f0a8461a0d8299825c842c8ce427f59c86238884609d463837b3b75", + "docs/assessments/cust81-private-reader/local-verification-r2.json": "sha256:d723561845a05a76be9554596011c267fc67dd24953160f3bd7bd26a85c4812c", + "docs/assessments/cust81-private-reader/private-reader-values.r1.yaml": "sha256:e02a319aca9c4860cdb8338009e60af6723bdb1fc17a591db0824273206eff83", + "docs/assessments/cust81-private-reader/reuse-surface-runtime-externalsecret.r1.yaml": "sha256:adfb34c0ee4d01bc602f57cd2320f1658465fb8f8660978828fffa05a5b604b5", + "docs/assessments/cust81-private-reader/runtime-auth-source-allowlist-r1.json": "sha256:e1948c537e206b30fc72b9e4732a3f600d92e131813e6f7ecfd4bfbb18c27286", + "docs/assessments/cust81-private-reader/supervision-preparation-r1.json": "sha256:232f195026d704164b5973b5098002205445ff3ed6b0d2dd0c4a3c92c6ad9ca5", + "docs/assessments/cust81-private-reader/verify_preparation.py": "sha256:c07921ea3fcd7c646aa6f28f34f7cdcb552e893341fe53dc7102ee376541d04d", + "scripts/reuse_authenticated_source_inventory.py": "sha256:42eed5513db090d338daec39258b89a4ce2e9369d175c3c8312904ed68867ee1", + "workplans/RAPPS-WP-0017-reuse-private-reader-release.md": "sha256:3a114a461647b46f3cc8e0ff9aa16b69aed0429cbc1578edf9b0fed5e220cc55" + }, + "chart_patch_sha256": "39ebaa7f4050f9204fe8ee38364fbbce874903447ac45ccd408cf434aa6e4a73", + "revision": 2, + "refinement_or_rescue": true, + "original_revision_preserved": "root owner-assembly/apps-r1 archive", + "correction": "pod SQL native registration url projection plus exact SQLite readonly fixture, safe legacy routes and untrusted successful output refusal" +} diff --git a/docs/assessments/cust81-private-reader/private-reader-values.r1.yaml b/docs/assessments/cust81-private-reader/private-reader-values.r1.yaml new file mode 100644 index 0000000..70dc932 --- /dev/null +++ b/docs/assessments/cust81-private-reader/private-reader-values.r1.yaml @@ -0,0 +1,5 @@ +# PREPARATION ONLY: empty digest deliberately prevents rendering/deployment. +# Fill only after reuse-surface publishes and reviews the fully hardened image. +image: + digest: "" +authSourceAllowlist: {"REUSE_SURFACE_FORGEJO_SOURCE_READ_TOKEN": ["https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-openbao/raw/registry/capabilities/capability.railiance.openbao-package.md?ref=35672be138036c8433a5ac5de91e068c03c2b124", "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-openbao/raw/registry/indexes/capabilities.yaml?ref=35672be138036c8433a5ac5de91e068c03c2b124", "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-policy-nexus/raw/registry/capabilities/capability.railiance.policy-publication-package.md?ref=088b78ac03b0cebeec34a69aae3569eb7ec2f9c3", "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-policy-nexus/raw/registry/indexes/capabilities.yaml?ref=088b78ac03b0cebeec34a69aae3569eb7ec2f9c3", "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-postgres/raw/registry/capabilities/capability.railiance.postgres-isolation-recovery.md?ref=a9f569c96d74aa384ac8d3ac4aa960e4061550b5", "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-postgres/raw/registry/indexes/capabilities.yaml?ref=a9f569c96d74aa384ac8d3ac4aa960e4061550b5", "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-sbom-nexus/raw/registry/capabilities/capability.railiance.sbom-runtime-package.md?ref=adbedd349e8df97be43a1f57db7095af45ff29d1", "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-sbom-nexus/raw/registry/indexes/capabilities.yaml?ref=adbedd349e8df97be43a1f57db7095af45ff29d1", "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-telemetry/raw/registry/capabilities/capability.railiance.telemetry-package.md?ref=f158307e24b86bab88a1af4830ff6b4f85afdeb5", "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-telemetry/raw/registry/indexes/capabilities.yaml?ref=f158307e24b86bab88a1af4830ff6b4f85afdeb5", "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-tenant-engine/raw/registry/capabilities/capability.railiance.tenant-engine-package.md?ref=9bdc86572728783a5ed94266f04f7b12d414c557", "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-tenant-engine/raw/registry/indexes/capabilities.yaml?ref=9bdc86572728783a5ed94266f04f7b12d414c557", "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-user-engine/raw/registry/capabilities/capability.railiance.user-engine-package.md?ref=f35108f9e1b8702fb6c526fe42b2fec5ca032738", "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-user-engine/raw/registry/indexes/capabilities.yaml?ref=f35108f9e1b8702fb6c526fe42b2fec5ca032738"]} diff --git a/docs/assessments/cust81-private-reader/reuse-surface-runtime-externalsecret.r1.yaml b/docs/assessments/cust81-private-reader/reuse-surface-runtime-externalsecret.r1.yaml new file mode 100644 index 0000000..3822799 --- /dev/null +++ b/docs/assessments/cust81-private-reader/reuse-surface-runtime-externalsecret.r1.yaml @@ -0,0 +1,46 @@ +# PROPOSAL ONLY: phase B, after hardened runtime verified and CCR approved. +# reuse-surface hub runtime secrets from OpenBao (RAILIANCE-WP-0011 / CCR-2026-0005). +# +# OpenBao path (platform KV v2): workloads/reuse/reuse-surface/runtime-secrets +# Fields: REUSE_SURFACE_TOKEN, REUSE_SURFACE_FORGEJO_WEBHOOK_SECRET +# +# Prereqs on railiance01: +# - External Secrets Operator (make eso-deploy) +# - ClusterSecretStore openbao-reuse (railiance-platform openbao-secretstore) +# - OpenBao policy workload-kv-read-reuse-surface-runtime + ESO token +# (make reuse-openbao-eso-token-apply) +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: reuse-surface-runtime + namespace: reuse + labels: + app.kubernetes.io/name: reuse-surface + app.kubernetes.io/part-of: railiance-apps +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: openbao-reuse + target: + # Explicit target metadata prevents ESO copying source last-applied annotations. + template: + metadata: + labels: + app.kubernetes.io/name: reuse-surface + app.kubernetes.io/part-of: railiance-apps + name: reuse-surface-env + creationPolicy: Owner + data: + - secretKey: REUSE_SURFACE_TOKEN + remoteRef: + key: workloads/reuse/reuse-surface/runtime-secrets + property: REUSE_SURFACE_TOKEN + - secretKey: REUSE_SURFACE_FORGEJO_WEBHOOK_SECRET + remoteRef: + key: workloads/reuse/reuse-surface/runtime-secrets + property: REUSE_SURFACE_FORGEJO_WEBHOOK_SECRET + - secretKey: REUSE_SURFACE_FORGEJO_SOURCE_READ_TOKEN + remoteRef: + key: workloads/reuse/reuse-surface/forgejo-source-read + property: FORGEJO_SOURCE_TOKEN diff --git a/docs/assessments/cust81-private-reader/runtime-auth-source-allowlist-r1.json b/docs/assessments/cust81-private-reader/runtime-auth-source-allowlist-r1.json new file mode 100644 index 0000000..edb5f74 --- /dev/null +++ b/docs/assessments/cust81-private-reader/runtime-auth-source-allowlist-r1.json @@ -0,0 +1,18 @@ +{ + "REUSE_SURFACE_FORGEJO_SOURCE_READ_TOKEN": [ + "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-openbao/raw/registry/capabilities/capability.railiance.openbao-package.md?ref=35672be138036c8433a5ac5de91e068c03c2b124", + "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-openbao/raw/registry/indexes/capabilities.yaml?ref=35672be138036c8433a5ac5de91e068c03c2b124", + "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-policy-nexus/raw/registry/capabilities/capability.railiance.policy-publication-package.md?ref=088b78ac03b0cebeec34a69aae3569eb7ec2f9c3", + "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-policy-nexus/raw/registry/indexes/capabilities.yaml?ref=088b78ac03b0cebeec34a69aae3569eb7ec2f9c3", + "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-postgres/raw/registry/capabilities/capability.railiance.postgres-isolation-recovery.md?ref=a9f569c96d74aa384ac8d3ac4aa960e4061550b5", + "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-postgres/raw/registry/indexes/capabilities.yaml?ref=a9f569c96d74aa384ac8d3ac4aa960e4061550b5", + "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-sbom-nexus/raw/registry/capabilities/capability.railiance.sbom-runtime-package.md?ref=adbedd349e8df97be43a1f57db7095af45ff29d1", + "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-sbom-nexus/raw/registry/indexes/capabilities.yaml?ref=adbedd349e8df97be43a1f57db7095af45ff29d1", + "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-telemetry/raw/registry/capabilities/capability.railiance.telemetry-package.md?ref=f158307e24b86bab88a1af4830ff6b4f85afdeb5", + "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-telemetry/raw/registry/indexes/capabilities.yaml?ref=f158307e24b86bab88a1af4830ff6b4f85afdeb5", + "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-tenant-engine/raw/registry/capabilities/capability.railiance.tenant-engine-package.md?ref=9bdc86572728783a5ed94266f04f7b12d414c557", + "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-tenant-engine/raw/registry/indexes/capabilities.yaml?ref=9bdc86572728783a5ed94266f04f7b12d414c557", + "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-user-engine/raw/registry/capabilities/capability.railiance.user-engine-package.md?ref=f35108f9e1b8702fb6c526fe42b2fec5ca032738", + "https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-user-engine/raw/registry/indexes/capabilities.yaml?ref=f35108f9e1b8702fb6c526fe42b2fec5ca032738" + ] +} diff --git a/docs/assessments/cust81-private-reader/source-repair-owner-refinement-r3.json b/docs/assessments/cust81-private-reader/source-repair-owner-refinement-r3.json new file mode 100644 index 0000000..9198919 --- /dev/null +++ b/docs/assessments/cust81-private-reader/source-repair-owner-refinement-r3.json @@ -0,0 +1,24 @@ +{ + "proposal_id": "CUST81-APPS-PRIVATE-READER-RELEASE-R1", + "owner": "railiance-apps", + "review_revision": 3, + "disposition": "approved", + "approved_scope": "ordinary source-only standard digest representation correction; no live runtime change", + "refinement_or_rescue": true, + "original_manifest_sha256": "45d467c2bb48462a388f5df4ba5188492d406bdddbddfbf3487bd2077486dec8", + "qualified_manifest_sha256": "324757f27a36090e34101adf9a68feb8f19530463303e53fec46abe2092943b8", + "source_file_digests_unchanged": true, + "scanner_rule": "generic-api-key", + "affected_field": "paths[docs/assessments/cust81-private-reader/reuse-surface-runtime-externalsecret.r1.yaml]", + "scanner_version": "8.30.1", + "pinned_scanner_sha256": "88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509", + "original_scanner_exit": 20, + "candidate_scanner_exit": 0, + "mechanism": "standard sha256-qualified metadata identifier; unchanged scanner/config, no ignore/exception/bypass", + "backup_ref": "custody/cust81-apps-unpublished-r2", + "original_commits": [ + "4e8e0f5", + "6a8a54d" + ], + "live_execution_approved": false +} diff --git a/docs/assessments/cust81-private-reader/supervision-preparation-r1.json b/docs/assessments/cust81-private-reader/supervision-preparation-r1.json new file mode 100644 index 0000000..722694b --- /dev/null +++ b/docs/assessments/cust81-private-reader/supervision-preparation-r1.json @@ -0,0 +1,10 @@ +{ + "agent": "delegated-railiance-apps-cust81-preparation", + "mode": "supervised", + "scope": "isolated local chart/config/ESO proposal preparation, local verification only", + "supervisor": "root custodian coordinating founder-authorized work", + "promotion_evidence": null, + "runtime_enforcement_verified": false, + "authority_granted": false, + "live_execution_authorized": false +} diff --git a/docs/assessments/cust81-private-reader/verify_preparation.py b/docs/assessments/cust81-private-reader/verify_preparation.py new file mode 100644 index 0000000..df57199 --- /dev/null +++ b/docs/assessments/cust81-private-reader/verify_preparation.py @@ -0,0 +1,94 @@ +"""Local chart and non-disclosure checks only; never contacts a cluster.""" +import hashlib +import importlib.util +import json +from pathlib import Path +import subprocess +import yaml + +ROOT = Path(__file__).resolve().parents[3] +ARTIFACT = Path(__file__).resolve().parent +calls = [] + +def render(extra, success=True): + command = ['helm', 'template', 'reuse', str(ROOT / 'charts/reuse-surface'), '-f', str(ROOT / 'helm/reuse-surface-values.yaml'), *extra] + proc = subprocess.run(command, capture_output=True, text=True) + calls.append({'command': command, 'exit_code': proc.returncode, 'expected_success': success}) + assert (proc.returncode == 0) == success + return list(yaml.safe_load_all(proc.stdout)) if success else [] + +def api(docs): + return next(d for d in docs if d and d.get('kind') == 'Deployment' and d['metadata']['name'] == 'reuse-surface') + +current = api(render([])) +assert current['spec']['template']['spec']['containers'][0]['image'].endswith(':main-6cbc862') +assert next(e['value'] for e in current['spec']['template']['spec']['containers'][0]['env'] if e['name']=='REUSE_SURFACE_AUTH_SOURCE_ALLOWLIST') == '{}' +proposal = ['-f', str(ARTIFACT / 'private-reader-values.r1.yaml')] +render(proposal, False) # unknown real digest is a hard gate +# Synthetic digest is render-only, and expressly not promotion/build evidence. +render(proposal+['--set', 'image.digest=sha256:bad'], False) +render(proposal+['--set-json', 'authSourceAllowlist={"BAD":["http://attacker.invalid"]}', '--set', 'image.digest=sha256:'+'0'*64], False) +docs = render(proposal+['--set', 'image.digest=sha256:'+'0'*64]) +deployment = api(docs) +container = deployment['spec']['template']['spec']['containers'][0] +assert container['image'].endswith('@sha256:'+'0'*64) +allowlist=json.loads(next(e['value'] for e in container['env'] if e['name']=='REUSE_SURFACE_AUTH_SOURCE_ALLOWLIST')) +expected = json.loads((ARTIFACT/'runtime-auth-source-allowlist-r1.json').read_text()) +assert allowlist==expected and len(next(iter(allowlist.values())))==14 +assert deployment['spec']['strategy'] == current['spec']['strategy'] +assert container['resources']==current['spec']['template']['spec']['containers'][0]['resources'] +assert deployment['spec']['template']['spec']['volumes']==current['spec']['template']['spec']['volumes'] +assert container['volumeMounts']==current['spec']['template']['spec']['containers'][0]['volumeMounts'] +secret=yaml.safe_load((ARTIFACT/'reuse-surface-runtime-externalsecret.r1.yaml').read_text()) +baseline=yaml.safe_load((ROOT/'manifests/reuse-surface-runtime-externalsecret.yaml').read_text()) +assert secret['spec']['data'][:-1]==baseline['spec']['data'] +assert secret['spec']['secretStoreRef']==baseline['spec']['secretStoreRef'] +assert secret['spec']['data'][-1]=={'secretKey':'REUSE_SURFACE_FORGEJO_SOURCE_READ_TOKEN','remoteRef':{'key':'workloads/reuse/reuse-surface/forgejo-source-read','property':'FORGEJO_SOURCE_TOKEN'}} +spec=importlib.util.spec_from_file_location('inventory',ROOT/'scripts/reuse_authenticated_source_inventory.py') +mod=importlib.util.module_from_spec(spec);spec.loader.exec_module(mod) +url=expected['REUSE_SURFACE_FORGEJO_SOURCE_READ_TOKEN'][1] +assert len(mod.sanitize([['rapp-openbao','FIXTURE_READ_TOKEN',url]]))==1 +legacy='https://forgejo.coulomb.social/coulomb/rapp-openbao/raw/branch/main/registry/indexes/capabilities.yaml' +assert len(mod.sanitize([['rapp-openbao','FIXTURE_READ_TOKEN',legacy]]))==1 +# Exercise the EXACT pod code against actual native registration keys in SQLite. +# Replacing only the fixture DB path leaves the metadata SELECT/read-only mode intact. +import sqlite3,tempfile +with tempfile.TemporaryDirectory(prefix='apps-reader-fixture-') as temp: + fixture=Path(temp)/'reuse.db' + with sqlite3.connect(fixture) as db: + db.execute('CREATE TABLE registrations(repo TEXT PRIMARY KEY, payload TEXT NOT NULL, registered_at TEXT, updated_at TEXT)') + db.execute('INSERT INTO registrations VALUES(?,?,?,?)', ('rapp-openbao',json.dumps({'repo':'rapp-openbao','url':url,'auth_env':'FIXTURE_READ_TOKEN','description':'fixture-secret-never-emit'}),'fixture','fixture')) + db.execute('INSERT INTO registrations VALUES(?,?,?,?)', ('anonymous',json.dumps({'repo':'anonymous','url':legacy}),'fixture','fixture')) + pod_code=mod.POD_CODE.replace('/data/reuse.db',str(fixture)) + proc=subprocess.run(['python3','-c',pod_code],capture_output=True,text=True,check=True) + assert json.loads(proc.stdout)==[['rapp-openbao','FIXTURE_READ_TOKEN',url]] + assert 'fixture-secret' not in proc.stdout + assert len(mod.sanitize(json.loads(proc.stdout)))==1 + with sqlite3.connect(fixture) as db: assert db.execute('SELECT count(*) FROM registrations').fetchone()==(2,) + assert 'mode=ro' in pod_code and '$.index_url' not in pod_code + assert 'os.environ' not in pod_code and 'getenv' not in pod_code and 'kubectl' not in pod_code + +for bad in ['https://token@forgejo.coulomb.social/x', url+'&token=fixture-secret','http://attacker.invalid',url+'#fixture-secret',legacy+'?token=fixture-secret',legacy+'/../credentials',legacy+'%3Ftoken=fixture-secret']: + try: mod.sanitize([['rapp-openbao','FIXTURE_READ_TOKEN',bad]]) + except ValueError: pass + else: raise AssertionError('unsafe URL accepted') +# Errors from kubectl are discarded before output; no subprocess invoked in test. +original=mod.subprocess.run +class Failure: + returncode=1;stdout=b'fixture-secret';stderr=b'fixture-secret' +mod.subprocess.run=lambda *args,**kwargs: Failure() +import sys,io,contextlib +argv=sys.argv;sys.argv=['inventory','--kubeconfig','fixture-path'] +buf=io.StringIO() +with contextlib.redirect_stdout(buf): assert mod.main()==1 +assert 'fixture-secret' not in buf.getvalue() +class UnsafeSuccess: + returncode=0;stdout=json.dumps([['rapp-openbao','FIXTURE_READ_TOKEN',url+'&token=fixture-secret']]).encode();stderr=b'' +mod.subprocess.run=lambda *args,**kwargs: UnsafeSuccess() +buf=io.StringIO() +with contextlib.redirect_stdout(buf): assert mod.main()==1 +assert 'fixture-secret' not in buf.getvalue() +sys.argv=argv;mod.subprocess.run=original +result={'status':'passed','local_only':True,'real_image_digest':None,'synthetic_digest_used_only_to_check_rendering':True,'checks':['production rendering unchanged except empty fail-closed allowlist','unknown/malformed digest fails','HTTP allowlist fails schema','exact14 URLs roundtrip','strategy resources PVC retained','dedicated ESO mapping preserves current fields/store','metadata sanitizer excludes userinfo/query secrets/fragments','kubectl failure output discarded','exact pod SQL real payload URL keys readonly fixture and anonymous filtering','legacy Forgejo branch URL accepted without weakening credential query refusal'],'helm_calls':calls} +(ARTIFACT/'local-verification-r2.json').write_text(json.dumps(result,indent=2)+'\n') +print(json.dumps({'status':'passed','checks':len(result['checks'])})) diff --git a/scripts/reuse_authenticated_source_inventory.py b/scripts/reuse_authenticated_source_inventory.py new file mode 100644 index 0000000..aea33a5 --- /dev/null +++ b/scripts/reuse_authenticated_source_inventory.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Read-only authenticated source metadata; never read env or Kubernetes Secrets. + +Caller must first confirm the kubeconfig targets railiance01. Raw command +stdout/stderr and exception text are never emitted. No credentials are needed +beyond the caller's separately approved Kubernetes observation/exec lane. +""" +import argparse +import json +import re +import subprocess +from urllib.parse import urlsplit, parse_qs + +# SQL projects metadata only; the registration payload itself is never printed. +POD_CODE = '''import json, sqlite3 +with sqlite3.connect("file:/data/reuse.db?mode=ro", uri=True) as db: + rows=db.execute("SELECT repo, json_extract(payload, '$.auth_env'), json_extract(payload, '$.url') FROM registrations WHERE json_extract(payload, '$.auth_env') IS NOT NULL AND json_extract(payload, '$.auth_env') != ''").fetchall() + print(json.dumps(rows)) +''' + + +def sanitize(rows): + result = [] + for repo, name, url in rows: + if not isinstance(repo, str) or not re.fullmatch(r'[a-zA-Z0-9_.-]{1,128}', repo): + raise ValueError('invalid metadata') + if not isinstance(name, str) or not re.fullmatch(r'[A-Z][A-Z0-9_]{0,127}', name): + raise ValueError('invalid metadata') + parts = urlsplit(url) + # URLs are untrusted stored data; refuse userinfo/fragments and arbitrary + # query fields (which could contain credentials). Never echo invalid rows. + query = parse_qs(parts.query, keep_blank_values=True) + if (parts.scheme != 'https' or parts.username or parts.password or parts.fragment + or parts.hostname != 'forgejo.coulomb.social' or parts.port not in (None, 443) + or not (re.fullmatch(r'/api/v1/repos/coulomb/[a-zA-Z0-9_.-]+/raw/registry/indexes/[a-zA-Z0-9_.-]+', parts.path) + or re.fullmatch(r'/coulomb/[a-zA-Z0-9_.-]+/raw/(?:branch|commit|tag)/[a-zA-Z0-9_.-]+/registry/indexes/[a-zA-Z0-9_.-]+', parts.path)) + or any(k != 'ref' for k in query) + or ('ref' in query and (len(query['ref']) != 1 or not re.fullmatch(r'[a-zA-Z0-9_.-]{1,128}', query['ref'][0])))): + raise ValueError('unsafe metadata') + result.append({'repo': repo, 'auth_env': name, 'index_url': url}) + return sorted(result, key=lambda row: row['repo']) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--kubeconfig', required=True) + args = parser.parse_args() + try: + proc = subprocess.run(['kubectl', '--kubeconfig', args.kubeconfig, '-n', 'reuse', + 'exec', 'deployment/reuse-surface', '-c', 'reuse-surface', '--', 'python3', '-c', POD_CODE], + capture_output=True, timeout=30) + if proc.returncode: + raise RuntimeError('inventory unavailable') + rows = sanitize(json.loads(proc.stdout)) + except Exception: + print(json.dumps({'status': 'refused_or_unavailable', 'raw_output_discarded': True})) + return 1 + print(json.dumps({'status': 'verified', 'authenticated_source_count': len(rows), 'sources': rows}, indent=2)) + return 0 + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/workplans/RAPPS-WP-0017-reuse-private-reader-release.md b/workplans/RAPPS-WP-0017-reuse-private-reader-release.md new file mode 100644 index 0000000..0363d08 --- /dev/null +++ b/workplans/RAPPS-WP-0017-reuse-private-reader-release.md @@ -0,0 +1,57 @@ +--- +id: RAPPS-WP-0017 +type: workplan +title: "Release hardened reuse federation with seven-source metadata reader" +domain: financials +repo: railiance-apps +status: proposed +owner: codex +created: "2026-10-15" +updated: "2026-10-15" +flavor: implementation +depends_on: [RPF-WP-0058, REUSE-WP-0025] +--- + +Provisional id in isolated preparation checkout; root integration must confirm +availability. No hub registration, fix-consistency or push until integration. +Source owners approved metadata disclosure under CUST81 R1; that is not approval +for live credential issuance or workload rollout. Apps owns only S5 release. + +## Prepare bounded release contract + +```task +id: RAPPS-WP-0017-T01 +status: done +priority: high +``` + +Prepare chart allowlist/digest support, exact14 URL override and separate phase-B +ESO manifest, own preparation review and meaningful local validation. +Evidence: docs/assessments/cust81-private-reader/apps-owner-review-r1.md. + +## Verify and promote hardened runtime before reader delivery + +```task +id: RAPPS-WP-0017-T02 +status: wait +priority: high +``` + +Wait for published fully hardened image digest/source/tests plus owner reviews +and change authorization. Obtain metadata-only existing auth-source migration +inventory, live headroom/PVC evidence, exact server dry-run/diff, baseline health +and rollback receipt. Phase A deploy excludes reader ESO mapping. + +## Deliver reviewed reader and prove complete hosted discovery + +```task +id: RAPPS-WP-0017-T03 +status: wait +priority: high +``` + +After verified phase A and approved/executed platform CCR, apply phase-B ESO +mapping and restart hardened runtime through reviewed owner lane. Require +sanitized ESO/key-presence proof and reuse native14 fetch/7 entry hashes before +registration. Final CUST81 owner verifies70/70 hosted; rollback revokes/removes +reader before any vulnerable image restoration. From 05f654e20f7ea68471e586d7892e25441c65875d Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 15 Oct 2026 01:39:30 +0200 Subject: [PATCH 33/87] Record canonical UUID bindings for reuse reader preparation Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a137ca-5c77-7a71-b9ae-23ad9b0b5684 --- workplans/RAPPS-WP-0017-reuse-private-reader-release.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/workplans/RAPPS-WP-0017-reuse-private-reader-release.md b/workplans/RAPPS-WP-0017-reuse-private-reader-release.md index 0363d08..105616d 100644 --- a/workplans/RAPPS-WP-0017-reuse-private-reader-release.md +++ b/workplans/RAPPS-WP-0017-reuse-private-reader-release.md @@ -10,6 +10,7 @@ created: "2026-10-15" updated: "2026-10-15" flavor: implementation depends_on: [RPF-WP-0058, REUSE-WP-0025] +state_hub_workstream_id: "7c71b152-f9ef-5377-8c95-72a416205cb2" --- Provisional id in isolated preparation checkout; root integration must confirm @@ -23,6 +24,7 @@ for live credential issuance or workload rollout. Apps owns only S5 release. id: RAPPS-WP-0017-T01 status: done priority: high +state_hub_task_id: "62c5384a-5344-5ce2-ab60-f73ca8dfd28f" ``` Prepare chart allowlist/digest support, exact14 URL override and separate phase-B @@ -35,6 +37,7 @@ Evidence: docs/assessments/cust81-private-reader/apps-owner-review-r1.md. id: RAPPS-WP-0017-T02 status: wait priority: high +state_hub_task_id: "a23046fc-8912-55a6-8cb9-34ae0104bc42" ``` Wait for published fully hardened image digest/source/tests plus owner reviews @@ -48,6 +51,7 @@ and rollback receipt. Phase A deploy excludes reader ESO mapping. id: RAPPS-WP-0017-T03 status: wait priority: high +state_hub_task_id: "6825e54c-2b67-52a7-aa46-d746b62b924b" ``` After verified phase A and approved/executed platform CCR, apply phase-B ESO From ea2d4632a114c348c70b01d684cc5a2d4f413b91 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 15 Oct 2026 02:01:53 +0200 Subject: [PATCH 34/87] Prepare exact reader delivery recovery and native runtime proof Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a137ca-5c77-7a71-b9ae-23ad9b0b5684 --- .../phase-b-r1/apps-owner-phase-b-review.md | 53 +++++++++++ .../phase-b-r1/command-parser-check-r2.json | 13 +++ .../phase-b-r1/eso-preflight-r1.json | 31 +++++++ .../phase-b-r1/manifest.json | 18 ++++ .../native-proof-wrapper-local-checks-r1.json | 15 ++++ .../phase-b-r1/native_runtime_proof_stdin.py | 38 ++++++++ .../phase-b-execution-proposal-r1.json | 88 +++++++++++++++++++ .../phase-b-r1/run_eso_preflight.py | 30 +++++++ 8 files changed, 286 insertions(+) create mode 100644 docs/assessments/cust81-private-reader/phase-b-r1/apps-owner-phase-b-review.md create mode 100644 docs/assessments/cust81-private-reader/phase-b-r1/command-parser-check-r2.json create mode 100644 docs/assessments/cust81-private-reader/phase-b-r1/eso-preflight-r1.json create mode 100644 docs/assessments/cust81-private-reader/phase-b-r1/manifest.json create mode 100644 docs/assessments/cust81-private-reader/phase-b-r1/native-proof-wrapper-local-checks-r1.json create mode 100644 docs/assessments/cust81-private-reader/phase-b-r1/native_runtime_proof_stdin.py create mode 100644 docs/assessments/cust81-private-reader/phase-b-r1/phase-b-execution-proposal-r1.json create mode 100644 docs/assessments/cust81-private-reader/phase-b-r1/run_eso_preflight.py diff --git a/docs/assessments/cust81-private-reader/phase-b-r1/apps-owner-phase-b-review.md b/docs/assessments/cust81-private-reader/phase-b-r1/apps-owner-phase-b-review.md new file mode 100644 index 0000000..bb0ae37 --- /dev/null +++ b/docs/assessments/cust81-private-reader/phase-b-r1/apps-owner-phase-b-review.md @@ -0,0 +1,53 @@ +# Apps phase B delivery preparation review + +Disposition: `approved` for exact bounded preparation only. No founder credential, +source-activation or production rollout authorization is inferred. Own workitem +RAPPS-WP-0017-T03; INTENT basis is release contracts, explicit S3 handoffs, +repeatable recovery and evidence before confidence. + +Full ExternalSecret server dry-run accepted and client diff verified only the +single new reader item plus native remoteRef defaults. Both old fields, existing +store, target and all other source spec/metadata are unchanged. The first strict +live equality check correctly refused on native ESO defaults, with no apply; +subsequent projection explicitly verified those defaults. The approved source +replacement is distinct from the current active file and must replace it only +after consent, then pass normal owner source publication before live apply. +Server acceptance does not prove the dedicated KV field or ESO read policy exists. + +Phase B must follow verified phase A *and* terminal verified platform provisioning +under the exact approved CCR. Verify current API pod ready, spec image index and +imageID against published OCI index or its linux/amd64 child (exclude attestation +manifest), source federation/private verification module hashes and exact14 URL +allowlist. Confirm ESO Ready and selected reader-key presence only, then controlled +restart with fresh headroom/PVC readiness and capture-only output. No token or env +value enters argv, source, receipts or logs. + +The native proof wrapper consumes only three exact-byte non-secret packets via +stdin, verifies their compiled hashes and module/source hashes, verifies the +complete14 URL allowlist, and invokes the application's native fetch path without +cache. Temporary packet files are0600 and removed. Four local refusal fixtures +passed; no real credentials or network requests were used. Its output must be +combined with separately verified API pod UID/readiness/image metadata; a +process receipt alone does not prove the target pod identity. Any missing, +mis-hashed, non-exact scope or native fetch outcome refuses success. Native +proof requires all7 sources and14 metadata fetches before registrations/final70. + +Rollback ordering is explicit: revoke dedicated Forgejo PAT and verify; restore +source-controlled two-field ESO mapping to stop rematerialization; remove only +the materialized reader key through a captured exact JSON patch; restart the +current hardened deployment and verify reader absence in new pod/materialized +Secret plus issuer revocation. Only then may an independently approved rollback +to vulnerable Helm revision10 occur. No old-token fallback, default DB restore, +credential re-mint or Secret/env dump is proposed. + +All exact predecessor/replacement digests, SSH delivery/restart/removal commands, +proof hashes/packet paths and gate schema are in phase-b-execution-proposal-r1.json. +No credential creation, ESO apply, Secret mutation, restart or deploy was executed. + +Revision2 of the same proposal corrects malformed shell quoting in the original +reader-removal command. Original proposal/review/manifest remain archived. +The replacement is structured SSH argv with `shlex.join` over exact remote +kubectl argv and compact JSON patch. Offline shell parsing roundtrips exactly +one removal operation at `/data/REUSE_SURFACE_FORGEJO_SOURCE_READ_TOKEN`; no +other key is touched. This refinement is not another successful trial. +Disposition remains approved preparation only, never founder live authority. diff --git a/docs/assessments/cust81-private-reader/phase-b-r1/command-parser-check-r2.json b/docs/assessments/cust81-private-reader/phase-b-r1/command-parser-check-r2.json new file mode 100644 index 0000000..54dfab4 --- /dev/null +++ b/docs/assessments/cust81-private-reader/phase-b-r1/command-parser-check-r2.json @@ -0,0 +1,13 @@ +{ + "id": "CUST81-PHASE-B-COMMAND-PARSER-CHECK-R2", + "status": "passed", + "proposal_revision": 2, + "original_proposal_sha256": "sha256:572af68fcf56072c1fd846101a76611885acd6efedfe5dba10d96d9f77f701e0", + "ssh_argv_count": 3, + "remote_argv_roundtrip": true, + "patch_operation_count": 1, + "patch_op": "remove", + "patch_path": "/data/REUSE_SURFACE_FORGEJO_SOURCE_READ_TOKEN", + "other_field_changes": false, + "live_command_executed": false +} diff --git a/docs/assessments/cust81-private-reader/phase-b-r1/eso-preflight-r1.json b/docs/assessments/cust81-private-reader/phase-b-r1/eso-preflight-r1.json new file mode 100644 index 0000000..db58a9d --- /dev/null +++ b/docs/assessments/cust81-private-reader/phase-b-r1/eso-preflight-r1.json @@ -0,0 +1,31 @@ +{ + "id": "CUST81-APPS-PHASE-B-ESO-PREFLIGHT-R1", + "checked_at": "2026-10-14T23:55:29.993576+00:00", + "target_node_ip": "92.205.62.239", + "status": "verified_preparation", + "active_predecessor_path": "/home/worsch/railiance-apps/manifests/reuse-surface-runtime-externalsecret.yaml", + "active_predecessor_sha256": "e036084aec9fad3bea63cc0d591c3b40a392ab9f54d9226a2d2b3fd7194d8a47", + "proposed_replacement_path": "/home/worsch/railiance-apps/docs/assessments/cust81-private-reader/reuse-surface-runtime-externalsecret.r1.yaml", + "proposed_replacement_sha256": "adfb34c0ee4d01bc602f57cd2320f1658465fb8f8660978828fffa05a5b604b5", + "old_two_fields_retained": true, + "existing_store_unchanged": true, + "live_spec_projected_matches_predecessor": true, + "native_remoteRef_defaults_verified": true, + "previous_attempt": "full live data equality assertion refused on native ESO defaults; no command apply executed, original refusal retained", + "results": [ + { + "operation": "server_dry_run", + "exit_code": 0, + "raw_stdout_stderr_discarded": true + }, + { + "operation": "client_diff", + "exit_code": 1, + "exact_new_reader_item_plus_native_defaults_only": true, + "raw_values_annotations_stderr_discarded": true + } + ], + "credentials_provisioned": false, + "live_apply_performed": false, + "limitation": "server acceptance does not prove dedicated KV field exists or ESO consumer policy reader path granted" +} diff --git a/docs/assessments/cust81-private-reader/phase-b-r1/manifest.json b/docs/assessments/cust81-private-reader/phase-b-r1/manifest.json new file mode 100644 index 0000000..3cbd15d --- /dev/null +++ b/docs/assessments/cust81-private-reader/phase-b-r1/manifest.json @@ -0,0 +1,18 @@ +{ + "proposal_id": "CUST81-APPS-PHASE-B-EXECUTION-PROPOSAL-R1", + "revision": 2, + "owner": "railiance-apps", + "disposition": "approved", + "approved_scope": "bounded source preparation only; no active ESO or live action", + "founder_live_approval": false, + "refinement_or_rescue": true, + "files": { + "apps-owner-phase-b-review.md": "sha256:496c842f4a284d6b2904c027dfd1e8aab85567f19fa74a228f27c4cc578ce026", + "command-parser-check-r2.json": "sha256:5080bdd2ece186200aec1a37b4450c99a50d435444f0f850277b82252516d6e1", + "eso-preflight-r1.json": "sha256:e39f07427af80fd036e6eec21202efe194fff2db67b8523bb07052e1388d8fab", + "native-proof-wrapper-local-checks-r1.json": "sha256:c5fcee128f7b4653ecfc0c455aab57a7efa8f6a9de382af69d447d8065091bab", + "native_runtime_proof_stdin.py": "sha256:1edc317e8563a120b62a6830eebb2875c144b45c57a491eb7da849095c5d1933", + "phase-b-execution-proposal-r1.json": "sha256:13f19158e09b0693a64ff400ecbc3e5ec09d28673731e87733d1e2fad9bd3222", + "run_eso_preflight.py": "sha256:fd42789518e9a6c1305fc3828bf1afb8c9a4e8238b4eeed30f7e7ec05656f09b" + } +} diff --git a/docs/assessments/cust81-private-reader/phase-b-r1/native-proof-wrapper-local-checks-r1.json b/docs/assessments/cust81-private-reader/phase-b-r1/native-proof-wrapper-local-checks-r1.json new file mode 100644 index 0000000..43cd703 --- /dev/null +++ b/docs/assessments/cust81-private-reader/phase-b-r1/native-proof-wrapper-local-checks-r1.json @@ -0,0 +1,15 @@ +{ + "id": "CUST81-NATIVE-RUNTIME-PROOF-PREPARATION-CHECKS-R1", + "status": "passed", + "wrapper_sha256": "1edc317e8563a120b62a6830eebb2875c144b45c57a491eb7da849095c5d1933", + "fixture_cases": [ + "missing_reader", + "malformed_allowlist", + "tampered_packet", + "native_refusal" + ], + "native_network_requests": 0, + "real_credentials_used": false, + "failure_output_sentinel_excluded": true, + "scope_refusal_before_any_fetch": true +} diff --git a/docs/assessments/cust81-private-reader/phase-b-r1/native_runtime_proof_stdin.py b/docs/assessments/cust81-private-reader/phase-b-r1/native_runtime_proof_stdin.py new file mode 100644 index 0000000..8b76d09 --- /dev/null +++ b/docs/assessments/cust81-private-reader/phase-b-r1/native_runtime_proof_stdin.py @@ -0,0 +1,38 @@ +"""Approved preparation only: execute in verified hardened API pod after phase B. +stdin envelope contains only three base64-encoded hash-bound non-secret packets. +No token accepted; native module consumes its reviewed runtime source credential. +""" +import base64,hashlib,json,os,tempfile +from pathlib import Path +import sys +from reuse_surface import federation, private_source_verify as native +EXPECTED={'proposal':'7819eb51d249f5c7aff4df9fb520afcfb76603b05a4104439499102b6c20af99','disclosure':'46ac4fe49a08054cc215aade2fbaf3b36ee334520997857ed84f530b52da9d35','consent':'4a4602ad926e3af855d6ddba4731430d33e4a7432c507ba0a5193b9f43056b52'} +FEDI='dbaa40f25cc0f090143578cd1c7068cb2cb00960fafd17d5239bf8a5bac514dd' +NATIVE='c67a284091cc4dd62dca56658a14b8ea1357e2b3df1518120da4c51e0ee8d85c' +receipt={'id':'CUST81-NATIVE-RUNTIME-PROOF-R1','status':'unverified','raw_errors_or_values_emitted':False} +try: + source_sha=hashlib.sha256(Path(federation.__file__).read_bytes()).hexdigest() + helper_sha=hashlib.sha256(Path(native.__file__).read_bytes()).hexdigest() + assert source_sha==FEDI and helper_sha==NATIVE + envelope=json.loads(sys.stdin.buffer.read(1048576)) + assert set(envelope)==set(EXPECTED) + with tempfile.TemporaryDirectory(prefix='cust81-native-packets-') as temporary: + paths={} + for name,digest in EXPECTED.items(): + content=base64.b64decode(envelope[name],validate=True) + assert hashlib.sha256(content).hexdigest()==digest + p=Path(temporary)/(name+'.json');p.write_bytes(content);p.chmod(0o600);paths[name]=p + sources=list(native._prepared_sources(paths['proposal'],paths['disclosure'],paths['consent'])) + expected_urls={native._raw_url(source['repo'],source['consented_commit'],path) for source,files in sources for path in files} + allow=json.loads(os.environ.get('REUSE_SURFACE_AUTH_SOURCE_ALLOWLIST','{}')) + assert set(allow)=={native.AUTH_ENV} and len(allow[native.AUTH_ENV])==14 and set(allow[native.AUTH_ENV])==expected_urls + # Presence only: never print/read the secret value here. Native acquisition + # is subsequently performed only at its reviewed exact URL routing boundary. + key_present=native.AUTH_ENV in os.environ + assert key_present + result=native.verify(paths['proposal'],paths['disclosure'],paths['consent']) + receipt.update(source_sha256=source_sha,helper_sha256=helper_sha,packet_sha256=EXPECTED,runtime_allowlist_exact14=True,reader_key_present=True,native_fetch=result,status='verified' if result['all_verified'] else 'native_fetch_failed') +except Exception: + receipt.update(status='runtime_or_native_proof_refused',error='hash_scope_or_native_validation_failed') +print(json.dumps(receipt,sort_keys=True)) +raise SystemExit(0 if receipt['status']=='verified' else 1) diff --git a/docs/assessments/cust81-private-reader/phase-b-r1/phase-b-execution-proposal-r1.json b/docs/assessments/cust81-private-reader/phase-b-r1/phase-b-execution-proposal-r1.json new file mode 100644 index 0000000..34d390a --- /dev/null +++ b/docs/assessments/cust81-private-reader/phase-b-r1/phase-b-execution-proposal-r1.json @@ -0,0 +1,88 @@ +{ + "id": "CUST81-APPS-PHASE-B-EXECUTION-PROPOSAL-R1", + "disposition": "approved", + "approved_scope": "bounded preparation only; founder live go still required", + "status": "prepared_not_executed", + "workitem": "RAPPS-WP-0017-T03", + "active_predecessor_sha256": "e036084aec9fad3bea63cc0d591c3b40a392ab9f54d9226a2d2b3fd7194d8a47", + "replacement_sha256": "adfb34c0ee4d01bc602f57cd2320f1658465fb8f8660978828fffa05a5b604b5", + "preconditions": [ + "verified phase A current API pod ready pinned hardened image/source hashes", + "platform provisioner terminal verified and approved CCR revision executed", + "Forgejo exact7 readonly grants and PAT receipt verified", + "native ESO path-read policy/store issuer receipt verified", + "founder consent to exact phase B source update and delivery" + ], + "source_update_after_consent": { + "command": "cp docs/assessments/cust81-private-reader/reuse-surface-runtime-externalsecret.r1.yaml manifests/reuse-surface-runtime-externalsecret.yaml", + "publication": "owner commits only exact replacement and normal rmgr sync --push; verify primary exact source commit before liveapply", + "not_executed": true + }, + "apply_command": "ssh railiance01 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl apply -f /tmp/cust81-phase-b/reuse-surface-runtime-externalsecret.yaml'", + "sync_verify_before_restart": "wait for ESO Ready and selected reader-key-presence boolean via reviewed captured-output wrapper; never raw Secret/annotation/env output", + "restart_command": "ssh railiance01 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl rollout restart deployment/reuse-surface --namespace reuse'", + "restart_wait_command": "ssh railiance01 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl rollout status deployment/reuse-surface --namespace reuse --timeout=300s'", + "headroom_gate": "recheck100m/256Mi surge headroom/PVC and actual hardened deployment before restart", + "cleanup_order": [ + "credential owner revokes exact dedicated Forgejo PAT and verifies revocation; no oldtoken fallback", + "restore reviewed two-field ESO manifest through source-controlled owner lane and apply, stops rematerialization", + "remove only REUSE_SURFACE_FORGEJO_SOURCE_READ_TOKEN from reuse/reuse-surface-env with captured-output exact JSON patch", + "restart/wait current hardened deployment, verify reader-key absence boolean in new pod and materialized Secret plus exact token revocation", + "only then may separately approved Helm rollback reuse10 restore vulnerable priorimage; do not restore DB by default" + ], + "raw_output_contract": "all mutating/proof child stdout/stderr captured/discarded; only whitelisted outcome/proof JSON may reach receipts", + "runtime_proof": { + "pod_metadata_schema": [ + "verified API pod name and UID", + "Ready true", + "expected spec image OCI indexsha2364b7e...", + "actual imageID equals index or linux/amd64childsha1a69354af2bc87d4baaecc4b02e13fda468efd3746024f440cc6d138beeee2ab", + "node92.205.62.239", + "source/header-routing/native helper hashes verified", + "exact14 allowlist boolean", + "reader-key presence boolean", + "native14fetch/7consentedhashall_verified" + ], + "wrapper_path": "/tmp/cust81-apps-reader-owner/docs/assessments/cust81-private-reader/phase-b-r1/native_runtime_proof_stdin.py", + "wrapper_sha256": "1edc317e8563a120b62a6830eebb2875c144b45c57a491eb7da849095c5d1933", + "packets": { + "proposal": { + "path": "/home/worsch/the-custodian/docs/assessments/netkingdom-2026-10-14/private-reader-provisioning-preparation-r1.json", + "sha256": "7819eb51d249f5c7aff4df9fb520afcfb76603b05a4104439499102b6c20af99" + }, + "disclosure": { + "path": "/home/worsch/the-custodian/docs/assessments/netkingdom-2026-10-14/private-source-disclosure-proposal-r1.json", + "sha256": "46ac4fe49a08054cc215aade2fbaf3b36ee334520997857ed84f530b52da9d35" + }, + "consent": { + "path": "/home/worsch/the-custodian/docs/assessments/netkingdom-2026-10-14/private-source-owner-consent-r1.json", + "sha256": "4a4602ad926e3af855d6ddba4731430d33e4a7432c507ba0a5193b9f43056b52" + } + }, + "invocation": "capture kubectl --kubeconfig -n reuse exec -i -c reuse-surface -- python3 -c ; stdin JSON envelope {proposal:base64(exactbytes),disclosure:base64(exactbytes),consent:base64(exactbytes)}; no token/credential in argv or stdin; wrapper writes/removes only temporary nonsecret packet files", + "exit_success_required": true + }, + "no_live_credential_or_apply_action": true, + "revision": 2, + "refinement_or_rescue": true, + "original_revision_sha256": "sha256:572af68fcf56072c1fd846101a76611885acd6efedfe5dba10d96d9f77f701e0", + "reader_key_removal_ssh_argv": [ + "ssh", + "railiance01", + "env KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl patch secret reuse-surface-env --namespace reuse --type=json -p '[{\"op\":\"remove\",\"path\":\"/data/REUSE_SURFACE_FORGEJO_SOURCE_READ_TOKEN\"}]'" + ], + "reader_key_removal_remote_argv": [ + "env", + "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", + "kubectl", + "patch", + "secret", + "reuse-surface-env", + "--namespace", + "reuse", + "--type=json", + "-p", + "[{\"op\":\"remove\",\"path\":\"/data/REUSE_SURFACE_FORGEJO_SOURCE_READ_TOKEN\"}]" + ], + "reader_key_removal_offline_check": "passed shell roundtrip exact single remove op on only selected reader key; no other data field/annotation/spec touched" +} diff --git a/docs/assessments/cust81-private-reader/phase-b-r1/run_eso_preflight.py b/docs/assessments/cust81-private-reader/phase-b-r1/run_eso_preflight.py new file mode 100644 index 0000000..a1640b6 --- /dev/null +++ b/docs/assessments/cust81-private-reader/phase-b-r1/run_eso_preflight.py @@ -0,0 +1,30 @@ +from pathlib import Path +import subprocess,json,yaml,hashlib,datetime +art=Path(__file__).resolve().parent +canonical=Path('/home/worsch/railiance-apps') +old=canonical/'manifests/reuse-surface-runtime-externalsecret.yaml';new=canonical/'docs/assessments/cust81-private-reader/reuse-surface-runtime-externalsecret.r1.yaml' +base=yaml.safe_load(old.read_text());proposal=yaml.safe_load(new.read_text()) +assert proposal['spec']['data'][:-1]==base['spec']['data'] +item=proposal['spec']['data'][-1] +assert item=={'secretKey':'REUSE_SURFACE_FORGEJO_SOURCE_READ_TOKEN','remoteRef':{'key':'workloads/reuse/reuse-surface/forgejo-source-read','property':'FORGEJO_SOURCE_TOKEN'}} +assert {k:v for k,v in proposal.items() if k!='spec'}=={k:v for k,v in base.items() if k!='spec'} +assert {k:v for k,v in proposal['spec'].items() if k!='data'}=={k:v for k,v in base['spec'].items() if k!='data'} +kube=['kubectl','--kubeconfig','/home/worsch/.kube/config-railiance01'] +p=subprocess.run(kube+['get','nodes','-o','json'],capture_output=True,timeout=20);nodes=json.loads(p.stdout);assert p.returncode==0 and len(nodes['items'])==1 and any(a['address']=='92.205.62.239' for a in nodes['items'][0]['status']['addresses']) +p=subprocess.run(kube+['-n','reuse','get','externalsecret','reuse-surface-runtime','-o','json'],capture_output=True,timeout=20);assert p.returncode==0;live=json.loads(p.stdout) +rows=[{'secretKey':r['secretKey'],'remoteRef':{k:r['remoteRef'][k] for k in ('key','property')}} for r in live['spec']['data']] +assert rows==base['spec']['data'] and live['spec']['secretStoreRef']==base['spec']['secretStoreRef'] +assert all({k:v for k,v in r['remoteRef'].items() if k not in ('key','property')}=={'conversionStrategy':'Default','decodingStrategy':'None','metadataPolicy':'None'} for r in live['spec']['data']) +results=[] +for args in [['apply','--dry-run=server','-f',str(new)],['diff','-f',str(new)]]: + p=subprocess.run(kube+['-n','reuse']+args,capture_output=True,timeout=60) + if args[0]=='diff': + changed=[line[1:].strip() for line in p.stdout.decode(errors='replace').splitlines() if line.startswith(('+','-')) and not line.startswith(('+++','---'))] + intended={'- remoteRef:', 'key: workloads/reuse/reuse-surface/forgejo-source-read','property: FORGEJO_SOURCE_TOKEN','secretKey: REUSE_SURFACE_FORGEJO_SOURCE_READ_TOKEN','conversionStrategy: Default','decodingStrategy: None','metadataPolicy: None'} + other=[line for line in changed if line not in intended and not line.startswith('generation:')] + assert not other + results.append({'operation':'client_diff','exit_code':p.returncode,'exact_new_reader_item_plus_native_defaults_only':True,'raw_values_annotations_stderr_discarded':True}) + else:results.append({'operation':'server_dry_run','exit_code':p.returncode,'raw_stdout_stderr_discarded':True}) +assert results[0]['exit_code']==0 and results[1]['exit_code'] in (0,1) +r={'id':'CUST81-APPS-PHASE-B-ESO-PREFLIGHT-R1','checked_at':datetime.datetime.now(datetime.timezone.utc).isoformat(),'target_node_ip':'92.205.62.239','status':'verified_preparation','active_predecessor_path':str(old),'active_predecessor_sha256':hashlib.sha256(old.read_bytes()).hexdigest(),'proposed_replacement_path':str(new),'proposed_replacement_sha256':hashlib.sha256(new.read_bytes()).hexdigest(),'old_two_fields_retained':True,'existing_store_unchanged':True,'live_spec_projected_matches_predecessor':True,'native_remoteRef_defaults_verified':True,'previous_attempt':'full live data equality assertion refused on native ESO defaults; no command apply executed, original refusal retained','results':results,'credentials_provisioned':False,'live_apply_performed':False,'limitation':'server acceptance does not prove dedicated KV field exists or ESO consumer policy reader path granted'} +(art/'eso-preflight-r1.json').write_text(json.dumps(r,indent=2)+'\n');print(json.dumps(r,indent=2)) From 91f828b3c167c72ab9a0cec1263467345a7ab63c Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 15 Oct 2026 02:15:36 +0200 Subject: [PATCH 35/87] Declare approved hardened reuse image and exact source routing Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a137ca-5c77-7a71-b9ae-23ad9b0b5684 --- helm/reuse-surface-values.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/helm/reuse-surface-values.yaml b/helm/reuse-surface-values.yaml index f8f1ffa..db6781c 100644 --- a/helm/reuse-surface-values.yaml +++ b/helm/reuse-surface-values.yaml @@ -11,6 +11,9 @@ image: # hardening that keeps one malformed member index from 500-ing the federated # endpoint. See REUSE-WP-0020-T05. tag: "main-6cbc862" + # Founder-approved CUST81 phase A: digest takes precedence over the legacy tag. + repository: forgejo.coulomb.social/coulomb/reuse-surface + digest: sha256:2364b7e7859daccf9340a063654e2b09eed1bc27e0adf7b23cbb4b30135a7db0 landing: enabled: true @@ -30,3 +33,21 @@ landing: ingress: redirectHttp: enabled: true + +# Exact founder-approved private metadata routing; credential delivery is separate. +authSourceAllowlist: + REUSE_SURFACE_FORGEJO_SOURCE_READ_TOKEN: + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-openbao/raw/registry/capabilities/capability.railiance.openbao-package.md?ref=35672be138036c8433a5ac5de91e068c03c2b124 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-openbao/raw/registry/indexes/capabilities.yaml?ref=35672be138036c8433a5ac5de91e068c03c2b124 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-policy-nexus/raw/registry/capabilities/capability.railiance.policy-publication-package.md?ref=088b78ac03b0cebeec34a69aae3569eb7ec2f9c3 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-policy-nexus/raw/registry/indexes/capabilities.yaml?ref=088b78ac03b0cebeec34a69aae3569eb7ec2f9c3 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-postgres/raw/registry/capabilities/capability.railiance.postgres-isolation-recovery.md?ref=a9f569c96d74aa384ac8d3ac4aa960e4061550b5 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-postgres/raw/registry/indexes/capabilities.yaml?ref=a9f569c96d74aa384ac8d3ac4aa960e4061550b5 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-sbom-nexus/raw/registry/capabilities/capability.railiance.sbom-runtime-package.md?ref=adbedd349e8df97be43a1f57db7095af45ff29d1 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-sbom-nexus/raw/registry/indexes/capabilities.yaml?ref=adbedd349e8df97be43a1f57db7095af45ff29d1 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-telemetry/raw/registry/capabilities/capability.railiance.telemetry-package.md?ref=f158307e24b86bab88a1af4830ff6b4f85afdeb5 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-telemetry/raw/registry/indexes/capabilities.yaml?ref=f158307e24b86bab88a1af4830ff6b4f85afdeb5 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-tenant-engine/raw/registry/capabilities/capability.railiance.tenant-engine-package.md?ref=9bdc86572728783a5ed94266f04f7b12d414c557 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-tenant-engine/raw/registry/indexes/capabilities.yaml?ref=9bdc86572728783a5ed94266f04f7b12d414c557 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-user-engine/raw/registry/capabilities/capability.railiance.user-engine-package.md?ref=f35108f9e1b8702fb6c526fe42b2fec5ca032738 + - https://forgejo.coulomb.social/api/v1/repos/coulomb/rapp-user-engine/raw/registry/indexes/capabilities.yaml?ref=f35108f9e1b8702fb6c526fe42b2fec5ca032738 From 2b9f030c24cc084ea1b78913bab895420ed76e2c Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 15 Oct 2026 05:11:08 +0200 Subject: [PATCH 36/87] Declare approved scoped Forgejo reader delivery to reuse hub Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a137ca-5c77-7a71-b9ae-23ad9b0b5684 --- manifests/reuse-surface-runtime-externalsecret.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/manifests/reuse-surface-runtime-externalsecret.yaml b/manifests/reuse-surface-runtime-externalsecret.yaml index 6239f45..3822799 100644 --- a/manifests/reuse-surface-runtime-externalsecret.yaml +++ b/manifests/reuse-surface-runtime-externalsecret.yaml @@ -1,3 +1,4 @@ +# PROPOSAL ONLY: phase B, after hardened runtime verified and CCR approved. # reuse-surface hub runtime secrets from OpenBao (RAILIANCE-WP-0011 / CCR-2026-0005). # # OpenBao path (platform KV v2): workloads/reuse/reuse-surface/runtime-secrets @@ -38,4 +39,8 @@ spec: - secretKey: REUSE_SURFACE_FORGEJO_WEBHOOK_SECRET remoteRef: key: workloads/reuse/reuse-surface/runtime-secrets - property: REUSE_SURFACE_FORGEJO_WEBHOOK_SECRET \ No newline at end of file + property: REUSE_SURFACE_FORGEJO_WEBHOOK_SECRET + - secretKey: REUSE_SURFACE_FORGEJO_SOURCE_READ_TOKEN + remoteRef: + key: workloads/reuse/reuse-surface/forgejo-source-read + property: FORGEJO_SOURCE_TOKEN From a1ef3fe77a2b441a9d664771932fe72b89515d99 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 15 Oct 2026 05:49:19 +0200 Subject: [PATCH 37/87] Close verified reuse reader rollout and hosted discovery Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a137ca-5c77-7a71-b9ae-23ad9b0b5684 --- ...PS-WP-0017-reuse-private-reader-release.md | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/workplans/RAPPS-WP-0017-reuse-private-reader-release.md b/workplans/RAPPS-WP-0017-reuse-private-reader-release.md index 105616d..50a6e25 100644 --- a/workplans/RAPPS-WP-0017-reuse-private-reader-release.md +++ b/workplans/RAPPS-WP-0017-reuse-private-reader-release.md @@ -4,7 +4,7 @@ type: workplan title: "Release hardened reuse federation with seven-source metadata reader" domain: financials repo: railiance-apps -status: proposed +status: finished owner: codex created: "2026-10-15" updated: "2026-10-15" @@ -13,10 +13,10 @@ depends_on: [RPF-WP-0058, REUSE-WP-0025] state_hub_workstream_id: "7c71b152-f9ef-5377-8c95-72a416205cb2" --- -Provisional id in isolated preparation checkout; root integration must confirm -availability. No hub registration, fix-consistency or push until integration. -Source owners approved metadata disclosure under CUST81 R1; that is not approval -for live credential issuance or workload rollout. Apps owns only S5 release. +Integrated and completed under founder-approved CUST81 exact execution revision1 +(SHA256 704a9c932202fc14456652ea4dfdb3acb8b1db0ab3bbf5fff50e92b2c517c380) +and approved writer amendment revision2. Metadata consent is recorded separately. +Apps owns only the S5 release; platform owns credential custody and maintenance. ## Prepare bounded release contract @@ -35,7 +35,7 @@ Evidence: docs/assessments/cust81-private-reader/apps-owner-review-r1.md. ```task id: RAPPS-WP-0017-T02 -status: wait +status: done priority: high state_hub_task_id: "a23046fc-8912-55a6-8cb9-34ae0104bc42" ``` @@ -49,7 +49,7 @@ and rollback receipt. Phase A deploy excludes reader ESO mapping. ```task id: RAPPS-WP-0017-T03 -status: wait +status: done priority: high state_hub_task_id: "6825e54c-2b67-52a7-aa46-d746b62b924b" ``` @@ -59,3 +59,19 @@ mapping and restart hardened runtime through reviewed owner lane. Require sanitized ESO/key-presence proof and reuse native14 fetch/7 entry hashes before registration. Final CUST81 owner verifies70/70 hosted; rollback revokes/removes reader before any vulnerable image restoration. + +2026-10-15 verified closeout: Phase A hardened image/runtime/14-URL guard proof +and source declaration were verified and published at 91f828b. Approved phase B +one-field ESO delivery source was published at 2b9f030; Ready/key presence, +restart and exact approved runtime were verified. Actual native verification +fetched all seven indexes and seven entries against consented hashes without +cache fallback. Final scoped writer registration completed seven POST201 and +full-field readback, with session revocation confirmed. Forced hosted composition +2026-10-15T03:31:48Z proves all70 reviewed IDs exactly once, correct source and +no warnings (114 total capabilities from99 sources). Evidence published in +The Custodian at e5aaa0db51edc4c9f935d03ab58808b86e340823: +phase-b-runtime-verification-r1.json, private-native-fetch-verification-r1.json, +phase-b-owner-execution-r1.json and hosted-private-registration-verification-r1.json +under docs/assessments/netkingdom-2026-10-14/. Owner maintenance remains live in +RPF-WP-0058-T05 (2027-01-13); OTP return-context triage is CUST-IN-0018. +No additional release work or production change remains in this workplan. From 9b0999fc377ebeb83f66427b2827d2c098681c83 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 17 Oct 2026 11:25:11 +0200 Subject: [PATCH 38/87] Record Apps owner review for NK-WP-0048-T16 and plan consumer seams Needs-change verdict on deliver-consumer-secret and hand-over adapters (net-kingdom ff8f597); option C preferred. WP-0016 T02 moves to the platform/workloads/keycape/forgejo custody path; new T05 for the real Apps owner seams. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 1031639@bnt-lap001 Assistant-Session: e5073fee-f4ae-4fc3-a75a-6c1fc83e0576 --- .custodian-brief.md | 7 ++- WORK-RECORDS.md | 6 +- .../owner-review-railiance-apps.json | 63 +++++++++++++++++++ .../RAPPS-WP-0016-forgejo-netkingdom-login.md | 30 ++++++++- 4 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 docs/evidence/nk48-t16-owner-review/owner-review-railiance-apps.json diff --git a/.custodian-brief.md b/.custodian-brief.md index c76ea12..51265c8 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,7 +2,7 @@ # Custodian Brief — railiance-apps **Domain:** financials -**Last synced:** 2026-10-11 01:12 UTC +**Last synced:** 2026-10-13 12:02 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams @@ -12,8 +12,11 @@ Progress: 0/4 done | workplan_id: `f6b1d6f6-0abb-5774-87b9-bf3cfb640a3b` **Open tasks:** - ! T01 — Make the local administrator a sealed break-glass `13cd3aec` + *(wait: Needs break-glass in place and the NetKingdom client registered with its secret in custody.)* - ! T02 — Add the NetKingdom authentication source `81f072aa` + *(wait: Linking and team mapping follow a working authentication source.)* - ! T03 — Link accounts and map groups to teams `32c54737` + *(wait: Restricting local password sign-in is a founder decision once NetKingdom sign-in is proven.)* - · Tasks `98004ed0` ### Coulomb-social persistent personal workspace release @@ -31,7 +34,7 @@ Progress: 2/3 done | workplan_id: `c7fdaa7e-cab8-5d1d-86c2-f1aad7927c57` ## Inbox Hygiene -**Stale unread:** 2 message(s) older than 3 day(s) — triage at session start. +**Stale unread:** 4 message(s) older than 3 day(s) — triage at session start. **Missing thread_id:** 4 unread message(s) lack supersession chains. --- diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index dba8a65..c785214 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -13,7 +13,8 @@ | workplan | RAPPS-WP-0003 | finished | — | workplans/RAPPS-WP-0003-policy-nexus-production-binding.md | | workplan | RAPPS-WP-0014 | blocked | — | workplans/RAPPS-WP-0014-vergabe-invited-pilot.md | | workplan | RAPPS-WP-0015 | active | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | -| workplan | RAPPS-WP-0016 | ready | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | +| workplan | RAPPS-WP-0016 | active | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | +| workplan | RAPPS-WP-0017 | finished | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | | task | RAPPS-WP-0001-T01 | done | — | workplans/RAPPS-WP-0001-cnpg-backup-scheduledbackup-coverage.md | | task | RAPPS-WP-0001-T02 | done | — | workplans/RAPPS-WP-0001-cnpg-backup-scheduledbackup-coverage.md | | task | RAPPS-WP-0001-T03 | done | — | workplans/RAPPS-WP-0001-cnpg-backup-scheduledbackup-coverage.md | @@ -41,3 +42,6 @@ | task | RAPPS-WP-0016-T02 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T03 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T04 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | +| task | RAPPS-WP-0017-T01 | done | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | +| task | RAPPS-WP-0017-T02 | done | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | +| task | RAPPS-WP-0017-T03 | done | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | diff --git a/docs/evidence/nk48-t16-owner-review/owner-review-railiance-apps.json b/docs/evidence/nk48-t16-owner-review/owner-review-railiance-apps.json new file mode 100644 index 0000000..b1ae754 --- /dev/null +++ b/docs/evidence/nk48-t16-owner-review/owner-review-railiance-apps.json @@ -0,0 +1,63 @@ +{ + "schema": "custodian.owner-review.v1", + "review": "NK-WP-0048-T16", + "owner": "railiance-apps", + "reviewed_at": "2026-10-17", + "reviewer": "railiance-apps (Claude Code session, source-only)", + "verdict": "needs-change", + "grants": "nothing; no admission, pin, dispatch or native action", + "request": "the-custodian docs/changes/netkingdom-target-2026-10-17/nk48-t16-owner-review/README.md @ bffeee9", + "frozen_input": { + "repo": "net-kingdom", + "commit": "ff8f597d5f2f32ce8e82e370c0ad43aa98dc31b7", + "files": { + "sso-mfa/k8s/keycape/deliver-consumer-secret-adapter.py": "sha256:d046eed63090d4e0849a931e2650ccfd89d3b686843aed9e5cb35a0e18e513ee", + "sso-mfa/k8s/keycape/handover-adapter.py": "sha256:0efc300a7b2765bce8da140d5c4666d9cb30daf02b4cbf0e0df96cba7c33d026", + "sso-mfa/k8s/keycape/deliver-consumer-secret-adapter.md": "sha256:4662cff37f2a445f450d5d9d9255cd4315f9d4e4a9adde2cb8d52bd8f1f1212b", + "sso-mfa/k8s/keycape/handover-adapter.md": "sha256:9756921a9a51eea3d9b2dd04b9f135bf0712dfe0c0cff638173da85ce7f5ddaa", + "sso-mfa/k8s/keycape/descriptor-composition-1.2.candidate.json": "sha256:745bb053e6776e8b96224563188405920227e4a17d7783ffa6b36c122be41b09" + }, + "hash_check": "both adapter .py hashes equal coordinator-review-r3.json hashes_verified" + }, + "option_preference": { + "choice": "C", + "reason": "Both Apps steps change the production forgejo namespace and stay owner-run with the founder's go per change. Apps does not want Warden to dispatch them; Warden verifying Apps receipts is acceptable. Apps does not object to B for mint-secret, which is Platform's step." + }, + "will_implement": { + "deliver-consumer-secret": { + "answer": "yes, as an owner-run operator path, not a Warden-dispatched one", + "owner_apply": "Commit the consumer ServiceAccount (automount false), SecretStore (serviceAccountRef, audiences [openbao], role workload-kv-read-keycape-forgejo-forgejo) and ExternalSecret (Owner creation, Retain deletion, path workloads/keycape/forgejo, key CLIENT_SECRET only) as manifests in this repo, apply them under RAPPS-WP-0016-T02, and emit the value-free netkingdom.consumer-secret-delivery.v1 receipt from kubectl metadata (uid, created, Ready).", + "owner_remove": "Revert the Forgejo oauth source secret reference first (helm rollback to the noted revision), then remove only the created objects in the order ExternalSecret, SecretStore, ServiceAccount, and report consumer_reference_reverted=true.", + "accepted_as_is": ["receipt schema and per-object metadata", "value-free rule", "created-only rollback", "custody path platform/workloads/keycape/forgejo field CLIENT_SECRET (net-kingdom 074b3a0)"] + }, + "hand-over": { + "answer": "yes, attended (actor human), lane name apps-consumer-handover accepted", + "record_handover": "Receipt store is this repo: a committed evidence file docs/evidence/-forgejo-netkingdom-handover.json; identifier is path@commit; created=false when a file with the same observation digest is already committed.", + "remove_handover": "A revert commit of that evidence file; confirmed only when the revert is on main." + } + }, + "consumer_verification": { + "principle": "Login and group enforcement are observed at the consumer, never inferred from registry CAS, the client registration or the ExternalSecret being Ready.", + "login_verified": "An attended browser sign-in through 'Sign in with netkingdom' on Forgejo by a member of the case access group succeeds, and Forgejo shows the account linked to auth source 'netkingdom' (admin API: user's login_source / external login entry).", + "enforces_groups": "Positive and negative probe: (1) a NetKingdom test user that is not in any case access group is refused or gets no team access; (2) adding that user to the mapped group grants the mapped Forgejo team membership on next sign-in; (3) removing it revokes the team membership. This is the RAPPS-WP-0016-T03 acceptance test.", + "observation_body": "The digest passed to record_handover covers the client_id, the groups probed, the Forgejo user ids and team ids observed, and the timestamps of the three probes. No tokens, cookies or secret values." + }, + "needs_change": [ + { + "file": "sso-mfa/k8s/keycape/handover-adapter.py", + "finding": "verify_consumer returns bare booleans (login_verified, enforces_groups). The adapter cannot tell an observed consumer result from a self-asserted one.", + "requested": "Require the verifier result to carry the observation evidence (or its authenticated identifier) for a positive login, a negative non-member probe and an add/remove membership probe, and include that in the digest." + }, + { + "file": "sso-mfa/k8s/keycape/deliver-consumer-secret-adapter.py", + "finding": "Descriptor actor is 'agent' for a step that changes a production consumer namespace. Under railiance-apps rules, production-touching applies need the founder's go per change.", + "requested": "Actor 'human' (attended), or an explicit per-change founder approval reference in the binding. Lane name consumer-owner-deployment is otherwise accepted." + } + ], + "carried_limits": { + "5_injected_stand_ins": "Accepted as a known limit; turned into task RAPPS-WP-0016-T05 (real Apps owner_apply/owner_remove, verifier and receipt store).", + "6_mint_then_revoke_failure": "Platform-owned; no Apps position.", + "others": "1-4 are net-kingdom / ops-warden concerns; Apps accepts them as known limits for its two steps." + }, + "not_reviewed": ["mint-secret", "deliver-secret", "ensure-groups", "bind-env", "register-client", "composer", "checker artifact", "kernel admission"] +} diff --git a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md index 2b2bd70..85f518f 100644 --- a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md +++ b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md @@ -74,8 +74,14 @@ state_hub_task_id: "13cd3aec-c87d-5d3f-a421-39d8ff131edd" ### T02 — Add the NetKingdom authentication source -- an ExternalSecret delivering the `forgejo` client secret from OpenBao (path - from KEY-WP-0038-T02) to a Secret in the `forgejo` namespace +- an ExternalSecret delivering the `forgejo` client secret from OpenBao to a + Secret in the `forgejo` namespace. Custody path is + `platform/workloads/keycape/forgejo`, field `CLIENT_SECRET`, read through role + `workload-kv-read-keycape-forgejo-forgejo` (net-kingdom 074b3a0; replaces the + earlier `platform/workloads/forgejo/netkingdom-oidc-client` proposal). The + SA/SecretStore/ExternalSecret shape follows the NK-WP-0048 + `deliver-consumer-secret` receipt contract + (`docs/evidence/nk48-t16-owner-review/owner-review-railiance-apps.json`) - `gitea.oauth`: name `netkingdom` (must match the redirect URI registered in key-cape), provider `openidConnect`, auto-discovery from `https://kc.coulomb.social/.well-known/openid-configuration`, scopes @@ -130,6 +136,26 @@ decides whether local password sign-in is turned off for everyone except break-glass. This is not required to close the forge; it is the next tightening step, taken separately. +```task +id: RAPPS-WP-0016-T05 +status: wait +depends_on: [RAPPS-WP-0016-T02] +blocking_reason: "Waits for the NK-WP-0048-T16 option decision and net-kingdom's answer to the Apps needs-change record." +priority: medium +``` + +### T05 — Apps owner seams for NK-WP-0048 consumer steps + +Implement the real Apps capabilities behind the `deliver-consumer-secret` and +`hand-over` adapters, as owner-run paths (option C): `owner_apply` / +`owner_remove` emitting `netkingdom.consumer-secret-delivery.v1` receipts from the +T02 objects; a consumer verifier that observes Forgejo sign-in and the T03 +positive/negative group probes; and a committed-evidence hand-over receipt store. +See the owner-review record for the contract. + +Acceptance: receipts produced from the live T02/T03 run validate against the +adapters' checks with no value-bearing keys. + ## Also in scope: stale node credential The k3s `registries.yaml` on railiance01 still holds a login for the frozen Gitea From fbe012789ef72c3aa8b9e00658ac0278495913d6 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 17 Oct 2026 11:26:05 +0200 Subject: [PATCH 39/87] Write back hub IDs for RAPPS-WP-0016-T05 Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 1031639@bnt-lap001 Assistant-Session: e5073fee-f4ae-4fc3-a75a-6c1fc83e0576 --- .custodian-brief.md | 6 ++++-- WORK-RECORDS.md | 1 + workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.custodian-brief.md b/.custodian-brief.md index 51265c8..739c57e 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,13 +2,13 @@ # Custodian Brief — railiance-apps **Domain:** financials -**Last synced:** 2026-10-13 12:02 UTC +**Last synced:** 2026-10-17 09:25 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams ### Forgejo sign-in through NetKingdom, before the forge closes -Progress: 0/4 done | workplan_id: `f6b1d6f6-0abb-5774-87b9-bf3cfb640a3b` +Progress: 0/5 done | workplan_id: `f6b1d6f6-0abb-5774-87b9-bf3cfb640a3b` **Open tasks:** - ! T01 — Make the local administrator a sealed break-glass `13cd3aec` @@ -17,6 +17,8 @@ Progress: 0/4 done | workplan_id: `f6b1d6f6-0abb-5774-87b9-bf3cfb640a3b` *(wait: Linking and team mapping follow a working authentication source.)* - ! T03 — Link accounts and map groups to teams `32c54737` *(wait: Restricting local password sign-in is a founder decision once NetKingdom sign-in is proven.)* +- ! T04 — Decide on local password sign-in `728786c9` + *(wait: Waits for the NK-WP-0048-T16 option decision and net-kingdom's answer to the Apps needs-change record.)* - · Tasks `98004ed0` ### Coulomb-social persistent personal workspace release diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index c785214..deccb0e 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -42,6 +42,7 @@ | task | RAPPS-WP-0016-T02 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T03 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T04 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | +| task | RAPPS-WP-0016-T05 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0017-T01 | done | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | | task | RAPPS-WP-0017-T02 | done | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | | task | RAPPS-WP-0017-T03 | done | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | diff --git a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md index 85f518f..4be2ae4 100644 --- a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md +++ b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md @@ -142,6 +142,7 @@ status: wait depends_on: [RAPPS-WP-0016-T02] blocking_reason: "Waits for the NK-WP-0048-T16 option decision and net-kingdom's answer to the Apps needs-change record." priority: medium +state_hub_task_id: "728786c9-f1f9-5983-a6a4-dea21faf67f5" ``` ### T05 — Apps owner seams for NK-WP-0048 consumer steps From fe5cabecd6632f678fca02b45d2938e65e6fd31b Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 17 Oct 2026 11:32:09 +0200 Subject: [PATCH 40/87] Add Forgejo break-glass seal/verify tooling and recovery runbook RAPPS-WP-0016-T01: founder-run targets rotate the forgejo_admin password into OpenBao (platform/workloads/forgejo/break-glass-admin) and record a value-free sign-in. T01 stays in progress until the founder runs them. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 1031639@bnt-lap001 Assistant-Session: e5073fee-f4ae-4fc3-a75a-6c1fc83e0576 --- Makefile | 6 ++ docs/forgejo-on-railiance01.md | 40 +++++++++++++ tools/forgejo-breakglass-seal.sh | 58 ++++++++++++++++++ tools/forgejo-breakglass-verify.sh | 59 +++++++++++++++++++ .../RAPPS-WP-0016-forgejo-netkingdom-login.md | 4 +- 5 files changed, 166 insertions(+), 1 deletion(-) create mode 100755 tools/forgejo-breakglass-seal.sh create mode 100755 tools/forgejo-breakglass-verify.sh diff --git a/Makefile b/Makefile index c02679d..a1cb302 100644 --- a/Makefile +++ b/Makefile @@ -514,6 +514,12 @@ forgejo-verify: check-railiance01-kubeconfig ## T05 acceptance: smoke, TLS, DB, forgejo-operator-bootstrap: ## Idempotent operator user + SSH key bootstrap (no secrets in Git) FORGEJO_BASE_URL="$(FORGEJO_BASE_URL)" tools/forgejo-operator-bootstrap.sh +forgejo-breakglass-seal: check-railiance01-kubeconfig ## Founder: rotate break-glass admin password into OpenBao (interactive, production-touching) + FORGEJO_KUBECONFIG="$(FORGEJO_KUBECONFIG)" FORGEJO_NAMESPACE="$(FORGEJO_NAMESPACE)" tools/forgejo-breakglass-seal.sh + +forgejo-breakglass-verify: ## Founder: verify one break-glass sign-in and write value-free evidence + FORGEJO_BASE_URL="$(FORGEJO_BASE_URL)" tools/forgejo-breakglass-verify.sh + forgejo-runner-registration-sops-bootstrap: check-railiance01-kubeconfig ## Capture cluster runner token into SOPS (operator age key) KUBECONFIG="$(FORGEJO_KUBECONFIG)" tools/forgejo-runner-registration-sops-bootstrap.sh diff --git a/docs/forgejo-on-railiance01.md b/docs/forgejo-on-railiance01.md index 1661731..fae7ee2 100644 --- a/docs/forgejo-on-railiance01.md +++ b/docs/forgejo-on-railiance01.md @@ -104,6 +104,46 @@ make forgejo-runner-registration-sops-bootstrap make check-sops SOPS_SENTINEL=helm/forgejo-runner-registration.sops.yaml ``` +## Break-glass administrator (RAPPS-WP-0016-T01) + +The floor under every sign-in change: one local site admin that works even when +NetKingdom, KeyCape or the OAuth source is broken. + +| Item | Value | +| --- | --- | +| Account | `forgejo_admin` (chart bootstrap admin, local password, site admin) | +| Custody | OpenBao coulombcore `platform/workloads/forgejo/break-glass-admin`, field `PASSWORD` | +| Delivered to cluster | No. Not synced by ESO; only a human retrieves it | +| Who | The founder. Agents never read or hold the value | + +The SOPS admin password in `helm/forgejo-secrets.sops.yaml` is only the chart's +initial value (`passwordMode: initialOnlyRequireReset`); Helm upgrades do not +reset the account. It is not the break-glass credential. + +Seal or rotate, then verify (founder, interactive): + +```bash +make forgejo-breakglass-seal # new password -> OpenBao first, then Forgejo +make forgejo-breakglass-verify # one local sign-in; writes docs/evidence/-forgejo-breakglass-signin.json +``` + +Recovery when normal sign-in is broken: + +1. `bao kv get -field=PASSWORD platform/workloads/forgejo/break-glass-admin` + (operator token on `https://bao.coulomb.social`). +2. Sign in at `https://forgejo.coulomb.social/user/login` as `forgejo_admin` + with local credentials. Local password sign-in stays enabled for this account + even if RAPPS-WP-0016-T04 restricts it for everyone else. +3. Repair the authentication source or users from Site Administration. +4. If the password itself is lost: with cluster access, run + `make forgejo-breakglass-seal` again (it sets the password through the + Forgejo CLI in the pod, so it needs no working sign-in). +5. Afterwards rotate (`make forgejo-breakglass-seal`), verify, and record the use. + +Where it is logged: OpenBao audit log (retrieval), Forgejo log and the account's +last-login time (sign-in), the evidence file in `docs/evidence/`, and a State Hub +progress event naming why break-glass was used. + ## SMTP (IONOS) — password reset / notifications Decision: `the-custodian/docs/forgejo-production-decisions.md` (IONOS, 2026-07-07). diff --git a/tools/forgejo-breakglass-seal.sh b/tools/forgejo-breakglass-seal.sh new file mode 100755 index 0000000..091164a --- /dev/null +++ b/tools/forgejo-breakglass-seal.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Seal the Forgejo break-glass administrator (RAPPS-WP-0016-T01). +# Founder-run, interactive. Generates a fresh password for the local site admin, +# stores it only in OpenBao (coulombcore), then sets it in Forgejo. The value is +# never printed, logged or written to disk. +set -euo pipefail + +BREAKGLASS_USER="${FORGEJO_BREAKGLASS_USER:-forgejo_admin}" +BREAKGLASS_PATH="${FORGEJO_BREAKGLASS_PATH:-platform/workloads/forgejo/break-glass-admin}" +BAO_ADDR="${BAO_ADDR:-https://bao.coulomb.social}" +FORGEJO_KUBECONFIG="${FORGEJO_KUBECONFIG:-$HOME/.kube/config-hosteurope}" +FORGEJO_NAMESPACE="${FORGEJO_NAMESPACE:-forgejo}" + +for cmd in bao kubectl openssl; do + command -v "$cmd" >/dev/null 2>&1 || { echo "ERROR: $cmd not found" >&2; exit 1; } +done + +if [[ -z "${BAO_TOKEN:-}" ]]; then + read -r -s -p "OpenBao operator token (coulombcore / bao.coulomb.social): " BAO_TOKEN + echo >&2 +fi +[[ -n "${BAO_TOKEN:-}" ]] || { echo "ERROR: empty OpenBao token" >&2; exit 1; } +export BAO_ADDR BAO_TOKEN + +if curl -fsS "$BAO_ADDR/v1/sys/health" | grep -q '"sealed":true'; then + echo "ERROR: OpenBao at $BAO_ADDR reports sealed" >&2 + exit 1 +fi + +pod="$(KUBECONFIG="$FORGEJO_KUBECONFIG" kubectl -n "$FORGEJO_NAMESPACE" get pod \ + -l app.kubernetes.io/name=gitea -o jsonpath='{.items[0].metadata.name}')" +[[ -n "$pod" ]] || { echo "ERROR: no Forgejo pod in $FORGEJO_NAMESPACE" >&2; exit 1; } + +echo "Break-glass user: $BREAKGLASS_USER (site admin, local sign-in)" +echo "Custody path: $BREAKGLASS_PATH (field PASSWORD) on $BAO_ADDR" +echo "Forgejo pod: $pod" +read -r -p "Rotate the break-glass password now? [y/N] " answer +[[ "$answer" == "y" ]] || { echo "Aborted; nothing changed."; exit 1; } + +password="$(openssl rand -base64 48 | tr -d '\n/+=' | cut -c1-40)" + +# Store first: if this fails, Forgejo still has the old password. +printf '%s' "$password" | bao kv put "$BREAKGLASS_PATH" \ + PASSWORD=- USERNAME="$BREAKGLASS_USER" ROTATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" >/dev/null +stored="$(bao kv get -field=PASSWORD "$BREAKGLASS_PATH")" +[[ "$stored" == "$password" ]] || { echo "ERROR: OpenBao read-back mismatch; Forgejo unchanged" >&2; exit 1; } +unset stored + +# Password goes to the pod on stdin so it never appears in process arguments +# on the workstation. +printf '%s\n' "$password" | KUBECONFIG="$FORGEJO_KUBECONFIG" kubectl -n "$FORGEJO_NAMESPACE" \ + exec -i "$pod" -c gitea -- sh -c \ + 'read -r p; forgejo admin user change-password --username "$0" --password "$p" --must-change-password=false >/dev/null' \ + "$BREAKGLASS_USER" +unset password + +echo "Sealed: $BREAKGLASS_USER password rotated and stored at $BREAKGLASS_PATH." +echo "Next: make forgejo-breakglass-verify" diff --git a/tools/forgejo-breakglass-verify.sh b/tools/forgejo-breakglass-verify.sh new file mode 100755 index 0000000..d7bd9d7 --- /dev/null +++ b/tools/forgejo-breakglass-verify.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Verify one break-glass sign-in (RAPPS-WP-0016-T01) and write value-free evidence. +# Founder-run. Reads the password from OpenBao into memory, authenticates against +# the Forgejo API with local credentials, and records only the outcome. +set -euo pipefail + +BREAKGLASS_USER="${FORGEJO_BREAKGLASS_USER:-forgejo_admin}" +BREAKGLASS_PATH="${FORGEJO_BREAKGLASS_PATH:-platform/workloads/forgejo/break-glass-admin}" +BAO_ADDR="${BAO_ADDR:-https://bao.coulomb.social}" +FORGEJO_BASE_URL="${FORGEJO_BASE_URL:-https://forgejo.coulomb.social}" +EVIDENCE="${FORGEJO_BREAKGLASS_EVIDENCE:-docs/evidence/$(date -u +%Y-%m-%d)-forgejo-breakglass-signin.json}" + +command -v bao >/dev/null 2>&1 || { echo "ERROR: bao CLI not found" >&2; exit 1; } +if [[ -z "${BAO_TOKEN:-}" ]]; then + read -r -s -p "OpenBao operator token (coulombcore / bao.coulomb.social): " BAO_TOKEN + echo >&2 +fi +export BAO_ADDR BAO_TOKEN + +password="$(bao kv get -field=PASSWORD "$BREAKGLASS_PATH")" +version="$(bao kv metadata get -format=json "$BREAKGLASS_PATH" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["current_version"])')" + +# Credentials reach curl through a config on stdin, not argv. +body_file="$(mktemp)" +trap 'rm -f "$body_file"' EXIT +code="$(printf 'user = "%s:%s"\n' "$BREAKGLASS_USER" "$password" \ + | curl -sS -K - -o "$body_file" -w '%{http_code}' "$FORGEJO_BASE_URL/api/v1/user")" +unset password + +python3 - "$code" "$BREAKGLASS_USER" "$BREAKGLASS_PATH" "$version" "$FORGEJO_BASE_URL" \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$EVIDENCE" "$body_file" <<'EOF' +import json, sys +code, user, path, version, base, at, out, body = sys.argv[1:] +try: + me = json.load(open(body)) +except ValueError: + me = {} +ok = code == "200" and me.get("login") == user and me.get("is_admin") is True +record = { + "schema": "railiance-apps.forgejo-breakglass-signin.v1", + "task": "RAPPS-WP-0016-T01", + "forgejo": base, + "account": user, + "auth": "local password (API basic auth)", + "http_status": int(code) if code.isdigit() else code, + "login_matches": me.get("login") == user, + "is_admin": me.get("is_admin"), + "custody": {"openbao_path": path, "field": "PASSWORD", "version": version}, + "verified_at": at, + "result": "pass" if ok else "fail", + "values_emitted": False, +} +with open(out, "w") as f: + json.dump(record, f, indent=2) + f.write("\n") +print(f"{record['result']}: {user} sign-in (HTTP {code}); evidence {out}") +sys.exit(0 if ok else 1) +EOF diff --git a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md index 4be2ae4..dc11cf9 100644 --- a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md +++ b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md @@ -48,7 +48,9 @@ side is key-cape `KEY-WP-0038`. ```task id: RAPPS-WP-0016-T01 -status: todo +status: progress +needs_human: true +blocking_reason: "Tooling and runbook ready; founder runs make forgejo-breakglass-seal and make forgejo-breakglass-verify." priority: high state_hub_task_id: "98004ed0-83e6-5f6e-9b18-15b8b2aec2e7" ``` From 74213d80f043d4830161af11e44e86e985774199 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 17 Oct 2026 11:41:51 +0200 Subject: [PATCH 41/87] Close RAPPS-WP-0016-T01: break-glass admin sealed and verified forgejo_admin password lives only in OpenBao platform/workloads/forgejo/break-glass-admin (v1); founder-run local sign-in passed (HTTP 200, site admin). Value-free evidence committed. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 1031639@bnt-lap001 Assistant-Session: e5073fee-f4ae-4fc3-a75a-6c1fc83e0576 --- .custodian-brief.md | 8 ++++---- WORK-RECORDS.md | 2 +- .../2026-10-17-forgejo-breakglass-signin.json | 18 ++++++++++++++++++ .../RAPPS-WP-0016-forgejo-netkingdom-login.md | 11 +++++++---- 4 files changed, 30 insertions(+), 9 deletions(-) create mode 100644 docs/evidence/2026-10-17-forgejo-breakglass-signin.json diff --git a/.custodian-brief.md b/.custodian-brief.md index 739c57e..4be7fc6 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,7 +2,7 @@ # Custodian Brief — railiance-apps **Domain:** financials -**Last synced:** 2026-10-17 09:25 UTC +**Last synced:** 2026-10-17 09:32 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams @@ -19,7 +19,7 @@ Progress: 0/5 done | workplan_id: `f6b1d6f6-0abb-5774-87b9-bf3cfb640a3b` *(wait: Restricting local password sign-in is a founder decision once NetKingdom sign-in is proven.)* - ! T04 — Decide on local password sign-in `728786c9` *(wait: Waits for the NK-WP-0048-T16 option decision and net-kingdom's answer to the Apps needs-change record.)* -- · Tasks `98004ed0` +- ► Tasks `98004ed0` ### Coulomb-social persistent personal workspace release Progress: 3/4 done | workplan_id: `79abacf8-361e-51ab-829d-40dec8e142d1` @@ -36,8 +36,8 @@ Progress: 2/3 done | workplan_id: `c7fdaa7e-cab8-5d1d-86c2-f1aad7927c57` ## Inbox Hygiene -**Stale unread:** 4 message(s) older than 3 day(s) — triage at session start. -**Missing thread_id:** 4 unread message(s) lack supersession chains. +**Stale unread:** 2 message(s) older than 3 day(s) — triage at session start. +**Missing thread_id:** 2 unread message(s) lack supersession chains. --- ## MCP Orientation (when available) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index deccb0e..f56782b 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -38,7 +38,7 @@ | task | RAPPS-WP-0015-T02 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T03 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T04 | wait | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | -| task | RAPPS-WP-0016-T01 | todo | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | +| task | RAPPS-WP-0016-T01 | progress | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T02 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T03 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T04 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | diff --git a/docs/evidence/2026-10-17-forgejo-breakglass-signin.json b/docs/evidence/2026-10-17-forgejo-breakglass-signin.json new file mode 100644 index 0000000..8a208f4 --- /dev/null +++ b/docs/evidence/2026-10-17-forgejo-breakglass-signin.json @@ -0,0 +1,18 @@ +{ + "schema": "railiance-apps.forgejo-breakglass-signin.v1", + "task": "RAPPS-WP-0016-T01", + "forgejo": "https://forgejo.coulomb.social", + "account": "forgejo_admin", + "auth": "local password (API basic auth)", + "http_status": 200, + "login_matches": true, + "is_admin": true, + "custody": { + "openbao_path": "platform/workloads/forgejo/break-glass-admin", + "field": "PASSWORD", + "version": "1" + }, + "verified_at": "2026-10-17T09:41:18Z", + "result": "pass", + "values_emitted": false +} diff --git a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md index dc11cf9..4640b64 100644 --- a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md +++ b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md @@ -48,9 +48,7 @@ side is key-cape `KEY-WP-0038`. ```task id: RAPPS-WP-0016-T01 -status: progress -needs_human: true -blocking_reason: "Tooling and runbook ready; founder runs make forgejo-breakglass-seal and make forgejo-breakglass-verify." +status: done priority: high state_hub_task_id: "98004ed0-83e6-5f6e-9b18-15b8b2aec2e7" ``` @@ -65,11 +63,16 @@ sign-in and record it without the credential. Acceptance: a recorded break-glass sign-in, with the recovery path written down. This is the "never lock ourselves out" floor for every later step. +Done 2026-10-17: founder sealed `forgejo_admin` into OpenBao +`platform/workloads/forgejo/break-glass-admin` (version 1) and verified a local +sign-in (HTTP 200, site admin): `docs/evidence/2026-10-17-forgejo-breakglass-signin.json`. +Recovery path: `docs/forgejo-on-railiance01.md` § Break-glass administrator. + ```task id: RAPPS-WP-0016-T02 status: wait depends_on: [RAPPS-WP-0016-T01, KEY-WP-0038-T02] -blocking_reason: "Needs break-glass in place and the NetKingdom client registered with its secret in custody." +blocking_reason: "Break-glass done (T01); needs the NetKingdom client registered with its secret in custody (KEY-WP-0038-T02)." priority: high state_hub_task_id: "13cd3aec-c87d-5d3f-a421-39d8ff131edd" ``` From 0da83472c4b0fdb76a9ccf60a7eaf8d64958ac53 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 17 Oct 2026 11:46:40 +0200 Subject: [PATCH 42/87] Default railiance01 kubeconfig to config-railiance01 config-hosteurope names port 16443, but the k3s-api-railiance01 ops-bridge tunnel listens on 16444, so every default target failed with connection refused. Switch Makefile, tools and docs to ~/.kube/config-railiance01 and fix the restore recipe to rewrite the API address to the tunnel port. The node-side hostPath in STATE.md is unrelated and unchanged. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 1031639@bnt-lap001 Assistant-Session: e5073fee-f4ae-4fc3-a75a-6c1fc83e0576 --- .custodian-brief.md | 7 +++---- Makefile | 2 +- STATE.md | 2 +- WORK-RECORDS.md | 2 +- docs/cnpg-backup-topology-inventory.md | 2 +- docs/credential-routing-railiance-apps.md | 2 +- docs/forgejo-on-railiance01.md | 14 ++++++------- docs/forgejo-package-registry.md | 2 +- docs/informed-decision-origin.md | 11 +++++----- docs/inter-hub-on-railiance01.md | 6 +++--- docs/operator-setup.md | 16 +++++++++------ docs/reuse-surface-on-railiance01.md | 20 +++++++++---------- tools/forgejo-breakglass-seal.sh | 2 +- tools/forgejo-helm-secrets.sh | 2 +- tools/forgejo-openbao-eso-token-apply.sh | 2 +- ...gejo-runner-registration-sops-bootstrap.sh | 2 +- tools/forgejo-verify.sh | 2 +- tools/reuse-forgejo-webhook.sh | 2 +- tools/reuse-openbao-eso-token-apply.sh | 2 +- tools/reuse-webhook-smoke.sh | 2 +- 20 files changed, 52 insertions(+), 50 deletions(-) diff --git a/.custodian-brief.md b/.custodian-brief.md index 4be7fc6..b12bc98 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,24 +2,23 @@ # Custodian Brief — railiance-apps **Domain:** financials -**Last synced:** 2026-10-17 09:32 UTC +**Last synced:** 2026-10-17 09:42 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams ### Forgejo sign-in through NetKingdom, before the forge closes -Progress: 0/5 done | workplan_id: `f6b1d6f6-0abb-5774-87b9-bf3cfb640a3b` +Progress: 1/5 done | workplan_id: `f6b1d6f6-0abb-5774-87b9-bf3cfb640a3b` **Open tasks:** - ! T01 — Make the local administrator a sealed break-glass `13cd3aec` - *(wait: Needs break-glass in place and the NetKingdom client registered with its secret in custody.)* + *(wait: Break-glass done (T01); needs the NetKingdom client registered with its secret in custody (KEY-WP-0038-T02).)* - ! T02 — Add the NetKingdom authentication source `81f072aa` *(wait: Linking and team mapping follow a working authentication source.)* - ! T03 — Link accounts and map groups to teams `32c54737` *(wait: Restricting local password sign-in is a founder decision once NetKingdom sign-in is proven.)* - ! T04 — Decide on local password sign-in `728786c9` *(wait: Waits for the NK-WP-0048-T16 option decision and net-kingdom's answer to the Apps needs-change record.)* -- ► Tasks `98004ed0` ### Coulomb-social persistent personal workspace release Progress: 3/4 done | workplan_id: `79abacf8-361e-51ab-829d-40dec8e142d1` diff --git a/Makefile b/Makefile index a1cb302..da70a3a 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,7 @@ POLICY_NEXUS_SOURCE_SET_DIGEST ?= $(shell python3 -c 'import json; print(json.lo POLICY_NEXUS_REVISION ?= COULOMBCORE_KUBECONFIG ?= $(HOME)/.kube/config -RAILIANCE01_KUBECONFIG ?= $(HOME)/.kube/config-hosteurope +RAILIANCE01_KUBECONFIG ?= $(HOME)/.kube/config-railiance01 PRODUCTION_KUBECONFIG ?= $(COULOMBCORE_KUBECONFIG) INTER_HUB_KUBECONFIG ?= $(RAILIANCE01_KUBECONFIG) REUSE_KUBECONFIG ?= $(RAILIANCE01_KUBECONFIG) diff --git a/STATE.md b/STATE.md index 29a4baf..8b954df 100644 --- a/STATE.md +++ b/STATE.md @@ -44,7 +44,7 @@ export KUBECONFIG=~/.kube/config make cnpg-backup-status # railiance01 activity -export KUBECONFIG=~/.kube/config-hosteurope +export KUBECONFIG=~/.kube/config-railiance01 kubectl -n activity-core get externalsecret actcore-backup-offsite # inventory should list Daily CNPG Option A Backup enabled ``` diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index f56782b..89c6d25 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -38,7 +38,7 @@ | task | RAPPS-WP-0015-T02 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T03 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T04 | wait | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | -| task | RAPPS-WP-0016-T01 | progress | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | +| task | RAPPS-WP-0016-T01 | done | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T02 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T03 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T04 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | diff --git a/docs/cnpg-backup-topology-inventory.md b/docs/cnpg-backup-topology-inventory.md index d189798..37f7bfd 100644 --- a/docs/cnpg-backup-topology-inventory.md +++ b/docs/cnpg-backup-topology-inventory.md @@ -9,7 +9,7 @@ | Host | Kubeconfig (operator workstation) | API | | --- | --- | --- | | CoulombCore | `~/.kube/config` | tunnel `k3s-api-coulombcore` | -| railiance01 | `~/.kube/config-hosteurope` | local / hosteurope | +| railiance01 | `~/.kube/config-railiance01` | local / hosteurope | ## Production-of-record (POR) decisions diff --git a/docs/credential-routing-railiance-apps.md b/docs/credential-routing-railiance-apps.md index b43444b..8fe4f40 100644 --- a/docs/credential-routing-railiance-apps.md +++ b/docs/credential-routing-railiance-apps.md @@ -9,7 +9,7 @@ platform tooling. | Host | IP | Kubeconfig | Tunnel | | --- | --- | --- | --- | | CoulombCore (production apps) | `92.205.130.254` | `~/.kube/config` via `k3s-api-coulombcore` | `bridge up k3s-api-coulombcore` | -| Railiance01 (Forgejo S5) | `92.205.62.239` | `~/.kube/config-hosteurope` | `bridge up k3s-api-railiance01` if configured | +| Railiance01 (Forgejo S5) | `92.205.62.239` | `~/.kube/config-railiance01` | `bridge up k3s-api-railiance01` if configured | Core Hub, `vergabe-teilnahme`, and `apps-pg` currently run on **CoulombCore**. Railiance01 hosts Forgejo and a overlapping subset of platform databases. diff --git a/docs/forgejo-on-railiance01.md b/docs/forgejo-on-railiance01.md index fae7ee2..aa5c795 100644 --- a/docs/forgejo-on-railiance01.md +++ b/docs/forgejo-on-railiance01.md @@ -27,7 +27,7 @@ Hostname decision: `the-custodian/docs/forgejo-production-decisions.md`. | Chart | `gitea-charts/gitea` **12.5.0** (Forgejo-compatible; 12.6+ needs Gitea 1.26 `config edit-ini`) | | Image | `code.forgejo.org/forgejo/forgejo:11.0.3` | | Database | `forgejo-db-rw.databases.svc.cluster.local:5432` | -| Kubeconfig | `~/.kube/config-hosteurope` | +| Kubeconfig | `~/.kube/config-railiance01` | ## Bootstrap (first deploy) @@ -36,8 +36,8 @@ Hostname decision: `the-custodian/docs/forgejo-production-decisions.md`. ```bash cd ~/railiance-platform # One-time: create and SOPS-encrypt helm/forgejo-db-secret.sops.yaml from template -KUBECONFIG=~/.kube/config-hosteurope make forgejo-db-deploy -KUBECONFIG=~/.kube/config-hosteurope make forgejo-db-status +KUBECONFIG=~/.kube/config-railiance01 make forgejo-db-deploy +KUBECONFIG=~/.kube/config-railiance01 make forgejo-db-status ``` ### 2. Application secrets (apps) @@ -185,10 +185,10 @@ Bootstrap (railiance01): ```bash cd ~/railiance-apps -KUBECONFIG=~/.kube/config-hosteurope make forgejo-openbao-eso-token-apply -KUBECONFIG=~/.kube/config-hosteurope make forgejo-mailer-es-deploy -KUBECONFIG=~/.kube/config-hosteurope make forgejo-mailer-es-status # expect SecretSynced -KUBECONFIG=~/.kube/config-hosteurope make forgejo-deploy +KUBECONFIG=~/.kube/config-railiance01 make forgejo-openbao-eso-token-apply +KUBECONFIG=~/.kube/config-railiance01 make forgejo-mailer-es-deploy +KUBECONFIG=~/.kube/config-railiance01 make forgejo-mailer-es-status # expect SecretSynced +KUBECONFIG=~/.kube/config-railiance01 make forgejo-deploy ``` Do not put the SMTP password in `helm/forgejo-secrets.sops.yaml`. diff --git a/docs/forgejo-package-registry.md b/docs/forgejo-package-registry.md index ba38fb0..99440ad 100644 --- a/docs/forgejo-package-registry.md +++ b/docs/forgejo-package-registry.md @@ -19,7 +19,7 @@ docker push forgejo.coulomb.social/coulomb/REPO:TAG Cluster pull (railiance01): ```bash -KUBECONFIG=~/.kube/config-hosteurope crictl pull forgejo.coulomb.social/coulomb/REPO:TAG +KUBECONFIG=~/.kube/config-railiance01 crictl pull forgejo.coulomb.social/coulomb/REPO:TAG ``` Smoke: `make forgejo-smoke` diff --git a/docs/informed-decision-origin.md b/docs/informed-decision-origin.md index 6c35e31..6d62535 100644 --- a/docs/informed-decision-origin.md +++ b/docs/informed-decision-origin.md @@ -120,7 +120,7 @@ see the correction above. ## Redeploy / recovery ```bash -export KUBECONFIG=$HOME/.kube/config-hosteurope +export KUBECONFIG=$HOME/.kube/config-railiance01 kubectl apply -f manifests/informed-decision-origin.yaml kubectl apply -f manifests/informed-decision-ingress.yaml kubectl apply -f manifests/informed-decision-http-redirect.yaml @@ -132,11 +132,10 @@ curl -sSI https://decisions.coulomb.social/ | head -1 That `curl` succeeded on 2026-09-10, which is the gate `informed-decision` was waiting on. -**Note on the kubeconfig:** `~/.kube/config-hosteurope` names port `16443`, but -the `k3s-api-railiance01` ops-bridge tunnel currently listens on `16444`. -Override with `--server https://127.0.0.1:16444` or fix the kubeconfig; `bridge -check` reports the tunnel healthy either way, so it does not surface the -mismatch. +**Note on the kubeconfig:** use `~/.kube/config-railiance01`, which targets the +`k3s-api-railiance01` ops-bridge tunnel on `16444`. The retired +`~/.kube/config-hosteurope` names port `16443`; `bridge check` reports the tunnel +healthy either way, so it does not surface that mismatch. ## HTTP → HTTPS diff --git a/docs/inter-hub-on-railiance01.md b/docs/inter-hub-on-railiance01.md index 98234e8..482d96d 100644 --- a/docs/inter-hub-on-railiance01.md +++ b/docs/inter-hub-on-railiance01.md @@ -22,7 +22,7 @@ deploy Inter-Hub to production. | Values | `helm/inter-hub-values.yaml` | | Image | `forgejo.coulomb.social/coulomb/inter-hub:` | | Secret | `inter-hub-env` | -| Production kubeconfig | `~/.kube/config-hosteurope` | +| Production kubeconfig | `~/.kube/config-railiance01` | ## Current evidence @@ -50,7 +50,7 @@ Read-only checks on 2026-06-15 showed: ## Deploy Use the Railiance01 kubeconfig. The Makefile defaults to -`~/.kube/config-hosteurope` and fails fast when it is missing. +`~/.kube/config-railiance01` and fails fast when it is missing. ```bash make inter-hub-render-baseline @@ -86,7 +86,7 @@ production trigger. It requires: - `image_tag`, the immutable inter-hub image tag to deploy; - `confirm`, with the exact value `deploy-inter-hub-production`; - an approved self-hosted runner with Railiance01 kubeconfig access at - `~/.kube/config-hosteurope` or an equivalent `RAILIANCE01_KUBECONFIG` + `~/.kube/config-railiance01` or an equivalent `RAILIANCE01_KUBECONFIG` override; - registry access through `skopeo`, `crane`, `docker manifest inspect`, or the registry HTTP API. diff --git a/docs/operator-setup.md b/docs/operator-setup.md index 5596735..687e8bc 100644 --- a/docs/operator-setup.md +++ b/docs/operator-setup.md @@ -51,23 +51,27 @@ ambient `kubectl` context for production app deploys. The production Makefile targets default to: ```text -~/.kube/config-hosteurope +~/.kube/config-railiance01 ``` +It reaches the API through the `k3s-api-railiance01` ops-bridge tunnel on +`127.0.0.1:16444` (`bridge up k3s-api-railiance01` if it is down). The older +`~/.kube/config-hosteurope` names port `16443` and is retired. + Restore it from Railiance01 when missing: ```bash ssh tegwick@92.205.62.239 'sudo cat /etc/rancher/k3s/k3s.yaml' \ - | sed 's|127.0.0.1|92.205.62.239|' > ~/.kube/config-hosteurope -chmod 600 ~/.kube/config-hosteurope -export KUBECONFIG=~/.kube/config-hosteurope + | sed 's|127.0.0.1:6443|127.0.0.1:16444|' > ~/.kube/config-railiance01 +chmod 600 ~/.kube/config-railiance01 +export KUBECONFIG=~/.kube/config-railiance01 ``` The app-specific targets also accept explicit overrides: ```bash -REUSE_KUBECONFIG=~/.kube/config-hosteurope make reuse-status -INTER_HUB_KUBECONFIG=~/.kube/config-hosteurope make inter-hub-status +REUSE_KUBECONFIG=~/.kube/config-railiance01 make reuse-status +INTER_HUB_KUBECONFIG=~/.kube/config-railiance01 make inter-hub-status ``` ## SOPS Age Key Bootstrap diff --git a/docs/reuse-surface-on-railiance01.md b/docs/reuse-surface-on-railiance01.md index 665c01e..8264dd0 100644 --- a/docs/reuse-surface-on-railiance01.md +++ b/docs/reuse-surface-on-railiance01.md @@ -22,7 +22,7 @@ Let's Encrypt HTTP-01 on Railiance01 requires the public A record to reach ```bash dig +short reuse.coulomb.social A -KUBECONFIG=~/.kube/config-hosteurope kubectl get certificate -n reuse +KUBECONFIG=~/.kube/config-railiance01 kubectl get certificate -n reuse ``` If DNS is being changed in the future, use an explicit resolve only during @@ -30,7 +30,7 @@ propagation: ```bash curl -k --resolve reuse.coulomb.social:443:92.205.62.239 https://reuse.coulomb.social/health -KUBECONFIG=~/.kube/config-hosteurope kubectl port-forward -n reuse svc/reuse-surface 18001:8000 +KUBECONFIG=~/.kube/config-railiance01 kubectl port-forward -n reuse svc/reuse-surface 18001:8000 export REUSE_SURFACE_URL=http://127.0.0.1:18001 ``` @@ -65,8 +65,8 @@ Deployment. After patching the Secret, restart the Deployment so new values are picked up: ```bash -kubectl --kubeconfig ~/.kube/config-hosteurope rollout restart deployment/reuse-surface -n reuse -kubectl --kubeconfig ~/.kube/config-hosteurope rollout status deployment/reuse-surface -n reuse +kubectl --kubeconfig ~/.kube/config-railiance01 rollout restart deployment/reuse-surface -n reuse +kubectl --kubeconfig ~/.kube/config-railiance01 rollout status deployment/reuse-surface -n reuse ``` **Rotation** (never commit or paste values into chat): @@ -75,10 +75,10 @@ Full procedure: `railiance-platform/docs/reuse-surface-runtime-secrets-rotation- ```bash # After updating field(s) in OpenBao — force ESO sync, roll hub, reconcile webhook if needed -kubectl --kubeconfig ~/.kube/config-hosteurope annotate externalsecret \ +kubectl --kubeconfig ~/.kube/config-railiance01 annotate externalsecret \ reuse-surface-runtime -n reuse force-sync="$(date +%s)" --overwrite make reuse-runtime-es-status -kubectl --kubeconfig ~/.kube/config-hosteurope rollout restart deployment/reuse-surface -n reuse +kubectl --kubeconfig ~/.kube/config-railiance01 rollout restart deployment/reuse-surface -n reuse make reuse-forgejo-webhook # required when REUSE_SURFACE_FORGEJO_WEBHOOK_SECRET changed make reuse-webhook-smoke ``` @@ -153,14 +153,14 @@ the API service. ```bash # 1. Pin image tag in helm/reuse-surface-values.yaml # 2. Ensure reuse-surface-env has both keys (see Runtime secrets above) -# 3. Production (Railiance01, defaults to ~/.kube/config-hosteurope) +# 3. Production (Railiance01, defaults to ~/.kube/config-railiance01) make reuse-deploy make reuse-forgejo-webhook # after webhook secret is in the cluster Secret make reuse-status # Restore kubeconfig from the node if missing: # ssh tegwick@92.205.62.239 'sudo cat /etc/rancher/k3s/k3s.yaml' \ -# | sed 's|127.0.0.1|92.205.62.239|' > ~/.kube/config-hosteurope +# | sed 's|127.0.0.1:6443|127.0.0.1:16444|' > ~/.kube/config-railiance01 ``` ## Smoke checks @@ -172,7 +172,7 @@ curl -fsS https://reuse.coulomb.social/ curl -fsS https://reuse.coulomb.social/health curl -fsS https://reuse.coulomb.social/v1/federated -export REUSE_SURFACE_TOKEN=$(kubectl --kubeconfig ~/.kube/config-hosteurope get secret reuse-surface-env -n reuse \ +export REUSE_SURFACE_TOKEN=$(kubectl --kubeconfig ~/.kube/config-railiance01 get secret reuse-surface-env -n reuse \ -o jsonpath='{.data.REUSE_SURFACE_TOKEN}' | base64 -d) export REUSE_SURFACE_URL=https://reuse.coulomb.social reuse-surface hub status @@ -212,4 +212,4 @@ make reuse-deploy ``` Bootstrap copy on CoulombCore (`92.205.130.254`) was removed 2026-06-15 — use -`config-hosteurope` only. +`config-railiance01` only. diff --git a/tools/forgejo-breakglass-seal.sh b/tools/forgejo-breakglass-seal.sh index 091164a..5adce66 100755 --- a/tools/forgejo-breakglass-seal.sh +++ b/tools/forgejo-breakglass-seal.sh @@ -8,7 +8,7 @@ set -euo pipefail BREAKGLASS_USER="${FORGEJO_BREAKGLASS_USER:-forgejo_admin}" BREAKGLASS_PATH="${FORGEJO_BREAKGLASS_PATH:-platform/workloads/forgejo/break-glass-admin}" BAO_ADDR="${BAO_ADDR:-https://bao.coulomb.social}" -FORGEJO_KUBECONFIG="${FORGEJO_KUBECONFIG:-$HOME/.kube/config-hosteurope}" +FORGEJO_KUBECONFIG="${FORGEJO_KUBECONFIG:-$HOME/.kube/config-railiance01}" FORGEJO_NAMESPACE="${FORGEJO_NAMESPACE:-forgejo}" for cmd in bao kubectl openssl; do diff --git a/tools/forgejo-helm-secrets.sh b/tools/forgejo-helm-secrets.sh index 4aba1af..f5c1f7f 100755 --- a/tools/forgejo-helm-secrets.sh +++ b/tools/forgejo-helm-secrets.sh @@ -4,7 +4,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SECRETS_FILE="${FORGEJO_SECRETS:-$ROOT_DIR/helm/forgejo-secrets.sops.yaml}" -KUBECONFIG="${KUBECONFIG:-$HOME/.kube/config-hosteurope}" +KUBECONFIG="${KUBECONFIG:-$HOME/.kube/config-railiance01}" emit_from_sops() { sops -d "$SECRETS_FILE" | python3 -c " diff --git a/tools/forgejo-openbao-eso-token-apply.sh b/tools/forgejo-openbao-eso-token-apply.sh index 40d8be6..a02249e 100755 --- a/tools/forgejo-openbao-eso-token-apply.sh +++ b/tools/forgejo-openbao-eso-token-apply.sh @@ -6,7 +6,7 @@ set -euo pipefail POLICY_NAME="${OPENBAO_FORGEJO_POLICY:-workload-kv-read-forgejo-mailer}" POLICY_FILE="${OPENBAO_FORGEJO_POLICY_FILE:-$HOME/railiance-platform/openbao/policies/workload-kv-read-forgejo-mailer.hcl}" BAO_ADDR="${BAO_ADDR:-https://bao.coulomb.social}" -RAILIANCE01_KUBECONFIG="${RAILIANCE01_KUBECONFIG:-$HOME/.kube/config-hosteurope}" +RAILIANCE01_KUBECONFIG="${RAILIANCE01_KUBECONFIG:-$HOME/.kube/config-railiance01}" SECRET_NAME="${OPENBAO_FORGEJO_ESO_SECRET:-openbao-forgejo-eso-token}" SECRET_NS="${OPENBAO_FORGEJO_ESO_NAMESPACE:-external-secrets}" diff --git a/tools/forgejo-runner-registration-sops-bootstrap.sh b/tools/forgejo-runner-registration-sops-bootstrap.sh index b639013..2a40d83 100755 --- a/tools/forgejo-runner-registration-sops-bootstrap.sh +++ b/tools/forgejo-runner-registration-sops-bootstrap.sh @@ -5,7 +5,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" OUT="${ROOT}/helm/forgejo-runner-registration.sops.yaml" -KUBECONFIG="${KUBECONFIG:-$HOME/.kube/config-hosteurope}" +KUBECONFIG="${KUBECONFIG:-$HOME/.kube/config-railiance01}" FORGEJO_NAMESPACE="${FORGEJO_NAMESPACE:-forgejo}" export KUBECONFIG diff --git a/tools/forgejo-verify.sh b/tools/forgejo-verify.sh index 1843d5f..d17791e 100755 --- a/tools/forgejo-verify.sh +++ b/tools/forgejo-verify.sh @@ -4,7 +4,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BASE_URL="${FORGEJO_BASE_URL:-https://forgejo.coulomb.social}" -KUBECONFIG="${KUBECONFIG:-$HOME/.kube/config-hosteurope}" +KUBECONFIG="${KUBECONFIG:-$HOME/.kube/config-railiance01}" FORGEJO_NAMESPACE="${FORGEJO_NAMESPACE:-forgejo}" FORGEJO_DB_NAMESPACE="${FORGEJO_DB_NAMESPACE:-databases}" FORGEJO_DB_CLUSTER="${FORGEJO_DB_CLUSTER:-forgejo-db}" diff --git a/tools/reuse-forgejo-webhook.sh b/tools/reuse-forgejo-webhook.sh index 15b7320..2ce77cb 100755 --- a/tools/reuse-forgejo-webhook.sh +++ b/tools/reuse-forgejo-webhook.sh @@ -6,7 +6,7 @@ set -euo pipefail FORGEJO_API="${FORGEJO_API:-https://forgejo.coulomb.social/api/v1}" FORGEJO_ORG="${FORGEJO_ORG:-coulomb}" WEBHOOK_URL="${REUSE_WEBHOOK_URL:-https://reuse.coulomb.social/v1/webhooks/forgejo}" -KUBECONFIG_PATH="${KUBECONFIG:-$HOME/.kube/config-hosteurope}" +KUBECONFIG_PATH="${KUBECONFIG:-$HOME/.kube/config-railiance01}" SECRET_NS="${REUSE_SECRET_NAMESPACE:-reuse}" SECRET_NAME="${REUSE_SECRET_NAME:-reuse-surface-env}" SECRET_KEY="${REUSE_WEBHOOK_SECRET_KEY:-REUSE_SURFACE_FORGEJO_WEBHOOK_SECRET}" diff --git a/tools/reuse-openbao-eso-token-apply.sh b/tools/reuse-openbao-eso-token-apply.sh index d1c8c15..ad5bc11 100755 --- a/tools/reuse-openbao-eso-token-apply.sh +++ b/tools/reuse-openbao-eso-token-apply.sh @@ -6,7 +6,7 @@ set -euo pipefail POLICY_NAME="${OPENBAO_REUSE_POLICY:-workload-kv-read-reuse-surface-runtime}" POLICY_FILE="${OPENBAO_REUSE_POLICY_FILE:-$HOME/railiance-platform/openbao/policies/workload-kv-read-reuse-surface-runtime.hcl}" BAO_ADDR="${BAO_ADDR:-https://bao.coulomb.social}" -RAILIANCE01_KUBECONFIG="${RAILIANCE01_KUBECONFIG:-$HOME/.kube/config-hosteurope}" +RAILIANCE01_KUBECONFIG="${RAILIANCE01_KUBECONFIG:-$HOME/.kube/config-railiance01}" SECRET_NAME="${OPENBAO_REUSE_ESO_SECRET:-openbao-reuse-eso-token}" SECRET_NS="${OPENBAO_REUSE_ESO_NAMESPACE:-external-secrets}" diff --git a/tools/reuse-webhook-smoke.sh b/tools/reuse-webhook-smoke.sh index f7a48a0..e406267 100755 --- a/tools/reuse-webhook-smoke.sh +++ b/tools/reuse-webhook-smoke.sh @@ -5,7 +5,7 @@ set -euo pipefail REUSE_URL="${REUSE_URL:-https://reuse.coulomb.social}" WEBHOOK_PATH="${REUSE_WEBHOOK_PATH:-/v1/webhooks/forgejo}" -KUBECONFIG_PATH="${KUBECONFIG:-$HOME/.kube/config-hosteurope}" +KUBECONFIG_PATH="${KUBECONFIG:-$HOME/.kube/config-railiance01}" SECRET_NS="${REUSE_SECRET_NAMESPACE:-reuse}" SECRET_NAME="${REUSE_SECRET_NAME:-reuse-surface-env}" SECRET_KEY="${REUSE_WEBHOOK_SECRET_KEY:-REUSE_SURFACE_FORGEJO_WEBHOOK_SECRET}" From 944b30ad63ab054cc24617e374e177d34cd572da Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 17 Oct 2026 12:12:52 +0200 Subject: [PATCH 43/87] RAPPS-WP-0016: correct T02 role/policy naming, point T05 at 1.3 review T02 named the OpenBao policy as the role; the Kubernetes-auth role is external-secrets-keycape-forgejo-forgejo, carrying only policy workload-kv-read-keycape-forgejo-forgejo. T05's blocking reason now points at the Apps 1.3 owner-review record instead of the settled 1.2 option decision. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 1045697@bnt-lap001 Assistant-Session: f16df812-4a57-4823-a2da-73c69e3bb4d1 --- workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md index 4640b64..a1615e3 100644 --- a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md +++ b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md @@ -81,9 +81,10 @@ state_hub_task_id: "13cd3aec-c87d-5d3f-a421-39d8ff131edd" - an ExternalSecret delivering the `forgejo` client secret from OpenBao to a Secret in the `forgejo` namespace. Custody path is - `platform/workloads/keycape/forgejo`, field `CLIENT_SECRET`, read through role - `workload-kv-read-keycape-forgejo-forgejo` (net-kingdom 074b3a0; replaces the - earlier `platform/workloads/forgejo/netkingdom-oidc-client` proposal). The + `platform/workloads/keycape/forgejo`, field `CLIENT_SECRET`, read by ESO through + Kubernetes-auth role `external-secrets-keycape-forgejo-forgejo`, which carries + only policy `workload-kv-read-keycape-forgejo-forgejo` (net-kingdom 074b3a0; + replaces the earlier `platform/workloads/forgejo/netkingdom-oidc-client` proposal). The SA/SecretStore/ExternalSecret shape follows the NK-WP-0048 `deliver-consumer-secret` receipt contract (`docs/evidence/nk48-t16-owner-review/owner-review-railiance-apps.json`) @@ -145,7 +146,7 @@ step, taken separately. id: RAPPS-WP-0016-T05 status: wait depends_on: [RAPPS-WP-0016-T02] -blocking_reason: "Waits for the NK-WP-0048-T16 option decision and net-kingdom's answer to the Apps needs-change record." +blocking_reason: "Waits for net-kingdom's answer to the Apps 1.3 needs-change record (the-custodian docs/changes/netkingdom-target-2026-10-17/nk48-1.3-owner-review/owner-review-railiance-apps.json: RAPPS-1.3-C1..C3) and the re-frozen 1.3 candidate." priority: medium state_hub_task_id: "728786c9-f1f9-5983-a6a4-dea21faf67f5" ``` From c29408ae6747158a639dc876e6d7492680675658 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 18 Oct 2026 00:06:47 +0200 Subject: [PATCH 44/87] Add pinned Forgejo release build path and track operational integration Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1468b-63fa-7710-85dc-8f2e16b3ebfa --- Makefile | 27 + build/forgejo/README.md | 72 +++ build/forgejo/inputs.lock.json | 63 +++ build/forgejo/required-claim-values.patch | 484 ++++++++++++++++++ build/forgejo/runtime-base-config.json | 1 + tools/forgejo-release-build.py | 239 +++++++++ .../RAPPS-WP-0016-forgejo-netkingdom-login.md | 95 +++- 7 files changed, 980 insertions(+), 1 deletion(-) create mode 100644 build/forgejo/README.md create mode 100644 build/forgejo/inputs.lock.json create mode 100644 build/forgejo/required-claim-values.patch create mode 100644 build/forgejo/runtime-base-config.json create mode 100644 tools/forgejo-release-build.py diff --git a/Makefile b/Makefile index da70a3a..d08e729 100644 --- a/Makefile +++ b/Makefile @@ -655,3 +655,30 @@ help: ## Show this help .PHONY: check-tools check-sops k8s-server-dry-run apps-pg-status apps-pg-restore-drill check-railiance01-kubeconfig check-inter-hub-image-tag check-inter-hub-image check-vergabe-image-tag check-vergabe-image vergabe-dry-run vergabe-deploy vergabe-ingress-deploy vergabe-status vergabe-migrate vergabe-seed vergabe-superuser vergabe-logs vergabe-db-url-secret check-coulomb-social-image-tag coulomb-social-dry-run coulomb-social-deploy coulomb-social-ingress-deploy coulomb-social-status coulomb-social-logs coulomb-social-env-secret coulomb-social-env-secret-dry-run eso-deploy forgejo-openbao-eso-token-apply forgejo-openbao-store-deploy forgejo-mailer-es-deploy forgejo-mailer-es-status forgejo-dry-run forgejo-server-dry-run forgejo-deploy forgejo-ingress-deploy forgejo-ssh-nodeport-deploy forgejo-status forgejo-smoke forgejo-npm-smoke forgejo-verify forgejo-operator-bootstrap forgejo-runner-registration-sops-bootstrap forgejo-secrets-check forgejo-logs forgejo-runner-registration-deploy forgejo-runner-deploy forgejo-runner-status forgejo-runner-logs inter-hub-render-baseline inter-hub-dry-run inter-hub-server-dry-run inter-hub-deploy inter-hub-status inter-hub-release-info inter-hub-smoke inter-hub-logs reuse-dry-run reuse-deploy reuse-status reuse-smoke reuse-logs reuse-forgejo-webhook reuse-openbao-eso-token-apply reuse-openbao-store-deploy reuse-runtime-es-deploy reuse-runtime-es-status reuse-webhook-smoke policy-nexus-check-binding policy-nexus-check-image policy-nexus-render policy-nexus-server-dry-run policy-nexus-deploy policy-nexus-status policy-nexus-smoke policy-nexus-rollback help .PHONY: forgejo-secret-scan-template-deploy + + +##@ Isolated Forgejo source release preparation (no deployment/publication) +FORGEJO_BUILD_WORKSPACE ?= /tmp/railiance-apps-forgejo-release +FORGEJO_SOURCE_ARCHIVE ?= +FORGEJO_GO_ARCHIVE ?= +FORGEJO_NODE_ARCHIVE ?= +FORGEJO_BUILD_GOMODCACHE ?= +FORGEJO_BUILD_GOCACHE_SEED ?= +FORGEJO_BUILD_NPM_CACHE_SEED ?= + +.PHONY: forgejo-release-prepare forgejo-release-acquire-npm forgejo-release-build forgejo-release-verify forgejo-release-container +forgejo-release-prepare: ## Verify archives/patch/locks and prepare a new isolated workspace + /usr/bin/python3 -I tools/forgejo-release-build.py prepare --workspace "$(FORGEJO_BUILD_WORKSPACE)" --source-archive "$(FORGEJO_SOURCE_ARCHIVE)" --go-archive "$(FORGEJO_GO_ARCHIVE)" --node-archive "$(FORGEJO_NODE_ARCHIVE)" --module-cache "$(FORGEJO_BUILD_GOMODCACHE)" $(if $(FORGEJO_BUILD_GOCACHE_SEED),--go-cache-seed "$(FORGEJO_BUILD_GOCACHE_SEED)") $(if $(FORGEJO_BUILD_NPM_CACHE_SEED),--npm-cache-seed "$(FORGEJO_BUILD_NPM_CACHE_SEED)") + +forgejo-release-acquire-npm: ## Explicit public registry dependency acquisition; ignores lifecycle scripts + /usr/bin/python3 -I tools/forgejo-release-build.py acquire-npm --workspace "$(FORGEJO_BUILD_WORKSPACE)" + +forgejo-release-build: ## Offline full frontend, bindata and static backend via upstream targets + /usr/bin/python3 -I tools/forgejo-release-build.py build --workspace "$(FORGEJO_BUILD_WORKSPACE)" + +forgejo-release-verify: ## Verify all embedded assets, static executable and new CLI shape offline + /usr/bin/python3 -I tools/forgejo-release-build.py verify --workspace "$(FORGEJO_BUILD_WORKSPACE)" + +forgejo-release-container: ## Build/verify a deterministic local rootless OCI archive; never publish or deploy + /usr/bin/python3 -I tools/forgejo-release-build.py container --workspace "$(FORGEJO_BUILD_WORKSPACE)" + diff --git a/build/forgejo/README.md b/build/forgejo/README.md new file mode 100644 index 0000000..ff29075 --- /dev/null +++ b/build/forgejo/README.md @@ -0,0 +1,72 @@ +# Forgejo release preparation (build only) + +The Apps Makefile targets package the independently reviewed seven-path Forgejo +11.0.3 source extension. They never deploy, push, change default values or choose +an operational image. Third-party application source is expanded only into a new +`/tmp` workspace; this repository retains a release input lock and exact reviewed +patch, not a second source-code tree. Upstream source ownership and publication +remain separate owner work. + +Supply explicit public input archives/cache paths to `make forgejo-release-prepare`: +`FORGEJO_SOURCE_ARCHIVE`, `FORGEJO_GO_ARCHIVE`, `FORGEJO_NODE_ARCHIVE`, +`FORGEJO_BUILD_GOMODCACHE`, optionally `FORGEJO_BUILD_GOCACHE_SEED` and +`FORGEJO_BUILD_NPM_CACHE_SEED`. Every archive, patch, source output, dependency +lock and selected host compiler tool is digest-bound by `inputs.lock.json`. +Go's official checksums and Node's official SHASUMS must be independently checked +when acquiring those public archives. The source archive is official Forgejo +v11.0.3; archive PAX commit b81aa4d0807cc1e2d6a2fbafd0b75c46cc0ae237. + +The workspace must not exist. Existing trials are preserved, never restarted +because an observer timed out. The recipe's `process.json` records the exact +active child PID, phase and command, then completion; logs and receipts stay in +the workspace. Do not run two phases against the same workspace concurrently. + +`make forgejo-release-acquire-npm` explicitly acquires only public registry +packages, with fresh HOME/config paths, integrity-bound lockfile and lifecycle +scripts disabled. With the cache ready, `make forgejo-release-build` reruns +`npm ci --offline --ignore-scripts`, Go module verification, upstream frontend +compilation and upstream generation/static backend targets inside a network +namespace without connectivity. It uses the exact Go/Node distributions, local +compiler inputs, `bindata timetzdata sqlite sqlite_unlock_notify` plus upstream +static target's `netgo osusergo`, explicit Make GOFLAGS for trimpath/buildvcs and +fixed file/directory timestamps. Build tags retain the upstream rootless build +profile. `make forgejo-release-verify` compares every embedded public/options/ +template file to actual generated source bytes, checks the extension CLI and +checks the ELF has no dynamic interpreter. These are local artifact checks, +not application login, DB, native rollout or architecture acceptance. + +For a **local container build** only, first acquire the locked public amd64 +base with an empty Docker client config and the explicit local socket. Then +`make forgejo-release-container` uses that exact digest as a build input and a +minimal context containing only the verified executable and generated Dockerfile. +It directly exports a local OCI archive with `rewrite-timestamp=true`, no-cache, +no image load or push. SOURCE_DATE_EPOCH alone does not fix COPY parent-directory +timestamps. The image epoch is max(source epoch,ceil immutable base created +instant), so the original base layers remain exact while new metadata is stable. +The recipe verifies every OCI blob, original base layer prefix, rootless runtime +configuration, provenance labels and exact final executable. Two no-cache exports +must reproduce the frozen output image and archive digests before claiming +reproducibility. The actual local proof receipts are in the accompanying source +proposal packet, not an admission of the resulting artifact. + +There is no deployment tag, registry publication, RUN directive, privileged +container or Docker host/secret mount. Public base acquisition and a standard +local Docker/buildx daemon supporting OCI export/layer timestamp rewriting are +explicit prerequisites. An unavailable feature is a retained blocker; do not +switch to a privileged builder or claim a successful image from a backend-only +binary. Existing contexts/output archives refuse overwrite. Reconcile an actual +failed export explicitly, retaining original metadata/logs/artifacts; observation +timeouts never justify another build. Older prototype load/save behavior and +its config-versus-manifest-ID issue are retained in the source trial history, +not shipped as a production continuation. + +The static host toolchain is pinned for this Linux amd64 review estate. Broader +cross-host reproducibility or a different architecture/compiler/libc is a new +input revision, not an implicit fallback. The glibc static-linker's optional +SQLite `dlopen` diagnostic is retained; no SQLite-extension runtime compatibility +or production platform admission is inferred from ELF inspection or asset checks. +An accepted owner image/source/backend, authenticated access-engine decision, +private secret-input boundary, type/case/descriptor re-freeze, current operation/ +inverse sessions, durable journal and application/Helm writer exclusion are +separate gates. Helm omission/rollback does not remove persisted OAuth source. +The existing case gate is compatibility code, not a single-PDP exception. diff --git a/build/forgejo/inputs.lock.json b/build/forgejo/inputs.lock.json new file mode 100644 index 0000000..743f819 --- /dev/null +++ b/build/forgejo/inputs.lock.json @@ -0,0 +1,63 @@ +{ + "schema": "railiance-apps.forgejo-release-inputs.v1", + "forgejo_version": "11.0.3", + "source_archive_sha256": "1ddd8c926ca07d40436897971458ace7dcf1d3496a0813ab9e0d869edef2365c", + "source_commit": "b81aa4d0807cc1e2d6a2fbafd0b75c46cc0ae237", + "patch_sha256": "f2788b3f2bf84dbc0f94e232b6c9614a864b8123c09f9e44703d50d5e1b53257", + "go_archive_sha256": "3333f6ea53afa971e9078895eaa4ac7204a8c6b5c68c10e6bc9a33e8e391bdd8", + "go_version": "1.24.3", + "node_archive_sha256": "dafe2e8f82cb97de1bd10db9e2ec4c07bbf53389b0799b1e095a918951e78fd4", + "node_version": "22.15.0", + "source_date_epoch": 1752105237, + "release_version": "11.0.3+nk48.f2788b3f", + "tags": "bindata timetzdata sqlite sqlite_unlock_notify", + "runtime_base": "code.forgejo.org/forgejo/forgejo@sha256:41649dff5311664097d29b4d80620403f0b27e7c0eee0b8af79b132a37f3807b", + "runtime_index_digest": "sha256:691657c7e64af7730a2e885e6d022cf16ae60724931c5392bd61838afadcaeba", + "platform": "linux/amd64", + "reviewed_outputs": [ + { + "path": "cmd/admin_auth_oauth.go", + "sha256": "1dd03f0bd23889a340a10fcc18d2e3a49270b79056aafd1b3baf01b18559b37f" + }, + { + "path": "routers/web/admin/auths.go", + "sha256": "3498229e06fb9ff52396bcb5e68c0cc2d2ad24e6494533d8717d7c833f3e2923" + }, + { + "path": "routers/web/auth/oauth.go", + "sha256": "081bcb94023d6e27289196bbe518bebba0a8a6082ea1a32d2794c5562075907b" + }, + { + "path": "services/auth/source/oauth2/providers_openid.go", + "sha256": "5dd9b906cce434c774968d57fcc790cb678147345fea4d2e58477ec6eed70bcf" + }, + { + "path": "services/auth/source/oauth2/required_claim_values.go", + "sha256": "29f16d07464bbe12e3f65616b6c1eb60561d04cd8ff89ecbfb56f93dbeaf1cda" + }, + { + "path": "services/auth/source/oauth2/required_claim_values_test.go", + "sha256": "54a8bd97dc51c524a9adf0c6aefb9a19fa152d234b3bb929bf1a90932662d3d0" + }, + { + "path": "services/auth/source/oauth2/source.go", + "sha256": "44e99883c2ec479427b3f72c73353fe33936eed2e5c962b2fa40803576aa5642" + } + ], + "lockfiles": { + "go.mod": "652162a4a95c6d1c349d2a8c0b514def5abd837f001137b77b7b3f4a0f162d00", + "go.sum": "19c305c7e68de9d3f1bc0dda2de09c5d38597f1ee7fa411c7b36b92467ae1eaf", + "package.json": "ab5c54ec7077501b50acf3ab5f1fff4f63a51abe99e720b8ecdc6cc6c78f39fc", + "package-lock.json": "fdd5c2f891311be7c01b1907b418c243ca1f0f951fd4b4ee5b5e931c43493657" + }, + "host_tools": { + "/usr/bin/make": "d78b8f1d099fbcfb6f2f49ab87223b9b68fb3956642f92d6ec6de812e8afa965", + "/usr/bin/gcc": "1b99826121ae6682a634e5efe09bd3e3df58ce58e0b28f849114ab5b89139c26", + "/usr/bin/ld": "e46a122cc3a29feba3b1d0fc2a274b6e46538be016ebe381370c0801bb98b0b8", + "/usr/bin/ar": "534681ac11c18868cfc4fdf98770aa0ba8973eedc90c231e94e6ba96e1a04f27", + "/usr/bin/bwrap": "e318903862396f96de3df57264e0158682b952fd3fb53ac23d876413e7b30f71", + "/usr/lib/x86_64-linux-gnu/libc.a": "a40c7eae7581f977b65eddb81cbc799213a695749e039d966db55bdee4ac9621", + "/usr/lib/x86_64-linux-gnu/libm.a": "420cd3c406f6f8c12deb3d1291146e9c091ee015a7c39eaf2237f01e80d6f0ae" + }, + "runtime_base_config_sha256": "bc5a3df111729582faadebd01d126ba769e2b65d0c62f306f6bc79f4e562452b" +} diff --git a/build/forgejo/required-claim-values.patch b/build/forgejo/required-claim-values.patch new file mode 100644 index 0000000..90d5131 --- /dev/null +++ b/build/forgejo/required-claim-values.patch @@ -0,0 +1,484 @@ +--- a/cmd/admin_auth_oauth.go ++++ b/cmd/admin_auth_oauth.go +@@ -94,6 +94,10 @@ + Name: "required-claim-value", + Value: "", + Usage: "Claim value that has to be set to allow users to login with this source", ++ }, ++ &cli.StringSliceFlag{ ++ Name: "required-claim-values", ++ Usage: "Require any one exact array value from current OpenID Connect UserInfo (cannot combine with required-claim-value)", + }, + &cli.StringFlag{ + Name: "group-claim-name", +@@ -160,6 +164,7 @@ + Scopes: c.StringSlice("scopes"), + RequiredClaimName: c.String("required-claim-name"), + RequiredClaimValue: c.String("required-claim-value"), ++ RequiredClaimValues: c.StringSlice("required-claim-values"), + GroupClaimName: c.String("group-claim-name"), + AdminGroup: c.String("admin-group"), + RestrictedGroup: c.String("restricted-group"), +@@ -177,6 +182,12 @@ + } + + config := parseOAuth2Config(c) ++ if err := oauth2.ValidateRequiredClaimValues(config.RequiredClaimName, config.RequiredClaimValue, config.RequiredClaimValues); err != nil { ++ return err ++ } ++ if config.RequiredClaimValues != nil && config.Provider != "openidConnect" { ++ return errors.New("required-claim-values requires openidConnect") ++ } + if config.Provider == "openidConnect" { + discoveryURL, err := url.Parse(config.OpenIDConnectAutoDiscoveryURL) + if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") { +@@ -246,6 +257,10 @@ + oAuth2Config.RequiredClaimValue = c.String("required-claim-value") + } + ++ if c.IsSet("required-claim-values") { ++ oAuth2Config.RequiredClaimValues = c.StringSlice("required-claim-values") ++ } ++ + if c.IsSet("group-claim-name") { + oAuth2Config.GroupClaimName = c.String("group-claim-name") + } +@@ -293,6 +308,12 @@ + } + + oAuth2Config.CustomURLMapping = customURLMapping ++ if err := oauth2.ValidateRequiredClaimValues(oAuth2Config.RequiredClaimName, oAuth2Config.RequiredClaimValue, oAuth2Config.RequiredClaimValues); err != nil { ++ return err ++ } ++ if oAuth2Config.RequiredClaimValues != nil && oAuth2Config.Provider != "openidConnect" { ++ return errors.New("required-claim-values requires openidConnect") ++ } + source.Cfg = oAuth2Config + + return auth_model.UpdateSource(ctx, source) +--- a/routers/web/admin/auths.go ++++ b/routers/web/admin/auths.go +@@ -322,6 +322,13 @@ + ctx.Data["Source"] = source + ctx.Data["HasTLS"] = source.HasTLS() + ++ // The legacy form has no representation for the opt-in current-UserInfo gate. ++ // Refuse rather than silently dropping it during source reconstruction. ++ if current, ok := source.Cfg.(*oauth2.Source); ok && current.RequiredClaimValues != nil { ++ ctx.RenderWithErr("This OAuth source uses required-claim-values; update it through the reviewed CLI.", tplAuthEdit, form) ++ return ++ } ++ + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplAuthEdit) + return +--- a/routers/web/auth/oauth.go ++++ b/routers/web/auth/oauth.go +@@ -1427,7 +1427,11 @@ + return goth.User{}, err + } + +- if oauth2Source.RequiredClaimName != "" { ++ if oauth2Source.RequiredClaimValues != nil { ++ if oauth2Source.Provider != "openidConnect" || !oauth2.MatchRequiredClaimValues(gothUser.RawData, oauth2Source.RequiredClaimName, oauth2Source.RequiredClaimValue, oauth2Source.RequiredClaimValues) { ++ return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} ++ } ++ } else if oauth2Source.RequiredClaimName != "" { + claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName] + if !has { + return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} +--- a/services/auth/source/oauth2/providers_openid.go ++++ b/services/auth/source/oauth2/providers_openid.go +@@ -4,6 +4,7 @@ + package oauth2 + + import ( ++ "errors" + "html/template" + + "forgejo.org/modules/log" +@@ -43,6 +44,22 @@ + if err != nil { + log.Warn("Failed to create OpenID Connect Provider with name '%s' with url '%s': %v", providerName, source.OpenIDConnectAutoDiscoveryURL, err) + } ++ if err != nil { ++ return nil, err ++ } ++ if source.RequiredClaimValues != nil { ++ if err := ValidateRequiredClaimValues(source.RequiredClaimName, source.RequiredClaimValue, source.RequiredClaimValues); err != nil { ++ return nil, err ++ } ++ if provider.OpenIDConfig == nil || provider.SkipUserInfoRequest { ++ return nil, errors.New("required-claim-values needs current UserInfo") ++ } ++ guarded, err := RequireCurrentUserInfoClaim(provider.Client(), provider.OpenIDConfig.UserInfoEndpoint, source.RequiredClaimName) ++ if err != nil { ++ return nil, err ++ } ++ provider.HTTPClient = guarded ++ } + return provider, err + } + +--- /dev/null ++++ b/services/auth/source/oauth2/required_claim_values.go +@@ -0,0 +1,202 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++package oauth2 ++ ++import ( ++ "bytes" ++ "encoding/json" ++ "errors" ++ "io" ++ "net/http" ++ "net/url" ++ "strings" ++ "unicode" ++ "unicode/utf8" ++) ++ ++const maxClaimValues = 1024 ++const maxUserInfoBytes = 1 << 20 ++ ++// ValidateRequiredClaimValues validates only the opt-in list extension. A nil ++// list retains every legacy single-value behavior. An explicitly empty list ++// fails closed rather than turning a configured gate into claim-presence-only. ++func ValidateRequiredClaimValues(name, legacy string, values []string) error { ++ if values == nil { ++ return nil ++ } ++ if !validClaimLiteral(name) || legacy != "" || len(values) == 0 || len(values) > 64 { ++ return errors.New("invalid or conflicting required-claim-values configuration") ++ } ++ seen := map[string]bool{} ++ for _, value := range values { ++ if !validClaimLiteral(value) || seen[value] { ++ return errors.New("invalid required-claim-values literal") ++ } ++ seen[value] = true ++ } ++ return nil ++} ++ ++func validClaimLiteral(value string) bool { ++ return value != "" && len(value) <= 256 && utf8.ValidString(value) && ++ value == strings.TrimSpace(value) && !strings.ContainsAny(value, "\x00\r\n,") && ++ !strings.ContainsFunc(value, func(r rune) bool { return unicode.IsControl(r) || r == utf8.RuneError }) ++} ++ ++// strictClaimValues accepts only a well-formed claim array. No scalar parsing, ++// coercion, regex or prefix matching is introduced into the new gate. ++func strictClaimValues(raw any) ([]string, bool) { ++ var values []string ++ switch v := raw.(type) { ++ case []string: ++ values = v ++ case []any: ++ values = make([]string, len(v)) ++ for i, value := range v { ++ s, ok := value.(string) ++ if !ok { ++ return nil, false ++ } ++ values[i] = s ++ } ++ default: ++ return nil, false ++ } ++ if len(values) > maxClaimValues { ++ return nil, false ++ } ++ for _, value := range values { ++ if !validClaimLiteral(value) { ++ return nil, false ++ } ++ } ++ return values, true ++} ++ ++// MatchRequiredClaimValues evaluates the new consumer gate against current ++// authenticated provider data; it does not mutate or manufacture issuer claims. ++func MatchRequiredClaimValues(claims map[string]any, name, legacy string, required []string) bool { ++ if required == nil || ValidateRequiredClaimValues(name, legacy, required) != nil { ++ return false ++ } ++ raw, present := claims[name] ++ if !present { ++ return false ++ } ++ values, ok := strictClaimValues(raw) ++ if !ok { ++ return false ++ } ++ for _, value := range values { ++ for _, wanted := range required { ++ if value == wanted { ++ return true ++ } ++ } ++ } ++ return false ++} ++ ++// RequireCurrentUserInfoClaim preserves the existing HTTP client settings and ++// adds a strict response guard for this opt-in source only. A current UserInfo ++// array must overwrite any stale ID-token claim. Missing/invalid data and HTTP ++// failures cannot fall back to the ID-token map in Goth's merging code. ++func RequireCurrentUserInfoClaim(client *http.Client, endpoint, claim string) (*http.Client, error) { ++ u, err := url.Parse(endpoint) ++ if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" || !validClaimLiteral(claim) { ++ return nil, errors.New("required-claim-values needs an HTTPS UserInfo endpoint") ++ } ++ if client == nil { ++ client = http.DefaultClient ++ } ++ result := *client ++ base := result.Transport ++ if base == nil { ++ base = http.DefaultTransport ++ } ++ result.Transport = ¤tClaimTransport{base: base, endpoint: endpoint, claim: claim} ++ // Redirects could evade endpoint matching or send a bearer to another origin. ++ // They are unsupported for the new guarded provider; legacy clients are intact. ++ result.CheckRedirect = func(*http.Request, []*http.Request) error { ++ return errors.New("guarded OAuth redirects are not supported") ++ } ++ return &result, nil ++} ++ ++type currentClaimTransport struct { ++ base http.RoundTripper ++ endpoint, claim string ++} ++ ++func (t *currentClaimTransport) RoundTrip(req *http.Request) (*http.Response, error) { ++ response, err := t.base.RoundTrip(req) ++ if req.URL.String() != t.endpoint { ++ return response, err ++ } ++ if err != nil { ++ if response != nil && response.Body != nil { ++ response.Body.Close() ++ } ++ return nil, errors.New("current UserInfo lookup failed") ++ } ++ if response == nil || response.Body == nil { ++ return nil, errors.New("current UserInfo response missing") ++ } ++ defer response.Body.Close() ++ if req.Method != http.MethodGet || response.StatusCode != http.StatusOK { ++ return nil, errors.New("current UserInfo refused") ++ } ++ body, err := io.ReadAll(io.LimitReader(response.Body, maxUserInfoBytes+1)) ++ if err != nil || len(body) > maxUserInfoBytes || !utf8.Valid(body) { ++ return nil, errors.New("current UserInfo response invalid") ++ } ++ // Reject duplicate top-level keys before Unmarshal can hide a conflicting ++ // groups or subject claim. json.RawMessage permits unrelated normal claims. ++ decoder := json.NewDecoder(bytes.NewReader(body)) ++ token, err := decoder.Token() ++ if err != nil || token != json.Delim('{') { ++ return nil, errors.New("current UserInfo object required") ++ } ++ claims := map[string]json.RawMessage{} ++ for decoder.More() { ++ keyToken, err := decoder.Token() ++ if err != nil { ++ return nil, errors.New("current UserInfo key invalid") ++ } ++ key, ok := keyToken.(string) ++ if !ok { ++ return nil, errors.New("current UserInfo key invalid") ++ } ++ if _, duplicate := claims[key]; duplicate { ++ return nil, errors.New("current UserInfo duplicate claim") ++ } ++ var value json.RawMessage ++ if decoder.Decode(&value) != nil { ++ return nil, errors.New("current UserInfo value invalid") ++ } ++ claims[key] = value ++ } ++ if token, err = decoder.Token(); err != nil || token != json.Delim('}') { ++ return nil, errors.New("current UserInfo object invalid") ++ } ++ var trailing any ++ if decoder.Decode(&trailing) != io.EOF { ++ return nil, errors.New("current UserInfo trailing data") ++ } ++ raw, present := claims[t.claim] ++ if !present { ++ return nil, errors.New("current UserInfo required claim missing") ++ } ++ var value any ++ if json.Unmarshal(raw, &value) != nil { ++ return nil, errors.New("current UserInfo required claim invalid") ++ } ++ if _, ok := strictClaimValues(value); !ok { ++ return nil, errors.New("current UserInfo required claim array invalid") ++ } ++ // Return the original public response bytes to Goth. Subject equality and ++ // audience/issuer checks remain in its existing authenticated provider path. ++ response.Body = io.NopCloser(bytes.NewReader(body)) ++ return response, nil ++} +--- /dev/null ++++ b/services/auth/source/oauth2/required_claim_values_test.go +@@ -0,0 +1,144 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++package oauth2 ++ ++import ( ++ "errors" ++ "io" ++ "net/http" ++ "reflect" ++ "strings" ++ "testing" ++) ++ ++var caseValues = []string{"forge-admins", "forge-coulomb-owners", "forge-coulomb-readers"} ++ ++type roundTripFunc func(*http.Request) (*http.Response, error) ++ ++func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } ++ ++func mockUserInfo(body string, status int) *http.Client { ++ return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { ++ return &http.Response{StatusCode: status, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil ++ })} ++} ++ ++func TestRequiredClaimExactAnyOfAndNoMutation(t *testing.T) { ++ for _, group := range caseValues { ++ groups := []any{group, "unrelated"} ++ claims := map[string]any{"groups": groups, "other": "preserved"} ++ if !MatchRequiredClaimValues(claims, "groups", "", caseValues) { ++ t.Fatal("individual allowed group rejected") ++ } ++ if !reflect.DeepEqual(claims, map[string]any{"groups": groups, "other": "preserved"}) { ++ t.Fatal("claims mutated") ++ } ++ } ++ if !MatchRequiredClaimValues(map[string]any{"groups": append([]string(nil), caseValues...)}, "groups", "", caseValues) { ++ t.Fatal("multiple allowed groups rejected") ++ } ++ for _, raw := range []any{nil, true, 1, "forge-admins", "forge-admins,forge-coulomb-owners", []any{"forge-admins", 1}, []any{"forge-admins", nil}, []string{}, []any{}, []string{"forge-admins-lookalike"}, []string{" Forge-admins"}, []string{"forge-admins\n"}, []string{"Forge-admins"}, []string{"forge-admins,other"}, []string{"forge-admins", ""}} { ++ if MatchRequiredClaimValues(map[string]any{"groups": raw}, "groups", "", caseValues) { ++ t.Fatal("malformed/nonmatching claim accepted") ++ } ++ } ++ if MatchRequiredClaimValues(map[string]any{}, "groups", "", caseValues) { ++ t.Fatal("absent claim accepted") ++ } ++ if MatchRequiredClaimValues(map[string]any{"unrelated": []string{"forge-admins"}}, "groups", "", caseValues) { ++ t.Fatal("unrelated claim used") ++ } ++} ++ ++func TestRequiredClaimConfigurationRefusalsAndLegacyIsolation(t *testing.T) { ++ if ValidateRequiredClaimValues("", "old arbitrary literal", nil) != nil { ++ t.Fatal("legacy config changed") ++ } ++ for _, cfg := range []struct { ++ name, legacy string ++ values []string ++ }{ ++ {"groups", "", []string{}}, {"groups", "", []string{""}}, {"groups", "old", caseValues}, {"", "", caseValues}, ++ {"groups", "", []string{"a", "a"}}, {"groups", "", []string{"a,b"}}, {"groups", "", []string{" a"}}, ++ } { ++ if ValidateRequiredClaimValues(cfg.name, cfg.legacy, cfg.values) == nil { ++ t.Fatal("invalid config accepted") ++ } ++ } ++ if ValidateRequiredClaimValues("groups", "", caseValues) != nil { ++ t.Fatal("case config invalid") ++ } ++} ++ ++func TestCurrentUserInfoGuardRejectsMissingMalformedAndUnavailable(t *testing.T) { ++ for _, body := range []string{ ++ `{}`, `{"sub":"alice"}`, `{"groups":null}`, `{"groups":true}`, `{"groups":"forge-admins"}`, ++ `{"groups":["forge-admins",null]}`, `{"groups":["forge-admins",1]}`, ++ `{"groups":["forge-admins"],"groups":[]}`, `{"groups":[]} {}`, `[]`, `{"groups":[]`, ++ `{"groups":["forge-admins",""]}`, strings.Repeat(" ", maxUserInfoBytes+1), ++ } { ++ client, err := RequireCurrentUserInfoClaim(mockUserInfo(body, 200), "https://issuer.example/userinfo", "groups") ++ if err != nil { ++ t.Fatal(err) ++ } ++ response, err := client.Get("https://issuer.example/userinfo") ++ if err == nil { ++ response.Body.Close() ++ t.Fatal("invalid current claim did not fail closed") ++ } ++ } ++ client, _ := RequireCurrentUserInfoClaim(mockUserInfo(`{"groups":[]}`, 503), "https://issuer.example/userinfo", "groups") ++ if response, err := client.Get("https://issuer.example/userinfo"); err == nil { ++ response.Body.Close() ++ t.Fatal("non200 accepted") ++ } ++ broken := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("private backend error") })} ++ client, _ = RequireCurrentUserInfoClaim(broken, "https://issuer.example/userinfo", "groups") ++ if _, err := client.Get("https://issuer.example/userinfo"); err == nil || strings.Contains(err.Error(), "private backend") { ++ t.Fatal("lookup failure not contained") ++ } ++} ++ ++func TestCurrentUserInfoEmptyArrayOverwritesAndGroupRemoval(t *testing.T) { ++ for _, body := range []string{`{"sub":"alice","groups":[]}`, `{"sub":"alice","groups":["forge-coulomb-readers"]}`} { ++ client, err := RequireCurrentUserInfoClaim(mockUserInfo(body, 200), "https://issuer.example/userinfo", "groups") ++ if err != nil { ++ t.Fatal(err) ++ } ++ response, err := client.Get("https://issuer.example/userinfo") ++ if err != nil { ++ t.Fatal(err) ++ } ++ b, _ := io.ReadAll(response.Body) ++ response.Body.Close() ++ if string(b) != body { ++ t.Fatal("response changed") ++ } ++ } ++} ++ ++func TestCurrentUserInfoGuardIsOptInAndRedirectsRefuse(t *testing.T) { ++ original := mockUserInfo(`{"unrelated":true}`, 200) ++ guarded, err := RequireCurrentUserInfoClaim(original, "https://issuer.example/userinfo", "groups") ++ if err != nil { ++ t.Fatal(err) ++ } ++ response, err := original.Get("https://issuer.example/userinfo") ++ if err != nil { ++ t.Fatal("original client mutated") ++ } ++ response.Body.Close() ++ response, err = guarded.Get("https://issuer.example/unrelated") ++ if err != nil { ++ t.Fatal("unrelated endpoint changed") ++ } ++ response.Body.Close() ++ if guarded.CheckRedirect == nil || guarded.CheckRedirect(nil, nil) == nil { ++ t.Fatal("redirect can bypass guard") ++ } ++ for _, endpoint := range []string{"", "http://issuer.example/userinfo", "https://user:pass@issuer.example/userinfo", "https://issuer.example/userinfo#fragment"} { ++ if _, err := RequireCurrentUserInfoClaim(original, endpoint, "groups"); err == nil { ++ t.Fatal("invalid endpoint accepted") ++ } ++ } ++} +--- a/services/auth/source/oauth2/source.go ++++ b/services/auth/source/oauth2/source.go +@@ -22,6 +22,7 @@ + Scopes []string + AttributeSSHPublicKey string + RequiredClaimName string ++ RequiredClaimValues []string `json:",omitempty"` + RequiredClaimValue string + GroupClaimName string + AdminGroup string diff --git a/build/forgejo/runtime-base-config.json b/build/forgejo/runtime-base-config.json new file mode 100644 index 0000000..e2ad4cd --- /dev/null +++ b/build/forgejo/runtime-base-config.json @@ -0,0 +1 @@ +{"architecture":"amd64","config":{"User":"1000:1000","ExposedPorts":{"2222/tcp":{},"3000/tcp":{}},"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","GITEA_WORK_DIR=/var/lib/gitea","GITEA_CUSTOM=/var/lib/gitea/custom","GITEA_TEMP=/tmp/gitea","TMPDIR=/tmp/gitea","GITEA_APP_INI_LEGACY=/etc/gitea/app.ini","GITEA_APP_INI=/var/lib/gitea/custom/conf/app.ini","HOME=/var/lib/gitea/git"],"Entrypoint":["/usr/bin/dumb-init","--","/usr/local/bin/docker-entrypoint.sh"],"Volumes":{"/etc/gitea":{},"/var/lib/gitea":{}},"WorkingDir":"/var/lib/gitea","Labels":{"maintainer":"contact@forgejo.org","org.opencontainers.image.authors":"Forgejo","org.opencontainers.image.description":"Forgejo is a self-hosted lightweight software forge. Easy to install and low maintenance, it just does the job.","org.opencontainers.image.documentation":"https://forgejo.org/download/#container-image","org.opencontainers.image.licenses":"GPL-3.0-or-later","org.opencontainers.image.source":"https://codeberg.org/forgejo/forgejo","org.opencontainers.image.title":"Forgejo. Beyond coding. We forge.","org.opencontainers.image.url":"https://forgejo.org","org.opencontainers.image.vendor":"Forgejo","org.opencontainers.image.version":"11.0.3"},"ArgsEscaped":true},"created":"2025-07-10T04:12:21.034425519Z","history":[{"created":"2025-02-14T03:28:36Z","created_by":"ADD alpine-minirootfs-3.21.3-x86_64.tar.gz / # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-02-14T03:28:36Z","created_by":"CMD [\"/bin/sh\"]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"ARG RELEASE_VERSION=11.0.3","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"LABEL maintainer=contact@forgejo.org org.opencontainers.image.authors=Forgejo org.opencontainers.image.url=https://forgejo.org org.opencontainers.image.documentation=https://forgejo.org/download/#container-image org.opencontainers.image.source=https://codeberg.org/forgejo/forgejo org.opencontainers.image.version=11.0.3 org.opencontainers.image.vendor=Forgejo org.opencontainers.image.licenses=GPL-3.0-or-later org.opencontainers.image.title=Forgejo. Beyond coding. We forge. org.opencontainers.image.description=Forgejo is a self-hosted lightweight software forge. Easy to install and low maintenance, it just does the job.","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"EXPOSE map[2222/tcp:{} 3000/tcp:{}]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c apk --no-cache add bash ca-certificates dumb-init gettext git curl gnupg openssh-client \u0026\u0026 rm -rf /var/cache/apk/* # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:08:22.524887902Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c addgroup -S -g 1000 git \u0026\u0026 adduser -S -H -D -h /var/lib/gitea/git -s /bin/bash -u 1000 -G git git # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:08:22.622516492Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c mkdir -p /var/lib/gitea /etc/gitea # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:08:22.719074007Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c chown git:git /var/lib/gitea /etc/gitea # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.677652029Z","created_by":"COPY /tmp/local / # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.772570625Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c cd /usr/local/bin ; ln -s gitea forgejo # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.904591549Z","created_by":"COPY --chown=root:root /go/src/forgejo.org/gitea /app/gitea/gitea # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.992814487Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c ln -s /app/gitea/gitea /app/gitea/forgejo-cli # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.019203454Z","created_by":"COPY --chown=root:root /go/src/forgejo.org/environment-to-ini /usr/local/bin/environment-to-ini # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"COPY /go/src/forgejo.org/contrib/autocompletion/bash_autocomplete /etc/profile.d/gitea_bash_autocomplete.sh # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"USER 1000:1000","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_WORK_DIR=/var/lib/gitea","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_CUSTOM=/var/lib/gitea/custom","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_TEMP=/tmp/gitea","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV TMPDIR=/tmp/gitea","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_APP_INI_LEGACY=/etc/gitea/app.ini","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_APP_INI=/var/lib/gitea/custom/conf/app.ini","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV HOME=/var/lib/gitea/git","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"VOLUME [/var/lib/gitea /etc/gitea]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.034425519Z","created_by":"WORKDIR /var/lib/gitea","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.034425519Z","created_by":"ENTRYPOINT [\"/usr/bin/dumb-init\" \"--\" \"/usr/local/bin/docker-entrypoint.sh\"]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.034425519Z","created_by":"CMD []","comment":"buildkit.dockerfile.v0","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:08000c18d16dadf9553d747a58cf44023423a9ab010aab96cf263d2216b8b350","sha256:cf760eaa10b0319495870aab29b322acf75ba0755f51f1f1a23bcf636243025b","sha256:c6d4eb3ac8d434116be911d6c5dbfbbf600b5f8db34d8564f42f916516bc8bee","sha256:4bbca535f88127e38de4d207fc606ff1f09612b9b45f4a583b39781d2a45ff91","sha256:4d7e7dd4baa79b36260ede93d34c7d57e7370d714e129c374aec60371f751977","sha256:b422e6485954feb40fe6d57beb145775bca592f0fe068477845a7e843e8d8214","sha256:51a112d24ca275b0dd383e1a9c1f725c0967f56aff9ba393d87db1c208369873","sha256:3129f4769ae273912802802ef504d87add6e9181916fb9e74ee609375c474789","sha256:cc72664d7cdf5bcebdac72c859627611fd05733067b566481d180a398b30d16b","sha256:fa9096e760abba41eb596e3d47f4bb9e9a032e906deb4ffccafa07aa6619ca7b","sha256:2c5411c383707f038d6f60e17502f73fb92254dce8d8b748f432287d12b9a633","sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"]}} \ No newline at end of file diff --git a/tools/forgejo-release-build.py b/tools/forgejo-release-build.py new file mode 100644 index 0000000..08524d2 --- /dev/null +++ b/tools/forgejo-release-build.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Build-only public Forgejo owner recipe; no deployment or publication code.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import time +import math +import datetime + +OWNER=Path(__file__).resolve().parents[1] +INPUTS=OWNER/'build/forgejo' +LOCK=json.loads((INPUTS/'inputs.lock.json').read_text()) + +def digest(path): + h=hashlib.sha256() + with Path(path).open('rb') as f: + for block in iter(lambda:f.read(1024*1024),b''):h.update(block) + return h.hexdigest() + +def save(path,data): + Path(path).write_text(json.dumps(data,indent=2,sort_keys=True)+'\n') + +def check(path,expected): + if digest(path)!=expected:raise ValueError('frozen public input digest differs: '+str(path)) + +def base_env(work): + return {'PATH':str(work/'node-v22.15.0-linux-x64/bin')+':'+str(work/'go/bin')+':/usr/bin:/bin', + 'HOME':str(work/'home'),'GOTOOLCHAIN':'local','GOPROXY':'off','GOSUMDB':'off','GOVCS':'*:off', + 'GOPATH':str(work/'gopath'),'GOMODCACHE':json.loads((work/'prepared.json').read_text())['module_cache'], + 'GOCACHE':str(work/'gocache'),'CGO_ENABLED':'1','GOMAXPROCS':'4', + 'GOFLAGS':'-trimpath -buildvcs=false -p=4','SOURCE_DATE_EPOCH':str(LOCK['source_date_epoch']), + 'TZ':'UTC','LC_ALL':'C','NODE_OPTIONS':'--max-old-space-size=4096', + 'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null','GIT_TERMINAL_PROMPT':'0', + 'NPM_CONFIG_USERCONFIG':str(work/'empty.npmrc'),'NPM_CONFIG_GLOBALCONFIG':str(work/'empty-global.npmrc'), + 'NPM_CONFIG_CACHE':str(work/'npm-cache'),'NPM_CONFIG_REGISTRY':'https://registry.npmjs.org', + 'NPM_CONFIG_OFFLINE':'true','NPM_CONFIG_AUDIT':'false','NPM_CONFIG_FUND':'false', + 'NPM_CONFIG_UPDATE_NOTIFIER':'false','PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD':'1'} + +def sandbox(work,env): + cache=Path(env['GOMODCACHE']) + return ['bwrap','--unshare-net','--unshare-pid','--ro-bind','/usr','/usr','--ro-bind','/lib','/lib', + '--ro-bind','/lib64','/lib64','--symlink','usr/bin','/bin','--proc','/proc','--dev','/dev', + '--tmpfs','/tmp','--bind',str(work),str(work),'--ro-bind',str(cache),str(cache), + '--chdir',str(work/'source'),'--','env','-i']+[k+'='+v for k,v in env.items()] + +def run(work,name,args,env=None,offline=False,cwd=None): + actual=sandbox(work,env)+args if offline else args + start=time.time() + with (work/(name+'.log')).open('w') as out: + proc=subprocess.Popen(actual,cwd=cwd or work,env={"PATH":"/usr/bin:/bin","HOME":str(work/"home")} if offline else env,stdout=out,stderr=subprocess.STDOUT) + save(work/'process.json',{'phase':name,'pid':proc.pid,'driver_pid':os.getpid(),'argv':args,'state':'running'}) + code=proc.wait() + result={'phase':name,'argv':args,'returncode':code,'seconds':round(time.time()-start,2)} + save(work/(name+'-result.json'),result);save(work/'process.json',dict(result,state='complete',pid=None,driver_pid=None)) + print(json.dumps(result),flush=True) + if code:raise RuntimeError('phase failed; retained log: '+str(work/(name+'.log'))) + return result + +def checked_inputs(work): + for n,h in LOCK['host_tools'].items():check(n,h) + for n,h in LOCK['lockfiles'].items():check(work/'source'/n,h) + for x in LOCK['reviewed_outputs']:check(work/'source'/x['path'],x['sha256']) + +def prepare(a,work): + if work.exists():raise ValueError('workspace must be new; existing trial is never restarted or overwritten') + for n,key in [('source_archive','source_archive_sha256'),('go_archive','go_archive_sha256'),('node_archive','node_archive_sha256')]: + if not getattr(a,n):raise ValueError('explicit '+n+' required') + check(getattr(a,n),LOCK[key]) + if not a.module_cache:raise ValueError('explicit public module cache required') + cache=Path(a.module_cache).resolve();assert cache.is_dir() + check(INPUTS/'required-claim-values.patch',LOCK['patch_sha256']) + for n,h in LOCK['host_tools'].items():check(n,h) + work.mkdir(parents=True);(work/'home').mkdir() + with tarfile.open(a.source_archive) as t: + # The official archive is digest-bound before any member is extracted. + parent=work/'unpacked';parent.mkdir();t.extractall(parent,filter='data') + folders=list(parent.iterdir());assert len(folders)==1 and folders[0].is_dir();folders[0].rename(work/'source');parent.rmdir() + for n in ['go_archive','node_archive']: + with tarfile.open(getattr(a,n)) as t:t.extractall(work,filter='data') + for n in ['empty.npmrc','empty-global.npmrc']:(work/n).write_text('') + if a.go_cache_seed:shutil.copytree(Path(a.go_cache_seed).resolve(),work/'gocache') + if a.npm_cache_seed:shutil.copytree(Path(a.npm_cache_seed).resolve(),work/'npm-cache') + subprocess.run(['/usr/bin/git','apply','--check',str(INPUTS/'required-claim-values.patch')],cwd=work/'source',check=True,env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null'}) + subprocess.run(['/usr/bin/git','apply',str(INPUTS/'required-claim-values.patch')],cwd=work/'source',check=True,env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null'}) + save(work/'prepared.json',{'module_cache':str(cache),'lock_sha256':digest(INPUTS/'inputs.lock.json'),'public_archives_verified':True}) + checked_inputs(work) + packages=json.loads((work/'source/package-lock.json').read_text())['packages'] + for name,package in packages.items(): + if not name:continue + if not package.get('resolved','').startswith('https://registry.npmjs.org/') or not package.get('integrity','').startswith('sha512-'): + raise ValueError('non-public/non-integrity-bound npm package: '+name) + print(json.dumps({'prepared':True,'lockfile_npm_packages':len(packages)-1})) + +def normalize(folder): + epoch=LOCK['source_date_epoch'] + for root,dirs,files in os.walk(folder): + dirs[:]=[x for x in dirs if x not in ('node_modules','.git')] + for n in files: + p=Path(root)/n + if not p.is_symlink():os.utime(p,(epoch,epoch)) + os.utime(root,(epoch,epoch)) + +def build(work): + checked_inputs(work);env=base_env(work) + run(work,'verify-go-modules',['go','mod','verify'],env,True) + run(work,'npm-offline-ci',['npm','ci','--offline','--ignore-scripts','--no-audit','--no-fund'],env,True) + normalize(work/'source') + version=['GOFLAGS=-trimpath -buildvcs=false -p=4','RELEASE_VERSION='+LOCK['release_version'],'FORGEJO_VERSION='+LOCK['release_version'],'FORGEJO_VERSION_API='+LOCK['release_version']] + run(work,'frontend',['make']+version+['frontend'],env,True) + for n in ['public','options','templates','modules/migration/schemas']:normalize(work/'source'/n) + run(work,'embedded-and-static-backend',['make']+version+['TAGS='+LOCK['tags'],'go-check','generate-backend','static-executable'],env,True) + checked_inputs(work) + save(work/'build.json',{'binary_sha256':digest(work/'source/gitea'),'source_locks_preserved':True,'frontend_built':True,'tags':LOCK['tags'],'image_deployment_selected':False}) + +def verify(work): + checked_inputs(work);binary=work/'source/gitea' + target=work/'embedded-extracted' + if target.exists():raise ValueError('retain prior verification; extraction target already exists') + target.mkdir() + # No source, asset tree, host /usr, libraries, configuration or HOME is + # exposed to the executable. A filesystem fallback cannot pass this proof. + bare=['bwrap','--unshare-net','--unshare-pid','--proc','/proc','--dev','/dev', + '--tmpfs','/tmp','--dir','/app','--ro-bind',str(binary),'/app/forgejo', + '--bind',str(target),'/results','--chdir','/tmp','--clearenv'] + for k,v in {'PATH':'/nonexistent','HOME':'/tmp/home','USER':'builder','USERNAME':'builder', + 'GITEA_WORK_DIR':'/tmp/work','GITEA_CUSTOM':'/tmp/custom', + 'GITEA_APP_INI':'/tmp/absent-app.ini','TZ':'UTC','LC_ALL':'C'}.items(): + bare+=['--setenv',k,v] + bare+=['--','/app/forgejo'] + outer={'PATH':'/usr/bin:/bin','HOME':str(work/'home')} + run(work,'binary-version',bare+['--version'],outer) + run(work,'new-cli-help',bare+['admin','auth','add-oauth','--help'],outer) + assert '--required-claim-values' in (work/'new-cli-help.log').read_text() + run(work,'embedded-extract',bare+['embedded','extract','--include-vendored','--destination','/results','**'],outer) + records={} + for folder in ['public','options','templates']: + for f in sorted((work/'source'/folder).rglob('*')): + if not f.is_file():continue + n=str(f.relative_to(work/'source'));q=target/n + if not q.is_file() or digest(f)!=digest(q):raise ValueError('embedded asset missing/different: '+n) + records[n]=digest(f) + assert 'public/assets/js/index.js' in records and 'public/assets/css/index.css' in records + assert 'public/assets/licenses.txt' in records + save(work/'embedded-files.json',records) + readelf=subprocess.run(['/usr/bin/readelf','-l',str(binary)],capture_output=True,text=True,check=True,env={'PATH':'/usr/bin:/bin'}) + assert 'INTERP' not in readelf.stdout + save(work/'verification.json',{'binary_sha256':digest(binary),'embedded_files':len(records), + 'embedded_file_manifest_sha256':digest(work/'embedded-files.json'),'all_public_options_templates_exact':True, + 'no_source_assets_libraries_or_host_config_mounted':True, + 'no_dynamic_interpreter':True,'new_cli_flag_present':True,'network':'bwrap --unshare-net', + 'source_locks_preserved':True,'architecture_approval':False,'native_admission':False}) + print((work/'verification.json').read_text()) + +def container(work): + verified=json.loads((work/'verification.json').read_text());check(work/'source/gitea',verified['binary_sha256']) + context=work/'container-context' + if context.exists():raise ValueError('container trial already exists; retain evidence before an explicit revised trial') + archive_path=work/'image.oci.tar' + if archive_path.exists():raise ValueError('retain existing OCI artifact; never overwrite it') + base_bytes=(INPUTS/'runtime-base-config.json').read_bytes() + assert hashlib.sha256(base_bytes).hexdigest()==LOCK['runtime_base_config_sha256'] + original=json.loads(base_bytes) + epoch=max(LOCK['source_date_epoch'],math.ceil(datetime.datetime.fromisoformat(original['created'].replace('Z','+00:00')).timestamp())) + context.mkdir();shutil.copyfile(work/'source/gitea',context/'gitea') + os.utime(context/'gitea',(LOCK['source_date_epoch'],)*2) + (context/'Dockerfile').write_text('FROM '+LOCK['runtime_base']+'\nARG SOURCE_DATE_EPOCH\nLABEL org.opencontainers.image.version="'+LOCK['release_version']+'" org.opencontainers.image.revision="'+LOCK['source_commit']+'" org.railiance.source.patch.sha256="'+LOCK['patch_sha256']+'"\nCOPY --chown=0:0 --chmod=755 gitea /app/gitea/gitea\n') + os.utime(context/'Dockerfile',(LOCK['source_date_epoch'],)*2) + config=work/'docker-config';config.mkdir(exist_ok=True) + env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'DOCKER_CONFIG':str(config),'DOCKER_HOST':'unix:///var/run/docker.sock'} + # SOURCE_DATE_EPOCH alone does not normalise COPY parent-directory mtimes. + # OCI output permits layer rewriting without the Docker-store unpack conflict. + # The epoch never predates the immutable base, preserving its exact layer prefix. + run(work,'container-build',['docker','buildx','build','--output','type=oci,dest='+str(archive_path)+',rewrite-timestamp=true', + '--no-cache','--platform',LOCK['platform'],'--pull=false','--network=none','--provenance=false','--sbom=false', + '--metadata-file',str(work/'image-build-metadata.json'),'--build-arg','SOURCE_DATE_EPOCH='+str(epoch),str(context)],env) + metadata=json.loads((work/'image-build-metadata.json').read_text()) + with tarfile.open(archive_path) as archive: + index_bytes=archive.extractfile('index.json').read();index=json.loads(index_bytes) + assert len(index['manifests'])==1 + descriptor=index['manifests'][0] + assert descriptor['platform']=={'architecture':'amd64','os':'linux'} + assert descriptor['digest']==metadata['containerimage.digest'] + def blob(d): + value=archive.extractfile('blobs/sha256/'+d.split(':')[1]).read() + assert 'sha256:'+hashlib.sha256(value).hexdigest()==d + return value + manifest=json.loads(blob(descriptor['digest'])) + config_digest=manifest['config']['digest'];config=json.loads(blob(config_digest)) + assert config_digest==metadata['containerimage.config.digest'] + for layer in manifest['layers']:blob(layer['digest']) + assert config['rootfs']['diff_ids'][:-1]==original['rootfs']['diff_ids'] + assert config['architecture']=='amd64' and config['os']=='linux' + for key in ['User','Entrypoint','Cmd','Env','WorkingDir','Volumes']: + assert config['config'].get(key)==original['config'].get(key),key + labels=dict(original['config'].get('Labels',{})) + labels.update({'org.opencontainers.image.version':LOCK['release_version'], + 'org.opencontainers.image.revision':LOCK['source_commit'],'org.railiance.source.patch.sha256':LOCK['patch_sha256']}) + assert config['config'].get('Labels')==labels + assert config['config']['User']=='1000:1000' + expected_created=datetime.datetime.fromtimestamp(epoch,datetime.timezone.utc).isoformat(timespec='seconds').replace('+00:00','Z') + assert config['created']==expected_created + found=False + last='blobs/sha256/'+manifest['layers'][-1]['digest'].split(':')[1] + with tarfile.open(fileobj=archive.extractfile(last),mode='r|*') as layer: + for member in layer: + name=member.name.lstrip('./');assert name in ('app','app/gitea','app/gitea/gitea') + if name=='app/gitea/gitea': + h=hashlib.sha256();stream=layer.extractfile(member) + for block in iter(lambda:stream.read(1024*1024),b''):h.update(block) + assert h.hexdigest()==verified['binary_sha256'];found=True + assert found + save(work/'container-result.json',{'image_manifest_digest':descriptor['digest'],'config_digest':config_digest, + 'oci_index_sha256':hashlib.sha256(index_bytes).hexdigest(),'archive_sha256':digest(archive_path), + 'artifact_path':str(archive_path),'source_epoch':LOCK['source_date_epoch'],'container_epoch':epoch, + 'all_base_layers_and_rootless_runtime_config_preserved':True,'all_oci_blob_digests_verified':True, + 'final_layer_binary_exact':True,'registry_publication':False,'deployment_tag':None, + 'host_or_secret_mounts':False,'privileged_containers':False,'local_runtime_image_loaded':False}) + print((work/'container-result.json').read_text()) + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('phase',choices=['prepare','acquire-npm','build','verify','container']) + parser.add_argument('--workspace',required=True) + for n in ['source-archive','go-archive','node-archive','module-cache','go-cache-seed','npm-cache-seed']:parser.add_argument('--'+n) + a=parser.parse_args();work=Path(a.workspace).resolve() + if not str(work).startswith('/tmp/'):raise ValueError('isolated build workspace must be under /tmp') + if a.phase=='prepare':return prepare(a,work) + assert json.loads((work/'prepared.json').read_text())['lock_sha256']==digest(INPUTS/'inputs.lock.json') + if a.phase=='acquire-npm': + checked_inputs(work);env=base_env(work);env['NPM_CONFIG_OFFLINE']='false' + return run(work,'npm-public-acquire',['npm','ci','--ignore-scripts','--no-audit','--no-fund'],env,cwd=work/'source') + {'build':build,'verify':verify,'container':container}[a.phase](work) + +if __name__=='__main__':main() diff --git a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md index a1615e3..6abc696 100644 --- a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md +++ b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md @@ -9,7 +9,7 @@ flavor: implementation owner: claude-code topic_slug: railiance created: "2026-10-08" -updated: "2026-10-08" +updated: "2026-10-18" depends_on: - KEY-WP-0038 related_repos: @@ -168,3 +168,96 @@ adapters' checks with no value-bearing keys. The k3s `registries.yaml` on railiance01 still holds a login for the frozen Gitea host `92.205.130.254` (ORGREF-WP-0002-T02). Coordinate its removal with the node owner when ORGREF-WP-0002-T06 adds the forge registry login, in the same change. + + +## T06 — Package the reviewed consumer source through the Apps build path + +```task +id: RAPPS-WP-0016-T06 +status: done +priority: high +state_hub_task_id: "08ab8921-7801-5309-b84b-bc2a3ceea1e4" +``` + +Completed 2026-10-18, source build tooling only. The existing Apps Makefile now +has explicit prepare, public-dependency acquisition, offline build, artifact +verification and local OCI export targets for the pinned official Forgejo11.0.3 +source plus reviewed multi-value claim patch. Source/toolchains/locks/compiler +inputs/base image are fixed. Third-party application source stays in a new /tmp +workspace; Apps holds the release input lock, patch and build recipe. + +The original build profile produced identical frontend assets, bindata sources +and complete static executable in two workspaces, and identical local OCI +archives in two no-cache exports. Independent review then found host process +argv visibility through /proc (APPSBUILD-R1-F01). The revised recipe adds a +private PID namespace; exact source and synthetic canary regression are separately +reviewed. The original full builds are historical r1-profile evidence; no full +r2 build is claimed. Original packet/review and failure history remain in +Custodian apps-forgejo-release-build-r1 and apps-release-independent-review-r1; +the correction is apps-forgejo-release-build-r2. This is coordinator source +integration after independent technical review, not formal owner assent or image +admission. Helm values, deployed image, local sign-in and visibility are unchanged. +NK-WP-0048-T39 remains progress for the full release acceptance scope. + +## T07 — Keep the client secret out of process arguments + +```task +id: RAPPS-WP-0016-T07 +status: progress +priority: high +state_hub_task_id: "786d6584-6db1-5a10-9324-d7b94db0df94" +``` + +Implement and independently review a real Forgejo add/update --secret-file path +and the actual chart helper/mount adaptation. Preserve the existing CLIENT_SECRET +key and custody route; use no agent-value argument or environment fallback. Test +bounded stable nofollow file reads, exact existing minted-secret encoding, +mutual exclusion/update preservation, fixed errors and actual Helm rendering. +Private mount identity/reader set and new package/type revisions need explicit +owner/runtime admission. The in-progress isolated candidate is Custodian +forgejo-private-input-r1; source completion is distinct from T02 native delivery. + +## T08 — Provide a transactional application-source inverse and durable recovery + +```task +id: RAPPS-WP-0016-T08 +status: progress +priority: high +state_hub_task_id: "4ab8a5e7-ab32-5ee2-aaf2-6da246d1b0c8" +``` + +Implement actual owner database primitives that record operation intent/outcome +in the same durable database, compare complete raw persisted before/post/current +source state privately, and serialize conflicting application writers at the DB +boundary. Never emit stored credential/config bytes or delete/detach linked users +or a preexisting/shared source. SQLite and PostgreSQL transaction semantics must +be tested against the real app packages; unsupported backends refuse. + +T05 and NK-WP-0048-T34 additionally require actual authenticated execution and +fresh inverse sessions, admitted evidence/recovery and exclusion of every release +writer, including Helm/runtime writers. The candidate DB primitive alone does +not establish those facilities. Helm rollback/omission does not remove persisted +OAuth sources: verify the exact app-state inverse before any image downgrade. +If links prevent removal, retain the extended image and reconcile. The earlier +T02 single-Helm-rollback statement is not sufficient recovery for this extension; +its historical text is retained and corrected here. + +## T09 — Enforce authenticated Access Engine decisions in the application path + +```task +id: RAPPS-WP-0016-T09 +status: todo +priority: high +state_hub_task_id: "d3804638-c73d-5b9f-a456-4262f62f9e6b" +``` + +Use the producer-owned decision format/canonical bytes and independently admitted +signer/policy/request pins. Forgejo must verify the caller's issuer/tenant and +assurance facts, obtain and authenticate the exact Access Engine resource/action +decision, and refuse missing, stale, wrong-pin, unavailable or malformed replies. +Keep the original three-group any-of access set as policy input; its local +compatibility gate is not a substitute PDP or an architecture exception. An +issuer allowed/denied claim cannot manufacture authorization. Source integration +needs real provider/callback/session wiring and meaningful producer-consumer +positive/adverse tests before re-freezing the case and package. Existing break- +glass/local account and linking behavior remain within the declared owner scope. From 3fb031a47ff5379358fd8cc230b40961b3b5d3a8 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 18 Oct 2026 02:21:33 +0200 Subject: [PATCH 45/87] Compose Forgejo private-input and authenticated decision build candidate Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a14bfc-9643-7872-aa12-98326dcf25e7 --- build/forgejo/README.md | 45 +- .../forgejo/forgejo-security-extension.patch | 4559 +++++++++++++++++ build/forgejo/inputs.lock.json | 135 +- tools/forgejo-release-build.py | 7 +- .../RAPPS-WP-0016-forgejo-netkingdom-login.md | 22 +- 5 files changed, 4756 insertions(+), 12 deletions(-) create mode 100644 build/forgejo/forgejo-security-extension.patch diff --git a/build/forgejo/README.md b/build/forgejo/README.md index ff29075..56d2a7b 100644 --- a/build/forgejo/README.md +++ b/build/forgejo/README.md @@ -1,7 +1,11 @@ # Forgejo release preparation (build only) -The Apps Makefile targets package the independently reviewed seven-path Forgejo -11.0.3 source extension. They never deploy, push, change default values or choose +The Apps Makefile targets package the digest-bound 37-path Forgejo 11.0.3 +source candidate in `forgejo-security-extension.patch`. It combines the original +three-group any-of gate, private secret-file input, private source-operation DB +primitives and the producer-owned Access Engine client in the OIDC callback. +Coordinator source tests and complete release checks are recorded separately; +independent owner review and operational admission of this revision remain open. They never deploy, push, change default values or choose an operational image. Third-party application source is expanded only into a new `/tmp` workspace; this repository retains a release input lock and exact reviewed patch, not a second source-code tree. Upstream source ownership and publication @@ -70,3 +74,40 @@ private secret-input boundary, type/case/descriptor re-freeze, current operation inverse sessions, durable journal and application/Helm writer exclusion are separate gates. Helm omission/rollback does not remove persisted OAuth source. The existing case gate is compatibility code, not a single-PDP exception. + +## KeyCape identity and callback admission + +The callback independently verifies the ID token against owner-admitted public +keys, algorithms, issuer, audience and token type. Public trust is loaded from +`FORGEJO_ACCESS_ENGINE_ADMISSION_FILE`; only an absolute private file reference +is used for the PDP caller token. No admission file or operational keys are +supplied by this build. New fields `tenant_source_claim` and +`max_authentication_age` are mandatory. The KeyCape v0.3 profile uses +`assurance_claim: assurance` and `tenant_source_claim: tenant_source`. Maximum +identity age and maximum authentication age are separate positive durations +chosen by the owner, not implicit defaults. + +The signed assurance object preserves `level`, `methods`, boolean `mfa`, +`source: key-cape` and integer `at`. Its authentication time must not follow +issuance or the current time, and must satisfy the admitted authentication-age +bound. Identity validity ends at the earliest token expiration, issued-at age +bound or authentication-age bound. Directory, registration and default tenant +provenance remain distinct in the request. Mutable UserInfo cannot replace +these authenticated facts. Current UserInfo subject and the Goth subject must +match the independently verified token; the three required group alternatives +remain policy input. + +The parser also represents valid lower-assurance facts. Application policy +continues to decide authorization. The Check client and unchanged canonical +signature bytes are a generated source slice of Access Engine ca0cb757; Forgejo +adds no policy evaluator. Exact caller/policy/registry/signer/request/lifetime +pins, an authenticated HTTPS connection and a current ALLOW are required. +Synthetic callback fixtures test ALLOW, DENY and adverse cases, including an ID +token serialized by the actual pinned KeyCape handler. Those fixtures do not +prove an actual browser, factor, custody or native rollout. + +The current integration covers this guarded OIDC callback. Ongoing application +HTTP/API/Git/SSH paths, current operation/inverse sessions, durable private +evidence and retention, PostgreSQL/native proof and exclusion of all release +writers remain open in RAPPS-WP-0016-T08/T09 and NK-WP-0048-T43. An old image +approval or source test result cannot admit this candidate. diff --git a/build/forgejo/forgejo-security-extension.patch b/build/forgejo/forgejo-security-extension.patch new file mode 100644 index 0000000..cd0fb52 --- /dev/null +++ b/build/forgejo/forgejo-security-extension.patch @@ -0,0 +1,4559 @@ +--- a/cmd/admin_auth_oauth.go ++++ b/cmd/admin_auth_oauth.go +@@ -9,6 +9,7 @@ + "net/url" + + auth_model "forgejo.org/models/auth" ++ "forgejo.org/models/db" + "forgejo.org/services/auth/source/oauth2" + + "github.com/urfave/cli/v2" +@@ -35,6 +36,10 @@ + Name: "secret", + Value: "", + Usage: "Client Secret", ++ }, ++ &cli.StringFlag{ ++ Name: "secret-file", ++ Usage: "Read a 64-byte base64url client secret from an admitted read-only regular file", + }, + &cli.StringFlag{ + Name: "auto-discover-url", +@@ -94,6 +99,10 @@ + Name: "required-claim-value", + Value: "", + Usage: "Claim value that has to be set to allow users to login with this source", ++ }, ++ &cli.StringSliceFlag{ ++ Name: "required-claim-values", ++ Usage: "Require any one exact array value from current OpenID Connect UserInfo (cannot combine with required-claim-value)", + }, + &cli.StringFlag{ + Name: "group-claim-name", +@@ -160,6 +169,7 @@ + Scopes: c.StringSlice("scopes"), + RequiredClaimName: c.String("required-claim-name"), + RequiredClaimValue: c.String("required-claim-value"), ++ RequiredClaimValues: c.StringSlice("required-claim-values"), + GroupClaimName: c.String("group-claim-name"), + AdminGroup: c.String("admin-group"), + RestrictedGroup: c.String("restricted-group"), +@@ -169,14 +179,31 @@ + } + + func runAddOauth(c *cli.Context) error { ++ secret, selected, err := oauthSecretInput(c) ++ if err != nil { ++ return err ++ } + ctx, cancel := installSignals() + defer cancel() ++ ctx = db.WithSensitiveSQL(ctx) + + if err := initDB(ctx); err != nil { + return err + } ++ if db.SQLLoggingEnabled(ctx) { ++ return errors.New("OAuth configuration requires SQL logging disabled") ++ } + + config := parseOAuth2Config(c) ++ if selected { ++ config.ClientSecret = secret ++ } ++ if err := oauth2.ValidateRequiredClaimValues(config.RequiredClaimName, config.RequiredClaimValue, config.RequiredClaimValues); err != nil { ++ return err ++ } ++ if config.RequiredClaimValues != nil && config.Provider != "openidConnect" { ++ return errors.New("required-claim-values requires openidConnect") ++ } + if config.Provider == "openidConnect" { + discoveryURL, err := url.Parse(config.OpenIDConnectAutoDiscoveryURL) + if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") { +@@ -184,24 +211,36 @@ + } + } + +- return auth_model.CreateSource(ctx, &auth_model.Source{ ++ err = auth_model.CreateSource(ctx, &auth_model.Source{ + Type: auth_model.OAuth2, + Name: c.String("name"), + IsActive: true, + Cfg: config, + }) ++ if err != nil && c.IsSet("secret-file") { ++ return errors.New("OAuth source creation failed") ++ } ++ return err + } + + func runUpdateOauth(c *cli.Context) error { ++ secret, selected, err := oauthSecretInput(c) ++ if err != nil { ++ return err ++ } + if !c.IsSet("id") { + return errors.New("--id flag is missing") + } + + ctx, cancel := installSignals() + defer cancel() ++ ctx = db.WithSensitiveSQL(ctx) + + if err := initDB(ctx); err != nil { + return err ++ } ++ if db.SQLLoggingEnabled(ctx) { ++ return errors.New("OAuth configuration requires SQL logging disabled") + } + + source, err := auth_model.GetSourceByID(ctx, c.Int64("id")) +@@ -209,7 +248,10 @@ + return err + } + +- oAuth2Config := source.Cfg.(*oauth2.Source) ++ oAuth2Config, ok := source.Cfg.(*oauth2.Source) ++ if !ok { ++ return errors.New("authentication source is not OAuth2") ++ } + + if c.IsSet("name") { + source.Name = c.String("name") +@@ -223,8 +265,8 @@ + oAuth2Config.ClientID = c.String("key") + } + +- if c.IsSet("secret") { +- oAuth2Config.ClientSecret = c.String("secret") ++ if selected { ++ oAuth2Config.ClientSecret = secret + } + + if c.IsSet("auto-discover-url") { +@@ -244,6 +286,10 @@ + } + if c.IsSet("required-claim-value") { + oAuth2Config.RequiredClaimValue = c.String("required-claim-value") ++ } ++ ++ if c.IsSet("required-claim-values") { ++ oAuth2Config.RequiredClaimValues = c.StringSlice("required-claim-values") + } + + if c.IsSet("group-claim-name") { +@@ -293,7 +339,30 @@ + } + + oAuth2Config.CustomURLMapping = customURLMapping ++ if err := oauth2.ValidateRequiredClaimValues(oAuth2Config.RequiredClaimName, oAuth2Config.RequiredClaimValue, oAuth2Config.RequiredClaimValues); err != nil { ++ return err ++ } ++ if oAuth2Config.RequiredClaimValues != nil && oAuth2Config.Provider != "openidConnect" { ++ return errors.New("required-claim-values requires openidConnect") ++ } + source.Cfg = oAuth2Config + +- return auth_model.UpdateSource(ctx, source) ++ err = auth_model.UpdateSource(ctx, source) ++ if err != nil && c.IsSet("secret-file") { ++ return errors.New("OAuth source update failed") ++ } ++ return err + } ++ ++// Resolve before opening the DB or mutating a loaded source. Never store the ++// resolved bytes in the CLI context: they must not become flag/argv diagnostics. ++func oauthSecretInput(c *cli.Context) (string, bool, error) { ++ if c.IsSet("secret-file") { ++ if c.IsSet("secret") { ++ return "", false, errors.New("secret and secret-file are mutually exclusive") ++ } ++ value, err := readOAuthSecretFile(c.String("secret-file")) ++ return value, true, err ++ } ++ return c.String("secret"), c.IsSet("secret"), nil ++} +--- /dev/null ++++ b/cmd/admin_auth_oauth_secret_linux.go +@@ -0,0 +1,114 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++//go:build linux ++ ++package cmd ++ ++import ( ++ "errors" ++ "io" ++ "os" ++ "path/filepath" ++ "strings" ++ ++ "golang.org/x/sys/unix" ++) ++ ++var errOAuthSecretFile = errors.New("secret-file input refused") ++ ++// This checks DAC and mount properties, not namespace, Secret provenance or ++// operation authority. Admission must bind the exact workload/mount/reader set. ++// Kubernetes Secret subPath leaves are root-owned; fsGroup can provide 0440 ++// access. Requiring euid ownership would incorrectly refuse that supported case. ++func readOAuthSecretFile(path string) (string, error) { ++ if len(path) > 4096 || !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.ContainsRune(path, 0) { ++ return "", errOAuthSecretFile ++ } ++ parts := strings.Split(strings.TrimPrefix(path, "/"), "/") ++ if len(parts) < 2 { ++ return "", errOAuthSecretFile ++ } ++ dir, err := unix.Open("/", unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) ++ if err != nil { ++ return "", errOAuthSecretFile ++ } ++ defer func() { unix.Close(dir) }() ++ for _, part := range parts[:len(parts)-1] { ++ var st unix.Stat_t ++ if unix.Fstat(dir, &st) != nil || !safeOAuthSecretParent(st) { ++ return "", errOAuthSecretFile ++ } ++ next, err := unix.Openat(dir, part, unix.O_PATH|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) ++ if err != nil { ++ return "", errOAuthSecretFile ++ } ++ unix.Close(dir) ++ dir = next ++ } ++ var parent unix.Stat_t ++ if unix.Fstat(dir, &parent) != nil || !safeOAuthSecretParent(parent) { ++ return "", errOAuthSecretFile ++ } ++ // Nonblocking is necessary: reject a FIFO without waiting for a writer. ++ fd, err := unix.Openat(dir, parts[len(parts)-1], unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0) ++ if err != nil { ++ return "", errOAuthSecretFile ++ } ++ f := os.NewFile(uintptr(fd), "private OAuth input") ++ defer f.Close() ++ return readOAuthSecretFD(f, dir, parts[len(parts)-1]) ++} ++ ++func readOAuthSecretFD(f *os.File, dir int, leaf string) (string, error) { ++ fd := int(f.Fd()) ++ var before, after, named unix.Stat_t ++ var mount unix.Statfs_t ++ if unix.Fstat(fd, &before) != nil || !safeOAuthSecretLeaf(before) || unix.Fstatfs(fd, &mount) != nil || mount.Flags&unix.ST_RDONLY == 0 { ++ return "", errOAuthSecretFile ++ } ++ // mint-secret uses secrets.token_urlsafe(48): exactly 64 ASCII characters, ++ // no padding/newline. Bounded read still detects a concurrent size increase. ++ data, err := io.ReadAll(io.LimitReader(f, 65)) ++ defer clear(data) ++ if err != nil || len(data) != 64 { ++ return "", errOAuthSecretFile ++ } ++ for _, b := range data { ++ if !(b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z' || b >= '0' && b <= '9' || b == '-' || b == '_') { ++ return "", errOAuthSecretFile ++ } ++ } ++ if unix.Fstat(fd, &after) != nil || !sameOAuthSecretFile(before, after) || unix.Fstatat(dir, leaf, &named, unix.AT_SYMLINK_NOFOLLOW) != nil || !sameOAuthSecretFile(before, named) { ++ return "", errOAuthSecretFile ++ } ++ return string(data), nil ++} ++ ++func safeOAuthSecretParent(st unix.Stat_t) bool { ++ return st.Mode&unix.S_IFMT == unix.S_IFDIR && (st.Uid == 0 || st.Uid == uint32(os.Geteuid())) && st.Mode&0022 == 0 ++} ++ ++func safeOAuthSecretLeaf(st unix.Stat_t) bool { ++ if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 || st.Size != 64 || st.Mode&07337 != 0 || (st.Uid != 0 && st.Uid != uint32(os.Geteuid())) { ++ return false ++ } ++ if st.Mode&0040 != 0 { ++ groups, err := os.Getgroups() ++ if err != nil { ++ return false ++ } ++ member := st.Gid == uint32(os.Getegid()) ++ for _, gid := range groups { ++ member = member || st.Gid == uint32(gid) ++ } ++ if !member { ++ return false ++ } ++ } ++ return true ++} ++ ++func sameOAuthSecretFile(a, b unix.Stat_t) bool { ++ return a.Dev == b.Dev && a.Ino == b.Ino && a.Mode == b.Mode && a.Nlink == b.Nlink && a.Uid == b.Uid && a.Gid == b.Gid && a.Size == b.Size && a.Mtim == b.Mtim && a.Ctim == b.Ctim ++} +--- /dev/null ++++ b/cmd/admin_auth_oauth_secret_linux_test.go +@@ -0,0 +1,79 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++//go:build linux ++ ++package cmd ++ ++import ( ++ "golang.org/x/sys/unix" ++ "os" ++ "testing" ++) ++ ++func TestPrivateSecretMetadata(t *testing.T) { ++ st := unix.Stat_t{Mode: unix.S_IFREG | 0440, Uid: 0, Gid: uint32(os.Getegid()), Nlink: 1, Size: 64} ++ if !safeOAuthSecretLeaf(st) { ++ t.Fatal("root-owned fsGroup-readable Secret subPath refused") ++ } ++ for _, mode := range []uint32{0444, 0640, 0540, 0460, 0441, 0440 | unix.S_ISUID, 0440 | unix.S_ISGID, 0440 | unix.S_ISVTX} { ++ x := st ++ x.Mode = unix.S_IFREG | mode ++ if safeOAuthSecretLeaf(x) { ++ t.Fatalf("unsafe mode accepted: %o", mode) ++ } ++ } ++ for _, mutate := range []func(*unix.Stat_t){func(x *unix.Stat_t) { x.Uid = 123456 }, func(x *unix.Stat_t) { x.Nlink = 2 }, func(x *unix.Stat_t) { x.Size = 63 }, func(x *unix.Stat_t) { x.Mode = unix.S_IFIFO | 0400 }, func(x *unix.Stat_t) { x.Gid = 123456 }} { ++ x := st ++ mutate(&x) ++ if safeOAuthSecretLeaf(x) { ++ t.Fatal("unsafe metadata accepted") ++ } ++ } ++ if !safeOAuthSecretParent(unix.Stat_t{Mode: unix.S_IFDIR | 0755, Uid: 0}) { ++ t.Fatal("root parent refused") ++ } ++ if safeOAuthSecretParent(unix.Stat_t{Mode: unix.S_IFDIR | 0775, Uid: 0}) { ++ t.Fatal("group writable parent accepted") ++ } ++ for _, mutate := range []func(*unix.Stat_t){func(x *unix.Stat_t) { x.Dev++ }, func(x *unix.Stat_t) { x.Ino++ }, func(x *unix.Stat_t) { x.Ctim.Nsec++ }, func(x *unix.Stat_t) { x.Mtim.Nsec++ }, func(x *unix.Stat_t) { x.Size++ }, func(x *unix.Stat_t) { x.Uid++ }, func(x *unix.Stat_t) { x.Gid++ }, func(x *unix.Stat_t) { x.Nlink++ }, func(x *unix.Stat_t) { x.Mode++ }} { ++ x := st ++ mutate(&x) ++ if sameOAuthSecretFile(st, x) { ++ t.Fatal("changed file considered stable") ++ } ++ } ++ x := st ++ x.Atim.Nsec++ ++ if !sameOAuthSecretFile(st, x) { ++ t.Fatal("read-induced atime change refused") ++ } ++} ++ ++// Actual Linux descriptors and namespace read-only bind mounts, not mocked stat ++// metadata. A writable alias lets the fixture simulate replacement after open. ++func TestPrivateSecretOpenedFileReplacement(t *testing.T) { ++ if _, err := os.Stat("/run/forgejo-oidc/race"); os.IsNotExist(err) { ++ t.Skip("requires isolated read-only bind fixture") ++ } ++ dir, err := unix.Open("/run/forgejo-oidc", unix.O_PATH|unix.O_DIRECTORY, 0) ++ if err != nil { ++ t.Fatal(err) ++ } ++ defer unix.Close(dir) ++ f, err := os.Open("/run/forgejo-oidc/race") ++ if err != nil { ++ t.Fatal(err) ++ } ++ defer f.Close() ++ // Preserve the opened inode with one link; replace only the named leaf. ++ if err := os.Rename("/run/writable-input/race", "/run/writable-input/race-backup"); err != nil { ++ t.Fatal(err) ++ } ++ if err := os.Rename("/run/writable-input/race-replacement", "/run/writable-input/race"); err != nil { ++ t.Fatal(err) ++ } ++ if value, err := readOAuthSecretFD(f, dir, "race"); err != errOAuthSecretFile || value != "" { ++ t.Fatal("replaced leaf accepted") ++ } ++} +--- /dev/null ++++ b/cmd/admin_auth_oauth_secret_other.go +@@ -0,0 +1,12 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++//go:build !linux ++ ++package cmd ++ ++import "errors" ++ ++func readOAuthSecretFile(string) (string, error) { ++ return "", errors.New("secret-file input refused") ++} +--- a/go.mod ++++ b/go.mod +@@ -255,3 +255,7 @@ + replace github.com/gliderlabs/ssh => code.forgejo.org/forgejo/ssh v0.0.0-20241211213324-5fc306ca0616 + + replace git.sr.ht/~mariusor/go-xsd-duration => code.forgejo.org/forgejo/go-xsd-duration v0.0.0-20220703122237-02e73435a078 ++ ++require github.com/netkingdom/access-engine v0.0.0 ++ ++replace github.com/netkingdom/access-engine => ./third_party/access-engine-pep +--- /dev/null ++++ b/models/auth/source_operation.go +@@ -0,0 +1,430 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++package auth ++ ++// This owner-confined database primitive is NOT an authorization boundary. ++// No CLI/web/API route exposes it. The admitted runtime must separately obtain ++// fresh authenticated apply/inverse sessions and retain signed public evidence. ++// Configurations and private journal rows must never leave database custody. ++ ++import ( ++ "context" ++ "encoding/json" ++ "errors" ++ "reflect" ++ "regexp" ++ "sort" ++ ++ "forgejo.org/models/db" ++ "forgejo.org/modules/setting" ++ "forgejo.org/modules/timeutil" ++ "xorm.io/xorm" ++) ++ ++var ( ++ ErrSourceOperationConflict = errors.New("source operation requires owner reconciliation") ++ ErrSourceOperationStorage = errors.New("source operation storage failure; reconcile before retry") ++ ErrSourceOperationUnsupported = errors.New("source operation requires standalone SQLite/PostgreSQL transaction with SQL logging disabled") ++ operationIDPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:-]{7,127}$`) ++) ++ ++// Private source rows deliberately bypass Config decoding: decoding and ++// reserializing can erase unknown fields, including a gate on an older image. ++type sourceOperationRow struct { ++ ID int64 `xorm:"pk autoincr"` ++ Type Type ++ Name string ++ IsActive bool ++ IsSyncEnabled bool ++ Cfg string `xorm:"TEXT"` ++ CreatedUnix timeutil.TimeStamp ++ UpdatedUnix timeutil.TimeStamp ++} ++ ++func (sourceOperationRow) TableName() string { return "login_source" } ++ ++// The whole inventory, including private configs, stays in the owner's database. ++// Neither journal payload nor its digest is a public result. ++type sourceOperationJournal struct { ++ OperationID string `xorm:"pk VARCHAR(128)"` ++ CaseID string `xorm:"VARCHAR(128)"` ++ TargetName string ++ TargetID int64 ++ Phase string ++ BeforeState string `xorm:"TEXT"` ++ DesiredState string `xorm:"TEXT"` ++ PostState string `xorm:"TEXT"` ++} ++ ++func (sourceOperationJournal) TableName() string { return "login_source_operation" } ++func init() { db.RegisterModel(new(sourceOperationJournal)) } ++ ++// SourceOperationResult contains only public outcome metadata. An authenticated ++// session/evidence producer must bind this result to its actual principal, ++// admitted source revision, exact parameters and current operation session. ++type SourceOperationResult struct { ++ OperationID string ++ Phase string ++ SourceID int64 ++} ++ ++// sourceOperationTx acquires a database-enforced lock before any source read. ++// SQLite's first write serializes every database writer, including stock admin ++// CRUD. PostgreSQL table locks exclude ordinary SQL source and user-link writes. ++// Privileged DDL, process/provider refresh and release writers are outside this ++// bounded primitive; they still require owner runtime/release exclusion. ++func sourceOperationTx(ctx context.Context, f func(context.Context) error) error { ++ if db.InTransaction(ctx) || setting.Database.LogSQL || (!setting.Database.Type.IsSQLite3() && !setting.Database.Type.IsPostgreSQL()) { ++ return ErrSourceOperationUnsupported ++ } ++ err := db.WithTx(db.WithSensitiveSQL(ctx), func(tx context.Context) error { ++ session, ok := db.GetEngine(tx).(*xorm.Session) ++ if !ok || session.Engine().Logger().IsShowSQL() { ++ return ErrSourceOperationUnsupported ++ } ++ if setting.Database.Type.IsPostgreSQL() { ++ if _, err := db.GetEngine(tx).Exec(`LOCK TABLE login_source_operation, login_source, "user", external_login_user IN EXCLUSIVE MODE`); err != nil { ++ return err ++ } ++ } else { ++ // Even with no matching row, UPDATE starts SQLite's write transaction. ++ if _, err := db.GetEngine(tx).Exec(`UPDATE login_source_operation SET phase = phase WHERE operation_id = ''`); err != nil { ++ return err ++ } ++ } ++ return f(tx) ++ }) ++ if err == nil || errors.Is(err, ErrSourceOperationConflict) || errors.Is(err, ErrSourceOperationUnsupported) || IsErrSourceInUse(err) { ++ return err ++ } ++ return ErrSourceOperationStorage ++} ++ ++func sourceInventory(ctx context.Context) ([]sourceOperationRow, error) { ++ rows := make([]sourceOperationRow, 0) ++ err := db.GetEngine(ctx).OrderBy("id").Find(&rows) ++ return rows, err ++} ++func encodeSourceInventory(rows []sourceOperationRow) string { ++ b, _ := json.Marshal(rows) ++ return string(b) ++} ++func decodeSourceInventory(s string) ([]sourceOperationRow, error) { ++ var rows []sourceOperationRow ++ if err := json.Unmarshal([]byte(s), &rows); err != nil { ++ return nil, ErrSourceOperationConflict ++ } ++ return rows, nil ++} ++func sourceJournal(ctx context.Context, operationID string) (*sourceOperationJournal, error) { ++ row := new(sourceOperationJournal) ++ found, err := db.GetEngine(ctx).ID(operationID).Get(row) ++ if err != nil { ++ return nil, err ++ } ++ if !found { ++ return nil, ErrSourceOperationConflict ++ } ++ return row, nil ++} ++func journalResult(j *sourceOperationJournal) SourceOperationResult { ++ return SourceOperationResult{OperationID: j.OperationID, Phase: j.Phase, SourceID: j.TargetID} ++} ++ ++// PrepareSourceOperation durably captures exact source identity and complete ++// original inventory before mutation. desired is private owner input, not an ++// authorization token. targetID=0 requires authoritative name absence. Existing ++// sources require both exact ID and name. Reuse of an operation ID is refused. ++func PrepareSourceOperation(ctx context.Context, operationID, caseID string, targetID int64, desired *Source) (SourceOperationResult, error) { ++ var result SourceOperationResult ++ if !operationIDPattern.MatchString(operationID) || !operationIDPattern.MatchString(caseID) || desired == nil || desired.Type != OAuth2 || desired.Name == "" || desired.Cfg == nil || desired.IsSyncEnabled || desired.ID != targetID { ++ return result, ErrSourceOperationConflict ++ } ++ cfg, err := desired.Cfg.ToDB() ++ if err != nil || len(cfg) > 64*1024 || !json.Valid(cfg) { ++ return result, ErrSourceOperationConflict ++ } ++ candidate := sourceOperationRow{ID: targetID, Type: OAuth2, Name: desired.Name, IsActive: desired.IsActive, Cfg: string(cfg)} ++ candidateBytes, _ := json.Marshal(candidate) ++ err = sourceOperationTx(ctx, func(tx context.Context) error { ++ // Bound confidential duplicate custody. Refuse rather than erase journals ++ // with pending recovery or silently expire terminal private snapshots. ++ count, err := db.GetEngine(tx).Count(new(sourceOperationJournal)) ++ if err != nil { ++ return err ++ } ++ if count >= 256 { ++ return ErrSourceOperationConflict ++ } ++ active, err := db.GetEngine(tx).Where("phase IN (?, ?, ?)", "prepared", "applied", "unchanged").Count(new(sourceOperationJournal)) ++ if err != nil { ++ return err ++ } ++ if active >= 64 { ++ return ErrSourceOperationConflict ++ } ++ found, err := db.GetEngine(tx).ID(operationID).Exist(new(sourceOperationJournal)) ++ if err != nil { ++ return err ++ } ++ if found { ++ return ErrSourceOperationConflict ++ } ++ shared, err := db.GetEngine(tx).Where("target_name = ? AND phase IN (?, ?, ?)", desired.Name, "prepared", "applied", "unchanged").Exist(new(sourceOperationJournal)) ++ if err != nil { ++ return err ++ } ++ if shared { ++ return ErrSourceOperationConflict ++ } ++ rows, err := sourceInventory(tx) ++ if err != nil { ++ return err ++ } ++ if len(encodeSourceInventory(rows)) > 1024*1024 { ++ return ErrSourceOperationConflict ++ } ++ matched := false ++ for _, row := range rows { ++ if row.Name == desired.Name || row.ID == targetID { ++ if targetID == 0 || row.ID != targetID || row.Name != desired.Name || row.Type != OAuth2 { ++ return ErrSourceOperationConflict ++ } ++ matched = true ++ } ++ } ++ if targetID != 0 && !matched { ++ return ErrSourceOperationConflict ++ } ++ j := &sourceOperationJournal{OperationID: operationID, CaseID: caseID, TargetName: desired.Name, TargetID: targetID, Phase: "prepared", BeforeState: encodeSourceInventory(rows), DesiredState: string(candidateBytes)} ++ if _, err := db.GetEngine(tx).Insert(j); err != nil { ++ return err ++ } ++ result = journalResult(j) ++ return nil ++ }) ++ if err != nil { ++ return SourceOperationResult{}, err ++ } ++ return result, nil ++} ++ ++// ApplySourceOperation atomically compares every original raw source row, ++// mutates exactly the target and persists confirmed post-state. A lost commit ++// acknowledgement is reconciled using the same operation ID, never a fresh ID. ++// No source registration/network call occurs inside the database transaction. ++func ApplySourceOperation(ctx context.Context, operationID, caseID string) (SourceOperationResult, error) { ++ var result SourceOperationResult ++ err := sourceOperationTx(ctx, func(tx context.Context) error { ++ j, err := sourceJournal(tx, operationID) ++ if err != nil { ++ return err ++ } ++ if j.CaseID != caseID { ++ return ErrSourceOperationConflict ++ } ++ rows, err := sourceInventory(tx) ++ if err != nil { ++ return err ++ } ++ if j.Phase == "applied" || j.Phase == "unchanged" { ++ if encodeSourceInventory(rows) != j.PostState { ++ return ErrSourceOperationConflict ++ } ++ result = journalResult(j) ++ return nil ++ } ++ if j.Phase != "prepared" || encodeSourceInventory(rows) != j.BeforeState { ++ return ErrSourceOperationConflict ++ } ++ var desired sourceOperationRow ++ if json.Unmarshal([]byte(j.DesiredState), &desired) != nil { ++ return ErrSourceOperationConflict ++ } ++ unchanged := false ++ for _, row := range rows { ++ if row.ID == j.TargetID { ++ desired.CreatedUnix = row.CreatedUnix ++ desired.UpdatedUnix = row.UpdatedUnix ++ unchanged = reflect.DeepEqual(row, desired) ++ } ++ } ++ if !unchanged { ++ desired.UpdatedUnix = timeutil.TimeStampNow() ++ if j.TargetID == 0 { ++ desired.CreatedUnix = desired.UpdatedUnix ++ if _, err := db.GetEngine(tx).NoAutoTime().Insert(&desired); err != nil { ++ return err ++ } ++ j.TargetID = desired.ID ++ } else { ++ n, err := db.GetEngine(tx).ID(j.TargetID).NoAutoTime().AllCols().Update(&desired) ++ if err != nil { ++ return err ++ } ++ if n != 1 { ++ return ErrSourceOperationConflict ++ } ++ } ++ } ++ post, err := sourceInventory(tx) ++ if err != nil { ++ return err ++ } ++ expected := append([]sourceOperationRow{}, rows...) ++ if !unchanged { ++ replaced := false ++ for i := range expected { ++ if expected[i].ID == desired.ID { ++ expected[i] = desired ++ replaced = true ++ } ++ } ++ if !replaced { ++ expected = append(expected, desired) ++ } ++ sort.Slice(expected, func(i, k int) bool { return expected[i].ID < expected[k].ID }) ++ } ++ if !reflect.DeepEqual(post, expected) { ++ return ErrSourceOperationConflict ++ } ++ j.PostState = encodeSourceInventory(post) ++ j.Phase = "applied" ++ if unchanged { ++ j.Phase = "unchanged" ++ } ++ if _, err := db.GetEngine(tx).ID(j.OperationID).AllCols().Update(j); err != nil { ++ return err ++ } ++ result = journalResult(j) ++ return nil ++ }) ++ if err != nil { ++ return SourceOperationResult{}, err ++ } ++ return result, nil ++} ++ ++// InverseSourceOperation requires fresh external inverse authority on EACH call. ++// It restores a preexisting source in place; only confirmed case creation can ++// delete. Any linked local/external account refuses deletion without alteration. ++func InverseSourceOperation(ctx context.Context, operationID, caseID string) (SourceOperationResult, error) { ++ var result SourceOperationResult ++ err := sourceOperationTx(ctx, func(tx context.Context) error { ++ j, err := sourceJournal(tx, operationID) ++ if err != nil { ++ return err ++ } ++ if j.CaseID != caseID { ++ return ErrSourceOperationConflict ++ } ++ rows, err := sourceInventory(tx) ++ if err != nil { ++ return err ++ } ++ if j.Phase == "reverted" || j.Phase == "cancelled" { ++ if encodeSourceInventory(rows) != j.BeforeState { ++ return ErrSourceOperationConflict ++ } ++ result = journalResult(j) ++ return nil ++ } ++ if j.Phase == "prepared" { ++ if encodeSourceInventory(rows) != j.BeforeState { ++ return ErrSourceOperationConflict ++ } ++ j.Phase = "cancelled" ++ } else { ++ if (j.Phase != "applied" && j.Phase != "unchanged") || encodeSourceInventory(rows) != j.PostState { ++ return ErrSourceOperationConflict ++ } ++ before, err := decodeSourceInventory(j.BeforeState) ++ if err != nil { ++ return err ++ } ++ var original *sourceOperationRow ++ for i := range before { ++ if before[i].ID == j.TargetID { ++ original = &before[i] ++ } ++ } ++ if original != nil { ++ if _, err := db.GetEngine(tx).ID(j.TargetID).NoAutoTime().AllCols().Update(original); err != nil { ++ return err ++ } ++ } else { ++ for _, table := range []struct{ name, column string }{{"user", "login_source"}, {"external_login_user", "login_source_id"}} { ++ count, err := db.GetEngine(tx).Table(table.name).Where(table.column+" = ?", j.TargetID).Count() ++ if err != nil { ++ return err ++ } ++ if count != 0 { ++ return ErrSourceInUse{ID: j.TargetID} ++ } ++ } ++ n, err := db.GetEngine(tx).ID(j.TargetID).Delete(new(sourceOperationRow)) ++ if err != nil { ++ return err ++ } ++ if n != 1 { ++ return ErrSourceOperationConflict ++ } ++ } ++ restored, err := sourceInventory(tx) ++ if err != nil { ++ return err ++ } ++ if encodeSourceInventory(restored) != j.BeforeState { ++ return ErrSourceOperationConflict ++ } ++ j.Phase = "reverted" ++ } ++ if _, err := db.GetEngine(tx).ID(j.OperationID).AllCols().Update(j); err != nil { ++ return err ++ } ++ result = journalResult(j) ++ return nil ++ }) ++ if err != nil { ++ return SourceOperationResult{}, err ++ } ++ return result, nil ++} ++ ++// ReconcileSourceOperation only checks committed journal/current equality. It ++// never infers that a prepared intent ran and never retries the mutation. The ++// separately authenticated runtime must call this after an ambiguous result. ++func ReconcileSourceOperation(ctx context.Context, operationID, caseID string) (SourceOperationResult, error) { ++ var result SourceOperationResult ++ err := sourceOperationTx(ctx, func(tx context.Context) error { ++ j, err := sourceJournal(tx, operationID) ++ if err != nil { ++ return err ++ } ++ if j.CaseID != caseID { ++ return ErrSourceOperationConflict ++ } ++ rows, err := sourceInventory(tx) ++ if err != nil { ++ return err ++ } ++ expected := j.BeforeState ++ switch j.Phase { ++ case "prepared", "reverted", "cancelled": ++ case "applied", "unchanged": ++ expected = j.PostState ++ default: ++ return ErrSourceOperationConflict ++ } ++ if encodeSourceInventory(rows) != expected { ++ return ErrSourceOperationConflict ++ } ++ result = journalResult(j) ++ return nil ++ }) ++ if err != nil { ++ return SourceOperationResult{}, err ++ } ++ return result, nil ++} +--- /dev/null ++++ b/models/auth/source_operation_internal_test.go +@@ -0,0 +1,87 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++package auth ++ ++import ( ++ "context" ++ "testing" ++ "time" ++ ++ "forgejo.org/models/db" ++ "xorm.io/xorm" ++) ++ ++func TestSourceOperationDatabaseWriterExclusion(t *testing.T) { ++ // Use DEFERRED transaction mode in the managed engine as well, so this ++ // test detects removal of the primitive's explicit first write. ++ previousContext := db.DefaultContext ++ previousEngine, ok := db.GetEngine(previousContext).(*xorm.Engine) ++ if !ok { ++ t.Fatal("missing actual fixture engine") ++ } ++ managed, err := xorm.NewEngine("sqlite3", "file::memory:?cache=shared&_txlock=deferred") ++ if err != nil { ++ t.Fatal(err) ++ } ++ db.SetDefaultEngine(context.Background(), managed) ++ defer func() { db.SetDefaultEngine(previousContext, previousEngine); managed.Close() }() ++ // A second actual connection executes uncooperative raw SQL, so this checks ++ // database exclusion rather than an advisory-lock convention. ++ raw, err := xorm.NewEngine("sqlite3", "file::memory:?cache=shared&_txlock=deferred") ++ if err != nil { ++ t.Fatal(err) ++ } ++ defer raw.Close() ++ locked := make(chan struct{}) ++ release := make(chan struct{}) ++ done := make(chan error, 1) ++ go func() { ++ done <- sourceOperationTx(db.DefaultContext, func(ctx context.Context) error { close(locked); <-release; return nil }) ++ }() ++ select { ++ case <-locked: ++ case err := <-done: ++ t.Fatalf("lock failed: %v", err) ++ case <-time.After(5 * time.Second): ++ t.Fatal("lock timed out") ++ } ++ writeDone := make(chan error, 1) ++ go func() { _, err := raw.Exec("UPDATE login_source SET name = name WHERE id = -123"); writeDone <- err }() ++ select { ++ case err := <-writeDone: ++ close(release) ++ <-done ++ t.Fatalf("uncooperative write escaped exclusion: %v", err) ++ case <-time.After(100 * time.Millisecond): ++ } ++ close(release) ++ select { ++ case err := <-done: ++ if err != nil { ++ t.Fatal(err) ++ } ++ case <-time.After(5 * time.Second): ++ t.Fatal("transaction did not finish") ++ } ++ select { ++ case err := <-writeDone: ++ if err != nil { ++ t.Fatal(err) ++ } ++ case <-time.After(5 * time.Second): ++ t.Fatal("writer did not resume") ++ } ++} ++func TestSourceOperationRefusesActualXORMArgumentLogging(t *testing.T) { ++ engine, ok := db.GetEngine(db.DefaultContext).(*xorm.Engine) ++ if !ok { ++ t.Fatal("missing actual engine") ++ } ++ engine.ShowSQL(true) ++ defer engine.ShowSQL(false) ++ called := false ++ err := sourceOperationTx(db.DefaultContext, func(context.Context) error { called = true; return nil }) ++ if err != ErrSourceOperationUnsupported || called { ++ t.Fatal("actual SQL argument logger did not fail closed") ++ } ++} +--- /dev/null ++++ b/models/auth/source_operation_process_test.go +@@ -0,0 +1,97 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++package auth_test ++ ++import ( ++ "context" ++ "os" ++ "os/exec" ++ "path/filepath" ++ "strings" ++ "testing" ++ ++ auth "forgejo.org/models/auth" ++ "forgejo.org/models/db" ++ oauth2 "forgejo.org/services/auth/source/oauth2" ++ ++ "github.com/stretchr/testify/require" ++ "xorm.io/xorm" ++ "xorm.io/xorm/contexts" ++ "xorm.io/xorm/names" ++) ++ ++type operationCrashAfterSourceInsert struct{} ++ ++func (operationCrashAfterSourceInsert) BeforeProcess(c *contexts.ContextHook) (context.Context, error) { ++ return c.Ctx, nil ++} ++func (operationCrashAfterSourceInsert) AfterProcess(c *contexts.ContextHook) error { ++ if c.Err == nil && strings.Contains(c.SQL, "INSERT INTO") && strings.Contains(c.SQL, "login_source") && !strings.Contains(c.SQL, "login_source_operation") { ++ // Process death after the real INSERT, before actual ApplySourceOperation's ++ // post-read/journal update/commit. No artificial operation is substituted. ++ os.Exit(73) ++ } ++ return nil ++} ++func TestSourceOperationProcessRecovery(t *testing.T) { ++ if step := os.Getenv("SOURCE_OPERATION_CHILD_STEP"); step != "" { ++ e, err := xorm.NewEngine("sqlite3", os.Getenv("SOURCE_OPERATION_CHILD_DB")+"?_txlock=deferred") ++ require.NoError(t, err) ++ e.SetMapper(names.GonicMapper{}) ++ db.SetDefaultEngine(context.Background(), e) ++ require.NoError(t, db.SyncAllTables()) ++ auth.RegisterTypeConfig(auth.OAuth2, new(oauth2.Source)) ++ ctx := db.DefaultContext ++ switch step { ++ case "prepare": ++ _, err = auth.PrepareSourceOperation(ctx, "operation-process-recovery", "case-process-recovery", 0, operationSource()) ++ require.NoError(t, err) ++ case "crash": ++ e.AddHook(operationCrashAfterSourceInsert{}) ++ _, err = auth.ApplySourceOperation(ctx, "operation-process-recovery", "case-process-recovery") ++ require.NoError(t, err) ++ t.Fatal("real insert did not reach crash hook") ++ case "recover-prepared": ++ r, err := auth.ReconcileSourceOperation(ctx, "operation-process-recovery", "case-process-recovery") ++ require.NoError(t, err) ++ require.Equal(t, "prepared", r.Phase) ++ n, err := db.GetEngine(ctx).Table("login_source").Count() ++ require.NoError(t, err) ++ require.Zero(t, n) ++ _, err = auth.ApplySourceOperation(ctx, "operation-process-recovery", "case-process-recovery") ++ require.NoError(t, err) ++ case "recover-applied": ++ r, err := auth.ReconcileSourceOperation(ctx, "operation-process-recovery", "case-process-recovery") ++ require.NoError(t, err) ++ require.Equal(t, "applied", r.Phase) ++ _, err = auth.InverseSourceOperation(ctx, "operation-process-recovery", "case-process-recovery") ++ require.NoError(t, err) ++ case "recover-inverse": ++ r, err := auth.ReconcileSourceOperation(ctx, "operation-process-recovery", "case-process-recovery") ++ require.NoError(t, err) ++ require.Equal(t, "reverted", r.Phase) ++ n, err := db.GetEngine(ctx).Table("login_source").Count() ++ require.NoError(t, err) ++ require.Zero(t, n) ++ default: ++ t.Fatal("unknown synthetic child step") ++ } ++ require.NoError(t, e.Close()) ++ return ++ } ++ exe, err := os.Executable() ++ require.NoError(t, err) ++ path := filepath.Join(t.TempDir(), "owner-private.sqlite") ++ for _, step := range []string{"prepare", "crash", "recover-prepared", "recover-applied", "recover-inverse"} { ++ child := exec.Command(exe, "-test.run=^TestSourceOperationProcessRecovery$", "-test.v") ++ child.Env = append(os.Environ(), "SOURCE_OPERATION_CHILD_STEP="+step, "SOURCE_OPERATION_CHILD_DB="+path) ++ output, err := child.CombinedOutput() ++ if step == "crash" { ++ exit, ok := err.(*exec.ExitError) ++ require.True(t, ok, string(output)) ++ require.Equal(t, 73, exit.ExitCode(), string(output)) ++ } else { ++ require.NoError(t, err, string(output)) ++ } ++ } ++} +--- /dev/null ++++ b/models/auth/source_operation_test.go +@@ -0,0 +1,235 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++package auth_test ++ ++import ( ++ "context" ++ "encoding/json" ++ "fmt" ++ "testing" ++ ++ auth "forgejo.org/models/auth" ++ "forgejo.org/models/db" ++ "forgejo.org/models/unittest" ++ "forgejo.org/modules/setting" ++ oauth2 "forgejo.org/services/auth/source/oauth2" ++ ++ "github.com/stretchr/testify/require" ++) ++ ++func operationDB(t *testing.T) context.Context { ++ t.Helper() ++ require.NoError(t, unittest.PrepareTestDatabase()) ++ _, err := db.GetEngine(db.DefaultContext).Exec("DELETE FROM login_source_operation") ++ require.NoError(t, err) ++ auth.RegisterTypeConfig(auth.OAuth2, new(oauth2.Source)) ++ return db.DefaultContext ++} ++func operationSource() *auth.Source { ++ return &auth.Source{Type: auth.OAuth2, Name: "operation-test", IsActive: false, Cfg: &oauth2.Source{Provider: "openidConnect", ClientID: "synthetic-client", ClientSecret: "synthetic-private-original", RequiredClaimName: "groups", RequiredClaimValues: []string{"g1", "g2", "g3"}, Scopes: []string{"openid", "profile", "groups"}}} ++} ++func operationRows(t *testing.T, ctx context.Context) string { ++ t.Helper() ++ rows, err := db.GetEngine(ctx).Query("SELECT * FROM login_source ORDER BY id") ++ require.NoError(t, err) ++ b, err := json.Marshal(rows) ++ require.NoError(t, err) ++ return string(b) ++} ++func TestSourceOperationCreateAndInverse(t *testing.T) { ++ ctx := operationDB(t) ++ before := operationRows(t, ctx) ++ r, err := auth.PrepareSourceOperation(ctx, "operation-create", "case-create", 0, operationSource()) ++ require.NoError(t, err) ++ require.Equal(t, "prepared", r.Phase) ++ require.Equal(t, before, operationRows(t, ctx)) ++ r, err = auth.ApplySourceOperation(ctx, "operation-create", "case-create") ++ require.NoError(t, err) ++ require.Equal(t, "applied", r.Phase) ++ require.Positive(t, r.SourceID) ++ id := r.SourceID ++ post := operationRows(t, ctx) ++ r, err = auth.ApplySourceOperation(ctx, "operation-create", "case-create") ++ require.NoError(t, err) ++ require.Equal(t, id, r.SourceID) ++ require.Equal(t, post, operationRows(t, ctx)) ++ _, err = auth.InverseSourceOperation(ctx, "operation-create", "wrong-case") ++ require.ErrorIs(t, err, auth.ErrSourceOperationConflict) ++ r, err = auth.InverseSourceOperation(ctx, "operation-create", "case-create") ++ require.NoError(t, err) ++ require.Equal(t, "reverted", r.Phase) ++ require.Equal(t, before, operationRows(t, ctx)) ++ _, err = auth.InverseSourceOperation(ctx, "operation-create", "case-create") ++ require.NoError(t, err) ++ _, err = auth.ApplySourceOperation(ctx, "operation-create", "case-create") ++ require.ErrorIs(t, err, auth.ErrSourceOperationConflict) ++} ++func TestSourceOperationRestoresExactRawPreexisting(t *testing.T) { ++ ctx := operationDB(t) ++ s := operationSource() ++ require.NoError(t, auth.CreateSource(ctx, s)) ++ // The old/unknown field must survive restoration byte for byte, including the ++ // private synthetic secret. A typed OAuth2 decoder would silently lose it. ++ raw := `{"Provider":"openidConnect","ClientID":"synthetic-client","ClientSecret":"synthetic-private-original","FutureGuard":{"x":true}}` ++ _, err := db.GetEngine(ctx).Exec("UPDATE login_source SET cfg = ? WHERE id = ?", raw, s.ID) ++ require.NoError(t, err) ++ before := operationRows(t, ctx) ++ s.Cfg.(*oauth2.Source).ClientSecret = "synthetic-private-new" ++ _, err = auth.PrepareSourceOperation(ctx, "operation-restore", "case-restore", s.ID, s) ++ require.NoError(t, err) ++ _, err = auth.ApplySourceOperation(ctx, "operation-restore", "case-restore") ++ require.NoError(t, err) ++ _, err = auth.InverseSourceOperation(ctx, "operation-restore", "case-restore") ++ require.NoError(t, err) ++ require.Equal(t, before, operationRows(t, ctx)) ++ source, err := auth.GetSourceByID(ctx, s.ID) ++ require.NoError(t, err) ++ require.Equal(t, s.ID, source.ID) ++} ++func TestSourceOperationUnchangedNeverDeletesPreexisting(t *testing.T) { ++ ctx := operationDB(t) ++ s := operationSource() ++ require.NoError(t, auth.CreateSource(ctx, s)) ++ before := operationRows(t, ctx) ++ _, err := auth.PrepareSourceOperation(ctx, "operation-unchanged", "case-unchanged", s.ID, s) ++ require.NoError(t, err) ++ r, err := auth.ApplySourceOperation(ctx, "operation-unchanged", "case-unchanged") ++ require.NoError(t, err) ++ require.Equal(t, "unchanged", r.Phase) ++ _, err = auth.InverseSourceOperation(ctx, "operation-unchanged", "case-unchanged") ++ require.NoError(t, err) ++ require.Equal(t, before, operationRows(t, ctx)) ++} ++func TestSourceOperationPrivateDriftRefuses(t *testing.T) { ++ for _, stage := range []string{"before-apply", "before-inverse"} { ++ t.Run(stage, func(t *testing.T) { ++ ctx := operationDB(t) ++ s := operationSource() ++ require.NoError(t, auth.CreateSource(ctx, s)) ++ _, err := auth.PrepareSourceOperation(ctx, "operation-private-drift", "case-private-drift", s.ID, s) ++ require.NoError(t, err) ++ if stage == "before-inverse" { ++ _, err = auth.ApplySourceOperation(ctx, "operation-private-drift", "case-private-drift") ++ require.NoError(t, err) ++ } ++ s.Cfg.(*oauth2.Source).ClientSecret = "synthetic-private-drift" ++ require.NoError(t, auth.UpdateSource(ctx, s)) ++ drift := operationRows(t, ctx) ++ if stage == "before-apply" { ++ _, err = auth.ApplySourceOperation(ctx, "operation-private-drift", "case-private-drift") ++ } else { ++ _, err = auth.InverseSourceOperation(ctx, "operation-private-drift", "case-private-drift") ++ } ++ require.ErrorIs(t, err, auth.ErrSourceOperationConflict) ++ require.NotContains(t, err.Error(), "synthetic-private") ++ require.Equal(t, drift, operationRows(t, ctx)) ++ }) ++ } ++} ++func TestSourceOperationStockCRUDDriftRefuses(t *testing.T) { ++ ctx := operationDB(t) ++ _, err := auth.PrepareSourceOperation(ctx, "operation-other-writer", "case-other-writer", 0, operationSource()) ++ require.NoError(t, err) ++ other := operationSource() ++ other.Name = "unrelated-source" ++ require.NoError(t, auth.CreateSource(ctx, other)) ++ drift := operationRows(t, ctx) ++ _, err = auth.ApplySourceOperation(ctx, "operation-other-writer", "case-other-writer") ++ require.ErrorIs(t, err, auth.ErrSourceOperationConflict) ++ require.Equal(t, drift, operationRows(t, ctx)) ++} ++func TestSourceOperationLinkedSourceRefusesDeletion(t *testing.T) { ++ for _, kind := range []string{"user", "external"} { ++ t.Run(kind, func(t *testing.T) { ++ ctx := operationDB(t) ++ _, err := auth.PrepareSourceOperation(ctx, "operation-linked", "case-linked", 0, operationSource()) ++ require.NoError(t, err) ++ r, err := auth.ApplySourceOperation(ctx, "operation-linked", "case-linked") ++ require.NoError(t, err) ++ if kind == "user" { ++ _, err = db.GetEngine(ctx).Exec("UPDATE `user` SET login_source = ? WHERE id = 1", r.SourceID) ++ } else { ++ _, err = db.GetEngine(ctx).Exec("INSERT INTO external_login_user (external_id, user_id, login_source_id) VALUES (?, ?, ?)", "synthetic-external", 1, r.SourceID) ++ } ++ require.NoError(t, err) ++ before := operationRows(t, ctx) ++ _, err = auth.InverseSourceOperation(ctx, "operation-linked", "case-linked") ++ require.True(t, auth.IsErrSourceInUse(err)) ++ require.Equal(t, before, operationRows(t, ctx)) ++ if kind == "user" { ++ rows, e := db.GetEngine(ctx).Query("SELECT login_source FROM `user` WHERE id = 1") ++ require.NoError(t, e) ++ require.NotEqual(t, []byte("0"), rows[0]["login_source"]) ++ } else { ++ n, e := db.GetEngine(ctx).Table("external_login_user").Count() ++ require.NoError(t, e) ++ require.EqualValues(t, 1, n) ++ } ++ }) ++ } ++} ++func TestSourceOperationPreparedCancellationAndIDReuse(t *testing.T) { ++ ctx := operationDB(t) ++ before := operationRows(t, ctx) ++ _, err := auth.PrepareSourceOperation(ctx, "operation-cancelled", "case-cancelled", 0, operationSource()) ++ require.NoError(t, err) ++ _, err = auth.PrepareSourceOperation(ctx, "operation-cancelled", "case-cancelled", 0, operationSource()) ++ require.ErrorIs(t, err, auth.ErrSourceOperationConflict) ++ r, err := auth.InverseSourceOperation(ctx, "operation-cancelled", "case-cancelled") ++ require.NoError(t, err) ++ require.Equal(t, "cancelled", r.Phase) ++ require.Equal(t, before, operationRows(t, ctx)) ++} ++func TestSourceOperationRejectsUnsafeRuntime(t *testing.T) { ++ ctx := operationDB(t) ++ setting.Database.LogSQL = true ++ t.Cleanup(func() { setting.Database.LogSQL = false }) ++ _, err := auth.PrepareSourceOperation(ctx, "operation-no-logging", "case-no-logging", 0, operationSource()) ++ require.ErrorIs(t, err, auth.ErrSourceOperationUnsupported) ++ setting.Database.LogSQL = false ++ err = db.WithTx(ctx, func(tx context.Context) error { ++ _, err := auth.PrepareSourceOperation(tx, "operation-nested", "case-nested", 0, operationSource()) ++ return err ++ }) ++ require.ErrorIs(t, err, auth.ErrSourceOperationUnsupported) ++} ++func TestSourceOperationRefusesSharedManagedSource(t *testing.T) { ++ ctx := operationDB(t) ++ _, err := auth.PrepareSourceOperation(ctx, "operation-first-case", "case-first-case", 0, operationSource()) ++ require.NoError(t, err) ++ r, err := auth.ApplySourceOperation(ctx, "operation-first-case", "case-first-case") ++ require.NoError(t, err) ++ shared := operationSource() ++ shared.ID = r.SourceID ++ _, err = auth.PrepareSourceOperation(ctx, "operation-second-case", "case-second-case", r.SourceID, shared) ++ require.ErrorIs(t, err, auth.ErrSourceOperationConflict) ++} ++func TestSourceOperationBoundsPrivateJournalCustody(t *testing.T) { ++ ctx := operationDB(t) ++ // A full unresolved-journal budget refuses new preparation, without deleting ++ // any intent or replacing private recovery data with an expiry assumption. ++ for i := 0; i < 64; i++ { ++ _, err := db.GetEngine(ctx).Exec("INSERT INTO login_source_operation (operation_id, case_id, target_name, phase) VALUES (?, ?, ?, ?)", fmt.Sprintf("bounded-operation-%03d", i), "bounded-case", fmt.Sprintf("bounded-target-%03d", i), "prepared") ++ require.NoError(t, err) ++ } ++ _, err := auth.PrepareSourceOperation(ctx, "operation-budget-full", "case-budget-full", 0, operationSource()) ++ require.ErrorIs(t, err, auth.ErrSourceOperationConflict) ++ n, err := db.GetEngine(ctx).Table("login_source_operation").Count() ++ require.NoError(t, err) ++ require.EqualValues(t, 64, n) ++} ++func TestSourceOperationRequiresExactPostState(t *testing.T) { ++ ctx := operationDB(t) ++ before := operationRows(t, ctx) ++ _, err := auth.PrepareSourceOperation(ctx, "operation-post-exact", "case-post-exact", 0, operationSource()) ++ require.NoError(t, err) ++ _, err = db.GetEngine(ctx).Exec("CREATE TRIGGER source_operation_test_drift AFTER INSERT ON login_source BEGIN UPDATE login_source SET name = 'unexpected-rewrite' WHERE id = NEW.id; END") ++ require.NoError(t, err) ++ defer db.GetEngine(ctx).Exec("DROP TRIGGER source_operation_test_drift") ++ _, err = auth.ApplySourceOperation(ctx, "operation-post-exact", "case-post-exact") ++ require.ErrorIs(t, err, auth.ErrSourceOperationConflict) ++ require.Equal(t, before, operationRows(t, ctx)) ++ r, err := auth.ReconcileSourceOperation(ctx, "operation-post-exact", "case-post-exact") ++ require.NoError(t, err) ++ require.Equal(t, "prepared", r.Phase) ++} +--- a/models/db/context.go ++++ b/models/db/context.go +@@ -72,6 +72,12 @@ + // GetEngine will get a db Engine from this context or return an Engine restricted to this context + func GetEngine(ctx context.Context) Engine { + if e := getEngine(ctx); e != nil { ++ // A private-query marker must reach XORM's real query hooks, including ++ // inside an existing engine/transaction context. Wrapping the Go ++ // context alone does not change XORM's stored session context. ++ if isSensitiveSQL(ctx) { ++ return e.Context(ctx) ++ } + return e + } + return x.Context(ctx) +--- a/models/db/engine.go ++++ b/models/db/engine.go +@@ -330,8 +330,12 @@ + func (TracingHook) BeforeProcess(c *contexts.ContextHook) (context.Context, error) { + ctx, task := trace.NewTask(c.Ctx, "sql") + ctx = context.WithValue(ctx, sqlTask{}, task) +- trace.Log(ctx, "query", c.SQL) +- trace.Logf(ctx, "args", "%v", c.Args) ++ if isSensitiveSQL(c.Ctx) { ++ trace.Log(ctx, "query", "[owner-confined query]") ++ } else { ++ trace.Log(ctx, "query", c.SQL) ++ trace.Logf(ctx, "args", "%v", c.Args) ++ } + return ctx, nil + } + +@@ -353,7 +357,11 @@ + + func (h *SlowQueryHook) AfterProcess(c *contexts.ContextHook) error { + if c.ExecuteTime >= h.Treshold { +- h.Logger.Log(8, log.WARN, "[Slow SQL Query] %s %v - %v", c.SQL, c.Args, c.ExecuteTime) ++ if isSensitiveSQL(c.Ctx) { ++ h.Logger.Log(8, log.WARN, "[Slow SQL Query] [owner-confined query] - %v", c.ExecuteTime) ++ } else { ++ h.Logger.Log(8, log.WARN, "[Slow SQL Query] %s %v - %v", c.SQL, c.Args, c.ExecuteTime) ++ } + } + return nil + } +@@ -370,7 +378,11 @@ + + func (h *ErrorQueryHook) AfterProcess(c *contexts.ContextHook) error { + if c.Err != nil && !errors.Is(c.Err, context.Canceled) { +- h.Logger.Log(8, log.ERROR, "[Error SQL Query] %s %v - %v", c.SQL, c.Args, c.Err) +- } +- return nil +-} ++ if isSensitiveSQL(c.Ctx) { ++ h.Logger.Log(8, log.ERROR, "[Error SQL Query] [owner-confined query]") ++ } else { ++ h.Logger.Log(8, log.ERROR, "[Error SQL Query] %s %v - %v", c.SQL, c.Args, c.Err) ++ } ++ } ++ return nil ++} +--- /dev/null ++++ b/models/db/sensitive_sql.go +@@ -0,0 +1,47 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++package db ++ ++import ( ++ "context" ++ "forgejo.org/modules/setting" ++ "xorm.io/xorm" ++) ++ ++type sensitiveSQLKey struct{} ++ ++// WithSensitiveSQL suppresses argument, statement and driver-error disclosure ++// in the application's query hooks. The caller must separately refuse raw XORM ++// SQL logging; custom driver/server auditing remains an owner custody concern. ++func WithSensitiveSQL(ctx context.Context) context.Context { ++ return context.WithValue(ctx, sensitiveSQLKey{}, true) ++} ++func isSensitiveSQL(ctx context.Context) bool { ++ sensitive, _ := ctx.Value(sensitiveSQLKey{}).(bool) ++ return sensitive ++} ++ ++// SQLLoggingEnabled checks the underlying engine without changing the context or ++// assuming that a sensitive context still exposes an Engine rather than Session. ++// Unknown/uninitialized backends refuse, and query redaction never permits raw ++// XORM logging. It neither creates a transaction nor closes an existing one. ++func SQLLoggingEnabled(ctx context.Context) bool { ++ if setting.Database.LogSQL { ++ return true ++ } ++ engine := getEngine(ctx) ++ if engine == nil { ++ if x == nil { ++ return true ++ } ++ engine = x ++ } ++ switch current := engine.(type) { ++ case *xorm.Engine: ++ return current == nil || current.Logger() == nil || current.Logger().IsShowSQL() ++ case *xorm.Session: ++ return current == nil || current.Engine() == nil || current.Engine().Logger() == nil || current.Engine().Logger().IsShowSQL() ++ default: ++ return true ++ } ++} +--- /dev/null ++++ b/models/db/sensitive_sql_logging_test.go +@@ -0,0 +1,41 @@ ++package db ++ ++import ( ++ "context" ++ "testing" ++ ++ "forgejo.org/modules/setting" ++ "xorm.io/xorm" ++) ++ ++func TestSensitiveSQLLoggingChecksUnderlyingEngine(t *testing.T) { ++ engine, err := xorm.NewEngine("sqlite3", "file::memory:?cache=shared") ++ if err != nil { ++ t.Fatal(err) ++ } ++ defer engine.Close() ++ engine.SetLogger(NewXORMLogger(false)) ++ session := engine.NewSession() ++ defer session.Close() ++ previous := setting.Database.LogSQL ++ defer func() { setting.Database.LogSQL = previous }() ++ for name, backend := range map[string]Engine{"engine": engine, "session": session} { ++ t.Run(name, func(t *testing.T) { ++ ctx := WithSensitiveSQL(newContext(context.Background(), backend, false)) ++ setting.Database.LogSQL = false ++ engine.ShowSQL(false) ++ if SQLLoggingEnabled(ctx) { ++ t.Fatal("private context with disabled actual SQL logger refused") ++ } ++ engine.ShowSQL(true) ++ if !SQLLoggingEnabled(ctx) { ++ t.Fatal("actual raw SQL logger escaped refusal") ++ } ++ engine.ShowSQL(false) ++ setting.Database.LogSQL = true ++ if !SQLLoggingEnabled(ctx) { ++ t.Fatal("configured raw SQL logger escaped refusal") ++ } ++ }) ++ } ++} +--- /dev/null ++++ b/models/db/sensitive_sql_test.go +@@ -0,0 +1,103 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++package db ++ ++import ( ++ "bytes" ++ "context" ++ "errors" ++ "fmt" ++ "runtime/trace" ++ "strings" ++ "testing" ++ "time" ++ ++ "forgejo.org/modules/log" ++ "xorm.io/xorm" ++ "xorm.io/xorm/contexts" ++) ++ ++type sensitiveSQLLog struct { ++ log.Logger ++ entries []string ++} ++ ++func (l *sensitiveSQLLog) Log(_ int, _ log.Level, f string, v ...any) { ++ l.entries = append(l.entries, fmt.Sprintf(f, v...)) ++} ++func TestSensitiveSQLHooks(t *testing.T) { ++ const secret = "synthetic-query-private-sentinel" ++ for _, sensitive := range []bool{false, true} { ++ ctx := context.Background() ++ if sensitive { ++ ctx = WithSensitiveSQL(ctx) ++ } ++ h := &contexts.ContextHook{Ctx: ctx, SQL: "SELECT '" + secret + "'", Args: []any{secret}, Err: errors.New(secret), ExecuteTime: time.Second} ++ logger := new(sensitiveSQLLog) ++ if err := (&SlowQueryHook{Logger: logger}).AfterProcess(h); err != nil { ++ t.Fatal(err) ++ } ++ if err := (&ErrorQueryHook{Logger: logger}).AfterProcess(h); err != nil { ++ t.Fatal(err) ++ } ++ found := strings.Contains(strings.Join(logger.entries, "\n"), secret) ++ if found == sensitive { ++ t.Fatal("query hook disclosure/redaction mismatch") ++ } ++ var out bytes.Buffer ++ if err := trace.Start(&out); err != nil { ++ t.Fatal(err) ++ } ++ next, err := (TracingHook{}).BeforeProcess(h) ++ if err != nil { ++ trace.Stop() ++ t.Fatal(err) ++ } ++ h.Ctx = next ++ err = (TracingHook{}).AfterProcess(h) ++ trace.Stop() ++ if err != nil { ++ t.Fatal(err) ++ } ++ if bytes.Contains(out.Bytes(), []byte(secret)) == sensitive { ++ t.Fatal("trace disclosure/redaction mismatch") ++ } ++ } ++} ++ ++func TestSensitiveSQLActualContextPropagation(t *testing.T) { ++ engine, ok := GetEngine(DefaultContext).(*xorm.Engine) ++ if !ok { ++ t.Fatal("missing actual fixture engine") ++ } ++ logger := new(sensitiveSQLLog) ++ engine.AddHook(&SlowQueryHook{Logger: logger}) ++ engine.AddHook(&ErrorQueryHook{Logger: logger}) ++ engine.AddHook(TracingHook{}) ++ const secret = "synthetic-actual-xorm-private-sentinel" ++ for _, sensitive := range []bool{false, true} { ++ logger.entries = nil ++ ctx := context.Context(DefaultContext) ++ if sensitive { ++ ctx = WithSensitiveSQL(ctx) ++ } ++ var out bytes.Buffer ++ if err := trace.Start(&out); err != nil { ++ t.Fatal(err) ++ } ++ _, err := GetEngine(ctx).Exec("SELECT missing_synthetic_function(?)", secret) ++ trace.Stop() ++ if err == nil { ++ t.Fatal("expected actual SQLite function refusal") ++ } ++ if len(logger.entries) < 2 { ++ t.Fatal("actual query hooks did not execute") ++ } ++ if strings.Contains(strings.Join(logger.entries, "\n"), secret) == sensitive { ++ t.Fatal("actual XORM query context did not preserve redaction") ++ } ++ if bytes.Contains(out.Bytes(), []byte(secret)) == sensitive { ++ t.Fatal("actual XORM trace context did not preserve redaction") ++ } ++ } ++} +--- a/routers/web/admin/auths.go ++++ b/routers/web/admin/auths.go +@@ -322,6 +322,13 @@ + ctx.Data["Source"] = source + ctx.Data["HasTLS"] = source.HasTLS() + ++ // The legacy form has no representation for the opt-in current-UserInfo gate. ++ // Refuse rather than silently dropping it during source reconstruction. ++ if current, ok := source.Cfg.(*oauth2.Source); ok && current.RequiredClaimValues != nil { ++ ctx.RenderWithErr("This OAuth source uses required-claim-values; update it through the reviewed CLI.", tplAuthEdit, form) ++ return ++ } ++ + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplAuthEdit) + return +--- a/routers/web/auth/oauth.go ++++ b/routers/web/auth/oauth.go +@@ -1427,7 +1427,13 @@ + return goth.User{}, err + } + +- if oauth2Source.RequiredClaimName != "" { ++ if oauth2Source.RequiredClaimValues != nil { ++ // The exact callback has already authenticated and enforced the current ++ // Access Engine application.access decision. Group policy lives there. ++ if oauth2Source.Provider != "openidConnect" { ++ return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} ++ } ++ } else if oauth2Source.RequiredClaimName != "" { + claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName] + if !has { + return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} +--- /dev/null ++++ b/services/auth/source/oauth2/access_engine.go +@@ -0,0 +1,224 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++package oauth2 ++ ++import ( ++ "context" ++ "crypto" ++ "crypto/x509" ++ "encoding/json" ++ "encoding/pem" ++ "errors" ++ "io" ++ "os" ++ "path/filepath" ++ "strings" ++ "time" ++ ++ "github.com/golang-jwt/jwt/v5" ++ "github.com/markbates/goth" ++ "github.com/netkingdom/access-engine/pkg/api" ++ "github.com/netkingdom/access-engine/pkg/decisionpep" ++) ++ ++var errApplicationDecision = errors.New("current authenticated application access refused") ++ ++// AccessEngineAdmission contains owner-admitted public trust, with only a file ++// reference to the private PDP caller token. It is not persisted OAuth source ++// configuration and cannot be replaced by token claims or provider discovery. ++type AccessEngineAdmission struct { ++ SourceName string `json:"source_name"` ++ Issuer string `json:"issuer"` ++ Audience string `json:"audience"` ++ TokenType string `json:"token_type"` ++ Algorithms []string `json:"algorithms"` ++ IdentityKeys map[string]string `json:"identity_keys"` ++ TenantClaim string `json:"tenant_claim"` ++ PrincipalTypeClaim string `json:"principal_type_claim"` ++ AssuranceClaim string `json:"assurance_claim"` ++ TenantSourceClaim string `json:"tenant_source_claim"` ++ MaxAuthenticationAge string `json:"max_authentication_age"` ++ MaxIdentityAge string `json:"max_identity_age"` ++ Decision decisionpep.Admission `json:"decision"` ++} ++ ++// VerifiedApplicationIdentity is derived only by VerifyApplicationIdentity from ++// independently admitted JOSE keys. RawData and UserInfo never supply these ++// fields. This snapshot is an identity fact, never an authorization verdict. ++type VerifiedApplicationIdentity struct { ++ Issuer string ++ Subject string ++ Tenant string ++ PrincipalType api.SubjectType ++ Assurance string ++ TenantSource string ++ AssuranceData map[string]any ++ AuthenticatedAt time.Time ++ NotAfter time.Time ++} ++ ++func loadAccessEngineAdmission(path string) (AccessEngineAdmission, error) { ++ var a AccessEngineAdmission ++ if !filepath.IsAbs(path) { ++ return a, errApplicationDecision ++ } ++ f, e := os.Open(path) ++ if e != nil { ++ return a, errApplicationDecision ++ } ++ defer f.Close() ++ stat, e := f.Stat() ++ if e != nil || !stat.Mode().IsRegular() || stat.Mode().Perm()&0022 != 0 { ++ return a, errApplicationDecision ++ } ++ raw, e := io.ReadAll(io.LimitReader(f, 1<<20+1)) ++ if e != nil || len(raw) > 1<<20 { ++ return a, errApplicationDecision ++ } ++ d := json.NewDecoder(strings.NewReader(string(raw))) ++ d.DisallowUnknownFields() ++ if d.Decode(&a) != nil { ++ return a, errApplicationDecision ++ } ++ if _, e = d.Token(); e != io.EOF { ++ return a, errApplicationDecision ++ } ++ return a, nil ++} ++ ++// VerifyApplicationIdentity validates the ID token independently of Goth's ++// decode/claim checks and of the UserInfo merge. Keys and accepted algorithm, ++// issuer, audience and type are admitted separately; none come from the JWT URL. ++func VerifyApplicationIdentity(raw string, a AccessEngineAdmission, now time.Time) (VerifiedApplicationIdentity, error) { ++ var out VerifiedApplicationIdentity ++ age, e := time.ParseDuration(a.MaxIdentityAge) ++ authAge, authErr := time.ParseDuration(a.MaxAuthenticationAge) ++ if e != nil || authErr != nil || authAge <= 0 || age <= 0 || a.TenantSourceClaim == "" || a.Issuer == "" || a.Audience == "" || a.TokenType == "" || len(a.Algorithms) == 0 || len(a.IdentityKeys) == 0 || a.TenantClaim == "" || a.PrincipalTypeClaim == "" || a.AssuranceClaim == "" { ++ return out, errApplicationDecision ++ } ++ for _, alg := range a.Algorithms { ++ if alg != "RS256" && alg != "RS384" && alg != "RS512" && alg != "ES256" && alg != "ES384" && alg != "ES512" && alg != "EdDSA" { ++ return out, errApplicationDecision ++ } ++ } ++ claims := jwt.MapClaims{} ++ token, e := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) { ++ if typ, ok := t.Header["typ"].(string); !ok || typ != a.TokenType { ++ return nil, errApplicationDecision ++ } ++ kid, ok := t.Header["kid"].(string) ++ if !ok || kid == "" { ++ return nil, errApplicationDecision ++ } ++ text, ok := a.IdentityKeys[kid] ++ if !ok { ++ return nil, errApplicationDecision ++ } ++ block, rest := pem.Decode([]byte(text)) ++ if block == nil || block.Type != "PUBLIC KEY" || len(strings.TrimSpace(string(rest))) != 0 { ++ return nil, errApplicationDecision ++ } ++ key, e := x509.ParsePKIXPublicKey(block.Bytes) ++ if e != nil { ++ return nil, errApplicationDecision ++ } ++ var pub crypto.PublicKey = key ++ return pub, nil ++ }, jwt.WithValidMethods(a.Algorithms), jwt.WithIssuer(a.Issuer), jwt.WithAudience(a.Audience), jwt.WithExpirationRequired(), jwt.WithIssuedAt(), jwt.WithJSONNumber(), jwt.WithTimeFunc(func() time.Time { return now })) ++ if e != nil || token == nil || !token.Valid { ++ return out, errApplicationDecision ++ } ++ subject, e := claims.GetSubject() ++ if e != nil || subject == "" { ++ return out, errApplicationDecision ++ } ++ issued, e := claims.GetIssuedAt() ++ if e != nil || issued == nil || issued.Time.After(now) || now.Sub(issued.Time) > age { ++ return out, errApplicationDecision ++ } ++ end, e := claims.GetExpirationTime() ++ if e != nil || end == nil || !now.Before(end.Time) { ++ return out, errApplicationDecision ++ } ++ aud, e := claims.GetAudience() ++ if e != nil { ++ return out, errApplicationDecision ++ } ++ if authorizedParty, present := claims["azp"]; present { ++ if azp, ok := authorizedParty.(string); !ok || azp != a.Audience { ++ return out, errApplicationDecision ++ } ++ } else if len(aud) > 1 { ++ return out, errApplicationDecision ++ } ++ value := func(name string) (string, bool) { v, ok := claims[name].(string); return v, ok && validClaimLiteral(v) } ++ tenant, ok := value(a.TenantClaim) ++ if !ok { ++ return out, errApplicationDecision ++ } ++ principal, ok := value(a.PrincipalTypeClaim) ++ if !ok { ++ return out, errApplicationDecision ++ } ++ tenantSource, ok := value(a.TenantSourceClaim) ++ if !ok || (tenantSource != "directory" && tenantSource != "registration" && tenantSource != "default") { ++ return out, errApplicationDecision ++ } ++ assurance, assuranceData, authenticatedAt, authenticationEnd, err := verifiedKeyCapeAssurance(claims[a.AssuranceClaim], issued.Time, now, authAge) ++ if err != nil { ++ return out, errApplicationDecision ++ } ++ validUntil := end.Time ++ for _, bound := range []time.Time{issued.Time.Add(age), authenticationEnd} { ++ if bound.Before(validUntil) { ++ validUntil = bound ++ } ++ } ++ if !now.Before(validUntil) { ++ return out, errApplicationDecision ++ } ++ return VerifiedApplicationIdentity{Issuer: a.Issuer, Subject: subject, Tenant: tenant, PrincipalType: api.SubjectType(principal), Assurance: assurance, TenantSource: tenantSource, AssuranceData: assuranceData, AuthenticatedAt: authenticatedAt, NotAfter: validUntil}, nil ++} ++ ++func (source *Source) authorizeAccessEngine(ctx context.Context, user goth.User) error { ++ // The guarded target extension cannot silently fall back to a local group ++ // decision when admission, keys, facts, PDP or caller authentication is absent. ++ if source.Provider != "openidConnect" || ValidateRequiredClaimValues(source.RequiredClaimName, source.RequiredClaimValue, source.RequiredClaimValues) != nil { ++ return errApplicationDecision ++ } ++ a, e := loadAccessEngineAdmission(os.Getenv("FORGEJO_ACCESS_ENGINE_ADMISSION_FILE")) ++ if e != nil || source.authSource == nil || a.SourceName != source.authSource.Name || a.Audience != source.ClientID { ++ return errApplicationDecision ++ } ++ identity, e := VerifyApplicationIdentity(user.IDToken, a, time.Now().UTC()) ++ if e != nil { ++ return errApplicationDecision ++ } ++ // RequireCurrentUserInfoClaim has refused missing/malformed current groups ++ // before Goth can merge. The validated ID subject must still match that join. ++ sub, ok := user.RawData["sub"].(string) ++ if !ok || sub != identity.Subject || user.UserID != identity.Subject { ++ return errApplicationDecision ++ } ++ groups, ok := strictClaimValues(user.RawData[source.RequiredClaimName]) ++ if !ok { ++ return errApplicationDecision ++ } ++ client, e := decisionpep.New(a.Decision) ++ if e != nil { ++ return errApplicationDecision ++ } ++ request := api.CheckRequest{Tenant: identity.Tenant, ++ Subject: api.SubjectRef{ID: identity.Subject, Type: identity.PrincipalType, Tenant: identity.Tenant, ++ Attributes: map[string]any{"issuer": identity.Issuer, "assurance": identity.Assurance, "current_userinfo_groups": groups}}, ++ Action: "application.access", Resource: api.ResourceRef{ID: "application:forgejo", Type: "application", System: "forgejo", Tenant: identity.Tenant}, ++ Context: map[string]any{"identity_issuer": identity.Issuer, "identity_subject": identity.Subject, "identity_tenant": identity.Tenant, "identity_principal_type": string(identity.PrincipalType), "identity_assurance": identity.Assurance, "identity_assurance_claim": identity.AssuranceData, "identity_authenticated_at": identity.AuthenticatedAt.UTC().Format(time.RFC3339Nano), "identity_tenant_source": identity.TenantSource, "identity_not_after": identity.NotAfter.UTC().Format(time.RFC3339Nano), "current_userinfo_groups": groups, "required_groups_any_of": source.RequiredClaimValues, "source_name": a.SourceName, "source_id": source.authSource.ID}} ++ if _, e = client.Check(ctx, request); e != nil { ++ return errApplicationDecision ++ } ++ if !time.Now().UTC().Before(identity.NotAfter) { ++ return errApplicationDecision ++ } ++ return nil ++} +--- /dev/null ++++ b/services/auth/source/oauth2/access_engine_assurance.go +@@ -0,0 +1,54 @@ ++package oauth2 ++ ++import ( ++ "encoding/json" ++ "time" ++) ++ ++// Validate the actual IAM v0.3 object only after issuer JOSE verification. These ++// are identity facts, not an access verdict: aal1/service facts still reach the ++// PDP, which owns the application's assurance and principal policy. Preserve the ++// full object and its integer timestamp in the signed decision request. ++func verifiedKeyCapeAssurance(raw any, issued, now time.Time, maxAge time.Duration) (string, map[string]any, time.Time, time.Time, error) { ++ refuse := func() (string, map[string]any, time.Time, time.Time, error) { ++ return "", nil, time.Time{}, time.Time{}, errApplicationDecision ++ } ++ claim, ok := raw.(map[string]any) ++ if !ok || len(claim) != 5 { ++ return refuse() ++ } ++ level, ok := claim["level"].(string) ++ if !ok || (level != "aal1" && level != "aal2" && level != "service") { ++ return refuse() ++ } ++ mfa, ok := claim["mfa"].(bool) ++ if !ok || mfa != (level == "aal2") || claim["source"] != "key-cape" { ++ return refuse() ++ } ++ methods, ok := claim["methods"].([]any) ++ if !ok || len(methods) == 0 { ++ return refuse() ++ } ++ seen := map[string]bool{} ++ for _, method := range methods { ++ value, ok := method.(string) ++ if !ok || !validClaimLiteral(value) || seen[value] { ++ return refuse() ++ } ++ seen[value] = true ++ } ++ number, ok := claim["at"].(json.Number) ++ if !ok { ++ return refuse() ++ } ++ seconds, err := number.Int64() ++ if err != nil || seconds <= 0 || seconds > issued.Unix() || seconds > now.Unix() { ++ return refuse() ++ } ++ authenticated := time.Unix(seconds, 0).UTC() ++ end := authenticated.Add(maxAge) ++ if maxAge <= 0 || !now.Before(end) { ++ return refuse() ++ } ++ return level, claim, authenticated, end, nil ++} +--- /dev/null ++++ b/services/auth/source/oauth2/access_engine_assurance_test.go +@@ -0,0 +1,75 @@ ++package oauth2 ++ ++import ( ++ "encoding/json" ++ "testing" ++ "time" ++) ++ ++func TestAccessEngineKeyCapeAssuranceRefusals(t *testing.T) { ++ cases := map[string]func(map[string]any, time.Time){ ++ "stale-authentication": func(c map[string]any, now time.Time) { c["at"] = now.Add(-10 * time.Minute).Unix() }, ++ "future-authentication": func(c map[string]any, now time.Time) { c["at"] = now.Add(time.Minute).Unix() }, ++ "missing-at": func(c map[string]any, _ time.Time) { delete(c, "at") }, ++ "string-at": func(c map[string]any, _ time.Time) { c["at"] = "recent" }, ++ "fractional-at": func(c map[string]any, now time.Time) { c["at"] = float64(now.Unix()) - 1.5 }, ++ "integer-mfa": func(c map[string]any, _ time.Time) { c["mfa"] = 1 }, ++ "inconsistent-mfa": func(c map[string]any, _ time.Time) { c["mfa"] = false }, ++ "wrong-source": func(c map[string]any, _ time.Time) { c["source"] = "userinfo" }, ++ "wrong-level": func(c map[string]any, _ time.Time) { c["level"] = "verified" }, ++ "object-methods": func(c map[string]any, _ time.Time) { c["methods"] = map[string]any{"pwd": true} }, ++ "empty-methods": func(c map[string]any, _ time.Time) { c["methods"] = []string{} }, ++ "mixed-methods": func(c map[string]any, _ time.Time) { c["methods"] = []any{"pwd", 1} }, ++ "duplicate-methods": func(c map[string]any, _ time.Time) { c["methods"] = []string{"pwd", "pwd"} }, ++ "unknown-object-field": func(c map[string]any, _ time.Time) { c["verified"] = true }, ++ } ++ for name, mutate := range cases { ++ t.Run(name, func(t *testing.T) { ++ admission, key, claims, now := identityFixture(t) ++ mutate(claims["assurance"].(map[string]any), now) ++ if _, err := VerifyApplicationIdentity(signedIdentity(t, key, claims, nil), admission, now); err == nil { ++ t.Fatal("malformed or stale issuer assurance accepted") ++ } ++ }) ++ } ++ for _, source := range []string{"", "assumed"} { ++ admission, key, claims, now := identityFixture(t) ++ claims["tenant_source"] = source ++ if _, err := VerifyApplicationIdentity(signedIdentity(t, key, claims, nil), admission, now); err == nil { ++ t.Fatal("missing or unknown tenant provenance accepted") ++ } ++ } ++} ++ ++func TestAccessEngineKeyCapeIdentityFactsAndFreshnessBounds(t *testing.T) { ++ admission, key, claims, now := identityFixture(t) ++ admission.MaxAuthenticationAge = "5s" ++ claims["assurance"].(map[string]any)["at"] = now.Add(-3 * time.Second).Unix() ++ identity, err := VerifyApplicationIdentity(signedIdentity(t, key, claims, nil), admission, now) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if !identity.NotAfter.Equal(now.Add(2*time.Second)) || !identity.AuthenticatedAt.Equal(now.Add(-3*time.Second)) || identity.TenantSource != "directory" { ++ t.Fatal("snapshot overstated authentication freshness or tenant provenance") ++ } ++ if _, ok := identity.AssuranceData["at"].(json.Number); !ok || identity.AssuranceData["mfa"] != true || identity.AssuranceData["source"] != "key-cape" { ++ t.Fatal("source assurance object lost its actual wire types") ++ } ++ admission.MaxIdentityAge = "2s" ++ identity, err = VerifyApplicationIdentity(signedIdentity(t, key, claims, nil), admission, now) ++ if err != nil || !identity.NotAfter.Equal(now.Add(time.Second)) { ++ t.Fatal("snapshot overstated token-age validity", err) ++ } ++} ++ ++func TestAccessEngineKeyCapeLowerAssuranceIsAnIdentityFact(t *testing.T) { ++ admission, key, claims, now := identityFixture(t) ++ claim := claims["assurance"].(map[string]any) ++ claim["level"] = "aal1" ++ claim["mfa"] = false ++ claim["methods"] = []string{"pwd"} ++ identity, err := VerifyApplicationIdentity(signedIdentity(t, key, claims, nil), admission, now) ++ if err != nil || identity.Assurance != "aal1" || identity.AssuranceData["mfa"] != false { ++ t.Fatal("identity parser substituted an application authorization policy", err) ++ } ++} +--- /dev/null ++++ b/services/auth/source/oauth2/access_engine_azp_test.go +@@ -0,0 +1,34 @@ ++package oauth2 ++ ++import "testing" ++ ++func TestAccessEngineAuthorizedPartyRefusesAnotherClient(t *testing.T) { ++ for name, value := range map[string]any{ ++ "other-client": "other-client", ++ "number": 17, ++ "array": []string{"forgejo"}, ++ "empty": "", ++ } { ++ t.Run(name, func(t *testing.T) { ++ admission, key, claims, now := identityFixture(t) ++ claims["azp"] = value ++ token := signedIdentity(t, key, claims, nil) ++ if _, err := VerifyApplicationIdentity(token, admission, now); err == nil { ++ t.Fatal("a valid signature and audience concealed an invalid authorized party") ++ } ++ }) ++ } ++} ++ ++func TestAccessEngineAuthorizedPartyAcceptsAdmittedClient(t *testing.T) { ++ for _, multiple := range []bool{false, true} { ++ admission, key, claims, now := identityFixture(t) ++ claims["azp"] = admission.Audience ++ if multiple { ++ claims["aud"] = []string{admission.Audience, "another-resource"} ++ } ++ if _, err := VerifyApplicationIdentity(signedIdentity(t, key, claims, nil), admission, now); err != nil { ++ t.Fatal("admitted authorized party refused", err) ++ } ++ } ++} +--- /dev/null ++++ b/services/auth/source/oauth2/access_engine_callback_test.go +@@ -0,0 +1,194 @@ ++package oauth2 ++ ++import ( ++ "crypto/ed25519" ++ "crypto/rand" ++ "encoding/base64" ++ "encoding/json" ++ "encoding/pem" ++ "net/http" ++ "net/http/httptest" ++ "os" ++ "path/filepath" ++ "reflect" ++ "strings" ++ "sync/atomic" ++ "testing" ++ "time" ++ ++ authmodel "forgejo.org/models/auth" ++ "github.com/golang-jwt/jwt/v5" ++ "github.com/markbates/goth" ++ "github.com/markbates/goth/gothic" ++ "github.com/netkingdom/access-engine/pkg/api" ++ "github.com/netkingdom/access-engine/pkg/decisionpep" ++ "github.com/netkingdom/access-engine/testsupport/signfixture" ++) ++ ++// This fixture substitutes only Goth's completed authentication result. The ++// actual Source.Callback, JOSE verifier, HTTPS PEP client and producer signature ++// implementation execute. Actual issuer login and native admission are separate. ++func callbackFixture(t *testing.T, group, mode string) (*Source, *goth.User, *atomic.Int64) { ++ return callbackFixtureWithIdentity(t, group, mode, nil, "") ++} ++ ++func callbackFixtureWithIdentity(t *testing.T, group, mode string, suppliedAdmission *AccessEngineAdmission, suppliedToken string) (*Source, *goth.User, *atomic.Int64) { ++ t.Helper() ++ admission, identityKey, claims, _ := identityFixture(t) ++ identityToken := signedIdentity(t, identityKey, claims, nil) ++ if suppliedAdmission != nil { ++ admission, identityToken = *suppliedAdmission, suppliedToken ++ } ++ verified, err := VerifyApplicationIdentity(identityToken, admission, time.Now().UTC()) ++ if err != nil { ++ t.Fatal("callback fixture identity invalid") ++ } ++ pub, key, err := ed25519.GenerateKey(rand.Reader) ++ if err != nil { ++ t.Fatal(err) ++ } ++ calls := new(atomic.Int64) ++ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ++ calls.Add(1) ++ if r.URL.Path != "/v1/check" || r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer synthetic-caller" || r.Header.Get("Access-Engine-JSON-Profile") != decisionpep.JSONProfile { ++ t.Error("incorrect authenticated PDP request") ++ w.WriteHeader(403) ++ return ++ } ++ var request api.CheckRequest ++ decoder := json.NewDecoder(r.Body) ++ decoder.UseNumber() ++ if err := decoder.Decode(&request); err != nil { ++ t.Error(err) ++ w.WriteHeader(400) ++ return ++ } ++ c := request.Context ++ if request.Subject.ID != "user:synthetic" || request.Subject.Tenant != "tenant:synthetic" || request.Subject.Type != "human" || request.Action != "application.access" || request.Resource.ID != "application:forgejo" || c["identity_issuer"] != admission.Issuer || c["identity_assurance"] != "aal2" || c["identity_tenant"] != "tenant:synthetic" || c["source_id"] != json.Number("17") || !reflect.DeepEqual(c["identity_assurance_claim"], verified.AssuranceData) || c["identity_authenticated_at"] != verified.AuthenticatedAt.UTC().Format(time.RFC3339Nano) || c["identity_tenant_source"] != verified.TenantSource { ++ t.Error("PDP request did not use authenticated identity and source") ++ w.WriteHeader(400) ++ return ++ } ++ now := time.Now().UTC() ++ envelope := api.DecisionEnvelope{ID: "synthetic-decision", RequestID: request.ID, Effect: api.DecisionEffectAllow, MatchedPolicyVersion: "v1", Subject: request.Subject, Resource: request.Resource, Binding: api.NewDecisionBindingFor(request, request), Provenance: api.DecisionProvenance{Evaluator: "flex-auth/local", Mode: "standalone/" + decisionpep.JSONProfile, PolicyPackage: "forgejo.application-access", PolicyVersion: "v1", PolicyPackageDigest: "sha256:" + strings.Repeat("1", 64), RegistrySnapshotDigest: "sha256:" + strings.Repeat("2", 64), Caller: &api.CallerProvenance{Mode: "enforce", Principal: "system:serviceaccount:synthetic:forgejo", Audience: "access-engine", NotAfter: now.Add(time.Minute).Format(time.RFC3339Nano)}}} ++ api.CompleteDecision(&envelope, request, api.DecisionCompletion{AllowTTL: "10s", Now: now}) ++ switch mode { ++ case "deny": ++ envelope.Effect = api.DecisionEffectDeny ++ case "wrong-binding": ++ envelope.Binding.Subject.ID = "other" ++ case "wrong-pin": ++ envelope.Provenance.PolicyPackageDigest = "sha256:" + strings.Repeat("3", 64) ++ case "expired": ++ envelope.Lifetime.ExpiresAt = now.Add(-time.Hour).Format(time.RFC3339Nano) ++ } ++ if mode != "unsigned" { ++ signingKey := key ++ if mode == "wrong-key" { ++ _, signingKey, err = ed25519.GenerateKey(rand.Reader) ++ if err != nil { ++ t.Error(err) ++ return ++ } ++ } ++ if err := signfixture.Sign(&envelope, signingKey, "synthetic-pdp"); err != nil { ++ t.Error(err) ++ return ++ } ++ } ++ w.Header().Set("Content-Type", "application/json") ++ if err := json.NewEncoder(w).Encode(envelope); err != nil { ++ t.Error(err) ++ } ++ })) ++ t.Cleanup(server.Close) ++ dir := t.TempDir() ++ ca := filepath.Join(dir, "ca.pem") ++ token := filepath.Join(dir, "caller") ++ config := filepath.Join(dir, "admission.json") ++ if err := os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0600); err != nil { ++ t.Fatal(err) ++ } ++ if err := os.WriteFile(token, []byte("synthetic-caller"), 0600); err != nil { ++ t.Fatal(err) ++ } ++ admission.SourceName = "synthetic-source" ++ admission.Decision = decisionpep.Admission{Endpoint: server.URL + "/v1/check", CAFile: ca, TokenFile: token, Keys: map[string]string{"synthetic-pdp": base64.RawURLEncoding.EncodeToString(pub)}, Evaluator: "flex-auth/local", PolicyPackage: "forgejo.application-access", PolicyVersion: "v1", PolicyDigest: "sha256:" + strings.Repeat("1", 64), RegistryDigest: "sha256:" + strings.Repeat("2", 64), CallerPrincipal: "system:serviceaccount:synthetic:forgejo", CallerAudience: "access-engine", MaxTTL: "30s", MaxClockSkew: "2s"} ++ raw, err := json.Marshal(admission) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if err = os.WriteFile(config, raw, 0600); err != nil { ++ t.Fatal(err) ++ } ++ t.Setenv("FORGEJO_ACCESS_ENGINE_ADMISSION_FILE", config) ++ source := &Source{Provider: "openidConnect", ClientID: admission.Audience, RequiredClaimName: "groups", RequiredClaimValues: []string{"forge-admins", "forge-coulomb-owners", "forge-coulomb-readers"}} ++ source.authSource = &authmodel.Source{ID: 17, Name: admission.SourceName} ++ user := &goth.User{UserID: "user:synthetic", IDToken: identityToken, RawData: map[string]any{"sub": "user:synthetic", "groups": []string{group}}} ++ previous := gothic.CompleteUserAuth ++ gothic.CompleteUserAuth = func(http.ResponseWriter, *http.Request) (goth.User, error) { return *user, nil } ++ t.Cleanup(func() { gothic.CompleteUserAuth = previous }) ++ return source, user, calls ++} ++ ++func TestAccessEngineActualCallbackAllowsAuthenticatedDecision(t *testing.T) { ++ for _, group := range []string{"forge-admins", "forge-coulomb-owners", "forge-coulomb-readers"} { ++ t.Run(group, func(t *testing.T) { ++ source, user, calls := callbackFixture(t, group, "allow") ++ // Mutable UserInfo cannot supply identity assurance, issuer or tenant. ++ user.RawData["acr"] = "aal1" ++ user.RawData["assurance"] = map[string]any{"level": "aal1", "source": "attacker"} ++ user.RawData["tenant_source"] = "attacker" ++ user.RawData["tenant"] = "tenant:attacker" ++ user.RawData["iss"] = "https://attacker.invalid" ++ got, err := source.Callback(httptest.NewRequest(http.MethodGet, "https://forgejo.invalid/callback", nil), httptest.NewRecorder(), "") ++ if err != nil || got.UserID != user.UserID || calls.Load() != 1 { ++ t.Fatalf("callback result: err=%v requests=%d", err, calls.Load()) ++ } ++ }) ++ } ++} ++ ++func TestAccessEngineActualCallbackRefusesUnverifiedPDP(t *testing.T) { ++ for _, mode := range []string{"deny", "unsigned", "wrong-key", "wrong-pin", "wrong-binding", "expired"} { ++ t.Run(mode, func(t *testing.T) { ++ source, _, calls := callbackFixture(t, "forge-admins", mode) ++ got, err := source.Callback(httptest.NewRequest(http.MethodGet, "https://forgejo.invalid/callback", nil), httptest.NewRecorder(), "") ++ if err == nil || got.UserID != "" || got.IDToken != "" || calls.Load() != 1 { ++ t.Fatalf("unverified decision escaped callback: requests=%d err=%v", calls.Load(), err) ++ } ++ }) ++ } ++} ++ ++func TestAccessEngineActualCallbackChecksIdentityBeforePDP(t *testing.T) { ++ cases := map[string]func(*Source, *goth.User){ ++ "missing-token": func(_ *Source, u *goth.User) { u.IDToken = "" }, ++ "mismatched-user": func(_ *Source, u *goth.User) { u.UserID = "other" }, ++ "mismatched-subject": func(_ *Source, u *goth.User) { u.RawData["sub"] = "other" }, ++ "missing-groups": func(_ *Source, u *goth.User) { delete(u.RawData, "groups") }, ++ "wrong-source": func(s *Source, _ *goth.User) { s.authSource.Name = "other" }, ++ "wrong-provider": func(s *Source, _ *goth.User) { s.Provider = "other" }, ++ "wrong-audience": func(s *Source, _ *goth.User) { s.ClientID = "other" }, ++ "missing-admission": func(_ *Source, _ *goth.User) { t.Setenv("FORGEJO_ACCESS_ENGINE_ADMISSION_FILE", "") }, ++ "expired-token": func(_ *Source, u *goth.User) { ++ _, key, claims, now := identityFixture(t) ++ claims["exp"] = now.Add(-time.Hour).Unix() ++ u.IDToken = signedIdentity(t, key, claims, nil) ++ }, ++ "untrusted-identity": func(_ *Source, u *goth.User) { ++ token := jwt.NewWithClaims(jwt.SigningMethodNone, jwt.MapClaims{"sub": "user:synthetic"}) ++ u.IDToken, _ = token.SignedString(jwt.UnsafeAllowNoneSignatureType) ++ }, ++ } ++ for name, mutate := range cases { ++ t.Run(name, func(t *testing.T) { ++ source, user, calls := callbackFixture(t, "forge-admins", "allow") ++ mutate(source, user) ++ got, err := source.Callback(httptest.NewRequest(http.MethodGet, "https://forgejo.invalid/callback", nil), httptest.NewRecorder(), "") ++ if err == nil || got.UserID != "" || calls.Load() != 0 { ++ t.Fatalf("unverified identity reached PDP: requests=%d err=%v", calls.Load(), err) ++ } ++ }) ++ } ++} +--- /dev/null ++++ b/services/auth/source/oauth2/access_engine_test.go +@@ -0,0 +1,100 @@ ++package oauth2 ++ ++import ( ++ "crypto/ed25519" ++ "crypto/rand" ++ "crypto/x509" ++ "encoding/pem" ++ "testing" ++ "time" ++ ++ "github.com/golang-jwt/jwt/v5" ++) ++ ++func identityFixture(t *testing.T) (AccessEngineAdmission, ed25519.PrivateKey, jwt.MapClaims, time.Time) { ++ t.Helper() ++ pub, key, e := ed25519.GenerateKey(rand.Reader) ++ if e != nil { ++ t.Fatal(e) ++ } ++ der, e := x509.MarshalPKIXPublicKey(pub) ++ if e != nil { ++ t.Fatal(e) ++ } ++ now := time.Now().UTC().Truncate(time.Second) ++ a := AccessEngineAdmission{Issuer: "https://issuer.invalid", Audience: "forgejo", TokenType: "JWT", Algorithms: []string{"EdDSA"}, IdentityKeys: map[string]string{"synthetic": string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}))}, TenantClaim: "tenant", PrincipalTypeClaim: "principal_type", AssuranceClaim: "assurance", TenantSourceClaim: "tenant_source", MaxAuthenticationAge: "5m", MaxIdentityAge: "5m"} ++ c := jwt.MapClaims{"iss": a.Issuer, "aud": a.Audience, "sub": "user:synthetic", "iat": now.Add(-time.Second).Unix(), "exp": now.Add(time.Minute).Unix(), "tenant": "tenant:synthetic", "principal_type": "human", "tenant_source": "directory", "assurance": map[string]any{"level": "aal2", "mfa": true, "source": "key-cape", "methods": []string{"pwd", "otp"}, "at": now.Add(-time.Second).Unix()}} ++ return a, key, c, now ++} ++func signedIdentity(t *testing.T, key ed25519.PrivateKey, c jwt.MapClaims, mutate func(*jwt.Token)) string { ++ t.Helper() ++ token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c) ++ token.Header["kid"] = "synthetic" ++ token.Header["typ"] = "JWT" ++ if mutate != nil { ++ mutate(token) ++ } ++ raw, e := token.SignedString(key) ++ if e != nil { ++ t.Fatal(e) ++ } ++ return raw ++} ++func TestVerifiedApplicationIdentityActualJOSE(t *testing.T) { ++ a, key, c, now := identityFixture(t) ++ raw := signedIdentity(t, key, c, nil) ++ got, e := VerifyApplicationIdentity(raw, a, now) ++ if e != nil { ++ t.Fatal(e) ++ } ++ if got.Subject != "user:synthetic" || got.Tenant != "tenant:synthetic" || got.Assurance != "aal2" || got.Issuer != a.Issuer || string(got.PrincipalType) != "human" { ++ t.Fatal("identity did not come from verified token") ++ } ++ cases := map[string]func(jwt.MapClaims){"issuer": func(c jwt.MapClaims) { c["iss"] = "https://other.invalid" }, "audience": func(c jwt.MapClaims) { c["aud"] = "other" }, "expired": func(c jwt.MapClaims) { c["exp"] = now.Add(-time.Nanosecond).Unix() }, "future-issued": func(c jwt.MapClaims) { c["iat"] = now.Add(time.Minute).Unix() }, "old-issued": func(c jwt.MapClaims) { c["iat"] = now.Add(-10 * time.Minute).Unix() }, "no-exp": func(c jwt.MapClaims) { delete(c, "exp") }, "no-iat": func(c jwt.MapClaims) { delete(c, "iat") }, "no-tenant": func(c jwt.MapClaims) { delete(c, "tenant") }, "no-principal": func(c jwt.MapClaims) { delete(c, "principal_type") }, "no-assurance": func(c jwt.MapClaims) { delete(c, "assurance") }, "multi-audience-no-azp": func(c jwt.MapClaims) { c["aud"] = []string{"forgejo", "other"} }, "empty-subject": func(c jwt.MapClaims) { c["sub"] = "" }} ++ for name, change := range cases { ++ t.Run(name, func(t *testing.T) { ++ copy := jwt.MapClaims{} ++ for k, v := range c { ++ copy[k] = v ++ } ++ change(copy) ++ if _, e := VerifyApplicationIdentity(signedIdentity(t, key, copy, nil), a, now); e == nil { ++ t.Fatal("unverified identity accepted") ++ } ++ }) ++ } ++ t.Run("wrong-key", func(t *testing.T) { ++ _, other, _ := ed25519.GenerateKey(rand.Reader) ++ if _, e := VerifyApplicationIdentity(signedIdentity(t, other, c, nil), a, now); e == nil { ++ t.Fatal("wrong key accepted") ++ } ++ }) ++ t.Run("unknown-key", func(t *testing.T) { ++ if _, e := VerifyApplicationIdentity(signedIdentity(t, key, c, func(t *jwt.Token) { t.Header["kid"] = "other" }), a, now); e == nil { ++ t.Fatal("unknown key accepted") ++ } ++ }) ++ t.Run("wrong-type", func(t *testing.T) { ++ if _, e := VerifyApplicationIdentity(signedIdentity(t, key, c, func(t *jwt.Token) { t.Header["typ"] = "at+jwt" }), a, now); e == nil { ++ t.Fatal("access token accepted as ID token") ++ } ++ }) ++ t.Run("algorithm-confusion", func(t *testing.T) { ++ token := jwt.NewWithClaims(jwt.SigningMethodHS256, c) ++ token.Header["kid"] = "synthetic" ++ token.Header["typ"] = "JWT" ++ raw, _ := token.SignedString([]byte(a.IdentityKeys["synthetic"])) ++ if _, e := VerifyApplicationIdentity(raw, a, now); e == nil { ++ t.Fatal("algorithm confusion accepted") ++ } ++ }) ++ t.Run("none", func(t *testing.T) { ++ token := jwt.NewWithClaims(jwt.SigningMethodNone, c) ++ token.Header["kid"] = "synthetic" ++ token.Header["typ"] = "JWT" ++ raw, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType) ++ if _, e := VerifyApplicationIdentity(raw, a, now); e == nil { ++ t.Fatal("unsigned identity accepted") ++ } ++ }) ++} +--- a/services/auth/source/oauth2/providers_openid.go ++++ b/services/auth/source/oauth2/providers_openid.go +@@ -4,6 +4,7 @@ + package oauth2 + + import ( ++ "errors" + "html/template" + + "forgejo.org/modules/log" +@@ -43,6 +44,22 @@ + if err != nil { + log.Warn("Failed to create OpenID Connect Provider with name '%s' with url '%s': %v", providerName, source.OpenIDConnectAutoDiscoveryURL, err) + } ++ if err != nil { ++ return nil, err ++ } ++ if source.RequiredClaimValues != nil { ++ if err := ValidateRequiredClaimValues(source.RequiredClaimName, source.RequiredClaimValue, source.RequiredClaimValues); err != nil { ++ return nil, err ++ } ++ if provider.OpenIDConfig == nil || provider.SkipUserInfoRequest { ++ return nil, errors.New("required-claim-values needs current UserInfo") ++ } ++ guarded, err := RequireCurrentUserInfoClaim(provider.Client(), provider.OpenIDConfig.UserInfoEndpoint, source.RequiredClaimName) ++ if err != nil { ++ return nil, err ++ } ++ provider.HTTPClient = guarded ++ } + return provider, err + } + +--- /dev/null ++++ b/services/auth/source/oauth2/required_claim_values.go +@@ -0,0 +1,202 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++package oauth2 ++ ++import ( ++ "bytes" ++ "encoding/json" ++ "errors" ++ "io" ++ "net/http" ++ "net/url" ++ "strings" ++ "unicode" ++ "unicode/utf8" ++) ++ ++const maxClaimValues = 1024 ++const maxUserInfoBytes = 1 << 20 ++ ++// ValidateRequiredClaimValues validates only the opt-in list extension. A nil ++// list retains every legacy single-value behavior. An explicitly empty list ++// fails closed rather than turning a configured gate into claim-presence-only. ++func ValidateRequiredClaimValues(name, legacy string, values []string) error { ++ if values == nil { ++ return nil ++ } ++ if !validClaimLiteral(name) || legacy != "" || len(values) == 0 || len(values) > 64 { ++ return errors.New("invalid or conflicting required-claim-values configuration") ++ } ++ seen := map[string]bool{} ++ for _, value := range values { ++ if !validClaimLiteral(value) || seen[value] { ++ return errors.New("invalid required-claim-values literal") ++ } ++ seen[value] = true ++ } ++ return nil ++} ++ ++func validClaimLiteral(value string) bool { ++ return value != "" && len(value) <= 256 && utf8.ValidString(value) && ++ value == strings.TrimSpace(value) && !strings.ContainsAny(value, "\x00\r\n,") && ++ !strings.ContainsFunc(value, func(r rune) bool { return unicode.IsControl(r) || r == utf8.RuneError }) ++} ++ ++// strictClaimValues accepts only a well-formed claim array. No scalar parsing, ++// coercion, regex or prefix matching is introduced into the new gate. ++func strictClaimValues(raw any) ([]string, bool) { ++ var values []string ++ switch v := raw.(type) { ++ case []string: ++ values = v ++ case []any: ++ values = make([]string, len(v)) ++ for i, value := range v { ++ s, ok := value.(string) ++ if !ok { ++ return nil, false ++ } ++ values[i] = s ++ } ++ default: ++ return nil, false ++ } ++ if len(values) > maxClaimValues { ++ return nil, false ++ } ++ for _, value := range values { ++ if !validClaimLiteral(value) { ++ return nil, false ++ } ++ } ++ return values, true ++} ++ ++// MatchRequiredClaimValues evaluates the new consumer gate against current ++// authenticated provider data; it does not mutate or manufacture issuer claims. ++func MatchRequiredClaimValues(claims map[string]any, name, legacy string, required []string) bool { ++ if required == nil || ValidateRequiredClaimValues(name, legacy, required) != nil { ++ return false ++ } ++ raw, present := claims[name] ++ if !present { ++ return false ++ } ++ values, ok := strictClaimValues(raw) ++ if !ok { ++ return false ++ } ++ for _, value := range values { ++ for _, wanted := range required { ++ if value == wanted { ++ return true ++ } ++ } ++ } ++ return false ++} ++ ++// RequireCurrentUserInfoClaim preserves the existing HTTP client settings and ++// adds a strict response guard for this opt-in source only. A current UserInfo ++// array must overwrite any stale ID-token claim. Missing/invalid data and HTTP ++// failures cannot fall back to the ID-token map in Goth's merging code. ++func RequireCurrentUserInfoClaim(client *http.Client, endpoint, claim string) (*http.Client, error) { ++ u, err := url.Parse(endpoint) ++ if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" || !validClaimLiteral(claim) { ++ return nil, errors.New("required-claim-values needs an HTTPS UserInfo endpoint") ++ } ++ if client == nil { ++ client = http.DefaultClient ++ } ++ result := *client ++ base := result.Transport ++ if base == nil { ++ base = http.DefaultTransport ++ } ++ result.Transport = ¤tClaimTransport{base: base, endpoint: endpoint, claim: claim} ++ // Redirects could evade endpoint matching or send a bearer to another origin. ++ // They are unsupported for the new guarded provider; legacy clients are intact. ++ result.CheckRedirect = func(*http.Request, []*http.Request) error { ++ return errors.New("guarded OAuth redirects are not supported") ++ } ++ return &result, nil ++} ++ ++type currentClaimTransport struct { ++ base http.RoundTripper ++ endpoint, claim string ++} ++ ++func (t *currentClaimTransport) RoundTrip(req *http.Request) (*http.Response, error) { ++ response, err := t.base.RoundTrip(req) ++ if req.URL.String() != t.endpoint { ++ return response, err ++ } ++ if err != nil { ++ if response != nil && response.Body != nil { ++ response.Body.Close() ++ } ++ return nil, errors.New("current UserInfo lookup failed") ++ } ++ if response == nil || response.Body == nil { ++ return nil, errors.New("current UserInfo response missing") ++ } ++ defer response.Body.Close() ++ if req.Method != http.MethodGet || response.StatusCode != http.StatusOK { ++ return nil, errors.New("current UserInfo refused") ++ } ++ body, err := io.ReadAll(io.LimitReader(response.Body, maxUserInfoBytes+1)) ++ if err != nil || len(body) > maxUserInfoBytes || !utf8.Valid(body) { ++ return nil, errors.New("current UserInfo response invalid") ++ } ++ // Reject duplicate top-level keys before Unmarshal can hide a conflicting ++ // groups or subject claim. json.RawMessage permits unrelated normal claims. ++ decoder := json.NewDecoder(bytes.NewReader(body)) ++ token, err := decoder.Token() ++ if err != nil || token != json.Delim('{') { ++ return nil, errors.New("current UserInfo object required") ++ } ++ claims := map[string]json.RawMessage{} ++ for decoder.More() { ++ keyToken, err := decoder.Token() ++ if err != nil { ++ return nil, errors.New("current UserInfo key invalid") ++ } ++ key, ok := keyToken.(string) ++ if !ok { ++ return nil, errors.New("current UserInfo key invalid") ++ } ++ if _, duplicate := claims[key]; duplicate { ++ return nil, errors.New("current UserInfo duplicate claim") ++ } ++ var value json.RawMessage ++ if decoder.Decode(&value) != nil { ++ return nil, errors.New("current UserInfo value invalid") ++ } ++ claims[key] = value ++ } ++ if token, err = decoder.Token(); err != nil || token != json.Delim('}') { ++ return nil, errors.New("current UserInfo object invalid") ++ } ++ var trailing any ++ if decoder.Decode(&trailing) != io.EOF { ++ return nil, errors.New("current UserInfo trailing data") ++ } ++ raw, present := claims[t.claim] ++ if !present { ++ return nil, errors.New("current UserInfo required claim missing") ++ } ++ var value any ++ if json.Unmarshal(raw, &value) != nil { ++ return nil, errors.New("current UserInfo required claim invalid") ++ } ++ if _, ok := strictClaimValues(value); !ok { ++ return nil, errors.New("current UserInfo required claim array invalid") ++ } ++ // Return the original public response bytes to Goth. Subject equality and ++ // audience/issuer checks remain in its existing authenticated provider path. ++ response.Body = io.NopCloser(bytes.NewReader(body)) ++ return response, nil ++} +--- /dev/null ++++ b/services/auth/source/oauth2/required_claim_values_test.go +@@ -0,0 +1,144 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++package oauth2 ++ ++import ( ++ "errors" ++ "io" ++ "net/http" ++ "reflect" ++ "strings" ++ "testing" ++) ++ ++var caseValues = []string{"forge-admins", "forge-coulomb-owners", "forge-coulomb-readers"} ++ ++type roundTripFunc func(*http.Request) (*http.Response, error) ++ ++func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } ++ ++func mockUserInfo(body string, status int) *http.Client { ++ return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { ++ return &http.Response{StatusCode: status, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil ++ })} ++} ++ ++func TestRequiredClaimExactAnyOfAndNoMutation(t *testing.T) { ++ for _, group := range caseValues { ++ groups := []any{group, "unrelated"} ++ claims := map[string]any{"groups": groups, "other": "preserved"} ++ if !MatchRequiredClaimValues(claims, "groups", "", caseValues) { ++ t.Fatal("individual allowed group rejected") ++ } ++ if !reflect.DeepEqual(claims, map[string]any{"groups": groups, "other": "preserved"}) { ++ t.Fatal("claims mutated") ++ } ++ } ++ if !MatchRequiredClaimValues(map[string]any{"groups": append([]string(nil), caseValues...)}, "groups", "", caseValues) { ++ t.Fatal("multiple allowed groups rejected") ++ } ++ for _, raw := range []any{nil, true, 1, "forge-admins", "forge-admins,forge-coulomb-owners", []any{"forge-admins", 1}, []any{"forge-admins", nil}, []string{}, []any{}, []string{"forge-admins-lookalike"}, []string{" Forge-admins"}, []string{"forge-admins\n"}, []string{"Forge-admins"}, []string{"forge-admins,other"}, []string{"forge-admins", ""}} { ++ if MatchRequiredClaimValues(map[string]any{"groups": raw}, "groups", "", caseValues) { ++ t.Fatal("malformed/nonmatching claim accepted") ++ } ++ } ++ if MatchRequiredClaimValues(map[string]any{}, "groups", "", caseValues) { ++ t.Fatal("absent claim accepted") ++ } ++ if MatchRequiredClaimValues(map[string]any{"unrelated": []string{"forge-admins"}}, "groups", "", caseValues) { ++ t.Fatal("unrelated claim used") ++ } ++} ++ ++func TestRequiredClaimConfigurationRefusalsAndLegacyIsolation(t *testing.T) { ++ if ValidateRequiredClaimValues("", "old arbitrary literal", nil) != nil { ++ t.Fatal("legacy config changed") ++ } ++ for _, cfg := range []struct { ++ name, legacy string ++ values []string ++ }{ ++ {"groups", "", []string{}}, {"groups", "", []string{""}}, {"groups", "old", caseValues}, {"", "", caseValues}, ++ {"groups", "", []string{"a", "a"}}, {"groups", "", []string{"a,b"}}, {"groups", "", []string{" a"}}, ++ } { ++ if ValidateRequiredClaimValues(cfg.name, cfg.legacy, cfg.values) == nil { ++ t.Fatal("invalid config accepted") ++ } ++ } ++ if ValidateRequiredClaimValues("groups", "", caseValues) != nil { ++ t.Fatal("case config invalid") ++ } ++} ++ ++func TestCurrentUserInfoGuardRejectsMissingMalformedAndUnavailable(t *testing.T) { ++ for _, body := range []string{ ++ `{}`, `{"sub":"alice"}`, `{"groups":null}`, `{"groups":true}`, `{"groups":"forge-admins"}`, ++ `{"groups":["forge-admins",null]}`, `{"groups":["forge-admins",1]}`, ++ `{"groups":["forge-admins"],"groups":[]}`, `{"groups":[]} {}`, `[]`, `{"groups":[]`, ++ `{"groups":["forge-admins",""]}`, strings.Repeat(" ", maxUserInfoBytes+1), ++ } { ++ client, err := RequireCurrentUserInfoClaim(mockUserInfo(body, 200), "https://issuer.example/userinfo", "groups") ++ if err != nil { ++ t.Fatal(err) ++ } ++ response, err := client.Get("https://issuer.example/userinfo") ++ if err == nil { ++ response.Body.Close() ++ t.Fatal("invalid current claim did not fail closed") ++ } ++ } ++ client, _ := RequireCurrentUserInfoClaim(mockUserInfo(`{"groups":[]}`, 503), "https://issuer.example/userinfo", "groups") ++ if response, err := client.Get("https://issuer.example/userinfo"); err == nil { ++ response.Body.Close() ++ t.Fatal("non200 accepted") ++ } ++ broken := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("private backend error") })} ++ client, _ = RequireCurrentUserInfoClaim(broken, "https://issuer.example/userinfo", "groups") ++ if _, err := client.Get("https://issuer.example/userinfo"); err == nil || strings.Contains(err.Error(), "private backend") { ++ t.Fatal("lookup failure not contained") ++ } ++} ++ ++func TestCurrentUserInfoEmptyArrayOverwritesAndGroupRemoval(t *testing.T) { ++ for _, body := range []string{`{"sub":"alice","groups":[]}`, `{"sub":"alice","groups":["forge-coulomb-readers"]}`} { ++ client, err := RequireCurrentUserInfoClaim(mockUserInfo(body, 200), "https://issuer.example/userinfo", "groups") ++ if err != nil { ++ t.Fatal(err) ++ } ++ response, err := client.Get("https://issuer.example/userinfo") ++ if err != nil { ++ t.Fatal(err) ++ } ++ b, _ := io.ReadAll(response.Body) ++ response.Body.Close() ++ if string(b) != body { ++ t.Fatal("response changed") ++ } ++ } ++} ++ ++func TestCurrentUserInfoGuardIsOptInAndRedirectsRefuse(t *testing.T) { ++ original := mockUserInfo(`{"unrelated":true}`, 200) ++ guarded, err := RequireCurrentUserInfoClaim(original, "https://issuer.example/userinfo", "groups") ++ if err != nil { ++ t.Fatal(err) ++ } ++ response, err := original.Get("https://issuer.example/userinfo") ++ if err != nil { ++ t.Fatal("original client mutated") ++ } ++ response.Body.Close() ++ response, err = guarded.Get("https://issuer.example/unrelated") ++ if err != nil { ++ t.Fatal("unrelated endpoint changed") ++ } ++ response.Body.Close() ++ if guarded.CheckRedirect == nil || guarded.CheckRedirect(nil, nil) == nil { ++ t.Fatal("redirect can bypass guard") ++ } ++ for _, endpoint := range []string{"", "http://issuer.example/userinfo", "https://user:pass@issuer.example/userinfo", "https://issuer.example/userinfo#fragment"} { ++ if _, err := RequireCurrentUserInfoClaim(original, endpoint, "groups"); err == nil { ++ t.Fatal("invalid endpoint accepted") ++ } ++ } ++} +--- a/services/auth/source/oauth2/source.go ++++ b/services/auth/source/oauth2/source.go +@@ -22,6 +22,7 @@ + Scopes []string + AttributeSSHPublicKey string + RequiredClaimName string ++ RequiredClaimValues []string `json:",omitempty"` + RequiredClaimValue string + GroupClaimName string + AdminGroup string +--- a/services/auth/source/oauth2/source_callout.go ++++ b/services/auth/source/oauth2/source_callout.go +@@ -64,5 +64,11 @@ + return user, err + } + ++ if source.RequiredClaimValues != nil { ++ if err := source.authorizeAccessEngine(request.Context(), user); err != nil { ++ return goth.User{}, err ++ } ++ } ++ + return user, nil + } +--- /dev/null ++++ b/third_party/access-engine-pep/LICENSE +@@ -0,0 +1,151 @@ ++# Target Revenue Source License ++ ++**Version 1.0, Candidate 1 (V1C1)** ++ ++--- ++ ++> **PRELIMINARY CANDIDATE — SUBJECT TO CHANGE — NOT FINAL** ++> ++> This repository is governed by the Target Revenue Source License ++> (TRSL), Version 1, Candidate 1. This is the framework's first working ++> candidate, adopted as the org's preliminary operating license across ++> all repos (maintainer decision, 2026-07-29) during the build/alpha ++> stage — see `SCOPE.md` §1 and Appendix A of the canonical text (link ++> below) for the alpha/beta risk-acceptance decision this reflects and ++> what it does and does not mean. Full specialist legal review is ++> explicitly deferred until the framework moves out of beta. ++> ++> **Canonical source, full candidate-status banner, and Appendix A ++> (non-normative candidate notes tracking every open item):** the ++> `coulomb` org's `target-revenue` repository, ++> `specs/TargetRevenueSourceLicense-V1C1.md` — this file is the operative ++> legal text (Preamble through Section 11) only; the canonical document ++> is authoritative if this copy and that document ever diverge. ++ ++--- ++ ++## Preamble ++ ++This Target Revenue Source License ("**License**") governs the Software identified in the applicable Phase Manifest. It implements the Target Revenue Framework: a defined development Phase accumulates Development Credit and Remission Credit against an immutable Initial Target until the Milestone Release automatically and irrevocably converts to a declared permissive Future License. ++ ++Commercial beneficiaries fund the creation and early availability of a software improvement; once the declared target is satisfied, the governed release becomes permissively open source. ++ ++## 1. Definitions ++ ++Capitalized terms used in this License have the meanings given below. Where a term is also defined in the Phase Manifest or Target Ledger for a specific Phase, the Phase Manifest and Target Ledger govern the *values* (amounts, dates, identifiers) and this License governs the *legal effect* of those values — the two must not be read as conflicting definitions of the same concept. ++ ++**"Commercial Entitlement"** means a right, purchased or otherwise granted under a Commercial Use Agreement, to make Commercial Use of the Software during a Phase. ++ ++**"Commercial Use"** means billing, invoicing, or otherwise charging any customer a fee, subscription, license fee, or other consideration for or in connection with use of the Software, at any time before the Conversion Event for the applicable Phase, regardless of whether the person or organization billed would otherwise qualify for Noncommercial Use. Commercial Use occurs by virtue of such billing alone, whether or not the resulting payment is registered with the Trust Service; in particular, billing a customer for pre-conversion use of the Software without recording the corresponding payment in the applicable Phase's Target Ledger is Commercial Use without a valid Commercial Entitlement — a violation of Section 3, addressed under Section 7 and, where applicable, the Enforcement Network described in the canonical repository's `specs/EnforcementNetworkConcept.md`. ++ ++**"Commercial Use Agreement"** means the separate agreement, referenced by the applicable Phase Manifest, under which a Commercial Entitlement is purchased or granted. This License does not itself set pricing, metering, or payment terms — those are governed by the Commercial Use Agreement. ++ ++**"Conversion Event"** means the moment the Outstanding Target for a Phase reaches zero, as computed from the Phase Manifest and Target Ledger per the Target Ledger Specification. The Conversion Event occurs automatically and is not conditioned on any declaration, attestation, or other act by the Licensor or any Trust Service. ++ ++**"Development Credit"** means the portion of a collected and settled payment explicitly allocated toward satisfying the Initial Target of a specific Phase, as recorded in that Phase's Target Ledger. ++ ++**"Future License"** means the permissive license identified in the applicable Phase Manifest, being either the MIT License or the Apache License, Version 2.0, which applies to the Milestone Release upon the Conversion Event. ++ ++**"Initial Target"** means the immutable monetary target declared for a Phase in its Phase Manifest. ++ ++**"Licensor"** means **Binky Hedgehog GmbH**, the party that publishes the Phase Manifest and holds the rights necessary to grant this License and the Future License for the Milestone Release. ++ ++**"Milestone Release"** means the precisely identified software release designated in the applicable Phase Manifest, identified by an immutable source revision, release artifact, or cryptographic digest. ++ ++**"Noncommercial Use"** means use of the Software for personal purposes, private study, hobby or amateur projects; use by any charitable organization, educational institution, public research organization, or government institution acting in a non-revenue-generating capacity; or other use of a materially similar character. ++ ++**"Outstanding Target"** means, at any time, `max(0, Initial Target − cumulative Development Credit − cumulative Remission Credit)` for a Phase, as computed from that Phase's Target Ledger. ++ ++**"Phase"** means a bounded development undertaking governed by one Initial Target, one Milestone Release, one degeneration policy, and one Future License declaration, as declared in a Phase Manifest. ++ ++**"Phase Manifest"** means the published, immutable declaration identifying a Phase, its Milestone Release, Initial Target, Future License, degeneration policy, and Target Ledger location, as specified in the Phase Manifest Specification. ++ ++**"Remission Credit"** means a transparent, non-revenue reduction of a Phase's Outstanding Target, generated under that Phase's published degeneration policy and recorded in the Target Ledger. ++ ++**"Settled Payment"** means a payment that has cleared through its payment processor and is no longer subject to reversal in the ordinary course (chargeback, dispute, or equivalent), as further specified by the applicable Commercial Use Agreement or monetization extension. ++ ++**"Software"** means the source code, object code, and associated documentation of the Milestone Release identified in the applicable Phase Manifest. ++ ++**"Target Ledger"** means the append-only record of Development Credit, Remission Credit, and correction entries for a Phase, as specified in the Target Ledger Specification. ++ ++**"You"** or **"Licensee"** means the individual or entity exercising rights under this License. ++ ++## 2. Grant of Rights for Noncommercial Use ++ ++Subject to the terms of this License, the Licensor grants You a worldwide, royalty-free, non-exclusive license, during the applicable Phase, to: ++ ++(a) use, reproduce, and study the Software for any Noncommercial Use; ++ ++(b) modify the Software and create derivative works of it for any Noncommercial Use; and ++ ++(c) redistribute the Software and Your modifications, in source or object form, for any Noncommercial Use, provided that You include this License, unmodified, with any such redistribution, and that You do not remove or alter any copyright, patent, trademark, or attribution notices contained in the Software. ++ ++This grant does not extend to Commercial Use. Commercial Use requires a Commercial Entitlement under Section 3. ++ ++## 3. Commercial Use ++ ++You may not make Commercial Use of the Software during the applicable Phase unless You hold a valid, current Commercial Entitlement under a Commercial Use Agreement with the Licensor covering the applicable Phase. A Commercial Entitlement granted under one Phase's Commercial Use Agreement does not extend to a later Phase's Milestone Release unless the Commercial Use Agreement expressly says so. ++ ++This Section 3 states the existence and boundary of the commercial-use restriction. It does not itself set pricing, invoicing, metering, audit rights, or payment terms — those are governed exclusively by the applicable Commercial Use Agreement. ++ ++## 4. Patent License ++ ++Subject to the terms of this License, each contributor to the Software grants You, during the applicable Phase and solely to the extent of rights granted under Sections 2 and 3, a perpetual (subject to the termination below), worldwide, non-exclusive, no-charge, royalty-free patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Software, limited to those patent claims licensable by that contributor that are necessarily infringed by their contribution(s) alone or by combination of their contribution(s) with the Software. ++ ++If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Software or a contribution incorporated within it constitutes direct or contributory patent infringement, then any patent licenses granted to You under this Section 4 for the Software shall terminate as of the date such litigation is filed. ++ ++## 5. Automatic Conversion to the Future License ++ ++**5.1 Automatic effect.** Upon the Conversion Event for a Phase, the rights and restrictions in Sections 3 (Commercial Use) of this License, as they apply to that Phase's Milestone Release, terminate automatically. In their place, the Milestone Release is licensed under the Future License identified in that Phase's Phase Manifest, effective as of the Conversion Event, without any further act, declaration, or attestation required by the Licensor, any Trust Service, or any other party. ++ ++**5.2 Irrevocability.** Once a valid Conversion Event has occurred for a Phase, no subsequent refund, chargeback, accounting correction, dispute, or termination of this License for an unrelated breach shall revoke, suspend, or otherwise impair the Future License grant for that Phase's Milestone Release. Any shortfall or dispute arising after a Conversion Event is a commercial or accounting matter between the relevant parties and does not reinstate a commercial-use restriction over already-converted Software. ++ ++**5.3 Prior freedom preserved.** A later Phase covering subsequent improvements to the Software does not restrict, withdraw, or otherwise affect the rights granted under the Future License for an earlier Phase's already-converted Milestone Release. ++ ++**5.4 Evidence, not cause.** A Trust Service may publish a Conversion Attestation documenting a Conversion Event. Such an attestation is evidence that the Conversion Event occurred; it is not a condition of, and its absence or delay does not postpone, the automatic effect described in Section 5.1. Any person may independently verify whether a Conversion Event has occurred directly from the Phase Manifest and Target Ledger. ++ ++## 6. Successive Phases ++ ++The Licensor may declare a new Phase covering subsequent improvements to the Software following a Milestone Release's Conversion Event. Each Phase is independently governed by its own Phase Manifest, Initial Target, degeneration policy, and Target Ledger. Nothing in a later Phase's Phase Manifest may be construed to reduce or withdraw rights already granted under Section 5 for an earlier Phase's Milestone Release. ++ ++## 7. Term and Termination ++ ++**7.1 Term.** This License applies to the Software for the duration of the applicable Phase, and, for the Milestone Release, indefinitely following that Phase's Conversion Event under the Future License. ++ ++**7.2 Termination for breach.** If You breach Section 3 (Commercial Use) or Section 2(c) (redistribution notice requirement), the Licensor may terminate this License as to You. Before such termination becomes effective, the Licensor shall provide You written notice of the breach; if You cure the breach within thirty (30) days of that notice, this License continues in effect. A second breach of the same provision within twelve (12) months may be terminated immediately without a further cure opportunity. ++ ++**7.3 Effect of termination.** Termination under this Section 7 affects only Your rights under Sections 2 and 3 for the Phase in which the breach occurred. It does not affect any rights already vested under Section 5 (Automatic Conversion) for a Milestone Release whose Conversion Event has already occurred, per Section 5.2. ++ ++**7.4 Public record of breach and resolution.** The Licensor shall cause the Trust Service to publish, as part of the public record for the affected Phase, notice of: (a) any breach notice issued under Section 7.2, stating the general nature of the breach and the date of notice; (b) whether the breach was cured within the applicable cure period, and the date of cure; and (c) any termination determination made under this Section 7, including its effective date and scope. This public record exists to give the ecosystem a transparent, verifiable conformity signal for the Phase, distinct from and in addition to the Development Credit and Remission Credit facts already published under Section 5.4 and the Target Ledger Specification. ++ ++A breach that You dispute, and that has not been finally determined, shall be recorded as **alleged**; it shall be recorded as **determined** only once the cure period has run without cure, or the dispute has been resolved against You under the applicable Commercial Use Agreement's dispute process, if any. The Trust Service shall update the record promptly upon resolution in either direction. Recording an alleged or determined breach under this Section 7.4 is a ministerial act of publishing the Licensor's determination (or a dispute process's outcome); it does not give the Trust Service discretionary authority to decide whether a breach occurred, consistent with Section 5.4's evidence-not-cause principle. ++ ++Whether, and under what conditions, the public record identifies a Commercial Entitlement holder by name is governed exclusively by the applicable Commercial Use Agreement, which the Licensor and that Commercial Entitlement holder negotiate and agree to directly. This License does not itself set a naming default. Where no Commercial Use Agreement addresses the question, or where the affected party has no Commercial Use Agreement at all (for example, a Section 2(c) breach by a Noncommercial Use licensee), the public record states the Phase and breach category only, without naming the party. ++ ++## 8. Disclaimer of Warranty ++ ++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. THE LICENSOR DOES NOT WARRANT THAT THE SOFTWARE WILL BE ERROR-FREE OR THAT ANY PHASE WILL REACH ITS CONVERSION EVENT. ++ ++## 9. Limitation of Liability ++ ++IN NO EVENT SHALL THE LICENSOR OR ANY CONTRIBUTOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE, EXCEPT TO THE EXTENT SUCH LIMITATION IS PROHIBITED BY APPLICABLE LAW. ++ ++## 10. Trademarks ++ ++This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary attribution. ++ ++## 11. General Provisions ++ ++**11.1 Governing law and venue.** Adopted for alpha/beta 2026-07-29: disputes arising under this License shall be resolved by binding arbitration, seated at a neutral, arbitration-mature venue (Singapore or London are the two candidates concretely supported by current research), rather than by litigation in a national court. The specific arbitral institution and substantive governing law remain a per-deployment blank pending final selection; they are not fixed by this candidate. See the canonical repository's `history/260729-TRSL-Jurisdiction-Synthesis.md` §2. ++ ++**11.2 Severability.** If any provision of this License is held unenforceable, the remaining provisions remain in full force, and the unenforceable provision shall be reformed to the minimum extent necessary to make it enforceable. ++ ++**11.3 No waiver.** Failure to enforce any provision of this License is not a waiver of future enforcement of that or any other provision. ++ ++**11.4 Entire agreement (as to licensing).** This License, together with the applicable Phase Manifest and, where applicable, the Commercial Use Agreement, constitutes the entire agreement between You and the Licensor regarding the Software's licensing terms. Operations, service, and consulting arrangements are governed by separate agreements, if any, and are not part of this License. ++ ++**11.5 Definitions control.** Marketing materials, documentation, or other non-normative communications about the Software must not describe pre-Conversion-Event Software as "Open Source," "free software," or "open core." Pre-conversion Noncommercial Use is **source-available**; pre-conversion Commercial Use requires a **Commercial Entitlement**; only post-conversion Software may be described as Open Source, under the Future License. ++ ++--- ++ ++**No Phase is currently declared for this repository under this License.** Until a Phase Manifest is published and registered with the Trust Service for a Milestone Release in this repository, Sections 2–7 above have no operative subject matter here — this License establishes the governing framework in advance of that declaration, consistent with the org-wide rollout decision recorded in `target-revenue`'s `workplans/TREV-WP-0008-governance-and-pilot-rollout.md`. +--- /dev/null ++++ b/third_party/access-engine-pep/go.mod +@@ -0,0 +1,3 @@ ++module github.com/netkingdom/access-engine ++ ++go 1.24 +--- /dev/null ++++ b/third_party/access-engine-pep/internal/sign/sign.go +@@ -0,0 +1,95 @@ ++// Package sign attaches a detached Ed25519 signature to a decision envelope ++// (FLEX-WP-0024). The signed material is json.Marshal of the envelope with ++// the signature field omitted. ++package sign ++ ++import ( ++ "bytes" ++ "crypto/ed25519" ++ "encoding/base64" ++ "encoding/json" ++ "fmt" ++ "io" ++ ++ "github.com/netkingdom/access-engine/pkg/api" ++) ++ ++const AlgorithmEd25519 = "ed25519" ++ ++// CanonicalBytes is the exact payload that is signed and verified: the ++// envelope as emitted, with signature absent. ++func CanonicalBytes(envelope api.DecisionEnvelope) ([]byte, error) { ++ envelope.Signature = nil ++ data, err := json.Marshal(envelope) ++ if err != nil { ++ return nil, fmt.Errorf("marshal canonical envelope: %w", err) ++ } ++ return data, nil ++} ++ ++func Sign(envelope *api.DecisionEnvelope, private ed25519.PrivateKey, kid string) error { ++ if envelope == nil { ++ return fmt.Errorf("envelope is required") ++ } ++ if len(private) != ed25519.PrivateKeySize { ++ return fmt.Errorf("ed25519 private key has length %d", len(private)) ++ } ++ if kid == "" { ++ return fmt.Errorf("kid is required") ++ } ++ payload, err := CanonicalBytes(*envelope) ++ if err != nil { ++ return err ++ } ++ sig := ed25519.Sign(private, payload) ++ envelope.Signature = &api.EnvelopeSignature{ ++ Mode: api.EnvelopeSignatureSigned, ++ Alg: AlgorithmEd25519, ++ Kid: kid, ++ Value: base64.RawURLEncoding.EncodeToString(sig), ++ } ++ return nil ++} ++ ++func Verify(envelope api.DecisionEnvelope, keys map[string]ed25519.PublicKey) error { ++ sig := envelope.Signature ++ if sig == nil || sig.Mode != api.EnvelopeSignatureSigned { ++ return fmt.Errorf("envelope is not signed") ++ } ++ if sig.Alg != AlgorithmEd25519 { ++ return fmt.Errorf("unsupported signature alg %q", sig.Alg) ++ } ++ pub, ok := keys[sig.Kid] ++ if !ok { ++ return fmt.Errorf("unknown signature kid %q", sig.Kid) ++ } ++ raw, err := base64.RawURLEncoding.DecodeString(sig.Value) ++ if err != nil { ++ return fmt.Errorf("decode signature: %w", err) ++ } ++ payload, err := CanonicalBytes(envelope) ++ if err != nil { ++ return err ++ } ++ if !ed25519.Verify(pub, payload, raw) { ++ return fmt.Errorf("signature does not match envelope") ++ } ++ return nil ++} ++ ++// DecodeEnvelope preserves JSON numeric kinds/lexemes emitted by task inputs. ++// Legacy producer envelopes remain byte-compatible. It does not invent another ++// signature canonicalization: CanonicalBytes still calls typed json.Marshal. ++func DecodeEnvelope(raw []byte) (api.DecisionEnvelope, error) { ++ var envelope api.DecisionEnvelope ++ decoder := json.NewDecoder(bytes.NewReader(raw)) ++ decoder.UseNumber() ++ decoder.DisallowUnknownFields() ++ if err := decoder.Decode(&envelope); err != nil { ++ return envelope, err ++ } ++ if _, err := decoder.Token(); err != io.EOF { ++ return api.DecisionEnvelope{}, fmt.Errorf("trailing envelope data") ++ } ++ return envelope, nil ++} +--- /dev/null ++++ b/third_party/access-engine-pep/pkg/api/canonical.go +@@ -0,0 +1,699 @@ ++package api ++ ++import ( ++ "crypto/sha256" ++ "encoding/hex" ++ "encoding/json" ++ "fmt" ++ "strings" ++ "time" ++) ++ ++// ProtectedSystemManifest describes a system that delegates authorization to ++// flex-auth. ++type ProtectedSystemManifest struct { ++ ID string `json:"id" yaml:"id"` ++ Name string `json:"name,omitempty" yaml:"name,omitempty"` ++ Description string `json:"description,omitempty" yaml:"description,omitempty"` ++ ClientBinding *ClientBinding `json:"client_binding,omitempty" yaml:"client_binding,omitempty"` ++ ResourceTypes []ResourceType `json:"resource_types,omitempty" yaml:"resource_types,omitempty"` ++ Actions []ActionDefinition `json:"actions,omitempty" yaml:"actions,omitempty"` ++ CaringProfiles []string `json:"caring_profiles,omitempty" yaml:"caring_profiles,omitempty"` ++ Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` ++} ++ ++// ClientBinding names the key-cape client a protected system's users sign in ++// through. It is descriptive only: the readback registry projection lists it, ++// and it never decides an allow (FLEX-DEC-2026-019). ++type ClientBinding struct { ++ Issuer string `json:"issuer" yaml:"issuer"` ++ ClientID string `json:"client_id" yaml:"client_id"` ++} ++ ++// ResourceType describes a resource namespace entry owned by a protected system. ++type ResourceType struct { ++ Name string `json:"name" yaml:"name"` ++ ParentTypes []string `json:"parent_types,omitempty" yaml:"parent_types,omitempty"` ++ ScopeLevel ScopeLevel `json:"scope_level,omitempty" yaml:"scope_level,omitempty"` ++ Planes []Plane `json:"planes,omitempty" yaml:"planes,omitempty"` ++ Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` ++} ++ ++// ActionDefinition maps a protected-system action to CARING capabilities. ++type ActionDefinition struct { ++ Name string `json:"name" yaml:"name"` ++ Capabilities []Capability `json:"capabilities,omitempty" yaml:"capabilities,omitempty"` ++ Planes []Plane `json:"planes,omitempty" yaml:"planes,omitempty"` ++ ExposureModes []ExposureMode `json:"exposure_modes,omitempty" yaml:"exposure_modes,omitempty"` ++ Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` ++} ++ ++// SubjectManifest declares subjects, groups, teams, and tenants for local ++// registry loading. ++type SubjectManifest struct { ++ ID string `json:"id" yaml:"id"` ++ Subjects []Subject `json:"subjects,omitempty" yaml:"subjects,omitempty"` ++ Groups []Group `json:"groups,omitempty" yaml:"groups,omitempty"` ++ Teams []Team `json:"teams,omitempty" yaml:"teams,omitempty"` ++ Tenants []Tenant `json:"tenants,omitempty" yaml:"tenants,omitempty"` ++ Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` ++} ++ ++// Subject is a human, service, automation, agent, or other acting identity. ++type Subject struct { ++ ID string `json:"id" yaml:"id"` ++ Type SubjectType `json:"type" yaml:"type"` ++ DisplayName string `json:"display_name,omitempty" yaml:"display_name,omitempty"` ++ OrganizationRelation OrganizationRelation `json:"organization_relation,omitempty" yaml:"organization_relation,omitempty"` ++ Roles []CanonicalRole `json:"roles,omitempty" yaml:"roles,omitempty"` ++ Groups []string `json:"groups,omitempty" yaml:"groups,omitempty"` ++ Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"` ++ Claims map[string]any `json:"claims,omitempty" yaml:"claims,omitempty"` ++ CaringDescriptors []CaringAccessDescriptor `json:"caring_descriptors,omitempty" yaml:"caring_descriptors,omitempty"` ++ Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` ++} ++ ++// Group is an assignment convenience, not a canonical role. ++type Group struct { ++ ID string `json:"id" yaml:"id"` ++ DisplayName string `json:"display_name,omitempty" yaml:"display_name,omitempty"` ++ Members []string `json:"members,omitempty" yaml:"members,omitempty"` ++ Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"` ++ CaringDescriptors []CaringAccessDescriptor `json:"caring_descriptors,omitempty" yaml:"caring_descriptors,omitempty"` ++ Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` ++} ++ ++// Team is a group-like ownership unit used by protected systems. ++type Team struct { ++ ID string `json:"id" yaml:"id"` ++ DisplayName string `json:"display_name,omitempty" yaml:"display_name,omitempty"` ++ Members []string `json:"members,omitempty" yaml:"members,omitempty"` ++ Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"` ++ CaringDescriptors []CaringAccessDescriptor `json:"caring_descriptors,omitempty" yaml:"caring_descriptors,omitempty"` ++ Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` ++} ++ ++// Tenant is a structural isolation boundary. ++type Tenant struct { ++ ID string `json:"id" yaml:"id"` ++ Name string `json:"name,omitempty" yaml:"name,omitempty"` ++ Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` ++} ++ ++// RelationshipFact records a relation between subjects, groups, teams, tenants, ++// and resources. ++type RelationshipFact struct { ++ ID string `json:"id" yaml:"id"` ++ System string `json:"system,omitempty" yaml:"system,omitempty"` ++ Subject string `json:"subject" yaml:"subject"` ++ Relation string `json:"relation" yaml:"relation"` ++ Object string `json:"object" yaml:"object"` ++ Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"` ++ Conditions []Condition `json:"conditions,omitempty" yaml:"conditions,omitempty"` ++ Caring *CaringAccessDescriptor `json:"caring,omitempty" yaml:"caring,omitempty"` ++ Provenance map[string]any `json:"provenance,omitempty" yaml:"provenance,omitempty"` ++ Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` ++} ++ ++// PolicyPackageMetadata is the frontmatter contract for Rego-in-Markdown ++// policy packages. ++type PolicyPackageMetadata struct { ++ ID string `json:"id" yaml:"id"` ++ Name string `json:"name,omitempty" yaml:"name,omitempty"` ++ Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"` ++ Version string `json:"version" yaml:"version"` ++ Status string `json:"status,omitempty" yaml:"status,omitempty"` ++ Package string `json:"package" yaml:"package"` ++ Actions []string `json:"actions,omitempty" yaml:"actions,omitempty"` ++ Owner string `json:"owner,omitempty" yaml:"owner,omitempty"` ++ Fixtures []string `json:"fixtures,omitempty" yaml:"fixtures,omitempty"` ++ Caring CaringPolicyMetadata `json:"caring" yaml:"caring"` ++ Activation map[string]any `json:"activation,omitempty" yaml:"activation,omitempty"` ++ Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` ++ // AllowTTL is a Go duration (for example "15m") that bounds every allow ++ // this package produces. Omit to use DefaultAllowTTL. "none" or "0s" ++ // means no stated end; the engine denies those allows (§9.7.1). ++ AllowTTL string `json:"allow_ttl,omitempty" yaml:"allow_ttl,omitempty"` ++} ++ ++// CaringPolicyMetadata declares the CARING envelope a policy governs. ++type CaringPolicyMetadata struct { ++ Profile string `json:"profile" yaml:"profile"` ++ Enforce bool `json:"enforce,omitempty" yaml:"enforce,omitempty"` ++ CanonicalRoles []CanonicalRole `json:"canonical_roles,omitempty" yaml:"canonical_roles,omitempty"` ++ OrganizationRelations []OrganizationRelation `json:"organization_relations,omitempty" yaml:"organization_relations,omitempty"` ++ Scopes []CaringScope `json:"scopes,omitempty" yaml:"scopes,omitempty"` ++ Planes []Plane `json:"planes,omitempty" yaml:"planes,omitempty"` ++ Capabilities []Capability `json:"capabilities,omitempty" yaml:"capabilities,omitempty"` ++ ExposureModes []ExposureMode `json:"exposure_modes,omitempty" yaml:"exposure_modes,omitempty"` ++ Conditions []Condition `json:"conditions,omitempty" yaml:"conditions,omitempty"` ++ Restrictions []Restriction `json:"restrictions,omitempty" yaml:"restrictions,omitempty"` ++ Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` ++} ++ ++// PolicyFixture binds a check request to an expected decision. ++type PolicyFixture struct { ++ ID string `json:"id" yaml:"id"` ++ Request CheckRequest `json:"request" yaml:"request"` ++ Expect DecisionExpectation `json:"expect" yaml:"expect"` ++ Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` ++} ++ ++// DecisionExpectation is the compact fixture expectation for policy tests. ++type DecisionExpectation struct { ++ Effect DecisionEffect `json:"effect" yaml:"effect"` ++ Reason string `json:"reason,omitempty" yaml:"reason,omitempty"` ++ Obligations []Obligation `json:"obligations,omitempty" yaml:"obligations,omitempty"` ++ ConformanceFindings []CaringConformanceFinding `json:"conformance_findings,omitempty" yaml:"conformance_findings,omitempty"` ++} ++ ++// CheckRequest is the stable protected-system-facing decision request. ++type CheckRequest struct { ++ ID string `json:"id,omitempty" yaml:"id,omitempty"` ++ Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"` ++ Subject SubjectRef `json:"subject" yaml:"subject"` ++ Action string `json:"action" yaml:"action"` ++ Resource ResourceRef `json:"resource" yaml:"resource"` ++ Context map[string]any `json:"context,omitempty" yaml:"context,omitempty"` ++ CaringContext *CaringAccessDescriptor `json:"caring_context,omitempty" yaml:"caring_context,omitempty"` ++ PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"` ++ ++ // Purpose is empty on every enforcement request. The readback pin sets it ++ // to PurposeReadback, and it is hashed into every request digest, so an ++ // advisory readback decision can never pass a PEP's §6.4.2 replay test for ++ // an enforcement request (FLEX-DEC-2026-019). /v1/check and /v1/batch_check ++ // refuse a request that carries it. ++ Purpose string `json:"purpose,omitempty" yaml:"purpose,omitempty"` ++} ++ ++// PurposeReadback marks an advisory application-access readback decision. ++const PurposeReadback = "readback" ++ ++// BatchCheckRequest evaluates one subject/action against multiple resources. ++type BatchCheckRequest struct { ++ ID string `json:"id,omitempty" yaml:"id,omitempty"` ++ Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"` ++ Subject SubjectRef `json:"subject" yaml:"subject"` ++ Action string `json:"action" yaml:"action"` ++ Resources []ResourceRef `json:"resources" yaml:"resources"` ++ Context map[string]any `json:"context,omitempty" yaml:"context,omitempty"` ++ PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"` ++} ++ ++// SubjectRef is a normalized subject reference in request and decision shapes. ++type SubjectRef struct { ++ ID string `json:"id" yaml:"id"` ++ Type SubjectType `json:"type,omitempty" yaml:"type,omitempty"` ++ Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"` ++ Attributes map[string]any `json:"attributes,omitempty" yaml:"attributes,omitempty"` ++} ++ ++// ResourceRef is a normalized resource reference in request and decision shapes. ++type ResourceRef struct { ++ ID string `json:"id" yaml:"id"` ++ Type string `json:"type,omitempty" yaml:"type,omitempty"` ++ System string `json:"system,omitempty" yaml:"system,omitempty"` ++ Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"` ++ Attributes map[string]any `json:"attributes,omitempty" yaml:"attributes,omitempty"` ++} ++ ++// DecisionEffect is the stable decision outcome vocabulary. ++type DecisionEffect string ++ ++const ( ++ DecisionEffectAllow DecisionEffect = "allow" ++ DecisionEffectDeny DecisionEffect = "deny" ++ DecisionEffectRedact DecisionEffect = "redact" ++ DecisionEffectAuditOnly DecisionEffect = "audit_only" ++ DecisionEffectNotApplicable DecisionEffect = "not_applicable" ++) ++ ++// DecisionEffects returns the complete effect vocabulary. Every effect is an ++// emission class in cadence.yaml; a test fails if a constant above is missing ++// here, so a new effect cannot ship unclassified (FLEX-WP-0031-T03). ++func DecisionEffects() []DecisionEffect { ++ return []DecisionEffect{ ++ DecisionEffectAllow, ++ DecisionEffectDeny, ++ DecisionEffectRedact, ++ DecisionEffectAuditOnly, ++ DecisionEffectNotApplicable, ++ } ++} ++ ++// Restricts reports whether an effect withholds authority. This classification ++// does not exempt an effect from required durable emission (GH-DEC-2026-024). ++func (e DecisionEffect) Restricts() bool { ++ switch e { ++ case DecisionEffectDeny, DecisionEffectRedact, DecisionEffectNotApplicable: ++ return true ++ } ++ return false ++} ++ ++// DecisionRecordContractV1 is the published decision-record contract identifier. ++const DecisionRecordContractV1 = "flex-auth.decision-record.v1" ++ ++// DecisionEnvelope is the stable response produced by standalone and delegated ++// evaluators. It is flex-auth's published decision-record contract (§17). ++type DecisionEnvelope struct { ++ ID string `json:"id" yaml:"id"` ++ ContractVersion string `json:"contract_version,omitempty" yaml:"contract_version,omitempty"` ++ RequestID string `json:"request_id,omitempty" yaml:"request_id,omitempty"` ++ Effect DecisionEffect `json:"effect" yaml:"effect"` ++ Reason string `json:"reason,omitempty" yaml:"reason,omitempty"` ++ MatchedPolicyVersion string `json:"matched_policy_version,omitempty" yaml:"matched_policy_version,omitempty"` ++ MatchedRule string `json:"matched_rule,omitempty" yaml:"matched_rule,omitempty"` ++ Resource ResourceRef `json:"resource" yaml:"resource"` ++ Subject SubjectRef `json:"subject" yaml:"subject"` ++ Binding *DecisionBinding `json:"binding,omitempty" yaml:"binding,omitempty"` ++ Lifetime *DecisionLifetime `json:"lifetime,omitempty" yaml:"lifetime,omitempty"` ++ Obligations []Obligation `json:"obligations,omitempty" yaml:"obligations,omitempty"` ++ Diagnostics map[string]any `json:"diagnostics,omitempty" yaml:"diagnostics,omitempty"` ++ Provenance DecisionProvenance `json:"provenance" yaml:"provenance"` ++ Signature *EnvelopeSignature `json:"signature,omitempty" yaml:"signature,omitempty"` ++ Caring *CaringDecisionMetadata `json:"caring,omitempty" yaml:"caring,omitempty"` ++} ++ ++// EnvelopeSignatureMode is how the responder authenticates the envelope. ++type EnvelopeSignatureMode string ++ ++const ( ++ EnvelopeSignatureSigned EnvelopeSignatureMode = "signed" ++ EnvelopeSignatureUnsigned EnvelopeSignatureMode = "unsigned" ++) ++ ++// EnvelopeSignature is a detached signature over the canonical envelope with ++// this field omitted (FLEX-WP-0024). It is not decision material and is not ++// part of request_digest. ++type EnvelopeSignature struct { ++ Mode EnvelopeSignatureMode `json:"mode" yaml:"mode"` ++ Alg string `json:"alg,omitempty" yaml:"alg,omitempty"` ++ Kid string `json:"kid,omitempty" yaml:"kid,omitempty"` ++ Value string `json:"value,omitempty" yaml:"value,omitempty"` ++} ++ ++func UnsignedEnvelopeSignature() *EnvelopeSignature { ++ return &EnvelopeSignature{Mode: EnvelopeSignatureUnsigned} ++} ++ ++// DecisionLifetimeKind identifies how an allow ends. ++type DecisionLifetimeKind string ++ ++const ( ++ DecisionLifetimeTTL DecisionLifetimeKind = "ttl" ++) ++ ++// DefaultAllowTTL is the engine default when a policy package omits allow_ttl. ++const DefaultAllowTTL = 15 * time.Minute ++ ++// ReasonAllowLifetimeUnstated is the deny reason for an allow with no stated end. ++const ReasonAllowLifetimeUnstated = "allow_lifetime_unstated" ++ ++// DecisionLifetime bounds an allow (§9.7.1). flex-auth has no session concept, ++// so the first honest shape is a policy-package-declared TTL. ++type DecisionLifetime struct { ++ Kind DecisionLifetimeKind `json:"kind" yaml:"kind"` ++ TTL string `json:"ttl,omitempty" yaml:"ttl,omitempty"` ++ NotBefore string `json:"not_before,omitempty" yaml:"not_before,omitempty"` ++ ExpiresAt string `json:"expires_at" yaml:"expires_at"` ++} ++ ++// DecisionBinding is the exact normalized authorization request evaluated by ++// a decision. It lets a consumer verify structured action, target, actor, and ++// context fields without parsing reason or diagnostic prose. ++type DecisionBinding struct { ++ Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"` ++ Subject SubjectRef `json:"subject" yaml:"subject"` ++ Action string `json:"action" yaml:"action"` ++ Resource ResourceRef `json:"resource" yaml:"resource"` ++ Context map[string]any `json:"context,omitempty" yaml:"context,omitempty"` ++ // Purpose is "readback" on an advisory readback decision and absent on an ++ // enforcement decision (FLEX-DEC-2026-019). ++ Purpose string `json:"purpose,omitempty" yaml:"purpose,omitempty"` ++ RequestDigest string `json:"request_digest" yaml:"request_digest"` ++ ++ // ApprovalBindingDigest is RequestDigest computed with the approval claim ++ // removed from context. It exists to break a circularity: an approval's ++ // pdp_digest is recorded at issue time, but the request that later carries ++ // the claim inside its hashed context has a different RequestDigest by ++ // construction, so a claim can never name the request that carries it. ++ // ++ // This digest is stable across attaching the claim, so it is the value ++ // claim.binding.pdp_digest must equal (GH-DEC-2026-008). It is NOT a replay ++ // identity and must not be used as one: two requests differing only in ++ // which approval was presented share it, and the decision does not. ++ ApprovalBindingDigest string `json:"approval_binding_digest,omitempty" yaml:"approval_binding_digest,omitempty"` ++ ++ // SubmittedRequestDigest is RequestDigest over the request exactly as the ++ // consumer sent it, before the evaluator overlays registry facts onto the ++ // subject and resource. ++ // ++ // It exists because RequestDigest is not consumer-computable and was ++ // published as though it were. The evaluator hashes the ENRICHED request — ++ // subject.tenant, subject.attributes, and resource attributes the registry ++ // contributed — and the registry is flex-auth's, so a consumer recomputing ++ // the digest over what it sent gets a different value on every real allow. ++ // secrets-engine found this on its first live request (FLEX-DEC-2026-012). ++ // ++ // This is the digest a PEP compares for §6.4 obligation 2's replay test. ++ // Nothing is lost by hashing the pre-enrichment form: enrichment is a ++ // function of the request and the registry snapshot, and ++ // provenance.registry_snapshot_digest already pins the snapshot, so ++ // SubmittedRequestDigest together with that digest identifies the evaluated ++ // request completely. ++ SubmittedRequestDigest string `json:"submitted_request_digest,omitempty" yaml:"submitted_request_digest,omitempty"` ++} ++ ++// ApprovalContextKey is the context key carrying an approval-engine ++// approval-claim. It is excluded from ApprovalBindingDigest and from nothing ++// else. ++const ApprovalContextKey = "approval" ++ ++// requestDigestMaterial is the exact tuple hashed for §6.4.2 replay. Request ++// id, policy version, and caring_context are excluded: id is correlation, the ++// version is provenance, and caring_context is an input-claim digest. ++type requestDigestMaterial struct { ++ Tenant string `json:"tenant,omitempty"` ++ Subject SubjectRef `json:"subject"` ++ Action string `json:"action"` ++ Resource ResourceRef `json:"resource"` ++ Context map[string]any `json:"context,omitempty"` ++ Purpose string `json:"purpose,omitempty"` ++} ++ ++// NewDecisionBinding returns a stable structured binding for the exact request ++// an evaluator consumed. ++// ++// Prefer NewDecisionBindingFor, which also records the digest of the request as ++// submitted. This form leaves SubmittedRequestDigest empty, which reads as "the ++// evaluator did not record one" rather than "the two are equal". ++func NewDecisionBinding(request CheckRequest) *DecisionBinding { ++ contextCopy := make(map[string]any, len(request.Context)) ++ for key, value := range request.Context { ++ contextCopy[key] = value ++ } ++ binding := &DecisionBinding{ ++ Tenant: request.Tenant, ++ Subject: request.Subject, ++ Action: request.Action, ++ Resource: request.Resource, ++ Context: contextCopy, ++ Purpose: request.Purpose, ++ RequestDigest: RequestDigest(request), ++ } ++ if _, carries := request.Context[ApprovalContextKey]; carries { ++ binding.ApprovalBindingDigest = ApprovalBindingDigest(request) ++ } ++ return binding ++} ++ ++// NewDecisionBindingFor records the binding of the evaluated request together ++// with the digest of the request as submitted. ++// ++// The two arguments are the same request before and after the evaluator ++// overlaid registry facts. Where a request named nothing the registry knows they ++// are equal, and the field is emitted anyway: a consumer that only sees it on ++// enriched decisions would build a check that passes by absence. ++func NewDecisionBindingFor(evaluated, submitted CheckRequest) *DecisionBinding { ++ binding := NewDecisionBinding(evaluated) ++ binding.SubmittedRequestDigest = RequestDigest(submitted) ++ return binding ++} ++ ++// ApprovalBindingDigest is RequestDigest over the same material with the ++// approval claim removed from context. ++// ++// When a request carries no approval claim the two are identical, which is the ++// point: an approval issued against a claim-free Check records that Check's ++// request_digest, and the later claim-bearing request reproduces the same value ++// here. Comparing it to claim.binding.pdp_digest establishes correspondence by ++// identity rather than by translating between two action vocabularies ++// (GH-DEC-2026-008). ++func ApprovalBindingDigest(request CheckRequest) string { ++ if _, carries := request.Context[ApprovalContextKey]; !carries { ++ return RequestDigest(request) ++ } ++ stripped := make(map[string]any, len(request.Context)) ++ for key, value := range request.Context { ++ if key == ApprovalContextKey { ++ continue ++ } ++ stripped[key] = value ++ } ++ if len(stripped) == 0 { ++ stripped = nil ++ } ++ return CanonicalDigest(requestDigestMaterial{ ++ Tenant: request.Tenant, ++ Subject: request.Subject, ++ Action: request.Action, ++ Resource: request.Resource, ++ Context: stripped, ++ Purpose: request.Purpose, ++ }) ++} ++ ++// RequestDigest is the mechanical §6.4.2 replay test: SHA-256 over canonical ++// JSON of tenant, subject, action, resource, context, and purpose. Purpose is ++// omitted when empty, so enforcement digests are unchanged by its existence. ++func RequestDigest(request CheckRequest) string { ++ return CanonicalDigest(requestDigestMaterial{ ++ Tenant: request.Tenant, ++ Subject: request.Subject, ++ Action: request.Action, ++ Resource: request.Resource, ++ Context: request.Context, ++ Purpose: request.Purpose, ++ }) ++} ++ ++// CanonicalDigest returns "sha256:" plus the hex SHA-256 of canonical JSON. ++// encoding/json sorts map keys, so two equal Go values agree. ++func CanonicalDigest(value any) string { ++ data, err := json.Marshal(value) ++ if err != nil { ++ sum := sha256.Sum256(nil) ++ return "sha256:" + hex.EncodeToString(sum[:]) ++ } ++ sum := sha256.Sum256(data) ++ return "sha256:" + hex.EncodeToString(sum[:]) ++} ++ ++// InputClaimDigests hashes the request-time claim classes the evaluator joined. ++func InputClaimDigests(request CheckRequest) map[string]string { ++ digests := make(map[string]string) ++ if len(request.Context) > 0 { ++ digests["context"] = CanonicalDigest(request.Context) ++ } ++ if request.CaringContext != nil { ++ digests["caring_context"] = CanonicalDigest(request.CaringContext) ++ } ++ if len(digests) == 0 { ++ return nil ++ } ++ return digests ++} ++ ++// DecisionCompletion carries evaluator-side inputs used to finish an envelope. ++type DecisionCompletion struct { ++ AllowTTL string ++ Now time.Time ++} ++ ++// CompleteDecision stamps contract version, input-claim digests, decision time, ++// and an explicit allow lifetime. An allow with no stated end becomes a deny. ++func CompleteDecision(envelope *DecisionEnvelope, request CheckRequest, completion DecisionCompletion) { ++ if envelope == nil { ++ return ++ } ++ if envelope.ContractVersion == "" { ++ envelope.ContractVersion = DecisionRecordContractV1 ++ } ++ if envelope.Provenance.InputClaimDigests == nil { ++ envelope.Provenance.InputClaimDigests = InputClaimDigests(request) ++ } ++ ApplyAllowLifetime(envelope, completion.AllowTTL, completion.Now) ++} ++ ++// ParseAllowTTL resolves a package-declared TTL. ok is false when the allow ++// would have no stated end. Invalid strings return an error so package ++// validation can reject them. ++func ParseAllowTTL(declared string) (time.Duration, error) { ++ trimmed := strings.TrimSpace(declared) ++ if trimmed == "" { ++ return DefaultAllowTTL, nil ++ } ++ if strings.EqualFold(trimmed, "none") { ++ return 0, nil ++ } ++ ttl, err := time.ParseDuration(trimmed) ++ if err != nil { ++ return 0, fmt.Errorf("allow_ttl %q is not a Go duration: %w", declared, err) ++ } ++ if ttl <= 0 { ++ return 0, nil ++ } ++ return ttl, nil ++} ++ ++// ApplyAllowLifetime sets DecisionTime and, for allows, an explicit TTL. A ++// missing or zero TTL denies the allow rather than mint a standing grant. ++func ApplyAllowLifetime(envelope *DecisionEnvelope, declaredTTL string, now time.Time) { ++ if envelope == nil { ++ return ++ } ++ if now.IsZero() { ++ now = time.Now().UTC() ++ } else { ++ now = now.UTC() ++ } ++ if envelope.Provenance.DecisionTime == "" { ++ envelope.Provenance.DecisionTime = now.Format(time.RFC3339) ++ } ++ if envelope.Effect != DecisionEffectAllow { ++ return ++ } ++ ttl, err := ParseAllowTTL(declaredTTL) ++ if err != nil || ttl <= 0 { ++ if envelope.Diagnostics == nil { ++ envelope.Diagnostics = map[string]any{} ++ } ++ if envelope.Reason != "" { ++ envelope.Diagnostics["unstated_allow_reason"] = envelope.Reason ++ } ++ envelope.Effect = DecisionEffectDeny ++ envelope.Reason = ReasonAllowLifetimeUnstated ++ envelope.MatchedRule = ReasonAllowLifetimeUnstated ++ envelope.Lifetime = nil ++ return ++ } ++ display := strings.TrimSpace(declaredTTL) ++ if display == "" { ++ display = "15m" ++ } ++ envelope.Lifetime = &DecisionLifetime{ ++ Kind: DecisionLifetimeTTL, ++ TTL: display, ++ NotBefore: now.Format(time.RFC3339), ++ ExpiresAt: now.Add(ttl).Format(time.RFC3339), ++ } ++} ++ ++// ActionAuthorizationStatus is the lifecycle state of a durable authorization. ++type ActionAuthorizationStatus string ++ ++const ( ++ ActionAuthorizationPending ActionAuthorizationStatus = "pending" ++ ActionAuthorizationApproved ActionAuthorizationStatus = "approved" ++ ActionAuthorizationDenied ActionAuthorizationStatus = "denied" ++ ActionAuthorizationSuperseded ActionAuthorizationStatus = "superseded" ++ ActionAuthorizationExpired ActionAuthorizationStatus = "expired" ++ ActionAuthorizationRevoked ActionAuthorizationStatus = "revoked" ++) ++ ++// ActionAuthorization joins a durable approval lifecycle to one exact ++// flex-auth request and decision. Storage and approval collection remain the ++// responsibility of the organizational decision authority. ++type ActionAuthorization struct { ++ SchemaVersion string `json:"schema_version" yaml:"schema_version"` ++ ID string `json:"id" yaml:"id"` ++ Status ActionAuthorizationStatus `json:"status" yaml:"status"` ++ SupersededBy string `json:"superseded_by,omitempty" yaml:"superseded_by,omitempty"` ++ Request CheckRequest `json:"request" yaml:"request"` ++ Validity ActionAuthorizationValidity `json:"validity" yaml:"validity"` ++ Approvals ActionAuthorizationApprovals `json:"approvals" yaml:"approvals"` ++ Decision DecisionEnvelope `json:"decision" yaml:"decision"` ++ Provenance map[string]any `json:"provenance,omitempty" yaml:"provenance,omitempty"` ++} ++ ++// ActionAuthorizationValidity bounds execution of an approved action. ++type ActionAuthorizationValidity struct { ++ NotBefore string `json:"not_before,omitempty" yaml:"not_before,omitempty"` ++ ExpiresAt string `json:"expires_at" yaml:"expires_at"` ++} ++ ++// ActionAuthorizationApprovals declares the approval threshold and evidence. ++type ActionAuthorizationApprovals struct { ++ RequiredCount int `json:"required_count" yaml:"required_count"` ++ Entries []ActionAuthorizationApprovalEntry `json:"entries" yaml:"entries"` ++} ++ ++// ActionAuthorizationApprovalEntry is one authenticated approver's evidence. ++type ActionAuthorizationApprovalEntry struct { ++ SubjectID string `json:"subject_id" yaml:"subject_id"` ++ ApprovedAt string `json:"approved_at" yaml:"approved_at"` ++ Assurance string `json:"assurance,omitempty" yaml:"assurance,omitempty"` ++ EvidenceRef string `json:"evidence_ref,omitempty" yaml:"evidence_ref,omitempty"` ++} ++ ++// Obligation describes a follow-up behavior required by a decision. ++type Obligation struct { ++ Type string `json:"type" yaml:"type"` ++ Parameters map[string]any `json:"parameters,omitempty" yaml:"parameters,omitempty"` ++} ++ ++// DecisionProvenance captures evaluator and policy provenance. ++type DecisionProvenance struct { ++ Evaluator string `json:"evaluator" yaml:"evaluator"` ++ Mode string `json:"mode" yaml:"mode"` ++ PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"` ++ PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"` ++ PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"` ++ RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"` ++ DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"` ++ InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"` ++ DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"` ++ ApprovalObservation *ApprovalObservationProvenance `json:"approval_observation,omitempty" yaml:"approval_observation,omitempty"` ++ Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"` ++} ++ ++// ApprovalObservationProvenance binds fresh server-owned fact evidence without ++// rewriting the original carried claim/context or either PDP request digest. ++type ApprovalObservationProvenance struct { ++ SourceID string `json:"source_id"` ++ SourceRevision string `json:"source_revision"` ++ ConfigurationDigest string `json:"configuration_digest"` ++ ClaimDigest string `json:"claim_digest"` ++ NativeBindingDigest string `json:"native_binding_digest"` ++ ObservedAt string `json:"observed_at"` ++ NotAfter string `json:"not_after"` ++ ValidatedAt string `json:"validated_at"` ++} ++ ++// CallerProvenance records how the request was authenticated to flex-auth. ++// It is not decision material: the same request from a different authenticated ++// caller must decide identically. Mode is required. Under disabled the object ++// is {"mode":"disabled"} with no principal (FLEX-DEC-2026-009). ++type CallerProvenance struct { ++ Mode string `json:"mode" yaml:"mode"` ++ Principal string `json:"principal,omitempty" yaml:"principal,omitempty"` ++ Audience string `json:"audience,omitempty" yaml:"audience,omitempty"` ++ NotAfter string `json:"not_after,omitempty" yaml:"not_after,omitempty"` ++} ++ ++// CaringDecisionMetadata carries CARING descriptor and conformance details in ++// a decision envelope. ++type CaringDecisionMetadata struct { ++ Profile string `json:"profile" yaml:"profile"` ++ Descriptor *CaringAccessDescriptor `json:"descriptor,omitempty" yaml:"descriptor,omitempty"` ++ RestrictionsEvaluated []Restriction `json:"restrictions_evaluated,omitempty" yaml:"restrictions_evaluated,omitempty"` ++ ExposureModes []ExposureMode `json:"exposure_modes,omitempty" yaml:"exposure_modes,omitempty"` ++ DerivedCapabilities []CaringDerivedCapability `json:"derived_capabilities,omitempty" yaml:"derived_capabilities,omitempty"` ++ ConformanceFindings []CaringConformanceFinding `json:"conformance_findings,omitempty" yaml:"conformance_findings,omitempty"` ++ ExposureEvent *CaringExposureEvent `json:"exposure_event,omitempty" yaml:"exposure_event,omitempty"` ++} ++ ++// AuditEvent is the local log shape for decisions and exposure events. ++type AuditEvent struct { ++ ID string `json:"id" yaml:"id"` ++ Type string `json:"type" yaml:"type"` ++ DecisionID string `json:"decision_id,omitempty" yaml:"decision_id,omitempty"` ++ Subject SubjectRef `json:"subject" yaml:"subject"` ++ Resource ResourceRef `json:"resource,omitempty" yaml:"resource,omitempty"` ++ Action string `json:"action,omitempty" yaml:"action,omitempty"` ++ Effect DecisionEffect `json:"effect,omitempty" yaml:"effect,omitempty"` ++ Timestamp string `json:"timestamp,omitempty" yaml:"timestamp,omitempty"` ++ ExposureEvent *CaringExposureEvent `json:"exposure_event,omitempty" yaml:"exposure_event,omitempty"` ++ Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` ++} +--- /dev/null ++++ b/third_party/access-engine-pep/pkg/api/caring.go +@@ -0,0 +1,335 @@ ++package api ++ ++// CaringProfileCaring040RC2 is the executable profile identifier for the ++// CARING 0.4.0-RC2 standard pinned by flex-auth. ++const CaringProfileCaring040RC2 = "caring-0.4.0-rc2" ++ ++// SubjectType is the CARING subject dimension. ++type SubjectType string ++ ++const ( ++ SubjectTypeHuman SubjectType = "Human" ++ SubjectTypeGroup SubjectType = "Group" ++ SubjectTypeOrganization SubjectType = "Organization" ++ SubjectTypeService SubjectType = "Service" ++ SubjectTypeAutomation SubjectType = "Automation" ++ SubjectTypeAgent SubjectType = "Agent" ++ SubjectTypeSystem SubjectType = "System" ++ SubjectTypeDevice SubjectType = "Device" ++ SubjectTypeProcess SubjectType = "Process" ++ SubjectTypeAnonymous SubjectType = "Anonymous" ++ SubjectTypeUnknown SubjectType = "Unknown" ++) ++ ++// OrganizationRelation is the CARING organization-relation dimension. ++type OrganizationRelation string ++ ++const ( ++ OrganizationRelationVendor OrganizationRelation = "Vendor" ++ OrganizationRelationServiceProvider OrganizationRelation = "ServiceProvider" ++ OrganizationRelationDistributor OrganizationRelation = "Distributor" ++ OrganizationRelationConsultant OrganizationRelation = "Consultant" ++ OrganizationRelationCustomer OrganizationRelation = "Customer" ++ OrganizationRelationCommunity OrganizationRelation = "Community" ++ OrganizationRelationAuthority OrganizationRelation = "Authority" ++ OrganizationRelationUnknown OrganizationRelation = "Unknown" ++) ++ ++// CanonicalRole is the CARING lifecycle responsibility posture. ++type CanonicalRole string ++ ++const ( ++ CanonicalRoleCreator CanonicalRole = "Creator" ++ CanonicalRoleBuilder CanonicalRole = "Builder" ++ CanonicalRoleVerifier CanonicalRole = "Verifier" ++ CanonicalRoleMaintainer CanonicalRole = "Maintainer" ++ CanonicalRoleIntegrator CanonicalRole = "Integrator" ++ CanonicalRoleOperator CanonicalRole = "Operator" ++ CanonicalRoleManager CanonicalRole = "Manager" ++ CanonicalRoleCoach CanonicalRole = "Coach" ++ CanonicalRoleDoer CanonicalRole = "Doer" ++) ++ ++// ScopeLevel is the CARING scope ladder. ++type ScopeLevel string ++ ++const ( ++ ScopeLevelEcosystem ScopeLevel = "Ecosystem" ++ ScopeLevelProduct ScopeLevel = "Product" ++ ScopeLevelPlatform ScopeLevel = "Platform" ++ ScopeLevelCluster ScopeLevel = "Cluster" ++ ScopeLevelEnvironment ScopeLevel = "Environment" ++ ScopeLevelTenant ScopeLevel = "Tenant" ++ ScopeLevelNamespace ScopeLevel = "Namespace" ++ ScopeLevelDomain ScopeLevel = "Domain" ++ ScopeLevelWorkspace ScopeLevel = "Workspace" ++ ScopeLevelProject ScopeLevel = "Project" ++ ScopeLevelProcess ScopeLevel = "Process" ++ ScopeLevelDataset ScopeLevel = "Dataset" ++ ScopeLevelResource ScopeLevel = "Resource" ++ ScopeLevelSubresource ScopeLevel = "Subresource" ++ ScopeLevelRecord ScopeLevel = "Record" ++ ScopeLevelField ScopeLevel = "Field" ++ ScopeLevelAction ScopeLevel = "Action" ++) ++ ++// Plane is the CARING access-surface dimension. ++type Plane string ++ ++const ( ++ PlaneIntent Plane = "Intent" ++ PlaneBuild Plane = "Build" ++ PlaneRuntime Plane = "Runtime" ++ PlaneExecution Plane = "Execution" ++ PlaneConfiguration Plane = "Configuration" ++ PlaneData Plane = "Data" ++ PlaneIdentity Plane = "Identity" ++ PlanePolicy Plane = "Policy" ++ PlaneSecret Plane = "Secret" ++ PlaneAudit Plane = "Audit" ++ PlaneCommercial Plane = "Commercial" ++ PlaneCommunity Plane = "Community" ++) ++ ++// Capability is a CARING capability verb. ++type Capability string ++ ++const ( ++ CapabilityView Capability = "View" ++ CapabilityViewCollection Capability = "ViewCollection" ++ CapabilityObserve Capability = "Observe" ++ CapabilityCreate Capability = "Create" ++ CapabilityEditOwn Capability = "EditOwn" ++ CapabilityEditAssigned Capability = "EditAssigned" ++ CapabilityEditAny Capability = "EditAny" ++ CapabilityDeleteOwn Capability = "DeleteOwn" ++ CapabilityDeleteAny Capability = "DeleteAny" ++ CapabilityBulkDelete Capability = "BulkDelete" ++ CapabilitySubmit Capability = "Submit" ++ CapabilityComment Capability = "Comment" ++ CapabilityReview Capability = "Review" ++ CapabilityApprove Capability = "Approve" ++ CapabilityReject Capability = "Reject" ++ CapabilityPublish Capability = "Publish" ++ CapabilityArchive Capability = "Archive" ++ CapabilityRestore Capability = "Restore" ++ CapabilityExecute Capability = "Execute" ++ CapabilityConfigure Capability = "Configure" ++ CapabilityOperate Capability = "Operate" ++ CapabilityDeploy Capability = "Deploy" ++ CapabilityIntegrate Capability = "Integrate" ++ CapabilityGrant Capability = "Grant" ++ CapabilityRevoke Capability = "Revoke" ++ CapabilityDelegate Capability = "Delegate" ++ CapabilityImpersonate Capability = "Impersonate" ++ CapabilityExport Capability = "Export" ++ CapabilityImport Capability = "Import" ++ CapabilityReplicate Capability = "Replicate" ++ CapabilityEncrypt Capability = "Encrypt" ++ CapabilityDecrypt Capability = "Decrypt" ++ CapabilityMask Capability = "Mask" ++ CapabilityInspect Capability = "Inspect" ++ CapabilityAudit Capability = "Audit" ++ CapabilityOverride Capability = "Override" ++ CapabilityEscalate Capability = "Escalate" ++ CapabilityBind Capability = "Bind" ++ CapabilityUse Capability = "Use" ++) ++ ++// ExposureMode describes how much information becomes visible or extractable. ++type ExposureMode string ++ ++const ( ++ ExposureModeNone ExposureMode = "None" ++ ExposureModeMetadata ExposureMode = "Metadata" ++ ExposureModeMasked ExposureMode = "Masked" ++ ExposureModeAggregated ExposureMode = "Aggregated" ++ ExposureModeSynthetic ExposureMode = "Synthetic" ++ ExposureModePseudonymous ExposureMode = "Pseudonymous" ++ ExposureModeEncrypted ExposureMode = "Encrypted" ++ ExposureModePlaintext ExposureMode = "Plaintext" ++ ExposureModeSecretMaterial ExposureMode = "SecretMaterial" ++ ExposureModeExportable ExposureMode = "Exportable" ++ ExposureModeCrossTenantAggregate ExposureMode = "CrossTenantAggregate" ++) ++ ++// Condition is a CARING runtime or governance condition. ++type Condition string ++ ++const ( ++ ConditionMFARequired Condition = "MFARequired" ++ ConditionDeviceTrusted Condition = "DeviceTrusted" ++ ConditionNetworkTrusted Condition = "NetworkTrusted" ++ ConditionTicketRequired Condition = "TicketRequired" ++ ConditionTenantConsentRequired Condition = "TenantConsentRequired" ++ ConditionCustomerApprovalRequired Condition = "CustomerApprovalRequired" ++ ConditionDualApprovalRequired Condition = "DualApprovalRequired" ++ ConditionTimeLimited Condition = "TimeLimited" ++ ConditionBusinessHoursOnly Condition = "BusinessHoursOnly" ++ ConditionEmergencyOnly Condition = "EmergencyOnly" ++ ConditionTrainingRequired Condition = "TrainingRequired" ++ ConditionContractRequired Condition = "ContractRequired" ++ ConditionNDARequired Condition = "NDARequired" ++ ConditionPurposeBound Condition = "PurposeBound" ++ ConditionCaseBound Condition = "CaseBound" ++ ConditionEnvironmentBound Condition = "EnvironmentBound" ++ ConditionNamespaceBound Condition = "NamespaceBound" ++ ConditionPipelineBound Condition = "PipelineBound" ++ ConditionChangeWindowBound Condition = "ChangeWindowBound" ++ ConditionLogged Condition = "Logged" ++ ConditionRecorded Condition = "Recorded" ++ ConditionNotificationRequired Condition = "NotificationRequired" ++ ConditionPostReviewRequired Condition = "PostReviewRequired" ++ ConditionHumanReviewRequired Condition = "HumanReviewRequired" ++ ConditionPolicyReviewRequired Condition = "PolicyReviewRequired" ++ ConditionWorkloadIdentityRequired Condition = "WorkloadIdentityRequired" ++) ++ ++// LifecycleState describes why access exists now. ++type LifecycleState string ++ ++const ( ++ LifecycleStateDesign LifecycleState = "Design" ++ LifecycleStateBuild LifecycleState = "Build" ++ LifecycleStateTest LifecycleState = "Test" ++ LifecycleStateReview LifecycleState = "Review" ++ LifecycleStateRelease LifecycleState = "Release" ++ LifecycleStateOnboard LifecycleState = "Onboard" ++ LifecycleStateIntegrate LifecycleState = "Integrate" ++ LifecycleStateMigrate LifecycleState = "Migrate" ++ LifecycleStateOperate LifecycleState = "Operate" ++ LifecycleStateSupport LifecycleState = "Support" ++ LifecycleStateImprove LifecycleState = "Improve" ++ LifecycleStateDeprecate LifecycleState = "Deprecate" ++ LifecycleStateArchive LifecycleState = "Archive" ++ LifecycleStateIncident LifecycleState = "Incident" ++ LifecycleStateLegal LifecycleState = "Legal" ++ LifecycleStateTerminate LifecycleState = "Terminate" ++) ++ ++// Restriction is an overriding CARING deny or limiting policy effect. ++type Restriction string ++ ++const ( ++ RestrictionNoAccess Restriction = "NoAccess" ++ RestrictionSuspended Restriction = "Suspended" ++ RestrictionTerminated Restriction = "Terminated" ++ RestrictionQuarantined Restriction = "Quarantined" ++ RestrictionScopeExcluded Restriction = "ScopeExcluded" ++ RestrictionDataClassRestricted Restriction = "DataClassRestricted" ++ RestrictionLegalHold Restriction = "LegalHold" ++ RestrictionExportBlocked Restriction = "ExportBlocked" ++ RestrictionImpersonationBlocked Restriction = "ImpersonationBlocked" ++ RestrictionCrossTenantBlocked Restriction = "CrossTenantBlocked" ++ RestrictionSecretAccessBlocked Restriction = "SecretAccessBlocked" ++ RestrictionPolicyFrozen Restriction = "PolicyFrozen" ++ RestrictionEmergencyLocked Restriction = "EmergencyLocked" ++ RestrictionRiskDenied Restriction = "RiskDenied" ++ RestrictionExecutionBlocked Restriction = "ExecutionBlocked" ++ RestrictionWorkloadCreationBlocked Restriction = "WorkloadCreationBlocked" ++ RestrictionPrivilegeEscalationBlocked Restriction = "PrivilegeEscalationBlocked" ++) ++ ++// ExposureEventType is a CARING exceptional or irregular access class. ++type ExposureEventType string ++ ++const ( ++ ExposureEventSupport ExposureEventType = "X-Support" ++ ExposureEventBreakGlass ExposureEventType = "X-BreakGlass" ++ ExposureEventSecurityTest ExposureEventType = "X-SecurityTest" ++ ExposureEventIncident ExposureEventType = "X-Incident" ++ ExposureEventLegalDemand ExposureEventType = "X-LegalDemand" ++ ExposureEventComplianceAudit ExposureEventType = "X-ComplianceAudit" ++ ExposureEventMigration ExposureEventType = "X-Migration" ++ ExposureEventRecovery ExposureEventType = "X-Recovery" ++ ExposureEventAdversarial ExposureEventType = "X-Adversarial" ++ ExposureEventMisconfiguration ExposureEventType = "X-Misconfiguration" ++ ExposureEventInducedAccess ExposureEventType = "X-InducedAccess" ++ ExposureEventPrivilegeEscalation ExposureEventType = "X-PrivilegeEscalation" ++) ++ ++// AccessPath describes how access is exercised. ++type AccessPath string ++ ++const ( ++ AccessPathDirect AccessPath = "direct" ++ AccessPathDelegated AccessPath = "delegated" ++ AccessPathMediated AccessPath = "mediated" ++ AccessPathInduced AccessPath = "induced" ++) ++ ++// CaringScope identifies where a CARING descriptor applies. ++type CaringScope struct { ++ Level ScopeLevel `json:"level" yaml:"level"` ++ ID string `json:"id" yaml:"id"` ++ Parent string `json:"parent,omitempty" yaml:"parent,omitempty"` ++ Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"` ++ Resource string `json:"resource,omitempty" yaml:"resource,omitempty"` ++ Attributes map[string]any `json:"attributes,omitempty" yaml:"attributes,omitempty"` ++} ++ ++// CaringAccessDescriptor is the executable flex-auth representation of a ++// CARING access assignment. ++type CaringAccessDescriptor struct { ++ ID string `json:"id,omitempty" yaml:"id,omitempty"` ++ Profile string `json:"profile" yaml:"profile"` ++ SubjectType SubjectType `json:"subject_type" yaml:"subject_type"` ++ OrganizationRelation OrganizationRelation `json:"organization_relation" yaml:"organization_relation"` ++ CanonicalRole CanonicalRole `json:"canonical_role" yaml:"canonical_role"` ++ Scope CaringScope `json:"scope" yaml:"scope"` ++ Planes []Plane `json:"planes" yaml:"planes"` ++ Capabilities []Capability `json:"capabilities" yaml:"capabilities"` ++ ExposureModes []ExposureMode `json:"exposure_modes,omitempty" yaml:"exposure_modes,omitempty"` ++ Conditions []Condition `json:"conditions,omitempty" yaml:"conditions,omitempty"` ++ LifecycleState LifecycleState `json:"lifecycle_state,omitempty" yaml:"lifecycle_state,omitempty"` ++ Restrictions []Restriction `json:"restrictions,omitempty" yaml:"restrictions,omitempty"` ++ ExposureEvent ExposureEventType `json:"exposure_event,omitempty" yaml:"exposure_event,omitempty"` ++ DerivedCapabilities []CaringDerivedCapability `json:"derived_capabilities,omitempty" yaml:"derived_capabilities,omitempty"` ++ AccessPath AccessPath `json:"access_path,omitempty" yaml:"access_path,omitempty"` ++ Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` ++} ++ ++// CaringDerivedCapability records effective authority created by another grant. ++type CaringDerivedCapability struct { ++ Capability Capability `json:"capability" yaml:"capability"` ++ Reason string `json:"reason" yaml:"reason"` ++ Source string `json:"source,omitempty" yaml:"source,omitempty"` ++ Planes []Plane `json:"planes,omitempty" yaml:"planes,omitempty"` ++ ExposureModes []ExposureMode `json:"exposure_modes,omitempty" yaml:"exposure_modes,omitempty"` ++} ++ ++// CaringConformanceFinding is a diagnostic emitted by descriptive or ++// prescriptive CARING validation. ++type CaringConformanceFinding struct { ++ Code string `json:"code" yaml:"code"` ++ Severity string `json:"severity" yaml:"severity"` ++ Message string `json:"message" yaml:"message"` ++ Fields []string `json:"fields,omitempty" yaml:"fields,omitempty"` ++ Descriptor string `json:"descriptor,omitempty" yaml:"descriptor,omitempty"` ++ Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` ++} ++ ++// CaringExposureEvent records exceptional or irregular information exposure. ++type CaringExposureEvent struct { ++ ID string `json:"id" yaml:"id"` ++ Type ExposureEventType `json:"type" yaml:"type"` ++ Actor string `json:"actor" yaml:"actor"` ++ Subject string `json:"subject" yaml:"subject"` ++ Descriptor *CaringAccessDescriptor `json:"descriptor,omitempty" yaml:"descriptor,omitempty"` ++ Scope *CaringScope `json:"scope,omitempty" yaml:"scope,omitempty"` ++ Planes []Plane `json:"planes,omitempty" yaml:"planes,omitempty"` ++ CapabilitiesUsed []Capability `json:"capabilities_used,omitempty" yaml:"capabilities_used,omitempty"` ++ DerivedCapabilities []CaringDerivedCapability `json:"derived_capabilities,omitempty" yaml:"derived_capabilities,omitempty"` ++ ExposureModes []ExposureMode `json:"exposure_modes,omitempty" yaml:"exposure_modes,omitempty"` ++ Reason string `json:"reason" yaml:"reason"` ++ AuthoritySource string `json:"authority_source,omitempty" yaml:"authority_source,omitempty"` ++ Approval string `json:"approval,omitempty" yaml:"approval,omitempty"` ++ StartTime string `json:"start_time,omitempty" yaml:"start_time,omitempty"` ++ EndTime string `json:"end_time,omitempty" yaml:"end_time,omitempty"` ++ ResourcesAccessed []string `json:"resources_accessed,omitempty" yaml:"resources_accessed,omitempty"` ++ Evidence []string `json:"evidence,omitempty" yaml:"evidence,omitempty"` ++ NotificationStatus string `json:"notification_status,omitempty" yaml:"notification_status,omitempty"` ++ PostReview string `json:"post_review,omitempty" yaml:"post_review,omitempty"` ++ ConformanceFindings []CaringConformanceFinding `json:"conformance_findings,omitempty" yaml:"conformance_findings,omitempty"` ++ Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` ++} +--- /dev/null ++++ b/third_party/access-engine-pep/pkg/api/resource_manifest.go +@@ -0,0 +1,32 @@ ++package api ++ ++// ResourceManifest is the shape a protected system publishes to register ++// its resources with flex-auth. The shape is pinned against the ++// Markitect-side emitter in markitect-tool (MKTT-WP-0014); see ++// schemas/resource_manifest.schema.json for the JSON Schema and ++// examples/markitect/resource_manifest.yaml for the canonical example. ++type ResourceManifest struct { ++ ID string `json:"id" yaml:"id"` ++ System string `json:"system" yaml:"system"` ++ Resources []Resource `json:"resources" yaml:"resources"` ++ Actions []string `json:"actions,omitempty" yaml:"actions,omitempty"` ++ CaringProfile string `json:"caring_profile,omitempty" yaml:"caring_profile,omitempty"` ++ Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` ++} ++ ++// Resource is one entry in a ResourceManifest. ++type Resource struct { ++ ID string `json:"id" yaml:"id"` ++ Type string `json:"type" yaml:"type"` ++ Path string `json:"path,omitempty" yaml:"path,omitempty"` ++ Parent string `json:"parent,omitempty" yaml:"parent,omitempty"` ++ Labels []string `json:"labels,omitempty" yaml:"labels,omitempty"` ++ TrustZone string `json:"trust_zone,omitempty" yaml:"trust_zone,omitempty"` ++ Owner string `json:"owner,omitempty" yaml:"owner,omitempty"` ++ Caring *CaringAccessDescriptor `json:"caring,omitempty" yaml:"caring,omitempty"` ++ Attributes map[string]any `json:"attributes,omitempty" yaml:"attributes,omitempty"` ++} ++ ++// FlexAuthContractV0 is the metadata.flex_auth_contract value that ++// signals the v0 resource-registration contract. ++const FlexAuthContractV0 = "resource-registration-v0" +--- /dev/null ++++ b/third_party/access-engine-pep/pkg/decisionpep/client.go +@@ -0,0 +1,296 @@ ++// Package decisionpep authenticates the producer's decision record for one ++// current request. It never evaluates policy, caches a verdict, or discovers trust. ++package decisionpep ++ ++import ( ++ "bytes" ++ "context" ++ "crypto/ed25519" ++ "crypto/rand" ++ "crypto/tls" ++ "crypto/x509" ++ "encoding/base64" ++ "encoding/hex" ++ "encoding/json" ++ "errors" ++ "io" ++ "net/http" ++ "net/url" ++ "os" ++ "path/filepath" ++ "strings" ++ "time" ++ ++ "github.com/netkingdom/access-engine/internal/sign" ++ "github.com/netkingdom/access-engine/pkg/api" ++) ++ ++var ErrRefused = errors.New("authenticated decision refused") ++ ++const nonceKey = "decisionpep_request_nonce" ++ ++const JSONProfile = "lossless-json-v1" ++const provenanceMode = "standalone/lossless-json-v1" ++const maxBytes = 2 << 20 ++ ++// Admission is public, independently admitted owner configuration. TokenFile is ++// only a path to a private rotating caller credential, never a literal token. ++type Admission struct { ++ Endpoint string `json:"endpoint"` ++ CAFile string `json:"ca_file"` ++ TokenFile string `json:"token_file"` ++ Keys map[string]string `json:"keys"` ++ Evaluator string `json:"evaluator"` ++ PolicyPackage string `json:"policy_package"` ++ PolicyVersion string `json:"policy_version"` ++ PolicyDigest string `json:"policy_digest"` ++ RegistryDigest string `json:"registry_digest"` ++ CallerPrincipal string `json:"caller_principal"` ++ CallerAudience string `json:"caller_audience"` ++ MaxTTL string `json:"max_ttl"` ++ MaxClockSkew string `json:"max_clock_skew"` ++} ++ ++type Client struct { ++ admission Admission ++ keys map[string]ed25519.PublicKey ++ http *http.Client ++ maxTTL, skew time.Duration ++ now func() time.Time ++} ++ ++// Load reads a current independently admitted file. No trust is bootstrapped ++// from a PDP response, token contents, discovery endpoint, or environment value. ++func Load(path string) (*Client, error) { ++ raw, err := readFile(path, false) ++ if err != nil { ++ return nil, ErrRefused ++ } ++ var a Admission ++ d := json.NewDecoder(bytes.NewReader(raw)) ++ d.DisallowUnknownFields() ++ if d.Decode(&a) != nil { ++ return nil, ErrRefused ++ } ++ if _, err = d.Token(); err != io.EOF { ++ return nil, ErrRefused ++ } ++ return New(a) ++} ++ ++func New(a Admission) (*Client, error) { ++ u, err := url.Parse(a.Endpoint) ++ if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Path != "/v1/check" || u.RawQuery != "" || u.Fragment != "" { ++ return nil, ErrRefused ++ } ++ if !filepath.IsAbs(a.CAFile) || !filepath.IsAbs(a.TokenFile) || a.Evaluator == "" || a.PolicyPackage == "" || a.PolicyVersion == "" || a.CallerPrincipal == "" || a.CallerAudience == "" { ++ return nil, ErrRefused ++ } ++ if !validDigest(a.PolicyDigest) || !validDigest(a.RegistryDigest) || len(a.Keys) == 0 { ++ return nil, ErrRefused ++ } ++ ttl, err := time.ParseDuration(a.MaxTTL) ++ if err != nil || ttl <= 0 { ++ return nil, ErrRefused ++ } ++ skew, err := time.ParseDuration(a.MaxClockSkew) ++ if err != nil || skew < 0 || skew > ttl { ++ return nil, ErrRefused ++ } ++ ca, err := readFile(a.CAFile, false) ++ if err != nil { ++ return nil, ErrRefused ++ } ++ roots := x509.NewCertPool() ++ if !roots.AppendCertsFromPEM(ca) { ++ return nil, ErrRefused ++ } ++ keys := map[string]ed25519.PublicKey{} ++ for kid, value := range a.Keys { ++ b, e := base64.RawURLEncoding.Strict().DecodeString(value) ++ if e != nil || kid == "" || len(b) != ed25519.PublicKeySize { ++ return nil, ErrRefused ++ } ++ keys[kid] = append(ed25519.PublicKey(nil), b...) ++ } ++ transport := &http.Transport{Proxy: nil, TLSClientConfig: &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}, DisableKeepAlives: true} ++ client := &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return ErrRefused }} ++ return &Client{admission: a, keys: keys, http: client, maxTTL: ttl, skew: skew, now: time.Now}, nil ++} ++ ++func validDigest(value string) bool { ++ if !strings.HasPrefix(value, "sha256:") || len(value) != 71 { ++ return false ++ } ++ _, e := hex.DecodeString(value[7:]) ++ return e == nil && strings.ToLower(value) == value ++} ++ ++func readFile(path string, private bool) ([]byte, error) { ++ if !filepath.IsAbs(path) { ++ return nil, ErrRefused ++ } ++ f, e := os.Open(path) ++ if e != nil { ++ return nil, ErrRefused ++ } ++ defer f.Close() ++ info, e := f.Stat() ++ if e != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0022 != 0 { ++ return nil, ErrRefused ++ } ++ if private && info.Mode().Perm()&0007 != 0 { ++ return nil, ErrRefused ++ } ++ b, e := io.ReadAll(io.LimitReader(f, maxBytes+1)) ++ if e != nil || len(b) > maxBytes { ++ return nil, ErrRefused ++ } ++ return b, nil ++} ++ ++// Check sends exactly one request with a cryptographically fresh nonce. The ++// returned envelope is the producer's authenticated typed interpretation. ++// Callers must execute only the request's exact operation and honor their own ++// execution boundary; a login decision is never a later resource decision. ++func (c *Client) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) { ++ refuse := func() (api.DecisionEnvelope, error) { return api.DecisionEnvelope{}, ErrRefused } ++ if c == nil || request.Purpose != "" || request.Tenant == "" || request.Subject.ID == "" || request.Subject.Type == "" || request.Resource.ID == "" || request.Resource.Type == "" || request.Resource.System == "" || request.Action == "" { ++ return refuse() ++ } ++ if request.Subject.Tenant != request.Tenant || request.Resource.Tenant != request.Tenant { ++ return refuse() ++ } ++ if request.PolicyVersion != "" && request.PolicyVersion != c.admission.PolicyVersion { ++ return refuse() ++ } ++ // Use the producer's typed representation and preserve numeric lexemes. A ++ // producer that rounds before hashing fails this join rather than weakening it. ++ raw, err := json.Marshal(request) ++ if err != nil { ++ return refuse() ++ } ++ decoder := json.NewDecoder(bytes.NewReader(raw)) ++ decoder.UseNumber() ++ if decoder.Decode(&request) != nil { ++ return refuse() ++ } ++ if request.Context == nil { ++ request.Context = map[string]any{} ++ } ++ if _, exists := request.Context[nonceKey]; exists { ++ return refuse() ++ } ++ nonce := make([]byte, 32) ++ if _, err = rand.Read(nonce); err != nil { ++ return refuse() ++ } ++ request.Context[nonceKey] = hex.EncodeToString(nonce) ++ request.Context["decisionpep_json_profile"] = JSONProfile ++ request.ID = hex.EncodeToString(nonce) ++ request.PolicyVersion = c.admission.PolicyVersion ++ raw, err = json.Marshal(request) ++ if err != nil || len(raw) > maxBytes { ++ return refuse() ++ } ++ token, err := readFile(c.admission.TokenFile, true) ++ if err != nil { ++ return refuse() ++ } ++ value := strings.TrimSpace(string(token)) ++ if value == "" || strings.ContainsAny(value, " \t\r\n") { ++ return refuse() ++ } ++ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.admission.Endpoint, bytes.NewReader(raw)) ++ if err != nil { ++ return refuse() ++ } ++ req.Header.Set("Content-Type", "application/json") ++ req.Header.Set("Access-Engine-JSON-Profile", JSONProfile) ++ req.Header.Set("Authorization", "Bearer "+value) ++ started := c.now().UTC() ++ response, err := c.http.Do(req) ++ if err != nil { ++ return refuse() ++ } ++ defer response.Body.Close() ++ if response.StatusCode != http.StatusOK { ++ return refuse() ++ } ++ body, err := io.ReadAll(io.LimitReader(response.Body, maxBytes+1)) ++ if err != nil || len(body) > maxBytes { ++ return refuse() ++ } ++ envelope, err := sign.DecodeEnvelope(body) ++ if err != nil || sign.Verify(envelope, c.keys) != nil { ++ return refuse() ++ } ++ if c.validate(envelope, request, started, c.now().UTC()) != nil { ++ return refuse() ++ } ++ return envelope, nil ++} ++ ++func (c *Client) validate(e api.DecisionEnvelope, r api.CheckRequest, started, now time.Time) error { ++ p := e.Provenance ++ b := e.Binding ++ l := e.Lifetime ++ if e.ID == "" || e.ContractVersion != api.DecisionRecordContractV1 || e.Effect != api.DecisionEffectAllow || e.RequestID != r.ID || len(e.Obligations) != 0 || b == nil || l == nil { ++ return ErrRefused ++ } ++ if p.Evaluator != c.admission.Evaluator || p.Mode != provenanceMode || p.PolicyPackage != c.admission.PolicyPackage || p.PolicyVersion != c.admission.PolicyVersion || p.PolicyPackageDigest != c.admission.PolicyDigest || p.RegistrySnapshotDigest != c.admission.RegistryDigest || e.MatchedPolicyVersion != p.PolicyVersion { ++ return ErrRefused ++ } ++ if b.Purpose != "" || b.SubmittedRequestDigest != api.RequestDigest(r) || b.Tenant != r.Tenant || b.Action != r.Action || api.CanonicalDigest(b.Context) != api.CanonicalDigest(r.Context) { ++ return ErrRefused ++ } ++ if !sameSubject(b.Subject, r.Subject) || !sameResource(b.Resource, r.Resource) || !sameSubject(e.Subject, b.Subject) || !sameResource(e.Resource, b.Resource) { ++ return ErrRefused ++ } ++ evaluated := api.CheckRequest{Tenant: b.Tenant, Subject: b.Subject, Action: b.Action, Resource: b.Resource, Context: b.Context, Purpose: b.Purpose} ++ if b.RequestDigest != api.RequestDigest(evaluated) { ++ return ErrRefused ++ } ++ for k, v := range api.InputClaimDigests(r) { ++ if p.InputClaimDigests[k] != v { ++ return ErrRefused ++ } ++ } ++ caller := p.Caller ++ if caller == nil || caller.Mode != "enforce" || caller.Principal != c.admission.CallerPrincipal || caller.Audience != c.admission.CallerAudience { ++ return ErrRefused ++ } ++ callerEnd, err := time.Parse(time.RFC3339Nano, caller.NotAfter) ++ if err != nil || !now.Before(callerEnd) { ++ return ErrRefused ++ } ++ decision, err := time.Parse(time.RFC3339Nano, p.DecisionTime) ++ if err != nil || decision.Before(started.Add(-c.skew)) || decision.After(now.Add(c.skew)) { ++ return ErrRefused ++ } ++ begin, err := time.Parse(time.RFC3339Nano, l.NotBefore) ++ if err != nil || begin.After(now) { ++ return ErrRefused ++ } ++ end, err := time.Parse(time.RFC3339Nano, l.ExpiresAt) ++ if err != nil || !now.Before(end) || !end.After(begin) || end.Sub(begin) > c.maxTTL || end.After(callerEnd) { ++ return ErrRefused ++ } ++ ttl, err := time.ParseDuration(l.TTL) ++ if err != nil || l.Kind != api.DecisionLifetimeTTL || ttl <= 0 || ttl > c.maxTTL { ++ return ErrRefused ++ } ++ // Producer CompleteDecision uses its exact decision instant for both bounds. ++ // RFC3339 output can truncate subsecond precision, so do not invent lost time. ++ if !begin.Equal(decision) || end.Sub(begin) != ttl { ++ return ErrRefused ++ } ++ return nil ++} ++ ++func sameSubject(a, b api.SubjectRef) bool { ++ return a.ID == b.ID && a.Type == b.Type && a.Tenant == b.Tenant ++} ++func sameResource(a, b api.ResourceRef) bool { ++ return a.ID == b.ID && a.Type == b.Type && a.System == b.System && a.Tenant == b.Tenant ++} +--- /dev/null ++++ b/third_party/access-engine-pep/pkg/decisionpep/client_test.go +@@ -0,0 +1,211 @@ ++package decisionpep ++ ++import ( ++ "context" ++ "crypto/ed25519" ++ "crypto/rand" ++ "crypto/x509" ++ "crypto/x509/pkix" ++ "encoding/base64" ++ "encoding/json" ++ "encoding/pem" ++ "io" ++ "math/big" ++ "net/http" ++ "os" ++ "path/filepath" ++ "strings" ++ "testing" ++ "time" ++ ++ "github.com/netkingdom/access-engine/internal/sign" ++ "github.com/netkingdom/access-engine/pkg/api" ++) ++ ++type transportFunc func(*http.Request) (*http.Response, error) ++ ++func (f transportFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } ++func testClient(t *testing.T) (*Client, ed25519.PrivateKey, time.Time) { ++ t.Helper() ++ pub, key, e := ed25519.GenerateKey(rand.Reader) ++ if e != nil { ++ t.Fatal(e) ++ } ++ now := time.Date(2026, 10, 18, 1, 2, 3, 0, time.UTC) ++ dir := t.TempDir() ++ cert, e := x509.CreateCertificate(rand.Reader, &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test-only"}, NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}, &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test-only"}, NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}, pub, key) ++ if e != nil { ++ t.Fatal(e) ++ } ++ ca := filepath.Join(dir, "ca.pem") ++ token := filepath.Join(dir, "token") ++ if e = os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert}), 0600); e != nil { ++ t.Fatal(e) ++ } ++ if e = os.WriteFile(token, []byte("synthetic-caller-token"), 0600); e != nil { ++ t.Fatal(e) ++ } ++ a := Admission{Endpoint: "https://pdp.invalid/v1/check", CAFile: ca, TokenFile: token, Keys: map[string]string{"test-key": base64.RawURLEncoding.EncodeToString(pub)}, Evaluator: "flex-auth/local", PolicyPackage: "forgejo.application-access", PolicyVersion: "v1", PolicyDigest: "sha256:" + strings.Repeat("1", 64), RegistryDigest: "sha256:" + strings.Repeat("2", 64), CallerPrincipal: "system:serviceaccount:test:forgejo", CallerAudience: "access-engine", MaxTTL: "30s", MaxClockSkew: "1s"} ++ c, e := New(a) ++ if e != nil { ++ t.Fatal(e) ++ } ++ c.now = func() time.Time { return now } ++ return c, key, now ++} ++func testRequest() api.CheckRequest { ++ return api.CheckRequest{Tenant: "tenant:test", Subject: api.SubjectRef{ID: "subject:test", Type: api.SubjectType("Human"), Tenant: "tenant:test", Attributes: map[string]any{"groups": []string{"engineers"}}}, Action: "application.access", Resource: api.ResourceRef{ID: "application:forgejo", Type: "application", System: "forgejo", Tenant: "tenant:test"}, Context: map[string]any{"big": json.Number("9007199254740993"), "fraction": json.Number("0.123456789123456789")}} ++} ++func envelope(c *Client, r api.CheckRequest, key ed25519.PrivateKey, now time.Time) api.DecisionEnvelope { ++ e := api.DecisionEnvelope{ID: "test-decision", RequestID: r.ID, Effect: api.DecisionEffectAllow, MatchedPolicyVersion: c.admission.PolicyVersion, Subject: r.Subject, Resource: r.Resource, Binding: api.NewDecisionBindingFor(r, r), Provenance: api.DecisionProvenance{Evaluator: c.admission.Evaluator, Mode: provenanceMode, PolicyPackage: c.admission.PolicyPackage, PolicyVersion: c.admission.PolicyVersion, PolicyPackageDigest: c.admission.PolicyDigest, RegistrySnapshotDigest: c.admission.RegistryDigest, Caller: &api.CallerProvenance{Mode: "enforce", Principal: c.admission.CallerPrincipal, Audience: c.admission.CallerAudience, NotAfter: now.Add(time.Minute).Format(time.RFC3339Nano)}}} ++ api.CompleteDecision(&e, r, api.DecisionCompletion{AllowTTL: "10s", Now: now}) ++ if sign.Sign(&e, key, "test-key") != nil { ++ panic("test sign") ++ } ++ return e ++} ++func respond(t *testing.T, c *Client, key ed25519.PrivateKey, now time.Time, mutate func(*api.DecisionEnvelope), resign bool) { ++ t.Helper() ++ c.http = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) { ++ if req.Header.Get("Access-Engine-JSON-Profile") != JSONProfile { ++ t.Fatal("missing lossless profile") ++ } ++ if req.Header.Get("Authorization") != "Bearer synthetic-caller-token" { ++ t.Fatal("wrong synthetic caller token") ++ } ++ var r api.CheckRequest ++ d := json.NewDecoder(req.Body) ++ d.UseNumber() ++ if d.Decode(&r) != nil { ++ t.Fatal("request decode") ++ } ++ if r.Context["big"] != json.Number("9007199254740993") || r.Context["fraction"] != json.Number("0.123456789123456789") { ++ t.Fatal("request numeric precision lost") ++ } ++ if len(r.Context[nonceKey].(string)) != 64 || r.ID != r.Context[nonceKey] { ++ t.Fatal("nonce") ++ } ++ e := envelope(c, r, key, now) ++ if mutate != nil { ++ mutate(&e) ++ } ++ if resign { ++ if sign.Sign(&e, key, "test-key") != nil { ++ t.Fatal("sign") ++ } ++ } ++ b, err := json.Marshal(e) ++ if err != nil { ++ t.Fatal(err) ++ } ++ return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(string(b))), Header: http.Header{}}, nil ++ })} ++} ++func TestAuthenticDecisionProducerCanonicalPrecision(t *testing.T) { ++ c, key, now := testClient(t) ++ respond(t, c, key, now, nil, false) ++ e, err := c.Check(context.Background(), testRequest()) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if e.Binding.Context["big"] != json.Number("9007199254740993") || e.Binding.Context["fraction"] != json.Number("0.123456789123456789") { ++ t.Fatal("signed typed precision lost") ++ } ++ if sign.Verify(e, c.keys) != nil { ++ t.Fatal("returned envelope not authentic producer bytes") ++ } ++} ++func TestDecisionAdverseBindings(t *testing.T) { ++ cases := map[string]func(*api.DecisionEnvelope){ ++ "unsigned": func(e *api.DecisionEnvelope) { e.Signature = api.UnsignedEnvelopeSignature() }, ++ "effect": func(e *api.DecisionEnvelope) { e.Effect = api.DecisionEffectDeny }, ++ "evaluator": func(e *api.DecisionEnvelope) { e.Provenance.Evaluator = "impostor" }, ++ "policy": func(e *api.DecisionEnvelope) { e.Provenance.PolicyPackage = "other" }, ++ "version": func(e *api.DecisionEnvelope) { e.Provenance.PolicyVersion = "v2" }, ++ "policy-digest": func(e *api.DecisionEnvelope) { e.Provenance.PolicyPackageDigest = "sha256:" + strings.Repeat("3", 64) }, ++ "registry": func(e *api.DecisionEnvelope) { ++ e.Provenance.RegistrySnapshotDigest = "sha256:" + strings.Repeat("4", 64) ++ }, ++ "tenant": func(e *api.DecisionEnvelope) { e.Binding.Tenant = "other" }, ++ "actor": func(e *api.DecisionEnvelope) { e.Binding.Subject.ID = "other" }, ++ "action": func(e *api.DecisionEnvelope) { e.Binding.Action = "write" }, ++ "resource": func(e *api.DecisionEnvelope) { e.Binding.Resource.ID = "other" }, ++ "purpose": func(e *api.DecisionEnvelope) { e.Binding.Purpose = "readback" }, ++ "submitted": func(e *api.DecisionEnvelope) { e.Binding.SubmittedRequestDigest = "wrong" }, ++ "context": func(e *api.DecisionEnvelope) { e.Binding.Context["fraction"] = json.Number("0.123456789123456788") }, ++ "legacy-numeric-profile": func(e *api.DecisionEnvelope) { e.Provenance.Mode = "standalone" }, ++ "caller-warn": func(e *api.DecisionEnvelope) { e.Provenance.Caller.Mode = "warn" }, ++ "caller-principal": func(e *api.DecisionEnvelope) { e.Provenance.Caller.Principal = "other" }, ++ "caller-audience": func(e *api.DecisionEnvelope) { e.Provenance.Caller.Audience = "other" }, ++ "caller-expired": func(e *api.DecisionEnvelope) { e.Provenance.Caller.NotAfter = "2020-01-01T00:00:00Z" }, ++ "stale-decision": func(e *api.DecisionEnvelope) { e.Provenance.DecisionTime = "2020-01-01T00:00:00Z" }, ++ "expired": func(e *api.DecisionEnvelope) { e.Lifetime.ExpiresAt = "2020-01-01T00:00:00Z" }, ++ "future-begin": func(e *api.DecisionEnvelope) { e.Lifetime.NotBefore = "2030-01-01T00:00:00Z" }, ++ "subnanosecond-expiry": func(e *api.DecisionEnvelope) { e.Lifetime.ExpiresAt = "2026-10-18T01:02:02.999999999Z" }, ++ "missing-TTL": func(e *api.DecisionEnvelope) { e.Lifetime = nil }, ++ "excess-TTL": func(e *api.DecisionEnvelope) { e.Lifetime.TTL = "1h" }, ++ "obligation": func(e *api.DecisionEnvelope) { e.Obligations = []api.Obligation{{Type: "unimplemented"}} }, ++ } ++ for name, mutation := range cases { ++ t.Run(name, func(t *testing.T) { ++ c, key, now := testClient(t) ++ respond(t, c, key, now, mutation, name != "unsigned") ++ if _, err := c.Check(context.Background(), testRequest()); err == nil { ++ t.Fatal("accepted wrong signed binding") ++ } ++ }) ++ } ++} ++func TestSignatureForgeryAndReplay(t *testing.T) { ++ t.Run("forged-payload", func(t *testing.T) { ++ c, key, now := testClient(t) ++ respond(t, c, key, now, func(e *api.DecisionEnvelope) { e.Reason = "changed after signature" }, false) ++ if _, e := c.Check(context.Background(), testRequest()); e == nil { ++ t.Fatal("forgery accepted") ++ } ++ }) ++ t.Run("wrong-key", func(t *testing.T) { ++ c, _, now := testClient(t) ++ _, key, _ := ed25519.GenerateKey(rand.Reader) ++ respond(t, c, key, now, nil, false) ++ if _, e := c.Check(context.Background(), testRequest()); e == nil { ++ t.Fatal("wrong key accepted") ++ } ++ }) ++ t.Run("replay", func(t *testing.T) { ++ c, key, now := testClient(t) ++ var saved []byte ++ c.http = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) { ++ if saved == nil { ++ var r api.CheckRequest ++ d := json.NewDecoder(req.Body) ++ d.UseNumber() ++ if d.Decode(&r) != nil { ++ t.Fatal("decode") ++ } ++ e := envelope(c, r, key, now) ++ saved, _ = json.Marshal(e) ++ } ++ return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(string(saved)))}, nil ++ })} ++ if _, e := c.Check(context.Background(), testRequest()); e != nil { ++ t.Fatal(e) ++ } ++ if _, e := c.Check(context.Background(), testRequest()); e == nil { ++ t.Fatal("old decision replay accepted for new nonce") ++ } ++ }) ++} ++func TestUnavailableAndPublicTokenFileRefuse(t *testing.T) { ++ c, _, _ := testClient(t) ++ c.http = &http.Client{Transport: transportFunc(func(*http.Request) (*http.Response, error) { return nil, io.ErrUnexpectedEOF })} ++ if _, e := c.Check(context.Background(), testRequest()); e == nil { ++ t.Fatal("unavailable allowed") ++ } ++ if e := os.Chmod(c.admission.TokenFile, 0644); e != nil { ++ t.Fatal(e) ++ } ++ if _, e := c.Check(context.Background(), testRequest()); e == nil { ++ t.Fatal("public token file allowed") ++ } ++} +--- /dev/null ++++ b/third_party/access-engine-pep/producer-source.json +@@ -0,0 +1,30 @@ ++{ ++ "upstream": "github.com/netkingdom/access-engine", ++ "commit": "d328a2601095686f74376d87791bff01e2a3a2a3", ++ "exact_unchanged_files": { ++ "pkg/api/canonical.go": "a122a2e8cd524333b13c5939c7a3685e41efbe47497a64e0c7297c92b7f10d19", ++ "pkg/api/caring.go": "1907156f0f889e7b9e9c14970cf7a86a20d00478a496efaf92c7231f27fec5e3", ++ "pkg/api/resource_manifest.go": "f0fa4080287d389ed48c2b1d6af96cc62e57ef72c902333f677f964420be133f", ++ "internal/sign/sign.go": "0df9515922e90775ebbfac798361dcafb20da8a4213796b44e246b8a92046cde", ++ "LICENSE": "ff413f361a3b3bf44cf50f110fbcc4837064d57eefbcef89df2b43997c6262b6" ++ }, ++ "module": "Standalone Go1.24 source slice with original module/import identity; no canonical/schema/signature modifications", ++ "new_code": [ ++ "pkg/decisionpep/client.go" ++ ], ++ "fixture_code": [ ++ "testsupport/signfixture/sign.go" ++ ], ++ "source_of_truth": "repository root; generated with tools/export_decisionpep_adapter.py", ++ "exported_file_sha256": { ++ "LICENSE": "ff413f361a3b3bf44cf50f110fbcc4837064d57eefbcef89df2b43997c6262b6", ++ "go.mod": "fea700810e47c1ba5638b86a168f8531c083a6e1222ca80bd2a8947b2ea6f2c3", ++ "internal/sign/sign.go": "0df9515922e90775ebbfac798361dcafb20da8a4213796b44e246b8a92046cde", ++ "pkg/api/canonical.go": "a122a2e8cd524333b13c5939c7a3685e41efbe47497a64e0c7297c92b7f10d19", ++ "pkg/api/caring.go": "1907156f0f889e7b9e9c14970cf7a86a20d00478a496efaf92c7231f27fec5e3", ++ "pkg/api/resource_manifest.go": "f0fa4080287d389ed48c2b1d6af96cc62e57ef72c902333f677f964420be133f", ++ "pkg/decisionpep/client.go": "0d57f6ddaca5c3f81ce426c0e8b14ba14ea5962096d3be5909aa456eef19eeba", ++ "pkg/decisionpep/client_test.go": "1a6be3485f580f95a1f1406fb76e525f2c4bf02fe04a424cfe0095bb728e3f62", ++ "testsupport/signfixture/sign.go": "fe4aa86f444dc20145aefbaca35b24f046264b2c5c8b4e3b057a4496b77a4d29" ++ } ++} +--- /dev/null ++++ b/third_party/access-engine-pep/testsupport/signfixture/sign.go +@@ -0,0 +1,14 @@ ++// Package signfixture exposes producer signing only for synthetic consumer tests. ++// Callers supply test keys; this package contains no credentials or runtime signer. ++package signfixture ++ ++import ( ++ "crypto/ed25519" ++ "github.com/netkingdom/access-engine/internal/sign" ++ "github.com/netkingdom/access-engine/pkg/api" ++) ++ ++// Sign uses the unchanged producer envelope format with a caller-owned fixture key. ++func Sign(envelope *api.DecisionEnvelope, key ed25519.PrivateKey, kid string) error { ++ return sign.Sign(envelope, key, kid) ++} diff --git a/build/forgejo/inputs.lock.json b/build/forgejo/inputs.lock.json index 743f819..645c5cb 100644 --- a/build/forgejo/inputs.lock.json +++ b/build/forgejo/inputs.lock.json @@ -3,13 +3,13 @@ "forgejo_version": "11.0.3", "source_archive_sha256": "1ddd8c926ca07d40436897971458ace7dcf1d3496a0813ab9e0d869edef2365c", "source_commit": "b81aa4d0807cc1e2d6a2fbafd0b75c46cc0ae237", - "patch_sha256": "f2788b3f2bf84dbc0f94e232b6c9614a864b8123c09f9e44703d50d5e1b53257", + "patch_sha256": "15cb7f13b1b1d1095cc344176ca337f705333a436a70ae803fcfd3d7f802ccf4", "go_archive_sha256": "3333f6ea53afa971e9078895eaa4ac7204a8c6b5c68c10e6bc9a33e8e391bdd8", "go_version": "1.24.3", "node_archive_sha256": "dafe2e8f82cb97de1bd10db9e2ec4c07bbf53389b0799b1e095a918951e78fd4", "node_version": "22.15.0", "source_date_epoch": 1752105237, - "release_version": "11.0.3+nk48.f2788b3f", + "release_version": "11.0.3+cust80.15cb7f13", "tags": "bindata timetzdata sqlite sqlite_unlock_notify", "runtime_base": "code.forgejo.org/forgejo/forgejo@sha256:41649dff5311664097d29b4d80620403f0b27e7c0eee0b8af79b132a37f3807b", "runtime_index_digest": "sha256:691657c7e64af7730a2e885e6d022cf16ae60724931c5392bd61838afadcaeba", @@ -17,7 +17,59 @@ "reviewed_outputs": [ { "path": "cmd/admin_auth_oauth.go", - "sha256": "1dd03f0bd23889a340a10fcc18d2e3a49270b79056aafd1b3baf01b18559b37f" + "sha256": "d718d817c46f6393488a9c5a894b7535640cd0ec1a1ae3f72f8411a2bbec3b38" + }, + { + "path": "cmd/admin_auth_oauth_secret_linux.go", + "sha256": "6741961f1df67c1afbf34c796e549425f0e04a0d0678c6eedb05f66d4754c64b" + }, + { + "path": "cmd/admin_auth_oauth_secret_linux_test.go", + "sha256": "b28c2369dfab9bf2ca48e707e01b4394a36c8be5490cfe5bb658017bb626c4b2" + }, + { + "path": "cmd/admin_auth_oauth_secret_other.go", + "sha256": "54056c989a4757e73db0252130e7c16bf16f5ec8eaaca2468f019ba2ef5c532d" + }, + { + "path": "go.mod", + "sha256": "130138c4c3ecc29fa0d7f1e2eef0589ddf3b06071f3cc0448aa7b2326847653a" + }, + { + "path": "models/auth/source_operation.go", + "sha256": "ef1b07ef6cf454015d00cde8588835f5118d829da91eb0303d37184859345a31" + }, + { + "path": "models/auth/source_operation_internal_test.go", + "sha256": "af0f424d3a1fd2b29793b64baa1fa07284fee7ea0b7721c503db4efac0d871cb" + }, + { + "path": "models/auth/source_operation_process_test.go", + "sha256": "40d33b11a01cb659f0bf6f8b545c4d79910e8c082a789060bcbc9cadeee965bd" + }, + { + "path": "models/auth/source_operation_test.go", + "sha256": "77d311bca42f48faf3fbdc15c53582e3d8a6d9108a72ed7bb6172e5b88951428" + }, + { + "path": "models/db/context.go", + "sha256": "d4c4acfcb8987071aecf5cd957b3821c8d2619a2696398d37c2970f2527686f7" + }, + { + "path": "models/db/engine.go", + "sha256": "43683804d0c5bc6ead1dba0344c69dd196825cf3d5eb502af8d932a7b8207b96" + }, + { + "path": "models/db/sensitive_sql.go", + "sha256": "06a298d0d6e628e6920384d4ddeef449e40c090841ebaa3f6d175725a45ae584" + }, + { + "path": "models/db/sensitive_sql_logging_test.go", + "sha256": "d924b35d30686473caf29601cf51cc363ba68158cb4ef0981c9e2fc7b03c04b7" + }, + { + "path": "models/db/sensitive_sql_test.go", + "sha256": "de6353c955047c3311747e4777b9f9b2195e9efb19160b971dd758dfc94cf31b" }, { "path": "routers/web/admin/auths.go", @@ -25,7 +77,31 @@ }, { "path": "routers/web/auth/oauth.go", - "sha256": "081bcb94023d6e27289196bbe518bebba0a8a6082ea1a32d2794c5562075907b" + "sha256": "58bff778e0dc17a521706289bf45c3e0d76a759a0874f76c379b3d1ddb419b17" + }, + { + "path": "services/auth/source/oauth2/access_engine.go", + "sha256": "d5f07f9491ac2212410e1fa8c0248d68ba3a723e7ca99e363627c5292d187aa8" + }, + { + "path": "services/auth/source/oauth2/access_engine_assurance.go", + "sha256": "42de751b1e9a243301d566260954813b770558ecb449639acaeab307fe788642" + }, + { + "path": "services/auth/source/oauth2/access_engine_assurance_test.go", + "sha256": "44e7459f6c1106a264093c19d61d01a8365f6ad22682906552d832ba39d61dd3" + }, + { + "path": "services/auth/source/oauth2/access_engine_azp_test.go", + "sha256": "5f94f43ade9a79b6b7ef529c80115a8c32acfece425dc8ed3f24e6ad839fc0ce" + }, + { + "path": "services/auth/source/oauth2/access_engine_callback_test.go", + "sha256": "5cea36b630c895a9473dc876b7128526ddef65d9395b2fddc56343e162452472" + }, + { + "path": "services/auth/source/oauth2/access_engine_test.go", + "sha256": "99edd6cfe3e834d73e0447a7915f52cc4ab7b82644669c5ae61963f850b447e8" }, { "path": "services/auth/source/oauth2/providers_openid.go", @@ -42,10 +118,54 @@ { "path": "services/auth/source/oauth2/source.go", "sha256": "44e99883c2ec479427b3f72c73353fe33936eed2e5c962b2fa40803576aa5642" + }, + { + "path": "services/auth/source/oauth2/source_callout.go", + "sha256": "6abba69df97afc62467f0328eaa5a6383cfb4900202a081dec4b84a21b37349c" + }, + { + "path": "third_party/access-engine-pep/LICENSE", + "sha256": "ff413f361a3b3bf44cf50f110fbcc4837064d57eefbcef89df2b43997c6262b6" + }, + { + "path": "third_party/access-engine-pep/go.mod", + "sha256": "fea700810e47c1ba5638b86a168f8531c083a6e1222ca80bd2a8947b2ea6f2c3" + }, + { + "path": "third_party/access-engine-pep/internal/sign/sign.go", + "sha256": "0df9515922e90775ebbfac798361dcafb20da8a4213796b44e246b8a92046cde" + }, + { + "path": "third_party/access-engine-pep/pkg/api/canonical.go", + "sha256": "a122a2e8cd524333b13c5939c7a3685e41efbe47497a64e0c7297c92b7f10d19" + }, + { + "path": "third_party/access-engine-pep/pkg/api/caring.go", + "sha256": "1907156f0f889e7b9e9c14970cf7a86a20d00478a496efaf92c7231f27fec5e3" + }, + { + "path": "third_party/access-engine-pep/pkg/api/resource_manifest.go", + "sha256": "f0fa4080287d389ed48c2b1d6af96cc62e57ef72c902333f677f964420be133f" + }, + { + "path": "third_party/access-engine-pep/pkg/decisionpep/client.go", + "sha256": "0d57f6ddaca5c3f81ce426c0e8b14ba14ea5962096d3be5909aa456eef19eeba" + }, + { + "path": "third_party/access-engine-pep/pkg/decisionpep/client_test.go", + "sha256": "1a6be3485f580f95a1f1406fb76e525f2c4bf02fe04a424cfe0095bb728e3f62" + }, + { + "path": "third_party/access-engine-pep/producer-source.json", + "sha256": "c1d83e3d79f9860ffaa61ec19a6b3415e914d3058b11b07cdd05a7d1182cdceb" + }, + { + "path": "third_party/access-engine-pep/testsupport/signfixture/sign.go", + "sha256": "fe4aa86f444dc20145aefbaca35b24f046264b2c5c8b4e3b057a4496b77a4d29" } ], "lockfiles": { - "go.mod": "652162a4a95c6d1c349d2a8c0b514def5abd837f001137b77b7b3f4a0f162d00", + "go.mod": "130138c4c3ecc29fa0d7f1e2eef0589ddf3b06071f3cc0448aa7b2326847653a", "go.sum": "19c305c7e68de9d3f1bc0dda2de09c5d38597f1ee7fa411c7b36b92467ae1eaf", "package.json": "ab5c54ec7077501b50acf3ab5f1fff4f63a51abe99e720b8ecdc6cc6c78f39fc", "package-lock.json": "fdd5c2f891311be7c01b1907b418c243ca1f0f951fd4b4ee5b5e931c43493657" @@ -59,5 +179,8 @@ "/usr/lib/x86_64-linux-gnu/libc.a": "a40c7eae7581f977b65eddb81cbc799213a695749e039d966db55bdee4ac9621", "/usr/lib/x86_64-linux-gnu/libm.a": "420cd3c406f6f8c12deb3d1291146e9c091ee015a7c39eaf2237f01e80d6f0ae" }, - "runtime_base_config_sha256": "bc5a3df111729582faadebd01d126ba769e2b65d0c62f306f6bc79f4e562452b" + "runtime_base_config_sha256": "bc5a3df111729582faadebd01d126ba769e2b65d0c62f306f6bc79f4e562452b", + "patch_file": "forgejo-security-extension.patch", + "candidate_review_status": "coordinator tested; independent owner review pending", + "producer_source_commit": "ca0cb75729a63c9288b7333382d0fa3eb1e8c9a3" } diff --git a/tools/forgejo-release-build.py b/tools/forgejo-release-build.py index 08524d2..491f710 100644 --- a/tools/forgejo-release-build.py +++ b/tools/forgejo-release-build.py @@ -73,7 +73,7 @@ def prepare(a,work): check(getattr(a,n),LOCK[key]) if not a.module_cache:raise ValueError('explicit public module cache required') cache=Path(a.module_cache).resolve();assert cache.is_dir() - check(INPUTS/'required-claim-values.patch',LOCK['patch_sha256']) + check(INPUTS/LOCK['patch_file'],LOCK['patch_sha256']) for n,h in LOCK['host_tools'].items():check(n,h) work.mkdir(parents=True);(work/'home').mkdir() with tarfile.open(a.source_archive) as t: @@ -85,8 +85,8 @@ def prepare(a,work): for n in ['empty.npmrc','empty-global.npmrc']:(work/n).write_text('') if a.go_cache_seed:shutil.copytree(Path(a.go_cache_seed).resolve(),work/'gocache') if a.npm_cache_seed:shutil.copytree(Path(a.npm_cache_seed).resolve(),work/'npm-cache') - subprocess.run(['/usr/bin/git','apply','--check',str(INPUTS/'required-claim-values.patch')],cwd=work/'source',check=True,env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null'}) - subprocess.run(['/usr/bin/git','apply',str(INPUTS/'required-claim-values.patch')],cwd=work/'source',check=True,env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null'}) + subprocess.run(['/usr/bin/git','apply','--check',str(INPUTS/LOCK['patch_file'])],cwd=work/'source',check=True,env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null'}) + subprocess.run(['/usr/bin/git','apply',str(INPUTS/LOCK['patch_file'])],cwd=work/'source',check=True,env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null'}) save(work/'prepared.json',{'module_cache':str(cache),'lock_sha256':digest(INPUTS/'inputs.lock.json'),'public_archives_verified':True}) checked_inputs(work) packages=json.loads((work/'source/package-lock.json').read_text())['packages'] @@ -136,6 +136,7 @@ def verify(work): run(work,'binary-version',bare+['--version'],outer) run(work,'new-cli-help',bare+['admin','auth','add-oauth','--help'],outer) assert '--required-claim-values' in (work/'new-cli-help.log').read_text() + assert '--secret-file' in (work/'new-cli-help.log').read_text() run(work,'embedded-extract',bare+['embedded','extract','--include-vendored','--destination','/results','**'],outer) records={} for folder in ['public','options','templates']: diff --git a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md index 6abc696..e733dd0 100644 --- a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md +++ b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md @@ -246,7 +246,7 @@ its historical text is retained and corrected here. ```task id: RAPPS-WP-0016-T09 -status: todo +status: progress priority: high state_hub_task_id: "d3804638-c73d-5b9f-a456-4262f62f9e6b" ``` @@ -261,3 +261,23 @@ issuer allowed/denied claim cannot manufacture authorization. Source integration needs real provider/callback/session wiring and meaningful producer-consumer positive/adverse tests before re-freezing the case and package. Existing break- glass/local account and linking behavior remain within the declared owner scope. + +2026-10-18 source continuation for T07/T08/T09: the digest-bound build +candidate now composes all 37 paths, including private secret-file input, actual +source-state transactions/private SQL hooks, the producer-owned Access Engine +Check client from ca0cb757 and guarded OIDC callback enforcement. The parser uses +the actual KeyCape typed assurance/authentication-time/provenance contract; its +actual issuer fixture/callback suite passes 71 test/subtest results, and selected +composed packages pass 93 with no skips. The previous incompatible-string-claim +rejection is retained. All patch postimages independently replay from the pinned +official archive. Complete release build/verification is tracked in Custodian +forgejo-keycape-claims-r1; earlier release outputs do not prove this revision. + +This is coordinator source integration of a tested build candidate; independent +owner review and formal operational admission remain open. T07's chart/private +mount admission, T08's fresh authenticated operation/inverse and release-wide +writer exclusion, T09's ongoing HTTP/API/Git/SSH paths, private evidence/retention, +PostgreSQL/native proof and actual issuer/factor/custody acceptance remain open. +No trust, deployed image, Helm values, source DB, user/link state or local sign-in +is changed by this build-source update. The preserved legacy patch is historical; +the input lock now explicitly selects forgejo-security-extension.patch. From 308e83fcfbf9ec98dbe6b0286cbe8ec7be8899b3 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 18 Oct 2026 03:21:56 +0200 Subject: [PATCH 46/87] Verify Forgejo PostgreSQL operations and preserve tracing hook context Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a14bfc-9643-7872-aa12-98326dcf25e7 --- build/forgejo/README.md | 27 +- .../forgejo/forgejo-security-extension.patch | 448 +++++++++++++++++- build/forgejo/inputs.lock.json | 28 +- .../RAPPS-WP-0016-forgejo-netkingdom-login.md | 27 ++ 4 files changed, 521 insertions(+), 9 deletions(-) diff --git a/build/forgejo/README.md b/build/forgejo/README.md index 56d2a7b..9a0562e 100644 --- a/build/forgejo/README.md +++ b/build/forgejo/README.md @@ -1,6 +1,6 @@ # Forgejo release preparation (build only) -The Apps Makefile targets package the digest-bound 37-path Forgejo 11.0.3 +The Apps Makefile targets package the digest-bound 40-path Forgejo 11.0.3 source candidate in `forgejo-security-extension.patch`. It combines the original three-group any-of gate, private secret-file input, private source-operation DB primitives and the producer-owned Access Engine client in the OIDC callback. @@ -108,6 +108,29 @@ prove an actual browser, factor, custody or native rollout. The current integration covers this guarded OIDC callback. Ongoing application HTTP/API/Git/SSH paths, current operation/inverse sessions, durable private -evidence and retention, PostgreSQL/native proof and exclusion of all release +evidence and retention, deployed PostgreSQL/custody acceptance and exclusion of all release writers remain open in RAPPS-WP-0016-T08/T09 and NK-WP-0048-T43. An old image approval or source test result cannot admit this candidate. + +## PostgreSQL operation-source verification + +The source primitive has now run on a disposable PostgreSQL 16.14 instance using +the actual Forgejo engine, model schemas and OAuth configuration types. Tests +cover default, explicit-public and named schemas, exact source restoration, drift +and linked-account refusals, trigger rollback, uncooperative SQL writer exclusion +and concurrent preparation. Independent client processes and an actual server +restart verify prepared/applied/inverse journal recovery. The test-only +`source_operation_postgres` tag selects the integration tests; no runtime route, +credential, session or authority callback is added. The companion Custodian +`forgejo-postgres-source-r1` packet holds the isolated fixture, commands, source +pins, counterexamples and original failed attempts. + +Actual PostgreSQL diagnostics exposed a tracing-hook composition crash. Tracing +now publishes its task context to the shared XORM hook context so later hooks +retain it. Ordinary and private SQLite traces and actual PostgreSQL failure +queries exercise this path. Controlled copies without the table lock or private +query marker fail their respective regressions. These are source/database +mechanics checks; owner admission, current authenticated operation/inverse +sessions, runtime/backup custody, retention, every release writer and native +deployed-database acceptance remain separate obligations. The previous frozen +release is historical and does not contain this context correction. diff --git a/build/forgejo/forgejo-security-extension.patch b/build/forgejo/forgejo-security-extension.patch index cd0fb52..9465e3f 100644 --- a/build/forgejo/forgejo-security-extension.patch +++ b/build/forgejo/forgejo-security-extension.patch @@ -922,6 +922,388 @@ + } +} --- /dev/null ++++ b/models/auth/source_operation_postgres_export_test.go +@@ -0,0 +1,7 @@ ++//go:build source_operation_postgres ++ ++package auth ++ ++// Test-only seam: the production transaction body still obtains its real locks ++// and private query context. No runtime API or authority callback is added. ++var SourceOperationPostgresTransactionForTest = sourceOperationTx +--- /dev/null ++++ b/models/auth/source_operation_postgres_test.go +@@ -0,0 +1,369 @@ ++//go:build source_operation_postgres ++ ++package auth_test ++ ++import ( ++ "bytes" ++ "context" ++ "errors" ++ "fmt" ++ "os" ++ "runtime/trace" ++ "strings" ++ "sync" ++ "testing" ++ "time" ++ ++ auth "forgejo.org/models/auth" ++ "forgejo.org/models/db" ++ user_model "forgejo.org/models/user" ++ "forgejo.org/modules/log" ++ "forgejo.org/modules/setting" ++ oauth2 "forgejo.org/services/auth/source/oauth2" ++ ++ "github.com/lib/pq" ++ "github.com/stretchr/testify/require" ++ "xorm.io/xorm" ++) ++ ++type postgresOperationLog struct { ++ log.Logger ++ mu sync.Mutex ++ entries []string ++} ++ ++func (l *postgresOperationLog) Log(_ int, _ log.Level, format string, values ...any) { ++ l.mu.Lock() ++ defer l.mu.Unlock() ++ l.entries = append(l.entries, fmt.Sprintf(format, values...)) ++} ++func (l *postgresOperationLog) take() string { ++ l.mu.Lock() ++ defer l.mu.Unlock() ++ result := strings.Join(l.entries, "\n") ++ l.entries = nil ++ return result ++} ++ ++// The real Forgejo engine/driver, registered model schemas and public operation ++// functions run. Only the database, application configuration and actors are ++// synthetic. No deployed owner database or authenticated operation is claimed. ++func postgresOperationDB(t *testing.T, schema string) (context.Context, *xorm.Engine, *xorm.Engine) { ++ t.Helper() ++ require.Equal(t, "/run/source-operation-postgres", os.Getenv("CUSTODIAN_PG_SOCKET"), "explicit isolated socket required") ++ oldSettings, oldContext := setting.Database, db.DefaultContext ++ oldEngine, ok := db.GetEngine(oldContext).(*xorm.Engine) ++ require.True(t, ok) ++ expectedSchema := schema ++ if expectedSchema == "" { ++ expectedSchema = "public" ++ } ++ raw, err := xorm.NewEngine("postgres", "host=/run/source-operation-postgres user=custodian_test dbname=custodian_source sslmode=disable search_path="+expectedSchema) ++ require.NoError(t, err) ++ _, err = raw.Exec(`CREATE SCHEMA IF NOT EXISTS "` + expectedSchema + `"`) ++ require.NoError(t, err) ++ setting.Database.Type = "postgres" ++ setting.Database.Host = "/run/source-operation-postgres" ++ setting.Database.Name = "custodian_source" ++ setting.Database.User = "custodian_test" ++ setting.Database.Passwd = "" ++ setting.Database.Schema = schema ++ setting.Database.SSLMode = "disable" ++ setting.Database.LogSQL = false ++ setting.Database.MaxOpenConns, setting.Database.MaxIdleConns = 8, 2 ++ setting.Database.SlowQueryThreshold = 0 ++ require.NoError(t, db.InitEngine(context.Background())) ++ engine, ok := db.GetEngine(db.DefaultContext).(*xorm.Engine) ++ require.True(t, ok) ++ t.Cleanup(func() { ++ _ = engine.Close() ++ _ = raw.Close() ++ setting.Database = oldSettings ++ db.SetDefaultEngine(oldContext, oldEngine) ++ }) ++ version, err := engine.Query("SELECT current_setting('server_version_num')::integer / 10000 AS major, current_schema() AS schema") ++ require.NoError(t, err) ++ require.Equal(t, "16", string(version[0]["major"])) ++ require.Equal(t, expectedSchema, string(version[0]["schema"])) ++ require.NoError(t, db.SyncAllTables()) ++ auth.RegisterTypeConfig(auth.OAuth2, new(oauth2.Source)) ++ return db.DefaultContext, engine, raw ++} ++ ++func resetPostgresOperations(t *testing.T, engine *xorm.Engine) { ++ t.Helper() ++ _, err := engine.Exec(`DROP TRIGGER IF EXISTS custodian_source_rewrite ON login_source`) ++ require.NoError(t, err) ++ _, err = engine.Exec(`TRUNCATE login_source_operation, login_source, "user", external_login_user RESTART IDENTITY CASCADE`) ++ require.NoError(t, err) ++} ++ ++func TestSourceOperationPostgres(t *testing.T) { ++ for _, profile := range []struct{ name, schema string }{{"default", ""}, {"public", "public"}, {"named", "custodian_named"}} { ++ t.Run(profile.name, func(t *testing.T) { ++ ctx, engine, raw := postgresOperationDB(t, profile.schema) ++ run := func(name string, f func(*testing.T)) { ++ t.Run(name, func(t *testing.T) { resetPostgresOperations(t, engine); f(t) }) ++ } ++ run("create-reconcile-inverse", func(t *testing.T) { ++ before := operationRows(t, ctx) ++ _, err := auth.PrepareSourceOperation(ctx, "postgres-create-operation", "postgres-create-case", 0, operationSource()) ++ require.NoError(t, err) ++ require.Equal(t, before, operationRows(t, ctx)) ++ r, err := auth.ApplySourceOperation(ctx, "postgres-create-operation", "postgres-create-case") ++ require.NoError(t, err) ++ require.Equal(t, "applied", r.Phase) ++ require.Positive(t, r.SourceID) ++ post := operationRows(t, ctx) ++ again, err := auth.ApplySourceOperation(ctx, "postgres-create-operation", "postgres-create-case") ++ require.NoError(t, err) ++ require.Equal(t, r, again) ++ require.Equal(t, post, operationRows(t, ctx)) ++ _, err = auth.InverseSourceOperation(ctx, "postgres-create-operation", "postgres-wrong-case") ++ require.ErrorIs(t, err, auth.ErrSourceOperationConflict) ++ r, err = auth.ReconcileSourceOperation(ctx, "postgres-create-operation", "postgres-create-case") ++ require.NoError(t, err) ++ require.Equal(t, "applied", r.Phase) ++ r, err = auth.InverseSourceOperation(ctx, "postgres-create-operation", "postgres-create-case") ++ require.NoError(t, err) ++ require.Equal(t, "reverted", r.Phase) ++ require.Equal(t, before, operationRows(t, ctx)) ++ _, err = auth.ApplySourceOperation(ctx, "postgres-create-operation", "postgres-create-case") ++ require.ErrorIs(t, err, auth.ErrSourceOperationConflict) ++ }) ++ run("exact-raw-preexisting-restoration", func(t *testing.T) { ++ s := operationSource() ++ require.NoError(t, auth.CreateSource(ctx, s)) ++ _, err := engine.Exec(`UPDATE login_source SET cfg = ? WHERE id = ?`, `{"Provider":"openidConnect","ClientID":"synthetic-client","ClientSecret":"synthetic-private-original","FutureGuard":{"x":true}}`, s.ID) ++ require.NoError(t, err) ++ before := operationRows(t, ctx) ++ s.Cfg.(*oauth2.Source).ClientSecret = "synthetic-private-new" ++ _, err = auth.PrepareSourceOperation(ctx, "postgres-restore-operation", "postgres-restore-case", s.ID, s) ++ require.NoError(t, err) ++ _, err = auth.ApplySourceOperation(ctx, "postgres-restore-operation", "postgres-restore-case") ++ require.NoError(t, err) ++ _, err = auth.InverseSourceOperation(ctx, "postgres-restore-operation", "postgres-restore-case") ++ require.NoError(t, err) ++ require.Equal(t, before, operationRows(t, ctx)) ++ }) ++ run("unchanged-preexisting-survives", func(t *testing.T) { ++ s := operationSource() ++ require.NoError(t, auth.CreateSource(ctx, s)) ++ before := operationRows(t, ctx) ++ _, err := auth.PrepareSourceOperation(ctx, "postgres-unchanged-operation", "postgres-unchanged-case", s.ID, s) ++ require.NoError(t, err) ++ r, err := auth.ApplySourceOperation(ctx, "postgres-unchanged-operation", "postgres-unchanged-case") ++ require.NoError(t, err) ++ require.Equal(t, "unchanged", r.Phase) ++ _, err = auth.InverseSourceOperation(ctx, "postgres-unchanged-operation", "postgres-unchanged-case") ++ require.NoError(t, err) ++ require.Equal(t, before, operationRows(t, ctx)) ++ }) ++ for _, stage := range []string{"before-apply", "before-inverse"} { ++ run("private-drift-"+stage, func(t *testing.T) { ++ s := operationSource() ++ require.NoError(t, auth.CreateSource(ctx, s)) ++ _, err := auth.PrepareSourceOperation(ctx, "postgres-drift-operation", "postgres-drift-case", s.ID, s) ++ require.NoError(t, err) ++ if stage == "before-inverse" { ++ _, err = auth.ApplySourceOperation(ctx, "postgres-drift-operation", "postgres-drift-case") ++ require.NoError(t, err) ++ } ++ _, err = engine.Exec(`UPDATE login_source SET cfg = cfg || ' ' WHERE id = ?`, s.ID) ++ require.NoError(t, err) ++ before := operationRows(t, ctx) ++ if stage == "before-apply" { ++ _, err = auth.ApplySourceOperation(ctx, "postgres-drift-operation", "postgres-drift-case") ++ } else { ++ _, err = auth.InverseSourceOperation(ctx, "postgres-drift-operation", "postgres-drift-case") ++ } ++ require.ErrorIs(t, err, auth.ErrSourceOperationConflict) ++ require.Equal(t, before, operationRows(t, ctx)) ++ }) ++ } ++ for _, kind := range []string{"user", "external"} { ++ run("linked-"+kind+"-refuses-deletion", func(t *testing.T) { ++ _, err := auth.PrepareSourceOperation(ctx, "postgres-linked-operation", "postgres-linked-case", 0, operationSource()) ++ require.NoError(t, err) ++ r, err := auth.ApplySourceOperation(ctx, "postgres-linked-operation", "postgres-linked-case") ++ require.NoError(t, err) ++ if kind == "user" { ++ _, err = engine.Insert(&user_model.User{ID: 1, LowerName: "synthetic", Name: "synthetic", Email: "synthetic@invalid.test", LoginSource: r.SourceID}) ++ } else { ++ _, err = engine.Exec(`INSERT INTO external_login_user (external_id, user_id, login_source_id) VALUES ('synthetic-external', 1, ?)`, r.SourceID) ++ } ++ require.NoError(t, err) ++ before := operationRows(t, ctx) ++ _, err = auth.InverseSourceOperation(ctx, "postgres-linked-operation", "postgres-linked-case") ++ require.True(t, auth.IsErrSourceInUse(err)) ++ require.Equal(t, before, operationRows(t, ctx)) ++ var count int64 ++ if kind == "user" { ++ count, err = engine.Table("user").Where("login_source = ?", r.SourceID).Count() ++ } else { ++ count, err = engine.Table("external_login_user").Where("login_source_id = ?", r.SourceID).Count() ++ } ++ require.NoError(t, err) ++ require.EqualValues(t, 1, count) ++ }) ++ } ++ run("unexpected-trigger-post-state-rolls-back", func(t *testing.T) { ++ before := operationRows(t, ctx) ++ _, err := auth.PrepareSourceOperation(ctx, "postgres-trigger-operation", "postgres-trigger-case", 0, operationSource()) ++ require.NoError(t, err) ++ _, err = engine.Exec(`CREATE OR REPLACE FUNCTION custodian_rewrite_source() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN NEW.name := 'unexpected-rewrite'; RETURN NEW; END $$`) ++ require.NoError(t, err) ++ _, err = engine.Exec(`CREATE TRIGGER custodian_source_rewrite BEFORE INSERT ON login_source FOR EACH ROW EXECUTE FUNCTION custodian_rewrite_source()`) ++ require.NoError(t, err) ++ _, err = auth.ApplySourceOperation(ctx, "postgres-trigger-operation", "postgres-trigger-case") ++ require.ErrorIs(t, err, auth.ErrSourceOperationConflict) ++ require.Equal(t, before, operationRows(t, ctx)) ++ r, err := auth.ReconcileSourceOperation(ctx, "postgres-trigger-operation", "postgres-trigger-case") ++ require.NoError(t, err) ++ require.Equal(t, "prepared", r.Phase) ++ }) ++ run("driver-error-rollback-and-private-diagnostics", func(t *testing.T) { ++ captured := new(postgresOperationLog) ++ engine.AddHook(&db.SlowQueryHook{Logger: captured}) ++ engine.AddHook(&db.ErrorQueryHook{Logger: captured}) ++ const control = "synthetic-postgres-unmarked-control" ++ _, err := db.GetEngine(ctx).Exec("SELECT missing_synthetic_function(?)", control) ++ require.Error(t, err) ++ require.Contains(t, captured.take(), control, "actual unmarked diagnostic control must be observable") ++ _, err = engine.Exec(`CREATE OR REPLACE FUNCTION custodian_fail_source() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION '%', NEW.cfg; END $$`) ++ require.NoError(t, err) ++ _, err = engine.Exec(`CREATE TRIGGER custodian_source_rewrite BEFORE INSERT ON login_source FOR EACH ROW EXECUTE FUNCTION custodian_fail_source()`) ++ require.NoError(t, err) ++ before := operationRows(t, ctx) ++ _, err = auth.PrepareSourceOperation(ctx, "postgres-error-operation", "postgres-error-case", 0, operationSource()) ++ require.NoError(t, err) ++ captured.take() ++ var tracing bytes.Buffer ++ require.NoError(t, trace.Start(&tracing)) ++ _, err = auth.ApplySourceOperation(ctx, "postgres-error-operation", "postgres-error-case") ++ trace.Stop() ++ require.ErrorIs(t, err, auth.ErrSourceOperationStorage) ++ messages := captured.take() ++ require.Contains(t, messages, "[Error SQL Query] [owner-confined query]") ++ require.Contains(t, messages, "[Slow SQL Query] [owner-confined query]") ++ require.NotContains(t, messages, "synthetic-private-original") ++ require.False(t, bytes.Contains(tracing.Bytes(), []byte("synthetic-private-original")), "actual PostgreSQL trace disclosed private cfg") ++ require.Equal(t, before, operationRows(t, ctx)) ++ r, err := auth.ReconcileSourceOperation(ctx, "postgres-error-operation", "postgres-error-case") ++ require.NoError(t, err) ++ require.Equal(t, "prepared", r.Phase) ++ }) ++ run("uncooperative-writers-excluded", func(t *testing.T) { ++ locked, release, done := make(chan struct{}), make(chan struct{}), make(chan error, 1) ++ var once sync.Once ++ releaseLock := func() { once.Do(func() { close(release) }) } ++ defer releaseLock() ++ go func() { ++ done <- auth.SourceOperationPostgresTransactionForTest(ctx, func(context.Context) error { close(locked); <-release; return nil }) ++ }() ++ select { ++ case <-locked: ++ case err := <-done: ++ t.Fatalf("lock acquisition refused: %v", err) ++ case <-time.After(5 * time.Second): ++ t.Fatal("lock acquisition timed out") ++ } ++ statements := []string{`UPDATE login_source_operation SET phase=phase WHERE operation_id='absent'`, `UPDATE login_source SET name=name WHERE id=-1`, `UPDATE "user" SET login_source=login_source WHERE id=-1`, `UPDATE external_login_user SET login_source_id=login_source_id WHERE user_id=-1`} ++ for _, statement := range statements { ++ session := raw.NewSession() ++ require.NoError(t, session.Begin()) ++ _, err := session.Exec(`SET LOCAL lock_timeout='150ms'`) ++ require.NoError(t, err) ++ _, err = session.Exec(statement) ++ var pgerr *pq.Error ++ blocked := errors.As(err, &pgerr) && pgerr.Code == "55P03" ++ _ = session.Rollback() ++ _ = session.Close() ++ require.True(t, blocked, "an uncooperative writer escaped the actual table lock") ++ } ++ releaseLock() ++ select { ++ case err := <-done: ++ require.NoError(t, err) ++ case <-time.After(5 * time.Second): ++ t.Fatal("transaction did not finish") ++ } ++ for _, statement := range statements { ++ _, err := raw.Exec(statement) ++ require.NoError(t, err) ++ } ++ }) ++ run("concurrent-preparation-has-one-owner", func(t *testing.T) { ++ start, done := make(chan struct{}), make(chan error, 2) ++ for _, id := range []string{"postgres-concurrent-first", "postgres-concurrent-second"} { ++ go func(id string) { ++ <-start ++ _, err := auth.PrepareSourceOperation(ctx, id, "postgres-concurrent-case", 0, operationSource()) ++ done <- err ++ }(id) ++ } ++ close(start) ++ ok, conflict := 0, 0 ++ for range 2 { ++ select { ++ case err := <-done: ++ if err == nil { ++ ok++ ++ } else if errors.Is(err, auth.ErrSourceOperationConflict) { ++ conflict++ ++ } else { ++ t.Fatal("unexpected concurrent preparation error") ++ } ++ case <-time.After(5 * time.Second): ++ t.Fatal("concurrent preparation timed out") ++ } ++ } ++ require.Equal(t, 1, ok) ++ require.Equal(t, 1, conflict) ++ }) ++ }) ++ } ++} ++ ++func TestSourceOperationPostgresProcessRecovery(t *testing.T) { ++ step := os.Getenv("CUSTODIAN_PG_STEP") ++ require.NotEmpty(t, step, "explicit synthetic recovery phase required") ++ ctx, engine, _ := postgresOperationDB(t, "custodian_recovery") ++ switch step { ++ case "prepare": ++ resetPostgresOperations(t, engine) ++ _, err := auth.PrepareSourceOperation(ctx, "postgres-process-operation", "postgres-process-case", 0, operationSource()) ++ require.NoError(t, err) ++ case "crash": ++ engine.AddHook(operationCrashAfterSourceInsert{}) ++ _, _ = auth.ApplySourceOperation(ctx, "postgres-process-operation", "postgres-process-case") ++ t.Fatal("actual source insert did not reach the process-death hook") ++ case "recover-prepared": ++ r, err := auth.ReconcileSourceOperation(ctx, "postgres-process-operation", "postgres-process-case") ++ require.NoError(t, err) ++ require.Equal(t, "prepared", r.Phase) ++ n, err := engine.Table("login_source").Count() ++ require.NoError(t, err) ++ require.Zero(t, n) ++ _, err = auth.ApplySourceOperation(ctx, "postgres-process-operation", "postgres-process-case") ++ require.NoError(t, err) ++ case "recover-applied": ++ r, err := auth.ReconcileSourceOperation(ctx, "postgres-process-operation", "postgres-process-case") ++ require.NoError(t, err) ++ require.Equal(t, "applied", r.Phase) ++ n, err := engine.Table("login_source").Count() ++ require.NoError(t, err) ++ require.EqualValues(t, 1, n) ++ _, err = auth.InverseSourceOperation(ctx, "postgres-process-operation", "postgres-process-case") ++ require.NoError(t, err) ++ case "recover-inverse": ++ r, err := auth.ReconcileSourceOperation(ctx, "postgres-process-operation", "postgres-process-case") ++ require.NoError(t, err) ++ require.Equal(t, "reverted", r.Phase) ++ n, err := engine.Table("login_source").Count() ++ require.NoError(t, err) ++ require.Zero(t, n) ++ default: ++ t.Fatal("unknown synthetic recovery phase") ++ } ++} +--- /dev/null +++ b/models/auth/source_operation_process_test.go @@ -0,0 +1,97 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. @@ -1276,12 +1658,15 @@ return x.Context(ctx) --- a/models/db/engine.go +++ b/models/db/engine.go -@@ -330,8 +330,12 @@ +@@ -330,8 +330,15 @@ func (TracingHook) BeforeProcess(c *contexts.ContextHook) (context.Context, error) { ctx, task := trace.NewTask(c.Ctx, "sql") ctx = context.WithValue(ctx, sqlTask{}, task) - trace.Log(ctx, "query", c.SQL) - trace.Logf(ctx, "args", "%v", c.Args) ++ // XORM passes the same ContextHook to every BeforeProcess hook. Publish ++ // the task there too, so a later hook returning c.Ctx retains it. ++ c.Ctx = ctx + if isSensitiveSQL(c.Ctx) { + trace.Log(ctx, "query", "[owner-confined query]") + } else { @@ -1291,7 +1676,7 @@ return ctx, nil } -@@ -353,7 +357,11 @@ +@@ -353,7 +360,11 @@ func (h *SlowQueryHook) AfterProcess(c *contexts.ContextHook) error { if c.ExecuteTime >= h.Treshold { @@ -1304,7 +1689,7 @@ } return nil } -@@ -370,7 +378,11 @@ +@@ -370,7 +381,11 @@ func (h *ErrorQueryHook) AfterProcess(c *contexts.ContextHook) error { if c.Err != nil && !errors.Is(c.Err, context.Canceled) { @@ -1520,6 +1905,63 @@ + } + } +} +--- /dev/null ++++ b/models/db/tracing_context_test.go +@@ -0,0 +1,54 @@ ++package db ++ ++import ( ++ "bytes" ++ "context" ++ "runtime/trace" ++ "testing" ++ ++ "xorm.io/xorm" ++ "xorm.io/xorm/contexts" ++) ++ ++type followingQueryHook struct{} ++ ++func (followingQueryHook) BeforeProcess(c *contexts.ContextHook) (context.Context, error) { ++ return c.Ctx, nil ++} ++func (followingQueryHook) AfterProcess(*contexts.ContextHook) error { return nil } ++ ++func TestTracingContextSurvivesFollowingHooks(t *testing.T) { ++ for _, sensitive := range []bool{false, true} { ++ for _, fail := range []bool{false, true} { ++ engine, err := xorm.NewEngine("sqlite3", ":memory:") ++ if err != nil { ++ t.Fatal(err) ++ } ++ engine.AddHook(TracingHook{}) ++ engine.AddHook(followingQueryHook{}) ++ ctx := context.Background() ++ if sensitive { ++ ctx = WithSensitiveSQL(ctx) ++ } ++ var output bytes.Buffer ++ if err := trace.Start(&output); err != nil { ++ t.Fatal(err) ++ } ++ func() { ++ defer trace.Stop() ++ query := "SELECT ?" ++ if fail { ++ query = "SELECT missing_synthetic_function(?)" ++ } ++ _, err = engine.Context(ctx).Exec(query, "synthetic-following-hook-sentinel") ++ }() ++ _ = engine.Close() ++ if (err != nil) != fail { ++ t.Fatal("unexpected real SQLite query outcome") ++ } ++ if bytes.Contains(output.Bytes(), []byte("synthetic-following-hook-sentinel")) == sensitive { ++ t.Fatal("following hook changed private or ordinary trace disclosure") ++ } ++ } ++ } ++} --- a/routers/web/admin/auths.go +++ b/routers/web/admin/auths.go @@ -322,6 +322,13 @@ diff --git a/build/forgejo/inputs.lock.json b/build/forgejo/inputs.lock.json index 645c5cb..d3259d0 100644 --- a/build/forgejo/inputs.lock.json +++ b/build/forgejo/inputs.lock.json @@ -3,13 +3,13 @@ "forgejo_version": "11.0.3", "source_archive_sha256": "1ddd8c926ca07d40436897971458ace7dcf1d3496a0813ab9e0d869edef2365c", "source_commit": "b81aa4d0807cc1e2d6a2fbafd0b75c46cc0ae237", - "patch_sha256": "15cb7f13b1b1d1095cc344176ca337f705333a436a70ae803fcfd3d7f802ccf4", + "patch_sha256": "feb1079399a43989742ae0a946c1ada50113d1821e6541f97f0b5592ee1fc20c", "go_archive_sha256": "3333f6ea53afa971e9078895eaa4ac7204a8c6b5c68c10e6bc9a33e8e391bdd8", "go_version": "1.24.3", "node_archive_sha256": "dafe2e8f82cb97de1bd10db9e2ec4c07bbf53389b0799b1e095a918951e78fd4", "node_version": "22.15.0", "source_date_epoch": 1752105237, - "release_version": "11.0.3+cust80.15cb7f13", + "release_version": "11.0.3+cust80.feb10793", "tags": "bindata timetzdata sqlite sqlite_unlock_notify", "runtime_base": "code.forgejo.org/forgejo/forgejo@sha256:41649dff5311664097d29b4d80620403f0b27e7c0eee0b8af79b132a37f3807b", "runtime_index_digest": "sha256:691657c7e64af7730a2e885e6d022cf16ae60724931c5392bd61838afadcaeba", @@ -43,6 +43,14 @@ "path": "models/auth/source_operation_internal_test.go", "sha256": "af0f424d3a1fd2b29793b64baa1fa07284fee7ea0b7721c503db4efac0d871cb" }, + { + "path": "models/auth/source_operation_postgres_export_test.go", + "sha256": "b9d1553144766b15c408295b87506106935abf4ed61460598b58dc81825e808d" + }, + { + "path": "models/auth/source_operation_postgres_test.go", + "sha256": "4a37504e5af00580d114ef1bed5cb32e09e4ec2de5fd3192adef043a4ea5f7fe" + }, { "path": "models/auth/source_operation_process_test.go", "sha256": "40d33b11a01cb659f0bf6f8b545c4d79910e8c082a789060bcbc9cadeee965bd" @@ -57,7 +65,7 @@ }, { "path": "models/db/engine.go", - "sha256": "43683804d0c5bc6ead1dba0344c69dd196825cf3d5eb502af8d932a7b8207b96" + "sha256": "d21a5b0b9ca37ac48222b79007467a232abc38edda99b3c5f54f1f4c5ab795e8" }, { "path": "models/db/sensitive_sql.go", @@ -71,6 +79,10 @@ "path": "models/db/sensitive_sql_test.go", "sha256": "de6353c955047c3311747e4777b9f9b2195e9efb19160b971dd758dfc94cf31b" }, + { + "path": "models/db/tracing_context_test.go", + "sha256": "612b60c67a2be7e2da9173b95d5ef5abd7b9e1e377360828e39a622610bba070" + }, { "path": "routers/web/admin/auths.go", "sha256": "3498229e06fb9ff52396bcb5e68c0cc2d2ad24e6494533d8717d7c833f3e2923" @@ -182,5 +194,13 @@ "runtime_base_config_sha256": "bc5a3df111729582faadebd01d126ba769e2b65d0c62f306f6bc79f4e562452b", "patch_file": "forgejo-security-extension.patch", "candidate_review_status": "coordinator tested; independent owner review pending", - "producer_source_commit": "ca0cb75729a63c9288b7333382d0fa3eb1e8c9a3" + "producer_source_commit": "ca0cb75729a63c9288b7333382d0fa3eb1e8c9a3", + "optional_source_tests": { + "source_operation_postgres": { + "database_major": 16, + "synthetic_fixture_image": "postgres@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777", + "source_only": true, + "production_connection_or_credentials": false + } + } } diff --git a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md index e733dd0..d53106e 100644 --- a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md +++ b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md @@ -242,6 +242,33 @@ If links prevent removal, retain the extended image and reconcile. The earlier T02 single-Helm-rollback statement is not sufficient recovery for this extension; its historical text is retained and corrected here. +2026-10-18 PostgreSQL/source-context continuation for T08: actual Forgejo model +schemas, OAuth configuration types and production DB initialization run on a +disposable PostgreSQL 16.14 instance. All 37 test/subtest results pass across +default, explicit-public and named schemas. Separate client processes prove +rollback after process death immediately following INSERT, committed apply +survival through a real server restart, and subsequent reconciliation/inverse. +Exact raw restoration, private drift, linked-user/external-link refusal, trigger +rollback, table-writer exclusion and concurrent preparation are covered. +Controlled copies with the table lock or private-query context removed fail the +corresponding tests in all three schema profiles. All owned fixture containers +and volumes were removed; no deployed database or real credential was used. + +The diagnostics check exposed a real tracing-hook composition panic. The new +40-path build candidate publishes the task context into XORM's shared query +context so subsequent hooks preserve it. Actual private/ordinary SQLite traces +and PostgreSQL driver-error hooks cover the correction. Six focused SQLite DB +checks and all 94 selected composed package results pass without skips. The +previous panic, fixture/compilation failures and refinement history remain in +Custodian forgejo-postgres-source-r1. Complete release evidence uses the new +feb10793 patch; the prior frozen artifact is historical. + +This proves bounded source/database mechanics. T08 remains progress for current +authenticated initiating/inverse sessions, release-wide writer exclusion, private +evidence/custody/retention and native deployed-database acceptance. Independent +owner review and exact package/type/runtime admission remain open. T07/T09 and +all original task UUIDs and statuses are preserved. + ## T09 — Enforce authenticated Access Engine decisions in the application path ```task From a927ec8cc821a93688ca939e5a06eb0379f8d9c3 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 18 Oct 2026 12:17:36 +0200 Subject: [PATCH 47/87] Join Forgejo callback identity to the issuer-owned current access grant Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a14bfc-9643-7872-aa12-98326dcf25e7 --- build/forgejo/README.md | 33 +- .../forgejo/forgejo-security-extension.patch | 1175 ++++++++++++++++- build/forgejo/inputs.lock.json | 56 +- .../RAPPS-WP-0016-forgejo-netkingdom-login.md | 23 + 4 files changed, 1245 insertions(+), 42 deletions(-) diff --git a/build/forgejo/README.md b/build/forgejo/README.md index 9a0562e..bb1d4df 100644 --- a/build/forgejo/README.md +++ b/build/forgejo/README.md @@ -1,6 +1,6 @@ # Forgejo release preparation (build only) -The Apps Makefile targets package the digest-bound 40-path Forgejo 11.0.3 +The Apps Makefile targets package the digest-bound 49-path Forgejo 11.0.3 source candidate in `forgejo-security-extension.patch`. It combines the original three-group any-of gate, private secret-file input, private source-operation DB primitives and the producer-owned Access Engine client in the OIDC callback. @@ -134,3 +134,34 @@ mechanics checks; owner admission, current authenticated operation/inverse sessions, runtime/backup custody, retention, every release writer and native deployed-database acceptance remain separate obligations. The previous frozen release is historical and does not contain this context correction. + + +## Current-token callback source continuation + +The 49-path candidate additionally vendors the unchanged, producer-generated +KeyCape current-token adapter from `08c083ba`; no consumer token registry or +handwritten copy is introduced. `current_token` (explicit HTTPS issuer, resource +audience, owning client, CA/JWKS/private-client-file references and maximum token +age) and `application_tenant` are mandatory in the independently admitted +`FORGEJO_ACCESS_ENGINE_ADMISSION_FILE` for this guarded callback profile. A signed +caller cannot select the application's tenant. No operational configuration or +credential is supplied by the build. + +The callback requires the online KeyCape human ID token's exact RS256 `at_hash` +access-grant binding before private caller use. Access-only `jti`/`scope` cannot +substitute for an ID token. It joins independently verified subject/tenant/type, +issuance, assurance/authentication time and tenant provenance to a fresh issuer +observation before the PDP, then checks current status again after the decision. +Source tests refuse mixed, revoked, suspended, stale, malformed and unavailable +inputs; actual HTTPS issuer PKCE/discovery/UserInfo/status/revocation and real +Forgejo provider/callback run in five isolated consumer processes. MFA session +seeds and PDP admissions are synthetic. Complete release build/asset/OCI evidence +for the preceding patch remains historical; this revision requires its own build +and independent owner/package/type/custody admission before operational use. + +The user selected current KeyCape checks on every ongoing API/Git/SSH operation +for NetKingdom-linked accounts, while retaining the separate local break-glass +path. That is an implementation requirement under RAPPS16-T09/NK48-T43, not a +claim that the callback implements those routes or a native deployment grant. +Ongoing resource/action/current-group decisions, protected operation/inverse and +all release writers/journal custody remain acceptance work. diff --git a/build/forgejo/forgejo-security-extension.patch b/build/forgejo/forgejo-security-extension.patch index 9465e3f..25c1c91 100644 --- a/build/forgejo/forgejo-security-extension.patch +++ b/build/forgejo/forgejo-security-extension.patch @@ -390,7 +390,7 @@ +} --- a/go.mod +++ b/go.mod -@@ -255,3 +255,7 @@ +@@ -255,3 +255,11 @@ replace github.com/gliderlabs/ssh => code.forgejo.org/forgejo/ssh v0.0.0-20241211213324-5fc306ca0616 replace git.sr.ht/~mariusor/go-xsd-duration => code.forgejo.org/forgejo/go-xsd-duration v0.0.0-20220703122237-02e73435a078 @@ -398,6 +398,10 @@ +require github.com/netkingdom/access-engine v0.0.0 + +replace github.com/netkingdom/access-engine => ./third_party/access-engine-pep ++ ++require keycape v0.0.0 ++ ++replace keycape => ./third_party/keycape-tokenstatus --- /dev/null +++ b/models/auth/source_operation.go @@ -0,0 +1,430 @@ @@ -1997,7 +2001,7 @@ return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} --- /dev/null +++ b/services/auth/source/oauth2/access_engine.go -@@ -0,0 +1,224 @@ +@@ -0,0 +1,247 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + @@ -2020,6 +2024,7 @@ + "github.com/markbates/goth" + "github.com/netkingdom/access-engine/pkg/api" + "github.com/netkingdom/access-engine/pkg/decisionpep" ++ "keycape/pkg/tokenstatus" +) + +var errApplicationDecision = errors.New("current authenticated application access refused") @@ -2028,34 +2033,39 @@ +// reference to the private PDP caller token. It is not persisted OAuth source +// configuration and cannot be replaced by token claims or provider discovery. +type AccessEngineAdmission struct { -+ SourceName string `json:"source_name"` -+ Issuer string `json:"issuer"` -+ Audience string `json:"audience"` -+ TokenType string `json:"token_type"` -+ Algorithms []string `json:"algorithms"` -+ IdentityKeys map[string]string `json:"identity_keys"` -+ TenantClaim string `json:"tenant_claim"` -+ PrincipalTypeClaim string `json:"principal_type_claim"` -+ AssuranceClaim string `json:"assurance_claim"` -+ TenantSourceClaim string `json:"tenant_source_claim"` -+ MaxAuthenticationAge string `json:"max_authentication_age"` -+ MaxIdentityAge string `json:"max_identity_age"` -+ Decision decisionpep.Admission `json:"decision"` ++ SourceName string `json:"source_name"` ++ Issuer string `json:"issuer"` ++ Audience string `json:"audience"` ++ ApplicationTenant string `json:"application_tenant"` ++ TokenType string `json:"token_type"` ++ Algorithms []string `json:"algorithms"` ++ IdentityKeys map[string]string `json:"identity_keys"` ++ TenantClaim string `json:"tenant_claim"` ++ PrincipalTypeClaim string `json:"principal_type_claim"` ++ AssuranceClaim string `json:"assurance_claim"` ++ TenantSourceClaim string `json:"tenant_source_claim"` ++ MaxAuthenticationAge string `json:"max_authentication_age"` ++ MaxIdentityAge string `json:"max_identity_age"` ++ Decision decisionpep.Admission `json:"decision"` ++ CurrentToken tokenstatus.OnlineAdmission `json:"current_token"` +} + +// VerifiedApplicationIdentity is derived only by VerifyApplicationIdentity from +// independently admitted JOSE keys. RawData and UserInfo never supply these +// fields. This snapshot is an identity fact, never an authorization verdict. +type VerifiedApplicationIdentity struct { -+ Issuer string -+ Subject string -+ Tenant string -+ PrincipalType api.SubjectType -+ Assurance string -+ TenantSource string -+ AssuranceData map[string]any -+ AuthenticatedAt time.Time -+ NotAfter time.Time ++ Issuer string ++ Subject string ++ Tenant string ++ PrincipalType api.SubjectType ++ Assurance string ++ TenantSource string ++ AssuranceData map[string]any ++ AuthenticatedAt time.Time ++ IssuedAt time.Time ++ SigningAlgorithm string ++ AccessTokenHash string ++ NotAfter time.Time +} + +func loadAccessEngineAdmission(path string) (AccessEngineAdmission, error) { @@ -2129,6 +2139,13 @@ + if e != nil || token == nil || !token.Valid { + return out, errApplicationDecision + } ++ // The admitted KeyCape ID-token profile never carries access-grant fields. ++ // Both producer tokens use typ JWT, so the header alone cannot separate them. ++ for _, field := range []string{"jti", "scope"} { ++ if _, exists := claims[field]; exists { ++ return out, errApplicationDecision ++ } ++ } + subject, e := claims.GetSubject() + if e != nil || subject == "" { + return out, errApplicationDecision @@ -2154,7 +2171,7 @@ + } + value := func(name string) (string, bool) { v, ok := claims[name].(string); return v, ok && validClaimLiteral(v) } + tenant, ok := value(a.TenantClaim) -+ if !ok { ++ if !ok || a.ApplicationTenant == "" || tenant != a.ApplicationTenant { + return out, errApplicationDecision + } + principal, ok := value(a.PrincipalTypeClaim) @@ -2178,7 +2195,8 @@ + if !now.Before(validUntil) { + return out, errApplicationDecision + } -+ return VerifiedApplicationIdentity{Issuer: a.Issuer, Subject: subject, Tenant: tenant, PrincipalType: api.SubjectType(principal), Assurance: assurance, TenantSource: tenantSource, AssuranceData: assuranceData, AuthenticatedAt: authenticatedAt, NotAfter: validUntil}, nil ++ accessHash, _ := claims["at_hash"].(string) ++ return VerifiedApplicationIdentity{Issuer: a.Issuer, Subject: subject, Tenant: tenant, PrincipalType: api.SubjectType(principal), Assurance: assurance, TenantSource: tenantSource, AssuranceData: assuranceData, AuthenticatedAt: authenticatedAt, IssuedAt: issued.Time, SigningAlgorithm: token.Method.Alg(), AccessTokenHash: accessHash, NotAfter: validUntil}, nil +} + +func (source *Source) authorizeAccessEngine(ctx context.Context, user goth.User) error { @@ -2205,6 +2223,10 @@ + if !ok { + return errApplicationDecision + } ++ current, e := verifyCurrentApplicationToken(ctx, user.AccessToken, a, identity) ++ if e != nil { ++ return errApplicationDecision ++ } + client, e := decisionpep.New(a.Decision) + if e != nil { + return errApplicationDecision @@ -2217,7 +2239,12 @@ + if _, e = client.Check(ctx, request); e != nil { + return errApplicationDecision + } -+ if !time.Now().UTC().Before(identity.NotAfter) { ++ // A slow decision must not turn a revoked authentication into a sign-in. ++ current, e = verifyCurrentApplicationToken(ctx, user.AccessToken, a, identity) ++ if e != nil { ++ return errApplicationDecision ++ } ++ if !time.Now().UTC().Before(identity.NotAfter) || !time.Now().UTC().Before(current.NotAfter) { + return errApplicationDecision + } + return nil @@ -2396,7 +2423,7 @@ +} --- /dev/null +++ b/services/auth/source/oauth2/access_engine_callback_test.go -@@ -0,0 +1,194 @@ +@@ -0,0 +1,207 @@ +package oauth2 + +import ( @@ -2431,10 +2458,21 @@ + return callbackFixtureWithIdentity(t, group, mode, nil, "") +} + -+func callbackFixtureWithIdentity(t *testing.T, group, mode string, suppliedAdmission *AccessEngineAdmission, suppliedToken string) (*Source, *goth.User, *atomic.Int64) { ++func callbackFixtureWithIdentity(t *testing.T, group, mode string, suppliedAdmission *AccessEngineAdmission, suppliedToken string, suppliedAccess ...string) (*Source, *goth.User, *atomic.Int64) { + t.Helper() + admission, identityKey, claims, _ := identityFixture(t) ++ var accessToken string ++ var currentState *callbackCurrentState ++ if suppliedAdmission == nil { ++ currentState = newCallbackCurrentFixture(t, &admission, claims) ++ accessToken = currentState.token ++ } else if len(suppliedAccess) == 1 { ++ accessToken = suppliedAccess[0] ++ } + identityToken := signedIdentity(t, identityKey, claims, nil) ++ if currentState != nil { ++ identityToken = currentState.signIdentity(t, claims) ++ } + if suppliedAdmission != nil { + admission, identityToken = *suppliedAdmission, suppliedToken + } @@ -2463,7 +2501,7 @@ + return + } + c := request.Context -+ if request.Subject.ID != "user:synthetic" || request.Subject.Tenant != "tenant:synthetic" || request.Subject.Type != "human" || request.Action != "application.access" || request.Resource.ID != "application:forgejo" || c["identity_issuer"] != admission.Issuer || c["identity_assurance"] != "aal2" || c["identity_tenant"] != "tenant:synthetic" || c["source_id"] != json.Number("17") || !reflect.DeepEqual(c["identity_assurance_claim"], verified.AssuranceData) || c["identity_authenticated_at"] != verified.AuthenticatedAt.UTC().Format(time.RFC3339Nano) || c["identity_tenant_source"] != verified.TenantSource { ++ if request.Subject.ID != verified.Subject || request.Subject.Tenant != verified.Tenant || request.Subject.Type != verified.PrincipalType || request.Action != "application.access" || request.Resource.ID != "application:forgejo" || c["identity_issuer"] != admission.Issuer || c["identity_assurance"] != verified.Assurance || c["identity_tenant"] != verified.Tenant || c["source_id"] != json.Number("17") || !reflect.DeepEqual(c["identity_assurance_claim"], verified.AssuranceData) || c["identity_authenticated_at"] != verified.AuthenticatedAt.UTC().Format(time.RFC3339Nano) || c["identity_tenant_source"] != verified.TenantSource { + t.Error("PDP request did not use authenticated identity and source") + w.WriteHeader(400) + return @@ -2472,6 +2510,8 @@ + envelope := api.DecisionEnvelope{ID: "synthetic-decision", RequestID: request.ID, Effect: api.DecisionEffectAllow, MatchedPolicyVersion: "v1", Subject: request.Subject, Resource: request.Resource, Binding: api.NewDecisionBindingFor(request, request), Provenance: api.DecisionProvenance{Evaluator: "flex-auth/local", Mode: "standalone/" + decisionpep.JSONProfile, PolicyPackage: "forgejo.application-access", PolicyVersion: "v1", PolicyPackageDigest: "sha256:" + strings.Repeat("1", 64), RegistrySnapshotDigest: "sha256:" + strings.Repeat("2", 64), Caller: &api.CallerProvenance{Mode: "enforce", Principal: "system:serviceaccount:synthetic:forgejo", Audience: "access-engine", NotAfter: now.Add(time.Minute).Format(time.RFC3339Nano)}}} + api.CompleteDecision(&envelope, request, api.DecisionCompletion{AllowTTL: "10s", Now: now}) + switch mode { ++ case "revoke-current": ++ currentState.mode.Store("inactive") + case "deny": + envelope.Effect = api.DecisionEffectDeny + case "wrong-binding": @@ -2523,7 +2563,7 @@ + t.Setenv("FORGEJO_ACCESS_ENGINE_ADMISSION_FILE", config) + source := &Source{Provider: "openidConnect", ClientID: admission.Audience, RequiredClaimName: "groups", RequiredClaimValues: []string{"forge-admins", "forge-coulomb-owners", "forge-coulomb-readers"}} + source.authSource = &authmodel.Source{ID: 17, Name: admission.SourceName} -+ user := &goth.User{UserID: "user:synthetic", IDToken: identityToken, RawData: map[string]any{"sub": "user:synthetic", "groups": []string{group}}} ++ user := &goth.User{UserID: verified.Subject, IDToken: identityToken, AccessToken: accessToken, RawData: map[string]any{"sub": verified.Subject, "groups": []string{group}}} + previous := gothic.CompleteUserAuth + gothic.CompleteUserAuth = func(http.ResponseWriter, *http.Request) (goth.User, error) { return *user, nil } + t.Cleanup(func() { gothic.CompleteUserAuth = previous }) @@ -2592,8 +2632,270 @@ + } +} --- /dev/null ++++ b/services/auth/source/oauth2/access_engine_current_token.go +@@ -0,0 +1,46 @@ ++package oauth2 ++ ++import ( ++ "bytes" ++ "context" ++ "crypto/sha256" ++ "encoding/base64" ++ "encoding/json" ++ "time" ++ ++ "keycape/pkg/tokenstatus" ++) ++ ++// This consumer joins its independently verified ID token to the producer's ++// current access-grant observation. It supplies no token registry or evaluator. ++// Native credential/session continuation retains its separate owner profile. ++func verifyCurrentApplicationToken(ctx context.Context, raw string, a AccessEngineAdmission, identity VerifiedApplicationIdentity) (tokenstatus.CurrentToken, error) { ++ var refused tokenstatus.CurrentToken ++ digest := sha256.Sum256([]byte(raw)) ++ if identity.SigningAlgorithm != "RS256" || identity.AccessTokenHash != base64.RawURLEncoding.EncodeToString(digest[:16]) { ++ return refused, errApplicationDecision ++ } ++ if a.CurrentToken.Issuer != identity.Issuer || a.CurrentToken.ClientID != a.Audience { ++ return refused, errApplicationDecision ++ } ++ client, err := tokenstatus.NewOnlineClient(a.CurrentToken) ++ if err != nil { ++ return refused, errApplicationDecision ++ } ++ current, err := client.Check(ctx, raw, tokenstatus.ExpectedIdentity{ ++ Subject: identity.Subject, Tenant: identity.Tenant, PrincipalType: string(identity.PrincipalType), ++ }) ++ if err != nil || !current.IssuedAt.Equal(identity.IssuedAt) { ++ return refused, errApplicationDecision ++ } ++ facts := current.Claims() ++ actual, err := json.Marshal(facts[a.AssuranceClaim]) ++ expected, expectedErr := json.Marshal(identity.AssuranceData) ++ if err != nil || expectedErr != nil || !bytes.Equal(actual, expected) || facts[a.TenantSourceClaim] != identity.TenantSource { ++ return refused, errApplicationDecision ++ } ++ if !time.Now().UTC().Before(identity.NotAfter) { ++ return refused, errApplicationDecision ++ } ++ return current, nil ++} +--- /dev/null ++++ b/services/auth/source/oauth2/access_engine_current_token_test.go +@@ -0,0 +1,179 @@ ++package oauth2 ++ ++import ( ++ "crypto/rand" ++ "crypto/rsa" ++ "crypto/sha256" ++ "crypto/x509" ++ "encoding/base64" ++ "encoding/json" ++ "encoding/pem" ++ "math/big" ++ "net/http" ++ "net/http/httptest" ++ "os" ++ "path/filepath" ++ "sync/atomic" ++ "testing" ++ ++ "github.com/golang-jwt/jwt/v5" ++ "keycape/pkg/tokenstatus" ++) ++ ++// This unit status responder is synthetic. Separate actual-issuer integration ++// tests exercise the producer's real PKCE, introspection and revocation handlers. ++type callbackCurrentState struct { ++ key *rsa.PrivateKey ++ claims jwt.MapClaims ++ token string ++ mode atomic.Value ++ calls atomic.Int64 ++} ++ ++func (s *callbackCurrentState) sign(t *testing.T) string { ++ t.Helper() ++ token := jwt.NewWithClaims(jwt.SigningMethodRS256, s.claims) ++ token.Header["kid"] = "access-fixture" ++ raw, err := token.SignedString(s.key) ++ if err != nil { ++ t.Fatal("synthetic access signing failed") ++ } ++ return raw ++} ++ ++func (s *callbackCurrentState) signIdentity(t *testing.T, claims jwt.MapClaims) string { ++ t.Helper() ++ id := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) ++ id.Header["kid"] = "access-fixture" ++ raw, err := id.SignedString(s.key) ++ if err != nil { ++ t.Fatal("synthetic ID signing failed") ++ } ++ return raw ++} ++ ++func newCallbackCurrentFixture(t *testing.T, a *AccessEngineAdmission, idClaims jwt.MapClaims) *callbackCurrentState { ++ t.Helper() ++ key, err := rsa.GenerateKey(rand.Reader, 2048) ++ if err != nil { ++ t.Fatal("synthetic access key failed") ++ } ++ f := &callbackCurrentState{key: key} ++ f.mode.Store("") ++ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ++ f.calls.Add(1) ++ client, secret, ok := r.BasicAuth() ++ if !ok || client != a.Audience || secret != "synthetic-current" || r.URL.Path != "/introspect" || r.Method != http.MethodPost || r.ParseForm() != nil || r.Form.Get("token") != f.token { ++ w.WriteHeader(http.StatusForbidden) ++ return ++ } ++ if f.mode.Load().(string) == "unavailable" { ++ w.WriteHeader(http.StatusServiceUnavailable) ++ return ++ } ++ w.Header().Set("Content-Type", "application/json") ++ w.Header().Set("Cache-Control", "no-store") ++ if f.mode.Load().(string) == "inactive" { ++ _ = json.NewEncoder(w).Encode(map[string]any{"active": false}) ++ return ++ } ++ status := map[string]any{"active": true, "client_id": a.Audience, "token_type": "Bearer", "iss": a.Issuer, "sub": f.claims["sub"], "aud": a.CurrentToken.Audience, "jti": f.claims["jti"], "scope": f.claims["scope"], "iat": f.claims["iat"], "exp": f.claims["exp"]} ++ if f.mode.Load().(string) == "wrong-status" { ++ status["jti"] = "other" ++ } ++ _ = json.NewEncoder(w).Encode(status) ++ })) ++ t.Cleanup(server.Close) ++ a.Issuer = server.URL ++ idClaims["iss"] = a.Issuer ++ raw, _ := json.Marshal(idClaims) ++ if json.Unmarshal(raw, &f.claims) != nil { ++ t.Fatal("synthetic claims failed") ++ } ++ f.claims["jti"] = base64.RawURLEncoding.EncodeToString(make([]byte, 32)) ++ f.claims["scope"] = "openid profile groups" ++ f.token = f.sign(t) ++ digest := sha256.Sum256([]byte(f.token)) ++ idClaims["at_hash"] = base64.RawURLEncoding.EncodeToString(digest[:16]) ++ der, err := x509.MarshalPKIXPublicKey(&key.PublicKey) ++ if err != nil { ++ t.Fatal("synthetic identity public key failed") ++ } ++ a.Algorithms = []string{"RS256"} ++ a.IdentityKeys = map[string]string{"access-fixture": string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}))} ++ dir := t.TempDir() ++ ca, keys, secret := filepath.Join(dir, "current-ca.pem"), filepath.Join(dir, "current-jwks.json"), filepath.Join(dir, "current-client") ++ jwks := map[string]any{"keys": []any{map[string]any{"kty": "RSA", "use": "sig", "alg": "RS256", "kid": "access-fixture", "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes())}}} ++ public, _ := json.Marshal(jwks) ++ if os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0644) != nil || os.WriteFile(keys, public, 0644) != nil || os.WriteFile(secret, []byte("synthetic-current"), 0600) != nil { ++ t.Fatal("synthetic admission failed") ++ } ++ a.CurrentToken = tokenstatus.OnlineAdmission{Issuer: a.Issuer, Audience: a.Audience, ClientID: a.Audience, CAFile: ca, JWKSFile: keys, SecretFile: secret, MaxTokenAge: "5m"} ++ return f ++} ++ ++func TestAccessEngineCurrentTokenRejectsBeforeDecision(t *testing.T) { ++ for _, mode := range []string{"missing", "id-as-access", "subject", "tenant", "principal", "assurance", "authentication-time", "tenant-source", "issued-at", "inactive", "unavailable", "wrong-status", "missing-current-admission", "wrong-client", "withdrawn-key", "mismatched-grant", "missing-at-hash"} { ++ t.Run(mode, func(t *testing.T) { ++ a, _, claims, _ := identityFixture(t) ++ state := newCallbackCurrentFixture(t, &a, claims) ++ switch mode { ++ case "mismatched-grant": ++ state.claims["jti"] = base64.RawURLEncoding.EncodeToString([]byte("12345678901234567890123456789012")) ++ case "subject": ++ state.claims["sub"] = "other" ++ case "tenant": ++ state.claims["tenant"] = "other" ++ case "principal": ++ state.claims["principal_type"] = "service" ++ case "assurance": ++ state.claims["assurance"].(map[string]any)["level"] = "aal1" ++ case "authentication-time": ++ state.claims["assurance"].(map[string]any)["at"] = state.claims["iat"].(float64) - 1 ++ case "tenant-source": ++ state.claims["tenant_source"] = "default" ++ case "issued-at": ++ state.claims["iat"] = state.claims["iat"].(float64) - 1 ++ case "inactive", "unavailable", "wrong-status": ++ state.mode.Store(mode) ++ case "missing-current-admission": ++ a.CurrentToken = tokenstatus.OnlineAdmission{} ++ case "wrong-client": ++ a.CurrentToken.ClientID = "other" ++ case "withdrawn-key": ++ if os.WriteFile(a.CurrentToken.JWKSFile, []byte(`{"keys":[]}`), 0644) != nil { ++ t.Fatal("fixture withdrawal failed") ++ } ++ } ++ state.token = state.sign(t) ++ if mode != "mismatched-grant" { ++ digest := sha256.Sum256([]byte(state.token)) ++ claims["at_hash"] = base64.RawURLEncoding.EncodeToString(digest[:16]) ++ } ++ if mode == "missing-at-hash" { ++ delete(claims, "at_hash") ++ } ++ id := state.signIdentity(t, claims) ++ access := state.token ++ if mode == "missing" { ++ access = "" ++ } ++ if mode == "id-as-access" { ++ access = id ++ } ++ source, _, calls := callbackFixtureWithIdentity(t, "forge-admins", "allow", &a, id, access) ++ got, err := source.Callback(httptest.NewRequest(http.MethodGet, "https://forgejo.invalid/callback", nil), httptest.NewRecorder(), "") ++ if err == nil || got.UserID != "" || calls.Load() != 0 { ++ t.Fatal("invalid current authentication reached the decision or callback") ++ } ++ }) ++ } ++} ++ ++func TestAccessEngineCurrentTokenRevokedDuringDecisionRefusesSignIn(t *testing.T) { ++ source, _, calls := callbackFixture(t, "forge-admins", "revoke-current") ++ got, err := source.Callback(httptest.NewRequest(http.MethodGet, "https://forgejo.invalid/callback", nil), httptest.NewRecorder(), "") ++ if err == nil || got.UserID != "" || calls.Load() != 1 { ++ t.Fatal("revocation during decision escaped the final current check") ++ } ++} +--- /dev/null ++++ b/services/auth/source/oauth2/access_engine_purpose_test.go +@@ -0,0 +1,28 @@ ++package oauth2 ++ ++import "testing" ++ ++func TestVerifiedApplicationIdentityRefusesAccessTokenGrantClaims(t *testing.T) { ++ for _, field := range []string{"jti", "scope"} { ++ t.Run(field, func(t *testing.T) { ++ a, key, claims, now := identityFixture(t) ++ claims[field] = "synthetic-access-grant" ++ if _, err := VerifyApplicationIdentity(signedIdentity(t, key, claims, nil), a, now); err == nil { ++ t.Fatal("access-token grant fields were accepted as an ID token") ++ } ++ }) ++ } ++} ++ ++func TestVerifiedApplicationIdentityCannotChooseApplicationTenant(t *testing.T) { ++ a, key, claims, now := identityFixture(t) ++ claims["tenant"] = "tenant:another-valid-issuer-tenant" ++ if _, err := VerifyApplicationIdentity(signedIdentity(t, key, claims, nil), a, now); err == nil { ++ t.Fatal("a valid signature let the caller select the application's tenant") ++ } ++ a.ApplicationTenant = "" ++ claims["tenant"] = "tenant:synthetic" ++ if _, err := VerifyApplicationIdentity(signedIdentity(t, key, claims, nil), a, now); err == nil { ++ t.Fatal("missing owner application tenant became a permissive default") ++ } ++} +--- /dev/null +++ b/services/auth/source/oauth2/access_engine_test.go -@@ -0,0 +1,100 @@ +@@ -0,0 +1,101 @@ +package oauth2 + +import ( @@ -2620,6 +2922,7 @@ + now := time.Now().UTC().Truncate(time.Second) + a := AccessEngineAdmission{Issuer: "https://issuer.invalid", Audience: "forgejo", TokenType: "JWT", Algorithms: []string{"EdDSA"}, IdentityKeys: map[string]string{"synthetic": string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}))}, TenantClaim: "tenant", PrincipalTypeClaim: "principal_type", AssuranceClaim: "assurance", TenantSourceClaim: "tenant_source", MaxAuthenticationAge: "5m", MaxIdentityAge: "5m"} + c := jwt.MapClaims{"iss": a.Issuer, "aud": a.Audience, "sub": "user:synthetic", "iat": now.Add(-time.Second).Unix(), "exp": now.Add(time.Minute).Unix(), "tenant": "tenant:synthetic", "principal_type": "human", "tenant_source": "directory", "assurance": map[string]any{"level": "aal2", "mfa": true, "source": "key-cape", "methods": []string{"pwd", "otp"}, "at": now.Add(-time.Second).Unix()}} ++ a.ApplicationTenant = "tenant:synthetic" + return a, key, c, now +} +func signedIdentity(t *testing.T, key ed25519.PrivateKey, c jwt.MapClaims, mutate func(*jwt.Token)) string { @@ -2694,6 +2997,77 @@ + } + }) +} +--- /dev/null ++++ b/services/auth/source/oauth2/current_issuer_fixture_test.go +@@ -0,0 +1,68 @@ ++//go:build current_issuer_fixture ++ ++package oauth2 ++ ++import ( ++ "crypto/tls" ++ "crypto/x509" ++ "encoding/json" ++ "net/http" ++ "net/http/httptest" ++ "os" ++ "testing" ++ "time" ++ ++ "github.com/markbates/goth" ++ "github.com/markbates/goth/gothic" ++ "github.com/markbates/goth/providers/openidConnect" ++ "keycape/pkg/tokenstatus" ++) ++ ++// Explicit isolated integration input only; never run against a deployed issuer. ++func TestAccessEngineLiveFixtureCurrentIssuer(t *testing.T) { ++ var fixture struct { ++ IDToken string `json:"id_token"` ++ AccessToken string `json:"access_token"` ++ Key string `json:"public_key_pem"` ++ Tenant string `json:"tenant"` ++ Phase string `json:"phase"` ++ Current tokenstatus.OnlineAdmission `json:"current"` ++ } ++ raw, err := os.ReadFile(os.Getenv("FORGEJO_CURRENT_ISSUER_FIXTURE")) ++ if err != nil || json.Unmarshal(raw, &fixture) != nil { ++ t.Fatal("private isolated issuer fixture missing") ++ } ++ a, _, _, _ := identityFixture(t) ++ a.Issuer, a.Audience, a.ApplicationTenant = fixture.Current.Issuer, fixture.Current.ClientID, fixture.Tenant ++ a.Algorithms, a.IdentityKeys = []string{"RS256"}, map[string]string{"key-1": fixture.Key} ++ a.CurrentToken = fixture.Current ++ source, _, calls := callbackFixtureWithIdentity(t, "forge-admins", "allow", &a, fixture.IDToken, fixture.AccessToken) ++ if fixture.Phase == "active" { ++ ca, err := os.ReadFile(fixture.Current.CAFile) ++ roots := x509.NewCertPool() ++ if err != nil || !roots.AppendCertsFromPEM(ca) { ++ t.Fatal("private fixture trust missing") ++ } ++ transport := &http.Transport{Proxy: nil, TLSClientConfig: &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}} ++ t.Cleanup(transport.CloseIdleConnections) ++ oldClient := http.DefaultClient ++ http.DefaultClient = &http.Client{Transport: transport, Timeout: 5 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return errApplicationDecision }} ++ t.Cleanup(func() { http.DefaultClient = oldClient }) ++ source.OpenIDConnectAutoDiscoveryURL = fixture.Current.Issuer + "/.well-known/openid-configuration" ++ provider, err := (&OpenIDProvider{}).CreateGothProvider("synthetic-source", "https://forgejo.invalid/callback", source) ++ if err != nil { ++ t.Fatal("actual provider discovery failed") ++ } ++ gothic.CompleteUserAuth = func(http.ResponseWriter, *http.Request) (goth.User, error) { ++ return provider.FetchUser(&openidConnect.Session{IDToken: fixture.IDToken, AccessToken: fixture.AccessToken, ExpiresAt: time.Now().Add(time.Minute)}) ++ } ++ } ++ got, err := source.Callback(httptest.NewRequest(http.MethodGet, "https://forgejo.invalid/callback", nil), httptest.NewRecorder(), "") ++ if fixture.Phase == "active" { ++ if err != nil || got.UserID == "" || calls.Load() != 1 { ++ t.Fatal("actual issuer/provider/current-token/decision callback did not pass") ++ } ++ } else if err == nil || got.UserID != "" || got.IDToken != "" || calls.Load() != 0 { ++ t.Fatal("revoked, suspended or substituted grant escaped current callback check") ++ } ++} --- a/services/auth/source/oauth2/providers_openid.go +++ b/services/auth/source/oauth2/providers_openid.go @@ -4,6 +4,7 @@ @@ -4999,3 +5373,740 @@ +func Sign(envelope *api.DecisionEnvelope, key ed25519.PrivateKey, kid string) error { + return sign.Sign(envelope, key, kid) +} +--- /dev/null ++++ b/third_party/keycape-tokenstatus/LICENSE +@@ -0,0 +1,151 @@ ++# Target Revenue Source License ++ ++**Version 1.0, Candidate 1 (V1C1)** ++ ++--- ++ ++> **PRELIMINARY CANDIDATE — SUBJECT TO CHANGE — NOT FINAL** ++> ++> This repository is governed by the Target Revenue Source License ++> (TRSL), Version 1, Candidate 1. This is the framework's first working ++> candidate, adopted as the org's preliminary operating license across ++> all repos (maintainer decision, 2026-07-29) during the build/alpha ++> stage — see `SCOPE.md` §1 and Appendix A of the canonical text (link ++> below) for the alpha/beta risk-acceptance decision this reflects and ++> what it does and does not mean. Full specialist legal review is ++> explicitly deferred until the framework moves out of beta. ++> ++> **Canonical source, full candidate-status banner, and Appendix A ++> (non-normative candidate notes tracking every open item):** the ++> `coulomb` org's `target-revenue` repository, ++> `specs/TargetRevenueSourceLicense-V1C1.md` — this file is the operative ++> legal text (Preamble through Section 11) only; the canonical document ++> is authoritative if this copy and that document ever diverge. ++ ++--- ++ ++## Preamble ++ ++This Target Revenue Source License ("**License**") governs the Software identified in the applicable Phase Manifest. It implements the Target Revenue Framework: a defined development Phase accumulates Development Credit and Remission Credit against an immutable Initial Target until the Milestone Release automatically and irrevocably converts to a declared permissive Future License. ++ ++Commercial beneficiaries fund the creation and early availability of a software improvement; once the declared target is satisfied, the governed release becomes permissively open source. ++ ++## 1. Definitions ++ ++Capitalized terms used in this License have the meanings given below. Where a term is also defined in the Phase Manifest or Target Ledger for a specific Phase, the Phase Manifest and Target Ledger govern the *values* (amounts, dates, identifiers) and this License governs the *legal effect* of those values — the two must not be read as conflicting definitions of the same concept. ++ ++**"Commercial Entitlement"** means a right, purchased or otherwise granted under a Commercial Use Agreement, to make Commercial Use of the Software during a Phase. ++ ++**"Commercial Use"** means billing, invoicing, or otherwise charging any customer a fee, subscription, license fee, or other consideration for or in connection with use of the Software, at any time before the Conversion Event for the applicable Phase, regardless of whether the person or organization billed would otherwise qualify for Noncommercial Use. Commercial Use occurs by virtue of such billing alone, whether or not the resulting payment is registered with the Trust Service; in particular, billing a customer for pre-conversion use of the Software without recording the corresponding payment in the applicable Phase's Target Ledger is Commercial Use without a valid Commercial Entitlement — a violation of Section 3, addressed under Section 7 and, where applicable, the Enforcement Network described in the canonical repository's `specs/EnforcementNetworkConcept.md`. ++ ++**"Commercial Use Agreement"** means the separate agreement, referenced by the applicable Phase Manifest, under which a Commercial Entitlement is purchased or granted. This License does not itself set pricing, metering, or payment terms — those are governed by the Commercial Use Agreement. ++ ++**"Conversion Event"** means the moment the Outstanding Target for a Phase reaches zero, as computed from the Phase Manifest and Target Ledger per the Target Ledger Specification. The Conversion Event occurs automatically and is not conditioned on any declaration, attestation, or other act by the Licensor or any Trust Service. ++ ++**"Development Credit"** means the portion of a collected and settled payment explicitly allocated toward satisfying the Initial Target of a specific Phase, as recorded in that Phase's Target Ledger. ++ ++**"Future License"** means the permissive license identified in the applicable Phase Manifest, being either the MIT License or the Apache License, Version 2.0, which applies to the Milestone Release upon the Conversion Event. ++ ++**"Initial Target"** means the immutable monetary target declared for a Phase in its Phase Manifest. ++ ++**"Licensor"** means **Binky Hedgehog GmbH**, the party that publishes the Phase Manifest and holds the rights necessary to grant this License and the Future License for the Milestone Release. ++ ++**"Milestone Release"** means the precisely identified software release designated in the applicable Phase Manifest, identified by an immutable source revision, release artifact, or cryptographic digest. ++ ++**"Noncommercial Use"** means use of the Software for personal purposes, private study, hobby or amateur projects; use by any charitable organization, educational institution, public research organization, or government institution acting in a non-revenue-generating capacity; or other use of a materially similar character. ++ ++**"Outstanding Target"** means, at any time, `max(0, Initial Target − cumulative Development Credit − cumulative Remission Credit)` for a Phase, as computed from that Phase's Target Ledger. ++ ++**"Phase"** means a bounded development undertaking governed by one Initial Target, one Milestone Release, one degeneration policy, and one Future License declaration, as declared in a Phase Manifest. ++ ++**"Phase Manifest"** means the published, immutable declaration identifying a Phase, its Milestone Release, Initial Target, Future License, degeneration policy, and Target Ledger location, as specified in the Phase Manifest Specification. ++ ++**"Remission Credit"** means a transparent, non-revenue reduction of a Phase's Outstanding Target, generated under that Phase's published degeneration policy and recorded in the Target Ledger. ++ ++**"Settled Payment"** means a payment that has cleared through its payment processor and is no longer subject to reversal in the ordinary course (chargeback, dispute, or equivalent), as further specified by the applicable Commercial Use Agreement or monetization extension. ++ ++**"Software"** means the source code, object code, and associated documentation of the Milestone Release identified in the applicable Phase Manifest. ++ ++**"Target Ledger"** means the append-only record of Development Credit, Remission Credit, and correction entries for a Phase, as specified in the Target Ledger Specification. ++ ++**"You"** or **"Licensee"** means the individual or entity exercising rights under this License. ++ ++## 2. Grant of Rights for Noncommercial Use ++ ++Subject to the terms of this License, the Licensor grants You a worldwide, royalty-free, non-exclusive license, during the applicable Phase, to: ++ ++(a) use, reproduce, and study the Software for any Noncommercial Use; ++ ++(b) modify the Software and create derivative works of it for any Noncommercial Use; and ++ ++(c) redistribute the Software and Your modifications, in source or object form, for any Noncommercial Use, provided that You include this License, unmodified, with any such redistribution, and that You do not remove or alter any copyright, patent, trademark, or attribution notices contained in the Software. ++ ++This grant does not extend to Commercial Use. Commercial Use requires a Commercial Entitlement under Section 3. ++ ++## 3. Commercial Use ++ ++You may not make Commercial Use of the Software during the applicable Phase unless You hold a valid, current Commercial Entitlement under a Commercial Use Agreement with the Licensor covering the applicable Phase. A Commercial Entitlement granted under one Phase's Commercial Use Agreement does not extend to a later Phase's Milestone Release unless the Commercial Use Agreement expressly says so. ++ ++This Section 3 states the existence and boundary of the commercial-use restriction. It does not itself set pricing, invoicing, metering, audit rights, or payment terms — those are governed exclusively by the applicable Commercial Use Agreement. ++ ++## 4. Patent License ++ ++Subject to the terms of this License, each contributor to the Software grants You, during the applicable Phase and solely to the extent of rights granted under Sections 2 and 3, a perpetual (subject to the termination below), worldwide, non-exclusive, no-charge, royalty-free patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Software, limited to those patent claims licensable by that contributor that are necessarily infringed by their contribution(s) alone or by combination of their contribution(s) with the Software. ++ ++If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Software or a contribution incorporated within it constitutes direct or contributory patent infringement, then any patent licenses granted to You under this Section 4 for the Software shall terminate as of the date such litigation is filed. ++ ++## 5. Automatic Conversion to the Future License ++ ++**5.1 Automatic effect.** Upon the Conversion Event for a Phase, the rights and restrictions in Sections 3 (Commercial Use) of this License, as they apply to that Phase's Milestone Release, terminate automatically. In their place, the Milestone Release is licensed under the Future License identified in that Phase's Phase Manifest, effective as of the Conversion Event, without any further act, declaration, or attestation required by the Licensor, any Trust Service, or any other party. ++ ++**5.2 Irrevocability.** Once a valid Conversion Event has occurred for a Phase, no subsequent refund, chargeback, accounting correction, dispute, or termination of this License for an unrelated breach shall revoke, suspend, or otherwise impair the Future License grant for that Phase's Milestone Release. Any shortfall or dispute arising after a Conversion Event is a commercial or accounting matter between the relevant parties and does not reinstate a commercial-use restriction over already-converted Software. ++ ++**5.3 Prior freedom preserved.** A later Phase covering subsequent improvements to the Software does not restrict, withdraw, or otherwise affect the rights granted under the Future License for an earlier Phase's already-converted Milestone Release. ++ ++**5.4 Evidence, not cause.** A Trust Service may publish a Conversion Attestation documenting a Conversion Event. Such an attestation is evidence that the Conversion Event occurred; it is not a condition of, and its absence or delay does not postpone, the automatic effect described in Section 5.1. Any person may independently verify whether a Conversion Event has occurred directly from the Phase Manifest and Target Ledger. ++ ++## 6. Successive Phases ++ ++The Licensor may declare a new Phase covering subsequent improvements to the Software following a Milestone Release's Conversion Event. Each Phase is independently governed by its own Phase Manifest, Initial Target, degeneration policy, and Target Ledger. Nothing in a later Phase's Phase Manifest may be construed to reduce or withdraw rights already granted under Section 5 for an earlier Phase's Milestone Release. ++ ++## 7. Term and Termination ++ ++**7.1 Term.** This License applies to the Software for the duration of the applicable Phase, and, for the Milestone Release, indefinitely following that Phase's Conversion Event under the Future License. ++ ++**7.2 Termination for breach.** If You breach Section 3 (Commercial Use) or Section 2(c) (redistribution notice requirement), the Licensor may terminate this License as to You. Before such termination becomes effective, the Licensor shall provide You written notice of the breach; if You cure the breach within thirty (30) days of that notice, this License continues in effect. A second breach of the same provision within twelve (12) months may be terminated immediately without a further cure opportunity. ++ ++**7.3 Effect of termination.** Termination under this Section 7 affects only Your rights under Sections 2 and 3 for the Phase in which the breach occurred. It does not affect any rights already vested under Section 5 (Automatic Conversion) for a Milestone Release whose Conversion Event has already occurred, per Section 5.2. ++ ++**7.4 Public record of breach and resolution.** The Licensor shall cause the Trust Service to publish, as part of the public record for the affected Phase, notice of: (a) any breach notice issued under Section 7.2, stating the general nature of the breach and the date of notice; (b) whether the breach was cured within the applicable cure period, and the date of cure; and (c) any termination determination made under this Section 7, including its effective date and scope. This public record exists to give the ecosystem a transparent, verifiable conformity signal for the Phase, distinct from and in addition to the Development Credit and Remission Credit facts already published under Section 5.4 and the Target Ledger Specification. ++ ++A breach that You dispute, and that has not been finally determined, shall be recorded as **alleged**; it shall be recorded as **determined** only once the cure period has run without cure, or the dispute has been resolved against You under the applicable Commercial Use Agreement's dispute process, if any. The Trust Service shall update the record promptly upon resolution in either direction. Recording an alleged or determined breach under this Section 7.4 is a ministerial act of publishing the Licensor's determination (or a dispute process's outcome); it does not give the Trust Service discretionary authority to decide whether a breach occurred, consistent with Section 5.4's evidence-not-cause principle. ++ ++Whether, and under what conditions, the public record identifies a Commercial Entitlement holder by name is governed exclusively by the applicable Commercial Use Agreement, which the Licensor and that Commercial Entitlement holder negotiate and agree to directly. This License does not itself set a naming default. Where no Commercial Use Agreement addresses the question, or where the affected party has no Commercial Use Agreement at all (for example, a Section 2(c) breach by a Noncommercial Use licensee), the public record states the Phase and breach category only, without naming the party. ++ ++## 8. Disclaimer of Warranty ++ ++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. THE LICENSOR DOES NOT WARRANT THAT THE SOFTWARE WILL BE ERROR-FREE OR THAT ANY PHASE WILL REACH ITS CONVERSION EVENT. ++ ++## 9. Limitation of Liability ++ ++IN NO EVENT SHALL THE LICENSOR OR ANY CONTRIBUTOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE, EXCEPT TO THE EXTENT SUCH LIMITATION IS PROHIBITED BY APPLICABLE LAW. ++ ++## 10. Trademarks ++ ++This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary attribution. ++ ++## 11. General Provisions ++ ++**11.1 Governing law and venue.** Adopted for alpha/beta 2026-07-29: disputes arising under this License shall be resolved by binding arbitration, seated at a neutral, arbitration-mature venue (Singapore or London are the two candidates concretely supported by current research), rather than by litigation in a national court. The specific arbitral institution and substantive governing law remain a per-deployment blank pending final selection; they are not fixed by this candidate. See the canonical repository's `history/260729-TRSL-Jurisdiction-Synthesis.md` §2. ++ ++**11.2 Severability.** If any provision of this License is held unenforceable, the remaining provisions remain in full force, and the unenforceable provision shall be reformed to the minimum extent necessary to make it enforceable. ++ ++**11.3 No waiver.** Failure to enforce any provision of this License is not a waiver of future enforcement of that or any other provision. ++ ++**11.4 Entire agreement (as to licensing).** This License, together with the applicable Phase Manifest and, where applicable, the Commercial Use Agreement, constitutes the entire agreement between You and the Licensor regarding the Software's licensing terms. Operations, service, and consulting arrangements are governed by separate agreements, if any, and are not part of this License. ++ ++**11.5 Definitions control.** Marketing materials, documentation, or other non-normative communications about the Software must not describe pre-Conversion-Event Software as "Open Source," "free software," or "open core." Pre-conversion Noncommercial Use is **source-available**; pre-conversion Commercial Use requires a **Commercial Entitlement**; only post-conversion Software may be described as Open Source, under the Future License. ++ ++--- ++ ++**No Phase is currently declared for this repository under this License.** Until a Phase Manifest is published and registered with the Trust Service for a Milestone Release in this repository, Sections 2–7 above have no operative subject matter here — this License establishes the governing framework in advance of that declaration, consistent with the org-wide rollout decision recorded in `target-revenue`'s `workplans/TREV-WP-0008-governance-and-pilot-rollout.md`. +--- /dev/null ++++ b/third_party/keycape-tokenstatus/go.mod +@@ -0,0 +1,3 @@ ++module keycape ++ ++go 1.23.0 +--- /dev/null ++++ b/third_party/keycape-tokenstatus/internal/jose/jose.go +@@ -0,0 +1,171 @@ ++// Package jose provides strict RS256 JWT signature verification against a JWK ++// set. It is deliberately narrow: it establishes that a token was signed by a ++// key in the given set and returns its claims. It applies no claim policy — ++// issuer, audience, expiry and nonce rules differ per caller and belong with ++// the caller that knows them. ++// ++// The strictness here is the point. A verifier that accepts an unexpected ++// algorithm, an unbounded exponent or an undersized modulus is worse than no ++// verifier, because callers stop checking what it appears to guarantee. ++package jose ++ ++import ( ++ "crypto" ++ "crypto/rsa" ++ "crypto/sha256" ++ "encoding/base64" ++ "encoding/json" ++ "errors" ++ "math/big" ++ "strings" ++) ++ ++// ErrVerification is returned for every verification failure. The cause is ++// deliberately not distinguished: a caller acting on why a token failed tends to ++// leak that distinction to whoever supplied the token. ++var ErrVerification = errors.New("jose: token verification failed") ++ ++// KeySet maps key IDs to RSA public keys. ++type KeySet map[string]*rsa.PublicKey ++ ++// ParseJWKS parses a JWK set, keeping only RSA signing keys usable with RS256. ++// Keys that are malformed, too small, or carry an even or trivial exponent are ++// rejected rather than skipped, since a key set that is partly wrong is not ++// evidence of anything. ++func ParseJWKS(raw []byte) (KeySet, error) { ++ var document struct { ++ Keys []struct { ++ Kty string `json:"kty"` ++ Use string `json:"use"` ++ Alg string `json:"alg"` ++ Kid string `json:"kid"` ++ N string `json:"n"` ++ E string `json:"e"` ++ } `json:"keys"` ++ } ++ if err := json.Unmarshal(raw, &document); err != nil { ++ return nil, ErrVerification ++ } ++ keys := make(KeySet, len(document.Keys)) ++ for _, key := range document.Keys { ++ // Ignore keys for other algorithms or purposes; they are not errors. ++ if key.Kty != "RSA" || (key.Use != "" && key.Use != "sig") || (key.Alg != "" && key.Alg != "RS256") { ++ continue ++ } ++ if key.Kid == "" { ++ return nil, ErrVerification ++ } ++ modulus, errN := decode(key.N) ++ exponent, errE := decode(key.E) ++ if errN != nil || errE != nil || len(exponent) == 0 || len(exponent) > 4 { ++ return nil, ErrVerification ++ } ++ public := &rsa.PublicKey{ ++ N: new(big.Int).SetBytes(modulus), ++ E: int(new(big.Int).SetBytes(exponent).Int64()), ++ } ++ if public.N.BitLen() < 2048 || public.E < 3 || public.E%2 == 0 { ++ return nil, ErrVerification ++ } ++ if _, duplicate := keys[key.Kid]; duplicate { ++ return nil, ErrVerification ++ } ++ keys[key.Kid] = public ++ } ++ if len(keys) == 0 { ++ return nil, ErrVerification ++ } ++ return keys, nil ++} ++ ++// KeyID returns the key ID from a token's JOSE header, after checking that the ++// header names RS256 and carries no critical extensions. Callers use it to ++// decide whether to refresh their key set before verifying. ++func KeyID(token string) (string, error) { ++ header, _, _, err := split(token) ++ if err != nil { ++ return "", err ++ } ++ return header.Kid, nil ++} ++ ++// Verify checks a token's RS256 signature against the key set and returns its ++// claims. It does not inspect any claim. ++func Verify(token string, keys KeySet) (map[string]interface{}, error) { ++ return verify(token, keys, "") ++} ++ ++// VerifyTyped applies the same signature verifier with an explicit JOSE type. ++// Existing Verify callers retain their claim/type policy; online access-token ++// consumers opt into the producer's exact JWT header contract here. ++func VerifyTyped(token string, keys KeySet, expectedType string) (map[string]interface{}, error) { ++ if expectedType == "" { ++ return nil, ErrVerification ++ } ++ return verify(token, keys, expectedType) ++} ++ ++func verify(token string, keys KeySet, expectedType string) (map[string]interface{}, error) { ++ header, signingInput, signature, err := split(token) ++ if err != nil { ++ return nil, err ++ } ++ if expectedType != "" && header.Typ != expectedType { ++ return nil, ErrVerification ++ } ++ public, ok := keys[header.Kid] ++ if !ok { ++ return nil, ErrVerification ++ } ++ digest := sha256.Sum256([]byte(signingInput)) ++ if rsa.VerifyPKCS1v15(public, crypto.SHA256, digest[:], signature) != nil { ++ return nil, ErrVerification ++ } ++ payload, err := decode(strings.Split(signingInput, ".")[1]) ++ if err != nil { ++ return nil, ErrVerification ++ } ++ claims := map[string]interface{}{} ++ if json.Unmarshal(payload, &claims) != nil { ++ return nil, ErrVerification ++ } ++ return claims, nil ++} ++ ++type joseHeader struct { ++ Alg string `json:"alg"` ++ Typ string `json:"typ"` ++ Kid string `json:"kid"` ++ Crit []string `json:"crit"` ++} ++ ++// split validates the token's shape and header and returns the header, the ++// signing input, and the decoded signature. ++func split(token string) (joseHeader, string, []byte, error) { ++ parts := strings.Split(token, ".") ++ if len(parts) != 3 { ++ return joseHeader{}, "", nil, ErrVerification ++ } ++ raw, err := decode(parts[0]) ++ if err != nil { ++ return joseHeader{}, "", nil, ErrVerification ++ } ++ var header joseHeader ++ if json.Unmarshal(raw, &header) != nil { ++ return joseHeader{}, "", nil, ErrVerification ++ } ++ // An unexpected algorithm must fail before any key is selected, so "none" ++ // and symmetric algorithms can never reach the RSA path. ++ if header.Alg != "RS256" || header.Kid == "" || len(header.Crit) != 0 { ++ return joseHeader{}, "", nil, ErrVerification ++ } ++ signature, err := decode(parts[2]) ++ if err != nil { ++ return joseHeader{}, "", nil, ErrVerification ++ } ++ return header, parts[0] + "." + parts[1], signature, nil ++} ++ ++func decode(segment string) ([]byte, error) { ++ return base64.RawURLEncoding.DecodeString(segment) ++} +--- /dev/null ++++ b/third_party/keycape-tokenstatus/pkg/tokenstatus/client.go +@@ -0,0 +1,379 @@ ++package tokenstatus ++ ++import ( ++ "bytes" ++ "context" ++ "crypto/sha256" ++ "crypto/tls" ++ "crypto/x509" ++ "encoding/base64" ++ "encoding/hex" ++ "encoding/json" ++ "errors" ++ "io" ++ "math" ++ "mime" ++ "net" ++ "net/http" ++ "net/url" ++ "os" ++ "path" ++ "path/filepath" ++ "strings" ++ "syscall" ++ "time" ++ ++ "keycape/internal/jose" ++) ++ ++var ErrOnlineToken = errors.New("current authenticated token refused") ++ ++// OnlineAdmission is independently admitted owner configuration. The public ++// JWKS/CA and private client credential files are reread for every request. ++// It selects the owning confidential client, not a resource-server delegation. ++type OnlineAdmission struct { ++ Issuer string `json:"issuer"` ++ Audience string `json:"audience"` ++ ClientID string `json:"client_id"` ++ CAFile string `json:"ca_file"` ++ JWKSFile string `json:"jwks_file"` ++ SecretFile string `json:"secret_file"` ++ MaxTokenAge string `json:"max_token_age"` ++} ++ ++type ExpectedIdentity struct { ++ Subject string ++ Tenant string ++ PrincipalType string ++} ++ ++// CurrentToken contains signed identity facts joined to a current issuer ++// observation. It is never a permission, approval or portable execution receipt. ++type CurrentToken struct { ++ Issuer string `json:"issuer"` ++ Audience string `json:"audience"` ++ ClientID string `json:"client_id"` ++ Subject string `json:"subject"` ++ Tenant string `json:"tenant"` ++ PrincipalType string `json:"principal_type"` ++ TokenID string `json:"jti"` ++ TokenDigest string `json:"token_sha256"` ++ IssuedAt time.Time `json:"issued_at"` ++ NotAfter time.Time `json:"not_after"` ++ CheckedAt time.Time `json:"checked_at"` ++ claims map[string]interface{} ++} ++ ++// Claims returns a separate copy of the verified signed facts. Authentication ++// assurance/age, tenant provenance, current groups and resource/effect policy ++// remain the consumer's existing profile obligations, not a new client policy. ++func (t CurrentToken) Claims() map[string]interface{} { ++ if t.claims == nil { ++ return nil ++ } ++ raw, _ := json.Marshal(t.claims) ++ var out map[string]interface{} ++ _ = json.Unmarshal(raw, &out) ++ return out ++} ++ ++// InactiveForClient proves only the issuer's current negative response for the ++// named client/token digest. An unknown/other-client token gives the same result; ++// original grant ownership is separately required before asserting cleanup. ++type InactiveForClient struct { ++ Issuer string `json:"issuer"` ++ ClientID string `json:"client_id"` ++ TokenDigest string `json:"token_sha256"` ++ CheckedAt time.Time `json:"checked_at"` ++} ++ ++type OnlineClient struct { ++ admission OnlineAdmission ++ age time.Duration ++ now func() time.Time ++ // Nil uses the normal resolver/dialer. Tests route a certified DNS name to ++ // isolated loopback; the TLS name/CA and JWT trust checks remain unchanged. ++ dialContext func(context.Context, string, string) (net.Conn, error) ++} ++ ++func NewOnlineClient(a OnlineAdmission) (*OnlineClient, error) { ++ u, err := url.Parse(a.Issuer) ++ if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || strings.ContainsAny(a.Issuer, "?#") || u.RawPath != "" || (u.Path != "" && path.Clean(u.Path) != u.Path) || strings.HasSuffix(a.Issuer, "/") { ++ return nil, ErrOnlineToken ++ } ++ if a.Audience == "" || a.ClientID == "" || !filepath.IsAbs(a.CAFile) || !filepath.IsAbs(a.JWKSFile) || !filepath.IsAbs(a.SecretFile) { ++ return nil, ErrOnlineToken ++ } ++ age, err := time.ParseDuration(a.MaxTokenAge) ++ if err != nil || age <= 0 || age > time.Hour { ++ return nil, ErrOnlineToken ++ } ++ return &OnlineClient{admission: a, age: age, now: time.Now}, nil ++} ++ ++func readOnlineFile(path string, private bool) ([]byte, error) { ++ limit := int64(1 << 20) ++ if private { ++ limit = 16384 ++ } ++ return readOnlineBoundedFile(path, private, limit) ++} ++ ++func readOnlineBoundedFile(path string, private bool, limit int64) ([]byte, error) { ++ if limit <= 0 || limit > 1<<20 { ++ return nil, ErrOnlineToken ++ } ++ if !filepath.IsAbs(path) { ++ return nil, ErrOnlineToken ++ } ++ f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) ++ if err != nil { ++ return nil, ErrOnlineToken ++ } ++ defer f.Close() ++ info, err := f.Stat() ++ if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0022 != 0 || (private && info.Mode().Perm()&0007 != 0) { ++ return nil, ErrOnlineToken ++ } ++ raw, err := io.ReadAll(io.LimitReader(f, limit+1)) ++ if err != nil || len(raw) > int(limit) || len(raw) == 0 { ++ return nil, ErrOnlineToken ++ } ++ return raw, nil ++} ++ ++func tokenDigest(raw string) string { ++ digest := sha256.Sum256([]byte(raw)) ++ return "sha256:" + hex.EncodeToString(digest[:]) ++} ++ ++func exactUnix(value interface{}) (time.Time, bool) { ++ n, ok := value.(float64) ++ if !ok || n <= 0 || n > 9007199254740991 || math.Trunc(n) != n { ++ return time.Time{}, false ++ } ++ return time.Unix(int64(n), 0).UTC(), true ++} ++ ++func (c *OnlineClient) verify(raw string, expected ExpectedIdentity, now time.Time) (CurrentToken, error) { ++ var out CurrentToken ++ if c == nil || len(raw) == 0 || len(raw) > 32768 || expected.Subject == "" || expected.Tenant == "" || (expected.PrincipalType != "human" && expected.PrincipalType != "service") { ++ return out, ErrOnlineToken ++ } ++ keysRaw, err := readOnlineFile(c.admission.JWKSFile, false) ++ if err != nil { ++ return out, ErrOnlineToken ++ } ++ keys, err := jose.ParseJWKS(keysRaw) ++ if err != nil { ++ return out, ErrOnlineToken ++ } ++ claims, err := jose.VerifyTyped(raw, keys, "JWT") ++ if err != nil { ++ return out, ErrOnlineToken ++ } ++ if claims["iss"] != c.admission.Issuer || claims["aud"] != c.admission.Audience || claims["sub"] != expected.Subject || claims["tenant"] != expected.Tenant || claims["principal_type"] != expected.PrincipalType { ++ return out, ErrOnlineToken ++ } ++ if azp, present := claims["azp"]; present && azp != c.admission.ClientID { ++ return out, ErrOnlineToken ++ } ++ _, scopeOK := claims["scope"].(string) ++ jti, jtiOK := claims["jti"].(string) ++ decodedID, idErr := base64.RawURLEncoding.Strict().DecodeString(jti) ++ issued, issuedOK := exactUnix(claims["iat"]) ++ expires, expiresOK := exactUnix(claims["exp"]) ++ if !scopeOK || !jtiOK || idErr != nil || len(decodedID) != 32 || !issuedOK || !expiresOK || issued.After(now) || !expires.After(issued) || !now.Before(expires) { ++ return out, ErrOnlineToken ++ } ++ if nbf, present := claims["nbf"]; present { ++ start, valid := exactUnix(nbf) ++ if !valid || start.After(now) { ++ return out, ErrOnlineToken ++ } ++ } ++ end := issued.Add(c.age) ++ if expires.Before(end) { ++ end = expires ++ } ++ if !now.Before(end) { ++ return out, ErrOnlineToken ++ } ++ return CurrentToken{Issuer: c.admission.Issuer, Audience: c.admission.Audience, ClientID: c.admission.ClientID, ++ Subject: expected.Subject, Tenant: expected.Tenant, PrincipalType: expected.PrincipalType, ++ TokenID: jti, TokenDigest: tokenDigest(raw), IssuedAt: issued, NotAfter: end, CheckedAt: now, claims: claims}, nil ++} ++ ++type statusWire struct { ++ Active *bool `json:"active"` ++ ClientID string `json:"client_id"` ++ TokenType string `json:"token_type"` ++ Issuer string `json:"iss"` ++ Subject string `json:"sub"` ++ Audience string `json:"aud"` ++ Scope string `json:"scope"` ++ IssuedAt int64 `json:"iat"` ++ ExpiresAt int64 `json:"exp"` ++ TokenID string `json:"jti"` ++} ++ ++func decodeOnlineObject(raw []byte, out interface{}) (map[string]bool, error) { ++ d := json.NewDecoder(bytes.NewReader(raw)) ++ first, err := d.Token() ++ if err != nil || first != json.Delim('{') { ++ return nil, ErrOnlineToken ++ } ++ seen := map[string]bool{} ++ for d.More() { ++ key, err := d.Token() ++ name, ok := key.(string) ++ if err != nil || !ok || seen[name] { ++ return nil, ErrOnlineToken ++ } ++ seen[name] = true ++ var value json.RawMessage ++ if d.Decode(&value) != nil || bytes.Equal(bytes.TrimSpace(value), []byte("null")) { ++ return nil, ErrOnlineToken ++ } ++ } ++ if last, err := d.Token(); err != nil || last != json.Delim('}') { ++ return nil, ErrOnlineToken ++ } ++ if _, err := d.Token(); err != io.EOF { ++ return nil, ErrOnlineToken ++ } ++ d = json.NewDecoder(bytes.NewReader(raw)) ++ d.DisallowUnknownFields() ++ if d.Decode(out) != nil { ++ return nil, ErrOnlineToken ++ } ++ return seen, nil ++} ++ ++func decodeOnlineStatus(raw []byte) (statusWire, error) { ++ var out statusWire ++ seen, err := decodeOnlineObject(raw, &out) ++ if err != nil || out.Active == nil || (!*out.Active && len(seen) != 1) { ++ return out, ErrOnlineToken ++ } ++ if *out.Active { ++ if len(seen) != 10 { ++ return out, ErrOnlineToken ++ } ++ for _, field := range []string{"active", "client_id", "token_type", "iss", "sub", "aud", "scope", "iat", "exp", "jti"} { ++ if !seen[field] { ++ return out, ErrOnlineToken ++ } ++ } ++ } ++ return out, nil ++} ++ ++func (c *OnlineClient) request(ctx context.Context, endpoint, raw string) (statusWire, error) { ++ var out statusWire ++ if c == nil || ctx == nil || ctx.Err() != nil || len(raw) == 0 || len(raw) > 32768 || (endpoint != "/introspect" && endpoint != "/revoke") { ++ return out, ErrOnlineToken ++ } ++ ca, err := readOnlineFile(c.admission.CAFile, false) ++ if err != nil { ++ return out, ErrOnlineToken ++ } ++ roots := x509.NewCertPool() ++ if !roots.AppendCertsFromPEM(ca) { ++ return out, ErrOnlineToken ++ } ++ secretRaw, err := readOnlineFile(c.admission.SecretFile, true) ++ if err != nil { ++ return out, ErrOnlineToken ++ } ++ secret := strings.TrimSuffix(strings.TrimSuffix(string(secretRaw), "\n"), "\r") ++ if secret == "" || strings.ContainsAny(secret, "\r\n\x00") { ++ return out, ErrOnlineToken ++ } ++ form := url.Values{"token": {raw}, "token_type_hint": {"access_token"}} ++ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.admission.Issuer+endpoint, strings.NewReader(form.Encode())) ++ if err != nil { ++ return out, ErrOnlineToken ++ } ++ req.Header.Set("Content-Type", "application/x-www-form-urlencoded") ++ req.Header.Set("Cache-Control", "no-cache") ++ req.SetBasicAuth(url.QueryEscape(c.admission.ClientID), url.QueryEscape(secret)) ++ transport := &http.Transport{Proxy: nil, TLSClientConfig: &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}, DisableKeepAlives: true, DialContext: c.dialContext} ++ defer transport.CloseIdleConnections() ++ client := &http.Client{Transport: transport, Timeout: 5 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return ErrOnlineToken }} ++ response, err := client.Do(req) ++ if err != nil { ++ return out, ErrOnlineToken ++ } ++ defer response.Body.Close() ++ if response.StatusCode != http.StatusOK || response.Header.Get("Cache-Control") != "no-store" { ++ return out, ErrOnlineToken ++ } ++ body, err := io.ReadAll(io.LimitReader(response.Body, 65537)) ++ if err != nil || len(body) > 65536 { ++ return out, ErrOnlineToken ++ } ++ if endpoint == "/revoke" { ++ if len(body) != 0 { ++ return out, ErrOnlineToken ++ } ++ return out, nil ++ } ++ media, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) ++ if err != nil || media != "application/json" { ++ return out, ErrOnlineToken ++ } ++ return decodeOnlineStatus(body) ++} ++ ++// Check verifies expected identity before accessing the private client credential, ++// sends a new authenticated introspection and rechecks lifetime after the network. ++func (c *OnlineClient) Check(ctx context.Context, raw string, expected ExpectedIdentity) (CurrentToken, error) { ++ if c == nil || ctx == nil || ctx.Err() != nil { ++ return CurrentToken{}, ErrOnlineToken ++ } ++ identity, err := c.verify(raw, expected, c.now().UTC()) ++ if err != nil { ++ return CurrentToken{}, ErrOnlineToken ++ } ++ status, err := c.request(ctx, "/introspect", raw) ++ if err != nil || status.Active == nil || !*status.Active { ++ return CurrentToken{}, ErrOnlineToken ++ } ++ expires, _ := exactUnix(identity.claims["exp"]) ++ if status.ClientID != c.admission.ClientID || status.TokenType != "Bearer" || status.Issuer != identity.Issuer || status.Subject != identity.Subject || status.Audience != identity.Audience || status.TokenID != identity.TokenID || status.Scope != identity.claims["scope"] || status.IssuedAt != identity.IssuedAt.Unix() || status.ExpiresAt != expires.Unix() { ++ return CurrentToken{}, ErrOnlineToken ++ } ++ end := c.now().UTC() ++ if end.Before(identity.CheckedAt) { ++ return CurrentToken{}, ErrOnlineToken ++ } ++ // A slow lookup cannot retain expired facts or a withdrawn JWT trust key. ++ return c.verify(raw, expected, end) ++} ++ ++// Revoke performs cleanup only for the exact opaque token. An empty 200 is not ++// proof: a separate authenticated introspection must confirm inactive-for-client. ++// The caller retains original grant ownership and uncertainty/retry duties. ++func (c *OnlineClient) Revoke(ctx context.Context, raw string) (InactiveForClient, error) { ++ if c == nil { ++ return InactiveForClient{}, ErrOnlineToken ++ } ++ if _, err := c.request(ctx, "/revoke", raw); err != nil { ++ return InactiveForClient{}, ErrOnlineToken ++ } ++ return c.ObserveInactive(ctx, raw) ++} ++ ++// ObserveInactive is read-only. Unavailability or a malformed response is never ++// interpreted as revocation; the negative result remains scoped to this client. ++func (c *OnlineClient) ObserveInactive(ctx context.Context, raw string) (InactiveForClient, error) { ++ if c == nil { ++ return InactiveForClient{}, ErrOnlineToken ++ } ++ status, err := c.request(ctx, "/introspect", raw) ++ if err != nil || status.Active == nil || *status.Active { ++ return InactiveForClient{}, ErrOnlineToken ++ } ++ return InactiveForClient{Issuer: c.admission.Issuer, ClientID: c.admission.ClientID, TokenDigest: tokenDigest(raw), CheckedAt: c.now().UTC()}, nil ++} +--- /dev/null ++++ b/third_party/keycape-tokenstatus/producer-source.json +@@ -0,0 +1,18 @@ ++{ ++ "source_of_truth": "key-cape source; generated by tools/export_tokenstatus_client.py", ++ "source_files": [ ++ "src/internal/authclient/online.go", ++ "src/internal/jose/jose.go", ++ "LICENSE" ++ ], ++ "transformation": "replace only the first package declaration authclient with tokenstatus; all remaining client bytes and shared JOSE bytes exact", ++ "module": "keycape", ++ "profile": "issuer online access-token status; no policy evaluator or deployment", ++ "supported_file_boundary": "Linux owner execution profile; POSIX mode checks; not a Windows ACL contract", ++ "sha256": { ++ "LICENSE": "sha256:ff413f361a3b3bf44cf50f110fbcc4837064d57eefbcef89df2b43997c6262b6", ++ "go.mod": "sha256:e8b028e1002e63a76dba78a4abbd67882307ad198e4783bad631652061aa0f54", ++ "internal/jose/jose.go": "sha256:a6de154cd81e34a521b8aac5dce6b45a3cfb015681ed16e2beb8f2a24c72c00d", ++ "pkg/tokenstatus/client.go": "sha256:f34626a85d67590b9c61474f19ce1021f8afd17093f8f522b3f98e9cef34e575" ++ } ++} diff --git a/build/forgejo/inputs.lock.json b/build/forgejo/inputs.lock.json index d3259d0..c9d13c3 100644 --- a/build/forgejo/inputs.lock.json +++ b/build/forgejo/inputs.lock.json @@ -3,13 +3,13 @@ "forgejo_version": "11.0.3", "source_archive_sha256": "1ddd8c926ca07d40436897971458ace7dcf1d3496a0813ab9e0d869edef2365c", "source_commit": "b81aa4d0807cc1e2d6a2fbafd0b75c46cc0ae237", - "patch_sha256": "feb1079399a43989742ae0a946c1ada50113d1821e6541f97f0b5592ee1fc20c", + "patch_sha256": "9fe3d7baa648c9347964a177befab5070e00151d1ec5ddff16c41890db84e62a", "go_archive_sha256": "3333f6ea53afa971e9078895eaa4ac7204a8c6b5c68c10e6bc9a33e8e391bdd8", "go_version": "1.24.3", "node_archive_sha256": "dafe2e8f82cb97de1bd10db9e2ec4c07bbf53389b0799b1e095a918951e78fd4", "node_version": "22.15.0", "source_date_epoch": 1752105237, - "release_version": "11.0.3+cust80.feb10793", + "release_version": "11.0.3+cust80.9fe3d7ba", "tags": "bindata timetzdata sqlite sqlite_unlock_notify", "runtime_base": "code.forgejo.org/forgejo/forgejo@sha256:41649dff5311664097d29b4d80620403f0b27e7c0eee0b8af79b132a37f3807b", "runtime_index_digest": "sha256:691657c7e64af7730a2e885e6d022cf16ae60724931c5392bd61838afadcaeba", @@ -33,7 +33,7 @@ }, { "path": "go.mod", - "sha256": "130138c4c3ecc29fa0d7f1e2eef0589ddf3b06071f3cc0448aa7b2326847653a" + "sha256": "11fe525ccb3f099aa281738b86097bbd587a99b4640ddae8dd04dd7d046c49f0" }, { "path": "models/auth/source_operation.go", @@ -93,7 +93,7 @@ }, { "path": "services/auth/source/oauth2/access_engine.go", - "sha256": "d5f07f9491ac2212410e1fa8c0248d68ba3a723e7ca99e363627c5292d187aa8" + "sha256": "d16330043825d0284253e65d28bda2de4edbc633e4887d8c3618a106ee251daa" }, { "path": "services/auth/source/oauth2/access_engine_assurance.go", @@ -109,11 +109,27 @@ }, { "path": "services/auth/source/oauth2/access_engine_callback_test.go", - "sha256": "5cea36b630c895a9473dc876b7128526ddef65d9395b2fddc56343e162452472" + "sha256": "1cbe378cc20149be7b354484ab3297916733564f03a2441cd276f9039d043580" + }, + { + "path": "services/auth/source/oauth2/access_engine_current_token.go", + "sha256": "4a2e484ece7cf74686173d5e85c127643287570c2298af135ddfdc7c450b18d8" + }, + { + "path": "services/auth/source/oauth2/access_engine_current_token_test.go", + "sha256": "58f799c08a7b4638780abc511bd1a7359bd5e691c7805ff54582d02d2f42cae1" + }, + { + "path": "services/auth/source/oauth2/access_engine_purpose_test.go", + "sha256": "533dc3153fcf9bfcf7bfc08712c057092c7b3892882f217fb8f839fdbdd99939" }, { "path": "services/auth/source/oauth2/access_engine_test.go", - "sha256": "99edd6cfe3e834d73e0447a7915f52cc4ab7b82644669c5ae61963f850b447e8" + "sha256": "9112a142983c0a58c5d40344b58a20fa2c1bdbc7a79b5c586796362839a43b48" + }, + { + "path": "services/auth/source/oauth2/current_issuer_fixture_test.go", + "sha256": "24fb430e72848326c5d9ef42eab5028cf915dd601f43fb06010b32b568908781" }, { "path": "services/auth/source/oauth2/providers_openid.go", @@ -174,10 +190,30 @@ { "path": "third_party/access-engine-pep/testsupport/signfixture/sign.go", "sha256": "fe4aa86f444dc20145aefbaca35b24f046264b2c5c8b4e3b057a4496b77a4d29" + }, + { + "path": "third_party/keycape-tokenstatus/LICENSE", + "sha256": "ff413f361a3b3bf44cf50f110fbcc4837064d57eefbcef89df2b43997c6262b6" + }, + { + "path": "third_party/keycape-tokenstatus/go.mod", + "sha256": "e8b028e1002e63a76dba78a4abbd67882307ad198e4783bad631652061aa0f54" + }, + { + "path": "third_party/keycape-tokenstatus/internal/jose/jose.go", + "sha256": "a6de154cd81e34a521b8aac5dce6b45a3cfb015681ed16e2beb8f2a24c72c00d" + }, + { + "path": "third_party/keycape-tokenstatus/pkg/tokenstatus/client.go", + "sha256": "f34626a85d67590b9c61474f19ce1021f8afd17093f8f522b3f98e9cef34e575" + }, + { + "path": "third_party/keycape-tokenstatus/producer-source.json", + "sha256": "3bd9b25b912e7c17b331997ca5fee9da8a7c314f336e09e3c116885954799b73" } ], "lockfiles": { - "go.mod": "130138c4c3ecc29fa0d7f1e2eef0589ddf3b06071f3cc0448aa7b2326847653a", + "go.mod": "11fe525ccb3f099aa281738b86097bbd587a99b4640ddae8dd04dd7d046c49f0", "go.sum": "19c305c7e68de9d3f1bc0dda2de09c5d38597f1ee7fa411c7b36b92467ae1eaf", "package.json": "ab5c54ec7077501b50acf3ab5f1fff4f63a51abe99e720b8ecdc6cc6c78f39fc", "package-lock.json": "fdd5c2f891311be7c01b1907b418c243ca1f0f951fd4b4ee5b5e931c43493657" @@ -193,7 +229,7 @@ }, "runtime_base_config_sha256": "bc5a3df111729582faadebd01d126ba769e2b65d0c62f306f6bc79f4e562452b", "patch_file": "forgejo-security-extension.patch", - "candidate_review_status": "coordinator tested; independent owner review pending", + "candidate_review_status": "current-token callback source tested; complete release rebuild and independent owner review pending", "producer_source_commit": "ca0cb75729a63c9288b7333382d0fa3eb1e8c9a3", "optional_source_tests": { "source_operation_postgres": { @@ -202,5 +238,7 @@ "source_only": true, "production_connection_or_credentials": false } - } + }, + "identity_client_source_revision": "git:08c083bae9ff35fbaf028c36d72a95b57d2541b7", + "identity_issuer_source_revision": "git:e2c5a947829568baf837543a6708fd0c3f6a863b" } diff --git a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md index d53106e..a15fc64 100644 --- a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md +++ b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md @@ -308,3 +308,26 @@ PostgreSQL/native proof and actual issuer/factor/custody acceptance remain open. No trust, deployed image, Helm values, source DB, user/link state or local sign-in is changed by this build-source update. The preserved legacy patch is historical; the input lock now explicitly selects forgejo-security-extension.patch. + + +2026-10-18 current-token callback continuation: the new 49-path source candidate +uses the exact generated KeyCape online client, without a consumer token registry. +Mandatory owner application-tenant/current-token admission, ID-token purpose, +exact signed RS256 at_hash, current status before/after the PDP, and signed +identity/assurance/authentication-time/tenant-provenance joins refuse substitution +and stale authority. The latest focused callback/identity suite passes 91 results +without skips; the selected four composed packages passed 114 results with one +optional PostgreSQL test skipped before the additional owner-tenant regression. +The actual HTTP PKCE/discovery/UserInfo/status/revocation and Forgejo provider / +callback pass five fresh consumer processes: two current grants accepted, mixed +pair/revoked original/suspended human refused. The original verifier and issuer +fail their new controls. MFA session seeds and PDP admission are synthetic. + +The user explicitly selected current KeyCape checks on every API/Git/SSH +operation for linked accounts, keeping the separate local break-glass path. +That full ongoing route/resource/action/group profile is still implementation +work here; callback proof does not close it. Input lock now selects patch +9fe3d7ba; complete release/asset/OCI checks, independent owner assent, +software/type/package/custody admission, real factors/current sessions and native +acceptance remain open. T07/T08 and every existing task UUID/status are preserved. +Exact source/evidence: Custodian forgejo-current-token-r1. From ab3cf5cdc56d085d040256e2e84633b0289ab0af Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 18 Oct 2026 12:21:03 +0200 Subject: [PATCH 48/87] Correct composed validation scope and retain the initial fixture skip Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a14bfc-9643-7872-aa12-98326dcf25e7 --- build/forgejo/README.md | 5 +++++ workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/build/forgejo/README.md b/build/forgejo/README.md index bb1d4df..d81c110 100644 --- a/build/forgejo/README.md +++ b/build/forgejo/README.md @@ -165,3 +165,8 @@ path. That is an implementation requirement under RAPPS16-T09/NK48-T43, not a claim that the callback implements those routes or a native deployment grant. Ongoing resource/action/current-group decisions, protected operation/inverse and all release writers/journal custody remain acceptance work. + +Final composed source validation passes 116 test/subtest results without skips. +The initial 114-pass run skipped its private file-replacement check because a +reused fixture was exhausted; the runner now creates fresh private/read-only +aliases. That initial skip is retained and is not PostgreSQL acceptance. diff --git a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md index a15fc64..28c2ce9 100644 --- a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md +++ b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md @@ -316,8 +316,10 @@ Mandatory owner application-tenant/current-token admission, ID-token purpose, exact signed RS256 at_hash, current status before/after the PDP, and signed identity/assurance/authentication-time/tenant-provenance joins refuse substitution and stale authority. The latest focused callback/identity suite passes 91 results -without skips; the selected four composed packages passed 114 results with one -optional PostgreSQL test skipped before the additional owner-tenant regression. +without skips; the final selected four composed packages pass 116 results without +skips. The earlier 114-pass run skipped the private file-replacement check because +its reused fixture directory was exhausted; a fresh private/read-only fixture +restores that check. The original skip is retained, not PostgreSQL evidence. The actual HTTP PKCE/discovery/UserInfo/status/revocation and Forgejo provider / callback pass five fresh consumer processes: two current grants accepted, mixed pair/revoked original/suspended human refused. The original verifier and issuer From 2fdc198b2bb8e6d4ad50768c22cf99328828eb4f Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 18 Oct 2026 17:04:45 +0200 Subject: [PATCH 49/87] Declare scanner rollout admitted by cb9a20f9-5f8d-411f-b034-b15856c0efb5 Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a14bfc-9643-7872-aa12-98326dcf25e7 --- helm/forgejo-values.yaml | 21 +++++---------------- manifests/forgejo-secret-scan-template.yaml | 8 ++++---- 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/helm/forgejo-values.yaml b/helm/forgejo-values.yaml index 8d3b8ee..dad1c7e 100644 --- a/helm/forgejo-values.yaml +++ b/helm/forgejo-values.yaml @@ -34,7 +34,7 @@ valkey-cluster: extraVolumes: - name: secret-scan-template configMap: - name: forgejo-secret-template-b1d7cf8cd13f + name: forgejo-secret-template-832f6dc6b809 defaultMode: 365 extraContainerVolumeMounts: - &id001 @@ -54,21 +54,10 @@ preExtraInitContainers: command: - sh - -c - - "#!/bin/sh\nset +vx\nset -eu\ntest \"$(id -u)\" = 1000\ntest -d /data && test ! -L /data\ntest -d /data/security\ - \ && test ! -L /data/security\ntest -d /data/security/receive-secret-scan && test ! -L /data/security/receive-secret-scan\n\ - test -d /data/security/receive-secret-scan/20261012-57a0642b6251 && test ! -L /data/security/receive-secret-scan/20261012-57a0642b6251\n\ - test -f /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan && test ! -L /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan\n\ - test \"$(stat -c %u /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan)\" = 1000\n\ - test \"$(sha256sum /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan | cut -d \"\ - \ \" -f 1)\" = 57a0642b625105440fe146fceb4d244fd2dd08a75beb316831675643e6cc27ec\ncase \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan)\"\ - \ in 755|775) ;; *) exit 21;; esac\ntest -f /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks\ - \ && test ! -L /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks\ntest \"$(stat -c %u /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks)\"\ - \ = 1000\ntest \"$(sha256sum /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks | cut -d \" \"\ - \ -f 1)\" = 88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509\ncase \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks)\"\ - \ in 755|775) ;; *) exit 21;; esac\nif [ \"${1:-check}\" = apply ]; then\n chmod 755 /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan\n\ - \ test \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan)\" = 755\n\ - \ chmod 755 /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks\n test \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks)\"\ - \ = 755\nfi\n" + - "#!/bin/sh\nset +vx\nset -eu\ntest \"$(id -u)\" = 1000\ntest -d /data && test ! -L /data\ntest -d /data/security && test ! -L /data/security\ntest -d /data/security/receive-secret-scan && test ! -L /data/security/receive-secret-scan\ntest -d /data/security/receive-secret-scan/20261012-57a0642b6251 && test ! -L /data/security/receive-secret-scan/20261012-57a0642b6251\ntest -f /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan && test ! -L /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan\ntest \"$(stat -c %u /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan)\" = 1000\ntest \"$(sha256sum /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan | cut -d \" \" -f 1)\" = 57a0642b625105440fe146fceb4d244fd2dd08a75beb316831675643e6cc27ec\ncase \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan)\" in 755|775) ;; *) exit 21;; esac\ntest -f /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks\ + \ && test ! -L /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks\ntest \"$(stat -c %u /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks)\" = 1000\ntest \"$(sha256sum /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks | cut -d \" \" -f 1)\" = 88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509\ncase \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks)\" in 755|775) ;; *) exit 21;; esac\nif [ \"${1:-check}\" = apply ]; then\n chmod 755 /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan\n test \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan)\" = 755\n chmod 755 /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks\n test \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks)\" = 755\nfi\n\n#!/bin/sh\nset +vx\nset -eu\ntest \"$(id -u)\" = 1000\ntest -d /data && test ! -L /data\ntest -d /data/security\ + \ && test ! -L /data/security\ntest -d /data/security/receive-secret-scan && test ! -L /data/security/receive-secret-scan\ntest -d /data/security/receive-secret-scan/20261018-73ee04078786 && test ! -L /data/security/receive-secret-scan/20261018-73ee04078786\ntest -f /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan && test ! -L /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan\ntest \"$(stat -c %u /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan)\" = 1000\ntest \"$(sha256sum /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan | cut -d \" \" -f 1)\" = 73ee04078786801fc74d80d2bef122b7764c93856e403e8de5b078336d7f9e1a\ncase \"$(stat -c %a /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan)\" in 755|775) ;; *) exit 21;; esac\ntest -f /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks && test ! -L /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks\n\ + test \"$(stat -c %u /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks)\" = 1000\ntest \"$(sha256sum /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks | cut -d \" \" -f 1)\" = 88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509\ncase \"$(stat -c %a /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks)\" in 755|775) ;; *) exit 21;; esac\nif [ \"${1:-check}\" = apply ]; then\n chmod 755 /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan\n test \"$(stat -c %a /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan)\" = 755\n chmod 755 /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks\n test \"$(stat -c %a /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks)\" = 755\nfi\n" - sh - apply securityContext: diff --git a/manifests/forgejo-secret-scan-template.yaml b/manifests/forgejo-secret-scan-template.yaml index 7142fec..f76dfb9 100644 --- a/manifests/forgejo-secret-scan-template.yaml +++ b/manifests/forgejo-secret-scan-template.yaml @@ -1,11 +1,11 @@ apiVersion: v1 kind: ConfigMap metadata: - name: forgejo-secret-template-b1d7cf8cd13f + name: forgejo-secret-template-832f6dc6b809 namespace: forgejo labels: app.kubernetes.io/part-of: forgejo - custodian.coulomb.social/workplan: FORGE-WP-0006 + custodian.coulomb.social/workplan: FORGE-WP-0010 immutable: true data: hook: '#!/bin/sh @@ -14,7 +14,7 @@ data: set -eu - exec /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan - --scanner /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks + exec /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan + --scanner /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks ' From 4d6a8dde21e3f800156c0aaa2bfe859c57908486 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 19 Oct 2026 10:35:14 +0200 Subject: [PATCH 50/87] Declare approved Forgejo public PKCE development sign-in profile Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a15302-b965-7d43-a9d8-42710914b24e --- helm/forgejo-values.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/helm/forgejo-values.yaml b/helm/forgejo-values.yaml index dad1c7e..3bb9812 100644 --- a/helm/forgejo-values.yaml +++ b/helm/forgejo-values.yaml @@ -84,6 +84,13 @@ preExtraInitContainers: terminationMessagePolicy: File gitea: + oauth: + - name: netkingdom + provider: openidConnect + key: forgejo + secret: "" + autoDiscoverUrl: https://kc.coulomb.social/.well-known/openid-configuration + scopes: openid,profile,email,groups admin: username: forgejo_admin email: admin@coulomb.social @@ -98,6 +105,10 @@ gitea: name: forgejo-mailer key: MAILER_PASSWD config: + oauth2_client: + ENABLE_AUTO_REGISTRATION: false + ACCOUNT_LINKING: login + OPENID_CONNECT_SCOPES: openid profile email groups database: DB_TYPE: postgres HOST: forgejo-db-rw.databases.svc.cluster.local:5432 From a6fe0aba0f1bb7c47006e3c7d516fd72e8ae1880 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 19 Oct 2026 13:23:12 +0200 Subject: [PATCH 51/87] build: adopt staged work-record index generation Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a15374-f59f-7a31-86d2-d23b5d2ed181 --- .custodian-brief.md | 4 ++-- .generated-work-records.json | 1 + .generated-work-records.mk | 8 ++++++++ Makefile | 7 +++++++ SCOPE.md | 13 +++++++++++++ WORK-RECORDS.md | 4 ++++ 6 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 .generated-work-records.json create mode 100644 .generated-work-records.mk diff --git a/.custodian-brief.md b/.custodian-brief.md index b12bc98..1f9b6fa 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,7 +2,7 @@ # Custodian Brief — railiance-apps **Domain:** financials -**Last synced:** 2026-10-17 09:42 UTC +**Last synced:** 2026-10-17 10:13 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams @@ -18,7 +18,7 @@ Progress: 1/5 done | workplan_id: `f6b1d6f6-0abb-5774-87b9-bf3cfb640a3b` - ! T03 — Link accounts and map groups to teams `32c54737` *(wait: Restricting local password sign-in is a founder decision once NetKingdom sign-in is proven.)* - ! T04 — Decide on local password sign-in `728786c9` - *(wait: Waits for the NK-WP-0048-T16 option decision and net-kingdom's answer to the Apps needs-change record.)* + *(wait: Waits for net-kingdom's answer to the Apps 1.3 needs-change record (the-custodian docs/changes/netkingdom-target-2026-10-17/nk48-1.3-owner-review/owner-review-railiance-apps.json: RAPPS-1.3-C1..C3) and the re-frozen 1.3 candidate.)* ### Coulomb-social persistent personal workspace release Progress: 3/4 done | workplan_id: `79abacf8-361e-51ab-829d-40dec8e142d1` diff --git a/.generated-work-records.json b/.generated-work-records.json new file mode 100644 index 0000000..fceeef4 --- /dev/null +++ b/.generated-work-records.json @@ -0,0 +1 @@ +{"version":1,"repo_slug":"railiance-apps"} diff --git a/.generated-work-records.mk b/.generated-work-records.mk new file mode 100644 index 0000000..f44240c --- /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_railiance-apps ?= railiance-apps +.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_railiance-apps)" +generated-work-records-check: + python3 "$(CUSTODIAN_SOURCE)/scripts/generate_staged_work_records.py" --repo . --repo-slug "$(GENERATED_REPO_railiance-apps)" --check diff --git a/Makefile b/Makefile index d08e729..7a58d4f 100644 --- a/Makefile +++ b/Makefile @@ -682,3 +682,10 @@ forgejo-release-verify: ## Verify all embedded assets, static executable and new forgejo-release-container: ## Build/verify a deterministic local rootless OCI archive; never publish or deploy /usr/bin/python3 -I tools/forgejo-release-build.py container --workspace "$(FORGEJO_BUILD_WORKSPACE)" + +# CUST-WP-0079-T05: staged-source generation; hook uses the isolated recipe. +.PHONY: generated-work-records generated-work-records-check +generated-work-records: + $(MAKE) --no-print-directory -f .generated-work-records.mk generated-work-records +generated-work-records-check: + $(MAKE) --no-print-directory -f .generated-work-records.mk generated-work-records-check diff --git a/SCOPE.md b/SCOPE.md index e75d84c..586ab7f 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -270,3 +270,16 @@ keywords: [operator, runbook, sops, cnpg, dry-run, smoke-test, deployment] 9. Run `make check-tools` before deploy work. Run `SOPS_SENTINEL= make check-sops` when app release work touches encrypted SOPS files. + + +## Generated work-record index + +This repository opts into staged-source WORK-RECORDS.md generation through +`.generated-work-records.json` and the isolated `.generated-work-records.mk` +recipe. `make generated-work-records` reuses the State Hub renderer through +the Custodian adapter; `make generated-work-records-check` verifies byte-exact +agreement with staged Markdown. The pre-commit hook uses the isolated recipe, +preserves unstaged index edits by refusing them, and retains the generated-only +commit guard and existing security checks. It does not contact the hub, alter +workplan statuses or grant deployment authority. This consumes shared tooling; +it does not make this repository the provider of the generator capability. diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 89c6d25..3b77af4 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -43,6 +43,10 @@ | task | RAPPS-WP-0016-T03 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T04 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T05 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | +| task | RAPPS-WP-0016-T06 | done | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | +| task | RAPPS-WP-0016-T07 | progress | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | +| task | RAPPS-WP-0016-T08 | progress | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | +| task | RAPPS-WP-0016-T09 | progress | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0017-T01 | done | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | | task | RAPPS-WP-0017-T02 | done | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | | task | RAPPS-WP-0017-T03 | done | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | From 0e1d2b3cfe426d2404ec52fecbb6fd8ca6462288 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 19 Oct 2026 13:50:32 +0200 Subject: [PATCH 52/87] chore: keep generated orientation brief local Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a15374-f59f-7a31-86d2-d23b5d2ed181 --- .custodian-brief.md | 47 --------------------------------------------- .gitignore | 4 ++++ SCOPE.md | 8 ++++++++ 3 files changed, 12 insertions(+), 47 deletions(-) delete mode 100644 .custodian-brief.md create mode 100644 .gitignore diff --git a/.custodian-brief.md b/.custodian-brief.md deleted file mode 100644 index 1f9b6fa..0000000 --- a/.custodian-brief.md +++ /dev/null @@ -1,47 +0,0 @@ - -# Custodian Brief — railiance-apps - -**Domain:** financials -**Last synced:** 2026-10-17 10:13 UTC -**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* - -## Active Workstreams - -### Forgejo sign-in through NetKingdom, before the forge closes -Progress: 1/5 done | workplan_id: `f6b1d6f6-0abb-5774-87b9-bf3cfb640a3b` - -**Open tasks:** -- ! T01 — Make the local administrator a sealed break-glass `13cd3aec` - *(wait: Break-glass done (T01); needs the NetKingdom client registered with its secret in custody (KEY-WP-0038-T02).)* -- ! T02 — Add the NetKingdom authentication source `81f072aa` - *(wait: Linking and team mapping follow a working authentication source.)* -- ! T03 — Link accounts and map groups to teams `32c54737` - *(wait: Restricting local password sign-in is a founder decision once NetKingdom sign-in is proven.)* -- ! T04 — Decide on local password sign-in `728786c9` - *(wait: Waits for net-kingdom's answer to the Apps 1.3 needs-change record (the-custodian docs/changes/netkingdom-target-2026-10-17/nk48-1.3-owner-review/owner-review-railiance-apps.json: RAPPS-1.3-C1..C3) and the re-frozen 1.3 candidate.)* - -### Coulomb-social persistent personal workspace release -Progress: 3/4 done | workplan_id: `79abacf8-361e-51ab-829d-40dec8e142d1` - -**Open tasks:** -- ! T04 — Personal sign-in and content acceptance `8bc4962a` - -### Deploy and recover the first invited Vergabe company pilot -Progress: 2/3 done | workplan_id: `c7fdaa7e-cab8-5d1d-86c2-f1aad7927c57` - -**Open tasks:** -- ! Demonstrate restart, isolated restore, rollback and operating ownership `dd069c6d` - *(wait: Await populated two-user/document workflow, coherent PostgreSQL/media/issue-state off-host recovery point, isolated restored workflow, rollback evidence and support ownership. September 25 proves database restore and restart only.)* - -## Inbox Hygiene - -**Stale unread:** 2 message(s) older than 3 day(s) — triage at session start. -**Missing thread_id:** 2 unread message(s) lack supersession chains. - ---- -## MCP Orientation (when available) - -If the state-hub MCP server is reachable, call: -`get_domain_summary("financials")` -This provides richer cross-domain context. -If the MCP call fails, use this file as your orientation source. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5fc885e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ + + +# Local State Hub orientation snapshot; authored work records remain tracked. +/.custodian-brief.md diff --git a/SCOPE.md b/SCOPE.md index 586ab7f..7ccf0f3 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -283,3 +283,11 @@ preserves unstaged index edits by refusing them, and retains the generated-only commit guard and existing security checks. It does not contact the hub, alter workplan statuses or grant deployment authority. This consumes shared tooling; it does not make this repository the provider of the generator capability. + +## Local orientation brief policy — 2026-10-19 + +`.custodian-brief.md` is a local, ignored State Hub orientation snapshot. Its +existing bytes remain available for offline session startup; refreshing it does +not require a Git commit. Authored workplans and the generated WORK-RECORDS index +retain their existing source and staged-generation contracts. No runtime or +credential change follows from this repository hygiene policy. From 3be92daf74a7572319bfe94dd882a70430528343 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 19 Oct 2026 15:29:55 +0200 Subject: [PATCH 53/87] Approve pinned Policy Nexus guardrail release Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a15374-f59f-7a31-86d2-d23b5d2ed181 --- bindings/policy-nexus-production.json | 22 +++++++++---------- values/policy-nexus-production-candidate.yaml | 8 +++---- values/policy-nexus-production.yaml | 8 +++---- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/bindings/policy-nexus-production.json b/bindings/policy-nexus-production.json index 3fddec0..c40815a 100644 --- a/bindings/policy-nexus-production.json +++ b/bindings/policy-nexus-production.json @@ -1,7 +1,7 @@ { "schema_version": "policy-nexus-production-binding/v1", "status": "release-approved", - "approved_on": "2026-10-11", + "approved_on": "2026-10-19", "rapp_repo": "rapp-policy-nexus", "reef": "reef-railiance", "rail": "rail-kubernetes", @@ -9,15 +9,15 @@ "release": "policy-nexus", "hostname": "policy.coulomb.social", "image_repository": "forgejo.coulomb.social/coulomb/policy-nexus", - "image_digest": "sha256:4e0db6a57eb00f577159aa6ee10f910dd67b03fd0541c6ce849f5426fdb99d06", - "publication_manifest_digest": "ba53cc4900e6bb9ab8aa4bac4e58cb5fe7a03d656bafb3f9cb3d9c8ebff7843a", - "source_inventory_digest": "40d9e8852fe4c9e75b71f371d0fdae38664b068e7498711b0ac5671d0f41efb5", - "source_set_digest": "15bda2e332285afc862df8974cf29de403737df5073fa362adfc6d203cf2f256", + "image_digest": "sha256:214b122c3d175563f114552fa1826dac9642433be019c736c3265d0a3ef379f1", + "publication_manifest_digest": "8c74b4e414f7760429600b7ba0b3627fcfe438417bfe6695f55acfa6905d352e", + "source_inventory_digest": "e84453b6b411a7d66d2b5efbf083cd0033441d6b12983326442933da87bf61fd", + "source_set_digest": "b00535063c56ede08f0a292b301a47add18bf70f81904bf3f434c8faa5ebe682", "residual_risk_owner": "the-custodian", - "proposal_id": "POLICY-PUB-20261011-SECURITY-LAYER-V08-R1", - "publication_source_revision": "b64a0c52ec1ea8d1efa99dd7f870e580d7c60df8", - "source_lock_digest": "99c695f46a54ead4cfe8f560f3648bf4db19404fe198b156c740115c97d3ed40", - "approval_ref": "informed-decision:infd-20261011-policy-nexus-hosting-r1@1; approval:52a3c479-2cb1-4074-abec-6a88117ad596", - "verified_helm_revision": 9, - "native_verification_ref": "rapp-policy-nexus/docs/evidence/2026-10-11-security-layer-v08-native-hosting/" + "proposal_id": "POLICY-PUB-20261019-BUILD-MODE-GUARDRAIL-R1", + "publication_source_revision": "9fabcce1cb4c83165bcaba8be58d76788b4d87a1", + "source_lock_digest": "c150c6c0745441b4b69884ecc6cf55e97db26080fcf4c2224775b1f755580529", + "approval_ref": "conversation:call_5157a09329d34746a3fdf857d5a47b8d:item0", + "verified_helm_revision": null, + "native_verification_ref": null } diff --git a/values/policy-nexus-production-candidate.yaml b/values/policy-nexus-production-candidate.yaml index 7261bcc..98984f2 100644 --- a/values/policy-nexus-production-candidate.yaml +++ b/values/policy-nexus-production-candidate.yaml @@ -1,6 +1,6 @@ image: repository: forgejo.coulomb.social/coulomb/policy-nexus - digest: sha256:4e0db6a57eb00f577159aa6ee10f910dd67b03fd0541c6ce849f5426fdb99d06 -publicationManifestDigest: ba53cc4900e6bb9ab8aa4bac4e58cb5fe7a03d656bafb3f9cb3d9c8ebff7843a -sourceInventoryDigest: 40d9e8852fe4c9e75b71f371d0fdae38664b068e7498711b0ac5671d0f41efb5 -sourceSetDigest: 15bda2e332285afc862df8974cf29de403737df5073fa362adfc6d203cf2f256 + digest: sha256:214b122c3d175563f114552fa1826dac9642433be019c736c3265d0a3ef379f1 +publicationManifestDigest: 8c74b4e414f7760429600b7ba0b3627fcfe438417bfe6695f55acfa6905d352e +sourceInventoryDigest: e84453b6b411a7d66d2b5efbf083cd0033441d6b12983326442933da87bf61fd +sourceSetDigest: b00535063c56ede08f0a292b301a47add18bf70f81904bf3f434c8faa5ebe682 diff --git a/values/policy-nexus-production.yaml b/values/policy-nexus-production.yaml index 7261bcc..98984f2 100644 --- a/values/policy-nexus-production.yaml +++ b/values/policy-nexus-production.yaml @@ -1,6 +1,6 @@ image: repository: forgejo.coulomb.social/coulomb/policy-nexus - digest: sha256:4e0db6a57eb00f577159aa6ee10f910dd67b03fd0541c6ce849f5426fdb99d06 -publicationManifestDigest: ba53cc4900e6bb9ab8aa4bac4e58cb5fe7a03d656bafb3f9cb3d9c8ebff7843a -sourceInventoryDigest: 40d9e8852fe4c9e75b71f371d0fdae38664b068e7498711b0ac5671d0f41efb5 -sourceSetDigest: 15bda2e332285afc862df8974cf29de403737df5073fa362adfc6d203cf2f256 + digest: sha256:214b122c3d175563f114552fa1826dac9642433be019c736c3265d0a3ef379f1 +publicationManifestDigest: 8c74b4e414f7760429600b7ba0b3627fcfe438417bfe6695f55acfa6905d352e +sourceInventoryDigest: e84453b6b411a7d66d2b5efbf083cd0033441d6b12983326442933da87bf61fd +sourceSetDigest: b00535063c56ede08f0a292b301a47add18bf70f81904bf3f434c8faa5ebe682 From 170e9b702ca3ca21ace041a027f598204337c4de Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 19 Oct 2026 15:39:36 +0200 Subject: [PATCH 54/87] plan: receive exact-release and contention sizing evidence Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a15374-f59f-7a31-86d2-d23b5d2ed181 --- SCOPE.md | 12 +++++ WORK-RECORDS.md | 3 +- .../RAPPS-WP-0014-vergabe-invited-pilot.md | 51 ++++++++++++++++++- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/SCOPE.md b/SCOPE.md index 7ccf0f3..3b2604c 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -291,3 +291,15 @@ existing bytes remain available for offline session startup; refreshing it does not require a Git commit. Authored workplans and the generated WORK-RECORDS index retain their existing source and staged-generation contracts. No runtime or credential change follows from this repository hygiene policy. + + +## Synthetic pilot sizing follow-up + +Application-owner local evidence and configurable workloads live in +`vergabe-teilnahme/docs/evidence/2026-10-19-synthetic-sizing/` and that repository's +`config/synthetic-pilot*.json`. RAPPS-WP-0014-T04 owns fresh exact release/hardware +metadata and a co-timed isolated replay/shared-contention assessment feeding +CUST-WP-0071-T02 and RESOURCE-WP-0008-T02. Routine read-only capture/preparation is +authorized; no benchmark writes to customer datasets or unreviewed deployment +changes follow from the local synthetic result. The existing T03 human/recovery +gate remains separate and unchanged. diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 3b77af4..94d107d 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -11,7 +11,7 @@ | workplan | RAPPS-WP-0001 | finished | — | workplans/RAPPS-WP-0001-cnpg-backup-scheduledbackup-coverage.md | | workplan | RAPPS-WP-0002 | finished | — | workplans/RAPPS-WP-0002-railiance01-activity-core-backup-automation.md | | workplan | RAPPS-WP-0003 | finished | — | workplans/RAPPS-WP-0003-policy-nexus-production-binding.md | -| workplan | RAPPS-WP-0014 | blocked | — | workplans/RAPPS-WP-0014-vergabe-invited-pilot.md | +| workplan | RAPPS-WP-0014 | active | — | workplans/RAPPS-WP-0014-vergabe-invited-pilot.md | | workplan | RAPPS-WP-0015 | active | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | workplan | RAPPS-WP-0016 | active | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | workplan | RAPPS-WP-0017 | finished | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | @@ -34,6 +34,7 @@ | task | RAPPS-WP-0014-T01 | done | — | workplans/RAPPS-WP-0014-vergabe-invited-pilot.md | | task | RAPPS-WP-0014-T02 | done | — | workplans/RAPPS-WP-0014-vergabe-invited-pilot.md | | task | RAPPS-WP-0014-T03 | wait | — | workplans/RAPPS-WP-0014-vergabe-invited-pilot.md | +| task | RAPPS-WP-0014-T04 | todo | — | workplans/RAPPS-WP-0014-vergabe-invited-pilot.md | | task | RAPPS-WP-0015-T01 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T02 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T03 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | diff --git a/workplans/RAPPS-WP-0014-vergabe-invited-pilot.md b/workplans/RAPPS-WP-0014-vergabe-invited-pilot.md index 377f27b..d1cfe51 100644 --- a/workplans/RAPPS-WP-0014-vergabe-invited-pilot.md +++ b/workplans/RAPPS-WP-0014-vergabe-invited-pilot.md @@ -4,12 +4,12 @@ type: workplan title: "Deploy and recover the first invited Vergabe company pilot" domain: financials repo: railiance-apps -status: blocked +status: active flavor: implementation owner: the-custodian topic_slug: railiance created: "2026-09-11" -updated: "2026-10-03" +updated: "2026-10-19" related: [VERGABE-WP-0019, VERGABE-WP-0018, HFACT-WP-0001, CUST-WP-0071] state_hub_workstream_id: "c7fdaa7e-cab8-5d1d-86c2-f1aad7927c57" --- @@ -246,3 +246,50 @@ path needs Bernd Worsch's attended login. Existing tasks retain all remaining work; no task or workplan was opened. 2026-10-03: human gate routed to hub decision d06307e5-2737-461a-a2f4-a12c3b492a9a (D7: Vergabe pilot: two real users run the invited setup-to-workflow acceptance) by CUST-WP-0075; resolving that decision raises C-40 here. + + +## Validate synthetic sizing against the exact release and shared contention + +```task +id: RAPPS-WP-0014-T04 +status: todo +priority: high +assignee: railiance-apps +state_hub_task_id: "c53fcaa9-859c-5b37-a17b-0a4cd2fb0e26" +``` + +Receiving task for `CUST-WP-0071-T02` and `RESOURCE-WP-0008-T02`. The founder +selected provisional configurable synthetic sizing on 2026-10-19; it does not +replace the separate T03 human/recovery gate. Local application-owner evidence +is in `vergabe-teilnahme/docs/evidence/2026-10-19-synthetic-sizing/`, with tunable +workload and runtime profiles in that repository's `config/synthetic-pilot*.json`. +The 1000m/1Gi local container comparison is a local observation, not a finding +about the exact deployed release or a guarantee provided by a 60m reservation. + +Routine read-only capture and source preparation are authorized work for this +task; they do not require another founder decision. Capture fresh exact release +metadata (image digest, chart/source revision, Gunicorn worker count, CPU/memory +requests and limits, node CPU model/capacity, database binding and observation +window). Record drift against the local test image and configuration rather than +assuming source HEAD equals the deployed image. + +Prepare the smallest isolated synthetic-data replay on the target hardware, +using the same named operation/phase profile and a pinned release image. Reuse +owner deployment/database custody and admission rules. Do not write workload +fixtures or benchmark operations to an existing customer dataset. Any deployment +mutation remains within its existing owner-reviewed execution scope; this task +is not a blanket production change or credential grant. + +Acceptance output must retain a co-timed idle baseline and workload window for +application CPU/memory/throttling, per-operation p95/errors and separate document +transfer, plus incremental PostgreSQL/shared-service demand and node contention. +Record competing workloads/available CPU during the window and distinguish a +CPU request from a quota. If shared-load representativeness or isolation cannot +be established, preserve that concrete gap rather than promote a local pass to +production acceptance. No new general benchmark suite is requested. + +Return pinned configuration, hardware/release provenance, raw sampled evidence, +coverage/gaps and a bounded recommendation to `RESOURCE-WP-0008-T02` for headroom +and allocation review and `CUST-WP-0071-T02` for sizing acceptance. T03 remains +unchanged: operator/customer recovery and human acceptance are not inferred from +these synthetic results. From 3851865c873b3b36550c0aa2c03e53509fe41ad2 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 19 Oct 2026 17:55:35 +0200 Subject: [PATCH 55/87] Record verified approved Policy Nexus deployment Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a15374-f59f-7a31-86d2-d23b5d2ed181 --- bindings/policy-nexus-production.json | 4 +- .../receipt.json | 83 +++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 docs/evidence/2026-10-19-build-mode-publication/receipt.json diff --git a/bindings/policy-nexus-production.json b/bindings/policy-nexus-production.json index c40815a..ce81297 100644 --- a/bindings/policy-nexus-production.json +++ b/bindings/policy-nexus-production.json @@ -18,6 +18,6 @@ "publication_source_revision": "9fabcce1cb4c83165bcaba8be58d76788b4d87a1", "source_lock_digest": "c150c6c0745441b4b69884ecc6cf55e97db26080fcf4c2224775b1f755580529", "approval_ref": "conversation:call_5157a09329d34746a3fdf857d5a47b8d:item0", - "verified_helm_revision": null, - "native_verification_ref": null + "verified_helm_revision": 10, + "native_verification_ref": "docs/evidence/2026-10-19-build-mode-publication/receipt.json" } diff --git a/docs/evidence/2026-10-19-build-mode-publication/receipt.json b/docs/evidence/2026-10-19-build-mode-publication/receipt.json new file mode 100644 index 0000000..054b82f --- /dev/null +++ b/docs/evidence/2026-10-19-build-mode-publication/receipt.json @@ -0,0 +1,83 @@ +{ + "proposal_id": "POLICY-PUB-20261019-BUILD-MODE-GUARDRAIL-R1", + "approved_revision": 1, + "approved_packet_hashlist_sha256": "309ef01650b65bc6b28e86daad9b2835a242631cf60e0896e849461bb917504f", + "approval_ref": "conversation:call_5157a09329d34746a3fdf857d5a47b8d:item0", + "result": "verified", + "helm_revision": 10, + "rollback_required": false, + "rollback_revision": 9, + "candidate_source_revision": "9fabcce1cb4c83165bcaba8be58d76788b4d87a1", + "candidate_origin": "local owner-format build; native CI run89 retained failed, no new CI success claimed", + "change_gate": { + "decision": "the-custodian/docs/kubernetes-change-gate-decision.md", + "target_tier": "production", + "readiness_state": "production-approved", + "path": "direct-administer-transition", + "auth_mode": "ADMINISTER @ realm:kubernetes/railiance01", + "activation": "APPROVED", + "evidence_boundary": "target-audited", + "transition_ends": "2026-12-21" + }, + "native": { + "capture_utc": "2026-10-19T13:35:06.098696+00:00", + "cluster_mutated": false, + "expected_node_ip": "92.205.62.239", + "expected_node_ip_present": true, + "helm_revision": 10, + "helm_status": "deployed", + "image_matches_candidate": true, + "machine_id_sha256": "fe7367a60951922122af7ba18ed2cde65c7e0a232bbedfb045cb575de2094c2f", + "ready_replicas": 1, + "release_pin_matches": { + "publication-manifest-digest": true, + "source-inventory-digest": true, + "source-set-digest": true + }, + "replicas": 1, + "secret_values_observed": false, + "ssh_alias": "railiance01" + }, + "public": { + "verified": true, + "capture_utc": "2026-10-19T13:36:20.944233+00:00", + "hashes": { + "publication_manifest_digest": "8c74b4e414f7760429600b7ba0b3627fcfe438417bfe6695f55acfa6905d352e", + "source_inventory_digest": "e84453b6b411a7d66d2b5efbf083cd0033441d6b12983326442933da87bf61fd", + "source_lock_digest": "c150c6c0745441b4b69884ecc6cf55e97db26080fcf4c2224775b1f755580529" + }, + "source_set_digest": "b00535063c56ede08f0a292b301a47add18bf70f81904bf3f434c8faa5ebe682", + "document_count": 74, + "previous73_source_digests_unchanged": true, + "added_document": "canon-build-mode-change-authorization", + "all_canonical_and_revision_pages_byte_equal_candidate": true, + "page_and_artifact_count": 150, + "guardrail_expiry_banner": "Expires on: production-close-down", + "guardrail_json_byte_equal_verified_candidate": true, + "guardrail_json_sha256": "a86462a57bb1df5804d3088881af22569082d43ff68bcab2b67cbe56b6fc46cd", + "tls_verified": true, + "refinements": [ + "Initial verifier incorrectly expected hosting transition date2026-12-21 in canon expiry.", + "Second verifier expected source expires_on label rather than rendered Expires on.", + "Correct final assertion matches approved rendered banner; both failed checks retained, artifact and deployment unchanged." + ] + }, + "deployment": { + "started_utc": "2026-10-19T13:34:20.566225+00:00", + "command": [ + "make", + "policy-nexus-deploy", + "POLICY_NEXUS_TARGET=railiance01" + ], + "atomic": true, + "attempt": 1, + "approval_ref": "conversation:call_5157a09329d34746a3fdf857d5a47b8d:item0", + "completed_utc": "2026-10-19T13:34:26.648713+00:00", + "returncode": 0, + "raw_output_suppressed": true + }, + "owner_source_commits": { + "rapp-policy-nexus": "6ead90ffa4ae5742c37c292485762e83113382c3", + "railiance-apps": "3be92daf74a7572319bfe94dd882a70430528343" + } +} From 1af6d828f5dcced5af4b279ba11f9f0236f56811 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 21 Oct 2026 08:38:12 +0200 Subject: [PATCH 56/87] RAPPS-WP-0014-T04: record isolated target-hardware Vergabe replay Throwaway namespace on railiance01 ran the deployed digest (3 workers, 60m/1000m CPU, 256Mi/1Gi) against fresh PostgreSQL 16 with the phased synthetic profile: ordinary p95 0.192s, transfer p95 0.076s, zero errors. Raw cgroup/node/Prometheus samples, manifests, driver and a bounded recommendation retained; namespace torn down; T03 unchanged. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 8340@bnt-lap001 Assistant-Session: 2e192b3d-b3e8-4403-94d4-d55a2c8b476c --- WORK-RECORDS.md | 2 +- .../README.md | 155 +++ .../analysis.json | 923 ++++++++++++++ .../harness/analyze.py | 73 ++ .../harness/bench.yaml | 125 ++ .../harness/driver-config.json | 53 + .../harness/driver.yaml | 29 + .../harness/executed-synthetic_pilot.py | 1091 +++++++++++++++++ .../harness/orchestrate.sh | 15 + .../harness/prom.py | 25 + .../harness/sampler.sh | 16 + .../harness/synthetic_settings.py | 12 + .../harness/top.sh | 3 + .../raw/app-cgroup.txt.gz | Bin 0 -> 30529 bytes .../raw/db-cgroup.txt.gz | Bin 0 -> 29035 bytes .../raw/driver-cgroup.txt.gz | Bin 0 -> 28201 bytes .../raw/driver-stdout.txt | 1 + .../raw/kubectl-top.txt.gz | Bin 0 -> 8104 bytes .../raw/namespace-events.txt | 36 + .../raw/pod-provenance.txt | 4 + .../raw/prometheus-baseline.json.gz | Bin 0 -> 7943 bytes .../raw/prometheus-workload.json.gz | Bin 0 -> 10883 bytes .../raw/result.json.gz | Bin 0 -> 58759 bytes .../raw/teardown.txt | 7 + .../raw/windows.txt | 4 + .../RAPPS-WP-0014-vergabe-invited-pilot.md | 19 +- 26 files changed, 2590 insertions(+), 3 deletions(-) create mode 100644 docs/evidence/2026-10-21-vergabe-target-replay/README.md create mode 100644 docs/evidence/2026-10-21-vergabe-target-replay/analysis.json create mode 100644 docs/evidence/2026-10-21-vergabe-target-replay/harness/analyze.py create mode 100644 docs/evidence/2026-10-21-vergabe-target-replay/harness/bench.yaml create mode 100644 docs/evidence/2026-10-21-vergabe-target-replay/harness/driver-config.json create mode 100644 docs/evidence/2026-10-21-vergabe-target-replay/harness/driver.yaml create mode 100644 docs/evidence/2026-10-21-vergabe-target-replay/harness/executed-synthetic_pilot.py create mode 100755 docs/evidence/2026-10-21-vergabe-target-replay/harness/orchestrate.sh create mode 100644 docs/evidence/2026-10-21-vergabe-target-replay/harness/prom.py create mode 100755 docs/evidence/2026-10-21-vergabe-target-replay/harness/sampler.sh create mode 100644 docs/evidence/2026-10-21-vergabe-target-replay/harness/synthetic_settings.py create mode 100755 docs/evidence/2026-10-21-vergabe-target-replay/harness/top.sh create mode 100644 docs/evidence/2026-10-21-vergabe-target-replay/raw/app-cgroup.txt.gz create mode 100644 docs/evidence/2026-10-21-vergabe-target-replay/raw/db-cgroup.txt.gz create mode 100644 docs/evidence/2026-10-21-vergabe-target-replay/raw/driver-cgroup.txt.gz create mode 100644 docs/evidence/2026-10-21-vergabe-target-replay/raw/driver-stdout.txt create mode 100644 docs/evidence/2026-10-21-vergabe-target-replay/raw/kubectl-top.txt.gz create mode 100644 docs/evidence/2026-10-21-vergabe-target-replay/raw/namespace-events.txt create mode 100644 docs/evidence/2026-10-21-vergabe-target-replay/raw/pod-provenance.txt create mode 100644 docs/evidence/2026-10-21-vergabe-target-replay/raw/prometheus-baseline.json.gz create mode 100644 docs/evidence/2026-10-21-vergabe-target-replay/raw/prometheus-workload.json.gz create mode 100644 docs/evidence/2026-10-21-vergabe-target-replay/raw/result.json.gz create mode 100644 docs/evidence/2026-10-21-vergabe-target-replay/raw/teardown.txt create mode 100644 docs/evidence/2026-10-21-vergabe-target-replay/raw/windows.txt diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 94d107d..f7c5fa8 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -34,7 +34,7 @@ | task | RAPPS-WP-0014-T01 | done | — | workplans/RAPPS-WP-0014-vergabe-invited-pilot.md | | task | RAPPS-WP-0014-T02 | done | — | workplans/RAPPS-WP-0014-vergabe-invited-pilot.md | | task | RAPPS-WP-0014-T03 | wait | — | workplans/RAPPS-WP-0014-vergabe-invited-pilot.md | -| task | RAPPS-WP-0014-T04 | todo | — | workplans/RAPPS-WP-0014-vergabe-invited-pilot.md | +| task | RAPPS-WP-0014-T04 | done | — | workplans/RAPPS-WP-0014-vergabe-invited-pilot.md | | task | RAPPS-WP-0015-T01 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T02 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T03 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | diff --git a/docs/evidence/2026-10-21-vergabe-target-replay/README.md b/docs/evidence/2026-10-21-vergabe-target-replay/README.md new file mode 100644 index 0000000..ef9c237 --- /dev/null +++ b/docs/evidence/2026-10-21-vergabe-target-replay/README.md @@ -0,0 +1,155 @@ +# Vergabe target-hardware synthetic replay — 2026-10-21 + +Task: `RAPPS-WP-0014-T04` (feeds `CUST-WP-0071-T02`, `RESOURCE-WP-0008-T02`). +Authority: founder decision of 2026-10-21 +(`the-custodian/docs/decisions/2026-10-21-cust-wp-0071-delivery-interview.md`): +isolated replay in a throwaway namespace on railiance01, reads elsewhere only. +No customer data was read or written; the live `vergabe-demo-company` deployment, +its database (`apps-pg`), quotas, policies and nodes were not modified (live +Deployment generation 4, same digest, Ready 1/1 before and after). No Secret was +read. This is synthetic sizing evidence, not human/recovery acceptance (T03). + +## Deployed release (read-only capture, 2026-10-21) + +| Item | Value | +| --- | --- | +| Cluster / node | context `default` → 92.205.62.239, single k3s v1.35.1 control-plane node | +| Node CPU / memory | 4 vCPU `AMD EPYC Processor (with IBPB)`, 16 GiB; requests 3605m (90%) / limits 37500m; reservation residual 395m | +| Image | `forgejo.coulomb.social/coulomb/vergabe-teilnahme@sha256:a26444f59c259698159c69ccb96f73dc648a261ece4c86bb2037a9d977870d91` (source `c067993`, per `docs/vergabe-demo-company-binding.md`) | +| Chart / rollout | `vergabe-teilnahme-0.2.1`, one replica, Recreate, init `migrate`; Deployment revision 4 (rollout restart 2026-09-25). Helm release revision not re-read (release storage is a Secret) | +| Gunicorn | no command override → image CMD `--workers 3` (sync), default 30s timeout | +| Resources (app and migrate) | requests 60m CPU / 256Mi, limits 1000m CPU / 1Gi | +| Settings | `vergabe_teilnahme.settings.prod`, SSO via NetKingdom, `envFrom` Secret reference `vergabe-demo-env` (not read) | +| Database binding | namespace label `railiance.io/postgres-client=apps-pg`: shared CNPG `databases/apps-pg`, PostgreSQL 16, 100m/256Mi request, 1000m/1Gi limit, WAL archiving + `wal_level=logical` | +| Storage | media 5Gi and app-state 1Gi PVCs, `local-path` | + +Drift vs. the 2026-10-19 local test image (`vergabe-synthetic:local-11f7c8a`, +`sha256:57da46…`): source `11f7c8a` is 16 commits ahead of deployed `c067993`; +9 application files differ (+181/−10: ausschreibungen forms/views/templates, +core views, search partial); Dockerfile, `uv.lock`, `pyproject.toml` identical. +The local runs used 2 workers; the deployed image runs 3. This replay used the +deployed digest itself, so the drift does not affect these results. + +## Replay configuration (pinned) + +Namespace `vergabe-sizing-20261021` (manifests in `harness/bench.yaml`, +`harness/driver.yaml`), all pods on the same node: + +- **app**: deployed digest, image CMD (3 workers), init `migrate`, 60m/256Mi request, + 1000m/1Gi limit, live securityContext/probes, Recreate; media on a fresh + `local-path` PVC, issue state emptyDir. Settings = harness synthetic settings + (`harness/synthetic_settings.py`: base settings, SSO off, password login). +- **db**: fresh `docker.io/library/postgres@sha256:4b7183ac…` (PostgreSQL 16), + emptyDir, 100m/256Mi request, 1000m/1Gi limit (matching apps-pg resources). +- **driver**: deployed digest running `vergabe-teilnahme/scripts/synthetic_pilot.py` + `runtime: remote` (commit `dca6683`; executed copy and config retained, digests + `c8da867f…` / `9045f2c7…` match `raw/result.json.gz`), 100m request/1000m limit. + In-cluster HTTP via ClusterIP Service — chosen over port-forward so latency + excludes the WSL/SSH tunnel. Restart deletes the app pod through a namespaced + Role (pods get/list/delete) and waits for a different ready pod. +- NetworkPolicy: ingress/egress within the namespace, DNS, API server only; + preflight confirmed the live Service was unreachable. +- Profile `config/synthetic-pilot-phases.json` unchanged except runtime fields + (`synthetic-pilot-target-remote.json`): 2 users, 100 seed tenders, 1 MiB + documents, 30s idle, 90s ordinary (0.2s think), 30s burst (0 think), restart, + 60s recovery; 11 named operations including edits. +- Sampling: 1s cgroup v2 `cpu.stat`/`memory.current`/`cpu.pressure` inside app, + db and driver containers plus node-wide `/proc/stat` (`harness/sampler.sh`); + `kubectl top` every 15s; Prometheus (cadvisor) per-namespace CPU/throttling. + The sampler shell itself costs ~11–12m per container and is included. + +Timeline (UTC): namespace created 06:26; pre-run idle baseline 06:27:14–06:29:26; +harness idle 06:29:31–06:30:01; ordinary 06:30:01–06:31:32; burst –06:32:03; +restart –06:32:22 (18.98s to ready incl. migrate); recovery –06:33:23; post +idle –06:34:28; namespace deletion requested 06:35:25, completed 06:36:18; namespace and its PV verified absent (`raw/teardown.txt`). + +## Results + +Driver: complete, `provisional_pass: true`, `measurement_complete: true`, no gaps. + +| Phase | Ordinary req / err / p95 | Transfer req / err / p95 | +| --- | --- | --- | +| ordinary | 1903 / 0 / 0.142s | 422 / 0 / 0.085s | +| burst | 697 / 0 / 0.192s | 154 / 0 / 0.069s | +| recovery | 1075 / 0 / 0.214s | 238 / 0 / 0.073s | +| **all** | **3675 / 0 / 0.192s** | **814 / 0 / 0.076s** | + +Per operation p95 (all phases): list 0.262s, search 0.255s, detail 0.146s, +creates/edits 0.059–0.066s, upload 0.088s, download 0.050s; login 1.27s and +login form 0.62s (n=6, first requests after idle). ~24.7 requests/s closed loop — +far above two human users' pace. + +| Window | App CPU mean / p95 / peak | Throttled periods | App memory peak | DB CPU mean / p95 | Node busy mean (free mean / free p5) | +| --- | --- | --- | --- | --- | --- | +| pre-run baseline | 12m / 15m / 16m | 0% | 166 MiB | 22m / 66m | 1348m (2652m / 1499m) | +| ordinary | 711m / 845m / 1003m | 20.4% | 403 MiB | 388m / 501m | 2979m (1021m / 78m) | +| burst | 953m / 1004m / 1011m | 65.8% | 482 MiB | 430m / 539m | 2806m (1194m / 278m) | +| recovery | 861m / 957m / 982m | 41.5% | 322 MiB | 335m / 399m | 2624m (1376m / 392m) | +| all load phases | 775m / 998m / 1020m | 35.7% (22.8s throttled) | 483 MiB | 341m / 500m | 2790m (1210m / 147m) | +| post | 12m | 0% | 323 MiB | 22m | 1413m (2587m) | + +App memory is cgroup `memory.current` (includes page cache); no OOM, no restarts. +CPU pressure (PSI some) on the app was 15–21% of wall time under load. +Incremental PostgreSQL CPU ≈ 320m mean / ~480m p95 over its idle baseline, on +the isolated DB only (live `apps-pg` stayed at ~8m during the window). Driver +added ~84m mean. + +Competing workloads (Prometheus, workload window): all containers 1622m mean, of +which the replay namespace 915m; others led by databases 121m, state-hub 120m, +telemetry 102m, forgejo 78m (max 385m), activity-core 59m, core-hub 43m; live +vergabe ~0m. Node-wide busy includes ~0.7 core of k3s/system outside containers. +Pre-run competing load left ~2.65 cores free on average; during the replay the +node reached near-saturation at peaks (free p5 ~150m) largely through the replay +itself (app + db + driver ≈ 1.2 cores). Requests during the run were 3865m of +4000m (replay 260m of the 395m residual, released at teardown). + +## Coverage and gaps + +- Representative: exact deployed image/digest, worker count, CPU/memory + requests and limits (CFS quota 100000/100000 verified), Recreate + init migrate + restart path, target hardware and real shared node contention. +- Not representative: synthetic settings instead of prod (no SSO/OIDC, insecure + cookies, no whitenoise manifest storage); no Traefik ingress/TLS/browser; DB is a + standalone PostgreSQL 16 pod, not shared CNPG `apps-pg` (WAL archiving, logical + WAL, other tenants) — the ~320m incremental DB demand would land on apps-pg's + 1000m limit/100m request in production and is not proven there. +- Load is a synthetic closed loop (~25 req/s), not observed human demand; one run + with ~3.5 min of load (pinned profile), ~9 min namespace lifetime. +- Node contention was partly self-induced; contention from other tenants was + light at this hour. A busier moment (forgejo CI, backups) is not covered. +- The 60m request is a scheduling weight/reservation, not a quota; the 1000m + limit is the quota that throttled. Sampler exec overhead is in the app cgroup. + +## Bounded recommendation (for RESOURCE-WP-0008-T02 / CUST-WP-0071-T02) + +- **CPU limit 1000m: keep.** Under an aggressive synthetic mix it throttles + (36% of periods, 66% in burst) yet ordinary p95 stays ≤0.22s per phase — ~9× + margin to the 2s target, zero failed operations, including restart recovery. +- **CPU request 60m: keep for the two-user invited pilot**, provisionally. Even + with the node near saturation and only a 60m weight, latency stayed far inside + target; two humans generate a small fraction of this load. Raising it (e.g. + 250m) is not required by these results and would consume most of the 395m + residual — an allocation-owner decision, not made here. +- **Memory limit 1Gi: keep** (peak 483 MiB incl. page cache, ~2× margin). + **Memory request 256Mi: keep, consider 512Mi** at the next allocation review, + since loaded working set (320–480 MiB) exceeds the request; node memory requests + are 61%, so this is low-risk but not urgent. +- **Database**: budget ~0.3–0.5 core incremental on apps-pg at this synthetic + rate; acceptable against its 1000m limit for a two-user pilot, unverified on the + shared cluster. +- **Revisit when**: user count/companies or corpus grow, image or worker count + changes, live p95 >1s or any 5xx, node requests >95% or sustained node free CPU + <500m outside benchmarks, apps-pg CPU >600m sustained, or the pilot moves to + railiance02/other hardware. + +## Files + +- `analysis.json` — per-window cgroup statistics, per-operation p95, phase results + (`harness/analyze.py raw/ analysis.json` after gunzip) +- `raw/result.json.gz` — driver output (all operations, phase UTC windows, host facts) +- `raw/{app,db,driver}-cgroup.txt.gz` — 1s cgroup/node samples (`POD` lines mark + the restart re-attach; the `exit code 137` exec termination is that restart) +- `raw/kubectl-top.txt.gz`, `raw/prometheus-{baseline,workload}.json.gz` +- `raw/windows.txt`, `raw/pod-provenance.txt`, `raw/namespace-events.txt`, + `raw/teardown.txt`, `raw/driver-stdout.txt` +- `harness/` — manifests, settings, samplers, orchestrator, executed driver/config diff --git a/docs/evidence/2026-10-21-vergabe-target-replay/analysis.json b/docs/evidence/2026-10-21-vergabe-target-replay/analysis.json new file mode 100644 index 0000000..06ef31f --- /dev/null +++ b/docs/evidence/2026-10-21-vergabe-target-replay/analysis.json @@ -0,0 +1,923 @@ +{ + "windows_utc": { + "baseline_pre": [ + "2026-10-21T06:27:14.447540Z", + "2026-10-21T06:29:26.175778Z" + ], + "harness_idle": [ + "2026-10-21T06:29:31.522662Z", + "2026-10-21T06:30:01.522800Z" + ], + "ordinary": [ + "2026-10-21T06:30:01.523056Z", + "2026-10-21T06:31:32.589152Z" + ], + "burst": [ + "2026-10-21T06:31:32.589194Z", + "2026-10-21T06:32:03.275085Z" + ], + "restart": [ + "2026-10-21T06:32:03.275117Z", + "2026-10-21T06:32:22.256585Z" + ], + "recovery": [ + "2026-10-21T06:32:22.256609Z", + "2026-10-21T06:33:23.057045Z" + ], + "workload_all_load_phases": [ + "2026-10-21T06:30:01.523056Z", + "2026-10-21T06:33:23.057045Z" + ], + "post": [ + "2026-10-21T06:33:22.885983Z", + "2026-10-21T06:34:28.753426Z" + ] + }, + "cgroup": { + "app": { + "pods": [ + "vergabe-app-579448bf49-2fsn2", + "vergabe-app-579448bf49-plp4j" + ], + "baseline_pre": { + "samples": 126, + "seconds": 128.0, + "cpu_mean_m": 12.2, + "cpu_p95_m": 14.7, + "cpu_peak_m": 16.2, + "cfs_periods": 317, + "cfs_throttled_periods": 0, + "throttle_ratio": 0.0, + "throttled_seconds": 0.0, + "psi_cpu_some_share": 0.002, + "mem_peak_mib": 165.6, + "mem_mean_mib": 165.2, + "node_busy_mean_m": 1348, + "node_busy_p95_m": 2501, + "node_free_mean_m": 2652, + "node_free_p5_m": 1499, + "load1_max": 4.91 + }, + "harness_idle": { + "samples": 28, + "seconds": 28.4, + "cpu_mean_m": 11.5, + "cpu_p95_m": 13.4, + "cpu_peak_m": 13.6, + "cfs_periods": 76, + "cfs_throttled_periods": 0, + "throttle_ratio": 0.0, + "throttled_seconds": 0.0, + "psi_cpu_some_share": 0.002, + "mem_peak_mib": 165.3, + "mem_mean_mib": 165.1, + "node_busy_mean_m": 1538, + "node_busy_p95_m": 2800, + "node_free_mean_m": 2462, + "node_free_p5_m": 1200, + "load1_max": 2.91 + }, + "ordinary": { + "samples": 88, + "seconds": 90.2, + "cpu_mean_m": 711.4, + "cpu_p95_m": 845.4, + "cpu_peak_m": 1003.2, + "cfs_periods": 902, + "cfs_throttled_periods": 184, + "throttle_ratio": 0.204, + "throttled_seconds": 4.85, + "psi_cpu_some_share": 0.15, + "mem_peak_mib": 402.5, + "mem_mean_mib": 295.2, + "node_busy_mean_m": 2979, + "node_busy_p95_m": 3922, + "node_free_mean_m": 1021, + "node_free_p5_m": 78, + "load1_max": 7.83 + }, + "burst": { + "samples": 29, + "seconds": 29.9, + "cpu_mean_m": 952.9, + "cpu_p95_m": 1003.7, + "cpu_peak_m": 1011.1, + "cfs_periods": 298, + "cfs_throttled_periods": 196, + "throttle_ratio": 0.658, + "throttled_seconds": 8.94, + "psi_cpu_some_share": 0.211, + "mem_peak_mib": 481.8, + "mem_mean_mib": 443.4, + "node_busy_mean_m": 2806, + "node_busy_p95_m": 3722, + "node_free_mean_m": 1194, + "node_free_p5_m": 278, + "load1_max": 8.19 + }, + "restart": { + "samples": 10, + "seconds": 10.3, + "cpu_mean_m": 403.3, + "cpu_p95_m": 1019.7, + "cpu_peak_m": 1019.7, + "cfs_periods": 54, + "cfs_throttled_periods": 39, + "throttle_ratio": 0.722, + "throttled_seconds": 2.37, + "psi_cpu_some_share": 0.235, + "mem_peak_mib": 483.3, + "mem_mean_mib": 154.0, + "node_busy_mean_m": 1908, + "node_busy_p95_m": 3359, + "node_free_mean_m": 2092, + "node_free_p5_m": 641, + "load1_max": 7.84 + }, + "recovery": { + "samples": 59, + "seconds": 60.6, + "cpu_mean_m": 860.6, + "cpu_p95_m": 956.8, + "cpu_peak_m": 982.3, + "cfs_periods": 605, + "cfs_throttled_periods": 251, + "throttle_ratio": 0.415, + "throttled_seconds": 6.17, + "psi_cpu_some_share": 0.152, + "mem_peak_mib": 322.2, + "mem_mean_mib": 251.0, + "node_busy_mean_m": 2624, + "node_busy_p95_m": 3608, + "node_free_mean_m": 1376, + "node_free_p5_m": 392, + "load1_max": 6.66 + }, + "workload_all_load_phases": { + "samples": 189, + "seconds": 194.0, + "cpu_mean_m": 775.3, + "cpu_p95_m": 998.3, + "cpu_peak_m": 1019.7, + "cfs_periods": 1889, + "cfs_throttled_periods": 675, + "throttle_ratio": 0.357, + "throttled_seconds": 22.77, + "psi_cpu_some_share": 0.166, + "mem_peak_mib": 483.3, + "mem_mean_mib": 295.7, + "node_busy_mean_m": 2790, + "node_busy_p95_m": 3853, + "node_free_mean_m": 1210, + "node_free_p5_m": 147, + "load1_max": 8.19 + }, + "post": { + "samples": 63, + "seconds": 63.9, + "cpu_mean_m": 12.3, + "cpu_p95_m": 14.3, + "cpu_peak_m": 15.1, + "cfs_periods": 161, + "cfs_throttled_periods": 0, + "throttle_ratio": 0.0, + "throttled_seconds": 0.0, + "psi_cpu_some_share": 0.001, + "mem_peak_mib": 322.5, + "mem_mean_mib": 322.2, + "node_busy_mean_m": 1413, + "node_busy_p95_m": 2414, + "node_free_mean_m": 2587, + "node_free_p5_m": 1586, + "load1_max": 5.75 + } + }, + "db": { + "pods": [ + "vergabe-db-7c4bffdd48-w4jz4" + ], + "baseline_pre": { + "samples": 127, + "seconds": 128.5, + "cpu_mean_m": 21.5, + "cpu_p95_m": 65.7, + "cpu_peak_m": 71.7, + "cfs_periods": 333, + "cfs_throttled_periods": 0, + "throttle_ratio": 0.0, + "throttled_seconds": 0.0, + "psi_cpu_some_share": 0.002, + "mem_peak_mib": 91.3, + "mem_mean_mib": 90.4, + "node_busy_mean_m": 1346, + "node_busy_p95_m": 2448, + "node_free_mean_m": 2654, + "node_free_p5_m": 1552, + "load1_max": 4.91 + }, + "harness_idle": { + "samples": 29, + "seconds": 29.3, + "cpu_mean_m": 20.9, + "cpu_p95_m": 64.5, + "cpu_peak_m": 64.9, + "cfs_periods": 75, + "cfs_throttled_periods": 0, + "throttle_ratio": 0.0, + "throttled_seconds": 0.0, + "psi_cpu_some_share": 0.003, + "mem_peak_mib": 93.0, + "mem_mean_mib": 92.7, + "node_busy_mean_m": 1522, + "node_busy_p95_m": 2680, + "node_free_mean_m": 2478, + "node_free_p5_m": 1320, + "load1_max": 3.15 + }, + "ordinary": { + "samples": 88, + "seconds": 89.9, + "cpu_mean_m": 387.8, + "cpu_p95_m": 501.1, + "cpu_peak_m": 532.2, + "cfs_periods": 892, + "cfs_throttled_periods": 2, + "throttle_ratio": 0.002, + "throttled_seconds": 0.02, + "psi_cpu_some_share": 0.067, + "mem_peak_mib": 101.4, + "mem_mean_mib": 95.7, + "node_busy_mean_m": 2974, + "node_busy_p95_m": 3895, + "node_free_mean_m": 1026, + "node_free_p5_m": 105, + "load1_max": 8.19 + }, + "burst": { + "samples": 29, + "seconds": 29.6, + "cpu_mean_m": 430.1, + "cpu_p95_m": 539.1, + "cpu_peak_m": 544.4, + "cfs_periods": 293, + "cfs_throttled_periods": 2, + "throttle_ratio": 0.007, + "throttled_seconds": 0.06, + "psi_cpu_some_share": 0.062, + "mem_peak_mib": 113.1, + "mem_mean_mib": 97.1, + "node_busy_mean_m": 2798, + "node_busy_p95_m": 3664, + "node_free_mean_m": 1202, + "node_free_p5_m": 336, + "load1_max": 8.19 + }, + "restart": { + "samples": 18, + "seconds": 18.3, + "cpu_mean_m": 24.3, + "cpu_p95_m": 67.7, + "cpu_peak_m": 67.7, + "cfs_periods": 55, + "cfs_throttled_periods": 0, + "throttle_ratio": 0.0, + "throttled_seconds": 0.0, + "psi_cpu_some_share": 0.009, + "mem_peak_mib": 103.3, + "mem_mean_mib": 101.4, + "node_busy_mean_m": 2650, + "node_busy_p95_m": 3961, + "node_free_mean_m": 1350, + "node_free_p5_m": 39, + "load1_max": 7.84 + }, + "recovery": { + "samples": 58, + "seconds": 58.9, + "cpu_mean_m": 335.0, + "cpu_p95_m": 398.7, + "cpu_peak_m": 408.1, + "cfs_periods": 581, + "cfs_throttled_periods": 1, + "throttle_ratio": 0.002, + "throttled_seconds": 0.0, + "psi_cpu_some_share": 0.041, + "mem_peak_mib": 111.3, + "mem_mean_mib": 106.9, + "node_busy_mean_m": 2621, + "node_busy_p95_m": 3524, + "node_free_mean_m": 1379, + "node_free_p5_m": 476, + "load1_max": 6.66 + }, + "workload_all_load_phases": { + "samples": 196, + "seconds": 199.8, + "cpu_mean_m": 340.8, + "cpu_p95_m": 500.0, + "cpu_peak_m": 544.4, + "cfs_periods": 1837, + "cfs_throttled_periods": 5, + "throttle_ratio": 0.003, + "throttled_seconds": 0.08, + "psi_cpu_some_share": 0.052, + "mem_peak_mib": 113.1, + "mem_mean_mib": 99.8, + "node_busy_mean_m": 2813, + "node_busy_p95_m": 3894, + "node_free_mean_m": 1187, + "node_free_p5_m": 106, + "load1_max": 8.19 + }, + "post": { + "samples": 64, + "seconds": 64.8, + "cpu_mean_m": 21.7, + "cpu_p95_m": 65.1, + "cpu_peak_m": 79.6, + "cfs_periods": 161, + "cfs_throttled_periods": 0, + "throttle_ratio": 0.0, + "throttled_seconds": 0.0, + "psi_cpu_some_share": 0.002, + "mem_peak_mib": 101.3, + "mem_mean_mib": 98.1, + "node_busy_mean_m": 1420, + "node_busy_p95_m": 2509, + "node_free_mean_m": 2580, + "node_free_p5_m": 1491, + "load1_max": 5.75 + } + }, + "driver": { + "pods": [ + "synthetic-driver" + ], + "baseline_pre": { + "samples": 126, + "seconds": 127.9, + "cpu_mean_m": 11.6, + "cpu_p95_m": 13.2, + "cpu_peak_m": 13.7, + "cfs_periods": 267, + "cfs_throttled_periods": 0, + "throttle_ratio": 0.0, + "throttled_seconds": 0.0, + "psi_cpu_some_share": 0.002, + "mem_peak_mib": 6.6, + "mem_mean_mib": 6.3, + "node_busy_mean_m": 1348, + "node_busy_p95_m": 2510, + "node_free_mean_m": 2652, + "node_free_p5_m": 1490, + "load1_max": 4.91 + }, + "harness_idle": { + "samples": 28, + "seconds": 28.4, + "cpu_mean_m": 11.6, + "cpu_p95_m": 13.1, + "cpu_peak_m": 13.1, + "cfs_periods": 59, + "cfs_throttled_periods": 0, + "throttle_ratio": 0.0, + "throttled_seconds": 0.0, + "psi_cpu_some_share": 0.002, + "mem_peak_mib": 63.3, + "mem_mean_mib": 63.0, + "node_busy_mean_m": 1537, + "node_busy_p95_m": 2829, + "node_free_mean_m": 2463, + "node_free_p5_m": 1171, + "load1_max": 2.91 + }, + "ordinary": { + "samples": 88, + "seconds": 90.0, + "cpu_mean_m": 91.7, + "cpu_p95_m": 117.2, + "cpu_peak_m": 124.8, + "cfs_periods": 891, + "cfs_throttled_periods": 0, + "throttle_ratio": 0.0, + "throttled_seconds": 0.0, + "psi_cpu_some_share": 0.029, + "mem_peak_mib": 75.9, + "mem_mean_mib": 72.4, + "node_busy_mean_m": 2977, + "node_busy_p95_m": 3912, + "node_free_mean_m": 1023, + "node_free_p5_m": 88, + "load1_max": 7.83 + }, + "burst": { + "samples": 29, + "seconds": 29.6, + "cpu_mean_m": 98.5, + "cpu_p95_m": 124.8, + "cpu_peak_m": 129.3, + "cfs_periods": 289, + "cfs_throttled_periods": 0, + "throttle_ratio": 0.0, + "throttled_seconds": 0.0, + "psi_cpu_some_share": 0.026, + "mem_peak_mib": 78.4, + "mem_mean_mib": 76.4, + "node_busy_mean_m": 2790, + "node_busy_p95_m": 3387, + "node_free_mean_m": 1210, + "node_free_p5_m": 613, + "load1_max": 8.19 + }, + "restart": { + "samples": 18, + "seconds": 18.3, + "cpu_mean_m": 22.4, + "cpu_p95_m": 25.5, + "cpu_peak_m": 25.5, + "cfs_periods": 112, + "cfs_throttled_periods": 0, + "throttle_ratio": 0.0, + "throttled_seconds": 0.0, + "psi_cpu_some_share": 0.009, + "mem_peak_mib": 73.7, + "mem_mean_mib": 71.3, + "node_busy_mean_m": 2650, + "node_busy_p95_m": 3990, + "node_free_mean_m": 1350, + "node_free_p5_m": 10, + "load1_max": 7.84 + }, + "recovery": { + "samples": 59, + "seconds": 60.1, + "cpu_mean_m": 82.2, + "cpu_p95_m": 95.4, + "cpu_peak_m": 139.7, + "cfs_periods": 594, + "cfs_throttled_periods": 0, + "throttle_ratio": 0.0, + "throttled_seconds": 0.0, + "psi_cpu_some_share": 0.02, + "mem_peak_mib": 80.9, + "mem_mean_mib": 77.2, + "node_busy_mean_m": 2635, + "node_busy_p95_m": 3540, + "node_free_mean_m": 1365, + "node_free_p5_m": 460, + "load1_max": 6.66 + }, + "workload_all_load_phases": { + "samples": 197, + "seconds": 201.1, + "cpu_mean_m": 84.4, + "cpu_p95_m": 117.7, + "cpu_peak_m": 200.8, + "cfs_periods": 1911, + "cfs_throttled_periods": 0, + "throttle_ratio": 0.0, + "throttled_seconds": 0.0, + "psi_cpu_some_share": 0.024, + "mem_peak_mib": 80.9, + "mem_mean_mib": 74.4, + "node_busy_mean_m": 2820, + "node_busy_p95_m": 3912, + "node_free_mean_m": 1180, + "node_free_p5_m": 88, + "load1_max": 8.19 + }, + "post": { + "samples": 64, + "seconds": 64.9, + "cpu_mean_m": 16.3, + "cpu_p95_m": 13.2, + "cpu_peak_m": 317.3, + "cfs_periods": 137, + "cfs_throttled_periods": 0, + "throttle_ratio": 0.0, + "throttled_seconds": 0.0, + "psi_cpu_some_share": 0.002, + "mem_peak_mib": 79.1, + "mem_mean_mib": 19.2, + "node_busy_mean_m": 1423, + "node_busy_p95_m": 2535, + "node_free_mean_m": 2577, + "node_free_p5_m": 1465, + "load1_max": 5.75 + } + } + }, + "per_operation": { + "burst/detail": { + "n": 77, + "errors": 0, + "p95_s": 0.1132, + "max_s": 0.1706 + }, + "burst/document-download": { + "n": 77, + "errors": 0, + "p95_s": 0.0447, + "max_s": 0.0592 + }, + "burst/document-upload": { + "n": 77, + "errors": 0, + "p95_s": 0.075, + "max_s": 0.1055 + }, + "burst/list": { + "n": 77, + "errors": 0, + "p95_s": 0.2974, + "max_s": 0.3233 + }, + "burst/login": { + "n": 2, + "errors": 0, + "p95_s": 1.1775, + "max_s": 1.1775 + }, + "burst/login-form": { + "n": 2, + "errors": 0, + "p95_s": 0.0281, + "max_s": 0.0281 + }, + "burst/lot-create": { + "n": 77, + "errors": 0, + "p95_s": 0.0581, + "max_s": 0.0971 + }, + "burst/lot-edit": { + "n": 77, + "errors": 0, + "p95_s": 0.0536, + "max_s": 0.0743 + }, + "burst/search": { + "n": 77, + "errors": 0, + "p95_s": 0.2652, + "max_s": 0.3359 + }, + "burst/task-create": { + "n": 77, + "errors": 0, + "p95_s": 0.0599, + "max_s": 0.1778 + }, + "burst/task-edit": { + "n": 77, + "errors": 0, + "p95_s": 0.0597, + "max_s": 0.0759 + }, + "burst/tender-create": { + "n": 77, + "errors": 0, + "p95_s": 0.0772, + "max_s": 0.0845 + }, + "burst/tender-edit": { + "n": 77, + "errors": 0, + "p95_s": 0.0623, + "max_s": 0.0732 + }, + "ordinary/detail": { + "n": 211, + "errors": 0, + "p95_s": 0.1758, + "max_s": 0.3877 + }, + "ordinary/document-download": { + "n": 211, + "errors": 0, + "p95_s": 0.0537, + "max_s": 0.11 + }, + "ordinary/document-upload": { + "n": 211, + "errors": 0, + "p95_s": 0.1051, + "max_s": 0.4418 + }, + "ordinary/list": { + "n": 211, + "errors": 0, + "p95_s": 0.194, + "max_s": 0.3779 + }, + "ordinary/login": { + "n": 2, + "errors": 0, + "p95_s": 1.2574, + "max_s": 1.2574 + }, + "ordinary/login-form": { + "n": 2, + "errors": 0, + "p95_s": 0.3288, + "max_s": 0.3288 + }, + "ordinary/lot-create": { + "n": 211, + "errors": 0, + "p95_s": 0.0696, + "max_s": 0.1603 + }, + "ordinary/lot-edit": { + "n": 211, + "errors": 0, + "p95_s": 0.0763, + "max_s": 0.1017 + }, + "ordinary/search": { + "n": 211, + "errors": 0, + "p95_s": 0.2294, + "max_s": 0.6217 + }, + "ordinary/task-create": { + "n": 211, + "errors": 0, + "p95_s": 0.0697, + "max_s": 0.1338 + }, + "ordinary/task-edit": { + "n": 211, + "errors": 0, + "p95_s": 0.0686, + "max_s": 0.1146 + }, + "ordinary/tender-create": { + "n": 211, + "errors": 0, + "p95_s": 0.0626, + "max_s": 0.1187 + }, + "ordinary/tender-edit": { + "n": 211, + "errors": 0, + "p95_s": 0.0633, + "max_s": 0.1171 + }, + "recovery/detail": { + "n": 119, + "errors": 0, + "p95_s": 0.131, + "max_s": 0.1379 + }, + "recovery/document-download": { + "n": 119, + "errors": 0, + "p95_s": 0.0475, + "max_s": 0.0818 + }, + "recovery/document-upload": { + "n": 119, + "errors": 0, + "p95_s": 0.08, + "max_s": 0.1587 + }, + "recovery/list": { + "n": 119, + "errors": 0, + "p95_s": 0.3302, + "max_s": 0.3757 + }, + "recovery/login": { + "n": 2, + "errors": 0, + "p95_s": 1.2653, + "max_s": 1.2653 + }, + "recovery/login-form": { + "n": 2, + "errors": 0, + "p95_s": 0.6157, + "max_s": 0.6157 + }, + "recovery/lot-create": { + "n": 119, + "errors": 0, + "p95_s": 0.0526, + "max_s": 0.0945 + }, + "recovery/lot-edit": { + "n": 119, + "errors": 0, + "p95_s": 0.0602, + "max_s": 0.1186 + }, + "recovery/search": { + "n": 119, + "errors": 0, + "p95_s": 0.3193, + "max_s": 0.4237 + }, + "recovery/task-create": { + "n": 119, + "errors": 0, + "p95_s": 0.0558, + "max_s": 0.0888 + }, + "recovery/task-edit": { + "n": 119, + "errors": 0, + "p95_s": 0.0577, + "max_s": 0.0959 + }, + "recovery/tender-create": { + "n": 119, + "errors": 0, + "p95_s": 0.0489, + "max_s": 0.0661 + }, + "recovery/tender-edit": { + "n": 119, + "errors": 0, + "p95_s": 0.0556, + "max_s": 0.0753 + } + }, + "per_operation_all_phases": { + "detail": { + "n": 407, + "errors": 0, + "p95_s": 0.1463 + }, + "document-download": { + "n": 407, + "errors": 0, + "p95_s": 0.0498 + }, + "document-upload": { + "n": 407, + "errors": 0, + "p95_s": 0.0878 + }, + "list": { + "n": 407, + "errors": 0, + "p95_s": 0.2624 + }, + "login": { + "n": 6, + "errors": 0, + "p95_s": 1.2653 + }, + "login-form": { + "n": 6, + "errors": 0, + "p95_s": 0.6157 + }, + "lot-create": { + "n": 407, + "errors": 0, + "p95_s": 0.0613 + }, + "lot-edit": { + "n": 407, + "errors": 0, + "p95_s": 0.0621 + }, + "search": { + "n": 407, + "errors": 0, + "p95_s": 0.2553 + }, + "task-create": { + "n": 407, + "errors": 0, + "p95_s": 0.0621 + }, + "task-edit": { + "n": 407, + "errors": 0, + "p95_s": 0.0657 + }, + "tender-create": { + "n": 407, + "errors": 0, + "p95_s": 0.0661 + }, + "tender-edit": { + "n": 407, + "errors": 0, + "p95_s": 0.0587 + } + }, + "summary": { + "ordinary": { + "requests": 3675, + "errors": 0, + "p95_seconds": 0.19182809395715594 + }, + "document_transfer": { + "requests": 814, + "errors": 0, + "p95_seconds": 0.07621732295956463 + }, + "complete": true, + "provisional_pass": true, + "measurement_complete": true, + "measurement_gaps": [], + "startup_seconds": 0.04661138809751719, + "duration_seconds": 201.53947580303065, + "completed_iterations": { + "ordinary": [ + 106, + 105 + ], + "burst": [ + 39, + 38 + ], + "recovery": [ + 59, + 60 + ] + }, + "host": { + "logical_cpus": 4, + "affinity_cpus": 4, + "cpu_model": "AMD EPYC Processor (with IBPB)", + "memory_total": "MemTotal: 16377032 kB", + "cgroup_cpu_max": "100000 100000", + "app_cpu_quota": 1.0 + } + }, + "phase_results": [ + { + "name": "ordinary", + "kind": "load", + "seconds": 91.06608834001236, + "started_at": "2026-10-21T06:30:01.523056+00:00", + "finished_at": "2026-10-21T06:31:32.589152+00:00", + "expected_requests": 2325, + "actual_requests": 2325, + "complete": true, + "ordinary": { + "requests": 1903, + "errors": 0, + "p95_seconds": 0.14162223995663226 + }, + "document_transfer": { + "requests": 422, + "errors": 0, + "p95_seconds": 0.08520571992266923 + } + }, + { + "name": "burst", + "kind": "load", + "seconds": 30.685886312043294, + "started_at": "2026-10-21T06:31:32.589194+00:00", + "finished_at": "2026-10-21T06:32:03.275085+00:00", + "expected_requests": 851, + "actual_requests": 851, + "complete": true, + "ordinary": { + "requests": 697, + "errors": 0, + "p95_seconds": 0.19206618797034025 + }, + "document_transfer": { + "requests": 154, + "errors": 0, + "p95_seconds": 0.06853019201662391 + } + }, + { + "name": "restart", + "kind": "restart", + "seconds": 18.981462653027847, + "started_at": "2026-10-21T06:32:03.275117+00:00", + "finished_at": "2026-10-21T06:32:22.256585+00:00", + "ready": true + }, + { + "name": "recovery", + "kind": "load", + "seconds": 60.80042844603304, + "started_at": "2026-10-21T06:32:22.256609+00:00", + "finished_at": "2026-10-21T06:33:23.057045+00:00", + "expected_requests": 1313, + "actual_requests": 1313, + "complete": true, + "ordinary": { + "requests": 1075, + "errors": 0, + "p95_seconds": 0.21410848898813128 + }, + "document_transfer": { + "requests": 238, + "errors": 0, + "p95_seconds": 0.07255190098658204 + } + } + ] +} \ No newline at end of file diff --git a/docs/evidence/2026-10-21-vergabe-target-replay/harness/analyze.py b/docs/evidence/2026-10-21-vergabe-target-replay/harness/analyze.py new file mode 100644 index 0000000..32435ba --- /dev/null +++ b/docs/evidence/2026-10-21-vergabe-target-replay/harness/analyze.py @@ -0,0 +1,73 @@ +import json, math, sys, gzip, collections +from datetime import datetime +R = sys.argv[1] +def ts(x): return datetime.fromisoformat(x.replace("Z", "+00:00").replace(".", ".", 1)[:26] + ("+00:00" if "+" not in x[19:] and not x.endswith("Z") else "")).timestamp() if False else _ts(x) +def _ts(x): + x = x.rstrip("Z") + if "+" in x[19:]: x = x[:x.index("+", 19)] + if "." in x: a, b = x.split("."); x = a + "." + b[:6] + return datetime.fromisoformat(x + "+00:00").timestamp() +def p95(v): + v = sorted(v); return v[max(0, math.ceil(0.95 * len(v)) - 1)] if v else None +def parse(path): + segs, cur, s = [], None, None + for line in open(path): + line = line.rstrip("\n") + if line.startswith("POD "): cur = {"pod": line.split()[1], "samples": []}; segs.append(cur) + elif line.startswith("S "): + s = {"t": float(line[2:])}; cur["samples"].append(s) + elif s is None: continue + elif line.startswith("M "): s["mem"] = int(line[2:]) + elif line.startswith("P "): s["psi_some_total"] = int(line.split("total=")[1]) + elif line.startswith("N "): + f = [int(x) for x in line.split()[2:]]; s["node_total"] = sum(f[:8]); s["node_idle"] = f[3] + f[4] + elif line.startswith("L "): s["load1"] = float(line.split()[1]) + elif line.split() and line.split()[0] in ("usage_usec","nr_periods","nr_throttled","throttled_usec"): + k, v = line.split(); s[k] = int(v) + for seg in segs: seg["samples"] = [x for x in seg["samples"] if "mem" in x and "node_total" in x and "usage_usec" in x] + return segs +def window(segs, a, b, cores=4): + rates, mems, thr_p, per_p, thr_us, nodebusy, psi, load = [], [], 0, 0, 0, [], 0, [] + span = 0; used = 0 + for seg in segs: + xs = [x for x in seg["samples"] if a <= x["t"] <= b] + for x in xs: mems.append(x["mem"]); load.append(x["load1"]) + for p, q in zip(xs, xs[1:]): + dt = q["t"] - p["t"] + if dt <= 0 or dt > 5: continue + du = q["usage_usec"] - p["usage_usec"]; used += du; span += dt + rates.append(du / 1e3 / dt) + per_p += q.get("nr_periods",0) - p.get("nr_periods",0); thr_p += q.get("nr_throttled",0) - p.get("nr_throttled",0) + thr_us += q.get("throttled_usec",0) - p.get("throttled_usec",0) + psi += q["psi_some_total"] - p["psi_some_total"] + tot = q["node_total"] - p["node_total"]; idle = q["node_idle"] - p["node_idle"] + if tot > 0: nodebusy.append(cores * (tot - idle) / tot * 1000) + if not rates: return None + return {"samples": len(rates), "seconds": round(span,1), "cpu_mean_m": round(used/1e3/span,1), "cpu_p95_m": round(p95(rates),1), "cpu_peak_m": round(max(rates),1), + "cfs_periods": per_p, "cfs_throttled_periods": thr_p, "throttle_ratio": round(thr_p/per_p,3) if per_p else None, "throttled_seconds": round(thr_us/1e6,2), + "psi_cpu_some_share": round(psi/1e6/span,3), "mem_peak_mib": round(max(mems)/2**20,1), "mem_mean_mib": round(sum(mems)/len(mems)/2**20,1), + "node_busy_mean_m": round(sum(nodebusy)/len(nodebusy)), "node_busy_p95_m": round(p95(nodebusy)), "node_free_mean_m": round(cores*1000-sum(nodebusy)/len(nodebusy)), + "node_free_p5_m": round(cores*1000-p95(nodebusy)), "load1_max": max(load)} +win = {} +for line in open(f"{R}/windows.txt"): + k, *v = line.split(); win[k] = v +res = json.load(open(f"{R}/result.json")) +W = {"baseline_pre": (_ts(win["baseline_start"][0]), _ts(win["driver_start"][0])), + "harness_idle": (_ts(res["idle_window"]["started_at"]), _ts(res["idle_window"]["finished_at"]))} +for p in res["phase_results"]: W[p["name"]] = (_ts(p["started_at"]), _ts(p["finished_at"])) +loads = [p for p in res["phase_results"] if p["kind"] == "load"] +W["workload_all_load_phases"] = (_ts(loads[0]["started_at"]), _ts(loads[-1]["finished_at"])) +W["post"] = (_ts(win["driver_exit"][1]), _ts(win["post_end"][0])) +out = {"windows_utc": {k: [datetime.utcfromtimestamp(a).isoformat()+"Z", datetime.utcfromtimestamp(b).isoformat()+"Z"] for k,(a,b) in W.items()}, "cgroup": {}} +for name in ("app", "db", "driver"): + segs = parse(f"{R}/{name}-cgroup.txt") + out["cgroup"][name] = {"pods": [s["pod"] for s in segs], **{k: window(segs, a, b) for k, (a, b) in W.items() if k != "workload_all_load_phases" or True}} +ops = collections.defaultdict(list) +for r in res["operations"]: ops[(r["phase"], r["operation"])].append(r) +out["per_operation"] = {f"{ph}/{op}": {"n": len(v), "errors": sum(not x["ok"] for x in v), "p95_s": round(p95([x["seconds"] for x in v]),4), "max_s": round(max(x["seconds"] for x in v),4)} for (ph, op), v in sorted(ops.items())} +allops = collections.defaultdict(list) +for r in res["operations"]: allops[r["operation"]].append(r) +out["per_operation_all_phases"] = {op: {"n": len(v), "errors": sum(not x["ok"] for x in v), "p95_s": round(p95([x["seconds"] for x in v]),4)} for op, v in sorted(allops.items())} +out["summary"] = {k: res[k] for k in ("ordinary","document_transfer","complete","provisional_pass","measurement_complete","measurement_gaps","startup_seconds","duration_seconds","completed_iterations","host")} +out["phase_results"] = [{k: v for k, v in p.items() if k != "operations"} for p in res["phase_results"]] +json.dump(out, open(sys.argv[2], "w"), indent=1) diff --git a/docs/evidence/2026-10-21-vergabe-target-replay/harness/bench.yaml b/docs/evidence/2026-10-21-vergabe-target-replay/harness/bench.yaml new file mode 100644 index 0000000..573740b --- /dev/null +++ b/docs/evidence/2026-10-21-vergabe-target-replay/harness/bench.yaml @@ -0,0 +1,125 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: vergabe-sizing-20261021 + labels: + railiance.io/purpose: synthetic-sizing + railiance.io/task: rapps-wp-0014-t04 + railiance.io/ephemeral: "true" +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: {name: bench-media, namespace: vergabe-sizing-20261021} +spec: + accessModes: [ReadWriteOnce] + storageClassName: local-path + resources: {requests: {storage: 5Gi}} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: {name: vergabe-db, namespace: vergabe-sizing-20261021} +spec: + replicas: 1 + strategy: {type: Recreate} + selector: {matchLabels: {app: vergabe-sizing-db}} + template: + metadata: {labels: {app: vergabe-sizing-db}} + spec: + automountServiceAccountToken: false + containers: + - name: postgres + image: docker.io/library/postgres@sha256:4b7183ac05f8ef417db21fd72d71047a4238340c261d3cc3ddb6d579ab5071ae + imagePullPolicy: IfNotPresent + env: + - {name: POSTGRES_DB, value: synthetic} + - {name: POSTGRES_USER, value: synthetic} + - {name: POSTGRES_PASSWORD, value: synthetic-local-only} + - {name: PGDATA, value: /var/lib/postgresql/data/pgdata} + ports: [{containerPort: 5432}] + readinessProbe: {exec: {command: [pg_isready, -U, synthetic]}, periodSeconds: 5} + resources: {requests: {cpu: 100m, memory: 256Mi}, limits: {cpu: "1", memory: 1Gi}} + volumeMounts: [{name: data, mountPath: /var/lib/postgresql/data}] + volumes: [{name: data, emptyDir: {}}] +--- +apiVersion: v1 +kind: Service +metadata: {name: vergabe-db, namespace: vergabe-sizing-20261021} +spec: {selector: {app: vergabe-sizing-db}, ports: [{port: 5432, targetPort: 5432}]} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: {name: vergabe-app, namespace: vergabe-sizing-20261021} +spec: + replicas: 1 + strategy: {type: Recreate} + selector: {matchLabels: {app: vergabe-sizing-app}} + template: + metadata: {labels: {app: vergabe-sizing-app}} + spec: + automountServiceAccountToken: false + securityContext: {fsGroup: 999, runAsGroup: 999, runAsNonRoot: true, runAsUser: 999} + initContainers: + - name: migrate + image: forgejo.coulomb.social/coulomb/vergabe-teilnahme@sha256:a26444f59c259698159c69ccb96f73dc648a261ece4c86bb2037a9d977870d91 + imagePullPolicy: IfNotPresent + command: [python, manage.py, migrate, --noinput] + env: &env + - {name: DJANGO_SETTINGS_MODULE, value: synthetic_settings} + - {name: PYTHONPATH, value: /synthetic} + - {name: SECRET_KEY, value: synthetic-disposable-not-a-deployment-key} + - {name: DATABASE_URL, value: "postgres://synthetic:synthetic-local-only@vergabe-db:5432/synthetic"} + resources: &res {requests: {cpu: 60m, memory: 256Mi}, limits: {cpu: "1", memory: 1Gi}} + securityContext: &sc {allowPrivilegeEscalation: false, capabilities: {drop: [ALL]}, readOnlyRootFilesystem: false} + volumeMounts: [{name: settings, mountPath: /synthetic}] + containers: + - name: app + image: forgejo.coulomb.social/coulomb/vergabe-teilnahme@sha256:a26444f59c259698159c69ccb96f73dc648a261ece4c86bb2037a9d977870d91 + imagePullPolicy: IfNotPresent + env: *env + ports: [{containerPort: 8000, name: http}] + readinessProbe: {httpGet: {path: /health/, port: 8000, httpHeaders: [{name: Host, value: vergabe-app}]}, initialDelaySeconds: 5, periodSeconds: 10, timeoutSeconds: 5, failureThreshold: 3} + livenessProbe: {httpGet: {path: /health/, port: 8000, httpHeaders: [{name: Host, value: vergabe-app}]}, initialDelaySeconds: 30, periodSeconds: 30, timeoutSeconds: 5, failureThreshold: 3} + resources: *res + securityContext: *sc + volumeMounts: + - {name: settings, mountPath: /synthetic} + - {name: media, mountPath: /app/media} + - {name: app-state, mountPath: /app/.issue-facade} + volumes: + - {name: settings, configMap: {name: synthetic-settings}} + - {name: media, persistentVolumeClaim: {claimName: bench-media}} + - {name: app-state, emptyDir: {}} +--- +apiVersion: v1 +kind: Service +metadata: {name: vergabe-app, namespace: vergabe-sizing-20261021} +spec: {selector: {app: vergabe-sizing-app}, ports: [{port: 80, targetPort: 8000}]} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: {name: synthetic-driver, namespace: vergabe-sizing-20261021} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: {name: synthetic-driver-restart, namespace: vergabe-sizing-20261021} +rules: [{apiGroups: [""], resources: [pods], verbs: [get, list, delete]}] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: {name: synthetic-driver-restart, namespace: vergabe-sizing-20261021} +roleRef: {apiGroup: rbac.authorization.k8s.io, kind: Role, name: synthetic-driver-restart} +subjects: [{kind: ServiceAccount, name: synthetic-driver, namespace: vergabe-sizing-20261021}] +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: {name: synthetic-isolation, namespace: vergabe-sizing-20261021} +spec: + podSelector: {} + policyTypes: [Ingress, Egress] + ingress: [{from: [{podSelector: {}}]}] + egress: + - to: [{podSelector: {}}] + - to: [{namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: kube-system}}, podSelector: {matchLabels: {k8s-app: kube-dns}}}] + ports: [{port: 53, protocol: UDP}, {port: 53, protocol: TCP}] + - to: [{ipBlock: {cidr: 92.205.62.239/32}}, {ipBlock: {cidr: 10.43.0.1/32}}] + ports: [{port: 6443, protocol: TCP}, {port: 443, protocol: TCP}] diff --git a/docs/evidence/2026-10-21-vergabe-target-replay/harness/driver-config.json b/docs/evidence/2026-10-21-vergabe-target-replay/harness/driver-config.json new file mode 100644 index 0000000..25241c5 --- /dev/null +++ b/docs/evidence/2026-10-21-vergabe-target-replay/harness/driver-config.json @@ -0,0 +1,53 @@ +{ + "users": 2, + "workers": 3, + "iterations": 100000, + "tenders": 100, + "document_bytes": 1048576, + "think_seconds": 0.2, + "timeout_seconds": 30, + "ordinary_p95_seconds": 2.0, + "duration_seconds": 180, + "idle_seconds": 30, + "runtime": "remote", + "app_image": "forgejo.coulomb.social/coulomb/vergabe-teilnahme@sha256:a26444f59c259698159c69ccb96f73dc648a261ece4c86bb2037a9d977870d91", + "app_cpu_limit": 1.0, + "app_memory_limit": "1Gi", + "operations": [ + "list", + "search", + "tender-create", + "detail", + "tender-edit", + "lot-create", + "lot-edit", + "task-create", + "task-edit", + "document-upload", + "document-download" + ], + "phases": [ + { + "name": "ordinary", + "kind": "load", + "duration_seconds": 90, + "think_seconds": 0.2 + }, + { + "name": "burst", + "kind": "load", + "duration_seconds": 30, + "think_seconds": 0 + }, + { + "name": "restart", + "kind": "restart" + }, + { + "name": "recovery", + "kind": "load", + "duration_seconds": 60, + "think_seconds": 0.2 + } + ] +} diff --git a/docs/evidence/2026-10-21-vergabe-target-replay/harness/driver.yaml b/docs/evidence/2026-10-21-vergabe-target-replay/harness/driver.yaml new file mode 100644 index 0000000..db2991a --- /dev/null +++ b/docs/evidence/2026-10-21-vergabe-target-replay/harness/driver.yaml @@ -0,0 +1,29 @@ +apiVersion: v1 +kind: Pod +metadata: {name: synthetic-driver, namespace: vergabe-sizing-20261021, labels: {app: vergabe-sizing-driver}} +spec: + serviceAccountName: synthetic-driver + restartPolicy: Never + securityContext: {runAsGroup: 999, runAsNonRoot: true, runAsUser: 999} + containers: + - name: driver + image: forgejo.coulomb.social/coulomb/vergabe-teilnahme@sha256:a26444f59c259698159c69ccb96f73dc648a261ece4c86bb2037a9d977870d91 + imagePullPolicy: IfNotPresent + workingDir: /app + command: [sleep, "7200"] + env: + - {name: PYTHONPATH, value: /app} + - {name: SYNTHETIC_BASE_URL, value: "http://vergabe-app"} + - {name: SYNTHETIC_DATABASE_URL, value: "postgres://synthetic:synthetic-local-only@vergabe-db:5432/synthetic"} + - {name: SYNTHETIC_RESTART_SELECTOR, value: "app=vergabe-sizing-app"} + - {name: SYNTHETIC_SOURCE_REVISION, value: "deployed image c067993037f1cdeeb7b22d5600b51c649bc239db; driver from vergabe-teilnahme working tree"} + - {name: SYNTHETIC_DATABASE_DESCRIPTION, value: "fresh PostgreSQL 16 pod in throwaway namespace (docker.io/library/postgres:16 pinned digest)"} + - {name: SYNTHETIC_TRANSPORT_DESCRIPTION, value: "in-cluster HTTP via ClusterIP Service to Gunicorn (image CMD, 3 workers)"} + resources: {requests: {cpu: 100m, memory: 256Mi}, limits: {cpu: "1", memory: 1Gi}} + securityContext: {allowPrivilegeEscalation: false, capabilities: {drop: [ALL]}} + volumeMounts: + - {name: driver, mountPath: /driver} + - {name: out, mountPath: /out} + volumes: + - {name: driver, configMap: {name: synthetic-driver}} + - {name: out, emptyDir: {}} diff --git a/docs/evidence/2026-10-21-vergabe-target-replay/harness/executed-synthetic_pilot.py b/docs/evidence/2026-10-21-vergabe-target-replay/harness/executed-synthetic_pilot.py new file mode 100644 index 0000000..0ef5b91 --- /dev/null +++ b/docs/evidence/2026-10-21-vergabe-target-replay/harness/executed-synthetic_pilot.py @@ -0,0 +1,1091 @@ +#!/usr/bin/env python3 +"""Provisional HTTP workload against Gunicorn and a disposable PostgreSQL. + +Runtimes: ``host`` and ``container`` start everything locally. ``remote`` drives +an already running isolated synthetic deployment (for example a throwaway +Kubernetes namespace): the base URL, database URL and optional restart pod +selector come from ``SYNTHETIC_BASE_URL``, ``SYNTHETIC_DATABASE_URL`` and +``SYNTHETIC_RESTART_SELECTOR``. Remote resource sampling is external; phase UTC +windows are retained so samples can be aligned afterwards. +""" + +import argparse +import concurrent.futures +import hashlib +import http.cookiejar +import json +import math +import os +import platform +import signal +import socket +import subprocess +import sys +import tempfile +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid +from datetime import UTC, datetime +from pathlib import Path + + +def summarize(rows): + durations = sorted(row["seconds"] for row in rows) + return { + "requests": len(rows), + "errors": sum(not r["ok"] for r in rows), + "p95_seconds": durations[math.ceil(len(durations) * 0.95) - 1] if durations else None, + } + + +LEGACY_OPERATIONS = [ + "list", + "search", + "tender-create", + "detail", + "lot-create", + "task-create", + "document-upload", + "document-download", +] +DEPENDENCIES = { + "list": None, + "search": None, + "tender-create": None, + "detail": "tender-create", + "tender-edit": "tender-create", + "lot-create": "tender-create", + "lot-edit": "lot-create", + "task-create": "tender-create", + "task-edit": "task-create", + "document-upload": "tender-create", + "document-download": "document-upload", +} + + +def operation_plan(operations): + if not isinstance(operations, list) or not operations: + raise ValueError("operations must be a nonempty ordered list") + seen = set() + for operation in operations: + if operation not in DEPENDENCIES: + raise ValueError(f"unknown operation: {operation}") + dependency = DEPENDENCIES[operation] + if dependency and dependency not in seen: + raise ValueError(f"{operation} requires earlier {dependency}") + if operation in seen and (operation.endswith("-create") or operation == "document-upload"): + raise ValueError(f"only one {operation} per cycle; repeat reads/edits to tune mix") + seen.add(operation) + return list(operations) + + +def phase_plan(config): + phases = config.get( + "phases", + [ + { + "name": "ordinary", + "kind": "load", + "duration_seconds": config["duration_seconds"], + "think_seconds": config["think_seconds"], + } + ], + ) + if not isinstance(phases, list) or not phases: + raise ValueError("phases must be a nonempty list") + names = set() + result = [] + for item in phases: + item = dict(item) + name = item.get("name") + if not isinstance(name, str) or not name or name in names or name in ("idle", "startup"): + raise ValueError("phase names must be unique and not idle/startup") + names.add(name) + if item.get("kind") == "restart": + if config.get("runtime") not in ("container", "remote"): + raise ValueError("restart phase requires container or remote runtime") + elif item.get("kind") == "load": + for key in ("duration_seconds", "think_seconds"): + value = item.get(key) + if type(value) not in (int, float) or not math.isfinite(value) or value < 0: + raise ValueError(f"invalid phase {key}") + if not item["duration_seconds"]: + raise ValueError("load duration must be positive") + item["operations"] = operation_plan( + item.get("operations", config.get("operations", LEGACY_OPERATIONS)) + ) + else: + raise ValueError("phase kind must be load or restart") + result.append(item) + if not any(p["kind"] == "load" for p in result): + raise ValueError("at least one load phase required") + return result + + +def expected_requests(completed, operations, users): + return sum(completed) * len(operations) + users * 2 + + +def wait_ready(base_url, timeout=30): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(base_url + "/health/", timeout=1) as response: + if response.status == 200: + return + except OSError: + pass + time.sleep(0.1) + raise RuntimeError("application readiness timeout") + + +def container_url(name): + binding = subprocess.check_output( + ["docker", "port", name, "8000/tcp"], text=True, timeout=10 + ).strip() + host, port = binding.rsplit(":", 1) + if host != "127.0.0.1" or not port.isdigit(): + raise ValueError("synthetic runtime must have one loopback HTTP binding") + return f"http://127.0.0.1:{port}" + + +KUBE_SA = Path("/var/run/secrets/kubernetes.io/serviceaccount") + + +def kube_request(method, path, sa_dir=KUBE_SA): + """Minimal in-cluster API call using the pod's own namespaced ServiceAccount.""" + host = os.environ["KUBERNETES_SERVICE_HOST"] + port = os.environ.get("KUBERNETES_SERVICE_PORT", "443") + import ssl + + context = ssl.create_default_context(cafile=str(sa_dir / "ca.crt")) + req = urllib.request.Request( + f"https://{host}:{port}{path}", + method=method, + headers={"Authorization": "Bearer " + (sa_dir / "token").read_text().strip()}, + ) + with urllib.request.urlopen(req, timeout=10, context=context) as response: + return json.loads(response.read()) + + +def ready_pods(listing): + return [ + item["metadata"]["name"] + for item in listing.get("items", []) + if not item["metadata"].get("deletionTimestamp") + and any( + c["type"] == "Ready" and c["status"] == "True" + for c in item.get("status", {}).get("conditions", []) + ) + ] + + +def restart_kubernetes_pod(base_url, selector, timeout=180, sa_dir=KUBE_SA, request=None): + """Delete the single selected pod in this namespace and wait for a new ready one.""" + request = request or (lambda method, path: kube_request(method, path, sa_dir)) + namespace = (sa_dir / "namespace").read_text().strip() + query = urllib.parse.urlencode({"labelSelector": selector}) + pods = f"/api/v1/namespaces/{namespace}/pods" + before = ready_pods(request("GET", f"{pods}?{query}")) + if len(before) != 1: + raise RuntimeError("restart requires exactly one ready selected pod") + request("DELETE", f"{pods}/{before[0]}") + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + listing = request("GET", f"{pods}?{query}") + names = [i["metadata"]["name"] for i in listing.get("items", [])] + if before[0] not in names and ready_pods(listing): + wait_ready(base_url, timeout=max(1, deadline - time.monotonic())) + return base_url + time.sleep(0.5) + raise RuntimeError("replacement pod readiness timeout") + + +def settings_text(media, issue_db, allowed_hosts=("127.0.0.1",)): + return ( + "from vergabe_teilnahme.settings.base import *\n" + f"DEBUG = False\nALLOWED_HOSTS = {list(allowed_hosts)!r}\nNETKINGDOM_ENABLED = False\n" + f"MEDIA_ROOT = {media!r}\n" + f"ISSUE_FACADE_LOCAL_DB = {issue_db!r}\n" + 'ISSUE_FACADE_GITEA = None\nFORCE_SCRIPT_NAME = None\nMEDIA_URL = "/media/"\n' + 'EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"\n' + "SESSION_COOKIE_SECURE = False\nCSRF_COOKIE_SECURE = False\n" + ) + + +def restart_container(name): + subprocess.run( + ["docker", "restart", "--time", "5", name], + check=True, + timeout=20, + stdout=subprocess.DEVNULL, + ) + # Docker can allocate a different random published port after restart. + endpoint = container_url(name) + wait_ready(endpoint) + return endpoint + + +def validate(config): + for key in ( + "users", + "iterations", + "tenders", + "document_bytes", + "workers", + "timeout_seconds", + ): + if type(config[key]) is not int or config[key] < 1: + raise ValueError(f"{key} must be a positive integer") + for key in ( + "think_seconds", + "ordinary_p95_seconds", + "duration_seconds", + "idle_seconds", + ): + if not isinstance(config[key], (int, float)) or not math.isfinite(config[key]): + raise ValueError(f"{key} must be finite") + if config["think_seconds"] < 0 or config["ordinary_p95_seconds"] <= 0: + raise ValueError("invalid timing parameter") + if config["duration_seconds"] <= 0 or config["idle_seconds"] < 0: + raise ValueError("duration must be positive and idle nonnegative") + if config["document_bytes"] < 8: + raise ValueError("document_bytes must be at least 8") + if config.get("runtime", "host") not in ("host", "container", "remote"): + raise ValueError("unsupported runtime") + if config.get("runtime") == "container": + if ( + not isinstance(config["app_cpu_limit"], (int, float)) + or not 0 < config["app_cpu_limit"] <= 8 + ): + raise ValueError("app_cpu_limit must be positive and at most 8") + if not config.get("app_image") or not config.get("app_memory_limit"): + raise ValueError("container image and memory limit required") + phase_plan(config) + return config + + +def stop_process(server): + """Bounded teardown of a process group, including Gunicorn workers.""" + if server is None: + return + try: + os.killpg(server.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + try: + os.killpg(server.pid, signal.SIGKILL) + except ProcessLookupError: + pass + server.wait(timeout=5) + + +def guarded_sampler(function, errors): + try: + function() + except Exception as exc: + errors.append({"sampler": function.__name__, "error_type": type(exc).__name__}) + + +def sampling_gaps(samples, db_samples, errors, remote=False): + gaps = list(errors) + if remote: + return gaps + if any(s.get("sample_error") for s in samples): + gaps.append({"sampler": "application", "reason": "sample failed"}) + good = [s for s in samples if "monotonic_seconds" in s] + if len(good) < 2 or not db_samples: + gaps.append({"sampler": "coverage", "reason": "insufficient samples"}) + if any( + b["monotonic_seconds"] - a["monotonic_seconds"] > 3 + for a, b in zip(good, good[1:]) + if a.get("phase") == b.get("phase") + ): + gaps.append({"sampler": "application", "reason": "sampling interval exceeds 3 seconds"}) + return gaps + + +def run(config, root, failure_output=None): + # Never consume a deployment database, media tree, session or credential. + os.environ["DATABASE_URL"] = os.environ["SYNTHETIC_DATABASE_URL"] + os.environ["SECRET_KEY"] = "synthetic-disposable-not-a-deployment-key" + os.environ["DJANGO_SETTINGS_MODULE"] = "synthetic_settings" + container_mode = config.get("runtime", "host") == "container" + remote_mode = config.get("runtime") == "remote" + media = "/tmp/synthetic-media" if container_mode else str(root / "media") + issue_db = "/tmp/synthetic-issues.db" if container_mode else str(root / "issues.db") + (root / "synthetic_settings.py").write_text(settings_text(media, issue_db)) + sys.path.insert(0, str(root)) + import django + + django.setup() + from django.core.management import call_command + from django.db import connections + + from vergabe_teilnahme.apps.accounts.models import Mitarbeiter + from vergabe_teilnahme.apps.aufgaben.models import Aufgabe + from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung + from vergabe_teilnahme.apps.dokumente.models import Dokument + from vergabe_teilnahme.apps.lose.models import Los + + call_command("migrate", verbosity=0) + for i in range(config["tenders"]): + Ausschreibung.objects.create(titel=f"Synthetic tender {i}", ausschreiber="Synthetic") + users = [] + password = uuid.uuid4().hex + for i in range(config["users"]): + user = Mitarbeiter.objects.create_user(username=f"synthetic-{i}", password=password) + users.append(user.username) + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + base_url = f"http://127.0.0.1:{port}" + env = dict(os.environ, PYTHONPATH=str(root) + os.pathsep + str(Path.cwd())) + server_log = (root / "server.log").open("w") + server = None + app_container = None + sampling_done = threading.Event() + sampler_threads = [] + sampler_errors = [] + sampling_lock = threading.Lock() + try: + startup_started = time.monotonic() + if remote_mode: + base_url = os.environ["SYNTHETIC_BASE_URL"].rstrip("/") + elif container_mode: + app_container = os.environ["SYNTHETIC_CONTAINER"] + "-app" + root.chmod(0o755) + subprocess.run( + [ + "docker", + "run", + "--detach", + "--pull=never", + "--name", + app_container, + "--network", + os.environ["SYNTHETIC_NETWORK"], + "--publish", + "127.0.0.1::8000", + "--cpus", + str(config["app_cpu_limit"]), + "--memory", + config["app_memory_limit"], + "--pids-limit", + "256", + "--mount", + ( + f"type=bind,src={root / 'synthetic_settings.py'}," + "dst=/synthetic_settings.py,readonly" + ), + "--env", + "PYTHONPATH=/", + "--env", + "DJANGO_SETTINGS_MODULE=synthetic_settings", + "--env", + "SECRET_KEY=synthetic-disposable-not-a-deployment-key", + "--env", + "DATABASE_URL=postgres://synthetic:synthetic-local-only@db:5432/synthetic", + config["app_image"], + "gunicorn", + "vergabe_teilnahme.wsgi:application", + "--bind", + "0.0.0.0:8000", + "--workers", + str(config["workers"]), + "--timeout", + "30", + ], + check=True, + timeout=30, + stdout=subprocess.DEVNULL, + ) + base_url = container_url(app_container) + else: + server = subprocess.Popen( + [ + sys.executable, + "-m", + "gunicorn", + "vergabe_teilnahme.wsgi:application", + "--bind", + f"127.0.0.1:{port}", + "--workers", + str(config["workers"]), + "--timeout", + "30", + ], + start_new_session=True, + env=env, + stdout=server_log, + stderr=server_log, + ) + samples = [] + db_samples = [] + phase = {"name": "startup", "kind": "startup"} + + def sample(): + ticks_per_second = os.sysconf("SC_CLK_TCK") + while not sampling_done.is_set(): + if phase.get("kind") == "restart": + samples.append( + { + "at": datetime.now(UTC).isoformat(), + "phase": phase["name"], + "expected_unavailable": True, + "reason": "planned restart", + } + ) + sampling_done.wait(0.5) + continue + if container_mode: + with sampling_lock: + value = subprocess.check_output( + [ + "docker", + "exec", + app_container, + "sh", + "-c", + ( + "cat /sys/fs/cgroup/cpu.stat; echo MEMORY; " + "cat /sys/fs/cgroup/memory.current" + ), + ], + text=True, + timeout=5, + ) + cpu_text, memory_text = value.split("MEMORY\n") + counters = { + k: int(v) for k, v in (line.split() for line in cpu_text.splitlines()) + } + samples.append( + { + "at": datetime.now(UTC).isoformat(), + "monotonic_seconds": time.monotonic(), + "phase": phase["name"], + "cpu_seconds": counters["usage_usec"] / 1e6, + "rss_bytes": int(memory_text.strip()), + "memory_kind": "cgroup-memory-current", + "cpu_stat": counters, + "pids": [app_container], + } + ) + sampling_done.wait(0.5) + continue + pids = [server.pid] + try: + pids += [ + int(x) + for x in Path(f"/proc/{server.pid}/task/{server.pid}/children") + .read_text() + .split() + ] + cpu, rss = 0, 0 + for pid in pids: + fields = Path(f"/proc/{pid}/stat").read_text().split(") ", 1)[1].split() + cpu += (int(fields[11]) + int(fields[12])) / ticks_per_second + rss += int(fields[21]) * os.sysconf("SC_PAGE_SIZE") + samples.append( + { + "at": datetime.now(UTC).isoformat(), + "monotonic_seconds": time.monotonic(), + "phase": phase["name"], + "cpu_seconds": cpu, + "rss_bytes": rss, + "pids": pids, + } + ) + except (OSError, ValueError): + samples.append({"at": datetime.now(UTC).isoformat(), "sample_error": True}) + sampling_done.wait(0.5) + + def sample_db(): + while not sampling_done.is_set(): + value = subprocess.check_output( + [ + "docker", + "stats", + "--no-stream", + "--format", + "{{json .}}", + os.environ["SYNTHETIC_CONTAINER"], + ], + text=True, + timeout=5, + ) + db_samples.append( + { + "at": datetime.now(UTC).isoformat(), + "phase": phase["name"], + "docker_stats": json.loads(value), + } + ) + sampling_done.wait(1) + + sampler = threading.Thread( + target=guarded_sampler, args=(sample, sampler_errors), daemon=True + ) + db_sampler = threading.Thread( + target=guarded_sampler, args=(sample_db, sampler_errors), daemon=True + ) + if not remote_mode: + sampler_threads.extend([sampler, db_sampler]) + sampler.start() + db_sampler.start() + wait_ready(base_url) + startup_seconds = time.monotonic() - startup_started + phase.update(name="idle", kind="idle") + idle_window = {"name": "idle", "kind": "idle", "started_at": datetime.now(UTC).isoformat()} + time.sleep(config["idle_seconds"]) + idle_window["finished_at"] = datetime.now(UTC).isoformat() + plan = phase_plan(config) + active_plan = [] + phase_results = [] + transport_rebinds = [] + phase_completed = {} + + def worker(i): + jar = http.cookiejar.CookieJar() + + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(jar), NoRedirect() + ) + rows = [] + + def request(operation, method, path, data=None, expected=200, transfer=False): + started = time.perf_counter() + try: + headers = {} + body = None + if method == "post": + token = next((c.value for c in jar if c.name == "csrftoken"), "") + fields = dict(data or {}, csrfmiddlewaretoken=token) + if transfer: + boundary = "synthetic-" + uuid.uuid4().hex + body = b"" + for name, value in fields.items(): + body += ( + f"--{boundary}\r\nContent-Disposition: form-data; " + f'name="{name}"\r\n\r\n{value}\r\n' + ).encode() + body += ( + ( + f"--{boundary}\r\nContent-Disposition: form-data; " + 'name="datei"; filename="synthetic.pdf"\r\n' + "Content-Type: application/pdf\r\n\r\n" + ).encode() + + b"%PDF-1.4" + + b"x" * (config["document_bytes"] - 8) + + f"\r\n--{boundary}--\r\n".encode() + ) + headers["Content-Type"] = f"multipart/form-data; boundary={boundary}" + else: + body = urllib.parse.urlencode(fields).encode() + headers["Referer"] = base_url + path + req = urllib.request.Request( + base_url + path, data=body, headers=headers, method=method.upper() + ) + try: + response = opener.open(req, timeout=config["timeout_seconds"]) + except urllib.error.HTTPError as exc: + response = exc + received = len(response.read()) + status = response.status + ok = status == expected + if transfer and method == "get": + ok = ok and received == config["document_bytes"] + response.close() + except Exception: + status, ok, received = None, False, 0 + rows.append( + { + "phase": phase["name"], + "user": i, + "operation": operation, + "transfer": transfer, + "seconds": time.perf_counter() - started, + "status": status, + "ok": ok, + "response_bytes": received, + } + ) + return ok + + request("login-form", "get", "/accounts/login/") + barrier.wait(timeout=30) + request( + "login", + "post", + "/accounts/login/", + {"username": users[i], "password": password}, + 302, + ) + for n in range(config["iterations"]): + if time.monotonic() >= deadline: + break + title = f"Workload-{phase['name']}-{i}-{n}" + tender = lot = task = document = None + base = None + for index, operation in enumerate(active_plan): + dependency = DEPENDENCIES[operation] + entity = { + "tender-create": tender, + "lot-create": lot, + "task-create": task, + "document-upload": document, + } + if dependency and entity[dependency] is None: + rows.append( + { + "phase": phase["name"], + "user": i, + "operation": operation, + "transfer": operation.startswith("document-"), + "seconds": 0, + "status": None, + "ok": False, + "response_bytes": 0, + "reason": "dependency operation failed", + } + ) + continue + if operation == "list": + request(operation, "get", "/ausschreibungen/") + elif operation == "search": + request(operation, "get", "/ausschreibungen/?q=Synthetic") + elif operation == "tender-create": + if request( + operation, + "post", + "/ausschreibungen/neu/", + {"titel": title, "ausschreiber": "Synthetic"}, + 302, + ): + tender = Ausschreibung.objects.filter(titel=title).first() + base = f"/ausschreibungen/{tender.pk}/" if tender else None + elif operation == "detail": + request(operation, "get", base) + elif operation == "tender-edit": + changed = f"{title} edited-{index}" + request( + operation, + "post", + base + "bearbeiten/", + {"titel": changed, "ausschreiber": "Synthetic edited"}, + 302, + ) + tender.refresh_from_db() + rows[-1]["ok"] &= tender.titel == changed + elif operation == "lot-create": + request( + operation, + "post", + base + "lose/neu/", + {"losnummer": "1", "lostitel": "Synthetic lot"}, + 302, + ) + lot = Los.objects.filter(ausschreibung=tender).first() + elif operation == "lot-edit": + changed = f"Edited lot-{index}" + request( + operation, + "post", + base + f"lose/{lot.pk}/bearbeiten/", + {"losnummer": "1", "lostitel": changed}, + 302, + ) + lot.refresh_from_db() + rows[-1]["ok"] &= lot.lostitel == changed + elif operation == "task-create": + request( + operation, + "post", + base + "aufgaben/neu/", + {"titel": "Synthetic task", "typ": "fachlich", "prioritaet": 2}, + 302, + ) + task = Aufgabe.objects.filter(ausschreibung=tender).first() + elif operation == "task-edit": + changed = f"Edited task-{index}" + request( + operation, + "post", + base + f"aufgaben/{task.pk}/bearbeiten/", + {"titel": changed, "typ": "fachlich", "prioritaet": 1}, + 302, + ) + task.refresh_from_db() + rows[-1]["ok"] &= task.titel == changed and task.prioritaet == 1 + elif operation == "document-upload": + request( + operation, + "post", + base + "dokumente/hochladen/", + {"kategorie": "intern", "version": "1.0"}, + 302, + True, + ) + document = Dokument.objects.filter(ausschreibung=tender).first() + elif operation == "document-download": + request(operation, "get", document.datei.url, transfer=True) + completed[i] += 1 + time.sleep(active_phase["think_seconds"]) + connections.close_all() + return rows + + started = datetime.now(UTC).isoformat() + before = time.perf_counter() + rows = [] + for active_phase in plan: + phase.update(name=active_phase["name"], kind=active_phase["kind"]) + phase_started = time.monotonic() + phase_started_at = datetime.now(UTC).isoformat() + if active_phase["kind"] == "restart": + old_url = base_url + with sampling_lock: + if remote_mode: + base_url = restart_kubernetes_pod( + base_url, os.environ["SYNTHETIC_RESTART_SELECTOR"] + ) + else: + base_url = restart_container(app_container) + transport_rebinds.append( + {"phase": active_phase["name"], "before": old_url, "after": base_url} + ) + phase_results.append( + { + "name": active_phase["name"], + "kind": "restart", + "seconds": time.monotonic() - phase_started, + "started_at": phase_started_at, + "finished_at": datetime.now(UTC).isoformat(), + "ready": True, + } + ) + continue + active_plan = active_phase["operations"] + deadline = time.monotonic() + active_phase["duration_seconds"] + completed = [0] * config["users"] + barrier = threading.Barrier(config["users"]) + with concurrent.futures.ThreadPoolExecutor(max_workers=config["users"]) as pool: + phase_rows = [ + r for group in pool.map(worker, range(config["users"])) for r in group + ] + phase_completed[active_phase["name"]] = completed + phase_ordinary = summarize([r for r in phase_rows if not r["transfer"]]) + phase_transfers = summarize([r for r in phase_rows if r["transfer"]]) + expected = expected_requests(completed, active_plan, config["users"]) + phase_results.append( + { + "name": active_phase["name"], + "kind": "load", + "seconds": time.monotonic() - phase_started, + "started_at": phase_started_at, + "finished_at": datetime.now(UTC).isoformat(), + "operations": active_plan, + "expected_requests": expected, + "actual_requests": len(phase_rows), + "complete": all(completed) and len(phase_rows) == expected, + "ordinary": phase_ordinary, + "document_transfer": phase_transfers, + } + ) + rows.extend(phase_rows) + ordinary = summarize([r for r in rows if not r["transfer"]]) + transfers = summarize([r for r in rows if r["transfer"]]) + complete = all(p.get("complete", p.get("ready", False)) for p in phase_results) + result = { + "schema_version": 2, + "startup_seconds": startup_seconds, + "idle_window": idle_window, + "transport_rebinds": transport_rebinds, + "phase_results": phase_results, + "started_at": started, + "finished_at": datetime.now(UTC).isoformat(), + "duration_seconds": time.perf_counter() - before, + "config": config, + "environment": { + "python": platform.python_version(), + "django": django.get_version(), + "platform": platform.platform(), + "database": os.environ.get( + "SYNTHETIC_DATABASE_DESCRIPTION", "disposable PostgreSQL 16 container" + ), + "transport": os.environ.get( + "SYNTHETIC_TRANSPORT_DESCRIPTION", "loopback HTTP Gunicorn" + ), + "workers": config["workers"], + }, + "ordinary": ordinary, + "document_transfer": transfers, + "complete": complete, + "provisional_pass": complete + and ordinary["errors"] == 0 + and transfers["errors"] == 0 + and all( + p["ordinary"]["p95_seconds"] <= config["ordinary_p95_seconds"] + for p in phase_results + if p["kind"] == "load" + ), + "limitations": [ + "No browser, ingress, TLS or SSO", + "No CPU/memory allocation recommendation or human acceptance", + "No deployed-release hardware or shared-service contention acceptance", + ], + "operations": rows, + "runtime_samples": samples, + "database_samples": db_samples, + "completed_iterations": phase_completed, + } + sampling_done.set() + for thread in sampler_threads: + thread.join(timeout=7) + gaps = sampling_gaps(samples, db_samples, sampler_errors, remote=remote_mode) + if remote_mode: + result["resource_sampling"] = "external; align with phase UTC windows" + if any(thread.is_alive() for thread in sampler_threads): + gaps.append({"sampler": "cleanup", "reason": "thread did not stop"}) + result["measurement_gaps"] = gaps + result["measurement_complete"] = not gaps + if container_mode: + result["app_container"] = json.loads( + subprocess.check_output( + ["docker", "inspect", "--format", "{{json .}}", app_container], + text=True, + timeout=10, + ) + ) + # Retain only public runtime identifiers/limits, never full container environment. + info = result.pop("app_container") + result["app_runtime"] = { + "image_id": info["Image"], + "nano_cpus": info["HostConfig"]["NanoCpus"], + "memory_bytes": info["HostConfig"]["Memory"], + "memory_kind": "cgroup-memory-current includes page cache", + "oom_killed": info["State"]["OOMKilled"], + "dockerfile_sha256": hashlib.sha256(Path("Dockerfile").read_bytes()).hexdigest(), + } + return result + except Exception as exc: + if failure_output is not None: + failure = { + "schema_version": 2, + "status": "failed", + "complete": False, + "provisional_pass": False, + "measurement_complete": False, + "error_type": type(exc).__name__, + "phase": locals().get("phase", {"name": "startup"}), + "config": config, + "phase_results": locals().get("phase_results", []), + "operations": locals().get("rows", []), + "runtime_samples": locals().get("samples", []), + "database_samples": locals().get("db_samples", []), + "measurement_gaps": [{"reason": "runtime failure; partial evidence only"}], + "driver_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + } + failure_output.write_text(json.dumps(failure, indent=2) + "\n") + raise + finally: + sampling_done.set() + for thread in sampler_threads: + if thread.ident is not None: + thread.join(timeout=7) + stop_process(server) + if app_container is not None: + subprocess.run( + ["docker", "rm", "--force", "--volumes", app_container], + timeout=15, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + server_log.close() + connections.close_all() + + +def host_facts(config): + return { + "logical_cpus": os.cpu_count(), + "affinity_cpus": len(os.sched_getaffinity(0)), + "cpu_model": next( + ( + x.split(":", 1)[1].strip() + for x in Path("/proc/cpuinfo").read_text().splitlines() + if x.startswith("model name") + ), + "unknown", + ), + "memory_total": next( + x for x in Path("/proc/meminfo").read_text().splitlines() if x.startswith("MemTotal:") + ), + "cgroup_cpu_max": Path("/sys/fs/cgroup/cpu.max").read_text().strip() + if Path("/sys/fs/cgroup/cpu.max").exists() + else "unavailable", + "app_cpu_quota": config.get("app_cpu_limit", "not imposed by harness"), + } + + +def main_remote(config, args): + """Drive an existing isolated synthetic deployment; never a shared database.""" + for name in ("SYNTHETIC_BASE_URL", "SYNTHETIC_DATABASE_URL"): + if not os.environ.get(name): + raise SystemExit(f"{name} is required for remote runtime") + with tempfile.TemporaryDirectory(prefix="vergabe-synthetic-") as directory: + result = run(config, Path(directory), args.output) + result["source_revision"] = os.environ.get("SYNTHETIC_SOURCE_REVISION", "unrecorded") + result["driver_sha256"] = hashlib.sha256(Path(__file__).read_bytes()).hexdigest() + result["host"] = host_facts(config) + result["config_sha256"] = hashlib.sha256(args.config.read_bytes()).hexdigest() + args.output.write_text(json.dumps(result, indent=2) + "\n") + keys = ("ordinary", "document_transfer", "complete", "provisional_pass") + print(json.dumps({k: result[k] for k in keys})) + return 0 if result["provisional_pass"] and result["measurement_complete"] else 1 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + config = validate(json.loads(args.config.read_text())) + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + if config.get("runtime") == "remote": + sys.exit(main_remote(config, args)) + name = "vergabe-synthetic-" + uuid.uuid4().hex[:12] + network = name + "-network" + network_args = [] + try: + if config.get("runtime") == "container": + subprocess.run( + ["docker", "network", "create", network], + check=True, + timeout=15, + stdout=subprocess.DEVNULL, + ) + os.environ["SYNTHETIC_NETWORK"] = network + network_args = ["--network", network, "--network-alias", "db"] + # Cached image only, loopback random host port, no existing volume or database. + subprocess.run( + [ + "docker", + "run", + "--detach", + "--pull=never", + "--name", + name, + "--publish", + "127.0.0.1::5432", + "--env", + "POSTGRES_DB=synthetic", + "--env", + "POSTGRES_USER=synthetic", + "--env", + "POSTGRES_PASSWORD=synthetic-local-only", + *network_args, + "postgres:16-alpine", + ], + check=True, + stdout=subprocess.DEVNULL, + ) + for _ in range(100): + ready = subprocess.run( + ["docker", "exec", name, "pg_isready", "-U", "synthetic"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if ready.returncode == 0: + break + time.sleep(0.1) + else: + raise RuntimeError("isolated PostgreSQL startup failed") + port = ( + subprocess.check_output(["docker", "port", name, "5432/tcp"], text=True) + .strip() + .split(":")[-1] + ) + os.environ["SYNTHETIC_CONTAINER"] = name + os.environ["SYNTHETIC_DATABASE_URL"] = ( + f"postgres://synthetic:synthetic-local-only@127.0.0.1:{port}/synthetic" + ) + with tempfile.TemporaryDirectory(prefix="vergabe-synthetic-") as directory: + result = run(config, Path(directory), args.output) + result["postgres_image_id"] = subprocess.check_output( + ["docker", "inspect", "--format", "{{.Image}}", name], text=True + ).strip() + result["postgres_final_stats"] = json.loads( + subprocess.check_output( + ["docker", "stats", "--no-stream", "--format", "{{json .}}", name], + text=True, + ) + ) + finally: + subprocess.run( + ["docker", "rm", "--force", "--volumes", name], + timeout=15, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if network_args: + subprocess.run( + ["docker", "network", "rm", network], + check=True, + timeout=15, + stdout=subprocess.DEVNULL, + ) + result["source_revision"] = subprocess.check_output( + ["git", "rev-parse", "HEAD"], text=True + ).strip() + result["driver_sha256"] = hashlib.sha256(Path(__file__).read_bytes()).hexdigest() + app_files = sorted(Path("vergabe_teilnahme").rglob("*.py")) + [ + Path("uv.lock"), + Path("pyproject.toml"), + ] + result["app_source_sha256"] = hashlib.sha256( + b"".join(str(p).encode() + b"\0" + p.read_bytes() for p in app_files) + ).hexdigest() + result["host"] = { + "logical_cpus": os.cpu_count(), + "affinity_cpus": len(os.sched_getaffinity(0)), + "cpu_model": next( + ( + x.split(":", 1)[1].strip() + for x in Path("/proc/cpuinfo").read_text().splitlines() + if x.startswith("model name") + ), + "unknown", + ), + "memory_total": next( + x for x in Path("/proc/meminfo").read_text().splitlines() if x.startswith("MemTotal:") + ), + "cgroup_cpu_max": Path("/sys/fs/cgroup/cpu.max").read_text().strip() + if Path("/sys/fs/cgroup/cpu.max").exists() + else "unavailable", + "app_cpu_quota": config.get("app_cpu_limit", "not imposed by harness"), + } + result["config_sha256"] = hashlib.sha256(args.config.read_bytes()).hexdigest() + args.output.write_text(json.dumps(result, indent=2) + "\n") + print( + json.dumps( + { + k: result[k] + for k in ( + "ordinary", + "document_transfer", + "complete", + "provisional_pass", + ) + } + ) + ) + raise SystemExit(0 if result["provisional_pass"] and result["measurement_complete"] else 1) diff --git a/docs/evidence/2026-10-21-vergabe-target-replay/harness/orchestrate.sh b/docs/evidence/2026-10-21-vergabe-target-replay/harness/orchestrate.sh new file mode 100755 index 0000000..5bbc6e7 --- /dev/null +++ b/docs/evidence/2026-10-21-vergabe-target-replay/harness/orchestrate.sh @@ -0,0 +1,15 @@ +#!/bin/bash +NS=vergabe-sizing-20261021; W=$SCRATCH; R=$W/raw; STOP=$R/stop; rm -f $STOP +$SCRATCH/sampler.sh app=vergabe-sizing-app app $R/app-cgroup.txt $STOP & +$SCRATCH/sampler.sh app=vergabe-sizing-db postgres $R/db-cgroup.txt $STOP & +$SCRATCH/sampler.sh app=vergabe-sizing-driver driver $R/driver-cgroup.txt $STOP & +$SCRATCH/top.sh $R/kubectl-top.txt $STOP & +echo "baseline_start $(date -u +%FT%T.%NZ)" > $R/windows.txt +sleep 120 +echo "driver_start $(date -u +%FT%T.%NZ)" >> $R/windows.txt +kubectl -n $NS exec synthetic-driver -- python /driver/synthetic_pilot.py --config /driver/config.json --output /out/result.json > $R/driver-stdout.txt 2>&1 +echo "driver_exit $? $(date -u +%FT%T.%NZ)" >> $R/windows.txt +sleep 60 +echo "post_end $(date -u +%FT%T.%NZ)" >> $R/windows.txt +touch $STOP; sleep 3; wait +echo done diff --git a/docs/evidence/2026-10-21-vergabe-target-replay/harness/prom.py b/docs/evidence/2026-10-21-vergabe-target-replay/harness/prom.py new file mode 100644 index 0000000..2dde2be --- /dev/null +++ b/docs/evidence/2026-10-21-vergabe-target-replay/harness/prom.py @@ -0,0 +1,25 @@ +import json, subprocess, sys, urllib.parse +start, end, out = sys.argv[1], sys.argv[2], sys.argv[3] +P = "/api/v1/namespaces/telemetry/services/telemetry-prometheus:9090/proxy/api/v1/query_range?" +def q(expr, step="15s"): + url = P + urllib.parse.urlencode({"query": expr, "start": start, "end": end, "step": step}) + return json.loads(subprocess.check_output(["kubectl", "get", "--raw", url])) +res = {"window": [start, end], "queries": {}} +for name, expr in { + "cpu_by_namespace_cores": 'sum by (namespace) (rate(container_cpu_usage_seconds_total{container!="",image!=""}[1m]))', + "throttled_ratio_by_namespace": 'sum by (namespace) (rate(container_cpu_cfs_throttled_periods_total{container!=""}[1m])) / sum by (namespace) (rate(container_cpu_cfs_periods_total{container!=""}[1m]))', + "memory_working_set_by_namespace_bytes": 'sum by (namespace) (container_memory_working_set_bytes{container!="",image!=""})', + "apps_pg_cpu_cores": 'sum by (pod) (rate(container_cpu_usage_seconds_total{namespace="databases",container!="",image!=""}[1m]))', + "live_vergabe_cpu_cores": 'sum (rate(container_cpu_usage_seconds_total{namespace="vergabe-demo-company",container!="",image!=""}[1m]))', +}.items(): + r = q(expr) + res["queries"][name] = {"expr": expr, "result": r["data"]["result"]} +json.dump(res, open(out, "w")) +# summary: mean per namespace cpu +s = {} +for series in res["queries"]["cpu_by_namespace_cores"]["result"]: + v = [float(x[1]) for x in series["values"]] + s[series["metric"].get("namespace", "?")] = (round(1000*sum(v)/len(v)), round(1000*max(v))) +tot = sum(a for a, b in s.values()) +print("container_total_mean_m", tot) +for k, v in sorted(s.items(), key=lambda kv: -kv[1][0])[:15]: print(k, "mean_m", v[0], "max_m", v[1]) diff --git a/docs/evidence/2026-10-21-vergabe-target-replay/harness/sampler.sh b/docs/evidence/2026-10-21-vergabe-target-replay/harness/sampler.sh new file mode 100755 index 0000000..dd469c6 --- /dev/null +++ b/docs/evidence/2026-10-21-vergabe-target-replay/harness/sampler.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# usage: sampler.sh +NS=vergabe-sizing-20261021; SEL=$1; C=$2; OUT=$3; STOP=$4 +LOOP='while :; do echo "S $(date +%s.%N)"; cat /sys/fs/cgroup/cpu.stat; echo "M $(cat /sys/fs/cgroup/memory.current)"; echo "P $(head -1 /sys/fs/cgroup/cpu.pressure)"; echo "N $(head -1 /proc/stat)"; echo "L $(cat /proc/loadavg)"; sleep 1; done' +while [ ! -e "$STOP" ]; do + POD=$(kubectl -n $NS get pod -l "$SEL" --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}' 2>/dev/null) + if [ -n "$POD" ]; then + echo "POD $POD $(date +%s.%N)" >> "$OUT" + timeout 1500 kubectl -n $NS exec "$POD" -c "$C" -- sh -c "$LOOP" >> "$OUT" 2>>"$OUT.err" & + PID=$! + while kill -0 $PID 2>/dev/null && [ ! -e "$STOP" ]; do sleep 1; done + kill $PID 2>/dev/null + echo "DETACH $(date +%s.%N)" >> "$OUT" + fi + sleep 0.5 +done diff --git a/docs/evidence/2026-10-21-vergabe-target-replay/harness/synthetic_settings.py b/docs/evidence/2026-10-21-vergabe-target-replay/harness/synthetic_settings.py new file mode 100644 index 0000000..2260675 --- /dev/null +++ b/docs/evidence/2026-10-21-vergabe-target-replay/harness/synthetic_settings.py @@ -0,0 +1,12 @@ +from vergabe_teilnahme.settings.base import * +DEBUG = False +ALLOWED_HOSTS = ['vergabe-app'] +NETKINGDOM_ENABLED = False +MEDIA_ROOT = '/app/media' +ISSUE_FACADE_LOCAL_DB = '/app/.issue-facade/issues.db' +ISSUE_FACADE_GITEA = None +FORCE_SCRIPT_NAME = None +MEDIA_URL = "/media/" +EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" +SESSION_COOKIE_SECURE = False +CSRF_COOKIE_SECURE = False diff --git a/docs/evidence/2026-10-21-vergabe-target-replay/harness/top.sh b/docs/evidence/2026-10-21-vergabe-target-replay/harness/top.sh new file mode 100755 index 0000000..8366b81 --- /dev/null +++ b/docs/evidence/2026-10-21-vergabe-target-replay/harness/top.sh @@ -0,0 +1,3 @@ +#!/bin/bash +OUT=$1; STOP=$2 +while [ ! -e "$STOP" ]; do echo "T $(date -u +%s)"; kubectl top pods -A --no-headers 2>&1; kubectl top node --no-headers 2>&1; sleep 15; done >> $OUT diff --git a/docs/evidence/2026-10-21-vergabe-target-replay/raw/app-cgroup.txt.gz b/docs/evidence/2026-10-21-vergabe-target-replay/raw/app-cgroup.txt.gz new file mode 100644 index 0000000000000000000000000000000000000000..7a1b12be8669b8b1b415aac554d0ea81aeaa0455 GIT binary patch literal 30529 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�$Uu1!lssg%T)J@y2c#*W&8jg)D(-b+aQ} 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 U@W+k*R`RiQQdCup)?{lAX9jf8$tjq{th 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

|aJ3p^3{Z0w?jo?`v*e4M>xz;Cj4>*&`gR zp-eKcSbEO2t%ukKG;!aB!AN(PwjnXx^HX-SQFD)7h)?_>sz&C&v1fvu_cQG#KO;;A zC^?_BE{|){cHQbS-uL#?EV|mrx9My#2E&uVg^GV9&W#`>+R?~Ox#ys-~G3yd0wfaL=8 z%JXsr$=%>-1JQz?CUuRxMmUd0v;Ku^9tGr`As9};0_BST1(7ao$@2c2VW4mhFb6y-AWKn;V#4v|`KV(>pJ8e~3tRt!5@ zB{Sigj+_aDN7R_xgPOWPV6e7t{OmV|XQ8@6vhZM}4#kp2j`oBZglw{vk|c-8iuFj`t;sWI>pqI0Wq+Fkvr5IXQvSOZOMSiI z+8ij(b$z?uBDPzyVItK>EGa-jgQk3KMg&?1VepJ6WZ`Fd@{-JPhi<9Q!02&Xs`Y<9 z(%^t?fDh4B;jC2q@o4790UnCS6RKifL@KHD#C#~S3O!}ZI-l-NjXsQP8)d1uqH&vU z_rcu$cTE93cZwhE1^sOc%2wG1Re&%EtsWQ!Cv3c_UDa;D>Erd9nid1Jfa~$`sk3QZ zq3k417+DGP;fLL7Kek)&b(~Rd2+$>?gngIGjPt@g$+`DMa<|5&eO?C=K=@{e=r~g# z*$jQgSwb5${SqmoXz;yS=vBBt#xe*R$$U(TR`mN8e@Xoi3l1Q{qYjDk$b$_*FA}qe z4jwOW1DsS4CPyY@SGiEniJ5q65icI^(LYX>6e}jBMwdBUEwbEx3WSVb@t%Ifi%&E0 z+reRi?=8l(3y1xzi-m3Vhta*8edGWq1?zis1W-c>eK6WLjp%(M<+p)MNP_u12pYDw zL5g1I22~hE@P$13etu+w7u^}5Iw;u0Hj#?mO*r_hT{R~%m$-MRos)=iV?c$8_xj~y z=P3@A4~KS4UhyhYn3o!KTB0zb8nF~Njxe!B|S%#0* zle?^-9;-wvMOPER`1=Mr6fDIQzN0v|ehCMwYB6F`=XDH|#YmdW^dQ$_$j=Q1RWhjR z2XU9?wQEW|JMF&x(V99VkpYk)dfLl~NF&0VJCmp58-yeCSEl`A&qCBI*oZ>_zF?H` zvH^GS)%8d3jfh7cJ-@!pS=6+`;;Xex&g=I(I$PRT>e&sBo6Lfz?<|RwBP00T-{l>qx+@!_!C6KKgHqU%p5+mM5o$z%;>)X0ctqkH?A zuHj=nM3w(zlZS!~?H5PM27)RFiG#T87X?0Kw^HYbC*H{cY=YZ$#B)A^!xIyviWw2MuQ!_9$^fvP6%rmn81ib=NKGaJyb K)?=yRVEqqjbpMe6 literal 0 HcmV?d00001 diff --git a/docs/evidence/2026-10-21-vergabe-target-replay/raw/driver-cgroup.txt.gz b/docs/evidence/2026-10-21-vergabe-target-replay/raw/driver-cgroup.txt.gz new file mode 100644 index 0000000000000000000000000000000000000000..1676805e805ed3344cefadb3ab04fe2127bb8c4e GIT binary patch literal 28201 zcmV)XK&`(YiwFqyT-a&?17vb(c4cxcV`p-2b#N|pcys{7o!gEi$Bl;X=PBw1HaCL7 zgoAOT$Hw4Z&*t@IPNaEek>`NKE|Ux6j}I_2YNH zeg62vjkriF{+W-T)=!|6*6_pUe|`S)@o(#=tTdw!Km7XX<9DBb`03-1%m4lQ>4%Ts z{ruxE{XxoyzkmAfA0I#c{OgaOZx!QTUw-=Z>z6OTeEiYBxPLr+_y7L(>GPM*{_*{T z?F;{TW9CwfKm6tP`PaXFynX-Azt-}nvXl}Z^8G`O4_mo^47V@8e);~FKUHW_%7_2D z{qT?9ZZ|5}ilW{sbHy8wA{1eNYDEY@%8jg1YqxT%O059gh>XntT|WHB4Hv8apMtlx zAl>la!N28Kv)mmSj67>B z;(;gB9-pnY9-qANZ1qi^ctad{)&;47(V;#s*(Jf*?R)9cX*o)^oJKX=9> zp7fAs{BenAi-7#f(^qxpNmOQ@Jn>`=o;7&-j|Wfj)Hw2N3snZspPYFDb4xt6RI8aM za-`I!dCIl*UP>c)2Tx;5JiV$Wo=rPX^-tY-%Ffe{c;4#~_%j=M;zA$-#!wT_#%OQx z^q?-!*CVh?JW-qZYkr!izYr5ou8F7hdW2qGSDw1@#JgPb?|;ZM*1IPQ7aapnS_lEj zQ$^aCt|3Im6% zdiPY&7USUQUs`$EXpc}A(gMzvvtCkvw7lM3FtyCn$Sc|NJg?OwDOBH4kG%SLRF6De zs3e|BD^IGuz7IV4h$pP;y_V4+p)H=4unE>=-`SeoEz?(^u2V|_3WbVS?WciWD@Y!l z#h?0lpuVyaP;~)00Oc(}$t%VR6gHq<+7Ezs!&%Gdkf z6l%3jOWuu@*URUDUX%1*|5V=*p*76P02E5G1XR0ZA~RZn?j0tkBcR)ohtVSuEbN9; z+?gx7jZE}HNdC=NjD4it(}25KonW%Ha2tuJ}pf!YL= zqP&}}HD3CXhtVWaTl^Jt$>i1`0c~0|x=iBF zx50U!4YP}fY6+-9cb%-zm;CNJg^qwyk5C+K64Am8ChR#g0riZ+$#$COg$XE^D(!io za1p2pydy&0Rj0Ncw31}KFV7XIi}5b&ciSoYW`qXN(I*ivzL>!OLCGh%wbztGsTAg^ zC`Bv(`6^HNVqO0jsIVkm519K*Mm<7f39LYO->LNoD0ZMU8YPm2t%#`?zXVj#ANdWS z+9EAH4fI-3B97;L9;mvy%RnU+C6g|je9_Ac6epn20_eRF+IAhvqf;VXq(HQmId$sA zxL{2{YnZ77)I6m>FYM`BQR3|5|m_wXp@?IBhdysv6sZ{;@$_cykp5bPfS3$j=hmqx4k5P{IxeiyO+26 zOoZy_l(;V9%EJF|sTr!SYQYjv@)DGR0^tek$rqbVtyMTL?5S98hC*&OwYn{N75g2~ zb_A5x&AI5g4?tmw`sV7FlJ&f|VR8<29h!?$YvgY5d7#(2P261Y;d!934V8du+MNq4 z+JRbIyY;kr`C3W>>MX_oqxUNBl`Xude{(cG2cm89itY^<4XeS6?00W?5f$G2YRvHRBAac^N)cta?(xB1y}-#TZBTf8R%hzc4hBob_B|c zHn&9#n6^OFe6er>8k!&j6`?Z`dU2%54KkmPP=6hA5^bJ5Mxa>w7F|qC%|PV{DD()0 z(JHYl>KfB)QjXA=rb9|V-S1C1R7FlGd(ju~fqVlfVQSeoX(>Xn^%(~f?PZ>Jp!RK` zu<0gfqg7&EJUR*6)NZoq(`2AJH$|0pO0VvfC zsAty|sO~`dO`sm3I9eqsi+U1u*(7OG$-gt?MgnS>tGvGq3MV4;T2Vq{ct=r^rc+U} zk|LC%gMSC=IqeRVc?YV`L@13`NveNcJkj{?NkFklZa2l~=oF#g()al)uNT`*9xr%a zhYDJr!)YetwrBHl+<|JhO1hc(+Uheolt-&1u$arO2AHe7xdUqxP@Q(^?%mqw_dwKM zJSg@UsQ+Nfp;*%3h-<%9HnG?AHHYe6<@+Eb?m#&<=rjx{`nQxGqC8q9aqR9uB9G<} zWslorm7t;Io;@N0Ja3ikqO#|3bf<~BZvoCkb5)Yw9`R2|57E6U^-;!e5bgaYkkKj$ zy4!CB6)Bf_jh*BKl-eAkZk0UaKMnL^zsW=E9U-bNe$tx9ib*#~-vr^vw|AiSbqg4`G{09iEPP)lFtL6x0oXkABmf;gX>BSKW4}nsnH0pqmhVPof2Gn{-)V6+8 z?Zb@U0J@cV9j%h^Vp?kWpj76)-VnC-22oKv6Qc6s5PeM46<1#KtwAEnJ&OV$&P4f5 zqW&m0dL@y?p)@Z|xg}alRe$voQOB3HJx0^(1ksBFw_+;qAZl@tB@wk0qwZolQG(Td z+TL(Norn?yQHQxiKG+V_jAlv9e|c45r71?Ek)OnUO!Q|z@9OZ()~CNpFIM*tky?d( z%}GQg((Jsi%iL z+DLJN1Wq8mSe`U8)On=h;hm658Y98z*R4pY@7UQkms9&}<2R7jwLHZ+g#l?>T;PLA z{JjaO_@9uv%@NAk*gIu+PA8Dsiw8*_A@xjJctR?ikZ#Ge#n6)@je*<^sZK~aw(Atu ziccHT(KabA<^H2Ynvtrf*i`Wm$|UnWyZWo~JknbK09gvr_fOlPs4XFdKGN(DU)zdw z8zrd%M@aj8W3)X7q_h+hYc4RSlJ82N^$nyXt(`dJUE3d^OQE=9&bib|OGx{sh|BKv z*pl1)9qFn|`7zSkoA{C92d2DKXkLtJK3L}=<)Srhi$pgu3sZ`@!!u!eaSYe|f8{*W zIMOmQrINIM>^66%(agIu-NRJd5z~IO&a>}`RF>M}+_9&{j1)Wtr9QsD+A`Irn2p2} zNH5Mjc`;NykJKoirlp!j#p4`DN2=@YGQ;lhbq-UChMv|TnL1J)?UU+KQ6Rbl6VmXv zsXiga_Gm(?3@6Sf*NX?fQAp$+^~uz&VM5Aj<-5IFk;aDaigfSmSvx|yMX8MbNp10p z=fWOK7N!;50px}6>pV}3yV|f*Na4j>_79PI2Fv5(6re_=2puU>kJ4T2+hLThtx{*I zqk*!rREa$p<%y{$Po2WlrUJz-8K|9LiZAZxKW17ri+yrAn3!5mre1G*nC{I|(`Uj| zNB?AX@r2>@EH_JQ%nwW}U*mK@LR#Z`gcC^l#YgEMBLx>u8L1?(Ph0<)@8gXlQs~1^ zZy>eNKN&3kV-Oqn6H?I%sXkGu(^WaqYOpga|J5_OBhs=XC4yx0{ZCCuxsO6H_x7Hb zh#e_TNbg6Pg!@O1)Dtt7(LY&Rz?HruEm@gXHLaSFBIHdH_M)aIRMf=-;9j7#=y{~> z4Re%I7W>TIeIrH zBRVJi)5T-pZy&jPjMQ4m2RipmV=(E&O)vH8QSdj8;3w^lnX_M|$;k5-rb{{I5tsOA`BBN=-;Nn;!!yE7DDw`s*}jOd_bEz`wx&>B|VW$FCGUcPx;5QW%VJ_$etN#+I03m zv+t2gOi0|3?kWn}5z;M6ar94Si>Dfn2$-VO6J?WQq*9=q>F?pIhoK%LMUyE?O%u|s zaU!NVBjvZE6!jLHNITM6%F?{%7bVOp3OsXVOUiL6Nk&?0Iny|~cqH8GN;_`sKSbIJ zOi>!c;v-V)^+{p9riAxvvjFTcO1Cg28JX6lxgINmYMH6ViOIy&WkFV^;;G;G1k;OG z55_Bx=b3`5qr?=_o*u=0cq>za&U71sgm$>{duKl;L9jnmfWk&5Twgf#mnD%O>$vUH@o*l#)^t#_nlKq|NBdk#orYbp*- zP(Fg^UZ+f(`D{4tl8UR`dZOgN{ABn;q*ys$_~fo(RHo$N(~+u`2`RQCq2k-7?Dakc^fyitVpf&{@#9v7En1t%IlT5 zu0cnnv>-UFaJU(%Gp+UpQU*D3eL=l?Iy`og-Vvpy`RpxQUii?jA@t7|nUSuCa`Z9M zb^nRtHkXkpFP=@jn#Uf0Vj5EuN$kfBv)Mn1AfMTJzIs6X&EvNZna0RmYDP&(R}IJD zVrSZ{u1sNPYDY}lhSWx+vLMIn*rt*nqt%qcR3OgRC!{V5&RjOk<;C+6kC95vV?5%@ zYDC(g*Qb~gS&{CRv~T8rMOwm=a&UF6)ptp$@Q73wR4?Y0C5!zQM_SUzi3f7YjeUu$ zOim4|mX}??N8%XcT)a^iRBT`$aM0-zdic()?kdb;O z&kJ8&*Pb|nTQ43H7pwIhQOe=26H-d&?rGoBGqYZwc9$=7gcN&}!swrBS^N{FqD;N0 z<`Gzu))!ZB**~eLf_fsCUOg!O<}LV-k;c8587ZY)8WZ|~R7#x3osilQ(ycxbjQ*+C zg<9B6&Ub3n*hk5o6N$;B%rp)OpYWu7b#m9Ew47(^FGcF?dzq!g)TlG%zL~$@ss(*M z;u}o+{2sN@KUG-BL!V=#G$V~x<6B6P>lvgM4~xHj`|x9=Ivp1GBA1dWk#wXgIwO^Y zG!8Tc(z_+y->iXf^iQ=dE;%hO+s?@}&R$ruq@r9Wr2cI5M17L>;wvH^BeghfG$NH; z^0x~~rPg&A+Ex0F)Q*sDQHrB~s<@E0IP%!?Ff_Wj;+#wkr+aoZZn!(4qU7S>u4kfm zAQe}GEhEiQ%3bV>mz2Knw~=~QQp!+>ep@UNVUv^QE_1W zOv_X*)~8AZ&*##(nJFXHbe7ClDRiV#SC_xvt_4O%NTEk5kM^nZ!WH6<*SS1J%P%c_ zPf61?5&qQl%oLAYD^Jd*y`#M+am+p;wOsNAH>9R*Mk+^0>n5Xk^iNg%8+>V?PMwpu zYdo)fg~l|!l~7Eho_MpC`r?uO$4p~hZ)B=*=l#gE_xIc&w$+mES8TODa$;XCDbFPz zVvcb{s!O~FL#dFjbFUazQ6!hI&7Vd_TH}(f6G+>OhoK)Mjawix(sW_A|6x5$*Gdba(lwSNEIFFIKR7vNbw5Gvb(tgz0 zO(gaxtvgaaLfYSRfNbP^%V?hf7Af(rYtf=gNHI>3C8RE^@{XRl^Ez=6dA0gS zbfNE;W+tR9-NF&0NFAxv9;IzZdN27>zLxyEBZXe_`yB^7zImXRrA6Y+uWFfOlndA< z6H}a~c;fD7JYh)<|A_h&?~|94DW&VHsnD* zGVSjq81KP2AeF^eeRX`C8z)>qB_k!9j_20m)%3*3N!&L1+9PF;nX2V6o=9OMQ>eXj zf(HLz+`A>ovg=xQ|J91y0vDfne9B5PQv=lhwg{6>dJv4rh+ujy?J_I7=j^lg%FK}r z5CDNW&t_ERi>WxKcIQ_xt<(FH9n&(`Pe~3587JK9=12SGadh{SqT{eIjVx)0Y5B)_ z)GMa;Uok7=m^!5>`;hjYX-$2={K528iu#Eu&MDM*>Rdo2g}`RE45IowpD_FEo=GwJ z92TaDkgk5ul>ZZ^_I2GHkEk2kr{U7bmgk(~(3d`3&iNhFuD(SyS5P?|lGGw4txQ`g zFfp}YP?tL2lCP4|PCV6roJjq^l$Tpy*^|?Go%gpG6d14XLsw{7rPP z;_}azSq`){CZ_oi#^QYq%e@cJ=d>_gJ%j^hh;@T${SWT_Y|%8h^c1JvG3|TLj4{EJ z>D7@sg}8oVI?k!gMN}^Khk~V4QvACfLD#f6riwv7&y-u+?R%zopG=zO=QdNAU3#XR z9Me(k56c{)tAA4JJEpxCO*&UmxgH9=O)E6K;sGf~neTnXn$^3}?%}l2K*3nxe{kCk2iT$eTic(I$9>Y-Rn zS1o?(j0=k0PfT+g{r;Zm%@q?^AMP_XqS^KM@wd!OF%o$)E&aXpl7-HvdFxM1yF@JK zxsECU0-TKg)WWoK&HkL)CuX_#Bc63O&S?(+cp>67(_H*oslw5IW=i9na$ne^t2U*# zpZz?T&UI9YhbnZw(vtHto>Qu0iobec3chw^lJ@kAJwf^Isz1L7BvH$R6bZ3St z9jd0Dpz@XUpJTie)FjUZKi9dCD&+{60>p}*XR206%?(sa*kf6kGOo7`ru>fwP+l`t z^>*#bPA!&n1XPQ(XP=+1OX~E={E2B_-QhYHQl%d7YFA0wC;c#=R`pCN>cUj;{cSMS zf4mO$k|``Q{KYTTCH;DxQ|?RUd^J$dIo_X`_N54>xsWP!1Vcp-r1DH7W-}a9IH4^} zH3)zI9+hrxmD@4WUo=rHBl1$_@Tr}c^3XOF=u4hVuYfB2k!gif>&%poVBsdThkM5q zQuFh?gcVz>e*xrz>rSJ;wi(4=H{sEnCjd_ft*HXiqZ4N^lG4<>Txqu z8bXKDXJ6(*s^}2|Tgexeg(!iNWctL^0u{v#ruUM5!8Ysuy>E%Nd*mx<#5tzj#z3t4 zNh?$O$kY;4oeQZNjtJY5nwCOJ2#?R6DO?~tQ#%7`hp>^s&9!QcnES#$qh|#i9J6};dCXne_=2^_0E1`iUuly z>0C$EdPFU7j9zjnyP(83Q6Pc7!_FObbGiJNO_YLXOvjX+p8+h*I@8p1smmJ% zZ>n#Y_FPKxTt`(pEZfKURq(l#{ZVxTQ&f`0`h_W;b~f_Uo7?4vCjX*=T7!jD$J84< z+h04*Y3-x@mz9bA(C?Ub4Fv23PfYo+9J38f`3EVxv?#cG9}g@`E|o(<9{)Yln|BoU z3x2^gvXhT2%|UNw+Q(1rBV5lOisp-HS(ui`;CUD60n@pTs&XXzMH2HO!mZG)baxcC z&uC?;Bs)xR-cf{ad!H$kBpg%tjZaL6U7?(&MPPc}`={g?=hsZf9aZN#s_Kyn45RB( z@2ma2&WIdJ>QCnsa?Bfd6y2QZRoH#)GcDpK&mr{YYE$Y95|)tX9#5}(KZkcr`y-#w zTt`u`Bo=FY78^ZNR#hKQF%z>ewS&6zc)~XyPa>DHpYvC)BA%&xJb_9_XUgM_qKj#H z$29h)G`e#W)Cf^d`+HZN9u>u(bWkz<=b>7ix1rZzx!28kek=I)FaE04HKn1bF~3Gz z`q~3(^W0pZ@;j*AZwvF!6Vr4gQCRbO?+tj>K-uF1Q%l2lCi`x>y#I2tk1#OfnY!&= zyG(jOExAuhUrt7)#Fc5jnthk-naEs75uUAJ(I;tPica3b6ccBjDJ6L6e9x5rizzfp z@MWBtCg%>tLp>J5{k$^G@0gB)U*|%K$^j!3Y6d!vsg3F$F#ki)@V@YN{Z@XQ8B`OhCEbwPr8E&KY-{A@q{h;qa=0p~8s0(G^Pp;&D=8u?2-;G#AC-o? zdlJvIrbR$DW^F<^-!sksMHhu}IXI@pfB1DA)LKqABMw(3Rnx~0fB7H0lTA$dz~5le zvn(HeN)YFnnR2Yli&P2Nd*5JsQ>Y7;Wc!ztWs1>t`eSt?Q_?K98&kBS7p7-6Rs8sQ zv*8#kQg%+`TuG4}_92H)BD)L~;~UwH^uvZr*QZnHqiq{(WOQ z-!m|+sc@G4vx>W3T(>`}Q|ppAIw`e}_`trrQ|+T@KTz#D6YAFaI2>@Bra;-nRJbWs z_sWmG7|XI2s<4avzUD)Wzblp_+ zHUji{6m<)I91qNA1gO@`R4Xbw_bz4i8_C+7wu=f=e_vBg3)Qqz#T4O(w2I`#_UT8k z7(aD+l|@>2TvIpP6|#VFg1tg3(Kle-Rv)LMKyIQ$FV0=dXD1}x%Z{%wzXRLne$*}baXpIVx~ZNzofBA0Q6~@9{=o{?K0-SucAfa$2W!`7 zdEv?T87waYRf(JqI^;Dv*c{3U_G+-o+fZc@20VddJk;T&0E=)E|y(6(L3 zR{K*sN4DNSvX+=U3(@5GAS*=w$0N+4`33xW3%NAM;VG{gSHs zA@y1@Bv&2T94*E#WXbZA9kP5s$|8-~C(GFDo5|WAaK%Br&Mc*#7hJv8Q_gcH8;8yL z6b#(f9}5&xEtXe5s9vqKf$^LJYgOxdeX-;XbI!a@0RQI5)>59YuSB1~68o3d9NkKl zj5RgEZ1oBDY7Xj|$=26kyN)-WZ)UOzp;S}|e#ENcB6SYZAiHW%MoiCdEdJ(o4)|BW zKwrGMd?qW9+L*~^pK(EV@+4~|)Jj&~k(G<=TzW~cTvvj9fvlb^MraqZwX9*50|pj1 z$maVO7?<=s#gaY+18AGL*Q^Yqo|$Y{^~QWz@MLSBw)%l=?-W@4pUW?a2f@j~fX zmcdo_WJz`DYT3P)H=bT{xqpELJh+`efo`p~q+^M*7M5!KNEY)68|iGhe}k12`1#53Gg za3(9U-(b?0f@iY%9oan2tlJEfa$uLCZE|_CVp;t{R`H7H$)fh&AY1?72CG){Jw*fK zbCKm&WEFPAGuaXrvblG;pdZ)B=5UdvcVw?P>(&FM9++yiui~vdTQDGhgOwE5tc%fH zNE>ADx4Gb#^*k^9nQT(uKg6#2OjbjGLD1A+x-Ki<8ujCuMQ-4h1ce~18tmUwpU*-F zM6xFfm9T{@<(*9-OYiQJtxfTDB^y7H&FZv3@r>XK1sxMvFn(Rgru2^Nb%VK8LE!@y zH&A=nsqfz zz*44YfR}H#Feq{`L=-TUz(}>&Ok<+Mp=(YYW1ve@+?S>mj5ENLNIWhD#gz#X#XUyfjc zIOp`1cFkmC&${d;t7k*zGW)IhZ8S(D&bk#sjR(VJj8U37vRD`PHCU~2J+oNc-pOa_ z{!2}0HU2(Xj!xhTXg%4!pJl1#xl6>C=WosYRh)H;gqjXxg9(?Ftld`28(1Y-Ockvx zCee)=jO6~A1>ENegHMl(R+twbXVJl$$x4p~v8k^)vVFMlLAJzUPmhq&&unz-gbM3= z)tGeY$Y94bE@zhE;pxdjEPmq#^(a&`Rd4YQ8s!_ zw)Qw1-BO{JqwUhwko=PZK>{l*WObFh5@fj>y)I6F&_%4T>}={svLZg~su~?x8qq*U zG`OnUqVLFFXV$G2YCRY(D`4cvGL#@ZS+XKy$-1yV${Pb5T>eoP@iSSBL_4zNZ?K%s zd{$G`ypZivT%XRYTQF2Q@OdslmwXo8m*gvjb7aetwRpVKH{km>7)B&t%yE{FWbKSP zvZ%3UvUQN{YAt-Z0@pst_36yIHA7`+Gh^<~dt6jJVDfTiTQA&`CA@iVkk$KVw!~M$ zV);R~z9y@|=LUX8&hD9hgC&V|CA&t0)LXO7#eU?o(JdRQ9CjmEWvAYQty(S?m+F?R z@?OuZeff=H?kw>i^$k9gP2&8RN)ab}56`}nZ0^e(T&}(WrFUdU7csh(L)Ak{8-|I# zKWn*ICSTn`HfBAuf|7fK>>u>HBF5OC$fi$Zp*-WrYMS@C6iwKZY>wv*b~y#tKGO9A z*)-1Vym**GIHdVxFTPBbN!HgI3rw`0S^I`$BR7!yy{;yX5%$RzWE2zG>XmVfMp;JKc8t`U~S_b!nlok>wxGY(5v|W1O2^DWsz<+}b}~ z(AvkvHE6LLheXVyO|XvGKzlpw!fyVHfrF1|QA7GeTiv6Afr|qzozLRsdSdKjUHn)` zXfrbPq2ElgY1++X^I;5YI|?&d3I+4ajx0J`%R`v`uENGjLJ4;REjNpaReUTSaUwf6 zyJ(Olw!+wvZCjlWvQkH&?SgK6Sqj!J==K9yG!E>Ni;-QREr)#&D((eWDnJO^FL}b| zMb<#8+N7SoCL3>)EtXe5J%oKh)=~pMBvZW{fodJ$Kzlg2oCRx_Ap3!Ad%Oi%ae=lT z+U!<|{h5X1HH#xFl$RYk`(!5CuxKvnK3P;ttH4vlQtKd_=TbpBSx!0ml`jf2St}3H zxkIZZoB~fXS^X3ZD&p+i>`H-;5D0&BatW=4@mQU;s9nj*OR0d(FxKyr1G}JaM}wfx zvUy*St%-f>+b*>{lWkT6jckl(gZAvk`sDf0)(6^>CbT-xvQ@<{9N^i+p;hf@(`hlxm+KhCx}asg zEQT#wBc(-{PIJ4<*w_tfbHeWrtF%Q{vh--ml<5wOV-d_MA7kgpGR2u}=5c7-J2=RWyo>r%*gLZHKTS_|A{&pu z5$=%U?=TYBoj9_LG}@De<#5@^4){LV2tBN_O%{cw4+kPVlf7C81p*L_tX^dG9a-%e zZJOI%_Hnj2FTsl3=n7n7=Io}v3lyO1a%PhSjSX$`BDcwwP_YTSlI6$pVx6Kv9GeOv zc#fXQa!?m&TX1M(yKD(x{y9|mW1vN-9^iWx$s7H`GB-x6j@-OIS;fQc7{a{DzE z+ECNBvgCf^Wfy%YyJ)S@tj^{Ua(XTtaJbNFT+mh@XbXl%GQMWU_DQ<#u({!7pXVdO zDPE&Dbtn5Z1h*M(3=?b%9NHZ5p7AwW><-;Tn_z>pqD_#wA82XHyzE}@Xd7FHmP&DG zrB}FRG-)q~BKYY6t-MC7y;?}F;T7ueTTNmZori@xt3R?BTCdJR^Zs;EC&~B z?E{kPdTOOPT7S(p-DVrHd{0lOwmfQFE)!e6*y2UnHD(0swq$9N#i0|()M3kFsCCjs5#5ZgeCRmQG z+rF*QPR_~J^03Z!^b?`J>)AGs4e5g&jV=CfngzI0g!&$|jH7=bXY_!bGJHXwVG)e6 z@6M|0?>h*!(EXUutlOSq!s8j2r}6JN>fDG26rX-co+mzyZJ$fD$eGU>FS^n%{emsx z?JUG)vFk`^2t&o+u{D&*S7TZ+7U|j6gcs%S+15W`8}pOr&NpnKh2gF;sKm@{llGP( zv`iaY9xX-gTG2nT%_H8wn! zp6;?F7h9PUuoNz~cm{W+u9bCj24?xrU|Z99ZaH0S_4By}+gX^)N-7A*akEQtY&lYP zy|3nev3PDKN*0{HugL1{0R%Rj?Yt^0><6v^ZfN%`fhfrw_CV;v5XV7G|}h&9h2ZN|r(-xGS(9Rz^APGcb$E z)oNgmmMz8DSZlQJ?si0g1k!T`_7Sbs^~I~mYosuvr7~_ZJ#CHoa^&GYQ~Lv2>K9p_ z#k%YYJ2Jiqn%^^8i=mMGb!aUeSd#>{i)CZWnZj+fXl;h@bZWz6cR~0i+MJ}i@eVu2 zF4*F>YG}zK^NDsO30&Tu+S2o{JZpB@NjzYXZ1qTV_n0NJf!$Mi+m4W>CX*l*qrOKQ z?+1j8LiHDDq1xw9ZJE6fDn?2N+O9Bnd311BnEL^3EWO@^aL&Y*j%=A0M3r49L11h^ z?j9=v{fmVymiIP}ZMx6aO2tr~**@xBoy5B=Pz<($?xAB#KGJ~LanvM>IO76GBm1zFMq5#0hV zyGI=Kxyc7xi&c5?n1SRB1f;%3TjhQuLG#lz_%~?ns#ws*d1$$&I1*bLZw;5xOdI<6 zfEEFv&h=Qvt4GObf4e?{)rEHbt3%5nFI`)V-*YG0w~UC;#qEzT{PYL~O>idKbo z(DH4xh>=g%*f(ggPY@j1!gCwJQ1sR!q+?ICBe9iG-l3Hd5NIyPaylxE_$*8C8G;sJ zr+x4(-(uFBWOtCV351TtLWUQqhY1^RYojVk>b) z+niFkE;R$s0iVqV!+)%tx%;P?k2!S4F>3${X<9b02PH@_16Q9Ii%h;kXI$hqeDBiq)Z z(I$RAv!$b2B2EP4GuzA(fqb^&Q)GleG+EF32(;cFWs^#$Mr1p$F~VXz{wXOfP2){%<4+ zT*7^{G34i)>@!+8!MfBoR@ePX*Z4B?X;Zx8` zJ<(qI7v*v$-Wj(N$Z+Yx;Y~c#!BSNZ7?E47taMPV*OMh@|E%Pj8o2>;j z6gV?mmAP!lA&iKiU8eSO0+HTFNI$SGgKe5ivJwu0%4N>U-(=XXF5XZzmAvfhTlU3i zqy5FVx5_R3LQVF3ZXY2bP9Y&*&p38~R9psTzoP8eN^c}c;1+EqjMk#?Tef9H+dP+K zB_5>MA35J_9%w~HviJVBJhu4GA|&qf#v~2PZM5)_kIyf=pV7j-gdN(%bCPh%fHt)0 zd?Ay|Yejk^;Rm#%ZYX&!$x5=6yI?5Y>m)_{39_j70)_D4a%}Aj?u>`??d35m8mN}= zT<$(XLY!KQ`Ra%hg0xG(QgN-tj5&mpZI*Phy>7D8U43SYAsUV+TO*QCrfS-~H$ zUm2cqJX=UsW*2VZBeRSxT8O})(${S5B;0P>T0{u)3$|+UmKN%0{6Wenl0snTLi?UMeWQ*brIV^D1lI^(I1lb+Ez{SSf_dx;n&3EH1973NfUf~Da zJi#3^q0oG^zuUpp;0|u)-d&Wo^9;Ch!R5Db(*<{~%1S*f=Tr+}d5H=Mvrx`2h_Fe# zL|lS(!}qVdaD2WEm!9W6zJXgSIh8NmUh@nX{CzrHI#s?VxV=;J1Kcr61NYjAEkW1E zzF{ogI=1#Ro+?TYwiSCoo-G_0cq1z0+iYR%gFv+2X7XS=Dca|&!PpkNT~s(j?1OC` z4MyqObP`4vOi$IncM-RpTb|3Z!UxueU}m|n#cPgRGPiUq)3UFNiQl{-KqKWoTg%Yn z^UUpMwwAK{+sv}_;Mk6BC+Tyc5M1qS^Vv>#IEXbjmeWB@ADM#8by<XfbLyySH{d zTx|Ir+bdI$xh^XTJ&(f8Y~8UX?CQA7OzeEGZ7?aR?!?>M(tli_hx{TY)Cb$C4VLL* z3l|W7ZpF31pl#OK##8M};%MLMIr8!G-0EDH)o?WRqZGWCXtXHG#R>e7uqAEFtAkj9 zzadyDw?(O-AV;s}5JLj zRy0g6XSi0l^&|{iJSW_8!Bq#hU{boTmGzfMbSSYOGqJUFqFTX38hyaE+pKuCaJKs` ztPkjFztWA;Oz+E2hQQkLO11dv0Z&DpZ3#V57`<_o^p36e zZVgw6TADr84zs_Hv9yUXZ6|4;M~2?p=qo{r9CbgGNZ+ zSlQG12XHa3753rc?aH-l(Ds?(mJ6<&!}v19f=lSrA3wlt?>bOlv0vB(mmn$tg^#l2 zxS0MBHxR}s#Fp9&1xVe{qEM>$Qw1wRo;Mxpqo+`(*M~Cd-jrerergG_jWb+`0-ta- z_LHjvZXf^n3GNWNhRoOmHy&25;rW4zhs!CkTP%Z|EL@5_T+7QhiZlDb-52_(L7Dd9 zmVh?r3>OAM6I>cIq6%F~z%5tEJ@qJlgxmC2Ekmw6S7($Cs|kxi@{7DNN}%ej#~90J z>MP%pVUWI&h{^qv3#r)WMUv_Lq0WhzTyQOvE@45^+_AArzg%#2fm_7Etq{Jk`T&H{ zs=-rwtI~L`&L|)DX@PsAEHT&W#K7}=FIO02EGHLA7#q65m@~b<5?Rvoh4v9{&dm{F zg3Epdy~xm6)6r@iBCOb2cnh~lsUR%KT%J)mQnp_z2`@1hV+spgvfLmpaFuuLTVe?G z2OAIJ#iYXY06}vqrWaiy``|me$<2;q#29qjn{r&}a(GL(c2P^H>rHg)k)V7oxq7-O zVA?_)T|3{j99?)5Yz&4Om;3SwQu2!#n~!wijpgpx(OYIHq4jiI*pNS+Xp^kU&ztTq3!L> zbP2chLbuqT%o}v+{@!#_m>92g^AB|SqO11db?L4p5p&K^^<3oxT`CLRQ-SR#y6Bgq zA#BC%3>QmLQ2$!2$qX5U?_5F#9R*q8T8^`Ibou^vue8qNHeAcfL-ugRC1jZB9&r0B zT1f+LeH$1Os^Df|Q0797t$s}QMY(#F@tB~r=MyrC{M2}?+A5Ck>B{|mz91BzkM7jK z(W!w$;i7AGy>lq5YM;cJZhBm5r@zZYm$ReGm|rjW+uLABYv3TALoPGXwS0oGAs6zJ zkXg~2{Mp4k`?7~x2m%jbgYJE;ikBUnFX##`YImur*AJvpu1igIp)2p{wo46Xx6Y*+ z7c7}j?ebL=@VeLKQb!uberWsD%Uh@rD-FTw==$h*k8R&ZM5hyXZVnVSf zJ<+Y_lZ4TY+%L5}=$?Y(La*tj{@g<}S881C547zTzj+pM!FpY6tjWqUom&V}kVp6pT&kh^qsvZI0gDSAu9bBs+;YK{cW||D{u9lG8e4FVl`~3Q4;Q`vG)G;* zQ^1;zS@w&T@b_@j?Wju@0rPXR8_R3B*L#mhti?T4s?N?CxSplMEq&@{IA=hd{i~D9 zbDhR~kmX%9r@Dj%v1BC#2N#3cOTu7B!8i1(-E6GW` zG#{B>-)Pr^kEYQc|fhJ6O~zuv)(;s3}1UOboso#^U8@Zr_44C7_| z`e=Tt6wBJeGFe{Qn(PkU`@$79HBX&=x{!AyN4JnWx_Ziw8(r>&M9ml7GwAYbx?}p; z{)!2%*q67@KXx1Z8@mNl3U2U5u-qHgqibnr+~_dG`_dK2>XDeQZiNMkm2U zf!v5Lhce*mDJ5uB2i#LQ>=RsUc~5Y$KVK2UtA9<)$x;_*P-0hfE^E4AcS1@#H`;B> zJ&Oz6w#l~PDkNhZTy{E*981r|V(XG?8-DC?OL_-adb+@IF4Dw4PZ%+Q)|rQ!D_kla zTrt{9SWt9CcEZAa`3jhcr{_V^Ot0aN&ZC92;8mC?w5fv!+!T8m5mP#EwBh%F_wEI5 zKnYy)sXTmcPvYptxkwXiT*tytiS=|buomY`*U~;bvpc#KeU6R=B0P zbbSps-IuVaa1wbI5Q~qd`79td!9}q4aJ{B8cb8LxTTcgc88Yv9cYFtTjOlWoYc#0` z3t`@|`Wh|65nGCbD^ZuATLbJN$_Cu~8Ww2MzX-aY;o1-6G%U~pn!CDCb?I=aoHrU> z=>ngxY4Qo~T%!rhdC}GgyyRj~@2@T_*gwe22m?gdEos|;dtbx?7D_MtN_2Ye;g@i+ zspHax*ec!$uJ$q_N;orJy3&QdR{aCqu^E9S%?Ykma*5F$Uq%?vwg}GbRN%C+4BtQr zBA1Pq%5-1GLSQ}gY!&wzuGJCj;ffn=;yT`eqlUV`?TxVqT&tZ6DnNsq&jJ|fLY-?g zk;5o5TINg4Rr}`L*qE?|`9;OG!aFi=6u{}ekcH&>yyq9Ycs@NAfjitW+LU92%kvD^ z$|4sQDEE_V)m&WQ_P*E;aL1%BgbF6OdcZ#(Qp3J=Q}Eev@07x7vBS4?u~naFC+6N) zvXDfSU+7&uTDmeV^$=ZfGvrtu+~|}nn8$O3-3(`mMpvS%{1)zYbZM^96r!>iV=BSl zXn0jd&OgzW!q{5zbRiwGlP=uXvPf8;f8n?Dk#2K>bab;@nc}N=lX5R3Xk@y%YvBDr zSNq%;^IW4TqE*B$q!+w0xY9IBX1EFq&;_nVvbuA0Z<}{R7Sk^F@S_H?e@Ub9l9p4j zpWSsAlDCaxsRM4Qyugj`<3bs9WiHY*9hP9FYJ`1zp+H%~DdlRwBOP3aBh>%@?~^FI z6o<@8_SuB&>{XO;=a7+%&N`f(gzN~H?Fh*xj<>V3v+k0Nv&Wt6nf<%(?+uju|jM|5iT}*ddIay;`bh?%C5fotxz(N2}R&dow=BTX=1oJ{rx^^#f z2&5t#hiYBpoR!5NKH0Y4^Pt5(e-}02GYInP^Y1Lg&!vU#cI3=SAJ#||fvkjSl4F4p z10N5cwk~{ARAz@_gT`Vc%-&$&WigL!{xqoVV}yb>JGkMGwyu85+Z?YMB0+U?W0O{K zYWSqg@ZuI+Yq+OV^X}Py<|lgWCYK|7l+ANF<{1}3UP+`F*Em5-Ww*CNcRq4jz3c4E z68j^cQ2LW+($k{$m%1Z|OT;qW2 zepyzY@e*}*ofRJX$qw?3bjwG?ImkyyN~KUsXZEIg%SJcfe+t=R5P*B$tE+=ZhgzG) zz4>OqZ>97G$KO!c5}w(#hi+*3+<&WvW^rxbgHo6YEaGtL2Qt?+U@SmfaKT%Gy*BvY zQ~foy7#SxQk>eu@uCFGbzqMdJWs3_6)j#Oua)Co1p$n?g%MAJuO zpDXm0^9=sx>8ExqPZC+@r!9XCgP&Vn*%VbzQYvFq`aCoO8$G^zNMz`ANWW{JCS?a=9d7O_2EaFo zS6F~lM~z1h(wwyz3EmI8VZ_i z)ea3J4i7wgmLcU=dKXmG%Xjg4l*(pZt+*(Bt8-de(tRnN+T#0MgfDOD- zvy>CCKoeaQ?T0H5UUp~Iov7Ty;upvP-^jevs!;RMRpw=bfzgO}0bT`PSZUsEcdDC@ z{x_Ky7%Eu)!weS`SPV;4diry}mbYB;GqGsYR$!VtEDH5paP{XOV?Q@4k++mnYI*-EwmqB2tTh7s#6tFXj#RMRZT-tzRcMrG%a77p_Rim^D-$8Xyli zXObi>BE<0#B#&ox&$|+rp4GIDZshO;-F(IVm~dEMr-r)ZkrvM?nI*X3uTj_xD_M!B zuM=5>62J#kMoG$!;DVl~LnYPG6)!-`(%CZ?2_o=iCFiGm6b5$qd&)%A%Hku{h~l*lB3GMk!glXs|Gd{W`~K zfv(%%^ka07eBt}2hZYs!hm>~!-Ti8XJiXKT{wNMh>YNw#u4W-sJr}o~3XsS83WVPd z?_SWJ@;g9mG);SyC(875PrHgQ1i54ngu_F0QIeCnQiMAz+T6>}jDJn1X6}FEH!@Ag zB_@<;m(siff|gh)^k7%TXq~rpEvsEq(?+h8lxO$MWn7F+oEv~Ls5Y%@O?tCoT=?4Qy`lI1BVy3~}v6gSKxUSl6qt|(-QJB~MFP;W{ zaa|6xfimByG(ar+l&h&VjdF6%1i;NMp7>dmLlNnuqJsLUx|#>{S>X=5T~$w#WSrlo zc}!Y3RbsNSBF=BSH}}5i1dn8Ae3YabLI;|kf2sntNdWe9SZ7;*pjahpMLQN$n~0O< z{yXvhd(hBDn}Hm(j{|b;5bw{D{8R}E44x0-WxhKW(^_r?`cH^)`6`?uM5(Qc8W09tb3&!)Hpt?XqP@_!8Q(t zy3ZA!D!1i}J&5~DvE{bPS3n;Pdb9u439AR*Ua5|;L;AEsccIwNL=+v?yD=>fF&fv7 z3Zt@6WjQ#?2(BLUqS@-}1-Ep&&i)Jb?oT03*sMs+xIG~>7dan(cTeV~a4%vOYwn7b ztqi{2M}A->#)%vmfQ5#3$M#QOzb>5)nn%|BYGIjaKfP`=ph-IR5#9Max80^sv{J;z zuTqE=Dr~38JW4rg|8LpDFj8)i61osR>DtoGT=~LxV6ez6KqL_rX-?tpb~7+D><0+&to?@YehbA1LoSMJ;%+q89~jkXSjR5p>W%%QI2c;IXP#~rTAk>U3H z-8gQy=GUDU8LGWU$gR6LwUob_3mytaN{fZGVnb(VZQbZ}9Rs91Bepm-KN2xt=!b+t zSmNM0!u4OC(tkB{^+m__spRf=Y_=; zqs5{OxY4|tU#H}OC4HKPlWYW@H1VHUh8m`&V9LzIqcXM8cEG~;`vUvDK3dox}j@biJAP$F&(&vXjn4~F=*an6goN`#CpZtI`_ z6$S&1^j3oi{CbSts$jPfMvsyHARjtAI=HyLYgOfFkgo@gvA9|}Mq~1=_%2jwfn>K6 z$nX61$ke)eQb37vMtb9FOIZ{;D%$e7z^dYneVAPGe?@oyplmhP#)hkB2I)$Rbu&B<$>~-$kIV!_xs@qwZdfk z?yI19O<$gw#*!~`Qv~5{c~1HuK3U^kzH{FDHoG?!PV}45mBSXf^0g+?aZJ~xb?;uY zS1ul1j@_KXtC`OgEE0!siCUO_D7{@gEi8VEm{3bg-xv)}5i4^@ z`tJ6k{XeCW0N_35Zz}kHN{F%fqDl;%&B%)((Z1(SP|PGdBr_-`;#|t`NhJO9fXpJF+kuoK`a~zU@8UGukPXwn`F*P+$Na|tqD$c8pI}<#wsu73G(f#88NhAzQ_SzY9?YHi92Hezw|REsb>s|JGB;yPI>na`}DDdCmE} zq5PPp$bi=sgW|2Z^Kd)EPbPo5U6-Hv0-{;FkH=MaCZXZR{V{UoNNObQ_L_%vgwwde zI1^3m3f8e0Ck7fIjj9si8R7oe8glI4YVj#$mdZMS4OzQ<)yV3TL@$YGA{G&0 zJfD^hZZ6PY#e2k5=$oxGsv1po6c@+B+`U#fZ!N5*HHjiN{HmnrsJH5-x*%!AQAgyn zEj8ePEi`u0+GHDnI3utRK%yQ5!D}8j*jg#_#RW?om)@Rz-+Mb2f~Q2sZchJ zC$um_Y|8DM%0mNRN*q~a+}(gZZX$8{A+E|8^q2f#_Z#49sO9jsu3Ojd0T$^SsDX*= zoy%~DV0_dV$rEeZ^x)Yptx&bab6tH}+`W`S;k$?}TGIUduCur3BMh*eTyayLSBdu8 z1i@t_TA7sQ(PH!?Y1_owy{=wul<0DF)h%k=r!R)9eeHd|zduCd#7o)I06FD8wOIEN z<|iAOzOX@%p{dFwbi4NcI`*^G4`84djH6^OC(&V1e`U_9XXgeNw*jyvz4qH7*}WRP zkj3#nBT7Kk6YmccTFg|bq1^FZWxn%HK%$XGDhgq0E7vZpmwxVBodH<-PwH%` zOe$`8lXa;O(yGuzK3M#%xwY`EKlr)==(&i)1J>kY2Uit2qBbwu0-`_DB=(BfE7?S0o zUGki?sM<=77RTK*2C+o>qlKxtFQOLp2E>9?9( z?!&T-3a@v^oCMq=+zVC%NtETiam;IMp84!;f$+z`U3rQEDK^=KiZ4yU9DNRK6SW@u z+JH=(X8Wk&8oUl?6JamVs2y3F^^xpF$!VQk`DE_92LfgroG^MbzOU}mZO`5aM#wMl zyg~HwO!>%3Rt3B62yPaQ|6WKmpU?Puq7_aV>#wIkuPyUA{jfGL^)|aQuC5`|>QmY7 zD4o4MWT67$+`Tjm{=LF7E*7~qGqjMAf@sZp%V{=IOw0WgKBD?U@ywf{u zLh!nONM^W`+;A6!`P5YB`PxFe3&yeH!mRU#qvaj1IbkN>?>vPwDouv_XM&qX%IJ!W z2CY8Z;dLp=*dLqgkiuz_p7QCc^Bq?Xa+)|kA?jUjcA)C|VNpRWe?pl*LJ6iOU`b>_ zq{tF+=M7L&s_mr{fE3uqu)j7$G^QDupJoPf)n&lMw}?eaQYnpQ1s>!SD&nsRwQDpa zm=_9D0iQ1-Zu~kzEX%?Su&pt4xkQt9_lCG#L8Q87sfFf)SB`3CVo%a zD0n1TzC&MJ)fdL`VR;ZycQ+!x&}+uU|CdWvZ2?q52e?R=mh%UwovCK&*HCrT0 zW0!8A^CxDzE&~EE)eiWH#_Ig*<0M@DZ!n&VZTbg17xt!bnVoIlN2VPF(_uaB<$-tK zVbRY9AC09v7J8{1PtK20$!uI&y?;J0s>}HJWbOKw(aLIr5HwVJy}?F9eZ{x@Rql7E zgR)`7i>d?Z(Yo6Tt90_XM@>w_g1uxGUv9e-6MWn{6+x~~qOxC|3J8dz)g%}qu0OPE z64Q0Q@JW9yWUU!VefW5RX!b#F{e9%_bwo+p>d1CFeDLYbY?56>aDHZI<574&iG#H- zXf&9Oedr6XwP5-=y%bEIiTp6S03g~iO?ugKvx|gN1Gp}CANzc@t;Aetb+;Hhwyp)t z{}2%E%Oc^*e%T@h@-mHLg$4`mtSE3?90 z99~o8+>v#&Q|CY?TsyTcoSlar0%oY(#Gam?e<$`sCCVej7 zfrYHN{U%TyT6QCR3d3-=yJ2M{dk59&LGSABs$N0$6d(B}J|Csa>oB9kbSp0^2I|?} zBZT{@fSRCrrxc-Ytta(%z^EZ>mJi|+;B{3Q%?~5tA1&)458R}CH8CB(Om?Ms%magLQ4LV?)=IB;8!2GGosLuWBIG?tqxR^&MuM2${4heF`r>7L=ID=bupe#J z>*%rc?X;vMU3aw$Rn*B_>BssHf#oVO%kWge34EdIgz@5o>VCI3+Yqx_QzL1%@Tm~# zTS8sNh|X}hzb*!{ym(*3N7$8(`%(*ZS0o7t7YUOuUzSn9tLcDnaBD$e{|%n) z-;Eab3hNa3Z}ZELaPA3Q&$EVU(J=F}nIvr3QA>Jq;UnM0mW{a2B~i~(G|fDPcj4HrR@oDYYh#(PT8*A9OTAAB&Bu zELeP1>r`IgtdZbL*%ixTMME@i%a=?+GZ2!mkr&Sd0H1BDvEUu3H6N?=I{7R<$mHROZO=cqo!`Rz!n-W)9Mx*8UWOh$4&7S$ZpRZKCgK$^mte;Bpd{{**xfxELhuiNu;MbiK9pGIR-PB%w}76#`g43yVvY1 z)~Q{7pNsg6?#Z0X>ZG+kovFt~o)+o(-dFv2Ucz34vYd#(s0)s$RPZK!-g(7VEXRm7 zH~Z~4!i}`=955w1j%5Fp>AN13hj#ovGB_wYjIVo(Sue7^C-sK7R>(?Z48R_ypHsqq z1fx|CC4x%IW)RWHTd2_@RT06bhljwx$u$r@wZcHI$tYk71|UcQ0-W z&iK9P*Tk2isr716*r`;>?Z5hs_=3uCTU*p zt_YG`Ss$_USgbo~J$-3AJT@=NvXva#^!~mD!(B^D>_(}>!IOH%9sIoLiGuVG+DaFm z;s>_-DoiXm$S9?MIgBfxJ!tNY%SVF8;KiqeFX*2K8NFcz zw&tN0Ce~qZb1Mbr?3qy`S2ob!H83+r*E$t`dgGoJk38iENcovM*>r%dpi_}>-KxWl z`~0wGBJT3J4p&Q&B7|5-Rxwe{3?=x?`f8;r!44fOm>mK3J#miOd;{hCZc|lIYYZxf zcp_?z*(?0{6<1%PV`Z0y5tXjxL(3UdH`FIV{UI$Xn*ZKMCpKA#S!6k{Cs2FZqw7Qm z2|0dD{?F)*g;@7~eprdE(41i6AmpzRX?y^ZblccK%&n=g+#&EiiovlAty79y5~{(U zK)A@I@rYW^ZEQD;HQDX;+Juw(rgkLm;Z^cQW)I_lee?c=iLA~v$)sQq*HlBnK2RiUTi|J~>wD_GJBfey}m3{we% zC;2~#3~>2b8kb^FO|wK~dY@1-)`5tRn-g@)rmMgstL0|;825zjt;Zzmu_sFRHG)EYMW5x6c3;*RKjt3fAjCx0CN{;4W*@A( zK#Pxa;Em*hOPmBkprh_`!uoQ+M|z*_+@*V2$4u$e6#yBAyQ8Fa^^Tx?;p+?ZMg3=g zjqjCh$pe%{Zq6kS1=MMfPb5L)Sy8>JzJl;L9l26M6I;3& zcsTGys*Rr~7#Kkv$y4}hy#mhirkDY-O#L4>Mhq)S31J)}Cx(c5AG!$)5U0cct9xRZ z&O&3f>%%jV{9oBU(TFM2zHcaqP4v+!YN|sI1Zx_(uL!&`e3P%p#jm>ZC2-6|Z;SYF zvtCf#E_Zw7e%n1w|K{J~w)0W!>w5Hu^OoCbtjK~pL-Q>L6lM2wS5H+Inca*Ak?0S~ zixIgVJyT8xk;9Jkyoy-noE_Lrlb4uRp>h{#_oN73d8W^Kd07@!i~w%xSAsblmGBK5Q|@+pBp#$va+D|DtoU#=asC+N%*A|Iwa8lhC< zn(O3M5Fj0-bdN+D4Tz!7Mhtu1Yn5-xQ;cn4E4%*zLRlCx#+liEa)&L0BEER)%Vj46 z0g($Y7t=au$$Zn;hvo4tzpg?D z8viVs!b+J<$UN)sUR%-#6War{D4=n4QO<%E5opbeJ)qVGQEnQbdL}sQxflWYmV!)e zhtHa`BH0S}el$u4^8QC&6X+XK2n9voavo@cI{lAyG$vRNX%7(7RMfuiDnk}$Y7Oy#V8@Z*!F+dL1x9^ybVKRi2}29tBg6y~rZ5;>SKjJAavlyH zjCltl%Nm`OY=f_x{6%o#Q%I+(;cmp26KLK3(2lmiCDH(!2GsLY?6*`vGfV# zd9U%zrI6NtO^le_%SAZFKyJM}VTKvp8$^8arvCEs@2!oVj&idh-(y`eD4W?mk*zta zCvrW78=b`r0({3DAw>17!DwOMl8xsbq>J*%yJwc@Ozwf(mezHiE?U3mNGyJ(%Mx!Z z4u$^*Y*Yk2{(l3Q&|YjaQ9S!-4jk4Y8^`AIs`lA_tl{XL`5gj|TxELAif2d0h+b4b z1&0v4rbyhN0Y9O1`01h|P>_*lxnKT7Ay2L{Y%QSF(rlqTlXuJfbsyU=n(gcz?=t+% zkYBl*@oc3hcPBY zpht|@oz>4m2+D=~7ZNThVK8?Yq0RN*qwk&UGlB!QAMfZH-4Nb&tg7Q?N zOW~}GIjC3#dZBt0Lsv_LMh84Fi2YI5O5Pk$;aKuM9X~l+*NU%+x-g_p4 zLtObh-T-waOk}kg%x+|bN&NTS7-L)4J<9A9cV)HQ2-Cd(c5}>$$6p)60Fb5E?Niob zh9tXcnYrAgp@ntGek<0mx-yFnyfo{y7_8lR5`sPZ^3UZTwu8h_A_^E; z{yt(zR5js#_oLy%a@e2QOwvlH^5up`?GjysvArc}S0S!IykR<@%qLbtSx_-p+dTLP z8!65x{c&G={*LQy5oy326s2DPwZRV(-X*;nloK{%82H*C9V=!G@(S6JBBXuV7+F?9 z9?zex*QRlC_V!ki%woSM!1q(D_HUWSN&Eee0g00!O&rQ_KeVi#_0Jr(`EBCW-Z7ah zb)()A1h{bXPohgN1)To910ULQ!q&@%5I!SHHX(7FV_iwx31C0qv1^N^oySs!Uf918 z^>L$RNU5c4OWdJG^^1CIQP(qm_m2Ncn?Qs!7km1^XD=SZoOCoM3k^*5h7o&;!2QkL zvg21WlP{@VOGqWH(3f1TOLR9m1ZZIv0_)_wg{;HDGdJ!NN?2F-rh#0Ogc449_$~GC zTC-U5#4^Uk$3)qr#c%k9CszoKX8K+ZP2?4VBA%Fq02|UnJ%4m2+PO;Oh!HLmeIS27 zHnA`_PwWZ1(7R$5=^P<;*hX+9@}T$0?5N@5I#~(6v0U3@^-HeJV>||Ij_9d7 zsgDUvbFx)xM~p6Y4~{45kM9B*XXR$oHGC;2Mu7YwRMZ;Ap(G`Fa=c#Opy=9?>SozM zX&R~Dt3D35u#}YKyTLAo*oY#KRL#!K5!{@;s+FXL>KVMxD?Juz*J4+dGrj+HdaTx6 zC}+s6ft|Mp+(iay+Vha;{uuN7m0_StE5$B$Lg)##xS~*NTc-Gq(qcKEA~tx636i<< zBT%OAxeAx{`$)1wSVCAp%tphlB0X)X()aX0llwW~bxXer8Q^mNc4w_`=;vI~PGPp0 zn2W~HI~7ZMFB1Zkja0v5xo1YZquZk*>3SAqY0{^r9K|C1$+ugcnpp&6RFdj5UG`Z|(^E ztX+Zj9lXAWmFQ2FZp+8Jwy4Q?y-}MiMtJ{@>CE+H$(E=Ws9MjMJgzUVmL$EVKK||V_mTL|pb2#5MW49Qqj!&qI7S3--wF{R zd2@AW^nvq($dM6#y!OAt=iB>F`|zvpTM%IJ{f02iGABetsfGi&YPNWa$oWqR2q&7~qRF$J4kgv;^0MyKkEbI<+WT zWyZrpv@wkVbKCu7YQ0B7`9r$!Y0}pxtyxYvsUmF zRvcD?#4b9~$VLw-~ox=M9J)o6#)M@Ij-S>e%erxUGSy-mGPxV2KpaYT zX9-{w9=NJ7;QG31IJZLHa=DJBo{l;;{4U~{k!bengp%-ey@m?1Mvc40-d%9&(N%F% zHb_J6f5QvqSzZx+UZs4}Rl!&G0xF+b>}&Fzi9v%oj>BcyilTCc;UOb~Vp$RK@R*4x zDqjZFq43{qDn-b-b(70{^6Y5*A|#V}R?pchf~DD~TB}!-*7`n$#$-3XYo*Jmt|(Oe ztM+g>Wu{gsw zJ?`O~=K&p(C1+o;w`p@bA=^6k0YeRSxLUAS0 z%A?8E)dL4Q_TG+od_(<36u{#&IH4pldUJ4&SFp-**q`P8m zWK}1gy|{XkvB}E!$#Q2ut1Oz>*HTb;(vC1V+?8%%5_ zaRVl)j)zzoD$5JieBS)Nl$p`NN|MkqN=9VzCt|eB86g^9Neg43CB*UKt%_^)wf?7l z8Ey{o8*CuaUJ*4%%j~bL)evv0cJ<#3u*Ohd;q~?-lI#mkZOk2Gxue?BLOJ%`v2Kz^ zk;(|UTWnQ2<|PiHwCJ`V+Ht`9dYM8>XkihhV^oBH*9#nYcG5Q8JdRm^eR|j=jW0L4 zqG3WDdv8KQR4Qw5ho6OQBAI`amC#=nUR~#6;~GtutWhEi`z(e84vXu*EbE_zKdWKI z?PZ>V5E7q=#N^g7lTG<>7k9Ol(qWz*PD1s~ueA%AJBkf|wjy3{P(A+WFN}Sn{m>?RU+tZDC-nm8NqXUX*$IR$BSvxmgqNi2N|*t==OwvWx$_ z11~Y!DerGjOMa%6K1X80T7UqdSmAdNLA2sIf$4zTaPfUlrs5!HZ_h`;v~j}6l6KLA z3Od04>`cwPQxVXxw9a0DjulLt2~JAoR4iZhM7!F^h+!c05JTA$I==H-=!O)md#2aG zJR5>e*_2PR?_I^}v;dDFGozer=?d*~HNsc$Al>l`uvwXmC}OInsCa6P#0eZTo;0N$ zk0=7mGO#e@;gfL_X^>xRq>-G$b>)Nu{2PZJbl@ zdr$)>fGMSJm;JeSXUPe8jRLa%>%)=fOjC{I^jpV>`2)H4G!&bBD#ipjKWFMiBKvx! zyY*Zh+Q572C~B?icM+F)8R86QhwDb}n`fUT>h?!11t%o}{@TRGC@S5P%zyT!4WY#%F*`$h=)dKHQU@6(TGYC|8r1X|IhE!vSf z68J)JMryoMVpGuQ;1-d=(O;m+5HY}eQ{8oMItM-^KjH-Zc{pA2aF_e1wyB-JzTdZp z|0L{VP!8OFrL`Q|!|Pu%Gng2N{hFv5{NU$8qkivlt%E9;yY9_ck=n^h_1t@86#wDt zD$OIthkbMH`csH!&c|$~ysLvk8ho@(Y(dIZKRGy3}q^Gb~`m$~s_MDrobLb+zoz!OSwhAXL~{^q-t zYg+tdGum3uh8D+vw*5BMH|p!6J>;S4u=}H?Z0#;yl!@1E(}d3lasGR5mqj>qMBL7Z zbPMbXtd9zNq1~ADd1y*4ctPHk*|_xVkkeR(W) zt^r5|d%E`g=31MN*0y=+Lyw@D6FnK#?rBSy%Mapz zBz*5JZl>2+_CM{nPa6tZA>VqKGuQbuOS}7}5uV?%D%&@oYj2h0nw+(}&aDDdb7`Y9 zTX~_J{J8MM-zE6#bOXcW3p+Bcel6~bI=ngf%hcaE_V)8_I^jwM?9GK+{AE?nsh3>W zo>(~8zW+(p{h%9fidDbqZua~7I`Z&xoOM-MD9NPm(`@<=&%H`eqm>g^A01s46inUN z{@KUhGUM@X<&fEeN?D!C0-e^<*O%(unlUo|;S&laHrv$nLl@=0w8vjEQE6$q{jDUm z{od*)f#^V?E@sT$GAYTr;8SY7Y@d1m{rJhi3#UF!D3z_07WA;66==&C-D=K6+J*BR zj?MV}_I8>dHb37I7O3nfqjGAy?BPD2p_A>U7Zs1po}QMQvUr&O!=>TG_s^l7NVRT< zbAwN7{02Jx);5Q}ISX5oD{#FT`Y9ndLYifIANZSeQ^FD*=5)W#@~YMEkgtJdt&yLj zADrKQY;@1i<*A(e13QDa-dvqpurla;HD_1h{?w4CeYv0Day|M+Zn@V?>|mZ;h>?Q! z)&=eN)k`~V^@Du>_-q^?9&a8yK5y#cu#wPfs&mgY;J>v)_w01u{^l=+{fJb{mt!l@ zqRYMkwG=-crS_kXG%UYS&b+^|DN7Q(jWiIHica+ z?M+HCh>WDSEE6_Xf6XDR$xZgm0W9eU?W$p?WV$9^`i4<1eR*v$5Q`}p{9w-Zyw!hb zM91m+n}CN|HN7MmQ+lCa$&!<)eRuMH`(j;BnLNUI-%Q{9=i^ZUjx1_(!@Z}>mUlPa z+bHY}BbqReW8!E}sw=nkW%&E^iHAkaGA{p${~>SWp5<>nD5IAh z*qr{KPjMfAiPug~IwSV_PW;ENSyuc~d!AnH&7GNfPwA~}i<9qO=9mtBTiUz4jMU70 z`rJWFc5DyTmG{0qbY!niSWCs9XQ=KGr$ND%mf@cNnm*{b#wYcpV%z5f3%~kKMKw$` zcv?M(7K{~eJ|3v{8(^uRj%i7!j~mKGR`B;U=D0_ojarPWtswI+hAx;UOXk(Q#Vie- zbnW`XlaqR)pw$rOl-D9|**xy_Zd^M!v^GAfy&)1U$A^p;HEPP$-VaQ1QXKy__N*&( zM@zqjN$IvKg1XG;XOW}M+J0Ln6qd&jWh+`{d+`yMrRNg$&Y!=T^YaE<>QQJ-ZH-~x zeP-w1kd02xQ9hENrrxNIJaAS|Dt&t0UB$Gu^-0cz-3!^yuRJ-|?ctkFZ*NYi`cPV> z)J!qkpG+t4QwmLXym+TU`kv!{yu!EkYWR9byQcHKV2t?b zT_Wp7=mWK+v&ufwYRRa*etmCVn0mdMtI0W`c}@3)oSn>Lb^9@0mA$+pk@u-nIt?T4 z4}#x3NxH5b>A5X9_~uKq;JUV)K{w-H@%#n1hpvYc!aVT1k&#JCnK`T$pw)dNf74pm zi(fakywfQj8qEFDal1`Qr+-enrvB&St$`c+8lMDRdwc_6O^4N&^h=sQ-ZK&P?&Jz* zS!Mogy~A(KWyQ3|nwxDLkji$iX=_7Xd*8b3 zvva~M_|%!xr(_?vVdZ>NU*=4eEy`P7ENj|s6nsS`a&hs(g|5wJ%J`)I=BixltHbTb zo;@Q)JtVmi?nWwl%&&d5Tv>^0xS1RnQ)yH?__+5)K;5KxL8tcGOj5~@0uFcH#_wqJ z(#2hgSKMYV-(^j_tG#&e`u1Perq@mfhuzAKe#y4pdH_CgGF>Tpeo}6byj4o8$d2xE>;M9eUOrlJn&1dmlMkD9HttBSb@NY+%wwQizo4(#@ISdT$mVp}^+8?br%dUvfw7ef6>`0^398p_-!$Q0 z^Y_nqe0cLf+3T(@jg&>!G-+FNBf%zlQs>W`M62mB4a-gzH2l2TJkIjjJk8@^!b(cu zgk1BqhG9u%ztWLrTju+rfeDSC7vs;u9!0PDetP63;a6Y`mfGa3{iNdAzO&0Ks}fft zLzd6XX0wPdR-($|AC82%#^o*DNeXdFxKSNaQl*{Kk{R@DGIopTk6{b_z12ZAVa?hO zpAUu@d8hrLc=Ytt)HOE<&g?werpdQxN!RQ-XP-Q;hUsA;U$n=^dZd@gNzL2I=rFh1 zyKZSIc7X>ZJ~ktp30@$+g#p?;V(Ja36kd z5w4uc@VjdCEg*Nvt~VNAevPQu(^utAbjv&)oTXXp_5FNwv=err;O>fP3-6-ArurBy zg?p0{!)@bJPxfxrvl-lRK8Q`N2z8wPxCBSG1S(N4|HPwd{`$k!_2D zFKReFTh#NN_2_wSMojcxKH%E0_|j`_$~@~6unR%3G?-?I?{bFbR*K@@il7l$M;D$+ zel1>Zj7fBcKCKk}@2!X-0%^5in(PLzF2P0;VqxMxouMLHIAzq1!&|*A5pS&*+9=kl z<4c+mY6c>i3LG0rkuq!HrjBttYNxDKK2bh`Xr*K4BJK+fbGha;bMId zS@t#~R{sC#izSyIH%X^|shUf;qNCHK~(jcZ-A;wu!P>EHhdqs2X7^0edk!*^Hy+KU5LX3-~pbBfwk5(4f zVA!{4Ca4pkxeQSps*>>`2#2j_h>}s7)s%2P#}}np(`&WV;C-JWLN^u<)nzJ; zFF=U;$IiyX2|3eh_C*I%tN(P4+;{?G=r4uPd?5Un>Nq|7qDd@MJ_3Rsw|z?ZmF z(aadMw94ldoS;A^XaTsg@=UB4tBU;uhPE$iPATaSzAsMGY-2QV!S+QRFve?D9Q-E? ztw)8S!C<-AP&Hj&i5dKuV-enG8BRdTR)PsJY!R}Rdm2OvuxkI(%OAP5BOHPXlBrz& zc4~_PuSmA?FHC0rwY-$*tbvO(g4z^ca@c1qJ)Y^ZMAbxV2R+3>ibH|7)&VHWnN)otui@b=~mg^Lh+ zj8joYrghOitA}B6aDrdXk$WeSJ2Y_4e_`l{+mJY2UEcw(pF2V!^qqUCh#d|mAt-g6 zM~>r32wz7Pw+&n+TDZR3%{ww6SbaDFVoJ635v~#i7_}O>#0hW9Qp5H0}h5 z8HE2N8_QEq$Bw`GnrK}l69Xr_@YvX0y&t6i5sY!url=RpYQ|)(XfQce1E)888-}*M zsdOU}7!S`#<8IzWa**R`7+frB`P z>>zXwj9~(%l#2!Llhj;5H7TyiX`YNoj6V2Kp)L(h$aE_D2nLf8zzOFdaKiU7g$*&* zddYBt2jp(&z8`N_Exht| z0dA>xuos5jScpY=Lx@Hw-lj{SAuZO^@O&wDDblo$nyaUQvjOQhOoA~)WGkn%sL78& z4}y2Z3$bMy>DV%XY$f;{aR6qV1DVAK$i{fvN)hWfXW9FMixPx5|AF){9lzq6yU-%n z;ba3!l?Ovh6=Hd#vX%1cxOKU2H~Y`uEd!0(Yq9AB;l^QVvf&{J7*d6r>;O9MB!qYu zhQ@jFII@+cJ2xB$zxTl>zQ|V665QOp&lq6K9*&SZ%rRjNQy}L0ccAtM<(AJ|DGORr zYpxJFem1(jNRmC1%GUsRd}}VM4C;%!v6K8u)OJ-X0zmCzTL7UBfSNRU7PHWK?~@V@ z10!|nr={mCyBwdnJD{Y8?J|=paPrjnFSIBChEiJNP;ss|$( zL0R`cjr@qgPz95UcB{(l<}7dip2q?XedLwm0royChy)jvz1$KB@TAE@o7lzTJIH!& zsTWlwNS9;;-HuSQ-e%VGTrM38gz5{reYzeNsI7@`QA=`cpYmw>6E`TUdPRY3qlX97 z^hO!m*%?q(vVR1?+d>9osk3MJZ4a)LB?~5H*dI5rrbNkbFsDo)UceZcGf-vsLSrxp zDc1f&?79uCW-2CW9Ri4S11tJe2TF)}18aRNQ2-Sa%M7FM4+QwFk%}g4#1`_93%G>o zIScJ901PAngBx)eeK;^a5*|y#z@=F7hlDYs0{&BBH;VyBA3|A!-p)DOCT=v%%odqO zVs>6P5G0@C@M|KNq7c^d46@hV`H$=TryBc~zN^ENtxX4`b*`htG=9|R3K%6#GMPVJ z#W9FE3goT3m|&Q((l!|^L0%}4f6!EHwHG7ON*s_x{BiwK^UD2BQ?pYL?=|>t5fn;s zGzkeRTffO@>V*=JrEe-VuMQ)itYe>RU{TD0U#_C;Cdyu;D9j(%zF)5U1p<`VS0oxy z+%(yA=WtjMZ~B0sTRc&eL|s)NZ_@{v7C>2FR5^4`%sM}gv(6xS;$;6#pl9L*lW$D6 z!boPDg{<~wijcsFZ+{B9?RN_(BEV5;P#57UFuagg3t)Cu9Zh4lh>(z6VkfAEmov^D zLKd6FZ^9sUgMT+T02o*vH3#`TeZFruyCa#_2O+(aAdAv4reZ)mN^B|MKafP77^dK& zAesLgp}73!g5*a#fJdu!MZP`m{PPS4<@Xb0`lvaK)CVK|`0^@{v5TF*pWPA1Y=Mw` zWZo(1pkaJ64fN20H9tL;IkvdzpZ;Jm*a>X8v7kdH&v1lpOofv~U6GID zKnf&MHYk$CK&Vj&IOvxtMsbm)(5?)f9&tEJJssfbjZP-Wu=YoE(1qA3w*-_CDmI3B z>0*)m4AB5MXhhPhrJJ^pcZ5#S%8pb9TR8if&gq0 zi+`(%B#0duv|M1eUceZ|sW`D^j^nr3Av{s!uV>WCHFvP_`G$gS4wYFXN9vt}knDdi z7HYY~GVcfBzHcD^s?A3JY0{)I?F7l2(*Ogk4fjk$42*O{o$L>KMw0cxXWN)K2`bD2 zlwJV^gs3H;77+*D^L6uMRIM4(71}7G#Nv7LCE^OsZFk+OzRa~u=wcEU6l;p+`10S;ygS}0$Tyt8I@7Y7CEoeLQ31CDNYKUqfGtM!z_ zrE}t#N)n{7-2%o`*&GxDW8-JPiBUwk#NTJFFG2?mM$wfT-rYIKKht$Y0EBql4nfh@ ziXaJ+g%u|T0Ufbk*(s?Iu%indes;H#+4u;O79%V=etXGgd3pBCd)_Gt(rQ5bQDHp` zTmOIqn+99YPxuvl?v#2LSfy%-Frm8Sz`!s=Jp#>RtfQH^|K2RZ5UygbWlW+n5K9iP|BgL|B)oUz4(smj8>)9Y(U{L&eWtJRt(8eUP$L z0eJ#dum71Mmr5=jp8ux$ixt3w8uZ3t zuE9w|9D1At={aa8xILH#IHiTNQx>!}$T&%yq@&GU*MIc{md2F(0-<3Zc8iOVkUPj) z+FJdRWN<9c!~RLEvMv}{;=*3MC=K|Qi?CE_%xE<+Y4XaY4!$La_&Wsj@Z}h=RUp7J z-%1XK39CZfo(+MKOubKU0SqQHXkh<5X~W%a%bt1IdKVn-{)qY7xp9bP2fgNQLc?J@ zgyF;d%5yGwT>f1ys$>JJT$1&7zb!&s*jzni$&nqM+7eLGIsAIuL+oO(iB^^FGH|fh z+Qp_+$EQ3AGUvv#z<&6xZeSCf^})-|e=8514>EQL9~&-cGw?XGm5onZpJN8~JHR83 z2<$zuAl$v5CBm%EX{pFqQ(B zXuRyTfeZ()&^+Yx#LSO+Tp~DY&}2z*7J(fVfKP*w=$Dv4=nWh0V#h&acHOvz4uYi( zp7gXL6ajj<2&s1Y`W73Yaiz-sr#zd1X_~JuebT|N%U0{q@u$1=q;4d=mKAh|Ym$$m zSUq@lNP-0ESUiTx45u+ye8!Lh#*tA905onOH&QVgK&TpEU^R;V9nX{!Oh(@>gpwi= zK;uM!80!Oq1o@wzMUvm_rq}AyHqAWaPNLD zy@R+i6BN(PBC_$LlNEyQvv{0_}K;ncKOOiE%6rf*-vIaK^&{Y6moHf_dFU&%C z3CGsIgbPJU;|>8(q6|A*JBPL7w# zq*c){u~cRh!k&h4Kp;R-Um#eYbCLV=kw|tFhJB(!gme-C&=+jDZ)<@@ZvlGay7WT% zS3r;l*dhNI9!WiVOO(v}eA1E%3RT`g=WQe{IdsQ5ArxULoON88RHn5YtC~ZUW!Is} zw<~~gzg*-W;p5X{B&kBAeHRj-|LWk+_>3}udVvvudSu3-hsM&F+Crwti5*06ysC?d zt#e6W%Yavu3fmTxrTh*Sphp0WBgZ9a%&3|eZL*ljqf$Zitp7Q{_uqPTG15G#Ee<>z zf%Jg{T5*U&_Z-pdXm;LZQ~|RW@b_Zi!t8@NL?pXehJC_gLBvBa`CEW?6lK|Oh#2ke z!LfTw1!WwfHmPAch%W++cb5wKnmRaikNcf(7k9I512015awN&|WGUa{Qi5)G2$_CX zth5OoV3FaT=dKjMdkHTNX6~!9!83nwAKPIRPyFgh_H%8m`T;ej3Dy{&;_pPb&OlfKkp@9AkMl${fMOubSL%h@jFcFfcw7a{b?hcRLzN# zpQ@}K`4}%=A2Y&^=Ysac|4K^^CmbsIh(e`SMjYYEqVc$e?UI$>Yf=k8E+Fq#L>%G` zXcajA3Q3N0Y0td5`~BMw3ImIG@;|;t29-zb<6YB|v8Q*5_1eWdk`a8oosu@sU8}(M z7e2WV^656Rt5mAbT~CdtqBU%N$C9!nU7E(QQjoy6rhbe>R+WTQESQ-dJK9?r@&=++ z^1WvB^^YFNm&Mj7;sNQDVryBV*%48jtYG%lMWrpi)dMMo1LlP2O~vnw|D!TkjSMV5 zp|~GHe7up6UbyT+oy3bVqBiym#27^oK3#o{P((S6_Y`AbP?h_R%?g)M{75roYwNyF>olDZ2O z5aP{pJatm565I&A(=k~>2giF@bpt^_ Scheduled vergabe-app-579448bf49-2fsn2 Successfully assigned vergabe-sizing-20261021/vergabe-app-579448bf49-2fsn2 to 239.62.205.92.host.secureserver.net + Scheduled vergabe-db-7c4bffdd48-w4jz4 Successfully assigned vergabe-sizing-20261021/vergabe-db-7c4bffdd48-w4jz4 to 239.62.205.92.host.secureserver.net + Scheduled synthetic-driver Successfully assigned vergabe-sizing-20261021/synthetic-driver to 239.62.205.92.host.secureserver.net + Scheduled vergabe-app-579448bf49-plp4j Successfully assigned vergabe-sizing-20261021/vergabe-app-579448bf49-plp4j to 239.62.205.92.host.secureserver.net +2026-10-21T06:26:14Z ScalingReplicaSet vergabe-db Scaled up replica set vergabe-db-7c4bffdd48 from 0 to 1 +2026-10-21T06:26:14Z SuccessfulCreate vergabe-db-7c4bffdd48 Created pod: vergabe-db-7c4bffdd48-w4jz4 +2026-10-21T06:26:14Z WaitForFirstConsumer bench-media waiting for first consumer to be created before binding +2026-10-21T06:26:16Z SuccessfulCreate vergabe-app-579448bf49 Created pod: vergabe-app-579448bf49-2fsn2 +2026-10-21T06:26:16Z ScalingReplicaSet vergabe-app Scaled up replica set vergabe-app-579448bf49 from 0 to 1 +2026-10-21T06:26:16Z Pulled vergabe-db-7c4bffdd48-w4jz4 Container image "docker.io/library/postgres@sha256:4b7183ac05f8ef417db21fd72d71047a4238340c261d3cc3ddb6d579ab5071ae" already present on machine and can be accessed by the pod +2026-10-21T06:26:16Z Created vergabe-db-7c4bffdd48-w4jz4 Container created +2026-10-21T06:26:16Z Provisioning bench-media External provisioner is provisioning volume for claim "vergabe-sizing-20261021/bench-media" +2026-10-21T06:26:16Z Started vergabe-db-7c4bffdd48-w4jz4 Container started +2026-10-21T06:26:19Z Unhealthy vergabe-db-7c4bffdd48-w4jz4 Readiness probe failed: /var/run/postgresql:5432 - no response + +2026-10-21T06:26:21Z Created synthetic-driver Container created +2026-10-21T06:26:21Z ExternalProvisioning bench-media Waiting for a volume to be created either by the external provisioner 'rancher.io/local-path' or manually by the system administrator. If volume creation is delayed, please verify that the provisioner is running and correctly registered. +2026-10-21T06:26:21Z Pulled synthetic-driver Container image "forgejo.coulomb.social/coulomb/vergabe-teilnahme@sha256:a26444f59c259698159c69ccb96f73dc648a261ece4c86bb2037a9d977870d91" already present on machine and can be accessed by the pod +2026-10-21T06:26:22Z Started synthetic-driver Container started +2026-10-21T06:26:25Z ProvisioningSucceeded bench-media Successfully provisioned volume pvc-a23ccb84-ecd8-47af-9832-6d8b09c48e38 +2026-10-21T06:26:26Z Started vergabe-app-579448bf49-2fsn2 Container started +2026-10-21T06:26:26Z Created vergabe-app-579448bf49-2fsn2 Container created +2026-10-21T06:26:26Z Pulled vergabe-app-579448bf49-2fsn2 Container image "forgejo.coulomb.social/coulomb/vergabe-teilnahme@sha256:a26444f59c259698159c69ccb96f73dc648a261ece4c86bb2037a9d977870d91" already present on machine and can be accessed by the pod +2026-10-21T06:26:32Z Started vergabe-app-579448bf49-2fsn2 Container started +2026-10-21T06:26:32Z Created vergabe-app-579448bf49-2fsn2 Container created +2026-10-21T06:26:32Z Pulled vergabe-app-579448bf49-2fsn2 Container image "forgejo.coulomb.social/coulomb/vergabe-teilnahme@sha256:a26444f59c259698159c69ccb96f73dc648a261ece4c86bb2037a9d977870d91" already present on machine and can be accessed by the pod +2026-10-21T06:32:03Z SuccessfulCreate vergabe-app-579448bf49 Created pod: vergabe-app-579448bf49-plp4j +2026-10-21T06:32:03Z Killing vergabe-app-579448bf49-2fsn2 Stopping container app +2026-10-21T06:32:04Z Started vergabe-app-579448bf49-plp4j Container started +2026-10-21T06:32:04Z Created vergabe-app-579448bf49-plp4j Container created +2026-10-21T06:32:04Z Pulled vergabe-app-579448bf49-plp4j Container image "forgejo.coulomb.social/coulomb/vergabe-teilnahme@sha256:a26444f59c259698159c69ccb96f73dc648a261ece4c86bb2037a9d977870d91" already present on machine and can be accessed by the pod +2026-10-21T06:32:04Z Unhealthy vergabe-app-579448bf49-2fsn2 Readiness probe failed: Get "http://10.42.0.96:8000/health/": dial tcp 10.42.0.96:8000: connect: connection refused +2026-10-21T06:32:09Z Started vergabe-app-579448bf49-plp4j Container started +2026-10-21T06:32:09Z Created vergabe-app-579448bf49-plp4j Container created +2026-10-21T06:32:09Z Pulled vergabe-app-579448bf49-plp4j Container image "forgejo.coulomb.social/coulomb/vergabe-teilnahme@sha256:a26444f59c259698159c69ccb96f73dc648a261ece4c86bb2037a9d977870d91" already present on machine and can be accessed by the pod diff --git a/docs/evidence/2026-10-21-vergabe-target-replay/raw/pod-provenance.txt b/docs/evidence/2026-10-21-vergabe-target-replay/raw/pod-provenance.txt new file mode 100644 index 0000000..0ca72f5 --- /dev/null +++ b/docs/evidence/2026-10-21-vergabe-target-replay/raw/pod-provenance.txt @@ -0,0 +1,4 @@ +synthetic-driver driver forgejo.coulomb.social/coulomb/vergabe-teilnahme@sha256:a26444f59c259698159c69ccb96f73dc648a261ece4c86bb2037a9d977870d91 0 {} ['running'] +vergabe-app-579448bf49-plp4j migrate forgejo.coulomb.social/coulomb/vergabe-teilnahme@sha256:a26444f59c259698159c69ccb96f73dc648a261ece4c86bb2037a9d977870d91 0 {} ['terminated'] +vergabe-app-579448bf49-plp4j app forgejo.coulomb.social/coulomb/vergabe-teilnahme@sha256:a26444f59c259698159c69ccb96f73dc648a261ece4c86bb2037a9d977870d91 0 {} ['running'] +vergabe-db-7c4bffdd48-w4jz4 postgres docker.io/library/postgres@sha256:4b7183ac05f8ef417db21fd72d71047a4238340c261d3cc3ddb6d579ab5071ae 0 {} ['running'] diff --git a/docs/evidence/2026-10-21-vergabe-target-replay/raw/prometheus-baseline.json.gz b/docs/evidence/2026-10-21-vergabe-target-replay/raw/prometheus-baseline.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..f246eec9fcefcbbeb0346e4a260847b17d0d1710 GIT binary patch literal 7943 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%0D9Oc3iuA+1qlVBfO(5fIuKG^M+ye^jVRb=0|knP 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 literal 0 HcmV?d00001 diff --git a/docs/evidence/2026-10-21-vergabe-target-replay/raw/prometheus-workload.json.gz b/docs/evidence/2026-10-21-vergabe-target-replay/raw/prometheus-workload.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..c589397d6a0e60e58eb2254cbbe8c3dba68973db GIT binary patch literal 10883 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 FzW`vhrdt32 literal 0 HcmV?d00001 diff --git a/docs/evidence/2026-10-21-vergabe-target-replay/raw/result.json.gz b/docs/evidence/2026-10-21-vergabe-target-replay/raw/result.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..7128c41085cca2445e1d3af7a5b8a72d2e779014 GIT binary patch literal 58759 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 Y@8vGHTi1(U{MRr38$Dg&iwYeN0PVd#g#Z8m literal 0 HcmV?d00001 diff --git a/docs/evidence/2026-10-21-vergabe-target-replay/raw/teardown.txt b/docs/evidence/2026-10-21-vergabe-target-replay/raw/teardown.txt new file mode 100644 index 0000000..85aa06d --- /dev/null +++ b/docs/evidence/2026-10-21-vergabe-target-replay/raw/teardown.txt @@ -0,0 +1,7 @@ +delete_requested 2026-10-21T06:35:25Z +namespace "vergabe-sizing-20261021" deleted +delete_returned 2026-10-21T06:36:18Z +Error from server (NotFound): namespaces "vergabe-sizing-20261021" not found +Error from server (NotFound): persistentvolumes "pvc-a23ccb84-ecd8-47af-9832-6d8b09c48e38" not found +0 +live generation=4 image=forgejo.coulomb.social/coulomb/vergabe-teilnahme@sha256:a26444f59c259698159c69ccb96f73dc648a261ece4c86bb2037a9d977870d91 ready=1 diff --git a/docs/evidence/2026-10-21-vergabe-target-replay/raw/windows.txt b/docs/evidence/2026-10-21-vergabe-target-replay/raw/windows.txt new file mode 100644 index 0000000..8e3d24d --- /dev/null +++ b/docs/evidence/2026-10-21-vergabe-target-replay/raw/windows.txt @@ -0,0 +1,4 @@ +baseline_start 2026-10-21T06:27:14.447540583Z +driver_start 2026-10-21T06:29:26.175778371Z +driver_exit 0 2026-10-21T06:33:22.885983171Z +post_end 2026-10-21T06:34:28.753426744Z diff --git a/workplans/RAPPS-WP-0014-vergabe-invited-pilot.md b/workplans/RAPPS-WP-0014-vergabe-invited-pilot.md index d1cfe51..af15267 100644 --- a/workplans/RAPPS-WP-0014-vergabe-invited-pilot.md +++ b/workplans/RAPPS-WP-0014-vergabe-invited-pilot.md @@ -9,7 +9,7 @@ flavor: implementation owner: the-custodian topic_slug: railiance created: "2026-09-11" -updated: "2026-10-19" +updated: "2026-10-21" related: [VERGABE-WP-0019, VERGABE-WP-0018, HFACT-WP-0001, CUST-WP-0071] state_hub_workstream_id: "c7fdaa7e-cab8-5d1d-86c2-f1aad7927c57" --- @@ -252,7 +252,7 @@ all remaining work; no task or workplan was opened. ```task id: RAPPS-WP-0014-T04 -status: todo +status: done priority: high assignee: railiance-apps state_hub_task_id: "c53fcaa9-859c-5b37-a17b-0a4cd2fb0e26" @@ -293,3 +293,18 @@ coverage/gaps and a bounded recommendation to `RESOURCE-WP-0008-T02` for headroo and allocation review and `CUST-WP-0071-T02` for sizing acceptance. T03 remains unchanged: operator/customer recovery and human acceptance are not inferred from these synthetic results. + +2026-10-21 result: isolated replay run on railiance01 under the founder's +2026-10-21 decision, in throwaway namespace `vergabe-sizing-20261021` (deleted +06:36:18Z, PV removed; live deployment unchanged). Deployed digest +`sha256:a26444f5…` (source `c067993`, chart 0.2.1, 3 Gunicorn workers, +60m/256Mi request, 1000m/1Gi limit) against a fresh PostgreSQL 16 pod, phased +profile with in-cluster driver. 3675 ordinary requests p95 0.192s and 814 +transfers p95 0.076s, zero errors, restart ready in 19s. App CPU 775m mean under +load, 36% of CFS periods throttled (66% in burst), memory peak 483 MiB; isolated +DB +~320m mean; node free CPU 1.2 cores mean, ~150m at p5 (largely self-induced). +Recommendation: keep 1000m/1Gi limits and the 60m request for the two-user pilot; +consider 512Mi memory request at allocation review. Gaps: synthetic settings (no +SSO/ingress/TLS), standalone DB rather than shared `apps-pg`, synthetic closed +loop, one run at a quiet hour. Evidence and revisit triggers: +`docs/evidence/2026-10-21-vergabe-target-replay/README.md`. T03 is unchanged. From 10465cb39c80152d8f710bbba20725964da06d16 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 21 Oct 2026 08:48:25 +0200 Subject: [PATCH 57/87] RAPPS-WP-0018: wave-2 resource right-sizing receiving workplan Receiving workplan for the 2026-10-21 node allocation review (RESOURCE-WP-0008-T03 / CUST-WP-0071-T03). Wave-2 apply is human-gated; no manifest changes. WORK-RECORDS.md regenerated from staged sources. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 8340@bnt-lap001 Assistant-Session: 2e192b3d-b3e8-4403-94d4-d55a2c8b476c --- WORK-RECORDS.md | 2 + ...PPS-WP-0018-wave2-resource-right-sizing.md | 56 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index f7c5fa8..9fabd1f 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -15,6 +15,7 @@ | workplan | RAPPS-WP-0015 | active | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | workplan | RAPPS-WP-0016 | active | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | workplan | RAPPS-WP-0017 | finished | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | +| workplan | RAPPS-WP-0018 | ready | — | workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md | | task | RAPPS-WP-0001-T01 | done | — | workplans/RAPPS-WP-0001-cnpg-backup-scheduledbackup-coverage.md | | task | RAPPS-WP-0001-T02 | done | — | workplans/RAPPS-WP-0001-cnpg-backup-scheduledbackup-coverage.md | | task | RAPPS-WP-0001-T03 | done | — | workplans/RAPPS-WP-0001-cnpg-backup-scheduledbackup-coverage.md | @@ -51,3 +52,4 @@ | task | RAPPS-WP-0017-T01 | done | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | | task | RAPPS-WP-0017-T02 | done | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | | task | RAPPS-WP-0017-T03 | done | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | +| task | RAPPS-WP-0018-T01 | wait | — | workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md | diff --git a/workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md b/workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md new file mode 100644 index 0000000..7c0d571 --- /dev/null +++ b/workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md @@ -0,0 +1,56 @@ +--- +id: RAPPS-WP-0018 +type: workplan +title: "Wave-2 resource right-sizing from the 2026-10-21 allocation review" +domain: financials +repo: railiance-apps +status: ready +flavor: implementation +owner: claude +topic_slug: railiance +created: "2026-10-21" +updated: "2026-10-21" +related: + - CUST-WP-0071 + - RESOURCE-WP-0008 +--- + +# RAPPS-WP-0018 — Wave-2 resource right-sizing from the 2026-10-21 allocation review + +Receiving workplan in `railiance-apps` for the Vergabe memory request (replay-backed) and the latent Inter-Hub request. + +Origin: the 2026-10-21 node allocation review (`RESOURCE-WP-0008-T03`, feeding `CUST-WP-0071-T03`). Per the founder's 2026-10-21 delivery interview (`the-custodian/docs/decisions/2026-10-21-cust-wp-0071-delivery-interview.md`) the small adjustments form wave 2: one receiving task per owner repo, applied later in a batch. Applying wave 2 needs a separate founder approval, so every cluster-write task here is a human-gated `wait`. This file changes no manifest. + +## Raise the Vergabe memory request and clear the latent inter-hub request + +```task +id: RAPPS-WP-0018-T01 +status: wait +priority: medium +needs_human: true +blocking_reason: "Wave-2 cluster write; founder approval for wave 2 not yet given (2026-10-21 interview)" +``` + +Values are pod-aggregate figures from the budget file (7-day window ending 2026-10-21T06:08Z). Limits stay unchanged unless stated. + +| Workload (ns/name) | Pods | CPU request | CPU limit | CPU p95/peak 7d (m) | Memory request | Memory limit | Mem p95/peak (Mi) | Throttle | Action | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---| +| `vergabe-demo-company/vergabe-teilnahme` | 1 | 60m → **60m** | 1000m | 0.5/20.3 | 256Mi → **512Mi** | 1024Mi | 171.5/171.6 | 0.0 | keep CPU (replay-backed) + memory up | + +Source and container detail: + +- `vergabe-demo-company/vergabe-teilnahme`: Deployment `vergabe-teilnahme` (container `vergabe-teilnahme`), `resources` in `helm/vergabe-demo-company-values.proposed.yaml` (60m/256Mi, limits 1000m/1Gi); the chart default in `charts/vergabe-teilnahme/values.yaml` (100m/256Mi) is not what is deployed. Keep the 60m CPU request and the 1000m/1Gi limits (replay-backed). Raise memory 256Mi → 512Mi: the target replay (`docs/evidence/2026-10-21-vergabe-target-replay/`, commit `1af6d82`, RAPPS-WP-0014-T04) showed a loaded working set of 320–480MiB. + +Evidence: resource-control `docs/evidence/2026-10-21-node-allocation-review/2026-10-21-node-budget.md` (+ `.json`, commit `3d9b1bd`). Rule: CPU = max(floor, p95x1.5, peakx0.5) rounded up to 5m; memory request = 7d p95 working set x1.15 (only when the change is at least 64Mi). + +Steps: + +1. Map the pod-aggregate proposal onto the named container(s) in the source file; where a workload has several containers or pods, split by the per-pod figures in `2026-10-21-workload-usage.json` and keep the sum at the proposed value. +2. Prepare the source change as a reviewed commit (no push, no sync) and record the before values here. +3. Apply only inside the batched wave-2 rollout, after founder approval, through the owner's normal deploy path (ArgoCD sync or documented helm/kubectl apply). + +Verification: `kubectl -n get pod -o jsonpath='{..resources}'` shows the new requests; pods Ready with no new restarts or OOMKilled over 24h; for CPU decreases the throttle ratio and request latency stay at or below the 7d baseline; node request total drops by the expected delta. + +Rollback: revert the source commit and re-sync/re-apply; the before values in the table above are the rollback target. Keep before/after `kubectl get -o yaml` captures as the receipt. + +Inter-Hub: `charts/inter-hub/values.yaml` still defaults to `requests: {cpu: 250m, memory: 512Mi}` (limits 1000m/2Gi), and `helm/inter-hub-values.yaml` does not override it. The release runs at 0 replicas, so the 250m is latent: it is reserved again the moment anything scales it up or the deploy workflow (`.gitea/workflows/inter-hub-production-deploy.yaml`) runs. Inter-Hub is retired (CORE-WP-0007; production `hub.coulomb.social` is Core Hub only). Preferred: remove the inter-hub release and retire its chart and deploy workflow; otherwise lower the default request to the 5m floor so a scale-up cannot reserve 250m. Record which option the owner chose. From 47b3a4a5ca6f6d5b9a7085b6b1a3f3915d7909b7 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 21 Oct 2026 12:30:30 +0200 Subject: [PATCH 58/87] chore(consistency): write back State Hub ids Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 8340@bnt-lap001 Assistant-Session: 2e192b3d-b3e8-4403-94d4-d55a2c8b476c --- workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md b/workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md index 7c0d571..5240220 100644 --- a/workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md +++ b/workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md @@ -13,6 +13,7 @@ updated: "2026-10-21" related: - CUST-WP-0071 - RESOURCE-WP-0008 +state_hub_workstream_id: "c3f7c479-7477-5d5e-865d-a5d5b9e79ab8" --- # RAPPS-WP-0018 — Wave-2 resource right-sizing from the 2026-10-21 allocation review @@ -29,6 +30,7 @@ status: wait priority: medium needs_human: true blocking_reason: "Wave-2 cluster write; founder approval for wave 2 not yet given (2026-10-21 interview)" +state_hub_task_id: "48a3c858-c65c-575c-8a97-4122245a1eca" ``` Values are pod-aggregate figures from the budget file (7-day window ending 2026-10-21T06:08Z). Limits stay unchanged unless stated. From 4a1d6539bf6e67b825421cd68a76ec68c5a24841 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 21 Oct 2026 20:54:00 +0200 Subject: [PATCH 59/87] FORGE-WP-0011: Forgejo and runner resource requests (CUST-WP-0071 wave 1) Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 276069@bnt-lap001 Assistant-Session: 2e192b3d-b3e8-4403-94d4-d55a2c8b476c --- helm/forgejo-values.yaml | 13 +++++++++++++ manifests/forgejo-runner.yaml | 17 +++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/helm/forgejo-values.yaml b/helm/forgejo-values.yaml index 3bb9812..08a219e 100644 --- a/helm/forgejo-values.yaml +++ b/helm/forgejo-values.yaml @@ -4,6 +4,19 @@ strategy: type: Recreate +# Main Forgejo container sizing (FORGE-WP-0011, CUST-WP-0071, RESOURCE-WP-0008). +# Evidence 2026-10-21 (7d): CPU p95 133-160m, peak ~1030m; memory working set +# p95 ~956Mi, per-pod peak 1252Mi, RSS peak 500Mi; no OOM events. +# Request = p95 rule (200m / 1104Mi). Memory limit 1536Mi = 1.23x working-set +# peak and ~3x RSS peak (the rest is reclaimable page cache). No CPU limit: +# bursts to ~1 core are normal for git pack/clone work and must not throttle. +resources: + requests: + cpu: 200m + memory: 1104Mi + limits: + memory: 1536Mi + service: http: type: ClusterIP diff --git a/manifests/forgejo-runner.yaml b/manifests/forgejo-runner.yaml index ccbb94b..a886adb 100644 --- a/manifests/forgejo-runner.yaml +++ b/manifests/forgejo-runner.yaml @@ -100,6 +100,16 @@ spec: - -H - tcp://127.0.0.1:2375 - --tls=false + # FORGE-WP-0011: job workload runs here (one job at a time, + # runner capacity: 1). Request covers idle p95 (391Mi); the limit + # bounds a build burst. Peak 7d: 200m / 693Mi working set. + resources: + requests: + cpu: 100m + memory: 432Mi + limits: + cpu: 1500m + memory: 2Gi readinessProbe: exec: command: ["sh", "-c", "wget -q -O- -T 3 http://127.0.0.1:2375/_ping | grep -qx OK"] @@ -108,6 +118,13 @@ spec: - name: runner image: code.forgejo.org/forgejo/runner@sha256:5071e6832313bafe71577e05631bece88caff08fcfb372193e4a21941f4ed54b imagePullPolicy: IfNotPresent + # FORGE-WP-0011: runner daemon; p95 2m / 56Mi, peak 124m / 459Mi + # (host-label jobs run in this container). Pod request total + # 125m / 496Mi. No limit set in this change (see FORGE-WP-0011). + resources: + requests: + cpu: 25m + memory: 64Mi env: - name: DOCKER_HOST value: tcp://127.0.0.1:2375 From e9d2b5f39a6fe1c4accb5c54e3ba0b37d05b23ee Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 21 Oct 2026 21:14:40 +0200 Subject: [PATCH 60/87] chore(consistency): write back State Hub ids Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 276069@bnt-lap001 Assistant-Session: 2e192b3d-b3e8-4403-94d4-d55a2c8b476c --- WORK-RECORDS.md | 2 +- workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 9fabd1f..ed2c94c 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -15,7 +15,7 @@ | workplan | RAPPS-WP-0015 | active | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | workplan | RAPPS-WP-0016 | active | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | workplan | RAPPS-WP-0017 | finished | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | -| workplan | RAPPS-WP-0018 | ready | — | workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md | +| workplan | RAPPS-WP-0018 | active | — | workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md | | task | RAPPS-WP-0001-T01 | done | — | workplans/RAPPS-WP-0001-cnpg-backup-scheduledbackup-coverage.md | | task | RAPPS-WP-0001-T02 | done | — | workplans/RAPPS-WP-0001-cnpg-backup-scheduledbackup-coverage.md | | task | RAPPS-WP-0001-T03 | done | — | workplans/RAPPS-WP-0001-cnpg-backup-scheduledbackup-coverage.md | diff --git a/workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md b/workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md index 5240220..d95a795 100644 --- a/workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md +++ b/workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md @@ -4,7 +4,7 @@ type: workplan title: "Wave-2 resource right-sizing from the 2026-10-21 allocation review" domain: financials repo: railiance-apps -status: ready +status: active flavor: implementation owner: claude topic_slug: railiance From 64530e19e2e23251a887c9afc60f6bf672307abf Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 21 Oct 2026 22:23:50 +0200 Subject: [PATCH 61/87] Declare the Vergabe release unit. The chart label is vergabe, the claim is the live Deployment in vergabe-demo-company, and the image pin is the digest that Deployment runs. The shared database is a reference, not a workload claim. RELREG-WP-0002-T01 Assistant: grok Assistant-Session: 01a15f9f-3d77-7d21-911b-d93c86f9d14f --- .../vergabe-teilnahme/templates/_helpers.tpl | 1 + helm/vergabe-teilnahme-values.yaml | 2 + registry/release-units/vergabe.yaml | 67 +++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 registry/release-units/vergabe.yaml diff --git a/charts/vergabe-teilnahme/templates/_helpers.tpl b/charts/vergabe-teilnahme/templates/_helpers.tpl index 22b9774..50c93cb 100644 --- a/charts/vergabe-teilnahme/templates/_helpers.tpl +++ b/charts/vergabe-teilnahme/templates/_helpers.tpl @@ -13,6 +13,7 @@ app.kubernetes.io/managed-by: {{ .Release.Service }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} app.kubernetes.io/part-of: railiance-apps helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +railiance.io/release-unit: vergabe {{- end -}} {{- define "vergabe.selectorLabels" -}} diff --git a/helm/vergabe-teilnahme-values.yaml b/helm/vergabe-teilnahme-values.yaml index 9b62ba0..72def91 100644 --- a/helm/vergabe-teilnahme-values.yaml +++ b/helm/vergabe-teilnahme-values.yaml @@ -5,6 +5,8 @@ image: repository: forgejo.coulomb.social/coulomb/vergabe-teilnahme tag: "064d295" # Forgejo-only uv.lock (issue-core 0.2.1 from Forgejo PyPI) + # The chart prefers digest. This is the digest running in vergabe-demo-company. + digest: "sha256:a26444f59c259698159c69ccb96f73dc648a261ece4c86bb2037a9d977870d91" env: DJANGO_SETTINGS_MODULE: vergabe_teilnahme.settings.prod diff --git a/registry/release-units/vergabe.yaml b/registry/release-units/vergabe.yaml new file mode 100644 index 0000000..6f99cde --- /dev/null +++ b/registry/release-units/vergabe.yaml @@ -0,0 +1,67 @@ +# Source digests are the commits this declaration was written against. +# The database is the logical database vergabe_demo_company on the shared +# apps-pg cluster. This unit does not claim that StatefulSet. +kind: release-unit +unit_id: vergabe +owner: railiance-apps +contract_version: "1.0.0" +data_classification: confidential +criticality: high +composition: + purpose: "Serves the Vergabe participation application for one company." + member_repos: + - repo: railiance-apps + role: rollout owner + deployables: + - vergabe + - repo: vergabe-teilnahme + role: application source + deployables: + - vergabe-teilnahme +rollout_contract: + default_mode: helm-upgrade-install +smoke_contract: + required: + - http-ready +rollback_contract: + order: + - helm-rollback +workloads: + - kind: Deployment + namespace: vergabe-demo-company + name: vergabe-teilnahme +depends_on: + - unit_id: vergabe-db + readiness: available + version: "1.0.0" +pins: + component: + name: ocm.railiance.dev/vergabe + version: "0.2.1" + resources: + - name: vergabe-teilnahme + type: ociImage + version: "0.2.1" + digest: + hashAlgorithm: SHA-256 + normalisationAlgorithm: ociArtifactDigest/v1 + value: "sha256:a26444f59c259698159c69ccb96f73dc648a261ece4c86bb2037a9d977870d91" + sources: + - name: railiance-apps + type: git + version: "0.2.1" + digest: + hashAlgorithm: SHA-1 + normalisationAlgorithm: gitCommit/v1 + value: "e9d2b5f39a6fe1c4accb5c54e3ba0b37d05b23ee" + - name: vergabe-teilnahme + type: git + version: "0.2.1" + digest: + hashAlgorithm: SHA-1 + normalisationAlgorithm: gitCommit/v1 + value: "dca6683a8ab7d4aa0de2a537924f73318b199f60" + references: + - name: database + componentName: vergabe-db + version: "1.0.0" From ce1c56dcb32cd07e27be14177b5dafefc4f0cd75 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 21 Oct 2026 23:14:42 +0200 Subject: [PATCH 62/87] RAPPS-WP-0019: plan product-neutral forge object names Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 276069@bnt-lap001 Assistant-Session: 2e192b3d-b3e8-4403-94d4-d55a2c8b476c --- WORK-RECORDS.md | 6 + ...0019-product-neutral-forge-object-names.md | 137 ++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index ed2c94c..df35f4c 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -16,6 +16,7 @@ | workplan | RAPPS-WP-0016 | active | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | workplan | RAPPS-WP-0017 | finished | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | | workplan | RAPPS-WP-0018 | active | — | workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md | +| workplan | RAPPS-WP-0019 | active | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | | task | RAPPS-WP-0001-T01 | done | — | workplans/RAPPS-WP-0001-cnpg-backup-scheduledbackup-coverage.md | | task | RAPPS-WP-0001-T02 | done | — | workplans/RAPPS-WP-0001-cnpg-backup-scheduledbackup-coverage.md | | task | RAPPS-WP-0001-T03 | done | — | workplans/RAPPS-WP-0001-cnpg-backup-scheduledbackup-coverage.md | @@ -53,3 +54,8 @@ | task | RAPPS-WP-0017-T02 | done | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | | task | RAPPS-WP-0017-T03 | done | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | | task | RAPPS-WP-0018-T01 | wait | — | workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md | +| task | RAPPS-WP-0019-T01 | todo | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | +| task | RAPPS-WP-0019-T02 | todo | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | +| task | RAPPS-WP-0019-T03 | todo | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | +| task | RAPPS-WP-0019-T04 | todo | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | +| task | RAPPS-WP-0019-T05 | todo | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | diff --git a/workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md b/workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md new file mode 100644 index 0000000..5352299 --- /dev/null +++ b/workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md @@ -0,0 +1,137 @@ +--- +id: RAPPS-WP-0019 +type: workplan +title: "Product-neutral forge object names (forgejo-gitea-* → forge-*)" +domain: infotech +repo: railiance-apps +status: active +flavor: refactoring +owner: railiance-apps +created: "2026-10-21" +updated: "2026-10-21" +related: [FORGE-WP-0011, CUST-WP-0071] +--- + +# Product-neutral forge object names + +Founder decision, 2026-10-21: the legacy "gitea" terminology should leave live +objects. The upstream Helm chart is called `gitea`, so the Forgejo objects were +named `forgejo-gitea*` and the pods were labelled `app.kubernetes.io/name: gitea`. +The founder also preferred the product-neutral word **forge**. + +**Scope:** object names and pod labels: +- Deployment `forge`; +- Services `forge-http` and `forge-ssh`; +- chart Secrets `forge*`; +- pod label `app.kubernetes.io/name: forge`. + +**Out of scope, kept on purpose:** +- the data volume `gitea-shared-storage` (renaming needs a data copy); +- the container names `gitea`/`configure-gitea` (hard-coded in the chart); +- the Helm release, the namespace `forgejo` and the host `forgejo.coulomb.social`. + Changing these is a separate, later decision. + +Leftovers from the coulombcore Gitea era (`railiance-bootstrap`/`railiance-cluster` +`gitea-ssh-nodeport.yaml`, `railiance-forge/manifests/gitea-ingress.yaml`, `gitea-db`) +are not live. They are listed for cleanup in T05. + +## Stage A: decouple the consumer NetworkPolicies + +```task +id: RAPPS-WP-0019-T01 +status: todo +priority: high +``` + +core-hub, issue-core and sbom-nexus allow egress to the forge by selecting +`app.kubernetes.io/instance: forgejo` plus `app.kubernetes.io/name: gitea`. Drop the +`name` label. `instance=forgejo` still selects only the forge server pod (the +runner has no instance label), and it stays valid before and after the rename. + +Diffs against live (2026-10-21) show exactly that one line per consumer: + +| Consumer | Source | Deploy path | +|---|---|---| +| core-hub | rapp-core-hub `helm/core-hub/templates/networkpolicy.yaml` | `make deploy` (Helm; the full release diff is only this line) | +| sbom-nexus | rapp-sbom-nexus `manifests/runtime.yaml` | server-side apply of the rendered `sbom-nexus-runtime` NetworkPolicy with field manager `rapp-sbom-nexus` | +| issue-core | rapp-issue-core `manifests/30-runtime.yaml` | push, pin the new revision in railiance-platform `argocd/railiance01/applications/issue-core.application.yaml`, manual ArgoCD sync (no other manifest change since `186c817`) | + +Verify: each consumer still reaches `http://forgejo-gitea-http.forgejo.svc:3000`. + +## Stage B: cutover (attended, about 3 minutes of forge downtime) + +```task +id: RAPPS-WP-0019-T02 +status: todo +priority: high +depends_on: [RAPPS-WP-0019-T01] +``` + +Source: `helm/forgejo-values.yaml` (`fullnameOverride: forge`, `nameOverride: forge`, +explicit `persistence.claimName: gitea-shared-storage`), +`manifests/forgejo-ingress.yaml` (backend `forge-http`), +`manifests/forgejo-ssh-nodeport.yaml` (selector `forge`), +`manifests/forgejo-compat-http-service.yaml` (temporary alias). + +1. Pre-check: no running Actions job. Record Helm revision 10 and Deployment revision 15. +2. `kubectl -n forgejo scale deployment/forgejo-gitea --replicas=0` and wait until + no forge pod is left. Helm creates the new Deployment before it deletes the old + one, so this prevents two pods mounting one volume. +3. Founder: the final `helm upgrade --install forgejo gitea-charts/gitea` step of + `make forgejo-deploy`, the same command as on 2026-10-21. Do not run + `make forgejo-deploy` itself: its `eso-deploy` dependency would upgrade ESO. +4. Apply the Ingress, the SSH NodePort and the compatibility Service manifests. +5. Verify: + - Deployment `forge` is Ready and the old `forgejo-gitea*` objects are gone; + - `/api/healthz` returns 200; + - `git ls-remote` works over HTTPS and over SSH port 30022; + - the sign-in source is unchanged and the redirect still uses S256 PKCE; + - core-hub, issue-core and sbom-nexus reach the forge; + - the runner still polls. + +Rollback: +1. `kubectl -n forgejo scale deployment/forge --replicas=0`. +2. `helm rollback forgejo 10`, the known-good revision from 2026-10-21. **Never use revision 9.** +3. Re-apply the previous Ingress and NodePort manifests from git, and delete the + compatibility Service. + +## Stage C: consumers move to forge-http, then remove the alias + +```task +id: RAPPS-WP-0019-T03 +status: todo +priority: medium +depends_on: [RAPPS-WP-0019-T02] +``` + +Change the in-cluster URLs `forgejo-gitea-http.forgejo.svc` to `forge-http.forgejo.svc`: +- rapp-core-hub `helm/core-hub/values.yaml` (`forgejoBaseURL`); +- rapp-issue-core `manifests/10-config.yaml`; +- rapp-sbom-nexus `manifests/runtime.yaml`. + +Deploy each through its own path. When no consumer uses the old name, delete +`manifests/forgejo-compat-http-service.yaml` and the live Service. + +## Tooling and docs wording + +```task +id: RAPPS-WP-0019-T04 +status: todo +priority: low +depends_on: [RAPPS-WP-0019-T02] +``` + +railiance-forge `Makefile` (`GITEA_HTTP_SERVICE`) and +`tools/observe_forge_resources.py` (`deploy/forgejo-gitea`); runbooks and docs that +name `forgejo-gitea`. Prefer "forge" wherever the concrete product is not meant. + +## Retire Gitea-era leftovers + +```task +id: RAPPS-WP-0019-T05 +status: todo +priority: low +``` + +Review and remove or archive the non-live Gitea-era manifests named above, once +their owners confirm. From 5d2c0d85643e340be834d3f3e76642f03ac26024 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 21 Oct 2026 23:14:46 +0200 Subject: [PATCH 63/87] RAPPS-WP-0019 stage B: rename forge objects (forgejo-gitea-* -> forge-*) Local until the attended cutover; see the workplan for the scale-down order. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 276069@bnt-lap001 Assistant-Session: 2e192b3d-b3e8-4403-94d4-d55a2c8b476c --- ...ejo-runner-registration.sops.yaml.template | 2 +- helm/forgejo-values.yaml | 7 ++++++ manifests/forgejo-compat-http-service.yaml | 23 +++++++++++++++++++ manifests/forgejo-ingress.yaml | 6 ++--- manifests/forgejo-ssh-nodeport.yaml | 4 ++-- 5 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 manifests/forgejo-compat-http-service.yaml diff --git a/helm/forgejo-runner-registration.sops.yaml.template b/helm/forgejo-runner-registration.sops.yaml.template index 261f1bc..42ac5c3 100644 --- a/helm/forgejo-runner-registration.sops.yaml.template +++ b/helm/forgejo-runner-registration.sops.yaml.template @@ -2,7 +2,7 @@ # kubectl apply -f <(sops -d helm/forgejo-runner-registration.sops.yaml) # # Generate token (one-time, do not commit plaintext): -# kubectl exec -n forgejo deploy/forgejo-gitea -- \ +# kubectl exec -n forgejo deploy/forge -- \ # forgejo actions generate-runner-token --scope coulomb --- apiVersion: v1 diff --git a/helm/forgejo-values.yaml b/helm/forgejo-values.yaml index 08a219e..aeea98b 100644 --- a/helm/forgejo-values.yaml +++ b/helm/forgejo-values.yaml @@ -1,3 +1,9 @@ +# Product-neutral object names (RAPPS-WP-0019). The upstream chart is named +# 'gitea'; these overrides keep that legacy name out of live objects. The data +# volume keeps its existing claim name; renaming it needs a data copy. +fullnameOverride: forge +nameOverride: forge + # Non-secret Forgejo Helm values (gitea-charts/gitea chart + Forgejo image). # Layer helm/forgejo-secrets.sops.yaml for database password and admin credentials. # Recreate avoids leveldb queue lock fights on the shared RWO PVC during upgrades. @@ -29,6 +35,7 @@ image: persistence: enabled: true + claimName: gitea-shared-storage size: 10Gi postgresql: diff --git a/manifests/forgejo-compat-http-service.yaml b/manifests/forgejo-compat-http-service.yaml new file mode 100644 index 0000000..3600103 --- /dev/null +++ b/manifests/forgejo-compat-http-service.yaml @@ -0,0 +1,23 @@ +# TEMPORARY compatibility alias (RAPPS-WP-0019, stage B -> C). +# The forge Helm objects were renamed from forgejo-gitea-* to forge-*. In-cluster +# consumers still call http://forgejo-gitea-http.forgejo.svc:3000 until they move to +# forge-http (stage C). Delete this Service once no consumer uses the old name. +apiVersion: v1 +kind: Service +metadata: + name: forgejo-gitea-http + namespace: forgejo + labels: + app.kubernetes.io/instance: forgejo + app.kubernetes.io/part-of: railiance-apps + railiance.io/compat-alias-for: forge-http +spec: + clusterIP: None + selector: + app.kubernetes.io/instance: forgejo + app.kubernetes.io/name: forge + ports: + - name: http + port: 3000 + targetPort: 3000 + protocol: TCP diff --git a/manifests/forgejo-ingress.yaml b/manifests/forgejo-ingress.yaml index 8d2a52c..c92f126 100644 --- a/manifests/forgejo-ingress.yaml +++ b/manifests/forgejo-ingress.yaml @@ -22,21 +22,21 @@ spec: pathType: Prefix backend: service: - name: forgejo-gitea-http + name: forge-http port: number: 3000 - path: /v2 pathType: Prefix backend: service: - name: forgejo-gitea-http + name: forge-http port: number: 3000 - path: / pathType: Prefix backend: service: - name: forgejo-gitea-http + name: forge-http port: number: 3000 tls: diff --git a/manifests/forgejo-ssh-nodeport.yaml b/manifests/forgejo-ssh-nodeport.yaml index 42998e2..2f9b5ec 100644 --- a/manifests/forgejo-ssh-nodeport.yaml +++ b/manifests/forgejo-ssh-nodeport.yaml @@ -1,4 +1,4 @@ -# Git/SSH access for Forgejo (NodePort pattern mirrors coulombcore Gitea). +# Git/SSH NodePort for the forge (Forgejo). apiVersion: v1 kind: Service metadata: @@ -12,7 +12,7 @@ spec: type: NodePort selector: app.kubernetes.io/instance: forgejo - app.kubernetes.io/name: gitea + app.kubernetes.io/name: forge ports: - name: ssh port: 22 From 86c255a932f2ecca9277bbb197c95d25cdfae115 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 21 Oct 2026 23:31:40 +0200 Subject: [PATCH 64/87] RAPPS-WP-0019: stage A and B applied; forge objects renamed Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 276069@bnt-lap001 Assistant-Session: 2e192b3d-b3e8-4403-94d4-d55a2c8b476c --- WORK-RECORDS.md | 4 +-- ...0019-product-neutral-forge-object-names.md | 26 +++++++++++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index df35f4c..3142fc9 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -54,8 +54,8 @@ | task | RAPPS-WP-0017-T02 | done | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | | task | RAPPS-WP-0017-T03 | done | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | | task | RAPPS-WP-0018-T01 | wait | — | workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md | -| task | RAPPS-WP-0019-T01 | todo | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | -| task | RAPPS-WP-0019-T02 | todo | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | +| task | RAPPS-WP-0019-T01 | done | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | +| task | RAPPS-WP-0019-T02 | done | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | | task | RAPPS-WP-0019-T03 | todo | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | | task | RAPPS-WP-0019-T04 | todo | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | | task | RAPPS-WP-0019-T05 | todo | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | diff --git a/workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md b/workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md index 5352299..2563f73 100644 --- a/workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md +++ b/workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md @@ -39,7 +39,7 @@ are not live. They are listed for cleanup in T05. ```task id: RAPPS-WP-0019-T01 -status: todo +status: done priority: high ``` @@ -62,7 +62,7 @@ Verify: each consumer still reaches `http://forgejo-gitea-http.forgejo.svc:3000` ```task id: RAPPS-WP-0019-T02 -status: todo +status: done priority: high depends_on: [RAPPS-WP-0019-T01] ``` @@ -135,3 +135,25 @@ priority: low Review and remove or archive the non-live Gitea-era manifests named above, once their owners confirm. + +## Stage A applied — 2026-10-21 + +Each consumer now selects the forge by `app.kubernetes.io/instance: forgejo` only: +- core-hub: `make deploy`, Helm revision 32; +- sbom-nexus: server-side apply with field manager `rapp-sbom-nexus`; +- issue-core: rapp-issue-core b60c194, pinned in railiance-platform 2c5d3b3, then the root app and issue-core synced manually. The root app needed a hard refresh first because ArgoCD served a cached `main`. + +Before the change, every full render diffed against live showed only the one label line. Afterwards all three reached the forge (healthz 200). + +`make argocd-pin-drift` in railiance-platform cannot read live Applications: its ssh hop cannot read the k3s kubeconfig. That is the same issue as RCLUSTER-WP-0020-T04. + +## Stage B cutover — 2026-10-21 21:24Z + +The founder ran the attended cutover script. It checked the node, waited until the runner was idle, scaled `forgejo-gitea` to 0, waited for no running forge pod, ran the Helm upgrade, then applied the Ingress, the SSH NodePort and the compatibility Service. Results: +- Helm revision 11: Deployment `forge` 1/1; Services `forge-http` and `forge-ssh`; pod label `app.kubernetes.io/name: forge`. +- The same data volume (`gitea-shared-storage`) is mounted. +- The old `forgejo-gitea*` objects are gone, including the pod that had been in ContainerStatusUnknown for 66 days. +- Checks: API 11.0.3; HTTPS `git ls-remote`; SSH through the NodePort (`forgejo-remote` alias, fetch ok); sign-in source unchanged (ID 1 `netkingdom`); redirect with S256, HTTPS callback and a single scope list; core-hub, issue-core and sbom-nexus answer healthz 200 on both `forgejo-gitea-http` (alias) and `forge-http`; no runner errors. +- Downtime was about 3 minutes. + +Rollback reference: scale `forge` to 0, then `helm rollback forgejo 10`, then the previous Ingress and NodePort manifests from git. From db0ade1cf3c10753385fb80b925564245b062a2b Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 21 Oct 2026 23:38:03 +0200 Subject: [PATCH 65/87] RAPPS-WP-0019 stage C: consumers on forge-http; remove compatibility alias Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 276069@bnt-lap001 Assistant-Session: 2e192b3d-b3e8-4403-94d4-d55a2c8b476c --- WORK-RECORDS.md | 2 +- manifests/forgejo-compat-http-service.yaml | 23 ------------------ ...0019-product-neutral-forge-object-names.md | 24 ++++++++++++++++++- 3 files changed, 24 insertions(+), 25 deletions(-) delete mode 100644 manifests/forgejo-compat-http-service.yaml diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 3142fc9..7b50901 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -56,6 +56,6 @@ | task | RAPPS-WP-0018-T01 | wait | — | workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md | | task | RAPPS-WP-0019-T01 | done | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | | task | RAPPS-WP-0019-T02 | done | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | -| task | RAPPS-WP-0019-T03 | todo | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | +| task | RAPPS-WP-0019-T03 | done | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | | task | RAPPS-WP-0019-T04 | todo | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | | task | RAPPS-WP-0019-T05 | todo | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | diff --git a/manifests/forgejo-compat-http-service.yaml b/manifests/forgejo-compat-http-service.yaml deleted file mode 100644 index 3600103..0000000 --- a/manifests/forgejo-compat-http-service.yaml +++ /dev/null @@ -1,23 +0,0 @@ -# TEMPORARY compatibility alias (RAPPS-WP-0019, stage B -> C). -# The forge Helm objects were renamed from forgejo-gitea-* to forge-*. In-cluster -# consumers still call http://forgejo-gitea-http.forgejo.svc:3000 until they move to -# forge-http (stage C). Delete this Service once no consumer uses the old name. -apiVersion: v1 -kind: Service -metadata: - name: forgejo-gitea-http - namespace: forgejo - labels: - app.kubernetes.io/instance: forgejo - app.kubernetes.io/part-of: railiance-apps - railiance.io/compat-alias-for: forge-http -spec: - clusterIP: None - selector: - app.kubernetes.io/instance: forgejo - app.kubernetes.io/name: forge - ports: - - name: http - port: 3000 - targetPort: 3000 - protocol: TCP diff --git a/workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md b/workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md index 2563f73..105acc9 100644 --- a/workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md +++ b/workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md @@ -10,6 +10,7 @@ owner: railiance-apps created: "2026-10-21" updated: "2026-10-21" related: [FORGE-WP-0011, CUST-WP-0071] +state_hub_workstream_id: "b0cc4018-0cf3-53a3-83d6-f76d377d763a" --- # Product-neutral forge object names @@ -41,6 +42,7 @@ are not live. They are listed for cleanup in T05. id: RAPPS-WP-0019-T01 status: done priority: high +state_hub_task_id: "a6bd310d-34f5-5642-a1cb-e682a72892c9" ``` core-hub, issue-core and sbom-nexus allow egress to the forge by selecting @@ -65,6 +67,7 @@ id: RAPPS-WP-0019-T02 status: done priority: high depends_on: [RAPPS-WP-0019-T01] +state_hub_task_id: "14c47d3d-24e9-539c-ad3e-45741e1e339f" ``` Source: `helm/forgejo-values.yaml` (`fullnameOverride: forge`, `nameOverride: forge`, @@ -99,9 +102,10 @@ Rollback: ```task id: RAPPS-WP-0019-T03 -status: todo +status: done priority: medium depends_on: [RAPPS-WP-0019-T02] +state_hub_task_id: "e8cf80bc-3f1a-5990-b39d-4ed20cb1ba7f" ``` Change the in-cluster URLs `forgejo-gitea-http.forgejo.svc` to `forge-http.forgejo.svc`: @@ -119,6 +123,7 @@ id: RAPPS-WP-0019-T04 status: todo priority: low depends_on: [RAPPS-WP-0019-T02] +state_hub_task_id: "0be013cc-6bc1-5e0e-8f06-e2deef0c919c" ``` railiance-forge `Makefile` (`GITEA_HTTP_SERVICE`) and @@ -131,6 +136,7 @@ name `forgejo-gitea`. Prefer "forge" wherever the concrete product is not meant. id: RAPPS-WP-0019-T05 status: todo priority: low +state_hub_task_id: "169aeda8-2f4a-5bef-9343-8ad367dde71a" ``` Review and remove or archive the non-live Gitea-era manifests named above, once @@ -157,3 +163,19 @@ The founder ran the attended cutover script. It checked the node, waited until t - Downtime was about 3 minutes. Rollback reference: scale `forge` to 0, then `helm rollback forgejo 10`, then the previous Ingress and NodePort manifests from git. + +## Stage C applied — 2026-10-21 + +In-cluster URLs now use `forge-http.forgejo.svc`: +- core-hub: rapp-core-hub f2a9ded, Helm revision 33; publisher rolled. +- sbom-nexus: rapp-sbom-nexus cc4db99, server-side apply; rolled. +- issue-core: rapp-issue-core 805e124 with a `railiance.io/backends-revision` pod + annotation, so that the ConfigMap-based setting rolls the pod. Pinned in + railiance-platform 51d5247, root app hard-refreshed and synced, issue-core synced: + Synced/Healthy. + +Each full render diffed against live first showed only the URL, plus issue-core's +annotation. A cluster-wide scan afterwards found no workload or ConfigMap still +naming `forgejo-gitea`. All three consumers answer healthz 200 on `forge-http`. +The compatibility Service was deleted live, its manifest was removed, and HTTPS +and SSH git still work. From 1dd7387cbc997bf016aca3e4bb871e6763b1fa61 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 21 Oct 2026 23:46:40 +0200 Subject: [PATCH 66/87] RAPPS-WP-0019 finished; forge runner memory limit Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 276069@bnt-lap001 Assistant-Session: 2e192b3d-b3e8-4403-94d4-d55a2c8b476c --- WORK-RECORDS.md | 6 +++--- docs/forgejo-on-railiance01.md | 4 ++-- manifests/forgejo-runner.yaml | 3 +++ ...0019-product-neutral-forge-object-names.md | 19 +++++++++++++++---- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 7b50901..32450fc 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -16,7 +16,7 @@ | workplan | RAPPS-WP-0016 | active | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | workplan | RAPPS-WP-0017 | finished | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | | workplan | RAPPS-WP-0018 | active | — | workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md | -| workplan | RAPPS-WP-0019 | active | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | +| workplan | RAPPS-WP-0019 | finished | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | | task | RAPPS-WP-0001-T01 | done | — | workplans/RAPPS-WP-0001-cnpg-backup-scheduledbackup-coverage.md | | task | RAPPS-WP-0001-T02 | done | — | workplans/RAPPS-WP-0001-cnpg-backup-scheduledbackup-coverage.md | | task | RAPPS-WP-0001-T03 | done | — | workplans/RAPPS-WP-0001-cnpg-backup-scheduledbackup-coverage.md | @@ -57,5 +57,5 @@ | task | RAPPS-WP-0019-T01 | done | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | | task | RAPPS-WP-0019-T02 | done | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | | task | RAPPS-WP-0019-T03 | done | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | -| task | RAPPS-WP-0019-T04 | todo | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | -| task | RAPPS-WP-0019-T05 | todo | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | +| task | RAPPS-WP-0019-T04 | done | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | +| task | RAPPS-WP-0019-T05 | done | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | diff --git a/docs/forgejo-on-railiance01.md b/docs/forgejo-on-railiance01.md index aa5c795..4e563b6 100644 --- a/docs/forgejo-on-railiance01.md +++ b/docs/forgejo-on-railiance01.md @@ -23,7 +23,7 @@ Hostname decision: `the-custodian/docs/forgejo-production-decisions.md`. | Server | railiance01 `92.205.62.239` | | Namespace | `forgejo` | | Helm release | `forgejo` | -| HTTP service | `forgejo-gitea-http` (chart naming; ingress must target this) | +| HTTP service | `forge-http` (chart naming; ingress must target this) | | Chart | `gitea-charts/gitea` **12.5.0** (Forgejo-compatible; 12.6+ needs Gitea 1.26 `config edit-ini`) | | Image | `code.forgejo.org/forgejo/forgejo:11.0.3` | | Database | `forgejo-db-rw.databases.svc.cluster.local:5432` | @@ -71,7 +71,7 @@ make forgejo-runner-status - `strategy.type: Recreate` in `helm/forgejo-values.yaml` — avoids leveldb queue lock on the shared RWO PVC during rolling updates. - Actions enabled via `gitea.config.actions.ENABLED: true`. -- Ingress backend service name is `forgejo-gitea-http` (Helm release naming). +- Ingress backend service name is `forge-http` (Helm release naming). ## Day-2 operator targets diff --git a/manifests/forgejo-runner.yaml b/manifests/forgejo-runner.yaml index a886adb..f9f8771 100644 --- a/manifests/forgejo-runner.yaml +++ b/manifests/forgejo-runner.yaml @@ -125,6 +125,9 @@ spec: requests: cpu: 25m memory: 64Mi + limits: + # 7-day peak 459Mi (`:host` jobs run in this container); ~2.2x margin. + memory: 1Gi env: - name: DOCKER_HOST value: tcp://127.0.0.1:2375 diff --git a/workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md b/workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md index 105acc9..c52368d 100644 --- a/workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md +++ b/workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md @@ -4,7 +4,7 @@ type: workplan title: "Product-neutral forge object names (forgejo-gitea-* → forge-*)" domain: infotech repo: railiance-apps -status: active +status: finished flavor: refactoring owner: railiance-apps created: "2026-10-21" @@ -120,9 +120,8 @@ Deploy each through its own path. When no consumer uses the old name, delete ```task id: RAPPS-WP-0019-T04 -status: todo +status: done priority: low -depends_on: [RAPPS-WP-0019-T02] state_hub_task_id: "0be013cc-6bc1-5e0e-8f06-e2deef0c919c" ``` @@ -134,7 +133,7 @@ name `forgejo-gitea`. Prefer "forge" wherever the concrete product is not meant. ```task id: RAPPS-WP-0019-T05 -status: todo +status: done priority: low state_hub_task_id: "169aeda8-2f4a-5bef-9343-8ad367dde71a" ``` @@ -179,3 +178,15 @@ annotation. A cluster-wide scan afterwards found no workload or ConfigMap still naming `forgejo-gitea`. All three consumers answer healthz 200 on `forge-http`. The compatibility Service was deleted live, its manifest was removed, and HTTPS and SSH git still work. + +## Tooling and leftovers done — 2026-10-21 + +**T04, tooling and docs:** railiance-forge now uses `FORGE_*` Makefile variables, `forge-status`, and an observer that targets `deploy/forge`. Its current docs use the new object names, and `docs/forgejo-on-railiance01.md` was updated here. + +**T05, Gitea-era leftovers removed.** None was referenced by a live path, and nothing named gitea runs in the cluster: +- railiance-forge FORGE-WP-0009 (finished): the stale `gitea-deploy` / `gitea-ingress-deploy` targets, which would have overwritten production with July values, plus their values files, the ingress and the old CoulombCore release values. +- railiance-platform: the `gitea-db` CNPG manifest and the db-deploy / db-status / db-shell / db-logs targets. They now refuse with a pointer to `forgejo-db-*`. +- railiance-bootstrap and railiance-cluster: `helm/gitea-ssh-nodeport.yaml`. +- the-custodian: the coulombcore Gitea package-push lockdown tooling. + +Kept on purpose: the data volume name `gitea-shared-storage`, the chart-imposed container names, and the namespace, release and hostname `forgejo` (deferred by the founder). From b7fb7b226f040fd30c7e321e56eed7aed09feab2 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 22 Oct 2026 00:03:48 +0200 Subject: [PATCH 67/87] Wave-2 right-sizing and inter-hub removal (RAPPS-WP-0018, CSOC-WP-0017, REUSE-WP-0026) - coulomb-social: requests 100m/256Mi -> 15m/176Mi (limits unchanged) - reuse-surface: main 100m/256Mi -> 5m/64Mi, landing 10m/32Mi -> 5m/16Mi - vergabe-demo-company: memory request 256Mi -> 512Mi, CPU 60m kept - inter-hub (retired, CORE-WP-0007): delete chart, values, Make targets, smoke script and production deploy workflow; release to be uninstalled Full renders vs live: resources only, except the vergabe release-unit label from 64530e1 not yet on its Service and PVCs. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 276069@bnt-lap001 Assistant-Session: 2e192b3d-b3e8-4403-94d4-d55a2c8b476c --- .claude/rules/stack-and-commands.md | 4 +- .../inter-hub-production-deploy.yaml | 50 ----------- Makefile | 60 +------------ charts/inter-hub/Chart.yaml | 16 ---- charts/inter-hub/templates/_helpers.tpl | 28 ------ charts/inter-hub/templates/deployment.yaml | 59 ------------- charts/inter-hub/templates/ingress.yaml | 28 ------ charts/inter-hub/templates/service.yaml | 13 --- charts/inter-hub/values.yaml | 55 ------------ docs/inter-hub-on-railiance01.md | 8 ++ docs/operator-setup.md | 1 - helm/coulomb-social-values.yaml | 10 +++ helm/inter-hub-values.yaml | 6 -- helm/reuse-surface-values.yaml | 18 ++++ .../vergabe-demo-company-values.proposed.yaml | 3 +- ...y.railiance.workload-deployment-tooling.md | 1 - tools/inter-hub-smoke.sh | 87 ------------------- tools/k8s-server-dry-run.sh | 20 ----- 18 files changed, 41 insertions(+), 426 deletions(-) delete mode 100644 .gitea/workflows/inter-hub-production-deploy.yaml delete mode 100644 charts/inter-hub/Chart.yaml delete mode 100644 charts/inter-hub/templates/_helpers.tpl delete mode 100644 charts/inter-hub/templates/deployment.yaml delete mode 100644 charts/inter-hub/templates/ingress.yaml delete mode 100644 charts/inter-hub/templates/service.yaml delete mode 100644 charts/inter-hub/values.yaml delete mode 100644 helm/inter-hub-values.yaml delete mode 100755 tools/inter-hub-smoke.sh diff --git a/.claude/rules/stack-and-commands.md b/.claude/rules/stack-and-commands.md index 1166d78..93b53d4 100644 --- a/.claude/rules/stack-and-commands.md +++ b/.claude/rules/stack-and-commands.md @@ -1,7 +1,7 @@ ## Stack - **Language:** Helm values + Kubernetes manifests, Bash Make targets -- **Key deps:** vergabe-teilnahme (Django) and inter-hub releases, shared apps-pg cnpg cluster, SOPS/age; runs against the S2 cluster on railiance01 +- **Key deps:** vergabe-teilnahme (Django), coulomb-social and reuse-surface releases (inter-hub retired and removed), shared apps-pg cnpg cluster, SOPS/age; runs against the S2 cluster on railiance01 ## Dev Commands @@ -13,7 +13,7 @@ make vergabe-dry-run # helm template render, no apply make vergabe-status # pod / svc / ingress / cert state (read-only) make vergabe-deploy # production-touching make vergabe-migrate | vergabe-seed # production-touching (Django management) -make check-inter-hub-image-tag # gate: explicit image tag for prod deploys +make check-vergabe-image-tag # gate: explicit image tag for prod deploys ``` `*-deploy` / `*-migrate` / `*-seed` change production workloads — operator diff --git a/.gitea/workflows/inter-hub-production-deploy.yaml b/.gitea/workflows/inter-hub-production-deploy.yaml deleted file mode 100644 index 39edf9e..0000000 --- a/.gitea/workflows/inter-hub-production-deploy.yaml +++ /dev/null @@ -1,50 +0,0 @@ -name: Inter-Hub production deploy - -on: - workflow_dispatch: - inputs: - image_tag: - description: "Immutable inter-hub image tag to deploy, for example 5101eb5" - required: true - confirm: - description: "Type deploy-inter-hub-production to confirm Railiance01 production deploy" - required: true - -jobs: - deploy: - runs-on: [self-hosted, haskelseed, linux_amd64] - timeout-minutes: 20 - env: - INTER_HUB_IMAGE_TAG: ${{ inputs.image_tag }} - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Validate dispatch inputs - env: - CONFIRM: ${{ inputs.confirm }} - run: | - set -euo pipefail - if [ "$CONFIRM" != "deploy-inter-hub-production" ]; then - echo "Refusing deploy: confirmation text did not match." >&2 - exit 1 - fi - if ! [[ "$INTER_HUB_IMAGE_TAG" =~ ^[A-Za-z0-9._-]+$ ]]; then - echo "Refusing deploy: image_tag contains unsupported characters." >&2 - exit 1 - fi - - - name: Verify image manifest exists - run: make check-inter-hub-image - - - name: Helm server dry-run - run: make inter-hub-server-dry-run - - - name: Deploy Inter-Hub - run: make inter-hub-deploy - - - name: Show release status - run: make inter-hub-status - - - name: Run public smoke checks - run: make inter-hub-smoke diff --git a/Makefile b/Makefile index 7a58d4f..40a4c21 100644 --- a/Makefile +++ b/Makefile @@ -24,14 +24,6 @@ COULOMB_SOCIAL_IMAGE_REPOSITORY ?= forgejo.coulomb.social/coulomb/coulomb-social COULOMB_SOCIAL_INGRESS ?= manifests/coulomb-social-ingress.yaml COULOMB_SOCIAL_ENV_SECRET ?= coulomb-social-env -INTER_HUB_RELEASE ?= inter-hub -INTER_HUB_NAMESPACE ?= inter-hub -INTER_HUB_CHART ?= charts/inter-hub -INTER_HUB_VALUES ?= helm/inter-hub-values.yaml -INTER_HUB_IMAGE_REPOSITORY ?= forgejo.coulomb.social/coulomb/inter-hub -INTER_HUB_IMAGE_TAG ?= -INTER_HUB_BASE_URL ?= https://hub.coulomb.social - REUSE_RELEASE ?= reuse REUSE_NAMESPACE ?= reuse REUSE_CHART ?= charts/reuse-surface @@ -52,7 +44,6 @@ POLICY_NEXUS_REVISION ?= COULOMBCORE_KUBECONFIG ?= $(HOME)/.kube/config RAILIANCE01_KUBECONFIG ?= $(HOME)/.kube/config-railiance01 PRODUCTION_KUBECONFIG ?= $(COULOMBCORE_KUBECONFIG) -INTER_HUB_KUBECONFIG ?= $(RAILIANCE01_KUBECONFIG) REUSE_KUBECONFIG ?= $(RAILIANCE01_KUBECONFIG) FORGEJO_KUBECONFIG ?= $(RAILIANCE01_KUBECONFIG) FORGEJO_RELEASE ?= forgejo @@ -75,8 +66,6 @@ FORGEJO_MAILER_EXTERNAL_SECRET ?= manifests/forgejo-mailer-externalsecret.yaml REUSE_RUNTIME_EXTERNAL_SECRET ?= manifests/reuse-surface-runtime-externalsecret.yaml PLATFORM_REPO ?= $(HOME)/railiance-platform ESO_CHART_VERSION ?= 0.16.1 -INTER_HUB_IMAGE_REF = $(INTER_HUB_IMAGE_REPOSITORY):$(INTER_HUB_IMAGE_TAG) -INTER_HUB_IMAGE_SET_ARG = $(if $(strip $(INTER_HUB_IMAGE_TAG)),--set image.tag=$(INTER_HUB_IMAGE_TAG),) CORE_HUB_RELEASE ?= core-hub CORE_HUB_NAMESPACE ?= core-hub @@ -139,16 +128,6 @@ check-coulombcore-kubeconfig: ## Verify CoulombCore kubeconfig and k3s API tunne check-production-kubeconfig: check-coulombcore-kubeconfig ## Verify production app cluster API (CoulombCore) -check-inter-hub-image-tag: ## Require an explicit inter-hub image tag for production deploys - @test -n "$(INTER_HUB_IMAGE_TAG)" || { \ - echo "Set INTER_HUB_IMAGE_TAG= for inter-hub production deploys." >&2; \ - echo "Example: INTER_HUB_IMAGE_TAG=91037a4 make inter-hub-deploy" >&2; \ - exit 1; \ - } - -check-inter-hub-image: check-inter-hub-image-tag ## Verify the inter-hub OCI image tag exists before deploy - tools/check-oci-image.sh "$(INTER_HUB_IMAGE_REF)" - check-core-hub-image-tag: ## Require an explicit core-hub image tag for production deploys @test -n "$(CORE_HUB_IMAGE_TAG)" || { \ echo "Set CORE_HUB_IMAGE_TAG= for core-hub production deploys." >&2; \ @@ -315,43 +294,6 @@ coulomb-social-env-secret: ## Create/patch coulomb-social-env (SECRET_KEY, DATAB coulomb-social-env-secret-dry-run: ## Plan coulomb-social-env keys without applying @bash tools/create-coulomb-social-env-secret.sh --dry-run $(COULOMB_SOCIAL_ENV_SECRET_ARGS) -##@ Inter-Hub - -inter-hub-render-baseline: ## Render checked-in inter-hub values for chart validation only - helm template $(INTER_HUB_RELEASE) $(INTER_HUB_CHART) \ - --namespace $(INTER_HUB_NAMESPACE) \ - -f $(INTER_HUB_VALUES) - -inter-hub-dry-run: check-railiance01-kubeconfig check-inter-hub-image-tag ## helm template render with an explicit production image tag - KUBECONFIG="$(INTER_HUB_KUBECONFIG)" helm template $(INTER_HUB_RELEASE) $(INTER_HUB_CHART) \ - --namespace $(INTER_HUB_NAMESPACE) \ - -f $(INTER_HUB_VALUES) $(INTER_HUB_IMAGE_SET_ARG) - -inter-hub-server-dry-run: check-railiance01-kubeconfig check-inter-hub-image ## Helm server dry-run inter-hub upgrade on Railiance01 - KUBECONFIG="$(INTER_HUB_KUBECONFIG)" helm upgrade --install $(INTER_HUB_RELEASE) $(INTER_HUB_CHART) \ - --namespace $(INTER_HUB_NAMESPACE) --create-namespace \ - -f $(INTER_HUB_VALUES) $(INTER_HUB_IMAGE_SET_ARG) --dry-run=server --timeout 5m - -inter-hub-deploy: check-railiance01-kubeconfig check-inter-hub-image ## Deploy / upgrade inter-hub Helm release on Railiance01 - KUBECONFIG="$(INTER_HUB_KUBECONFIG)" helm upgrade --install $(INTER_HUB_RELEASE) $(INTER_HUB_CHART) \ - --namespace $(INTER_HUB_NAMESPACE) --create-namespace \ - -f $(INTER_HUB_VALUES) $(INTER_HUB_IMAGE_SET_ARG) --wait --timeout 5m - -inter-hub-status: check-railiance01-kubeconfig ## Show inter-hub pod / svc / ingress / cert state on Railiance01 - KUBECONFIG="$(INTER_HUB_KUBECONFIG)" kubectl get pods,svc,ingress,certificate -n $(INTER_HUB_NAMESPACE) -l app=$(INTER_HUB_RELEASE) --ignore-not-found - -inter-hub-release-info: check-railiance01-kubeconfig ## Show inter-hub Helm history, values, deployment, and pods - KUBECONFIG="$(INTER_HUB_KUBECONFIG)" helm history $(INTER_HUB_RELEASE) -n $(INTER_HUB_NAMESPACE) - KUBECONFIG="$(INTER_HUB_KUBECONFIG)" helm get values $(INTER_HUB_RELEASE) -n $(INTER_HUB_NAMESPACE) - KUBECONFIG="$(INTER_HUB_KUBECONFIG)" kubectl describe deploy/$(INTER_HUB_RELEASE) -n $(INTER_HUB_NAMESPACE) - KUBECONFIG="$(INTER_HUB_KUBECONFIG)" kubectl get pods -n $(INTER_HUB_NAMESPACE) -o wide - -inter-hub-smoke: ## Verify public inter-hub v2 route and OpenAPI surface after rollout - INTER_HUB_BASE_URL="$(INTER_HUB_BASE_URL)" tools/inter-hub-smoke.sh - -inter-hub-logs: check-railiance01-kubeconfig ## Tail inter-hub app logs from Railiance01 - KUBECONFIG="$(INTER_HUB_KUBECONFIG)" kubectl logs -n $(INTER_HUB_NAMESPACE) -l app=$(INTER_HUB_RELEASE) -f --tail=50 - ##@ Core Hub core-hub-helm-adopt: check-production-kubeconfig ## Annotate live Core Hub resources for Helm ownership @@ -652,7 +594,7 @@ help: ## Show this help /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 } \ /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST) -.PHONY: check-tools check-sops k8s-server-dry-run apps-pg-status apps-pg-restore-drill check-railiance01-kubeconfig check-inter-hub-image-tag check-inter-hub-image check-vergabe-image-tag check-vergabe-image vergabe-dry-run vergabe-deploy vergabe-ingress-deploy vergabe-status vergabe-migrate vergabe-seed vergabe-superuser vergabe-logs vergabe-db-url-secret check-coulomb-social-image-tag coulomb-social-dry-run coulomb-social-deploy coulomb-social-ingress-deploy coulomb-social-status coulomb-social-logs coulomb-social-env-secret coulomb-social-env-secret-dry-run eso-deploy forgejo-openbao-eso-token-apply forgejo-openbao-store-deploy forgejo-mailer-es-deploy forgejo-mailer-es-status forgejo-dry-run forgejo-server-dry-run forgejo-deploy forgejo-ingress-deploy forgejo-ssh-nodeport-deploy forgejo-status forgejo-smoke forgejo-npm-smoke forgejo-verify forgejo-operator-bootstrap forgejo-runner-registration-sops-bootstrap forgejo-secrets-check forgejo-logs forgejo-runner-registration-deploy forgejo-runner-deploy forgejo-runner-status forgejo-runner-logs inter-hub-render-baseline inter-hub-dry-run inter-hub-server-dry-run inter-hub-deploy inter-hub-status inter-hub-release-info inter-hub-smoke inter-hub-logs reuse-dry-run reuse-deploy reuse-status reuse-smoke reuse-logs reuse-forgejo-webhook reuse-openbao-eso-token-apply reuse-openbao-store-deploy reuse-runtime-es-deploy reuse-runtime-es-status reuse-webhook-smoke policy-nexus-check-binding policy-nexus-check-image policy-nexus-render policy-nexus-server-dry-run policy-nexus-deploy policy-nexus-status policy-nexus-smoke policy-nexus-rollback help +.PHONY: check-tools check-sops k8s-server-dry-run apps-pg-status apps-pg-restore-drill check-railiance01-kubeconfig check-vergabe-image-tag check-vergabe-image vergabe-dry-run vergabe-deploy vergabe-ingress-deploy vergabe-status vergabe-migrate vergabe-seed vergabe-superuser vergabe-logs vergabe-db-url-secret check-coulomb-social-image-tag coulomb-social-dry-run coulomb-social-deploy coulomb-social-ingress-deploy coulomb-social-status coulomb-social-logs coulomb-social-env-secret coulomb-social-env-secret-dry-run eso-deploy forgejo-openbao-eso-token-apply forgejo-openbao-store-deploy forgejo-mailer-es-deploy forgejo-mailer-es-status forgejo-dry-run forgejo-server-dry-run forgejo-deploy forgejo-ingress-deploy forgejo-ssh-nodeport-deploy forgejo-status forgejo-smoke forgejo-npm-smoke forgejo-verify forgejo-operator-bootstrap forgejo-runner-registration-sops-bootstrap forgejo-secrets-check forgejo-logs forgejo-runner-registration-deploy forgejo-runner-deploy forgejo-runner-status forgejo-runner-logs reuse-dry-run reuse-deploy reuse-status reuse-smoke reuse-logs reuse-forgejo-webhook reuse-openbao-eso-token-apply reuse-openbao-store-deploy reuse-runtime-es-deploy reuse-runtime-es-status reuse-webhook-smoke policy-nexus-check-binding policy-nexus-check-image policy-nexus-render policy-nexus-server-dry-run policy-nexus-deploy policy-nexus-status policy-nexus-smoke policy-nexus-rollback help .PHONY: forgejo-secret-scan-template-deploy diff --git a/charts/inter-hub/Chart.yaml b/charts/inter-hub/Chart.yaml deleted file mode 100644 index 23104d3..0000000 --- a/charts/inter-hub/Chart.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: v2 -name: inter-hub -description: | - Interaction Hub Framework reference deployment for Railiance01. -type: application -version: 0.1.0 -appVersion: "0.2.0-alpha.1" -keywords: - - ihf - - inter-hub - - railiance -home: https://forgejo.coulomb.social/coulomb/inter-hub-haskell -sources: - - https://forgejo.coulomb.social/coulomb/inter-hub-haskell -maintainers: - - name: railiance-apps diff --git a/charts/inter-hub/templates/_helpers.tpl b/charts/inter-hub/templates/_helpers.tpl deleted file mode 100644 index f7fa4f1..0000000 --- a/charts/inter-hub/templates/_helpers.tpl +++ /dev/null @@ -1,28 +0,0 @@ -{{/* -Chart name + release name produce a unique resource name. -*/}} -{{- define "interhub.fullname" -}} -{{- $name := default .Chart.Name .Values.nameOverride -}} -{{- printf "%s" $name | trunc 63 | trimSuffix "-" -}} -{{- end -}} - -{{- define "interhub.labels" -}} -app: {{ include "interhub.fullname" . }} -app.kubernetes.io/name: {{ include "interhub.fullname" . }} -app.kubernetes.io/instance: {{ .Release.Name }} -app.kubernetes.io/managed-by: {{ .Release.Service }} -app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} -app.kubernetes.io/part-of: railiance-apps -helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} -{{- end -}} - -{{- define "interhub.selectorLabels" -}} -app: {{ include "interhub.fullname" . }} -{{- end -}} - -{{- define "interhub.image" -}} -{{- if not .Values.image.tag -}} -{{- fail "image.tag is required - pin it in helm/inter-hub-values.yaml or pass --set image.tag=" -}} -{{- end -}} -{{- printf "%s:%s" .Values.image.repository .Values.image.tag -}} -{{- end -}} diff --git a/charts/inter-hub/templates/deployment.yaml b/charts/inter-hub/templates/deployment.yaml deleted file mode 100644 index 0646509..0000000 --- a/charts/inter-hub/templates/deployment.yaml +++ /dev/null @@ -1,59 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "interhub.fullname" . }} - labels: {{- include "interhub.labels" . | nindent 4 }} -spec: - replicas: {{ .Values.replicaCount }} - selector: - matchLabels: {{- include "interhub.selectorLabels" . | nindent 6 }} - strategy: - type: RollingUpdate - rollingUpdate: - maxSurge: 1 - maxUnavailable: 0 - template: - metadata: - labels: {{- include "interhub.labels" . | nindent 8 }} - spec: - securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} - containers: - - name: inter-hub - image: {{ include "interhub.image" . | quote }} - imagePullPolicy: {{ .Values.image.pullPolicy }} - securityContext: {{- toYaml .Values.securityContext | nindent 12 }} - ports: - - name: http - containerPort: {{ .Values.service.targetPort }} - protocol: TCP - envFrom: - - secretRef: - name: {{ .Values.envSecretName | quote }} - {{- if .Values.probes.enabled }} - readinessProbe: - httpGet: - path: {{ .Values.probes.path }} - port: {{ .Values.probes.port }} - initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }} - periodSeconds: {{ .Values.probes.readiness.periodSeconds }} - timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }} - failureThreshold: {{ .Values.probes.readiness.failureThreshold }} - livenessProbe: - httpGet: - path: {{ .Values.probes.path }} - port: {{ .Values.probes.port }} - initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }} - periodSeconds: {{ .Values.probes.liveness.periodSeconds }} - timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }} - failureThreshold: {{ .Values.probes.liveness.failureThreshold }} - {{- end }} - resources: {{- toYaml .Values.resources | nindent 12 }} - {{- with .Values.nodeSelector }} - nodeSelector: {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.affinity }} - affinity: {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.tolerations }} - tolerations: {{- toYaml . | nindent 8 }} - {{- end }} diff --git a/charts/inter-hub/templates/ingress.yaml b/charts/inter-hub/templates/ingress.yaml deleted file mode 100644 index 959b42e..0000000 --- a/charts/inter-hub/templates/ingress.yaml +++ /dev/null @@ -1,28 +0,0 @@ -{{- if .Values.ingress.enabled }} -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: {{ include "interhub.fullname" . }} - labels: {{- include "interhub.labels" . | nindent 4 }} - annotations: - {{- toYaml .Values.ingress.annotations | nindent 4 }} -spec: - ingressClassName: {{ .Values.ingress.className }} - {{- if .Values.ingress.tls }} - tls: - - hosts: - - {{ .Values.ingress.host }} - secretName: {{ include "interhub.fullname" . }}-tls - {{- end }} - rules: - - host: {{ .Values.ingress.host }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: {{ include "interhub.fullname" . }} - port: - number: {{ .Values.service.port }} -{{- end }} diff --git a/charts/inter-hub/templates/service.yaml b/charts/inter-hub/templates/service.yaml deleted file mode 100644 index dd8eac4..0000000 --- a/charts/inter-hub/templates/service.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: {{ include "interhub.fullname" . }} - labels: {{- include "interhub.labels" . | nindent 4 }} -spec: - type: {{ .Values.service.type }} - ports: - - port: {{ .Values.service.port }} - targetPort: {{ .Values.service.targetPort }} - protocol: TCP - name: http - selector: {{- include "interhub.selectorLabels" . | nindent 4 }} diff --git a/charts/inter-hub/values.yaml b/charts/inter-hub/values.yaml deleted file mode 100644 index 2af740e..0000000 --- a/charts/inter-hub/values.yaml +++ /dev/null @@ -1,55 +0,0 @@ -image: - repository: forgejo.coulomb.social/coulomb/inter-hub - tag: "" # required; pinned via helm/inter-hub-values.yaml or --set image.tag= - pullPolicy: IfNotPresent - -replicaCount: 1 - -service: - type: ClusterIP - port: 8000 - targetPort: 8000 - -resources: - requests: - cpu: 250m - memory: 512Mi - limits: - cpu: 1000m - memory: 2Gi - -# Env from the K8s Secret created from inter-hub's SOPS handoff file: -# deploy/railiance/secrets/inter-hub.env.sops.yaml -envSecretName: inter-hub-env - -ingress: - enabled: true - className: traefik - host: hub.coulomb.social - tls: true - annotations: - traefik.ingress.kubernetes.io/router.entrypoints: websecure - traefik.ingress.kubernetes.io/router.tls: "true" - cert-manager.io/cluster-issuer: letsencrypt-prod - -probes: - enabled: true - path: / - port: 8000 - liveness: - initialDelaySeconds: 30 - periodSeconds: 30 - timeoutSeconds: 5 - failureThreshold: 3 - readiness: - initialDelaySeconds: 15 - periodSeconds: 10 - timeoutSeconds: 5 - failureThreshold: 3 - -podSecurityContext: {} -securityContext: {} - -nodeSelector: {} -tolerations: [] -affinity: {} diff --git a/docs/inter-hub-on-railiance01.md b/docs/inter-hub-on-railiance01.md index 482d96d..208be9f 100644 --- a/docs/inter-hub-on-railiance01.md +++ b/docs/inter-hub-on-railiance01.md @@ -7,6 +7,14 @@ deployments on CoulombCore and railiance01 are scaled to `0`. This document preserves the Railiance01 Helm surface for audit only. Do not deploy Inter-Hub to production. +**Removed 2026-10-21 (`RAPPS-WP-0018`, founder-approved wave 2).** The +`charts/inter-hub` chart, `helm/inter-hub-values.yaml`, the `inter-hub-*` Make +targets, `tools/inter-hub-smoke.sh` and the +`.gitea/workflows/inter-hub-production-deploy.yaml` workflow are deleted. The +release (0 replicas, latent 250m/512Mi request) is uninstalled with +`helm uninstall inter-hub -n inter-hub`. The commands below no longer exist; +recover them from git history if ever needed. + ## Hosts and release surface | Server | IP | Role | diff --git a/docs/operator-setup.md b/docs/operator-setup.md index 687e8bc..5e8eb0b 100644 --- a/docs/operator-setup.md +++ b/docs/operator-setup.md @@ -71,7 +71,6 @@ The app-specific targets also accept explicit overrides: ```bash REUSE_KUBECONFIG=~/.kube/config-railiance01 make reuse-status -INTER_HUB_KUBECONFIG=~/.kube/config-railiance01 make inter-hub-status ``` ## SOPS Age Key Bootstrap diff --git a/helm/coulomb-social-values.yaml b/helm/coulomb-social-values.yaml index f0a6cea..bc34981 100644 --- a/helm/coulomb-social-values.yaml +++ b/helm/coulomb-social-values.yaml @@ -38,3 +38,13 @@ persistence: size: 2Gi mountPath: /app/var/content retain: true + +# Wave-2 right-sizing (CSOC-WP-0017-T01, 2026-10-21 node budget): 7d p95 +# 0.4m CPU / 146Mi. Limits stay at the chart defaults (1000m/1Gi). +resources: + requests: + cpu: 15m + memory: 176Mi + limits: + cpu: 1000m + memory: 1Gi diff --git a/helm/inter-hub-values.yaml b/helm/inter-hub-values.yaml deleted file mode 100644 index 160f6af..0000000 --- a/helm/inter-hub-values.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Production overrides for the inter-hub Helm chart. -# Non-secret values only; DATABASE_URL and IHP_SESSION_SECRET come from the -# Secret 'inter-hub-env' in the inter-hub namespace. - -image: - tag: "fde5525" # Render baseline only; production deploy requires INTER_HUB_IMAGE_TAG=. diff --git a/helm/reuse-surface-values.yaml b/helm/reuse-surface-values.yaml index db6781c..0e170e6 100644 --- a/helm/reuse-surface-values.yaml +++ b/helm/reuse-surface-values.yaml @@ -15,8 +15,26 @@ image: repository: forgejo.coulomb.social/coulomb/reuse-surface digest: sha256:2364b7e7859daccf9340a063654e2b09eed1bc27e0adf7b23cbb4b30135a7db0 +# Wave-2 right-sizing (REUSE-WP-0026-T01, 2026-10-21 node budget): pod +# aggregate 110m/288Mi -> 10m/80Mi; 7d p95 2.1m CPU; split by +# per-pod p95 x1.15 (main ~51Mi -> 64Mi, landing ~5Mi -> 16Mi). Limits unchanged. +resources: + requests: + cpu: 5m + memory: 64Mi + limits: + cpu: 500m + memory: 512Mi + landing: enabled: true + resources: + requests: + cpu: 5m + memory: 16Mi + limits: + cpu: 50m + memory: 64Mi title: "REUSE federation endpoint" eyebrow: "reuse.coulomb.social" body: "This is the Railiance REUSE capability federation service. It is an API endpoint for automated clients and operators; there is no browser login for this service." diff --git a/helm/vergabe-demo-company-values.proposed.yaml b/helm/vergabe-demo-company-values.proposed.yaml index 6693145..332a760 100644 --- a/helm/vergabe-demo-company-values.proposed.yaml +++ b/helm/vergabe-demo-company-values.proposed.yaml @@ -11,7 +11,8 @@ replicaCount: 1 resources: requests: cpu: 60m - memory: 256Mi + # Wave-2 (RAPPS-WP-0018-T01): target replay loaded working set 320-480MiB. + memory: 512Mi limits: cpu: 1000m memory: 1Gi diff --git a/registry/capabilities/capability.railiance.workload-deployment-tooling.md b/registry/capabilities/capability.railiance.workload-deployment-tooling.md index 063141c..f9fbfc9 100644 --- a/registry/capabilities/capability.railiance.workload-deployment-tooling.md +++ b/registry/capabilities/capability.railiance.workload-deployment-tooling.md @@ -78,7 +78,6 @@ evidence: - SCOPE.md tests: - tools/forgejo-smoke.sh - - tools/inter-hub-smoke.sh - tools/k8s-server-dry-run.sh consumer_feedback: [] bug_reports: [] diff --git a/tools/inter-hub-smoke.sh b/tools/inter-hub-smoke.sh deleted file mode 100755 index a939be0..0000000 --- a/tools/inter-hub-smoke.sh +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -base_url="${INTER_HUB_BASE_URL:-https://hub.coulomb.social}" -base_url="${base_url%/}" - -tmpdir="$(mktemp -d)" -trap 'rm -rf "$tmpdir"' EXIT - -request() { - local name="$1" - local url="$2" - local expected_status="$3" - local body="$tmpdir/${name}.json" - local status - - status="$(curl -sS -o "$body" -w "%{http_code}" "$url")" - if [[ "$status" != "$expected_status" ]]; then - echo "ERROR: expected $url to return $expected_status, got $status" >&2 - cat "$body" >&2 - echo >&2 - exit 1 - fi - - echo "$body" -} - -hubs_body="$(request hubs "${base_url}/api/v2/hubs" 200)" -python3 - "$hubs_body" <<'PY' -import json -import sys - -with open(sys.argv[1], encoding="utf-8") as fh: - payload = json.load(fh) - -if not isinstance(payload, dict) or not isinstance(payload.get("data"), list): - raise SystemExit("/api/v2/hubs did not return a paginated data list") - -print("ok: /api/v2/hubs returned public discovery JSON") -PY - -widgets_body="$(request widgets "${base_url}/api/v2/widgets" 401)" -hub_registry_body="$(request hub-registry "${base_url}/api/v2/hub-registry" 401)" - -python3 - "$widgets_body" "$hub_registry_body" <<'PY' -import json -import sys - -for path, filename in ( - ("/api/v2/widgets", sys.argv[1]), - ("/api/v2/hub-registry", sys.argv[2]), -): - with open(filename, encoding="utf-8") as fh: - payload = json.load(fh) - code = payload.get("code") if isinstance(payload, dict) else None - if code != "invalid_api_key": - raise SystemExit(f"{path} returned 401 but not invalid_api_key JSON") - print(f"ok: {path} requires an API key") -PY - -openapi_body="$(request openapi "${base_url}/api/v2/openapi.json" 200)" -python3 - "$openapi_body" <<'PY' -import json -import sys - -required_paths = { - "/hubs", - "/hub-capability-manifests", - "/api-consumers", - "/policy-scopes", - "/widgets", - "/hub-registry", -} - -with open(sys.argv[1], encoding="utf-8") as fh: - payload = json.load(fh) - -paths = payload.get("paths") -if not isinstance(paths, dict): - raise SystemExit("/api/v2/openapi.json did not include an OpenAPI paths object") - -missing = sorted(required_paths - set(paths)) -if missing: - raise SystemExit("OpenAPI missing paths: " + ", ".join(missing)) - -print("ok: /api/v2/openapi.json lists expected v2 resources") -PY diff --git a/tools/k8s-server-dry-run.sh b/tools/k8s-server-dry-run.sh index 1b228a2..3ad39fa 100755 --- a/tools/k8s-server-dry-run.sh +++ b/tools/k8s-server-dry-run.sh @@ -8,11 +8,6 @@ VERGABE_RELEASE="${VERGABE_RELEASE:-vergabe-teilnahme}" VERGABE_NAMESPACE="${VERGABE_NAMESPACE:-vergabe-teilnahme}" VERGABE_CHART="${VERGABE_CHART:-charts/vergabe-teilnahme}" VERGABE_VALUES="${VERGABE_VALUES:-helm/vergabe-teilnahme-values.yaml}" -INTER_HUB_RELEASE="${INTER_HUB_RELEASE:-inter-hub}" -INTER_HUB_NAMESPACE="${INTER_HUB_NAMESPACE:-inter-hub}" -INTER_HUB_CHART="${INTER_HUB_CHART:-charts/inter-hub}" -INTER_HUB_VALUES="${INTER_HUB_VALUES:-helm/inter-hub-values.yaml}" -INTER_HUB_IMAGE_TAG="${INTER_HUB_IMAGE_TAG:-}" DRY_RUN_CREATE_NAMESPACES="${DRY_RUN_CREATE_NAMESPACES:-false}" for cmd in kubectl helm; do @@ -33,33 +28,18 @@ fi if [[ "$DRY_RUN_CREATE_NAMESPACES" == "true" ]]; then echo "server dry-run: ensuring namespace $VERGABE_NAMESPACE exists" kubectl create namespace "$VERGABE_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - - echo "server dry-run: ensuring namespace $INTER_HUB_NAMESPACE exists" - kubectl create namespace "$INTER_HUB_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - fi tmpdir="$(mktemp -d)" trap 'rm -rf "$tmpdir"' EXIT -inter_hub_image_args=() -if [[ -n "$INTER_HUB_IMAGE_TAG" ]]; then - inter_hub_image_args=(--set "image.tag=$INTER_HUB_IMAGE_TAG") -fi - helm template "$VERGABE_RELEASE" "$VERGABE_CHART" \ --namespace "$VERGABE_NAMESPACE" \ -f "$VERGABE_VALUES" \ > "$tmpdir/vergabe-teilnahme.yaml" -helm template "$INTER_HUB_RELEASE" "$INTER_HUB_CHART" \ - --namespace "$INTER_HUB_NAMESPACE" \ - -f "$INTER_HUB_VALUES" \ - "${inter_hub_image_args[@]}" \ - > "$tmpdir/inter-hub.yaml" - echo "server dry-run: committed manifests" kubectl apply --dry-run=server -f manifests echo "server dry-run: rendered $VERGABE_RELEASE chart" kubectl apply --dry-run=server -n "$VERGABE_NAMESPACE" -f "$tmpdir/vergabe-teilnahme.yaml" -echo "server dry-run: rendered $INTER_HUB_RELEASE chart" -kubectl apply --dry-run=server -n "$INTER_HUB_NAMESPACE" -f "$tmpdir/inter-hub.yaml" From 9ecb123451717a4d70ec50d490a228c30ea5ba88 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 22 Oct 2026 00:06:57 +0200 Subject: [PATCH 68/87] RAPPS-WP-0018-T01: record wave-2 preparation (not applied) Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 276069@bnt-lap001 Assistant-Session: 2e192b3d-b3e8-4403-94d4-d55a2c8b476c --- workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md b/workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md index d95a795..a0af966 100644 --- a/workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md +++ b/workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md @@ -43,6 +43,10 @@ Source and container detail: - `vergabe-demo-company/vergabe-teilnahme`: Deployment `vergabe-teilnahme` (container `vergabe-teilnahme`), `resources` in `helm/vergabe-demo-company-values.proposed.yaml` (60m/256Mi, limits 1000m/1Gi); the chart default in `charts/vergabe-teilnahme/values.yaml` (100m/256Mi) is not what is deployed. Keep the 60m CPU request and the 1000m/1Gi limits (replay-backed). Raise memory 256Mi → 512Mi: the target replay (`docs/evidence/2026-10-21-vergabe-target-replay/`, commit `1af6d82`, RAPPS-WP-0014-T04) showed a loaded working set of 320–480MiB. +**Prepared 2026-10-21 (wave-2 worker W1a; not applied).** Change: railiance-apps `b7fb7b2`. +- Vergabe: live Helm release `vergabe-teilnahme` in `vergabe-demo-company` (rev 3) matches `helm/vergabe-demo-company-values.proposed.yaml`. Memory request 256Mi → 512Mi for the app and the `migrate` init container, which share `resources`. CPU stays 60m and limits stay 1000m/1Gi. The full render against live adds **drift beyond resources**: the label `railiance.io/release-unit: vergabe` (from commit 64530e1, 2026-10-21) is missing on Service `vergabe-teilnahme` and both PVCs; the Deployment already has it. Because of this the apply comes to the founder. Apply: `helm upgrade vergabe-teilnahme charts/vergabe-teilnahme -n vergabe-demo-company -f helm/vergabe-demo-company-values.proposed.yaml --wait --timeout 5m`. Strategy is Recreate (short outage). +- Inter-Hub: the owner chose removal. Deleted `charts/inter-hub`, `helm/inter-hub-values.yaml`, the `inter-hub-*` Make targets, `tools/inter-hub-smoke.sh` and `.gitea/workflows/inter-hub-production-deploy.yaml`; the inter-hub part of `tools/k8s-server-dry-run.sh` is removed and the docs updated. Live: Deployment (0 replicas, 250m/512Mi), Service and 8 old ReplicaSets, all from Helm release `inter-hub` (rev 2); it has no Ingress. Apply: `helm uninstall inter-hub -n inter-hub`. Afterwards the founder checks for leftover credential objects, which agents do not inspect, before deleting the namespace. A stale rollback artifact `core-hub/k8s/railiance-production/rollback-inter-hub-ingress.yaml` (archived CORE-WP-0005) still points at the release. + Evidence: resource-control `docs/evidence/2026-10-21-node-allocation-review/2026-10-21-node-budget.md` (+ `.json`, commit `3d9b1bd`). Rule: CPU = max(floor, p95x1.5, peakx0.5) rounded up to 5m; memory request = 7d p95 working set x1.15 (only when the change is at least 64Mi). Steps: From cd6cd3835698c6aef7a35daad08f634c61edd1e7 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 22 Oct 2026 00:52:51 +0200 Subject: [PATCH 69/87] Wave-2 right-sizing applied 2026-10-22 (CUST-WP-0071) Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 276069@bnt-lap001 Assistant-Session: 2e192b3d-b3e8-4403-94d4-d55a2c8b476c --- WORK-RECORDS.md | 4 ++-- workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 32450fc..54ea2a7 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -15,7 +15,7 @@ | workplan | RAPPS-WP-0015 | active | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | workplan | RAPPS-WP-0016 | active | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | workplan | RAPPS-WP-0017 | finished | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | -| workplan | RAPPS-WP-0018 | active | — | workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md | +| workplan | RAPPS-WP-0018 | finished | — | workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md | | workplan | RAPPS-WP-0019 | finished | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | | task | RAPPS-WP-0001-T01 | done | — | workplans/RAPPS-WP-0001-cnpg-backup-scheduledbackup-coverage.md | | task | RAPPS-WP-0001-T02 | done | — | workplans/RAPPS-WP-0001-cnpg-backup-scheduledbackup-coverage.md | @@ -53,7 +53,7 @@ | task | RAPPS-WP-0017-T01 | done | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | | task | RAPPS-WP-0017-T02 | done | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | | task | RAPPS-WP-0017-T03 | done | — | workplans/RAPPS-WP-0017-reuse-private-reader-release.md | -| task | RAPPS-WP-0018-T01 | wait | — | workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md | +| task | RAPPS-WP-0018-T01 | done | — | workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md | | task | RAPPS-WP-0019-T01 | done | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | | task | RAPPS-WP-0019-T02 | done | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | | task | RAPPS-WP-0019-T03 | done | — | workplans/RAPPS-WP-0019-product-neutral-forge-object-names.md | diff --git a/workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md b/workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md index a0af966..7a4bbb3 100644 --- a/workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md +++ b/workplans/RAPPS-WP-0018-wave2-resource-right-sizing.md @@ -4,7 +4,7 @@ type: workplan title: "Wave-2 resource right-sizing from the 2026-10-21 allocation review" domain: financials repo: railiance-apps -status: active +status: finished flavor: implementation owner: claude topic_slug: railiance @@ -26,10 +26,8 @@ Origin: the 2026-10-21 node allocation review (`RESOURCE-WP-0008-T03`, feeding ` ```task id: RAPPS-WP-0018-T01 -status: wait +status: done priority: medium -needs_human: true -blocking_reason: "Wave-2 cluster write; founder approval for wave 2 not yet given (2026-10-21 interview)" state_hub_task_id: "48a3c858-c65c-575c-8a97-4122245a1eca" ``` @@ -60,3 +58,7 @@ Verification: `kubectl -n get pod -o jsonpath='{..resources}'` shows the ne Rollback: revert the source commit and re-sync/re-apply; the before values in the table above are the rollback target. Keep before/after `kubectl get -o yaml` captures as the receipt. Inter-Hub: `charts/inter-hub/values.yaml` still defaults to `requests: {cpu: 250m, memory: 512Mi}` (limits 1000m/2Gi), and `helm/inter-hub-values.yaml` does not override it. The release runs at 0 replicas, so the 250m is latent: it is reserved again the moment anything scales it up or the deploy workflow (`.gitea/workflows/inter-hub-production-deploy.yaml`) runs. Inter-Hub is retired (CORE-WP-0007; production `hub.coulomb.social` is Core Hub only). Preferred: remove the inter-hub release and retire its chart and deploy workflow; otherwise lower the default request to the 5m floor so a scale-up cannot reserve 250m. Record which option the owner chose. + +## Applied — 2026-10-22 (RAPPS-WP-0018-T01) + +Applied under the founder's wave-2 authorization (the-custodian docs/decisions/2026-10-21-cust-wp-0071-delivery-interview.md), after a full-render diff against live that showed resources only, plus the founder-approved `railiance.io/release-unit` label. Vergabe memory 512Mi, Helm revision 4. The retired inter-hub release was uninstalled; namespace `inter-hub` is kept until the founder checks what is left in it. Evidence: the-custodian docs/evidence/2026-10-22-wave2-apply.md. From 0a1aba9d623250100550b6abfbb53b2927cc6576 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 22 Oct 2026 00:56:07 +0200 Subject: [PATCH 70/87] Declare the forgejo release unit. Claims Deployment forgejo/forge and Deployment forgejo/forgejo-runner so rapp-forgejo is no longer the declaration authority. Assistant: grok Assistant-Session: 01a1609f-72b8-7222-9dff-fae300115e42 --- registry/release-units/forgejo.yaml | 79 +++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 registry/release-units/forgejo.yaml diff --git a/registry/release-units/forgejo.yaml b/registry/release-units/forgejo.yaml new file mode 100644 index 0000000..df0f241 --- /dev/null +++ b/registry/release-units/forgejo.yaml @@ -0,0 +1,79 @@ +# Source digest is railiance-apps at the commit this declaration was written +# against. Live Helm release name is forgejo. The package registry runs inside +# the forge Deployment; it is not a second workload. Image tags that the live +# Deployment still shows without a digest are not pinned here. +kind: release-unit +unit_id: forgejo +owner: railiance-apps +contract_version: "1.0.0" +data_classification: restricted +criticality: critical +composition: + purpose: "Serves Forgejo git, packages, and the in-cluster Actions runner as one rollout." + member_repos: + - repo: railiance-apps + role: helm values and runner manifest + deployables: + - forge + - forgejo-runner + - repo: railiance-forge + role: forge operating contracts + deployables: + - forge + - forgejo-runner +rollout_contract: + default_mode: helm-upgrade-install +smoke_contract: + required: + - http-ready +rollback_contract: + order: + - helm-rollback +workloads: + - kind: Deployment + namespace: forgejo + name: forge + - kind: Deployment + namespace: forgejo + name: forgejo-runner +pins: + component: + name: ocm.railiance.dev/forgejo + version: "11.0.3" + resources: + - name: forgejo + type: ociImage + version: "11.0.3" + digest: + hashAlgorithm: SHA-256 + normalisationAlgorithm: ociArtifactDigest/v1 + value: "sha256:691657c7e64af7730a2e885e6d022cf16ae60724931c5392bd61838afadcaeba" + - name: forgejo-runner + type: ociImage + version: "11.0.3" + digest: + hashAlgorithm: SHA-256 + normalisationAlgorithm: ociArtifactDigest/v1 + value: "sha256:5071e6832313bafe71577e05631bece88caff08fcfb372193e4a21941f4ed54b" + - name: docker + type: ociImage + version: "11.0.3" + digest: + hashAlgorithm: SHA-256 + normalisationAlgorithm: ociArtifactDigest/v1 + value: "sha256:aa3df78ecf320f5fafdce71c659f1629e96e9de0968305fe1de670e0ca9176ce" + sources: + - name: railiance-apps + type: git + version: "11.0.3" + digest: + hashAlgorithm: SHA-1 + normalisationAlgorithm: gitCommit/v1 + value: "9ecb123451717a4d70ec50d490a228c30ea5ba88" + - name: railiance-forge + type: git + version: "11.0.3" + digest: + hashAlgorithm: SHA-1 + normalisationAlgorithm: gitCommit/v1 + value: "2afdff68b1e22c48c4e7d5f20457f6cc420cb25c" From bf77257d526dccfc000c8a94c0b54d396c657968 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 27 Oct 2026 22:45:21 +0100 Subject: [PATCH 71/87] FORGE-WP-ADHOC-2026-10-27: admit the filename-digest receive scanner The Forgejo template and init guard now include the 20261027 adapter beside the two older admitted binaries. Assistant: grok Assistant-Model: grok-4.7 Assistant-Session: 01a17f2b-8cc3-76f0-9bce-3b3444b9a5e9 --- helm/forgejo-values.yaml | 7 ++----- manifests/forgejo-secret-scan-template.yaml | 14 +++++--------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/helm/forgejo-values.yaml b/helm/forgejo-values.yaml index aeea98b..48fda8f 100644 --- a/helm/forgejo-values.yaml +++ b/helm/forgejo-values.yaml @@ -54,7 +54,7 @@ valkey-cluster: extraVolumes: - name: secret-scan-template configMap: - name: forgejo-secret-template-832f6dc6b809 + name: forgejo-secret-template-d1df801be39f defaultMode: 365 extraContainerVolumeMounts: - &id001 @@ -74,10 +74,7 @@ preExtraInitContainers: command: - sh - -c - - "#!/bin/sh\nset +vx\nset -eu\ntest \"$(id -u)\" = 1000\ntest -d /data && test ! -L /data\ntest -d /data/security && test ! -L /data/security\ntest -d /data/security/receive-secret-scan && test ! -L /data/security/receive-secret-scan\ntest -d /data/security/receive-secret-scan/20261012-57a0642b6251 && test ! -L /data/security/receive-secret-scan/20261012-57a0642b6251\ntest -f /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan && test ! -L /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan\ntest \"$(stat -c %u /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan)\" = 1000\ntest \"$(sha256sum /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan | cut -d \" \" -f 1)\" = 57a0642b625105440fe146fceb4d244fd2dd08a75beb316831675643e6cc27ec\ncase \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan)\" in 755|775) ;; *) exit 21;; esac\ntest -f /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks\ - \ && test ! -L /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks\ntest \"$(stat -c %u /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks)\" = 1000\ntest \"$(sha256sum /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks | cut -d \" \" -f 1)\" = 88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509\ncase \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks)\" in 755|775) ;; *) exit 21;; esac\nif [ \"${1:-check}\" = apply ]; then\n chmod 755 /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan\n test \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan)\" = 755\n chmod 755 /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks\n test \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks)\" = 755\nfi\n\n#!/bin/sh\nset +vx\nset -eu\ntest \"$(id -u)\" = 1000\ntest -d /data && test ! -L /data\ntest -d /data/security\ - \ && test ! -L /data/security\ntest -d /data/security/receive-secret-scan && test ! -L /data/security/receive-secret-scan\ntest -d /data/security/receive-secret-scan/20261018-73ee04078786 && test ! -L /data/security/receive-secret-scan/20261018-73ee04078786\ntest -f /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan && test ! -L /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan\ntest \"$(stat -c %u /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan)\" = 1000\ntest \"$(sha256sum /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan | cut -d \" \" -f 1)\" = 73ee04078786801fc74d80d2bef122b7764c93856e403e8de5b078336d7f9e1a\ncase \"$(stat -c %a /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan)\" in 755|775) ;; *) exit 21;; esac\ntest -f /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks && test ! -L /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks\n\ - test \"$(stat -c %u /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks)\" = 1000\ntest \"$(sha256sum /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks | cut -d \" \" -f 1)\" = 88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509\ncase \"$(stat -c %a /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks)\" in 755|775) ;; *) exit 21;; esac\nif [ \"${1:-check}\" = apply ]; then\n chmod 755 /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan\n test \"$(stat -c %a /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan)\" = 755\n chmod 755 /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks\n test \"$(stat -c %a /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks)\" = 755\nfi\n" + - "#!/bin/sh\nset +vx\nset -eu\ntest \"$(id -u)\" = 1000\ntest -d /data && test ! -L /data\ntest -d /data/security && test ! -L /data/security\ntest -d /data/security/receive-secret-scan && test ! -L /data/security/receive-secret-scan\ntest -d /data/security/receive-secret-scan/20261012-57a0642b6251 && test ! -L /data/security/receive-secret-scan/20261012-57a0642b6251\ntest -f /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan && test ! -L /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan\ntest \"$(stat -c %u /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan)\" = 1000\ntest \"$(sha256sum /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan | cut -d \" \" -f 1)\" = 57a0642b625105440fe146fceb4d244fd2dd08a75beb316831675643e6cc27ec\ncase \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan)\" in 755|775) ;; *) exit 21;; esac\ntest -f /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks && test ! -L /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks\ntest \"$(stat -c %u /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks)\" = 1000\ntest \"$(sha256sum /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks | cut -d \" \" -f 1)\" = 88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509\ncase \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks)\" in 755|775) ;; *) exit 21;; esac\nif [ \"${1:-check}\" = apply ]; then\n chmod 755 /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan\n test \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/receive-secret-scan)\" = 755\n chmod 755 /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks\n test \"$(stat -c %a /data/security/receive-secret-scan/20261012-57a0642b6251/gitleaks)\" = 755\nfi\n\n#!/bin/sh\nset +vx\nset -eu\ntest \"$(id -u)\" = 1000\ntest -d /data && test ! -L /data\ntest -d /data/security && test ! -L /data/security\ntest -d /data/security/receive-secret-scan && test ! -L /data/security/receive-secret-scan\ntest -d /data/security/receive-secret-scan/20261018-73ee04078786 && test ! -L /data/security/receive-secret-scan/20261018-73ee04078786\ntest -f /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan && test ! -L /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan\ntest \"$(stat -c %u /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan)\" = 1000\ntest \"$(sha256sum /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan | cut -d \" \" -f 1)\" = 73ee04078786801fc74d80d2bef122b7764c93856e403e8de5b078336d7f9e1a\ncase \"$(stat -c %a /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan)\" in 755|775) ;; *) exit 21;; esac\ntest -f /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks && test ! -L /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks\ntest \"$(stat -c %u /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks)\" = 1000\ntest \"$(sha256sum /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks | cut -d \" \" -f 1)\" = 88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509\ncase \"$(stat -c %a /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks)\" in 755|775) ;; *) exit 21;; esac\nif [ \"${1:-check}\" = apply ]; then\n chmod 755 /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan\n test \"$(stat -c %a /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan)\" = 755\n chmod 755 /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks\n test \"$(stat -c %a /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks)\" = 755\nfi\n\n#!/bin/sh\nset +vx\nset -eu\ntest \"$(id -u)\" = 1000\ntest -d /data && test ! -L /data\ntest -d /data/security && test ! -L /data/security\ntest -d /data/security/receive-secret-scan && test ! -L /data/security/receive-secret-scan\ntest -d /data/security/receive-secret-scan/20261027-b735d052a49f && test ! -L /data/security/receive-secret-scan/20261027-b735d052a49f\ntest -f /data/security/receive-secret-scan/20261027-b735d052a49f/receive-secret-scan && test ! -L /data/security/receive-secret-scan/20261027-b735d052a49f/receive-secret-scan\ntest \"$(stat -c %u /data/security/receive-secret-scan/20261027-b735d052a49f/receive-secret-scan)\" = 1000\ntest \"$(sha256sum /data/security/receive-secret-scan/20261027-b735d052a49f/receive-secret-scan | cut -d \" \" -f 1)\" = b735d052a49fc8e9cca14cddee074c5429352ad71841eb6548459294f15683a2\ncase \"$(stat -c %a /data/security/receive-secret-scan/20261027-b735d052a49f/receive-secret-scan)\" in 755|775) ;; *) exit 21;; esac\ntest -f /data/security/receive-secret-scan/20261027-b735d052a49f/gitleaks && test ! -L /data/security/receive-secret-scan/20261027-b735d052a49f/gitleaks\ntest \"$(stat -c %u /data/security/receive-secret-scan/20261027-b735d052a49f/gitleaks)\" = 1000\ntest \"$(sha256sum /data/security/receive-secret-scan/20261027-b735d052a49f/gitleaks | cut -d \" \" -f 1)\" = 88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509\ncase \"$(stat -c %a /data/security/receive-secret-scan/20261027-b735d052a49f/gitleaks)\" in 755|775) ;; *) exit 21;; esac\nif [ \"${1:-check}\" = apply ]; then\n chmod 755 /data/security/receive-secret-scan/20261027-b735d052a49f/receive-secret-scan\n test \"$(stat -c %a /data/security/receive-secret-scan/20261027-b735d052a49f/receive-secret-scan)\" = 755\n chmod 755 /data/security/receive-secret-scan/20261027-b735d052a49f/gitleaks\n test \"$(stat -c %a /data/security/receive-secret-scan/20261027-b735d052a49f/gitleaks)\" = 755\nfi\n" - sh - apply securityContext: diff --git a/manifests/forgejo-secret-scan-template.yaml b/manifests/forgejo-secret-scan-template.yaml index f76dfb9..14ea2cd 100644 --- a/manifests/forgejo-secret-scan-template.yaml +++ b/manifests/forgejo-secret-scan-template.yaml @@ -1,20 +1,16 @@ apiVersion: v1 kind: ConfigMap metadata: - name: forgejo-secret-template-832f6dc6b809 + name: forgejo-secret-template-d1df801be39f namespace: forgejo labels: app.kubernetes.io/part-of: forgejo - custodian.coulomb.social/workplan: FORGE-WP-0010 + custodian.coulomb.social/workplan: FORGE-WP-ADHOC-2026-10-27 immutable: true data: - hook: '#!/bin/sh - + hook: | + #!/bin/sh set +vx - set -eu + exec /data/security/receive-secret-scan/20261027-b735d052a49f/receive-secret-scan --scanner /data/security/receive-secret-scan/20261027-b735d052a49f/gitleaks - exec /data/security/receive-secret-scan/20261018-73ee04078786/receive-secret-scan - --scanner /data/security/receive-secret-scan/20261018-73ee04078786/gitleaks - - ' From 8434134391f2b7715fbeefd965edbd4bcd588910 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 01:33:51 +0100 Subject: [PATCH 72/87] Record founder-verified Coulomb storage release revision 18 Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- .../2026-10-31-coulomb-storage-release.json | 68 +++++++++++++++++++ helm/coulomb-social-values.yaml | 4 +- 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 docs/evidence/2026-10-31-coulomb-storage-release.json diff --git a/docs/evidence/2026-10-31-coulomb-storage-release.json b/docs/evidence/2026-10-31-coulomb-storage-release.json new file mode 100644 index 0000000..ee3265a --- /dev/null +++ b/docs/evidence/2026-10-31-coulomb-storage-release.json @@ -0,0 +1,68 @@ +{ + "completed_at": "2026-10-31T00:29:28.113460+00:00", + "elapsed_seconds": 99.527, + "fresh_paired_point": { + "age_recipient": "age1qm30jk8q0cezey652dk2dhcr3d9vs528v6lg8mavl6d4q3e3vgnqmrmyy3", + "application": "coulomb-social", + "application_restore_verified": false, + "captured_at": "2026-10-31T00:27:56Z", + "ciphertext_bytes": 2181848, + "ciphertext_sha256": "f7be502c7e122283e70b10d2fd8a2cbf9550d2261192ba11f94d945a9f39af51", + "completed_at": "2026-10-31T00:28:53.504056+00:00", + "content_sha256": "22b694a56178a70274df55bdfc60f2cc0fe24ec66563c2e952058e5d8cf2154a", + "database_sha256": "99e704023d0832aa29dcfd7695dd436dea58038ae48e3ab142d0ec2b916eb574", + "destination": "s3://railiance-platform-pg-backup/platform-pg/application-archives/coulomb-social/2026/10/31/20261031T002756Z-c31b84b48b3874bdaa87cbf5.tar.age", + "encrypted_bytes": 2181848, + "encrypted_sha256": "f7be502c7e122283e70b10d2fd8a2cbf9550d2261192ba11f94d945a9f39af51", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:9a646d8e01a61ba5316d3c2eb0273064e17f2d0300ad07af766ccedda65ecb18", + "immutable_create": true, + "local_archive": "20261031T002756Z-c31b84b48b3874bdaa87cbf5.tar.age", + "object_key": "platform-pg/application-archives/coulomb-social/2026/10/31/20261031T002756Z-c31b84b48b3874bdaa87cbf5.tar.age", + "pause_and_capture_seconds": 56.63, + "remote_retrieval_verified": true, + "schema": "platform.application-primary-archive.v1", + "status": "offsite_fetched_verified", + "version_id": "1793406532815253" + }, + "global_constraint_applied": true, + "helm_revision": 18, + "node_request_percent": { + "cpu": 68, + "memory": 76 + }, + "pid": 2724118, + "postflight": { + "actual_owner_pages_rendered": 40, + "business_rows": 92, + "business_rows_sha256": "b65c4a32d554d741569df5622c09f31423f6c18aa1c4edc5154c4a95e3511508", + "business_tables": 12, + "content_files": 86, + "content_files_sha256": "fd9ea2fa6890ad960e45dff308713ccd1aa303c502dd0ceb0cc26908326216a4", + "duplicate_slug_groups": 0, + "global_slug_constraint": true, + "migration_0003_applied": true, + "production_identity_or_grant_created": false, + "tenant_slug_constraint": true, + "unsaved_outsider_page_denials": 40 + }, + "preflight": { + "business_rows": 92, + "business_rows_sha256": "b65c4a32d554d741569df5622c09f31423f6c18aa1c4edc5154c4a95e3511508", + "business_tables": 12, + "content_files": 86, + "content_files_sha256": "fd9ea2fa6890ad960e45dff308713ccd1aa303c502dd0ceb0cc26908326216a4", + "duplicate_slug_groups": 0, + "global_slug_constraint": false, + "migration_0003_applied": false, + "tenant_slug_constraint": true + }, + "private_values_printed": false, + "production_data_restored": false, + "proposal_digest": "sha256:5fd7ccd8f68057346367d81efb82b48280a16dd31d87683a5bd83e60e3d1837e", + "public_health_landing_static_verified": true, + "schema": "coulomb.guarded-storage-release.v1", + "snapshot_pin_refreshed": true, + "started_at": "2026-10-31T00:27:48.578472+00:00", + "status": "storage_candidate_deployed_verified", + "timers_activated": false +} diff --git a/helm/coulomb-social-values.yaml b/helm/coulomb-social-values.yaml index bc34981..48b243c 100644 --- a/helm/coulomb-social-values.yaml +++ b/helm/coulomb-social-values.yaml @@ -3,8 +3,8 @@ image: repository: forgejo.coulomb.social/coulomb/coulomb-social - tag: "dd83c90" - digest: "sha256:9a646d8e01a61ba5316d3c2eb0273064e17f2d0300ad07af766ccedda65ecb18" + tag: "17d0eb6" + digest: "sha256:512819463a1902b43a615beee14be7bf97e6a05e016385082b9380692ad1a8cd" # Public host: app.coulomb.social (parallel to Bubble on apex coulomb.social). # Apex cutover is deferred until data + self-registration are ready. From 84a5aa0c9281bf2ef6b8e8f734998731cea9025b Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 04:47:32 +0100 Subject: [PATCH 73/87] Prepare supervised Coulomb identity and export image release Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- WORK-RECORDS.md | 1 + docs/coulomb-image-release-2026-10-31.md | 38 ++ ...10-31-coulomb-image-release-preflight.json | 25 + release/coulomb-identity-export-23ffe2c.json | 51 ++ ...ity-export-candidate-build-2026-10-31.json | 95 ++++ ...xport-candidate-provenance-2026-10-31.json | 20 + ...-export-candidate-recovery-2026-10-31.json | 63 +++ ...ity-export-candidate-tests-2026-10-31.json | 17 + release/identity-export-snapshot.py | 175 ++++++ release/identity-export-values.yaml | 50 ++ tests/test_coulomb_image_release.py | 194 +++++++ tools/coulomb-image-release.py | 185 ++++++ tools/coulomb_image_probe.py | 134 +++++ tools/coulomb_image_release_node.py | 525 ++++++++++++++++++ ...P-0015-coulomb-personal-content-release.md | 44 +- 15 files changed, 1616 insertions(+), 1 deletion(-) create mode 100644 docs/coulomb-image-release-2026-10-31.md create mode 100644 docs/evidence/2026-10-31-coulomb-image-release-preflight.json create mode 100644 release/coulomb-identity-export-23ffe2c.json create mode 100644 release/identity-export-evidence/identity-export-candidate-build-2026-10-31.json create mode 100644 release/identity-export-evidence/identity-export-candidate-provenance-2026-10-31.json create mode 100644 release/identity-export-evidence/identity-export-candidate-recovery-2026-10-31.json create mode 100644 release/identity-export-evidence/identity-export-candidate-tests-2026-10-31.json create mode 100644 release/identity-export-snapshot.py create mode 100644 release/identity-export-values.yaml create mode 100644 tests/test_coulomb_image_release.py create mode 100644 tools/coulomb-image-release.py create mode 100644 tools/coulomb_image_probe.py create mode 100644 tools/coulomb_image_release_node.py diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 54ea2a7..dc3c219 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -41,6 +41,7 @@ | task | RAPPS-WP-0015-T02 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T03 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T04 | wait | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | +| task | RAPPS-WP-0015-T05 | progress | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0016-T01 | done | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T02 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T03 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | diff --git a/docs/coulomb-image-release-2026-10-31.md b/docs/coulomb-image-release-2026-10-31.md new file mode 100644 index 0000000..2a16234 --- /dev/null +++ b/docs/coulomb-image-release-2026-10-31.md @@ -0,0 +1,38 @@ +# Prepared Coulomb identity/export image release + +The exact supervised proposal is +`sha256:9f1daf6c5fb56b2b4fc9817f28f5ad090c884b0295f7d6f4469e42d48fa8d59f`. +Open decision: `75b94bb6-4709-495f-91fe-43e53e2867ed`. +The full founder packet is +[coulomb-social release review](/home/worsch/coulomb-social/docs/release/identity-export-release-review-2026-10-31.md). + +`tools/coulomb-image-release.py` prepares the capsule by default. `--preflight` +checks the live target without capture or deployment. `--execute --expect-digest` +requires the founder's own interactive terminal and the exact typed proposal. +The node controller freezes verified source bytes into its private durable +journal before execution. An existing attempt refuses blind replay. + +This distinct controller adapts the previously reviewed storage controller +from owner source 45d6178. It requires migration 0003 and the global constraint +already present, adds exact migration-history equality, verifies installed +helpers during preflight, verifies the desired complete spec through an image-only +server-dry-run patch and verifies rollback readiness/specification. Unknown +helper changes refuse overwrite. Historical storage commands/capsules remain +unchanged and must not be replayed. + +Candidate values live in `release/identity-export-values.yaml`; the current +`helm/coulomb-social-values.yaml` remains pinned to the actual revision 18 image. +The new snapshot-helper bytes are separate review material until execution. +No unit/timer, sender, grant, apex or data-restore operation is included. + +Validation: 19 release safety cases pass; Ruff, Python compile and Helm lint pass. +The sealed native preflight is retained in +`docs/evidence/2026-10-31-coulomb-image-release-preflight.json`. +Four hash-bound candidate evidence files travel inside the capsule. The product +review names source 441 tests, actual packaged 25 tests, all 80 packaged source matches +and the retained isolated recovery/export/view checks with their limits. + +After founder execution, inspect the durable receipt and independent current +state before reconciling the live values and snapshot pin. The product then +mirrors/retrieves the newly captured point and verifies packaged export on that +coherent post-correction data. Source publication alone does not finish T05. diff --git a/docs/evidence/2026-10-31-coulomb-image-release-preflight.json b/docs/evidence/2026-10-31-coulomb-image-release-preflight.json new file mode 100644 index 0000000..0402fbf --- /dev/null +++ b/docs/evidence/2026-10-31-coulomb-image-release-preflight.json @@ -0,0 +1,25 @@ +{ + "schema": "coulomb.guarded-image-release.v1", + "proposal_digest": "sha256:9f1daf6c5fb56b2b4fc9817f28f5ad090c884b0295f7d6f4469e42d48fa8d59f", + "started_at": "2026-10-31T03:41:35.362323+00:00", + "status": "live_release_preflight_verified", + "production_data_restored": false, + "timers_activated": false, + "node_request_percent": { + "cpu": 68, + "memory": 76 + }, + "preflight": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 92, + "business_rows_sha256": "6243955479f7861fcdcd79b62f7f1e9f9a27c05a17bd2c17b792ebc98b2fa7b6", + "content_files": 86, + "content_files_sha256": "ed535246f4e8f46fcd7ce01f936127996aff873c6936b297f7272194b594aeda", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0 + }, + "live_changes": false +} \ No newline at end of file diff --git a/release/coulomb-identity-export-23ffe2c.json b/release/coulomb-identity-export-23ffe2c.json new file mode 100644 index 0000000..c89ca3d --- /dev/null +++ b/release/coulomb-identity-export-23ffe2c.json @@ -0,0 +1,51 @@ +{ + "schema": "coulomb.reviewed-image-release.v1", + "proposal_id": "CSOC-IDENTITY-EXPORT-20261031", + "scope": "ADMINISTER @ realm:kubernetes/railiance01; supervised founder execution only", + "operation": "image_only_existing_migration_0003", + "cluster_uid": "a553c742-0115-43d4-99a4-a5ca56fe0786", + "baseline_revision": 18, + "baseline_helm_manifest_sha256": "1f74302e7be7211b3ae8728f58ef6b8ec243ffa342306b25bbf42dd6f27b5ef7", + "candidate_render_sha256": "98ffff162a17a9ff4b3922364d002ddbc571f9c79af95191c84d7c0b47c9a16f", + "deployment_uid": "2dbe661a-d232-4477-bbd1-833813077fbe", + "pvc_uid": "9888abeb-9d52-4c70-a23d-b5494780d9c3", + "baseline_spec_sha256": "013aca79ec910b52ed2c2df7a18050c436751005320d1dec616590962da30b8e", + "candidate_spec_sha256": "42e091e52d5dae3c1f986e4898c62057fa80846adfce049a75d8fbd4681180ea", + "old_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:512819463a1902b43a615beee14be7bf97e6a05e016385082b9380692ad1a8cd", + "candidate_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "product_source": "23ffe2cf4b214666754a2420558943438e54251a", + "expected_data": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 92, + "business_rows_sha256": "6243955479f7861fcdcd79b62f7f1e9f9a27c05a17bd2c17b792ebc98b2fa7b6", + "content_files": 86, + "content_files_sha256": "ed535246f4e8f46fcd7ce01f936127996aff873c6936b297f7272194b594aeda", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0 + }, + "public_recipient": "age1qm30jk8q0cezey652dk2dhcr3d9vs528v6lg8mavl6d4q3e3vgnqmrmyy3", + "installed_helper_hashes": { + "/opt/coulomb-social-backup/paired-backup.py": "43e119acb6a4933b4e144c349a091e074e1a91ab61dbe15a65c8264c1d47661a", + "/opt/coulomb-social-backup/snapshot-content-and-db.py": "4ff81b791daf6c8b976a4f89ef152e52f385fc1656986e2ea28bf555b9bd0d8a", + "/opt/railiance-platform/scripts/primary_application_archive.py": "a3f0a61e869a6012edb4b5288be14e3b0c4a9a1273b090320ab146d12e5aa94d" + }, + "clipboard_static_sha256": "347eabe0c8d93bf439df02b5a08a7ea71004600e6ff4e8a42cecbee52a9f0413", + "fresh_backup_before_helm": true, + "requires_dataset_equality_before_and_after": true, + "node_request_percent_limit": 90, + "helm_atomic_timeout_seconds": 300, + "rollback": "Restore Helm revision18/image and verified snapshot pin; preserve all post-capture data. No production data restore or schema downgrade.", + "approval_status": "prepared_not_admitted", + "recovery_rehearsal_scope": "retained October30 point; fresh post-correction primary backup/readback occurs before Helm; independent retrieval/export of that new point remains subsequent consumer acceptance", + "rehearsal_evidence_sha256": { + "identity-export-candidate-build-2026-10-31.json": "12e8709529f27323274b40c68bd701c39e1cd2aa7428f42e061d8b5c2d410176", + "identity-export-candidate-provenance-2026-10-31.json": "71a8e1b2e60d0da6948fb74d9d1be8a8b892cef4be0d6dd0b98fa45d8983551c", + "identity-export-candidate-recovery-2026-10-31.json": "ccfe90ff68ecc80c02ef673a1d1efd21291e7c829c1dc17dc8bd0c902b9cb8b1", + "identity-export-candidate-tests-2026-10-31.json": "bd9d7535f42c7fe79752b46672f8da67492baf0dd06893b8a3288ac9eb3edaae" + }, + "new_resources_committed": 0, + "autopilot_or_eur_budget_claimed": false +} diff --git a/release/identity-export-evidence/identity-export-candidate-build-2026-10-31.json b/release/identity-export-evidence/identity-export-candidate-build-2026-10-31.json new file mode 100644 index 0000000..51aa01b --- /dev/null +++ b/release/identity-export-evidence/identity-export-candidate-build-2026-10-31.json @@ -0,0 +1,95 @@ +{ + "schema": "coulomb.identity-export-candidate-build.v1", + "source": "23ffe2cf4b214666754a2420558943438e54251a", + "tag": "forgejo.coulomb.social/coulomb/coulomb-social:23ffe2cf4b21", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "registry_digest_independently_verified": true, + "source_revision_label_verified": true, + "build_context": "git archive of exact source; Dockerfile, dockerignore, lock, project, manage.py, app and entrypoint only", + "build_inputs": { + "Dockerfile": "d0e197a18db8980eb3e51cca842d693e10ab0b4208627bba28691d10719d4a00", + ".dockerignore": "901c8b95d95549d0e78c1c3d6d0538bf0252c8a77e9409129e0cadfa55c7c56c", + "pyproject.toml": "5758c56fd1be89b973861ccc5ecbadb495e742ee13ebd7e19a221a0f119c54ee", + "uv.lock": "e8ebb6fc4b5abd1ac80f1c9012f1983642d0528a8a213cfcc394261606fad887", + "scripts/docker-entrypoint.sh": "70100256ca41ae6afe77078c810a8b6012472f0a741919f403f32088f98ab463" + }, + "runtime": { + "live": { + "python": "3.12.16", + "distributions": { + "anyio": "4.14.2", + "asgiref": "3.12.1", + "authlib": "1.7.2", + "bleach": "6.4.0", + "certifi": "2026.7.22", + "cffi": "2.1.1", + "cryptography": "50.0.0", + "dj-database-url": "3.1.2", + "django": "6.1", + "gunicorn": "26.0.0", + "h11": "0.16.0", + "httpcore": "1.0.9", + "httpx": "0.28.1", + "idna": "3.18", + "joserfc": "1.7.4", + "markdown": "3.10.3", + "packaging": "26.3", + "psycopg": "3.3.4", + "psycopg-binary": "3.3.4", + "pycparser": "3.0", + "python-decouple": "3.8", + "sqlparse": "0.5.5", + "typing_extensions": "4.16.0", + "webencodings": "0.5.1", + "whitenoise": "6.12.0" + } + }, + "candidate": { + "python": "3.12.16", + "distributions": { + "anyio": "4.14.2", + "asgiref": "3.12.1", + "authlib": "1.7.2", + "bleach": "6.4.0", + "certifi": "2026.7.22", + "cffi": "2.1.1", + "cryptography": "50.0.0", + "dj-database-url": "3.1.2", + "django": "6.1", + "gunicorn": "26.0.0", + "h11": "0.16.0", + "httpcore": "1.0.9", + "httpx": "0.28.1", + "idna": "3.18", + "joserfc": "1.7.4", + "markdown": "3.10.3", + "packaging": "26.3", + "psycopg": "3.3.4", + "psycopg-binary": "3.3.4", + "pycparser": "3.0", + "python-decouple": "3.8", + "sqlparse": "0.5.5", + "typing_extensions": "4.16.0", + "webencodings": "0.5.1", + "whitenoise": "6.12.0" + } + }, + "runtime_packages_equal": true, + "python_versions_equal": true + }, + "production_deployed": false, + "live_release_pins_modified": false, + "new_database_migrations": false, + "not_an_offboarding_or_transfer_acceptance": true, + "source_manifest_sha256": "a90b0217c6424a89e166a7df514fe4a86c9643b502ecdc9ebf0b64b76c06b739", + "packaged_django_check": "passed", + "packaged_migration_drift_check": "passed_no_changes_detected", + "source_gate": { + "command": "make check", + "tests": 441, + "failures": 0, + "errors": 0, + "skipped": 0, + "junit_sha256": "1e5b3c63d85a23f176420f2004831b59406d294c1528b3be80d49669edbb116f" + } +} diff --git a/release/identity-export-evidence/identity-export-candidate-provenance-2026-10-31.json b/release/identity-export-evidence/identity-export-candidate-provenance-2026-10-31.json new file mode 100644 index 0000000..2b769b0 --- /dev/null +++ b/release/identity-export-evidence/identity-export-candidate-provenance-2026-10-31.json @@ -0,0 +1,20 @@ +{ + "schema": "coulomb.packaged-source-provenance.v1", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "network": "none", + "read_only_root": true, + "entrypoint_bypassed": true, + "database_contact": false, + "production_changes": false, + "comparisons": [ + { + "commit": "23ffe2cf4b214666754a2420558943438e54251a", + "packaged_file_count": 80, + "source_file_count": 80, + "different_files": 0, + "all_packaged_source_bytes_match": true + } + ], + "actual_packaged_manifest_sha256": "6b5a7b339d88f3c5eafb70a33aa28a7d3972988645b246ae1a92a3f159d72528", + "dependency_or_build_reproducibility_proved": false +} diff --git a/release/identity-export-evidence/identity-export-candidate-recovery-2026-10-31.json b/release/identity-export-evidence/identity-export-candidate-recovery-2026-10-31.json new file mode 100644 index 0000000..6714caf --- /dev/null +++ b/release/identity-export-evidence/identity-export-candidate-recovery-2026-10-31.json @@ -0,0 +1,63 @@ +{ + "schema": "coulomb.packaged-identity-export-recovery.v1", + "status": "packaged_recovered_client_export_and_views_verified", + "source": "23ffe2cf4b214666754a2420558943438e54251a", + "image_id": "sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "restore_receipt_sha256": "b648348780f87f81094b40876a6b0ebabaefff7986cf20fb426c3d7456a0bc84", + "client_export": { + "status": "recovered_client_export_verified", + "schema": "coulomb.client-dataset-export.v1", + "content_files": 85, + "records": { + "members.user": 1, + "members.member": 1, + "spaces.space": 1, + "spaces.spacemembership": 1 + }, + "admin_events": 0, + "archive_sha256": "8efa82e7c29c95840d61d1290e20cac4b8acf5ae33d2c5b632382a7f6b779c37", + "offboarding_complete": false, + "all_source_tables_compared": 13, + "all_source_files_compared": 86, + "archive_member_hashes_verified": true, + "selected_client_content_complete_locally": true, + "source_sql_and_content_unchanged": true, + "external_bindings_require_review": 0, + "sessions_and_credentials_excluded": true + }, + "candidate_application": { + "status": "isolated_application_recovery_verified", + "recovered_spaces_with_content": 1, + "actual_pages_rendered_through_view": 40, + "restored_owner_access_verified": true, + "synthetic_viewer_reads": 1, + "synthetic_viewer_mutation_denials": 1, + "same_tenant_ungranted_page_denials": 40, + "fixture_transaction_rolled_back": true, + "production_access": false, + "external_identity_provider_used": false, + "real_browser_journey_verified": false + }, + "previous_image_application": { + "status": "isolated_application_recovery_verified", + "recovered_spaces_with_content": 1, + "actual_pages_rendered_through_view": 40, + "restored_owner_access_verified": true, + "synthetic_viewer_reads": 1, + "synthetic_viewer_mutation_denials": 1, + "same_tenant_ungranted_page_denials": 40, + "fixture_transaction_rolled_back": true, + "production_access": false, + "external_identity_provider_used": false, + "real_browser_journey_verified": false + }, + "source_content_files_unchanged": 86, + "application_source_overlay": false, + "export_archive_removed": true, + "network_internal": true, + "published_ports": 0, + "production_access": false, + "production_restore": false, + "post_date_correction_data_claimed": false, + "driver_sha256": "9c370b6e7e1acb442cb660cc3709e3ffd3676c87419a217a7656174c8a347f95" +} diff --git a/release/identity-export-evidence/identity-export-candidate-tests-2026-10-31.json b/release/identity-export-evidence/identity-export-candidate-tests-2026-10-31.json new file mode 100644 index 0000000..bda3298 --- /dev/null +++ b/release/identity-export-evidence/identity-export-candidate-tests-2026-10-31.json @@ -0,0 +1,17 @@ +{ + "schema": "coulomb.packaged-identity-export-tests.v1", + "source": "23ffe2cf4b214666754a2420558943438e54251a", + "image_id": "sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "tests": 25, + "failures": 0, + "errors": 0, + "skipped": 0, + "network": "none", + "read_only_root": true, + "application_source_overlay": false, + "test_tooling_injected": true, + "application_modules_from_image_verified": true, + "production_access": false, + "junit_sha256": "85aac22d33a101d68e478f41f61bc3c7e7adb4f3d7f893a2810f7eb25fce5a39", + "driver_sha256": "a19f94f3dbc71dfd80a2d380f03e5f737c7453b64df022b6e5c2ede8141976b3" +} diff --git a/release/identity-export-snapshot.py b/release/identity-export-snapshot.py new file mode 100644 index 0000000..a928c1b --- /dev/null +++ b/release/identity-export-snapshot.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Node-side quiesced coulomb-social snapshot. Binary output stays private. + +Run on railiance01 after reviewed live approval. Does not restore production. +Default is server dry-run; --execute briefly pauses the app and captures a +paired pg_dump/content archive. A separate isolated restore must validate it. +""" +from __future__ import annotations + +import argparse +import fcntl +import hashlib +import json +import os +import signal +import stat +import subprocess +import sys +import time +from pathlib import Path + +NS = 'coulomb-social' +IMAGE = ('forgejo.coulomb.social/coulomb/coulomb-social@sha256:' + '522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed') +CLUSTER_UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786' +LOCK_PATH = Path('/var/lib/coulomb-social-backup/snapshot.lock') + + +class Refused(RuntimeError): + pass + + +def run(args: list[str], *, payload: bytes | None = None, timeout: int = 180) -> bytes: + p = subprocess.run(args, input=payload, capture_output=True, timeout=timeout) + if p.returncode: + raise Refused('command_failed') + return p.stdout + + +def k(*args: str, payload: bytes | None = None) -> bytes: + return run(['kubectl', *args], payload=payload) + + +def document(*args: str) -> dict: + return json.loads(k(*args, '-o', 'json')) + + +def private_write(path: Path, value: bytes) -> None: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, 'wb') as f: + f.write(value) + + +def snapshot() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--execute', action='store_true') + parser.add_argument('--destination', type=Path, required=True) + args = parser.parse_args() + os.environ['KUBECONFIG'] = '/etc/rancher/k3s/k3s.yaml' + started = time.monotonic() + if document('get', 'namespace', 'kube-system')['metadata']['uid'] != CLUSTER_UID: + raise Refused('wrong_cluster') + dep = document('get', 'deployment', 'coulomb-social', '-n', NS) + spec = dep['spec']['template']['spec'] + if dep['spec']['replicas'] != 1 or spec['containers'][0]['image'] != IMAGE: + raise Refused('unexpected_release') + if document('get', 'pvc', 'coulomb-social-content', '-n', NS)['status']['phase'] != 'Bound': + raise Refused('pvc_not_bound') + cluster = document('get', 'cluster', 'apps-pg', '-n', 'databases') + primary = cluster['status']['currentPrimary'] + name = 'csoc-paired-snapshot-' + time.strftime('%Y%m%d%H%M%S', time.gmtime()) + pod = { + 'apiVersion': 'v1', 'kind': 'Pod', + 'metadata': {'name': name, 'namespace': NS, 'labels': {'csoc-recovery': name}}, + 'spec': { + 'restartPolicy': 'Never', 'automountServiceAccountToken': False, + 'securityContext': {'runAsNonRoot': True, 'runAsUser': 10001, + 'runAsGroup': 10001, 'fsGroup': 10001, + 'fsGroupChangePolicy': 'OnRootMismatch'}, + 'containers': [{'name': 'snapshot', 'image': IMAGE, + 'command': ['python', '-c', 'import time;time.sleep(1800)'], + 'resources': {'requests': {'cpu': '10m', 'memory': '64Mi'}, + 'limits': {'cpu': '100m', 'memory': '256Mi'}}, + 'securityContext': {'allowPrivilegeEscalation': False, + 'readOnlyRootFilesystem': True, + 'capabilities': {'drop': ['ALL']}}, + 'volumeMounts': [{'name': 'content', 'mountPath': '/snapshot', 'readOnly': True}]}], + 'volumes': [{'name': 'content', 'persistentVolumeClaim': + {'claimName': 'coulomb-social-content', 'readOnly': True}}], + }, + } + body = json.dumps(pod).encode() + k('create', '--dry-run=server', '-f', '-', payload=body) + if not args.execute: + print(json.dumps({'status': 'reviewed', 'server_dry_run': True, + 'image_pinned': True, 'pvc_read_only': True, + 'db_scope': 'coulomb_social_db', 'replicas_restored_to': 1})) + return 0 + dest = args.destination + if not dest.is_absolute() or dest.exists(): + raise Refused('destination_must_be_new_absolute_directory') + dest.mkdir(mode=0o700) + paused = False + created = False + receipt = {'status': 'incomplete', 'app': 'coulomb-social', + 'database': 'coulomb_social_db', 'image': IMAGE, + 'started_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} + try: + paused = True + k('scale', 'deployment/coulomb-social', '-n', NS, '--replicas=0') + k('wait', '--for=delete', 'pod', '-n', NS, + '-l', 'app.kubernetes.io/instance=coulomb-social', '--timeout=120s') + created = True + k('create', '-f', '-', payload=body) + k('wait', '--for=condition=Ready', 'pod/' + name, '-n', NS, '--timeout=120s') + dump = k('exec', '-n', 'databases', primary, '-c', 'postgres', '--', + 'pg_dump', '-U', 'postgres', '-d', 'coulomb_social_db', '-Fc') + if not dump.startswith(b'PGDMP'): + raise Refused('invalid_pg_dump') + private_write(dest / 'database.dump', dump) + archive_code = ('import io,sys,tarfile; b=io.BytesIO(); ' + 't=tarfile.open(fileobj=b,mode="w:gz"); ' + 't.add("/snapshot",arcname="content"); ' + 't.close();sys.stdout.buffer.write(b.getvalue())') + content = k('exec', '-n', NS, name, '--', 'python', '-c', archive_code) + private_write(dest / 'content.tar.gz', content) + receipt.update(status='captured', writers_quiesced=True, + database_sha256=hashlib.sha256(dump).hexdigest(), + content_sha256=hashlib.sha256(content).hexdigest(), + database_bytes=len(dump), content_bytes=len(content)) + finally: + try: + if created: + k('delete', 'pod', name, '-n', NS, '--wait=true', '--timeout=120s') + finally: + if paused: + k('scale', 'deployment/coulomb-social', '-n', NS, '--replicas=1') + k('rollout', 'status', 'deployment/coulomb-social', '-n', NS, '--timeout=180s') + receipt['app_resumed'] = True + receipt['elapsed_seconds'] = round(time.monotonic() - started, 2) + private_write(dest / 'receipt.json', json.dumps(receipt, indent=2).encode()) + print(json.dumps(receipt)) + return 0 + + +def main() -> int: + if '--execute' not in sys.argv: + return snapshot() + LOCK_PATH.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + parent = LOCK_PATH.parent.lstat() + if (not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.geteuid() + or parent.st_mode & 0o077): + raise Refused('lock_directory_not_private') + fd = os.open(LOCK_PATH, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'w') as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise Refused('snapshot_already_running') from None + def terminate(signum, frame): + raise Refused('snapshot_interrupted') + previous = signal.signal(signal.SIGTERM, terminate) + try: + return snapshot() + finally: + signal.signal(signal.SIGTERM, previous) + + +if __name__ == '__main__': + try: + sys.exit(main()) + except Exception as exc: + # Never expose child output, kubectl objects, credentials or raw exception messages. + print(json.dumps({'status': 'failed', 'failure_type': type(exc).__name__})) + sys.exit(1) diff --git a/release/identity-export-values.yaml b/release/identity-export-values.yaml new file mode 100644 index 0000000..56d4ded --- /dev/null +++ b/release/identity-export-values.yaml @@ -0,0 +1,50 @@ +# Production overrides for coulomb-social. +# Non-secret values only. Secrets live in K8s Secret coulomb-social-env. + +image: + repository: forgejo.coulomb.social/coulomb/coulomb-social + tag: "23ffe2cf4b21" + digest: "sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed" + +# Public host: app.coulomb.social (parallel to Bubble on apex coulomb.social). +# Apex cutover is deferred until data + self-registration are ready. +env: + DJANGO_SETTINGS_MODULE: coulomb_social.settings.prod + ALLOWED_HOSTS: app.coulomb.social,localhost + CSRF_TRUSTED_ORIGINS: https://app.coulomb.social + CONTENT_ROOT: /app/var/content + DEFAULT_TENANT_ID: "tenant:coulomb" + OIDC_ENABLED: "true" + OIDC_ISSUER: https://kc.coulomb.social + OIDC_CLIENT_ID: coulomb-social + OIDC_REDIRECT_URI: https://app.coulomb.social/auth/callback/ + OIDC_SCOPES: "openid profile email groups" + USER_ENGINE_BASE_URL: https://users.coulomb.social + USER_ENGINE_APPLICATION_ID: coulomb-social + USER_ENGINE_EXPECTED_AUDIENCE: user-engine-portal + NETKINGDOM_REGISTRATION_URL: https://users.92-205-62-239.nip.io/register + +probes: + hostHeader: app.coulomb.social + path: /healthz + +# One writable local-path volume; Recreate prevents concurrent old/new writers. +replicaCount: 1 +deploymentStrategy: Recreate +persistence: + content: + enabled: true + storageClass: local-path + size: 2Gi + mountPath: /app/var/content + retain: true + +# Wave-2 right-sizing (CSOC-WP-0017-T01, 2026-10-21 node budget): 7d p95 +# 0.4m CPU / 146Mi. Limits stay at the chart defaults (1000m/1Gi). +resources: + requests: + cpu: 15m + memory: 176Mi + limits: + cpu: 1000m + memory: 1Gi diff --git a/tests/test_coulomb_image_release.py b/tests/test_coulomb_image_release.py new file mode 100644 index 0000000..e733c02 --- /dev/null +++ b/tests/test_coulomb_image_release.py @@ -0,0 +1,194 @@ +"""Synthetic release safety cases; no cluster, credentials or real content.""" +import copy +import importlib.util +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +SOURCE = Path(__file__).resolve().parents[1] / "tools/coulomb_image_release_node.py" +spec = importlib.util.spec_from_file_location("storage_release", SOURCE) +release = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release) + +launcher_spec = importlib.util.spec_from_file_location("storage_launcher", SOURCE.with_name("coulomb-image-release.py")) +launcher = importlib.util.module_from_spec(launcher_spec) +launcher_spec.loader.exec_module(launcher) + + +def capsule(tmp_path): + root = tmp_path / "capsule" + root.mkdir() + (root / "probe.py").write_bytes(b"synthetic read-only probe") + plan = {"files": {"probe.py": release.sha((root / "probe.py").read_bytes())}} + (root / "manifest.json").write_text(json.dumps(plan)) + return root, "sha256:" + release.sha(release.canonical(plan)) + + +def test_capsule_tampering_refuses_before_privileged_work(tmp_path, monkeypatch): + root, digest = capsule(tmp_path) + monkeypatch.setattr(release.os, "geteuid", lambda: 0) + monkeypatch.setattr(release, "JOURNAL_ROOT", tmp_path / "journal") + (root / "probe.py").write_bytes(b"changed") + with pytest.raises(release.Refused, match="capsule_file_differs"): + release.Release(root, digest) + assert not (tmp_path / "journal").exists() + + +def test_preflight_never_creates_an_execution_journal(tmp_path, monkeypatch): + root, digest = capsule(tmp_path) + monkeypatch.setattr(release.os, "geteuid", lambda: 0) + monkeypatch.setattr(release, "JOURNAL_ROOT", tmp_path / "journal") + instance = release.Release(root, digest, read_only=True) + instance.save() + assert not (tmp_path / "journal").exists() + + +def test_prior_attempt_is_not_blindly_replayed(tmp_path, monkeypatch): + root, digest = capsule(tmp_path) + journal = tmp_path / "journal" + (journal / digest.removeprefix("sha256:")).mkdir(parents=True) + monkeypatch.setattr(release.os, "geteuid", lambda: 0) + monkeypatch.setattr(release, "JOURNAL_ROOT", journal) + with pytest.raises(release.Refused, match="prior_execution_requires_review"): + release.Release(root, digest) + + +def test_mutable_staging_is_frozen_before_live_work(tmp_path, monkeypatch): + root, digest = capsule(tmp_path) + monkeypatch.setattr(release.os, "geteuid", lambda: 0) + monkeypatch.setattr(release, "JOURNAL_ROOT", tmp_path / "journal") + instance = release.Release(root, digest) + (root / "probe.py").write_bytes(b"changed after verification") + assert (instance.root / "probe.py").read_bytes() == b"synthetic read-only probe" + assert (instance.journal / "receipt.json").is_file() + + +@pytest.mark.parametrize("field", ["business_rows_sha256", "content_files_sha256", "migration_rows_sha256"]) +def test_dataset_drift_is_not_accepted(field): + instance = object.__new__(release.Release) + baseline = {"business_tables": 2, "business_rows": 3, "business_rows_sha256": "synthetic-a", + "content_files": 4, "content_files_sha256": "synthetic-b", + "migration_rows_sha256": "synthetic-migrations"} + instance.plan = {"expected_data": baseline} + changed = dict(baseline, duplicate_slug_groups=0, tenant_slug_constraint=True) + changed[field] = "different" + with pytest.raises(release.Refused, match="dataset_drifted"): + instance.same_data(changed) + + +def test_failed_preflight_never_captures_or_upgrades(): + instance = object.__new__(release.Release) + instance.preflight = Mock(side_effect=release.Refused("guard stopped")) + instance.capture = Mock() + instance.command = Mock() + with pytest.raises(release.Refused): + instance.execute() + instance.capture.assert_not_called() + instance.command.assert_not_called() + + +@pytest.mark.parametrize("rollback_spec,rollback_ready,verified", [ + ({"synthetic": "old"}, 1, True), + ({"synthetic": "candidate"}, 1, False), + ({"synthetic": "old"}, 0, False), +]) +def test_postflight_failure_rolls_back_image_without_restoring_data( + tmp_path, monkeypatch, rollback_spec, rollback_ready, verified): + pin = tmp_path / "snapshot.py" + pin.write_bytes(b"old pin") + monkeypatch.setattr(release, "SNAPSHOT", pin) + instance = object.__new__(release.Release) + instance.receipt = {} + instance.root = tmp_path + spec = {"synthetic": "candidate"} + baseline = {"business_tables": 2, "business_rows": 3, "business_rows_sha256": "synthetic-a", + "content_files": 4, "content_files_sha256": "synthetic-b", + "migration_rows_sha256": "synthetic-migrations"} + instance.plan = {"candidate_spec_sha256": release.sha(release.canonical(spec)), "baseline_revision": 18, + "baseline_spec_sha256": release.sha(release.canonical({"synthetic": "old"})), + "expected_data": baseline} + instance.preflight = Mock() + instance.capture = Mock() + instance.save = Mock() + instance.deployment = Mock(side_effect=[ + {"spec": copy.deepcopy(spec), "status": {"readyReplicas": 1}}, + {"spec": rollback_spec, "status": {"readyReplicas": rollback_ready}}, + ]) + instance.probe = lambda mode: dict(baseline, business_rows_sha256="changed") + instance.command = Mock(return_value=SimpleNamespace(stdout=b"", returncode=0)) + instance.restore_pin = Mock() + with pytest.raises(release.Refused, match="dataset_drifted"): + instance.execute() + commands = [call.args[0] for call in instance.command.call_args_list] + assert commands[0][:2] == ["helm", "upgrade"] and "--atomic" in commands[0] + assert commands[1][:4] == ["helm", "rollback", "coulomb-social", "18"] + assert not any("pg_restore" in command for command in commands) + instance.restore_pin.assert_called_once_with(b"old pin") + assert instance.receipt["image_rollback_completed"] is verified + + +def test_live_launcher_rejects_unattended_execution_before_ssh(monkeypatch): + _, digest, _ = launcher.prepare() + monkeypatch.setattr(launcher.sys, "argv", ["release", "--execute", "--expect-digest", digest]) + monkeypatch.setattr(launcher.sys, "stdin", SimpleNamespace(isatty=lambda: False)) + command = Mock() + monkeypatch.setattr(launcher.subprocess, "run", command) + with pytest.raises(SystemExit, match="own terminal"): + launcher.main() + command.assert_not_called() + + +def test_timeout_gives_the_existing_capture_resume_window(monkeypatch): + instance = object.__new__(release.Release) + instance.env = {} + process = Mock() + process.communicate.side_effect = release.subprocess.TimeoutExpired(["synthetic"], 1) + monkeypatch.setattr(release.subprocess, "Popen", Mock(return_value=process)) + with pytest.raises(release.Refused, match="cleanup_window"): + instance.command(["synthetic"], timeout=1) + process.terminate.assert_called_once() + process.wait.assert_called_once_with(timeout=450) + process.kill.assert_not_called() + + +@pytest.mark.parametrize("field", ["global_slug_constraint", "migration_0003_applied"]) +@pytest.mark.parametrize("value", [False, None]) +def test_image_only_release_requires_existing_migration_and_constraint(field, value): + instance = object.__new__(release.Release) + baseline = {"business_tables": 2, "business_rows": 3, + "business_rows_sha256": "rows", "content_files": 4, + "content_files_sha256": "files", "migration_rows_sha256": "migrations"} + instance.plan = {"expected_data": baseline} + observed = dict(baseline, duplicate_slug_groups=0, tenant_slug_constraint=True, + global_slug_constraint=True, migration_0003_applied=True) + instance.same_data(observed) + observed[field] = value + with pytest.raises(release.Refused, match="already_migrated_baseline_required"): + instance.same_data(observed) + + +def test_rollback_does_not_overwrite_unrecognized_snapshot_helper(tmp_path, monkeypatch): + pin = tmp_path / "snapshot.py" + pin.write_bytes(b"concurrent owner change") + monkeypatch.setattr(release, "SNAPSHOT", pin) + instance = object.__new__(release.Release) + instance.plan = {"files": {"release/identity-export-snapshot.py": release.sha(b"new pin")}} + with pytest.raises(release.Refused, match="snapshot_pin_changed_during_rollback"): + instance.restore_pin(b"old pin") + assert pin.read_bytes() == b"concurrent owner change" + + +def test_known_candidate_helper_can_be_rolled_back(tmp_path, monkeypatch): + pin = tmp_path / "snapshot.py" + pin.write_bytes(b"new pin") + monkeypatch.setattr(release, "SNAPSHOT", pin) + instance = object.__new__(release.Release) + instance.plan = {"files": {"release/identity-export-snapshot.py": release.sha(b"new pin")}} + instance.restore_pin(b"old pin") + assert pin.read_bytes() == b"old pin" + # A completed inverse can be checked again without changing it. + instance.restore_pin(b"old pin") + assert pin.read_bytes() == b"old pin" diff --git a/tools/coulomb-image-release.py b/tools/coulomb-image-release.py new file mode 100644 index 0000000..1b2efc7 --- /dev/null +++ b/tools/coulomb-image-release.py @@ -0,0 +1,185 @@ +"""Prepare/read-only-check a sealed release; live mode is attended founder work. + +This launcher does not mint an approval or bypass gated-executor. The existing +gated-executor profile covers Kubernetes, not the root-owned backup helper refresh. +Only the founder runs --execute in their own terminal after reviewing the packet. +""" + +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import os +import shlex +import subprocess +import sys +import tarfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PLAN = ROOT / "release/coulomb-identity-export-23ffe2c.json" + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + + +def prepare(): + plan = json.loads(PLAN.read_text()) + paths = [ + ROOT / name + for name in ( + "tools/coulomb-image-release.py", + "tools/coulomb_image_release_node.py", + "tools/coulomb_image_probe.py", + "release/identity-export-values.yaml", + "release/identity-export-snapshot.py", + ) + ] + paths += sorted((ROOT / "charts/coulomb-social").rglob("*")) + paths += sorted((ROOT / "release/identity-export-evidence").glob("*.json")) + paths = [p for p in paths if p.is_file()] + if any(p.is_symlink() or not p.resolve().is_relative_to(ROOT) for p in paths): + raise RuntimeError("regular_reviewed_files_required") + content = {str(p.relative_to(ROOT)): p.read_bytes() for p in paths} + plan["files"] = { + name: hashlib.sha256(data).hexdigest() for name, data in content.items() + } + for name, expected in plan["rehearsal_evidence_sha256"].items(): + if plan["files"].get("release/identity-export-evidence/" + name) != expected: + raise RuntimeError("reviewed_candidate_evidence_changed") + digest = "sha256:" + hashlib.sha256(canonical(plan)).hexdigest() + content["manifest.json"] = canonical(plan) + return plan, digest, content + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--preflight", action="store_true") + parser.add_argument("--execute", action="store_true") + parser.add_argument("--expect-digest") + parser.add_argument("--output", type=Path) + args = parser.parse_args() + if args.preflight and args.execute: + raise SystemExit("choose read-only preflight or attended execution") + plan, digest, content = prepare() + if args.expect_digest and args.expect_digest != digest: + raise SystemExit("reviewed proposal changed; do not execute") + if not args.preflight and not args.execute: + print( + json.dumps( + { + "status": "prepared", + "proposal_digest": digest, + "file_count": len(content), + "candidate": plan["candidate_image"], + "live_changes": False, + } + ) + ) + return + if args.execute: + if not args.expect_digest or not sys.stdin.isatty(): + raise SystemExit( + "founder must review and run the exact digest in their own terminal" + ) + print("Reviewed proposal: " + digest) + print( + "Coulomb-only image update; existing database schema retained, fresh paired backup, postflight and backup image-pin refresh." + ) + if ( + input("Type DEPLOY followed by the full digest to proceed: ").strip() + != "DEPLOY " + digest + ): + raise SystemExit("not approved; no live change") + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:") as archive: + for name, data in content.items(): + entry = tarfile.TarInfo(name) + entry.size, entry.mode = len(data), 0o600 + archive.addfile(entry, io.BytesIO(data)) + remote = "/tmp/csoc-reviewed-image-" + digest.removeprefix("sha256:") + receiver = r""" +import hashlib,io,json,os,pathlib,sys,tarfile +root=pathlib.Path(sys.argv[1]);expected=sys.argv[2] +raw=sys.stdin.buffer.read(2*1024**2+1);assert len(raw)<=2*1024**2 +with tarfile.open(fileobj=io.BytesIO(raw),mode='r:') as archive: + entries=archive.getmembers();assert len({e.name for e in entries})==len(entries) + assert all(e.isfile() and not pathlib.Path(e.name).is_absolute() and '..' not in pathlib.Path(e.name).parts for e in entries) + data={e.name:archive.extractfile(e).read() for e in entries} + plan=json.loads(data['manifest.json']) + canonical=json.dumps(plan,sort_keys=True,separators=(',',':')).encode() + assert 'sha256:'+hashlib.sha256(canonical).hexdigest()==expected + assert set(data)==set(plan['files'])|{'manifest.json'} + for name,digest in plan['files'].items():assert hashlib.sha256(data[name]).hexdigest()==digest + root.mkdir(mode=0o700,exist_ok=True) + assert root.lstat().st_uid==os.getuid() and root.lstat().st_mode&0o077==0 + for name,value in data.items(): + target=root/name;target.parent.mkdir(parents=True,exist_ok=True,mode=0o700) + if target.exists():assert not target.is_symlink() and target.read_bytes()==value;continue + fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + with os.fdopen(fd,'wb') as stream:stream.write(value) +""" + staged = subprocess.run( + [ + "ssh", + "railiance01", + "python3 -c " + + shlex.quote(receiver) + + " " + + shlex.quote(remote) + + " " + + shlex.quote(digest), + ], + input=buffer.getvalue(), + capture_output=True, + timeout=60, + check=False, + ) + if staged.returncode: + raise SystemExit("reviewed capsule staging refused; no live change") + node_hash = plan["files"]["tools/coulomb_image_release_node.py"] + guard = ( + "import hashlib,pathlib,sys;p=pathlib.Path(" + + repr(remote + "/tools/coulomb_image_release_node.py") + + ");" + "b=p.read_bytes();assert hashlib.sha256(b).hexdigest()==" + + repr(node_hash) + + ";" + "sys.argv=['reviewed-release','--capsule'," + + repr(remote) + + ",'--expect-digest'," + + repr(digest) + + "," + + repr("--execute" if args.execute else "--preflight") + + "];exec(compile(b,str(p),'exec'),{'__name__':'__main__'})" + ) + result = subprocess.run( + ["ssh", "railiance01", "sudo -n python3 -c " + shlex.quote(guard)], + capture_output=True, + timeout=3600, + check=False, + ) + try: + receipt = json.loads(result.stdout) + except (ValueError, TypeError): + receipt = { + "status": "remote_execution_unconfirmed", + "proposal_digest": digest, + "journal": "/var/lib/coulomb-social-backup/releases/" + + digest.removeprefix("sha256:") + + "/receipt.json", + } + if args.output: + fd = os.open(args.output, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as stream: + json.dump(receipt, stream, indent=2) + print(json.dumps(receipt)) + if result.returncode: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/tools/coulomb_image_probe.py b/tools/coulomb_image_probe.py new file mode 100644 index 0000000..8d7b028 --- /dev/null +++ b/tools/coulomb_image_probe.py @@ -0,0 +1,134 @@ +"""Read-only data/schema and view probe; runs inside the existing app container.""" + +import hashlib +import json +import sys +import uuid +from pathlib import Path + +import django + +django.setup() +from coulomb_social.apps.members.models import Member, User +from coulomb_social.apps.spaces.models import Space +from django.conf import settings +from django.db import connection +from django.db.models import Count + +assert connection.vendor == "postgresql" +assert connection.settings_dict["NAME"] in { + "coulomb_social_db", + "coulomb_social_restore", +} +tables = {} +with connection.cursor() as cursor: + cursor.execute( + "SELECT tablename FROM pg_tables WHERE schemaname='public' " + "AND tablename!='django_migrations' ORDER BY tablename" + ) + names = [row[0] for row in cursor.fetchall()] + for table in names: + cursor.execute("SELECT * FROM " + connection.ops.quote_name(table)) + rows = sorted( + json.dumps(row, default=str, separators=(",", ":")) + for row in cursor.fetchall() + ) + tables[table] = { + "rows": len(rows), + "sha256": hashlib.sha256("\n".join(rows).encode()).hexdigest(), + } + cursor.execute( + "SELECT conname FROM pg_constraint WHERE conrelid='spaces_space'::regclass" + ) + constraints = {row[0] for row in cursor.fetchall()} + cursor.execute( + "SELECT COUNT(*) FROM django_migrations WHERE app='spaces' AND name='0003_space_slug_global_uniq'" + ) + applied = cursor.fetchone()[0] == 1 + cursor.execute("SELECT * FROM django_migrations") + migration_rows = sorted(json.dumps(row, default=str, separators=(",", ":")) + for row in cursor.fetchall()) +root = Path(settings.CONTENT_ROOT) +files = { + str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in root.rglob("*") + if p.is_file() +} +result = { + "business_tables": len(tables), + "migration_rows_sha256": hashlib.sha256("\n".join(migration_rows).encode()).hexdigest(), + "business_rows": sum(t["rows"] for t in tables.values()), + "business_rows_sha256": hashlib.sha256( + json.dumps(tables, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest(), + "content_files": len(files), + "content_files_sha256": hashlib.sha256( + json.dumps(files, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest(), + "global_slug_constraint": "spaces_space_slug_global_uniq" in constraints, + "tenant_slug_constraint": "spaces_space_tenant_slug_uniq" in constraints, + "migration_0003_applied": applied, + "duplicate_slug_groups": Space.objects.values("slug") + .annotate(n=Count("pk")) + .filter(n__gt=1) + .count(), +} +if len(sys.argv) > 1 and sys.argv[1] == "views": + from coulomb_social.apps.spaces import views + from coulomb_social.apps.spaces.content import load_space_page + from django.test import RequestFactory + from django.urls import reverse + + assert not settings.FLEX_AUTH_BASE_URL + factory = RequestFactory() + rendered_count, denied_count = 0, 0 + for space in Space.objects.filter(is_active=True): + paths = sorted((root / "spaces" / space.slug / "pages").glob("*.md")) + if not paths: + continue + owner = space.created_by + if owner is None: + owner = ( + space.memberships.filter(role="owner") + .select_related("member__user") + .first() + .member + ) + token = "unsaved-release-check-" + uuid.uuid4().hex + outsider = Member( + user=User(username=token), + tenant_id=space.tenant_id, + subject=token, + issuer="https://release-check.invalid", + user_engine_user_id=token, + ) + for path in paths: + page = path.stem + loaded = load_space_page(space, page) + assert loaded.error is None + + def request(user, slug=space.slug, page_slug=page): + req = factory.get( + reverse("spaces:detail", kwargs={"slug": slug}), + {"page": page_slug}, + ) + req.user, req.session = user, {} + return req + + response = views.space_detail(request(owner.user), slug=space.slug) + assert ( + response.status_code == 200 and loaded.html.encode() in response.content + ) + rendered_count += 1 + denied = views.space_detail(request(outsider.user), slug=space.slug) + assert denied.status_code == 404 + if loaded.html: + assert loaded.html.encode() not in denied.content + denied_count += 1 + assert rendered_count > 0 and denied_count == rendered_count + result.update( + actual_owner_pages_rendered=rendered_count, + unsaved_outsider_page_denials=denied_count, + production_identity_or_grant_created=False, + ) +print(json.dumps(result)) diff --git a/tools/coulomb_image_release_node.py b/tools/coulomb_image_release_node.py new file mode 100644 index 0000000..d84b525 --- /dev/null +++ b/tools/coulomb_image_release_node.py @@ -0,0 +1,525 @@ +"""Attended founder-only node release; argv/data are sealed by the reviewed capsule. + +This is a supervisor command, not a new agent credential or approval boundary. +No Secret object/output is fetched. Existing approved backup custody stays local. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import stat +import subprocess +import tempfile +import time +import urllib.request +from datetime import UTC, datetime +from pathlib import Path + +JOURNAL_ROOT = Path("/var/lib/coulomb-social-backup/releases") +SNAPSHOT = Path("/opt/coulomb-social-backup/snapshot-content-and-db.py") + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +class Refused(RuntimeError): + pass + + +class Release: + def __init__(self, capsule, expected, read_only=False): + self.root = capsule + self.plan = json.loads((capsule / "manifest.json").read_text()) + frozen = {} + if "sha256:" + sha(canonical(self.plan)) != expected: + raise Refused("proposal_digest_differs") + for name, digest in self.plan["files"].items(): + path = capsule / name + if ( + not path.resolve().is_relative_to(capsule.resolve()) + or Path(name).is_absolute() + or ".." in Path(name).parts + or path.is_symlink() + or not path.is_file() + or sha(path.read_bytes()) != digest + ): + raise Refused("capsule_file_differs") + frozen[name] = path.read_bytes() + if sha(frozen[name]) != digest: + raise Refused("capsule_changed_during_verification") + if os.geteuid() != 0: + raise Refused("attended_founder_root_execution_required") + self.env = dict(os.environ, KUBECONFIG="/etc/rancher/k3s/k3s.yaml") + self.read_only = read_only + self.receipt = { + "schema": "coulomb.guarded-image-release.v1", + "proposal_digest": expected, + "started_at": datetime.now(UTC).isoformat(), + "status": "preflight", + "production_data_restored": False, + "timers_activated": False, + } + self.journal = JOURNAL_ROOT / expected.removeprefix("sha256:") + if read_only: + return + if self.journal.exists(): + raise Refused("prior_execution_requires_review") + self.journal.mkdir(parents=True, mode=0o700) + self.root = self.journal / "capsule" + self.root.mkdir(mode=0o700) + for name, data in frozen.items(): + target = self.root / name + target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "wb") as stream: + stream.write(data) + self.receipt["pid"] = os.getpid() + self.save() + + def save(self): + if self.read_only: + return + fd, name = tempfile.mkstemp(prefix=".receipt-", dir=self.journal) + with os.fdopen(fd, "w") as stream: + json.dump(self.receipt, stream, sort_keys=True, indent=2) + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, self.journal / "receipt.json") + directory = os.open(self.journal, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + + def command(self, arguments, payload=None, env=None, timeout=180, allowed=(0,)): + # In particular, give the paired capture its existing app-resume window + # on timeout; subprocess.run would SIGKILL it before that cleanup. + with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr: + process = subprocess.Popen( + arguments, + env=env or self.env, + stdin=subprocess.PIPE if payload is not None else subprocess.DEVNULL, + stdout=stdout, + stderr=stderr, + ) + try: + process.communicate(payload, timeout=timeout) + except subprocess.TimeoutExpired: + process.terminate() + try: + process.wait(timeout=450) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + raise Refused("private_child_timed_out_after_cleanup_window") from None + stdout.seek(0) + body = stdout.read(8 * 1024**2 + 1) + if len(body) > 8 * 1024**2: + raise Refused("bounded_child_output_required") + result = subprocess.CompletedProcess(arguments, process.returncode, body) + if result.returncode not in allowed: + raise Refused("private_child_refused") + return result + + def kdoc(self, *arguments): + return json.loads(self.command(["kubectl", *arguments, "-o", "json"]).stdout) + + def history(self): + return json.loads( + self.command( + [ + "helm", + "history", + "coulomb-social", + "-n", + "coulomb-social", + "-o", + "json", + ] + ).stdout + ) + + def deployment(self): + return self.kdoc("get", "deployment", "coulomb-social", "-n", "coulomb-social") + + def probe(self, mode="data"): + source = (self.root / "tools/coulomb_image_probe.py").read_text() + result = self.command( + [ + "kubectl", + "exec", + "-n", + "coulomb-social", + "deploy/coulomb-social", + "--", + "python", + "-c", + source, + mode, + ] + ) + return json.loads(result.stdout) + + def same_data(self, actual): + for name in ( + "business_tables", + "business_rows", + "business_rows_sha256", + "content_files", + "content_files_sha256", + "migration_rows_sha256", + ): + if actual[name] != self.plan["expected_data"][name]: + raise Refused("approved_recovered_dataset_drifted") + if actual["duplicate_slug_groups"] != 0 or actual["tenant_slug_constraint"] is not True: + raise Refused("storage_invariant_refused") + if (actual.get("global_slug_constraint") is not True + or actual.get("migration_0003_applied") is not True): + raise Refused("already_migrated_baseline_required") + + def preflight(self): + self.verify_installed_helpers() + if ( + self.kdoc("get", "namespace", "kube-system")["metadata"]["uid"] + != self.plan["cluster_uid"] + ): + raise Refused("wrong_cluster") + node = self.kdoc("get", "nodes")["items"] + if len(node) != 1 or not any( + a["address"] == "92.205.62.239" for a in node[0]["status"]["addresses"] + ): + raise Refused("wrong_node") + conditions = {v["type"]: v["status"] for v in node[0]["status"]["conditions"]} + if conditions.get("Ready") != "True" or any( + conditions.get(k) != "False" + for k in ("MemoryPressure", "DiskPressure", "PIDPressure") + ): + raise Refused("node_pressure") + allocated = self.command( + ["kubectl", "describe", "node", node[0]["metadata"]["name"]] + ).stdout.decode() + requests = {} + for resource in ("cpu", "memory"): + found = re.search( + r"(?m)^\s+" + resource + r"\s+\S+\s+\((\d+)%\)", allocated + ) + if not found or int(found[1]) > 90: + raise Refused("node_request_headroom_refused") + requests[resource] = int(found[1]) + self.receipt["node_request_percent"] = requests + history = self.history() + if ( + history[-1]["revision"] != self.plan["baseline_revision"] + or history[-1]["status"] != "deployed" + ): + raise Refused("helm_baseline_changed") + manifest = self.command( + ["helm", "get", "manifest", "coulomb-social", "-n", "coulomb-social"] + ).stdout + if sha(manifest) != self.plan["baseline_helm_manifest_sha256"]: + raise Refused("helm_manifest_changed") + deployment = self.deployment() + if ( + deployment["metadata"]["uid"] != self.plan["deployment_uid"] + or sha(canonical(deployment["spec"])) != self.plan["baseline_spec_sha256"] + ): + raise Refused("live_spec_changed") + if ( + deployment["status"].get("readyReplicas") != 1 + or deployment["spec"]["strategy"]["type"] != "Recreate" + ): + raise Refused("single_ready_writer_required") + # Helm's three-way merge preserves unmanaged pod metadata. Bind the + # complete desired live spec rather than treating kubectl apply's + # last-applied projection as Helm's result. + image_patch = [ + {"op": "test", "path": "/spec", "value": deployment["spec"]}, + {"op": "replace", "path": "/spec/template/spec/containers/0/image", + "value": self.plan["candidate_image"]}, + ] + preview = self.kdoc( + "patch", "deployment", "coulomb-social", "-n", "coulomb-social", + "--type=json", "--dry-run=server", "-p", json.dumps(image_patch), + ) + if sha(canonical(preview["spec"])) != self.plan["candidate_spec_sha256"]: + raise Refused("image_only_server_preview_differs") + pvc = self.kdoc("get", "pvc", "coulomb-social-content", "-n", "coulomb-social") + if ( + pvc["metadata"]["uid"] != self.plan["pvc_uid"] + or pvc["status"]["phase"] != "Bound" + ): + raise Refused("pvc_changed") + apps = self.kdoc("get", "applications.argoproj.io", "-A")["items"] + if any( + a.get("spec", {}).get("destination", {}).get("namespace") + == "coulomb-social" + for a in apps + ): + raise Refused("gitops_adoption_requires_owner_path") + rendered = self.command( + [ + "helm", + "template", + "coulomb-social", + str(self.root / "charts/coulomb-social"), + "-f", + str(self.root / "release/identity-export-values.yaml"), + "-n", + "coulomb-social", + ] + ).stdout + if sha(rendered) != self.plan["candidate_render_sha256"]: + raise Refused("candidate_render_changed") + self.command(["kubectl", "diff", "-f", "-"], payload=rendered, allowed=(0, 1)) + self.command( + ["kubectl", "apply", "--dry-run=server", "-f", "-"], payload=rendered + ) + self.command( + [ + "helm", + "upgrade", + "coulomb-social", + str(self.root / "charts/coulomb-social"), + "-f", + str(self.root / "release/identity-export-values.yaml"), + "-n", + "coulomb-social", + "--dry-run=server", + ] + ) + before = self.probe() + self.same_data(before) + self.receipt["preflight"] = before + self.save() + + def verify_installed_helpers(self): + for path, expected in self.plan["installed_helper_hashes"].items(): + metadata = Path(path).lstat() + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != 0 + or metadata.st_mode & 0o022 + or sha(Path(path).read_bytes()) != expected + ): + raise Refused("installed_backup_helper_changed") + def capture(self): + self.verify_installed_helpers() + environment = dict(self.env) + for name in ("primary.env", "recipient.env"): + path = Path("/etc/coulomb-social-backup") / name + metadata = path.lstat() + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != 0 + or metadata.st_mode & 0o077 + ): + raise Refused("private_own_backup_configuration_required") + environment.update( + dict( + line.split("=", 1) + for line in path.read_text().splitlines() + if line and not line.startswith("#") + ) + ) + output = self.command( + ["python3", "/opt/coulomb-social-backup/paired-backup.py", "--execute"], + env=environment, + timeout=1200, + ) + point = json.loads(output.stdout) + if ( + point.get("status") != "offsite_fetched_verified" + or not point.get("remote_retrieval_verified") + or not point.get("version_id") + or point.get("version_id") == "null" + or point.get("image") != self.plan["old_image"] + or point.get("age_recipient") != self.plan["public_recipient"] + or not point.get("immutable_create") + or not point.get("destination", "").startswith( + "s3://railiance-platform-pg-backup/platform-pg/application-archives/coulomb-social/" + ) + ): + raise Refused("fresh_paired_primary_readback_required") + self.receipt["fresh_paired_point"] = point + self.save() + self.same_data(self.probe()) + + def restore_pin(self, old): + path = SNAPSHOT + if ( + sha(path.read_bytes()) + == self.plan["files"]["release/identity-export-snapshot.py"] + ): + self.replace_pin(path, old) + elif sha(path.read_bytes()) != sha(old): + raise Refused("snapshot_pin_changed_during_rollback") + + def replace_pin(self, path, value): + fd, name = tempfile.mkstemp(prefix=".reviewed-snapshot-", dir=path.parent) + try: + with os.fdopen(fd, "wb") as stream: + stream.write(value) + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, path) + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + if os.path.exists(name): + os.unlink(name) + + def execute(self): + began = time.monotonic() + self.preflight() + self.capture() + old_pin = SNAPSHOT.read_bytes() + self.receipt["status"] = "upgrade_started" + self.save() + try: + self.command( + [ + "helm", + "upgrade", + "coulomb-social", + str(self.root / "charts/coulomb-social"), + "-f", + str(self.root / "release/identity-export-values.yaml"), + "-n", + "coulomb-social", + "--atomic", + "--wait", + "--timeout", + "5m", + ], + timeout=660, + ) + after = self.deployment() + if ( + sha(canonical(after["spec"])) != self.plan["candidate_spec_sha256"] + or after["status"].get("readyReplicas") != 1 + ): + raise Refused("candidate_spec_or_readiness_differs") + probe = self.probe("views") + self.same_data(probe) + for suffix in ("/healthz", "/", "/static/spaces/copy-content.js"): + with urllib.request.urlopen( + "https://app.coulomb.social" + suffix, timeout=20 + ) as response: + body = response.read(1024**2) + if response.status != 200: + raise Refused("public_http_postflight_refused") + if ( + suffix.endswith("copy-content.js") + and sha(body) != self.plan["clipboard_static_sha256"] + ): + raise Refused("served_static_differs") + path = SNAPSHOT + if ( + sha(path.read_bytes()) + != self.plan["installed_helper_hashes"][str(path)] + ): + raise Refused("snapshot_pin_drifted") + self.replace_pin( + path, (self.root / "release/identity-export-snapshot.py").read_bytes() + ) + self.command( + [ + "python3", + str(path), + "--destination", + "/tmp/csoc-post-image-server-review", + ] + ) + self.receipt.update( + status="image_candidate_deployed_verified", + postflight=probe, + helm_revision=self.history()[-1]["revision"], + snapshot_pin_refreshed=True, + public_health_landing_static_verified=True, + existing_global_constraint_preserved=True, + ) + except Exception: + self.receipt["rollback_requested"] = True + try: + self.command( + [ + "helm", + "rollback", + "coulomb-social", + str(self.plan["baseline_revision"]), + "-n", + "coulomb-social", + "--wait", + "--timeout", + "5m", + ], + timeout=360, + ) + self.restore_pin(old_pin) + restored = self.deployment() + if (sha(canonical(restored["spec"])) != self.plan["baseline_spec_sha256"] + or restored["status"].get("readyReplicas") != 1): + raise Refused("rollback_spec_or_readiness_differs") + self.receipt["image_rollback_completed"] = True + except Exception: # noqa: BLE001 -- retain explicit unconfirmed rollback outcome + self.receipt["image_rollback_completed"] = False + raise + self.receipt.update( + elapsed_seconds=round(time.monotonic() - began, 3), + completed_at=datetime.now(UTC).isoformat(), + private_values_printed=False, + ) + self.save() + return self.receipt + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--capsule", type=Path, required=True) + parser.add_argument("--expect-digest", required=True) + parser.add_argument("--execute", action="store_true") + parser.add_argument("--preflight", action="store_true") + args = parser.parse_args() + if not args.execute and not args.preflight: + print(json.dumps({"status": "prepared", "live_changes": False})) + return + if args.execute and args.preflight: + raise SystemExit("choose read-only preflight or attended execution") + release = Release(args.capsule, args.expect_digest, read_only=args.preflight) + try: + if args.preflight: + release.preflight() + print( + json.dumps( + dict( + release.receipt, + status="live_release_preflight_verified", + live_changes=False, + ) + ) + ) + return + print(json.dumps(release.execute())) + except Exception as error: # noqa: BLE001 -- private child diagnostics are suppressed + release.receipt.update( + status="release_refused_or_failed", failure_type=type(error).__name__ + ) + release.save() + print(json.dumps(release.receipt)) + raise SystemExit(1) from None + + +if __name__ == "__main__": + main() diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index fd4cac8..b08998c 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: railiance-apps created: "2026-10-06" -updated: "2026-10-08" +updated: "2026-10-31" related: - CSOC-WP-0008 state_hub_workstream_id: "79abacf8-361e-51ab-829d-40dec8e142d1" @@ -340,3 +340,45 @@ server dry-run pass. No Secret, issuer, mail, caller, PVC, offsite schedule or Bubble/apex change. T04 remains wait for productive complete user acceptance; product WP-0008/WP-0010 retain their broader open tasks. Product receipt: docs/release/sharing-portability-review-2026-10-08.md. + +## T05 — Deliver the identity-binding and operator-export image + +```task +id: RAPPS-WP-0015-T05 +status: progress +priority: high +needs_human: true +decision_id: "75b94bb6-4709-495f-91fe-43e53e2867ed" +intervention_note: "Exact image-only proposal9f1daf6c passes native read-only preflight. Founder terminal execution remains required; then verify durable receipt, current source/values/helper pins and consumer recovery/export follow-through." +``` + +Owner execution dependency for CSOC-WP-0012-T03 and CSOC-WP-0015-T04. This is +separate from completed storage release 5fd7ccd8 and date correction 47829c7a. +Candidate source 23ffe2c/image 522bcd7f is published and packaged-source verified; +441 source tests and 25 packaged identity/export tests pass. The retained isolated +recovery verifies 85 client files and 40 owner/40 outsider views without app overlays. +Current production remains revision 18/image 51281946; default live Helm values are +unchanged until the verified execution return. + +Prepared separate image-only launcher/node/probe, candidate values and snapshot +pin. The controller requires both existing slug constraints and migration 0003, +compares complete business/migration-history/content fingerprints, makes a fresh +admitted primary backup/readback, then performs atomic Helm upgrade and postflight. +Rollback verifies the previous live specification, Ready replica and recognized +helper; no data restore/schema downgrade or automatic replay. The old storage +launcher is not reused. 19 synthetic safety tests, Ruff, compile and Helm lint pass. + +Sealed read-only preflight 9f1daf6c passes at 2026-10-31T03:41:35Z: revision 18, +12 business tables/92 rows/86 files, exact migration history, unchanged helper pins, +CPU 68%/memory 76%, image-only chart/server-preview checks and no Argo adoption. +The expected live spec preserves the unmanaged restart annotation; raw kubectl +apply remains dry-run only. Receipt: docs/evidence/2026-10-31-coulomb-image-release-preflight.json. +Founder packet: coulomb-social/docs/release/identity-export-release-review-2026-10-31.md. +The new execution has not occurred. No node helper, Helm release, timer or data +changed. Existing recovery rehearsal predates the date correction; independent +retrieval/restore/export of the new execution point remains consumer follow-through. + +Done when founder execution and independent postflight are verified, the native +receipt and actual image/helper pins are reconciled into owner/product source, +and the product records reference that exact release. This does not accept the +remaining productive journeys or service/offboarding obligations for the consumer. From a70e3b2e9f477fe0d6380b6650a1964da9a0f044 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 04:52:12 +0100 Subject: [PATCH 74/87] Record published identity export release handoff Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index b08998c..fea3cfc 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -350,6 +350,7 @@ priority: high needs_human: true decision_id: "75b94bb6-4709-495f-91fe-43e53e2867ed" intervention_note: "Exact image-only proposal9f1daf6c passes native read-only preflight. Founder terminal execution remains required; then verify durable receipt, current source/values/helper pins and consumer recovery/export follow-through." +state_hub_task_id: "9f657a95-ce0e-5805-b754-a64e38827c73" ``` Owner execution dependency for CSOC-WP-0012-T03 and CSOC-WP-0015-T04. This is @@ -382,3 +383,8 @@ Done when founder execution and independent postflight are verified, the native receipt and actual image/helper pins are reconciled into owner/product source, and the product records reference that exact release. This does not accept the remaining productive journeys or service/offboarding obligations for the consumer. + +2026-10-31 handoff: controller commit `84a5aa0` is published on owner main. +The canonical workstation launcher reproduces proposal `9f1daf6c` with +`live_changes: false`; the founder packet is ready for attended execution. +Consumer follow-through remains CSOC-WP-0012-T03 and CSOC-WP-0015-T04. From d795120f982264290ed180d0ebc7a7a987e12725 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 08:35:54 +0100 Subject: [PATCH 75/87] Reconcile verified Coulomb revision 19 and complete image delivery Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- WORK-RECORDS.md | 2 +- docs/coulomb-image-release-2026-10-31.md | 17 ++--- ...-image-release-independent-postflight.json | 49 +++++++++++++ ...6-10-31-coulomb-image-release-receipt.json | 70 +++++++++++++++++++ ...ulomb-image-release-source-provenance.json | 10 +++ helm/coulomb-social-values.yaml | 4 +- ...P-0015-coulomb-personal-content-release.md | 17 ++++- 7 files changed, 155 insertions(+), 14 deletions(-) create mode 100644 docs/evidence/2026-10-31-coulomb-image-release-independent-postflight.json create mode 100644 docs/evidence/2026-10-31-coulomb-image-release-receipt.json create mode 100644 docs/evidence/2026-10-31-coulomb-image-release-source-provenance.json diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index dc3c219..c255f81 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -41,7 +41,7 @@ | task | RAPPS-WP-0015-T02 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T03 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T04 | wait | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | -| task | RAPPS-WP-0015-T05 | progress | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | +| task | RAPPS-WP-0015-T05 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0016-T01 | done | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T02 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T03 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | diff --git a/docs/coulomb-image-release-2026-10-31.md b/docs/coulomb-image-release-2026-10-31.md index 2a16234..7d654e5 100644 --- a/docs/coulomb-image-release-2026-10-31.md +++ b/docs/coulomb-image-release-2026-10-31.md @@ -1,8 +1,8 @@ -# Prepared Coulomb identity/export image release +# Completed Coulomb identity/export image release The exact supervised proposal is `sha256:9f1daf6c5fb56b2b4fc9817f28f5ad090c884b0295f7d6f4469e42d48fa8d59f`. -Open decision: `75b94bb6-4709-495f-91fe-43e53e2867ed`. +Resolved execution decision: `75b94bb6-4709-495f-91fe-43e53e2867ed`. The full founder packet is [coulomb-social release review](/home/worsch/coulomb-social/docs/release/identity-export-release-review-2026-10-31.md). @@ -21,8 +21,8 @@ helper changes refuse overwrite. Historical storage commands/capsules remain unchanged and must not be replayed. Candidate values live in `release/identity-export-values.yaml`; the current -`helm/coulomb-social-values.yaml` remains pinned to the actual revision 18 image. -The new snapshot-helper bytes are separate review material until execution. +`helm/coulomb-social-values.yaml` now matches verified revision 19/image522bcd7f. +The installed snapshot helper matches the reviewed bytes (dcc8dd2a). No unit/timer, sender, grant, apex or data-restore operation is included. Validation: 19 release safety cases pass; Ruff, Python compile and Helm lint pass. @@ -32,7 +32,8 @@ Four hash-bound candidate evidence files travel inside the capsule. The product review names source 441 tests, actual packaged 25 tests, all 80 packaged source matches and the retained isolated recovery/export/view checks with their limits. -After founder execution, inspect the durable receipt and independent current -state before reconciling the live values and snapshot pin. The product then -mirrors/retrieves the newly captured point and verifies packaged export on that -coherent post-correction data. Source publication alone does not finish T05. +Founder execution and independent postflight are verified at revision19. The +product independently retrieved/restored the fresh point and verified packaged +client export on the corrected data. T05 is complete. Native receipt, independent +postflight and live packaged-source proof are under docs/evidence/2026-10-31-coulomb-image-release-*.json. +The historical launcher/capsule is retained for audit and must not be replayed. diff --git a/docs/evidence/2026-10-31-coulomb-image-release-independent-postflight.json b/docs/evidence/2026-10-31-coulomb-image-release-independent-postflight.json new file mode 100644 index 0000000..f93afb7 --- /dev/null +++ b/docs/evidence/2026-10-31-coulomb-image-release-independent-postflight.json @@ -0,0 +1,49 @@ +{ + "schema": "coulomb.independent-image-release-postflight.v1", + "status": "live_image_release_verified", + "observed_at": "2026-10-31T07:31:19.932895+00:00", + "proposal_digest": "sha256:9f1daf6c5fb56b2b4fc9817f28f5ad090c884b0295f7d6f4469e42d48fa8d59f", + "helm_revision": 19, + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "source": "23ffe2cf4b214666754a2420558943438e54251a", + "cluster_deployment_pvc_identity_verified": true, + "complete_deployment_spec_verified": true, + "sole_ready_pvc_writer": true, + "data": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 92, + "business_rows_sha256": "6243955479f7861fcdcd79b62f7f1e9f9a27c05a17bd2c17b792ebc98b2fa7b6", + "content_files": 86, + "content_files_sha256": "ed535246f4e8f46fcd7ce01f936127996aff873c6936b297f7272194b594aeda", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0, + "actual_owner_pages_rendered": 40, + "unsaved_outsider_page_denials": 40, + "production_identity_or_grant_created": false + }, + "installed_helper_sha256": { + "/opt/coulomb-social-backup/paired-backup.py": "43e119acb6a4933b4e144c349a091e074e1a91ab61dbe15a65c8264c1d47661a", + "/opt/coulomb-social-backup/snapshot-content-and-db.py": "dcc8dd2a3989f279597595f9260b31b24fcc9b6dc787f04c17ef0491c53d830d", + "/opt/railiance-platform/scripts/primary_application_archive.py": "a3f0a61e869a6012edb4b5288be14e3b0c4a9a1273b090320ab146d12e5aa94d" + }, + "snapshot_server_dry_run": "passed", + "public_status": { + "/healthz": 200, + "/": 200, + "/static/spaces/copy-content.js": 200 + }, + "timers": { + "coulomb-social-paired-backup.timer": { + "LoadState": "not-found", + "ActiveState": "inactive" + }, + "coulomb-social-backup-freshness.timer": { + "LoadState": "not-found", + "ActiveState": "inactive" + } + }, + "production_data_modified": false +} diff --git a/docs/evidence/2026-10-31-coulomb-image-release-receipt.json b/docs/evidence/2026-10-31-coulomb-image-release-receipt.json new file mode 100644 index 0000000..0799b60 --- /dev/null +++ b/docs/evidence/2026-10-31-coulomb-image-release-receipt.json @@ -0,0 +1,70 @@ +{ + "completed_at": "2026-10-31T07:15:24.659887+00:00", + "elapsed_seconds": 99.14, + "existing_global_constraint_preserved": true, + "fresh_paired_point": { + "age_recipient": "age1qm30jk8q0cezey652dk2dhcr3d9vs528v6lg8mavl6d4q3e3vgnqmrmyy3", + "application": "coulomb-social", + "application_restore_verified": false, + "captured_at": "2026-10-31T07:13:52Z", + "ciphertext_bytes": 2181848, + "ciphertext_sha256": "840e633047fddd88c6e6a1a3d6909d9af1ad80eaba8c90f83d8e81188dfc17a4", + "completed_at": "2026-10-31T07:14:50.236959+00:00", + "content_sha256": "644db8f09684bd4a79c126a01a329904351c30bb340295b29c8bb0ca71092dfe", + "database_sha256": "498a11e221dcdef38075d23ef89a26f213316c6164addb70d47bfcb98a1999a7", + "destination": "s3://railiance-platform-pg-backup/platform-pg/application-archives/coulomb-social/2026/10/31/20261031T071352Z-abf5997f01a14d9cb05e4bba.tar.age", + "encrypted_bytes": 2181848, + "encrypted_sha256": "840e633047fddd88c6e6a1a3d6909d9af1ad80eaba8c90f83d8e81188dfc17a4", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:512819463a1902b43a615beee14be7bf97e6a05e016385082b9380692ad1a8cd", + "immutable_create": true, + "local_archive": "20261031T071352Z-abf5997f01a14d9cb05e4bba.tar.age", + "object_key": "platform-pg/application-archives/coulomb-social/2026/10/31/20261031T071352Z-abf5997f01a14d9cb05e4bba.tar.age", + "pause_and_capture_seconds": 56.34, + "remote_retrieval_verified": true, + "schema": "platform.application-primary-archive.v1", + "status": "offsite_fetched_verified", + "version_id": "1793430889823443" + }, + "helm_revision": 19, + "node_request_percent": { + "cpu": 68, + "memory": 76 + }, + "pid": 3639216, + "postflight": { + "actual_owner_pages_rendered": 40, + "business_rows": 92, + "business_rows_sha256": "6243955479f7861fcdcd79b62f7f1e9f9a27c05a17bd2c17b792ebc98b2fa7b6", + "business_tables": 12, + "content_files": 86, + "content_files_sha256": "ed535246f4e8f46fcd7ce01f936127996aff873c6936b297f7272194b594aeda", + "duplicate_slug_groups": 0, + "global_slug_constraint": true, + "migration_0003_applied": true, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "production_identity_or_grant_created": false, + "tenant_slug_constraint": true, + "unsaved_outsider_page_denials": 40 + }, + "preflight": { + "business_rows": 92, + "business_rows_sha256": "6243955479f7861fcdcd79b62f7f1e9f9a27c05a17bd2c17b792ebc98b2fa7b6", + "business_tables": 12, + "content_files": 86, + "content_files_sha256": "ed535246f4e8f46fcd7ce01f936127996aff873c6936b297f7272194b594aeda", + "duplicate_slug_groups": 0, + "global_slug_constraint": true, + "migration_0003_applied": true, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "tenant_slug_constraint": true + }, + "private_values_printed": false, + "production_data_restored": false, + "proposal_digest": "sha256:9f1daf6c5fb56b2b4fc9817f28f5ad090c884b0295f7d6f4469e42d48fa8d59f", + "public_health_landing_static_verified": true, + "schema": "coulomb.guarded-image-release.v1", + "snapshot_pin_refreshed": true, + "started_at": "2026-10-31T07:13:45.511369+00:00", + "status": "image_candidate_deployed_verified", + "timers_activated": false +} diff --git a/docs/evidence/2026-10-31-coulomb-image-release-source-provenance.json b/docs/evidence/2026-10-31-coulomb-image-release-source-provenance.json new file mode 100644 index 0000000..255572a --- /dev/null +++ b/docs/evidence/2026-10-31-coulomb-image-release-source-provenance.json @@ -0,0 +1,10 @@ +{ + "schema": "coulomb.live-source-provenance.v1", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "source": "23ffe2cf4b214666754a2420558943438e54251a", + "packaged_files": 80, + "all_packaged_source_bytes_match": true, + "packaged_manifest_sha256": "6b5a7b339d88f3c5eafb70a33aa28a7d3972988645b246ae1a92a3f159d72528", + "live_read_only_probe": true, + "production_data_changed": false +} diff --git a/helm/coulomb-social-values.yaml b/helm/coulomb-social-values.yaml index 48b243c..56d4ded 100644 --- a/helm/coulomb-social-values.yaml +++ b/helm/coulomb-social-values.yaml @@ -3,8 +3,8 @@ image: repository: forgejo.coulomb.social/coulomb/coulomb-social - tag: "17d0eb6" - digest: "sha256:512819463a1902b43a615beee14be7bf97e6a05e016385082b9380692ad1a8cd" + tag: "23ffe2cf4b21" + digest: "sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed" # Public host: app.coulomb.social (parallel to Bubble on apex coulomb.social). # Apex cutover is deferred until data + self-registration are ready. diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index fea3cfc..119f326 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -345,11 +345,11 @@ docs/release/sharing-portability-review-2026-10-08.md. ```task id: RAPPS-WP-0015-T05 -status: progress +status: done priority: high -needs_human: true +needs_human: false decision_id: "75b94bb6-4709-495f-91fe-43e53e2867ed" -intervention_note: "Exact image-only proposal9f1daf6c passes native read-only preflight. Founder terminal execution remains required; then verify durable receipt, current source/values/helper pins and consumer recovery/export follow-through." +intervention_note: "Founder executed9f1daf6c; revision19 and complete independent postflight, source/helper/values reconciliation and current-point consumer recovery/export are verified. No rerun required." state_hub_task_id: "9f657a95-ce0e-5805-b754-a64e38827c73" ``` @@ -388,3 +388,14 @@ remaining productive journeys or service/offboarding obligations for the consume The canonical workstation launcher reproduces proposal `9f1daf6c` with `live_changes: false`; the founder packet is ready for attended execution. Consumer follow-through remains CSOC-WP-0012-T03 and CSOC-WP-0015-T04. + +2026-10-31 founder image-release return: proposal9f1daf6c completed at07:15:24Z, +Helm revision19/image522bcd7f. Independent complete-spec/Ready/identity checks, +all80packaged source hashes, unchanged92business rows/86files/migration history, +40owner renders/40outsider denials, public endpoints and refreshed helperdcc8dd2a +all pass. Live owner values/product pins are reconciled. Fresh07:13 primary point +is mirrored, independently retrieved and restored (13tables/114rows/86files); +packaged client export verifies85files without source mutation. Full make check +441passes, zero skips. Release decision75b94bb6 is resolved only for that exact +execution; RAPPS-WP-0015-T05 is complete. Productive flow and service/offboarding +acceptance remain open. Return: docs/release/identity-export-release-return-2026-10-31.md. From 904dcb3ce32c446fe4236d3e2c5e42b409a5fa7b Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 12:44:59 +0100 Subject: [PATCH 76/87] Keep future Coulomb release evidence directories private Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- WORK-RECORDS.md | 1 + tests/test_coulomb_image_release.py | 44 +++++++++++++++++++ tools/coulomb_image_release_node.py | 33 +++++++++++++- ...P-0015-coulomb-personal-content-release.md | 23 ++++++++++ 4 files changed, 99 insertions(+), 2 deletions(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index c255f81..1a75a6c 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -42,6 +42,7 @@ | task | RAPPS-WP-0015-T03 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T04 | wait | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T05 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | +| task | RAPPS-WP-0015-T06 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0016-T01 | done | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T02 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T03 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | diff --git a/tests/test_coulomb_image_release.py b/tests/test_coulomb_image_release.py index e733c02..703326e 100644 --- a/tests/test_coulomb_image_release.py +++ b/tests/test_coulomb_image_release.py @@ -2,6 +2,8 @@ import copy import importlib.util import json +import os +import stat from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock @@ -66,6 +68,48 @@ def test_mutable_staging_is_frozen_before_live_work(tmp_path, monkeypatch): assert (instance.journal / "receipt.json").is_file() +def test_nested_capsule_and_release_root_stay_private_under_normal_umask(tmp_path, monkeypatch): + root, _ = capsule(tmp_path) + nested = root / "charts/coulomb/templates/deployment.yaml" + nested.parent.mkdir(parents=True) + nested.write_bytes(b"synthetic chart") + plan = {"files": {str(p.relative_to(root)): release.sha(p.read_bytes()) + for p in root.rglob("*") if p.is_file() and p.name != "manifest.json"}} + (root / "manifest.json").write_text(json.dumps(plan)) + monkeypatch.setattr(release.os, "geteuid", lambda: 0) + monkeypatch.setattr(release, "JOURNAL_ROOT", tmp_path / "journal") + previous = os.umask(0o022) + try: + instance = release.Release(root, "sha256:" + release.sha(release.canonical(plan))) + finally: + os.umask(previous) + for path in [tmp_path / "journal", *(tmp_path / "journal").rglob("*")]: + assert stat.S_IMODE(path.stat().st_mode) == (0o700 if path.is_dir() else 0o600) + assert (instance.root / nested.relative_to(root)).read_bytes() == b"synthetic chart" + + +@pytest.mark.parametrize("problem", ["broad", "link", "ancestor_link", "file"]) +def test_unsafe_release_parent_is_refused_without_changing_it(tmp_path, monkeypatch, problem): + root, digest = capsule(tmp_path) + journal = tmp_path / "journal" + if problem == "broad": + journal.mkdir(mode=0o755) + elif problem == "file": + journal.write_text("untouched") + elif problem == "link": + journal.symlink_to(root, target_is_directory=True) + else: + alias = tmp_path / "alias" + alias.symlink_to(root, target_is_directory=True) + journal = alias / "journal" + monkeypatch.setattr(release.os, "geteuid", lambda: 0) + monkeypatch.setattr(release, "JOURNAL_ROOT", journal) + before = {str(p): p.lstat() for p in tmp_path.rglob("*")} + with pytest.raises((release.Refused, OSError)): + release.Release(root, digest) + assert before == {str(p): p.lstat() for p in tmp_path.rglob("*")} + + @pytest.mark.parametrize("field", ["business_rows_sha256", "content_files_sha256", "migration_rows_sha256"]) def test_dataset_drift_is_not_accepted(field): instance = object.__new__(release.Release) diff --git a/tools/coulomb_image_release_node.py b/tools/coulomb_image_release_node.py index d84b525..10381ea 100644 --- a/tools/coulomb_image_release_node.py +++ b/tools/coulomb_image_release_node.py @@ -16,6 +16,7 @@ import subprocess import tempfile import time import urllib.request +from contextlib import ExitStack from datetime import UTC, datetime from pathlib import Path @@ -35,6 +36,33 @@ class Refused(RuntimeError): pass +def private_directories(root, relative): + """Create every component privately; never repair existing unsafe state.""" + if not root.is_absolute() or relative.is_absolute() or ".." in relative.parts: + raise Refused("private_directory_path_required") + with ExitStack() as stack: + def opened(name, parent=None): + fd = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent) + stack.callback(os.close, fd) + return fd + + fd = opened("/") + for component in root.parts[1:]: + fd = opened(component, fd) + for component in (*relative.parts, None): + info = os.fstat(fd) + if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700: + raise Refused("private_owned_directory_required") + if component is None: + break + try: + os.mkdir(component, mode=0o700, dir_fd=fd) + os.fsync(fd) + except FileExistsError: + pass + fd = opened(component, fd) + + class Release: def __init__(self, capsule, expected, read_only=False): self.root = capsule @@ -73,12 +101,13 @@ class Release: return if self.journal.exists(): raise Refused("prior_execution_requires_review") - self.journal.mkdir(parents=True, mode=0o700) + private_directories(JOURNAL_ROOT.parent, Path(JOURNAL_ROOT.name)) + self.journal.mkdir(mode=0o700) self.root = self.journal / "capsule" self.root.mkdir(mode=0o700) for name, data in frozen.items(): target = self.root / name - target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + private_directories(self.root, Path(name).parent) fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, "wb") as stream: stream.write(data) diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index 119f326..d91b2c4 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -399,3 +399,26 @@ packaged client export verifies85files without source mutation. Full make check 441passes, zero skips. Release decision75b94bb6 is resolved only for that exact execution; RAPPS-WP-0015-T05 is complete. Productive flow and service/offboarding acceptance remain open. Return: docs/release/identity-export-release-return-2026-10-31.md. + +## T06 — Preserve private custody when freezing future release evidence + +```task +id: RAPPS-WP-0015-T06 +status: done +priority: high +needs_human: false +``` + +Source correction for CSOC-WP-0012-T13 after backup installation preflight exposed +0755 intermediate capsule directories from completed releases. The current image +controller creates each component with mode0700 through no-follow directory +file descriptors, checks private ownership/mode and refuses unsafe existing state. +It does not chmod historical directories or replay the completed release. Files +remain0600. The old storage capsule remains frozen; it is not a current release path. + +Validation: 24 focused release safety cases pass, including a nested chart under +umask022 and refusal of broad, symlinked, ancestor-symlinked and non-directory +parents without mutation. Ruff and git diff --check pass. Product receipt: +coulomb-social/docs/release/backup-producer-custody-validation-2026-10-31.json. +The historical eight-entry repair and recurring service acceptance remain product +T04/T09. No cluster operation occurred; future release proposals bind the new source. From 169dd3e0743a164abed4c0cc9505d61b9d678fa1 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 12:46:11 +0100 Subject: [PATCH 77/87] Record State Hub identity for release custody correction Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | 1 + 1 file changed, 1 insertion(+) diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index d91b2c4..35fe049 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -407,6 +407,7 @@ id: RAPPS-WP-0015-T06 status: done priority: high needs_human: false +state_hub_task_id: "5357f194-ae4d-599e-9a87-08e8d585a124" ``` Source correction for CSOC-WP-0012-T13 after backup installation preflight exposed From d3c46421b8afe79a8204d12fe4d98f47ed9539e8 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 12:58:59 +0100 Subject: [PATCH 78/87] Track release delivery for the content recovery correction Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- WORK-RECORDS.md | 1 + ...P-0015-coulomb-personal-content-release.md | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 1a75a6c..00cca4b 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -43,6 +43,7 @@ | task | RAPPS-WP-0015-T04 | wait | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T05 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T06 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | +| task | RAPPS-WP-0015-T07 | todo | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0016-T01 | done | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T02 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T03 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index 35fe049..f78e10e 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -423,3 +423,32 @@ parents without mutation. Ruff and git diff --check pass. Product receipt: coulomb-social/docs/release/backup-producer-custody-validation-2026-10-31.json. The historical eight-entry repair and recurring service acceptance remain product T04/T09. No cluster operation occurred; future release proposals bind the new source. + +## T07 — Deliver the attachment crash-recovery correction + +```task +id: RAPPS-WP-0015-T07 +status: todo +priority: high +needs_human: false +``` + +Deployment-owner follow-through for CSOC-WP-0013-T05. Candidate source f306dbf is +published at `sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec`. +Only packaged pageops.py differs from verified revision19/image522bcd7f. All80 +packaged sources match the commit; Python/runtime distribution versions match; +no migration is added. Product full gate546 and packaged lifecycle29 pass. The +previous image reproduces exactly four partial-attachment recovery failures under +real child-process exits. Product evidence: docs/release/content-lifecycle-candidate- +{build,provenance,tests}-2026-10-31.json and the baseline-tests receipt. + +Prepare an exact current-target image-only packet using the existing release +controller, including its private directory fix from T06. Verify current release, +full dataset/migration fingerprints and complete candidate/previous-image recovery +checks. Bind fresh paired rollback capture, postflight and snapshot image-pin +refresh. Keep historical capsules and existing pending backup repair/installation/ +route packets intact. Then obtain the attended execution and independently verify +its durable return. Current verified revision19 remains authoritative until that +return; no image deploy is implied by publication. Productive review stays with +CSOC-WP-0013-T05/T03. Retention policy c3a191f2 is accepted; no cutoff or purge was +authorized. Do not replay the completed identity image release. From 75343cfec199dbeb3198351739fba7ef7f2231d0 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 13:00:13 +0100 Subject: [PATCH 79/87] Record deployment-owner task identity for content recovery release Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | 1 + 1 file changed, 1 insertion(+) diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index f78e10e..c8e5e2b 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -431,6 +431,7 @@ id: RAPPS-WP-0015-T07 status: todo priority: high needs_human: false +state_hub_task_id: "8f448934-bdfe-539f-8cab-a739ec752491" ``` Deployment-owner follow-through for CSOC-WP-0013-T05. Candidate source f306dbf is From 9c748846413c6db3f13ae3e55627fe3f6ad89194 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 13:20:46 +0100 Subject: [PATCH 80/87] Prepare guarded content release after inactive backup installation Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- WORK-RECORDS.md | 2 +- ...t-lifecycle-baseline-tests-2026-10-31.json | 25 +++ ...-lifecycle-candidate-build-2026-10-31.json | 119 ++++++++++++ ...cycle-candidate-provenance-2026-10-31.json | 20 ++ ...fecycle-candidate-recovery-2026-10-31.json | 65 +++++++ ...-lifecycle-candidate-tests-2026-10-31.json | 20 ++ ...ifecycle-release-row-drift-2026-10-31.json | 61 ++++++ ...t-lifecycle-release-target-2026-10-31.json | 62 +++++++ ...ntent-lifecycle-validation-2026-10-31.json | 58 ++++++ release/content-lifecycle-snapshot.py | 175 ++++++++++++++++++ release/content-lifecycle-values.yaml | 50 +++++ .../coulomb-content-lifecycle-f306dbf.json | 69 +++++++ tests/test_coulomb_image_release.py | 126 +++++++++++++ tools/coulomb-image-release.py | 27 ++- tools/coulomb_image_release_node.py | 82 ++++++-- ...P-0015-coulomb-personal-content-release.md | 31 +++- 16 files changed, 972 insertions(+), 20 deletions(-) create mode 100644 release/content-lifecycle-evidence/content-lifecycle-baseline-tests-2026-10-31.json create mode 100644 release/content-lifecycle-evidence/content-lifecycle-candidate-build-2026-10-31.json create mode 100644 release/content-lifecycle-evidence/content-lifecycle-candidate-provenance-2026-10-31.json create mode 100644 release/content-lifecycle-evidence/content-lifecycle-candidate-recovery-2026-10-31.json create mode 100644 release/content-lifecycle-evidence/content-lifecycle-candidate-tests-2026-10-31.json create mode 100644 release/content-lifecycle-evidence/content-lifecycle-release-row-drift-2026-10-31.json create mode 100644 release/content-lifecycle-evidence/content-lifecycle-release-target-2026-10-31.json create mode 100644 release/content-lifecycle-evidence/content-lifecycle-validation-2026-10-31.json create mode 100644 release/content-lifecycle-snapshot.py create mode 100644 release/content-lifecycle-values.yaml create mode 100644 release/coulomb-content-lifecycle-f306dbf.json diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 00cca4b..af8ac76 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -43,7 +43,7 @@ | task | RAPPS-WP-0015-T04 | wait | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T05 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T06 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | -| task | RAPPS-WP-0015-T07 | todo | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | +| task | RAPPS-WP-0015-T07 | wait | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0016-T01 | done | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T02 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T03 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | diff --git a/release/content-lifecycle-evidence/content-lifecycle-baseline-tests-2026-10-31.json b/release/content-lifecycle-evidence/content-lifecycle-baseline-tests-2026-10-31.json new file mode 100644 index 0000000..6b3e676 --- /dev/null +++ b/release/content-lifecycle-evidence/content-lifecycle-baseline-tests-2026-10-31.json @@ -0,0 +1,25 @@ +{ + "schema": "coulomb.packaged-content-lifecycle-tests.v1", + "source": "23ffe2cf4b214666754a2420558943438e54251a", + "image_id": "sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "tests": 29, + "failures": 4, + "expected_baseline_regressions": true, + "failed_nodes": [ + "test_process_death_during_copy_or_move_is_recoverable[copy_page-during_first_asset]", + "test_process_death_during_copy_or_move_is_recoverable[copy_page-during_second_asset]", + "test_process_death_during_copy_or_move_is_recoverable[transfer_page-during_first_asset]", + "test_process_death_during_copy_or_move_is_recoverable[transfer_page-during_second_asset]" + ], + "errors": 0, + "skipped": 0, + "network": "none", + "read_only_root": true, + "application_source_overlay": false, + "test_tooling_injected": true, + "application_modules_from_image_verified": true, + "production_access": false, + "junit_sha256": "edc52bd3a321d028d255b3d478020b3383d94747957ffe2985583b1f0741cb85", + "driver_sha256": "85b65114997c7e7c5323cb77a0043e495ec132506eef07dd394c6a6361380d36", + "test_source_sha256": "84a25a22a3cbda397e39c0508db8ea5aa90e6c467ccfa7da2b568d174d1ec449" +} diff --git a/release/content-lifecycle-evidence/content-lifecycle-candidate-build-2026-10-31.json b/release/content-lifecycle-evidence/content-lifecycle-candidate-build-2026-10-31.json new file mode 100644 index 0000000..07207bc --- /dev/null +++ b/release/content-lifecycle-evidence/content-lifecycle-candidate-build-2026-10-31.json @@ -0,0 +1,119 @@ +{ + "schema": "coulomb.content-lifecycle-candidate-build.v1", + "completed_at": "2026-10-31T11:58:26.697591+00:00", + "source": "f306dbfa2604e4815523667740be2925c333b84e", + "tag": "forgejo.coulomb.social/coulomb/coulomb-social:f306dbfa2604", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec", + "registry_digest_independently_verified": true, + "source_revision_label_verified": true, + "build_context": "Clean git archive restricted to Dockerfile, dockerignore, lock, project, manage.py, app and entrypoint.", + "build_inputs": [ + { + "path": "Dockerfile", + "sha256": "d0e197a18db8980eb3e51cca842d693e10ab0b4208627bba28691d10719d4a00" + }, + { + "path": ".dockerignore", + "sha256": "901c8b95d95549d0e78c1c3d6d0538bf0252c8a77e9409129e0cadfa55c7c56c" + }, + { + "path": "pyproject.toml", + "sha256": "5758c56fd1be89b973861ccc5ecbadb495e742ee13ebd7e19a221a0f119c54ee" + }, + { + "path": "uv.lock", + "sha256": "e8ebb6fc4b5abd1ac80f1c9012f1983642d0528a8a213cfcc394261606fad887" + }, + { + "path": "scripts/docker-entrypoint.sh", + "sha256": "70100256ca41ae6afe77078c810a8b6012472f0a741919f403f32088f98ab463" + } + ], + "runtime": { + "previous": { + "python": "3.12.16", + "distributions": { + "h11": "0.16.0", + "httpcore": "1.0.9", + "psycopg": "3.3.4", + "joserfc": "1.7.4", + "anyio": "4.14.2", + "cryptography": "50.0.0", + "idna": "3.18", + "certifi": "2026.7.22", + "packaging": "26.3", + "python-decouple": "3.8", + "psycopg-binary": "3.3.4", + "sqlparse": "0.5.5", + "whitenoise": "6.12.0", + "cffi": "2.1.1", + "markdown": "3.10.3", + "webencodings": "0.5.1", + "pycparser": "3.0", + "bleach": "6.4.0", + "authlib": "1.7.2", + "django": "6.1", + "typing_extensions": "4.16.0", + "gunicorn": "26.0.0", + "asgiref": "3.12.1", + "httpx": "0.28.1", + "dj-database-url": "3.1.2" + } + }, + "candidate": { + "python": "3.12.16", + "distributions": { + "h11": "0.16.0", + "httpcore": "1.0.9", + "psycopg": "3.3.4", + "joserfc": "1.7.4", + "anyio": "4.14.2", + "cryptography": "50.0.0", + "idna": "3.18", + "certifi": "2026.7.22", + "packaging": "26.3", + "python-decouple": "3.8", + "psycopg-binary": "3.3.4", + "sqlparse": "0.5.5", + "whitenoise": "6.12.0", + "cffi": "2.1.1", + "markdown": "3.10.3", + "webencodings": "0.5.1", + "pycparser": "3.0", + "bleach": "6.4.0", + "authlib": "1.7.2", + "django": "6.1", + "typing_extensions": "4.16.0", + "gunicorn": "26.0.0", + "asgiref": "3.12.1", + "httpx": "0.28.1", + "dj-database-url": "3.1.2" + } + }, + "runtime_packages_equal": true, + "python_versions_equal": true, + "django_check": { + "returncode": 0, + "output": "System check identified no issues (0 silenced)." + }, + "migration_drift": { + "returncode": 0, + "output": "No changes detected" + } + }, + "source_delta_from_revision_19": [ + "coulomb_social/apps/spaces/pageops.py" + ], + "new_database_migrations": false, + "production_deployed": false, + "live_release_pins_modified": false, + "source_full_gate_tests": 546, + "packaged_lifecycle_tests": 29, + "previous_image_expected_lifecycle_failures": 4, + "owner_release_task": "RAPPS-WP-0015-T07", + "remaining": [ + "Exact current-target release packet, paired rollback evidence and candidate/previous-image restored-store checks.", + "Attended image release and independent native postflight.", + "Productive lifecycle verdict; retention policy already accepted under c3a191f2-ce06-483f-82d7-ef8c54ea0d5e." + ] +} diff --git a/release/content-lifecycle-evidence/content-lifecycle-candidate-provenance-2026-10-31.json b/release/content-lifecycle-evidence/content-lifecycle-candidate-provenance-2026-10-31.json new file mode 100644 index 0000000..60aefca --- /dev/null +++ b/release/content-lifecycle-evidence/content-lifecycle-candidate-provenance-2026-10-31.json @@ -0,0 +1,20 @@ +{ + "schema": "coulomb.packaged-source-provenance.v1", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec", + "network": "none", + "read_only_root": true, + "entrypoint_bypassed": true, + "database_contact": false, + "production_changes": false, + "comparisons": [ + { + "commit": "f306dbfa2604e4815523667740be2925c333b84e", + "packaged_file_count": 80, + "source_file_count": 80, + "different_files": 0, + "all_packaged_source_bytes_match": true + } + ], + "actual_packaged_manifest_sha256": "b5c8a16b4b3d30cc9d8a7a9f3ed61bd8cb0e182589aceb5e4127fff27a8ae482", + "dependency_or_build_reproducibility_proved": false +} diff --git a/release/content-lifecycle-evidence/content-lifecycle-candidate-recovery-2026-10-31.json b/release/content-lifecycle-evidence/content-lifecycle-candidate-recovery-2026-10-31.json new file mode 100644 index 0000000..72b4365 --- /dev/null +++ b/release/content-lifecycle-evidence/content-lifecycle-candidate-recovery-2026-10-31.json @@ -0,0 +1,65 @@ +{ + "schema": "coulomb.packaged-content-lifecycle-recovery.v1", + "status": "packaged_recovered_client_export_and_views_verified", + "source": "f306dbfa2604e4815523667740be2925c333b84e", + "image_id": "sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec", + "restore_receipt_sha256": "966d6640e42e2f4940c66d1cd5c8a802cd950de0305572f3c8b720ee3d81ae2b", + "client_export": { + "status": "recovered_client_export_verified", + "schema": "coulomb.client-dataset-export.v1", + "content_files": 85, + "records": { + "members.user": 1, + "members.member": 1, + "spaces.space": 1, + "spaces.spacemembership": 1 + }, + "admin_events": 0, + "archive_sha256": "e364a61570e0083e729a3f314eb707c57347200438bd98170c77b8f7fcc2b337", + "offboarding_complete": false, + "all_source_tables_compared": 13, + "all_source_files_compared": 86, + "archive_member_hashes_verified": true, + "selected_client_content_complete_locally": true, + "source_sql_and_content_unchanged": true, + "external_bindings_require_review": 0, + "sessions_and_credentials_excluded": true + }, + "candidate_application": { + "status": "isolated_application_recovery_verified", + "recovered_spaces_with_content": 1, + "actual_pages_rendered_through_view": 40, + "restored_owner_access_verified": true, + "synthetic_viewer_reads": 1, + "synthetic_viewer_mutation_denials": 1, + "same_tenant_ungranted_page_denials": 40, + "fixture_transaction_rolled_back": true, + "production_access": false, + "external_identity_provider_used": false, + "real_browser_journey_verified": false + }, + "previous_image_application": { + "status": "isolated_application_recovery_verified", + "recovered_spaces_with_content": 1, + "actual_pages_rendered_through_view": 40, + "restored_owner_access_verified": true, + "synthetic_viewer_reads": 1, + "synthetic_viewer_mutation_denials": 1, + "same_tenant_ungranted_page_denials": 40, + "fixture_transaction_rolled_back": true, + "production_access": false, + "external_identity_provider_used": false, + "real_browser_journey_verified": false + }, + "source_content_files_unchanged": 86, + "all_tables_unchanged_after_both_view_checks": true, + "application_source_overlay": false, + "export_archive_removed": true, + "network_internal": true, + "published_ports": 0, + "production_access": false, + "production_restore": false, + "post_date_correction_data_claimed": true, + "retained_point_captured_at": "2026-10-31T07:13:52Z", + "driver_sha256": "92b66225e5f9bdf03d0a0999b613370c42341a2ceef468f1743fb4301884cb25" +} diff --git a/release/content-lifecycle-evidence/content-lifecycle-candidate-tests-2026-10-31.json b/release/content-lifecycle-evidence/content-lifecycle-candidate-tests-2026-10-31.json new file mode 100644 index 0000000..a0748e4 --- /dev/null +++ b/release/content-lifecycle-evidence/content-lifecycle-candidate-tests-2026-10-31.json @@ -0,0 +1,20 @@ +{ + "schema": "coulomb.packaged-content-lifecycle-tests.v1", + "source": "f306dbfa2604e4815523667740be2925c333b84e", + "image_id": "sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec", + "tests": 29, + "failures": 0, + "expected_baseline_regressions": false, + "failed_nodes": [], + "errors": 0, + "skipped": 0, + "network": "none", + "read_only_root": true, + "application_source_overlay": false, + "test_tooling_injected": true, + "application_modules_from_image_verified": true, + "production_access": false, + "junit_sha256": "f2968ef1048ec9af68a187b967d558db0f6ee76e765a8106fb7fab284953511f", + "driver_sha256": "85b65114997c7e7c5323cb77a0043e495ec132506eef07dd394c6a6361380d36", + "test_source_sha256": "84a25a22a3cbda397e39c0508db8ea5aa90e6c467ccfa7da2b568d174d1ec449" +} diff --git a/release/content-lifecycle-evidence/content-lifecycle-release-row-drift-2026-10-31.json b/release/content-lifecycle-evidence/content-lifecycle-release-row-drift-2026-10-31.json new file mode 100644 index 0000000..8c8aeec --- /dev/null +++ b/release/content-lifecycle-evidence/content-lifecycle-release-row-drift-2026-10-31.json @@ -0,0 +1,61 @@ +{ + "changed_columns": { + "django_session": [ + "session_key", + "session_data", + "expire_date" + ], + "members_member": [ + "updated_at", + "last_login_at" + ], + "members_user": [ + "last_login" + ] + }, + "file_changes": [], + "retained_fingerprint": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 92, + "business_rows_sha256": "6243955479f7861fcdcd79b62f7f1e9f9a27c05a17bd2c17b792ebc98b2fa7b6", + "content_files": 86, + "content_files_sha256": "ed535246f4e8f46fcd7ce01f936127996aff873c6936b297f7272194b594aeda", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0 + }, + "schema": "coulomb.content-release-row-drift.v1", + "changed_tables": [ + { + "table": "django_session", + "retained_rows": 42, + "current_rows": 43, + "retained_sha256": "c958e33ec782f9f2fd1b3cceba4a64da0038fde51050f25905ae42de91ca6dde", + "current_sha256": "594e246ef6297d9be9b6e8d9eda076a5b2ab36afb9263606b719f64f51293ba6" + }, + { + "table": "members_member", + "retained_rows": 1, + "current_rows": 1, + "retained_sha256": "4d465d2b9d1e6311e505f861f2ed4b67763a545a965f9a7d0c586a3d07e3e9a3", + "current_sha256": "ab82b3f2c5e49f8634c598c9642894747f4b6fcff0f5230ac27f41939feb8640" + }, + { + "table": "members_user", + "retained_rows": 1, + "current_rows": 1, + "retained_sha256": "81ccb3a40191e910a68c101896df518a588c00231728934c4c900577f8ae4f12", + "current_sha256": "0fc7a71b4b80a71fbedce527bbd2cae3858cf6a062e5714fc231be13ff987256" + } + ], + "all_other_business_tables_equal": true, + "migration_history_equal": true, + "content_equal": true, + "retained_point": "2026-10-31T07:13:52Z", + "current_business_rows": 93, + "current_business_rows_sha256": "7953b37b02b80d89ca6869dab6b2b0b996541de20ca0dd1b294a5f25572fb2ca", + "private_row_values_printed": false, + "production_changes": false +} diff --git a/release/content-lifecycle-evidence/content-lifecycle-release-target-2026-10-31.json b/release/content-lifecycle-evidence/content-lifecycle-release-target-2026-10-31.json new file mode 100644 index 0000000..a3e6e05 --- /dev/null +++ b/release/content-lifecycle-evidence/content-lifecycle-release-target-2026-10-31.json @@ -0,0 +1,62 @@ +{ + "schema": "coulomb.content-release-target-observation.v1", + "observed_at": "2026-10-31T12:07:24.322343+00:00", + "cluster_uid": "a553c742-0115-43d4-99a4-a5ca56fe0786", + "deployment_uid": "2dbe661a-d232-4477-bbd1-833813077fbe", + "pvc_uid": "9888abeb-9d52-4c70-a23d-b5494780d9c3", + "baseline_revision": 19, + "baseline_helm_manifest_sha256": "93cb277abd1aea0657be482e458dcfb268fe0bfbd45d90aa1faedcc7f5bce499", + "baseline_spec_sha256": "42e091e52d5dae3c1f986e4898c62057fa80846adfce049a75d8fbd4681180ea", + "candidate_spec_sha256": "c65cb691bab7054ae25de58cdb75933d383621ec4e049d2daa1b1bf52dce0bba", + "old_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "candidate_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec", + "sole_ready_pvc_writer": true, + "existing_unmanaged_metadata_preserved": true, + "candidate_image_only_server_preview_verified": true, + "data": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 93, + "business_rows_sha256": "7953b37b02b80d89ca6869dab6b2b0b996541de20ca0dd1b294a5f25572fb2ca", + "content_files": 86, + "content_files_sha256": "ed535246f4e8f46fcd7ce01f936127996aff873c6936b297f7272194b594aeda", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0, + "actual_owner_pages_rendered": 40, + "unsaved_outsider_page_denials": 40, + "production_identity_or_grant_created": false + }, + "matches_verified_revision19_data": false, + "installed_helper_sha256": { + "/opt/coulomb-social-backup/paired-backup.py": "43e119acb6a4933b4e144c349a091e074e1a91ab61dbe15a65c8264c1d47661a", + "/opt/coulomb-social-backup/snapshot-content-and-db.py": "dcc8dd2a3989f279597595f9260b31b24fcc9b6dc787f04c17ef0491c53d830d", + "/opt/railiance-platform/scripts/primary_application_archive.py": "a3f0a61e869a6012edb4b5288be14e3b0c4a9a1273b090320ab146d12e5aa94d" + }, + "timers": { + "coulomb-social-paired-backup.timer": { + "LoadState": "not-found", + "ActiveState": "inactive", + "UnitFileState": "" + }, + "coulomb-social-backup-freshness.timer": { + "LoadState": "not-found", + "ActiveState": "inactive", + "UnitFileState": "" + } + }, + "prerequisites": { + "custody": { + "exists": false + }, + "installation": { + "exists": false + }, + "route": { + "exists": false + } + }, + "production_data_modified": false, + "release_executed": false +} diff --git a/release/content-lifecycle-evidence/content-lifecycle-validation-2026-10-31.json b/release/content-lifecycle-evidence/content-lifecycle-validation-2026-10-31.json new file mode 100644 index 0000000..f426e59 --- /dev/null +++ b/release/content-lifecycle-evidence/content-lifecycle-validation-2026-10-31.json @@ -0,0 +1,58 @@ +{ + "schema": "coulomb.content-lifecycle-validation.v1", + "recorded_at": "2026-10-31T11:54:24.215512+00:00", + "status": "source_fix_verified", + "workplan_task": "CSOC-WP-0013-T05", + "bug": "A process exit during attachment creation left bytes absent from the copy/move cleanup journal; the orphan blocked retry.", + "regression_observed_before_fix": { + "baseline": "f70708839cd5ca43310f6891c8d94aaff915139c", + "operations": [ + "copy_page", + "transfer_page" + ], + "interruption": "partial first attachment flushed, immediate os._exit(86)", + "result": "both failed because the partial destination survived recovery" + }, + "fix": [ + "Refuse existing attachment entries before recording cleanup authority.", + "Persist intended new attachment name before exclusive creation under the content writer lock.", + "Flush recovery and attachment directory entries before destination publication." + ], + "focused_cases": { + "total": 29, + "abrupt_child_process_exit": 14, + "existing_destination_collision": 8, + "prior_retention_and_recovery": 7 + }, + "full_gate": { + "command": "make check", + "tests": 546, + "failures": 0, + "errors": 0, + "skipped": 0, + "warnings": 69, + "junit_sha256": "dd3d0433c8109f00f6dddd119cfdf8281915f94da2da1f5e6009c31cfe76c65b", + "log_sha256": "eb59a394c95c5307c17069874640d1655ef61c5aa7bb426ef6d21db4929f748e" + }, + "files": [ + { + "path": "coulomb_social/apps/spaces/pageops.py", + "sha256": "055ef25b5efc75798cf17ffa68739de4d4099d2cab4d041e7c7eacdfaf43803e" + }, + { + "path": "tests/test_content_recovery.py", + "sha256": "84a25a22a3cbda397e39c0508db8ea5aa90e6c467ccfa7da2b568d174d1ec449" + } + ], + "policy": "User accepted retain-all default and separately reviewed manual cutoff; keep live page and all history of deleted pages.", + "live_purge_authorized": false, + "live_purge_executed": false, + "production_deployed": false, + "productive_acceptance": false, + "limits": [ + "Child process termination on synthetic local files is proved; physical power-loss durability is not experimentally proved.", + "Cooperating application/import writers must use the content writer lock.", + "Release delivery and productive copy/move review remain open." + ], + "policy_decision_id": "c3a191f2-ce06-483f-82d7-ef8c54ea0d5e" +} diff --git a/release/content-lifecycle-snapshot.py b/release/content-lifecycle-snapshot.py new file mode 100644 index 0000000..de340de --- /dev/null +++ b/release/content-lifecycle-snapshot.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Node-side quiesced coulomb-social snapshot. Binary output stays private. + +Run on railiance01 after reviewed live approval. Does not restore production. +Default is server dry-run; --execute briefly pauses the app and captures a +paired pg_dump/content archive. A separate isolated restore must validate it. +""" +from __future__ import annotations + +import argparse +import fcntl +import hashlib +import json +import os +import signal +import stat +import subprocess +import sys +import time +from pathlib import Path + +NS = 'coulomb-social' +IMAGE = ('forgejo.coulomb.social/coulomb/coulomb-social@sha256:' + 'd3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec') +CLUSTER_UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786' +LOCK_PATH = Path('/var/lib/coulomb-social-backup/snapshot.lock') + + +class Refused(RuntimeError): + pass + + +def run(args: list[str], *, payload: bytes | None = None, timeout: int = 180) -> bytes: + p = subprocess.run(args, input=payload, capture_output=True, timeout=timeout) + if p.returncode: + raise Refused('command_failed') + return p.stdout + + +def k(*args: str, payload: bytes | None = None) -> bytes: + return run(['kubectl', *args], payload=payload) + + +def document(*args: str) -> dict: + return json.loads(k(*args, '-o', 'json')) + + +def private_write(path: Path, value: bytes) -> None: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, 'wb') as f: + f.write(value) + + +def snapshot() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--execute', action='store_true') + parser.add_argument('--destination', type=Path, required=True) + args = parser.parse_args() + os.environ['KUBECONFIG'] = '/etc/rancher/k3s/k3s.yaml' + started = time.monotonic() + if document('get', 'namespace', 'kube-system')['metadata']['uid'] != CLUSTER_UID: + raise Refused('wrong_cluster') + dep = document('get', 'deployment', 'coulomb-social', '-n', NS) + spec = dep['spec']['template']['spec'] + if dep['spec']['replicas'] != 1 or spec['containers'][0]['image'] != IMAGE: + raise Refused('unexpected_release') + if document('get', 'pvc', 'coulomb-social-content', '-n', NS)['status']['phase'] != 'Bound': + raise Refused('pvc_not_bound') + cluster = document('get', 'cluster', 'apps-pg', '-n', 'databases') + primary = cluster['status']['currentPrimary'] + name = 'csoc-paired-snapshot-' + time.strftime('%Y%m%d%H%M%S', time.gmtime()) + pod = { + 'apiVersion': 'v1', 'kind': 'Pod', + 'metadata': {'name': name, 'namespace': NS, 'labels': {'csoc-recovery': name}}, + 'spec': { + 'restartPolicy': 'Never', 'automountServiceAccountToken': False, + 'securityContext': {'runAsNonRoot': True, 'runAsUser': 10001, + 'runAsGroup': 10001, 'fsGroup': 10001, + 'fsGroupChangePolicy': 'OnRootMismatch'}, + 'containers': [{'name': 'snapshot', 'image': IMAGE, + 'command': ['python', '-c', 'import time;time.sleep(1800)'], + 'resources': {'requests': {'cpu': '10m', 'memory': '64Mi'}, + 'limits': {'cpu': '100m', 'memory': '256Mi'}}, + 'securityContext': {'allowPrivilegeEscalation': False, + 'readOnlyRootFilesystem': True, + 'capabilities': {'drop': ['ALL']}}, + 'volumeMounts': [{'name': 'content', 'mountPath': '/snapshot', 'readOnly': True}]}], + 'volumes': [{'name': 'content', 'persistentVolumeClaim': + {'claimName': 'coulomb-social-content', 'readOnly': True}}], + }, + } + body = json.dumps(pod).encode() + k('create', '--dry-run=server', '-f', '-', payload=body) + if not args.execute: + print(json.dumps({'status': 'reviewed', 'server_dry_run': True, + 'image_pinned': True, 'pvc_read_only': True, + 'db_scope': 'coulomb_social_db', 'replicas_restored_to': 1})) + return 0 + dest = args.destination + if not dest.is_absolute() or dest.exists(): + raise Refused('destination_must_be_new_absolute_directory') + dest.mkdir(mode=0o700) + paused = False + created = False + receipt = {'status': 'incomplete', 'app': 'coulomb-social', + 'database': 'coulomb_social_db', 'image': IMAGE, + 'started_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} + try: + paused = True + k('scale', 'deployment/coulomb-social', '-n', NS, '--replicas=0') + k('wait', '--for=delete', 'pod', '-n', NS, + '-l', 'app.kubernetes.io/instance=coulomb-social', '--timeout=120s') + created = True + k('create', '-f', '-', payload=body) + k('wait', '--for=condition=Ready', 'pod/' + name, '-n', NS, '--timeout=120s') + dump = k('exec', '-n', 'databases', primary, '-c', 'postgres', '--', + 'pg_dump', '-U', 'postgres', '-d', 'coulomb_social_db', '-Fc') + if not dump.startswith(b'PGDMP'): + raise Refused('invalid_pg_dump') + private_write(dest / 'database.dump', dump) + archive_code = ('import io,sys,tarfile; b=io.BytesIO(); ' + 't=tarfile.open(fileobj=b,mode="w:gz"); ' + 't.add("/snapshot",arcname="content"); ' + 't.close();sys.stdout.buffer.write(b.getvalue())') + content = k('exec', '-n', NS, name, '--', 'python', '-c', archive_code) + private_write(dest / 'content.tar.gz', content) + receipt.update(status='captured', writers_quiesced=True, + database_sha256=hashlib.sha256(dump).hexdigest(), + content_sha256=hashlib.sha256(content).hexdigest(), + database_bytes=len(dump), content_bytes=len(content)) + finally: + try: + if created: + k('delete', 'pod', name, '-n', NS, '--wait=true', '--timeout=120s') + finally: + if paused: + k('scale', 'deployment/coulomb-social', '-n', NS, '--replicas=1') + k('rollout', 'status', 'deployment/coulomb-social', '-n', NS, '--timeout=180s') + receipt['app_resumed'] = True + receipt['elapsed_seconds'] = round(time.monotonic() - started, 2) + private_write(dest / 'receipt.json', json.dumps(receipt, indent=2).encode()) + print(json.dumps(receipt)) + return 0 + + +def main() -> int: + if '--execute' not in sys.argv: + return snapshot() + LOCK_PATH.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + parent = LOCK_PATH.parent.lstat() + if (not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.geteuid() + or parent.st_mode & 0o077): + raise Refused('lock_directory_not_private') + fd = os.open(LOCK_PATH, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'w') as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise Refused('snapshot_already_running') from None + def terminate(signum, frame): + raise Refused('snapshot_interrupted') + previous = signal.signal(signal.SIGTERM, terminate) + try: + return snapshot() + finally: + signal.signal(signal.SIGTERM, previous) + + +if __name__ == '__main__': + try: + sys.exit(main()) + except Exception as exc: + # Never expose child output, kubectl objects, credentials or raw exception messages. + print(json.dumps({'status': 'failed', 'failure_type': type(exc).__name__})) + sys.exit(1) diff --git a/release/content-lifecycle-values.yaml b/release/content-lifecycle-values.yaml new file mode 100644 index 0000000..3c0e2f6 --- /dev/null +++ b/release/content-lifecycle-values.yaml @@ -0,0 +1,50 @@ +# Production overrides for coulomb-social. +# Non-secret values only. Secrets live in K8s Secret coulomb-social-env. + +image: + repository: forgejo.coulomb.social/coulomb/coulomb-social + tag: "f306dbfa2604" + digest: "sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec" + +# Public host: app.coulomb.social (parallel to Bubble on apex coulomb.social). +# Apex cutover is deferred until data + self-registration are ready. +env: + DJANGO_SETTINGS_MODULE: coulomb_social.settings.prod + ALLOWED_HOSTS: app.coulomb.social,localhost + CSRF_TRUSTED_ORIGINS: https://app.coulomb.social + CONTENT_ROOT: /app/var/content + DEFAULT_TENANT_ID: "tenant:coulomb" + OIDC_ENABLED: "true" + OIDC_ISSUER: https://kc.coulomb.social + OIDC_CLIENT_ID: coulomb-social + OIDC_REDIRECT_URI: https://app.coulomb.social/auth/callback/ + OIDC_SCOPES: "openid profile email groups" + USER_ENGINE_BASE_URL: https://users.coulomb.social + USER_ENGINE_APPLICATION_ID: coulomb-social + USER_ENGINE_EXPECTED_AUDIENCE: user-engine-portal + NETKINGDOM_REGISTRATION_URL: https://users.92-205-62-239.nip.io/register + +probes: + hostHeader: app.coulomb.social + path: /healthz + +# One writable local-path volume; Recreate prevents concurrent old/new writers. +replicaCount: 1 +deploymentStrategy: Recreate +persistence: + content: + enabled: true + storageClass: local-path + size: 2Gi + mountPath: /app/var/content + retain: true + +# Wave-2 right-sizing (CSOC-WP-0017-T01, 2026-10-21 node budget): 7d p95 +# 0.4m CPU / 146Mi. Limits stay at the chart defaults (1000m/1Gi). +resources: + requests: + cpu: 15m + memory: 176Mi + limits: + cpu: 1000m + memory: 1Gi diff --git a/release/coulomb-content-lifecycle-f306dbf.json b/release/coulomb-content-lifecycle-f306dbf.json new file mode 100644 index 0000000..9937810 --- /dev/null +++ b/release/coulomb-content-lifecycle-f306dbf.json @@ -0,0 +1,69 @@ +{ + "schema": "coulomb.reviewed-image-release.v1", + "proposal_id": "CSOC-CONTENT-LIFECYCLE-20261031", + "scope": "ADMINISTER @ realm:kubernetes/railiance01; supervised founder execution only", + "operation": "image_only_existing_migration_0003", + "cluster_uid": "a553c742-0115-43d4-99a4-a5ca56fe0786", + "baseline_revision": 19, + "baseline_helm_manifest_sha256": "93cb277abd1aea0657be482e458dcfb268fe0bfbd45d90aa1faedcc7f5bce499", + "candidate_render_sha256": "1606725273089b83689850ad1c25d1834faa15e00c8cf8aa24e1053273af71bc", + "deployment_uid": "2dbe661a-d232-4477-bbd1-833813077fbe", + "pvc_uid": "9888abeb-9d52-4c70-a23d-b5494780d9c3", + "baseline_spec_sha256": "42e091e52d5dae3c1f986e4898c62057fa80846adfce049a75d8fbd4681180ea", + "candidate_spec_sha256": "c65cb691bab7054ae25de58cdb75933d383621ec4e049d2daa1b1bf52dce0bba", + "old_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "candidate_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec", + "product_source": "f306dbfa2604e4815523667740be2925c333b84e", + "expected_data": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 93, + "business_rows_sha256": "7953b37b02b80d89ca6869dab6b2b0b996541de20ca0dd1b294a5f25572fb2ca", + "content_files": 86, + "content_files_sha256": "ed535246f4e8f46fcd7ce01f936127996aff873c6936b297f7272194b594aeda", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0 + }, + "public_recipient": "age1qm30jk8q0cezey652dk2dhcr3d9vs528v6lg8mavl6d4q3e3vgnqmrmyy3", + "installed_helper_hashes": { + "/opt/coulomb-social-backup/paired-backup.py": "d161c742d1a6a182460a57d0a1719591ab58fc94006f55169ad3d74442a93630", + "/opt/coulomb-social-backup/snapshot-content-and-db.py": "dcc8dd2a3989f279597595f9260b31b24fcc9b6dc787f04c17ef0491c53d830d", + "/opt/coulomb-social-backup/mirror-paired-backup.py": "9ceb26d9083af016c4b787907e4dce7bdf437c782fb2d15eb28d9e334e6e431c", + "/opt/coulomb-social-backup/check-paired-backup-health.py": "2d40ec6e4c59ea7fe1d46065d197b9a87ca6811f023cc6f3e20e4184a3490566", + "/opt/coulomb-social-backup/backup_capacity.py": "cb699a79e67b129239b5aa5d6f3b0d4ffaa9773a8b073227ad17fdecb5b9d893", + "/opt/railiance-platform/scripts/application_recovery_identities.py": "20f56255ed6eeec4aafe33f142bc3ee03f623d4957a7b0cabb741d340f15f79d", + "/opt/railiance-platform/scripts/application_recovery_mirror.py": "d48ab95e89b9eeae36269d853ebe4c44c22e1f57d4e14224f03d0b9aeeb34178", + "/opt/railiance-platform/scripts/application_recovery_resources.py": "ab70ec341e738272730eb0daf76840d0bca807caeb3c4e4c2357f162fbed4498", + "/opt/railiance-platform/scripts/primary_application_archive.py": "a3f0a61e869a6012edb4b5288be14e3b0c4a9a1273b090320ab146d12e5aa94d", + "/opt/coulomb-social-backup/backup_alerts.py": "07522437d4276b92f0a44149adc6c9e81bb18dbe59ce0f1f9e58a48347700d0e", + "/opt/coulomb-social-backup/notify-backup-alert.py": "0bd53bc5ad22d8bcb0326fb18a17737744b06416fe785dad077308e45f051d5a", + "/opt/coulomb-social-backup/observe-backup-alerts.py": "17caf4fa65283242f618b039ff1d018801c9890ea2a9178912cbdd4b50ee2664" + }, + "clipboard_static_sha256": "347eabe0c8d93bf439df02b5a08a7ea71004600e6ff4e8a42cecbee52a9f0413", + "fresh_backup_before_helm": true, + "requires_dataset_equality_before_and_after": true, + "node_request_percent_limit": 90, + "helm_atomic_timeout_seconds": 300, + "rollback": "Restore Helm revision19/image522bcd7f and verified snapshot pin; preserve all post-capture data. No production data restore or schema downgrade.", + "approval_status": "prepared_not_admitted", + "recovery_rehearsal_scope": "Retained independently recovered 2026-10-31T07:13:52Z point; candidate and previous image preserve all 13 source tables and 86 files. Live observation now has93 business rows versus92 in that point; exact fresh paired backup/readback is required before Helm. Independent restore of the new point remains consumer follow-through. Read-only drift comparison confines differences to sessions and user/member login timestamps; all content and migration history match the retained point.", + "rehearsal_evidence_sha256": { + "content-lifecycle-candidate-build-2026-10-31.json": "83ad2ec41c85f8bb5087a250fdb91126ed92f12351cdc72b353c6f0e1a26452b", + "content-lifecycle-candidate-provenance-2026-10-31.json": "35ed909e0a2fa5c57cea1fcc4fbebb877eae559ca9dc753c3da00f84806d7b73", + "content-lifecycle-candidate-tests-2026-10-31.json": "27a75cfee6bbda41f377b73d6a6c7ecde0c54806c113f1be5223dd2f2cf39b3b", + "content-lifecycle-baseline-tests-2026-10-31.json": "de14e4e10337990ccec1c483f7d943bdc7e11b03aa813aff47d00a9b38a07b65", + "content-lifecycle-candidate-recovery-2026-10-31.json": "8d15172e5e330d01f153336e67f6bffb4363d571530c31ea05db872ca10807a9", + "content-lifecycle-validation-2026-10-31.json": "cbb093af470588ca6e8eb3ec5bc1ccee0be537ab37a46625b6ac267c17343d99", + "content-lifecycle-release-target-2026-10-31.json": "e9b50be3c8284c5a9ae96d75b800cbc4090580fa811c2ecd098d73ce8cbee74e", + "content-lifecycle-release-row-drift-2026-10-31.json": "b10f65bf55e900199ae6037767f32e8901aaccb6f7f3a86461f13dd02ec8efce" + }, + "new_resources_committed": 0, + "autopilot_or_eur_budget_claimed": false, + "values_file": "release/content-lifecycle-values.yaml", + "snapshot_file": "release/content-lifecycle-snapshot.py", + "evidence_directory": "release/content-lifecycle-evidence", + "prerequisite_installation_digest": "sha256:8f442bf0ebed8bbf3b7f227b52081df52613f3f5f6874c4a59511cfc2ba06d42", + "requires_inactive_backup_units": true +} diff --git a/tests/test_coulomb_image_release.py b/tests/test_coulomb_image_release.py index 703326e..d4e5fae 100644 --- a/tests/test_coulomb_image_release.py +++ b/tests/test_coulomb_image_release.py @@ -236,3 +236,129 @@ def test_known_candidate_helper_can_be_rolled_back(tmp_path, monkeypatch): # A completed inverse can be checked again without changing it. instance.restore_pin(b"old pin") assert pin.read_bytes() == b"old pin" + + +def test_missing_installation_refuses_before_execution_journal(tmp_path, monkeypatch): + root, _ = capsule(tmp_path) + plan = json.loads((root / 'manifest.json').read_text()) + plan['prerequisite_installation_digest'] = 'sha256:' + 'a' * 64 + (root / 'manifest.json').write_text(json.dumps(plan)) + digest = 'sha256:' + release.sha(release.canonical(plan)) + monkeypatch.setattr(release.os, 'geteuid', lambda: 0) + monkeypatch.setattr(release, 'JOURNAL_ROOT', tmp_path / 'journal') + monkeypatch.setattr(release, 'INSTALL_RECEIPT', tmp_path / 'missing.json') + with pytest.raises(release.Refused, match='installation_required_before_image'): + release.Release(root, digest) + assert not (tmp_path / 'journal').exists() + + +@pytest.fixture +def installed(monkeypatch): + instance = object.__new__(release.Release) + digest = 'sha256:' + 'a' * 64 + instance.plan = {'prerequisite_installation_digest': digest} + receipt = {'schema': 'coulomb.backup-source-supervised-installation.v1', + 'proposal_digest': digest, 'status': 'sources_installed_units_inactive_verified', + 'verified_targets': 19, 'timers_activated': False} + path = SimpleNamespace(parents=[], lstat=lambda: SimpleNamespace( + st_mode=stat.S_IFREG | 0o600, st_uid=0, st_nlink=1), + read_bytes=lambda: json.dumps(receipt).encode()) + monkeypatch.setattr(release, 'INSTALL_RECEIPT', path) + state = {'active': 'inactive', 'enabled': 'disabled', 'jobs': b''} + def command(arguments): + if arguments[1] == 'list-jobs': + return SimpleNamespace(stdout=state['jobs']) + return SimpleNamespace(stdout=(f"LoadState=loaded\nActiveState={state['active']}\n" + f"UnitFileState={state['enabled']}\n").encode()) + instance.command = Mock(side_effect=command) + return instance, receipt, state + + +def test_completed_installer_and_inactive_units_admit_release_prerequisite(installed): + instance, _, _ = installed + instance.verify_installation_prerequisite() + assert instance.command.call_count == 5 + + +@pytest.mark.parametrize('field,value', [ + ('status', 'installation_requires_inspection'), ('verified_targets', 18), + ('proposal_digest', 'sha256:' + 'b' * 64), ('timers_activated', True), +]) +def test_wrong_or_incomplete_installation_never_checks_or_changes_units(installed, field, value): + instance, receipt, _ = installed + receipt[field] = value + with pytest.raises(release.Refused, match='verified_inactive_backup_installation'): + instance.verify_installation_prerequisite() + instance.command.assert_not_called() + + +@pytest.mark.parametrize('field,value', [('active', 'active'), ('enabled', 'enabled'), + ('jobs', b'17 coulomb-social-paired-backup.service start waiting\n'), + ('jobs', b'unrecognized job format\n')]) +def test_active_enabled_or_queued_backup_refuses_image_release(installed, field, value): + instance, _, state = installed + state[field] = value + with pytest.raises(release.Refused): + instance.verify_installation_prerequisite() + assert all(c.args[0][1] in {'show', 'list-jobs'} for c in instance.command.call_args_list) + + +def test_an_explicit_release_plan_seals_its_own_artifacts(tmp_path, monkeypatch): + monkeypatch.setattr(launcher, 'ROOT', tmp_path) + paths = ['tools/coulomb-image-release.py', 'tools/coulomb_image_release_node.py', + 'tools/coulomb_image_probe.py', 'release/next-values.yaml', + 'release/next-snapshot.py', 'charts/coulomb-social/Chart.yaml', + 'release/next-evidence/proof.json'] + for name in paths: + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(('synthetic ' + name).encode()) + plan = {'values_file': paths[3], 'snapshot_file': paths[4], + 'evidence_directory': 'release/next-evidence', + 'rehearsal_evidence_sha256': {'proof.json': release.sha((tmp_path / paths[-1]).read_bytes())}} + path = tmp_path / 'release/next.json' + path.write_text(json.dumps(plan)) + prepared, digest, content = launcher.prepare(path) + assert set(content) == set(paths) | {'manifest.json'} + assert digest == 'sha256:' + release.sha(release.canonical(prepared)) + plan['values_file'] = '../outside.yaml' + path.write_text(json.dumps(plan)) + with pytest.raises(RuntimeError, match='release_paths'): + launcher.prepare(path) + + +@pytest.mark.parametrize('name', ['release/missing.yaml', '../outside.yaml', '/outside.yaml']) +def test_node_refuses_unsealed_artifact_reference_before_journal(tmp_path, monkeypatch, name): + root, _ = capsule(tmp_path) + plan = json.loads((root / 'manifest.json').read_text()) + plan['values_file'] = name + (root / 'manifest.json').write_text(json.dumps(plan)) + monkeypatch.setattr(release.os, 'geteuid', lambda: 0) + monkeypatch.setattr(release, 'JOURNAL_ROOT', tmp_path / 'journal') + with pytest.raises(release.Refused, match='reviewed_release_artifact'): + release.Release(root, 'sha256:' + release.sha(release.canonical(plan))) + assert not (tmp_path / 'journal').exists() + + +def test_initializer_refusal_is_structured_and_never_calls_execute(monkeypatch, capsys): + monkeypatch.setattr(release, 'Release', Mock(side_effect=release.Refused( + 'inactive_backup_installation_required_before_image_release'))) + monkeypatch.setattr(release.argparse.ArgumentParser, 'parse_args', lambda _: SimpleNamespace( + capsule=Path('/synthetic'), expect_digest='sha256:' + 'a' * 64, + execute=False, preflight=True)) + with pytest.raises(SystemExit): + release.main() + result = json.loads(capsys.readouterr().out) + assert result['execution_started'] is False + assert result['failure_reason'] == 'inactive_backup_installation_required_before_image_release' + + +def test_custom_snapshot_artifact_is_used_for_recognized_pin_rollback(tmp_path, monkeypatch): + path = tmp_path / 'snapshot.py' + path.write_bytes(b'new reviewed pin') + monkeypatch.setattr(release, 'SNAPSHOT', path) + instance = object.__new__(release.Release) + instance.plan = {'snapshot_file': 'release/next-snapshot.py', + 'files': {'release/next-snapshot.py': release.sha(path.read_bytes())}} + instance.restore_pin(b'previous pin') + assert path.read_bytes() == b'previous pin' diff --git a/tools/coulomb-image-release.py b/tools/coulomb-image-release.py index 1b2efc7..308adb0 100644 --- a/tools/coulomb-image-release.py +++ b/tools/coulomb-image-release.py @@ -26,20 +26,32 @@ def canonical(value): return json.dumps(value, sort_keys=True, separators=(",", ":")).encode() -def prepare(): - plan = json.loads(PLAN.read_text()) +def prepare(plan_path=None): + plan_path = plan_path or PLAN + if plan_path.is_symlink() or not plan_path.resolve().is_relative_to(ROOT.resolve()): + raise RuntimeError("reviewed_plan_inside_owner_repository_required") + plan = json.loads(plan_path.read_text()) + values = plan.get("values_file", "release/identity-export-values.yaml") + snapshot = plan.get("snapshot_file", "release/identity-export-snapshot.py") + evidence = plan.get("evidence_directory", "release/identity-export-evidence") + for name in (values, snapshot, evidence): + if (not isinstance(name, str) or Path(name).is_absolute() + or ".." in Path(name).parts or not name.startswith("release/")): + raise RuntimeError("reviewed_release_paths_required") + if not all((ROOT / name).is_file() for name in (values, snapshot)): + raise RuntimeError("reviewed_release_artifacts_missing") paths = [ ROOT / name for name in ( "tools/coulomb-image-release.py", "tools/coulomb_image_release_node.py", "tools/coulomb_image_probe.py", - "release/identity-export-values.yaml", - "release/identity-export-snapshot.py", + values, + snapshot, ) ] paths += sorted((ROOT / "charts/coulomb-social").rglob("*")) - paths += sorted((ROOT / "release/identity-export-evidence").glob("*.json")) + paths += sorted((ROOT / evidence).glob("*.json")) paths = [p for p in paths if p.is_file()] if any(p.is_symlink() or not p.resolve().is_relative_to(ROOT) for p in paths): raise RuntimeError("regular_reviewed_files_required") @@ -48,7 +60,7 @@ def prepare(): name: hashlib.sha256(data).hexdigest() for name, data in content.items() } for name, expected in plan["rehearsal_evidence_sha256"].items(): - if plan["files"].get("release/identity-export-evidence/" + name) != expected: + if plan["files"].get(evidence + "/" + name) != expected: raise RuntimeError("reviewed_candidate_evidence_changed") digest = "sha256:" + hashlib.sha256(canonical(plan)).hexdigest() content["manifest.json"] = canonical(plan) @@ -61,10 +73,11 @@ def main(): parser.add_argument("--execute", action="store_true") parser.add_argument("--expect-digest") parser.add_argument("--output", type=Path) + parser.add_argument("--plan", type=Path, default=PLAN) args = parser.parse_args() if args.preflight and args.execute: raise SystemExit("choose read-only preflight or attended execution") - plan, digest, content = prepare() + plan, digest, content = prepare(args.plan) if args.expect_digest and args.expect_digest != digest: raise SystemExit("reviewed proposal changed; do not execute") if not args.preflight and not args.execute: diff --git a/tools/coulomb_image_release_node.py b/tools/coulomb_image_release_node.py index 10381ea..df49bc9 100644 --- a/tools/coulomb_image_release_node.py +++ b/tools/coulomb_image_release_node.py @@ -22,6 +22,9 @@ from pathlib import Path JOURNAL_ROOT = Path("/var/lib/coulomb-social-backup/releases") SNAPSHOT = Path("/opt/coulomb-social-backup/snapshot-content-and-db.py") +INSTALL_RECEIPT = Path( + "/var/lib/coulomb-social-backup-installations/20261031bacc0001/receipt.json" +) def canonical(value): @@ -88,6 +91,12 @@ class Release: raise Refused("attended_founder_root_execution_required") self.env = dict(os.environ, KUBECONFIG="/etc/rancher/k3s/k3s.yaml") self.read_only = read_only + for field in ("values_file", "snapshot_file"): + if field in self.plan: + name = self.plan[field] + if (not isinstance(name, str) or not name.startswith("release/") + or ".." in Path(name).parts or name not in frozen): + raise Refused("reviewed_release_artifact_required") self.receipt = { "schema": "coulomb.guarded-image-release.v1", "proposal_digest": expected, @@ -97,6 +106,7 @@ class Release: "timers_activated": False, } self.journal = JOURNAL_ROOT / expected.removeprefix("sha256:") + self.verify_installation_prerequisite() if read_only: return if self.journal.exists(): @@ -215,7 +225,52 @@ class Release: or actual.get("migration_0003_applied") is not True): raise Refused("already_migrated_baseline_required") + def artifact_name(self, field): + return self.plan.get(field, { + "values_file": "release/identity-export-values.yaml", + "snapshot_file": "release/identity-export-snapshot.py", + }[field]) + + def verify_installation_prerequisite(self): + expected = self.plan.get("prerequisite_installation_digest") + if expected is None: + return + if not re.fullmatch(r"sha256:[a-f0-9]{64}", expected): + raise Refused("reviewed_installation_digest_required") + try: + info = INSTALL_RECEIPT.lstat() + except FileNotFoundError: + raise Refused("inactive_backup_installation_required_before_image_release") from None + if (not stat.S_ISREG(info.st_mode) or info.st_uid != 0 + or info.st_mode & 0o077 or info.st_nlink != 1 + or any(p.is_symlink() for p in INSTALL_RECEIPT.parents)): + raise Refused("private_installation_receipt_required") + receipt = json.loads(INSTALL_RECEIPT.read_bytes()) + if (receipt.get("schema") != "coulomb.backup-source-supervised-installation.v1" + or receipt.get("proposal_digest") != expected + or receipt.get("status") != "sources_installed_units_inactive_verified" + or receipt.get("verified_targets") != 19 + or receipt.get("timers_activated") is not False): + raise Refused("verified_inactive_backup_installation_required") + for name in ("paired-backup", "backup-freshness"): + for suffix in ("service", "timer"): + unit = f"coulomb-social-{name}.{suffix}" + raw = self.command(["systemctl", "show", unit, + "--property=LoadState,ActiveState,UnitFileState"]).stdout + state = dict(line.split("=", 1) for line in raw.decode().splitlines()) + if (state.get("LoadState") != "loaded" or state.get("ActiveState") != "inactive" + or state.get("UnitFileState") not in {"disabled", "static"}): + raise Refused("backup_units_must_remain_inactive_for_image_release") + jobs = self.command(["systemctl", "list-jobs", "--no-legend", "--no-pager", + "--plain"]).stdout.decode().splitlines() + for line in jobs: + fields = line.split() + if (len(fields) != 4 or not fields[0].isdecimal() + or fields[1].startswith("coulomb-social-")): + raise Refused("queued_backup_job_requires_review") + def preflight(self): + self.verify_installation_prerequisite() self.verify_installed_helpers() if ( self.kdoc("get", "namespace", "kube-system")["metadata"]["uid"] @@ -301,7 +356,7 @@ class Release: "coulomb-social", str(self.root / "charts/coulomb-social"), "-f", - str(self.root / "release/identity-export-values.yaml"), + str(self.root / self.artifact_name("values_file")), "-n", "coulomb-social", ] @@ -319,7 +374,7 @@ class Release: "coulomb-social", str(self.root / "charts/coulomb-social"), "-f", - str(self.root / "release/identity-export-values.yaml"), + str(self.root / self.artifact_name("values_file")), "-n", "coulomb-social", "--dry-run=server", @@ -386,7 +441,7 @@ class Release: path = SNAPSHOT if ( sha(path.read_bytes()) - == self.plan["files"]["release/identity-export-snapshot.py"] + == self.plan["files"][self.artifact_name("snapshot_file")] ): self.replace_pin(path, old) elif sha(path.read_bytes()) != sha(old): @@ -424,7 +479,7 @@ class Release: "coulomb-social", str(self.root / "charts/coulomb-social"), "-f", - str(self.root / "release/identity-export-values.yaml"), + str(self.root / self.artifact_name("values_file")), "-n", "coulomb-social", "--atomic", @@ -461,7 +516,7 @@ class Release: ): raise Refused("snapshot_pin_drifted") self.replace_pin( - path, (self.root / "release/identity-export-snapshot.py").read_bytes() + path, (self.root / self.artifact_name("snapshot_file")).read_bytes() ) self.command( [ @@ -526,8 +581,9 @@ def main(): return if args.execute and args.preflight: raise SystemExit("choose read-only preflight or attended execution") - release = Release(args.capsule, args.expect_digest, read_only=args.preflight) + release = None try: + release = Release(args.capsule, args.expect_digest, read_only=args.preflight) if args.preflight: release.preflight() print( @@ -542,11 +598,15 @@ def main(): return print(json.dumps(release.execute())) except Exception as error: # noqa: BLE001 -- private child diagnostics are suppressed - release.receipt.update( - status="release_refused_or_failed", failure_type=type(error).__name__ - ) - release.save() - print(json.dumps(release.receipt)) + receipt = release.receipt if release is not None else { + "proposal_digest": args.expect_digest, "execution_started": False, + } + receipt.update(status="release_refused_or_failed", failure_type=type(error).__name__) + if isinstance(error, Refused): + receipt["failure_reason"] = str(error) + if release is not None: + release.save() + print(json.dumps(receipt)) raise SystemExit(1) from None diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index c8e5e2b..472fc6f 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -428,9 +428,13 @@ T04/T09. No cluster operation occurred; future release proposals bind the new so ```task id: RAPPS-WP-0015-T07 -status: todo +status: wait priority: high needs_human: false +wait_kind: external +blocking_reason: "Prepared image packet c9c32316 waits for CSOC-WP-0012-T08 inactive installation8f442bf0; native preflight confirms its receipt is absent." +depends_on: +- CSOC-WP-0012-T08 state_hub_task_id: "8f448934-bdfe-539f-8cab-a739ec752491" ``` @@ -453,3 +457,28 @@ its durable return. Current verified revision19 remains authoritative until that return; no image deploy is implied by publication. Productive review stays with CSOC-WP-0013-T05/T03. Retention policy c3a191f2 is accepted; no cutoff or purge was authorized. Do not replay the completed identity image release. + +2026-10-31 preparation return: exact20-member proposal +`sha256:c9c3231620721ef3ea9b2bf22fc6c62fe3ed8efb91386cf7ec96acc6959dae08` +is prepared with a separate plan/values/snapshot/evidence set. The existing launcher +accepts an explicit reviewed plan; historical frozen capsules stay intact.40 +controller tests, Ruff and Helm lint pass. Native image-only server preview preserves +the complete current specification and unmanaged metadata. Candidate/previous-image +checks on the independent07:13 recovery point render40owner pages, deny40outsiders, +verify85export files, and preserve all13tables/86files. Current target remains +revision19;93business rows reflect a new session plus login timestamp changes. +Content and migration history match the retained point; private values are not exposed. + +Ordering is material: the pending installer8f442bf0 pins snapshotdcc8dd2a. Deploying +this image first would stale that reviewed packet. The new controller therefore +requires that exact completed installation receipt and currently inactive/disabled +backup units before creating its release journal, then pins all12 installed Python +helpers. Native full preflight correctly refuses the absent installation receipt; +it is not a pass and no image execution occurred. Historical custodyfa2763d1, +route4d848106 and installer8f442bf0 packets are unchanged. After their returns, +re-run the full image preflight before admitting execution; finish native service +proof/schedule activation afterward. Consumer review commands and exact scope: +coulomb-social/docs/usecases/sharing-portability-review.md, lifecycle release section. +Evidence: content-lifecycle-release-{target,row-drift,preflight,proposal,preparation} +JSON files in the consumer docs/release directory. T07 is now external wait for T08; +product policy is settled, and productive lifecycle acceptance remains open. From c733e86b0fac4802445484233d962181eb57950c Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 14:19:03 +0100 Subject: [PATCH 81/87] Answer NK-WP-0056-T02 owner request: rapp-bounded-executor intake Owner answer for who creates the rapp-bounded-executor repository and which Railiance rapp conventions apply. Proposal only. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 994874@bnt-lap001 Assistant-Session: 6017c416-d619-47eb-bd1a-f670a02d7d87 --- docs/rapp-bounded-executor-intake.md | 151 +++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/rapp-bounded-executor-intake.md diff --git a/docs/rapp-bounded-executor-intake.md b/docs/rapp-bounded-executor-intake.md new file mode 100644 index 0000000..fffef84 --- /dev/null +++ b/docs/rapp-bounded-executor-intake.md @@ -0,0 +1,151 @@ +# rapp-bounded-executor intake (owner answer to NK-WP-0056-T02) + +Status: owner answer from `railiance-apps`, 2026-10-31. Proposal only. Creating +the repository, its Forgejo remote and any live deploy remain founder decisions. + +Request: `net-kingdom/docs/proposals/2026-10-31-nk56-t02-owner-requests.md` +(section "railiance-apps"), hub message `41dbcc41`. Founder decision hub +`6d1adce9`: NetKingdom writes a bounded executor runtime (standard +`net-kingdom/canon/standards/bounded-execution-admission_v0.1.md`); a new +`rapp-*` package deploys it in namespace `sso` with ServiceAccount +`nk-bind-executor`. + +## Short answer + +1. **`railiance-apps` does not create or own the repository.** Under ADR-0015 + and the Railiance family contract, a `rapp-*` is its own repository. This + repository (S5) owns shared app-release lessons and the apps it still hosts + (Path C); it is not the factory for rapp repos. +2. **Who creates it:** the founder, by admitting the creation. The tool is + `repo-manager` (`rmgr rapp wrap`), run from a workstation checkout with + `--ownership-repo net-kingdom`. `wrap` writes local files only. Creating + the Forgejo remote (`coulomb/rapp-bounded-executor`) is a separate + founder-run step. Every existing NetKingdom rapp was bootstrapped this way + (for example `rapp-user-engine`, first commit 2026-08-18, "bootstrap managed + package from NetKingdom apply home"). NetKingdom's agent may run `rmgr rapp + wrap` locally once the founder says the repo should exist. +3. **The image is not built by the rapp.** Correction to the request wording + ("rapp-bounded-executor builds the image"). The fleet rule is that the + **app repo** builds and publishes the image digest + (`.forgejo/workflows/image.yaml`), and the rapp **pins** that digest. + Here the app repo is `net-kingdom`, unless the executor gets its own source + repository (see founder item F2). +4. **Deploy path:** `kubectl` server-side apply of digest-pinned manifests + through the rapp's Makefile (`render`, `server-dry-run`, `deploy`, + `verify-live`, `rollback`). There is no ArgoCD or other GitOps controller + in this estate. Helm is optional; the default for a new first-party rapp is + `manifest-managed-platform-service`. Live apply is founder-run. + +## Conventions that apply (with sources) + +| Topic | Convention | Source | +| --- | --- | --- | +| What a rapp is | One Railiance-managed workload; packaging, not ownership | `railiance-master/docs/repository-axes.md`, ADR-0015 | +| Declaration | `declarations/rapp.yaml`, validated against the schema; there is no `register-rapp` command | `railiance-master/schemas/rapp.schema.json`, `railiance-master/docs/adr/ADR-0007-rapp-declaration-contract.md` | +| Repo baseline | `.repo-classification.yaml`, README, INTENT, SCOPE, AGENTS, CLAUDE, LICENSE, Makefile, `declarations/rapp.yaml`, `workplans/`, `workplans/archived/` | `railiance-master/docs/repo-family-bootstrap-contract.md`; `repo-manager/docs/RailianceAppDeploymentGuide.md` section 6 | +| Greenfield path | `rmgr rapp wrap` → human review → CI digest → `rmgr rapp pin-image` → `rmgr rapp place` → operator deploy | Deployment Guide section 0 | +| Ownership split | Code, tests, Containerfile, image build: app repo. Manifests, ServiceAccount, NetworkPolicy, Makefile, smoke, digest pin: rapp. Secret values: OpenBao via platform. | Deployment Guide section 5 | +| Image | Forge registry `forgejo.coulomb.social/coulomb/@sha256:...`; never a tag; check pull with `railiance-apps/tools/check-oci-image.sh` | Deployment Guide sections 0, 9.4; `docs/s5-app-onboarding-checklist.md` | +| Reef binding | `bound_reefs: []` at `declared`; add `reef-railiance` binding in `reef-railiance/bindings/rapps.yaml` only with an explicit substrate decision | Deployment Guide 10.2; `reef-railiance/bindings/rapps.yaml` notes | +| Readiness | `draft` → `declared` → `deployed`/`verified` only with evidence | Deployment Guide section 6 table | +| Exposure | Omit (private). The executor has no Ingress. | Deployment Guide section 4 | +| Closest model | `rapp-user-engine` (manifest-managed, digest pin, `make verify-live`, `make rollback DIGEST=`) | `~/rapp-user-engine` | + +## Proposed declaration values + +| Field | Proposed value | Why | +| --- | --- | --- | +| `rapp_id` / `repo` | `rapp-bounded-executor` | working name from the request | +| `workload_identity.name` | `bounded-executor` (or `nk-bind-executor`) | must not carry `rapp-` | +| `ownership_repo` | `net-kingdom` | executor runtime is written there | +| `package_type` | `manifest-managed-platform-service` | no existing chart | +| `primary_rail` | `rail-kubernetes` | Job semantics; no scale-to-zero need | +| `bound_reefs` | `[]` until placement is admitted | | +| `data_classification` | `confidential` (consider `restricted`) | it handles directory credentials | +| `criticality` | `high` | it writes identity credentials | +| `runtime_dependencies` | `kubernetes-api`, `forgejo-oci-registry`, `openbao`, `approval-engine`, `lldap` | from the T02 request | +| `secret_references` | the OpenBao paths the secrets-engine answer grants (paths only) | | +| exposure | omitted | | + +## Workload shape: Job, not Deployment + +The standard says one admission is consumed once, before the first step. A +`Job` per admission (`backoffLimit: 0`, `activeDeadlineSeconds` equal to or +shorter than the admission window, `ttlSecondsAfterFinished`) matches that. +A long-running Deployment would hold OpenBao and directory reach with no +admission in hand. Proposed: the rapp ships a Job template and a founder-run +`make run ADMISSION_ID=` that renders the Job with the pinned digest and the +admission id and applies it. No CronJob. + +## Namespace `sso` implications + +- The `sso` Namespace object, and its namespace-wide NetworkPolicy + (`net-kingdom/sso-mfa/k8s/network-policies/netpol-sso.yaml`), currently live + in `net-kingdom`'s migration apply home. The rapp must **not** redeclare the + Namespace. It applies only its own objects in `sso`. +- The rapp ships its own pod-scoped NetworkPolicy: egress to the OpenBao + service, LLDAP, approval-engine and DNS only; no ingress. +- `sso` already hosts KeyCape, LLDAP and Authelia. An executor pod there shares + their namespace. It needs its own Pod Security settings (non-root, read-only + root filesystem, no privilege escalation, dropped capabilities) and + `automountServiceAccountToken` only if the Kubernetes Role below needs it. +- A separate namespace would isolate it more. The founder chose `sso`; record + that choice in the rapp's runtime contract. + +## ServiceAccount and RBAC ownership + +| Object | Owner | Note | +| --- | --- | --- | +| `ServiceAccount nk-bind-executor` (in `sso`) | rapp | per the ownership split | +| `Role` / `RoleBinding` in `sso` (the Kubernetes part of the ceiling) | rapp applies it; **content is set by `net-kingdom`** | the ceiling is NetKingdom canon (BEA standard). The rapp must not widen it. Default: no Kubernetes permissions at all unless a pinned step needs them. | +| OpenBao Kubernetes-auth role bound to `sso/nk-bind-executor` (15-minute TTL) and its policy | `railiance-platform` / `secrets-engine` | answer pending from secrets-engine | +| LLDAP directory account for the executor | NetKingdom / secrets-engine lane | pending | +| Approval consume scope for `service:nk-bind-executor` | approval-engine | pending | +| PDP gate (later) | access-engine | pending | + +## Proposed skeleton (not created) + +```text +rapp-bounded-executor/ + .repo-classification.yaml + README.md INTENT.md SCOPE.md AGENTS.md CLAUDE.md LICENSE + Makefile # check, render, server-dry-run, run ADMISSION_ID=, verify-live, rollback DIGEST= + declarations/rapp.yaml + manifests/ + serviceaccount.yaml # nk-bind-executor + role.yaml # ceiling from net-kingdom; empty by default + networkpolicy.yaml # pod-scoped egress allowlist + job.template.yaml # digest placeholder, admission id parameter + docs/runtime-contract.md # identity table, namespace sso decision, smoke, rollback + docs/platform-handoffs.md # OpenBao role, LLDAP account, approval scope + tools/render.py tools/verify_live.sh + tests/ # render tests, no cluster + workplans/ workplans/archived/ +``` + +And in `net-kingdom` (app repo): `Containerfile` for the executor and +`.forgejo/workflows/image.yaml` (fleet workflow) that publishes +`forgejo.coulomb.social/coulomb/bounded-executor@sha256:...`. + +## Checklist + +1. [ ] Founder: decide the repo should exist (F1) and where the executor source lives (F2). +2. [ ] Founder or NetKingdom agent: `rmgr rapp wrap --path ../rapp-bounded-executor --app bounded-executor --ownership-repo net-kingdom` (local files only). +3. [ ] Founder: create the Forgejo remote `coulomb/rapp-bounded-executor` and push. +4. [ ] NetKingdom: Containerfile and fleet `image.yaml` in the app repo; CI prints a digest. +5. [ ] `rmgr rapp pin-image --digest sha256:...`; `rmgr rapp validate --family-root ..`. +6. [ ] Rapp: ServiceAccount, Role (ceiling from NetKingdom), NetworkPolicy, Job template, Makefile, runtime contract. +7. [ ] Platform owners: OpenBao role and policy, LLDAP account, approval consume scope. +8. [ ] `make server-dry-run` against the cluster (read-only); `check-oci-image.sh` pull check. +9. [ ] Founder: `rmgr rapp place --reef reef-railiance` and a reef binding when the pilot is admitted. +10. [ ] Founder-run first `make run ADMISSION_ID=...` for the T06 pilot; evidence into the rapp. + +## Items for the founder + +- **F1** Admit creation of `rapp-bounded-executor` (local wrap plus Forgejo remote). +- **F2** Executor source home: inside `net-kingdom` (current decision) or a + separate app repo. If it stays in `net-kingdom`, that repo gains a + Containerfile and an image workflow. That is the fleet pattern, but it is new + for a canon repo. +- **F3** Job (recommended) or Deployment. +- **F4** Confirm `sso` over a dedicated namespace, accepting co-tenancy with KeyCape, LLDAP and Authelia. From dc927d775164c40a2e0f8d8ebb8a47d22182b86e Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 19:42:01 +0100 Subject: [PATCH 82/87] Prepare verified combined Coulomb attachment and content release Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a192e9-c28b-7533-bf25-e2341364c22a --- ...attachment-menu-validation-2026-10-31.json | 32 ++ ...llation-independent-return-2026-10-31.json | 371 ++++++++++++++++++ ...p-route-independent-return-2026-10-31.json | 29 ++ .../content-acceptance-build-2026-10-31.json | 45 +++ ...tent-acceptance-provenance-2026-10-31.json | 11 + ...ontent-acceptance-recovery-2026-10-31.json | 67 ++++ .../content-acceptance-target-2026-10-31.json | 21 + .../content-acceptance-tests-2026-10-31.json | 13 + ...t-lifecycle-baseline-tests-2026-10-31.json | 25 ++ ...ifecycle-release-row-drift-2026-10-31.json | 61 +++ ...ntent-lifecycle-validation-2026-10-31.json | 58 +++ .../ordered-list-fidelity-2026-10-31.json | 42 ++ release/content-acceptance-snapshot.py | 175 +++++++++ release/content-acceptance-values.yaml | 50 +++ .../coulomb-content-acceptance-1139a6d.json | 79 ++++ ...P-0015-coulomb-personal-content-release.md | 30 +- 16 files changed, 1105 insertions(+), 4 deletions(-) create mode 100644 release/content-acceptance-evidence/attachment-menu-validation-2026-10-31.json create mode 100644 release/content-acceptance-evidence/backup-installation-independent-return-2026-10-31.json create mode 100644 release/content-acceptance-evidence/backup-route-independent-return-2026-10-31.json create mode 100644 release/content-acceptance-evidence/content-acceptance-build-2026-10-31.json create mode 100644 release/content-acceptance-evidence/content-acceptance-provenance-2026-10-31.json create mode 100644 release/content-acceptance-evidence/content-acceptance-recovery-2026-10-31.json create mode 100644 release/content-acceptance-evidence/content-acceptance-target-2026-10-31.json create mode 100644 release/content-acceptance-evidence/content-acceptance-tests-2026-10-31.json create mode 100644 release/content-acceptance-evidence/content-lifecycle-baseline-tests-2026-10-31.json create mode 100644 release/content-acceptance-evidence/content-lifecycle-release-row-drift-2026-10-31.json create mode 100644 release/content-acceptance-evidence/content-lifecycle-validation-2026-10-31.json create mode 100644 release/content-acceptance-evidence/ordered-list-fidelity-2026-10-31.json create mode 100644 release/content-acceptance-snapshot.py create mode 100644 release/content-acceptance-values.yaml create mode 100644 release/coulomb-content-acceptance-1139a6d.json diff --git a/release/content-acceptance-evidence/attachment-menu-validation-2026-10-31.json b/release/content-acceptance-evidence/attachment-menu-validation-2026-10-31.json new file mode 100644 index 0000000..82c4af5 --- /dev/null +++ b/release/content-acceptance-evidence/attachment-menu-validation-2026-10-31.json @@ -0,0 +1,32 @@ +{ + "schema": "coulomb.attachment-menu-source-validation.v1", + "product_decision": "78175c34-58e1-4ad6-944d-0662c0462ae6", + "full_main_gate": { + "command": "TMPDIR=/var/tmp make check", + "passed": 670, + "failures": 0, + "errors": 0, + "skipped": 0, + "log_sha256": "357547d34f7d73f750721338cff3e07439e8c710d0f1cf838541c1b175268335" + }, + "isolated_release_gate": { + "source": "1139a6d30eee4f75317b46bd45120a15d818455f", + "command": "make check", + "passed": 568, + "failures": 0, + "errors": 0, + "skipped": 0, + "log_sha256": "d9a9c8883f0d4021918c8600f9b5055cac24ca6b9943ee89db067f55d65ccdb1" + }, + "checks": [ + "Author upload/remove; authorized reader download; foreign-tenant, outsider, revoked, archived and anonymous denial", + "Stale page and CSRF refusal; immutable file names; 16 MiB limit; traversal and symlink denial", + "Retained body/source dates and associations through edit/rename; removal keeps historical revision bytes", + "Independent copy/move associations and managed files through 14 abrupt process deaths", + "Upload recovery at partial bytes, complete bytes, stored revision and published page", + "Real loopback browser upload/download/copy/move/remove and permission checks; no external requests" + ], + "synthetic_fixtures_only": true, + "production_deployed": false, + "productive_user_acceptance": false +} diff --git a/release/content-acceptance-evidence/backup-installation-independent-return-2026-10-31.json b/release/content-acceptance-evidence/backup-installation-independent-return-2026-10-31.json new file mode 100644 index 0000000..8417e00 --- /dev/null +++ b/release/content-acceptance-evidence/backup-installation-independent-return-2026-10-31.json @@ -0,0 +1,371 @@ +{ + "schema": "coulomb.backup-installation-independent-postflight.v1", + "verified_at": "2026-10-31T17:30:18.219708+00:00", + "status": "installed_files_and_inactive_units_verified", + "proposal_digest": "sha256:8f442bf0ebed8bbf3b7f227b52081df52613f3f5f6874c4a59511cfc2ba06d42", + "receipt_sha256": "96329afbd5662f71bf6d6b05c003f0810be12c10036f9cadb41cc373d3a1108b", + "receipt": { + "schema": "coulomb.backup-source-supervised-installation.v1", + "proposal_digest": "sha256:8f442bf0ebed8bbf3b7f227b52081df52613f3f5f6874c4a59511cfc2ba06d42", + "status": "sources_installed_units_inactive_verified", + "preflight": { + "units": { + "coulomb-social-paired-backup.service": { + "LoadState": "not-found", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "", + "UnitFileState": "" + }, + "coulomb-social-paired-backup.timer": { + "LoadState": "not-found", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "", + "UnitFileState": "" + }, + "coulomb-social-backup-freshness.service": { + "LoadState": "not-found", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "", + "UnitFileState": "" + }, + "coulomb-social-backup-freshness.timer": { + "LoadState": "not-found", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "", + "UnitFileState": "" + }, + "coulomb-social-backup-alert@coulomb-social-paired-backup.service.service": { + "LoadState": "not-found", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "", + "UnitFileState": "" + } + }, + "capacity": { + "state_bytes": 11259904, + "state_reserve_bytes": 7532969984, + "disk_reserve_bytes": 8606711808, + "free_bytes": 115579949056, + "free_inodes": 24523113, + "filesystem_blocks_reserved": false + }, + "sdk": { + "boto3": "1.40.18", + "botocore": "1.40.76" + }, + "route": { + "helm_revision": 6, + "runtime_and_complete_resources_verified": true + } + }, + "timers_activated": false, + "capture_executed": false, + "email_sent": false, + "retained_data_deleted": false, + "started_at": "2026-10-31T17:22:48.757530+00:00", + "capacity_profile": { + "maximum_new_file_bytes": 1073741824, + "state_budget_bytes": 8589934592, + "minimum_free_bytes": 8589934592, + "minimum_free_inodes": 1024, + "control_margin_bytes": 16777216 + }, + "founder_confirmed_profile_and_installation": true, + "route_postflight": { + "helm_revision": 6, + "runtime_and_complete_resources_verified": true + }, + "verified_targets": 19, + "units": { + "coulomb-social-paired-backup.service": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-paired-backup.service", + "UnitFileState": "static" + }, + "coulomb-social-paired-backup.timer": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-paired-backup.timer", + "UnitFileState": "disabled" + }, + "coulomb-social-backup-freshness.service": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-backup-freshness.service", + "UnitFileState": "static" + }, + "coulomb-social-backup-freshness.timer": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-backup-freshness.timer", + "UnitFileState": "disabled" + }, + "coulomb-social-backup-alert@coulomb-social-paired-backup.service.service": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-backup-alert@.service", + "UnitFileState": "static" + } + }, + "updated_at": "2026-10-31T17:22:54.560675+00:00" + }, + "nested_receipt_sha256": "795d9eb672b24495c77ad636a210c5d2022c8c3124d276920db7eda02257e519", + "verified_targets": 19, + "installed_fingerprints": { + "/opt/coulomb-social-backup/paired-backup.py": { + "exists": true, + "sha256": "d161c742d1a6a182460a57d0a1719591ab58fc94006f55169ad3d74442a93630", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 534009, + "device": 2049, + "mtime_ns": 1793467368826077309 + }, + "/opt/coulomb-social-backup/snapshot-content-and-db.py": { + "exists": true, + "sha256": "dcc8dd2a3989f279597595f9260b31b24fcc9b6dc787f04c17ef0491c53d830d", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549255, + "device": 2049, + "mtime_ns": 1793430923413704430 + }, + "/opt/coulomb-social-backup/mirror-paired-backup.py": { + "exists": true, + "sha256": "9ceb26d9083af016c4b787907e4dce7bdf437c782fb2d15eb28d9e334e6e431c", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549201, + "device": 2049, + "mtime_ns": 1793467368831077393 + }, + "/opt/coulomb-social-backup/check-paired-backup-health.py": { + "exists": true, + "sha256": "2d40ec6e4c59ea7fe1d46065d197b9a87ca6811f023cc6f3e20e4184a3490566", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549309, + "device": 2049, + "mtime_ns": 1793467368836077477 + }, + "/opt/coulomb-social-backup/backup_capacity.py": { + "exists": true, + "sha256": "cb699a79e67b129239b5aa5d6f3b0d4ffaa9773a8b073227ad17fdecb5b9d893", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549354, + "device": 2049, + "mtime_ns": 1793467368843077594 + }, + "/opt/coulomb-social-backup/mirror-platform-runtime.json": { + "exists": true, + "sha256": "9253c5f6e956b67f499d3e12d2851be7fabd3bc56b00496b8810bbd0be8209f1", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549355, + "device": 2049, + "mtime_ns": 1793467368847077662 + }, + "/opt/coulomb-social-backup/backup_alerts.py": { + "exists": true, + "sha256": "07522437d4276b92f0a44149adc6c9e81bb18dbe59ce0f1f9e58a48347700d0e", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549359, + "device": 2049, + "mtime_ns": 1793467368887078332 + }, + "/opt/coulomb-social-backup/notify-backup-alert.py": { + "exists": true, + "sha256": "0bd53bc5ad22d8bcb0326fb18a17737744b06416fe785dad077308e45f051d5a", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549360, + "device": 2049, + "mtime_ns": 1793467368891078399 + }, + "/opt/coulomb-social-backup/observe-backup-alerts.py": { + "exists": true, + "sha256": "17caf4fa65283242f618b039ff1d018801c9890ea2a9178912cbdd4b50ee2664", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549361, + "device": 2049, + "mtime_ns": 1793467368895078466 + }, + "/opt/railiance-platform/scripts/application_recovery_identities.py": { + "exists": true, + "sha256": "20f56255ed6eeec4aafe33f142bc3ee03f623d4957a7b0cabb741d340f15f79d", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549356, + "device": 2049, + "mtime_ns": 1793467368852077745 + }, + "/opt/railiance-platform/scripts/application_recovery_mirror.py": { + "exists": true, + "sha256": "d48ab95e89b9eeae36269d853ebe4c44c22e1f57d4e14224f03d0b9aeeb34178", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549357, + "device": 2049, + "mtime_ns": 1793467368857077829 + }, + "/opt/railiance-platform/scripts/application_recovery_resources.py": { + "exists": true, + "sha256": "ab70ec341e738272730eb0daf76840d0bca807caeb3c4e4c2357f162fbed4498", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549358, + "device": 2049, + "mtime_ns": 1793467368862077913 + }, + "/opt/railiance-platform/scripts/primary_application_archive.py": { + "exists": true, + "sha256": "a3f0a61e869a6012edb4b5288be14e3b0c4a9a1273b090320ab146d12e5aa94d", + "mode": 420, + "uid": 0, + "gid": 0, + "inode": 540610, + "device": 2049, + "mtime_ns": 1791537447437616251 + }, + "/etc/systemd/system/coulomb-social-backup-freshness.service": { + "exists": true, + "sha256": "8d6278c2cbca7f48b659c239c1d1e181fa4b9cf023fe7429850e294c6cd84d0e", + "mode": 420, + "uid": 0, + "gid": 0, + "inode": 19366, + "device": 2049, + "mtime_ns": 1793467368868078014 + }, + "/etc/systemd/system/coulomb-social-paired-backup.service": { + "exists": true, + "sha256": "8b01b788bb08f453fdd4807104185c89411e0b0b4dedee9240f14f110ceb5513", + "mode": 420, + "uid": 0, + "gid": 0, + "inode": 14867, + "device": 2049, + "mtime_ns": 1793467368873078098 + }, + "/etc/systemd/system/coulomb-social-backup-freshness.timer": { + "exists": true, + "sha256": "75d1d0721c07fdd0703910c06bb8a678396440e7d95411a37ee7de4938f3ac31", + "mode": 420, + "uid": 0, + "gid": 0, + "inode": 19367, + "device": 2049, + "mtime_ns": 1793467368878078181 + }, + "/etc/systemd/system/coulomb-social-paired-backup.timer": { + "exists": true, + "sha256": "5e87235e66f89a64a972562d6f163f62420628fc44fb7fd8d603a96c347dfbb9", + "mode": 420, + "uid": 0, + "gid": 0, + "inode": 19368, + "device": 2049, + "mtime_ns": 1793467368882078248 + }, + "/etc/systemd/system/coulomb-social-backup-alert@.service": { + "exists": true, + "sha256": "4bfa019d29b1d0de278f03553602a5e824de50ed99ebcf3b5035cf4d5b06fe98", + "mode": 420, + "uid": 0, + "gid": 0, + "inode": 19370, + "device": 2049, + "mtime_ns": 1793467368899078533 + }, + "/etc/coulomb-social-backup/alerting.env": { + "exists": true, + "sha256": "509844b5c8ef618b812d52f32e19c3c559623636dca62f7b16f437aa90cc0458", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 19369, + "device": 2049, + "mtime_ns": 1793467368904078617 + } + }, + "units": { + "coulomb-social-paired-backup.service": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-paired-backup.service", + "UnitFileState": "static" + }, + "coulomb-social-paired-backup.timer": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-paired-backup.timer", + "UnitFileState": "disabled" + }, + "coulomb-social-backup-freshness.service": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-backup-freshness.service", + "UnitFileState": "static" + }, + "coulomb-social-backup-freshness.timer": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-backup-freshness.timer", + "UnitFileState": "disabled" + }, + "coulomb-social-backup-alert@coulomb-social-paired-backup.service.service": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-backup-alert@.service", + "UnitFileState": "static" + } + }, + "route": { + "helm_revision": 6, + "runtime_and_complete_resources_verified": true + }, + "capacity": { + "state_bytes": 11259904, + "state_reserve_bytes": 7532969984, + "disk_reserve_bytes": 8606711808, + "free_bytes": 115555618816, + "free_inodes": 24523041, + "filesystem_blocks_reserved": false + }, + "sdk": { + "boto3": "1.40.18", + "botocore": "1.40.76" + }, + "runtime_modified_by_verification": false +} diff --git a/release/content-acceptance-evidence/backup-route-independent-return-2026-10-31.json b/release/content-acceptance-evidence/backup-route-independent-return-2026-10-31.json new file mode 100644 index 0000000..fb0a51f --- /dev/null +++ b/release/content-acceptance-evidence/backup-route-independent-return-2026-10-31.json @@ -0,0 +1,29 @@ +{ + "schema": "coulomb.backup-route-independent-postflight.v1", + "verified_at": "2026-10-31T17:19:36.926769+00:00", + "status": "durable_receipt_and_live_route_verified", + "execution_digest": "sha256:4d8481061dc9ce6835169defc96887945052332a0db8d4234ae84032d63cc328", + "receipt": { + "schema": "rapp-telemetry.backup-route-execution.v1", + "execution_digest": "sha256:4d8481061dc9ce6835169defc96887945052332a0db8d4234ae84032d63cc328", + "proposal_digest": "sha256:4bcad54f7b8e1e77b94112e452cbb58eaf8e882dc10305129c5f9ca69f014a66", + "status": "backup_email_route_deployed_verified", + "timers_activated": false, + "notification_sent_by_helper": false, + "production_data_restored": false, + "started_at": "2026-10-31T17:17:13.743344+00:00", + "helm_revision": 6, + "verified_resource_count": 87, + "after_resources_sha256": "600c4998e54b8e11845849c7d8e4c6b6699556c21f5df6ca79bb86b2e8d1683f", + "live_desired_fields_verified": true, + "runtime_config_verified": true, + "existing_image_and_pvc_preserved": true, + "completed_at": "2026-10-31T17:17:31.488654+00:00" + }, + "receipt_sha256": "c26d636f59109956f281ad46f4885c4db800a9553cf05cac324aa3155ef7ac40", + "original_execution_packet_matches": true, + "live_desired_fields_verified": true, + "runtime_config_verified": true, + "existing_image_and_pvc_preserved": true, + "runtime_modified_by_verification": false +} diff --git a/release/content-acceptance-evidence/content-acceptance-build-2026-10-31.json b/release/content-acceptance-evidence/content-acceptance-build-2026-10-31.json new file mode 100644 index 0000000..4a60dbe --- /dev/null +++ b/release/content-acceptance-evidence/content-acceptance-build-2026-10-31.json @@ -0,0 +1,45 @@ +{ + "schema": "coulomb.content-acceptance-build.v1", + "completed_at": "2026-10-31T18:38:08.525103+00:00", + "source": "1139a6d30eee4f75317b46bd45120a15d818455f", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:7906f461bc8d2d5204fd4fde67a251850f725b914452a66172919d6853672ed5", + "build_context": "clean restricted git archive", + "source_full_gate_tests": 568, + "runtime_packages_and_python_unchanged": true, + "runtime": { + "python": "3.12.16", + "distributions": { + "h11": "0.16.0", + "httpcore": "1.0.9", + "psycopg": "3.3.4", + "joserfc": "1.7.4", + "anyio": "4.14.2", + "cryptography": "50.0.0", + "idna": "3.18", + "certifi": "2026.7.22", + "packaging": "26.3", + "python-decouple": "3.8", + "psycopg-binary": "3.3.4", + "sqlparse": "0.5.5", + "whitenoise": "6.12.0", + "cffi": "2.1.1", + "markdown": "3.10.3", + "webencodings": "0.5.1", + "pycparser": "3.0", + "bleach": "6.4.0", + "authlib": "1.7.2", + "django": "6.1", + "typing_extensions": "4.16.0", + "gunicorn": "26.0.0", + "asgiref": "3.12.1", + "httpx": "0.28.1", + "dj-database-url": "3.1.2" + } + }, + "django_checks": { + "check": "System check identified no issues (0 silenced).", + "makemigrations": "No changes detected" + }, + "new_database_migrations": false, + "production_deployed": false +} diff --git a/release/content-acceptance-evidence/content-acceptance-provenance-2026-10-31.json b/release/content-acceptance-evidence/content-acceptance-provenance-2026-10-31.json new file mode 100644 index 0000000..6c1e4c2 --- /dev/null +++ b/release/content-acceptance-evidence/content-acceptance-provenance-2026-10-31.json @@ -0,0 +1,11 @@ +{ + "schema": "coulomb.packaged-source-provenance.v1", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:7906f461bc8d2d5204fd4fde67a251850f725b914452a66172919d6853672ed5", + "source": "1139a6d30eee4f75317b46bd45120a15d818455f", + "packaged_file_count": 79, + "all_packaged_source_bytes_match": true, + "source_revision_label_verified": true, + "registry_digest_resolves": true, + "application_source_overlay": false, + "manifest_sha256": "2eca0618df5bdf35928869c3b848be36190065a4cca8f855bba372df6c516556" +} diff --git a/release/content-acceptance-evidence/content-acceptance-recovery-2026-10-31.json b/release/content-acceptance-evidence/content-acceptance-recovery-2026-10-31.json new file mode 100644 index 0000000..ddc5c3b --- /dev/null +++ b/release/content-acceptance-evidence/content-acceptance-recovery-2026-10-31.json @@ -0,0 +1,67 @@ +{ + "schema": "coulomb.packaged-content-acceptance-recovery.v1", + "status": "packaged_recovered_export_and_views_verified", + "source": "1139a6d30eee4f75317b46bd45120a15d818455f", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:7906f461bc8d2d5204fd4fde67a251850f725b914452a66172919d6853672ed5", + "restore_receipt_sha256": "3d6fe0b5d7426c9fb39ba22f18770319e0c89ba1981c61db171199d3b4e68524", + "retained_point_captured_at": "2026-10-31T07:13:52Z", + "candidate_application": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 92, + "business_rows_sha256": "6243955479f7861fcdcd79b62f7f1e9f9a27c05a17bd2c17b792ebc98b2fa7b6", + "content_files": 86, + "content_files_sha256": "ed535246f4e8f46fcd7ce01f936127996aff873c6936b297f7272194b594aeda", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0, + "actual_owner_pages_rendered": 40, + "unsaved_outsider_page_denials": 40, + "production_identity_or_grant_created": false + }, + "previous_image_application": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 92, + "business_rows_sha256": "6243955479f7861fcdcd79b62f7f1e9f9a27c05a17bd2c17b792ebc98b2fa7b6", + "content_files": 86, + "content_files_sha256": "ed535246f4e8f46fcd7ce01f936127996aff873c6936b297f7272194b594aeda", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0, + "actual_owner_pages_rendered": 40, + "unsaved_outsider_page_denials": 40, + "production_identity_or_grant_created": false + }, + "client_export": { + "status": "recovered_client_export_verified", + "schema": "coulomb.client-dataset-export.v1", + "content_files": 85, + "records": { + "members.user": 1, + "members.member": 1, + "spaces.space": 1, + "spaces.spacemembership": 1 + }, + "admin_events": 0, + "archive_sha256": "eb982edcdb94c976ab3d4e9797e4c34eb72a3ee3886a37e64508744ac172a8a8", + "offboarding_complete": false, + "all_source_tables_compared": 13, + "all_source_files_compared": 86, + "archive_member_hashes_verified": true, + "selected_client_content_complete_locally": true, + "source_sql_and_content_unchanged": true, + "external_bindings_require_review": 0, + "sessions_and_credentials_excluded": true + }, + "source_content_files_unchanged": 86, + "all_tables_unchanged": true, + "application_source_overlay": false, + "network_internal": true, + "published_ports": 0, + "production_access": false, + "production_restore": false, + "driver_sha256": "da85e5f7d5cf68f599a897a932d51399340bdd026a7cddf9603bef4aeb40909a" +} diff --git a/release/content-acceptance-evidence/content-acceptance-target-2026-10-31.json b/release/content-acceptance-evidence/content-acceptance-target-2026-10-31.json new file mode 100644 index 0000000..cd50ca6 --- /dev/null +++ b/release/content-acceptance-evidence/content-acceptance-target-2026-10-31.json @@ -0,0 +1,21 @@ +{ + "baseline_revision": 19, + "baseline_helm_manifest_sha256": "93cb277abd1aea0657be482e458dcfb268fe0bfbd45d90aa1faedcc7f5bce499", + "deployment_uid": "2dbe661a-d232-4477-bbd1-833813077fbe", + "baseline_spec_sha256": "42e091e52d5dae3c1f986e4898c62057fa80846adfce049a75d8fbd4681180ea", + "candidate_spec_sha256": "8d79c64cff4bd2c62f03494e80847dc99b965ddabf35645da67e8d1020220798", + "old_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "expected_data": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 93, + "business_rows_sha256": "7953b37b02b80d89ca6869dab6b2b0b996541de20ca0dd1b294a5f25572fb2ca", + "content_files": 86, + "content_files_sha256": "ed535246f4e8f46fcd7ce01f936127996aff873c6936b297f7272194b594aeda", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0 + }, + "image_only_server_preview": true +} diff --git a/release/content-acceptance-evidence/content-acceptance-tests-2026-10-31.json b/release/content-acceptance-evidence/content-acceptance-tests-2026-10-31.json new file mode 100644 index 0000000..971fb31 --- /dev/null +++ b/release/content-acceptance-evidence/content-acceptance-tests-2026-10-31.json @@ -0,0 +1,13 @@ +{ + "schema": "coulomb.packaged-content-acceptance-tests.v1", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:7906f461bc8d2d5204fd4fde67a251850f725b914452a66172919d6853672ed5", + "source": "1139a6d30eee4f75317b46bd45120a15d818455f", + "tests": 110, + "failures": 0, + "errors": 0, + "skipped": 0, + "network": "none", + "read_only_root": true, + "application_source_overlay": false, + "synthetic_fixtures_only": true +} diff --git a/release/content-acceptance-evidence/content-lifecycle-baseline-tests-2026-10-31.json b/release/content-acceptance-evidence/content-lifecycle-baseline-tests-2026-10-31.json new file mode 100644 index 0000000..6b3e676 --- /dev/null +++ b/release/content-acceptance-evidence/content-lifecycle-baseline-tests-2026-10-31.json @@ -0,0 +1,25 @@ +{ + "schema": "coulomb.packaged-content-lifecycle-tests.v1", + "source": "23ffe2cf4b214666754a2420558943438e54251a", + "image_id": "sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "tests": 29, + "failures": 4, + "expected_baseline_regressions": true, + "failed_nodes": [ + "test_process_death_during_copy_or_move_is_recoverable[copy_page-during_first_asset]", + "test_process_death_during_copy_or_move_is_recoverable[copy_page-during_second_asset]", + "test_process_death_during_copy_or_move_is_recoverable[transfer_page-during_first_asset]", + "test_process_death_during_copy_or_move_is_recoverable[transfer_page-during_second_asset]" + ], + "errors": 0, + "skipped": 0, + "network": "none", + "read_only_root": true, + "application_source_overlay": false, + "test_tooling_injected": true, + "application_modules_from_image_verified": true, + "production_access": false, + "junit_sha256": "edc52bd3a321d028d255b3d478020b3383d94747957ffe2985583b1f0741cb85", + "driver_sha256": "85b65114997c7e7c5323cb77a0043e495ec132506eef07dd394c6a6361380d36", + "test_source_sha256": "84a25a22a3cbda397e39c0508db8ea5aa90e6c467ccfa7da2b568d174d1ec449" +} diff --git a/release/content-acceptance-evidence/content-lifecycle-release-row-drift-2026-10-31.json b/release/content-acceptance-evidence/content-lifecycle-release-row-drift-2026-10-31.json new file mode 100644 index 0000000..8c8aeec --- /dev/null +++ b/release/content-acceptance-evidence/content-lifecycle-release-row-drift-2026-10-31.json @@ -0,0 +1,61 @@ +{ + "changed_columns": { + "django_session": [ + "session_key", + "session_data", + "expire_date" + ], + "members_member": [ + "updated_at", + "last_login_at" + ], + "members_user": [ + "last_login" + ] + }, + "file_changes": [], + "retained_fingerprint": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 92, + "business_rows_sha256": "6243955479f7861fcdcd79b62f7f1e9f9a27c05a17bd2c17b792ebc98b2fa7b6", + "content_files": 86, + "content_files_sha256": "ed535246f4e8f46fcd7ce01f936127996aff873c6936b297f7272194b594aeda", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0 + }, + "schema": "coulomb.content-release-row-drift.v1", + "changed_tables": [ + { + "table": "django_session", + "retained_rows": 42, + "current_rows": 43, + "retained_sha256": "c958e33ec782f9f2fd1b3cceba4a64da0038fde51050f25905ae42de91ca6dde", + "current_sha256": "594e246ef6297d9be9b6e8d9eda076a5b2ab36afb9263606b719f64f51293ba6" + }, + { + "table": "members_member", + "retained_rows": 1, + "current_rows": 1, + "retained_sha256": "4d465d2b9d1e6311e505f861f2ed4b67763a545a965f9a7d0c586a3d07e3e9a3", + "current_sha256": "ab82b3f2c5e49f8634c598c9642894747f4b6fcff0f5230ac27f41939feb8640" + }, + { + "table": "members_user", + "retained_rows": 1, + "current_rows": 1, + "retained_sha256": "81ccb3a40191e910a68c101896df518a588c00231728934c4c900577f8ae4f12", + "current_sha256": "0fc7a71b4b80a71fbedce527bbd2cae3858cf6a062e5714fc231be13ff987256" + } + ], + "all_other_business_tables_equal": true, + "migration_history_equal": true, + "content_equal": true, + "retained_point": "2026-10-31T07:13:52Z", + "current_business_rows": 93, + "current_business_rows_sha256": "7953b37b02b80d89ca6869dab6b2b0b996541de20ca0dd1b294a5f25572fb2ca", + "private_row_values_printed": false, + "production_changes": false +} diff --git a/release/content-acceptance-evidence/content-lifecycle-validation-2026-10-31.json b/release/content-acceptance-evidence/content-lifecycle-validation-2026-10-31.json new file mode 100644 index 0000000..f426e59 --- /dev/null +++ b/release/content-acceptance-evidence/content-lifecycle-validation-2026-10-31.json @@ -0,0 +1,58 @@ +{ + "schema": "coulomb.content-lifecycle-validation.v1", + "recorded_at": "2026-10-31T11:54:24.215512+00:00", + "status": "source_fix_verified", + "workplan_task": "CSOC-WP-0013-T05", + "bug": "A process exit during attachment creation left bytes absent from the copy/move cleanup journal; the orphan blocked retry.", + "regression_observed_before_fix": { + "baseline": "f70708839cd5ca43310f6891c8d94aaff915139c", + "operations": [ + "copy_page", + "transfer_page" + ], + "interruption": "partial first attachment flushed, immediate os._exit(86)", + "result": "both failed because the partial destination survived recovery" + }, + "fix": [ + "Refuse existing attachment entries before recording cleanup authority.", + "Persist intended new attachment name before exclusive creation under the content writer lock.", + "Flush recovery and attachment directory entries before destination publication." + ], + "focused_cases": { + "total": 29, + "abrupt_child_process_exit": 14, + "existing_destination_collision": 8, + "prior_retention_and_recovery": 7 + }, + "full_gate": { + "command": "make check", + "tests": 546, + "failures": 0, + "errors": 0, + "skipped": 0, + "warnings": 69, + "junit_sha256": "dd3d0433c8109f00f6dddd119cfdf8281915f94da2da1f5e6009c31cfe76c65b", + "log_sha256": "eb59a394c95c5307c17069874640d1655ef61c5aa7bb426ef6d21db4929f748e" + }, + "files": [ + { + "path": "coulomb_social/apps/spaces/pageops.py", + "sha256": "055ef25b5efc75798cf17ffa68739de4d4099d2cab4d041e7c7eacdfaf43803e" + }, + { + "path": "tests/test_content_recovery.py", + "sha256": "84a25a22a3cbda397e39c0508db8ea5aa90e6c467ccfa7da2b568d174d1ec449" + } + ], + "policy": "User accepted retain-all default and separately reviewed manual cutoff; keep live page and all history of deleted pages.", + "live_purge_authorized": false, + "live_purge_executed": false, + "production_deployed": false, + "productive_acceptance": false, + "limits": [ + "Child process termination on synthetic local files is proved; physical power-loss durability is not experimentally proved.", + "Cooperating application/import writers must use the content writer lock.", + "Release delivery and productive copy/move review remain open." + ], + "policy_decision_id": "c3a191f2-ce06-483f-82d7-ef8c54ea0d5e" +} diff --git a/release/content-acceptance-evidence/ordered-list-fidelity-2026-10-31.json b/release/content-acceptance-evidence/ordered-list-fidelity-2026-10-31.json new file mode 100644 index 0000000..cbcbd89 --- /dev/null +++ b/release/content-acceptance-evidence/ordered-list-fidelity-2026-10-31.json @@ -0,0 +1,42 @@ +{ + "schema": "coulomb.ordered-list-fidelity.v1", + "verified_at": "2026-10-31T17:20:32.943192+00:00", + "source_articles_sha256": "f33967c79c0f8a60a7d504f5d5e4082f8bc9e3c34f6a3160cb200fd380831d62", + "prior_audit_sha256": "3926e24a3b903f5e1d1c7133dc5f9df1e958183432c5131949ce838eb60acf1e", + "current_audit_sha256": "d8f382e24d7adfe3c43dc71f32f8f6e647bf68239cbcdc6769a6c8fb85d666b9", + "records": 245, + "rendered_cards_verified": 242, + "source_bodies_unchanged": true, + "missing_bodies_retained": 3, + "ordered_list_starts_restored": 15, + "affected_records": 4, + "markup_change_cases_before": 6, + "markup_change_cases_after": 3, + "remaining_removed_tags": { + "topic": 3 + }, + "remaining_removed_attributes": [ + { + "tag": "topic", + "attribute": "name", + "count": 2 + } + ], + "private_review_directory": "~/.config/coulomb-social/auth/exports/rendering-review-20261031-numbering", + "source_hashes": { + "coulomb_social/apps/spaces/content.py": "5d6bb5c800b3e776fff385fa73284f4916403676dc2a8bc06b8fadc7d2387633", + "tests/test_rendering_audit.py": "ae092753f4d1d006bb88f1ce11581975c105c5231819f9409ddaaf2fc83d32f5" + }, + "validation": { + "focused_passed": 14, + "full_gate_passed": 650, + "full_gate_warnings": 69, + "failures": 0, + "errors": 0, + "skipped": 0, + "full_gate_seconds": 91.85 + }, + "production_deployed": false, + "original_bubble_render_compared": false, + "productive_fidelity_accepted": false +} diff --git a/release/content-acceptance-snapshot.py b/release/content-acceptance-snapshot.py new file mode 100644 index 0000000..52c92ec --- /dev/null +++ b/release/content-acceptance-snapshot.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Node-side quiesced coulomb-social snapshot. Binary output stays private. + +Run on railiance01 after reviewed live approval. Does not restore production. +Default is server dry-run; --execute briefly pauses the app and captures a +paired pg_dump/content archive. A separate isolated restore must validate it. +""" +from __future__ import annotations + +import argparse +import fcntl +import hashlib +import json +import os +import signal +import stat +import subprocess +import sys +import time +from pathlib import Path + +NS = 'coulomb-social' +IMAGE = ('forgejo.coulomb.social/coulomb/coulomb-social@sha256:' + '7906f461bc8d2d5204fd4fde67a251850f725b914452a66172919d6853672ed5') +CLUSTER_UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786' +LOCK_PATH = Path('/var/lib/coulomb-social-backup/snapshot.lock') + + +class Refused(RuntimeError): + pass + + +def run(args: list[str], *, payload: bytes | None = None, timeout: int = 180) -> bytes: + p = subprocess.run(args, input=payload, capture_output=True, timeout=timeout) + if p.returncode: + raise Refused('command_failed') + return p.stdout + + +def k(*args: str, payload: bytes | None = None) -> bytes: + return run(['kubectl', *args], payload=payload) + + +def document(*args: str) -> dict: + return json.loads(k(*args, '-o', 'json')) + + +def private_write(path: Path, value: bytes) -> None: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, 'wb') as f: + f.write(value) + + +def snapshot() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--execute', action='store_true') + parser.add_argument('--destination', type=Path, required=True) + args = parser.parse_args() + os.environ['KUBECONFIG'] = '/etc/rancher/k3s/k3s.yaml' + started = time.monotonic() + if document('get', 'namespace', 'kube-system')['metadata']['uid'] != CLUSTER_UID: + raise Refused('wrong_cluster') + dep = document('get', 'deployment', 'coulomb-social', '-n', NS) + spec = dep['spec']['template']['spec'] + if dep['spec']['replicas'] != 1 or spec['containers'][0]['image'] != IMAGE: + raise Refused('unexpected_release') + if document('get', 'pvc', 'coulomb-social-content', '-n', NS)['status']['phase'] != 'Bound': + raise Refused('pvc_not_bound') + cluster = document('get', 'cluster', 'apps-pg', '-n', 'databases') + primary = cluster['status']['currentPrimary'] + name = 'csoc-paired-snapshot-' + time.strftime('%Y%m%d%H%M%S', time.gmtime()) + pod = { + 'apiVersion': 'v1', 'kind': 'Pod', + 'metadata': {'name': name, 'namespace': NS, 'labels': {'csoc-recovery': name}}, + 'spec': { + 'restartPolicy': 'Never', 'automountServiceAccountToken': False, + 'securityContext': {'runAsNonRoot': True, 'runAsUser': 10001, + 'runAsGroup': 10001, 'fsGroup': 10001, + 'fsGroupChangePolicy': 'OnRootMismatch'}, + 'containers': [{'name': 'snapshot', 'image': IMAGE, + 'command': ['python', '-c', 'import time;time.sleep(1800)'], + 'resources': {'requests': {'cpu': '10m', 'memory': '64Mi'}, + 'limits': {'cpu': '100m', 'memory': '256Mi'}}, + 'securityContext': {'allowPrivilegeEscalation': False, + 'readOnlyRootFilesystem': True, + 'capabilities': {'drop': ['ALL']}}, + 'volumeMounts': [{'name': 'content', 'mountPath': '/snapshot', 'readOnly': True}]}], + 'volumes': [{'name': 'content', 'persistentVolumeClaim': + {'claimName': 'coulomb-social-content', 'readOnly': True}}], + }, + } + body = json.dumps(pod).encode() + k('create', '--dry-run=server', '-f', '-', payload=body) + if not args.execute: + print(json.dumps({'status': 'reviewed', 'server_dry_run': True, + 'image_pinned': True, 'pvc_read_only': True, + 'db_scope': 'coulomb_social_db', 'replicas_restored_to': 1})) + return 0 + dest = args.destination + if not dest.is_absolute() or dest.exists(): + raise Refused('destination_must_be_new_absolute_directory') + dest.mkdir(mode=0o700) + paused = False + created = False + receipt = {'status': 'incomplete', 'app': 'coulomb-social', + 'database': 'coulomb_social_db', 'image': IMAGE, + 'started_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} + try: + paused = True + k('scale', 'deployment/coulomb-social', '-n', NS, '--replicas=0') + k('wait', '--for=delete', 'pod', '-n', NS, + '-l', 'app.kubernetes.io/instance=coulomb-social', '--timeout=120s') + created = True + k('create', '-f', '-', payload=body) + k('wait', '--for=condition=Ready', 'pod/' + name, '-n', NS, '--timeout=120s') + dump = k('exec', '-n', 'databases', primary, '-c', 'postgres', '--', + 'pg_dump', '-U', 'postgres', '-d', 'coulomb_social_db', '-Fc') + if not dump.startswith(b'PGDMP'): + raise Refused('invalid_pg_dump') + private_write(dest / 'database.dump', dump) + archive_code = ('import io,sys,tarfile; b=io.BytesIO(); ' + 't=tarfile.open(fileobj=b,mode="w:gz"); ' + 't.add("/snapshot",arcname="content"); ' + 't.close();sys.stdout.buffer.write(b.getvalue())') + content = k('exec', '-n', NS, name, '--', 'python', '-c', archive_code) + private_write(dest / 'content.tar.gz', content) + receipt.update(status='captured', writers_quiesced=True, + database_sha256=hashlib.sha256(dump).hexdigest(), + content_sha256=hashlib.sha256(content).hexdigest(), + database_bytes=len(dump), content_bytes=len(content)) + finally: + try: + if created: + k('delete', 'pod', name, '-n', NS, '--wait=true', '--timeout=120s') + finally: + if paused: + k('scale', 'deployment/coulomb-social', '-n', NS, '--replicas=1') + k('rollout', 'status', 'deployment/coulomb-social', '-n', NS, '--timeout=180s') + receipt['app_resumed'] = True + receipt['elapsed_seconds'] = round(time.monotonic() - started, 2) + private_write(dest / 'receipt.json', json.dumps(receipt, indent=2).encode()) + print(json.dumps(receipt)) + return 0 + + +def main() -> int: + if '--execute' not in sys.argv: + return snapshot() + LOCK_PATH.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + parent = LOCK_PATH.parent.lstat() + if (not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.geteuid() + or parent.st_mode & 0o077): + raise Refused('lock_directory_not_private') + fd = os.open(LOCK_PATH, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'w') as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise Refused('snapshot_already_running') from None + def terminate(signum, frame): + raise Refused('snapshot_interrupted') + previous = signal.signal(signal.SIGTERM, terminate) + try: + return snapshot() + finally: + signal.signal(signal.SIGTERM, previous) + + +if __name__ == '__main__': + try: + sys.exit(main()) + except Exception as exc: + # Never expose child output, kubectl objects, credentials or raw exception messages. + print(json.dumps({'status': 'failed', 'failure_type': type(exc).__name__})) + sys.exit(1) diff --git a/release/content-acceptance-values.yaml b/release/content-acceptance-values.yaml new file mode 100644 index 0000000..25232f7 --- /dev/null +++ b/release/content-acceptance-values.yaml @@ -0,0 +1,50 @@ +# Production overrides for coulomb-social. +# Non-secret values only. Secrets live in K8s Secret coulomb-social-env. + +image: + repository: forgejo.coulomb.social/coulomb/coulomb-social + tag: "1139a6d30eee" + digest: "sha256:7906f461bc8d2d5204fd4fde67a251850f725b914452a66172919d6853672ed5" + +# Public host: app.coulomb.social (parallel to Bubble on apex coulomb.social). +# Apex cutover is deferred until data + self-registration are ready. +env: + DJANGO_SETTINGS_MODULE: coulomb_social.settings.prod + ALLOWED_HOSTS: app.coulomb.social,localhost + CSRF_TRUSTED_ORIGINS: https://app.coulomb.social + CONTENT_ROOT: /app/var/content + DEFAULT_TENANT_ID: "tenant:coulomb" + OIDC_ENABLED: "true" + OIDC_ISSUER: https://kc.coulomb.social + OIDC_CLIENT_ID: coulomb-social + OIDC_REDIRECT_URI: https://app.coulomb.social/auth/callback/ + OIDC_SCOPES: "openid profile email groups" + USER_ENGINE_BASE_URL: https://users.coulomb.social + USER_ENGINE_APPLICATION_ID: coulomb-social + USER_ENGINE_EXPECTED_AUDIENCE: user-engine-portal + NETKINGDOM_REGISTRATION_URL: https://users.92-205-62-239.nip.io/register + +probes: + hostHeader: app.coulomb.social + path: /healthz + +# One writable local-path volume; Recreate prevents concurrent old/new writers. +replicaCount: 1 +deploymentStrategy: Recreate +persistence: + content: + enabled: true + storageClass: local-path + size: 2Gi + mountPath: /app/var/content + retain: true + +# Wave-2 right-sizing (CSOC-WP-0017-T01, 2026-10-21 node budget): 7d p95 +# 0.4m CPU / 146Mi. Limits stay at the chart defaults (1000m/1Gi). +resources: + requests: + cpu: 15m + memory: 176Mi + limits: + cpu: 1000m + memory: 1Gi diff --git a/release/coulomb-content-acceptance-1139a6d.json b/release/coulomb-content-acceptance-1139a6d.json new file mode 100644 index 0000000..a256679 --- /dev/null +++ b/release/coulomb-content-acceptance-1139a6d.json @@ -0,0 +1,79 @@ +{ + "schema": "coulomb.reviewed-image-release.v1", + "proposal_id": "CSOC-CONTENT-ACCEPTANCE-20261031", + "scope": "ADMINISTER @ realm:kubernetes/railiance01; supervised founder execution only", + "operation": "image_only_existing_migration_0003", + "cluster_uid": "a553c742-0115-43d4-99a4-a5ca56fe0786", + "baseline_revision": 19, + "baseline_helm_manifest_sha256": "93cb277abd1aea0657be482e458dcfb268fe0bfbd45d90aa1faedcc7f5bce499", + "candidate_render_sha256": "680debbf4f35d9571916e2016c83327d1212665be82164c40d2d0a49081fc4a8", + "deployment_uid": "2dbe661a-d232-4477-bbd1-833813077fbe", + "pvc_uid": "9888abeb-9d52-4c70-a23d-b5494780d9c3", + "baseline_spec_sha256": "42e091e52d5dae3c1f986e4898c62057fa80846adfce049a75d8fbd4681180ea", + "candidate_spec_sha256": "8d79c64cff4bd2c62f03494e80847dc99b965ddabf35645da67e8d1020220798", + "old_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "candidate_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:7906f461bc8d2d5204fd4fde67a251850f725b914452a66172919d6853672ed5", + "product_source": "1139a6d30eee4f75317b46bd45120a15d818455f", + "expected_data": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 93, + "business_rows_sha256": "7953b37b02b80d89ca6869dab6b2b0b996541de20ca0dd1b294a5f25572fb2ca", + "content_files": 86, + "content_files_sha256": "ed535246f4e8f46fcd7ce01f936127996aff873c6936b297f7272194b594aeda", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0 + }, + "public_recipient": "age1qm30jk8q0cezey652dk2dhcr3d9vs528v6lg8mavl6d4q3e3vgnqmrmyy3", + "installed_helper_hashes": { + "/opt/coulomb-social-backup/paired-backup.py": "d161c742d1a6a182460a57d0a1719591ab58fc94006f55169ad3d74442a93630", + "/opt/coulomb-social-backup/snapshot-content-and-db.py": "dcc8dd2a3989f279597595f9260b31b24fcc9b6dc787f04c17ef0491c53d830d", + "/opt/coulomb-social-backup/mirror-paired-backup.py": "9ceb26d9083af016c4b787907e4dce7bdf437c782fb2d15eb28d9e334e6e431c", + "/opt/coulomb-social-backup/check-paired-backup-health.py": "2d40ec6e4c59ea7fe1d46065d197b9a87ca6811f023cc6f3e20e4184a3490566", + "/opt/coulomb-social-backup/backup_capacity.py": "cb699a79e67b129239b5aa5d6f3b0d4ffaa9773a8b073227ad17fdecb5b9d893", + "/opt/railiance-platform/scripts/application_recovery_identities.py": "20f56255ed6eeec4aafe33f142bc3ee03f623d4957a7b0cabb741d340f15f79d", + "/opt/railiance-platform/scripts/application_recovery_mirror.py": "d48ab95e89b9eeae36269d853ebe4c44c22e1f57d4e14224f03d0b9aeeb34178", + "/opt/railiance-platform/scripts/application_recovery_resources.py": "ab70ec341e738272730eb0daf76840d0bca807caeb3c4e4c2357f162fbed4498", + "/opt/railiance-platform/scripts/primary_application_archive.py": "a3f0a61e869a6012edb4b5288be14e3b0c4a9a1273b090320ab146d12e5aa94d", + "/opt/coulomb-social-backup/backup_alerts.py": "07522437d4276b92f0a44149adc6c9e81bb18dbe59ce0f1f9e58a48347700d0e", + "/opt/coulomb-social-backup/notify-backup-alert.py": "0bd53bc5ad22d8bcb0326fb18a17737744b06416fe785dad077308e45f051d5a", + "/opt/coulomb-social-backup/observe-backup-alerts.py": "17caf4fa65283242f618b039ff1d018801c9890ea2a9178912cbdd4b50ee2664" + }, + "clipboard_static_sha256": "347eabe0c8d93bf439df02b5a08a7ea71004600e6ff4e8a42cecbee52a9f0413", + "fresh_backup_before_helm": true, + "requires_dataset_equality_before_and_after": true, + "node_request_percent_limit": 90, + "helm_atomic_timeout_seconds": 300, + "rollback": "Restore Helm revision19/image522bcd7f and verified snapshot pin; preserve all post-capture data. No production data restore or schema downgrade.", + "approval_status": "prepared_not_admitted", + "recovery_rehearsal_scope": "Candidate and previous image verified read-only against retained independently recovered 2026-10-31T07:13:52Z point: all13 tables/86 files unchanged,40 owner views/40 outsider denials,85 selected-client export files verified. Live baseline remains93 business rows versus92 at the retained point; previously verified session/login-only drift remains unchanged. Fresh paired capture/readback before Helm and independent recovery of that fresh point remain required.", + "rehearsal_evidence_sha256": { + "content-acceptance-build-2026-10-31.json": "39c5c78174e0dfc7fd93c4b718c2b15aa68cea8298be517e28ee2bd206912dd9", + "content-acceptance-provenance-2026-10-31.json": "7d24b3eecf0c2c9c923812a318d448233f5bfc9ae4242046dae1c4d20e7e664a", + "content-acceptance-tests-2026-10-31.json": "3da4d155d84553d7f2949d7e14e5c9cc6c819a6be6af04db5819b7f43ed18e47", + "content-acceptance-recovery-2026-10-31.json": "4ec1b46d8076c42ac0e5bc106b7ff7debf25a65e6c02d907d7148c200a811ede", + "content-acceptance-target-2026-10-31.json": "6651cbf0e2df889315464638158c3aaad885c3cb39fd5b51f06a5037434efebb", + "attachment-menu-validation-2026-10-31.json": "7e491219c701d2e67bb2c3bc849af1fb1a58f787fd17e0527eefcf6345c62f2d", + "ordered-list-fidelity-2026-10-31.json": "4dc3091876d355648d7482187d54f6a0501355274c263d0009d5d5ff6f42f64f", + "content-lifecycle-validation-2026-10-31.json": "cbb093af470588ca6e8eb3ec5bc1ccee0be537ab37a46625b6ac267c17343d99", + "content-lifecycle-baseline-tests-2026-10-31.json": "de14e4e10337990ccec1c483f7d943bdc7e11b03aa813aff47d00a9b38a07b65", + "content-lifecycle-release-row-drift-2026-10-31.json": "b10f65bf55e900199ae6037767f32e8901aaccb6f7f3a86461f13dd02ec8efce", + "backup-route-independent-return-2026-10-31.json": "3a3feb56c1af7e3db7b0f2380ec355bb465663073c0a0160034a35984ef23f6e", + "backup-installation-independent-return-2026-10-31.json": "2fe1972f4db0ba2468027f4926cd8111d6aa4b61d404592eda794b47733ce0df" + }, + "new_resources_committed": 0, + "autopilot_or_eur_budget_claimed": false, + "values_file": "release/content-acceptance-values.yaml", + "snapshot_file": "release/content-acceptance-snapshot.py", + "evidence_directory": "release/content-acceptance-evidence", + "prerequisite_installation_digest": "sha256:8f442bf0ebed8bbf3b7f227b52081df52613f3f5f6874c4a59511cfc2ba06d42", + "requires_inactive_backup_units": true, + "product_changes": [ + "Preserve ordered-list start numbers", + "Durable interrupted copy/move asset recovery", + "Plain attachment menu: author upload/remove and authenticated reader downloads; revisioned associations and independent copies/moves" + ], + "product_decision": "78175c34-58e1-4ad6-944d-0662c0462ae6" +} diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index 472fc6f..b64d3bf 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -424,15 +424,15 @@ coulomb-social/docs/release/backup-producer-custody-validation-2026-10-31.json. The historical eight-entry repair and recurring service acceptance remain product T04/T09. No cluster operation occurred; future release proposals bind the new source. -## T07 — Deliver the attachment crash-recovery correction +## T07 — Deliver the combined content and author attachment release ```task id: RAPPS-WP-0015-T07 status: wait priority: high -needs_human: false -wait_kind: external -blocking_reason: "Prepared image packet c9c32316 waits for CSOC-WP-0012-T08 inactive installation8f442bf0; native preflight confirms its receipt is absent." +needs_human: true +wait_kind: human +blocking_reason: "Combined packet e96752ec passes full native preflight after independently verified inactive installation8f442bf0; founder-terminal execution and independent return remain required." depends_on: - CSOC-WP-0012-T08 state_hub_task_id: "8f448934-bdfe-539f-8cab-a739ec752491" @@ -482,3 +482,25 @@ coulomb-social/docs/usecases/sharing-portability-review.md, lifecycle release se Evidence: content-lifecycle-release-{target,row-drift,preflight,proposal,preparation} JSON files in the consumer docs/release directory. T07 is now external wait for T08; product policy is settled, and productive lifecycle acceptance remains open. + + +2026-10-31 combined candidate: the route and inactive installation now have +founder-executed independent returns. User requested plain attachment actions, +including author add/remove (product decision78175c34), and accepted the three +custom-topic rendering cases for now. The new source1139a6d combines the original +crash-recovery correction, ordered-list starts and revisioned attachment actions. +Image7906f461 has exact packaged provenance and unchanged runtime dependencies; +no schema migration. Main gate670, isolated source gate568 and packaged110 pass. +The retained independently recovered pair remains unchanged across candidate/old +40owner/40outsider views and85file selected-client export, all13tables/86files. + +New immutable plan: release/coulomb-content-acceptance-1139a6d.json. +Exact proposal: sha256:e96752ecfa7adef2fd1d9da48902c8e63de124ccfd272fe20ba79b758b3ca4a3. +Full native read-only preflight passes at revision19 with unchanged93business +rows/86files, complete desired specification, Helm/server previews, installed +source prerequisite and inactive units. Historical c9c32316 remains unchanged; +use the new combined proposal only. Founder-terminal execution remains required. +The reviewed action captures a fresh pair before atomic Helm rollout and refreshes +the snapshot image pin after unchanged-data/view checks. No timer activation, +production restore, purge or mail. Productive acceptance remains with WP-0013. +Review/command: coulomb-social/docs/release/content-acceptance-review-2026-10-31.md. From a15cc888d263221c9082daf1e9ecd9ff38769f1e Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 19:44:16 +0100 Subject: [PATCH 83/87] Route content release wait to its exact execution decision Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a192e9-c28b-7533-bf25-e2341364c22a --- workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index b64d3bf..4e98503 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -432,9 +432,8 @@ status: wait priority: high needs_human: true wait_kind: human +decision_id: "d9b70cc5-e035-40ae-8d0d-020190581202" blocking_reason: "Combined packet e96752ec passes full native preflight after independently verified inactive installation8f442bf0; founder-terminal execution and independent return remain required." -depends_on: -- CSOC-WP-0012-T08 state_hub_task_id: "8f448934-bdfe-539f-8cab-a739ec752491" ``` From b39910e3306b03f80e52ee1158d6b95130e435e6 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 21:29:42 +0100 Subject: [PATCH 84/87] Reconcile verified Coulomb revision 20 and close release delivery Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a192e9-c28b-7533-bf25-e2341364c22a --- WORK-RECORDS.md | 2 +- helm/coulomb-social-values.yaml | 4 ++-- ...P-0015-coulomb-personal-content-release.md | 23 +++++++++++++++---- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index af8ac76..ffb3b14 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -43,7 +43,7 @@ | task | RAPPS-WP-0015-T04 | wait | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T05 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T06 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | -| task | RAPPS-WP-0015-T07 | wait | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | +| task | RAPPS-WP-0015-T07 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0016-T01 | done | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T02 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T03 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | diff --git a/helm/coulomb-social-values.yaml b/helm/coulomb-social-values.yaml index 56d4ded..25232f7 100644 --- a/helm/coulomb-social-values.yaml +++ b/helm/coulomb-social-values.yaml @@ -3,8 +3,8 @@ image: repository: forgejo.coulomb.social/coulomb/coulomb-social - tag: "23ffe2cf4b21" - digest: "sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed" + tag: "1139a6d30eee" + digest: "sha256:7906f461bc8d2d5204fd4fde67a251850f725b914452a66172919d6853672ed5" # Public host: app.coulomb.social (parallel to Bubble on apex coulomb.social). # Apex cutover is deferred until data + self-registration are ready. diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index 4e98503..7682515 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -428,12 +428,11 @@ T04/T09. No cluster operation occurred; future release proposals bind the new so ```task id: RAPPS-WP-0015-T07 -status: wait +status: done priority: high -needs_human: true -wait_kind: human +needs_human: false decision_id: "d9b70cc5-e035-40ae-8d0d-020190581202" -blocking_reason: "Combined packet e96752ec passes full native preflight after independently verified inactive installation8f442bf0; founder-terminal execution and independent return remain required." +intervention_note: "Founder executed e96752ec; independent verification confirms revision20/image7906f461 with unchanged data. No execution rerun required. Source pins and fresh-point mirror/recovery/export are verified; productive verdicts remain CSOC-WP-0013." state_hub_task_id: "8f448934-bdfe-539f-8cab-a739ec752491" ``` @@ -503,3 +502,19 @@ The reviewed action captures a fresh pair before atomic Helm rollout and refresh the snapshot image pin after unchanged-data/view checks. No timer activation, production restore, purge or mail. Productive acceptance remains with WP-0013. Review/command: coulomb-social/docs/release/content-acceptance-review-2026-10-31.md. + +2026-10-31 verified combined release: founder executed exact proposal e96752ec, +completed20:22:30Z at Helm revision20/image7906f461. Independent read-only proof +verifies complete desired spec, identities, sole Ready writer,79packaged source +files and23capsule members. All93business rows/86files are unchanged;40owner views +and40outsider denials pass. Snapshot/helper hashes match. Fresh20:21paired point +was fetched from primary; timers remain inactive. Deployment approval decision +d9b70cc5 is resolved. Consumer recovery/export follow-through and live source-pin +reconciliation are in progress; productive verdicts remain CSOC-WP-0013. + +T07 complete: exact fresh20:21point independently mirrored/read back/decrypted +and restored (13tables/115rows,86files). Deployed and previous package views +pass40owner/40outsider checks; client export verifies85files with all source +SQL/files unchanged. Owner live values and consumer source/helper pins now match +revision20. Recovery remains isolated; timers are inactive/disabled. Remaining +user workflow verdicts are tracked by existing CSOC-WP-0013-T02/T03/T05/T06. From 979b7819df469a052021ccfa64ddbbc4eb640e12 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 22:16:46 +0100 Subject: [PATCH 85/87] Prepare attended Coulomb editor attachment visibility release Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a192e9-c28b-7533-bf25-e2341364c22a --- WORK-RECORDS.md | 1 + .../coulomb-editor-attachments-31eaa58.json | 77 ++++ ...llation-independent-return-2026-10-31.json | 371 ++++++++++++++++++ ...p-route-independent-return-2026-10-31.json | 29 ++ ...nce-independent-postflight-2026-10-31.json | 41 ++ ...ttachments-baseline-change-2026-10-31.json | 39 ++ .../editor-attachments-build-2026-10-31.json | 45 +++ ...tor-attachments-provenance-2026-10-31.json | 11 + ...ditor-attachments-recovery-2026-10-31.json | 67 ++++ .../editor-attachments-target-2026-10-31.json | 21 + .../editor-attachments-tests-2026-10-31.json | 13 + ...tor-attachments-validation-2026-10-31.json | 34 ++ release/editor-attachments-snapshot.py | 175 +++++++++ release/editor-attachments-values.yaml | 50 +++ ...P-0015-coulomb-personal-content-release.md | 28 ++ 15 files changed, 1002 insertions(+) create mode 100644 release/coulomb-editor-attachments-31eaa58.json create mode 100644 release/editor-attachments-evidence/backup-installation-independent-return-2026-10-31.json create mode 100644 release/editor-attachments-evidence/backup-route-independent-return-2026-10-31.json create mode 100644 release/editor-attachments-evidence/content-acceptance-independent-postflight-2026-10-31.json create mode 100644 release/editor-attachments-evidence/editor-attachments-baseline-change-2026-10-31.json create mode 100644 release/editor-attachments-evidence/editor-attachments-build-2026-10-31.json create mode 100644 release/editor-attachments-evidence/editor-attachments-provenance-2026-10-31.json create mode 100644 release/editor-attachments-evidence/editor-attachments-recovery-2026-10-31.json create mode 100644 release/editor-attachments-evidence/editor-attachments-target-2026-10-31.json create mode 100644 release/editor-attachments-evidence/editor-attachments-tests-2026-10-31.json create mode 100644 release/editor-attachments-evidence/editor-attachments-validation-2026-10-31.json create mode 100644 release/editor-attachments-snapshot.py create mode 100644 release/editor-attachments-values.yaml diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index ffb3b14..dd00903 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -44,6 +44,7 @@ | task | RAPPS-WP-0015-T05 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T06 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T07 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | +| task | RAPPS-WP-0015-T08 | wait | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0016-T01 | done | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T02 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T03 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | diff --git a/release/coulomb-editor-attachments-31eaa58.json b/release/coulomb-editor-attachments-31eaa58.json new file mode 100644 index 0000000..bf8cddf --- /dev/null +++ b/release/coulomb-editor-attachments-31eaa58.json @@ -0,0 +1,77 @@ +{ + "schema": "coulomb.reviewed-image-release.v1", + "proposal_id": "CSOC-EDITOR-ATTACHMENTS-20261031", + "scope": "ADMINISTER @ realm:kubernetes/railiance01; supervised founder execution only", + "operation": "image_only_existing_migration_0003", + "cluster_uid": "a553c742-0115-43d4-99a4-a5ca56fe0786", + "baseline_revision": 20, + "baseline_helm_manifest_sha256": "e388f62c97e97d54d87671825bd0b05c854a70b0cca8a00faee452ae7841799e", + "candidate_render_sha256": "78ee8e0a73f87e1152b6f826029ed85385b6293c61a1cdab7171b6c6e40580bc", + "deployment_uid": "2dbe661a-d232-4477-bbd1-833813077fbe", + "pvc_uid": "9888abeb-9d52-4c70-a23d-b5494780d9c3", + "baseline_spec_sha256": "8d79c64cff4bd2c62f03494e80847dc99b965ddabf35645da67e8d1020220798", + "candidate_spec_sha256": "4deab26fc15d63e09922259b3362cd4e5c6bfa10884de058f776c0de8b642b00", + "old_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:7906f461bc8d2d5204fd4fde67a251850f725b914452a66172919d6853672ed5", + "candidate_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:b58d858abd5b4acbd6b893c3f622f7a9fdb5e606164ab9866eb3cfaa04df1a0e", + "product_source": "31eaa581a65e454b6ef9979326c5bdfe10e13806", + "expected_data": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 97, + "business_rows_sha256": "35b0ae7d9dd1ff21c2ceaa04649162545ec36a2c68629548238352d9cd0fb303", + "content_files": 98, + "content_files_sha256": "b657c0e470399b6c7500da500cbbec061bea12ab9633bd74e2f142d797b2d26a", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0 + }, + "public_recipient": "age1qm30jk8q0cezey652dk2dhcr3d9vs528v6lg8mavl6d4q3e3vgnqmrmyy3", + "installed_helper_hashes": { + "/opt/coulomb-social-backup/paired-backup.py": "d161c742d1a6a182460a57d0a1719591ab58fc94006f55169ad3d74442a93630", + "/opt/coulomb-social-backup/snapshot-content-and-db.py": "23f92c96b840df61f92858004241e860e0ddb32ed6fd7448294d3c8278906e46", + "/opt/coulomb-social-backup/mirror-paired-backup.py": "9ceb26d9083af016c4b787907e4dce7bdf437c782fb2d15eb28d9e334e6e431c", + "/opt/coulomb-social-backup/check-paired-backup-health.py": "2d40ec6e4c59ea7fe1d46065d197b9a87ca6811f023cc6f3e20e4184a3490566", + "/opt/coulomb-social-backup/backup_capacity.py": "cb699a79e67b129239b5aa5d6f3b0d4ffaa9773a8b073227ad17fdecb5b9d893", + "/opt/railiance-platform/scripts/application_recovery_identities.py": "20f56255ed6eeec4aafe33f142bc3ee03f623d4957a7b0cabb741d340f15f79d", + "/opt/railiance-platform/scripts/application_recovery_mirror.py": "d48ab95e89b9eeae36269d853ebe4c44c22e1f57d4e14224f03d0b9aeeb34178", + "/opt/railiance-platform/scripts/application_recovery_resources.py": "ab70ec341e738272730eb0daf76840d0bca807caeb3c4e4c2357f162fbed4498", + "/opt/railiance-platform/scripts/primary_application_archive.py": "a3f0a61e869a6012edb4b5288be14e3b0c4a9a1273b090320ab146d12e5aa94d", + "/opt/coulomb-social-backup/backup_alerts.py": "07522437d4276b92f0a44149adc6c9e81bb18dbe59ce0f1f9e58a48347700d0e", + "/opt/coulomb-social-backup/notify-backup-alert.py": "0bd53bc5ad22d8bcb0326fb18a17737744b06416fe785dad077308e45f051d5a", + "/opt/coulomb-social-backup/observe-backup-alerts.py": "17caf4fa65283242f618b039ff1d018801c9890ea2a9178912cbdd4b50ee2664" + }, + "clipboard_static_sha256": "347eabe0c8d93bf439df02b5a08a7ea71004600e6ff4e8a42cecbee52a9f0413", + "fresh_backup_before_helm": true, + "requires_dataset_equality_before_and_after": true, + "node_request_percent_limit": 90, + "helm_atomic_timeout_seconds": 300, + "rollback": "Restore Helm revision20/image7906f461 and verified snapshot pin, preserving all data; no production restore or schema downgrade.", + "approval_status": "prepared_not_admitted", + "recovery_rehearsal_scope": "Candidate and previous image verified against independently recovered 2026-10-31T20:21:01Z point:13tables/86files unchanged,40owner views/40outsider denials,85export files verified. Current97-row/98-file baseline adds2spaces/2memberships/12files from productive acceptance; all prior rows and86files are unchanged. Fresh paired capture/readback before Helm and independent recovery afterward remain required.", + "rehearsal_evidence_sha256": { + "editor-attachments-build-2026-10-31.json": "bb58b2fe23d101ab82ee0b210cc8f552f3667c785bd0207b1e1656b0577c106d", + "editor-attachments-provenance-2026-10-31.json": "3d4ec97702c5165af2198da4c4563cba204355480f5036fd22c48b9e3c300ba3", + "editor-attachments-tests-2026-10-31.json": "59d2d121befa7a8c3853fc89172bad5ce2abad1c1615cc2f2bea550f4d68a62d", + "editor-attachments-recovery-2026-10-31.json": "af68f6e86c0b3291d4ad880717c36eb95f1592f78d3c65596dd9cecd10755052", + "editor-attachments-target-2026-10-31.json": "b6d767ecb93b4638ca77a5052dcf3aab8a95dee922d3927956ad578290f9e58f", + "editor-attachments-baseline-change-2026-10-31.json": "db79f2838b88281c562b36996e74694c2a98e113c962b46eab0ef2cf69dd3b8c", + "editor-attachments-validation-2026-10-31.json": "427e228a9f2763cd583496d65e21b7599ef5e0df074dc507277ce2051169f562", + "backup-route-independent-return-2026-10-31.json": "3a3feb56c1af7e3db7b0f2380ec355bb465663073c0a0160034a35984ef23f6e", + "backup-installation-independent-return-2026-10-31.json": "2fe1972f4db0ba2468027f4926cd8111d6aa4b61d404592eda794b47733ce0df", + "content-acceptance-independent-postflight-2026-10-31.json": "6a8a4056b143011c50af24dd31d6338d00892bfe626666f85e607d86562dfe7c" + }, + "new_resources_committed": 0, + "autopilot_or_eur_budget_claimed": false, + "values_file": "release/editor-attachments-values.yaml", + "snapshot_file": "release/editor-attachments-snapshot.py", + "evidence_directory": "release/editor-attachments-evidence", + "prerequisite_installation_digest": "sha256:8f442bf0ebed8bbf3b7f227b52081df52613f3f5f6874c4a59511cfc2ba06d42", + "requires_inactive_backup_units": true, + "product_changes": [ + "Show saved attachments and downloads in editor tabs", + "Keep attachment list and submitted text after stale edit refusal", + "Open attachment management in a separate tab to preserve unsaved editor text" + ], + "product_decision": "Productive review feedback: attachments present on original but absent from editing tabs." +} diff --git a/release/editor-attachments-evidence/backup-installation-independent-return-2026-10-31.json b/release/editor-attachments-evidence/backup-installation-independent-return-2026-10-31.json new file mode 100644 index 0000000..8417e00 --- /dev/null +++ b/release/editor-attachments-evidence/backup-installation-independent-return-2026-10-31.json @@ -0,0 +1,371 @@ +{ + "schema": "coulomb.backup-installation-independent-postflight.v1", + "verified_at": "2026-10-31T17:30:18.219708+00:00", + "status": "installed_files_and_inactive_units_verified", + "proposal_digest": "sha256:8f442bf0ebed8bbf3b7f227b52081df52613f3f5f6874c4a59511cfc2ba06d42", + "receipt_sha256": "96329afbd5662f71bf6d6b05c003f0810be12c10036f9cadb41cc373d3a1108b", + "receipt": { + "schema": "coulomb.backup-source-supervised-installation.v1", + "proposal_digest": "sha256:8f442bf0ebed8bbf3b7f227b52081df52613f3f5f6874c4a59511cfc2ba06d42", + "status": "sources_installed_units_inactive_verified", + "preflight": { + "units": { + "coulomb-social-paired-backup.service": { + "LoadState": "not-found", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "", + "UnitFileState": "" + }, + "coulomb-social-paired-backup.timer": { + "LoadState": "not-found", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "", + "UnitFileState": "" + }, + "coulomb-social-backup-freshness.service": { + "LoadState": "not-found", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "", + "UnitFileState": "" + }, + "coulomb-social-backup-freshness.timer": { + "LoadState": "not-found", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "", + "UnitFileState": "" + }, + "coulomb-social-backup-alert@coulomb-social-paired-backup.service.service": { + "LoadState": "not-found", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "", + "UnitFileState": "" + } + }, + "capacity": { + "state_bytes": 11259904, + "state_reserve_bytes": 7532969984, + "disk_reserve_bytes": 8606711808, + "free_bytes": 115579949056, + "free_inodes": 24523113, + "filesystem_blocks_reserved": false + }, + "sdk": { + "boto3": "1.40.18", + "botocore": "1.40.76" + }, + "route": { + "helm_revision": 6, + "runtime_and_complete_resources_verified": true + } + }, + "timers_activated": false, + "capture_executed": false, + "email_sent": false, + "retained_data_deleted": false, + "started_at": "2026-10-31T17:22:48.757530+00:00", + "capacity_profile": { + "maximum_new_file_bytes": 1073741824, + "state_budget_bytes": 8589934592, + "minimum_free_bytes": 8589934592, + "minimum_free_inodes": 1024, + "control_margin_bytes": 16777216 + }, + "founder_confirmed_profile_and_installation": true, + "route_postflight": { + "helm_revision": 6, + "runtime_and_complete_resources_verified": true + }, + "verified_targets": 19, + "units": { + "coulomb-social-paired-backup.service": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-paired-backup.service", + "UnitFileState": "static" + }, + "coulomb-social-paired-backup.timer": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-paired-backup.timer", + "UnitFileState": "disabled" + }, + "coulomb-social-backup-freshness.service": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-backup-freshness.service", + "UnitFileState": "static" + }, + "coulomb-social-backup-freshness.timer": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-backup-freshness.timer", + "UnitFileState": "disabled" + }, + "coulomb-social-backup-alert@coulomb-social-paired-backup.service.service": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-backup-alert@.service", + "UnitFileState": "static" + } + }, + "updated_at": "2026-10-31T17:22:54.560675+00:00" + }, + "nested_receipt_sha256": "795d9eb672b24495c77ad636a210c5d2022c8c3124d276920db7eda02257e519", + "verified_targets": 19, + "installed_fingerprints": { + "/opt/coulomb-social-backup/paired-backup.py": { + "exists": true, + "sha256": "d161c742d1a6a182460a57d0a1719591ab58fc94006f55169ad3d74442a93630", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 534009, + "device": 2049, + "mtime_ns": 1793467368826077309 + }, + "/opt/coulomb-social-backup/snapshot-content-and-db.py": { + "exists": true, + "sha256": "dcc8dd2a3989f279597595f9260b31b24fcc9b6dc787f04c17ef0491c53d830d", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549255, + "device": 2049, + "mtime_ns": 1793430923413704430 + }, + "/opt/coulomb-social-backup/mirror-paired-backup.py": { + "exists": true, + "sha256": "9ceb26d9083af016c4b787907e4dce7bdf437c782fb2d15eb28d9e334e6e431c", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549201, + "device": 2049, + "mtime_ns": 1793467368831077393 + }, + "/opt/coulomb-social-backup/check-paired-backup-health.py": { + "exists": true, + "sha256": "2d40ec6e4c59ea7fe1d46065d197b9a87ca6811f023cc6f3e20e4184a3490566", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549309, + "device": 2049, + "mtime_ns": 1793467368836077477 + }, + "/opt/coulomb-social-backup/backup_capacity.py": { + "exists": true, + "sha256": "cb699a79e67b129239b5aa5d6f3b0d4ffaa9773a8b073227ad17fdecb5b9d893", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549354, + "device": 2049, + "mtime_ns": 1793467368843077594 + }, + "/opt/coulomb-social-backup/mirror-platform-runtime.json": { + "exists": true, + "sha256": "9253c5f6e956b67f499d3e12d2851be7fabd3bc56b00496b8810bbd0be8209f1", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549355, + "device": 2049, + "mtime_ns": 1793467368847077662 + }, + "/opt/coulomb-social-backup/backup_alerts.py": { + "exists": true, + "sha256": "07522437d4276b92f0a44149adc6c9e81bb18dbe59ce0f1f9e58a48347700d0e", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549359, + "device": 2049, + "mtime_ns": 1793467368887078332 + }, + "/opt/coulomb-social-backup/notify-backup-alert.py": { + "exists": true, + "sha256": "0bd53bc5ad22d8bcb0326fb18a17737744b06416fe785dad077308e45f051d5a", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549360, + "device": 2049, + "mtime_ns": 1793467368891078399 + }, + "/opt/coulomb-social-backup/observe-backup-alerts.py": { + "exists": true, + "sha256": "17caf4fa65283242f618b039ff1d018801c9890ea2a9178912cbdd4b50ee2664", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549361, + "device": 2049, + "mtime_ns": 1793467368895078466 + }, + "/opt/railiance-platform/scripts/application_recovery_identities.py": { + "exists": true, + "sha256": "20f56255ed6eeec4aafe33f142bc3ee03f623d4957a7b0cabb741d340f15f79d", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549356, + "device": 2049, + "mtime_ns": 1793467368852077745 + }, + "/opt/railiance-platform/scripts/application_recovery_mirror.py": { + "exists": true, + "sha256": "d48ab95e89b9eeae36269d853ebe4c44c22e1f57d4e14224f03d0b9aeeb34178", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549357, + "device": 2049, + "mtime_ns": 1793467368857077829 + }, + "/opt/railiance-platform/scripts/application_recovery_resources.py": { + "exists": true, + "sha256": "ab70ec341e738272730eb0daf76840d0bca807caeb3c4e4c2357f162fbed4498", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549358, + "device": 2049, + "mtime_ns": 1793467368862077913 + }, + "/opt/railiance-platform/scripts/primary_application_archive.py": { + "exists": true, + "sha256": "a3f0a61e869a6012edb4b5288be14e3b0c4a9a1273b090320ab146d12e5aa94d", + "mode": 420, + "uid": 0, + "gid": 0, + "inode": 540610, + "device": 2049, + "mtime_ns": 1791537447437616251 + }, + "/etc/systemd/system/coulomb-social-backup-freshness.service": { + "exists": true, + "sha256": "8d6278c2cbca7f48b659c239c1d1e181fa4b9cf023fe7429850e294c6cd84d0e", + "mode": 420, + "uid": 0, + "gid": 0, + "inode": 19366, + "device": 2049, + "mtime_ns": 1793467368868078014 + }, + "/etc/systemd/system/coulomb-social-paired-backup.service": { + "exists": true, + "sha256": "8b01b788bb08f453fdd4807104185c89411e0b0b4dedee9240f14f110ceb5513", + "mode": 420, + "uid": 0, + "gid": 0, + "inode": 14867, + "device": 2049, + "mtime_ns": 1793467368873078098 + }, + "/etc/systemd/system/coulomb-social-backup-freshness.timer": { + "exists": true, + "sha256": "75d1d0721c07fdd0703910c06bb8a678396440e7d95411a37ee7de4938f3ac31", + "mode": 420, + "uid": 0, + "gid": 0, + "inode": 19367, + "device": 2049, + "mtime_ns": 1793467368878078181 + }, + "/etc/systemd/system/coulomb-social-paired-backup.timer": { + "exists": true, + "sha256": "5e87235e66f89a64a972562d6f163f62420628fc44fb7fd8d603a96c347dfbb9", + "mode": 420, + "uid": 0, + "gid": 0, + "inode": 19368, + "device": 2049, + "mtime_ns": 1793467368882078248 + }, + "/etc/systemd/system/coulomb-social-backup-alert@.service": { + "exists": true, + "sha256": "4bfa019d29b1d0de278f03553602a5e824de50ed99ebcf3b5035cf4d5b06fe98", + "mode": 420, + "uid": 0, + "gid": 0, + "inode": 19370, + "device": 2049, + "mtime_ns": 1793467368899078533 + }, + "/etc/coulomb-social-backup/alerting.env": { + "exists": true, + "sha256": "509844b5c8ef618b812d52f32e19c3c559623636dca62f7b16f437aa90cc0458", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 19369, + "device": 2049, + "mtime_ns": 1793467368904078617 + } + }, + "units": { + "coulomb-social-paired-backup.service": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-paired-backup.service", + "UnitFileState": "static" + }, + "coulomb-social-paired-backup.timer": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-paired-backup.timer", + "UnitFileState": "disabled" + }, + "coulomb-social-backup-freshness.service": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-backup-freshness.service", + "UnitFileState": "static" + }, + "coulomb-social-backup-freshness.timer": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-backup-freshness.timer", + "UnitFileState": "disabled" + }, + "coulomb-social-backup-alert@coulomb-social-paired-backup.service.service": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-backup-alert@.service", + "UnitFileState": "static" + } + }, + "route": { + "helm_revision": 6, + "runtime_and_complete_resources_verified": true + }, + "capacity": { + "state_bytes": 11259904, + "state_reserve_bytes": 7532969984, + "disk_reserve_bytes": 8606711808, + "free_bytes": 115555618816, + "free_inodes": 24523041, + "filesystem_blocks_reserved": false + }, + "sdk": { + "boto3": "1.40.18", + "botocore": "1.40.76" + }, + "runtime_modified_by_verification": false +} diff --git a/release/editor-attachments-evidence/backup-route-independent-return-2026-10-31.json b/release/editor-attachments-evidence/backup-route-independent-return-2026-10-31.json new file mode 100644 index 0000000..fb0a51f --- /dev/null +++ b/release/editor-attachments-evidence/backup-route-independent-return-2026-10-31.json @@ -0,0 +1,29 @@ +{ + "schema": "coulomb.backup-route-independent-postflight.v1", + "verified_at": "2026-10-31T17:19:36.926769+00:00", + "status": "durable_receipt_and_live_route_verified", + "execution_digest": "sha256:4d8481061dc9ce6835169defc96887945052332a0db8d4234ae84032d63cc328", + "receipt": { + "schema": "rapp-telemetry.backup-route-execution.v1", + "execution_digest": "sha256:4d8481061dc9ce6835169defc96887945052332a0db8d4234ae84032d63cc328", + "proposal_digest": "sha256:4bcad54f7b8e1e77b94112e452cbb58eaf8e882dc10305129c5f9ca69f014a66", + "status": "backup_email_route_deployed_verified", + "timers_activated": false, + "notification_sent_by_helper": false, + "production_data_restored": false, + "started_at": "2026-10-31T17:17:13.743344+00:00", + "helm_revision": 6, + "verified_resource_count": 87, + "after_resources_sha256": "600c4998e54b8e11845849c7d8e4c6b6699556c21f5df6ca79bb86b2e8d1683f", + "live_desired_fields_verified": true, + "runtime_config_verified": true, + "existing_image_and_pvc_preserved": true, + "completed_at": "2026-10-31T17:17:31.488654+00:00" + }, + "receipt_sha256": "c26d636f59109956f281ad46f4885c4db800a9553cf05cac324aa3155ef7ac40", + "original_execution_packet_matches": true, + "live_desired_fields_verified": true, + "runtime_config_verified": true, + "existing_image_and_pvc_preserved": true, + "runtime_modified_by_verification": false +} diff --git a/release/editor-attachments-evidence/content-acceptance-independent-postflight-2026-10-31.json b/release/editor-attachments-evidence/content-acceptance-independent-postflight-2026-10-31.json new file mode 100644 index 0000000..cdbc019 --- /dev/null +++ b/release/editor-attachments-evidence/content-acceptance-independent-postflight-2026-10-31.json @@ -0,0 +1,41 @@ +{ + "schema": "coulomb.content-acceptance-independent-postflight.v1", + "status": "deployed_image_and_complete_native_state_verified", + "proposal_digest": "sha256:e96752ecfa7adef2fd1d9da48902c8e63de124ccfd272fe20ba79b758b3ca4a3", + "receipt_sha256": "074ec03d62d43f35bd6c276213a28de4a7b0161132379221bec0e402b5334233", + "helm_revision": 20, + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:7906f461bc8d2d5204fd4fde67a251850f725b914452a66172919d6853672ed5", + "source": "1139a6d30eee4f75317b46bd45120a15d818455f", + "packaged_source_files_verified": 79, + "sole_ready_content_writer": true, + "all_frozen_capsule_files_verified": 23, + "installed_helper_hashes_verified": true, + "snapshot_pin_refreshed": true, + "public_health_landing_static_verified": true, + "postflight": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 93, + "business_rows_sha256": "7953b37b02b80d89ca6869dab6b2b0b996541de20ca0dd1b294a5f25572fb2ca", + "content_files": 86, + "content_files_sha256": "ed535246f4e8f46fcd7ce01f936127996aff873c6936b297f7272194b594aeda", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0, + "actual_owner_pages_rendered": 40, + "unsaved_outsider_page_denials": 40, + "production_identity_or_grant_created": false + }, + "fresh_paired_point": { + "captured_at": "2026-10-31T20:21:01Z", + "destination": "s3://railiance-platform-pg-backup/platform-pg/application-archives/coulomb-social/2026/10/31/20261031T202101Z-2ad4faf215939d67e82f67a4.tar.age", + "version_id": "1793478121590106", + "encrypted_sha256": "91b4ffe4874a53e3422d604b7c3fa85f6551a481411ef82b2209993552486bfb", + "encrypted_bytes": 2181848, + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed" + }, + "timers_activated": false, + "production_data_restored": false, + "productive_acceptance_inferred": false +} diff --git a/release/editor-attachments-evidence/editor-attachments-baseline-change-2026-10-31.json b/release/editor-attachments-evidence/editor-attachments-baseline-change-2026-10-31.json new file mode 100644 index 0000000..71dc2f7 --- /dev/null +++ b/release/editor-attachments-evidence/editor-attachments-baseline-change-2026-10-31.json @@ -0,0 +1,39 @@ +{ + "schema": "coulomb.acceptance-baseline-change.v1", + "retained_point": "2026-10-31T20:21:01Z", + "old_business_rows": 93, + "new_business_rows": 97, + "table_changes": { + "spaces_space": { + "old": 2, + "new": 4, + "removed": 0, + "added": 2 + }, + "spaces_spacemembership": { + "old": 1, + "new": 3, + "removed": 0, + "added": 2 + } + }, + "old_content_files": 86, + "new_content_files": 98, + "old_files_missing": 0, + "old_files_changed": 0, + "new_files_added": 12, + "old_rows_preserved": true, + "current_baseline": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 97, + "business_rows_sha256": "35b0ae7d9dd1ff21c2ceaa04649162545ec36a2c68629548238352d9cd0fb303", + "content_files": 98, + "content_files_sha256": "b657c0e470399b6c7500da500cbbec061bea12ab9633bd74e2f142d797b2d26a", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0 + }, + "interpretation": "Changes observed during the user-requested productive acceptance session; no user verdict inferred." +} diff --git a/release/editor-attachments-evidence/editor-attachments-build-2026-10-31.json b/release/editor-attachments-evidence/editor-attachments-build-2026-10-31.json new file mode 100644 index 0000000..30efe22 --- /dev/null +++ b/release/editor-attachments-evidence/editor-attachments-build-2026-10-31.json @@ -0,0 +1,45 @@ +{ + "schema": "coulomb.content-acceptance-build.v1", + "completed_at": "2026-10-31T21:13:04.039838+00:00", + "source": "31eaa581a65e454b6ef9979326c5bdfe10e13806", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:b58d858abd5b4acbd6b893c3f622f7a9fdb5e606164ab9866eb3cfaa04df1a0e", + "build_context": "clean restricted git archive", + "source_full_gate_tests": 568, + "runtime_packages_and_python_unchanged": true, + "runtime": { + "python": "3.12.16", + "distributions": { + "h11": "0.16.0", + "httpcore": "1.0.9", + "psycopg": "3.3.4", + "joserfc": "1.7.4", + "anyio": "4.14.2", + "cryptography": "50.0.0", + "idna": "3.18", + "certifi": "2026.7.22", + "packaging": "26.3", + "python-decouple": "3.8", + "psycopg-binary": "3.3.4", + "sqlparse": "0.5.5", + "whitenoise": "6.12.0", + "cffi": "2.1.1", + "markdown": "3.10.3", + "webencodings": "0.5.1", + "pycparser": "3.0", + "bleach": "6.4.0", + "authlib": "1.7.2", + "django": "6.1", + "typing_extensions": "4.16.0", + "gunicorn": "26.0.0", + "asgiref": "3.12.1", + "httpx": "0.28.1", + "dj-database-url": "3.1.2" + } + }, + "django_checks": { + "check": "System check identified no issues (0 silenced).", + "makemigrations": "No changes detected" + }, + "new_database_migrations": false, + "production_deployed": false +} diff --git a/release/editor-attachments-evidence/editor-attachments-provenance-2026-10-31.json b/release/editor-attachments-evidence/editor-attachments-provenance-2026-10-31.json new file mode 100644 index 0000000..df09c4b --- /dev/null +++ b/release/editor-attachments-evidence/editor-attachments-provenance-2026-10-31.json @@ -0,0 +1,11 @@ +{ + "schema": "coulomb.packaged-source-provenance.v1", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:b58d858abd5b4acbd6b893c3f622f7a9fdb5e606164ab9866eb3cfaa04df1a0e", + "source": "31eaa581a65e454b6ef9979326c5bdfe10e13806", + "packaged_file_count": 79, + "all_packaged_source_bytes_match": true, + "source_revision_label_verified": true, + "registry_digest_resolves": true, + "application_source_overlay": false, + "manifest_sha256": "99401a60fdd9a95aff1168858e81fd7d8802cd7603f15c0ea35eb6917981f9fe" +} diff --git a/release/editor-attachments-evidence/editor-attachments-recovery-2026-10-31.json b/release/editor-attachments-evidence/editor-attachments-recovery-2026-10-31.json new file mode 100644 index 0000000..1623bcb --- /dev/null +++ b/release/editor-attachments-evidence/editor-attachments-recovery-2026-10-31.json @@ -0,0 +1,67 @@ +{ + "schema": "coulomb.packaged-content-acceptance-recovery.v1", + "status": "packaged_recovered_export_and_views_verified", + "source": "31eaa581a65e454b6ef9979326c5bdfe10e13806", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:b58d858abd5b4acbd6b893c3f622f7a9fdb5e606164ab9866eb3cfaa04df1a0e", + "restore_receipt_sha256": "9dacbd44544f93abf99354158cf610b4fb1f4e59611b22056b3086e0033231ab", + "retained_point_captured_at": "2026-10-31T20:21:01Z", + "candidate_application": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 93, + "business_rows_sha256": "7953b37b02b80d89ca6869dab6b2b0b996541de20ca0dd1b294a5f25572fb2ca", + "content_files": 86, + "content_files_sha256": "ed535246f4e8f46fcd7ce01f936127996aff873c6936b297f7272194b594aeda", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0, + "actual_owner_pages_rendered": 40, + "unsaved_outsider_page_denials": 40, + "production_identity_or_grant_created": false + }, + "previous_image_application": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 93, + "business_rows_sha256": "7953b37b02b80d89ca6869dab6b2b0b996541de20ca0dd1b294a5f25572fb2ca", + "content_files": 86, + "content_files_sha256": "ed535246f4e8f46fcd7ce01f936127996aff873c6936b297f7272194b594aeda", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0, + "actual_owner_pages_rendered": 40, + "unsaved_outsider_page_denials": 40, + "production_identity_or_grant_created": false + }, + "client_export": { + "status": "recovered_client_export_verified", + "schema": "coulomb.client-dataset-export.v1", + "content_files": 85, + "records": { + "members.user": 1, + "members.member": 1, + "spaces.space": 1, + "spaces.spacemembership": 1 + }, + "admin_events": 0, + "archive_sha256": "ecf5dba7ce891475cd5df15eb30205606f805850af7339ecd46e1479b45d6c5f", + "offboarding_complete": false, + "all_source_tables_compared": 13, + "all_source_files_compared": 86, + "archive_member_hashes_verified": true, + "selected_client_content_complete_locally": true, + "source_sql_and_content_unchanged": true, + "external_bindings_require_review": 0, + "sessions_and_credentials_excluded": true + }, + "source_content_files_unchanged": 86, + "all_tables_unchanged": true, + "application_source_overlay": false, + "network_internal": true, + "published_ports": 0, + "production_access": false, + "production_restore": false, + "driver_sha256": "29c53d551eec2b4f4d60b047f123c727eef76ee7677c054bbfb0ab008355cff9" +} diff --git a/release/editor-attachments-evidence/editor-attachments-target-2026-10-31.json b/release/editor-attachments-evidence/editor-attachments-target-2026-10-31.json new file mode 100644 index 0000000..023ddde --- /dev/null +++ b/release/editor-attachments-evidence/editor-attachments-target-2026-10-31.json @@ -0,0 +1,21 @@ +{ + "baseline_revision": 20, + "baseline_helm_manifest_sha256": "e388f62c97e97d54d87671825bd0b05c854a70b0cca8a00faee452ae7841799e", + "deployment_uid": "2dbe661a-d232-4477-bbd1-833813077fbe", + "baseline_spec_sha256": "8d79c64cff4bd2c62f03494e80847dc99b965ddabf35645da67e8d1020220798", + "candidate_spec_sha256": "4deab26fc15d63e09922259b3362cd4e5c6bfa10884de058f776c0de8b642b00", + "old_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:7906f461bc8d2d5204fd4fde67a251850f725b914452a66172919d6853672ed5", + "expected_data": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 97, + "business_rows_sha256": "35b0ae7d9dd1ff21c2ceaa04649162545ec36a2c68629548238352d9cd0fb303", + "content_files": 98, + "content_files_sha256": "b657c0e470399b6c7500da500cbbec061bea12ab9633bd74e2f142d797b2d26a", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0 + }, + "image_only_server_preview": true +} diff --git a/release/editor-attachments-evidence/editor-attachments-tests-2026-10-31.json b/release/editor-attachments-evidence/editor-attachments-tests-2026-10-31.json new file mode 100644 index 0000000..3570551 --- /dev/null +++ b/release/editor-attachments-evidence/editor-attachments-tests-2026-10-31.json @@ -0,0 +1,13 @@ +{ + "schema": "coulomb.packaged-content-acceptance-tests.v1", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:b58d858abd5b4acbd6b893c3f622f7a9fdb5e606164ab9866eb3cfaa04df1a0e", + "source": "31eaa581a65e454b6ef9979326c5bdfe10e13806", + "tests": 110, + "failures": 0, + "errors": 0, + "skipped": 0, + "network": "none", + "read_only_root": true, + "application_source_overlay": false, + "synthetic_fixtures_only": true +} diff --git a/release/editor-attachments-evidence/editor-attachments-validation-2026-10-31.json b/release/editor-attachments-evidence/editor-attachments-validation-2026-10-31.json new file mode 100644 index 0000000..23e9b42 --- /dev/null +++ b/release/editor-attachments-evidence/editor-attachments-validation-2026-10-31.json @@ -0,0 +1,34 @@ +{ + "schema": "coulomb.editor-attachments-validation.v1", + "reported_gap": "Attachments visible on the original page were absent from editor tabs.", + "main_source": "8b38afddf3f3648f209c60b1bafe3dd25501d857", + "release_source": "31eaa581a65e454b6ef9979326c5bdfe10e13806", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:b58d858abd5b4acbd6b893c3f622f7a9fdb5e606164ab9866eb3cfaa04df1a0e", + "main_full_gate": { + "tests": 670, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + "release_full_gate": { + "tests": 568, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + "packaged_tests": 110, + "browser_verifies": [ + "saved file list and exact download bytes in both editor tabs", + "management opens separately and keeps unsaved text", + "stale refusal keeps submitted text and attachments", + "save keeps attachments and rejects stale overwrite", + "existing copy/move/download/detach permissions journey" + ], + "runtime_changes": [ + "coulomb_social/apps/spaces/views.py", + "coulomb_social/templates/spaces/page_form.html" + ], + "storage_schema_or_authority_changed": false, + "productive_acceptance_inferred": false, + "test_environment": "Main uses /var/tmp; isolated branch native observer requires /tmp. Final complete gates pass without deselection." +} diff --git a/release/editor-attachments-snapshot.py b/release/editor-attachments-snapshot.py new file mode 100644 index 0000000..342510b --- /dev/null +++ b/release/editor-attachments-snapshot.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Node-side quiesced coulomb-social snapshot. Binary output stays private. + +Run on railiance01 after reviewed live approval. Does not restore production. +Default is server dry-run; --execute briefly pauses the app and captures a +paired pg_dump/content archive. A separate isolated restore must validate it. +""" +from __future__ import annotations + +import argparse +import fcntl +import hashlib +import json +import os +import signal +import stat +import subprocess +import sys +import time +from pathlib import Path + +NS = 'coulomb-social' +IMAGE = ('forgejo.coulomb.social/coulomb/coulomb-social@sha256:' + 'b58d858abd5b4acbd6b893c3f622f7a9fdb5e606164ab9866eb3cfaa04df1a0e') +CLUSTER_UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786' +LOCK_PATH = Path('/var/lib/coulomb-social-backup/snapshot.lock') + + +class Refused(RuntimeError): + pass + + +def run(args: list[str], *, payload: bytes | None = None, timeout: int = 180) -> bytes: + p = subprocess.run(args, input=payload, capture_output=True, timeout=timeout) + if p.returncode: + raise Refused('command_failed') + return p.stdout + + +def k(*args: str, payload: bytes | None = None) -> bytes: + return run(['kubectl', *args], payload=payload) + + +def document(*args: str) -> dict: + return json.loads(k(*args, '-o', 'json')) + + +def private_write(path: Path, value: bytes) -> None: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, 'wb') as f: + f.write(value) + + +def snapshot() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--execute', action='store_true') + parser.add_argument('--destination', type=Path, required=True) + args = parser.parse_args() + os.environ['KUBECONFIG'] = '/etc/rancher/k3s/k3s.yaml' + started = time.monotonic() + if document('get', 'namespace', 'kube-system')['metadata']['uid'] != CLUSTER_UID: + raise Refused('wrong_cluster') + dep = document('get', 'deployment', 'coulomb-social', '-n', NS) + spec = dep['spec']['template']['spec'] + if dep['spec']['replicas'] != 1 or spec['containers'][0]['image'] != IMAGE: + raise Refused('unexpected_release') + if document('get', 'pvc', 'coulomb-social-content', '-n', NS)['status']['phase'] != 'Bound': + raise Refused('pvc_not_bound') + cluster = document('get', 'cluster', 'apps-pg', '-n', 'databases') + primary = cluster['status']['currentPrimary'] + name = 'csoc-paired-snapshot-' + time.strftime('%Y%m%d%H%M%S', time.gmtime()) + pod = { + 'apiVersion': 'v1', 'kind': 'Pod', + 'metadata': {'name': name, 'namespace': NS, 'labels': {'csoc-recovery': name}}, + 'spec': { + 'restartPolicy': 'Never', 'automountServiceAccountToken': False, + 'securityContext': {'runAsNonRoot': True, 'runAsUser': 10001, + 'runAsGroup': 10001, 'fsGroup': 10001, + 'fsGroupChangePolicy': 'OnRootMismatch'}, + 'containers': [{'name': 'snapshot', 'image': IMAGE, + 'command': ['python', '-c', 'import time;time.sleep(1800)'], + 'resources': {'requests': {'cpu': '10m', 'memory': '64Mi'}, + 'limits': {'cpu': '100m', 'memory': '256Mi'}}, + 'securityContext': {'allowPrivilegeEscalation': False, + 'readOnlyRootFilesystem': True, + 'capabilities': {'drop': ['ALL']}}, + 'volumeMounts': [{'name': 'content', 'mountPath': '/snapshot', 'readOnly': True}]}], + 'volumes': [{'name': 'content', 'persistentVolumeClaim': + {'claimName': 'coulomb-social-content', 'readOnly': True}}], + }, + } + body = json.dumps(pod).encode() + k('create', '--dry-run=server', '-f', '-', payload=body) + if not args.execute: + print(json.dumps({'status': 'reviewed', 'server_dry_run': True, + 'image_pinned': True, 'pvc_read_only': True, + 'db_scope': 'coulomb_social_db', 'replicas_restored_to': 1})) + return 0 + dest = args.destination + if not dest.is_absolute() or dest.exists(): + raise Refused('destination_must_be_new_absolute_directory') + dest.mkdir(mode=0o700) + paused = False + created = False + receipt = {'status': 'incomplete', 'app': 'coulomb-social', + 'database': 'coulomb_social_db', 'image': IMAGE, + 'started_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} + try: + paused = True + k('scale', 'deployment/coulomb-social', '-n', NS, '--replicas=0') + k('wait', '--for=delete', 'pod', '-n', NS, + '-l', 'app.kubernetes.io/instance=coulomb-social', '--timeout=120s') + created = True + k('create', '-f', '-', payload=body) + k('wait', '--for=condition=Ready', 'pod/' + name, '-n', NS, '--timeout=120s') + dump = k('exec', '-n', 'databases', primary, '-c', 'postgres', '--', + 'pg_dump', '-U', 'postgres', '-d', 'coulomb_social_db', '-Fc') + if not dump.startswith(b'PGDMP'): + raise Refused('invalid_pg_dump') + private_write(dest / 'database.dump', dump) + archive_code = ('import io,sys,tarfile; b=io.BytesIO(); ' + 't=tarfile.open(fileobj=b,mode="w:gz"); ' + 't.add("/snapshot",arcname="content"); ' + 't.close();sys.stdout.buffer.write(b.getvalue())') + content = k('exec', '-n', NS, name, '--', 'python', '-c', archive_code) + private_write(dest / 'content.tar.gz', content) + receipt.update(status='captured', writers_quiesced=True, + database_sha256=hashlib.sha256(dump).hexdigest(), + content_sha256=hashlib.sha256(content).hexdigest(), + database_bytes=len(dump), content_bytes=len(content)) + finally: + try: + if created: + k('delete', 'pod', name, '-n', NS, '--wait=true', '--timeout=120s') + finally: + if paused: + k('scale', 'deployment/coulomb-social', '-n', NS, '--replicas=1') + k('rollout', 'status', 'deployment/coulomb-social', '-n', NS, '--timeout=180s') + receipt['app_resumed'] = True + receipt['elapsed_seconds'] = round(time.monotonic() - started, 2) + private_write(dest / 'receipt.json', json.dumps(receipt, indent=2).encode()) + print(json.dumps(receipt)) + return 0 + + +def main() -> int: + if '--execute' not in sys.argv: + return snapshot() + LOCK_PATH.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + parent = LOCK_PATH.parent.lstat() + if (not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.geteuid() + or parent.st_mode & 0o077): + raise Refused('lock_directory_not_private') + fd = os.open(LOCK_PATH, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'w') as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise Refused('snapshot_already_running') from None + def terminate(signum, frame): + raise Refused('snapshot_interrupted') + previous = signal.signal(signal.SIGTERM, terminate) + try: + return snapshot() + finally: + signal.signal(signal.SIGTERM, previous) + + +if __name__ == '__main__': + try: + sys.exit(main()) + except Exception as exc: + # Never expose child output, kubectl objects, credentials or raw exception messages. + print(json.dumps({'status': 'failed', 'failure_type': type(exc).__name__})) + sys.exit(1) diff --git a/release/editor-attachments-values.yaml b/release/editor-attachments-values.yaml new file mode 100644 index 0000000..9e85e9b --- /dev/null +++ b/release/editor-attachments-values.yaml @@ -0,0 +1,50 @@ +# Production overrides for coulomb-social. +# Non-secret values only. Secrets live in K8s Secret coulomb-social-env. + +image: + repository: forgejo.coulomb.social/coulomb/coulomb-social + tag: "31eaa581a65e" + digest: "sha256:b58d858abd5b4acbd6b893c3f622f7a9fdb5e606164ab9866eb3cfaa04df1a0e" + +# Public host: app.coulomb.social (parallel to Bubble on apex coulomb.social). +# Apex cutover is deferred until data + self-registration are ready. +env: + DJANGO_SETTINGS_MODULE: coulomb_social.settings.prod + ALLOWED_HOSTS: app.coulomb.social,localhost + CSRF_TRUSTED_ORIGINS: https://app.coulomb.social + CONTENT_ROOT: /app/var/content + DEFAULT_TENANT_ID: "tenant:coulomb" + OIDC_ENABLED: "true" + OIDC_ISSUER: https://kc.coulomb.social + OIDC_CLIENT_ID: coulomb-social + OIDC_REDIRECT_URI: https://app.coulomb.social/auth/callback/ + OIDC_SCOPES: "openid profile email groups" + USER_ENGINE_BASE_URL: https://users.coulomb.social + USER_ENGINE_APPLICATION_ID: coulomb-social + USER_ENGINE_EXPECTED_AUDIENCE: user-engine-portal + NETKINGDOM_REGISTRATION_URL: https://users.92-205-62-239.nip.io/register + +probes: + hostHeader: app.coulomb.social + path: /healthz + +# One writable local-path volume; Recreate prevents concurrent old/new writers. +replicaCount: 1 +deploymentStrategy: Recreate +persistence: + content: + enabled: true + storageClass: local-path + size: 2Gi + mountPath: /app/var/content + retain: true + +# Wave-2 right-sizing (CSOC-WP-0017-T01, 2026-10-21 node budget): 7d p95 +# 0.4m CPU / 146Mi. Limits stay at the chart defaults (1000m/1Gi). +resources: + requests: + cpu: 15m + memory: 176Mi + limits: + cpu: 1000m + memory: 1Gi diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index 7682515..3a151d4 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -518,3 +518,31 @@ pass40owner/40outsider checks; client export verifies85files with all source SQL/files unchanged. Owner live values and consumer source/helper pins now match revision20. Recovery remains isolated; timers are inactive/disabled. Remaining user workflow verdicts are tracked by existing CSOC-WP-0013-T02/T03/T05/T06. + +## T08 — Deliver the editor attachment visibility correction + +```task +id: RAPPS-WP-0015-T08 +status: wait +priority: high +needs_human: true +wait_kind: human +decision_id: "0d538348-aadd-41a2-88f6-191defcf22b2" +blocking_reason: "Exact editor attachment proposal2cddcf38 passes native preflight; founder-terminal execution and independent return are required." +``` + +Productive revision20 review identified that attachments are visible on the +original page but absent from editor tabs. CSOC-WP-0013-T06 owns the correction: +display saved attachments/downloads in the editor and preserve that list after +stale-edit refusal, with management opening separately to retain unsaved text. +Prepare the minimal follow-up image from the deployed source, preserve existing +schema/data/permissions, and reuse the exact attended image-release lane with a +fresh paired rollback point. Complete after founder execution and independent +postflight/recovery/source-pin reconciliation. Productive acceptance remains +CSOC-WP-0013-T03/T06; T07's verified revision20 delivery stays complete. + +Prepared source31eaa58/imageb58d858a; exact proposal2cddcf38b7052287c10dce467a200a74cfa9699c666ae9ac538fc82917129e7a. Main670/isolated568/packaged110 pass. +Recovered views/export pass. Full native preflight at revision20/97rows/98files +passes; existing93rows/86files are unchanged by the productive review additions. +Plan: release/coulomb-editor-attachments-31eaa58.json. Review/command: +coulomb-social/docs/release/content-acceptance-review-2026-10-31.md. From 0c72b3bec85a98faf9e9cc76631ad2f0ed3f4025 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 22:17:56 +0100 Subject: [PATCH 86/87] Link editor release execution task to State Hub Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a192e9-c28b-7533-bf25-e2341364c22a --- workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | 1 + 1 file changed, 1 insertion(+) diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index 3a151d4..4ebe899 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -529,6 +529,7 @@ needs_human: true wait_kind: human decision_id: "0d538348-aadd-41a2-88f6-191defcf22b2" blocking_reason: "Exact editor attachment proposal2cddcf38 passes native preflight; founder-terminal execution and independent return are required." +state_hub_task_id: "e7b04735-e618-5551-9ecf-6d423c83d156" ``` Productive revision20 review identified that attachments are visible on the From 4e2809cb1ebb75fef17c18be44aa86d297c7f4cc Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 1 Nov 2026 00:41:57 +0100 Subject: [PATCH 87/87] Close verified Coulomb editor release delivery at revision 21 Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a192e9-c28b-7533-bf25-e2341364c22a --- WORK-RECORDS.md | 2 +- helm/coulomb-social-values.yaml | 4 +-- ...P-0015-coulomb-personal-content-release.md | 25 +++++++++++++++---- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index dd00903..50d329a 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -44,7 +44,7 @@ | task | RAPPS-WP-0015-T05 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T06 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T07 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | -| task | RAPPS-WP-0015-T08 | wait | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | +| task | RAPPS-WP-0015-T08 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0016-T01 | done | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T02 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T03 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | diff --git a/helm/coulomb-social-values.yaml b/helm/coulomb-social-values.yaml index 25232f7..9e85e9b 100644 --- a/helm/coulomb-social-values.yaml +++ b/helm/coulomb-social-values.yaml @@ -3,8 +3,8 @@ image: repository: forgejo.coulomb.social/coulomb/coulomb-social - tag: "1139a6d30eee" - digest: "sha256:7906f461bc8d2d5204fd4fde67a251850f725b914452a66172919d6853672ed5" + tag: "31eaa581a65e" + digest: "sha256:b58d858abd5b4acbd6b893c3f622f7a9fdb5e606164ab9866eb3cfaa04df1a0e" # Public host: app.coulomb.social (parallel to Bubble on apex coulomb.social). # Apex cutover is deferred until data + self-registration are ready. diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index 4ebe899..4d609ed 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: railiance-apps created: "2026-10-06" -updated: "2026-10-31" +updated: "2026-11-01" related: - CSOC-WP-0008 state_hub_workstream_id: "79abacf8-361e-51ab-829d-40dec8e142d1" @@ -523,12 +523,11 @@ user workflow verdicts are tracked by existing CSOC-WP-0013-T02/T03/T05/T06. ```task id: RAPPS-WP-0015-T08 -status: wait +status: done priority: high -needs_human: true -wait_kind: human +needs_human: false decision_id: "0d538348-aadd-41a2-88f6-191defcf22b2" -blocking_reason: "Exact editor attachment proposal2cddcf38 passes native preflight; founder-terminal execution and independent return are required." +intervention_note: "Founder executed2cddcf38; revision21/imageb58d858a independently verified, source pins reconciled, fresh-point recovery/export passed. No rerun required. User confirmed editor visibility; remaining productive checks stay CSOC-WP-0013." state_hub_task_id: "e7b04735-e618-5551-9ecf-6d423c83d156" ``` @@ -547,3 +546,19 @@ Recovered views/export pass. Full native preflight at revision20/97rows/98files passes; existing93rows/86files are unchanged by the productive review additions. Plan: release/coulomb-editor-attachments-31eaa58.json. Review/command: coulomb-social/docs/release/content-acceptance-review-2026-10-31.md. + +2026-11-01 verified return: founder executed2cddcf38, completed at2026-10-31 +23:32:38Z, Helm21/imageb58d858a/source31eaa58. Independent complete-spec/source/ +helper checks agree with durable receipt:97business rows/98files unchanged, +43owner views/43outsider denials,79packaged files and21frozen capsule files. +Live GET-only check renders43editors and verifies42download links across41editors +with saved resources. Bernd confirms both reopened tabs show the attachment. + +The fresh23:29:54point independently mirrors/reads back/decrypts/restores all13 +SQL tables/119rows and98files. Deployed and previous packages read the recovered +store; exports verify its content tenant, with all source +SQL/files unchanged. Live values and consumer source/helper pins now match +revision21; snapshot server dry-run and9snapshot/restore regressions pass. +Recovery container is stopped and timers remain inactive/disabled. T08 is done; +remaining copy/move/sharing/portability outcomes stay CSOC-WP-0013-T03/T05/T06. +Evidence: coulomb-social/docs/release/editor-attachments-*-2026-11-01.json.