Pulp Engine Document Rendering
Get started
Release v0.66.0

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

AreaWhat shippedNew env / API surface
SchemaTenant model, tenant_id columns on 8 scoped tables, composite uniques on templates.key and assets.filenamePostgres + SQL Server migrations (idempotent)
Store interfacesMandatory tenantId: string first arg on ~80 data-access methods across Postgres, File, SQL ServerInternal type-level change; TypeScript-enforced
Auth plumbingrequest.tenantId: string | null decoration; every built-in provider returns 'default'Internal — no new env vars in stage 1
Asset isolationPrivate-mode getByFilename(tenantId, filename) ownership check before streaming + render-time createTenantScopedBinaryStore() wrapperNone — closes pre-existing leak path
Schedule engineLatent unbounded findDueSchedules query capped via SCHEDULE_ENGINE_TICK_LIMIT; tenant threaded from each loaded scheduleSCHEDULE_ENGINE_TICK_LIMIT (default 500)
Forward-compat flagMULTI_TENANT_ENABLED defined but unused in stage 1MULTI_TENANT_ENABLED (default false)
Grep gateAllowlist-based CI check for missing tenant args and bare 'default' literalsscripts/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, TemplateLabel
  • Asset
  • Schedule, ScheduleExecution, ScheduleDeliveryDlqEntry
  • AuditEvent

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:

  1. The private-mode proxy now calls assetStore.getByFilename(tenantId, filename) and 404s on miss.
  2. A new createTenantScopedBinaryStore(binaryStore, assetStore, tenantId) wrapper at asset-inline.ts checks the metadata row before delegating stream(). 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

  • findDueSchedules was unbounded. postgres-schedule.store.ts had no take clause. New SCHEDULE_ENGINE_TICK_LIMIT env 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 errors
  • node scripts/check-tenant-propagation.mjs — passes clean
  • node scripts/check-template-resolution.mjs — passes clean
  • pnpm --filter @pulp-engine/api test — all real assertions pass; remaining failures are pre-existing Windows Fastify-startup timeouts documented in project_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-upWhat’s deferredWhere it lands
v0.67.0 Stage 2MULTI_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 testsv0.67.0
C.0b plugin contractFull plugin-api tenant-awareness across PluginIdentityProvider, PluginResolvedIdentity, PluginTemplateStore, PluginAssetStore, PluginAuditStore, StorageBackendFactory. Semver-major for plugin authorsv0.68.0+
C.0b startup audit loopstorage.plugin.ts legacy-SVG scan iterates across all tenants instead of hardcoding 'default'v0.67.0 / C.0b
C.0b Postgres RLSRow-Level Security policies as defense-in-depth alongside the type-level enforcementv0.68.0+
C.0b UserTenantMembershipCross-tenant users (consultant case). Stage 1 keeps a flat user namespace with tenantId as an attribution tag; cross-tenant duplicate IDs are rejected globallyDeferred until a customer asks
C.0b ApiCredential tableCredential CRUD routes instead of env-var-only API_KEYS_JSON. Stage 2 ships env-var onlyDeferred until a customer asks
C.1 per-tenant schedule enginesSharded engine with per-tenant polling, gated on a specific noisy-neighbor report. Stage 1 ships one global engineC.1 or later
C.1 tenant usage analyticsRenderUsage table, GET /usage, GET /usage.csvv0.68.0+
C.2 per-tenant rate limits@fastify/rate-limit keyGenerator resolving `(tenantId
SDK regenerationPython/.NET/Go/Java SDKs with the eventual tenantId field. Batched after stage 2 to avoid double-regenAfter v0.67.0

Files of interest