Request lifecycle
This is the single most useful page on the site if you're trying to understand how Constellation actually works at runtime. It walks an HTTP request from the browser through every layer down to Postgres and back, names the rule each layer enforces, and points at the runtime event (or observability hook) that fires at each stage so you can find a request in the logs / outbox / audit log.
The worked example is POST /api/organisations in the Directory app — a real route that exercises every layer (auth, tenant validation, RBAC, transactional audit, and outbox event publishing). Catalog and Project Tracker work the same way; the only differences are which tools and services they own.
Sequence diagram
Mermaid's autonumber numbers each arrow (1 through 27); the narrative below groups arrows into 13 stages with stage headings like "4–6", "13–18", "25–27". Audit row, outbox event, and the mutation all share one transaction — if any one step throws, all three roll back together. That's the rule audit-critical operationalised.
Stage-by-stage narrative
1. Browser sends POST /api/organisations
Standard JSON request. The body is whatever the UI sent; assume it's a malicious agent until validated. The request carries:
- Browser path: a Supabase session cookie set by
createConstellationAuthMiddleware. The middleware validates the cookie and forwards canonical claims asx-constellation-auth-*headers to the route. API / MCP path:Authorization: Bearer <jwt>(or a service-account API key) — the same downstream code path applies. x-tenant-id: <uuid>(optional) — set from thex-active-orgcookie (the UI's org switcher). The value may carry anorganisation_idor atenant_id;resolveActiveMembershipaccepts either and resolves the{ tenantId, organisationId }pair from the membership row. If absent, falls back to thetenant_idclaim baked into the JWT.x-request-id: <uuid>(optional) — correlation ID. Generated server-side if absent.
Rule. None yet at this stage — the request is untrusted.
2–3. withAuth extracts the canonical claims
Implemented in @constellation-platform/auth-next. On the browser path, JWT signature validation already happened in createConstellationAuthMiddleware (Supabase session cookie → forwarded x-constellation-auth-* headers); withAuth parses those forwarded headers into a typed AuthContext. On the API / MCP path, withAuth performs the JWT verification itself against the configured provider (Supabase / Keycloak / Mock per the AUTH_PROVIDER env var — see provider-abstraction).
The resulting AuthContext exposes { user: PlatformJWT, locale? } — user.sub, user.tenant_id (raw claim), user.org_id, user.roles come from the JWT. The correlationId is not on AuthContext; it's extracted from x-request-id (or generated) by getCorrelationId() at the route boundary and threaded through to the tool layer alongside the auth context.
If the JWT is missing, expired, or signed by an unrecognised key, withAuth short-circuits to 401 Unauthorized and the rest of the chain doesn't run.
Rule. provider-abstraction — JWT verification is provider-pluggable.
4–6. withTenantAuth validates membership and rewrites the active tenant
This is the layer most newcomers miss. The tenant_id claim in the JWT is not trusted on its own. withTenantAuth resolves the active tenant per this precedence:
x-tenant-idheader (set from thex-active-orgcookie — the UI's org switcher). The header value may carry either anorganisation_idor atenant_id;resolveActiveMembershipaccepts both forms and resolves them via the same membership lookup.jwt.tenant_idfrom the validated session.- Otherwise →
403 FORBIDDEN.
It then runs a single-row lookup against identity.user_tenant_memberships (no joins — just WHERE user_id = $1 AND status = 'ACTIVE' AND (tenant_id = $2 OR organisation_id = $2)) — still outside any tenant-scoped transaction — to verify the user has an active membership for that tenant. If not, 403. On success, both ctx.user.tenant_id and ctx.user.org_id are rewritten from the validated row so downstream code sees a consistent pair. (The list-form resolveUserMemberships joins identity.users for home-tenant-first ordering — that's a different code path used by the bootstrap /api/auth/memberships endpoint, not by withTenantAuth.)
The route handler (via the per-app authedRoute() helper from apps/<module>/src/server/tenant-auth.ts) composes withAuth and withTenantAuth together — see route-wrapping.
Rules. tenant-context, route-wrapping.
7. requirePermission(user, 'create', 'organisation')
In-handler RBAC gate. Looks up the user's effective permissions (cached) and confirms organisation.create is granted in the active tenant scope. Throws an AppError with HTTP 403 if not — withErrorHandler (or the route's local try/catch) converts it to a JSON envelope.
Rule. Permissions live in identity.permissions and are evaluated via matchPermission() from @constellation-platform/auth-core.
8. Zod parses the request body
CreateOrganisationSchema.safeParse(body). If the parse fails, the route returns a ValidationError with the per-field issue list. The body is unknown until it parses — see no-any.
9. Route handler calls into the tool layer
const organisation = await createOrganisation(
{ prisma, user, correlationId: getCorrelationId(request) },
parsed.data,
);
This is a deliberate boundary. Routes never touch repositories or services directly — they go through tools. The tool layer is where the transaction begins and where audit / events are emitted.
10. Tool re-checks access (defence in depth)
assertCanAccess(ctx, 'create', 'organisation') mirrors the route's requirePermission check. It looks redundant — and is, on the happy path. The point is that services and tools are also called from workflows and event handlers, where there's no HTTP layer and therefore no requirePermission call. Keeping the access assertion at the tool boundary makes the rule "every mutation is permission-checked" robust to call-site changes.
11–12. withTenantContext opens the transaction
Here's where the database actually sees the tenant. withTenantContext() from @constellation-platform/db opens a Prisma transaction, executes SELECT set_config('app.tenant_id', $1, true) (transaction-local, equivalent to SET LOCAL), and runs the callback inside it. From this point on, every query against tenant-scoped tables is filtered by RLS.
If the tool tries to set app.tenant_id to anything other than what withTenantAuth validated, that's a bug — withTenantContext calls assertTenantUuid() to reject the nil-UUID sentinel and any non-UUID value, and there's a separate audit trail for tenant-scope changes. But the normal path uses ctx.user.tenant_id directly.
Rule. tenant-context.
13–18. Service → Repository → Postgres
The service (OrganisationService.create) is where business logic lives. It validates domain invariants (e.g. hypothetically "a sub-tier-supplier organisation must have a parent" — the actual rule today is just "if a parent is supplied, it must exist"), then calls the repository to issue the SQL. The repository is the only layer that touches Prisma / raw SQL.
When the INSERT lands, Postgres evaluates the RLS policies. The actual identity.organisations policies are split per-operation (organisations_select / organisations_insert / organisations_update); INSERT uses WITH CHECK, SELECT/UPDATE use USING (and UPDATE re-checks with WITH CHECK). The simplified shape of the predicate they all share:
-- Simplified — see apps/directory/prisma/migrations/001_directory_schema.sql
-- for the per-operation organisations_select / _insert / _update policies.
tenant_id::text = current_setting('app.tenant_id', true)
Because app.tenant_id was set in stage 11–12, the row passes the policy and lands. If a code path ever forgets to wrap in withTenantContext, RLS rejects with permission denied for table organisations — an ugly error, but it's the database catching the bug.
Rule. module-isolation — repositories only touch their own module's schema. Cross-module reads go through the API or events, not cross-schema joins.
19–20. Audit write — same transaction
auditAction(tx, {...}) writes a row into audit.audit_entries. Note that it takes the same tx the service used — they're in the same transaction, so either both land or both roll back.
For security-critical mutations (permission changes, role assignments, classification changes), use auditCritical() from @constellation-platform/audit instead — it does the audit row plus an outbox audit.entry.created event in the same transaction.
Rule. audit-critical.
21–22. Outbox publish — same transaction
await publish(tx, {
eventType: 'directory.organisation.created',
payload: { organisationId: id, name: input.name, type: input.type },
meta: { tenantId, actorId, correlationId },
});
Same tx. The event lands in events.outbox atomically with the mutation and the audit row. A polling dispatcher (cron at /api/cron/dispatch-events or a per-app worker) reads undispatched rows, delivers them to subscribers, and marks them as dispatched. Subscribers must be idempotent — the dispatcher may re-deliver an event if it crashes mid-acknowledge.
Rules. event-naming, events-append-only.
23–24. Commit
The transaction commits. Three rows are now durable: the new organisation, the audit entry, the outbox event. If anything between stage 12 and here had thrown, all three would have rolled back together.
25–27. Bubble back to the browser
return Response.json({ data: organisation }, { status: 201 });
After COMMIT, control unwinds back through withTenantContext → tool → route handler (arrows 25–27 in the diagram). The route shapes the response as a { data, ... } envelope on success. Failures go through toErrorResponse(error) from @constellation-platform/errors, which serialises AppError instances into a { error: { code, message, details? } } envelope — code is the machine-readable error code (FORBIDDEN, VALIDATION_ERROR, NOT_FOUND, …), message is a short human-readable string, details (optional) carries field-level Zod issues for ValidationError. Standard across the platform.
Failure modes
| What goes wrong | Where it's caught | What the user sees |
|---|---|---|
| JWT missing, expired, or invalid signature | withAuth (stage 2) | 401 Unauthorized — generic message |
| User has no active membership for the tenant | withTenantAuth (stage 4) | 403 Forbidden — generic ("no active membership") |
| User lacks the requested permission | requirePermission (stage 7) or assertCanAccess (stage 10) | 403 Forbidden — { error: { code: 'FORBIDDEN', message: '…' } } (thrown by ForbiddenError) |
| Request body fails Zod validation | CreateOrganisationSchema.safeParse (stage 8) | 400 Bad Request — { error: { code: 'VALIDATION_ERROR', message: '…', details: { … } } } |
| Service throws a domain rule violation | The service call inside the transaction (stage 13) | 400 or 409 per the AppError subclass; transaction rolls back |
| RLS policy rejects the INSERT | Postgres (stage 16) — usually means a bug where app.tenant_id wasn't set correctly | 500 — surfaces as permission denied for table in logs |
| Audit write fails | auditAction inside the transaction (stage 19) | 500 — transaction rolls back, no outbox event published |
| Outbox publish fails | publish inside the transaction (stage 21) | 500 — transaction rolls back, no audit row, no organisation row |
Wrap missing — route uses withAuth only | npm run check:routes in the Quality Gates CI job (script: scripts/check-route-wrapping.ts) | PR fails to merge |
The pattern is fail loudly and roll back. There is no scenario where a half-written audit log diverges from the database state — the same-transaction guarantee is what makes the audit log trustworthy for forensic replay.
Where to find a request in the data after the fact
Given the correlationId (UUID propagated from x-request-id or generated by getCorrelationId):
- Audit log.
SELECT * FROM audit.audit_entries WHERE correlation_id = $1— every audit row this request wrote. - Outbox.
SELECT * FROM events.outbox WHERE meta->>'correlationId' = $1— every event this request published. - Application logs. Structured JSON logs are emitted with
correlationIdper line — every log line is greppable by ID. - Distributed traces. OpenTelemetry spans emitted by the platform DB / audit / events helpers are stamped with the
constellation.correlation_idattribute (alongsideconstellation.tenant_id). One trace per request; the per-stage span coverage depends on which call sites are instrumented today.
This is what makes correlation-id the single most useful field to learn early.
Source map
Files exercised by the worked example, in stage order:
| Stage | File |
|---|---|
| 2–3 | packages/platform/auth-next/src/with-auth.ts |
| 4–6 | packages/platform/auth-next/src/with-tenant-auth.ts, apps/directory/src/server/tenant-auth.ts |
| 7 | apps/directory/src/app/api/_helpers/require-permission.ts |
| 8 | apps/directory/src/lib/schemas/organisation.schema.ts |
| 9–10 | apps/directory/src/app/api/organisations/route.ts, apps/directory/src/server/tools/organisation.tools.ts |
| 11–12 | packages/platform/db/src/tenant-context.ts |
| 13–18 | apps/directory/src/server/services/organisation.service.ts |
| 19 | packages/platform/db/src/audit.ts (or packages/platform/audit/src/audit-critical.ts for security-critical mutations) |
| 21 | packages/platform/events/src/publish.ts |
See also
Architecture overview— the C4 L1 + L2 view of the same system.Rules & invariants— the rules cited in this page, each with a stable slug-anchor.Tenancy & RLS— deeper notes on the tenancy mechanics covered briefly in stages 4–12.Events & audit— outbox + audit-chain mechanics covered briefly in stages 19–22.Domain events index— the full catalogue of events any module can publish.