# Capability Spec: api-tenant-provider-memory **Package**: `@suaveplan/api-tenant-provider-memory` **Status**: SHIPPED (2026-05-26) ## Requirements ### Requirement: Enumerate tenants from an in-memory store The package SHALL implement `TenantProvider` from `@suaveplan/api-tenant-provider/provider` using an insertion-ordered `Map` as the backing store. Initial state comes from `createMemoryTenantProvider({ tenants? })`; runtime mutation flows through out-of-band `add` / `remove` / `clear` admin helpers exposed on the returned object. #### Scenario: list yields active tenants in insertion order - **WHEN** the provider is constructed with `tenants: [A_active, B_active, C_active]` - **THEN** iterating `list()` yields A, B, C in that order #### Scenario: list filters archived and conditionally surfaces suspended - **WHEN** the seed contains active, suspended, and archived records - **THEN** `list()` yields only active, `list({ includeSuspended: true })` yields active + suspended, and `archived` is never yielded by `list` #### Scenario: lookup returns archived records - **WHEN** `lookup(id)` is called for an `archived` tenant - **THEN** the archived record is returned (not null) #### Scenario: dispose is idempotent and clears state - **WHEN** `dispose()` is called twice - **THEN** the second call resolves without throwing AND subsequent `count()` returns 0 AND `lookup(any)` returns null **Verified by:** `src/provider/provider.test.ts::"seeds in insertion order"` AND `src/provider/provider.test.ts::"count is active-only"` AND `src/provider/provider.test.ts::"lookup returns archived records"` AND `src/provider/provider.test.ts::"dispose drops state and is idempotent"` AND the `runTenantProviderConformance({ label: "memory", ... })` invocation in the same file. --- ### Requirement: Pagination via canonical cursor codec `list()` SHALL honour `ListOpts.pageSize` (clamps the single iteration's yield count) and `ListOpts.cursor` (resumes from a saved offset, using the `encodeCursor` / `decodeCursor` codec shipped by `@suaveplan/api-tenant-provider/pagination`). Invalid cursors raise `TENANT_PROVIDER.INVALID_CURSOR` (from the abstract contract package), not a memory-adapter code. #### Scenario: pageSize limits a single iteration - **WHEN** `list({ pageSize: 4 })` runs over a 10-tenant active seed - **THEN** exactly 4 tenants are yielded #### Scenario: cursor resumes from offset - **WHEN** an `encodeCursor({ offset: 2, version: 1 })` is passed to `list()` over a 5-tenant active seed - **THEN** the iteration yields tenants 2, 3, 4 only #### Scenario: invalid cursor raises TENANT_PROVIDER.INVALID_CURSOR - **WHEN** `list({ cursor: "not-a-cursor" })` is iterated - **THEN** the iteration rejects with a `TenantProviderError` whose code is `TENANT_PROVIDER.INVALID_CURSOR` **Verified by:** `src/provider/provider.test.ts::"pageSize limits one iteration"` AND `src/provider/provider.test.ts::"cursor resumes from offset"` AND the conformance harness invariant `"invalid cursor throws TENANT_PROVIDER.INVALID_CURSOR"`. --- ### Requirement: Boundary validation through tenantRecordSchema Every record entering the backing store SHALL be parsed through `tenantRecordSchema` from `@suaveplan/api-tenant-provider/tenant-record`. As of the foundation-fix-gap-2-tenant-chain change, `tenantRecordSchema` requires a non-empty `slug` and accepts an optional `metadata` bag — callers MUST supply `slug` on every record entering the store. Schema failures (including missing/empty `slug`) raise `TENANT_PROVIDER_MEMORY.INVALID_RECORD`. #### Scenario: seed with malformed record is rejected - **WHEN** `createMemoryTenantProvider({ tenants: [{ id: "", slug: "x", status: "active", ... }] })` is called - **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.INVALID_RECORD` #### Scenario: seed with missing slug is rejected - **WHEN** `createMemoryTenantProvider({ tenants: [{ id: "t1", status: "active", createdAt }] })` (no slug) is called - **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.INVALID_RECORD` #### Scenario: add() validates input - **WHEN** `add({ id: "", slug: "x", ... })` is called with an empty id - **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.INVALID_RECORD` #### Scenario: invalid options shape rejected - **WHEN** `createMemoryTenantProvider({ tenants: "not-an-array" })` is called - **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.INVALID_RECORD` **Verified by:** `src/provider/provider.test.ts::"rejects malformed seed record (INVALID_RECORD)"` AND `src/provider/provider.test.ts::"add rejects malformed record (INVALID_RECORD)"` AND `src/provider/provider.test.ts::"rejects invalid options shape"`. --- ### Requirement: Mutation admin helpers The returned `MemoryTenantProvider` SHALL expose `add(record)`, `remove(tenantId)`, and `clear()` as out-of-band admin helpers (not on the abstract `TenantProvider` contract). `add` raises `TENANT_PROVIDER_MEMORY.DUPLICATE_TENANT` on a colliding id; `remove` raises `TENANT_PROVIDER_MEMORY.TENANT_NOT_FOUND` for unknown ids. `clear()` always succeeds. #### Scenario: add rejects duplicate id - **WHEN** the provider seed already contains `id="acme"` AND `add({ id: "acme", ... })` is called - **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.DUPLICATE_TENANT` #### Scenario: remove of unknown id - **WHEN** `remove("nope")` is called against an empty provider - **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.TENANT_NOT_FOUND` #### Scenario: seed with duplicate ids rejected - **WHEN** `createMemoryTenantProvider({ tenants: [A, A] })` is called - **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.DUPLICATE_TENANT` **Verified by:** `src/provider/provider.test.ts::"add appends a new tenant"` AND `src/provider/provider.test.ts::"add rejects duplicate id (DUPLICATE_TENANT)"` AND `src/provider/provider.test.ts::"remove drops a tenant"` AND `src/provider/provider.test.ts::"remove of unknown id throws TENANT_NOT_FOUND"` AND `src/provider/provider.test.ts::"clear empties the store"` AND `src/provider/provider.test.ts::"rejects duplicate id in seed"`. --- ### Requirement: Conformance with the abstract contract The package SHALL pass `runTenantProviderConformance` from `@suaveplan/api-tenant-provider/testing` with `label: "memory"`. #### Scenario: conformance suite is invoked at module-load time - **WHEN** the test file imports and calls `runTenantProviderConformance({ label: "memory", factory: (seed) => createMemoryTenantProvider({ tenants: seed }) })` - **THEN** every invariant of the abstract contract passes (8 cases) **Verified by:** the top-level call in `src/provider/provider.test.ts`.