Annex A: Reference Implementation Examples
Fastify and NestJS Patterns Across Current Verticals
Companion to: RaR-IT SaaS Platform Architecture Policy, Sections 5–6.1
:::note Purpose This annex illustrates, with real component names drawn from the current AMAAR, Clinivio, EduSuite, Transport, and Tourism backlogs, how the two sanctioned framework patterns actually look in code — the Fastify default (Section 5) and the NestJS scoped allowance (Section 6.1) — plus the acyclic-dependency rule (Section 4): how a real two-way relationship between engines, or between verticals, must be restructured so no package imports another that (directly or indirectly) imports it back.
It also shows, deliberately, four patterns that are not permitted without Architecture Review Board sign-off or restructuring: Nest owning its own HTTP pipeline (A.6), a direct reverse import between two engines (A.7), a direct import between two verticals (A.8), and a repository reaching the database through fastify.decorate('db', ...) instead of the tenant-scoped pattern (A.10) — shown so the risks are concrete rather than abstract.
Every example shares one non-negotiable property: Entitlements' module discovery reads the same framework-agnostic manifest contract regardless of which pattern a module's internals use.
A.11 is different in kind from the rest: it evaluates an old, unrelated system's RLS setup for prior art, separating patterns worth adapting from patterns that would be a regression if copied. :::
:::caution Errata — DB-access API names corrected throughout, 2026-09-08
Every example below that shows database access was originally written
against getDb()/withTenantScope(), the API db-access-pattern-decision.md
specifies. That API does not exist in the real, shipped
@raritllc/platform-core@0.1.2 — confirmed during the SAFAR and Clinivo
vertical bootstraps. The real API is withTenant(tenantId, fn) /
withPlatformScope(fn), explicit-parameter style: the service opens the
scope and passes the resulting database handle into the repository
function as a parameter, rather than the repository calling a global
accessor. Every code example in this annex has been updated to that
shape. The exact type of the passed handle (a raw pg.PoolClient, or
something the real package wraps before returning it) should be confirmed
against platform-core's actual source before treating any single example
here as exact — see db-access-pattern-decision.md's matching errata for
the two open questions this still leaves for the Architecture Review
Board.
:::
A.1 Fastify Pattern — Simple CRUD Module
AMAAR: Properties
:::info Updated 2026-08-27, API names corrected 2026-09-08
This example previously showed fastify.db.query.properties.findMany(...) called directly inside the route handler. That contradicts db-access-pattern-decision.md (Accepted 2026-08-26) and was flagged in review before policy sign-off. Corrected below to the three-layer pattern the ADR specifies: repository (receives the database handle as a parameter, no framework import) → service (opens withTenant(), calls the repository and Audit inside it) → Fastify plugin (owns the route, the manifest, and the entitlement check — unchanged from before). See the annex-wide errata above: the original version of this example used getDb()/withTenantScope(), renamed here to match the real shipped API.
:::
The default and most common case for a vertical module — no NestJS needed because there is nothing to inject, but the database access still goes through the three layers below, not straight from the route handler.
import { FeatureManifest } from '@rarit-kernel/entitlements';
export const manifest: FeatureManifest = {
key: 'properties',
displayName: { en: 'Properties', ar: 'العقارات' },
requiredPlan: ['starter', 'growth', 'enterprise'],
routes: ['/properties/*'],
};
import { eq } from 'drizzle-orm';
import type { DbHandle } from '@rarit-kernel/platform-core';
import { properties } from '../../../db/schema';
import type { NewProperty } from '../../../db/schema';
// db is the handle the calling service opened with withTenant() and passed
// in explicitly -- no global accessor, no framework import. There is no
// scope to forget: a repository simply cannot be called without one.
export async function listProperties(db: DbHandle, tenantId: string) {
// The explicit tenantId filter stays even though RLS also enforces it --
// this is the "two locks" defense-in-depth the ADR describes, not a
// leftover from before RLS existed.
return db
.select()
.from(properties)
.where(eq(properties.tenantId, tenantId));
}
export async function insertProperty(db: DbHandle, input: NewProperty) {
const [row] = await db.insert(properties).values(input).returning();
return row;
}
import { withTenant } from '@rarit-kernel/platform-core';
import { logAction } from '@rarit-kernel/audit';
import * as propertiesRepo from '../repositories/properties.repository';
export async function listPropertiesForTenant(tenantId: string) {
return withTenant(tenantId, (db) => propertiesRepo.listProperties(db, tenantId));
}
export async function createProperty(input: CreatePropertyInput) {
return withTenant(input.tenantId, async (db) => {
const property = await propertiesRepo.insertProperty(db, input);
// logAction() joins this same transaction via the same db handle.
// No transaction object crosses the properties/audit package boundary.
// If this write throws, the insert above rolls back too.
await logAction(db, {
tenantId: input.tenantId,
actorId: input.createdBy,
action: 'property.created',
entityId: property.id,
after: property,
});
return property;
});
}
import fp from 'fastify-plugin';
import { z } from 'zod';
import { requireEntitlement } from '@rarit-kernel/entitlements';
import { listPropertiesForTenant, createProperty } from './services/properties.service';
const CreatePropertySchema = z.object({
title: z.string().min(3),
type: z.enum(['apartment', 'villa', 'office', 'land']),
price: z.number().positive(),
city: z.string(),
bedrooms: z.number().int().optional(),
});
export default fp(async (fastify) => {
fastify.addHook('preHandler', requireEntitlement('properties'));
fastify.get('/properties', async (req) => {
return listPropertiesForTenant(req.tenant.tenantId);
});
fastify.post('/properties', { schema: { body: CreatePropertySchema } },
async (req, reply) => {
const property = await createProperty({
...req.body,
tenantId: req.tenant.tenantId,
createdBy: req.tenant.userId,
});
return reply.code(201).send(property);
});
}, { name: 'properties' });
A.2 Fastify Pattern — Thin Consumer of a Shared Engine
Transport: Commission & Settlement
A module whose entire job is to read and write against a fixed-contract shared engine. No vertical-side business logic complex enough to justify a DI container.
Note what this example deliberately does not show: where the commission rate itself is configured. Per Section 4 ("vertical-specific monetization models are configuration on top of Payment and Entitlements, not new engines"), that rate is tenant/plan configuration data living inside Payment or Entitlements' own manifest-driven settings — not a number Transport computes or stores itself. recordSettlement() below reads whatever rate Payment already resolved; Transport never sees or duplicates the rate logic. If a future example needs to show setting that configuration (as opposed to consuming its result, which is all this module does), it belongs in Payment's or Entitlements' own documentation, not here — this module has no code path that touches it.
export const manifest: FeatureManifest = {
key: 'commission-settlement',
displayName: { en: 'Commission & Settlement', ar: 'العمولات والتسوية' },
requiredPlan: ['growth', 'enterprise'],
routes: ['/commission/*'],
};
import fp from 'fastify-plugin';
import { requireEntitlement } from '@rarit-kernel/entitlements';
import { getLedgerForTenant, recordSettlement } from '@rarit-kernel/payment';
export default fp(async (fastify) => {
fastify.addHook('preHandler', requireEntitlement('commission-settlement'));
// Ledger entries (due / pending / approved / rejected) come straight
// from the Payment engine's shared ledger primitive -- Transport does
// not maintain its own copy of settlement state.
fastify.get('/commission/ledger/:packetShopId', async (req) => {
return getLedgerForTenant(req.tenant.tenantId, {
scope: 'packet_shop',
scopeId: req.params.packetShopId,
});
});
fastify.post('/commission/settle/:entryId', async (req, reply) => {
const settled = await recordSettlement(req.tenant.tenantId, req.params.entryId, {
approvedBy: req.tenant.userId,
});
return reply.send(settled);
});
}, { name: 'commission-settlement' });
A.3 Fastify Pattern — Simple CRUD Module (Second Vertical)
Tourism: CRM & Customer Management
A second CRUD example from a different vertical, to make clear the A.1 repository/service/route split is the norm, not an AMAAR-specific convention — and specifically that it holds even for a two-step read-then-update like the loyalty-tier case below, which is exactly the kind of code that tempts a direct fastify.db call.
import { eq, and } from 'drizzle-orm';
import type { DbHandle } from '@rarit-kernel/platform-core';
import { customers } from '../../../db/schema';
export async function getCustomerWithBookings(db: DbHandle, tenantId: string, customerId: string) {
return db.query.customers.findFirst({
where: (c, { eq, and }) => and(eq(c.id, customerId), eq(c.tenantId, tenantId)),
with: { bookings: true },
});
}
export async function getCustomer(db: DbHandle, tenantId: string, customerId: string) {
return db.query.customers.findFirst({
where: (c, { eq, and }) => and(eq(c.id, customerId), eq(c.tenantId, tenantId)),
});
}
export async function setLoyaltyTier(db: DbHandle, tenantId: string, customerId: string, tier: string) {
await db
.update(customers)
.set({ tier })
.where(and(eq(customers.id, customerId), eq(customers.tenantId, tenantId)));
}
import { withTenant } from '@rarit-kernel/platform-core';
import * as customersRepo from '../repositories/customers.repository';
export async function getCustomerProfile(tenantId: string, customerId: string) {
return withTenant(tenantId, (db) => customersRepo.getCustomerWithBookings(db, tenantId, customerId));
}
export async function updateLoyaltyTier(tenantId: string, customerId: string) {
// Read and write join the same transaction -- no risk of the tier being
// computed from a value that changes between the read and the write.
return withTenant(tenantId, async (db) => {
const customer = await customersRepo.getCustomer(db, tenantId, customerId);
const tier = customer.lifetimeSpend >= 10000 ? 'preferred' : 'standard';
await customersRepo.setLoyaltyTier(db, tenantId, customerId, tier);
return tier;
});
}
import fp from 'fastify-plugin';
import { requireEntitlement } from '@rarit-kernel/entitlements';
import { getCustomerProfile, updateLoyaltyTier } from './services/customers.service';
export default fp(async (fastify) => {
fastify.addHook('preHandler', requireEntitlement('crm'));
fastify.get('/customers/:id', async (req) => {
return getCustomerProfile(req.tenant.tenantId, req.params.id);
});
fastify.patch('/customers/:id/loyalty-tier', async (req, reply) => {
const tier = await updateLoyaltyTier(req.tenant.tenantId, req.params.id);
return reply.send({ tier });
});
}, { name: 'crm' });
A.4 NestJS Pattern — Shared Providers Across Many Templates
Clinivio: Clinical Templates Module (Section 6.1 scoped allowance)
This is the case Section 6.1 was written for: ten specialty clinical templates, several sharing common building blocks — a body/anatomy diagram widget, a structured-value validator, a template-registry lookup. Fastify still owns the HTTP route, the manifest, and the entitlement check; Nest is invisible from Entitlements' point of view.
There is no discovery mechanism for Nest modules, and none is needed — that question only applies to the Fastify plugin layer, where every module ships a manifest.ts that Entitlements scans at boot (Section 5). A Nest module like ClinicalTemplatesModule below is never scanned or auto-registered anywhere; it's a plain TypeScript class, directly import-ed by name into the one Fastify plugin's index.ts that uses it (visible in the last code block below: import { ClinicalTemplatesModule, ... } from './nest/clinical-templates.module'). If a second module needed it too, it would import it the same explicit way — there's no central Nest registry to register it with, by design, since Section 6.1 scopes Nest to a single DI application context per module, not a shared Nest application spanning the vertical.
:::caution Scope note This module is currently Phase 2 under the lean-MVP launch scope (deferred alongside Hospitals/Centers/Dental). Included here as the clearest real example of the pattern, not as a claim it ships at initial launch. :::
import { Injectable, Module } from '@nestjs/common';
export interface ClinicalTemplateDefinition {
specialtyKey: string;
schema: ZodSchema;
usesBodyDiagram: boolean;
}
@Injectable()
export class TemplateRegistryService {
private templates = new Map<string, ClinicalTemplateDefinition>();
register(template: ClinicalTemplateDefinition) {
this.templates.set(template.specialtyKey, template);
}
get(specialtyKey: string) {
return this.templates.get(specialtyKey);
}
}
@Injectable()
export class BodyDiagramWidgetService {
// Shared by Dermatology (skin map), Orthopedics (pain map),
// Physiotherapy (assessment diagram), and Beauty (treatment-area marking).
renderDiagram(regionMap: BodyRegionMap, markings: Marking[]) {
/* shared rendering + coordinate-validation logic lives here once */
}
}
@Injectable()
export class ClinicalValidationService {
constructor(private readonly registry: TemplateRegistryService) {}
validate(specialtyKey: string, payload: unknown) {
const template = this.registry.get(specialtyKey);
if (!template) throw new UnknownSpecialtyError(specialtyKey);
return template.schema.parse(payload);
}
}
@Module({
providers: [
TemplateRegistryService,
BodyDiagramWidgetService,
ClinicalValidationService,
DermatologyTemplateProvider,
OrthopedicsTemplateProvider,
PhysiotherapyTemplateProvider,
// ... remaining specialty template providers
],
exports: [ClinicalValidationService, BodyDiagramWidgetService],
})
export class ClinicalTemplatesModule {}
import type { DbHandle } from '@rarit-kernel/platform-core';
import { visitNotes } from '../../../db/schema';
import type { NewVisitNote } from '../../../db/schema';
export async function insertVisitNote(db: DbHandle, input: NewVisitNote) {
const [note] = await db.insert(visitNotes).values(input).returning();
return note;
}
import { withTenant } from '@rarit-kernel/platform-core';
import * as visitNotesRepo from '../repositories/visit-notes.repository';
import type { ParsedVisitNote } from '../nest/clinical-templates.module';
// This service handles persistence only. Clinical validation (Nest DI,
// A.4 above) and tenant-scoped persistence (platform-core, here) are two
// separate concerns and stay in two separate files.
export async function recordVisitNote(tenantId: string, parsed: ParsedVisitNote) {
return withTenant(tenantId, (db) => visitNotesRepo.insertVisitNote(db, { ...parsed, tenantId }));
}
import fp from 'fastify-plugin';
import { NestFactory } from '@nestjs/core';
import { requireEntitlement } from '@rarit-kernel/entitlements';
import { ClinicalTemplatesModule, ClinicalValidationService } from './nest/clinical-templates.module';
import { recordVisitNote } from './services/visit-notes.service';
export const manifest = {
key: 'clinical-templates',
displayName: { en: 'Specialty Clinical Templates', ar: 'القوالب السريرية التخصصية' },
requiredPlan: ['enterprise'],
routes: ['/clinical-templates/*'],
};
export default fp(async (fastify) => {
// Nest used ONLY as a DI application context -- no HTTP adapter,
// no Nest guards/interceptors, no Nest-owned routing. This is the
// required integration pattern under Policy Section 6.1. It is
// unrelated to, and does not replace, the platform-core tenant-scope
// pattern used for persistence below -- Nest handles validation only.
const nestCtx = await NestFactory.createApplicationContext(ClinicalTemplatesModule);
const validation = nestCtx.get(ClinicalValidationService);
fastify.addHook('preHandler', requireEntitlement('clinical-templates'));
fastify.post('/clinical-templates/:specialty/visit-note', async (req, reply) => {
const parsed = validation.validate(req.params.specialty, req.body);
const note = await recordVisitNote(req.tenant.tenantId, parsed);
return reply.code(201).send(note);
});
}, { name: 'clinical-templates' });
A.5 NestJS Pattern — Pluggable Strategy Objects
AMAAR: Financial Forecasting
A second real Nest-scoped-allowance case: multiple interchangeable forecasting algorithms implementing the same interface, selected at request time — the textbook strategy pattern.
import { Inject, Injectable, Module } from '@nestjs/common';
export interface ForecastStrategy {
readonly key: string;
forecast(history: RevenuePoint[], horizonMonths: number): ForecastResult;
}
const FORECAST_STRATEGIES = Symbol('FORECAST_STRATEGIES');
@Injectable()
class LinearTrendStrategy implements ForecastStrategy {
readonly key = 'linear';
forecast(history, horizonMonths) { /* simple linear regression */ }
}
@Injectable()
class SeasonalAdjustedStrategy implements ForecastStrategy {
readonly key = 'seasonal';
forecast(history, horizonMonths) { /* seasonal decomposition */ }
}
@Injectable()
export class ForecastingService {
constructor(@Inject(FORECAST_STRATEGIES) private readonly strategies: ForecastStrategy[]) {}
run(key: string, history: RevenuePoint[], horizonMonths: number) {
const strategy = this.strategies.find((s) => s.key === key) ?? this.strategies[0];
return strategy.forecast(history, horizonMonths);
}
}
@Module({
providers: [
LinearTrendStrategy,
SeasonalAdjustedStrategy,
{
provide: FORECAST_STRATEGIES,
useFactory: (linear: LinearTrendStrategy, seasonal: SeasonalAdjustedStrategy) => [linear, seasonal],
inject: [LinearTrendStrategy, SeasonalAdjustedStrategy],
},
ForecastingService,
],
exports: [ForecastingService],
})
export class FinancialForecastingModule {}
```typescript title="verticals/amaar/modules/financial-forecasting/repositories/revenue-history.repository.ts"
import { eq } from 'drizzle-orm';
import type { DbHandle } from '@rarit-kernel/platform-core';
import { revenueHistory } from '../../../db/schema';
export async function getRevenueHistory(db: DbHandle, tenantId: string) {
return db
.select()
.from(revenueHistory)
.where(eq(revenueHistory.tenantId, tenantId));
}
import { withTenant } from '@rarit-kernel/platform-core';
import * as revenueHistoryRepo from '../repositories/revenue-history.repository';
import type { ForecastingService } from '../nest/financial-forecasting.module';
// This is AMAAR's own application service: tenant-scoped access to AMAAR's
// own revenue-history table. It is deliberately NOT part of the shared
// @rarit-kernel/forecasting package in A.8 -- what's shared there is the
// pure strategy-selection algorithm (no DB access at all), because AMAAR's
// revenue table and Tourism's revenue table are different data models.
// Promoting this repository too would recreate exactly the kind of
// vertical-to-vertical data coupling Section 4 prohibits.
export async function forecastRevenue(
tenantId: string,
forecasting: ForecastingService,
strategyKey: string,
horizonMonths: number,
) {
return withTenant(tenantId, async (db) => {
const history = await revenueHistoryRepo.getRevenueHistory(db, tenantId);
return forecasting.run(strategyKey, history, horizonMonths);
});
}
import fp from 'fastify-plugin';
import { NestFactory } from '@nestjs/core';
import { requireEntitlement } from '@rarit-kernel/entitlements';
import { FinancialForecastingModule, ForecastingService } from './nest/financial-forecasting.module';
import { forecastRevenue } from './services/forecasting.service';
export const manifest = {
key: 'financial-forecasting',
displayName: { en: 'Financial Forecasting', ar: 'التوقعات المالية' },
requiredPlan: ['enterprise'],
routes: ['/forecasting/*'],
};
export default fp(async (fastify) => {
const nestCtx = await NestFactory.createApplicationContext(FinancialForecastingModule);
const forecasting = nestCtx.get(ForecastingService);
fastify.addHook('preHandler', requireEntitlement('financial-forecasting'));
fastify.get('/forecasting/revenue', async (req) => {
const strategyKey = req.query.strategy ?? 'linear';
const horizon = Number(req.query.months ?? 6);
return forecastRevenue(req.tenant.tenantId, forecasting, strategyKey, horizon);
});
}, { name: 'financial-forecasting' });
A.6 Anti-Pattern — Nest Owning Its Own HTTP Pipeline
:::danger NOT PERMITTED WITHOUT ARCHITECTURE REVIEW BOARD SIGN-OFF Shown here only to make the risk in Policy Section 6.1 concrete — do not copy this as a starting template. :::
The problem: once Nest owns its own Fastify-adapter HTTP instance, requests to it never pass through the parent process's entitlement-check hook.
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter } from '@nestjs/platform-fastify';
// Nest now owns its own HTTP pipeline and lifecycle.
const nestApp = await NestFactory.create(AiIntelligenceModule, new FastifyAdapter());
await nestApp.init();
// Mounting it under the parent process does NOT route these requests
// through the parent's requireEntitlement() preHandler hook:
fastify.register(async (instance) => {
instance.all('/ai-intelligence/*', (req, reply) =>
nestApp.getHttpAdapter().getInstance().routing(req.raw, reply.raw)
);
});
// -> A tenant without the 'ai-intelligence' entitlement can still
// reach this route. Fixing it requires either a duplicated Nest
// guard re-implementing the same check, or a proxy hop through
// Fastify's own hook first -- both are the added complexity and
// dual-enforcement-path risk Section 6.1 flags before this pattern
// may be used.
If the Architecture Review Board ever approves this pattern for a specific service (per Section 6.1's sign-off requirement), the proxy hop is the standard to require, not the duplicated guard. A duplicated guard means the entitlement check exists twice, in two languages/frameworks, maintained by two different people over time — the moment one is updated and the other isn't, the two silently disagree about who's allowed in, and nobody notices until an incident. A proxy hop keeps exactly one implementation of the check (Fastify's preHandler, the same one every other module in this Annex already uses) and simply routes Nest's traffic through it first. Slower per request, and worth measuring, but a single, auditable source of truth for authorization is worth more than the latency in an approved exception, not less.
A.7 Circular Dependency — Engines (IAM ↔ Notifications)
Policy Section 4 requires engine-to-engine dependencies to be acyclic. This is not a hypothetical risk: every OTP-based password-reset flow in this platform needs IAM to trigger a notification, and Notifications needs to resolve tenant contact details that IAM owns — a genuine two-way relationship between two engines.
Wrong: direct reverse import creates the cycle
import { sendNotification } from '@rarit-kernel/notifications'; // IAM -> Notifications
export async function requestOtp(userId: string) {
const code = generateOtp();
await storeOtp(userId, code);
const contact = await resolveContact(userId);
await sendNotification(contact, 'otp', { code });
}
import { getTenantContact } from '@rarit-kernel/iam'; // Notifications -> IAM
// @rarit-kernel/iam already imports @rarit-kernel/notifications above --
// the workspace now contains IAM <-> Notifications, a real import cycle.
export async function sendNotification(contactRef, type, payload) {
const contact = await getTenantContact(contactRef); // duplicates IAM's own resolution
await deliver(contact, type, payload);
}
This is exactly the shape a circular-dependency reference would describe: npm may still install both packages, and Node may still execute both modules by handing back a partially-initialized export during the cycle — but neither guarantees the OTP code is ever actually delivered on every code path, and which path fails can depend on which engine happens to initialize first.
Right: IAM emits an event; only Notifications reads across the boundary
import { emitEvent } from '@rarit-kernel/event-bus';
export async function requestOtp(userId: string) {
const code = generateOtp();
await storeOtp(userId, code);
await emitEvent('iam.otp_requested', { userId, code });
// IAM's job ends here. It does not know or care who listens.
}
import { onEvent } from '@rarit-kernel/event-bus';
import { getTenantContact } from '@rarit-kernel/iam'; // one-directional read -- allowed
onEvent('iam.otp_requested', async ({ userId, code }) => {
const contact = await getTenantContact(userId);
await deliver(contact, 'otp', { code });
});
Notifications is still allowed to import IAM read-only — that direction was never the problem. What had to change was IAM's side: it no longer imports Notifications at all, so the cycle is structurally impossible, not just avoided by convention. The identical fix applies to the Entitlements ↔ Notifications pair (plan-limit alerts, plan-gated notification channels): Entitlements emits entitlements.plan_limit_reached; Notifications subscribes and reads plan data from Entitlements read-only when it needs to decide which channel is allowed.
:::caution Open question, not yet answered by this Annex: does @rarit-kernel/event-bus durably persist events? Tracked as Actions ACT-003.
emitEvent()/onEvent() above solve the import-direction problem — they do not, on their own, say anything about delivery guarantees. If @rarit-kernel/event-bus is an in-process emitter (e.g., wrapping Node's own EventEmitter), an event is lost if the process crashes between emitEvent('iam.otp_requested', ...) committing and the Notifications subscriber actually running — which would mean the OTP silently never sends, a worse failure than the reverse-import cycle this pattern was designed to fix. For OTP delivery this is a UX problem; for anything touching Payment or Audit, it's a correctness problem. This Annex does not assert which implementation @rarit-kernel/event-bus actually uses — that's a question for whoever owns it, not something to guess at here. If it is in-process only, the recommended fix is a transactional outbox: the event payload is written to an outbox table in the same transaction as the triggering change (so it either commits with the mutation or not at all), and a separate durable worker polls or streams that table to actually dispatch. Confirm this before treating any event in this pattern as safe for OTP, payment, or audit-sensitive flows specifically — a lower-stakes event (e.g., a UI-only "recently viewed" cache invalidation) may not need this guarantee at all, so the answer doesn't have to be one-size-fits-all across every event type.
:::
A.8 Circular Dependency — Verticals (AMAAR ↔ Tourism)
The same risk recurs one level up, between verticals instead of engines, and it typically starts innocently: a developer notices AMAAR's Financial Forecasting module (A.5) would work just as well for Tourism's revenue projections, and imports it directly instead of asking whether it belongs to AMAAR at all.
Wrong: direct cross-vertical import
import { ForecastingModule, ForecastingService } from '@rarit/amaar/modules/financial-forecasting';
// Tourism now depends directly on AMAAR's package.
export default fp(async (fastify) => {
const nestCtx = await NestFactory.createApplicationContext(ForecastingModule);
const forecasting = nestCtx.get(ForecastingService);
// ...
});
This works fine in isolation. The risk appears later: if AMAAR's team subsequently wants something Tourism owns — seasonal travel-demand curves, say, to improve a forecast strategy — and imports it the same way, the workspace now contains AMAAR ↔ Tourism, the exact A ↔ B shape a circular-dependency reference warns about, except between two product verticals instead of two npm packages in a tutorial.
Right: promote the shared logic into a kernel-consumable package
Financial forecasting was never really an AMAAR concept — it is a general-purpose capability AMAAR happened to build first. Per Section 4's existing precedent for cross-cutting logic (monitoring is instrumentation, not an engine; settlement is configuration, not an engine), a shared forecasting library is a plain consumed package, not a sixth manifest-driven or fixed-contract engine — it does not need Entitlements gating or a feature manifest of its own.
@Module({
providers: [LinearTrendStrategy, SeasonalAdjustedStrategy, ForecastingService],
exports: [ForecastingService],
})
export class ForecastingModule {}
import { ForecastingModule, ForecastingService } from '@rarit-kernel/forecasting';
// AMAAR now depends downward on a shared package, not sideways on Tourism.
import { ForecastingModule, ForecastingService } from '@rarit-kernel/forecasting';
// Tourism depends on the same shared package. AMAAR and Tourism no longer
// know the other vertical exists.
Both verticals now depend downward on a shared package; neither depends on the other, and neither can ever be blocked waiting on the other's package to finish initializing. This is the same fix commonly recommended for package-level cycles ("extract shared functionality"), applied at the vertical level: the question is never "can npm install this," it is "does this shared thing actually belong to either vertical."
A.9 Implementing Dependency-Cycle Enforcement in CI
Policy Section 5 mandates eslint-plugin-import's no-cycle rule across the whole workspace so this class of mistake fails a pull request automatically, the same way an unreviewed Section 6 violation would. This section is the practical walkthrough: install, configure, wire into CI, enforce via branch protection, and roll out safely against a codebase that may already contain cycles nobody has noticed yet.
Step 1 — Install the plugin
At the workspace root (not inside an individual engine or vertical package):
npm install --save-dev eslint eslint-plugin-import eslint-import-resolver-typescript
The TypeScript resolver is required, not optional, in this workspace specifically: without it, ESLint cannot follow imports across package boundaries (engine → engine, vertical → kernel) that resolve via each package's own tsconfig paths rather than plain relative imports.
Step 2 — Configure the rule at the workspace root
const importPlugin = require('eslint-plugin-import');
module.exports = [
{
plugins: { import: importPlugin },
settings: {
'import/resolver': {
typescript: {
project: [
'./packages/*/tsconfig.json', // @rarit-kernel/* engines
'./verticals/*/tsconfig.json', // AMAAR, Clinivio, EduSuite, Transport, Tourism
],
},
},
},
rules: {
// error, not warn -- a warning does not fail CI and does not
// satisfy Policy Section 5's "cannot merge" requirement.
'import/no-cycle': ['error', { maxDepth: Infinity, ignoreExternal: true }],
},
},
];
Two settings matter enough to call out explicitly:
maxDepth: Infinity— a shallow check would catch IAM directly importing Notifications, but might miss a longer cycle routed through two or three intermediate packages. Since this platform's whole point is packages that reference each other by design, the check needs to walk the full graph, not just immediate neighbors.ignoreExternal: true— restricts the check to this workspace's own packages. Without it, the rule also tries to analyze cycles inside third-partynode_modulesdependencies, which is not this policy's concern and can produce false positives or an unusably slow lint run.
Step 3 — Add a dedicated npm script
{
"scripts": {
"lint": "eslint . --ext .ts,.tsx"
}
}
npm run lint already runs import/no-cycle (it's the rule defined in Step 2's shared eslint.config.js), so there is no separate "cycles-only" script to maintain. An earlier version of this walkthrough included a lint:cycles alternative using --no-eslintrc --rulesdir; those are ESLint 8 legacy-config-only flags that do not work against the flat-config eslint.config.js from Step 2, so that script has been removed rather than fixed to avoid two divergent config systems in one repo. If a genuinely fast, single-rule pre-commit check is needed later (relevant once eslint.config.js grows beyond just this one rule), scope it with a second flat-config file passed via --config, not with legacy CLI flags.
Step 4 — Wire it into CI
name: Lint
on:
pull_request:
push:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint # fails the job -- and the PR -- on any import/no-cycle error
Step 5 — Make the check mandatory, not advisory
This is the step that actually delivers Policy Section 5's "cannot merge" guarantee — without it, a failing lint job is just a red X someone can ignore. In the repository's GitHub settings:
- Settings → Branches → Branch protection rules →
main - Enable "Require status checks to pass before merging"
- Select the
lintjob from Step 4 as a required check - Combine with "Require branches to be up to date before merging" so a cycle introduced by a different, since-merged PR is still caught
This is the same enforcement mechanism — branch protection plus a required check — that this repository already uses for CODEOWNERS-gated policy changes (see the repository's own README.md). Dependency-cycle enforcement and policy-change enforcement are two instances of the same pattern: a rule is not real until GitHub, not a person, is the one refusing the merge.
Step 6 — Optional: catch it before the PR, with a pre-commit hook
CI enforcement (Steps 4–5) is the mandatory floor. A pre-commit hook is a faster feedback loop on top of it, not a replacement for it — CI must still run, since a hook can always be bypassed locally with --no-verify.
npm install --save-dev husky lint-staged
npx husky init
npx lint-staged
{
"lint-staged": {
"*.{ts,tsx}": ["eslint --rule '{\"import/no-cycle\": \"error\"}'"]
}
}
Step 7 — Rolling this out against an existing codebase
Turning import/no-cycle on workspace-wide for the first time can surface cycles nobody has noticed yet — that is the point, but it can also block every open PR at once if the rule is enabled with no transition plan. Recommended sequence:
- Run
npm run lintonce, locally, before adding the CI job, and read the output — this tells you how many existing cycles you are inheriting, not introducing. - If the count is small (a handful), fix them directly using the patterns in A.7–A.8 before turning on CI enforcement at all.
- If the count is large, use ESLint's
overridesto exempt the specific already-cyclic files by path with a dated comment explaining why, so the rule is fully enforced for all new code from day one while existing debt is tracked explicitly rather than silently ignored:
{
files: ['packages/legacy-billing/**/*.ts'],
rules: {
// TODO(2026-09-01, @platform-architecture-owner): pre-existing cycle,
// tracked in JIRA-1234. Do not add new files to this override list.
'import/no-cycle': 'off',
},
},
An override list that only ever shrinks is a legitimate transition tool. An override list that grows is the rule quietly failing — review it on the same cadence as Policy Section 9's quarterly policy review.
Step 8 — Verify the rule actually catches something
Before trusting this in production, confirm it fails on a real example. Temporarily reproduce the wrong pattern from A.7 (IAM importing Notifications directly) in a scratch branch and run the lint job:
packages/iam/src/otp.ts
1:1 error Dependency cycle via packages/notifications/src/dispatch.ts:1 import/no-cycle
✖ 1 problem (1 error, 0 warnings)
If this does not fail, the resolver in Step 2 is not correctly following the workspace's package paths — revisit the import/resolver → typescript → project glob before relying on the rule for anything real.
Step 9 — no-cycle catches cycles, not one-way prohibited imports
:::caution A distinct gap, not covered by Steps 1–8
Section 4 prohibits any direct vertical-to-vertical import, in either direction — not just cyclic ones. import/no-cycle cannot enforce this: if Tourism imports directly from AMAAR and AMAAR never imports anything back, there is no cycle for the rule to detect, even though Section 4 already forbids the import on its own. A.8's fix (promote to @rarit-kernel/forecasting) works whether or not the reverse edge exists — the policy violation is the one-way import itself, not the round trip.
:::
Enforcing this needs a boundary/direction rule, not a cycle rule — a second, distinct check:
const boundaries = require('eslint-plugin-boundaries');
module.exports = [
// ... existing import/no-cycle block from Step 2 stays as-is ...
{
plugins: { boundaries },
settings: {
'boundaries/elements': [
{ type: 'kernel-engine', pattern: 'packages/*' },
{ type: 'vertical', pattern: 'verticals/*' },
],
},
rules: {
'boundaries/element-types': ['error', {
default: 'disallow',
rules: [
// Verticals may import kernel engines, never each other.
{ from: 'vertical', allow: ['kernel-engine'] },
// Kernel engines may import other kernel engines, never a vertical.
{ from: 'kernel-engine', allow: ['kernel-engine'] },
],
}],
},
},
];
This is additive to Step 2, not a replacement — import/no-cycle still catches the engine-to-engine and vertical-to-vertical cycles in A.7 and A.8; boundaries/element-types catches the one-way case neither of those examples happens to demonstrate. Both belong in the same CI job from Step 4.
A.10 Tenant-Scoped Database Access — the withTenant() / withPlatformScope() Pattern
:::caution Naming corrected 2026-09-08
This section originally documented getDb()/withTenantScope(), per
db-access-pattern-decision.md's original text. That API was never
shipped — the real, installed @raritllc/platform-core@0.1.2 exports
withTenant(tenantId, fn) / withPlatformScope(fn) instead, an
explicit-parameter style with no AsyncLocalStorage anywhere. Confirmed
during the SAFAR and Clinivo vertical bootstraps; see the ADR's own
errata for the two questions this leaves open for the Architecture Review
Board. This section is corrected to match the real API below; the
underlying safety properties (RLS as primary boundary, atomic audit
writes, fail-loud on a missing scope) are described as confirmed for the
real API where the bootstraps actually exercised them, and flagged as
still-to-verify where they weren't.
:::
This section exists because of a real review comment: the examples in A.1, A.3, A.4, and A.5 originally called fastify.db directly inside route handlers, which is exactly the pattern db-access-pattern-decision.md (Accepted 2026-08-26) rejects. This section documents the accepted pattern on its own, separately from any single vertical's example, since it applies to all five engines and every vertical without exception.
The pattern, in three parts
-
One place opens the connection. The vertical app calls
initDatabase(connectionString)once at boot. No engine or vertical module readsDATABASE_URLitself. -
The service opens a scope. One use-case runs inside
withTenant(tenantId, async (db) => { ... }). This takes one client from the pool, runsBEGIN, setsstatement_timeoutandapp.tenant_idfor that transaction only, runs the callback, thenCOMMITorROLLBACK.set_config('app.tenant_id', tenantId, true)— the third argument,true— is PostgreSQL's function-call equivalent ofSET LOCAL: the setting is scoped to the current transaction and is automatically cleared onCOMMITorROLLBACK. This is exactly what makes the pattern safe with connection pooling: when the client is released back to the pool at the end ofwithTenant(), noapp.tenant_idvalue survives on that connection for the next, unrelated transaction to accidentally inherit. Using plainSET(session-scoped, not transaction-scoped) instead of this third argument would leak one tenant's context into whichever request happens to reuse the same pooled connection next — a real cross-tenant risk, not a theoretical one. This paragraph'sset_configmechanics describe the original ADR's design intent; confirm they still hold for the realwithTenant()implementation before treating them as verified fact — the bootstraps confirmed the function names and the explicit-parameter shape, not this specific transaction-scoping mechanism line by line. -
The repository receives the handle as a parameter. The service passes the
dbhandle it opened into the repository function explicitly — no global accessor, no framework import. There's no scope to forget: a repository simply cannot be called without a handle to call it with, which the type system enforces rather than a thrown runtime error. (This is a genuine behavioral difference from the original ADR's "getDb()throws when no scope is open" design — worth the Architecture Review Board's attention per the ADR's own errata, since a compile-time guarantee and a fail-loud runtime guarantee are both acceptable but not identical properties.)
RLS is the primary, non-bypassable boundary; the application-layer WHERE tenantId = ... filter is a secondary layer, not a substitute. If a developer forgets the explicit filter, RLS still blocks cross-tenant rows — that's the entire point of running both. But the reverse is not true and must never be treated as equivalent: if RLS were ever misconfigured, missing FORCE, or absent from a new table, an application-layer filter alone is not sufficient for this policy to consider that table's tenant isolation correct. When in doubt about which layer is "the" answer, RLS is. Confirmed still true for the real API: both the SAFAR and Clinivo bootstraps hand-edited generated migration SQL to add ENABLE/FORCE ROW LEVEL SECURITY, since drizzle-kit doesn't emit it itself — this part of the pattern held regardless of the accessor-naming question.
// service layer
export async function doSomething(tenantId: string, input: Input) {
return withTenant(tenantId, async (db) => {
const row = await repo.insertSomething(db, input);
await logAction(db, { tenantId, /* ... */ }); // joins the same transaction
return row;
});
}
// repository layer -- identical shape in every engine and every vertical
export async function insertSomething(db: DbHandle, input: Input) {
const [row] = await db.insert(something).values(input).returning();
return row;
}
Two properties matter enough to repeat here even though they're stated in the ADR — confirm both against platform-core's real source before relying on them, since the bootstraps confirmed the function names and shape but didn't specifically stress-test cross-package transaction-joining behavior:
- A scope inside a scope for the same tenant joins the same transaction; a different tenant inside an open scope throws. This is the mechanism that's meant to let the Audit engine write inside the caller's transaction —
logAction()takes the samedbhandle and joins whatever transaction it belongs to. No transaction object crosses a package boundary any differently than before; only the mechanism for obtaining the handle changed. - Repository function signatures gain exactly one new parameter to adopt this pattern. A function that took
(tenantId: string)before now takes(db: DbHandle, tenantId: string)— this is the one real signature change the explicit-parameter style requires, unlike the original ADR's claim that signatures wouldn't need to change at all. Every repository in this annex has been updated to reflect it (see the annex-wide errata above).
Where audit rows are stored follows directly from Section 3.2, not a separate decision. Since each vertical is "an isolated runtime and database" and engines are "embedded plugins... within each vertical's own backend process" (Section 4), Audit's logAction() call inside a vertical's withTenant() block writes to that vertical's own database — there is no separate centralized audit store today. This has a real consequence worth stating plainly: a query across every tenant's audit history, or every vertical's audit history in one place, is not possible without a new cross-database mechanism that does not exist yet. If a future requirement needs that (e.g., a platform-wide security audit dashboard), it is new scope, not something this pattern already provides.
Anti-pattern: fastify.decorate('db', ...)
:::danger NOT PERMITTED
One engine's codebase already had this pattern before the ADR (packages/notifications/src/db/drizzle.db.plugin.ts) — it is being removed (tracked as DBI-12), not extended. Do not copy it into new code.
:::
import Fastify from 'fastify';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
fastify.decorate('db', drizzle(pool));
// Then, anywhere a route or repository needs data access:
fastify.get('/properties', async (req) => {
return fastify.db.query.properties.findMany(/* ... */); // looks fine. Isn't.
});
Three specific, independent reasons this is rejected — any one of them alone would be sufficient:
- The handle is a pool, not a transaction. A mutation and its audit-log write become two separate implicit transactions. If the audit write fails after the mutation already committed, there is no rollback — the mutation stands, permanently unrecorded by Audit.
- The pool carries no
app.tenant_id. Row-Level Security policies compare againstcurrent_setting('app.tenant_id', true). A plain pool connection never sets it, so the policy has nothing to evaluate against and does not filter anything. This is the sharpest version of the risk: nothing crashes, no error appears, tests can still pass — the system just silently drops from two enforcement layers (the application's ownWHERE tenantId = ...filter, and the database's own RLS policy) down to one. Losing the database-layer lock is invisible until a specific cross-tenant leak is deliberately tested for. - It leaks the framework into the repository layer. A repository that needs
fastify.dbneeds the Fastify instance, which means importing or receiving it — directly contradicting the same separation this policy already requires of engine services (Section 4: services never import Fastify, never receiverequestorreply).
Also rejected: an explicit transaction parameter
A second alternative was considered and rejected in the same ADR: passing a transaction object as an explicit first parameter to every repository function, e.g. getPlan(tx, id). It is more conventional and more explicit than AsyncLocalStorage — but it was rejected because it would (a) change the signature of every repository function across all five engines and every vertical, and (b) require the Audit engine's write to join the caller's transaction by receiving that transaction object across a package boundary, which would make Drizzle's own transaction type a de facto part of every engine's public API — a Drizzle version upgrade would then become a breaking change everywhere at once.
:::caution This is, in substance, what actually shipped
The real withTenant(tenantId, fn) / withPlatformScope(fn) API confirmed
during the SAFAR and Clinivo bootstraps is an explicit-parameter design —
the option this ADR rejected, not the AsyncLocalStorage option it
accepted. Concern (a) (every repository signature changes) is confirmed
true in practice: every repository in this annex now takes a db
parameter it didn't take before. Whether concern (b) (Drizzle's
transaction type becoming public API) actually materialized, and whether
it's caused any real problem, is not something either bootstrap
specifically checked — worth a direct question to whoever owns
platform-core today, alongside the two open questions in the ADR's own
errata.
:::
What this costs, and how the cost is contained
The original design intent, if AsyncLocalStorage-based implicit context had shipped, would have been: a developer cannot tell from a repository function's signature alone whether it's safe to call, mitigated by getDb() failing loudly on a missing scope. Since the real API is explicit-parameter instead, this specific cost doesn't apply — a repository's signature now shows plainly that it needs a db handle, and there's no implicit state to forget. The real cost is the one "Also rejected" above named: every repository signature changed, across every engine and vertical. Two independent vertical builds absorbed that cost without incident, which is evidence it's a manageable one — but it's a different cost than the one this section originally weighed, not a smaller version of the same one.
A.11 Row-Level Security Patterns from Prior Art — What Transfers and What Doesn't
:::note Different in kind from A.1–A.10
Everything above this point documents a decision the team has already made. This section is an evaluation of an old, unrelated PostgreSQL system's RLS setup (a pre-platform-core accreditation-body database, not a RaR-IT project), reviewed for patterns worth adapting. A.11.1 and A.11.2 are genuinely useful and written in RaR-IT's own schema/naming conventions. A.11.3–A.11.4 are things the old system did that would be a regression if copied here, kept as concrete negative examples rather than abstract warnings. None of this section overrides db-access-pattern-decision.md — where they'd conflict, that ADR governs.
:::
A.11.1 Useful: scoping a child table that has no tenant_id column of its own
The current pattern examples (A.1–A.10) all show tables with a direct tenant_id column. A child table one join away — common enough to be worth a named pattern rather than reinventing it per engine — needs a subquery-scoped policy instead:
ALTER TABLE amaar.property_documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE amaar.property_documents FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON amaar.property_documents
USING (
property_id IN (
SELECT id FROM amaar.properties
WHERE tenant_id = current_setting('app.tenant_id', true)
)
);
This only stays fast if the subquery hits an index on both sides — amaar.properties.tenant_id (already required by the direct-column policy on properties itself) and amaar.property_documents.property_id. Add the second explicitly; it is easy to miss because the table "obviously" needs it for the foreign key, but a foreign key alone does not guarantee an index exists.
A.11.2 Proposed: an elevated role for legitimate cross-tenant reads
:::info Status: Proposed, tracked as Actions ACT-001 — not yet accepted The shape below is a concrete proposal, not a settled pattern. It requires Architecture Review Board decision before use, tracked in the Actions doc alongside the specific open sub-questions it doesn't resolve on its own. :::
This isn't hypothetical. Tourism's own backlog already has a story that cannot be built without it:
TEN-DASH-01 — As a Super Admin, I want a status dashboard across all tenants (active/suspended, plan tier, usage)... Given I open the Super Admin dashboard, When it loads, Then it lists every tenant with its status, plan, and last-activity timestamp.
"Lists every tenant" cannot be satisfied by withTenant(tenantId, fn) — it takes exactly one tenantId and opens exactly one RLS-scoped transaction for it, by design. Looping it once per tenant doesn't work either: you'd need to already know every tenant ID to loop over, which presupposes the answer the dashboard exists to provide.
The old system solved the general problem with per-database-role policies keyed to PostgREST JWT claims, which is not this platform's architecture (RaR-IT connects as a single APP_ROLE and reads tenant context from app.tenant_id, not a JWT claim inside Postgres). The proposal below borrows only the underlying idea — an elevated context that legitimately bypasses tenant scoping — not the PostgREST mechanism itself.
A separate, narrow function — never a parameter on withTenant(). A magic null tenantId to mean "admin mode" is exactly the kind of thing that becomes a bug later (was null intentional, or did someone forget to pass a tenant?). A distinctly-named function can't be reached by accident:
// Signature updated 2026-09-08 to thread the db handle as an explicit
// parameter, matching withTenant()/withPlatformScope()'s real shape --
// this function is still a proposal (see the sub-questions noted below
// the concrete example), not built or confirmed against real code either
// way, but should match the pattern actually shipped if it's ever built.
export async function withPlatformAdminScope<T>(
actor: AuthenticatedActor,
reason: string, // required, not optional -- see audit logging below
work: (db: DbHandle) => Promise<T>,
): Promise<T> {
if (!actor.roles.includes('platform_admin')) {
throw new ForbiddenError('platform_admin role required');
}
return withDbTransaction(async (client) => {
await client.query(`SET LOCAL app.role = 'platform_admin'`);
await client.query(`SET LOCAL TRANSACTION READ ONLY`);
await client.query(`SET LOCAL statement_timeout = ${ADMIN_STATEMENT_TIMEOUT_MS}`);
await logAction(client, {
actorId: actor.id,
action: 'platform_admin.scope_opened',
reason,
timestamp: new Date(),
});
return work(client);
});
}
Three more properties, each closing a specific way this could go wrong:
-
Read-only by default.
SET LOCAL TRANSACTION READ ONLYinside the scope. Almost every legitimate need is a read (a dashboard, a support lookup, a usage report); a cross-tenant write is a categorically scarier operation — a bug there could touch every tenant at once. A genuine cross-tenant write need would be a separate, more tightly gated function requiring its own Section 8 sign-off, not folded into this one. -
Opt-in per table, never a blanket policy change. The
OR current_setting('app.role', true) = 'platform_admin'clause is added only to the small set of tables an admin dashboard actually needs (tenant metadata, plan/status, aggregate usage) — never to business data (patient records, payments, customer data). Adding it platform-wide would turnplatform_admininto a single role that can read everything, defeating much of the point of RLS and creating one catastrophic point of compromise.Applied narrowly -- tenants and tenant_subscriptions, not business dataCREATE POLICY tenant_isolation ON iam.tenantsUSING (id = current_setting('app.tenant_id', true)OR current_setting('app.role', true) = 'platform_admin');CREATE POLICY tenant_isolation ON entitlements.tenant_subscriptionsUSING (tenant_id = current_setting('app.tenant_id', true)OR current_setting('app.role', true) = 'platform_admin'); -
reasonis required, not optional. Every call is logged, in the same transaction, beforeworkruns — captured verbatim, so an audit review later answers "why did someone read every tenant's status on this date" instead of just "someone did an admin thing, no context."
export async function getTenantStatusDashboard(actor: AuthenticatedActor) {
return withPlatformAdminScope(actor, 'Super Admin dashboard load', async (db) => {
return db.select().from(tenants); // no WHERE clause -- the RLS
// bypass is what makes this legal
});
}
See Actions ACT-001 for the sub-questions this proposal deliberately leaves open (who is granted platform_admin, and whether it's time-boxed or standing) — those are IAM/governance decisions layered on top of this mechanism, not blockers to the mechanism itself.
A.11.3 Not to copy: RLS enabled but never forced
The old system's own dump:
ALTER TABLE accred.appraisal ENABLE ROW LEVEL SECURITY;
CREATE POLICY appraisal_policy_webuser ON accred.appraisal TO webuser
USING (regid IN ( SELECT registration.id FROM accred.registration WHERE /* ... */ ));
No FORCE ROW LEVEL SECURITY anywhere in the dump. The app did correctly connect as a non-owner role (webuser, not the table-owning sascdb) — the harder half of the mistake DB_INTEGRATION.md warns about was avoided. But without FORCE, the table owner specifically — a migration script, an admin one-liner, anything that happens to connect as sascdb — silently bypasses every policy above. This is precisely the risk Part 7 of DB_INTEGRATION.md names: "A superuser ignores RLS. The owner of the table also ignores it. And there is no error message." Both ENABLE and FORCE are required together, every time; this old system is a real example of what having only one of them looks like.
A.11.4 Not to copy: business logic in triggers instead of the service layer
CREATE FUNCTION accred.notifyappraisal() RETURNS trigger AS $$
BEGIN
if NEW.status = 2 then
insert into public.notifications (typeid, objid, tobenotified) values (5, NEW.id, NEW.leadappraiser);
PERFORM moct_sasc_auth.addlog('Appraisal Approved', 'succeed, Appraisal ID:'||NEW.id);
end if;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
A status change fires this trigger, which writes directly to a notifications table and calls an audit-log function — entirely inside PostgreSQL, invisible to the application layer. Three specific reasons this would be a regression if copied into any RaR-IT engine or vertical:
- It bypasses Entitlements entirely. A trigger fires unconditionally; it cannot call
requireEntitlement(), so a notification could dispatch for a tenant whose plan doesn't include it. - It bypasses the event-bus pattern this policy already requires (Section 4, Annex A.7): engine-to-engine communication goes through published events specifically so Notifications stays decoupled from whoever triggers it. A DB trigger calling a notifications table directly is the reverse-import problem from A.7, just implemented in SQL instead of TypeScript.
- It's invisible to the normal test suite. Nothing in
*.service.tsor*.repository.tsshows this behavior exists; a developer reading the TypeScript service layer would have no way to discover that changing a status field has side effects.
The RaR-IT equivalent of notifyappraisal() is: the service layer's withTenant() block calls emitEvent('appraisal.status_changed', {...}) (per A.7's pattern) after the status update commits, and Notifications subscribes to that event. Same outcome, visible in TypeScript, entitlement-checked, no coupling at the database level.
A.12 Summary Table — Pattern Choice by Module
| Vertical / Engines | Module | Pattern Used | Why |
|---|---|---|---|
| AMAAR | Properties | ✅ Fastify (direct) | Ordinary CRUD; no internal collaborators to inject. |
| Transport | Commission & Settlement | ✅ Fastify (direct) | Thin consumer of the fixed-contract Payment engine; no vertical-side complexity. |
| Tourism | CRM & Customer Management | ✅ Fastify (direct) | Ordinary CRUD across a different vertical — confirms the pattern is the norm. |
| Clinivio | Clinical Templates Module | 🟡 NestJS (DI context) | Shared providers reused across 10 specialty templates — genuine DI use case. |
| AMAAR | Financial Forecasting | 🟡 NestJS (DI context) | Multiple interchangeable forecast strategies selected at request time — textbook strategy pattern. |
| Tourism (illustrative) | AI Intelligence | 🔴 Not permitted — Nest-owned HTTP | Shown in A.6 only to demonstrate why this pattern requires Architecture Review Board sign-off. |
| IAM ↔ Notifications | OTP delivery | 🔴 Not permitted — reverse import | Shown in A.7 to demonstrate the fix: IAM emits an event; Notifications subscribes and reads IAM read-only. |
| AMAAR ↔ Tourism | Financial Forecasting / Revenue Projection | 🔴 Not permitted — cross-vertical import | Shown in A.8 to demonstrate the fix: promote to @rarit-kernel/forecasting; both verticals depend downward instead of on each other. |
| All engines & verticals | Any repository's database access | ✅ withTenant() + withPlatformScope() | Mandatory per db-access-pattern-decision.md (see errata) — the only pattern that keeps a mutation and its audit row atomic and lets RLS actually engage. |
| All engines & verticals (illustrative) | Any repository's database access | 🔴 Not permitted — fastify.decorate('db', ...) | Shown in A.10 — silently drops RLS enforcement to zero and leaks Fastify into the repository layer. |
This annex is illustrative and will be extended as new real modules are built. It does not itself carry policy force independent of the main document — where this annex and the policy body appear to conflict, the policy body (Sections 1–10) governs.