feat(informed-decision): claim decisions.coulomb.social as the approver origin
INFD-WP-0001-T07 has been blocked since 2026-09-08 on an OIDC redirect URI it cannot publish without a real deployed origin, which in turn blocks key-cape's KEY-WP-0013-T02. The operator assigned decisions.coulomb.social; DNS already resolves to the cluster address. Adds the Ingress + letsencrypt-prod certificate for the host and a placeholder nginx backend, so the origin answers before the approver UI itself exists (INFD-WP-0001-T08 is still gated on approval-engine and on intake INFD-IN-0003). A redirect URI matches byte-exactly at /authorize, so a host that resolves but does not complete a TLS handshake fails closed at first login and presents as a rejected approval rather than a registration defect. The Ingress carries one path rule on purpose: reuse-surface reported on 2026-07-07 that an Exact rule alongside a catch-all Prefix rule on the same host was swallowed by the catch-all. That trap is worth avoiding on a host whose entire purpose is exact-match redirect handling. Dry-run clean against the live API; deliberately not applied. 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
This commit is contained in:
parent
debc54b02b
commit
c5546ac729
3 changed files with 307 additions and 0 deletions
94
docs/informed-decision-origin.md
Normal file
94
docs/informed-decision-origin.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# decisions.coulomb.social — origin for the informed-decision approver surface
|
||||
|
||||
**Status:** manifests written and dry-run clean; **not applied**.
|
||||
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` |
|
||||
|
||||
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`.
|
||||
|
||||
## To deploy (operator approval required — not yet given)
|
||||
|
||||
```bash
|
||||
export KUBECONFIG=$HOME/.kube/config-hosteurope
|
||||
kubectl apply -f manifests/informed-decision-origin.yaml
|
||||
kubectl apply -f manifests/informed-decision-ingress.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
|
||||
```
|
||||
|
||||
Only when that `curl` succeeds does `informed-decision` submit
|
||||
`docs/keycape-client-registration.md` to `key-cape`.
|
||||
|
||||
**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
|
||||
|
||||
The Ingress is `websecure`-only, following the `forgejo` and `coulomb-social`
|
||||
pattern, so port 80 stays free for cert-manager HTTP-01 solvers. Plain
|
||||
`http://decisions.coulomb.social/` will not redirect. `reuse-surface` adds a
|
||||
separate `-http-redirect` Ingress for this; add one here if a bare-host redirect
|
||||
is wanted. It is not required for the OIDC flow, which is always `https`.
|
||||
40
manifests/informed-decision-ingress.yaml
Normal file
40
manifests/informed-decision-ingress.yaml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Ingress for the informed-decision approver surface on decisions.coulomb.social.
|
||||
#
|
||||
# Requires cert-manager ClusterIssuer letsencrypt-prod and DNS
|
||||
# decisions.coulomb.social -> cluster IP (A record confirmed 2026-09-10).
|
||||
#
|
||||
# ONE path rule on purpose. reuse-surface reported (2026-07-07) that a host
|
||||
# carrying an Exact /health rule alongside a catch-all `/` Prefix rule had the
|
||||
# exact match swallowed by the catch-all, so the public health URL 404'd while
|
||||
# the pod was healthy. Splitting /auth, /api and / across backends here would
|
||||
# reproduce that. Everything on this host stays on one backend; if the surface
|
||||
# ever needs a second one, set traefik.ingress.kubernetes.io/router.priority
|
||||
# explicitly rather than relying on rule order.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: informed-decision
|
||||
namespace: informed-decision
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
# websecure only (forgejo/coulomb-social pattern): leave HTTP free for
|
||||
# cert-manager HTTP-01 solvers. TLS app traffic stays on 443; the ACME
|
||||
# challenge uses the solver ingress on 80.
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts: [decisions.coulomb.social]
|
||||
secretName: informed-decision-tls
|
||||
rules:
|
||||
- host: decisions.coulomb.social
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: informed-decision
|
||||
port:
|
||||
number: 80
|
||||
173
manifests/informed-decision-origin.yaml
Normal file
173
manifests/informed-decision-origin.yaml
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
# Origin holder for decisions.coulomb.social.
|
||||
#
|
||||
# `informed-decision` (INFD-WP-0001-T07) must publish an exact OIDC redirect URI
|
||||
# to key-cape: https://decisions.coulomb.social/auth/callback. Redirect URIs match
|
||||
# byte-exactly at /authorize, so the origin has to be real before the registration
|
||||
# is submitted — a host that resolves but does not answer over TLS fails the same
|
||||
# way a wrong hostname does, only later and less legibly.
|
||||
#
|
||||
# The approver UI itself does not exist yet (INFD-WP-0001-T08, the walking
|
||||
# skeleton, is still gated on approval-engine and on intake INFD-IN-0003). This
|
||||
# placeholder exists solely so the host answers and cert-manager can issue.
|
||||
# When the real surface lands it replaces this Deployment/Service behind the same
|
||||
# Service name, and this file shrinks to the Namespace.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: informed-decision
|
||||
labels:
|
||||
app.kubernetes.io/part-of: informed-decision
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: informed-decision-placeholder
|
||||
namespace: informed-decision
|
||||
labels:
|
||||
app.kubernetes.io/name: informed-decision
|
||||
app.kubernetes.io/component: placeholder
|
||||
data:
|
||||
index.html: |
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>Decisions — Railiance</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
background: #f7f7f5; color: #1a1a19;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #14140f; color: #e8e8e3; }
|
||||
}
|
||||
main { max-width: 34rem; padding: 2rem 1.5rem; }
|
||||
h1 { font-size: 1.5rem; margin: 0 0 .5rem; }
|
||||
p { margin: 0 0 .75rem; }
|
||||
.eyebrow {
|
||||
text-transform: uppercase; letter-spacing: .08em;
|
||||
font-size: .75rem; opacity: .6; margin-bottom: .25rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<p class="eyebrow">Railiance</p>
|
||||
<h1>Decisions</h1>
|
||||
<p>
|
||||
This host is reserved for the <strong>informed-decision</strong>
|
||||
approver surface. The service is not deployed yet.
|
||||
</p>
|
||||
<p>
|
||||
The origin is live so that its OIDC redirect URI can be registered
|
||||
against a host that actually answers.
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
# nginx serves the page on 8080 so the container needs no root.
|
||||
default.conf: |
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
add_header Content-Type text/plain;
|
||||
return 200 'ok';
|
||||
}
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: informed-decision
|
||||
namespace: informed-decision
|
||||
labels:
|
||||
app.kubernetes.io/name: informed-decision
|
||||
app.kubernetes.io/component: placeholder
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: informed-decision
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: informed-decision
|
||||
app.kubernetes.io/component: placeholder
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 101
|
||||
runAsGroup: 101
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginxinc/nginx-unprivileged:1.27-alpine
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
readinessProbe:
|
||||
httpGet: { path: /healthz, port: http }
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet: { path: /healthz, port: http }
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
resources:
|
||||
requests: { cpu: 10m, memory: 24Mi }
|
||||
limits: { memory: 64Mi }
|
||||
volumeMounts:
|
||||
- name: content
|
||||
mountPath: /usr/share/nginx/html/index.html
|
||||
subPath: index.html
|
||||
readOnly: true
|
||||
- name: content
|
||||
mountPath: /etc/nginx/conf.d/default.conf
|
||||
subPath: default.conf
|
||||
readOnly: true
|
||||
- name: cache
|
||||
mountPath: /var/cache/nginx
|
||||
- name: run
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: content
|
||||
configMap:
|
||||
name: informed-decision-placeholder
|
||||
- name: cache
|
||||
emptyDir: {}
|
||||
- name: run
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: informed-decision
|
||||
namespace: informed-decision
|
||||
labels:
|
||||
app.kubernetes.io/name: informed-decision
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: informed-decision
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
Loading…
Add table
Add a link
Reference in a new issue