# Schema Reference — Estate Agent

Vertical schema for `businessType: "estate-agent"` — estate agents, letting agents, property managers.

This reference extends the base schema. All base node types remain valid. Load `schema-base.md` first.

When loading this reference, confirm: "Using schema-base + schema-estate-agent".

---

## Top-level node types (operator-entry, natural key)

| Entity | Neo4j Label | Owner | Schema.org Type | Required Properties |
|--------|-------------|-------|-----------------|---------------------|
| Property | `Property` |  | `schema:Accommodation` | `accountId`, `slug` (id, address-derived), `address` |
| Listing | `Listing` | `Property` | `schema:RealEstateListing` | `accountId`, `slug`, `displayName`, `listingType` (sale/let), `status`, `sourceSystem`, `sourceId`, `scope`, `heroImageUrl`*, `pageUrl`*, `blurb` |
| Viewing | `Viewing` | `Listing` | `schema:Event` (subtype) | `accountId`, `listing` (id), `applicant` (id), `date`, `feedback`, `outcome` |
| Offer | `Offer` | `Listing` | `schema:Offer` | `accountId`, `listing` (id), `applicant` (id), `price`, `conditions`, `status`, `date` |

`Owner` names the parent entity a label's records are contained by in the graph
ontology. A blank `Owner` is a root entity with its own top-level filesystem
bucket; a named `Owner` (here `Listing`) is contained by that parent and gets no
root bucket in the account SCHEMA.md projection.

## Child node types (reached via parent neighbourhood)

| Entity | Neo4j Label | Schema.org Type | Required Properties |
|--------|-------------|-----------------|---------------------|
| ImageObject | `ImageObject` | `schema:ImageObject` | `accountId`, `listingSlug`, `url`, `description`, `tags`, `scope` |
| Applicant | `Applicant` | `schema:Person` (extended) | Base Person properties + `budget`, `tenure`, `moveDate`, `requirements` |

\* `heroImageUrl` and `pageUrl` are required for any Listing whose `status` is `for-sale`, `under-offer`, or `sold` (anything publicly listable). Listings with `status: "off-market"` or `status: "withdrawn"` are exempt — they describe stock not currently visible to visitors and have no public listing page to point at. The curator writes `status: "off-market"` and skips the two URLs when the source has no public marketing.

### Property — obligatory hub

`:Property` is the obligatory top-level node of the estate-agent vertical: the physical property, with its listing events hanging beneath it. Every curated listing produces a `:Property` — there is no `:Listing` without a parent `:Property`. The physical property is the operator-facing object; a listing is a marketing event on it (a property can be listed for sale, withdrawn, then re-listed to let — three `:Listing` nodes, one `:Property`).

