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.
Exception 3 — the platform-owned coordinator schema (ADR-026). No module owns coordinator: packages/platform/coordinator does, and every module reaches it only through @constellation-platform/coordinator. Project Tracker's direct coordinator.* SQL that existed when ADR-026 was ratified is grandfathered and frozen until the ADR-026 milestone moves it behind the package. No new direct access may be added, in PT or anywhere else under apps/, and test code is not exempt. A further set of PT test call sites landed after ratification, while the freeze was enforced by convention only; they are recorded, not authorized, and retire under PLT-1388.
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. npm run check:coordinator-boundaryfails on directcoordinator.*access underapps/outside the ledgerscripts/coordinator-boundary-baseline.json. It is a tripwire over the shapes a static scanner can see — a schema-qualified name written whole, in a string literal, a.sqlor.prismafile, or a shell script — and the script header enumerates what it cannot see (search_path, a constant in another file, SQL assembled by a helper, split or escaped names, and a replacement inside one call site). The invariant binds regardless of what the gate detects. Itssiteslist is re-verified against ADR-026's ratification commit on every run; itsdriftlist is the recorded, unauthorized remainder. Every entry must already exist at the merge base of a develop-bound change, so no list can absorb a site that change adds, and a retired entry cannot be written back; a release or hotfix PR inherits the ledger and is held to the exact tree-versus-ledger match alone.npm run check:identity-writesenforces it overapps/project-tracker/{src,prisma,scripts}andapps/wiki/{src,scripts,evals}. The wiki's one sanctioned raw writer is its audited seed identity bootstrap — a bounded exception recorded in ADR-042. Project Tracker's seeds and operator scripts are grandfathered, not sanctioned: ADR-057 inventories every statement they still write and pins it, so a new one fails. Their audited seed module,prisma/seed-authority.ts, relocates grandfathered statements under a constitution §2 bullet, live since ADR-057 was ratified.
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.
destructive-script-locality
An operator script that destroys data and must refuse a remote target proves locality from the socket's peer, never from a process name. Use
scripts/lib/socket-peer-attestation.ts.
Why. Loopback is not locality: a tunnel, pooler or relay presents a remote database as localhost. Asking lsof what owns the port or socket does not close that gap, because its COMMAND field is derived from the executable's file name — a forwarder saved as com.docker.backend reports as com.docke, exactly what a name allowlist accepts. The PLT-1102 review fixture shipped such a guard and had to concede it; PLT-1189 replaced it.
How to apply.
- Attest the socket before every use.
attestSocketPeer(socketPath, policy)reads the connected process from kernel peer credentials, requires it to be the only process holding the connection and the socket's listener (on Linux, systemd's PID 1 may also hold a socket-activated listener, and its own executable is verified), and verifies its running code against the policy — a code-signing requirement on macOS, an untraced root process running a dpkg-installed executable the policy lists on Linux. Call it before each probe that trusts the socket, not once per run. - Fail closed, with no override. Refuse when the helper throws. Do not add an environment variable or flag that skips it — a bypass any run can take is not an exception.
- Know what it accepts. Today: the Docker engine — Docker Desktop on macOS, and a packaged Docker CE or
docker.iodockerdon Linux. On Linux the caller must be root in the host's initial PID and user namespaces, and must be able to read every process's descriptors: without that view it cannot rule out a second holder or read the engine's executable, so it is refused. A uid is never accepted as identity, and a Linux policy never lists an interpreter. Native PostgreSQL is refused everywhere. A new identity is a reviewed change to the helper's policies. - Know what it does not prove. An attested engine is local; a container it runs is not thereby local. Attest the workload too — a registry-confirmed image digest asked from an empty docker config (never
RepoDigests, which a local tag forges, nor adocker manifest inspectthat can answer from the local manifest store), the image's own command, and only the server executable in the engine's process listing (seeattestContainerWorkloadinapps/wiki/scripts/review-fixture.ts, PLT-1247) — then pair it with an identity check of the server you reach (assertServerRunsInContainer), refuse a server whose catalog can reach another host (assertNoRemoteReach), and state the remaining limits in the script's guide.
Enforcement. Review, and the helper's own tests: scripts/lib/socket-peer-attestation.test.ts refuses a renamed forwarder, a forged argv[0], forwarders that hand a connection to a child and exec signed code, and on Linux a non-root or traced server, a child serving what another process listened for, and an executable dpkg did not install. Specs: .ai/specs/SPEC-plt-1189-socket-peer-attestation.md, .ai/specs/SPEC-plt-1246-linux-socket-peer-verifier.md.
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.