# v0.3 — Working Memory (Cognitive Phase 3 PR-10)

> Status: design + PR-10a infrastructure (this PR)
> Pinned to: `docs/ARCHITECTURE.md` §5.7.2 (Trust zone, "Reflection state"
> row), §5.7.6 (memory scope layering)
> Tracks: `docs/handovers/v0.3-cognitive-layer-track.md` Phase 3
> Companion: `docs/design/v0.3-episodic-memory.md` (the sister memory tier)

## Why working memory exists

After PR-9b, JAMES persists **final** reasoning decisions in the
session-scoped episodic store (`core/memory/episodic.py` — plan,
reflect, verify, synth records survive across turns within a session).
What is missing is the **intra-turn workspace**: a place where stages
inside the same answer pipeline can hand off intermediate state to
each other without polluting the episodic store with drafts the
verifier or reflection later discards.

ARCHITECTURE.md §5.7.2 directly anticipates this split:

> "Reflection state — working memory only — **not persisted by default**;
>  episodic memory captures only the **final** verified answer trace."

The reflection critique that drove a `revised` answer is valuable
*while the revised step is running* (the LLM prompt is built from it)
but useless after the turn ends — the audit_log already keeps a
forensic copy. Storing it in episodic conflates the "decision record"
contract.

Similarly, the verifier's per-claim fact-check intermediates and the
planner's per-subtask scratch space are working-memory-shaped: many
of them, dropped on turn end, never compared across turns.

## Position in the scope hierarchy (§5.7.6)

```
┌────────────────────────────────────────────────────────┐
│  system     — ontology, PolicyEngine, evolutions      │
├────────────────────────────────────────────────────────┤
│  workspace  — wiki, vector store, audit_log           │
├────────────────────────────────────────────────────────┤
│  session    — episodic events (PR-9b)                  │
│                + working memory (PR-10) ← NEW         │
│                    - cleared at TURN END               │
│                    - never read across turns           │
└────────────────────────────────────────────────────────┘
```

**Two invariants pin working memory inside this row**:

1. **Turn isolation, not session isolation.** A read keyed by
   `(session_id, turn_id_A, …)` must return empty for any other
   turn — even a turn from the same session. This is stricter than
   episodic's session boundary. Concretely: when engine.query()
   binds a new turn_id, the previous turn's working slots are
   reclaimed.
2. **No promotion to episodic.** Working slots that the cognitive
   middleware wants to preserve must be explicitly written to
   episodic (via the existing PR-9b `record_event`). There is no
   automatic copy at turn end — the stages decide what was
   final-worthy.

## Comparison to episodic (the PR-9 store)

