---
name: memory
description: "Graph memory plugin. Provides memory-search (with optional `fields` projection for known-shape lookups), memory-write, memory-update, memory-edge (create/delete typed directed edges between pre-existing nodes), memory-update-by-name (repair surface that resolves an elementId from a (label, name, accountId) tuple when search cannot return one), memory-lookup-by-name (deterministic read surface that returns nodes by case-insensitive exact `name` match — a Person, which carries no `name`, is matched on its composed `givenName` + `familyName` display name instead — optionally constrained by labels, bypassing the memory-search ranking stack so a present entity is never missed because a diluted query ranked it below the cut), and the :Report surface (memory-report-write / memory-report-read-latest / memory-report-list) for reading from, writing to, and updating the Neo4j knowledge graph. Includes conversational memory — organic preference learning, evidence-backed recall, and transparent 'what do you know about me?' responses. Document ingestion goes through memory-ingest; the dispatched specialist produces typed-section JSON in-turn from the loaded ontology. Operator-solicited reclassify of an already-stored thread-shaped KD (today: email-thread KDs) is exposed as `kd-classify <attachmentId>` — runs a body-growth gate (proceed iff body grew ≥25% since the last classify, or first-ever classify), stages the body to a temp file under `$ACCOUNT_DIR/tmp/`, and returns a dispatch envelope for the `librarian`; on success `memory-ingest` replaces the prior :Section children and stamps `lastClassifiedAt` + `lastClassifiedBodyLength` on the parent KD. Two modes: `document` (default) for unstructured PDF/web content → :KnowledgeDocument (keyed on `attachmentId`) + :Section, and `chat` for conversation transcripts → :ConversationArchive (keyed on `conversationIdentity`) + :Section chunks; two parent labels, two writer paths chosen by which identity property is set. Conversation-archive Phase 2 splits across two read-only tools: `conversation-archive-list-chunks` pages chunk bodies for the dispatched specialist to read in-turn; `conversation-archive-derive-insights` converts the specialist's per-chunk claims into operator-facing proposals with per-kind cypher. `conversation-archive-enrich-rejection` records (or undoes) durable per-row rejections so already-triaged claims do not re-surface on re-runs. Ships five skills: `conversational-memory`, `document-ingest`, `conversation-archive` (source-agnostic transcript ingest for WhatsApp, Telegram, Signal, LinkedIn DMs, Zoom, meeting minutes, iMessage, Slack), `conversation-archive-mcp` (operator-initiated MCP-driven ingest for Granola, Otter, Circleback meetings — sibling of `conversation-archive` sharing the same writer surface and identity formula), and `conversation-archive-enrich` (per-row operator-gated insight derivation over a named archive's chunks). Ships three thinking-tool skills: `challenge` (adversarial retrieval — strongest counter-evidence from the graph for a given claim), `connect` (bridge-finding between two topics via shared graph neighbors), and `emerge` (clusters uncategorised KnowledgeDocument and Section nodes into operator-approved Concept proposals)."
account-owned-dirs: [{"dir": "archive", "description": "Archived obsidian imports and their embedded attachments."}]
tools:
  - name: memory-search
    publicAllowlist: false
    adminAllowlist: true
    riskClass: read
  - name: memory-write
    publicAllowlist: false
    adminAllowlist: true
    riskClass: write_local
  - name: memory-update
    publicAllowlist: false
    adminAllowlist: true
    riskClass: write_local
  - name: memory-edge
    publicAllowlist: false
    adminAllowlist: true
    riskClass: write_local
  - name: memory-update-by-name
    publicAllowlist: false
    adminAllowlist: true
    riskClass: write_local
  - name: memory-reindex
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: memory-ingest
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: kd-classify
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: memory-ingest-extract
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: memory-ingest-web
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: memory-find-candidates
    publicAllowlist: false
    adminAllowlist: true
    riskClass: read
  - name: memory-node-exists
    publicAllowlist: false
    adminAllowlist: true
    riskClass: read
  - name: memory-lookup-by-name
    publicAllowlist: false
    adminAllowlist: true
    riskClass: read
  - name: memory-delete
    publicAllowlist: false
    adminAllowlist: true
    riskClass: write_local
  - name: memory-restore
    publicAllowlist: false
    adminAllowlist: true
    riskClass: write_local
  - name: memory-empty-trash
    publicAllowlist: false
    adminAllowlist: true
    riskClass: write_local
  - name: memory-list-attachments
    publicAllowlist: false
    adminAllowlist: true
    riskClass: read
  - name: memory-read-attachment
    publicAllowlist: false
    adminAllowlist: true
    riskClass: read
  - name: memory-edit-attachment
    publicAllowlist: false
    adminAllowlist: true
    riskClass: write_local
  - name: memory-rename-attachment
    publicAllowlist: false
    adminAllowlist: true
    riskClass: write_local
  - name: memory-archive-write
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: obsidian-vault-import
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: conversation-archive-list-chunks
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
  - name: conversation-archive-derive-insights
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: conversation-archive-enrich-rejection
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: memory-report-write
    publicAllowlist: false
    adminAllowlist: true
    riskClass: write_local
  - name: memory-report-read-latest
    publicAllowlist: false
    adminAllowlist: true
    riskClass: read
  - name: memory-report-list
    publicAllowlist: false
    adminAllowlist: true
    riskClass: read
  - name: memory-compiled-truth-history
    publicAllowlist: false
    adminAllowlist: true
    riskClass: read
  - name: conversation-list
    publicAllowlist: false
    adminAllowlist: true
    riskClass: read
  - name: conversation-search
    publicAllowlist: false
    adminAllowlist: true
    riskClass: read
  - name: profile-read
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
  - name: profile-update
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: profile-delete
    publicAllowlist: false
    adminAllowlist: true
    riskClass: write_local
  - name: memory-brain-capture-recent
    publicAllowlist: false
    adminAllowlist: true
    riskClass: write_local
  - name: graph-prune-denylist-add
    publicAllowlist: false
    adminAllowlist: true
    riskClass: write_local
  - name: graph-prune-denylist-list
    publicAllowlist: false
    adminAllowlist: true
    riskClass: read
  - name: graph-prune-denylist-remove
    publicAllowlist: false
    adminAllowlist: true
    riskClass: write_local
  - name: conversation-memory-expunge
    publicAllowlist: false
    adminAllowlist: true
    riskClass: write_local
  - name: memory-dream-run
    publicAllowlist: false
    adminAllowlist: true
    riskClass: write_local
  - name: memory-review-queue
    publicAllowlist: false
    adminAllowlist: true
    riskClass: read
