Skip to main content

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.

Source: the constitution

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:

  1. Tenant selection + membership validation happens at the route layer. Every Next.js route in catalog and directory uses the per-app authedRoute / authedRouteWithParams helpers from @/server/tenant-auth, which compose withAuth + withTenantAuth. The withTenantAuth wrapper resolves the active tenant from the x-tenant-id header (set from the x-active-org cookie) first, falling back to the jwt.tenant_id claim, then validates that the user has an active membership for that tenant — denying if not. ctx.user.tenant_id and ctx.user.org_id are rewritten from the validated row so handlers see a consistent pair.
  2. DB session scoping happens when a tool opens a transaction via withTenantContext() from @constellation-platform/db. That helper executes SELECT set_config('app.tenant_id', $1, true) (transaction-local, equivalent to SET 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 with withTenantAuth(...). Use authedRoute(...) / 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 any types. Zod schemas define the wire format; TypeScript types derive from them via z.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/audit in 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/directory cannot import from apps/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/src by npm 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). The AUTH_PROVIDER env var (mock / supabase / keycloak) selects the implementation at boot.
  • Jobs: use @constellation-platform/jobs (PostgresJobQueue default, InMemoryJobQueue for tests). A BullMQ adapter is on the roadmap behind the same JobQueue interface — code against the interface, not the implementation.
  • Email: use @constellation-platform/email adapters.
  • Storage: use @constellation-platform/storage (S3-compatible adapter; MinIO in dev, S3 in cloud, MinIO again on-prem).

overlay-layering

Never set a z-index class on an overlay. Declare a layer and 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 flat layer. 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 OverlayLayerProvider or 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: (or feat(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.