ADR-175: Trust-boundary placement as fleet-wide principle
Status: Proposed Date: 2026-05-08
Related: warp#1974 (parent EPIC: 6-phase structural enforcement), warp#1976 (this ADR — Phase 1), warp#1980 (Phase 2: schema + tier field), warp#1977 (Phase 3: drift-detection script), warp#1978 (Phase 4: PR gate), warp#1979 (Phase 5: nightly cron), warp#1981 (Phase 6: doc-pairing structural gate), warp#1910 (originating instance: Prometheus pivot), ADR-083 (defense-in-depth-rule-promotion), ADR-113 (principles tier above initiative), ADR-115 (principle-aware reviewers), ADR-174 (Prometheus pivot supersedes runner-monitor transport)
TL;DR
Every SaaS app the fleet runs has exactly one trust boundary — the BFF — and internal services trust the network behind it. New services default to no public IP. Public IPs are by justification only, declared in infrastructure/server-ids.json with tier: bff + a one-line tier_justification. This rule is what makes weak-auth services (node_exporter on :9100 with no bearer support, vendored tools, legacy systems) safe to run — they don't each implement their own auth because the BFF + cloud-firewall combination is doing that job at the network layer.
The rule applies whether the BFF is in-process (single-service apps; the framework's server-routes ARE the BFF), a dedicated tier (multi-service apps; e.g. warp's /api/v1/*), or at the edge (Cloudflare Workers; future shapes). The implementation can flex; the rule — one trust boundary per app, internal services trust the network — is invariant.
Context
On 2026-05-08 the operator hit a class of architectural error during the warp#1910 Prometheus pivot: node_exporter (the upstream prometheus.io binary) listens on :9100 with no bearer-token auth — the binary doesn't natively support it. Applying the warp#1921 ansible role onto a host with a public IP would publish CPU / memory / disk / network / load-average / process counts on the public internet. The pivot fixed this instance with a Hetzner cloud-firewall (warp#1940, PR #716) restricting :9100 + :9877 inbound to warp-04's IP only.
But the class recurs every time someone adds an internal service. Examples the fleet has hit or will hit:
- A vendored database admin tool listening on
0.0.0.0by default - A new metrics exporter for a third-party service whose auth model is "trust the network"
- A legacy system being containerised whose original deployment assumed an internal LAN
- A debugging endpoint a developer added "temporarily" that survived to production
- A new SaaS app whose backend was placed on a public IP because "it was faster to test"
The ultrathink session that surfaced the principle (operator + agent, 2026-05-08) traced the deeper structural pattern: warp's /api/v1/runners/* was already a BFF in disguise. The pivot didn't add the pattern; it clarified what was already there. Going forward, the question is no longer "should the next internal service have a public IP?" but "where does this app's trust boundary live, and does the rest of its inventory follow from that placement?".
The rule needs to be codified before more SaaS apps land. The fleet runs one Prometheus instance today; tomorrow it will run dozens of customer-facing apps with the same shape. Without a documented + structurally-enforced rule, every new app re-derives the trust model from operator memory under deadline pressure. The class will recur.
This ADR codifies the rule. Companion ADRs and tickets (Phase 2-6 of warp#1974) ship the structural enforcement that makes the rule self-policing.
Decision
Each SaaS app the fleet runs has exactly one trust boundary, implemented as either a framework's server-routes (in-process BFF) or a dedicated BFF service. Internal services default to no public IP. Public IPs are by justification only, declared in infrastructure/server-ids.json with tier: bff plus a one-line tier_justification. Internal services trust the network; the BFF + cloud-firewall combination is the auth boundary. This rule is invariant; the BFF's implementation shape (in-process / dedicated / edge) flexes per app's complexity.
Three sub-rules expand this:
1. Tier hierarchy
Every server in infrastructure/server-ids.json carries an explicit tier:
| Tier | Meaning | Required fields |
|---|---|---|
bff |
Public IP is intentional; this is a trust boundary for some app | tier_justification (one-liner) |
internal |
Public IP is firewall-restricted; only trusted_inbound aliases reach exposed_ports |
exposed_ports (TCP), trusted_inbound (alias list) |
bastion |
Allow-listed exception (SSH-bastion / dev-jumphost); strictly enumerated | tier_justification, trusted_inbound |
A schema validator (Phase 2 of warp#1974) refuses inventory edits that introduce a public-IP'd server without tier.
2. The rule survives the BFF's shape
The implementation hierarchy — pick by app complexity:
| Shape | When | Example |
|---|---|---|
| Framework-BFF (in-process) | Single-service app; one frontend + one backend module; ≤2 internal dependencies | Most new SaaS. Default. |
| Dedicated BFF tier | Multi-service backend; composition across services | warp itself (/api/v1/runners/* composes Prometheus + runner-exporter + audit-log) |
| API Gateway in front of BFF | Public partner APIs; rate-limiting + DDoS at edge with full BFF logic behind | Future product with partner-facing API |
| Edge-BFF (Workers/Vercel) | Global perf becomes a constraint; static-cacheable responses | Phase ≥2 product where one-region latency hurts |
| BFM (per-client BFF) | Mobile or partner-API consumers with materially different shapes | When/if mobile ships |
These are not five different architectures — they're five positions on the same axis: where the trust boundary lives. Each tier refactors cleanly to the next. Start at framework-BFF; promote to dedicated BFF when monolith composition gets unwieldy; add edge layer when latency demands. The rule does not change; the shape evolves.
3. Internal services trust the network
This is what makes the architecture economical. Without it, every weak-auth service needs an auth-proxy retrofit:
- node_exporter has no bearer support → would need an envoy / nginx wrapper for auth
- Prometheus has weak default auth → would need OAuth2 proxy
- Vendored DB tools rarely auth well → would need per-tool wrapper
- Legacy systems assume LAN trust → would need full auth bolt-on
The aggregate ops cost is enormous. With the rule, none of these need their own auth: the BFF in front (or the framework that IS the BFF) handles authn/authz; the cloud-firewall enforces network-level access. Internal services run as upstream-shipped, and the trust posture is structural, not per-service.
The rule has one pre-condition: the firewall must actually be enforced. Cloud-firewall configuration is the single point of trust; misconfigured firewall = ruined trust model. This is why Phases 2-6 of warp#1974 exist — schema + drift detector + PR gate + cron + doc-pairing make the firewall config self-checking, not operator-discretionary.
Alternatives considered
The ultrathink session walked the full architectural landscape. Each alternative was rejected for the fleet's specific context (small team, Hetzner-based, Go + SvelteKit, building "a lot of SaaS"):
| Alternative | Why rejected for the fleet |
|---|---|
| Direct-exposure (every service has public IP, auths itself) | Surface area scales N× services. Weak-auth services can't be secured without per-service wrappers. Audit story degrades. |
| API Gateway (Kong / Cloudflare API Shield, pure routing) | UI ends up knowing internal service shapes; no composition layer. Per-tenant logic has nowhere to live. Useful as a complement to BFF for rate-limiting, not a replacement. |
| GraphQL Federation (Apollo) | Solves problems we don't have (REST shapes work; not query-driven). N+1 problems, auth-per-field hard, caching breaks. Adopt per-app if specifically beneficial; not as fleet default. |
| Service Mesh (Istio, Linkerd) | Heavyweight for fleet size 3-10. Control plane adds ops cost. Revisit at ≥50 services where mTLS-everywhere economics flip. |
| Edge-BFF only (Cloudflare Workers / Vercel Edge) | Vendor lock conflicts with gyrum-go shared-library investment. Limited runtime. Useful as the BFF implementation for some apps; not the only option. |
| BaaS (Supabase, Firebase) | Vendor lock. RLS-only auth limits complex policies. Doesn't compose with custom Go/Postgres backends. |
| Cell-based (one stack per tenant) | Premature; revisit when a tenant's compliance requires it. |
| Event-driven (Kafka, NATS as primary) | Eventually consistent; debugging hard; bus is SPOF. Use as a feature (warp's webhooks already do this for some flows) not the architecture. |
| Zero-trust IAP (Google IAP, Cloudflare Zero Trust) | Heavyweight; vendor lock for IdP; latency cost. BFF + cloud-firewall provides 80% of the value at 10% of the ops cost. Revisit if compliance posture demands explicit IdP-per-request. |
The five survivors — direct-exposure / API gateway / framework-BFF / dedicated BFF / edge-BFF — share the same rule (one trust boundary per app, internal services trust the network) and differ only in where the boundary lives. The decision is: commit to the rule, then pick the implementation shape per-app.
Consequences
What becomes easier.
- New SaaS apps slot into a known trust model. Adding the N+1th app costs ~1 day of trust-boundary wiring (cloud-firewall + BFF route placement); without the rule, each app re-derives the model from scratch.
- Weak-auth and no-auth services are usable as-is. node_exporter doesn't need an auth retrofit; vendored tools run upstream-shipped; legacy systems containerise without auth bolt-ons.
- Audit story is one line: list public IPs, every one is a
tier: bffwith a justification field. "What's exposed to the internet?" answers in a single jq query. - Fleet composition. Each new internal service slots into existing trust placement; cost grows linearly per service. (Direct-exposure scales super-linearly because services start interacting and need service-to-service auth too.)
- Reviewer judgement converges. Persona reviewers (per ADR-115) cite this ADR on every new-service PR; the question "should this have a public IP?" has a documented answer instead of recurring debate.
What becomes harder.
- Single-service SaaS pays one extra conceptual layer (the framework's server-routes are now load-bearing as BFF, not just convenience). Mitigated: most modern frameworks (Next.js, SvelteKit, FastAPI, Rails) ship server-routes that ARE the BFF in-process — no separate service required.
- Multi-service SaaS pays a +20-50ms hop per request (browser → BFF → service). Acceptable for SaaS workloads; latency-critical apps (real-time gaming, HFT) fall outside the fleet's product space.
- Debugging internal services requires SSH-tunnel or
kubectl port-forwardrather thancurl http://service/healthz. Mitigated: most debug flows go through the BFF anyway (composition, auth context, observability) — direct internal-service access is rare in practice. - Adding a new internal service requires a firewall-config step (per-app for v1, generalized via Phase 3's check script). Mitigated: the warp#1940 firewall script shape generalises in Phase 3 to fleet-wide; the cost amortises.
What stays the same.
- Test infrastructure. Internal services tested at unit + integration + journey layers same as today.
- Deployment shape. Ansible + Hetzner; this rule is about firewall + tier classification, not deployment mechanics.
- Latency budget for the BFF tier itself. The BFF runs in the same datacenter as the internal services it composes; intra-DC latency is sub-ms.
Enforcement
This ADR is documentary by itself. The structural enforcement that makes the rule self-policing rides five sister tickets, each promoting the rule one tier up the documentary→structural axis (per ADR-083):
| Phase | Ticket | Promotion |
|---|---|---|
| 2 | warp#1980 | Schema validator refuses inventory edits adding public-IP'd servers without tier |
| 3 | warp#1977 | scripts/check-trust-boundaries.sh --check — drift detector across inventory ↔ hcloud reality |
| 4 | warp#1978 | gyrum-review-pr runs --check automatically; refuses merges that introduce drift |
| 5 | warp#1979 | Nightly cron runs --check against main; auto-files warp ticket on gap |
| 6 | warp#1981 | Doc-pairing gate: PRs adding new scripts/roles/ADRs/workflows refuse without matching docs |
Phases 4-5-6 each emit findings to gyrum-review-findings.json per the warp#1420 contract so gyrum-recover surfaces gaps with canonical rule body + fix-hint.
Persona reviewers (per ADR-115) fetch this ADR before reviewing any PR that:
- Touches
infrastructure/server-ids.json - Adds an
ansible/roles/<name>/for a new service - Adds a
.github/workflows/that opens a port or provisions a host - Adds new
+server.ts/app/api/*/ FastAPI / Rails routes that proxy internal services
On a PR adding a public-IP'd service, reviewers cite this ADR by number and quote the rule directly: "ADR-175: every SaaS app has exactly one trust boundary; this PR adds a second public exposure for app X."
Exceptions
Three classes:
tier: bastion— SSH-bastion or dev-jumphost servers that allow-list operator IPs for SSH access. Reviewed per addition; carriestier_justification+trusted_inbound.- Public-IP'd internal services running upstream-default ports while the firewall stands up — strictly time-bound transition windows (e.g. the warp#1910 Phase 1 apply runs after warp#1940 firewall script applies). The exception ends when the firewall lands; the schema validator (Phase 2) refuses the long-lived state.
- Tools authored explicitly for direct-internet exposure — public APIs, marketing pages, status sites. These are BFFs by definition (their purpose IS the trust boundary); declared
tier: bffwith the justification naming the audience.
Any case outside these three is a re-evaluation of this ADR, not an exception. New exception classes ride the ADR amendment process.
Open questions
- When does in-process BFF stop being enough? Heuristic for now: when ≥3 backend modules need to cross-call each other behind one route. Codify the heuristic per-app; revisit fleet-wide if multiple apps hit the same wall.
- Does Phase 6's doc-pairing gate apply to this ADR? Yes once warp#1981 lands. Until then, this ADR ships in the same PR as the schema (Phase 2) — they're paired by topic and convenience, not by structural gate.
- Cloudflare Workers as edge-BFF — first instance? Open. Filed as a future investigation if/when one product hits global-perf as a constraint.
- Should the rule extend to non-SaaS infrastructure (CI runners, databases, observability stacks)? Yes — those are already covered (
tier: internalfor runners + databases + Prometheus). The rule is fleet-wide, not SaaS-specific. The phrasing "every SaaS app" in the TL;DR is illustrative, not restrictive.
References
- Originating instance: warp#1910 (Prometheus pivot — first time the rule was needed in anger)
- First implementation template:
scripts/setup-prometheus-firewall.sh(warp#1940 / PR #716) — the per-instance shape Phase 3 generalises from - Cancelled superseded designs: warp#1909 (REST-vs-SSH alternative — Prometheus pivot supersedes both), warp#1907 (runner-monitor deploy infra — retired)
- Conversation that surfaced the rule: 2026-05-08 ultrathink session (operator + agent), captured in user-memory
feedback_trust_boundary_pattern_2026_05_08.md - Cross-cutting: ADR-083 (defense-in-depth-rule-promotion — the documentary→structural axis this ADR rides), ADR-113 (principles tier — why fleet-wide rules need this kind of codification), ADR-115 (principle-aware reviewers — the consumer side of this ADR)