hidden:
  - name: session-compact
    riskClass: write_local
  - name: session-compact-status
    riskClass: read
  - name: image-fetch
    riskClass: read
skills:
  - skills/conversational-memory/SKILL.md
  - skills/preference-audit/SKILL.md
  - skills/document-ingest/SKILL.md
  - skills/conversation-archive/SKILL.md
  - skills/conversation-archive-enrich/SKILL.md
  - skills/concept-synthesis/SKILL.md
  - skills/archive-crawler/SKILL.md
  - skills/challenge/SKILL.md
  - skills/connect/SKILL.md
  - skills/emerge/SKILL.md
always: true
embed: false
mcp:
  command: node
  args:
    - ${PLATFORM_ROOT}/lib/mcp-spawn-tee/dist/index.js
    - ${PLATFORM_ROOT}/plugins/memory/mcp/dist/index.js
  env:
    MCP_SPAWN_TEE_NAME: memory
    LOG_DIR: ${LOG_DIR}
    PLATFORM_ROOT: ${PLATFORM_ROOT}
    ACCOUNT_ID: ${ACCOUNT_ID}
    SESSION_ID: ${SESSION_ID}
    AGENT_SLUG: ${AGENT_SLUG}
    AGENT_SLUG_SCOPE: ${AGENT_SLUG_SCOPE}
mcp-manifest: auto
---

# Memory

Provides read and write access to the Neo4j knowledge graph — search, structured writes, document ingestion, attachment management, conversation history, and user profiles. Tool routing is each agent's IDENTITY.md responsibility; this plugin describes what tools are available, not when to use them. The admin agent and specialist subagents call these tools directly via MCP. Public agents are toolless by construction: they receive no MCP tools from this plugin and have no graph access mid-conversation — their knowledge comes only from the baked KNOWLEDGE.md and SOUL assembled into the system prompt at spawn.

Tools are available via the `memory` MCP server.

