# v1.12.1 — L9 conflict-subsystem tenant-scoping (PATCH)

**Episode:** `01KSBK0HY8R8YRWRBS0QGM7TKE` (`/dev-framework-rl`)
**Branch:** `feat/v1.12.1-l9-tenant-scoping`
**Status:** Draft → plan-eng-critic gate (round 2)
**Release vehicle:** v1.12.1 PATCH. All new parameters are **OPTIONAL** with defaults preserving current host-wide behaviour, so no public-API consumer breaks. Per-tenant correctness is opt-in.
**Depends on:** v1.12.0 sub-1 (`Context.actor: Actor` object shape) — landed in master `c9f98e5`.

**Plan-round-1 critic verdict:** FAIL @ score 58. 3 CRITs, 2 HIGHs, 3 MEDs, 2 LOWs. Round-2 changes itemized in §11 "Round-2 changes log" at the bottom.

**Out of scope:** `cli.ts` / `dashboard.ts` unscoped reader sites (single-tenant-per-process trust holds until non-loopback serving lands); `dedupe.ts` / `memory.ts` unscoped reader sites (separate audit; not in L9 brief).

## 1. Goal

Close the L9 deferral from v1.11.0 + v1.12.0 sub-1: plumb optional `tenantId` through 11 unscoped `loadAllEntries` / `readEntry` call sites across 8 background-pipeline files so per-tenant operations CAN respect the tenant boundary (when caller passes `tenantId`), and host-wide operations are explicitly named host-wide (via JSDoc + missing default).

**Back-compat invariant:** no test in `tests/` breaks. Existing external library consumers see no signature regression. All new params optional + default to current host-wide behaviour.

## 2. Per-site classification (discover artefact)

