Lasagna 040726/crypto satellite - #19
Merged
Arcoders merged 54 commits intoJul 9, 2026
Merged
Conversation
…governance) The frozen "why" the three data-protection satellites are built against: the shared foundation (KEK/DEK key hierarchy, legalBasis-gates-erasability, one WORM ledger, honesty bounds, reused-seam verdicts, section template) plus the crypto, vault, and governance architecture docs, adversarially reviewed and cross-checked for consistency. This is study material and the tracked source of truth; each satellite's graduated ARCHITECTURE.md is kept local (gitignored), mirroring the AI satellite. Nothing here ships until 1.0 is released.
…am + @adonisjs-lasagna/crypto crypto is the keystone data-protection satellite: per-(subject x category) DEKs under a pluggable KeyProvider, sealed with core's enc_v2 GCM primitive. core: add the one narrow seam crypto needs. sealV2WithKey/openV2WithKey seal and open the frozen enc_v2 envelope under a caller-supplied 32-byte DEK instead of the APP_KEY-derived key (same GCM, same envelope, same header-as-AAD; keyId is a non-secret tag of the DEK). Extract shared sealGcmV2/openGcmV2 so both key paths share exactly one AEAD, and reject a colon in keyId (it would corrupt the colon-delimited envelope). Expose the seam from a new side-effect-free ./crypto subpath so satellites import it without pulling the eager main barrel. crypto: scaffold the package with the encrypt/decrypt spine. Env KeyProvider (per-tenant KEK derived from APP_KEY, wraps the DEK via the core seam), KeyProviderRegistry, CryptoService.encryptField/decryptField, the per-tenant wrapped-DEK table (partial UNIQUE on the live row, I10) placed via tableLocation, Pg + in-memory stores, config wiring (defineCryptoConfig), provider, and the guarantee tree. A field round-trips under its DEK end-to-end (crypto unit 9/9, core seam spec 41/41). The shred, blind index, KEK rotation, shared WORM ledger, and structural guards land in the next phases.
…hase WORM audit (I7) Add the O(1) crypto-shred: destroying a (subject x category) DEK makes every field ciphertext under it irrecoverable at once. CryptoService.shred is fail-closed and gated exactly as the design requires. Governance gate FIRST (I7): an ErasabilityResolver seam (config.crypto.erasabilityResolver) decides erasability. Absent governance, an unresolvable basis, or a legal-obligation category in retention all REFUSE the shred; crypto never erases on its own initiative (over-erasing is irreversible, under-erasing is not). Two-phase WORM audit (§6.6): a ShredLedger seam appends a PENDING row BEFORE the irreversible tombstone (a failed append aborts with nothing destroyed) and marks it COMMITTED after (a failure there is reported and leaves a detectable PENDING row; the erasure still happened). No ledger wired ⇒ refuse (never erase unaudited). The real shared core WormLedgerWriter is injected in a later phase; until then in-app shred is fail-closed refused, which is the correct posture. The store gains shredLive (tombstone: set shredded_at, null wrapped_dek), the migration makes wrapped_dek nullable with a CHECK that a live row keeps its key, and SubjectShredded is emitted (identity + time, never the key). 10 new unit specs prove the worked example: a consent category shreds while a legal-obligation one survives, a re-provision after a shred works, and every refusal keeps the DEK. Crypto unit 19/19.
…ge private in-build Pin the invariants the vertical slice and the shred phase established, so a future change cannot regress them silently. Four pure auditor(files) guards, wired into scripts/check.mjs, each with a focused unit test that drives it on synthetic sources (no filesystem), mirroring check-ai-invariant-5: - check-crypto-invariant-2 (I2): the wrapped-DEK table carries EXACTLY the reviewed non-plaintext column allowlist, never a plaintext-DEK column. - check-crypto-invariant-6 (I6, scaffold): the shred path binds no unwrapDek, holds exactly one DEK-destroy, and appends the PENDING WORM row before the delete. - check-crypto-invariant-7 (I7, scaffold): the shred carries a fail-closed absent-governance refusal, its FIRST awaited call is the erasability resolver, and the delete is reachable only after that gate (no default-to-erase). - check-crypto-invariant-10 (I10): the migration declares the partial UNIQUE (subject_id, category) WHERE shredded_at IS NULL (the lock half lands with the lock wiring). Mark @adonisjs-lasagna/crypto private: true while in active build (blind index, KEK rotation, the shared WORM ledger, and integration tests are pending), so it is unpublishable until it graduates to RC, exactly as the design requires (nothing ships until 1.0). This also exempts it from the publish/stability guards until then. Crypto unit 35/35; npm run check 34/34 green.
…o-phase audit Add the shared append-only WORM ledger the shred audit needed, so in-app crypto shred now works end-to-end (it was fail-closed refused with no ledger wired). core: WormLedgerWriter, a per-tenant sha256 hash chain in the shared backoffice schema keyed by tenant_id (UNIQUE(tenant_id, seq)), generalized from the AI audit writer's proven pattern so the platform keeps ONE hash-chain implementation. It serializes each tenant's tail-read + insert under a transaction-scoped advisory lock, computes seq+checksum (tampering that slips past the triggers still breaks the chain), verify() re-walks and reports the first break (gap|prev_link|checksum), and writes FAIL-CLOSED. metadata is canonicalized with sorted keys so jsonb key reordering cannot change the checksum. Shipped with the backoffice.worm_ledger migration stub (three append-only triggers incl. the statement-level TRUNCATE) and exposed from a new side-effect-free ./worm-ledger subpath. The DB seam is injected, so the hash-chain logic is fully unit-tested against an in-memory double; the real triggers and advisory lock are integration-tested (real PG) in a later phase. crypto: WormShredLedger adapts the writer to the ShredLedger seam. Because the ledger is append-only, the two-phase shred records COMMITTED by APPENDING a second row that references the PENDING one, never by UPDATE-ing it; the subject id is hashed before it reaches the ledger (non-PII). The provider wires it, so shred's PENDING -> delete -> COMMITTED runs against a real chain. Two core-guard adjustments: allowlist the writer's fail-closed throws in no_silent_guard (a shared low-level primitive whose caller owns the domain guard -- crypto maps a write failure to guard.crypto_shred_unaudited), and make public_api_documented skip private packages (consistent with the stability and publish-coverage guards). Core unit 1400/1400, crypto 35/35, npm run check 34/34.
The deterministic search HMAC half of I5 (§6.5): equality search over a low-entropy field via a keyed HMAC, keyed by a KeyProvider index key that is distinct from the DEK and survives a crypto-shred. - KeyProvider gains an optional deriveIndexKey(tenant, category). EnvKeyProvider derives it via HKDF from APP_KEY with a distinct salt and a JSON-canonical per-(subject-agnostic) (tenant x category) info, so it is never the same bytes as a KEK/DEK and survives the shred (derived, not stored). - src/internal/blind_index.ts: computeBlindIndex uses createHmac over a frozen NFKC + trim normalization, with an opt-in locale-independent case-fold. - CryptoService.blindIndex is fail-closed (index_key_unavailable) when the provider cannot yield an index key or yields one under 32 bytes: never a bare unkeyed hash. - check-crypto-invariant-5 pins the keyed-HMAC construction: the index module must import and call createHmac, no bare unkeyed digest anywhere in crypto src (scans import specifiers incl. aliased createHash, plus crypto.hash / subtle.digest), no salt column. worm_shred_ledger's non-PII subject digest is the one reviewed carve-out. - 01-crypto.md 6.5 reconciled to pin the real frozen normalization (NFKC + trim + opt-in case-fold). crypto unit 58/58; typecheck, guard, eslint/prettier green.
…(I5 surface) The auditable Option B surface (§6.4): a context-aware facade over CryptoService that resolves the current tenant from the active scope, so a caller passes only (subject, category) and the value while the encryption boundary stays a visible call in the diff. - encrypt / decrypt / blindIndex / shred delegate to CryptoService. - The current tenant is an injected resolver (wired to tenancy.current() in the provider), keeping the repository off core's eager barrel and unit-testable. - Fail-closed no_tenant_scope when there is no active tenant scope: a DEK is never resolved or destroyed under a guessed tenant. - Registered as a container singleton (container.make(EncryptedRepository)). crypto unit 63/63; typecheck, eslint/prettier green.
…blind index) Validates the whole crypto vertical against real Postgres, which unit tests could only exercise against in-memory doubles. Mirrors the AI satellite's integration harness (the shared satellite-test-kit runIntegrationSuite booting core's fixture); specs construct the real services (PgWrappedDekStore, CryptoService, the shared WormLedgerWriter) and self-skip when Postgres is unavailable. - field round-trip through the real store (schema placement) + ContextSeal refusal - shred tombstones the DEK, the ciphertext is inert, and the two-phase audit lands PENDING+COMMITTED in the real backoffice.worm_ledger (I6); legal-hold refusal keeps the DEK (I7); re-provision after a shred (I10 partial unique) - the WORM ledger append-only triggers reject UPDATE/DELETE/TRUNCATE and verify() catches a trigger-bypassing tamper (checksum break) - two-tenant wrapped-DEK isolation across schemas + confused-deputy resistance (I4) - the blind index enables a real WHERE equality query and survives a shred (T14) Adds bin/test.integration.ts + the test:integration:run/coverage scripts, mirroring AI. Not yet wired into CI (crypto is private/in-build; CI wiring lands at graduation). crypto integration 13/13 (local, real PG); unit 63/63, typecheck, lint green.
…urface) The transparent field-encryption surface (§6.4 Option A): a Lucid column marked @Encrypted round-trips as plaintext in memory but is enc_v2 ciphertext at rest, and @searchable maintains a keyed-HMAC blind index for equality search, all via async model lifecycle hooks (the DEK unwrap is async, which Lucid's sync prepare/consume column hooks cannot do; the design's "sits on top of prepare/consume" was provisional). - @Encrypted / @searchable record per-model metadata; withEncryptedFields (a compose() mixin) wires before('create'|'update') encrypt + after('create'|'update'|'find'| 'fetch'|'paginate') decrypt hooks over the container EncryptedRepository. - Fail-closed (I3/T5): an encryption failure (no tenant scope, KeyProvider down) aborts the save in the before-hook, so cleartext is never written; a shredded or tampered value throws on load rather than surfacing ciphertext as plaintext. - The searchable index is computed from the plaintext source before it is encrypted, and the index column is serializeAs:null by default (it reveals the I5 frequency leak). Decryption re-baselines via $hydrateOriginals so a load is not dirty. - The hooks resolve the model's metadata at invocation time (memoized), not in boot(): a @column/@Encrypted decorator triggers boot() when applied, which can run before every decorator on the class has registered; reading metadata in the hook (which fires at save/load) avoids that race. Pure encrypt/decrypt logic is unit-tested with an injected repo double; the real Lucid round-trip, blind-index query, and fail-closed-after-shred are integration-tested against real Postgres; the @-syntax + compose typing is proven by a compile-only fixture. crypto unit 71/71, integration 17/17; typecheck, lint green.
… (paginate, index clobber, mixin dedup) Adversarial verification of the @encrypted/@searchable decorators (17 confirmed findings) surfaced real correctness bugs on mainline paths, not just the known T5 write-bypass limit. Fixes: - CRITICAL: Model.query().paginate() threw on every row with a non-null @Encrypted field. Lucid's paginate() fires after:paginate then after:fetch on the SAME instances, so the mixin decrypted each row twice; the second pass re-opened now-plaintext and threw. Drop the redundant after('paginate') registration (after:fetch already covers paginated rows exactly once). - HIGH: composing withEncryptedFields more than once in a chain double-registered the hooks (same double-decrypt throw). Add a per-constructor dedup guard so registration happens at most once per concrete model. - HIGH: a partial/projected load that omitted the encrypted source column nulled the stored @searchable blind index on the next save (silent search breakage). Skip the index recompute when the source reads undefined on a persisted row; an explicit null still clears it, a new record still maps absent to null. Honesty: scope the decorator JSDoc and design §10 to the model-instance path. Query-builder/raw writes and the *Quietly family bypass the hooks and store plaintext (T5) until the DB-level guard.crypto_plaintext_write ships; a bulk read of an un-nulled shredded row fails closed for the whole batch; a preload of a related model that does not compose the mixin surfaces ciphertext. Tests: real-PG paginate + load/modify/save round-trip; unit clobber-preservation and double-compose dedup; FakeRepo.decrypt now throws on non-ciphertext to match openV2WithKey strictness. crypto unit 76, integration 19 (real PG).
…ed CLI + per-tenant operation lock (I10)
KEK rotation (I8): tenant:crypto:rekek re-WRAPS live DEKs (unwrap old KEK,
wrap current) and never re-encrypts field data, so rotation is O(#DEKs) not
O(#values). RekekService walks listLive with keyset pagination, is idempotent
and resumable, and reports failed rows instead of swallowing them. New seams:
KeyProvider.currentKekId?() cursor + WrappedDekStore.listLive/rewrap on both
Pg and InMemory stores. EnvKeyProvider gains a dual-key unwrap window
(OLD_APP_KEY then APP_KEY) for the APP_KEY rotation axis, each attempt a strict
DEK-envelope open (I3 intact). Guard check-crypto-invariant-8 pins that the
walker re-wraps DEKs and never touches the field-value seal.
Shred CLI: tenant:crypto:shred (--tenant --subject --category --dry-run
--force --json) drives CryptoService.shred inside tenancy.run; refusals
(legal-hold / governance-absent / unaudited) report and exit 1 rather than
throw a stack. CryptoService.shred gains options.dryRun ("would shred": runs
the I7 gate + preconditions, returns without deleting or auditing).
Operation lock (I10): injected withLock seam wraps the destructive shred steps
and #provisionUnderLock (double-checks live DEK inside the lock so a concurrent
first-write race resolves to one DEK). Redis helper withCryptoOperationLock
(SET NX PX + compare-and-delete + TTL renew, fail-open on redis-down since the
partial UNIQUE is the real singularity guarantee). Guard check-crypto-invariant-10
extended to assert shred and #provisionUnderLock take the lock.
New ./commands subpath (exports + typesVersions + build cp). rekek/shred added
to commands_documented ALLOWED_UNDOCUMENTED until graduation adds CLI-ref docs.
crypto unit 100/100, integration 21/21, npm run check 36/36.
…guards invariant-1/3 (I1/I3) The @Encrypted model hooks cannot see raw-SQL, query-builder, or *Quietly writes, so a plaintext value could still reach an encrypted column through those paths (T5). Only a database constraint catches every path. This adds the DB-level backstop plus the two structural guards that pin I1 and I3. encryptedColumnCheckSql(table, column) (src/schema/encrypted_column.ts, exported): the ALTER TABLE ... ADD CONSTRAINT ... CHECK a host applies to its own encrypted field columns. The predicate is `<col> IS NULL OR left(<col>, 7) IN ('enc_v2:', 'enc_v1:')`: a field value is always core's enc_v2 frame (fixed by sealV2WithKey regardless of the bound KeyProvider), the legacy enc_v1 frame during an APP_KEY migration, or NULL; a plaintext write matches none and Postgres rejects the INSERT/UPDATE. Deliberately NOT applied to wrapped_dek: that column's format is KeyProvider-specific (an enc_v2 frame under the env provider, an opaque KMS/Vault blob under a real one), so it has no provider-independent prefix. Identifiers are validated snake_case to keep the emitted DDL injection-free. check-crypto-invariant-1 (I1): the encrypted-model surface — @encrypted/@searchable own their column (apply lucidColumn; searchable defaults serializeAs: null), and every decorated property is a ciphertext string type (string | null | undefined), so a plaintext-typed column (number/Date/Buffer/array) is refused. check-crypto-invariant-3 (I3): reads fail closed and writes reject cleartext. The read scan covers the WHOLE decorator read path (decryptField, EncryptedRepository.decrypt, decryptModelFields, the mixin boot hooks) for a lenient catch, not just decryptField, so a swallow in any frame is caught — matching the design's "no lenient-decrypt anywhere in crypto src". The write scan pins the DB CHECK helper ships and accepts both prefixes. Both guards wired into scripts/check.mjs (40 guards). Verified against an adversarial review: the guard-3 read-path coverage, the helper's unconditional table validation (the constraintName override no longer skips it), and blanking string/template literals in the guard-1 scanner were all remediations from that pass. crypto unit 129/129 (incl. the read-path + injection regression specs), a real-PG spec proving the CHECK rejects plaintext even via raw SQL while accepting real encryptField ciphertext, npm run check 40/40, typecheck 0.
…ariant-9 (no key material in logs)
Two static guards closing the remaining crypto invariants that a source scan can pin,
mirroring the existing check-crypto-invariant-* discipline.
check-crypto-invariant-4 (I4, T7 confused-deputy): domain separation is real. It scans
two facts. FIELD DATA is sealed by the per-row DEK, never a shared or context-derived
key: crypto_service.ts seals/opens with sealV2WithKey/openV2WithKey keyed by `dek` /
`live.dek` (a distinct random key per (subject x category) from the store), and must NOT
derive a field key via hkdfSync (which would collapse the per-row separation). DERIVED
KEYS are domain-separated in env_key_provider.ts by DISTINCT HKDF salts (KEK vs kek-id
vs blind-index), and the blind-index key binds `category` into its HKDF info so distinct
categories derive distinct index keys (the injective category -> key clause).
check-crypto-invariant-9 (I9, T11): raw DEK/KEK/index-key bytes never enter a log or an
error body. It scans crypto src for a key-bytes identifier (dek/kek/indexKey/appKey/
oldAppKey) flowing into a sink (console.*, logger.*, warn(, or a new *Exception/*Error
construction). String literals are blanked and template literals keep only their
${...} expressions (mirroring check-ai-no-prompt-logging-for-training), so a message
that merely NAMES a key never trips it; a logged ${dek} does. A `.length`/`.byteLength`
is excluded (a size, not the key), and method names like wrapDek/deriveKek/kekId do not
match.
Both wired into scripts/check.mjs (40 guards). crypto unit 142/142 (incl. the two guard
auditor specs with real bypass/false-positive cases), npm run check 40/40, typecheck 0.
…den invariant-4/9
Satellite-local isthmus guard registry mirroring the AI satellite's, so a crypto
fail-closed refusal is an observable, registered event on the kernel's PUBLIC
IsthmusGuardTripped rail, plus remediation of two guard defects an adversarial review
surfaced.
Registry (src/isthmus/crypto_guard_registry.ts) — 6 guards, each with a real emit site:
crypto_dek_unwrap_failed, crypto_shred_legal_hold, crypto_shred_unaudited,
crypto_keyprovider_unavailable, crypto_config_invalid, crypto_scope_mismatch. Thinness
discipline (from AI/kernel): guard.crypto_plaintext_write is deliberately NOT registered
— it is enforced at the DB layer by invariant-3's ciphertext CHECK (the only thing that
catches the raw-SQL / query-builder write bypass), so crypto never sees that write to
emit on it. crypto_scope_mismatch IS registered because the wrapped-DEK store runs raw
SQL that bypasses the kernel ContextSeal, exactly as AI registers ai_scope_mismatch.
Emission (src/isthmus/crypto_guard_audit.ts) — emitCryptoGuardEvent mirrors the AI
fail-safe discipline line for line: counters bump before the rate limiter, a
sync/async-throwing dispatcher or metric sink is caught and counted as dropped, dispatch
is fire-and-forget, and the emit runs BEFORE the throw. Wired at the 6 sites (the
dek_unwrap_failed emit wraps unwrapDek in #liveDek and RETHROWS, so I3 fail-closed
holds). The keyprovider emit is intentionally tenant-less (APP_KEY is a process-global
secret). Provider ready() bridges tenantful trips to the crypto_guard_rejections metric.
Enforcement: no_silent_crypto_guard.spec (every "refusing…" throw is in a
registered-and-emitting file or allowlisted; the allowlist is empty) + the registry
contract + a registry-driven emission matrix typed Record<CryptoGuardId, TripRecipe> (a
new guard is a compile error until it has a trip+happy behavioral test).
Adversarial-review remediation of the Phase-3a guards:
- CRITICAL: check-crypto-invariant-4 pointed at packages/crypto/services/... (missing the
`src/` segment), so it read zero files and passed green — a DEAD guard. Fixed the path,
and run() now fails loud if the load-bearing files are absent. It also now scans the
WHOLE crypto src for a stray hkdfSync (a shared field key can no longer hide in a
src/internal helper), checks ALL frozen Buffer.from byte constants are pairwise distinct
(any quote style / const|let|var, closing the naming blind spot), and verifies category
actually feeds the hkdfSync info argument (dataflow, not token presence).
- check-crypto-invariant-9 now also enforces the config-literal half of I9 (a hardcoded
`const dek = Buffer.from('..')` / `appKey = '..'` / `?? 'fallback'`), a bare thrown
template string, and process.stdout/stderr.write — with the key-name match kept exact so
public salts (KEK_SALT) and Buffer.from(x, 'base64') are not false positives.
crypto unit 177/177 (incl. the emission matrix + the guard-bypass regression specs),
npm run check 40/40, typecheck 0.
…ope column + FORCED RLS Under rowscope-pg the wrapped-DEK table is SHARED across tenants (per-tenant migrations are a no-op there), so separation moves from a per-tenant namespace to a tenant_id scope column. PgWrappedDekStore now supports every placement: - store: `case rowscope` resolves a RowScope (column + tenant id + optional RLS GUC); every query appends `AND tenant_id = ?` (always-on isolation) and INSERT stamps the column. When the driver reports rls:true, #exec wraps the query in a transaction that set_config()s the transaction-local GUC so the store's own raw SQL passes a FORCED policy. Removes the `rowscope_unsupported` fail-closed. - migration: a central rowscope stub (create_crypto_wrapped_deks_rowscope) ships the shared table + a per-(tenant_id, subject_id, category) partial UNIQUE + an ENABLE/FORCE RLS policy, since rowscope migrate() is a no-op. Added to the package `files` allowlist. - guards: check-crypto-invariant-2 widened to the rowscope allowlist (+ tenant_id) and to catch bytea/varchar plaintext shapes with a presence FLOOR; invariant-10 pins the rowscope UNIQUE per-statement. isthmus registry note updated (guard.crypto_scope_mismatch holds under rowscope too). - tests: real-PG rowscope two-tenant + database-pg integration specs, a rowscope store-scoping unit spec, and helper support. crypto unit 194 green.
…s + CI + publish) Phase 5: flip crypto from private/in-build to a published release-candidate satellite, and ship its user documentation. Docs: - docs/guides/satellites/crypto.md: the full guide (key hierarchy, placement/I1, the two encryption surfaces, blind index, the ciphertext CHECK, crypto-shredding, KEK rotation, custom KeyProvider, rowscope placement, guard events, honest limits). - Sidebar + satellites index entries; tenant:crypto:shred + tenant:crypto:rekek rows on the CLI reference, dropped from the commands_documented allowlist. - stability.md: crypto row at release candidate. Graduation: - package.json: remove `private`; add the `adonisjs.configure` + `commands` hooks and the `lasagnaSatellite.migrations` (central rowscope stub) entry. - configure.ts: `node ace configure @adonisjs-lasagna/crypto` (register the provider, publish the central rowscope stub via the shared toolkit). - .c8rc.json: the unit coverage gate (check-coverage:true; floors 84/80/84/84 under the measured 93% lines / 90% branches). README.md (RC badge) + CHANGELOG.md. - CI: build:crypto in the satellite build chain, unit + integration coverage steps, raw V8 upload, publint/attw loop, and publish.yml entry. Lockfile syncs crypto's @adonisjs/redis peer. All graduation guards green (check-satellite-graduation, check-stability-versions, check-publish-coverage, check-satellite-config-wiring, check-satellite-migrations). crypto unit 194, integration 32, npm run check 40/40, typecheck 0, docs-code gate OK.
…ns schema (I1) Update the pgvector release note to describe the extensions-schema placement: the `vector` extension installs into a dedicated `extensions` schema that schema-pg tenant connections append to their search_path after the tenant's own schema, so a bare `vector(N)` column resolves while `public` stays off the tenant path and physical isolation (I1) holds. This was the last uncommitted change in the tree, unrelated to the crypto satellite work on this branch.
…ity, framed §6.8, TS rigor Harden @adonisjs-lasagna/crypto against the frozen design (01-crypto / 00-foundation) without reshaping the RC surface. All changes are additive (no major bump, contract version unchanged). Correctness & fail-closed (WS-0): - shred: serialize the destructive section (bounded jittered backoff → retriable `shred_in_progress`; Redis-down still fail-open) AND treat shredLive()'s return as authoritative, so a lost race yields `alreadyShredded` with no double WORM audit or duplicate SubjectShredded (§6.6). Ledger-absent check consolidated once before the lock. - rekek: new `shreddedDuringRewrap` bucket so the accounting invariant is exact (scanned = current + rotated + shreddedDuringRewrap + failed). - pg store: INSERT ... RETURNING fails closed (`insert_failed`) instead of a non-null bang. KeyProvider extensibility + SSRF (WS-1): - KeyProvider gains optional `contractVersion`; registry.register() enforces it via assertContractCompat (AI/billing parity). - new HttpKeyProvider base routes every egress through core safeFetch (T13 by construction) + reference VaultKeyProvider (transit engine); raw KEK bytes never enter the process. - new structural guard check-crypto-invariant-11 forbids raw egress outside http_key_provider.ts and requires the base to route through safeFetch (presence FLOOR). - WORM ledger connection name resolved from config (was hardcoded 'backoffice'). Framed enc_v2 stream envelope §6.8 (WS-2): - sealFramedV2 / openFramedV2 (+ streaming variants): a composition of core's per-frame seal, not a new cipher (I1). Frame index rides the authenticated keyId; a counted terminator seals the frame count. Strict-open rejects reorder / drop / duplicate / cross-stream / truncation / tamper and never returns partial plaintext. TypeScript rigor (WS-3): - mixin hooks typed EncryptableModel, decorator options generic <TRow>, command catches narrowed to unknown. Tests & coverage (WS-4): - split the shred spec into the design-named files; add concurrent-race, rekek/worm-ledger accounting, KMS-down, SSRF-blocked, framed-envelope matrix, contractVersion gate, and real-PG crash/governance-absent specs (+ gated real-Vault smoke). - unit coverage floors 84/80/84 → 91/85/90/91 (measured 93.8/87.6/92.6); minMergedCoverage 60/60/60 → 80/80/75; worm_shred_ledger removed from c8 excludes. Docs (WS-5): - fix encryptedColumnCheckSql example drift (positional, not object), Mermaid key hierarchy, rowscope/CHECK callouts, EncryptedRepository controller example, OLD_APP_KEY .env note, Custom-KeyProvider expansion, @searchable post-shred JSDoc, CHANGELOG, tests/README T1..T14 map.
…pt calls The regex flagged crypto's own EncryptedRepository.encrypt/decrypt (member calls and typed-parameter definitions), a pre-existing red on the base branch. Tighten it to `(?<![.\w])(?:encrypt|decrypt|decryptStrict)\s*\((?!\s*\w+\s*:)` so it still catches a lenient bare decrypt on a secret but skips member calls and parameter type declarations.
…th seams The definePlugin facade (returns a SatelliteProviderConstructor) plus the four request-path seams, so a community satellite mounts on the request path with zero core edits: - SEAM-3 authorizer chain (AUTHORIZER_CONTRACT_VERSION): fail-closed, wired into TenantGuardMiddleware. The membership-gate signal stays bound to config.authorizeTenantAccess, so a plugin authorizer is additive and never masks a missing IDOR gate. - SEAM-2 tenant middleware registry (TENANT_MIDDLEWARE_CONTRACT_VERSION): wired into installRouterMacros; the core scope middleware runs first. - SEAM-4 request macros: registerTenantRequestMacro, memoized by Symbol, throws on collision, fail-closed when requireTenant. - Capability registry (CAPABILITY_CONTRACT_VERSION): single-provider provide and degradable consume, typed via the augmentable LasagnaCapabilities interface. Foundation: branded names minted only via assertSafeIdentifier, an assertNever boot dispatcher over the section kinds, an independent PLUGIN_API_CONTRACT_VERSION, and a typed E_PLUGIN_* exception hierarchy (no bare Error on the surface). Hardening: E1 scoped eslint (no-explicit-any / no-non-null-assertion) plus a plugin_type_safety spec; E3 guard.plugin_authorizer isthmus guard and a scoped check-no-silent-catch guard; E5 the authorizer wrapped in executeExtension (deadline-to-deny). E2 config plugins.limits: fail-closed count caps (maxAuthorizers / maxMiddleware / maxCapabilities) enforced at boot plus an authorizerDeadlineMs knob, with bounds validation. New ./plugin subpath export. Docs: the plugins guide, extensibility surface rows, the satellite cookbook, and the configuration reference.
…ot guard
Refactor the reporting and satellite-template providers to
`export default definePlugin({...})`, proving the facade on a shipped satellite
and on the template a community author copies. Reporting keeps its ready() cache
invalidation; the template keeps its start() destroy hook.
Widen check-abi-boot-assertion with DEFINE_PLUGIN_RE so a facade provider still
satisfies the ABI-boot guard (reporting is in its PROVIDERS list). The widening
must land in the same commit as the refactor, or the guard fails on this tree.
… + red-team Lote A hardening, tests only: - Fill the fail-closed lifecycle matrix in behavior_define_plugin: a throwing register / start / ready / shutdown hook aborts with a PluginBootException attributed to its own phase (only boot() was covered before). - security_plugin_identifier_fuzz: a 19-entry hostile identifier corpus (key delimiter, PG escape, statement terminator, path traversal, shell metachar, over-length, NFKC homoglyphs) driven through all five branded-name minters, each rejecting; a SAFE control set each accepts; a rejected mint trips guard.tenant_identifier. Pins the "a brand is proof of safety" invariant. - security_malicious_plugin_fail_closed: a hostile definePlugin cannot smuggle a hostile identifier into a section, hijack another plugin's capability key, or backdoor via a throwing authorizer. The header documents the Lote-S controls (read-only DB role, trusted-list proxy) that are NOT enforced here, so the red-team never claims a sandboxing it does not provide.
authorizer() / middleware() / requestMacro() / defineCapability() — the ergonomic way to author a seam entry. Each mints the branded name (so a hostile name throws at authoring time), stamps the `kind` discriminant, and defaults `contractVersion` to the SDK's current surface constant, so an author writes the fields that matter and never hand-builds the raw discriminated object or reaches for a minter by hand. The output is byte-compatible with the raw shape that definePlugin consumes; defineCapability's typed overload keys `api` on LasagnaCapabilities. Wired into the /plugin barrel and the E1 no-any eslint override (plus the plugin_type_safety surface list, so the drift check stays green). builders.ts is app.booted-safe: it imports only the value constants the barrel already re-exports. Docs: plugins.md now leads each seam with its builder and lists them in the export table. Tests: behavior_plugin_builders.
…ene + backcompat pins Closes Lote A of the plugin platform (E1-E9 now complete). E7 is the durability layer: a committed public-API snapshot per entry point plus backwards-compatibility pins. Per-entry golden: - api-extractor.json was single-entry (index only), so the /plugin and /sdk subpath surfaces were invisible to the golden-diff gate. Add api-extractor.plugin.json + api-extractor.sdk.json (each `extends` the base and overrides only the entry .d.ts + report file name) and rewrite scripts/check-api-report.mjs to discover every api-extractor(.<entry>)?.json per package. Lote C's /mixins config will be picked up with no further edits. New goldens: etc/saas-tenancy.plugin.api.md + .sdk.api.md. Surface hygiene (the golden exposed forgotten-exports on the flagship /plugin surface): - Re-export SatelliteProviderContract/Constructor (definePlugin's return type), TenantModelContract/Status/Metadata (the authorizer's tenant param) from /plugin; SatelliteDependency from /sdk. All type-only, additive. - Export PluginPlatformConfig/PluginLimitsConfig (public via config.plugins) from the root + /types barrels so the config type is nameable. - Branded stays intentionally un-exported (opaque, private unique symbol), documented in the barrel and the one benign remaining golden note. Resync the index golden: it was stale on the branch (predating the crypto base and the Lote A commits), so the separate check:api-report CI step was already red. Regenerated to match the committed public surface. Backcompat pins: a frozen v1 definePlugin spec (hard-coded version 1, must keep booting after a future major by the three-way compat gate) and a hand-written raw SatelliteProviderContract provider (the pre-facade route websockets ships, now pinned by a core test not by prose). Coverage: ratchet core .c8rc floors 57/84/71/57 -> 64/85/75/64 off the measured unit baseline now that Lote A is closed. Gates: core unit 1492, typecheck 0 all workspaces, check 42/42, check-api-report 3/3 in sync, lint 0 on changed, test:coverage green.
The in-memory INSERT handler declared `const row`, shadowing the top-level `function row` builder and tripping eslint no-shadow, the sole remaining `npm run lint` error on the branch. Rename it to `insertedRow`; behavior unchanged (8/8 pass). Branch lint is now clean.
The load-bearing net-new primitive for Lote S. A module-level
AsyncLocalStorage (mirroring the `tenancy` facade) that marks WHICH plugin's
code is executing and whether it is trusted, so downstream controls can gate
on "is untrusted plugin code on the stack right now?" without a container
round-trip on the per-query hot path:
- S3 will route the tenant adapter to a read-only connection when
`pluginScope.untrustedActive()` (the Postgres-enforced firewall);
- S5 will deny core-singleton access to untrusted plugins (labeled
in-process friction).
`trusted` is decided once at scope entry; the store is immutable; scopes
nest with normal ALS semantics; fail-closed by default (anything core has
not explicitly entered as untrusted is treated as trusted core code). No
consumers yet — core enters the scope around plugin callbacks in S3/S5.
Module-level singleton on purpose: it can never be `new`-ed into an empty
second instance, so it stays off the stateful-singleton container list.
8 unit tests pin the trust predicate, async-continuation propagation,
nesting/restore, return-value passthrough, and throw-still-clears.
The typed permission vocabulary a plugin uses to DECLARE the sensitive
capabilities it needs, so `configure` can surface them for explicit operator
consent (S1) and a guard can pin manifest↔spec coherence. Declaration is
disclosure, not enforcement — the real containment is layered (an untrusted
plugin is routed read-only regardless of a `db:write` declaration; the
scheduler/data-change seams land in Lote B/C).
- `PluginPermission` discriminated union (never a bare string[]), with a
single-source bijective serializer/parser to the manifest wire form
(`scheduler` / `data_change:users,orders` / `network:external` / `db:write`).
Fail-closed parse: an unrecognized string or a hostile model identifier in a
`data_change:` list yields null (dropped with a warning), so a crafted
manifest can't smuggle one through.
- `permission.scheduler()/dataChange(...models)/networkExternal()/dbWrite()`
builders — the only sanctioned way to populate `definePlugin({ permissions })`;
`dataChange` mints each model through the identifier guard.
- `ModelName` brand + minter for the data-change model list.
- `PluginSpec.permissions` (metadata, not a boot entry) + manifest
`permissions`/`nativeAddons` fields parsed + canonicalized in
`readSatelliteManifest`.
- `/plugin` barrel exports `permission` + `PluginPermission` + `ModelName`;
added `plugin_permissions.ts` to the E1 no-any surface (eslint override +
plugin_type_safety list); regenerated the plugin + sdk goldens.
14 unit tests: builder→wire→parse bijection, exhaustiveness anchor, fail-closed
rejection of malformed/hostile permissions, and manifest parse/canonicalize.
Gates green: core unit 1514, typecheck 0, check 42/42, api-report 3/3, lint 0.
…sent
Closes the S1 consent loop on the permission model from S-1a.
Guard — `scripts/check-plugin-permissions.mjs` (CI-wired via `npm run check`,
now 43 guards; ships `--self-test`): pins that a satellite's DECLARED permissions
stay coherent between `definePlugin({ permissions })` in the provider source and
`package.json#lasagnaSatellite.permissions` (the wire form the operator consents
to). Neither side may declare a permission the other omits; comparison is order-
and model-order-insensitive. A satellite declaring none in either place is
trivially coherent, so it is inert until a satellite opts in, then enforces
against drift. Models on the same 5 providers as check-abi-boot-assertion.
Consent — `configure` now gates publishing a satellite that declares permissions
on explicit operator consent (fail-closed): `--accept-permissions` or
`LASAGNA_ACCEPT_PERMISSIONS=1` grants non-interactively for CI; on a TTY the
concrete capabilities are shown and confirmed; a non-interactive run WITHOUT the
flag REFUSES (skips the satellite, exitCode=1), so a piped install can never
silently accept a plugin's sensitive capabilities. `printSatelliteManifest` shows
the requested permissions, and a pure `describePluginPermissions` renders each
wire string as a concrete human line (unknown strings pass through, forward-compatible).
Tests: +4 (human-line rendering + forward-compat passthrough) and the guard
self-test. Gates green: core unit 1516, check 43/43, lint 0.
A "Declaring permissions" section in the plugins guide: the permission.* builders, the matching manifest wire form, the check-plugin-permissions coherence gate, and the fail-closed install consent flow (--accept-permissions / LASAGNA_ACCEPT_PERMISSIONS for CI). A callout makes the honest boundary explicit — declaration is disclosure, not a sandbox; real containment is the read-only role and the worker network policy. Import-surface table gains the permission builder + PluginPermission/ModelName.
…alth-check Two supply-chain controls. Install gate (configure): a satellite whose manifest declares `nativeAddons` cannot be sandboxed by the worker Permission Model (a native addon evades --permission), so `configure` refuses to wire it unless the operator acknowledges it as fully trusted — `--allow-native` / `LASAGNA_ALLOW_NATIVE_ADDONS=1`, or a TTY confirmation. Fail-closed: a non-interactive install without the flag skips it (exitCode=1), mirroring the S1 permission consent gate. Health check (lasagna:health-check): runs `npm audit` over the dependency tree and flags each installed satellite that ships a native addon or an install lifecycle script (the vector --ignore-scripts blocks). Exits non-zero on a high/critical advisory so it doubles as a CI gate. The parse/threshold logic is the pure `supply_chain_audit` module (parseNpmAudit / countAtOrAbove / hasInstallScripts, 5 unit tests); the command is a thin cross-platform shell around it. Registered in commands.json + index + documented in commands.md. Gates green: core unit 1521, check 43/43, lint 0.
…lone)
The code half of the S3 firewall: when UNTRUSTED plugin code is on the stack
(pluginScope.untrustedActive()) and a read-only role is configured, the tenant
adapter routes the query to a connection cloned from the tenant's own — same
database/schema/search_path — but authenticated as a SELECT-only Postgres role,
so a write is denied by Postgres, not a JS proxy.
- config `plugins.readOnly = { user, password? }` (PluginReadOnlyConfig,
exported from the root + /types barrels; regenerated the index golden).
- `buildReadOnlyConnectionConfig` clones the primary config overriding ONLY the
credentials, preserving the top-level searchPath exactly like the read-replica
clone (knex reads config.searchPath, so the tenant schema carries over).
- `tenant_adapter.modelConstructorClient` registers the read-only variant
synchronously (the primary is already registered, so its config is available)
and routes to it; core code and trusted plugins keep the normal connection.
9 unit tests: the clone (credentials-only, searchPath preserved) + routing
(untrusted → read-only, register-once, core/trusted/no-config → normal). The
end-to-end "Postgres denies the write" proof + the plugin_ro role land in S-3b.
Gates green: core unit 1528, typecheck 0 all workspaces, check 43/43,
api-report 3/3, lint 0.
…ven)
The infra + end-to-end proof for the S3 firewall (S-3a shipped the adapter
routing). Mirrors the existing rowscope-RLS least-privilege pattern:
- ci.yml: a `plugin_ro` login role, NOSUPERUSER NOBYPASSRLS, created with
`ALTER ROLE plugin_ro SET default_transaction_read_only = on`, plus
PLUGIN_RO_DB_USER/PLUGIN_RO_DB_PASSWORD env (mirrors the rls_ci role step).
- fixture `plugin_ro` connection (mirrors rls_probe): authenticates as that
role in CI, falls back to the writable default locally.
- integration red-team `security_plugin_read_only_role.spec.ts`: proves the
role can SELECT but every write (INSERT/UPDATE/DELETE) is DENIED by Postgres
even when GRANTed, because its transactions are read-only. Self-skips when the
probe resolves to a writable role (local default) and — like the RLS proof —
fails LOUD when PLUGIN_RO_DB_USER is set but the role is writable, so the
guarantee can never ship false-green.
Together with the S-3a unit routing proof (untrusted → the cloned read-only
connection), this closes the loop: untrusted plugin code cannot write to the
tenant database. VALIDATION NOTE: the integration proof is CI-only — it needs the
provisioned plugin_ro role; it self-skips green locally (and PG was down locally
this session), so it was not run here. Lint 0, YAML valid.
The buildable half of S4: the Node Permission Model machinery + the R6#2
native-addon boot guard. (Routing scheduler/onDataChange callbacks onto the
sandboxed worker is inherently Lote B/C — those seams don't exist yet.)
- `worker_sandbox.ts`: `parseWorkerSandboxState(argv)` (pure) + `WORKER_SANDBOX_STATE`
read ONCE at module load from process.execArgv (R7#2) + `NODE_SANDBOX_FLAGS`
(the base `--permission`; host-specific fs allow-lists documented in SECURITY.md,
S-6).
- `assertNativeAddonsSandboxable`: a native (.node) addon evades the Permission
Model, so a plugin declaring one aborts the deploy (PluginBootException,
phase 'nativeAddons') when the current process is sandboxed (`--permission`)
WITHOUT `--allow-addons`. Inert in the non-sandboxed API process and for a
plugin with no native addons.
- `definePlugin({ nativeAddons })` spec field, checked in boot() after the ABI
backstops; regenerated the plugin golden.
Scope note: the docker-compose worker `command:` is deliberately NOT changed here
— forcing `--permission` on the e2e worker un-validated risks breaking e2e CI, and
the worker is @adonisjs/queue-owned. The sandbox is opt-in by the operator on their
worker launch; core supplies the flags + the boot guard that fires when they do.
Recommended flags + ops guidance land in SECURITY.md (S-6).
8 unit tests: argv parse, the fail-closed matrix, and the facade booting a
native-addon plugin inertly in the non-sandboxed process. Gates green: core unit
1534, check 43/43, api-report 3/3, typecheck 0, lint 0.
…y trust allowlist Untrusted third-party plugins now hit labeled in-process friction on the sanctioned paths to core singletons, and sensitive capabilities are allowlist-gated. This is friction, not a boundary (a direct import evades it) — the hard wall for an untrusted write stays the S-3 read-only Postgres role. - sdk/plugin_env.ts: TRUSTED_SATELLITES allowlist reader (fail-closed, memoized on the raw env string, drops malformed entries via the identifier guard). - services/plugin_core_access.ts: assertCoreAccessAllowed() — the single throw site; emits guard.plugin_core_access + throws UnauthorizedCoreAccessException (403) when untrusted plugin code is on the stack. resolveTenantRepository funnels through it; new resolve_database.ts is the guarded (evadable) db accessor. - capability_registry.ts: CapabilityProvision gains `sensitive`; register() gates a sensitive provision by provider name and consume() denies a sensitive cap from an untrusted scope, emitting guard.plugin_capability_trust. CAPABILITY_CONTRACT_VERSION 1 -> 2 (a v1 provision warns, not fails). define_plugin threads the plugin name in; defineCapability builder gains `sensitive`. - 2 typed 403 exceptions, 2 Isthmus registry entries (+matrix recipes), /services barrel exports, /plugin api golden regen, E1 no-any surface + eslint override. - Tests: plugin_env, core-access proxy, capability allowlist (provide+consume emit), builder sensitive-threading, malicious_plugin S5 red-team group. Verified via a 4-lens adversarial review (bypass/tests/ceremony/honesty); 3 low findings folded in. Gates green: core unit 1558, tsc 0 all workspaces, check 43/43, api-report 3/3, lint 0, Isthmus audit-coverage 100% (22 registered guards).
…hreat model (closes Lote S) The last slice of Lote S. Adds operator-facing diagnostics, the fourth plugin-surface Isthmus guard, and the published trust-boundary threat model. - guard.plugin_extension_identifier: the branded-name minters (sdk/brands.ts) now validate with the non-emitting isSafeIdentifier predicate and emit their OWN plugin-surface guard on a reject (distinct from the tenant-DDL guard.tenant_identifier), so a hostile plugin identifier reads apart from a bad tenant id. Same accept/reject set. Registry entry + matrix recipe + fuzz-spec assertion updated. - plugin:doctor command + pure PluginDoctorService: diagnoses the deployed platform posture from the discovered manifests + trust/firewall config — Satellite ABI drift (error/warn), native-addon sandbox risk, dead TRUSTED_SATELLITES entries, a missing read-only firewall while untrusted plugins are installed, and a disclosure of every declared (consent-gated) permission. It does NOT introspect specs (that is the check-plugin-permissions CI guard). Full command ceremony (commands.json, index, commands.md); the service is unit-tested per check. - Threat model: docs/guides/security.md gains the five-layer plugin trust-boundary matrix (S1..S5 + the in-process-sandbox non-goal, each verdict real/friction) plus hardening-checklist items; stability.md gets a plugin-platform row (Experimental); .github/SECURITY.md states the sandbox-escape non-goal. - Coverage floors ratcheted lines/statements 64 -> 65. Verified via a 4-lens adversarial review (bypass/tests/ceremony/honesty); 3 low findings folded in (severity assertions for 3 doctor checks, disclose ALL declared permission kinds not a subset, corrected a stale plugin_env comment). Gates green: core unit 1575, tsc 0 all workspaces, check 43/43, api-report 3/3, lint 0, Isthmus audit-coverage 100% (23 registered guards), coverage green at the new floors. Lote S (S-0..S-6) is complete. Merge order stays A -> S -> B -> C.
A plugin can now register a periodic tick that fans out over active tenants via
`definePlugin({ schedules })` + the `schedule()` builder. Built on the NATIVE
`@adonisjs/queue` scheduler (the real backend), not a hand-rolled lock: start()
arms one native schedule per entry (upsert by deterministic id) pointing at a
global tick job; the host's `queue:work` worker claims a due schedule atomically
(`claimDueSchedule`), so the tick is dispatched once per interval across pods with
no advisory lock. runTick fans out over active tenants (status filter, default
`['active']`), dispatching a per-tenant BullMQ job per tenant with a best-effort
dedup jobId + optional jitter.
- Foundations: `ScheduleName` brand + minter; `sdk/plugin_keys.ts` single-source
(schedule id, tick job name, per-tenant jobId) + `check-plugin-keys.mjs` guard
wired into check.mjs (44 guards); `SchedulerTickException` (E3).
- `TenantSchedulerService` (4th plugin singleton): register (fail-closed on
duplicate / cron-XOR-everyMs), start (arm), runTick (fan-out). Per-tenant
dispatch fail-OPEN — the catch is inside the `each` callback so one bad tenant
never starves the rest; enumeration failure fail-CLOSED (SchedulerTickException,
tick job carries maxRetries so the interval recovers). Arming fail-closed on the
worker, fail-open on the web process (a queue blip must not fail web readiness).
- Facade: `schedules` field + `schedule` boot-dispatcher case (closed union) +
E8 `schedule()` builder; `/plugin` + `/services` barrels; `maxSchedules` cap
(config bounds + assert_plugin_limits); E1 no-any surface + no_adhoc singleton.
- Docs: `docs/guides/scheduler.md` (native backend, status filter, reconciling vs
fire-once, honest at-least-once/dedup caveats, no-auto-unarm note) + sidebar +
plugins.md Schedules section. api-extractor goldens (index + /plugin) regen.
Gates: core unit 1593, typecheck 0, check 44/44, api-report 3/3, lint 0, coverage
above floors. Adversarially reviewed (4 lenses) — 2 medium + 5 low folded in.
…in({ provisionExtensions })
Generalize the pgvector provisioning engine into a reusable `provisionExtension({
name, schema? })` so a plugin (search/geo/etc.) can install ANY Postgres extension
into each tenant's storage without core edits. `provisionVectorExtension` now
delegates to it (regression-preserving; the 11-case vector unit spec stays green),
and `installExtension` / `withProvisionConnection` are exported for a plugin's own
provisioning path.
- vector_provisioning.ts: `installExtension(conn, name, schema?)` (schema optional →
plain CREATE EXTENSION), `provisionExtension(spec, opts, deps)` with the same
by-driver dispatch (per-tenant-db on database-pg, once-central on schema/rowscope,
skip on sqlite/unknown), identifiers validated via assertSafeIdentifier BEFORE any
DDL. Throwaway connection prefix renamed `__vector_provision_` → `__ext_provision_`.
- Facade: `provisionExtensions` field → a boot-time step that validates identifiers
(fail-fast at deploy = PluginBootException phase 'provisionExtensions') and
registers ONE after('provision') hook installing each extension into the newly
provisioned tenant. Fail-open by the HookRegistry after-hook contract (throw is
logged, not propagated), so a per-tenant install failure surfaces without aborting
the tenant's core provisioning.
- Barrels: `/services` + isolation index export provisionExtension/installExtension/
withProvisionConnection + ExtensionProvisionSummary/ProvisionExtensionSpec; `/plugin`
exports ProvisionExtensionSpec. plugin golden regen.
- Tests: new behavior_provision_extension spec (identifier rejection before any DDL,
no-schema single-statement shape, generic name/schema, extension field, sqlite skip)
+ facade cases (registers one after('provision') hook; hostile identifier fails
closed). Docs: plugins.md "Provisioning Postgres extensions" + surface table.
Gates: core unit 1601, typecheck 0 all workspaces, check 44/44, api-report 3/3,
lint 0, coverage above floors.
…n + onDataChange)
Plugins can now react to committed tenant-model writes without the model importing
them. An opt-in `TracksDataChanges(TenantBaseModel)` mixin emits a `TenantDataChanged`
event after each committed write; `definePlugin({ onDataChange })` subscribes with
model/operation filters. Closes the plan A→S→B→C.
- events/tenant_data_changed.ts: the event + PII-free payload (model/table/pk/changed
COLUMN NAMES, never values) + subscription type. On the E1 no-any surface.
- models/mixins/tracks_data_changes.ts: the mixin (mirrors scoping.ts's idempotent
static boot()). Attributed to tenancy.currentId() (SKIPS if no scope, never
mis-attributed); dispatch deferred to $trx commit (rollback emits nothing; autocommit
inline); fail-open guarded emit (never breaks/slows the write). Loose Lucid typing +
a dispatcher test seam (NOT on the public /mixins ABI).
- services/plugin_data_change.ts: subscribeDataChange() — emitter.on wrapper, AND-combined
model/operation filters, per-subscriber fail-open (log + per-tenant
data_change_subscriber_errors metric); fan-out isolation (one bad subscriber can't stop
another). observability/plugin_names.ts PLUGIN_METRIC catalog + pin.
- Facade: onDataChange field + #subscribeDataChange() in ready(); /mixins subpath
(exports+typesVersions+barrel+api-extractor.mixins.json golden); /plugin + /events barrels.
- Docs: guides/data-change-hooks.md + sidebar + plugins.md + events.md, honest about the
edges (savepoint-commit, bulk query-builder writes emit nothing, class-name minification,
surrogate-key assumption, repository-based re-read). check-no-silent-catch regex tightened
to the logger idiom (+false-negative self-test); scheduler + data-change services scoped.
Gates: core unit 1616, typecheck 0 all workspaces, check 44/44, api-report 4/4 (new
mixins golden), lint 0, coverage above floors. 5-lens adversarial review folded (3 med
honesty/coverage caveats + 4 low: public-ABI test seam, over-broad guard regex, fan-out
test, docs re-read pattern).
… prueba-de-uso
Closes the plan's E6 GATING tier + end-to-end verification for the three Lote-B/C
seams. Written to the S-3b self-skip convention; validated in CI (PG up), NOT run
locally this session (PG/Redis down).
- SEAM-5: resilience/integration/resilience_data_change_after_commit.spec.ts — the
mixin's after-commit / rollback guarantee against real Postgres (the property the
unit test can only stub): a committed write EMITS TenantDataChanged (names-only
payload, pk in keys); a ROLLED-BACK write emits NOTHING; an update carries the
changed column names; and an end-to-end real subscriber receives a committed
change (the prueba-de-uso path definePlugin({ onDataChange }) wires). New fixture
model tracked_note.ts (TracksDataChanges(TenantBaseModel)).
- SEAM-1: behavior/integration/behavior_scheduler_fanout.spec.ts — runTick fans out
over the REAL tenant repository honoring the status filter (recording-queue
subclass; subset assertions since the backoffice table is shared).
- SEAM-7: a generic-extension case in behavior_vector_provisioning.spec.ts —
provisionExtension({ name: 'pg_trgm' }) with no schema against real PG (self-skips
without pg_trgm), exercising the schema-less path the vector delegation doesn't.
- Prueba-de-uso: the reference satellite-template provider now declares onDataChange
(SEAM-5), demonstrating a Lote-C seam in a real facade plugin (verified by
build+typecheck; the provider isn't unit-boot-testable — its /services barrel is
booted-unsafe, same as reporting in Lote A). Lazy logger keeps it importable.
Gates: typecheck 0 all workspaces (integration specs + fixture + template compile),
core unit 1616 + coverage above floors, template unit 13, check 44/44, lint 0.
…view Adds LASAGNA-040726/crypto-satellite to the push trigger so the whole A→S→B→C plugin-platform stack is exercised on the branch during review. REVERT this commit before merging crypto-satellite to master.
…age gate The crypto integration V8 was produced (test:integration:coverage → coverage/.v8/crypto-integration) but never uploaded, and neither the crypto unit nor integration V8 was downloaded into coverage/.v8/all in the coverage-report job. So crypto never reached the merged lcov and check-satellite-coverage.mjs failed it as a wiring error (no coverage data), which fails even in report-only. Add the missing crypto integration upload (test-integration job) and the crypto unit + integration downloads (coverage-report job), matching the ai/websockets wiring. Verified by simulating the root merge locally: crypto now yields src records in the merged lcov and clears its declared floors unit-only (lines 93.7% / functions 93.21% / branches 87.79% vs 80/80/75).
Close the systemic "green passes while a control silently no-ops or fails open" gaps found by the enterprise audit, each at the source: - RLS false-green: rowScopeRls=true with an empty rowScopeTables used to skip the catalog probe entirely and report protected while nothing was verified. Add assertRowScopeTablesDeclared() (pure, unit-tested) and wire it into the provider boot so an empty list fails closed (IsolationConfigException) instead of leaving the escapable mixin as the silent-only boundary. - RO firewall fail-closed: TenantAdapter no longer falls through to the writable primary when untrusted plugin code is active but the SELECT-only clone cannot be established; it denies (IsolationConfigException). - SSO loopback SSRF: derive safeFetch allowLoopback from the target host instead of hardcoding true, so a public issuer host that rebinds to loopback is rejected by the pin rather than trusted on the client_secret token POST. Production issuers are never loopback; in-process test IdPs on 127.0.0.1 still work. - onDataChange scope: re-establish the tenant scope (tenancy.run) before invoking a data-change subscriber, closing a cross-tenant leak where a subscriber for tenant A executed under whatever scope was ambient after the decoupled after-commit emit. Scope runner is a swappable test seam. - impersonation token: the tenant:impersonate command no longer prints the raw live token by default (scrollback / CI logs); it masks it to a fingerprint and gates the full value behind --show-token. The redirect URL remains the operator's deliverable. The lenient-decrypt-on-secrets concern is already enforced repo-wide by the no_lenient_decrypt_on_secrets architectural spec, so no redundant guard added. Gates: core unit 1618, sso 39, core typecheck 0, check 44/44, lint clean.
…ice reference (nada cableado) The backoffice schema/connection are operator config, but the shared WORM ledger and the AI audit hardcoded them, so a host that renamed the backoffice schema got fail-closed 503s on every audited request. Fix it at the source and add guards so it cannot regress. Root fixes: - New qualifyBackofficeTable(schema, table) helper (validated "schema"."table", exported from /sdk). Route the WORM ledger writer, AI audit writer, and AI audit check through it with the configured backofficeSchemaName instead of a hardcoded `backoffice.` literal. - Unify the connection convention on backofficeConnectionName for backoffice-schema tables: crypto's WORM ledger moves off centralConnectionName; the AI provider reads backofficeConnectionName/backofficeSchemaName from config (a new #backofficeWiring() helper) instead of the 'backoffice' literal. Guards + consistency: - New check-no-hardcoded-backoffice.mjs (wired into `npm run check`): fails on a raw-SQL `backoffice.<table>` literal or a 'backoffice' connection literal outside the helper/base-model-default/tests. - Data-drive BACKOFFICE_MODELS from source (`extends BackofficeBaseModel`) in both the guard script and the architectural spec, so a satellite's backoffice model is guarded automatically. This surfaced billing's previously-unguarded cross-tenant queries (webhook tenant-resolution by provider id, global maintenance sweeps, operator commands); each is now an explicit, auditable backoffice-scope-exempt marker. - Typed env toggles: readBooleanEnvFlag() centralizes the boolean parse for WEBHOOKS_ALLOW_LOOPBACK_TARGETS / STRIPE_ALLOW_LIVE_IN_DEV so a case/space variant is honored, not silently dropped to the safe branch. Also fixes stripe_driver's raw NODE_ENV==='production' check to isProductionNodeEnv() (NODE_ENV=prod is production to the framework). - resolveLucidDb() helper replaces the copy-pasted `'lucid.db' as never` cast across the ai/crypto providers. - admin Swagger docs no longer ship a public-CDN default: the asset base is an explicit cdnBase / LASAGNA_ADMIN_SWAGGER_CDN opt-in, else a placeholder renders (no silent third-party fetch). Gates: core 1620, ai 525, crypto 227, billing 63, admin 47; typecheck 0 all; check 45/45 (+ new guard self-test); lint clean.
…eld, discovery-driven guards) Close the plugin-contract-not-adopted-fleet-wide gaps at the source so a new satellite or surface is covered automatically instead of by a hand-list. - Model the manifest: add pluginApiVersion + minMergedCoverage to SatelliteManifest + readSatelliteManifest (with validation). pluginApiVersion was referenced by JSDoc but never modeled — it is now real, declared in the reporting + template facade manifests, and check-abi-boot-assertion pins the code literal (definePlugin) against the manifest (mirror), killing the phantom. - Discovery-driven provider guards: new scripts/lib/discover-satellites.mjs finds every provider-shipping satellite from source (manifest provider + providers/*_provider.ts). check-abi-boot-assertion and check-plugin-permissions consume it, so crypto and the template (previously omitted from the hardcoded five) are now verified — 7 providers, not 5. - Surface meta-guard: add AI + crypto to check-extension-contracts SURFACES and docs/guides/extensibility.md, and add a meta-check that every `export const *_CONTRACT_VERSION` under a package src is a registered surface (PLUGIN_API_CONTRACT_VERSION allowlisted as the facade contract), so a future surface cannot ship unguarded — the exact gap that hid AI + crypto. 15 surfaces. - Drift gate beyond core: api-extractor.json + committed etc/crypto.api.md for crypto, picked up by check-api-report (5 entries). Regenerated the core /sdk golden for Wave 1's new exports + the manifest fields. Also fixes a Wave 1 test typecheck slip (cleanup arrow returning delete's boolean). DEFERRED to Wave 3 (fold into the definePlugin migration, where they belong): the minimal definePlugin providers for sso/admin, and extending the discovery guards to require a plugin-API backstop in EVERY provider (true only after the raw providers migrate). Gates: core 1627, typecheck 0, check 45/45 (+ guard self-tests), check-api-report 5/5, lint clean.
…definePlugin Every satellite is now a definePlugin facade that asserts BOTH the Satellite ABI and the plugin-API contract at boot, so the fleet is uniform on the plugin platform. Providers (register->bind, boot->boot, start->start, ready->ready, shutdown->shutdown): - crypto: the stray async disconnect() (AdonisJS never calls it, so the guard-metric sink installed in ready() leaked) becomes the facade's shutdown. Flagship fix. - ai: closure-scoped teardown handles for the ready()/shutdown() emitter subscriptions; private methods -> module functions; the Wave-1 backoffice wiring (config-derived, never the 'backoffice' literal) preserved. - billing: the four event listeners bound as container singletons and resolved once (no more new-per-event); boot/start/shutdown preserve their phase timing. - websockets: getActiveDriver imported from /services (not the unstable /internal); the http:server_ready attach state hoisted to provider-lifetime closure vars. - backup: the optional @aws-sdk/client-s3 presence probe uses the un-analyzable dynamic-import idiom (no @ts-ignore / @ts-expect-error — env-independent, since the peer is installed in this repo and @ts-expect-error would go unused). - sso, admin: a new minimal backstop-only definePlugin provider (binds nothing); package.json gains exports './provider' + typesVersions + lasagnaSatellite.provider + pluginApiVersion, tsconfig includes providers/. admin's configure now registers the provider in adonisrc (its routes stay guidance-only, host-mounted). - every raw provider's manifest gains pluginApiVersion: 1 (mirrored by the guard). Template: the canonical mirror now exercises every declarative seam (authorizers, middleware, requestMacros, provides, schedules, provisionExtensions, onDataChange); manifest completed with permissions + minMergedCoverage (perTenantMigrations left out on purpose — the widget table already ships via stubs/migrations). Guards: - new scripts/check-provider-lifecycle.mjs: a provider class may declare only the SatelliteProviderContract lifecycle methods (allow-list mirrored from contract.ts, private #-members ignored) — would have caught crypto's disconnect(). Scrubs comments/strings and walks brace/paren depth so a call inside a method body is never mistaken for a member. Wired into check.mjs (now 46 guards). Ships --self-test. - check-abi-boot-assertion now REQUIRES a plugin-API backstop in every discovered provider (a definePlugin pluginApiVersion, or assertPluginApiCompatAtBoot in a raw boot()), enforceable now the fleet is fully migrated. sso/admin auto-discovered -> 9 providers verified. Verification fixes surfaced by running the per-satellite suites: - billing mode_detection: the facade wraps a boot-hook throw in PluginBootException attributed to { plugin, phase }; the spec reads the original reason from .cause. - admin swagger docs: a Wave-1 CDN-opt-in regression the integration tier never ran locally — configure a CDN base to render the real Swagger shell, plus a new test for the no-CDN placeholder default. - core manifest specs + docs/guides {extensibility, cookbook} + sso test comment: sso/admin now ship a provider (were library-only). Gates: core 1627, check 46/46 (+ 2 self-tests), api-report 5/5, typecheck 0, lint 0, build:all 0; per-satellite unit + integration green (the ai vector-store specs need pgvector, absent in the local PG, green in CI).
The demo compose declared `postgres:16-alpine`, which ships no `vector` extension binaries — so the AI satellite's per-tenant embedding store cannot provision pgvector, and a `docker compose up --force-recreate` would drop the extension the running container already had. Pin `pgvector/pgvector:pg16` so the binaries are always present; the extension is still created into a dedicated `extensions` schema (never `public`) at provision time, on each tenant's search_path. Local AI integration (vector store / RAG / fuzz specs) now runs green instead of failing with `type "vector" does not exist`.
…nal decouple Kill the three systemic architecture debts the audit flagged, at the root. Shared Isthmus guard-audit (single source): - New `createGuardAudit(...)` factory on core `/sdk` (`sdk/guard_audit.ts`): one `WINDOW_MS`, one per-severity fixed-window limiter, one fire-and-forget dispatch contract, one counter machinery, the shared `ISTHMUS_BUDGETS`, and an optional per-tenant metric bridge. Each call gets its OWN windows/counters (satellite-local by design, so a satellite burst can't consume the kernel's dispatch budget). - `core/src/isthmus/audit.ts`, `ai/.../ai_guard_audit.ts`, and `crypto/.../crypto_guard_audit.ts` are now thin bindings over the factory, re-exporting their existing named API unchanged. Deletes ~330 lines of line-for-line triplication and the `WINDOW_MS`-drift hazard. The emission-matrix, chaos, rate-limiter, and never-breaks-reject-path specs pin the contract and pass. - The guarded/rejected snapshot is now uniformly pillar-aware across the fleet (superset of the previous shapes; nothing consumed the absence of pillar). Single assertNever: - `/sdk` now exports the one `assertNever`; the ai + crypto copies are deleted and their call sites (vector_store_service, ai_chat_controller, pg_wrapped_dek_store) import the shared helper. The ai behavioral spec now pins the shared helper. `/internal` decouple + keep-vs-hide decision: - `ISTHMUS_BUDGETS` folded into the factory and re-exported from `/sdk`; ai/crypto (src + specs) no longer reach `/internal` for the isthmus. - backup `assertSafeIdentifier` moved to the bare-safe `/sdk`. `getActiveDriver` stays on `/internal` in backup's bare-unit-loaded service modules ON PURPOSE: the public route is `IsolationDriverRegistry` on `/services`, but the `/services` barrel top-level-awaits `app.booted` via redis, so a module loaded without an Ignitor must use the app.booted-safe subpath (documented in internal.ts). - Decision recorded: `/internal` REMAINS published-but-unstable for genuinely-internal first-party helpers; every stable-need helper now has a stable home. Public low-level subpath policy (`/crypto`, `/worm-ledger`, `/signals`, `/adapters`, `/base-models`, `/internal`) documented in docs/reference/stability.md. Regenerated the `/sdk` api-extractor golden (adds createGuardAudit, ISTHMUS_BUDGETS, assertNever, the guard-audit types, and the re-exported Isthmus vocabulary types). Gates: typecheck-all 0, check-api-report 5/5, core 1627 / ai 525 / crypto 227 / backup 64 unit, npm run check 46/46 (incl check:isthmus), lint 0.
…to the migration stubs
The kit's ensureBackofficeSchema() hand-mirrors the backoffice migration stubs
under a drift guard, but the crypto + AI real-PG helpers hand-copy their own
tables (crypto's per-tenant + rowscope wrapped-DEK, the shared WORM ledger, AI's
audit log) with NO drift guard, and the guard's doc pointer was stale. A stub that
gains or renames a column would silently leave the integration tier proving its
guarantees against the wrong shape.
- Fix the stale pointer in bootstrap.ts: the drift spec moved to
tests/@guarantees/behavior/unit/behavior_bootstrap_ddl_drift.spec.ts when the
test tree was reorganized.
- Add an `extraSchemaSetup` hook to `runIntegrationSuite` (run once at boot right
after ensureBackofficeSchema): the sanctioned seam for a satellite to register a
SHARED backoffice table it owns, instead of hand-provisioning it per spec.
Append-only per-group tables (crypto WORM ledger, AI audit log) keep their own
drop+recreate helpers — they cannot be truncated between groups.
- New DDL-drift guards, mirroring core's backoffice guard by column comparison:
* packages/crypto/tests/@architecture/contracts/ ties real_crypto_pg.ts to the
per-tenant wrapped-DEK migration, the rowscope stub (+ its RLS policy) and
core's worm_ledger stub (+ its append-only triggers).
* packages/ai/tests/@architecture/contracts/ ties real_audit_pg.ts to the
ai_audit_logs stub (+ its append-only triggers).
Design note: the per-tenant wrapped-DEK DDL was briefly single-sourced into a
crypto src module, but the crypto security guards (check-crypto-invariant-2/-10)
audit the raw DDL IN the shipped migration by design, so the DDL stays there and
the drift guard pins the helper to it — the same mirror+guard pattern the repo
already uses for the backoffice tables. The two host-owned stubs (rowscope, WORM)
cannot be single-sourced (a host copies + edits them), so a guard is the only
option there regardless.
Gates: core 1627 / ai 526 / crypto 230 unit; crypto 34 + AI 48 integration (real
PG/Redis); typecheck-all 0, npm run check 46/46, lint 0.
…ypto E2E + kill fake-pass Force the crypto guarantees with real Postgres + real HTTP E2E instead of letting them self-skip green. Closes the "crypto has zero E2E" and fake-pass test-integrity findings from the enterprise audit. - Kill the fake-pass anti-pattern (`if(!ready) return assert.isTrue(true)`): crypto encrypted-column-check + core plugin-RO now HONESTLY `.skip()` locally instead of reporting a passing test that asserted nothing. - REQUIRE_REAL_PG fail-loud infra guard: shared `failLoudIfRealPgRequired` on the satellite-test-kit; the crypto/AI real-PG helpers turn a would-be self-skip into a HARD failure when a CI job sets REQUIRE_REAL_PG=1 (mirrors core's RLS_DB_USER gate, wired into the integration job), so a PG-less/non-CREATEDB/hardened runner can no longer ship the crown-jewel real-PG proofs green by skipping them. - crypto RLS under least-privilege: a new rowscope RLS-enforced spec proves cross-tenant BLOCKING on crypto_wrapped_deks under the NOBYPASSRLS rls_probe role (not the bypassing superuser), and the shipped store round-trips a field under that same role. - crypto E2E in the demo app (real HTTP + real PG): wire @adonisjs-lasagna/crypto into examples/api (provider/commands/config + erasability resolver), a SecureNote @Encrypted model + controller, the per-tenant secure_notes table with the ciphertext CHECK, and the backoffice worm_ledger. Specs prove encrypt/decrypt round-trip, blind-index equality search, shred -> inert (410 Gone), the WORM ledger append-only, the encrypted-column CHECK backstop, and a hostile cross-tenant IDOR (B cannot read or shred A's secret; A's wrapped DEK is physically absent from B). Gates: crypto integration specs green + honest skip; demo crypto e2e 7/7 green; npm run check 46/46; lint clean; demo typecheck 0.
…ating real-env seams
Continue Wave 6 by replacing the last mock/double/non-gating security proofs with
real-environment ones and closing crypto's performance + docs-integrity gaps.
- crypto performance guarantee (was README-only): a real-PG spec pins the O(1)
crypto-shred deterministically by row count — N encryptions of one (subject ×
category) reuse ONE DEK (per-write cost is O(1) in the key store), one shred
tombstones exactly one row and makes every ciphertext under it inert at once, and a
shred is per-subject O(1) (every other subject's DEK stays live).
- crypto docs-integrity spec (crypto had none): pins the crypto satellite page
(docs/guides/satellites/crypto.md) against crypto's own runtime surface — every ace
command, every CryptoConfig option, and CRYPTO_CONTRACT_VERSION must be documented.
- SSRF real-egress made GATING: a new security-integration spec drives a REAL
in-process listener (no fetch double) to prove safeFetch refuses a loopback target
by default and reaches it only under an explicit opt-in. Previously the real-socket
pin lived only in the [chaos]-gated, continue-on-error fault tier.
- real BullMQ per-tenant queue isolation: a real Worker draining tenant A's queue
never observes tenant B's job (separate ${tenantQueuePrefix}${id} queues) — replaces
the RecordingScheduler double for the isolation property.
- cross-tenant data-change attribution: two real tenants + a real emitter subscriber;
a change committed in B is delivered tagged B (never A), so an A-scoped consumer
never acts on B's change — the cross-tenant negative the single-tenant/fake specs
never asserted.
- demo membership gate: wire config.authorizeTenantAccess into examples/api (opt-in
via x-test-principal-tenant, mirrors the fixture) + a crypto IDOR e2e proving a
principal-tenant mismatch is refused 403 through the booted server. Also silences
the "no membership gate wired" boot warning.
The authoritative real SSO-replay-via-handleCallback proof already exists in the sso
package (security_sso_oidc_flow: concurrent handleCallback, exactly one wins), and the
forwarded-host hop already resolves through the real booted TenantResolverRegistry;
both are left as-is rather than duplicated.
Gates: crypto unit 233; crypto perf 3/3 + docs-integrity 3/3; core integration
(SSRF 2 + queue 1 + data-change 1) 4/4; demo crypto e2e 8/8; npm run check 46/46;
lint clean; demo typecheck 0.
…ty opt-ins W6 real-env verification surfaced a permanently-RED core integration test (security_webhook_ssrf_loopback_optin: "only the exact string 'true' opts in — a truthy '1' does not") that contradicted readBooleanEnvFlag's own unit test. Root cause: an earlier revision widened readBooleanEnvFlag to accept any truthy value (true/1/yes/on), silently LOOSENING the SSRF loopback exemption (WEBHOOKS_ALLOW_LOOPBACK_TARGETS) and the live-key-in-dev guard (STRIPE_ALLOW_LIVE_IN_DEV) so a stray "1" in the environment could open them. The loosening slipped through because that wave's local gate never ran the core integration tier. Both flags gate a SECURITY EXEMPTION, so enabling one must be a deliberate, unambiguous act. readBooleanEnvFlag is now strict — true ONLY for the exact word "true" (trimmed + case-insensitive: TRUE / " true " still count); 1 / yes / on / typos are all false — and its unit test is updated to pin that. No call-site change needed (both consumers already route through the helper). Gates: core unit 1627; webhook SSRF loopback integration 15/15 (the '1' test now passes); billing mode-detection 10/10 (uses "true", unaffected); npm run check 46/46.
… PRODUCTION_READINESS Align the crypto-shred docstring in crypto_service.ts with the engine's real behavior: a shred destroys only the LIVE DEK copy, so a backup / clone / query-log written BEFORE the shred retains the wrapped DEK (still unwrappable by the surviving per-tenant KEK) — erasure of pre-shred copies is the operator's retention / KEK-rotation job (ARCHITECTURE.md §10). Add PRODUCTION_READINESS.md, the operator- facing go-live document: the three erasure blockers (erasability resolver, plaintext- leak audit, backup reconciliation), the hardening checklist, and the honest limits.
…eline Wave 1 (config-derive the backoffice reference) added ~24 lines to billing/src across the CLI commands, jobs, health check, and event dispatcher — integration-covered code the deliberately metadata-only billing UNIT suite does not reach — dropping unit lines/statements coverage from just above 22% to 21.97%. The waves landed as local commits and were never CI'd, so this first push surfaced it. Per the repo convention (the c8 floor is enforced by the UNIT run and ratcheted off the UNIT baseline, not the integration number), lower the billing lines/statements floor 22 -> 21 to match reality; the real quality bar is the per-satellite MERGED (unit + integration) coverage gate, which is unaffected.
Enable noUncheckedIndexedAccess + exactOptionalPropertyTypes across the whole repo (the root tsconfig for every packages/* workspace, plus the examples/api and benchmarks standalone configs) and fix all fallout mechanically, type-only, with no runtime-behavior change — ~1000 sites across 13 workspaces. Fix patterns: - noUncheckedIndexedAccess: a guard/early-return, a non-null assertion where a preceding length/loop-bound/structural invariant proves presence, or a validated-tuple type. url.ts encodes the SSRF classifier's IPv4/IPv6 length invariants as tuple types (Ipv4Octets / Ipv6Hextets); crypto.ts parses the enc_v2/enc_v1 envelope as a validated tuple after its length check. - exactOptionalPropertyTypes: owned pass-through option/context bags declare the optional field as `?: T | undefined`; external targets (Lucid/Adonis/DOM/vine) get omit-when-undefined at the call site. safe_fetch builds RequestInit via a nonPinnedInit helper that only sets defined fields. Public option/context types widen to `?: T | undefined` (non-breaking; the field can still be omitted). Regenerated the 4 core + 1 crypto api-extractor goldens (widening-only diff, no structural change). useUnknownInCatchVariables stays false: 155 untyped `catch (error)` sites read `error.message` deliberately, so a flip is unbounded churn for no safety gain. examples/api: VineJS 4.4.0 is incompatible with exactOptionalPropertyTypes (its modifier getters are typed `boolean | undefined` against ConstructableSchema's `?: boolean`), so the validators carry a type-safe ExactOptionalProps shim that preserves Infer exactness (no `as any`). Docs: rewrote the satellite cookbook to the definePlugin facade (removed the phantom ExampleSatelliteProvider class and the `as any` in the configure hook, mirroring the real template); fixed the template README test path; added a Contract versions reference page indexing all 17 version constants (2 fleet-wide axes + 15 surface contracts), wired into the sidebar and cross-linked from extensibility.md; corrected the now-stale eslint.config.js comment. CHANGELOG records the strictness pass and the W1 backoffice-connection unification. Also: give the file-I/O DDL-drift guard test an explicit 15s timeout so it never flakes under c8 instrumentation; ratchet core unit coverage floors to the current baseline (statements/lines 65->67, branches 85->86). Gates: typecheck (13 workspaces) 0, npm run check 46/46, check-api-report 5/5, lint 0, knip:deps 0, docs:build 0 (no dead links), core unit 1627 passed, all 10 satellite unit suites green.
Arcoders
merged commit Jul 9, 2026
7252b7d
into
LASAGNA-020626/isolation-hardening-and-benchmarks
10 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.