Version: Current
Historical release snapshot. These docs are frozen. Read current docs.
Consumer launch spec
Status: draft, not applied (observed 2026-11-05 in this repository). This page records a specification, not an applied deployment. Nothing in the site configuration, the control plane or any service was changed while writing it. The consumer plane now exists in the binary; the public exposure described here is the plan that governs it.
Scope: publish the acumen consumer plane on https://ai.tcp.systems without
widening the operator surface.
Decision required before implementation: proxy authorization, DNS posture,
and admin-key rotation.
Objective
Section titled “Objective”Make the invite-only consumer surface reachable on the public origin while:
- keeping the machine API (
/v1/*) and/healthzbehaviour unchanged; - keeping every operator surface absent from the public host: a 404 at the edge, not a 401 and not an upstream error page;
- moving the console’s injected admin credential out of an inline site-file literal into a rotated, file-based reference;
- making session cookies unambiguously
Securebehind TLS; - putting a bounded rate and abuse posture in front of public sign-in and invite acceptance before the routes are exposed;
- shipping a rollout and a rollback that can be executed from the host without guessing.
Non-goals
Section titled “Non-goals”- No new product features, self-registration, payments, email delivery, OIDC or passkeys in this change.
- No database schema migration.
- No change to the control plane, DNS, or the acumen listener
(
127.0.0.1:4020). - No proxy plugin or custom build in the default plan.
- No
*host allowlist, no disabling key enforcement (ACUMEN_REQUIRE_KEYSmust stay enabled), no public:4020.
Verified starting state
Section titled “Verified starting state”| Fact | Evidence |
|---|---|
| The edge 404s every consumer path | /, /signin, /app → 404; /healthz → 200; /v1/models → 401 |
| The consumer plane works on loopback | / 200, /signin 200, /app 303, /account 303, /i/x 410, /favicon.ico 204, /robots.txt 200 |
| Proxy has no rate-limit module | version 2.6.2, module list checked |
| Proposed directives validate | request_body { max_size 256KB }, flush_interval -1, path lists and ?Header defaults all accepted |
| acumen is loopback with keys armed | acumen.service, 127.0.0.1:4020, env carries ACUMEN_REQUIRE_KEYS=1 and the allowed host |
| Disk and running config had drifted | the running config held a working admin token while the on-disk file held a stale literal; a reload would have replaced the working value with the broken one |
| Console was publicly reachable | public :8088 answered 200 for the console and key API; no source allowlist on the listener |
Shape of the change
Section titled “Shape of the change”browser ──TLS──► proxy (ai.tcp.systems) ──► 127.0.0.1:4020 ├─ /healthz, /v1/* → acumen (keys still enforced) ├─ /, /i/*, /signin, /app/*, │ /account, /favicon.ico, │ /robots.txt → acumen (session cookie) └─ /console*, /metrics, /health, /readyz, /v1/router/*, /debug/* → 404 (empty body)The listener stays loopback with no process bearer: a browser cannot send one, and embedding it in a page would hand it to every visitor. TLS and policy live at the edge.
Rollout order
Section titled “Rollout order”The order is the safety property. Each step is verifiable before the next.
Snapshot the running edge
Section titled “Snapshot the running edge”Copy the running proxy config, the on-disk site files and a key inventory to a
timestamped, mode 700 backup directory. Diff running against disk and record
the difference.
Move the console credential out of the site file
Section titled “Move the console credential out of the site file”Create a 0640 token file owned by the proxy group, imported by the console
site. The site file keeps no credential literal.
Fix the console bind
Section titled “Fix the console bind”Pin the console listener to the tailnet address and add a remote_ip fallback
that answers 404 off-tailnet. Confirm the public port refuses before
continuing.
Stage and validate
Section titled “Stage and validate”caddy fmt --overwrite <staged files>caddy validate --config <staged main> --adapter caddyfilecaddy validate --config <staged console> --adapter caddyfileValidating the files separately does not prove the combination, because the main file imports the console site. Install the console file first, then validate the combined configuration.
Apply and reload
Section titled “Apply and reload”Install, systemctl reload caddy, check systemctl is-active. A failed reload
leaves the old configuration running. Read the journal before declaring
success.
Verify the public plane and the closed paths
Section titled “Verify the public plane and the closed paths”Consumer paths answer 200/303/410/204 as expected; every operator path answers
404 with no upstream markers; the tailnet console answers 200; the public
console port refuses; ss shows the tailnet address, not *; /v1/models
without a key answers 401.
Soak, then retire the old key
Section titled “Soak, then retire the old key”Disable the old admin key first; rollback can re-enable it without minting a new one, then delete it after the agreed soak (24 h by default).
Rollback
Section titled “Rollback”- Proxy: restore the pre-change main and console files from the backup directory,
caddy validate,systemctl reload caddy. The pre-bind copy is the safe one; the pre-rotation copy contains a revoked token and must be paired with a live key. - Keys: re-enable the old key if it was only disabled. If it was deleted, point the console at another live admin key.
- Consumer routes: removing the
@consumerhandler from the public site returns the edge to 404 for those paths without touching the API, the listener or the database.
Where the code changes
Section titled “Where the code changes”| Path | Change site |
|---|---|
internal/consumer/consumer.go |
routes, cookie, limiter |
internal/consumer/acceptance_test.go |
invite → session → key → revoke |
internal/consumer/templates.go |
inline HTML, no external assets |
internal/server/keys.go |
key auth, scopes, CRUD |
internal/server/middleware.go |
Host/Origin guard, baseline headers |
cmd/acumen/invite.go |
invite bootstrap |
tools/edge-check.sh |
operator-path assertions |
tools/consumer-check.sh |
public consumer assertions (new) |
Style rules for the change: stdlib only, no new module for rate limiting;
net/netip for IP canonicalisation; a mutex plus a fixed window or token bucket;
table-driven tests; never log tokens, cookies, recovery codes, invite URLs or raw
emails; hash client IPs in logs.
Acceptance matrix
Section titled “Acceptance matrix”| Area | Must pass |
|---|---|
| Public pages | / 200, /signin 200, /app 303, /account 303, /i/invalid 410, /favicon.ico 204, /robots.txt 200 |
| API unchanged | /healthz 200; /v1/models 401 without key, 200 with a chat key; one real completion 200 |
| Operator 404 | /console*, /metrics, /health, /readyz, /v1/router/*, /debug/* → 404, empty body, no X-Acumen-* |
| Console | tailnet 200; public port never 200; listener pinned; new admin key works; old key disabled then deleted |
| Cookie | HttpOnly, Secure, SameSite=Lax, Path=/, no Domain; HTTPS only; clearing cookie Max-Age<=0 |
| Invite | CLI bootstrap 200; accept 200; recovery code once; reuse and expiry 410 |
| Abuse | per-IP limits 429 with Retry-After; a second IP unaffected; 300 KB POST changes no state; /healthz never 429 |
| Authz | non-operator 403 on /app/people and /app/invites; chat key cannot open /app or operator paths; CSRF enforced |
| Ops | caddy validate clean; tools/gate.sh --race green; edge and consumer checks green; rollback rehearsed; ledger updated |
Open questions
Section titled “Open questions”- Proxy authorization. Who authorizes editing the site files and reloading the proxy? Is a maintenance window required, or is a graceful reload acceptable?
- Public console containment. Is the bind plus
remote_ipfix authorized in the same window as the launch, or held until it is fixed separately? It is a blocker, not a nice-to-have. - DNS posture. Direct A record or Cloudflare in front for WAF and rate limiting? Cloudflare in front needs an origin firewall, trusted proxies, real-client-IP handling and a TLS-mode design. Is this the final consumer origin, or will it move; cookies are host-only?
- Rate limiting. App-level middleware and the proposed thresholds, a custom proxy build with a rate-limit module, or Cloudflare? Who owns tuning and the on-call response to 429 spikes?
- Invite policy. Who may be invited, how many active invites per operator, default and maximum TTL, and what happens on abuse. Is there a terms and privacy page, a support address, and a retention policy for public accounts?
/readyzmonitoring. May it leave the public edge? If an external uptime check depends on it, switch the check to/healthz.- Admin key custody. Who holds the console admin key and the break-glass copy, and where is the offline copy stored?
- Release pin. Which commit is the release of record for any rebuild?
- Staging. Is there a disposable instance and database for invite acceptance, rate-limit and rollback tests, or is production the only environment?
- Legal and abuse. Is an invite-only public launch approved under the current terms, who is the abuse contact, and what is the freeze procedure?