railiance-apps/docs/informed-decision-origin.md
tegwick 8d3e4a62b6
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
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

7.6 KiB
Raw Blame 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 close KEY-WP-0013-T02, which has been blocked on them since 2026-09-08.

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
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.social92.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, /healthzok in-pod
Certificate informed-decision-tls Ready=True, ACME order valid
Issuer / subject CN=decisions.coulomb.social, Let's Encrypt YR2
Validity 2026-09-102026-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
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 is unblocked to submit docs/keycape-client-registration.md to key-cape and close KEY-WP-0013-T02.

Redeploy / recovery

export KUBECONFIG=$HOME/.kube/config-hosteurope
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
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

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.