## Ranking

Ranking is in-turn agent work — call `memory-search` for a candidate pool of 15–30 rows (use `limit` to size the pool), then order them yourself against the operator's criterion. The agent has the same context the server-side ranker used to have plus the conversation history the server never saw, so the right place to rank is the calling agent's turn. The `memory-rank` tool (server-side Haiku rerank against a `criteria` string) was removed — the criterion now lives in your reasoning, not in a separate OAuth round-trip.

## Search Output Size

`memory-search` enforces an output character budget. When the formatted output exceeds the budget, it degrades gracefully: related nodes are stripped from the lowest-scored results first, then entire results are dropped from the tail. A truncation notice at the end states what was trimmed.

Use `expandHops: 0` for listing and inventory queries (returns node properties only — compact output). Use `expandHops: 1` (default) for deep context queries where related nodes add value. Queries that combine a high `limit` with `expandHops: 1` on node types that have many relationships (e.g. KnowledgeDocument with HAS_SECTION) are most likely to trigger trimming.

## Hierarchy

Every node in the graph sits under a parent in the canonical chain `LocalBusiness → Project → Task | Person | Organisation | KnowledgeDocument | ConversationArchive` (`:ConversationArchive` was promoted to a peer parent). `memory-write` and `memory-ingest` require the caller to name the parent (an `elementId` for the resolved parent node) and refuse the write if the parent does not exist or is at the wrong level of the chain. Specialists author the parent resolution — typically via `memory-search` against the brief — before invoking the write. Cross-hierarchy edges (a `:Person` linked to several Projects, a `:KnowledgeDocument` or `:ConversationArchive` about several Organisations) are normal and are added as separate `memory-write` calls; the hierarchy rule governs the containment edge that anchors the node, not the additional cross-edges that describe what the node relates to.

## Anchoring writes to provenance — `producedByTaskId` and the conversation env-stamp

Writes targeting `:Person`, `:UserProfile`, `:AdminUser`, `:Organization`, `:LocalBusiness`, `:CloudflareTunnel`, or `:CloudflareHostname` expect an inbound `:PRODUCED` edge whose source is one of `:Task`, `:Conversation`, or `:Message` (deterministic bootstrap paths run as `createdBy.agent === 'system'` and are exempt). The expectation is enforced as a soft warning, not a reject: when no qualifying edge resolves the storage primitive emits a single `[graph-write] warn reason=missing-provenance labels=<csv> agent=<agentLabel>` line on stderr and the write proceeds. This was relaxed from a hard throw — the composer-spawned admin path inherits a bare per-account env that never receives the `SESSION_NODE_ID` stamp, so the throw was failing every direct admin contact-create / memory-write for a gated label. The two stamping surfaces below remain the right thing to use; the warn line is the operator-visible signal for how often the gap fires. Two surfaces feed the stamp:

- **Workflow path — `producedByTaskId`.** `memory-write` accepts an optional `producedByTaskId` parameter. When set, the platform composes an inbound `:PRODUCED` edge from that `:Task` node into the write's `relationships` array before the write runs. The Task and the new node must share the same `accountId`; mismatch is rejected loud. The typical pattern: call `work-create` at the start of an autonomous flow (onboarding skill, cloudflare tunnel-login) with `kind` and `raisedDuringConversationKey`; capture the returned `taskId`; pass it as `producedByTaskId` on every subsequent `memory-write` for one of the gated labels.

- **Direct-ask path — `SESSION_NODE_ID` env-stamp.** When `producedByTaskId` is not provided (typical of direct admin asks like "add Anneke as person"), `memory-write` falls back to the `SESSION_NODE_ID` env var. The admin server stamps it at PTY spawn time on both admin sessions and on the specialist subagent spawns the admin dispatches (listing-curator, content-producer, database-operator and friends inherit the same anchor). The value is the `sessionId` UUID of the active `:AdminConversation`; the wrapper's `injectConversationProvenance` MATCHes `(c:Conversation {sessionId, accountId})` so account isolation is enforced inside the natural key — a cross-account stamp returns zero rows and never injects. Autonomous spawns with no parent conversation (cron, scheduled-task) legitimately have no env-stamp; those must thread `producedByTaskId` via `work-create` instead. The env-stamp is invisible to the agent (no schema field changes); the LLM never decides whether to use it.

