# Janua Full LLM Context Generated: 2026-05-13 · contracts section updated 2026-09-07 · email events section 2026-09-27 ## Purpose This file gives LLM agents a stable, tool-agnostic map for working in this repository without depending on vendor-specific instruction files. ## Canonical instruction order 1. `AGENTS.md` 2. `README.md` if present 3. `ECOSYSTEM.md` if present 4. Relevant files under `docs/`, `infra/`, `.github/workflows/`, and source directories for the specific task ## Agent compatibility `AGENTS.md` is the source of truth. `CLAUDE.md` exists only to redirect Claude and other legacy agent integrations to `AGENTS.md`. ## Enclii-first operations contract Use Enclii web, API, or CLI for routine production provisioning, deployment, observability, domain management, secrets, provider operations, scaling, rollback, and remediation. Raw `kubectl`, `helm`, SSH, provider CLIs/APIs, `docker exec`, and direct container access are allowed only for platform bootstrap or documented break-glass emergencies when Enclii is unavailable or lacks an implemented adapter. Any break-glass use must record the actor, reason, target service/environment, commands executed, result, and follow-up Enclii adapter gap or incident link. ## Durable contracts an agent must not break These are load-bearing behaviours with production evidence behind them. Changing one is a decision, not a refactor. Each names the file that owns it. ### Sending identity — `app/services/sender_binding.py`, `email_sender.py` A From header pairing a client's display NAME with an address on MADFAM's domain (`Client `) must never be produced. Display name and address move together on one fact: is the binding's address domain verified for the ACCOUNT that will send it. There is no intermediate state. This reversed an earlier rule after the forbidden header reached a real client inbox on 2026-09-07; several tests exist only to assert it can never recur, so a test naming that string is a guard, not a stale reference. Verification is per ACCOUNT, not per domain. A tenant on its own provider account reads its own `verified_domains`; the global `RESEND_VERIFIED_DOMAINS` describes MADFAM's account only. The PLATFORM sender depends on the message class (ruling R101, 2026-10-04): system and security mail (sign-in links, codes, resets, invitations, alerts) comes from `MADFAM ` with a Reply-To on `support@madfam.io` (`security@madfam.io` for security mail); welcome and every unclassified message keep `MADFAM `. A tenant's branded sender is unchanged for every class. Classes live in `email_sender.TEMPLATE_MESSAGE_CLASS`; an empty `EMAIL_SYSTEM_FROM_ADDRESS` is the rollback. A missing tenant credential degrades the sender to the platform From line and logs `sender_credentials.tenant_credential_missing`. It never blocks the send: a degraded From is a degraded outcome, a magic link that never arrives is an outage. `credential_ref` names an ENV VAR for a tenant-account binding, not a Vault path, because janua-api runs with no `VAULT_ADDR`/`VAULT_TOKEN` — a `path#field` reference cannot resolve at runtime in production. Vault reaches the pod through an enclii-managed ExternalSecret instead. ### Magic links — `app/auth/hosted_hop.py`, `routers/v1/auth.py` `GET` on the magic-link callback spends nothing; only `POST` exchanges. Mail scanners GET every URL in an email, and this estate has already lost a live ceremony to a scanner-consumed link. When the destination host cannot receive the estate cookie (it is outside `COOKIE_DOMAIN`), the link lands on janua for one hop and then forwards `?token=` to the registered `redirect_url`. The rule is DERIVED from the cookie domain, never configured, so it cannot drift from the relay guard in `@madfam/janua-next`. Products land the forward with 0.3.0 or later. ### Estate session — `app/auth/sso_cookie.py` `janua_sso` is HttpOnly, `Domain=.madfam.io`, and outranks `janua_access_token` at `/authorize`. It is the only session that reaches the browser across the estate, because products exchange magic links server-to-server and Node drops the `Set-Cookie` on a fetch response. ### Revocation and readiness — `services/auth_service.py`, `services/token_revocation.py`, `routers/v1/health.py` Revocation-list and refresh-reuse reads are strict: Redis unavailable answers `503` + `Retry-After`, never "not revoked" (owner ruling 2026-10-04). Every revocation path calls `AuthService.revoke_sessions`; `/auth/refresh` also checks the `sessions` row, so a revocation holds when its Redis write is lost. `POST /oauth/revoke` requires client authentication. Readiness reports Redis and the database and gates on neither (each check bounded at 2 s); health bodies never carry error text. Hub: `docs/runbooks/oauth-shared-state-redis.md`. ## Email API surface — `apps/api/app/routers/v1/email.py` Two internal send endpoints under `/api/v1/internal/email/`: - `POST /send` — raw email: caller supplies `html`/`text`, subject, recipients. - `POST /send-template` — renders a registry template with `variables`. Also `GET /templates` (lists the registry live) and `GET /health` (reports `templates_available` as `len(EMAIL_TEMPLATES)`, a live count). The template registry is two paired dicts in the same file: `EMAIL_TEMPLATES` (id → description, `required`/`optional` variables, `subject`, optional `default_from_email`) and `TEMPLATE_FILENAMES` (id → whitelisted HTML filename under `templates/emails/`). An id must be in both. A template with no HTML file falls back to `generate_fallback_html()` rather than erroring. **Two render paths — do not conflate them.** The `send-template` route renders with **naive `{{key}}` string substitution** (`render_template()`): no Jinja conditionals, loops, or auto-escaping. Anything conditional is composed in Python first (e.g. the tenant payment-confirmation template derives `{{sesiones_detalle}}` from an optional count in `_derive_template_variables()`). The **Jinja2** path (`FileSystemLoader`, autoescape) lives in `services/email_i18n.py` and `services/email/resend_service.py` and renders the localized auth/magic-link mail via `email_service.py` — a different template set, not this route. **Sender resolution** is not the caller's to dictate: the From line is chosen by `services/email_sender.py` under `EMAIL_SENDER_POLICY.md` (THE RULE above). A caller's explicit `from_email` is honoured only from a verified domain; a template may declare a `default_from_email` (e.g. `billing/cfdi` → `facturacion@madfam.io`); otherwise the sender resolves from `org_id` or the `redirect_url` host (e.g. a tenant's template → that tenant's own sender). `attachments` on either endpoint are forwarded to Resend (used for CFDI XML+PDF delivery). `bcc` on `/send-template` rides on the FIRST `to` recipient's message only (each recipient gets its own rendering, so a BCC holder gets one copy of one real message); `/send` puts its `cc`/`bcc` on every message. Full walkthrough and the template table: `docs/MADFAM_EMAIL_INTEGRATION.md`. ## Email events and first-party measurement Operator guide: `docs/runbooks/resend-email-events.md`. Secret handling: `docs/runbooks/secrets/resend-webhook-secret-rotation.md`. ### Webhook receiver — `routers/v1/email_webhooks.py`, `services/email_events.py` `POST /api/v1/email/webhooks/resend/{cuenta}`, one `cuenta` per Resend ACCOUNT (`platform`, plus one slug per tenant account in `WEBHOOK_SECRET_SETTINGS`), because each account's endpoint has its own signing secret. The Svix-style signature is the only authentication, checked against `RESEND_WEBHOOK_SECRET_` before anything is written. Unset or blank secret: 404, indistinguishable from an unknown account. Bad, stale or missing signature headers: 401 `{"detail":"Invalid webhook signature"}`. Stored: 200 `{"status":"stored"}`. Rows are minimized: no recipient, sender, subject, body, IP, user agent, or link query string. The secret reaches the pod from Vault `secret/janua` through the enclii-managed `janua-secrets` ExternalSecret (`resend_webhook_secret_` → `resend-webhook-secret-` → `RESEND_WEBHOOK_SECRET_`), read at process start. ### First-party measurement — `services/email_engagement.py`, `routers/v1/email_engagement.py` Token-bearing mail is never measured. Only `POST /internal/email/send` with `track_engagement: true`, no token link (explicit `contains_token_link` or the credential-parameter detector), an HTML part, and a sender binding with a tracking host on the From domain actually used. Otherwise the message goes out unmodified. Resend's own tracking stays OFF and `EMAIL_TRACKED_SENDER_DOMAINS` stays empty, so branded sign-in mail keeps its HTML. The setting a binding names in `tracking_host_setting` (an https origin) is the per-tenant on-switch. Pixel `/e/o/{token}.gif` is constant; click `/e/c/{token}/{i}` redirects only to the target stored for that token and index, else to the tenant's site. Only the token's SHA-256 is stored. ### Tracking host scope — `middleware/tracking_host_scope.py` Tracking hosts join `TrustedHostMiddleware`'s global list at startup, so `TrackingHostScopeMiddleware` (added after it, so it runs first) keeps them to `/e/o/*` and `/e/c/*`: 404 for any other path, websocket close 1008. Removing it would serve sign-in, reset and OIDC discovery under a tenant's marketing domain. ### Production migrations — `apps/api/alembic/PROD_ALEMBIC_STATE.json` Deploys never migrate. Revisions are applied by hand (`docs/ops/sql/*.sql`) before promoting code that needs them, and the ledger records the revision a human last read out of production. `promote-to-prod.yml` refuses a promote whose alembic head is ahead of the ledger unless `migrations_acknowledged=true`. Update the ledger only after reading the database (`docs/runbooks/ALEMBIC_CONVERGENCE.md`). ### Token verification — `app/core/jwt_manager.py`, `routers/v1/oauth_provider.py` Issuer = `https://` when set, else `API_BASE_URL`; read it from `/.well-known/openid-configuration`. JWKS (`/.well-known/jwks.json`) publishes exactly one RS256 key whose `kid` is `JWT_KID`; rotating the key or the `kid` is a hard cut (no overlap). `aud`: ID token = `client_id`; access and service tokens = the OAuth client's registered `audience`, else `JWT_AUDIENCE`. Reference: `docs/reference/ISSUER_AND_JWKS.md`. ### Data-subject request exports — `services/compliance_service.py` `DataSubjectRightsService.get_request_for_processor` lets only the request's subject or a platform admin (`users.is_admin`) process a request; the export route returns the unknown-id 404 to anyone else and leaves the request unchanged. Organization admins get no extra access. Read-only counts: `apps/api/scripts/audit_data_subject_request_access.py` (exit 2 = a request was last processed by someone else). Runbook: `docs/runbooks/data-subject-request-access-audit.md`. ## Pending work One list, with priorities and owner-decision vs engineering: AGENTS.md → «Known gaps / Backlog». Don't copy it here. ## Repo entrypoints detected - `README.md` - `ECOSYSTEM.md` - `docs/` - `infra/` - `.github/workflows/` ## Maintenance These files are maintained by `internal-devops/scripts/sync-agent-docs.py`. If repo-specific long-form agent guidance is needed, put it in `AGENTS.md`, not `CLAUDE.md`.