# v1.2.0: Scope Read-Side + MCP/HTTP Continuity Exposure

**Goal:** Close the v1.0.0 + v1.1.0 known limitations. Wire scope writers on continuity tables, lock the read-side default-deny filter (already drafted in v1.1.0 as a no-op), then expose continuity through MCP `hippo_recall` and HTTP `GET /v1/memories?include_continuity=true`. Also audit MCP `hippo_context` (which already exposes the active snapshot) for the same scope filter.

**Architecture:**

1. **Schema v23:** add nullable `scope TEXT` to `task_snapshots`. Mirrors what v22 did for `session_events` and `session_handoffs`. Idempotent ALTER TABLE.
2. **Writers:** Slack connector emits `scope='slack:private:<channel>'` on every continuity row originating from a private channel ingestion path (snapshot, handoff, event). Public-channel rows write `scope='slack:public:<channel>'` so the no-scope default-deny correctly leaves them visible. Non-Slack writers leave scope NULL.
3. **Readers:** the v1.1.0 default-deny filter in `api.recall` and `cmdRecall` was already written forward-compat. With writers shipping, it stops being a no-op. No code change there — just drop the `as { scope?: string | null }` cast hacks once `TaskSnapshot` / `SessionEvent` / `SessionHandoff` types include `scope`.
4. **MCP `hippo_recall`:** `include_continuity?: boolean` arg. When set, append a "## Continuity" text section to the existing return string. No structured-shape change to the MCP contract.
5. **MCP `hippo_context`:** audit. It currently calls `loadActiveTaskSnapshot(hippoRoot, tenantId)` and `listSessionEvents(hippoRoot, tenantId, ...)` directly with no scope filter. Same default-deny rule must apply here. Caller-supplied `scope` arg threads through. This is the surface that already shipped pre-v1.1; v1.2 retroactively secures it.
6. **HTTP `GET /v1/memories?include_continuity=true`:** propagates flag to `api.recall`, returns `continuity` and `continuityTokens` as JSON sibling fields. Drop the `client.recall` throw guard and send the query param.

**Tech Stack:** TypeScript, node:sqlite, vitest. One schema migration (v23). No new deps.

**Roadmap source:** v1.1.0 CHANGELOG "Deferred to v1.2.0" + v1.0.0 known limitation on scope=NULL.

**Effort budget:** 1 session, ~8 tasks, 5-7 commits.

---

## Pre-flight

- `src/db.ts:74-86` (task_snapshots create), `:380-391` (v16 tenant migration — pattern to mirror for v23)
- `src/store.ts:1466+` (appendSessionEvent), search for saveSessionHandoff and saveActiveTaskSnapshot. Both will need `scope` parameter passthrough.
- `src/store.ts:147` TaskSnapshot, `:170` SessionEvent. Add `scope: string | null`. handoff.ts SessionHandoff likewise.
- `src/api.ts:240-258` continuity scope filter (already in place as no-op; remove cast hack post-typing).
- `src/cli.ts:1242-1257` mirror of same filter.
- `src/cli.ts` cmdContext (search) — calls loadActiveTaskSnapshot with no scope; needs the same filter wired.
- `src/mcp/server.ts:281-340` hippo_recall handler.
- `src/mcp/server.ts:395+` hippo_context handler.
- `src/server.ts:404-420` GET /v1/memories.
- `src/client.ts:70-95` client.recall (drop throw, add param).
- `src/connectors/slack/transform.ts` (where snapshots / handoffs / events originate from Slack).

---

## Task 1: Schema v23 — task_snapshots.scope

**Files:**
- Modify: `src/db.ts` (CURRENT_SCHEMA_VERSION 22 → 23, append migration)
- Test: `tests/migration-v23-scope.test.ts` (new)

**Test:** v22 store with task_snapshots rows present. Run migration. Assert `tableHasColumn(db, 'task_snapshots', 'scope')` is true. Assert existing rows have scope=NULL (no backfill — scope is a forward signal, not historical).

**Implement:**
```typescript
{
  version: 23,
  up: (db) => {
    if (!tableHasColumn(db, 'task_snapshots', 'scope')) {
      db.exec(`ALTER TABLE task_snapshots ADD COLUMN scope TEXT`);
    }
    // Composite index for scope-filtered tenant lookups.
    db.exec(`CREATE INDEX IF NOT EXISTS idx_task_snapshots_tenant_scope ON task_snapshots(tenant_id, scope, status)`);
  },
},
```

Commit: `feat(schema): v23 adds scope column to task_snapshots`

---

## Task 2: Writer signatures accept scope

**Files:**
- Modify: `src/store.ts` — `saveActiveTaskSnapshot`, `saveSessionHandoff`, `appendSessionEvent` signatures and INSERT statements
- Modify: `TaskSnapshot`, `SessionEvent` interfaces (`scope: string | null` field). Same for `SessionHandoff` in src/handoff.ts.
- Test: `tests/store-scope-writes.test.ts` (new)

Add optional `scope?: string | null` to each writer's input shape. Default: null. INSERT sets the column. Reader rowTo* helpers map the column to the type.

Test: write a row with scope='slack:private:Cxxx', read it back, assert scope is preserved. Same for null. Same for empty string (treat as null).

Commit: `feat(store): writers accept scope on snapshots/handoffs/events`

---

## Task 3: Slack connector emits scope