## Cross-account access (house admin) — `targetAccountId`

The memory tools whose Neo4j read/write re-scopes on the resolved account accept an optional `targetAccountId`. A house-scoped `role:admin` session sets it to read or write a **sub-account's** subgraph instead of its own; omitting it is byte-identical to the pre-existing own-account behaviour. Only a house-scoped session may set it: a sub-account or public session that passes `targetAccountId` is rejected `cross-account-denied`, and a house session naming an account that is not provisioned on this install is rejected `cross-account-target-not-provisioned`. The validity check re-scans the accounts dir on a miss, so a sub-account created at runtime this session resolves without a service restart — a stale boot-time enumeration cache no longer masks it, and the distinct reason tells a genuine wrong/typo id apart from that (now-eliminated) created-after-boot case. Resolution runs through the shared `resolveEffectiveAccount` primitive — there is no house fallback and no local re-implementation. Each call that sets `targetAccountId` emits one `[xacct] op=call tool=<t> caller=<own8> target=<tgt8> houseScope=<bool> decision=<allow|deny> reason=<…>` line before the graph call, and the tool's own `[memory-<t>] [<accountId>]` line prints the resolved (target) id as a wrong-subgraph cross-check.

`profile-read/update/delete` also take `targetAccountId`. They key on `userId` as well as `accountId`, so cross-account they cannot use the caller's boot `USER_ID` (a house operator has no node in the sub-account subgraph). Instead they resolve the target sub-account's **owner** `AdminUser` — the single identity seeded per account (`role:"owner"`, one per account) — and attribute the read/write to it, emitting `[xacct] op=identity tool=profile-* target=<sub8> resolved-userId=<owner8>`. A target with no owner `AdminUser` is rejected loudly (never a wrong-identity write). `profile-update` bootstraps the owner's `UserProfile` on this path so a preference-only write lands. This covers preferences and every `profileFields` value (name via `givenName`/`familyName`, `role`, `timezone`, `locale`, `expertise`); no `Person` node or PIN is required. Setting `personFields` identity (email/telephone → the owner `Person`) cross-account also lands: when the owner `Person`/`OWNS` edge is absent (a sub-account is seeded with an `AdminUser` but no `Person`), `profile-update` bootstraps it, MERGEing the owner `Person` off the owner `AdminUser` via the `OWNS` edge — keyed on the `OWNS` edge + `accountId` (the same identity key `profile-read/update` resolve through), so the one existing owner `Person` is reused. It is never keyed on phone (which keys the separate access-grant `Person`, `HAS_ACCESS` not `OWNS`), so that node is never matched. Own-account is unchanged: a `personFields` write with a missing `OWNS`-`Person` still loud-fails, since that edge is a real post-PIN invariant there. The bootstrap does not replicate the PIN-setup identity `Person` in full (no name-based reuse, no embedding — this path carries only email/telephone); the residual `(accountId, email/telephone)` uniqueness, concurrency, and embedding gapsremain.

Tools that do **not** take `targetAccountId` fall into three groups. **Not account-scoped:** `image-fetch` fetches image bytes by URL; `graph-prune-denylist-add/remove/list` mutate a brand-global deny-list; `memory-ingest-extract` stages a temp file — none read or write a sub-account subgraph. **Bound to the caller's own live session:** `session-compact` compacts the caller's `SESSION_ID` Conversation, which belongs to the house account the operator sits in — re-scoping `accountId` to a sub-account matches nothing and "compact a sub-account's live session" has no referent. **Scheduled, not interactive:** the dream-cycle **cron** (`lib/dream-cycle/*`) takes its account from the scheduler context, not a tool argument; only the interactive `memory-dream-run` tool is house-targetable. These tools keep their own-account no-account guard.

## Graph Hygiene

Graph hygiene is **agent-directed, case by case** — no autonomous rule engine, no cron. The Neo4j graph degrades into a landfill if pollution from prior agent errors (wrong-account hostnames, fabricated identifiers, raw monologue dumps) is allowed to compound. Cleanup happens when the admin agent observes a match against the operator-curated deny-list (or the user directly asks) and applies discretion using `memory-update`, `memory-delete`, or `conversation-memory-expunge`.

