railiance-apps/docs/informed-decision-origin.md

176 lines
8.5 KiB
Markdown
Raw Normal View History

# decisions.coulomb.social — origin for the informed-decision approver surface
**Status:** applied and live. `https://decisions.coulomb.social/` answers 200
over a valid Let's Encrypt certificate as of 2026-09-10 14:32 UTC.
Last reviewed: 2026-09-10
## Why this host exists before the app does
`informed-decision` owns the browser-facing approver UI that `approval-engine`
deliberately does not contain. Its workplan task `INFD-WP-0001-T07` must publish two strings to `key-cape` — an
OIDC `client_id` and a full callback URI — and cannot do so against a host that
does not answer.
**Correction, 2026-09-11.** An earlier version of this document said the origin
unblocks `key-cape`'s `KEY-WP-0013-T02`, "blocked since 2026-09-08". That is
wrong twice over, and the error came from repeating `informed-decision`'s
workplan prose of 2026-09-09 without checking the source repo. `KEY-WP-0013-T02`
is `done`; so is `T05`, the task that actually carried the human registration.
`key-cape` published the contract shape from its side — including the
`approval:read` scope `informed-decision` asked for and a fix to the `assurance`
object's `at` field — and closed a follow-up (`KEY-WP-0030`) that
`informed-decision` prompted. **Nothing in `key-cape` is waiting on this host.**
What remains is `informed-decision`'s own `T07`, whose acceptance requires the
real origin regardless.
Both strings are now fixed except for the host:
| Field | Value |
| --- | --- |
| `client_id` | `informed-decision-approver` |
| Redirect URI | `https://decisions.coulomb.social/auth/callback` |
| Flow | authorization code + S256 PKCE, public client |
| Scopes | `openid`, `approval:read`, `approval:approve` |
Redirect URIs match byte-exactly at `/authorize`. A registration pointing at a
host that does not answer fails closed at first login and presents as a rejected
approval rather than as a registration defect — which is exactly the failure
`approval-engine` avoided by refusing to invent these strings. So the origin has
to be real before the registration is submitted, and that is S5 work here rather
than in `informed-decision`.
The approver UI itself is `INFD-WP-0001-T08`, still gated on `approval-engine`
`APPROVAL-WP-0002-T01` and on intake `INFD-IN-0003` (the evidence copy must reach
`audit-core` independently of this component). This host therefore ships a
placeholder first and the real surface later.
## What is in the repo
| File | Contents |
| --- | --- |
| `manifests/informed-decision-origin.yaml` | Namespace, placeholder nginx ConfigMap/Deployment/Service on `informed-decision:80` |
| `manifests/informed-decision-ingress.yaml` | Traefik Ingress + `letsencrypt-prod` certificate for `decisions.coulomb.social` |
feat(informed-decision): HTTP -> HTTPS redirect for decisions.coulomb.social The main Ingress is websecure-only per the forgejo pattern, so port 80 was unrouted for this host and plain http:// returned Traefik's default 404. Adds a redirectScheme Middleware plus a web-entrypoint Ingress, following the reuse-surface pattern. Pins router.priority: "1" on the redirect. cert-manager solves HTTP-01 by creating a solver Ingress on this same host and entrypoint, so a catch-all "/" redirect competes with it directly. Traefik would normally settle that by rule length, but reuse-surface's 2026-07-07 report is exactly a case of a specific rule losing to a catch-all when precedence was left implicit -- and here the symptom would not be a visible 404 but a silently failed renewal ~60 days out, surfacing as an expired certificate on the origin backing an OIDC redirect URI. Verified by probe twice: with a solver-shaped Ingress present the challenge path returns 200 (solver wins) while "/" still redirects; with it absent the redirect correctly catches both. Probe was throwaway and is not committed; the runbook carries the table to recreate it. GET returns 301 and HEAD 308. That split is Traefik's own behaviour on this cluster, not a defect here -- reuse.coulomb.social does the same. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJbh7o7UWF4tQ5jxygnNGu Assistant: claude-code Assistant-Model: opus Assistant-Process: 2072522@bnt-lap001 Assistant-Session: 46173adf-7302-4ede-99d6-963b61359928
2026-09-10 16:57:28 +02:00
| `manifests/informed-decision-http-redirect.yaml` | `redirectScheme` Middleware + `web`-entrypoint Ingress, HTTP → HTTPS |
The placeholder is `nginxinc/nginx-unprivileged`, read-only root filesystem,
non-root, `noindex`. When the real surface lands it takes over the same Service
name and the origin file shrinks to the Namespace.
## Deliberate single path rule
The Ingress carries exactly one rule: `/` Prefix to one backend.
`reuse-surface` reported on 2026-07-07 that an Ingress declaring an `Exact`
`/health` rule alongside a catch-all `/` Prefix rule on the same host had the
exact match swallowed by the catch-all — the public health URL returned 404 from
the landing container while the pod was `1/1 Ready` and its own probes passed.
Splitting `/auth`, `/api` and `/` across backends here would reproduce that on a
host whose whole purpose is an exact-match redirect URI. If a second backend ever
becomes necessary, set `traefik.ingress.kubernetes.io/router.priority`
explicitly rather than relying on rule order.
## Preconditions verified 2026-09-10
- DNS `decisions.coulomb.social``92.205.62.239` (same A record as `reuse`).
- `letsencrypt-prod` ClusterIssuer `Ready=True`.
- No existing Ingress claims `decisions.coulomb.social`.
- Server-side dry-run of all five objects against the live API is clean.
The namespaced four were validated against an existing namespace, since a
server dry-run cannot create the new one first; see
`DRY_RUN_CREATE_NAMESPACES` in `tools/k8s-server-dry-run.sh`.
## Deployed 2026-09-10
Applied with operator approval. Evidence:
| Check | Result |
| --- | --- |
| Placeholder pod | `1/1 Running`, `/healthz``ok` in-pod |
| Certificate `informed-decision-tls` | `Ready=True`, ACME order `valid` |
| Issuer / subject | `CN=decisions.coulomb.social`, Let's Encrypt `YR2` |
| Validity | `2026-09-10``2026-12-09` (cert-manager renews) |
| `GET https://decisions.coulomb.social/` | `HTTP/2 200`, chain verify `0` |
| `GET https://decisions.coulomb.social/auth/callback` | `200` — the exact redirect URI resolves |
feat(informed-decision): HTTP -> HTTPS redirect for decisions.coulomb.social The main Ingress is websecure-only per the forgejo pattern, so port 80 was unrouted for this host and plain http:// returned Traefik's default 404. Adds a redirectScheme Middleware plus a web-entrypoint Ingress, following the reuse-surface pattern. Pins router.priority: "1" on the redirect. cert-manager solves HTTP-01 by creating a solver Ingress on this same host and entrypoint, so a catch-all "/" redirect competes with it directly. Traefik would normally settle that by rule length, but reuse-surface's 2026-07-07 report is exactly a case of a specific rule losing to a catch-all when precedence was left implicit -- and here the symptom would not be a visible 404 but a silently failed renewal ~60 days out, surfacing as an expired certificate on the origin backing an OIDC redirect URI. Verified by probe twice: with a solver-shaped Ingress present the challenge path returns 200 (solver wins) while "/" still redirects; with it absent the redirect correctly catches both. Probe was throwaway and is not committed; the runbook carries the table to recreate it. GET returns 301 and HEAD 308. That split is Traefik's own behaviour on this cluster, not a defect here -- reuse.coulomb.social does the same. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJbh7o7UWF4tQ5jxygnNGu Assistant: claude-code Assistant-Model: opus Assistant-Process: 2072522@bnt-lap001 Assistant-Session: 46173adf-7302-4ede-99d6-963b61359928
2026-09-10 16:57:28 +02:00
| `GET http://decisions.coulomb.social/` | `301`/`308` → HTTPS (added 2026-09-10) |
`/auth/callback` currently returns the placeholder page via the SPA `try_files`
fallback. That is the correct behaviour for now: the origin answers, which is
what the registration needs. The real surface will handle the path when
`INFD-WP-0001-T08` ships.
**`informed-decision` can now submit `docs/keycape-client-registration.md` to
`key-cape`.** That closes its own `INFD-WP-0001-T07`, not a `key-cape` task —
see the correction above.
## Redeploy / recovery
```bash
export KUBECONFIG=$HOME/.kube/config-hosteurope
kubectl apply -f manifests/informed-decision-origin.yaml
kubectl apply -f manifests/informed-decision-ingress.yaml
feat(informed-decision): HTTP -> HTTPS redirect for decisions.coulomb.social The main Ingress is websecure-only per the forgejo pattern, so port 80 was unrouted for this host and plain http:// returned Traefik's default 404. Adds a redirectScheme Middleware plus a web-entrypoint Ingress, following the reuse-surface pattern. Pins router.priority: "1" on the redirect. cert-manager solves HTTP-01 by creating a solver Ingress on this same host and entrypoint, so a catch-all "/" redirect competes with it directly. Traefik would normally settle that by rule length, but reuse-surface's 2026-07-07 report is exactly a case of a specific rule losing to a catch-all when precedence was left implicit -- and here the symptom would not be a visible 404 but a silently failed renewal ~60 days out, surfacing as an expired certificate on the origin backing an OIDC redirect URI. Verified by probe twice: with a solver-shaped Ingress present the challenge path returns 200 (solver wins) while "/" still redirects; with it absent the redirect correctly catches both. Probe was throwaway and is not committed; the runbook carries the table to recreate it. GET returns 301 and HEAD 308. That split is Traefik's own behaviour on this cluster, not a defect here -- reuse.coulomb.social does the same. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJbh7o7UWF4tQ5jxygnNGu Assistant: claude-code Assistant-Model: opus Assistant-Process: 2072522@bnt-lap001 Assistant-Session: 46173adf-7302-4ede-99d6-963b61359928
2026-09-10 16:57:28 +02:00
kubectl apply -f manifests/informed-decision-http-redirect.yaml
kubectl -n informed-decision get pods,svc,ingress
kubectl -n informed-decision get certificate informed-decision-tls -w
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.
## HTTP → HTTPS
feat(informed-decision): HTTP -> HTTPS redirect for decisions.coulomb.social The main Ingress is websecure-only per the forgejo pattern, so port 80 was unrouted for this host and plain http:// returned Traefik's default 404. Adds a redirectScheme Middleware plus a web-entrypoint Ingress, following the reuse-surface pattern. Pins router.priority: "1" on the redirect. cert-manager solves HTTP-01 by creating a solver Ingress on this same host and entrypoint, so a catch-all "/" redirect competes with it directly. Traefik would normally settle that by rule length, but reuse-surface's 2026-07-07 report is exactly a case of a specific rule losing to a catch-all when precedence was left implicit -- and here the symptom would not be a visible 404 but a silently failed renewal ~60 days out, surfacing as an expired certificate on the origin backing an OIDC redirect URI. Verified by probe twice: with a solver-shaped Ingress present the challenge path returns 200 (solver wins) while "/" still redirects; with it absent the redirect correctly catches both. Probe was throwaway and is not committed; the runbook carries the table to recreate it. GET returns 301 and HEAD 308. That split is Traefik's own behaviour on this cluster, not a defect here -- reuse.coulomb.social does the same. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJbh7o7UWF4tQ5jxygnNGu Assistant: claude-code Assistant-Model: opus Assistant-Process: 2072522@bnt-lap001 Assistant-Session: 46173adf-7302-4ede-99d6-963b61359928
2026-09-10 16:57:28 +02:00
`manifests/informed-decision-http-redirect.yaml` adds a `redirectScheme`
Middleware plus a `web`-entrypoint Ingress, following the `reuse-surface`
pattern. Applied 2026-09-10.
`http://decisions.coulomb.social/` now returns `308` to HEAD and `301` to GET.
That split is not a defect in this configuration — `reuse.coulomb.social`, which
uses the identical Middleware, behaves the same way. It is Traefik's own
behaviour on this cluster.
The main Ingress stays `websecure`-only; this is a second router on port 80
rather than a change to the first.
### Router priority is load-bearing here
cert-manager solves HTTP-01 by creating a temporary solver Ingress on **this
same host and this same `web` entrypoint**, serving
`/.well-known/acme-challenge/<token>`. A catch-all `/` redirect on that
entrypoint competes with it directly.
Traefik would normally settle this by rule length — the solver's `Path` rule is
much longer than `PathPrefix(/)` — but `reuse-surface`'s 2026-07-07 report is
precisely a case of a specific rule losing to a catch-all when precedence was
left implicit. There the symptom was a 404 someone noticed within the day. Here
it would be a **silently failed renewal about 60 days out**, surfacing as an
expired certificate on the origin backing an OIDC redirect URI — the kind of
failure that presents as a broken login rather than as an expired cert.
So the redirect router pins `traefik.ingress.kubernetes.io/router.priority: "1"`.
Any solver router, using the default computed priority, outbids it.
**Verified by probe, twice, on 2026-09-10.** A throwaway Ingress mimicking the
solver shape (`web` entrypoint, `Exact` acme-challenge path, default priority)
was applied and the challenge path was polled:
| Condition | `/.well-known/acme-challenge/<token>` | `/` |
| --- | --- | --- |
| No solver present | `301` (redirect catches it — correct) | `301` |
| Solver present | `200` (**solver wins**) | `301` |
| Solver removed | `301` | `301` |
Traefik takes 3060s to converge on Ingress changes, so the flip is not
instant; poll rather than reading a single response. The probe was deleted after
each run and is not in the repo — recreate it from this table if the redirect
or the priority annotation is ever changed.
The next real renewal is due around 2026-11-09. Confirm the certificate's
`notAfter` advances then; that is the only test that exercises the real solver
rather than a probe of its shape.