# Account file-schema as a real projection of the domain graph ontology — design

Source task: `.tasks/1622-account-file-schema-does-not-mirror-the-domain-graph-ontology.md`.
Prior design (the mechanism this extends): `2026-06-23-account-filesystem-schema-design.md`.

## Problem

The account data directory is specified to be a projection of the graph ontology,
but the allowed top-level set is a hardcoded generic base
(`platform/templates/account-schema/SCHEMA.md`: `projects`, `contacts`,
`documents` plus tool-owned dirs). Domain entities that the businessType ontology
declares top-level are never added. On SiteDesk the ontology
(`platform/plugins/memory/references/schema-construction.md`) makes `:Job` the hub
with `:Customer`/`:Site`/`:Quote`/`:Visit`/`:Asset` as first-class operator-entry
node types, and the sidebar's SiteDesk primary container is `:Job`, yet the
account `SCHEMA.md` lists no `jobs` bucket. So a produced quote is filed inside the
pricing plugin's private `quoting/jobs/<jobId>/` workspace, disconnected from the
`:Job`/`:Quote` graph nodes and invisible to the Jobs view.

A second, internal inconsistency exists inside the sitedesk-job plugin: the
`valuation` skill's `references/job-folder.md` already documents the job folder as
the top-level `$ACCOUNT_DIR/jobs/{jobId}/`, a path the file-schema does not permit,
so valuation's own filing is admissible only by accident. Making `jobs/` a real
derived bucket fixes both the quotation gap and this latent valuation gap.

## The single source (resolves the task's "Depends on")

`platform/plugins/memory/mcp/src/lib/schema-loader.ts` already machine-parses the
schema markdown entity tables (each recognised by a `Neo4j Label` +
`Required Properties` header pair). `brand.json#vertical` already names which
`schema-<vertical>.md` applies (SiteDesk = `schema-construction`). Therefore
`schema-<vertical>.md` is the single authoritative ontology, and the file-schema
generator becomes a second reader of the same file. The graph loader itself does
not change: it already reads these files. "The ontology" is one list, read by both
the loader (for validation) and the generator (for top-level bucket derivation).

`graph-labels.ts` `FILTER_TOP_LEVEL_LABELS` (the `/graph` UI chip allowlist) stays
a separate concern this sprint. Single-sourcing it from the same per-vertical
source is filed as a follow-up, not built here.

## What ships

### A. File-schema derives domain buckets from the vertical ontology

**Convention for "top-level".** A label is a derived operator-entity bucket iff it
appears as a row in the entity table under the heading
`## Top-level node types (operator-entry, natural key)` in `schema-<vertical>.md`
**and** its `Schema.org Type` column is a `schema:`-namespaced type. The
`schema:`-namespace test deterministically excludes transport labels
(`WhatsAppGroup` carries `cdm:Channel`) with no hardcoded skip-list. A vertical
whose schema file carries no such heading contributes no domain buckets (generic
base only) and is surfaced by the reconcile check (below).

**Label to directory name.** kebab-case-plural of the label: camelCase word
boundaries split on case, joined with `-`, lowercased, with a trailing `s`
appended (or `y` becomes `ies`). Examples: `Job` to `jobs`, `Customer` to
`customers`, `Quote` to `quotes`, `Asset` to `assets`, `Visit` to `visits`,
`Part` to `parts`, `PurchaseOrder` to `purchase-orders`, `PpmContract` to
`ppm-contracts`, `InboundInvoice` to `inbound-invoices`. One collision
special-case: `Site` maps to `customer-sites`, so the tool-owned `sites/`
published-tree bucket is untouched. The special-case is a single explicit entry in
a `LABEL_DIR_OVERRIDES` map keyed by label; every other label uses the mechanical
rule.

**Generator.** `platform/scripts/lib/account-schema-owned-dirs.py` gains a
`resolve_domain_buckets(project_dir)` beside the existing `resolve()` (plugin
owned-dirs). It reads `brand.json#vertical`, opens
`<project_dir>/plugins/memory/references/<vertical>.md`, extracts the top-level
labels per the convention above, maps each to a dir, and returns
`[{dir, label}, ...]` in table order. `merge()` unions these dirs into the
`allowed-top-level` fenced block (same insertion path as owned-dirs) and writes a
documented region between `<!-- ontology-buckets:start -->` and
`<!-- ontology-buckets:end -->` markers, listing each bucket and its source label.
The region is regenerated wholesale on every merge (idempotent), exactly like the
existing plugin-owned region.

**Provision.** No new call site. `merge_owned_dirs_into_schema` (in
`account-schema-owned-dirs.sh`, already called by `provision-account-dir.sh` and
`setup-account.sh`) now emits domain buckets as well as owned-dirs. Empty domain
directories are **not** pre-created on provision. A fresh account has no jobs yet;
the directory is created on first write, the same as any tool-owned dir. Only the
allowed set and the `SCHEMA.md` region change at provision time.

**Guard.** `platform/plugins/admin/hooks/fs-schema-guard.sh` needs no logic change.
It already reads the account's `SCHEMA.md` allowed block and admits any first
segment in that set at any depth except the three flat operator-data buckets
(`projects`, `contacts`, `documents`). A domain bucket such as `jobs/` is therefore
admitted at any depth, which is correct for the `jobs/<jobId>/Quotations/...`
subtree the ontology's own filesystem projection describes. The derived buckets
reach the guard through the regenerated allowed block.