**Operator-curated deny-list.** Stored signal, not an automatic hit list. The admin agent reads this list and uses it as advisory context when deciding what to scrub.
- `graph-prune-denylist-add` — append substrings you want the agent to watch for (retired domains, decommissioned account ids, fabricated identifiers).
- `graph-prune-denylist-list` — read the current list.
- `graph-prune-denylist-remove` — drop substrings. Previously-nulled property values are not restored; audit entries on affected nodes remain for forensic review.

**Conversation-level expunge.**
- `conversation-memory-expunge` — agent-invoked, scoped to one conversation. Scrubs a specified substring from every linked `:Memory` node's string properties with audit. Transcript Message nodes are immutable history and are never modified — only the derived `:Memory` payload that subsequent `memory-search` would re-surface.

**Single-node removal.** Use `memory-delete` by `elementId`. The node is marked `:Trashed` (relationships preserved, invisible to `memory-search` and any read filtered via `notTrashed`). For `KnowledgeDocument` and `ConversationArchive`, the trash cascades to linked `Section` and `Chunk` children. Restore via `memory-restore` (fails loudly when an active node already holds a unique slot the trashed one needs back). Hard removal happens later via `memory-empty-trash` after the grace period (default 30 days). GDPR cascades go through `contact-erase` (per Art. 17) — that bypasses the trash primitive deliberately. There is no bulk delete-by-shape.

