{
  "schemaVersion": 1,
  "sensitiveFieldsExcluded": true,
  "generatedAt": "1970-01-01T00:00:00.000Z",
  "packageName": "@happyvertical/smrt-tenancy",
  "packageVersion": "0.43.2",
  "sourceManifestPath": "dist/manifest.json",
  "agentDocPath": "AGENTS.md",
  "sourceHashes": {
    "manifest": "c5297fb850bc37ae2f99d4de109d15113618433f4bf130cc0790fb6fd0a3bed3",
    "packageJson": "579e04b04cc70073135ae95d773e3a31334f99227249d0f3ab9be69dc36a602a",
    "agents": "712ade2d4a37ad90f911a3f88b6625dee351a72233b89ade5a723db8b17131bb"
  },
  "exports": [
    ".",
    "./adapters",
    "./manifest",
    "./manifest.json",
    "./playground",
    "./svelte",
    "./testing",
    "./ui"
  ],
  "dependencies": {
    "@happyvertical/logger": "catalog:",
    "@happyvertical/smrt-core": "workspace:*",
    "@happyvertical/smrt-types": "workspace:*",
    "@happyvertical/smrt-ui": "workspace:*",
    "@happyvertical/sql": "catalog:",
    "@happyvertical/utils": "catalog:",
    "@happyvertical/smrt-vitest": "workspace:*",
    "@sveltejs/package": "^2.5.8",
    "@sveltejs/vite-plugin-svelte": "^7.1.2",
    "@types/node": "24.13.2",
    "svelte": "^5.56.4",
    "svelte-check": "^4.7.1",
    "typescript": "5.9.3",
    "vite": "8.1.4",
    "vitest": "4.1.10"
  },
  "smrtDependencies": [
    "@happyvertical/smrt-core",
    "@happyvertical/smrt-types",
    "@happyvertical/smrt-ui",
    "@happyvertical/smrt-vitest"
  ],
  "sdkDependencies": [
    "@happyvertical/logger",
    "@happyvertical/sql",
    "@happyvertical/utils"
  ],
  "tags": [],
  "risks": [],
  "objects": [],
  "surfaces": [],
  "prompts": [],
  "relationshipsV2": {
    "foreignKeyFields": 0,
    "crossPackageRefFields": 0,
    "junctionCollections": 0,
    "hierarchicalObjects": 0,
    "polymorphicAssociations": 0,
    "uuidColumns": 0
  },
  "agentDoc": "# @happyvertical/smrt-tenancy\n\nMulti-tenancy via AsyncLocalStorage context propagation with automatic query filtering and tenant ID population.\n\n## Context Propagation\n\n```typescript\nimport { withTenant, getTenantId, withSystemContext } from '@happyvertical/smrt-tenancy';\n\nawait withTenant({ tenantId: 'tenant-123' }, async () => {\n  // All SmrtCollection queries auto-filtered by tenantId\n  // All creates auto-populate tenantId\n  const docs = await collection.list({}); // WHERE tenant_id = 'tenant-123'\n});\n\nawait withSystemContext(async () => { /* bypasses all tenant checks */ });\n```\n\n**Critical distinction**: `withSystemContext()` sets a SYSTEM_CONTEXT_MARKER sentinel — different from \"no context\" (undefined). Interceptor can distinguish intentional bypass from missing context.\n\n**Duplication-safe storage**: the underlying `AsyncLocalStorage` is a `Symbol.for`-keyed singleton on `globalThis`, so context survives Vite/vitest/SvelteKit pipelines that evaluate the module more than once — context entered through one module instance is visible to guards in another (#2077).\n\n## Interceptor System\n\nHooks into SmrtCollection via `GlobalInterceptors.register()` (priority 100, runs first):\n\n| Hook | Behavior |\n|------|----------|\n| `beforeList` | Injects `tenantId` into WHERE clause; validates existing filters match context |\n| `beforeGet` | Same — resolves string lookups via core's `resolveGetStringFilter()` (UUID → `{ id }`, else `{ slug, context: '' }`) and adds the tenant predicate (#2365) |\n| `beforeSave` | Auto-populates tenantId if empty + `autoPopulate: true`; validates if already set |\n| `beforeDelete` | Validates instance.tenantId matches context |\n| `beforeQuery` | Enforces raw SQL policy on tenant-scoped classes (`throw`/`warn`/`allow`) |\n| `afterSave` | Emits `directory.<class>.created`/`updated` via `dispatchBus` for configured `directoryClasses` |\n| `afterDelete` | Emits `directory.<class>.deleted` via `dispatchBus` for configured `directoryClasses` |\n\nMismatches throw `TenantIsolationError`. Missing required context throws `TenantContextError`.\n\n**Optional-mode reads with no context pass through UNFILTERED at the interceptor.** That is intentional for trusted/admin call paths, but it means the interceptor alone does not protect a tenant-scoped model exposed as `@smrt({ api: { public } })`: an anonymous HTTP read has no context, so the interceptor would return every tenant's rows. The generated REST + SvelteKit read routes close this by injecting a `{ tenantId: null }` filter when tenancy is enabled but no context is active, so public/anonymous reads fail closed to **global (NULL-tenant) rows only** — mirroring the dispatch resolver's *enforced, no active tenant → global rows only* rule (#1782). Authenticated reads still scope to the caller's tenant via the interceptor.\n\n## Read-Path Coverage (#2365)\n\nTenant scoping is a whole-path property — every read path is interceptor-aware,\nnot only collection list/get:\n\n- **Get-by-slug**: `collection.get('<slug>')` works under a tenant context. The\n  interceptor resolves string filters with core's `resolveGetStringFilter()`\n  instead of assuming they are ids. Any custom `beforeGet` interceptor that\n  rewrites a string filter must do the same.\n- **Hydration and identity**: `new Model({ id | slug }).initialize()`,\n  `loadFromId()`, `loadFromSlug()`, `getSavedId()` and `getId()` run their\n  filters through the `beforeGet` pipeline, so constructor hydration cannot\n  read another tenant's row and `getId()` can never adopt another tenant's\n  same-slug row id (which would steer a later `save()` onto the foreign row).\n  Required-mode classes fail closed (`TenantContextError`) when hydrated\n  outside a tenant context; `withSystemContext()` / super-admin bypass remain\n  the explicit cross-tenant paths.\n- **Vector search**: `semanticSearch()` / `findSimilarToEmbedding()` restrict\n  candidates to the tenant's rows BEFORE top-K ranking (the tenant predicate is\n  resolved through the `beforeList` pipeline), so results are never starved by\n  — and similarity ranks never leak — other tenants' content.\n- **Collection memory**: `remember()`/`recall()`/`recallAll()`/`forget()` on a\n  tenant-scoped collection key `_smrt_contexts.owner_id` per tenant\n  (`__collection__:<tenantId>`) under an active tenant context. Isolation is\n  strict: tenant-keyed memory never falls back to the shared `__collection__`\n  key, and memory learned outside a tenant context is invisible inside one.\n  Two edge semantics to know: under `withSuperAdminBypass()` reads skip\n  filtering but memory still keys to the active tenant (scoped tighter, not a\n  leak), and an empty-string tenant id resolves to the shared key (an\n  empty-string tenant is a misconfiguration — real tenant ids are uuids).\n\n## Registration — Two Patterns\n\n```typescript\n// Pattern 1: Tenancy decorator\n@TenantScoped({ mode: 'optional' })\nclass Doc extends SmrtObject { @tenantId({ nullable: true }) tenantId: string | null = null; }\n\n// Pattern 2: Core decorator (tenancy package reads this too)\n@smrt({ tenantScoped: { mode: 'optional' } })\nclass Doc extends SmrtObject { tenantId: string | null = null; }\n```\n\nModes: `'required'` (default — throws without context) or `'optional'` (passes through if no context).\n\n## Adapters\n\n- **Express**: `createExpressMiddleware()` — uses `enterTenantContext()` (not withTenant, because middleware returns before handlers run)\n- **SvelteKit**: `createSvelteKitHandle()` — stores context in `event.locals`\n- **CLI**: `createCliContext()` — `run()`, `runWithTenant()`, `runAsSystem()`, `runAsSuperAdmin()`\n\n## Super Admin Bypass\n\n`withSuperAdminBypass()` keeps tenant context but disables auto-filtering. Different from `withSystemContext()` which removes context entirely.\n\n## Gotchas\n\n- **Context lost in callbacks**: `setTimeout(() => getTenantId(), 100)` → undefined. Fix: `TenantContext.bind(fn)`\n- **Nested contexts override**: inner `withTenant()` overrides outer; restores on exit\n- **Auto-populate only if empty**: if tenantId already set, interceptor validates (not overwrites)\n- **Isolation checked at query time**: `list({ where: { tenantId: 'other' } })` throws immediately\n- **Testing**: `resetTenancy()` + `setupTestTenancy()` in beforeEach; `testTenantIsolation()` helper\n- **Natural keys are per tenant (smrt#2360)**: a tenant-scoped class with no explicit `conflictColumns` upserts on, and indexes, `(tenant_id, slug, context[, _meta_type])` — `save()` from tenant B with tenant A's slug is a second row, never an overwrite; within a tenant the natural key still dedups; NULL-tenant (`optional` mode, no context) rows dedup among themselves through the SDK's null-aware upsert but not through the index (NULLs are distinct), so raw SQL `ON CONFLICT (slug, context…)` on such a table no longer binds — use `WHERE NOT EXISTS`, and on PostgreSQL an advisory lock, as `ProfileTypeCollection.getOrCreateGlobalBySlug()` does. Core recognizes the class as tenant-scoped through the manifest's `decoratorConfig.tenantScoped` (the scanner folds `@TenantScoped()` in), never through the tenancy registry, so the schema and the upsert agree before `enableTenancy()` runs. Rollout: deploy the code and `smrt db:migrate` together (neither version's create works against the other's index), and backfill `tenant_id` on legacy NULL-tenant rows first — a tenant-context save no longer adopts a `(NULL, slug)` row, it inserts beside it and that tenant stops seeing the legacy one (details in `packages/core/agents/schema-paths.md`).\n\n## Known exceptions to monorepo standards\n\n- **`serializeInstance()` in `src/interceptor.ts` calls `instance.toJSON()` directly** (standards.md §7 forbids this in favor of `transformJSON()`). The interceptor must serialize arbitrary instances handed to it — including workspace stubs and plain-object test doubles whose classes may not extend `SmrtObject` and therefore have no `transformJSON()` hook. The call is duck-typed and falls back to manual key iteration when `toJSON` is absent. See the inline comment at the call site for the full rationale.\n"
}