N1 Platform — Architecture & Security Report
N1 Platform — Architecture & Security Report
Section titled “N1 Platform — Architecture & Security Report”What’s strong, what’s dangerous, and the path to the platform we’re building toward.
Current state · 2026-06-10 · every finding read in code, re-verified, and adversarially checked
Introduction
Section titled “Introduction”The way the platform is split into services is right — there’s no sprawl of pointless microservices, and no giant monolith screaming to be broken up. The danger isn’t the shape; it’s in the seams between services, a handful of security settings, and one layer we’d barely looked at before: the infrastructure itself. The pattern underneath almost every serious finding is the same — we built a door, then quietly assumed nobody would try the handle. This report opens each door, shows exactly how someone walks through it, gives the fix, and says what fixing it unlocks. Then it shows where this leads: the reusable platform that lets us sell white-label instances and launch new products without forking the core.
Key metrics
Section titled “Key metrics”- 3 critical incl. any pod in production can read every patient’s record
- ~30 findings each read in the code, cited to file & line
- adversarially checked what survived is what’s worth acting on
- 5 notable patterns documented in the codebase
Want to go deeper on anything here?
Section titled “Want to go deeper on anything here?”Every finding names the real component and the exact file. To learn a repo or a concept end-to-end — “how does auth work”, “walk me through forge-runner”, “why is billing built this way” — run /n1:guide in Claude Code. It teaches from the live code, one step at a time, never from memory.
How to trust this. Every finding below was read directly in the code, re-read a second time, and the load-bearing ones were put through an adversarial reviewer whose only job was to prove them wrong. That process killed two confident-looking findings outright (noted at the end) and softened the wording on several others — which is exactly why the ones that remain are worth acting on.
The flat cluster — why one small break-in becomes total
Section titled “The flat cluster — why one small break-in becomes total”The single most important idea in this report: right now, a foothold in any one service — your smallest, least-important pod — can be turned into “read every patient’s records.” That’s not how it should work. A break-in should stay where it lands. These four findings are what let it spread.
Critical: The “read everything” vault key is handed to every pod
Section titled “Critical: The “read everything” vault key is handed to every pod”What it is: Pods don’t carry AWS passwords. Each gets a temporary badge — an “IRSA role” — that says “I’m allowed to do X.” One badge, secrets_reader, unlocks the vault: every production secret (database passwords, API keys), read/write/delete on the S3 buckets holding patient records and KYC documents, and the AI inference APIs. The rule for who may wear that badge is written system:serviceaccount:*:* — the two stars mean “any service, in any namespace.” In practice: every pod in production can pick up the master vault key.
How someone actually walks through it: Say one service has a vulnerable dependency — one library with a known remote-code bug. An attacker gets a foothold in that one pod. Normally the vault key would be out of reach. But the two stars mean the pod just asks AWS for the role, and AWS says yes:
aws secretsmanager get-secret-value --secret-id production-cluster/db-password # every DB passwordaws s3 sync s3://n1-user-records ./loot # every patient's recordsA break-in in the smallest corner becomes a full patient-data breach. The blast radius of any single compromise is everything.
The fix: Change the two stars to the real name. The badge should trust exactly the one service account that needs it: system:serviceaccount:n1-prod:secrets-reader-sa. One line. After it, the compromised pod asks for the key and AWS says no — wrong service account. Same one-line fix for the second over-trusting badge, release_notes_publisher.
What it unlocks: This is the foundation of selling white-label instances. The promise we’d make a white-label customer is “a problem in one tenant can’t touch another.” Today we can’t make that promise honestly — everything can reach everything. Scoping these badges is the first brick of real tenant isolation, and it’s the kind of thing an enterprise customer’s security review checks on line one.
Where: n1-infrastructure/modules/irsa/main.tf:62-85 (the *:* trust) and :334-356 (second role). Scoped to this cluster’s identity provider — so “any namespace” means any namespace on the production cluster, not cross-account.
High: Hydra admin — the master key to every login — answers to anyone inside the cluster
Section titled “High: Hydra admin — the master key to every login — answers to anyone inside the cluster”What it is: Ory Hydra is the service that issues, refreshes, and revokes the login tokens every user carries. It has two doors: a public one where users log in, and an admin door on port 4445 — the master panel that can mint a token for any user, wave a login through as “already approved,” or kill anyone’s session. That admin door has no password, by design — on the assumption that only trusted services inside the cluster can reach it.
Why that assumption is false here: There is not one NetworkPolicy in the whole cluster. A Kubernetes cluster with no NetworkPolicies is one big open room: every pod can reach every other pod and every admin port. “Only trusted services can reach Hydra admin” isn’t enforced by anything — it’s just hoped.
How someone actually walks through it: Same foothold as above — one compromised pod. From there it’s a single command, no credential needed:
curl -X PUT http://hydra-admin:4445/admin/oauth2/auth/requests/login/accept?login_challenge=… \```json-d '{"subject":"any-user-you-want"}'They've just told Hydra "this login is approved, as that user." They now hold a valid session for any patient or clinician on the platform.
**The fix:** Turn the open room into rooms with doors. Add a **default-deny NetworkPolicy** (nothing talks to anything unless explicitly allowed), then one allow-rule: only `authentication-service` and `oathkeeper` may reach `hydra-admin:4445`. The compromised pod's `curl` now hits a wall — the packet never arrives. ~20 lines of YAML, no app changes, no downtime. The Hydra chart's own README already *says* this port "MUST stay internal-cluster-only" — this just makes the cluster enforce what the doc already promises.
**What it unlocks:** Network segmentation is the other half of the tenant-isolation promise (with the vault-key fix above). It also turns a dependency vulnerability from "the whole platform is at risk" into "one pod is at risk" — which is the difference between a routine patch and a breach-notification letter to every patient.
**Where:** zero `NetworkPolicy` across `n1-helm-charts`; admin URL at `charts/hydra/values.yaml:58`; the rule nothing enforces at `charts/hydra/README.md:62`.
### High: The cluster's control panel is reachable from the open internet
**What it is:** The "Kubernetes API" is the control panel for the whole cluster — create pods, read configs, run commands inside containers. AWS lets you say which IP addresses may even *reach* that panel. The setting is left at `0.0.0.0/0` — "anywhere on Earth" — in all three environments, and there's no override file anywhere that narrows it.
**How someone actually walks through it:** The panel still asks for credentials — so this isn't "anyone walks in." It's worse in a quieter way: it means a *stolen* credential works from anywhere. A leaked kubeconfig in a laptop backup, a token scraped from a CI log, a phished engineer — any of those normally still needs network access to the cluster. Here, the attacker just points `kubectl` at the public endpoint from their own machine. Combined with the open network above, it's the whole game.
**The fix:** Set the allowed range to the office / VPN egress IPs — or disable public access entirely, since the cluster's own nodes already reach the control plane over the private path (that's also already enabled). One variable, real values instead of the wide-open default.
**What it unlocks:** Takes the control plane off the public internet — one fewer precondition for every credential-theft scenario, and a box that enterprise and white-label customers' security questionnaires explicitly require ticked.
**Where:** `n1-infrastructure/environments/production/main.tf:72`, default at `variables.tf:52`, public access hardcoded on at `modules/eks-core/main.tf:15`. Caveat: the code proves the unsafe *default*; if a deploy pipeline injects a real IP at apply time the live state may differ — worth confirming, then making the safe value the default regardless.
---
## Patient data & secrets — where the sensitive stuff leaks
N1's whole job is handling protected health information under a legal duty of care. These are the places it ends up somewhere it shouldn't — a log file, a side database, an unencrypted wire.
### Critical: Every prompt — patient data included — is copied into a billing-analytics database
**What it is:** All AI calls go through one shared gateway (`litellm`). To track spend, it's told to *store the full text of every prompt* in its spend-logs database, and to forward everything to Langfuse, an analytics tool. The prompts are patient clinical data. So a copy of PHI lands in two places that were never designed to be protected health-data stores — with no masking turned on.
**Why it's serious:** Under HIPAA, every place PHI lives has to be controlled, encrypted, access-logged, and covered by a legal agreement with the vendor. A spend-logs table and an analytics dashboard are neither. This is the one finding that's a compliance problem the instant a regulator or auditor looks, independent of any attacker.
**The fix:** Two settings: `store_prompts_in_spend_logs: false` and turn on input/output masking. Billing only needs the token *counts*, never the words. (A guardrails block is also commented out due to an upstream litellm bug — re-enable it when that's fixed; it's secondary to stopping the storage first.)
**What it unlocks:** Closes the biggest standing compliance gap and lets us say, truthfully, that PHI lives only where it's supposed to — the baseline claim every healthcare customer and partner BAA depends on.
**Where:** `n1-helm-charts/environments/prod/litellm.yaml:378` (storage on), `:341` (Langfuse), `:388-404` (guardrails commented).
### High: Login tokens and session contents get written straight into the logs
**What it is:** The authentication service logs the full set of request headers on every request — and headers carry the user's session cookie and bearer token. On token-refresh it also dumps the raw session object (personal details) into the log, and on a failed webhook check it logs the first ten characters of the secret. Logs are widely readable and long-lived; anything in them is effectively shared.
**How it bites:** Anyone with log access — a broad group, plus anything that ships logs onward — can lift a live session token out of the log and replay it as that user. No password needed; the token *is* the identity.
**The fix:** Log an allow-list of safe headers (method, path, request-id), never the raw set; delete the debug session-dump; stop logging the secret prefix. No behaviour changes, no downtime.
**What it unlocks:** Makes logs safe to centralise and share with the team and with customers — which you need anyway for the observability work below.
**Where:** `authentication-service/src/main.py:91`; `src/routers/hooks.py:637-666` (session dump) and `:108` (secret prefix).
### High: The staging gatekeeper is configured to log the tokens it checks
**What it is:** Oathkeeper is the gatekeeper every request passes through to prove who it is. In staging it has `leak_sensitive_values: true` switched on — a debug flag whose literal job is to write the bearer tokens it inspects into its logs. Staging often carries real test tokens and test PHI, so this is the same token-leak as above, in a second place.
**The fix:** Set it to `false`. It's a debug switch that should never be on outside a local laptop.
**What it unlocks:** Lets staging be used with realistic data without quietly minting a credential leak — important once more of the team works against staging.
**Where:** `n1-helm-charts/environments/staging/oathkeeper.yaml:311`.
### High: Patient data travels between services unencrypted
**What it is:** Services pass work to each other through Valkey (the message bus / cache). Every connection uses the plain `redis://` address, not the encrypted `rediss://` — and transit encryption isn't enabled on the clusters. The data crossing those connections includes patient content. So PHI moves over the network in the clear.
**How it bites:** Anyone who can observe traffic inside the network — which, given the flat cluster above, is a low bar — reads patient data straight off the wire. Encryption in transit is also an explicit HIPAA expectation, so this is a compliance gap as well as an attacker one.
**The fix:** Switch the URLs to `rediss://` and enable transit encryption on both clusters. A connection-string change.
**What it unlocks:** Closes the "PHI in the clear" gap end-to-end and pairs with the network fix to make the internal network trustworthy.
**Where:** `environments/prod/api-backend.yaml:142`, `api-websocket-proxy.yaml:65`, `forge-sentinel.yaml:23`.
### Hygiene: Real-looking API keys are committed in template and script files
**What it is — and the honest context:** A LiteLLM/OpenAI proxy key sits in five `chr-dev-kit` environment templates, an N1 key and a dev key in others, and more in `misc-scripts`. These are known to be the founder's own / already-rotated test credentials, so **this is not a live breach** and shouldn't be treated as an incident. It's git-history hygiene: real values don't belong in committed templates, because the next one might not be dead.
**The fix:** Replace committed values with placeholders (`sk-REPLACE_ME`), confirm every committed key is truly rotated/dead, and scrub them from history. **One to actually check first:** a key of the form `n1-devkey-…` is an unfamiliar format — confirm it's dead before dismissing it.
**What it unlocks:** A clean rule ("never commit a real secret") that a scanner can enforce automatically, so this category stops recurring.
**Where:** `organisation/chr-dev-kit/env-templates/*` (5 files), `misc-scripts/…/config.py:20` and two `.env` files. (Several originally-cited line numbers were off — re-read before editing.)
---
## The login system — doors that open without the right key
Identity is the front door to everything. These are places where the lock can be talked past — by a header, a URL parameter, or just the wrong Google account.
### High: The admin login trusts you if the security config simply isn't there
**What it is:** The admin area is meant to be gated by Cloudflare Access (which proves you're a real, approved staff member). But the check has two escape hatches. If the Cloudflare settings are missing, it hands back a default `admin@n1.care` identity with no challenge at all. And if the environment is labelled `development`, it skips every check and simply trusts whatever email the caller types into a header.
**How someone walks through it:** Both are "fail open" — when unsure, the code lets you in rather than keeping you out. A misconfigured deploy, or a request that sets `Cf-Access-Authenticated-User-Email: anyone@n1.care`, becomes admin. The safe default for an auth check is the exact opposite: when unsure, deny.
**The fix:** Fail **closed** in every branch: no Cloudflare config → refuse to start (don't serve unprotected). Never trust a caller-supplied identity header outside a local machine. Assert the required config at boot, so a misconfigured deploy stops loudly instead of silently letting everyone in.
**What it unlocks:** "Fail closed, always" is the single rule that makes the admin surface trustworthy — and it's a rule a white-label customer's auditor will test directly.
**Where:** `authentication-service/src/routers/admin.py:50-51` (dev-trust) and `:57-58` (no-config default).
### High: Adding `?dev_mode=yes` to a URL reroutes the request, with no auth check
**What it is:** The lightweight proxy in front of the API looks for `?dev_mode=yes` in the URL and, if present, routes the request to the staging backend instead of production. There's no authentication in front of that decision — anyone who knows the parameter flips the switch.
**How it bites:** It's a routing control any outsider can toggle. The good news from the re-read: the staging target is a fixed, configured address — so it's not a server-side-request-forgery hole where an attacker picks the destination. The risk is unauthenticated access to the staging environment and its data via a public parameter.
**The fix:** Require authentication before honouring the switch, or drop the parameter entirely and select environment by deploy config, not by a query string anyone can type.
**What it unlocks:** Removes a "secret handshake" style control — the kind of thing that's invisible until someone finds it, and exactly what you don't want in a product you're handing to other companies.
**Where:** `api-proxy/main.go:77-81` (target fixed at `:17`).
### High: The skills marketplace lets *any* Google account sign in
**What it is:** The skills-marketplace backend signs people in with Google, but never checks *which* Google account. There's no Workspace-domain restriction — any Gmail on Earth authenticates, not just `@n1` staff. Two smaller issues ride along: the login token is handed back in the URL query string (where it leaks into browser history, server logs, and Referer headers), and there's no CSRF "state" value protecting the OAuth round-trip.
**How someone walks through it:** An outsider clicks "sign in with Google," uses their personal Gmail, and is now an authenticated user of an internal tool. No N1 account, no invite, no approval.
**The fix:** Restrict to the N1 Google Workspace domain (check the verified `hd`/email-domain after login). Deliver the token in a secure cookie, not the URL. Add and verify a random `state` parameter across the round-trip. All three are small, standard, well-trodden changes.
**What it unlocks:** A correct OAuth pattern here becomes the template for every other "sign in with Google" surface — including white-label tenants who'll each need their own domain restriction.
**Where:** `skills-marketplace/backend/app/auth.py:62-68` (no domain check), `:69` (token in URL), `:34-49` (no state).
### Medium: One webhook secret is compared in a way that can be guessed character-by-character
**What it is:** When Kratos (the login engine) calls back into our service, we check a shared secret. That check uses a plain `!=` comparison, which bails out at the first wrong character — so the time it takes to fail leaks how many leading characters were right. The sibling Hydra check right next door does this correctly with a constant-time compare, which is why the inconsistency stands out.
**The fix:** Use the same constant-time comparison (`hmac.compare_digest`) that the Hydra path already uses. One-line change; the correct pattern is already in the file to copy.
**What it unlocks:** Consistency — every secret check in the auth service done the one right way, so there's no "which comparison did this one use?" doubt.
**Where:** `authentication-service/src/routers/hooks.py:107` (vs the correct `:631`).
---
## The deploy gate — the checks that are supposed to guard production
How code reaches production is its own security boundary. These are the guards that look like they're on but aren't — and the dev clutter riding into the production image.
### High: The "someone must approve this deploy" step is commented out
**What it is:** Two services' deploy pipelines have an approval job defined — a human signs off before it ships to production. But the line that makes the deploy *wait* for that approval (`needs: [approval]`) is commented out. So the approval step exists, runs, and is ignored: the deploy proceeds whether or not anyone approves.
**The honest scope:** This isn't "anyone can ship anything" — merging to the branch still requires a signed, reviewed pull request via the org rules. The specific gap is the *second* gate, the deploy-time human approval, which is currently decorative.
**The fix:** Uncomment the one line. Re-wire `needs: [approval]` so the deploy actually waits.
**What it unlocks:** A real, auditable "who approved this production change" trail — table stakes for a regulated platform and for any enterprise customer.
**Where:** `api-proxy/.github/workflows/deploy_aws.yaml:28` and the same line in `api-websocket-proxy`.
### Medium: The approval bot lets anyone reject — and its owner-check never actually matches
**What it is:** The deployment-approval bot has two matching problems. The *reject* handler has no authorization check at all, so any member of the Slack channel can block a deploy (the approve path *is* checked — so it's lopsided). And the code that figures out who the required approvers are parses CODEOWNERS with a pattern that only matches a bare email address — it never matches a normal `@handle` or `@org/team` line, so owner-matching is silently dead.
**The fix:** Apply the same approver-allowlist check to the reject path as the approve path, and fix the CODEOWNERS pattern to match real `@owner` entries. Then the gate from the finding above actually has correct people behind it.
**What it unlocks:** The approval gate becomes trustworthy in both directions — only the right people can approve *or* block — instead of theatre.
**Where:** `github-action-deployment-approval/src/approval.py:187-205` (reject) and `:54` (CODEOWNERS regex).
### High: The production API image ships with all the dev and test tooling inside it
**What it is:** The main API's dependency list mixes runtime needs with developer tools — the test runner, docs generator, formatters, a headless browser. The build runs `uv sync --no-dev` to strip dev tools, but there's no "dev" group defined for it to strip, so the flag does nothing. The production container carries the whole workshop.
**Why it matters:** Every extra package in a production image is more attack surface to patch and more weight to ship. A headless browser in a backend API is a lot of code that has no business running next to patient data.
**The fix:** Move the tools into a `[dependency-groups] dev` section. Now `--no-dev` has something to exclude, and the production image carries only what production runs.
**What it unlocks:** A smaller, faster, more defensible image — and a cleaner story when a customer asks "what's actually running in your production containers?"
**Where:** `api-backend/pyproject.toml:17-37`; `Dockerfile:47`.
---
## The seams — where services lean on each other the wrong way
The service split is right; the trouble is in how they're wired together. These are the couplings that make one service's bad day everyone's bad day — and they're the exact knots we have to untie to sell any piece of the platform on its own.
### High: A patient can't upload a record if the billing service is having a bad day
**What it is:** When a record is uploaded, the API pauses mid-flow to ask the billing service "does this user have quota?" and *waits* for the answer before continuing. That makes the clinical hot path — getting a patient's data in — depend on billing being healthy. If billing is slow or down, uploads fail.
**Why it's backwards:** Billing is a money concern; ingestion is a care concern. Tying care to money means a billing wobble blocks clinical work. (The reverse call — billing telling the API about an event — is already fire-and-forget, which is the right shape; it's this direction that's dangerous.)
**The fix:** Don't block on billing. Check a cached quota value and reconcile asynchronously; let the upload through and settle the count just after. Care never waits on money.
**What it unlocks:** This is the keystone for selling billing as a standalone service. A white-label customer might bring their *own* billing — or none. If ingestion can't run without our billing service answering synchronously, billing isn't really separable. Cut this cord and billing becomes an optional, swappable part instead of a load-bearing wall.
**Where:** upload path `api-backend/routes/records.py:117,154` → `services/billing_balance.py:57` → `billing_client.py:273` (`/quota/status`, blocking).
### High: Billing holds a database transaction open while phoning another service over the network
**What it is:** Handling a Stripe webhook, the billing service opens a database transaction, then — still inside it — makes an HTTP call to the API to look up user details, then commits. A network call inside an open transaction means the database row stays locked for as long as the network takes. If the other service is slow, transactions pile up and the database's connections drain.
**The fix:** Do the network call *before* opening the transaction (or after committing). The transaction should wrap only fast, local database work — never a call that can hang. Two call sites need this.
**What it unlocks:** Billing stops being able to take itself (and its database) down under load — a prerequisite for running it as a service other products and tenants depend on.
**Where:** `billing-service/app/api/stripe_webhook.py:793` and `:1018` (transaction `:291`→`:443`).
### High: Billing connects to its database directly, bypassing the connection pooler everyone else uses
**What it is:** Databases can only handle so many simultaneous connections, so the platform puts a pooler (pgbouncer) in front to share a small set efficiently. Billing skips it and opens raw connections — a write pool of 20+40 and a read pool of 40+80. As billing scales up under load, that connection count climbs toward the database's hard ceiling, where new connections start getting refused.
**The fix:** Route billing's connections through `pgbouncer:6432` like the other services. Same database, far fewer real connections.
**What it unlocks:** Billing can scale horizontally without threatening its own database — necessary the moment it serves more than one product.
**Where:** `billing-service/app/database.py:36,53-67`; pool sizes in `n1-helm-charts/environments/prod/billing-service.yaml:67-68`.
### Medium: The API *is* instrumented — but its telemetry goes to a tool we don't watch
**What it is:** The original read said the API had no instrumentation. That was wrong, and the re-read corrected it: the API *does* set up OpenTelemetry and auto-instrument itself, with sampling enabled in production. The real gap is where it points — the config routes telemetry to Datadog via a collector, while the rest of the platform standardises on SigNoz. So SigNoz, the place the team actually looks, never sees the busiest, most PHI-adjacent service.
**The fix:** Point the API's telemetry export at SigNoz (or run both during a transition), and add an alert on dead-letter-queue depth so a backed-up pipeline pages someone. One source of truth for "how is the platform doing right now."
**What it unlocks:** Real visibility into the core path — which is how you find the *next* R1/R2 before a customer does, and what lets you promise uptime instead of hoping for it.
**Where:** `api-backend/metrics/otel_setup.py:163` (instrumented), export target at `environments/prod/api-backend.yaml:222` (Datadog collector).
### High: One class of pipeline error retries forever and never gives up
**What it is:** The parser pipeline sorts failures into buckets. "Retryable" errors have a cap — try a few times, then send to the dead-letter queue. But errors marked *"transient"* have no such cap: they return "retry" unconditionally. A message that keeps hitting a transient error loops indefinitely and never lands in the dead-letter queue, so it's never visible as a stuck item — it just churns.
**Note on the good news:** This is the one nuance against an otherwise excellent design (the dead-letter queues, below, are real and well-built). The fix is small precisely because everything around it is right.
**The fix:** Give transient errors the same delivery-count cap as retryable ones — after N attempts, escalate to the dead-letter queue. Now nothing loops silently forever.
**What it unlocks:** The pipeline's "nothing gets silently stuck" guarantee becomes complete — important when you're processing tens of thousands of documents and can't eyeball them.
**Where:** `phoenix-platform/n1r/phoenix/errors.py:146-153`, `worker.py:219`.
### High: The concurrency limiter can be overrun because it checks and sets in two steps
**What it is:** forge-sentinel limits how many jobs run at once by first *reading* the current count, then *adding* one — two separate steps. When several workers do this at the same instant, they all read the same "under the limit" count, all add, and all proceed. The limit gets blown past by however many raced.
**The fix:** Make it one atomic step — a single Redis increment-and-check (or a tiny Lua script) so only one worker can cross the threshold. This is the textbook fix for a check-then-act race.
**What it unlocks:** The limit actually holds under load — so capacity planning and cost controls mean what they say.
**Where:** `forge-sentinel/src/limits.py:35-49`.
### Medium: Two bookkeeping sets in Redis grow forever and are never trimmed
**What it is:** forge-sentinel records every processed and cancelled job in two Redis sets, with no expiry and no cleanup anywhere. They grow without bound for the life of the instance — a slow memory leak that ends in an out-of-memory surprise months from now.
**The fix:** Give the entries a TTL (they're only needed for a recent window), or remove them once a job is fully settled. Bounded memory, set and forget.
**What it unlocks:** One less "why did Valkey fall over?" incident waiting in the future.
**Where:** `forge-sentinel/src/reconciler.py:222`, `forge-sentinel/src/cancellation.py:190`.
### Medium: If the telemetry collector is down, the websocket proxy won't start at all
**What it is:** On startup the websocket proxy opens a *blocking* connection to the telemetry collector with no timeout. If the collector is unreachable, startup hangs indefinitely — so an optional observability dependency can stop a core service from booting. A monitoring tool should never be able to take down the thing it monitors.
**The fix:** Make the telemetry connection non-blocking (or give it a short timeout and continue without it). Telemetry being down should cost you telemetry, nothing more.
**What it unlocks:** Observability becomes safe to depend on — you can roll it out everywhere without it becoming a new single point of failure.
**Where:** `api-websocket-proxy/telemetry/telemetry.go:50,68-70`.
### Medium: A storage error is silently reported as "the bucket is empty"
**What it is:** When listing files in S3, the document manager catches *every* storage error and returns an empty list, with a comment guessing "bucket may be empty." But access-denied and throttling errors are caught the same way — so a permissions problem or a rate-limit looks identical to "there's nothing here." The system can quietly act as if a patient's documents don't exist when in fact it just couldn't read them.
**The fix:** Only treat the genuine "no-such-key/empty" case as empty. Let real errors (access denied, throttling) raise, so they're seen and retried rather than swallowed.
**What it unlocks:** Removes a class of silent data-loss bug — the worst kind, because nothing alarms.
**Where:** `n1-document-manager/n1r/document_manager/providers/s3.py:166-168`.
---
## Correctness — things that don't do what they say
Not security, but the kind of quiet breakage that erodes trust: a config that's never read, a library that can't run as shipped, a safety tool that fails without telling anyone.
### Critical: The cloud-storage credential the docs tell you to set is never actually read
**What it is:** The document manager's docs, examples, and its own docstring all tell you to set `GCP_SERVICE_JSON` for Google Cloud Storage. But the code reads a differently-named setting (`gcs_credentials_json`) with no alias — so the variable everyone is told to set is silently ignored. On the Google-storage path, credentials end up absent, and uploads fail (or worse, fail quietly) in production. For a service whose job is storing patient documents, that's a data-loss risk.
**The fix:** Read the variable the docs promise (add it as the accepted name), and **fail loudly at startup** if the storage backend is GCS and no credential is present — so it can never silently run without one.
**What it unlocks:** Confidence that "it started, so it's configured" — the document store either works or refuses to start, never the dangerous middle.
**Where:** `n1-document-manager/n1r/document_manager/config.py:19` (docs) vs `:39` (the field actually read).
### High: The medical-unit converter is broken three ways and likely doesn't run as shipped
**What it is:** Unit conversion turns one lab unit into another — clinical-safety-adjacent, because a wrong conversion is a wrong number in front of a clinician. As shipped it has three breaks: the command-line entry point calls the converter without a required argument (instant crash); a helper uses an import style that fails once the package is installed normally; and it imports scikit-learn, which isn't declared as a dependency (so a clean install is missing it). Each alone breaks it at the boundary a consumer would call.
**The fix:** Pass the required argument at the entry point; use a proper package-relative import; declare scikit-learn in the dependency list. Then add one test that actually runs a conversion end-to-end — because clinical-adjacent code earns a test that proves it works.
**What it unlocks:** A unit converter you can actually trust in the clinical pipeline — and a flagged candidate for the dedicated clinical-safety review.
**Where:** `medical-unit-conversion/medical_unit_conversion/cli.py:123`, `download_model.py:2`, `unit_convertor.py:11`.
### Medium: The security scanner swallows its own crashes and reports nothing
**What it is:** Our own repo-security scanner wraps each rule in a catch-all that, on a crash, simply moves on — no log, no metric. A rule that breaks just silently stops checking. For a security tool, that's fail-open with the lights off: it can look green while quietly not looking at all.
**The fix:** On a rule crash, record it (log + a counter) and surface it. A security check that can't run should be loud, not invisible — the same "fail-secure, fail-loud" rule we'd apply anywhere else.
**What it unlocks:** The scanner's "clean" result starts meaning "checked and clean," not "maybe didn't check."
**Where:** `n1-security/repo-security/scanner/engine.py:87-90`.
### Medium: A handful of smaller, well-understood gaps
**Supply-chain pins (inference-servers):** One image builds from `:latest` and another `git clone`s an external repo with no pinned version — so two builds of the "same" thing can differ, and an upstream change lands in our image unreviewed. **Fix:** pin to a digest and a commit. `embeddinggemma/Dockerfile:1`, `mineru-vllm/Dockerfile:7`.
**Import-time crash (parser-gate):** The library raises an error at *import* time if LibreOffice/libheif aren't installed — so merely importing it crashes on a host without those binaries, instead of failing only when that feature is used. **Fix:** move the check to where the feature is actually called. `parser-gate/n1r/parser_gate/platform.py:146,213`.
**Unbounded LLM input (release-scribe):** It sends the full content of every changed file to the model with no size cap — a cost and reliability risk on a big diff (low data risk; it's code). **Fix:** cap the size. `release-scribe/src/llm_client.py:31-37`. (A classic late-binding loop bug also sits in `misc-scripts`, but that's throwaway tooling — low priority.)
**What it unlocks:** Reproducible builds and predictable behaviour — the unglamorous hygiene that keeps the bigger system honest.
### Delete: Dead code — delete it, don't fix it
**What it is:** Some of what an audit flags isn't a risk to fix — it's code for infrastructure that doesn't exist, and the right action is deletion. The clearest case: **the GCP Cloud Run + Cloud SQL Terraform in `n1-infrastructure` (`gcp/cloud-run-service/`, `gcp/cloud-sql/`) is obsolete and was never deployed.** N1's only live GCP footprint is **Vertex AI** (the model backend) and the **Google sign-in OAuth clients** — nothing else. That dead Terraform still carries unsafe defaults (a public database IP, plaintext service-account keys); it read like a security finding, but since it deploys nothing it isn't one.
**Why call it out separately:** Treating dead code as a vulnerability wastes effort hardening something that should simply be removed — and leaving it in place is a trap: a future engineer could deploy it and ship the unsafe defaults for real. Delete it and both problems vanish at once.
**The fix:** Remove the `gcp/cloud-run-service/` and `gcp/cloud-sql/` Terraform. For the rest of the codebase, a dedicated dead-code sweep (`/n1:dead-code`) is the right tool — it proves un-reachability before deleting, rather than guessing.
**What it unlocks:** A smaller, truer infrastructure-as-code that matches reality — so the next person reading it to rebuild N1 isn't misled into standing up infra we don't run.
**Where:** `n1-infrastructure/gcp/cloud-run-service/`, `n1-infrastructure/gcp/cloud-sql/` (obsolete, per owner).
---
## Notable patterns in the codebase
### Spot-instance checkpointing
The parser saves its place per page. When a spot machine is preempted mid-job, another picks up from the last saved page rather than restarting the whole document.
### Dead-letter queue on streams
Work that can't succeed (e.g. a missing file → non-retryable) is moved to a dead-letter queue rather than looping or being silently dropped. (The gap with transient errors is finding N5.)
### Valkey streams as the service backbone
Services hand work to each other through named streams. A new consumer can attach without changing the producer — the producer has no knowledge of its consumers.
### Markdown-defined agents (forge-runner)
Report-generating agents are declared in `AGENTS.md` files. Adding, removing, or reshuffling one does not require redeploying the others.
### Ory (Kratos / Hydra / Oathkeeper) as the identity foundation
Kratos handles self-service identity flows, Hydra is the OAuth2/OIDC server, Oathkeeper is the decision proxy. The findings above are about how these are wired and exposed — the choice of primitives is sound for multi-tenant, white-label authentication.
---
## Where this is going — the platform underneath the product
The decomposition is already right. The work in this report — cutting the couplings, closing the gaps — isn't cleanup for its own sake. It's what turns today's single product into a set of reusable parts other products (and other companies) can stand on. The north star: **"AWS for health-tech — you could do health-tech without us, but it'd be a lot harder than using us."**
### Products sit on top — built, not forked
- White-label clinic instance- Clinical-trials management- The N1 clinician app
Each reaches a primitive only through its public door.
### Reusable platform primitives — one clean job each
- **Identity** — who is this, what may they do- **Billing & metering** — plans, quota, usage- **Document ingestion** — files → structured data- **Clinical data store** — the canonical record- **Agent runtime** — markdown-defined AI workflows- **Clinical chat** — clinicians chat with patient data; patients understand their own health- **Patient login / platform** — patient identity & access
The black-and-white rule: every primitive offers **one clean charter** and is reached only through its **public door** — never through another's basement.
### What that buys us, in plausible terms:
**A clinic chain wants their own branded instance**
Because Identity and Billing are standalone and multi-tenant — and because the vault-key (N1) and network (S4) fixes mean one tenant's trouble can't reach another — we hand them an isolated tenant in days, not a forked codebase to maintain forever. Their security team's questionnaire ("is the control plane private? are tenants isolated? where does PHI live?") gets a clean yes, because we already fixed exactly those.
**We launch clinical-trials management**
A trial platform needs logins, document intake, a place to keep structured data, and AI workflows over it — all of which already exist as primitives. The new product is the *trial-specific* logic on top; it reuses ingestion, the data store, and the agent runtime rather than rebuilding them. That's only possible if ingestion doesn't secretly depend on our billing (R1) — which is why that cord has to be cut.
**The patient digital twin grows up**
As the agent runtime's memory deepens into a longitudinal model of a patient, the rule that keeps it safe is simple: the **structured clinical record stays the single source of truth**, and the AI-built memory is a *derived view* that always points back to the record it came from — never a second, competing source for a clinical value. Keep that line and the twin can get as ambitious as you like without ever putting a made-up number where a real one belongs.
Extract these surgically, as each next product actually needs them — not speculatively. The point isn't to platformise everything tomorrow; it's that every fix in this report moves one primitive closer to standing on its own.
---
## The build sheet — what to do, in order
Sequenced by "stop the bleeding first, then harden, then decouple." Each row ties back to a finding above.
| Step | Do this | Why first ||---|---|---|| 1 · now | Scope the IRSA vault key to one service account (N1); add a default-deny NetworkPolicy + lock Hydra admin (S4). | These two together end "one break-in = all patient data." Nothing else matters as much. || 2 · now | Stop storing PHI prompts + turn on masking (S1); scrub token/secret logging (S3, N11). | No downtime, and closes the standing compliance gaps an auditor sees immediately. || 3 | Fail-closed admin auth (S6); close the `dev_mode` bypass (S7); restrict marketplace login to the N1 domain (N10); re-wire the deploy approval (S9, N12); constant-time compare (S8). | Shut the doors that open without the right key. || 4 | Encrypt Valkey in transit (S2); private EKS endpoint (N2); delete the obsolete GCP Terraform (dead code). | Harden the network/infra layer; remove dead infra-as-code before it can ever be deployed. || 5 | Decouple ingestion from billing (R1); HTTP outside the DB transaction (R2); route billing through pgbouncer (R3); cap transient retries (N5); atomic limiter + bounded sets (N6, N7); non-blocking telemetry (N8). | Untie the seams — and make billing genuinely separable, the first step toward selling it. || 6 | Point the API's telemetry at SigNoz + DLQ-depth alerts (R4); fix the dead GCS config (C2), the unit converter (C1), the silent storage error (N9), and the scanner fail-open (N13); pin builds (N15). | Visibility + correctness: find the next problem before a customer does. || later | Extract Identity and Billing as standalone multi-tenant services; give the API real internal module boundaries; drop dev deps from the prod image (S10). | The structural work that turns the product into the platform — surgically, per product need. |---
## How this was produced
Every finding was read in the actual code, re-read a second time, and the load-bearing ones run past an adversarial reviewer whose only job was to break them — so what survived is what's worth acting on. This pass covered the **high & critical** findings; a deeper sweep of the medium and low leads is the next round, spread across the team so it doesn't lean on one machine.
Want to understand any piece of this more deeply — a repo, a flow, why something is built the way it is? Run `/n1:guide` in Claude Code. It teaches from the live code, one step at a time.