11 unscoped sites, classified by intent (corrected function-name list per critic round-1 CRIT #1):

| # | File:line | Function | Class | Fix |
|---|---|---|---|---|
| 1 | `src/consolidate.ts:106` | `runConsolidation` | HOST-WIDE | JSDoc comment; no signature change. |
| 2 | `src/embeddings.ts:377` | `embedEntry` (rebuild path) | HOST-WIDE | JSDoc; no change. Embedding index is host-wide by design. |
| 3 | `src/embeddings.ts:425` | `embedAll` | HOST-WIDE | JSDoc; no change. |
| 4 | `src/invalidation.ts:73` | `invalidateMatching` | **PER-TENANT (opt-in)** | Add `tenantId?: string` to signature; pass through to `loadAllEntries`. |
| 5 | `src/refine-llm.ts:130` | `refineStore` (top-level scan) | **PER-TENANT (opt-in)** | Add `tenantId?: string` to `RefineOptions`; pass through. |
| 6 | `src/refine-llm.ts:151` | `refineStore` parent lookup | **PER-TENANT (opt-in)** | Pass same `opts.tenantId` to `readEntry`. |
| 7 | `src/autolearn.ts:95` | `deduplicateLesson` (root-string overload) | **PER-TENANT (opt-in)** | Add `tenantId?: string` parameter; pass to `loadAllEntries`. |
| 8 | `src/capture.ts:546` | `cmdCaptureCore` dedup load (via `CaptureOptions`) | **PER-TENANT (opt-in)** | Add `tenantId?: string` to `CaptureOptions`. |
| 9 | `src/importers.ts:51` | `importEntries` dedup load (via `ImportOptions`) | **PER-TENANT (opt-in)** | Add `tenantId?: string` to `ImportOptions`; 5 typed sibling importers (`importChatGPT`, `importClaude`, `importCursor`, `importGenericFile`, `importMarkdown`) need NO change — they pass options through unchanged. |
| 10 | `src/shared.ts:310` | `listPeers` (global root) | HOST-WIDE | JSDoc; no change. Metadata aggregation by design. |
| 11 | `src/shared.ts:350` | `autoShare` local entries load | **PER-TENANT (opt-in)** | Add `tenantId?: string` to options bag; default undefined = current host-wide behaviour. |
| 12 | `src/shared.ts:353` | `autoShare` global entries load | HOST-WIDE | JSDoc; no change. |
| 13 | `src/shared.ts:390` | `syncGlobalToLocal` global read | HOST-WIDE | JSDoc; no change. |

Net: **6 PER-TENANT plumb tasks (all opt-in)** + **7 HOST-WIDE document-only tasks** (sites 1, 2, 3, 10, 12, 13 — that's 6 documented; site 11 default-undefined IS host-wide back-compat behaviour, so 7 host-wide-path call sites total).

## 3. File-by-file diff plan

### `src/invalidation.ts`

```typescript
export function invalidateMatching(
  hippoRoot: string,
  target: InvalidationTarget,
  tenantId?: string,  // NEW: optional. undefined = host-wide (current behaviour).
): InvalidationResult {
  const entries = loadAllEntries(hippoRoot, tenantId);
  // ... rest unchanged
}
```

### `src/refine-llm.ts`

```typescript
export interface RefineOptions {
  apiKey: string;
  model?: string;
  limit?: number;
  dryRun?: boolean;
  all?: boolean;
  fetcher?: typeof fetch;
  /**
   * Optional tenant scope. When provided, refineStore only scans
   * consolidated entries belonging to this tenant, and parent lookups
   * (line 151) are scoped to the same tenant. Cross-tenant parents are
   * silently skipped (matches readEntry null-return convention). When
   * undefined, refineStore scans all entries host-wide (current behaviour).
   */
  tenantId?: string;
}

export async function refineStore(
  hippoRoot: string,
  opts: RefineOptions,
): Promise<RefineResult> {
  // ...
  const entries = loadAllEntries(hippoRoot, opts.tenantId);
  // ...
  for (const pid of parentIds) {
    const p = readEntry(hippoRoot, pid, opts.tenantId);  // line 151
    if (p) sources.push(p);
    // Cross-tenant parent returns null → silently skipped. Refine still
    // produces output from merged content even without sources.
  }
  // ...
}
```

### `src/autolearn.ts`

```typescript
export function deduplicateLesson(
  hippoRootOrEntries: string | MemoryEntry[],
  lesson: string,
  threshold = 0.7,
  tenantId?: string,  // NEW: optional, only used when first arg is a root string.
): boolean {
  const entries = typeof hippoRootOrEntries === 'string'
    ? loadAllEntries(hippoRootOrEntries, tenantId)
    : hippoRootOrEntries;
  // ...
}
```

### `src/capture.ts` (real function `cmdCaptureCore` at line 460)

Add `tenantId?: string` to `CaptureOptions` interface. `cmdCapture` (line 390) and `cmdCaptureCore` pass options through unchanged at signature level. The line 546 load becomes:

```typescript
const existing = loadAllEntries(targetRoot, options.tenantId);
```

`extractFromText` (line 158) is pure text → items extraction with no DB access; no change needed.

### `src/importers.ts` (real function `importEntries` at line 38)

```typescript
export interface ImportOptions {
  dryRun?: boolean;
  global?: boolean;
  extraTags?: string[];
  hippoRoot: string;
  tenantId?: string;  // NEW: optional. Ignored when global=true (global writes are host-wide by definition).
}

// In importEntries body:
const existing = loadAllEntries(
  targetRoot,
  options.global ? undefined : options.tenantId,
);
```

**5 sibling importers need NO change** — `importChatGPT`/`importClaude`/`importCursor`/`importGenericFile`/`importMarkdown` (importers.ts:172, 285, 349, 379, 470) all pass `options` through to `importEntries(chunks, source, tags, options)` unchanged.

### `src/shared.ts` — only `autoShare` (line 344) changes

```typescript
export function autoShare(
  localRoot: string,
  options: {
    minScore?: number;
    dryRun?: boolean;
    tenantId?: string;  // NEW: optional. undefined = host-wide local read (current behaviour).
  } = {},
): MemoryEntry[] {
  // ...
  const localEntries = loadAllEntries(localRoot, options.tenantId);
  // ...
  // Global read stays unscoped: globalRoot IS the union across all tenants.
  const globalEntries = loadAllEntries(globalRoot);
  // ...
}
```

### Host-wide documentation pass (6 inline JSDoc additions, no behaviour change)

Each of these reader call sites gets a 2-3 line comment locking the contract:

- `src/consolidate.ts:106` — "L9: host-wide by design. Consolidation runs across all tenants in one pass; per-tenant filtering would create N runs with no cross-tenant dedup. The api.sleep audit row carries the admin synthetic actor; see api.ts:2050."
- `src/embeddings.ts:377` — "L9: host-wide rebuild. Embedding model index is keyed by entry.id (which is tenant-scoped) but the index itself is one per hippoRoot. Cross-tenant content equivalence is visible at the vector level. Per-tenant indices would be a larger architecture change."
- `src/embeddings.ts:425` — same comment as above.
- `src/shared.ts:310` — "L9: host-wide aggregate. listPeers is a metadata function showing peer projects; per-tenant filtering is meaningless."
- `src/shared.ts:353` — "L9: host-wide read. The global store IS the union across all tenants; per-tenant filtering on the global root would defeat the purpose."
- `src/shared.ts:390` — "L9: host-wide read. syncGlobalToLocal copies the global union into a tenant-scoped local store (the writeEntry call below carries tenant if options pass it through)."

**Total: 6 comments** (1 in consolidate, 2 in embeddings, 3 in shared.ts).

Inline JSDoc preferred over a shared `loadAllEntriesHostWide()` wrapper because (a) Keith's pattern is minimal surface area, (b) the wrapper would require import changes across 4 files for a 6-call-site need, (c) the existing `loadAllEntries(root)` signature already makes "no tenantId = host-wide" the literal interpretation. Shared-helper alternative considered and rejected.

## 4. Caller updates

| Caller | Change | Identifier source |
|---|---|---|
| `src/api.ts:2041` (`api.sleep` autoShare) | **NO CHANGE.** Keep `autoShare(ctx.hippoRoot, { minScore: 0.6 })` — `api.sleep` is host-wide by intent per api.ts:2073-2077 TODO. options.tenantId stays undefined. | n/a (intentional host-wide) |
| `src/cli.ts:1941` (refine cmd) | Add `tenantId: resolveTenantId({})` to RefineOptions object | `resolveTenantId({})` — CLI canonical (cli.ts:678, 833, 1392, etc.) |
| `src/cli.ts:3618` (autolearn-onto-existing) | Pass `resolveTenantId({})` as 4th arg to `deduplicateLesson` | `resolveTenantId({})` |
| `src/cli.ts:3625` (autolearn invalidate) | Pass `resolveTenantId({})` as 3rd arg to `invalidateMatching` | `resolveTenantId({})` |
| `src/cli.ts:6076` (cmdInvalidate dispatch) | Pass `resolveTenantId({})` as 3rd arg to `invalidateMatching` | `resolveTenantId({})` |
| `src/cli.ts:2322, 2471, 6008` (cmdCapture calls) | Add `tenantId: resolveTenantId({})` to captureOpts where missing | `resolveTenantId({})` |
| `src/cli.ts:5899` (autoShare cmd) | Add `tenantId: resolveTenantId({})` to autoShare options | `resolveTenantId({})` |
| `src/mcp/server.ts:760` (mcp autolearn) | Pass `tenantId` (local var, already resolved at handler entry; cited in critic round-1 as in-scope at line 767) as 4th arg | local `tenantId` |
| `src/connectors/slack/ingest.ts` | If they reach into these helpers, pass `adminActor` ctx's tenantId equivalent | Connectors use `adminActor('connector:slack')`; tenant scope = `'default'` or admin. Verify in execute. |
| `src/connectors/github/ingest.ts` | Same | Same |

**Tests:** because all new parameters are OPTIONAL with defaults preserving current behaviour, **no existing test signature requires updates**. The compile-fail-fast safety net §5c proposed in plan-v1 is unnecessary. Existing tests stay green.

## 5. Test plan

New file `tests/l9-tenant-scoping.test.ts` (~13 cases):

### 5a. Per-tenant negative tests (cross-tenant leak prevention) — 7 cases
1. `invalidateMatching(root, target, 'tenant-a')` does NOT weaken tenant-b's matching memories.
2. `refineStore(root, { tenantId: 'tenant-a', ... })` does NOT refine tenant-b's consolidated memories.
3. `refineStore` parent lookup (line 151) returns NULL for tenant-b parents when refining a tenant-a child; refine output produced from merged content + zero sources (graceful degradation, matches `if (p) sources.push(p)` skip).
4. `deduplicateLesson(root, lesson, threshold, 'tenant-a')` does NOT consider tenant-b's existing lessons as duplicates.
5. `cmdCaptureCore` dedup with `options.tenantId: 'tenant-a'` does NOT see tenant-b's content.
6. `importEntries(..., { tenantId: 'tenant-a', global: false })` dedup ignores tenant-b's existing entries.
7. `autoShare(root, { tenantId: 'tenant-a' })` only considers tenant-a's local memories as share candidates; global read still unioned.

### 5b. Host-wide back-compat parity tests — 6 cases
8. `invalidateMatching(root, target)` (no tenantId) still weakens matching memories across all tenants (current behaviour preserved).
9. `refineStore(root, { ... })` (no tenantId in opts) still scans all tenants' consolidated entries.
10. `embedAll(hippoRoot)` rebuilds the host-wide index including all tenants' entries.
11. **NEW (critic round-1 MED #7):** `embedEntry(...)` rebuild branch (triggered by model change on a single per-tenant write) includes all tenants' entries in the rebuilt index. Asserts that the rebuild-path host-wide behaviour is locked.
12. `listPeers(globalRoot)` aggregates across all peers regardless of tenant.
13. `syncGlobalToLocal(localRoot, globalRoot)` copies all global entries to the local store (no tenant filtering on the global read).

### Real-DB convention

Per `~/.claude/CLAUDE.md` "always use real DB for tests" — `openHippoDb(tmpDir)` per test, multi-tenant fixtures constructed via `createMemory(...)` + direct INSERT for tenant-scoped seeds where needed. No mocks.

## 6. CHANGELOG entry (v1.12.1)

```markdown
## [1.12.1] - 2026-05-24

PATCH release. Closes the L9 deferral (conflict-subsystem tenant-scoping residue) from v1.11.0 + v1.12.0 sub-1. 11 unscoped `loadAllEntries` / `readEntry` sites across 8 background-pipeline files re-classified into 6 PER-TENANT-OPT-IN + 7 HOST-WIDE-DOCUMENTED. Back-compat-safe: all new parameters are OPTIONAL with defaults preserving current (host-wide) behaviour; no public-API consumer breaks. No schema migration.

### Shipped

- **`tenantId?: string` (optional, new) on `invalidateMatching(hippoRoot, target, tenantId?)`** (`src/invalidation.ts`). Callers passing `tenantId` get per-tenant scope; undefined keeps current host-wide behaviour.
- **`tenantId?: string` (optional, new) in `RefineOptions`** (`src/refine-llm.ts`). Per-tenant refining + parent lookup. Cross-tenant parents silently skipped (matches readEntry null-return convention).
- **`tenantId?: string` (optional, new) on `deduplicateLesson` root-string overload** (`src/autolearn.ts`). Pre-loaded-entries overload unchanged (caller controls scope).
- **`tenantId?: string` (optional, new) in `CaptureOptions`** (`src/capture.ts`). Per-tenant dedup during capture.
- **`tenantId?: string` (optional, new) in `ImportOptions`** (`src/importers.ts`). Per-tenant dedup during import; ignored when `global: true` (global writes are host-wide by definition).
- **`tenantId?: string` (optional, new) in `autoShare` options bag** (`src/shared.ts`). Per-tenant local-entries read; global read stays unioned.
- **6 host-wide reader sites documented with L9 JSDoc** — `consolidate.ts:106`, `embeddings.ts:377+425`, `shared.ts:310+353+390`. No behaviour change; the comments lock the contract so a future pass doesn't accidentally "fix" them.
- **Internal callers updated** to pass `resolveTenantId({})` (CLI) or the in-scope `tenantId` (MCP): `cli.ts:1941, 2322, 2471, 3618, 3625, 5899, 6008, 6076`; `mcp/server.ts:760`.

### Tests
- 13 cases in `tests/l9-tenant-scoping.test.ts` covering 7 per-tenant negative tests + 6 host-wide back-compat parity tests.

### Migration notes
- **No DB migration** (schema v26 unchanged).
- **No caller updates required for external library consumers.** All new params are optional with defaults preserving current host-wide behaviour. External callers gain per-tenant capability opt-in only.
- **`src/api.ts:2041` `api.sleep` autoShare call intentionally unchanged** — `api.sleep` is host-wide by intent (admin-gated at HTTP boundary; see api.ts:2073-2077 TODO).

### Out of scope (deferred)
- `cli.ts` / `dashboard.ts` unscoped reader sites (single-tenant-per-process trust holds until non-loopback serving lands).
- `dedupe.ts` / `memory.ts` unscoped reader sites (separate audit; not in L9 brief).
- `hippo auth create-key --role` / `hippo auth list` role-column CLI surfacing (v1.12.0 follow-up).
- Future MAJOR: make `tenantId` REQUIRED on the 6 functions above (the optional-with-warn deprecation cycle could land in 1.13.x and the breaking change in 2.0).
```

## 7. Manifests / versions

**5 manifests + 1 lockfile bumped** 1.12.0 → 1.12.1 per `src/version.ts:4-8`:
- `package.json`
- `src/version.ts`
- `openclaw.plugin.json`
- `extensions/openclaw-plugin/package.json`
- `extensions/openclaw-plugin/openclaw.plugin.json`
- `package-lock.json` (auto-regenerated by `npm install`)

(Consistent with version.ts:4-8 comment wording: "five manifests in total carrying the version field" plus the lockfile.)

## 8. Acceptance criteria (verify stage)

- All 13 new tests in `tests/l9-tenant-scoping.test.ts` pass.
- Full suite (build + test) clean (`npm run build && npm test`).
- Zero new TypeScript errors at compile.
- Zero existing tests broken (back-compat invariant).
- `git diff --stat` confined to: 8 src files + caller files (cli.ts, mcp/server.ts) + 1 new test file + 5 manifests + lockfile + CHANGELOG + this plan doc + TODOS.md (mark L9 done).
- Manual smoke: `hippo invalidate <fixture>` works without tenantId (host-wide; current behaviour); programmatic invalidateMatching with `tenantId: 'tenant-a'` does not weaken tenant-b on a 2-tenant fixture.

## 9. Brainstorm carry-forwards (must address or explicitly out-of-scope)

| # | Brainstorm concern | Status in plan |
|---|---|---|
| 1 | Per-call-site classification table | §2 (13 sites — 11 unscoped + 2 already-scoped notation) |
| 2 | consolidate + embeddings: host-wide | §3 host-wide subsection — JSDoc-only |
| 3 | invalidation: per-tenant | §3 invalidation.ts — opt-in via optional param |
| 4 | refine-llm: 2 sites both per-tenant | §3 refine-llm.ts + §5 case 3 (parent skip semantics) |
| 5 | autolearn / capture / importers: per-tenant | §3 (3 subsections) + §4 caller table |
| 6 | shared.ts mixed: per-call-site | §2 (3 host-wide + 1 per-tenant) + §3 shared.ts subsection |
| 7 | Test plan: negative + parity | §5a (7 negative) + §5b (6 parity, incl. round-1 MED #7 fix) |
| 8 | Non-goals: cli.ts/dashboard.ts | Out-of-scope marker at top of doc |
| 9 | CHANGELOG: patch vs minor | §6 — PATCH justified by optional-params (no public-API consumer breaks) |

## 10. Risks

- **LOW**: opt-in tenant scoping means external library callers retain the cross-tenant leak unless they update. Mitigation: documented in CHANGELOG migration notes + the future-MAJOR deprecation path is named.
- **LOW**: a future PR that makes any of these params REQUIRED will need a deprecation cycle (warn for one minor, break in next major). Out of scope for v1.12.1.
- **LOW**: `autoShare` options bag with all-optional fields means a caller passing `{ tenantId: 'x' }` but no `minScore` gets the default 0.6. Existing default preserved; no new behaviour.

## 11. Round-2 changes log (responses to plan-eng-critic round 1)

| Critic finding | Fix applied |
|---|---|
| CRIT #1: invented `captureFromText`/`importChunks` | §3 §4 §6 corrected to `cmdCapture`/`cmdCaptureCore` and `importEntries`. §3 §4 explicit that 5 sibling importers need no change (they pass options through). |
| CRIT #2: missing `src/api.ts:2041` autoShare caller | §4 explicit row: "NO CHANGE" — `api.sleep` is host-wide by intent per api.ts:2073-2077. autoShare's optional `tenantId` defaults to undefined which preserves this. |
| CRIT #3: unnamed test callers will break-build | Resolved by changing all new params to OPTIONAL with defaults. §4 explicit: "no existing test signature requires updates." §5 §8 reframed accordingly. |
| HIGH #4: `ctx.tenantId` wrong identifier | §4 each row now names the in-scope identifier explicitly: `resolveTenantId({})` at CLI sites (matches cli.ts:678 +14 canonical examples); local `tenantId` at mcp/server.ts:760 (line 767 callsite confirmed in-scope). |
| HIGH #5: semver classification borderline | Decision: PATCH. Justified by switching all new params to OPTIONAL — no public-API consumer breaks. §6 §10 updated. Future MAJOR with required + deprecation cycle named as out-of-scope. |
| MED #6: refine-llm.ts:151 cross-tenant parent behaviour-spec missing | §3 refine-llm.ts JSDoc + §5 case 3 explicit: cross-tenant parent returns NULL → silently skipped → refine produces output from merged content alone (graceful degradation). |
| MED #7: embeddings.embedEntry rebuild-path parity test missing | §5b case 11 NEW: rebuild branch test that confirms host-wide behaviour when triggered by a per-tenant write. |
| MED #8: manifest count inconsistent | §7 standardised to "5 manifests + 1 lockfile bumped" + cross-referenced version.ts:4-8 comment wording. |
| LOW #9: comment count 1+2+3=6 not 5 | §3 fixed to "Total: 6 comments". |
| LOW #10: inline JSDoc vs shared helper | §3 paragraph naming the shared-helper alternative and rejecting it with reasons (Keith's minimal-surface-area preference + 4-file import overhead vs 6-call-site need). |

## 12. Estimated cost

- Execute: ~10 file edits (8 src + 1 cli batch + 1 mcp) + ~13 test cases. ~2 hours.
- Verify: build + test full suite. ~5 minutes.
- Review: independent-review-critic. ~10 minutes.
- Ship: branch already created, commit + push + PR. ~5 minutes.
- Total to PR-ready: ~2.5 hours.
