Lasagna 090706/soft - #20
Merged
Arcoders merged 55 commits intoJul 16, 2026
Merged
Conversation
…ublishes them The publish decision is six packages: core, billing, backup, sso, reporting, ai. `admin` and `websockets` stay in-repo and unpublished. `private: true` is the one mechanical lever that stops `changeset publish` from first-publishing them at 1.0.0. It also releases three gates that key off the same field, so nothing needs a matching docs or coverage change: - scripts/check-stability-versions.mjs:93 - scripts/check-satellite-coverage.mjs:62 - packages/core/tests/@architecture/docs/public_api_documented.spec.ts:71
Installing the packed tarball into a fresh AdonisJS 7 app shows the first command
in the quickstart was a silent no-op. Four defects, none caught by the 46 gates:
1. The root barrel never exported `configure`. @adonisjs/core's configure command
does `app.import(pkg)` and reads `packageExports.configure`; without it the
command warns "the module does not export the configure hook" and exits 0.
@adonisjs/lucid re-exports its hook from its root entry the same way.
2. 22 of 30 stubs could not be rendered. AdonisJS runs every stub through
`tempura.compile()`, which builds a JS template literal out of the file, so an
unescaped backtick, `${` or backslash in the body aborts compilation or
corrupts the generated file. config/multitenancy.stub died on a `ttlMs` inside
a comment; billing's Edge view died on `{{ quota }}`, which tempura evaluated.
3. Stubs called `app.appPath()`, which Application does not expose. Nobody
noticed because (1) and (2) meant that stub was never rendered.
4. No migration created the central `tenants` table, so `tenant:create` hit a
missing relation. tenant.stub also imported @adonisjs/cache, which nothing in
the monorepo depends on and configure never installs.
The unit specs missed all of this because their `makeUsingStub` double never
evaluates a stub's frontmatter, and the demo app is hand-written, not generated.
Changes:
- Export `configure` from the root barrel, and keep it there however far the
barrel is later trimmed.
- Escape the body of every stub. The generated files are byte-identical to
what the sources intended; verified by round-trip.
- Publish `create_tenants_table` unconditionally, before the "no features
selected" early return. Its filename carries a fixed 0 prefix so it always
sorts ahead of `--with=maintenance`, which ALTERs the same table and would
otherwise lose a same-millisecond `Date.now()` tie.
- Scaffold `app/repositories/tenant_repository.ts` and
`providers/tenancy_provider.ts`, and register the provider in adonisrc. The
TENANT_REPOSITORY binding was documented but never generated, and the doc
snippet did not even typecheck: it bound 3 of the contract's 7 methods and
was missing `create`, the one `tenant:create` calls.
- Drop the @adonisjs/cache import; `invalidateCache()` is a host concern that
TenantRepositoryContract explicitly does not require.
New gate, scripts/check-stub-render.mjs, wired into `npm run check`. Its
invariant: escaping what a stub renders must give the stub back. That is
stronger than "it compiles" — it also catches a `${foo}` that interpolates away
and a literal `\n` that becomes a real newline. It also rejects frontmatter that
calls an `app.<x>()` helper Application does not have. `--self-test` covers both
directions; it caught a bug in the guard's own first draft.
Docs: quickstart, installation and tutorial/setup now describe what configure
writes instead of asking the reader to hand-copy a repository that was wrong.
installation.md no longer claims configure publishes every satellite migration
by default; it publishes none.
Packing the tarball into an app scaffolded by `npm init adonisjs` and driving
`configure -> backoffice:setup -> tenant:create -> queue:work` is the only gate
that sees the package the way a stranger does. Every other one resolves
@adonisjs-lasagna/saas-tenancy through the workspace symlink.
Beyond the four defects fixed in the previous commit, it found five more, each
of which broke the documented golden path:
- The scaffolded config/multitenancy.ts read 16 env vars the host never
declares, so `env.get()` was a compile error in the file configure had just
written. `configure` now calls `defineEnvValidations` first. It skips keys
the host already declares, so a re-run is safe and a stricter host rule wins.
- That same config shipped an enabled `backup` block. The backup satellite is
what contributes `backup` to MultitenancyConfig, so a bare install could not
compile it. Shipped commented out, with the reason.
- `searchPath` was documented (and written by tenant.stub) *inside* the Lucid
`connection` object. It belongs beside it, on the connection node. Nested, it
fails to typecheck and silently leaves the connection on `public`.
- config/database.ts needs a `tenant` template connection: SchemaPgDriver
clones it for every tenant_<uuid>. None of the three install pages mentioned
it, so provisioning died with "template connection not found".
- tenant.stub's `schemaName` rewrote the uuid's dashes to underscores, but
SchemaPgDriver appends the raw uuid. The scaffolded model therefore named a
schema that never exists: `uninstall()` would DROP a non-existent schema and
silently leave the tenant's data live. `src/testing/builders.ts` had the same
bug, and `behavior_builders.spec.ts` pinned it ("replaces hyphens with
underscores"). The spec now asserts against SchemaPgDriver itself so the
double and the driver cannot drift again.
The gate lives in scripts/clean-install-smoke.sh so it runs the same on a laptop
as in CI, and asserts the tenant reaches `active` AND owns its schema — reading
pg_namespace, not information_schema.schemata, which hides schemas the current
role does not own.
Regenerated etc/saas-tenancy.api.md for the new `configure` export.
Never published to npm, zero forks. Withholding it costs nothing, and it is a
commodity: crypto-shredding is a well-understood AEAD + per-subject-DEK pattern.
Removal, not secrecy — what shipped MIT stays MIT, and it is still in git history.
Removed: packages/crypto, design/data-protection-satellites (which telegraphed the
unshipped vault/governance roadmap in a public repo), the eleven
check-crypto-invariant-*.mjs guards, build:crypto and its link in the build:all
chain, and the crypto steps in CI. Also dropped the two stale review branches from
the CI push trigger; they were flagged "REVERT before merging" and never were.
The demo loses its crypto controller, SecureNote model, secure_notes migration,
worm-ledger migration (now unconsumed) and config block.
Two things deliberately kept:
- packages/core/src/utils/crypto.ts and secret_at_rest.ts stay. webhook_service,
sso_service, tenant_secrets_reencrypt and tenant_webhooks_encrypt_secrets all
depend on them; removing them would silently un-encrypt webhook signing secrets
and SSO client secrets at rest. Their docstrings no longer advertise a package
that does not exist.
- The crypto e2e held a proof that had nothing to do with encryption: that
`authorizeTenantAccess` refuses a caller whose principal belongs to another
tenant, over real HTTP, before any controller runs. Deleting the file would have
quietly dropped a security guarantee, so it is ported to
examples/api/tests/@integration/e2e/membership_gate.spec.ts, retargeted at
/demo/notes. Core already covers the same seam against its fixture app.
check-extension-contracts.mjs and check-satellite-config-wiring.mjs each carried a
hardcoded crypto entry and failed loudly, which is what they are for.
Verified: build:all, typecheck (12 workspaces), check (36/36 guards), core unit
(1630), test:integrity (10), lint, knip:deps, check:api-report (4/4), docs:build
(dead-link gate, 79 redirect stubs), and the demo e2e suite (225 passed).
Stop the docs claiming things that are not true.
**Phantom installs.** Three `npm install` lines pointed at packages that 404 and
always will: `@adonisjs-lasagna/admin` and `@adonisjs-lasagna/websockets` are
unpublished by choice. Rather than delete 937 lines of accurate documentation for
code that exists, works, and is exercised by the demo e2e suite, each page now opens
with a banner saying the package is not on npm and how to vendor it. The other
23 install lines name the six packages that will be published, so they become true
at first publish rather than being wrong today.
**sponsor.md contradicted the plan.** It promised, in a dated, quotable, public
page: "Every feature ships in the public package: no premium tier, no locked
features, no 'sponsor edition'." That absolutism cannot survive Unreallab keeping a
hosted service or an operator dashboard internal. Replaced with a covenant we can
actually keep: what shipped MIT stays MIT; some of what we build on top stays
internal; any future paid surface is NEW code, source-available from commit one,
never a relicense of anything already public.
**showcase.md is gone.** "Lasagna reached 1.0 recently" — it never released, and a
five-star project does not have a gallery. A showcase page at this stage reads as
vaporware.
**packages/README.md** was a 420-line internal migration runbook that publicly
admitted "a wiring mistake there would not be caught by the local gates" — the most
damaging sentence in the repository. Replaced with an index of what each workspace
is, and the four places a new workspace has to touch.
**docs/testing/MISSING_FEATURES.md** was a QA scratchpad ("the brief asked us to
flag…"). Deleted.
**roadmap.md / introduction.md** no longer list admin and websockets among the
published satellites, and no longer promise an Inertia+Vue dashboard or a starter
kit as roadmap items.
The "1.0" framing across these pages is left alone on purpose: versions are Stage 4,
and splitting that across two commits would just churn.
Verified: check (36/36), docs:build (dead-link gate), test:integrity (10),
check:doc-coverage.
The root barrel goes from 186 exported symbols to 95, the exports map from 25 subpaths to 20. Nothing becomes unreachable: everything pulled off the root still lives on a published subpath, and everything on a de-listed subpath still lives on the root or on /internal. The plan for this stage claimed that every symbol on /adapters, /middleware, /helpers and /mixins already existed on the root barrel. That holds for /adapters (3 of 3) and /helpers (1 of 1). It is false for the other two: /middleware also exports TrackMetricsMiddleware and enforceRateLimit, and /mixins exports TracksDataChanges, none of which are on the root or on any other subpath. De-listing those two would have orphaned nine public symbols and broken the demo, the benchmarks fixture, core's own fixtures, two integration specs, eleven compiled doc fences, and the /middleware import instruction that `node ace configure` prints to a fresh install. Both stay listed. De-listed (no importer anywhere): /crypto, /worm-ledger, /adapters, /helpers, /extensions/request. The last one was the only public path to __setMemoizedTenant and its four sibling test seams, which are now private. Off the root, onto their own subpath: the four extension registries, the bootstrapper helpers, the six satellite models, safeFetch, the service type cluster. Onto /internal: the AEAD envelope primitives and the SSRF URL guards. readSecret / writeSecret / SECRET_CLASS stay on the root, because storing a webhook or SSO secret at rest is a host-app job and the SSO guide says so. The RLS helpers stay on the root too: they are not on /services, and the rowscope guide imports them from the bare root. setConfig stays for the same kind of reason, 319 call sites across 102 files, usually on the same import line as getConfig. __resetConfigForTests moves off the public /config subpath onto /testing. It reaches the config singleton through the new, unexported src/config_store.ts rather than re-deriving the Symbol.for key, so a rename cannot silently turn the reset into a no-op. Four new guards in public_api_surface.spec.ts pin all of this: configure stays on the root barrel, the de-listed subpaths stay de-listed, exports and typesVersions describe the same subpaths, and config.ts exposes no __*ForTests seam. Also corrects a doc-truth bug found on the way: the core and admin changelogs promised the old /admin subpath "remains a deprecated throwing shim for one minor", but no ./admin key was ever in the exports map. Verified: build:all, typecheck (13 workspaces), check, check:api-report, check-docs-code, lint, knip:deps, test:integrity, core unit (1634), core integration (388), demo e2e (225), docs:build, and the clean-install smoke.
docs/reference/release-notes.md is generated by docs/scripts/sync-changelog.mjs from packages/core/CHANGELOG.md on every `npm run docs:sync` (which docs:build runs first). The surface-freeze entry landed in the changelog after the last docs:build, so the committed page was stale. Also repoints the stability link in that entry at its GitHub URL: a path relative to packages/core/ does not resolve once the entry is rendered into the docs site.
Core drops from 1.0.0 to 0.3.0; the five publishable satellites drop to 0.1.0 and are relabeled experimental. Nothing was ever published at 1.0.0: npm holds only saas-tenancy 0.1.0 through 0.2.2, and the satellites 404. So 0.3.0 is an upgrade over the registry, not a downgrade. The next green CI run on master would have published six packages at 1.0.0. release.yml does not fire on a tag, as the plan assumed. It fires on `workflow_run` when CI succeeds on master, and with no changesets pending changesets/action skips the Version PR and runs `changeset publish` directly. npm versions are immutable, so that was a one-way door opened by a merge rather than by a decision. The publish now requires the `PUBLISH_ENABLED` repository variable to be set to 'true'. Three more things the plan did not see: - The caret trap. All five satellites peer-depended on the core at `^1.0.0`, which no 0.x core can ever satisfy, so every `npm install` of a satellite would have failed with ERESOLVE. Changesets cannot repair it: it computes from the 1.0.0 base and only bumps upward. The ranges are now `>=0.3.0 <1.0.0`, a band rather than `^0.3.0`, because caret on a 0.x pins the minor and would lock all five out on every core minor. - The stability guard contradicted the plan. check-stability-versions.mjs required a release candidate to be >=1.0.0, so core at 0.3.0 failed CI. That rule pushed toward inflating the version to justify the label. It now only forbids the combinations that lie: experimental must be 0.x, and stable must be >=1.0.0. A release candidate may sit at any version, and 0.x is the more conservative pairing. Its BANNED_PROSE pass is inverted to match: it used to fail CI on prose calling the satellites experimental, which is now the truth. - publish.yml, the break-glass publisher, was broken. Under `set -euo pipefail` it called publish_pkg on admin and websockets (both `private: true`, so npm aborts with EPRIVATE) and on packages/crypto, deleted in Stage 2. A manual run would have shipped core, sso and billing, then died. check-publish-coverage now guards both directions instead of only flagging missing packages. Docs move with the versions: `upgrade-to-1.0.md` becomes `upgrade-to-0.3.md` (with a redirects.json mapping and every inbound link repointed), the stability matrix and all nine README badges tell the truth, and the eight changelogs are renumbered. The satellite changelogs each carried two `[0.1.0]` entries after the renumber, since an earlier unpublished 0.1.0 already existed; they are merged into the one first release. sync-changelog.mjs listed admin (private) and omitted reporting and ai. No package was published and no tag was cut. Verified: build:all, typecheck (13 workspaces), check (all guards, incl. both rewritten self-tests), lint, knip:deps, test:integrity, core unit (1634), core integration (388), all 7 satellite suites, demo e2e (225), docs:build.
Every migration stub named its own output `${Date.now()}_<name>.ts`. Two stubs of
one batch render in the same millisecond and tie, and `migration:run` sorts by
filename, so the tie fell through to the alphabet:
create_tenant_webhook_deliveries_table < create_tenant_webhooks_table
add_processing_status_to_billing_... < create_billing_processed_events_table
The first is a race. `configure --with=webhooks` failed whenever those two stubs
shared a millisecond, because the deliveries table's foreign key points at a
table that did not exist yet.
The second always failed. `publishSatellite` walks the stub dir in
`readdir().sort()` order, so billing published its ALTER ahead of the CREATE it
alters, and `migration:run` aborted on every clean install of the satellite. Only
new installs were affected: the four-value status enum that ALTER adds is already
in the create migration.
Nothing caught either one. The clean-install gate never passed `--with`, so it
published no migration with a dependency; and the unit double for `makeUsingStub`
appended a monotonic counter to `Date.now()`, which made a tie impossible.
Mechanism: `finalizeNewMigrations` re-stamps a published batch so publish order is
run order, clearing every timestamp already in the directory. The toolkit already
owned the final filename (it namespaces satellite migrations there), so this
extends that seam rather than adding one.
Policy: a satellite's stub filenames now carry an `NNNN_` publish-order prefix,
which is stripped before the migration reaches the host. It is not part of the
migration's identity, so numbering billing's existing stubs does not republish
them for hosts that already ran them.
Guards, so neither can come back:
- `check-migration-order.mjs` reads the createTable / alterTable / inTable / raw
ALTER TABLE statements out of every stub and fails when a batch declares a
dependency it publishes too late. Also requires ordinals in any satellite stub
dir holding two or more stubs, since one unnumbered sibling jumps the sort.
- `clean-install-smoke.sh` now configures `--with=webhooks,maintenance`, so real
PostgreSQL adjudicates the foreign key.
- The `makeUsingStub` double freezes its clock, making the tie certain. The three
new specs each fail without the fix.
Also corrects a third untruth found on the way: `configure` told the operator the
maintenance migration adds an `is_maintenance` column. It adds `maintenance`.
The sidebar had one tier: Start, Guides and an 18-item Reference, all expanded. A newcomer met every page at once, and the page that answers the only question they arrive with (does this leak tenant A's rows into tenant B) sat inside a collapsed Production group. Two tiers now. Start, Core concepts, Trust and Extend stay open; Reference and Advanced collapse. security.md leads Trust under the name it earns, architecture.md stays in the top nav. Nothing is dropped: every page a guard pins keeps exactly one entry, because docs_nav_documented flags any tracked page no menu links to. Demoting a page means moving its link, never deleting it. Adds create-lasagna-saas, private and unpublished. It runs the one install sequence this repo proves on a clean machine, the one clean-install-smoke.sh drives against a packed tarball. Its reason to exist is the last step: configure never writes config/database.ts, so the file that decides whether queries land in a tenant's schema is the file nobody generates. Both defects that shipped in the install docs are pinned by specs here. searchPath beside connection, and a tenant template connection for the schema driver to clone. src/ is pure. Options map to a plan, the plan maps to actions, and run.ts is the only module that spawns or writes. That is what lets --dry-run print the exact sequence and lets the suite pin the order without a network. Core is installed with a floor rather than unpinned. Below 0.3.0 the root entry never re-exported the configure hook, so configure warned, exited 0 and published nothing; an unpinned resolve would hand back an app that only fails later, at tenant:create, on a missing relation. npm refuses the install instead. npm is invoked through its own cli.js under the running node. Spawning npm.cmd without a shell fails with EINVAL on every supported node, and a shell would let cmd.exe read the >= in the version floor as a redirection. No argument this package builds ever reaches a command interpreter.
Wave 0 pilot of the comment-cleanup handoff. Rewrites comments in eight core hot files to a single human voice: em-dash separators become the punctuation the sentence actually needs, mapping/consequence arrows become words, aligned-dash lists become term: definition, and internal codes (SEAM-N, I/E/S, "lote") are expressed in prose. Conventions that already are the voice are kept: best-effort, ALL-CAPS emphasis, defense in depth. No code, strings, identifiers, or test titles changed. Verified by a token-stream oracle that parses HEAD and the working tree and asserts the non-comment token stream is byte-identical, plus core build, typecheck, eslint, check-api-report (golden in sync), and the 37-guard check suite. Files: quota_service, types/config, extensions/request, isolation/driver, isolation/rls, sdk/plugin, providers/multitenancy_provider, configure.
Extends the Wave 0 pilot across the remaining packages/core/src (162 files, partitioned by subdir). Same ratified rules: em-dash separators become the punctuation the sentence needs, mapping/consequence arrows become words, and internal codes in comments (SEAM-N, I/G/E/C/S/B/P finding+invariant ids, "lote") are expressed in prose. The real ids still live in test titles and script names, so nothing depends on the comment labels. Conventions that already are the voice are kept: best-effort, ALL-CAPS emphasis, defense in depth, opt-in. No code, strings, identifiers, or test titles changed. Each file was verified by the token-stream oracle (parse HEAD and the working tree, assert the non-comment token stream is byte-identical), plus core build, typecheck, eslint, check-api-report (4 goldens in sync), and the 37-guard check suite.
Rewrite comments and JSDoc across the 10 satellite packages (admin, ai, backup, billing, reporting, doc-coverage, satellite-test-kit, sso, websockets, create-lasagna-saas) to the same calm, why-first senior-engineer voice as core: em-dash separators and arrows dissolved into prose, internal provenance codes expressed in words. Comment-only, proven token-for-token identical against HEAD by the AST oracle; the five exemption-marker families, JSDoc tags, and the published AI-security I/G contract codes are preserved.
…e section Capability-first H1, neutral-ink CTAs with terracotta kept as an accent (never overriding --vp-c-brand-*), and an AI satellite added to the orbit. HomeExtend is rewritten into 'Extensible by contract', whose two fleet-wide contract integers are read straight from packages/core/src/sdk by a new VitePress data loader (contracts.data.ts) so the landing page cannot drift from the code. Drops the dead HomeAdoption panel. Also narrows the blanket docs/.vitepress/ gitignore rule that silently dropped any NEW tracked theme file (now only cache/, dist/, and the timestamp artifacts are ignored), fixes a stale plugins guide that claimed shipping providers hand-write SatelliteProviderContract (they all use definePlugin), and guards check-positioning against a file deleted in the working tree but not yet staged.
Rewrite comments and JSDoc across the demo app's controllers, models, services, listeners, validators, repositories, config, start, and backoffice migrations to the same calm, why-first voice as core and the satellites: em-dash separators and 'then' arrows dissolved into prose, mapping arrows spelled out, and the demo AI config's I4/I8 shorthand expressed as tenant-context purity and the mandatory output bound (that comment is not CI-parsed; only packages/ai/src is). Comment-only, proven token-for-token identical against HEAD by the AST oracle over all 26 files; string and template literals (log lines, exception messages, route paths) were left untouched. The e2e test suite under examples/api/tests is deliberately out of scope here and lands in wave 4.
Rewrite comments, JSDoc, and file headers across 279 test files (all of packages/*/tests and examples/api/tests) to the same calm, why-first voice as the rest of the repo: em-dash separators dissolved into prose, mapping and 'then' arrows spelled out, aligned arrow-tables turned into term: definition lists, and internal provenance codes that appeared in comment text (WS-*, I/E/G/SEAM shorthand) expressed in plain words. Comment-only: proven token-for-token identical against HEAD by the AST oracle over every file, which by construction leaves all test titles, group names, evidence.ref values, and assertion messages (string literals that carry CI-asserted traceability codes) exactly intact.
Rewrite comments and JSDoc across the benchmark harness, fixture, and the http/memory/micro/resilience/soak suites to the same calm, why-first voice as the rest of the repo: em-dash separators dissolved into prose, pipeline and sequence arrows spelled out, and the one WS-shorthand in the fixture config expressed in words. Comment-only, proven token-for-token identical against HEAD by the AST oracle over all 27 files; bench and group names, log lines, SQL, and metric labels (string literals) were left untouched.
Dissolve em-dash clause separators in the running body prose of 19 guide and reference pages so the docs read in the same calm voice as the code: each becomes the sentence break, comma, parentheses, or colon the sentence actually wanted. Scope is deliberately narrow. Site conventions that use an em-dash on purpose are preserved untouched: table cells (including the '—' not-applicable cells), 'Read next' link/gloss trails, term/definition list bullets, headings, and frontmatter descriptions. Fenced code, inline code, and every link target are byte-identical to HEAD (verified by a markdown structural guard), and the published AI-security I/G contract codes in ai.md are unchanged. Verified with the structural guard over all 19 files plus test:integrity (10/10). The docs:build dead-link gate was not run locally (it OOMs here); no link target or anchor was touched, so CI's link check remains the backstop.
Adds @adonisjs/auth 10.1.0 to the demo (lockfile regenerated via the workspace-aware install), wires its provider and the initialize_auth router middleware, and declares one access-tokens guard per plane in config/auth.ts: `backoffice` for operators, `tenant` for tenant users. Schema routing needs no configuration here because DbAccessTokensProvider resolves storage through each model's package-installed adapter. New config/hash.ts pins scrypt for both realms. The kernel swaps the old demoAdminAuth named middleware for the real `auth` middleware and gains a `centralOnly` entry, and `npm run setup` now chains demo:seed. Verified against the published tarball before building on it: the guard memoizes authentication, token compare is timing-safe (safeEqual) with expiry enforcement, ctx.auth module augmentation is present, and token.value/delete signatures match the plan's assumptions.
Two realms that share nothing. Operators live in
backoffice.backoffice_users (uuid ids, bko_ tokens stored in the
backoffice schema) and log in on the central plane; tenant users live in
each tenant schema''s own users table (tnt_ tokens stored alongside), and
their login sits inside the tenant-guarded /demo group so the credential
lookup and the minted token both land in the resolved tenant''s schema.
The prefixes are diagnostic only: nothing branches on them.
The admin API, /metrics and the reporting dashboard now mount
middleware.auth({ guards: [''backoffice''] }) and resolveAdminActor
returns the authenticated operator''s id or null (the spoofable
x-admin-id header and its uuid fallback are gone). The membership gate
moves to app/security/membership_authorizer.ts: the e2e stand-in header
must match the resolved tenant, any bearer must pass the tenant guard''s
check() (one memoized token lookup per request, shared with the route
middleware), and anonymous requests stay open for demo exploration where
a real app would deny.
The central login group is wired through the named centralOnly
middleware rather than the router.central() macro: the macro passes bare
instances to route.use(), which http-server invokes as
handle(containerResolver, ctx, next), so the macro 500s on real HTTP.
That core bug is left for its own fix; the named path is the supported
one and keeps this change host-side only.
Seeding is secure by default. The afterMigrate hook seeds the demo
tenant user only when DEMO_SEED_TENANT_USERS is set (absent = off),
never in production, idempotently, inside tenancy.run; demo:seed upserts
the operator and refuses to run in production. Credentials live in one
place (app/helpers/demo_credentials.ts). DEMO_ADMIN_TOKEN leaves the env
contract.
…contract The suite seeds the demo operator and mints its bearer once in the e2e suite setup; ADMIN_HEADERS starts empty and is filled there, so admin_audit reads it through a lazy HEADERS() accessor and asserts attribution against the seeded operator''s uuid via operatorId(). The boot_misconfig case that covered DEMO_ADMIN_TOKEN now covers TENANT_HEADER_KEY, and the mail-outage liveness ping asserts a strict 200 now that the suite bearer is always valid. New auth_realms.spec.ts pins the two-realm contract end to end: login, me and logout per realm; cross-tenant token reuse refused; the token row-id collision case proving rejection comes from the timing-safe hash compare rather than row absence; realm separation both ways (operator token 403 on tenant routes via the gate, tenant token 401 on /admin via the guard); deterministic expiry forced in SQL; independent identities for the same email in two tenants; and the credential/validation fail paths. DEMO_ADMIN_TOKEN is eradicated everywhere it lived: env contract, .env.example (replaced by DEMO_SEED_TENANT_USERS=true for the dev/e2e stack), both CI e2e jobs (the demo job gains the seed flag; the deploy smoke stack deliberately stays without it to keep proving the default-off path), the deploy e2e compose file, and the README, whose admin recipes now show the real login-then-bearer flow. The e2e scripts top up a pre-existing .env with the seed flag, since they own .env provisioning.
The authentication guide grows a "Two auth realms: operator and tenant"
section absorbing the old central-and-operator-users note: the realm
contract table, a two-guard config/auth.ts, the operator realm with its
hardening callout (MFA, short lifetimes, separate password policy, never
the tenant user table, rate-limited logins), the tenant realm with the
per-schema token storage consequence spelled out, the one-line
membership gate over auth.use('tenant').check() with its fail-closed
semantics, the impersonation interplay warning for deny-by-default apps,
the session-guard alternative, and a token-pruning note. The security
guide's hardening checklist gains the separate-realms row.
The admin REST API guide now points at the demo's real backoffice guard
instead of the deleted header-token middleware, and the audit guide's
query example sends a bearer.
router.tenant()/central()/universal() handed route.use() bare middleware
instances behind an `as any`. The route executor runs middleware in
exactly two shapes (http-server src/router/executor.ts): a plain function
is invoked as fn(ctx, next), and anything else is treated as a
ParsedNamedMiddleware whose handle receives the container resolver first,
handle(resolver, ctx, next, args). A bare instance satisfies neither, so
its handle ran with the resolver where it expects the HttpContext and
every macro-scoped request died in a 500 TypeError. The demo's new
operator realm was the first real-HTTP consumer of a macro, which is how
it surfaced; the existing unit specs only asserted what the group
wrapped, never what the executor could run.
The root fix is a toRouteMiddleware adapter: everything a macro stacks,
the core scope middleware and the plugin middleware from
TenantMiddlewareRegistry (which accepts a function or { handle }), is
wrapped into the plain-function form, with this kept bound and the
wrapper renamed after the real middleware so route debug traces stay
readable. The `as any` on the use() call is gone; the array now
typechecks as MiddlewareFn[], which is what would have caught the bug in
the first place, and the registry's doc comment no longer claims the raw
object form is accepted by use().
Tests now pin the executable contract at both tiers. The unit spec
replicates the executor's exact calling convention and drives the real
TenantGuardMiddleware, CentralOnlyMiddleware and UniversalMiddleware
through the adapted entries (ignored-path, tenant-less, and
tenant-carrying requests), asserts every stacked entry is a plain
function, and proves object-form plugin middleware sees the HttpContext
with this bound. A new integration spec drives all three macros over
real HTTP against the fixture app (200 on the central and universal
planes, 404 E_CENTRAL_ROUTE_VIOLATION for a tenant id on a central
route, 200 through the tenant guard with a resolved tenant, 400 without
one), mounted at /macro/* in the fixture routes.
The operator realm's central plane went in through a named centralOnly middleware because the router.central() macro 500ed over real HTTP at the time. With the macro fixed, the demo goes back to the documented canonical API, which also makes the auth_realms e2e a real-HTTP consumer of the macro on every CI run. The now-unused centralOnly named middleware leaves the kernel.
The humanization waves covered packages/*/src but walked past packages/*/providers, packages/*/configure.ts, packages/*/bin, the demo's tests/ and scripts/, and a handful of root-level core modules. Those trees still carried 96 em-dash separators and arrows in comment prose across 43 files. Same treatment as the waves: every em-dash separator becomes what its sentence actually needs (a period and a new sentence, a comma, parentheses, or a colon before a consequence), arrows in prose become words, and internal finding codes on the touched lines are restated in plain words. String literals, test titles, marker-comment families, box-drawing banners and JSDoc tags stay untouched, and CAPS emphasis stays a kept convention. Verified comments-only with a token-stream oracle: the parsed AST token stream of every edited .ts file is identical to HEAD (JSDoc excluded as trivia), and the shell scripts' non-comment lines are byte-identical. Unit suite and every repo guard stay green.
…est group titles The humanize sweep grepped the spaced ' — ' form, so four em-dashes sitting at the end of a comment line slipped through; they now read as the sentence wants (a period, a comma, or plain prose). The two test groups this branch introduced also lose the em-dash in their titles in favor of a colon; pre-existing titles keep theirs, since they carry traceability. Comments-only for the .ts prose, verified with the AST token-stream oracle.
…he worker
The clean-install smoke gate timed out at 25 minutes on its first-ever CI run.
The smoke passed (tenant reached active in under two minutes); the hang was in
the exit trap, which SIGTERMs the backgrounded queue:work and waits on it. The
worker never exited.
A Node --report-on-signal dump taken in a container repro showed exactly one
active, referenced libuv handle keeping the event loop alive after the signal: a
TCP socket to Redis. Its owner is TenantQueueService, the MultitenancyProvider
singleton that keeps a persistent BullMQ Queue per dispatched-to tenant, each
holding its own ioredis connection. A worker that processed an InstallTenant job
opens one such handle ("Tenant queue initialized"), and nothing ever closed it:
app.terminate() drains Lucid's pools and the shared @adonisjs/redis connection,
but not these, and the provider's shutdown() only reset module caches.
Give the service a non-destructive closeAll() (close every open Queue, releasing
its ioredis, and clear the map plus LRU bookkeeping; unlike destroy() it does not
obliterate the durable queue, so a queued job survives a graceful restart), and
call it from the provider's shutdown() via an extracted closeOwnedHandles() so
the wiring is unit-testable without an Ignitor. The other handle-owning
singletons are already clean on this axis: opossum's timers are unref'd and the
quota and rate-limit paths borrow the shared @adonisjs/redis connection.
Verified in the container repro: the same worker now exits two seconds after
SIGTERM instead of surviving past the 20s report window. No change to the smoke
script itself; the real fix makes its wait return promptly.
The shutdown fix adds a public closeAll() to TenantQueueService, which shifts the package's public API surface. Regenerate the committed golden report so the api-extractor golden-diff gate matches. Public-surface-only change: the new closeOwnedHandles() lives in an internal provider module and is not exported.
…D-2)
CFG-5: remove the dead {version,config} envelope (ConfigStore.version, the
setConfig bump, getConfigVersion). It implied a reactivity the deep-frozen
config does not have, inviting a future maintainer to wire a cache expecting
an invalidation that never fires. getConfigVersion was public via /config but
tracked by no api-extractor golden and had no consumers.
CFG-8: document the one-tenancy-config-per-process invariant on the globalThis
store, and route re-seeding/clearing through the /testing reset seam.
PLD-8: add an integration guard pinning isProductionNodeEnv() === app.inProduction
so the pure NODE_ENV helper cannot silently diverge from the framework's notion
of production (the risk a raw === 'production' check carries on NODE_ENV=prod).
PLD-2 (partial): correct two boot() ordering comments that over-declared what is
load-bearing. The adapter reads the driver/resolver registries lazily per query,
so only the registry objects (constructor args) must exist at wiring time, not
their contents; the real ordering constraints are setConfig-first and
driver-registered-before-start()'s assert.
First wave of the packages/core 5-year foundation roadmap. No behavior change.
…6/PLD-3/6/AD-07/CFG-7)
TS-1: type the Lucid ConnectionManager seam. Tenant routing and the read-only
plugin firewall ran entirely under `(this.db as any).manager`; it now reads
`this.db.manager as ConnectionManagerContract | undefined` (Lucid's own exported
type), so a rename or a `manager.hass` typo on the cross-tenant-write path fails to
compile. buildReadOnlyConnectionConfig returns a typed PostgreConfig. No behavior
change.
CFG-4 + F5: assertConfigBounds(config, { inDevOrTest }) and setConfig(config,
{ inProduction }) — both options optional with env-derived fallbacks; the provider
passes app.inDev||app.inTest and app.inProduction, so the framework is the single
authority for the current environment. The resolution-safety gate now fails CLOSED
everywhere EXCEPT a recognized dev/test env (was: production only). BREAKING for a
host on a client-controlled or host strategy with no membership gate / host suffix
deployed under an unrecognized NODE_ENV (staging/qa/unset): it now hard-fails at
boot instead of only warning. Deliberate — an insecure resolution posture must not
ship on a warning alone. The fixture, demo, and test runners are unaffected (they
gate the posture or run dev/test). The unit runner defaults NODE_ENV=test.
CFG-6: deepFreeze recurses into plain objects + arrays only; a stateful custom
resolver instance in resolverChain/resolvers stays mutable (freezing it violated
its ownership and could break a resolver that caches).
PLD-3: the boot() security warnings (rowscope-no-RLS, resolution-risks) and the
queue/bootstrapper diagnostics defer to app.booted, where the logger is guaranteed,
removing four `container.make('logger').catch(() => undefined)` reads that could
silently drop a warning. A diagnostic can never fail boot (isolated).
PLD-6: a provider disposer registry captures wireResolutionCacheInvalidation's
teardown in ready() and runs disposers LIFO in shutdown(), so a ready()/shutdown()
cycle no longer leaks the eight lifecycle listeners. Each disposer is isolated so a
throwing one never skips closeOwnedHandles (the SIGTERM queue drain).
AD-07: delete the dead BackofficeAdapter (never instantiated; the unified
TenantAdapter routes backoffice by the `static isolation` marker). Barrel, root
export, api-extractor golden, stability doc, and stale satellite comments updated.
CFG-7: forbid `[]` array grammar on enforce:true secret paths (the dotted-path
bounds reader cannot navigate them, so enforcement would silently no-op).
Adversarially reviewed (multi-agent); eight findings folded in: exception-isolated
shutdown/boot diagnostics, and doc/comment drift for the removed adapter and the
broadened fail-closed gate (security.md, changelog, release-notes). Gate green:
typecheck, unit 1651, integration 393, 37 guards, lint. Golden regenerated
(BackofficeAdapter removed, setConfig signature).
… the legacy switch (TRES-01) CRITICAL. Rate-limit buckets and the `backoffice.tenant_metrics` rows attributed the tenant through the public `resolveTenantId` — the v1 strategy switch, which reads `resolverStrategy` ALONE and never consults the resolver chain. On ANY `resolverChain` deployment that attributed to a DIFFERENT tenant than routing served: a silent cross-tenant rate-limit bypass (one tenant's requests counted against another's bucket, or collapsed into the shared per-IP 'global' bucket) and corrupted metering (a row forged under the wrong tenant). Fix: one internal, chain-aware `resolveTenantIdSync(request)` that walks the SAME boot-seeded registry chain the adapter routes by and `request.tenant()` resolves (honouring `legacyAdapterFallback`, degrading a domain hit to undefined). The rate-limit and metrics middlewares now use it instead of the chain-blind switch. The registry is seeded into the request module cache from the provider's boot() (the same pattern as TenantResolutionCache / TenantLogContext), so the sync path has a guaranteed hit from the first request and never falls back to the switch. The public `resolveTenantId` stays as the documented legacy fallback (TRES-02 folds it into the unified authority in a later wave). Wave 2, the resolution security patch (its critical item); AD-03/PLD-4/PLD-7/F2/F3 follow. Gate: unit 1654, integration 393, 37 guards, lint.
…LD-4/PLD-7/F3/F2) Second and final Wave 2 commit (TRES-01, the critical bug, landed separately in 8f13c95). All five remaining Wave 2 items, gated green (unit 1663, integration 395, typecheck, 37 guards, lint, knip, api-extractor golden regen). - AD-03: extract the cross-tenant cached-connection identity seal into a shared connection_identity_seal.ts (the single registered guardFile for seal.connection_identity, renamed from seal.connection_search_path since the event now covers both drivers). schema-pg keys on searchPath, database-pg now keys on connection.database — closing the missing seal on the strongest isolation. sqlite documents why it needs none. Regression specs for both PG drivers; this is the AD-05 verifyCachedConnection down-payment. - PLD-4: ImpersonationService resolves its optional AuditLogService lazily in #audit(), so the provider registers it with a plain sync factory — killing the only async factory among the provider's singletons. - PLD-7: replace boot()'s 4-way driver if/else with a data-driven BUILT_IN_ISOLATION_DRIVERS map; a sixth built-in is a map entry, not a new branch (OCP). Provider no longer imports the driver classes. - F3: decouple the breaker's fail-fast decision from the heavy opossum object via a lightweight per-tenant marker (openUntil), so a heavy OPEN breaker can be evicted under fleet-wide-outage memory pressure while isOpen()/run() keep failing fast synchronously, and self-heal once the window elapses (recreate on HALF_OPEN). Only HALF_OPEN breakers are now spared from eviction. - F2: give the ReadReplicaService LRU the same in-use-aware release the per-tenant drivers use (decline eviction while a query is in flight) and settlePending() in resolve(), so an in-flight analytics read is never severed under cap pressure.
…ard (CFG-2) Mirror the proven SECRET_CONFIG_FIELDS pattern for numeric tunables. The bounds were a hand-written list of atLeast()/inRange() calls in assertConfigBounds, so a new numeric tunable could ship unbounded just by no one adding a line. - NUMERIC_CONFIG_FIELDS: one declarative registry of every numeric config leaf, each either enforced (min/optional max) or exempt-with-reason. The enforced set is byte-identical to the pre-registry bounds, so this is behavior-preserving; currently-unbounded leaves are registered as exempt with a written reason (correctness-neutral tunables, operator-managed infra endpoints), candidates to promote to enforced later. - assertConfigBounds now loops the registry (keeping the impersonation maxDuration>=defaultDuration cross-field invariant, secrets, resolver chain, and the resolution-safety gate untouched). - numeric_fields_bounded.spec.ts: parses every `name: number` leaf of the config type (config.ts + config/isolation.ts) and fails unless it is registered (bounded or exempt), so a new numeric field cannot ship without a conscious bounds decision — the structural half of CFG-2 / F1's ceiling bound. First of the Wave 3 sub-commits; TS-2, TRES-02, CFG-1 follow.
BUILT_IN_ISOLATION_DRIVERS['database-pg'] is typed to return the IsolationDriver interface, which has no databaseName(); the test casts to reach the database-pg concrete method. Direct cast tripped TS2352 (insufficient overlap). Route it through unknown as the compiler suggests. Pre-existing since the wave-2 driver map.
…e authority (TRES-02)
Delete the legacy resolverStrategy switch (legacyResolveTenantId) and its
resolveTenantIdSync twin: a single chain-aware resolveTenantId now walks the
registry chain, and the config.resolver.legacyAdapterFallback opt-out is gone
(the adapter fallback and the provider primeTenancy are both unconditional now).
Resolution has ONE authority — the host-allowlist and UUID-border math live in
the resolvers alone. buildResolverRegistry(config) (pure, no Ignitor) is the sync
fallback and test builder; canonicalChainNames(config) single-sources the "which
resolvers run" question for the boot wiring and the host-trust audit.
F7 — UUID policy at the resolver border (builtins): header/subdomain/path mint an
id ONLY for a canonical UUID v4; a non-UUID value falls through the chain instead
of forging an id that only fails a downstream assert (and, on a chain deployment,
never reaches a rate-limit/metrics key). DomainOrSubdomain: a non-UUID label under
baseDomain falls through to its {domain} envelope.
F8 — ResolverHit.id() lowercases a UUID, so a mixed-case identifier collapses onto
one connection, one resolution-cache entry, one rate-limit bucket, one metrics row.
Adversarial review (5-lens workflow) surfaced three gaps, all folded in:
- the demo e2e resolution specs (resolution_strategies, forwarded_host_tenant_hop)
pinned the old non-UUID behaviour and are invisible to the unit tier — migrated
to UUID fixtures + the booted registry (verified 14/14 e2e green);
- the middleware attribution specs never pinned "attribution follows the chain,
not the header" AT THE SEAM — added a chain-divergence test to both;
- post-F7 a colon-injected header no longer reaches the middleware isSafeIdentifier
seam guard — added a custom-resolver-unsafe-id test to both, so the seam
defense-in-depth for custom resolvers stays pinned.
Breaking (pre-1.0, 0.3.0): removes resolver.legacyAdapterFallback; a host that
attributed tenants by opaque non-UUID ids must move to UUID v4 tenant ids.
Gate: unit 1675, integration 395/8-skip, e2e 14, typecheck clean, 37 guards,
lint, knip, test:integrity, docs:build, golden regen. Absorbs TRES-03/05, TS-4, F7/F8.
…guard (TS-2)
identifier.ts reached UP into isthmus/audit.js to emit guard.tenant_identifier
inside a throwing assertSafeIdentifier — a low-level policy leaf depending on the
guard/audit layer above it. And the non-throwing twin isSafeIdentifier, used at
the rate-limit and metrics attribution seams, dropped a forged custom-resolver id
with no audit trail at all.
TS-2 inverts the arrow and closes the gap:
- identifier.ts is now a pure zero-import leaf: the regexes and the predicates
(isUuidV4, isSafeIdentifier) only. No throw, no emit, no imports.
- new isthmus/guarded_identifier.ts (guard layer) owns the single
emitIsthmusEvent('guard.tenant_identifier'). It exports the throwing
assertSafeIdentifier (emit-then-throw, for every DDL / Redis-key slot) and a new
non-throwing guardedSafeIdentifier(value, kind) for attribution seams: it emits
on a PRESENT-but-unsafe id and stays silent on an ABSENT one (undefined/null/''),
so a forged attribution is audited without flooding on ordinary untenanted
traffic.
- rate_limit + track_metrics point their seam at guardedSafeIdentifier; the
degrade-to-'global' / skip control flow is byte-identical, the only new
observable is the audit emit on a present-but-unsafe id.
- 17 call sites + define_plugin repointed to the guard; the internal/services/sdk
barrels split (isUuidV4 from the leaf, assertSafeIdentifier from the guard);
registry guardFile for guard.tenant_identifier moved to the new file.
The degrade emit shares the high-severity dispatch window with the other high
guards; that residue is reachable only behind a lax custom resolver (built-ins
gate id hits on isUuidV4) and leaves the Prometheus counters accurate. It is
documented on the guard — a per-id sub-cap in the shared limiter is the eventual
structural fix.
Gate: unit 1679, typecheck (all workspaces), integration 395/8-skip, check (37
guards, Audit-coverage Index 100%), eslint, integrity 10, docs:build; api golden
regen (isUuidV4 doc-status flip only, signature unchanged).
…y-critical event (TS-2 review) The TS-2 adversarial review found one medium security-observability issue: the new guardedSafeIdentifier degrade emit dispatched an IsthmusGuardTripped event per present-but-unsafe id, sharing the one per-severity 'high' dispatch window with the security-critical high guards (SSRF, RLS, webhook). A forged-id flood — reachable behind a lax custom resolver, since the built-ins gate id hits on isUuidV4 — could crowd out those guards' EVENT dispatch (their counters stayed accurate). Fix by putting each refusal path on the right surface instead of throttling a shared budget: - guard_audit.ts: GuardEmitOptions gains an opt-in `dispatch?: boolean` (default true). When false, emit records the trip on the counters (and the metric bridge) but broadcasts no event, so it touches no dispatch window. Additive and backward-compatible — every existing caller omits it and is unchanged. - guarded_identifier.ts: the throwing assertSafeIdentifier (a near-miss DDL/key injection) still bumps the counter AND broadcasts the event; the non-throwing guardedSafeIdentifier degrade (a dropped attribution) bumps the counter with dispatch:false. A forged attribution stays visible on the Prometheus multitenancy_isthmus_* counters (the kernel's primary trip surface), while a high-volume degrade can never consume the high dispatch budget. Counter for the volume signal, event for the near-miss. Gate: core unit 1679, ai unit 526 (the only satellite that builds its own audit), typecheck (all workspaces), core integration 395/8-skip, check (37 guards, Audit-coverage 100%), eslint, integrity 10; api golden regen (additive optional GuardEmitOptions.dispatch only).
…AULTS (CFG-1) MultitenancyConfig stays the INPUT type an app authors; getConfig() now returns a new ResolvedMultitenancyConfig with the always-present tunables promoted to required. defineConfig and setConfig both run one pure, idempotent resolveConfig that merges a single CONFIG_DEFAULTS (src/config_defaults.ts). setConfig is the store choke point, so an untyped host config or a partial test config still yields a fully-resolved getConfig(). circuitBreaker / queue / isolation / resolver.cache are resolved, so their read sites drop the scattered `?? DEFAULT_*`: circuit_breaker_service, tenant_queue_service, schema_pg_driver, database_pg_driver, read_replica_service, extensions/request read the value directly. resolveConfig uses per-field `??`, so a valid 0 (circuitBreaker.volumeThreshold / isolation.evictionGracePeriodMs) survives. Genuine feature toggles stay optional and are never materialized — impersonation (secret boot check), plans (quota fallback), tenantReadReplicas (empty-hosts short-circuit) — so their presence gates keep working. Materializing isolation leaves its per-driver sub-fields undefined when unset. Deleted the now-scattered private consts DEFAULT_MAX_OPEN_QUEUES, DEFAULT_MAX_TRACKED_CIRCUITS, CIRCUIT_BREAKER_DEFAULTS. The publicly re-exported / benchmark-referenced defaults (DEFAULT_MAX_TENANT_CONNECTIONS, DEFAULT_EVICTION_GRACE_MS, DEFAULT_RESOLUTION_CACHE_*) are kept and sourced into CONFIG_DEFAULTS. New public exports: ResolvedMultitenancyConfig + sub-types + ResolverConfig/ResolverCacheConfig; api-extractor golden regenerated. Regression: behavior_config_resolution.spec.ts (defaults filled, 0 preserved, host wins, toggles not materialized, isolation sub-fields stay undefined, satellite blocks pass through, idempotent, getConfig resolved). Gate green: unit 1688, typecheck all workspaces, integration 395/8-skip, check 37 guards, eslint, integrity 10, docs:build, golden regen. Adversarial review (5 lenses + verify) -> 0 confirmed defects.
…ook (AD-06) enforce() was required of every driver but is a no-op in all four shipped ones: connection-isolated drivers (schema-pg/database-pg/sqlite-memory) have the connection as their boundary, and rowscope-pg scopes at query time via the withTenantScope mixin / RLS. The contract oversold it as "the" row-scoping point and forced a ceremonial no-op on every driver. Make it `enforce?(client, tenantId)`: an OPTIONAL synchronous per-query hook a custom driver may implement, called by the adapter as `driver.enforce?.()`. Drop the four no-op implementations. Rewrite the contract doc + architecture.md so the boundary story is the connection / mixin, not enforce. Update the driver-contract and adapter-enforce specs to the optional shape (the adapter still invokes enforce when a driver provides one). Behavior-preserving: every shipped enforce() was already a no-op. No public api-extractor surface change (IsolationDriver is not golden-tracked). Gate: unit 1688, 37 check guards, core typecheck, eslint on the touched files.
…try (TRES-04/06) Bumps RESOLVER_CONTRACT_VERSION 1->2 with two additions to the TenantResolver contract, both closing a real cross-tenant hazard. TRES-04 (+F4) — trust classification from one source. Adds `readonly trust: 'host'|'client'|'server'`. A new resolver_trust.ts (entryTrust/chainTrusts) classifies the resolution path from each resolver's declared trust, and the membership-gate (IDOR) and host-trust audits now derive from it instead of two hand-kept name sets (CLIENT_CONTROLLED, HOST_STRATEGIES, deleted). FAIL-SAFE: an undeclared/unknown resolver classifies as 'client', so a bespoke resolver can no longer silently disable the IDOR hard-fail — the host opts out with trust:'server' (or a wired gate). A built-in NAME wins the classification even when an inline instance reuses it, because wireResolverChain runs the built-in, so a host-based built-in can never escape the host-trust audit. TRES-06 (absorbs TS-3) — synchronous resolution is explicit. Adds optional `resolveSync(request)`. registry.resolveSync calls it directly (no `.then` sniffing); setChain FAILS CLOSED when a routing-chain resolver lacks it (an async-only resolver would split-brain: request.tenant() resolves a tenant the sync attribution path — model routing, rate-limit, metrics — skips). Built-ins extend a new SyncTenantResolver base (resolve() forwards to resolveSync(), carries contractVersion), also exported for host resolvers. Breaking (pre-1.0): a custom resolver in a routing chain must now expose resolveSync (extend SyncTenantResolver); an undeclared-trust resolver hard-fails the production resolution-safety gate unless trust:'server' / a gate / an ack. Gate: unit 1697, typecheck all workspaces, integration 395/8-skip, e2e resolution 14 + membership_gate 2, 37 check guards, eslint, knip, integrity 10, check-docs-code, golden unchanged (/services + resolver contract not api-extractor-tracked). Adversarial review (1 agent) -> 1 PLAUSIBLE finding folded (host-name-collision classification) + comment accuracy fix.
…(AD-05)
schema-pg and database-pg carried byte-for-byte copies of the per-tenant
connection pool: the same ConnectionLru wiring, connect/disconnect/markUsed/
migrate flow, hard-cap admission, settle/evict race handling, and the
"never re-adopt a draining pool" firewall. Two copies means markUsed and the
in-use-aware eviction were implemented (and could drift) twice.
Extract a shared abstract PooledPgDriver base that owns all of it. A subclass now
supplies only what genuinely differs, through two hooks plus its own DDL:
- buildTenantConfig(template, tenant): schema-pg sets searchPath; database-pg
overrides connection.database and drops searchPath.
- verifyCachedConnection(tenant, cachedConfig, name): the cached-connection
identity seal — schema-pg keys on searchPath, database-pg on
connection.database — both routed through the shared assertCachedConnectionIdentity.
schema_pg_driver / database_pg_driver drop to those hooks + provision/destroy/
reset/tableLocation. lucid() is one protected method; the template-not-found
error keeps each driver's label.
Behavior-preserving pure extraction. The searchpath-pin anti-regression lock is
updated for the new layout: it now asserts the base fast path delegates to
verifyCachedConnection AND schema-pg's hook keys the seal on searchPath, so
neither can be quietly dropped. This base is also where AD-01 will land the
read-only firewall clone so it counts against the same pool.
Gate: unit 1698, typecheck all workspaces, integration 395/8-skip, 37 check
guards (incl. no_adhoc_stateful_services — ConnectionLru is not a container
singleton), eslint, golden unchanged (PooledPgDriver internal; driver public
API identical).
…-01a)
The untrusted-plugin read-only firewall clones a tenant's connection into a
SELECT-only variant (`<primary>__plugin_ro`) so Postgres denies writes. The
ADAPTER built that clone inline via db.manager and NEVER released it: a
multi-tenant process running untrusted plugin code leaked one pool per tenant
(verified — there was no release site for `__plugin_ro` anywhere in src).
Move ownership to the driver. New optional contract method
`IsolationDriver.ensureReadOnlyClient(tenantId, db, role)`; PooledPgDriver
implements it synchronously (the adapter passes `this.db`): it builds/registers
the clone via buildReadOnlyConnectionConfig, tracks it in the SAME ConnectionLru
as the primary (so it counts against the cap and is touched on use), and
`disconnect` now releases BOTH the primary and the clone — closing the leak. The
adapter's read-only block collapses to `driver.ensureReadOnlyClient?.(...)` and
FAILS CLOSED when it is absent: a driver with no per-tenant PG pool (rowscope-pg,
sqlite-memory) offers no firewall, so untrusted code is denied rather than routed
to a shared/writable connection (this also closes a latent rowscope cross-tenant
read hole, where the "clone" was of the shared central connection).
Hardening from adversarial review, folded in:
- The clone is now identity-SEALED before it is built: ensureReadOnlyClient
runs verifyCachedConnection against the primary (emitting
seal.connection_identity) so a stale/collided primary can never be cloned
into a read-only window onto another tenant.
- ensureReadOnlyClient is synchronous and cannot await settlePending like
connect(), so it now refuses (fail-closed, transient) via a new
ConnectionLru.hasPendingRelease() check rather than re-adopting a draining
clone pool.
New spec isolation_read_only_clone_lifecycle (release-on-disconnect, register-
once, fail-closed-no-primary, primary-seal). pooled_pg_driver added to
NO_SILENT_GUARD_ALLOWLIST (RO fail-closed is a firewall SETUP failure, and the
primary it derives from is sealed by the emitting seal.connection_identity).
Gate: unit 1702, typecheck all workspaces, integration 395/8-skip, 37 check
guards (isthmus Audit-coverage Index 100%), eslint, golden unchanged
(IsolationDriver contract + adapter bodies not api-extractor-tracked).
maxTenantConnections is a SOFT target: with enforceConnectionCap off (the
availability-favouring default) a burst of concurrently-active tenants lets the
pool grow past it, trending toward the active-tenant count and, unchecked,
toward PostgreSQL max_connections. Add a second, absolute tier so an
availability-favouring deployment still has a hard backstop.
New optional isolation.maxTenantConnectionsHardCeiling (unset = no ceiling).
ConnectionLru.atHardCeiling() refuses admission at/above it ALWAYS (independent
of enforceConnectionCap); PooledPgDriver.connect() throws
TenantConnectionLimitException (503) on `atHardCeiling() || atHardLimit()` for
request-path connects (operational paths bypass, as with the soft cap). Bounded
+ registered like maxTenantConnections (numeric_config_fields, +docs so
test:integrity passes; golden gets the new field). A throttled runtime warning
fires when the ceiling bites, and the provider logs a boot warning when the
ceiling is set BELOW maxTenantConnections (a misconfig — the pool would be
refused before the LRU ever evicts an idle connection).
Adversarial review (3-lens workflow → 2 confirmed findings) folded, then
re-verified:
- LOCKUP (high): the ceiling must not refuse while idle connections sit unshed
(eviction only runs on a successful admit, so a pinned-at-ceiling pool would
refuse new tenants forever). atHardCeiling now yields to an evictable victim
via a shared #atCapacityWithNoVictim(threshold) — the same policy as the soft
cap — so it refuses only when the pool is full of ACTIVE connections.
- RO-CLONE BYPASS (medium): ensureReadOnlyClient added the __plugin_ro clone
(a second real connection) with no ceiling check, so untrusted-plugin traffic
could push the pool to ~2x the ceiling. The clone-creation branch now honors
the same two admission tiers, failing closed (503) rather than opening
connection N+1 for untrusted code.
Gate: unit 1707, typecheck all workspaces, integration 396/8-skip (incl. a new
ceiling-refuses-with-503-even-with-enforceConnectionCap-off spec), 37 check
guards, eslint, golden regenerated (IsolationConfig.maxTenantConnectionsHardCeiling).
The TRES-04/06 commit edited seedResolver in these two specs but they were not prettier-clean in unrelated regions; format them so `npm run lint` is green. No behavior change.
…uard (EXT-5) check-abi-boot-assertion now mirror-checks a third per-provider boot surface, nativeAddons, the same way it already pins satelliteApi and pluginApiVersion: - a definePlugin facade that declares `nativeAddons` must agree with package.json#lasagnaSatellite.nativeAddons (the manifest drives the install-time consent gate, doctor, and health-check); - when the manifest claims native addons, the provider MUST wire the boot-time sandbox fail-closed (assertNativeAddonsSandboxable) — a facade with nativeAddons:true runs it, or a raw boot() calls it. Otherwise a native addon loads in a --permission worker with no --allow-addons and the guard is silently absent. The backstop is conditional: a plugin with no native addons needs nothing. Self-test fixtures cover the mirror, the drift case, the missing-backstop case, the raw-provider-with-assert case, and the non-native no-op. No real provider declares nativeAddons today, so the guard is inert over the current fleet. Also documents the umbrella-vs-per-surface versioning rule in api_version.ts: there is deliberately no umbrella version; each axis (Satellite ABI, plugin facade contract, per-surface *_CONTRACT_VERSION, marketing version) bumps independently, and nativeAddons is a boolean flag mirrored the same way, not a version.
The v2 bump recorded an ADDITIVE change: the optional `sensitive?` trust flag. A provision that never sets it behaves exactly as before, and one that opts in gets the TRUSTED_SATELLITES gate for free — by the contract-version rule (bump only on a backward-incompatible change) that never earned a bump. The cost of the spurious bump is real: assertContractCompat WARNS every extension built against an older version, so bumping capability for a non-breaking change floods that channel with false alarms and desensitizes operators to the genuinely breaking bumps (isolation's v2 tableLocation requirement) that need the warning read. Reverting keeps the warning meaningful. The `sensitive?` field and its trust gate are unchanged; only the version integer moves 2 -> 1. Also fixes a latent Wave-4 doc staleness: the surfaces at 2 are ISOLATION and RESOLVER (RESOLVER bumped in TRES-04/06), not CAPABILITY. Golden regenerated; docs (contract-versions, plugins) updated.
…hape (EXT-1/EXT-2/EXT-3)
Nine single-source registries (resolver, isolation driver, authorizer, tenant
middleware, audit destination, webhook transformer, feature-flag strategy,
capability, bootstrapper) each hand-rolled the same registration gate — name
check, dup collision, contract-version compat — with six different collision
errors (four typed, six plain Error). Extract a shared ExtensionRegistry base
that owns the Map + has/unregister/clear/contractVersion and the ONE gate, via a
protected assertRegistrable(entry, opts). Each subclass keeps its own public
register()/list() and side effects (activation, LIFO order, chain, the trust
gate, a value transform) — only the plumbing is shared. HookRegistry stays out
(an append multimap, not a single-source registry).
EXT-2 (the prize): the base runs an abstract assertShape(entry) UNCONDITIONALLY
at registration, INDEPENDENT of the version comparison. assertContractCompat only
WARNS a plugin built against an older contract, so before this a v1 plugin
missing a v2-required member registered (with a warning) and crashed mid-request.
Now a missing member throws at register(). Isolation moves its tableLocation
presence check into assertShape; resolver gains one for its required resolve()
entry point (a resolver missing it used to register and crash on the async chain
walk). A single ExtensionCollisionException (E_EXTENSION_COLLISION) replaces the
six plain Errors; capability/authorizer/middleware keep their domain collision
exceptions via a collisionError override.
EXT-3 guard: check-extension-contracts now requires every surface that reaches a
breaking version (> 1) to declare a shapeGate — the registry file whose
assertShape override enforces the member, or null for a non-registry config hook.
An undeclared shapeGate at v>1 is an error, so a future bump (core OR satellite)
cannot silently reopen the warn-then-crash gap. A shared strip-js-comments lib
means a doc-comment MENTIONING assertShape no longer satisfies the gate.
No public surface change (the registry classes are internal); golden unchanged.
Gate: unit 1707, typecheck-all, integration 396/8, check 37 guards, api goldens
in sync, lint. Adversarial review (2 agents) folded: restored the { override:
true } remediation hint on the two override-supporting registries; hardened the
guard's shapeGate scoping and comment handling.
…XT-5) Adversarial review of the EXT-5 nativeAddons mirror found three latent defects, all now fixed: - the facade regexes read tokens from comments and strings (a "nativeAddons:" in a block comment inside the facade braces, a slash-slash in a URL string) — now the provider source is passed through the shared strip-js-comments lib first, so only real code is scanned. Hardens the sibling satelliteApi/pluginApiVersion reads against the same class of misread. - a truthy-non-boolean manifest value (nativeAddons: 1) was Boolean-true in the mirror but failed the strict "=== true" backstop, so the two disagreed — now normalized to a boolean up front, matching the runtime manifest parser which drops a non-boolean value. - the self-test never exercised an explicit "nativeAddons: false" facade or the normalization, so a regression in either branch would have passed — added fixtures for the false branch, the truthy-non-boolean case, and a comment-inside-braces misread. Guard-only; --self-test and the real run over 8 providers both green.
…ver (AD-02) The TenantAdapter is the tenant-routing chokepoint, but it had grown three private resolution methods, a per-request memo symbol, and an inline reach into `db.manager` — mixing tenant-context resolution and connection ownership into the one class that must stay obviously correct. Split those concerns out: - Extract TenantContextResolver, the single synchronous authority for "which tenant is active now?" (tenancy.run() scope first, then the request via the chain-aware resolver). It absorbs #resolveTenantId, #requestComparandId, #resolveIdFromRequest and the ContextSeal. The seal's HTTP-side comparand now derives from ONE cached value — the tenant request.tenant() resolved (memoizedTenantId) — falling back to the cheap synchronous chain, so the duplicate HTTP_COMPARAND per-request memo is gone. This also makes the seal strictly stricter: it can no longer read a stale cached comparand that hid a late context switch. - Fold the adapter's "connection not established" guard into an optional driver method assertConnected(tenantId, db), so the adapter no longer casts db.manager at all — the driver that owns the per-tenant pool owns the check and the single cast (the roadmap's confine-the-Lucid-cast theme). The typed, actionable IsolationConfigException is single-sourced in assertTenantConnectionEstablished and shared by the PG drivers and sqlite-memory (all three own a per-tenant connection); rowscope-pg's shared connection is always registered, so it needs no check. The adapter body is now resolve-id -> ask the driver for a client -> return. Behavior on the routing path is preserved (verified against every adapter spec, the context-seal fuzz + fail-closed integration specs, and an adversarial review): the fallback path still resolves sync-chain-only, the seal fires identically, and the read-only firewall is untouched. Gate: unit 1707, typecheck-all, integration 396/8, check 37 guards (isthmus Index 100%, adapter's read-only setup fail-closed newly allowlisted, seal.tenant_context guardFile repointed to the resolver), api goldens regenerated (adapter ctor param), lint.
…schema (AD-08) Backoffice models (audit logs, webhooks, SSO configs, billing rows, the host Tenant) read through the adapter's query() override, which pins them to backoffice.<table> with .withSchema regardless of search_path. But writes — insert/update/delete — and refresh bypass query() and resolved their schema purely through the backoffice connection's search_path, which the package never sets (only the host's database config does). A host that mis- or un-set it would silently write backoffice rows to the wrong schema (public by default) while reads still targeted backoffice, so the divergence read as data loss. That read/write asymmetry is the leak AD-08 closes. The roadmap's namingStrategy approach is blocked (satellites set a literal `static table`, ~14 raw-SQL sites qualify the schema themselves off a bare Model.table and would double-qualify, and reading config in a naming strategy throws at @column import time). Instead, qualify writes the SAME way reads are qualified, at the adapter: extract a single protected `queryForInstance(instance, action)` seam in the base adapter (insert/update/delete/refresh route through it, behavior-identical) and override it in TenantAdapter to add `.withSchema(backofficeSchemaName)` for backoffice-marked models. Backoffice ORM writes now resolve to backoffice.<table> independent of search_path, symmetric with reads; tenant/central writes are byte-identical (no qualification). refresh, which was also unqualified, is pinned too. The shipped test-tenant factory writer is pinned the same way. The bare `Model.table` is untouched, so the raw-SQL satellite writers (which qualify the schema themselves) don't double-qualify. Adversarial review found no regression across the satellite write paths; the .withSchema mechanism is the proven read-path one applied to writes. Gate: unit 1709 (2 new write-qualification specs), typecheck-all, core integration 396/8, SSO integration 17/5 (backoffice ORM writes green under .withSchema), check 37 guards, golden regenerated (protected queryForInstance), lint. NOTE: billing (126/93) and reporting (48/4) integration are red both before and after this change — pre-existing local issues (billing inserts omit a NOT-NULL `provider` column; reporting has a date-sensitive metrics-bucket test), neither caused by AD-08 (a schema-only change cannot omit a column) nor Wave 5.
…e-robust getAggregate's BUCKET_SQL selected the period bucket as a raw `date` (`period`, `DATE_TRUNC(...)::date`) and the rollup path selected the `month` date column directly. node-postgres round-trips a `date` through a JS Date, so under a non-UTC process timezone the bucket shifts by a day — day/week/month buckets, and the live-vs-rollup equivalence, came back off by one on a machine in e.g. Europe/Paris while passing under UTC. It also disagreed with the OpenAPI contract, which documents `period` as a `YYYY-MM-DD` string. Cast each bucket to `::text` in SQL so PostgreSQL emits the ISO string directly, with no Date round-trip and no timezone sensitivity. Pure output-shape fix; the grouping/order/filter are unchanged (WHERE compares the date column to the `since`/`until` strings in-SQL, which was already tz-safe). Reporting integration now passes under both UTC and Europe/Paris (52/52); unit 105/105.
…enum)
The billing integration suite was 126/93 red — untested for a while because CI
runs the satellite UNIT suites (test:coverage), not the integration ones. Two
pre-existing defects, both unrelated to the core waves:
1. Every integration spec seeds billing models with `new BillingCustomer()` /
`new BillingProcessedEvent()` / `Subscription` / `UsageEvent` / `InvoiceSnapshot`
and `.save()` WITHOUT the required NOT-NULL `provider` column — production code
sets it (the webhook controller's ledger insert, BillingService), but the test
seeders never did, so they rotted when the multi-provider refactor made
`provider` required. Add `provider = 'stripe'` (the MockStripe driver the suites
inject) to all 63 seed sites.
2. The demo app's billing_processed_events migration declared
`status enum('pending','completed','failed')` — MISSING 'processing', which the
worker's claim step (process_billing_event_job SET status='processing') and the
published stub both require. The stale enum made the demo/fixture DB reject every
worker claim once the provider inserts started landing. Sync the fixture migration
to the stub + the code (add 'processing').
Billing integration now 219 passed / 12 skipped. No production code changed —
only test seeders and the demo migration. (Existing local DBs also need the
constraint widened; a fresh migrate now creates it correctly.)
…ubsystem installers (PLD-1) The capstone. MultitenancyProvider was a ~545-line God-object owning every subsystem's register/boot/start/ready/shutdown wiring inline. Split it into 11 per-subsystem installers under providers/installers/, each owning its slice of the lifecycle, behind a small ProviderInstaller interface + a shared InstallerContext (app + warnWhenBooted + addDisposer). The provider collapses to a thin orchestrator that iterates the ordered INSTALLERS array for register/boot/start/ready and keeps the proven 3-step shutdown. This dogfoods the same decomposition the package already ships as definePlugin for satellites, and mirrors native AdonisJS idiom (@adonisjs/core's AppServiceProvider runs an ordered sequence of per-concern register methods; @adonisjs/lucid delegates to internal src/bindings/ modules kept off the public surface). Absorbs PLD-6: the old #disposers registry generalizes to ctx.addDisposer, still torn down LIFO in shutdown(). Installers (array = boot order; the only hard within-boot edge is FoundationWiring's setConfig before any getConfig() consumer): foundation, isolation, resolution, tenancy-context, resilience, diagnostics, audit, plugin-platform, queue, scheduler, http-extensions. IsolationWiring owns the unified-adapter construction (its hard dependency is the driver set; the resolver registry is a lazily-read soft dependency), so no boot-order coupling to ResolutionWiring. Zero behavior change on any successful boot, verified adversarially against the pre-split monolith: all 24 singletons map 1:1, every condition and every warning/error string is byte-identical, assertConfigShape is byte-equivalent, the lazy-vs-eager import discipline is preserved, and shutdown() is untouched. The decomposition necessarily fixes the relative order of a few independent side-effects (installers own contiguous lifecycle slices, so the monolith's interleaved start()/ready() ordering cannot be bit-reproduced): the start-phase driver/plugin-limit asserts now run before the route-macro install, and ready() wires cache-invalidation before arming the scheduler. Both were verified behavior-neutral for every successful boot — observable only in which exception surfaces first on a multi-fault failed boot (which aborts either way) and in log-line ordering. Failing fast on a misconfigured driver before doing route autoload is, if anything, the better order. Guards: - Repoint the three grep-the-provider-file anti-regression locks (resolution_safety_single_source, membership_gate_boot_wired, rowscope_rls_probe_wired) at the installer files that now own that wiring. - Extend no_adhoc_stateful_services' NEW_ALLOWED to providers/installers/*_wiring.ts (the register() slices are the new single authority for the singleton list) and drop the provider from the allowlist — it no longer `new`s anything. - Close a real gap: rename no_app_booted_imports_in_sdk_and_testing -> no_app_booted_imports and extend its roots to src/providers/, so a static import of a booted-touching module in any installer (which would make the provider — and the three integration specs that construct it — unloadable outside an Ignitor) fails structurally instead of by hand-review. Gate: build + typecheck (all workspaces) + test 1709/0 + test:integration 396/8 + lint + check 37/37 + test:integrity + api-extractor goldens in sync (the provider is internal, not golden-tracked — no regen).
The architecture-roadmap waves (0-6) re-introduced AI-flavored punctuation in code comments: em-dash separators, prose arrows (->, →, ⇒), and the balanced "not X, but Y" / "X — so Y" cadence. This rewrites those comments into the plain, calm voice of an engineer leaving a note for a teammate, keeping every technical fact and every "why" (often a touch clearer, since the comments double as learning notes). Scope: 48 wave-touched core source files. Comments only. Verified by the AST comment oracle (the TypeScript parser's leaf-token stream is byte-identical to HEAD for every file, so no code, identifier, or string literal changed — the warning/error message strings with their own em-dashes were deliberately left untouched). Gate: build, unit 1709/0, check 37/37, lint. Integration is unaffected by a proven comments-only change. No em-dash, no prose arrow, no robotic antithesis before adding a comment: this is the standing house voice, not a one-off cleanup.
Arcoders
merged commit Jul 16, 2026
efc3d9d
into
LASAGNA-020626/isolation-hardening-and-benchmarks
11 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.