**Why soft-delete.** On 2026-04-20 an autonomous orphan-delete rule wiped 19 nodes in one `graph-prune-run` call; properties were unrecoverable (Neo4j Community has no PITR, no APOC). The cron, the `graph-prune-run` tool, and the rule engine have been removed. An earlier fix added the `:Trashed` label primitive so every soft-delete caller (`memory-delete`, `contact-delete`, the admin `/graph` page's trash button) runs the same shared `trashNode` helper — relationships preserved, unique keys snapshotted into `_trashedKeys`, audit lines `[trash:marked] by=<surface>` for cross-cut provenance.

## Dream-cycle maintenance — `memory-dream-run` / `memory-review-queue`

`memory-dream-run` is the operator-initiated graph-hygiene sweep — seven phases: orphan detection on `:Person`/`:Organization`/`:Concept`/`:Project`; stale compiled-truth scan on `compiledTruthUpdatedAt`; mark edges to `:Trashed` targets with `staleTarget: true`; LLM-bounded citation audit on `:TimelineEvent`; prune compiled-truth revisions to newest 20 per entity; materialise persistent `:BACKLINKS` edges derived from `:MENTIONS`/`:REFERENCES` (making reverse-link queries O(1)); normalise `tags[]` array properties on `:KnowledgeDocument` nodes and merge `:DefinedTerm {category:'obsidian-tag'}` variants. Each run writes a `:Report` (via `memory-report-write`) summarising counts; per-phase log lines `[dream-cycle] phase=<n>` join on the run id. Failure of any phase logs `[dream-cycle:phase-<n>:error]` and continues. Pass `phases: [4]` to run only the citation audit (e.g. after a bulk ingest); `maxEventsPerRun` defaults to 200 for phase 4, with a resume cursor stored as `:Report.lastEventCursor`. `memory-review-queue` is the read-only surface for `:OrphanCandidate` and `:CitationProposal` queues — counts plus a small sample per queue.

**Scheduled sweep.** `memory-dream-run` is also driven automatically, per account, by the self-contained `mcp/dist/scripts/dream-cycle-sweep.js` binary. The always-running `platform/ui` server spawns it hourly (the same `registerLoop` pattern as `calendar-reconcile`), because the Pi has no other periodic surface. Per tick it enumerates every account (`account-enumeration`) and, for each, reads the most-recent dream-cycle `:Report`: an account swept within the last 24h is skipped (`op=skip reason=within-window`), an account with an empty graph is skipped (`op=skip reason=no-graph`), an account with no `:AdminUser` to parent a `:Report` is skipped (`op=skip reason=no-report-parent`, the provisioned-but-pre-PIN state), and any other account runs all seven phases via `memoryDreamRun` (`op=run … reportId=…`). The 24h cadence is enforced from that `:Report`, so the hourly tick is idempotent and needs no scheduler entry. The sweep is deterministic end to end — no LLM, no channel, no agent turn, no Claude credential — and is not a schedulable `:Event`, so the operator cannot cancel it. It PID-locks at `<configDir>/dream-cycle-sweep.lock` against overlap; a missing `dist` (dev tree) makes the server loop a silent no-op. Diagnostic: `logs-read --tail server 200 | grep '\[dream-cycle-sweep\]'`. The opt-in, mailbox-driven counterpart that extracts entities from inbound is a scheduled agent turn, not this deterministic path.

## Brain-capture diagnostic — `memory-brain-capture-recent`

Read-only window into the extractive graph writes produced under this account. Returns recent `:MENTIONS` edges (`:Message-[:MENTIONS]->Entity`) and `:Idea` nodes interleaved by `observedAt`. Default window is 24 hours; pass `windowHours` (max 720) and `limit` (max 200) to scan a longer arc. Use this when the operator asks "what has the brain captured today?", "show me recent mentions of X", or "what ideas did I leave on the table this week?". Writes are produced by `database-operator` per-write briefs admin dispatches — either inline mid-turn, or as part of the on-demand `/insight` graph-completeness sweep (`platform/plugins/admin/skills/insight/SKILL.md`).

## Conversational Memory

The owner's profile and preferences accumulate organically from conversation — never from questionnaires. Run `skill-load skillName=conversational-memory` for full guidance on when to observe preferences, how to handle remember/forget requests, and how to answer "what do you know about me?" transparently with confidence scores and evidence trail.

When a house operator operates a client's sub-account, the session is scoped into that account (`ACCOUNT_ID` is the sub-account, `USER_ID` is the operator, no `HOUSE_ADMIN_SCOPE`). In that session `profile-read`/`profile-update`/`profile-delete` resolve to the account's **owner** `AdminUser`, not the operator: the operator has no `AdminUser` node in the sub-account subgraph, so `resolveProfileIdentity` sees the caller is not an admin of the account it is scoped to and attributes to the seeded owner — the same owner identity the cross-account `targetAccountId` path resolves (above), reached without `targetAccountId` because the re-scoped session cannot set it. An account with no seeded owner rejects the write loudly (`… has no owner AdminUser …`) rather than attributing it to the operator, and emits `[xacct] op=identity tool=profile-* mode=operator-on-behalf target=<sub8> caller=<op8> resolved-userId=<owner8>` on the owner-attributed path. The operator's own preferences live on their own (house) session.

## UserProfile + Person Field Management

`profile-update` serves three purposes: managing Preference nodes (category/key/value), setting top-level UserProfile node properties via `profileFields`, and setting Person properties on the operator's personal-profile Person via `personFields`.

Settable UserProfile fields: `timezone` (IANA format, e.g. `Europe/London`), `locale`, `givenName`, `role`, `expertise`. These are account metadata stored directly on the UserProfile node — distinct from Preference nodes, which represent learned behavioural patterns.

House-style asset bindings are recorded as ordinary Preference nodes, not a new field type: write them through `profile-update` under an existing category — `communication` for an email signature, `content` for a document header or quote template — with the `value` naming the on-disk asset path (e.g. `quoting/templates/assets/gls-email-signature.html`). The binary file stays on disk; the Preference is the single index that names it, so the binding reconciles on write and injects per turn exactly like any other standing rule. No new category is introduced for this.

Example — setting a user's timezone:
```json
{ "category": "scheduling", "key": "timezone_set", "value": "User requested timezone configuration", "profileFields": { "timezone": "Europe/London" } }
```

Restricted fields (`accountId`, `embedding`, `profileVersion`) cannot be set via `profileFields`.

**Personal-profile Person via `personFields`.** The personal-profile Person is open by default — the agent decides which Person properties best help it serve this operator (identity, contact, context); the central schema validator enforces synonym + Forbidden Properties rejection (per `references/schema-base.md`). Writes route to the personal-profile Person via the OWNS edge from the AdminUser. Validation runs in `mode: "update"` so the required-property loop is skipped on partial SET (givenName/familyName were established at PIN setup). Pass any Person properties the operator volunteers; the validator surfaces synonym (e.g. `phone`) and forbidden (e.g. `name`) errors with descriptive messages. No allow-list, no enumeration — see the schema-base.md Person row notes for the doctrine.

## Schema References

Before any structured write, load `references/schema-base.md` via `plugin-read`. This defines property naming rules, required-property groups for documented types, forbidden-property rules, and relationship patterns.

**Brand declares the default vertical** in `brand.json#vertical` (the schema file basename without `.md`, e.g. `"schema-estate-agent"`, or `null` for non-vertical brands). The validator enforces base + that vertical against every write. If the file named by `brand.json#vertical` is absent from `references/`, the memory server fails to start — a misconfigured brand never validates against the wrong rule set silently.

The same vertical drives the **account file-schema**: `platform/scripts/lib/account-schema-owned-dirs.py` reads the `## Top-level node types` table of the brand's `references/<vertical>.md` and derives one top-level operator-entity bucket per `schema:`-namespaced label (kebab-case-plural, `Site` mapped to `customer-sites`), unioning them into each account's `SCHEMA.md` allowed set. So the account data directory mirrors the graph ontology rather than a hardcoded folder list. A vertical whose schema file carries no top-level table contributes no domain buckets, and `account-schema-owned-dirs.py reconcile` reports the gap.

**Every account has exactly one root `:LocalBusiness`.** The installer's `seed-neo4j.sh` MERGEs it at install (and on every re-seed) with the unique key `accountId`, `createdBySource = 'installer'`, a non-null `createdAt`, and `businessType` set to the bare slug derived from `brand.json#vertical` (i.e. `"schema-estate-agent"` becomes `"estate-agent"`, `null` stays null). The MERGE is idempotent: re-runs preserve `createdAt`; `businessType` is coalesced so an operator override on the node wins over the brand default. Failure to write the node aborts the install with `[install-invariant] localbusiness-bootstrap result=failed reason=<text>`. The recorder, validator, and any other writer can rely on the LocalBusiness existing from the first turn of the first install — no first-use bootstrap branch.

**A LocalBusiness with its own `businessType` overrides the default** for writes that resolve to it. The validator looks up the account's LocalBusiness on every write; if `businessType` is set (e.g. `"creator"`), the resolved vertical becomes `schema-${businessType}` instead of the brand default. The override applies only to writes anchored under that LocalBusiness.

On every write the validator emits `[schema-validator] resolved-vertical=<X|none> source=<brand-default|localbusiness-override> accountId=<id>` so an operator can grep `server.log` to confirm which rules a write ran against without reading source.

**Validation surface.** `memory-write` validates labels against `db.labels` ∪ `schema.cypher` declarations — not against this markdown. A label is recognised if it appears in either set. The markdown defines property *shape* (required-property groups, naming rules, forbidden-property rules) for documented labels only; recognised-but-undocumented labels (e.g. `LocalBusiness`, `AdminUser`, `KnowledgeDocument`) accept any property shape and emit `[schema-validator] markdown-undocumented label=<X>` so the doc gap is visible to operators. If `memory-write` rejects a label as unknown, the rejection lists both source sets — the agent can call `maxy-graph-get_neo4j_schema` to refresh its view.

**Forbidden properties.** A "Forbidden Properties" table in any schema markdown file declares per-label deny-listed property keys. The validator rejects writes that include a forbidden key for the matching primary label and emits `[schema-validator] outcome=rejected label=<X> property=<Y> reason=forbidden writer=<agentName>`. The first row is `Person | name` — use `givenName` + `familyName` (rendered display name composed at read time). The check fires after the synonym pass, so `firstName → givenName` rewrites still happen first.

**Drift log shape.** On every schema-cache refresh the validator compares `db.labels()` (live) against `schema.cypher` declarations and the schema markdown's documented labels, and emits one summary line per non-empty drift kind:

```
[schema-validator] drift kind=<kind> count=<n> tokens=<csv up to 10>[+<remainder>]
```

`<kind>` is one of `declared-not-live`, `live-not-declared`, `markdown-orphan`. Tokens are alphabetically sorted; if more than 10 drift in a kind the line names the first 10 and appends `+<remainder>` (e.g. `count=38 tokens=Applicant,…,Engagement+28`). Empty kinds emit nothing — a clean schema produces no drift lines per refresh. Set `MEMORY_MCP_VERBOSE_DRIFT=1` on the manager unit to keep the historical per-token lines underneath each summary; default off because drift sets of 50+ tokens otherwise bury real signals (write errors, validator rejections) in `mcp-memory-<sessionId>.log`.

## Document Ingestion

Document ingestion of any kind — PDFs, text, transcripts, web pages, single files — routes to the `specialists:database-operator` specialist, which loads the universal `document-ingest` skill at `skills/document-ingest/SKILL.md`. The admin agent never calls `memory-ingest` directly; it dispatches with the document path, the document subject (the anchor node), and the visibility scope.

### Pipeline

The skill drives a two-tool pipeline with the dispatched specialist's in-turn work in between:

1. **`memory-ingest-extract`** — pulls text from PDF/markdown/plain-text and caches it under the `attachmentId`. No chunking — the chunker has moved upstream into in-turn section classification.
2. **In-turn classification (specialist's work)** — the dispatched specialist reads the cached text and the loaded ontology, then produces typed-section JSON (`{kind, title, body, properties, anchorEdge, related}`) directly in its turn. Every `kind` must come from the ontology label set the writer enforces — the writer rejects out-of-set kinds, so the specialist verifies before emitting. The `memory-classify` server tool that previously ran this step as a separate OAuth round-trip was deleted.
3. **`memory-ingest`** — writes typed graph nodes (Position, Service, Credential, etc.) anchored to `UserProfile` / `LocalBusiness` / `Person` / `Organization` via natural ontology edges, plus `(KnowledgeDocument)-[:REFERENCES]->(typed)` links. `Other` sections become generic `:Section:Other` nodes hanging off the document via `HAS_SECTION` (ontology-growth fallback) so free-form prose retrieval still works.

### Scope

`memory-ingest` requires a `scope` value on every call. The admin agent confirms the scope with the operator before dispatching:

- **public** — visible to public agents and the admin. Use for business knowledge that customers or visitors should be able to ask about.
- **shared** — visible to all agents on the account but not surfaced to unauthenticated public visitors.
- **admin** — visible only to the admin agent. Use for sensitive content (contracts, credentials, internal processes).

If the user's intent is unambiguous from context — e.g., "save this for the sales agent" implies public, "this is just for my reference" implies admin — confirm the inferred scope rather than asking.

### Keywords

The classifier extracts topic keywords as `documentKeywords`; the user can supply their own as `userKeywords`. Both are merged additively (lowercase, trimmed, deduplicated) and stored on the `KnowledgeDocument.keywords` array. User-supplied keywords appear first.

After a successful `memory-ingest` call, the dispatching admin agent should:
1. Display the document summary and the extracted keywords to the user.
2. Prompt for user keywords if none were supplied with the brief.
3. If the user provides additional keywords post-ingest, call `memory-update` on the `documentNodeId` with the merged array.

Keywords support user-defined collections via naming convention (e.g., `["reports", "reports/quarterly", "reports/quarterly/q1-2026"]`).

**Inline compiled-truth reconciliation (opt-in).** Pass `inlineRewrite: true` to enable the edge-density gate. After all sections and edges are written, the writer counts MENTIONS and REFERENCES edges from the new document (KnowledgeDocument and its sections) to existing entities that carry `compiledTruth`. Entities with >= `inlineRewriteThreshold` (default 2) such edges qualify — unless their `compiledTruthUpdatedAt` is within the 7-day cooldown, in which case they land in `inlineRewriteDeferred` and remain candidates for the next dream-cycle. The result includes `inlineRewriteCandidates: [{ elementId, label, name, edgeCount }]`. Dispatch the `compiled-truth-rewriter` specialist for each entry before the skill returns; pass `nodeId = entry.elementId` and an optional `operatorHint` if the new source suggests a correction. Pass `forceRewrite: true` to bypass the cooldown. Diagnostic: `grep '[memory-ingest] op=compiled-truth-inline'` shows `entitiesChecked`, `entitiesRecompiled`, `entitiesDeferred`, and `reason` per ingest.

For the conversation-archive path, the equivalent signal is in `conversation-archive-derive-insights`: pass `inlineRewrite: true` and optionally `inlineRewriteThreshold` to receive `inlineRewriteSignals` (display name + mention count) in the per-chunk result. The skill accumulates signals across chunks; when a cumulative count hits threshold, resolve the entity via `memory-search` and dispatch the `compiled-truth-rewriter` specialist.