| Dimension | Working memory (PR-10) | Episodic memory (PR-9) |
|---|---|---|
| Lifetime | one turn | session (7-day retention) |
| Storage | in-process dict (no SQLite) | SQLite (`james_episodic.db`) |
| Persistence | gone on process restart | survives restart |
| Isolation key | `(session_id, turn_id, role)` | `(session_id, ...)` |
| Typical contents | reflection critique drafts, per-claim verifier intermediates, planner subtask scratch | final plan / reflect / verify / synth decisions |
| Read cross-turn? | **never** | yes (PR-9b cross-turn read in `engine_memory`) |
| Promotion to higher scope | none in v0.3 | via "save as long-term memory" (PR #264) |

## API surface

```python
class WorkingMemory:
    """Process-wide turn-scoped scratch space. One instance per
    process; isolation happens via (session_id, turn_id) keys at
    method boundary.

    Backed by a plain dict + threading.Lock. No SQLite — working
    memory MUST NOT survive a process restart, which is the cheapest
    way to enforce the "cleared at turn end" invariant against any
    accidental cross-turn read.
    """

    def __init__(self) -> None: ...

    # ── write ──────────────────────────────────────────────────
    def set(
        self,
        *,
        session_id: str,
        turn_id:    str,
        role:       str,    # "reflect.draft" / "verify.claims" / "plan.subtasks"
        key:        str,    # arbitrary stage-defined sub-key
        value:      Any,    # any picklable / JSON-shaped value
    ) -> None: ...

    # ── read ───────────────────────────────────────────────────
    def get(
        self,
        *,
        session_id: str,
        turn_id:    str,
        role:       str,
        key:        str,
        default:    Any = None,
    ) -> Any: ...

    def keys(
        self,
        *,
        session_id: str,
        turn_id:    str,
        role:       str,
    ) -> list[str]: ...

    # ── lifecycle ──────────────────────────────────────────────
    def clear_turn(
        self, session_id: str, turn_id: str
    ) -> int:
        """Drop every slot under (session_id, turn_id). Returns the
        number of slots removed. Called from engine.query() in the
        finally block so a crashed turn still releases its scratch.
        """
        ...

    def prune_idle_turns(self, *, max_age_seconds: int) -> int:
        """Sweep turns whose last write is older than the threshold.
        Defensive against engine.query() finally not running (rare:
        OS-level process kill mid-request). Default age in the
        callsite: 600s (10 min) — long enough that a slow LLM turn
        won't get its working memory yanked, short enough that the
        dict doesn't grow unbounded over a multi-day uptime.
        """
        ...
```

**Module-level helper (mirrors `record_event` from episodic)**:

```python
def working_event(
    *,
    role:  str,
    key:   str,
    value: Any,
) -> bool:
    """Thin wiring helper for cognitive stages.

    Reads (session_id, turn_id) from the
    `core.observability.current_session` ContextVar set by
    `engine.query()` at turn start, then forwards to
    `WorkingMemory.set()`. Returns True on success, False when
    called outside a tracked turn (silent no-op — same pattern as
    `episodic.record_event`).
    """
```

The matching read helper is intentionally not provided as a
module-level function — reads are common enough across stages that
the caller takes the singleton explicitly:

```python
wm = get_working_memory()
draft = wm.get(session_id=sid, turn_id=tid,
                role="reflect.draft", key="text")
```

This keeps the read site auditable in code search (`get_working_memory`
greppable) and prevents tests from accidentally relying on a
"current turn" implicit binding for assertions.

## Persistence — none on disk

Working memory is **in-process only**. Reasons:

- **Invariant enforcement**. A SQLite-backed store would survive
  process restart, and an operator restarting JAMES mid-session
  would silently expose the prior turn's working slots to the next
  request. Plain-dict storage rules this out by construction.
- **Cost shape**. A typical turn writes 5-20 slots (critique +
  per-claim intermediates + subtask scratch), each a few KB. The
  10-minute prune window caps memory at "active turns × ~50 KB"
  — well inside the cost JAMES already pays for vector + graph
  caches.
- **Multi-process forward compat**. If v0.4 introduces a multi-
  process topology, working memory does not become a shared
  resource — each process owns its own. This is the correct
  behaviour because a turn is bound to one request handler.

## ContextVar reuse — no new bindings

PR-9b introduced
`core.observability.current_session = ContextVar[tuple[str, str]]`
holding `(session_id, turn_id)`. PR-10 reuses it directly:

- `working_event(...)` reads the ContextVar via
  `get_session_context()` (already exported from observability).
- `engine.query()` already calls `set_session_context()` at turn
  start, so the helper "just works" without any new wiring at the
  engine layer.

PR-10b will additionally call `WorkingMemory.clear_turn(sid, tid)`
from the `finally:` block of `engine.query()` so a crashed turn
still releases its slots.

## What PR-10a does NOT do (the wiring lives in PR-10b)

- **No call sites** in `reflect.py`, `verify.py`, `planner.py`,
  or `pipeline_synth.py`. The infrastructure ships first so the
  schema can be reviewed in isolation; the wiring lands in PR-10b.
  This mirrors the L0/L1 split the backends track used (PR #283 →
  PR #284) and the PR-9a/PR-9b split the episodic track used.
- **No Context Optimizer.** The `core/reasoning/context.py`
  component anticipated by ARCHITECTURE.md §5.7.1 reads working
  memory as one of its inputs (token-budget-aware evidence drop)
  but is its own PR (tentatively PR-12). Shipping it in PR-10
  would conflate "scratch space" with "trimming policy".
- **No turn-end clear wiring.** PR-10a ships the `clear_turn()`
  method but does not call it from `engine.query()`. PR-10b adds
  the finally-block hookup. Until then the prune sweep
  (`prune_idle_turns`) is the only floor on memory growth — fine
  for an infra-only PR with no callers.

## Trust zone

| Surface | Trust | Rationale |
|---|---|---|
| Read across turns | **forbidden** | Method API enforces `(session_id, turn_id)` keys at every entry. No `keys_all_turns()` exists. |
| Cross-session read | forbidden | Same — the prefix mismatches. |
| Promotion to episodic | manual only | Stages explicitly call `record_event(...)` with the final result. No automatic copy. |
| Plugin write (future v0.4) | PolicyEngine `cognitive.working.write` | Out of scope until plugin API lands. v0.3 has only cognitive middleware as a writer. |

PolicyEngine does not yet need a permission for PR-10a because no
endpoint exposes working memory to the user. PR-10b's
`engine.query()` finally hook also stays inside the trust boundary
(server-side cleanup).

## Test plan (for PR-10a)

1. `set()` round-trip — `get()` returns what `set()` wrote
2. `get(default=…)` returns the default on miss
3. **Turn isolation** — `set(s, t1, …)` then `get(s, t2, …)` returns
   default. Stricter than session isolation.
4. **Session isolation** — same role/key under different
   `session_id` are independent
5. `keys(...)` lists only the slots for the given `(s, t, role)`
6. `clear_turn(s, t)` removes exactly that turn's slots, returns
   count; other turns untouched
7. `prune_idle_turns(max_age_seconds=…)` removes slots whose last
   write is older than the threshold; recent slots survive
8. **Thread safety** — concurrent `set`/`get` across two threads
   does not raise or lose writes (sanity-only — full stress is out
   of scope for v0.3)
9. **`working_event()` no-ops outside tracked turn** — when
   ContextVar is `("", "")`, the helper returns `False` and no
   slot is created
10. **`working_event()` writes when bound** — under
    `set_session_context()`, the helper writes and `get()` retrieves

## PR sequence

| PR | Scope |
|---|---|
| **PR-10a** | `core/memory/working.py` + tests + this design memo. Wiring 0. |
| **PR-10b** | Wire `set()/get()` into reflect / verify / planner. Hook `clear_turn()` into engine.query finally. Cross-stage integration tests. |
| **PR-12 (optional)** | Context Optimizer (`core/reasoning/context.py`) — token-budget evidence drop driven by working memory's plan trace. |

## Diff log

| Date | Author | Change | PR |
|---|---|---|---|
| 2026-05-20 | Jiwon | Initial design (PR-10 of Cognitive Phase 3) | (this PR — PR-10a) |
