Skip to content

Lasagna 090706/soft - #20

Merged
Arcoders merged 55 commits into
LASAGNA-020626/isolation-hardening-and-benchmarksfrom
LASAGNA-090706/soft
Jul 16, 2026
Merged

Lasagna 090706/soft#20
Arcoders merged 55 commits into
LASAGNA-020626/isolation-hardening-and-benchmarksfrom
LASAGNA-090706/soft

Conversation

@Arcoders

Copy link
Copy Markdown
Owner

No description provided.

Arcoders added 30 commits July 9, 2026 17:03
…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.
Arcoders added 25 commits July 12, 2026 19:47
…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
Arcoders merged commit efc3d9d into LASAGNA-020626/isolation-hardening-and-benchmarks Jul 16, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant