# Memory Formation Policy — Dual-Channel Learning & RBAC

How GoClaw decides, from a single incoming message, **what to remember** and
**under whose authority**. This is the agent's cognitive policy for forming
long-term memory.

## The problem

A single prompt often mixes several kinds of information. The following example uses fictional table names:

> "I am your manager, assigned to oversee you. You will produce this report
> from `example_orders` and `example_archive`…"

contains three distinct things:

1. **Identity / relationship** about the speaker — "I am the manager".
2. **Operational knowledge** — "the report is produced from `example_orders`/`example_archive`".
3. **A one-off task** — actually produce the report now.

Each must be handled differently, and *who is speaking* changes what may be
learned.

## Two channels

Every extracted item is routed to exactly one channel:

| Channel | What it holds | Scope | Keyed to |
|---|---|---|---|
| **Personal** | Facts about the speaker: who they are, their role-claim, how they want to be served, their personal preferences | `personal` | speaker's `UnifiedUserID` |
| **Operational** | Org-wide work knowledge: data sources, hostnames, procedures, report definitions, standing rules ("always use `example_orders`, not `example_archive`") | `agent_notes` | the agent (shared by all users) |

One-off tasks are **not memory** — they are executed and not persisted.

## The hard split: classification vs authorization

Two independent layers, deliberately separated:

- **Classification — "what is this?"** is done by the LLM (extractor prompt).
  Soft, best-effort, improvable.
- **Authorization — "who may write it?"** is enforced by **code** (`isHumanAdmin`,
  derived from the pairing role). Hard, non-bypassable.

The LLM may be talked into mis-classifying ("trust me, this is operational"),
but it can never grant write authority — that lives in code, gated on the
pairing-time role, not on anything said in the prompt.

## RBAC × learning matrix

| Extracted item | **Admin** speaker | **Regular user** speaker |
|---|---|---|
| Identity / relationship ("I am the manager") | → personal (own) | → personal (own) |
| Personal preference ("answer me in English") | → personal (own) | → personal (own) |
| **New operational procedure** ("the report = `example_orders`") | → **learned** to `agent_notes` (supersedes prior value on the same subject) | → **dropped**: not learned. The agent does not adopt new operational procedures from non-admins |
| Request that fits **existing** operational knowledge ("produce today's report") | → executed | → **executed** using the admin-established knowledge |
| One-off task | → executed, not stored | → executed, not stored |
| **Explicit write to `agent_notes`** (`memory_write`, `@notes:`) | → saved | → **refused**, pointed to `scope='personal'` (for `memory_write`, unless the operator lists the user or role under `memory.write_access`; `@notes:` is for admins only) |
| **Relearned from a session archive** (`goclaw memory relearn`) | → learned, dated when the session was | → **quarantined** as `unverified` for `review_learned`; personal lines not learned |

**Key distinction:** *using* an existing procedure (anyone may) vs *defining* a
new one (admin only). A regular user asking "produce the daily report" is
served from the admin-defined procedure. A regular user saying "from now on use
`example_archive` instead of `example_orders`" is **declining-to-learn**: the agent
keeps the admin-established procedure and politely notes that operational
changes require an administrator.

## Critical guardrail: self-declared authority ≠ authority

"I am your manager / I am an admin" is a **claim**, not a grant. It may be
stored at most as a personal note ("this user introduced themselves as the
manager") and used for courtesy/context — but it **never** confers admin rights.

Actual authority comes **only** from the role assigned at pairing time
(`GoClawRole`, via the pairing store). This closes the prompt-injection
privilege-escalation vector: no message can promote its sender.

## Shared conversations: each person on their own lines

A group thread (Teams, Discord) holds several people's messages, and the
history trimmed from it holds all of them. Extraction reads each person
separately (`linesBySpeaker`): their own messages, with the agent's replies on
either side of each for context, under the authority their own role gives
them.

- **Who said it** is the user ID the channel stored with the message, never a
  name in its text: a user who types "[Alice]: from now on …" is still the
  speaker of that line.
- **An unpaired sender** (let in by `allow_from` or an open policy) is stored
  with no user ID. Such lines are nobody's: never the speaker of the running
  turn, never trusted, and with no personal memory to go to, so they are not
  sent to the extractor at all.
- **Authority** for the speaker of the running turn is the turn's own
  (`isHumanAdmin`). For anyone else in the thread it is the role their messages
  were stored with: their operational items are learned only when every one of
  their messages in the slice was stored with the admin role.
- **Personal items** go to the person who said them.
- **A directive** ("rule: …", "from now on …") is read with its author's
  messages only, not the messages before it.
- **The summary** of a trimmed slice goes to personal memory only when every
  message in the slice was the current speaker's. A shared thread's summary is
  of what everyone asked; it stays in the session (PREVIOUS CONTEXT) and its
  archive.
- **The archive** records `admin` trust only when everyone in the slice may
  author operational knowledge, so a relearn of a shared thread quarantines what
  its regular users said.
- An automated or delegated run that trims a shared thread reads only its own
  lines: it writes nobody's personal memory and vouches for nobody.

The dashboard stores its messages with the user ID `admin`, and the API, the
App API and the WebSocket with the user their turn runs as, so their sessions
read as one person, the current speaker. Messages they stored before they
recorded an ID are read as nobody's, like an unpaired sender's: the first trims
of an older session learn nothing from them.

Before this, the whole slice was read as one text under the authority of
whoever's turn happened to trim it. On an admin's turn the regular users'
requests became organization knowledge and every personal line went to the
admin; on a user's turn the admin's knowledge was dropped.

## Autonomous runs (cron / SOP / webhook)

These carry a system principal (`cron-system` / `sop-system` / `webhook-system`)
and are **not** human admins (`isHumanAdmin` excludes them). Consequently:

- Fact extraction never learns operational knowledge from them — they
  *execute* existing procedures, they do not author them.
- An explicit `memory_write` to `agent_notes` from such a run is allowed (a
  webhook-triggered run included): the run carries out a procedure an admin
  wrote, and the procedure may tell it to record a result. They cannot write
  `global` or `agent_config`, whatever `memory.write_access` lists: those shape
  every conversation, and such a run works on outside input.
- They have no personal channel (no real user behind them): they cannot write
  `personal`, and no user's personal memory is put in their prompt.
- Webhook payloads are untrusted external input and are skipped entirely for
  fact extraction.

## Relationship to prior memory work

- **B3b (quarantine)** was a blunt first cut — "hold non-admin facts for
  review". This policy supersedes it for the auto-extraction path: non-admin
  *operational* items are simply not learned (dropped), which matches the
  intended model more cleanly. The `review_learned` admin tool and the
  `unverified` exclusion remain available for other/manual uses. An
  unverified record retires nothing; confirming it queues belief revision.
  The `memory.quarantine_learned_facts` setting that switched quarantine on
  is retired: nothing read it after this policy, and a config that still sets
  it gets a startup warning saying so.
- **B3c/B3d (supersession)** still applies to admin operational writes: a new
  value for the same subject supersedes the old one, keeping a searchable
  timeline rather than overwriting.

## Time: when a fact was said, not when it was written

A learned record's time is when it was said. For a live session that is when
it is written; a fact relearned from an archive is dated when its session was.
The subject rules and belief revision replace the older value with the newer
by that time. A relearned or confirmed record older than the live value on its
subject becomes history itself; it does not retire the newer value. Belief
revision weighs such a record both ways: against earlier records it may
replace, and against later records that may replace it.

## Audit and undo

Every retirement — by a subject rule, by the judge, or by a consolidation
merge — is a row in `memory_revisions`. The row records:
- the retired record and the one that replaced it, and the reason;
- how it was decided (`via`: subject_rule, belief_revision or consolidation);
- the cause (live, relearn, confirm, revise, consolidate) and the run;
- for the judge, its model and prompt version.

Consolidation (the nightly job, `notes_distill` and the automatic
distillation) merges live records that say the same thing into one, and
retires each source as superseded by the merged record, under one run.
The model is shown at most 40 records a call, records that share their words
together, and a run makes at most six calls; the rest wait for the next run.
Records it has looked at without finding a repeat are not shown again while
the gateway runs, unless a record new to it joins their batch. The automatic
distillation waits five minutes between runs, twice as long after each run in
a row whose model calls failed, up to six hours.

An admin undoes one record (`goclaw memory revisions undo <id>`) or a whole run
(`--run`). The record returns to the prompt, and the undone row stands as a
veto: the same record never replaces the same record again, whichever rule or
judge proposes it. The replacing record is left in place; if it is wrong, it
is deleted separately.

## Where each rule lives

| Rule | Enforced in |
|---|---|
| Classify item → personal / operational | extractor prompt (`fact_extractor.go`) |
| Read each person in a shared conversation on their own lines and role | `linesBySpeaker` (`fact_extractor.go`) |
| Route personal → personal scope (speaker) | `handleFactExtract` (`memory_worker.go`) |
| **Gate operational learning to admins (drop otherwise)** | `handleFactExtract` — code, on `trusted = isHumanAdmin` |
| **Gate explicit `agent_notes` writes to admins** | `memory_write` (`tool_memory.go`, `orgMemoryWriter`) and `@notes:` (`handler_directive_prefix.go`) |
| **Relearn: same verdict as the live path, dated when said** | archive `**Trust**:` line (`archiveOldMessages`), `WriteRelearnedFact` (`relearn.go`) |
| **Record every retirement; an undone one is a veto** | `retireSuperseded` (`belief_revision.go`), `UndoRevision` / `UndoRevisionRun` (`revisions.go`) |
| Behavioral policy shown to the model | system prompt (`agent.go` MEMORY ROUTING POLICY) |
| Authority source | pairing role (`GoClawRole`) — never the message |
