S436 — Infra Code-Truth & Posture Verdicts
S436 — Infra Code-Truth & Posture Verdicts
Section titled “S436 — Infra Code-Truth & Posture Verdicts”Task: read-only evidence pass answering Liam’s 6 pointed security/architecture
questions against LIVE files. Every claim cites file:line. No repo/docs-site file was
edited (this artefact is the only write). No network/SSH — where only a live probe could
confirm, marked VERIFY-LIVE.
Baseline read: .user-scratch/client-deployment-architecture-s435.md (the explainer
under review). Verdict vocabulary: INTENTIONAL-LONG-TERM / INTENTIONAL-INTERIM / OVERSIGHT / GAP.
Q1 — /walk + /extract public over HTTPS, bearer-gated: long-term or interim?
Section titled “Q1 — /walk + /extract public over HTTPS, bearer-gated: long-term or interim?”Evidence
Section titled “Evidence”| Fact | Source |
|---|---|
/walk gate = Authorization: Bearer ${CRON_SECRET}; unset→503 fail-closed, wrong→401, 2nd concurrent→409 | scripts/cocoindex_pipeline/server.py:487-498, 516-519 |
/extract gate = DEDICATED Bearer ${EXTRACT_API_TOKEN} (diff secret); unset→401 fail-closed; rate-limited (429); 20 MB body cap | server.py:592-602, 606-609, 576-583 |
Container binds 0.0.0.0:8080 but publishes NO ports: mapping — reachable only via the coolify-proxy Traefik on the shared docker net | server.py:881-885 |
Traefik routes exactly /walk+/health+/extract on the public FQDN, HTTPS, LE cert (certresolver=letsencrypt) — all 4 apps | docker-compose.production.yaml:84-91; staging.yaml:107-114; platform.yaml:98-105; platform-staging.yaml:99-106 |
The disabled Coolify cron hits LOOPBACK, not the FQDN: both prod+staging scheduled tasks target http://127.0.0.1:8080/walk (runs INSIDE the container via python3 urllib / bash /dev/tcp; no curl in image) | runbooks/onprem-b1-deploy.md:241-242, 256, 269, 244 |
App→pipeline calls run from Vercel over ${COCOINDEX_WORKER_URL} (serverless, no stable egress IP): walk-nudge on feed publish, folder-drop /walk, /extract cleaner | lib/intelligence/pipeline.ts:526-529; lib/upload/folder-drop.ts:262-266; lib/extraction/clean-via-worker.ts:63; runbook :303-308 |
Analysis
Section titled “Analysis”WHO must reach each route: (a) the cron — already loopback-internal, needs zero public reach; (b) the Vercel app — walk-nudge, /extract cleaner, folder-drop — runs on serverless with no stable egress IP, so it can only reach the pipeline via the public FQDN. That Vercel→VPS hop is the ONLY thing forcing public exposure. Alternatives, honest:
- Traefik IP-allowlist — impractical: Vercel serverless egress IPs aren’t stable/enumerable without Vercel Enterprise “Secure Compute” dedicated egress.
- mTLS at Traefik — feasible; app presents a client cert → 2nd independent factor beyond bearer.
- Cloudflare Tunnel (cloudflared on VPS) + Access service-token — origin stops being directly public at all; strongest + simple to operate.
- Keep public+bearer, harden — rate-limits, secret rotation, fail2ban/WAF.
Verdict: INTENTIONAL-INTERIM
Section titled “Verdict: INTENTIONAL-INTERIM”Deliberate, documented, fail-closed, path-scoped, LE-certed — not an oversight. But public+bearer is not the ideal multi-client posture.
Recommendation (plain English)
Section titled “Recommendation (plain English)”Fine for now (pre-launch, single tenant). Before multiple clients go live: (1) put the
pipeline behind a Cloudflare Tunnel or require a client certificate (mTLS) so it isn’t
directly on the open internet, keeping the password/token as a second lock; (2) add a
rate-limit to /walk (only /extract has one today); (3) rotate the /walk and
/extract secrets on a schedule. The scheduled auto-ingest cron already runs privately
inside the box — good, leave it.
Q2 — /stage loopback-only, not Traefik-routed: should it be?
Section titled “Q2 — /stage loopback-only, not Traefik-routed: should it be?”Evidence
Section titled “Evidence”| Fact | Source |
|---|---|
/stage writes multipart fixture bytes straight into the corpus dir (COCOINDEX_SOURCE_PATH) | server.py:265-350 |
| Deliberately NOT routed (Inv-13) — Traefik has no router for it → 404 at edge | server.py:881-884; compose docstring production.yaml:64-72; runbook :344-347 |
Reached only via docker exec loopback by the verify driver | deploy/onprem/verify/live-verify.sh:8, 194-195 |
Tension: live folder-drop upload UI → /api/ingest/folder-drop → POST {COCOINDEX_WORKER_URL}/stage from Vercel | components/create-content/upload-tab-content.tsx:118; lib/upload/folder-drop.ts:212 |
Analysis
Section titled “Analysis”/stage accepts arbitrary bytes that then get walked + AI-extracted (token burn) + landed
in the KB. Public exposure would be a corpus-injection vector. Keeping it loopback/docker exec-only is a correct, deliberate security decision — it’s a test-fixture staging seam
for the verify driver, not a production ingest route. HOWEVER the folder-drop Path B upload
(a live admin/editor UI path) POSTs /stage over the same public COCOINDEX_WORKER_URL —
which Traefik 404s. So Path B’s stage-leg cannot complete from Vercel against the deployed
split topology.
Verdict: INTENTIONAL-LONG-TERM (for /stage staying loopback-only) + GAP (folder-drop Path B)
Section titled “Verdict: INTENTIONAL-LONG-TERM (for /stage staying loopback-only) + GAP (folder-drop Path B)”Recommendation
Section titled “Recommendation”Keep /stage private — do not route it. Separately, VERIFY-LIVE whether the
folder-drop upload tab actually works in production; by code-truth its /stage call 404s
from Vercel. Either retire Path B in favour of the app-side Path A (/api/upload, which
needs no /stage) or give the app a dedicated bearer-gated staging route. This is a real
latent break, not cosmetic.
Q3 — pipeline-runs/record: two auth methods, or two layers?
Section titled “Q3 — pipeline-runs/record: two auth methods, or two layers?”Evidence
Section titled “Evidence”| Fact | Source |
|---|---|
Route enforces verifyCronAuth = Authorization: Bearer ${CRON_SECRET}, fail-closed if unset — SAME compiled route in every env | app/api/internal/pipeline-runs/record/route.ts:171; lib/cron-auth.ts:16-26 |
| Staging pipeline → client’s staging Vercel preview; auth = Vercel protection-bypass token (preview deploys are SSO-walled; token gets THROUGH the platform wall) | runbooks/client-app-deploy.md §3A.2 table + “Why a protection-bypass token (staging only)“ |
| Prod pipeline → client’s real domain (no preview wall); auth = the route’s own bearer | same §3A.2 |
| S435 baseline compresses this to “Bearer/secret (prod) OR bypass token (staging)” — reads as either/or | .user-scratch/client-deployment-architecture-s435.md:119 |
Analysis
Section titled “Analysis”Two LAYERS, not two auth methods. The route’s CRON_SECRET bearer is in the code and
enforced in both envs. The Vercel protection-bypass token is an additional
platform-level gate that exists only on staging preview deployments (SSO wall); the
token merely gets the caller THROUGH that wall to reach the route, which then STILL checks
the bearer. On prod (real domain) there’s no wall, so only the bearer applies.
Verdict: INTENTIONAL-LONG-TERM (architecture correct); doc one-liner is imprecise
Section titled “Verdict: INTENTIONAL-LONG-TERM (architecture correct); doc one-liner is imprecise”Recommendation
Section titled “Recommendation”The runbook already states it correctly. Fix the S435 explainer’s one-liner (line 119) to say “two layers: the route’s bearer is always required in both envs; staging preview adds a Vercel platform SSO wall that the pipeline clears with a bypass token.” Docs SHOULD spell this out — it’s a common source of “is staging less secure?” confusion (it isn’t; it has an extra layer).
Q4 — goose on client deployments: per-client container?
Section titled “Q4 — goose on client deployments: per-client container?”Evidence
Section titled “Evidence”| Fact | Source |
|---|---|
goose = ONE service kh-goose-pilot on Server B (Platform), its own container | deploy/coolify/goose-pilot/docker-compose.yaml:35-47 |
| Reaches the app ONLY via the public remote-MCP endpoint (default prod platform MCP) — “just another MCP client on a timer”, no DB/pipeline access | docker-compose.yaml:89 (GOOSE_MCP_URL), :19-21 |
| Pilot-gated, digest-pin unresolved, NOT yet run E2E (G4 never completed, S424) | compose G-notes :29-33, 54-59; baseline §(e); specs/id-71-ai-tooling/goose-pilot-deploy-plan.md |
| No per-client replication defined anywhere in compose or the deploy plan | (absence) |
Analysis
Section titled “Analysis”Today goose is Platform-only and unproven. Because it touches the knowledge base ONLY through the public MCP surface (never client data-plane), a client does not need a goose container on its own VPS — one goose can serve many clients by pointing at each client’s MCP URL with that client’s service-actor bearer. The per-client topology is simply undecided and undocumented.
Verdict: GAP (doc coverage) — not a code oversight (goose is deliberately pilot-scoped)
Section titled “Verdict: GAP (doc coverage) — not a code oversight (goose is deliberately pilot-scoped)”Recommendation
Section titled “Recommendation”Don’t build per-client goose yet — the pilot hasn’t proven out. Once it does, document the model explicitly. Recommended: keep goose on Server B, one recipe + service-actor per client pointing at each client’s MCP URL; no goose on client VPSes needed.
Q5 — Raindrop (id-104 / B-INV-21): code state + per-client local deployment?
Section titled “Q5 — Raindrop (id-104 / B-INV-21): code state + per-client local deployment?”Evidence
Section titled “Evidence”| Fact | Source |
|---|---|
The three DEFERRED organs are pattern_detector / ab_runner / auto_rollback — none is Raindrop | lib/eval/deferred-organs.ts:22-82 |
| Graduation metric computed in-house from Supabase only — no Raindrop host, no Workshop write key (B-INV-18) | lib/eval/graduation.ts:13-15, 76 |
Raindrop Workshop documented as local-only localhost:5899 + local SQLite, no account/key; hosted Raindrop EXCLUDED from client-data path (WS-14) | specs/id-71-ai-tooling/headless-requirement-refinement.md:112, 114, 147; RESEARCH.md:475 |
| Raindrop appears in docs-site ONLY inside id-71 specs — nowhere in runbooks/reference | grep of docs-site |
| No per-client local-Raindrop deployment documented anywhere | (absence) |
Analysis
Section titled “Analysis”Code CONFIRMS the ruling: hosted Raindrop is excluded (B-INV-18 in graduation.ts); Raindrop is not a deferred organ (the brief’s “check deferred-organs.ts” framing is imprecise — it isn’t there). Raindrop Workshop is a dev/CI eval-authoring tool, local to whoever writes evals — never a client-runtime component. So per-client deployment doesn’t apply and its absence from docs is correct, not a gap.
Verdict: INTENTIONAL-LONG-TERM (exclusion enforced in code); minor doc-clarity note
Section titled “Verdict: INTENTIONAL-LONG-TERM (exclusion enforced in code); minor doc-clarity note”Recommendation
Section titled “Recommendation”No deployment action. Optionally add one line to platform-context.md clarifying “Raindrop
= dev/CI eval tooling, local-only, excluded from client runtime” so the local-only vs
per-client-deploy confusion doesn’t recur. Do NOT describe it as a deferred organ.
Q6 — manual-upload forms: what does cataloguing + extraction TODAY? Is Liam right?
Section titled “Q6 — manual-upload forms: what does cataloguing + extraction TODAY? Is Liam right?”Evidence
Section titled “Evidence”| Fact | Source |
|---|---|
Form template upload = APP-SIDE: .docx → Supabase Storage + form_templates row (status uploaded); NO cocoindex | app/api/procurement/[id]/templates/route.ts:179-216 |
Form/tender question extraction = APP-SIDE: download from Storage → direct Anthropic + mammoth | app/api/procurement/[id]/questions/extract/route.ts:86-88; lib/domains/procurement/ai/extract-questions.ts:1-7 |
| Auto-map = APP-SIDE string similarity (not AI, not cocoindex) | .../templates/[templateId]/auto-map/route.ts:139 |
Path C catalogue = human-confirmed app-side script → form_template_requirements; pipeline ingest NEVER writes the catalogue (Inv-20) | .claude/skills/catalogue-form-requirements/SKILL.md; lib/domains/procurement/form-templating/catalogue/from-instance.ts:163 |
| Whole procurement/forms subsystem is cocoindex-free (only ref: a comment noting a prompt is a TS replica of the Python one) | grep app/api/procurement + lib/domains/procurement → from-instance.ts:110 (comment only) |
CONTRAST — URL/content path DOES use cocoindex: feed URL publish → nudgeCocoindexWalk → POST /walk → cocoindex extracts | lib/intelligence/pipeline.ts:676, 526-529 |
Vestigial corpus-forms machinery (workspace_resolver RouteKind “forms” + flow.py fork + manifest-gate test) still on main until BL-392 | baseline §(g) item 8; DR-014 |
Analysis
Section titled “Analysis”Liam is partly right, but not for forms. For CORPUS/URL content — reference_items via
feed publish, and Path B folder-drop generic files — yes, cocoindex still does the
extraction, just triggered on-demand (/walk) instead of watching. But forms are
app-side end-to-end (DR-014 decoupled them): upload to Storage, extract questions with a
direct Anthropic call, auto-map + catalogue in the app. cocoindex is not in the forms path
at all. The only “gap” vs DR-014 target state is dead-but-present corpus-forms code in the
Python pipeline that BL-392 will delete — it’s not an active second path (nothing feeds
forms to the corpus anymore).
Verdict: INTENTIONAL-LONG-TERM (DR-014 forms→app-side decoupling)
Section titled “Verdict: INTENTIONAL-LONG-TERM (DR-014 forms→app-side decoupling)”Recommendation
Section titled “Recommendation”Tell Liam: for web pages / feed URLs / dropped files, cocoindex does the extraction (fired by an on-demand walk, not a watcher). For forms specifically, the app does the extraction directly — cocoindex isn’t involved. That split is intentional (DR-014). The leftover forms code in the pipeline is scheduled for removal under BL-392.
Hardening candidates (consolidated)
Section titled “Hardening candidates (consolidated)”- Rate-limit
/walk— parity with/extract’s_rate_limit_allows;/walkhas none today (server.py). - Rotate
CRON_SECRET+EXTRACT_API_TOKENon a schedule — long-lived shared secrets. - De-overload
CRON_SECRET— it gates/walkinbound,pipeline-runs/recordinbound, datapath-watch + intelligence crons, AND the outbound walk-nudge (cron-auth.ts,server.py:487,pipeline.ts:517). Split the pipeline-trigger secret from the Vercel-cron secret (as/extractalready did) to shrink blast radius. - Cloudflare Tunnel or mTLS in front of the pipeline for multi-client GA so the origin isn’t directly public; keep the bearer as defence-in-depth.
- fail2ban / CrowdSec on the VPS for auth-fail bursts against the pipeline FQDNs.
- Reconcile folder-drop Path B vs
/stage-not-routed (Q2) — private/bearer-gated staging reach, or retire Path B for Path A. VERIFY-LIVE first. - Doc fixes: S435 line 119 (Q3 two-layers); add goose per-client model (Q4) + Raindrop dev-tooling note (Q5).
VERIFY-LIVE items
Section titled “VERIFY-LIVE items”- Does folder-drop Path B upload actually succeed in the deployed split topology? (Q2 — code says its
/stageleg 404s from Vercel.) - Confirm
COCOINDEX_WORKER_URLin each Vercel env = the public pipeline FQDN (assumed from code; not probeable here). - Confirm the two
/walkCoolify scheduled tasks remainenabled: falsein the live Coolify UI (Q1/§d).