# Schema Reference — Base

Universal node types shared across all business verticals. Load this reference before any structured write to the graph. The active vertical reference (declared by `brand.json#vertical`, or overridden by a LocalBusiness's `businessType`) extends these types — it never replaces them.

When loading this reference, confirm which schema files were consulted by noting: "Using schema-base" and the active vertical file name (or "no vertical" for non-vertical installs). The active vertical is the one the validator emits on every write as `[schema-validator] resolved-vertical=<X|none>`.

> **Role of this file.** It declares **property-shape rules** — required properties, naming conventions, and relationship patterns — for documented labels. It is **not** the label-existence gate. Label existence is established by the live `db.labels()` ∪ `schema.cypher` declarations. Platform-native labels (`LocalBusiness`, `AdminUser`, `Conversation`, `KnowledgeDocument`) live in `platform/neo4j/schema.cypher` and are accepted by the validator without a markdown row here; their property shape is the agent's responsibility until a row is added. A label appearing in this file is the agent's commitment that writes carry the listed required properties. A label absent from this file is not a write blocker.

---

## Node Types

| Entity | Neo4j Label | Schema.org Type | Interchange Standard | Required Properties |
|--------|-------------|-----------------|----------------------|---------------------|
| Customer | `Person` | `schema:Person` | vCard (RFC 6350) | `accountId` |
| Organization | `Organization` | `schema:Organization` | vCard (RFC 6350) | `accountId`, `name` |
| Staff | `Person` | `schema:Person` + `cdm:Contact` | vCard (RFC 6350) | `accountId`, `givenName`, `familyName`, `jobTitle`, `permissions` |
| Job | `Order` | `schema:Order` + `cdm:Opportunity` | — | `accountId`, `orderNumber`, `customer` (id), `orderStatus` |
| Event | `Event` | `schema:Event` | iCalendar (RFC 5545) | `accountId`, `name`, `startDate`, `endDate` |
| Invoice | `Invoice` | `schema:Invoice` | UBL 2.1 + PDF/A | `accountId`, `confirmationNumber`, `customer` (id), `totalPaymentDue`, `paymentStatus` |
| Supplier invoice | `InboundInvoice` | `schema:Invoice` | UBL 2.1 + PDF/A | `accountId`, `supplier`, `confirmationNumber`, `totalPaymentDue` |
| Invoice line | `InvoiceLine` | — | — | `accountId`, `invoiceId`, `lineId`, `description`, `quantity`, `amount` |
| Invoice payment | `InvoicePayment` | — | — | `accountId`, `invoiceId`, `paymentId`, `amount`, `date`, `paymentType` |
| Credit | `Credit` | — | — | `accountId`, `creditId`, `amount`, `date` |
| Cash entry | `CashEntry` | — | — | `accountId`, `entryId`, `direction` (`in`/`out`), `amount`, `date`, `description` |
| Service | `Service` | `schema:Service` | Schema.org JSON-LD | `accountId`, `name`, `description` |
| PriceRule | `PriceSpecification` | `schema:PriceSpecification` | — | `accountId`, `price`, `priceCurrency` |
| Communication | `Message` | `schema:Message` + `cdm:Activity` | — | `accountId`, `sender`, `recipient`, `dateSent`, `channel` |
| Document | `DigitalDocument` | `schema:DigitalDocument` | Original format preserved | `accountId`, `name`, `dateCreated`, `encodingFormat` |
| Address | `PostalAddress` | `schema:PostalAddress` | — | `streetAddress`, `addressLocality`, `postalCode` |
| Task | `Task` | `schema:Action` (semantics) | — | `accountId`, `taskId`, `name`, `status`, `priority` |
| UserProfile | `UserProfile` | platform-native | — | `accountId`, `userId` |
| Lead | `Lead` | `cdm:Lead` | CDM only | `accountId`, `subject`, `customer` (id), `status` |
| Case | `Case` | `cdm:Case` | CDM only | `accountId`, `title`, `customer` (id), `status`, `priority` |
| OpeningHours | `OpeningHoursSpecification` | `schema:OpeningHoursSpecification` | Schema.org JSON-LD | `accountId`, `dayOfWeek`, `opens`, `closes` |
| FAQ | `FAQPage` | `schema:FAQPage` | Schema.org JSON-LD | `accountId`, `name` |
| Question | `Question` | `schema:Question` | Schema.org JSON-LD | `accountId`, `name` (question text), `acceptedAnswer` |
| BrandAsset | `ImageObject` | `schema:ImageObject` | — | `accountId`, `name`, `contentUrl`, `encodingFormat`, `purpose` (logo/hero/icon) |
| CreativeWork | `CreativeWork` | `schema:CreativeWork` | — | `accountId`, `title`, `abstract` |
| DefinedTerm | `DefinedTerm` | `schema:DefinedTerm` | — | `accountId`, `name`, `description` |
| AccessGrant | `AccessGrant` | platform-native | — | `accountId`, `agentSlug`, `contactMethod` (always `email`), `contactValue`, `status` (`invited`/`active`/`expired`/`revoked`), `sliceToken` (UUIDv4, immutable, ring-fences per-visitor memory writes when `accessMode: "gated"`). Scope must be `admin`. |
| CloudflareTunnel | `CloudflareTunnel` | platform-native | — | `accountId`, `tunnelId`, `tunnelName` |
| CloudflareHostname | `CloudflareHostname` | platform-native | — | `accountId`, `hostnameValue`, `tunnelId` |
| Position | `Position` | platform-native (analogue of `schema:OrganizationRole`) | — | `accountId`, `title`, `startDate` |
| WhatsApp Conversation (legacy) | `WhatsAppConversation` | extends `schema:Conversation` | — | `accountId`, `sessionId`, `archiveSourceFile`, `firstMessageAt`, `lastMessageAt`, `participantCount`, `messageCount`, `scope`, `createdByAgent`, `createdBySession`, `createdAt` |
| WhatsApp Message (legacy) | `WhatsAppMessage` | extends `schema:Message` | — | `accountId`, `sessionId`, `messageId`, `dateSent`, `body`, `senderName`, `sequenceIndex`, `scope`, `createdByAgent`, `createdBySession`, `createdAt` |
| Email thread | `ConversationArchive {source:'email'}` | One archive per thread (the `:Email` label was retired; threads moved off `:KnowledgeDocument` onto the source-agnostic conversation-archive pipeline) | RFC 5322 over IMAP | `accountId`, `conversationIdentity` (sha256 of accountId + sortedParticipantElementIds + source), `source = 'email'`, `title` (subject of root message), `firstMessageAt`, `lastMessageAt`, `lastIngestedMessageHash`, `authorshipMode`. `:Section` chunks carry the message bodies via `HAS_SECTION` and the `NEXT` chain. |
| SocialPost | `SocialPost` | `schema:SocialMediaPosting` | — | `accountId`, `platform` (linkedin/twitter/instagram/…), `body`, `datePublished`, `authorshipMode` |
| VoiceProfile | `VoiceProfile` | platform-native (authorial-voice style card) | — | `accountId`, `adminUserId`, `styleCard`, `generatedAt`, `corpusSize`, `feedbackEntries` |
| VoiceEdit | `VoiceEdit` | platform-native (operator edit on an agent draft) | — | `accountId`, `adminUserId`, `originalText`, `editedText`, `intent`, `occurredAt` |
| AEO Audit | `AEOAudit` | platform-native (AEO audit history) | — | `accountId`, `auditId`, `target`, `score`, `heuristicsJson`, `runAt`, `createdByAgent`, `createdBySession`, `createdAt` |
| Idea | `Idea` | platform-native (original-thinking candidate extracted by the pre-turn graph pass) | — | `accountId`, `body`, `sessionId`, `scope`, `createdByAgent`, `createdBySession`, `createdAt` |
| TimelineEvent | `TimelineEvent` | platform-native (compiled-truth + timeline doctrine) | — | `accountId`, `timelineEventId`, `occurredAt`, `kind`, `summary`, `source` |

**Branding properties on LocalBusiness:** `primaryColor`, `accentColor`, `backgroundColor`, `tagline` — optional, used to brand the public chat endpoint. Hex color values must match `#[0-9a-fA-F]{3,8}`. Logo and icon are linked via `HAS_BRAND_ASSET → ImageObject` with `purpose: "logo"` or `"icon"`.

**LocalBusiness is a per-account singleton.** The root `:LocalBusiness` is seeded once by `seed-neo4j.sh` (system origin). Non-system *creates* against `:LocalBusiness` are refused by `memory-write`; the refusal names `Organization` as the correct label for third-party companies. Property *updates* against the existing root are allowed and are how the operator fills in business identity (name, vertical, branding, description); the standard write-gate and per-label property restrictions still apply. See [label-origin-gate.ts](../mcp/src/lib/label-origin-gate.ts). Deletes are gated symmetrically to creates: `memory-delete` and `memory-empty-trash` refuse any operation against `:LocalBusiness` unless the caller is the installer reset path (`origin: "system"`); cascade descendants resolving to `:LocalBusiness` are skipped, not trashed. See [label-delete-gate.ts](../mcp/src/lib/label-delete-gate.ts).

**Personal-profile role on Person:** the optional `role` property on a `Person` node carries the value `"admin-personal"` when the node is the operator's personal-profile bootstrap (onboarding step 9 personal mode). The platform accepts `Person {role: "admin-personal"}` in lieu of a `LocalBusiness` so personal-mode users can complete onboarding without registering a business. Do not set `role: "admin-personal"` on customer or staff Person nodes — it's the personal-profile discriminator and only the bootstrap node should carry it.

**Organization is open by default.** The label covers both supplier-style use (a third-party company you do business with, where vCard-shaped contact data is meaningful) and reference-style use (an organization named in a CV, bio, or document where contact info is unknown or irrelevant). The validator enforces only `accountId` and `name`. For supplier-style use, gather `telephone` or `email` and a postal address when the source provides them — agent judgment, not validator-enforced. The row previously enforced `telephone or email` and the agent-write path obeyed it, but the document-ingest writer bypassed the validator and minted 23 contact-less Organizations on the first install that combined a CV PDF with a website ingest; those rows were schema-invalid against the prose contract while being valid against the actual write surface. Relaxing the rule resolves the straddle: the agent decides per case whether contacts belong, and search no longer hides ingest-created Orgs.

**Person is open by default.** Any Person property the agent judges useful is permitted; the set is comprehensive, not enumerated. The only hard requirement is `accountId` (the universal account-scoping property — see [feedback_accountid_must_be_uuid.md](../../../../.claude/projects/-Users-neo-getmaxy/memory/feedback_accountid_must_be_uuid.md)). Identifying fields (`givenName`, `familyName`, `email`, `telephone`, …) are agent-judged: a single field is enough to create a Person; the agent and operator manage dedup and conflicts downstream. Forbidden Properties (e.g. `Person.name` rejects in favour of `givenName` + `familyName`) and Schema.org synonyms (e.g. `phone` rejects in favour of `telephone`) still apply. The `profile-update` tool's `personFields` parameter is the personal-profile conversational surface, validating in `mode: "update"` so SET-only writes skip the (now-empty) required-property check (givenName/familyName were established at PIN setup).

**CreativeWork** covers long-form textual records written by the platform: session summaries (`session-compact`), workflow digests (e.g. public chat review), and skill definitions saved by `skill-builder`. The `title` is a short identifier; the `abstract` carries the full content that drives the embedding.

**DefinedTerm** covers reusable concepts, learnings, and glossary entries — e.g. project retrospective learnings (`name` is a short handle, `description` is the full learning). Additional properties like `category` are encouraged but not required.

**Position** carries a single role held by a `UserProfile` at an `Organization` — its title, dates, and per-role properties (e.g. achievement bullets in `description`, `endDate` for a finished role). Each role is its own node so it can carry its own structure; multiple Positions per UserProfile (career history); one Organization may host many Positions across many UserProfiles. Written by the `document-ingest` skill when a CV section classifies as `Position`. The natural pattern is `(:UserProfile)-[:HAS_POSITION]->(:Position)-[:AT]->(:Organization)` — never set the employer name as a property on Position; always create the AT edge to the (MERGEd) Organization.

**`authorshipMode` on content labels.** Every `:KnowledgeDocument`, `:ConversationArchive` (every chat transcript parent, including email threads under `source:'email'`), `:Message`, `:SocialPost`, and `:Section` write carries an `authorshipMode` enum drawn from `human-only | human-led-agent-assisted | agent-led-human-reviewed | agent-only | unknown`. The value records who actually wrote the content body, not who triggered the write. `human-only` means every word of the body came from the operator; `human-led-agent-assisted` is operator-authored with light agent polish (the operator chose every claim and structure); `agent-led-human-reviewed` is agent-drafted then operator-edited before send; `agent-only` is pure agent output the operator did not touch. The voice-mirror distillation walks the corpus where `authorshipMode IN ['human-only', 'human-led-agent-assisted']` — both modes carry voice signal. `:ConversationArchive` `:Section` chunks (WhatsApp / Telegram / Signal / Granola / Otter / Circleback exports ingested under `(:ConversationArchive)-[:HAS_SECTION]->(:Section)`) are first-class voice corpus and the highest-leverage signal source because chat captures the unguarded voice in a way email rarely does. Legacy nodes default to `unknown` until reclassified via the voice-mirror backfill flow. The per-label authorshipMode index on `:Section` covers transcript chunks without a secondary label. Writers that cannot determine the mode (e.g. bulk archive imports) write `unknown` rather than guessing.

**Voice profile pattern.** `(:AdminUser)-[:HAS_VOICE_PROFILE]->(:VoiceProfile)`. The styleCard is YAML serialized to a string property containing sentence-length distribution, register, favoured/avoided constructions and words, taboos, opening/closing patterns, and exemplar sentences. `(:VoiceProfile)-[:LEARNED_FROM]->(:KnowledgeDocument|:Message|:SocialPost|:Section)` links the profile to every node in its source corpus so the operator can audit what drove the distillation. Each operator edit on an agent draft becomes `(:VoiceEdit)-[:FEEDBACK_FOR]->(:VoiceProfile)` with the original/edited bodies plus a Haiku-summarised `intent` so the next distillation knows what to adjust.

**Idea** is a forward-only write surface for original-thinking candidates extracted from admin-side conversations by `database-operator` per-write briefs (dispatched by admin inline mid-turn, and during the on-demand `/insight` graph-completeness sweep). `body` is the verbatim operator statement; `sessionId` denormalises the source conversation; `scope` is always `"admin"`. Never written by any other path. No dedup or clustering at write time — let the brain accumulate first.

**Durable-action properties on Task:** `kind` (string discriminator, e.g. `"onboarding-establish-owner"`, `"cloudflare-tunnel-login"`), `steps` (string[], append-only as the action progresses), `startedAt` (ISO timestamp, set when status transitions to `running`), `completedAt` (ISO timestamp, set when status transitions to `completed` or `failed`), `errorMessage` (string, set when status=`failed`; cleared to `null` when the same Task transitions to `completed`), `inputsProvided` (string[] of property names supplied at action start — the canonical "call shape" record), `inputs.<field>` (Record of arbitrary non-secret JSON values from the action's form payload, redacted at write time per the schema declared at the form definition). All optional. Per-kind value-field enumeration is intentionally absent — values vary by what the action records, governed by the central `redactSecrets` primitive rather than per-kind allow-lists. The `running` status value joins the existing `pending|active|completed|failed|cancelled` enum to discriminate "an action is mid-flight in this conversation" from `active` ("a commitment the operator has accepted").

---

## Compiled truth + timeline

Every `:Person`, `:Organization`, and `:Concept` carries a two-part knowledge shape: a **compiled truth** above the rule (the agent's current best understanding of the subject, rewritten when evidence changes) and an **append-only timeline** below (dated events linked via `:HAS_TIMELINE_EVENT`, never edited).

| Property | Type | Purpose |
|---|---|---|
| `compiledTruth` | string ≤2000 chars | The current summary. Empty on first write. |
| `compiledTruthUpdatedAt` | datetime | Last rewriter or operator write. Epoch on first write. |
| `compiledTruthSource` | `'rewriter' \| 'operator'` | Last writer. `'operator'` marks passes shaped by an operator hint from chat; the rewriter respects a 7-day cooldown against further auto-rewriter passes unless `forceRewrite=true` is set. |

The compiled truth is system-managed: direct writes to any of the three properties via `memory-update` or `memory-write` are **rejected loudly** for normal callers. The rewriter runs in the dispatched `compiled-truth-rewriter` specialist, which is the only sanctioned writer. The admin agent surfaces an operator request (e.g. "refresh Acme's compiled truth", or "fix the bio for Joel — say he's an angel investor now") by dispatching the specialist via the Task tool with the `nodeId`, an optional `operatorHint` (admin-twin correction), and an optional `publicOperatorHint` (public-twin correction). The specialist reads the node + 90-day timeline, computes the new summary, and writes it back via `memory-update`; the gate allows the write because `MAXY_SPECIALIST=compiled-truth-rewriter` is stamped on its env at spawn time.

### Revision history

Existing `:CompiledTruthRevision` rows remain readable via `memory-compiled-truth-history(nodeId)`; the dream-cycle phase 5 prune (keep newest 20 per entity) still runs. The inline single-TX revision stamp that used to fire on every rewriter SET was deleted. Reinstatement under the new specialist path is tracked as a follow-up; until then, new specialist writes do not extend the revision chain.

### Timeline events

`:TimelineEvent` is distinct from the scheduling plugin's `:Event` (which holds `eventId`-unique calendar/reminder rows). Naming chosen to avoid collision.

`:HAS_TIMELINE_EVENT` is distinct from visitor-analytics' `:HAS_EVENT` (`Session → PageView/Click/ScrollMilestone`). Naming chosen to avoid collision.

`kind` enum (5 values, no catch-all):

```
mentioned | met | quoted | attended | transacted
```

The extractor refuses on ambiguity rather than collapsing to "other". Default on write is `mentioned`; the typed-edge post-write hook upgrades the kind asynchronously when it can.

### Write doctrine

Path-anchored MERGE — never label-only:

```cypher
MATCH (e) WHERE elementId(e) = $entityId AND e.accountId = $accountId
MERGE (e)-[:HAS_TIMELINE_EVENT]->(t:TimelineEvent {timelineEventId: $id})
```

`memory-write` runs the deterministic date extractor against the `body` property of any `:Person` / `:Organization` / `:Concept` write and creates one `:TimelineEvent` per parseable dated statement.

---

## Forbidden Properties

Properties listed here are **rejected at write-time** by the schema validator on the listed label. Rejection runs after the synonym pass, so a `firstName → givenName` rewrite still happens before the forbidden-property check. The rule is per-label: `name` is forbidden on `Person` only; `Organization`, `Event`, `Conversation`, `Service`, etc. retain `name` as a canonical property.

| Label | Property | Rationale |
|-------|----------|-----------|
| `Person` | `name` | Use `givenName` + `familyName`. The rendered display name is composed at read time; persisting `name` denormalises the canonical pair and creates a divergence trap when `name` truncates to `givenName` ("Dan") while the pair holds the full identity ("Dan Brett"). |
| `Invoice`, `InboundInvoice` | `outstanding`, `balance`, `amountPaid` | What is still owed is **computed on read** as `totalPaymentDue` minus the summed `amount` of the invoice's `InvoicePayment` and `Credit` children. A stored figure drifts silently the moment a payment lands by any path that forgets to update it, and nothing fails: the invoice simply reports a number that no longer reconciles against its own children. |

---

## Property Naming Rules

Schema.org property names are camelCase. Use them exactly as defined — no synonyms, no abbreviations:

| Correct | Wrong |
|---------|-------|
| `givenName` | `firstName`, `first_name` |
| `familyName` | `lastName`, `last_name`, `surname` |
| `telephone` | `phone`, `phoneNumber`, `mobile` |
| `startDate` / `endDate` | `start`, `end`, `from`, `to` |
| `totalPaymentDue` | `amount`, `total`, `invoiceTotal` |
| `orderStatus` | `status` (on Order nodes — use the qualified name) |
| `dateSent` | `timestamp`, `date`, `sentAt` (on Message nodes) |
| `confirmationNumber` | `invoiceNumber`, `invoiceNo` |
| `addressLocality` | `city` |
| `addressLocality` | `town` (on PostalAddress nodes — estate-agent Listing carries `town` directly) |
| `postalCode` | `zipCode`, `zip` |
| `postalCode` | `postcode` (on PostalAddress nodes — estate-agent Listing carries `postcode` directly) |

`totalPaymentDue` is invoice semantics. On `Offer` nodes the monetary property is `price` (the Schema.org canonical for Offer) — see the estate-agent schema's Offer row.

`InvoiceLine`, `InvoicePayment`, `Credit` and `CashEntry` carry a monetary `amount`, and the ban above does not reach them. The ban is **invoice-level** semantics: `totalPaymentDue` is the total owed on a whole bill. A line item, a single payment, a credit and a loose cash movement are each one figure among several that sum toward or against that total, so `amount` is the correct name and `totalPaymentDue` would be actively wrong on them. The contradiction detector (`detectContradictions` in `schema-loader.ts`) exempts `amount` for exactly these four labels and nowhere else, via the exported `BAN_EXEMPT` that the write-path validator shares. (Moved here from `schema-construction.md` by the ledger promotion when these labels were promoted to base.)

---

## accountId Scoping

Every node except `PostalAddress` must carry `accountId`. No write may omit it. No query result without `accountId` should be treated as valid. There is no default value and no fallback — if `accountId` is unknown, the write must not proceed.

`PostalAddress` nodes are exempt because addresses are shared across entities (a customer and their orders may share the same address node).

---

## Embedding

Every write that creates or updates a structured node produces a 768-dimension embedding via `nomic-embed-text` (Ollama). The `memory-write` tool handles embedding generation automatically. The agent's responsibility is to provide complete, meaningful property values so the embedding accurately represents the node's content.

Do not write placeholder values ("TBD", "unknown", empty strings) for properties that will be embedded — they degrade search quality.

---

## Relationship Patterns

```
(:Person)-[:WORKS_FOR]->(:Organization)
(:Order)-[:ORDERED_BY]->(:Person)
(:Invoice)-[:REFERENCES_ORDER]->(:Order)
(:Invoice)-[:BILLED_TO]->(:Person|Organization)
(:Invoice)-[:HAS_LINE]->(:InvoiceLine)
(:InboundInvoice)-[:HAS_LINE]->(:InvoiceLine)
(:Invoice)-[:HAS_PAYMENT]->(:InvoicePayment)
(:InboundInvoice)-[:HAS_PAYMENT]->(:InvoicePayment)
(:Invoice)-[:HAS_CREDIT]->(:Credit)
(:InboundInvoice)-[:FROM_SUPPLIER]->(:Organization)
(:CashEntry)-[:COUNTERPARTY]->(:Person)
(:CashEntry)-[:COUNTERPARTY]->(:Organization)
(:Event)-[:ATTENDED_BY]->(:Person)
(:Message)-[:SENT_TO]->(:Person|Organization)
(:DigitalDocument)-[:ABOUT]->(:*)
(:Task)-[:RAISED_BY]->(:Person)
(:Task)-[:RAISED_DURING]->(:AdminConversation|:PublicConversation|:WhatsAppConversation)
(:Task)-[:PRODUCED]->(:Person|:UserProfile|:AdminUser|:Organization|:LocalBusiness|:CloudflareTunnel|:CloudflareHostname|:KnowledgeDocument|:*)
(:Task)-[:ABOUT]->(:Person|Service|Order|*)
(:AdminUser|:Person)-[:HAS_PROFILE]->(:UserProfile)
(:LocalBusiness)-[:HAS_HOURS]->(:OpeningHoursSpecification)
(:LocalBusiness)-[:HAS_FAQ]->(:FAQPage)
(:FAQPage)-[:CONTAINS]->(:Question)
(:LocalBusiness)-[:HAS_BRAND_ASSET]->(:ImageObject)
(:Person)-[:HAS_ACCESS]->(:AccessGrant)
(:LocalBusiness)-[:OFFERS]->(:Service)
(:UserProfile)-[:HAS_POSITION]->(:Position)
(:Position)-[:AT]->(:Organization)
(:UserProfile)-[:ATTENDED]->(:Organization {organizationCategory:'educational'})
(:UserProfile)-[:HOLDS]->(:Credential)
(:UserProfile)-[:HAS_SKILL]->(:DefinedTerm {category:'skill'})
(:KnowledgeDocument)-[:REFERENCES]->(:*)
(:KnowledgeDocument)-[:HAS_SECTION]->(:Section)
(:AdminUser)-[:HAS_VOICE_PROFILE]->(:VoiceProfile)
(:VoiceProfile)-[:LEARNED_FROM]->(:KnowledgeDocument|:Message|:SocialPost|:Section)
(:VoiceEdit)-[:FEEDBACK_FOR]->(:VoiceProfile)
(:KnowledgeDocument)-[:HAS_AEO_AUDIT]->(:AEOAudit)
(:ConversationArchive|:KnowledgeDocument)-[:HAS_ENCLOSURE]->(:DigitalDocument|:KnowledgeDocument|:ImageObject)
(:Message)-[:PART_OF]->(:Conversation)
(:Person|:AdminUser)-[:SENT]->(:Message)
(:Person|:AdminUser)-[:PARTICIPANT_IN]->(:Conversation)
(:Message)-[:NEXT]->(:Message)
(:Conversation)-[:HAS_IDEA]->(:Idea)
```

Payment **direction is derived from the parent label, never stored**. An `:InvoicePayment` reached from
an `:Invoice` is money received; the same label reached from an `:InboundInvoice` is money paid. Adding a
`direction` property to `:InvoicePayment` would create a second source of truth that can disagree with the
edge it hangs from. `:CashEntry` does carry `direction`, because it has no parent invoice to derive it
from.

### WhatsApp ingest natural-key contract

Live nodes carry compound labels: `:Conversation:WhatsAppConversation` and `:Message:WhatsAppMessage`. The Node Types table registers `WhatsAppConversation` and `WhatsAppMessage` individually — the schema validator matches on the more-specific sublabel. Base `Conversation` and `Message` rules (e.g. `dateSent` on Message) still apply via the base label.

`:Message:WhatsAppMessage` natural key is `messageId = whatsapp-export:msg:<conversationSha256>:<dateSentISO>:<NFKC-trim-lower(senderName)>:<sha256-hex(body)>`. Re-imports of the same archive — same operator-confirmed timezone — collapse to the same Message identity. The chain `(:Message)-[:NEXT]->(:Message)` is rebuilt deterministically by ordering on `(dateSent, sequenceIndex, messageId)`; `sequenceIndex` is preserved on the node only as a tiebreaker, never as part of `messageId`.

### Document-ingestion section kinds

The `document-ingest` skill maps unstructured-document sections onto a closed enumeration of `kind` values. Every section becomes one `:Section` node; recognised kinds carry a secondary label so the same node serves as both the document section and the typed entity. Identity-kind anchor edges point from the document subject directly at the multi-labeled section node — there is no parallel "Section vs typed-node" concept.

The closed enumeration:

**Identity kinds (anchor edge from the document subject, usually `UserProfile`):**

| `kind` | Section labels | Anchor pattern |
|---|---|---|
| `Position` | `:Section:Position` | `(:UserProfile)-[:HAS_POSITION]->(:Section:Position)`; section `-[:AT]->` `:Organization` |
| `Education` | `:Section:Education` | `(:UserProfile)-[:ATTENDED {degree, startDate, endDate}]->(:Section:Education)`; section `-[:ATTENDED]->` `:Organization {organizationCategory:'educational'}` |
| `Credential` | `:Section:Credential` | `(:UserProfile)-[:HOLDS]->(:Section:Credential)` |
| `Skill` | `:Section:Skill` | `(:UserProfile)-[:HAS_SKILL]->(:Section:Skill)` |
| `Biography` | `:Section:Biography` | `(:UserProfile)-[:DESCRIBED_BY]->(:Section:Biography)` |

**Standalone (non-Section) node kind:**

| `kind` | Node label | Anchor pattern |
|---|---|---|
| `Project` | `:Project` | `(:UserProfile)-[:CREATED]->(:Project)`; optional `(:Project)-[:UNDER]->(:Organization)` |

**Document-structural kinds (HAS_SECTION + NEXT only — no anchor edge):**

`Preface`, `Abstract` (alias for Summary / ExecutiveSummary), `Introduction`, `TableOfContents`, `Chapter`, `Conclusion`, `Appendix`, `Bibliography` (alias for References), `Glossary`, `Acknowledgments` — each becomes `:Section:<Kind>` linked to the document via `(:KnowledgeDocument)-[:HAS_SECTION]->` and chained to siblings via `(:Section)-[:NEXT]->(:Section)` in reading order.

`Conversation` — chat-mode chunks emitted by `memory-classify` with `mode='chat'`. Becomes `:Section` linked via `(:ConversationArchive)-[:HAS_SECTION]->` — same Section shape as document-mode, only the parent label differs. The parent `:ConversationArchive` carries `conversationIdentity` (and `source`); readers that need conversation-vs-document filtering match on the parent label rather than a property heuristic. Properties beyond the base section shape: `summary`, `keywords`, `firstMessageAt`, `lastMessageAt`, `participantNames`, `messageCount`, `archiveSha256`. The `:Section` validator rules (embedding, body, position) apply uniformly.

**Contract-clause kinds (HAS_SECTION + NEXT, plus special-case extras for two kinds):**

`Parties`, `Recitals`, `Definitions`, `Scope`, `Term`, `Payment`, `Confidentiality`, `IntellectualProperty`, `Warranties`, `Indemnification`, `Liability`, `Termination`, `GoverningLaw`, `ForceMajeure`, `Notices`, `EntireAgreement`, `Amendment`, `Assignment`, `Severability`, `Signatures`.

Two contract-clause kinds carry additional structure:
- `:Section:Parties` — the classifier additionally emits `documentEdges` of type `PARTY` so the writer creates `(:KnowledgeDocument)-[:PARTY]->(:Person|:Organization)` for each party named.
- `:Section:Definitions` — the classifier additionally emits `related` entries with edge type `DEFINES` so the writer creates `(:Section:Definitions)-[:DEFINES]->(:DefinedTerm)` for each defined term.

**Label fallback:**

| `kind` | Section labels | Notes |
|---|---|---|
| `Other` | `:Section:Other` | Classifier ran but the section's content fits no listed kind. The `classifierReason` property carries the one-line description. Distinct from a classifier *crash* (which aborts ingest entirely). Distinct from edge-orphan-candidate (which is a node-level concept). |

The classifier returns `kind` strings from the closed enumeration above. `kind` values not in the list collapse to `Other` server-side — `memory-ingest` never invents a label or accepts an unrecognised kind silently.

**Edges introduced by document-ingest:**

| Edge | From → To | Notes |
|---|---|---|
| `HAS_SECTION` | `KnowledgeDocument` → `Section` | Always, every section. |
| `NEXT` | `Section` → `Section` | Reading-order chain; one per pair of consecutive sections within a single document. |
| `HAS_POSITION` | `UserProfile` → `Section:Position` | Identity anchor. |
| `ATTENDED` | `UserProfile` → `Section:Education` and `Section:Education` → `Organization` | Anchor + related-entity. |
| `HOLDS` | `UserProfile` → `Section:Credential` | Identity anchor. |
| `HAS_SKILL` | `UserProfile` → `Section:Skill` | Identity anchor. |
| `DESCRIBED_BY` | `UserProfile` → `Section:Biography` | Identity anchor. |
| `CREATED` | `UserProfile` → `Project` | Standalone-node anchor. |
| `UNDER` | `Project` → `Organization` | Optional Project context. |
| `AT` | `Section:Position` → `Organization` | Position's employer. |
| `PARTY` | `KnowledgeDocument` → `Person` or `Organization` | Contract Parties — written from `documentEdges` when a `Parties` section is detected. |
| `PARTICIPANT` | `KnowledgeDocument` → `Person` or `Organization` | Meeting / call attendees — written from `documentEdges` when the document is a multi-party transcript or meeting notes. |
| `PARTICIPANT_IN` | `Person` or `AdminUser` → `ConversationArchive` | Operator-confirmed sender or recipient on an email thread (and every other conversation source). The FROM/TO/CC distinction is not preserved — the chat-mode classifier surfaces the per-message `From: <Name> <email>` prefix as a body fragment, not as an edge property. |
| `SPEAKER` | `KnowledgeDocument` → `Person` | Voice-note / single-speaker transcript speaker — written from `documentEdges`. |
| `MENTIONS` | `KnowledgeDocument` → `Person`, `Organization`, `Service`, `Task`, `Event`, `KnowledgeDocument`, `BrandingData` | Catch-all for entities the dispatch brief named that the document references but for which no document-shape-specific edge applies — written by `specialists:database-operator` in the `wire-brief-entities` pipeline step. |
| `DEFINES` | `Section:Definitions` → `DefinedTerm` | Contract definitions — written from per-section `related`. |

**Ontology-growth review query.** When a document accumulates several `:Section:Other` nodes, the operator (or admin agent) can run the following Cypher to surface candidate ontology additions:

```cypher
MATCH (s:Section:Other)
WHERE s.accountId = $acc
RETURN s.title, s.classifierReason, count(*) AS occurrences
ORDER BY occurrences DESC
```

Patterns with high occurrence count are candidates for new section-kind labels. Adding a kind = adding a row to `schema-base.md` (and any relevant vertical schema) — never per-account.

---

## Visitor Graph

Behavioural analytics — pixel events from operator-owned listing pages land here.
Cross-session attribution is via a signed-token cookie that the property-recommender
issues; the pixel never sees a third-party domain.

| Entity | Neo4j Label | schema.org | Description | Required Properties |
|---|---|---|---|---|
| Session | `Session` | (none — bespoke) | One browser tab session. | `accountId`, `sessionId`, `startedAt` |
| AnonVisitor | `AnonVisitor` | (none — bespoke) | Pre-identification browser identity. Merges into `:Person` on signed-token click. | `accountId`, `visitorId` |
| PageView | `PageView` | `schema:ViewAction` | One page load. One node per pageview; idempotent within 5s. | `accountId`, `occurredAt`, `referrer` |
| Click | `Click` | `schema:InteractAction` | DOM click on `data-track="<label>"` or a known interaction (gallery / floorplan). | `accountId`, `label`, `occurredAt` |
| ScrollMilestone | `ScrollMilestone` | (none — bespoke) | Roll-up: one node per PageView with `maxDepth` ∈ {25,50,75,100}. Updated in place. | `accountId`, `maxDepth` |
| Page | `Page` | `schema:WebPage` | URL metadata. Persists across erasure (no PII). | `accountId`, `url` |
| Recommendation | `Recommendation` | `schema:Recommendation` | Materialised `[property-recommended]` log line — what the recommender suggested for this session. | `accountId`, `sessionKey`, `listingSlug`, `recommendedAt` |

### Edges

| Edge | Direction | Note |
|---|---|---|
| `VISITED` | `Person` → `Session` or `AnonVisitor` → `Session` | One edge per session. Re-attribution on merge replaces the AnonVisitor edge with a Person edge. |
| `OWNS_VISITOR` | `Person` → `AnonVisitor` | Created on first signed-token click. Records the merge history. |
| `HAS_EVENT` | `Session` → `PageView` / `Click` / `ScrollMilestone` | Every event hangs off its Session. |
| `OF_PAGE` | `PageView` → `Page` | Click/Scroll inherit their Page through their PageView. |
| `OF_LISTING` | `PageView` → `Listing` | Optional: present when the URL was a known listing slug. |
| `FOR_SESSION` | `Recommendation` → `Session` | Joins recommendations to the session their CTR is computed against. |

### Cascade rules

- `contact-erase` on a `:Person` trashes every `(:Session)<-[:VISITED]-(:Person)` and every `(:Session)-[:HAS_EVENT]->(*)` reachable from it. Uses the same `trashNode` pattern as `profile-delete`.
- `:Page` and `:Listing` are content metadata — they do not cascade.
- `:Recommendation` cascades only when its `:Session` cascades; orphan `:Recommendation`s without a session never exist (the writer always pairs them).

### Retention

Erasure-on-request only. Visit data persists until the linked `:Person` is erased via `contact-erase`, at which point the cascade in the previous section runs. There is no time-based retention cron and the privacy page does not promise one. The `brand.json:visitorGraphRetentionDays` field is plumbed but has no enforcement reader.

## Order Node — Disambiguation

The base `Order` label is used for general jobs and orders. Several vertical schemas define specialised subtypes that also map to `schema:Order` but use different Neo4j labels:

- **Trades:** `Quote` (quote/estimate before acceptance)
- **Professional Services:** `Retainer` (recurring service agreement)
- **Food & Beverage:** `Order` with F&B-specific properties (table, servedBy)
- **Retail:** `PurchaseOrder` (supplier-facing order)

When a vertical is loaded, use its specific label for domain-specific orders. Use the base `Order` label only for general-purpose jobs or when no vertical-specific type applies.
