Rules & invariants
Platform-wide rules every change must respect. Each rule has a stable anchor so an agent or PR review can cite it directly — link to docs.planetb2b.com/architecture/rules#tenant-context rather than restating the rule inline.
If you find a rule that contradicts what the code actually does, the code is the source of truth — open an issue and fix the rule here in the same PR.
These rules derive from .ai/constitution.md, the unchanging architectural invariants every Constellation agent should re-read once per session. This page expands each invariant into a citable rule with anchor, rationale, and application notes. The constitution is the why; this page is the how.
tenant-context
Every query against a tenant-scoped table runs under
app.tenant_id, established per transaction from a validated active membership.
Why. Tenant isolation is enforced in two layers — service-level scoping in repositories, and Postgres Row-Level Security policies that gate reads/writes on the app.tenant_id setting (empty-string-safe form: NULLIF(current_setting('app.tenant_id', true), '')::uuid, or a module helper like identity.row_in_current_tenant — never a bare ::uuid cast, whose '' reset value on pooled connections raises 22P02; DIR-93). RLS is the backstop: if a service-level filter is missed, RLS still rejects the query. But RLS only works when app.tenant_id is set on the connection.
How to apply. Two distinct steps, each owned by a different layer:
- Tenant selection + membership validation happens at the route layer. Every Next.js route in catalog and directory uses the per-app
authedRoute/authedRouteWithParamshelpers from@/server/tenant-auth, which composewithAuth+withTenantAuth. ThewithTenantAuthwrapper resolves the active tenant from thex-tenant-idheader (set from thex-active-orgcookie) first, falling back to thejwt.tenant_idclaim, then validates that the user has an active membership for that tenant — denying if not.ctx.user.tenant_idandctx.user.org_idare rewritten from the validated row so handlers see a consistent pair. - DB session scoping happens when a tool opens a transaction via
withTenantContext()from@constellation-platform/db. That helper executesSELECT set_config('app.tenant_id', $1, true)(transaction-local, equivalent toSET LOCAL) so RLS policies pick up the value for every query in the transaction.
// apps/directory/src/app/api/organisations/route.ts
import { authedRoute } from '@/server/tenant-auth';
import type { AuthContext } from '@constellation-platform/auth-next';
async function handlePost(request: Request, { user }: AuthContext): Promise<Response> {
// user.tenant_id is guaranteed populated and validated.
// app.tenant_id will be set when the tool opens its transaction
// via withTenantContext(...).
...
}
export const POST = authedRoute(handlePost);
Enforcement. route-wrapping below — a CI check fails the PR if a new route imports withAuth without also wrapping in withTenantAuth.
route-wrapping
Every
withAuth(...)in catalog + directory route files must be paired withwithTenantAuth(...). UseauthedRoute(...)/authedRouteWithParams(...)to compose both.
Why. A withAuth-only route validates the JWT but skips active-membership validation, so a tool downstream may open a transaction with a tenant the user no longer belongs to. Past incident: a single missed wrap leaked tenant-bound data across boundaries.
How to apply. Use the per-app helpers in @/server/tenant-auth:
export const GET = authedRoute(handleGet);
export const PATCH = authedRouteWithParams<{ id: string }>(handlePatch);
If you genuinely need a route that authenticates without tenant scoping (rare — typically only org-level admin endpoints), add a // @route-wrap: skip <reason> comment anywhere in the route file. The <reason> text after skip must be non-empty — a bare skip is rejected.
Enforcement. npm run check:routes (script: scripts/check-route-wrapping.ts) runs in the Quality Gates CI job and fails on any new unwrapped withAuth without an explicit skip marker.
no-any
No
anytypes. Zod schemas define the wire format; TypeScript types derive from them viaz.infer.
Why. any defeats compile-time tenancy / permission / classification checks. Worse, it silently accepts anything at the boundary, making validation rules untestable. Zod-first ensures every API surface has a runtime check that matches the type.
How to apply. Define the schema; derive the type:
// apps/directory/src/lib/schemas/organisation.schema.ts
export const ORGANISATION_TYPES = [
'AGENCY',
'PRIME_CONTRACTOR',
'SUB_TIER_SUPPLIER',
'SME',
'PROGRAMME_OFFICE',
] as const;
export const CreateOrganisationSchema = z.object({
name: z.string().min(1),
type: z.enum(ORGANISATION_TYPES),
...
});
export type CreateOrganisationInput = z.infer<typeof CreateOrganisationSchema>;
Route handlers then .parse() the request body before passing to the tool layer.
Enforcement. Standard ESLint @typescript-eslint/no-explicit-any rule. Reach for unknown if you genuinely don't know the shape, then narrow with Zod or a type guard.
event-naming
Domain events use
<module-namespace>.<entity>.<verb-past-tense>. Verbs are past tense (created,completed,escalated), not imperative.
Why. Events describe facts, not commands. task.completed is a fact about something that already happened; complete.task would be a command (and commands belong in the tool layer, not on the event bus). Past-tense + entity-first naming makes events scannable and consistent across modules. The leading namespace is the publishing module (directory, catalog, projects) — it happens to coincide with the DB schema name in each current case, but conceptually it's the module's published-event prefix.
How to apply.
await publish(tx, {
eventType: 'projects.task.completed',
payload: { taskId, projectId, completedBy },
meta: { tenantId, actorId, correlationId },
});
Real examples (Project Tracker): projects.task.completed, projects.deliverable.submitted, projects.issue.escalated, projects.initiative.progress_updated (plus the legacy projects.programme.progress_updated, still emitted alongside it during the PLT-182 dual-emit window — append-only means the old event is never removed, only superseded). See the Domain events index for the complete list.
Scope of this rule. Applies to cross-module domain events published via publish(tx, ...) from @constellation-platform/events into the outbox. It does NOT govern internal notification-queue payloads (e.g. collaborator_added, delegate_added) — those are intra-module hand-offs to the in-app notification system, not event contracts other modules can subscribe to.
events-append-only
Cross-module event contracts are append-only. Once a subscriber has shipped, the event's name and payload schema must remain replayable forever.
Why. Events are the public API between modules. A subscriber may be running today, may run tomorrow, may need to replay a backlog six months from now. Renaming task.completed to tasks.completed silently breaks every subscriber that hasn't been simultaneously updated, and every replay of the existing outbox.
How to apply.
- Adding fields: OK — make them optional. Subscribers must tolerate unknown fields.
- Removing fields: introduce a new event version (e.g.
projects.task.completed.v2); do not remove fields from the existing one. - Renaming an event: introduce the new name; publish both during the deprecation window; migrate subscribers; stop emitting the old name once subscribers are migrated, but keep its schema and dispatcher path intact so historical events remain replayable.
- Breaking the payload shape: treat as a new event version.
audit-critical
Mutations that must be forensically replayable use
auditCritical()from@constellation-platform/auditin the same transaction as the mutation.
Why. auditCritical() writes the audit row AND publishes an audit.entry.created outbox event in the current transaction. If the mutation rolls back, so does the audit row and the outbox publish — guaranteeing the audit record never falsely claims a mutation that didn't land. The outbox event is what feeds downstream SIEM / compliance pipelines.
Use for. Permission changes, role assignments, authentication failures, clearance changes, tenant administration, and any other security-sensitive operation that must be traceable.
How to apply.
// Inside a transaction
await prisma.$transaction(async (tx) => {
await tx.role.update({ ... });
await auditCritical(tx, {
tenantId,
actorId,
actorType: 'USER',
action: 'role.permission_changed',
resourceType: 'role',
resourceId,
module: 'directory',
correlationId,
changes: { before, after },
});
});
For non-security-critical audit (e.g. routine project updates), use auditAction() from @constellation-platform/db directly, or withAuditedMutation() for the common case.
no-cross-app-imports
Apps never import from other apps.
apps/directorycannot import fromapps/catalog, ever.
Why. Cross-app imports turn modules into a single distributed monolith. If Directory needs Catalog data, it goes through Catalog's public API or subscribes to Catalog's events — both of which are typed contracts that survive a future split into separate deploys.
How to apply.
- Need data from another module? Call the module's HTTP API or read from its public event stream.
- Need a shared type? Put it in
packages/contracts. - Need a shared utility? Put it in
packages/platform/<name>. - Need a shared UI primitive? Put it in
packages/ui.
module-isolation
Schema-per-module in a single Postgres instance. Cross-module data access is via API or events — not via cross-schema joins.
Why. Each module owns its schema (identity, catalog, projects) and is the only writer of that schema. Cross-schema reads create silent coupling — if Catalog reads identity.users directly, every change to the users table is a Catalog deploy concern.
Exception 1 — shared identity reads. The identity schema is shared-read by all modules for tenant / user lookups. This is deliberate and lives in @constellation-platform/db's tenant-scoped Prisma client.
Exception 2 — Directory-owned identity write functions (ADR-025). A module may mutate identity.* only by calling a SECURITY DEFINER function in the identity schema that a Directory migration created and owns. Because such a call is ordinary SQL, it joins the caller's transaction — which is what makes an atomic cross-schema operation possible (invitation acceptance has to commit the membership and the invitation status together, and an HTTP call cannot enlist in the caller's transaction). The function, not the caller, owns the write semantics.
These functions run as an RLS-exempt owner, so RLS does not guard their writes: each one must assert that the caller's app.tenant_id is set and equals the tenant being written, and validate that any referenced rows belong to it. Editing one of those bodies is an authorization change. Their EXECUTE grant is derived from the full set of privileges the function exercises, so calling it can never achieve more than the caller could do directly. That guarantee is time-bounded: the conjunction is evaluated when the migration RUNS, so the grant is never ISSUED to a role that could not already do it, but it does not follow the grantee afterwards — narrowing a role's identity.* access must re-apply these migrations (which revoke every non-owner and re-derive) or revoke the function grant, in the same change.
How to apply.
- Module-owned schemas: only that module's repositories touch them.
- Need data the other module owns? Subscribe to its events to maintain a local read model, or call its API on demand.
- Reading
identity.*cross-schema is sanctioned; writing it directly is not — go through a Directory-owned function, and add a new one via a Directory migration if none fits. - For Project Tracker this is CI-enforced over
apps/project-tracker/srcbynpm run check:identity-writes.
provider-abstraction
Auth and jobs go through provider abstractions. No direct Supabase / BullMQ / Keycloak imports outside the provider implementations.
Why. Constellation deploys in three tiers — SaaS (Supabase + Vercel), Dedicated Cloud (Docker + Keycloak), On-Prem (air-gapped). Each tier swaps the auth + jobs providers without touching application code. A direct import { createClient } from '@supabase/...' in apps/directory/src/... ties the app to one tier.
How to apply.
- Auth: use
@constellation-platform/auth-core(provider-agnostic JWT + permission helpers) and@constellation-platform/auth-next(Next.js middleware adapters). TheAUTH_PROVIDERenv var (mock/supabase/keycloak) selects the implementation at boot. - Jobs: use
@constellation-platform/jobs(PostgresJobQueuedefault,InMemoryJobQueuefor tests). A BullMQ adapter is on the roadmap behind the sameJobQueueinterface — code against the interface, not the implementation. - Email: use
@constellation-platform/emailadapters. - Storage: use
@constellation-platform/storage(S3-compatible adapter; MinIO in dev, S3 in cloud, MinIO again on-prem).
overlay-layering
Never set a
z-indexclass on an overlay. Declare alayerand let everything nested inside derive from it.
Why. Every overlay primitive in @constellation-platform/ui (Dialog, Sheet, AlertDialog, Popover, DropdownMenu, Select, Tooltip) renders through a portal into document.body. That makes them all peers in the root stacking context, where DOM nesting counts for nothing and only z-index decides paint order. An overlay raised with a hardcoded class therefore also outranks the menus opened from inside it: they mount invisible behind it and clicks land on its backdrop. That is what made Project Tracker's task popup look broken while the identical controls worked on the full-page route.
How to apply.
- Nested overlays need nothing. An overlay opened inside another derives one step above its host automatically, at any depth, through React context (context crosses portals — it follows the React tree, not the DOM). A top-level overlay keeps the flat base tier.
- To clear non-overlay chrome, pass
layer. A floating action bar or sticky filter row sets its z-index by hand and publishes no layer, so an overlay that must sit above it declares a tier:<DialogContent layer={OVERLAY_LAYERS.drawer}>. Backdrop and descendants follow automatically. - Reused on a bare page and inside a raised host? Take a floor with
useOverlayLayerAtLeast(<tier>), not a flatlayer. A flat tier pins the component below a host that already sits higher. - A
z-*class moves only the element it is on — not the backdrop, not the layer published to descendants. It is a leaf-only escape hatch, never the way to move an overlay. - New primitive? Setting a layer and publishing it are separate steps. Wrap children in
OverlayLayerProvideror nesting silently stops compounding.
Enforcement. packages/ui/tests/overlay-layer.test.tsx asserts that any module positioning itself with withOverlayLayerStyle also renders an OverlayLayerProvider, so a primitive that sets a layer without publishing it fails the suite.
spec-before-implementation
PRs whose title starts with
feat:(orfeat(scope):) must add or modify a spec under.ai/specs/. Write the spec first.
Why. Specs are how architectural consistency is reviewed. A spec captures the intent, the contract, and the acceptance criteria before the diff lands — so the review is "is this the right thing to build?" not "have we accidentally built three subtly different things?"
How to apply.
- Sketch the spec (1-3 pages typically) under
.ai/specs/SPEC-<short-name>.md. - Reference the spec in the PR description.
- For PRs that genuinely don't need a spec (typo fixes, no-impact internal refactors, CI tweaks, this rules page itself), add
[skip spec]to the PR title or body.
Enforcement. scripts/check-pr-requirements.ts runs in the Quality Gates CI job and fails any feat: PR that doesn't either touch .ai/specs/ or carry a [skip spec] marker. The frozen .ai/specs/archive/ subtree is excluded — relocating history is not authoring a spec.
See also
- Architecture overview — system shape, C4 context + container diagrams.
- Tenancy — deeper notes on the tenant model.
- Events & audit — outbox + audit chain mechanics.
- Source of truth: root
AGENTS.md— the binding rules for contributors and AI agents. This page distils the most-cited rules from there.