`:Property` is keyed `(accountId, slug)`, where `slug` is the address-normalised slug (lowercase, ASCII, dash-separated; the same derivation the curator applies to a listing's address). One canonical address resolves to one `:Property` per account. (A UPRN is the stronger identity when one is resolvable; the curator carries no UPRN-resolution tool today, so the address slug is the operative key and UPRN is a future enrichment.)

`:Property` carries the mandatory parent edge `(:Property)-[:LISTED_BY]->(:Organization {brandSlug})` naming the estate agency — this is the ≥1-adjacency write-gate parent that `:Listing` carried in the pre-inversion model. Its children attach via `(:Property)-[:HAS_LISTING]->(:Listing)`.

`:Property` is created address-only. Structural fields — `propertyType`, `tenure`, `epcRating`, `features` — are optional enrichment a later curation top-up fills in; their absence never blocks the hub from being written (a listing routinely lands before structural data is collected). Only `accountId`, `slug`, and `address` are required on write.

### Listing — full property set

The required-properties column above is the minimum a Listing must carry. The complete property set the `listing-curator` specialist writes:

**Visitor-renderable (the public agent reads these to assemble property cards):**
- `slug` — URL-safe identifier derived from address (`griffin-house-upper-church-street-chepstow`)
- `displayName` — human-readable title (`Griffin House, Upper Church Street, Chepstow NP16 5EX`)
- `heroImageUrl` — primary image URL that will render inline in the chat
- `blurb` — single marketing-grade sentence, ≤ 280 characters, suitable as alt-text and short caption
- `pageUrl` — the live listing URL the visitor follows to view full details

**Marketing entity:**
- `listingType` — `sale` or `let`
- `status` — `for-sale` / `under-offer` / `sold` / `withdrawn` / `off-market`
- `price` — integer in pence (no currency symbol, no commas)
- `priceQualifier` — `guide` / `offers-over` / `offers-in-excess` / `poa` / `fixed-price` / `auction` (null when not stated)
- `bedrooms`, `bathrooms`, `receptionRooms` — integers (null when unknown)
- `floorAreaSqft` — integer square feet (null when unknown)

**Address (carried directly on the Listing rather than via a separate PostalAddress when only the formatted address is known — addresses upgrade to a linked `:PostalAddress` once structured):**
- `postcode` — UK postcode, upper-case, single-space-separated outward/inward (`NP16 5EX`)
- `addressLine` — first line of the address
- `town`

**Structured detail (optional):**
- `propertyType` — `cottage` / `detached` / `semi-detached` / `terraced` / `flat` / `bungalow` / etc.
- `tenure` — `freehold` / `leasehold` / `share-of-freehold` / `commonhold`
- `description` — the full descriptive copy from the source (the `body` of any legacy KnowledgeDocument; the description block of the source listing)
- `imageUrls` — string array of all image URLs (`heroImageUrl` is always also present in this array, at index 0)
- `floorplanUrls` — string array
- `epcUrl` — the EPC document URL

**Published served-tree references (harvested by the curator after a site is published — see Filesystem ↔ graph below):**
- `brochureUrl` — the hosted HTML brochure (`https://<publicHost>/sites/<slug>/brochure.html`) when a brochure was published with the site; null when the served tree carries no `brochure.html`. Unconstrained string — no `schema.cypher` constraint or index.
- `socialTileUrls` — string array of the published Open Graph / social-share tile URLs (`https://<publicHost>/sites/<slug>/socials/og-*.jpg`); empty array when the site was published without a `socials/` subtree. Unconstrained string array — no `schema.cypher` constraint or index.

**Provenance + search:**
- `sourceSystem` — `loop-crm` / `zip-upload` / `manual`
- `sourceId` — natural key in the source system (for Loop, the `loopPropertyId`)
- `ingestedAt`, `lastVerifiedAt` — ISO timestamps
- `scope` — `public` for listings that should be returned to anonymous visitors via the public memory-search; `shared` for staff-only listings (e.g. pre-marketing pipeline)
- `embedding` — 768-dim vector (written automatically by `memory-write`)

### ImageObject — per-photo node

Every visitor-renderable image on a Listing is its own `:ImageObject` node, linked by `(:Listing)-[:DEPICTS]->(:ImageObject)`. Modelling images as graph entities lets the public agent pick contextually ("show me the kitchen") rather than always emitting the hero — memory-search returns the Listing plus its child ImageObjects in the `related` set, and the recommender chooses by reading each image's `description` and `tags`.

Properties:

- `accountId` — tenancy, mirrored from the parent Listing
- `listingSlug` — the parent Listing's slug; carried directly so memory-search results on ImageObject alone can resolve back without a graph join
- `url` — the live image URL (b-cdn.net for Loop, hosted-site path for zip-upload). Visitor-renderable as `![desc](url)` per the public agent IDENTITY image-allowlist clause
- `description` — single sentence describing what the image shows. Vision-LLM-generated (Haiku) at curation time. ≤ 200 chars, plain English, no estate-agent clichés
- `tags` — string array of normalised room/feature labels: `kitchen`, `bathroom`, `bedroom-master`, `bedroom-secondary`, `living-room`, `dining-room`, `garden-rear`, `garden-front`, `exterior-front`, `exterior-rear`, `hallway`, `utility`, `floorplan`, `epc`, `view`. Empty array when the image is unclassifiable. Tags drive room-specific picks; `description` drives general semantic search
- `heroIndex` — integer position in the parent Listing's `imageUrls` array. `0` = the hero image. Used to fall back to hero when room-specific tags don't match
- `scope` — mirrored from parent Listing (`public` for visitor-renderable; `shared` for staff-only)
- `sourceSystem` — mirrored from parent Listing

Natural key: **`(accountId, listingSlug, url)`**. The curator MERGEs on this triple. Re-running the curator over a Listing tops up missing ImageObjects without duplicating existing ones; description and tags refresh on each run (so a later vision-LLM upgrade improves quality without manual cleanup).

No `embedding` on v1. The agent picks by reading the parent Listing's child ImageObjects from the `related` set returned by hybrid search; vector search over images is a follow-up only if pick quality is poor.

Known limit: hybrid search's 1-hop expand caps `related` at 20 neighbours per Listing. Listings with more than 20 images lose the surplus to the cap. Acceptable for v1 (most listings ≤ 20); file a follow-up if it bites.

### Listing — natural-key contract

Idempotency key: **`(accountId, sourceSystem, sourceId)`**. The curator MERGEs on this triple. Re-running the curator over the same source record updates the existing Listing in place — fields refresh (status drift over the listing's lifecycle is expected and desired: `for-sale` → `under-offer` → `sold`); no duplicate Listing is ever created.

If the source emits multiple distinct listing events tied to a single underlying property (e.g. relisted after withdrawal, then again let separately), each listing event carries its own `sourceId` in the source system and becomes its own `:Listing` node. The listings share one `:Property` parent, which holds a separate `(:Property)-[:HAS_LISTING]->(:Listing)` edge to each. Property idempotency on `(accountId, slug)` is additive to Listing idempotency: re-running the curator over the same source record creates no duplicate `:Property` and no duplicate `:Listing`. The curator never collapses distinct listing events into one node.

### Applicant vs base Person

`Applicant` extends the base `Person` type with property-search-specific properties. Use `Applicant` as an additional label on the `Person` node — the applicant is still a `Person` for base queries, but the extra label carries estate-agent-specific fields like `budget` and `requirements`.

---

## Relationship Patterns

```
(:Property)-[:LISTED_BY]->(:Organization {brandSlug})
(:Property)-[:HAS_LISTING]->(:Listing)
(:Listing)-[:DEPICTS]->(:ImageObject)
(:Viewing)-[:FOR_LISTING]->(:Listing)
(:Viewing)-[:BY_APPLICANT]->(:Person)
(:Offer)-[:ON_LISTING]->(:Listing)
(:Offer)-[:FROM_APPLICANT]->(:Person)
```

`(:Property)-[:LISTED_BY]->(:Organization {brandSlug})` is the mandatory parent edge that every `:Property` carries — it satisfies the ≥1-adjacency write-gate doctrine ([[feedback_graph_hierarchy_doctrine]]) and names the estate agency that listed the property. The target is `:Organization` keyed by `brandSlug`, not `:LocalBusiness`. `:LocalBusiness` is the per-account installer-seeded singleton (the account's own business — Real Agent itself); the write gate refuses any non-system attempt to create a second one. The agencies whose listings ingest through the curator are third-party businesses and live as `:Organization`.

`(:Property)-[:HAS_LISTING]->(:Listing)` is the parent edge of every `:Listing` — the `:Listing` satisfies the ≥1-adjacency gate via this incoming edge from its `:Property` (the gate is direction-agnostic; an incoming edge from a parent counts). The pre-inversion `(:Listing)-[:FOR_PROPERTY]->(:Property)` edge is retired: the property is now the obligatory parent above the listing, not optional structural enrichment below it. Because the agency edge lives on `:Property`, the agency `:Organization` is two hops from a `:Listing` (Listing → Property → Organization); hybrid search's one-hop expansion returns the `:Property` for a Listing hit, not the agency.

`(:Listing)-[:DEPICTS]->(:ImageObject)` is written by the curator once per image present on the Listing. ImageObjects satisfy the ≥1-adjacency rule via this edge (Listing is the parent); they do not carry their own `LISTED_BY` edge because the Listing already does. Hybrid search expands one hop by default, so a memory-search returning a Listing also returns its DEPICTS-linked ImageObjects in the result's `related` array — no separate search call needed to assemble a property card with the right image.

---

## Sales progression (sale-agreed → completion)

The ontology above stops at `:Offer` — the pre-sale-agreed funnel (enquiry, viewing, offer, negotiation) is modelled by `:Applicant`/`:Viewing`/`:Offer` and owned by the buyers and sales-cycle skills. **A chain only exists once a sale is agreed**, so the progression ontology begins there. It is the source of truth for the negotiator's `chase-progression` and `progression-inbox` workflows; it retires the old `memory/admin/pipeline/{date}-{slug}.md` markdown pipeline (that free-text store is no longer written).

| Entity | Neo4j Label | Schema.org Type | Natural key | Key properties |
|--------|-------------|-----------------|-------------|----------------|
| Sale | `Sale` | `schema:SellAction` | `(accountId, sourceSystem, sourceId)` | `chainId`, `isCompleteChain`, `salePrice` (pence), `status` (`sale-agreed`/`exchanged`/`completed`/`fallen-through`), `sstcDate` |
| Chain | `Chain` | `schema:ItemList` | `(accountId, chainId)` | `anticipatedExchangeDate`, `desiredCompletionDate`, `createdDate`, `owningTeam`, `owningAgent`, `isComplete` |
| ChainLink | `ChainLink` | `schema:ListItem` | `(accountId, chainId, priority)` | `priority` (int), `linkType` (`loop-property`/`external`), ten milestone dates (below), `address`, `salePrice` (pence), `generalNotes`, `proceedabilityNotes` |

**The ten milestone dates** on each `:ChainLink` (ISO-8601 strings, stored verbatim, nullable until known): `mortgageValuationDate`, `surveyDate`, `draftContractIssuedDate`, `searchesRequestedDate`, `searchesReceivedDate`, `mortgageOfferReceivedDate`, `sellerSignedDate`, `buyerSignedDate`, `exchangedDate`, `completedDate`. When a date is set from an email, the tool also stamps `<field>Source` (the email message id) for audit.

**Parties are first-class `:Person` nodes** (the `Applicant` pattern), one per role per link, keyed `(accountId, chainId, priority, partyRole)` where `partyRole ∈ {seller, seller-solicitor, buyer, buyer-solicitor, agent}`. Each carries `name` plus contact on `partyEmail`/`partyPhone`. Contact uses these party-scoped property names rather than the canonical `email`/`telephone` because `:Person` enforces `(accountId, email)` and `(accountId, telephone)` uniqueness — a party (a solicitor across several chains, or a buyer who is also a registered applicant) frequently shares an email with another `:Person`, which a synthetic-keyed node storing the canonical `email` would violate. `progression-party-lookup` matches `partyEmail`/`partyPhone`. Contact detail is never a property blob on the link, and re-running the upsert refreshes the party node in place rather than duplicating it.

### Relationship patterns (progression)

```
(:Sale)-[:IN_CHAIN]->(:Chain)                      // mandatory adjacency
(:Sale)-[:FOR_LISTING]->(:Listing)                 // best-effort domain parent
(:Sale)-[:FROM_OFFER]->(:Offer)                    // best-effort — the accepted offer the sale came from
(:Chain)-[:OWNED_BY]->(:LocalBusiness {accountId}) // mandatory adjacency (the account's own business)
(:Chain)-[:HAS_LINK]->(:ChainLink)                 // each link
(:ChainLink)-[:IS_PROPERTY]->(:Property)           // when linkType = loop-property
(:ChainLink)-[:HAS_SELLER|:HAS_SELLER_SOLICITOR|:HAS_BUYER|:HAS_BUYER_SOLICITOR|:HAS_AGENT]->(:Person)
```

`(:Sale)-[:IN_CHAIN]->(:Chain)` and `(:Chain)-[:OWNED_BY]->(:LocalBusiness)` are the mandatory ≥1-adjacency edges (a `:Sale` with no `IN_CHAIN` is a structural orphan; a `:Chain` with no `HAS_LINK` is an empty chain — both surfaced by the `seed-neo4j.sh` `[progression-reconcile]` signal). `FOR_LISTING`, `FROM_OFFER`, and `IS_PROPERTY` are best-effort: attached when the target node resolves, never blocking the write. The `owningAgent`/`owningTeam` chain properties are the account's own staff, not chain parties.

### Natural-key contract

The estate-sales `progression-chain-upsert` tool MERGEs each label on its natural key, account-scoped, attaching every parent edge in the same statement. Re-running it over the same chain updates in place — milestone dates and party detail refresh; no duplicate `:Sale`/`:Chain`/`:ChainLink`/party `:Person` is created. `progression-milestone-set` advances one date on one link (`MATCH … SET`). All writes carry `accountId = ACCOUNT_ID`; a cross-account or adjacency-less write is structurally impossible.

---

## Filesystem ↔ graph (base template)

Unlike a construction job folder (operator-authored source files), the estate-agent projection is over a **published served tree** — hosting output the platform writes, not files the operator edits by hand. Each served listing lives at `<accountDir>/sites/<slug>/`, the on-disk target of `publish-site`, and is served at `https://<publicHost>/sites/<slug>/`. The graph references this tree by **hosted URL**, never by local path: a `:Listing`/`:ImageObject` URL property is the canonical reference, and the served file is the thing it points at. This mapping is the **base template** — the standard a brochure-pipeline publish projects into; it is shipped-generic, not any operator's data.

**URL ↔ path rule (deterministic).** Every served artefact resolves both ways by a single substitution: `https://<publicHost>/sites/<slug>/X` ↔ `<accountDir>/sites/<slug>/X`. The `/sites/<slug>/` segment is invariant — it is the publish target's on-disk shape mirrored into the hosted path. To resolve a graph URL to a local file, strip the `https://<publicHost>/sites/<slug>/` prefix and join the remainder onto `<accountDir>/sites/<slug>/`; to project a served file, do the reverse.

**Artefact → graph reference.** Every visitor-renderable or publicly-meaningful artefact class under `sites/<slug>/` resolves to a `:Listing` or `:ImageObject` reference:

| Served artefact | Graph reference |
|---|---|
| `index.html` — the published landing page | `:Listing.pageUrl` |
| `images/<photo>.{jpg,jpeg,png,webp}` (non-logo) | `:Listing.imageUrls[]`, `:Listing.heroImageUrl` (index 0), and one `:ImageObject.url` per photo |
| `images/*floorplan*.{jpg,png}` | `:Listing.floorplanUrls[]` (and its `:ImageObject.url`) |
| `images/epc.{jpg,png,pdf}` or `epc.*` at the root | `:Listing.epcUrl` (and its `:ImageObject.url`) |
| `brochure.html` — the hosted HTML brochure | `:Listing.brochureUrl` |
| `socials/og-*.jpg` — Open Graph / social-share tiles | `:Listing.socialTileUrls[]` |

**Served but intentionally unreferenced (tool / collateral).** These classes are part of the published bundle but carry no graph reference by design — the reconcile audit excludes them exactly as it excludes the `url-get/` cache, and their presence is never an orphan:

- `<slug>-brochure.pdf` — the downloadable form of the brochure; `brochureUrl` references the canonical `brochure.html`, so the PDF is the same artefact class, not a separate node reference.
- `socials.html`, `social-posts.md`, `og.html` — operator-only social collateral and the re-run template; not visitor-renderable, not indexable.
- `images.json` — the curator's brochure sidecar (consumed at curation time, not a renderable artefact).
- `llms.txt`, `llms-full.txt` — AEO surfaces written by `publish-site`.

A served file outside the referenced classes and outside this exclusion set is an orphan — the reconcile audit counts it in `unreachable`. `broken-refs` is scoped to **served-tree URLs only**: a `:Listing`/`:ImageObject` URL that matches the `https://<publicHost>/sites/<slug>/` prefix but resolves to no served file (slug rename, un-republished listing) is counted in `broken-refs`. Externally-hosted URLs are outside the served tree and outside this audit — a Loop listing's `b-cdn.net` `:ImageObject.url` or its Rightmove/Zoopla/agent-site `pageUrl` carries no `sites/<slug>/` prefix, the URL↔path rule cannot resolve it, and it is never counted as broken. Map a served file to its nearest canonical reference; invent no node or edge type absent from this schema.

**Cloudflare Pages-hosted listing pages are external to this `/sites/` audit.** A property landing page published via `cloudflare:site-deploy` is hosted on Cloudflare Pages, not the device tunnel: its source tree lives at `<accountDir>/pages/<slug>/` and it answers on the deterministic production alias `https://<slug>.pages.dev/` (the Pages project name is the listing slug). The curator writes that Pages URL to `:Listing.pageUrl` / `imageUrls` / `brochureUrl` / `socialTileUrls`. These URLs carry no `https://<publicHost>/sites/<slug>/` prefix, so the URL↔path rule above cannot resolve them and the reconcile audit does not police them — the same treatment as the Loop / Rightmove external URLs described above. Bringing the Pages served tree (`<accountDir>/pages/<slug>/` ↔ `https://<slug>.pages.dev/`) into this base template as a first-class audited tree, along with Pages listing takedown/unpublish and custom-domain `pageUrl` reconciliation, is tracked in `maxy-code/.tasks/1420-pages-served-tree-audit-and-listing-takedown.md`.
