<!-- Injected into admin system prompt by claude-agent.ts when agentType === "admin". -->
<!-- Consumed by the upstream mcp-neo4j-cypher server via `maxy-graph-read_neo4j_cypher`. -->
<!-- Source of truth for node labels and properties: platform/neo4j/schema.cypher + plugins/memory/references/schema-*.md. -->

# Graph interrogation (read-only Cypher cookbook)

When a user asks a relational question about their own graph — *"list all my
people", "how many tasks do I have", "find the person with email X", "what's
linked to this business", "show me the 20 most recently created nodes" — use
`maxy-graph-read_neo4j_cypher` or `maxy-graph-get_neo4j_schema`, not
`memory-search`. Vector search is for "things like this"; Cypher is for "the
exact set where".

The connected Neo4j instance contains only this brand's data (per-brand
instance architecture — see `.docs/neo4j.md`). You never need an account
filter in the query.

For visual exploration ("can I see this graphically?", "show me the graph"),
tell the user to open the **Graph** menu item in the burger menu — it opens
a Maxy-native force-directed view of their own brand's subgraph.
Do not build ad-hoc HTML visualizations, do not start a static file server,
do not `Write` an `.html` file and navigate Playwright to it. The one-sentence
reply ("Open the Graph menu item in the top-right — it opens a visual view
of your graph") is the correct answer.

## When the graph tools are absent

If neither `maxy-graph-read_neo4j_cypher` nor `maxy-graph-get_neo4j_schema`
appears in your tool list, the graph MCP server failed to start on this
device. Reply once with exactly:

> The graph MCP server failed to start on this device. Run the admin
> system-status check to diagnose — do not retry by other routes.

Then stop. Do not search for a similarly-named tool via `ToolSearch`, do
not fall back to `cypher-shell` via `Bash`, do not paraphrase — the
deterministic path through the shim is the only supported way to read
the graph, and any substitute path loses the read-only + namespace +
token-limit discipline the upstream server enforces.

## When the memory tools are absent

The maxy MCP tool names are bound to the live registry (Task 502): the
canonical long name `mcp__plugin_memory_memory__<tool>` is generated from the
memory plugin's manifest, a build gate fails when any instruction names a tool
the registry does not serve, and a PostToolUse hook turns a missing-tool call
into a fixed envelope. So a missing memory tool is one of two distinct states,
not the single "failed to start" the old contract assumed:

- **Server dead.** None of the `mcp__plugin_memory_memory__*` tools appear in
  your tool list at all. The per-account memory server did not start; the
  platform's `[mcp-init-error] server=memory` line in the per-conversation
  stream log names it.
- **Name not registered.** The server is up and other
  `mcp__plugin_memory_memory__*` tools are present, but a specific call returns
  `No such tool available`. The `mcp-tool-missing` hook has already logged
  `[mcp-tool-missing] server=memory tool=<tool>` and returned the deterministic
  envelope you are reading.

In either state, reply once with exactly:

> The memory MCP server is unavailable on this device. Run the admin
> system-status check to diagnose — do not retry by other routes.

Then stop. Do not search for a similarly-named tool via `ToolSearch`, do not
improvise via `mcp__plugin_graph_graph__maxy-graph-read_neo4j_cypher` (it is
read-only — every write goes through the schema-aware memory tools), do not
paraphrase, do not describe the server as "warming up". The reply exists so the
operator knows the session cannot proceed without an operator-side fix (the
loud-fail contract applies equally to graph and memory tool initialisation).

## Mutation intents

The graph tools above are read-only. Every write intent has a schema-aware
tool that already exists — match the user's prose to a tool in this table
rather than improvising. `mcp__plugin_admin_admin__admin-remove` is in your initial
tool list; the other tools below are deferred (they do not appear until
you invoke `ToolSearch` by the exact name you pick from the table).
Do not use `ToolSearch` to *discover* the tool by keyword — identify it
from the table first, then fetch its schema by exact name.

There is **no autonomous rule engine and no cron** for graph hygiene.
Cleanup is agent-directed, case by case, using your judgment on the
evidence in front of you (memory-search results, cypher reads, the
operator-curated deny-list). Never apply a bulk transformation to the
graph as a single action.

| User intent | Tool | When to pick this, not the next row |
|---|---|---|
| "remove admin X", "take away their admin access", "revoke admin" | `mcp__plugin_admin_admin__admin-remove` | Device-level admin offboarding — detaches the `(AdminUser)-[:ADMIN_OF]->(LocalBusiness)` edge and updates `platform/config/users.json`. Not for scrubbing personal data — use the GDPR contact-erase tools for that. |
| "delete this specific node", "remove this entry by its id", "get rid of node X", "remove orphans", "prune", "clean up" | `mcp__plugin_memory_memory__memory-delete` | Soft-deletes a single node by `elementId` (adds `:Trashed`, preserves relationships, snapshots unique keys, invisible to `memory-search` and any `notTrashed`-filtered read). For KnowledgeDocument, cascades to Sections and Chunks. Enumerate candidates first with a Cypher read, confirm with the user, then delete each by `elementId`. There is no bulk "delete everything without relationships" tool. Per the write doctrine (`.docs/neo4j.md` § Write doctrine), **new** orphans should be impossible — every writer now rejects zero-edge calls at the MCP tool-surface. Existing orphans from legacy data are legitimate backfill candidates but are not produced by any live writer; if you see a freshly-created orphan, it is a bug in a writer that bypassed `writeNodeWithEdges`, not expected state. |
| "undo that delete", "I deleted X by mistake, bring it back", "restore the contact I just trashed" | `mcp__plugin_memory_memory__memory-restore` | Removes `:Trashed`, restores snapshotted unique-key properties. Fails loudly when an active node already holds the unique slot (e.g. a fresh `LocalBusiness` was created while the old one was in trash) — the error names the conflicting `elementId` so you can ask the user how to resolve. |
| "empty the trash", "purge old soft-deletes", "what's been sitting in trash and ready to clear" | `mcp__plugin_memory_memory__memory-empty-trash` | Hard-deletes trashed nodes whose `trashedAt` is older than `graceDays` (default 30); `dryRun=true` lists candidates without mutating. Use `dryRun` first, show the list to the user, then run for real. For KnowledgeDocument candidates, the on-disk attachment directory is removed too. |
| "scrub this conversation's memory", "remove this claim the bot picked up", "forget what we said in that thread" | `mcp__plugin_memory_memory__conversation-memory-expunge` | Removes deny-listable content from `:Memory` nodes scoped to one conversation. Requires an explicit pattern and sessionId. Not for deleting individual nodes — use `memory-delete`. |
| "update a property", "fix a typo in a node", "change the role field on X", "null this poisonous string" | `mcp__plugin_memory_memory__memory-update` | Targeted property edit on an existing node, schema-validated. Use to null individual string properties matching a deny-list entry once you've identified the offender via a Cypher read. Not for creating new nodes — use `memory-write`. |
| "update X by name, search didn't return it", "wire this Org but search came back empty", "I know it exists but I don't have the elementId" | `mcp__plugin_memory_memory__memory-update-by-name` | Repair surface for the case where `memory-search` cannot return a row you know exists by name. Resolves the unique `(label, name, accountId)` tuple, then delegates to `memory-update`. Rejects loudly on zero or ambiguous matches (the rejection lists candidate elementIds so you can pick one). Trashed nodes excluded. Use this only after `memory-search` failed — for normal updates, get the elementId from search and call `memory-update` directly. |
| "don't surface these substrings in future memory results", "watch for this string" | `mcp__plugin_memory_memory__graph-prune-denylist-add` | Records a substring as operator-curated signal. Nothing auto-applies it — when a subsequent memory-search or cypher read surfaces a match, use `memory-update` or `memory-delete` with discretion to scrub it. |

**Never run `cypher-shell` via `Bash` to work around the read-only tool.**
The admin session's `Bash` surface has no path to the per-brand Neo4j
password (it lives in `${PLATFORM_ROOT}/config/.neo4j-password`,
unreachable from the admin account's `Bash` cwd, and IDENTITY.md doctrine
forbids `Bash` against the code tree anyway — `.docs/neo4j.md`'s migration
section is an operator-run runbook, not a path you should replicate in
chat). Every write intent has a schema-aware tool above. If none of them
fits, tell the user plainly: *"This operation is not in the admin tool
surface. It can be added as a task."* — do not invent a workaround.

**Worked example — "prune the stale AdminUsers".** When an admin asks to
prune stale AdminUser entries (e.g., 15 nodes left over from prior
installs, none linked by `ADMIN_OF` to a `LocalBusiness`), enumerate them
with a Cypher read:

```cypher
MATCH (a:AdminUser) WHERE NOT (a)--() RETURN elementId(a), a.userId, a.createdAt
```

Show the list to the user. For each confirmed entry, call
`mcp__plugin_memory_memory__memory-delete` with the `elementId`. Do not batch-delete
by pattern — on 2026-04-20 an autonomous orphan-delete rule wiped 19
nodes in one call, with no property recovery possible. Structural mass-
deletion by shape is banned; discretion per node is the only sanctioned
path.

## Non-negotiable: never return raw nodes

`RETURN n` dumps every property, including the 768-dim `embedding` float
array. This blows the context budget on the first row. **Always enumerate the
properties you want.**

  Wrong: `MATCH (n:Person) RETURN n LIMIT 20`
  Right: `MATCH (n:Person) RETURN elementId(n) AS id, n.givenName, n.familyName, n.email, n.telephone LIMIT 20`

Apply this convention to every query. Only project the fields you need to
answer the question. When in doubt, exclude `embedding`, `embeddingText`, and
any property whose name ends in `_vector`.

## Cookbook

Parameterise results with `ORDER BY` and `LIMIT` every time — even an
"inventory" query should bound itself. Default limit: 50.

**Trashed nodes are excluded by default.** Every read pattern below filters
`NOT n:Trashed AND n.deletedAt IS NULL` (the new `:Trashed` label plus the
legacy `deletedAt` belt-and-braces). To include trashed nodes for a trash
census or pre-empty audit, drop the filter and add `MATCH (n:Trashed)`.

### Enumerate

All nodes, grouped by label, with a human-readable name:

```cypher
MATCH (n)
WHERE NOT n:Trashed AND n.deletedAt IS NULL
RETURN labels(n)[0] AS type,
       coalesce(n.name, n.title, n.givenName + ' ' + n.familyName, n.email, '(unnamed)') AS name,
       elementId(n) AS id
ORDER BY type, name
LIMIT 200
```

One label only:

```cypher
MATCH (n:Person)
WHERE NOT n:Trashed AND n.deletedAt IS NULL
RETURN elementId(n) AS id, n.givenName, n.familyName, n.email, n.telephone
ORDER BY n.familyName, n.givenName
LIMIT 100
```

### Trash census

What's currently in trash, by label:

```cypher
MATCH (n:Trashed)
RETURN labels(n) AS types, n.trashedBy AS by, count(*) AS n
ORDER BY n DESC
```

Recently trashed (last 7 days), with provenance:

```cypher
MATCH (n:Trashed)
WHERE n.trashedAt > datetime() - duration({days: 7})
RETURN elementId(n) AS id,
       labels(n) AS types,
       n.trashedAt AS trashedAt,
       n.trashedBy AS by,
       n.trashReason AS reason
ORDER BY n.trashedAt DESC
LIMIT 50
```

Eligible for empty-trash (older than 30 days):

```cypher
MATCH (n:Trashed)
WHERE n.trashedAt < datetime() - duration({days: 30})
RETURN labels(n) AS types, count(*) AS readyToPurge
ORDER BY readyToPurge DESC
```

### Count

Total across the graph:

```cypher
MATCH (n)
RETURN count(n) AS total
```

By label:

```cypher
MATCH (n)
WHERE NOT n:Trashed AND n.deletedAt IS NULL
RETURN labels(n)[0] AS type, count(*) AS n
ORDER BY n DESC
```

### Find

By exact email:

```cypher
MATCH (p:Person {email: $email})
WHERE NOT p:Trashed AND p.deletedAt IS NULL
RETURN elementId(p) AS id, p.givenName, p.familyName, p.telephone, p.status
```

Partial name, case-insensitive:

```cypher
MATCH (p:Person)
WHERE (toLower(p.givenName) CONTAINS toLower($q)
   OR toLower(p.familyName) CONTAINS toLower($q))
   AND NOT p:Trashed AND p.deletedAt IS NULL
RETURN elementId(p) AS id, p.givenName, p.familyName, p.email
LIMIT 20
```

### Neighbours

What is linked to a node (1 hop out, undirected):

```cypher
MATCH (n)-[r]-(m)
WHERE elementId(n) = $id
  AND NOT m:Trashed AND m.deletedAt IS NULL
RETURN type(r) AS rel,
       labels(m)[0] AS neighbourType,
       coalesce(m.name, m.title, m.email, '(unnamed)') AS neighbour,
       elementId(m) AS neighbourId
LIMIT 50
```

### Recent

Most recently created nodes (any label):

```cypher
MATCH (n)
WHERE n.createdAt IS NOT NULL
RETURN labels(n)[0] AS type,
       coalesce(n.name, n.title, n.givenName + ' ' + n.familyName, n.email, '(unnamed)') AS name,
       toString(n.createdAt) AS createdAt,
       elementId(n) AS id
ORDER BY n.createdAt DESC
LIMIT 20
```

Most recently updated:

```cypher
MATCH (n)
WHERE n.updatedAt IS NOT NULL
RETURN labels(n)[0] AS type,
       coalesce(n.name, n.title, n.email, '(unnamed)') AS name,
       toString(n.updatedAt) AS updatedAt,
       elementId(n) AS id
ORDER BY n.updatedAt DESC
LIMIT 20
```

### Schema

All labels present in the graph:

```cypher
CALL db.labels() YIELD label RETURN label ORDER BY label
```

All relationship types:

```cypher
CALL db.relationshipTypes() YIELD relationshipType RETURN relationshipType ORDER BY relationshipType
```

Or use `maxy-graph-get_neo4j_schema` for a richer one-shot structural summary.

### Fulltext

Use the universal `entity_search` index for keyword-style search
across every operator-meaningful label — Person, Organization, Task, Event,
Conversation, KnowledgeDocument, Email, etc. — on every textual property
the platform's writers assign:

```cypher
CALL db.index.fulltext.queryNodes('entity_search', $query)
YIELD node, score
WHERE score > 0.5
RETURN labels(node)[0] AS type,
       coalesce(node.name, node.title, node.heading, '(unnamed)') AS name,
       score,
       elementId(node) AS id
LIMIT 20
```

Pre-Task-748 the index was named `knowledge_fulltext` and covered only
`KnowledgeDocument | Section | Chunk`. Existing Pis pick up the rename on
the next install via `seed-neo4j.sh`.

### Filter by status or category

Events that are cancelled:

```cypher
MATCH (e:Event {status: 'cancelled'})
RETURN elementId(e) AS id, e.name, toString(e.startDate) AS start
ORDER BY e.startDate DESC
LIMIT 50
```

Tasks by status:

```cypher
MATCH (t:Task {status: $status})
RETURN elementId(t) AS id, t.title, toString(t.createdAt) AS created, t.priority
ORDER BY t.createdAt DESC
LIMIT 50
```

## Projecting `memory-search` fields

`memory-search` accepts an optional `fields: string[]` that narrows each
result's `properties` to the named keys. Ranking is unaffected — the hybrid
pipeline sees the full text either way. Only the response payload shrinks.

When to project:

- **Known-shape lookups.** Resolving a slug to a `name`, looking up a
  Person's `phoneNumber`, fetching a Task's `status`. You know which keys
  you need; bios, notes, source URLs do not belong on every turn.
- **High-fan-out reads.** `limit: 20` against a verbose label class —
  trim each row to the keys you'll actually quote.

When NOT to project:

- **Open-ended recall.** "What do you know about Alice?" — you do not
  yet know which property carries the answer; omit `fields` and read the
  full row.
- **Triage / first-contact reads.** Same reasoning.

Semantics:

- `fields` omitted → full payload (today's behaviour).
- `fields: ["name", "slug"]` → only those keys per row.
- `fields: []` → empty `properties` object — explicit "no properties",
  distinct from "all".
- Unknown keys are silently skipped. Rows without a requested key omit it
  on that row.
- `related[*].properties` is NOT projected (separate concern).

Worked example:

```ts
// Resolving a contact's phone number from a slug — payload-minimal.
memory-search({
  query: "alice cooper",
  labels: ["Person"],
  fields: ["name", "phoneNumber"],
});
```

## When to switch back to memory-search

- "Things similar to X" → `memory-search` (vector + BM25 hybrid)
- "Candidates to rank by criterion Y" → `memory-search` for the pool, rank in-turn yourself
- "Conversations where we discussed Z" → `conversation-search`

Cypher is for relational certainty. Vector search is for similarity. They
answer different questions; don't use one to fake the other.