**Files:**
- Modify: `src/connectors/slack/transform.ts` (and any companion file that calls saveActiveTaskSnapshot / saveSessionHandoff / appendSessionEvent from a Slack message)
- Test: `tests/slack-connector-scope.test.ts` (new)

When the incoming Slack message originates from a private channel, every derived continuity row gets `scope='slack:private:<channel_id>'`. Public channels: `scope='slack:public:<channel_id>'`. DM/IM: `scope='slack:dm:<user_id>'`.

Test pattern: simulate a Slack message webhook for a private channel, assert the resulting snapshot/handoff/event row carries the right scope. Repeat for public.

Commit: `feat(slack): tag continuity rows with scope from channel privacy`

---

## Task 4: Lock the v1.1.0 forward-compat filter (drop cast hacks)

**Files:**
- Modify: `src/api.ts` (drop `as TaskSnapshot & { scope?: string | null }` casts now that the type carries it)
- Modify: `src/cli.ts` cmdRecall (same)
- Test: extend `tests/api-recall-continuity.test.ts` — the existing forward-compat test that special-cased "column absent" can now assert the filter actually fires.

The filter logic stays identical. This is just type cleanup + test tightening.

Commit: `refactor(api): drop scope cast hacks now that v1.2 types carry it`

---

## Task 5: MCP hippo_recall include_continuity (text appendix)

**Files:**
- Modify: `src/mcp/server.ts` `hippo_recall` schema (add `include_continuity: { type: 'boolean' }`) and handler
- Test: `tests/mcp-recall-continuity.test.ts` (new)

When `args.include_continuity` is true, pass through to `apiRecall` and append a text section to the existing return string:

```
... existing memory results ...

## Active Task Snapshot
Task: ...
Summary: ...
Next step: ...

## Session Handoff
Summary: ...
Next action: ...
Artifacts: ...

## Recent Session Trail
- [event_type] event content...
```

Reuse the same scope-aware filter via `apiRecall` (already enforces it). The MCP contract stays text-shaped — no breaking change for existing clients.

Commit: `feat(mcp): hippo_recall surfaces continuity text appendix when flag set`

---

## Task 6: MCP hippo_context scope audit

**Files:**
- Modify: `src/mcp/server.ts` `hippo_context` handler
- Test: `tests/mcp-context-scope.test.ts` (new)

This surface predates v1.1 and currently exposes the active snapshot over MCP without a scope filter. Apply the same default-deny rule: when the caller passes no scope, drop `slack:private:*` rows. When the caller passes an explicit scope arg, match exactly.

Test cases:
- A no-scope caller does NOT see private-channel snapshots.
- An explicit `scope: 'slack:private:Cxxx'` caller DOES see that channel's snapshot.

Commit: `feat(mcp): hippo_context applies default-deny scope filter`

---

## Task 7: HTTP GET /v1/memories?include_continuity=true

**Files:**
- Modify: `src/server.ts` recall route at line 404
- Modify: `src/client.ts` recall function (drop throw, add param)
- Test: `tests/http-recall-continuity.test.ts` (new)

Server: parse `include_continuity` query param as boolean, pass through to `api.recall`. The continuity block flows back through the existing JSON serializer.

Client: remove the v1.1 throw guard. Append `&include_continuity=1` when `opts.includeContinuity` is true.

Test: stand up a real server (mirror existing `tests/http-recall*.test.ts` if any). Tenant A bearer must not see tenant B's continuity (regression). Caller with no scope must not see private-channel continuity (default-deny). Explicit scope works.

Commit: `feat(http): GET /v1/memories propagates include_continuity`

---

## Task 8: CHANGELOG + README + version bump prep

**Files:**
- Modify: `CHANGELOG.md` (new `## 1.2.0 (date)` section)
- Modify: `README.md` ("What's new in v1.2.0")

Notes to include:
- Schema v23 (additive; no breaking change).
- Continuity now exposed via MCP `hippo_recall`, HTTP `GET /v1/memories`, and CLI `--continuity` (CLI shipped in v1.1).
- `hippo_context` retroactively gets the default-deny scope filter; private-channel snapshots no longer leak to no-scope MCP callers.
- `client.recall` no longer throws on `includeContinuity`.
- v1.0.0 + v1.1.0 known limitations on scope=NULL are CLOSED.

Commit handled by /publish-repo flow.

---

## Definition of Done

1. `npm run build` clean.
2. Full vitest suite passes (1054+ tests, no regressions). New tests: ~8-12 cases across schema, writers, slack, mcp, http, scope filter assertions.
3. **Adversarial cross-tenant + cross-scope sweep** passes:
   - Tenant A private snapshot, tenant B no-scope MCP `hippo_context` call → empty.
   - Tenant A explicit scope='slack:private:Cxxx' → sees the row.
   - HTTP `GET /v1/memories?include_continuity=1` no scope → no private rows.
4. v1.1.0 "Known limitation: scope=NULL" CLOSED in CHANGELOG.
5. v1.1.0 "Deferred to v1.2.0" CLOSED.
6. Codex round 1 + 2 reviews completed before implementation; results table in GSTACK REVIEW REPORT.

## Out of scope (next slice)

- DAG summarization layer on top of continuity (next major: v2.0 candidate).
- @xenova/transformers v4.2 swap (CVE clearing; tracked separately).
- Mirror file write optimization in `loadActiveTaskSnapshot` (the bench's main cost driver).
- Per-tenant consolidate loop (v1.0.0 deferred item).

## GSTACK REVIEW REPORT

(To be filled by `/codex` before any task implementation.)