### B. Quotation reifies the Job/Quote/QuoteLine chain and files under the Job

With `jobs/` a real bucket, the quotation apply-path is brought to parity with
`valuation`'s job-setup (which already creates/updates `:Job` and `:Quote` and
seeds `:QuoteLine`s on the quote). After the engine prices the job and
`quote-render`'s fail-closed gates pass, the apply-path:

1. **Reifies** `:Job-[:HAS_QUOTE]->:Quote-[:HAS_LINE]->:QuoteLine`, idempotent on
   the natural keys the construction ontology defines (`:Job` on
   `(accountId, jobId)`, `:Quote` on `(accountId, ref)`, `:QuoteLine` on
   `(accountId, jobId, trade, description)`), via the same graph-write path
   `valuation` uses. The exact write surface (memory tools vs the
   `database-operator` specialist) is pinned in the plan by reading how
   `valuation`'s job-setup writes today, so the two paths stay identical.
2. **Files** the produced quote documents under the Job bucket
   (`jobs/<jobId>/Quotations/...`, the operator-facing location valuation's
   `job-folder.md` already documents), not orphaned in `quoting/jobs/<jobId>/`.
   The `quoting/` engine workspace (`engine.mjs`, `method.json`, `render.mjs`, and
   per-job figures) stays tool-owned. What changes is `quote-render`'s
   filing-scope for the operator-facing result and the graph binding.
3. **Emits** `[quote-render] op=quote-reify jobId=<id> quoteRef=<ref> lines=<n>` on
   the graph write, a post-condition breadcrumb. A quote that renders documents but
   writes no `:Quote` node is then immediately visible in the log.

The schema-free pricing-method capture (item catalogue, rates, roll-up) is
untouched. Only the output's placement and its entity binding are in scope.

### C. estate-agent schema reorganisation (proves derivation is not hardcoded)

`schema-estate-agent.md` currently holds one mixed `## Additional Node Types` table
(hub `Property` plus its children `Listing`, `ImageObject`, `Viewing`, `Applicant`,
`Offer`). It is reorganised into a `## Top-level node types (operator-entry,
natural key)` table and a `## Child node types` table, with no row duplication, so
`schema-loader` sees the same union of labels it sees today (it already parses
construction's two-table shape). Top-level for estate-agent: `Property` (the
obligatory hub) plus the operator-entry entities the vertical's prose names
(`Listing`, `Viewing`, `Offer`). `ImageObject` is a child of `Listing` (reached
through its parent, as construction places `QuoteLine`); `Applicant` is a Person
extension (base `:Person` plus applicant properties, the same additional-label
pattern construction uses for `Engineer`/`Supplier`), so neither is a top-level
bucket. The exact top-level/child split for estate-agent is finalised during
implementation by reading the whole file, but the two `schema:`-namespaced sets
above are the intent. The result: a construction account derives `jobs`/`customers`/...
and an estate-agent account derives `properties`/`listings`/`viewings`/`offers`
from the same generator, proving the set is derived, not hardcoded.

## Testing

- `platform/scripts/__tests__/account-schema-owned-dirs.test.sh`: for a
  construction brand, the merged allowed set contains `jobs`, `customers`,
  `customer-sites`, `assets`, `visits`, `quotes` (and the rest), and does **not**
  collide with the tool-owned `sites`. For an estate-agent brand it contains
  `properties`, `listings`, `viewings`, `offers` and none of SiteDesk's buckets. A
  vertical with no top-level heading yields no domain buckets (generic base
  intact). `reconcile` reports every top-level label and whether its bucket is
  present.
- `platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh`: a write under
  `jobs/<jobId>/Quotations/q.pdf` is admitted (domain bucket, any depth); an
  undeclared top-level segment is still blocked; the three flat buckets keep their
  over-deep block.
- Cardinal repro (device-side, per the Pi-signal verification step): producing a
  quote files the documents under the Job and the graph shows
  `:Job-[:HAS_QUOTE]->:Quote-[:HAS_LINE]->:QuoteLine`; the quote appears in the
  sidebar Jobs view. What is unit-testable locally is the derivation, the guard
  admission, and the presence of the reify call and its breadcrumb.

## Observability

- `account-schema-owned-dirs.py reconcile` is extended into a standing check that
  reports, per account, every top-level ontology entity **missing** a file-schema
  bucket, so a re-divergence surfaces without waiting for a mis-filed artifact.
- `[quote-render] op=quote-reify jobId= quoteRef= lines=` on quote production
  confirms the graph write occurred.

## Out of scope (each a filed follow-up, no prose-only deferral)

- Normalising the remaining vertical schema files (`schema-retail`,
  `schema-logistics`, `schema-hospitality`, `schema-food-beverage`,
  `schema-professional-services`, `schema-trades`, `schema-creator`,
  `schema-knowledge-work`) to carry the top-level heading. Until done they fall
  back to the generic base and the reconcile check flags them. New task.
- Single-sourcing `graph-labels.ts` `FILTER_TOP_LEVEL_LABELS` from the same
  per-vertical ontology source. New task.
- Backfilling existing `quoting/jobs/<jobId>/` figures folders for
  already-produced quotes (the source task already defers this).
- Any change to the graph schema, the `schema-loader` validation path, or the
  `fs-schema-guard` decision logic beyond consuming the derived set.
