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 current_setting('app.tenant_id', true)::uuid. 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.programme.progress_updated. 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. 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.

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.
  • The cross-schema identity read is the only sanctioned exception.

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).

spec-before-implementation

PRs whose title starts with feat: (or feat(scope):) must add or modify a spec under .ai/specs/ or .kiro/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 or under a .kiro/specs/<feature>/ directory with requirements.md / design.md / tasks.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/ / .kiro/specs/ or carry a [skip spec] marker.

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.