Release v0.66.0
Date: 2026-04-11
Theme
Phase C.0 Stage 1 — Tenant primitive (foundation refactor).
This release introduces the Tenant model and threads tenantId through every data-access store method as a mandatory first parameter, behind a hardcoded 'default' tenant. Single-tenant deployments upgrade with zero behavior change — no new env flags lit up, no new feature surface, no HTTP response shape change.
The full multi-tenant feature path (MULTI_TENANT_ENABLED=true, API_KEYS_JSON, tenant CRUD, super-admin scope, OIDC default tenant, plugin rejection policy, binary store tenant-prefixing) ships in v0.67.0 (C.0 Stage 2). Splitting C.0 across two releases lets the ~80-method refactor bake in production single-tenant deployments before any flag flips, so a regression in stage 1 is unambiguously a refactor bug rather than a feature bug.
At a glance
| Area | What shipped | New env / API surface |
|---|---|---|
| Schema | Tenant model, tenant_id columns on 8 scoped tables, composite uniques on templates.key and assets.filename | Postgres + SQL Server migrations (idempotent) |
| Store interfaces | Mandatory tenantId: string first arg on ~80 data-access methods across Postgres, File, SQL Server | Internal type-level change; TypeScript-enforced |
| Auth plumbing | request.tenantId: string | null decoration; every built-in provider returns 'default' | Internal — no new env vars in stage 1 |
| Asset isolation | Private-mode getByFilename(tenantId, filename) ownership check before streaming + render-time createTenantScopedBinaryStore() wrapper | None — closes pre-existing leak path |
| Schedule engine | Latent unbounded findDueSchedules query capped via SCHEDULE_ENGINE_TICK_LIMIT; tenant threaded from each loaded schedule | SCHEDULE_ENGINE_TICK_LIMIT (default 500) |
| Forward-compat flag | MULTI_TENANT_ENABLED defined but unused in stage 1 | MULTI_TENANT_ENABLED (default false) |
| Grep gate | Allowlist-based CI check for missing tenant args and bare 'default' literals | scripts/check-tenant-propagation.mjs |
What changed
Schema and migrations
A new Tenant Prisma model anchors every tenant-owned row. Eight existing models gain a tenantId column with FK to tenants(id):
Template,TemplateVersion,TemplateLabelAssetSchedule,ScheduleExecution,ScheduleDeliveryDlqEntryAuditEvent
Template.key and Asset.filename swap from single-column to composite uniques: @@unique([tenantId, key]) and @@unique([tenantId, filename]). The migration is idempotent: CREATE TABLE IF NOT EXISTS, INSERT ... ON CONFLICT DO NOTHING for the seeded default tenant, nullable column add → backfill → NOT NULL → FK in five distinct steps, and a duplicate pre-check guard before the unique-constraint swap (which is trivially safe in stage 1 because every row is 'default').
The SQL Server migration mirrors the Postgres one for templates, template_versions, template_labels, assets, and audit_events — schedule tables don’t exist in the SQL Server backend.
Store interface refactor
Every data-access method on ITemplateStore (13), IAssetStore (4 + new getByFilename), IScheduleStore (7), IScheduleExecutionStore, IScheduleDeliveryDlqStore, and IAuditEventStore (3) gains tenantId: string as a mandatory first parameter. Lifecycle and probe methods (ping, close, ensureDir, verifyAvailable) stay tenant-agnostic — they’re connectivity checks, not data access.
The TypeScript compiler is the primary “did the refactor land everywhere” signal: any missed call site is a build error. The grep gate is a belt-and-suspenders backstop for dynamic paths and future edits.
Private-mode asset isolation fix
Pre-C.0, the private-mode asset proxy at assets.routes.ts streamed by filename with no metadata check — any binary present on disk was served regardless of which template uploaded it. The same path was reachable via the render-time inliner at asset-inline.ts.
Stage 1 closes both leak paths under the hardcoded 'default' tenant:
- The private-mode proxy now calls
assetStore.getByFilename(tenantId, filename)and 404s on miss. - A new
createTenantScopedBinaryStore(binaryStore, assetStore, tenantId)wrapper atasset-inline.tschecks the metadata row before delegatingstream().inlineAssets()itself is unchanged — render routes construct the wrapper once per request and pass it in. Every existing asset-inline test keeps working without modification.
In stage 1 the tenant is always 'default', so this is functionally a single-tenant ownership check. In stage 2 the same code path enforces real cross-tenant isolation.
Internal DTOs vs HTTP responses
Internal store DTOs — TemplateSummary, TemplateWithDefinition, AssetRecord, AuditEvent, ScheduleRecord, ExecutionRecord, ScheduleDeliveryDlqEntry — carry tenantId so non-route callers (schedule engine, audit reads, plugin bridges) can thread tenant correctly without re-deriving it.
HTTP responses are unchanged. The asset routes use a toPublicAssetRecord() projection helper that strips internal tenantId before reply.send(). OpenAPI schemas, response shapes, and SDK contracts all stay identical to v0.65.0. Stage 2 may expose tenantId on super-admin routes, but that’s a separate OpenAPI update behind the super-admin scope.
Drive-by fixes
findDueScheduleswas unbounded.postgres-schedule.store.tshad notakeclause. NewSCHEDULE_ENGINE_TICK_LIMITenv var (default 500, range 10–10000) caps the query and the engine warns when the limit is hit so operators can tune.
Migration notes
Single-tenant deployments
You don’t need to do anything except apply the Postgres / SQL Server migration. No env vars to set, no behavior to validate, no SDK regeneration. The migration is idempotent and safe to re-run.
Application-code rollback
Reverting to v0.65.0 is safe for 'default'-only deployments. The new tenant_id columns and the tenants table are simply ignored by pre-C.0 code. Every existing row is 'default', so the old code continues to read and write successfully against the new schema. No data migration required.
Schema rollback
Schema rollback is non-trivial because the migration swaps templates.key and assets.filename from single-column to composite uniques. Reverting requires that no two rows share key or filename across distinct tenants — trivially true for stage 1 deployments, but not true for any deployment that has lit up stage 2 multi-tenant mode and created data under non-default tenants. Schema rollback is not recommended for stage 2 users.
The additive parts of the migration (the tenants table, the tenant_id columns, indexes, FKs) reverse cleanly. The composite-unique swap is the only step that can fail loud on rollback, and only when real multi-tenant data exists.
Verification
cd apps/api && pnpm exec tsc --noEmit— zero errorsnode scripts/check-tenant-propagation.mjs— passes cleannode scripts/check-template-resolution.mjs— passes cleanpnpm --filter @pulp-engine/api test— all real assertions pass; remaining failures are pre-existing Windows Fastify-startup timeouts documented inproject_flaky_tests_v1.md(verified to pass in isolation)node scripts/check-version.mjs— lockstep version bump verified across all 9 sites
Known limitations / deferred follow-ups
| Follow-up | What’s deferred | Where it lands |
|---|---|---|
| v0.67.0 Stage 2 | MULTI_TENANT_ENABLED=true actually usable. API_KEYS_JSON, API_KEY_SUPER_ADMIN, tenant CRUD, editor token tenant claim, OIDC default tenant, plugin rejection policy, render hook context tenantId, binary store tenant-prefixing for non-default tenants, cross-tenant isolation tests | v0.67.0 |
| C.0b plugin contract | Full plugin-api tenant-awareness across PluginIdentityProvider, PluginResolvedIdentity, PluginTemplateStore, PluginAssetStore, PluginAuditStore, StorageBackendFactory. Semver-major for plugin authors | v0.68.0+ |
| C.0b startup audit loop | storage.plugin.ts legacy-SVG scan iterates across all tenants instead of hardcoding 'default' | v0.67.0 / C.0b |
| C.0b Postgres RLS | Row-Level Security policies as defense-in-depth alongside the type-level enforcement | v0.68.0+ |
C.0b UserTenantMembership | Cross-tenant users (consultant case). Stage 1 keeps a flat user namespace with tenantId as an attribution tag; cross-tenant duplicate IDs are rejected globally | Deferred until a customer asks |
C.0b ApiCredential table | Credential CRUD routes instead of env-var-only API_KEYS_JSON. Stage 2 ships env-var only | Deferred until a customer asks |
| C.1 per-tenant schedule engines | Sharded engine with per-tenant polling, gated on a specific noisy-neighbor report. Stage 1 ships one global engine | C.1 or later |
| C.1 tenant usage analytics | RenderUsage table, GET /usage, GET /usage.csv | v0.68.0+ |
| C.2 per-tenant rate limits | @fastify/rate-limit keyGenerator resolving `(tenantId | |
| SDK regeneration | Python/.NET/Go/Java SDKs with the eventual tenantId field. Batched after stage 2 to avoid double-regen | After v0.67.0 |
Files of interest
- apps/api/src/prisma/schema.prisma —
Tenantmodel + child relations - apps/api/src/prisma/migrations/20260421120000_add_tenant_primitive/migration.sql
- apps/api/src/storage/sqlserver/migrations/006_add_tenant_primitive.sql
- apps/api/src/storage/types.ts — interface refactor
- apps/api/src/lib/asset-inline.ts —
createTenantScopedBinaryStore - apps/api/src/lib/template-resolution.ts — tenantId-threaded
resolveTemplate - apps/api/src/plugins/auth.plugin.ts —
request.tenantIddecoration - apps/api/src/lib/identity-provider.ts —
ResolvedIdentity.tenantId - apps/api/src/routes/assets/assets.routes.ts — private-mode ownership check +
toPublicAssetRecord - scripts/check-tenant-propagation.mjs — grep gate