# 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".

---

## Additional Node Types

| Entity | Neo4j Label | Schema.org Type | Required Properties |
|--------|-------------|-----------------|---------------------|
| Listing | `Listing` | `schema:RealEstateListing` | `accountId`, `slug`, `displayName`, `listingType` (sale/let), `status`, `sourceSystem`, `sourceId`, `scope`, `heroImageUrl`*, `pageUrl`*, `blurb` |
| ImageObject | `ImageObject` | `schema:ImageObject` | `accountId`, `listingSlug`, `url`, `description`, `tags`, `scope` |
| Property | `Property` | `schema:Accommodation` | `accountId`, `address` (id), `propertyType`, `tenure`, `epcRating`, `features` |
| Viewing | `Viewing` | `schema:Event` (subtype) | `accountId`, `listing` (id), `applicant` (id), `date`, `feedback`, `outcome` |
| Applicant | `Applicant` | `schema:Person` (extended) | Base Person properties + `budget`, `tenure`, `moveDate`, `requirements` |
| Offer | `Offer` | `schema:Offer` | `accountId`, `listing` (id), `applicant` (id), `amount`, `conditions`, `status`, `date` |

\* `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.

### 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

**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 two listings share a `:Property` parent via separate `[:FOR_PROPERTY]` edges. 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

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

`(:Listing)-[:LISTED_BY]->(:Organization {brandSlug})` is the mandatory parent edge that every Listing 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`. `[:FOR_PROPERTY]` is composed additionally when the underlying physical `:Property` node is known (full structural data captured); it is optional because Listings frequently land before Property structural data has been collected.

`(: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.
