# Account file-schema ontology projection — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make the account file-schema's allowed top-level operator-entity buckets derived from the brand's businessType ontology (the `schema-<vertical>.md` top-level table), and bring quotation to parity with valuation by reifying `:Job->:Quote->:QuoteLine` and recording the quote under a real `jobs/` bucket.

**Architecture:** The generator `account-schema-owned-dirs.py` gains a second resolver that reads `brand.json#vertical`, parses the top-level entity table from `plugins/memory/references/<vertical>.md` (the same file `schema-loader.ts` already parses), maps each `schema:`-namespaced label to a kebab-case-plural dir, and unions those dirs into the account `SCHEMA.md` allowed block plus a documented region — exactly the mechanism the existing plugin-owned-dir merge uses. The write-path guard needs no change (a domain bucket is outside the three flat operator-data buckets, so it already passes at any depth). Quotation reification is a skill-prose change, no MCP tool rebuild.

**Tech Stack:** Python 3 (stdlib only), Bash, Markdown. No Node build in this sprint.

## Global Constraints

- Precision; no speculation; implement only what is agreed. If a rule has N cases, implement N, not N+1.
- No em-dashes in shipped markdown (schema files, SCHEMA.md, skill prose). No task numbers or internal refs in any operator-visible string or log line.
- The shipped template `platform/templates/account-schema/SCHEMA.md` is NOT modified: domain buckets are added per-account by the generator, like plugin-owned dirs. This keeps the guard's template-parity assertion valid.
- Generator reads the vertical schema file only (`schema-<vertical>.md`), never the base file, for domain-bucket derivation. Base entities (Person/Organization) stay covered by the generic `contacts` bucket.
- `schema:`-namespace test is the sole top-level inclusion filter (excludes transport labels like `WhatsAppGroup` -> `cdm:Channel`). No hardcoded label skip-list.
- Every commit carries a `Session:` trailer and `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
- All paths below are relative to the `maxy-code/` subtree inside the worktree.

---

### Task 1: Label-to-dir mapping and top-level table parse

**Files:**
- Modify: `platform/scripts/lib/account-schema-owned-dirs.py`

**Interfaces:**
- Produces: `_label_to_dir(label: str) -> str`; `_read_vertical(project_dir: str) -> str | None`; `resolve_domain_buckets(project_dir: str) -> list[dict]` where each dict is `{"dir": str, "label": str}`, table order, deduped by dir.

- [ ] **Step 1: Add the mapping helpers** (insert after the module constants, before `_read_brand`)

```python
LABEL_DIR_OVERRIDES = {"Site": "customer-sites"}


def _label_to_dir(label):
    """Map a Neo4j label to its file-schema bucket dir: kebab-case-plural,
    lowercased. camelCase word boundaries split on case. One override:
    `Site` -> `customer-sites`, so the tool-owned `sites/` published tree is
    untouched."""
    if label in LABEL_DIR_OVERRIDES:
        return LABEL_DIR_OVERRIDES[label]
    kebab = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", label).lower()
    if kebab.endswith("y") and len(kebab) > 1 and kebab[-2] not in "aeiou":
        return kebab[:-1] + "ies"
    return kebab + "s"
```

- [ ] **Step 2: Add the vertical reader** (insert after `_read_brand`)

```python
def _read_vertical(project_dir):
    """Return the brand's default vertical (the schema file basename, e.g.
    'schema-construction'), or None. Read from config/brand.json#vertical."""
    path = os.path.join(project_dir, "config", "brand.json")
    if not os.path.isfile(path):
        return None
    try:
        with open(path) as f:
            v = json.load(f).get("vertical")
        v = (v or "").strip()
        return v or None
    except Exception:
        return None
```

- [ ] **Step 3: Add the top-level table parser and domain-bucket resolver** (insert after `resolve`)

```python
def _top_level_labels(schema_path):
    """Extract operator-entry labels from the entity table under the
    '## Top-level node types' heading of a vertical schema file. A row is
    included iff its 'Schema.org Type' cell is a schema:-namespaced type
    (excludes transport labels such as WhatsAppGroup -> cdm:Channel). Returns
    labels in table order. No such heading -> empty list."""
    try:
        with open(schema_path) as f:
            lines = f.read().split("\n")
    except Exception:
        return []
    start = None
    for i, ln in enumerate(lines):
        if ln.startswith("## ") and "top-level node types" in ln.lower():
            start = i
            break
    if start is None:
        return []
    # Find the table header (a pipe row naming Neo4j Label + Schema.org Type),
    # stopping at the next '## ' heading.
    hdr = None
    for j in range(start + 1, len(lines)):
        if lines[j].startswith("## "):
            return []
        if "|" in lines[j] and "Neo4j Label" in lines[j] and "Schema.org Type" in lines[j]:
            hdr = j
            break
    if hdr is None:
        return []
    cols = [c.strip() for c in lines[hdr].strip().strip("|").split("|")]
    label_i = cols.index("Neo4j Label")
    type_i = cols.index("Schema.org Type")
    out = []
    for k in range(hdr + 2, len(lines)):  # +2 skips the header separator row
        row = lines[k]
        if row.startswith("## "):
            break
        if "|" not in row or not row.strip():
            break
        cells = [c.strip() for c in row.strip().strip("|").split("|")]
        if len(cells) <= max(label_i, type_i):
            continue
        label = cells[label_i].strip("`").strip()
        stype = cells[type_i].strip("`").strip()
        if label and stype.startswith("schema:"):
            out.append(label)
    return out


def resolve_domain_buckets(project_dir):
    """Domain operator-entity buckets derived from the brand's vertical
    ontology. Returns [{dir, label}] in table order, deduped by dir. Empty when
    the brand declares no vertical or the vertical file has no top-level
    section."""
    vertical = _read_vertical(project_dir)
    if not vertical:
        return []
    schema_path = os.path.join(project_dir, "plugins", "memory", "references", vertical + ".md")
    seen = set()
    out = []
    for label in _top_level_labels(schema_path):
        d = _label_to_dir(label)
        if d in seen:
            continue
        seen.add(d)
        out.append({"dir": d, "label": label})
    return out
```

- [ ] **Step 4: Smoke-check the mapping in isolation**

Run:
```bash
cd platform/scripts/lib
python3 -c "import account_schema_owned_dirs as m" 2>/dev/null || \
python3 - <<'PY'
import importlib.util, os
spec = importlib.util.spec_from_file_location("g", "account-schema-owned-dirs.py")
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
assert m._label_to_dir("Job") == "jobs", m._label_to_dir("Job")
assert m._label_to_dir("PurchaseOrder") == "purchase-orders", m._label_to_dir("PurchaseOrder")
assert m._label_to_dir("PpmContract") == "ppm-contracts"
assert m._label_to_dir("InboundInvoice") == "inbound-invoices"
assert m._label_to_dir("Site") == "customer-sites"
assert m._label_to_dir("Property") == "properties"
print("mapping OK")
PY
```
Expected: `mapping OK`

- [ ] **Step 5: Commit**

```bash
git add platform/scripts/lib/account-schema-owned-dirs.py
git commit -m "feat: label->bucket mapping + vertical top-level parse (1622)"
```

---

### Task 2: estate-agent schema reorganised into top-level + child tables

**Files:**
- Modify: `platform/plugins/memory/references/schema-estate-agent.md`

**Rationale:** The generator identifies top-level entities by the `## Top-level node types (operator-entry, natural key)` heading. construction already has it; estate-agent carries one mixed `## Additional Node Types` table. Split it so the generator derives estate-agent buckets, proving derivation is not hardcoded. `schema-loader.ts` parses ALL entity tables and unions their labels, so preserving every row across two tables leaves validation unchanged.

- [ ] **Step 1: Read the whole file and confirm the label roster**

Run: `sed -n '1,60p' platform/plugins/memory/references/schema-estate-agent.md`
Confirm the entity table under `## Additional Node Types` and the sales-progression table lower down. Record every label so none is dropped.

- [ ] **Step 2: Reorganise the entity table**

Replace the single `## Additional Node Types` heading with two headed tables, moving each existing row verbatim (same columns, same cell contents) into the correct one:

- `## Top-level node types (operator-entry, natural key)` — `Property`, `Listing`, `Viewing`, `Offer` (the operator-entry entities the vertical's prose names; `Property` is the obligatory hub).
- `## Child node types (reached via parent neighbourhood)` — `ImageObject` (child of `Listing`), `Applicant` (a `:Person` extension, same pattern construction uses for `Engineer`/`Supplier`).

Do not alter cell contents, the sales-progression table, the relationship-patterns block, or any prose. Only the two headings and the row grouping change. No em-dashes introduced.

- [ ] **Step 3: Verify the schema-loader still parses estate-agent cleanly**

Check whether any loader test loads estate-agent:
```bash
grep -rn "estate-agent" platform/plugins/memory/mcp/src/lib/__tests__/ || echo "no direct estate-agent loader test"
```
If a test references it and node_modules is present, run:
```bash
cd platform/plugins/memory/mcp && npx vitest run src/lib/__tests__/schema-loader 2>&1 | tail -20
```
Expected: PASS (same label union as before). If node_modules is absent, the parse-compatibility is guaranteed structurally (two entity tables, same rows) and asserted by construction already parsing two tables; note this in the commit body.

- [ ] **Step 4: Commit**

```bash
git add platform/plugins/memory/references/schema-estate-agent.md
git commit -m "refactor: split estate-agent schema into top-level + child tables (1622)"
```

---

### Task 3: Generator merges domain buckets and reconcile reports missing ones

**Files:**
- Modify: `platform/scripts/lib/account-schema-owned-dirs.py`
- Test: `platform/scripts/__tests__/account-schema-owned-dirs.test.sh`

**Interfaces:**
- Consumes: `resolve_domain_buckets` (Task 1).
- Produces: `merge()` inserts domain-bucket dirs into the `allowed-top-level` block and writes an `<!-- ontology-buckets:start -->`..`<!-- ontology-buckets:end -->` region; its stdout gains ` domainBuckets=<comma dirs newly added|none>`. `reconcile()` gains lines `brand=<b> ontologyEntity=<Label> bucket=<dir> present=<true|false>`, and one `brand=<b> ontologyVertical=<v> topLevelSection=absent` line when a vertical is declared but its file has no top-level section.

- [ ] **Step 1: Add the region markers constant** (beside `MARK_START`/`MARK_END`)

```python
ONT_START = "<!-- ontology-buckets:start -->"
ONT_END = "<!-- ontology-buckets:end -->"
```

- [ ] **Step 2: Generalise `_strip_region` to strip either region**

Replace the body of `_strip_region(text)` so it strips both the plugin-owned region and the ontology region:

```python
def _strip_region(text, start=MARK_START, end=MARK_END):
    if start not in text:
        return text
    pre = text.split(start, 1)[0]
    post = ""
    if end in text:
        post = text.split(end, 1)[1]
    return (pre.rstrip("\n") + "\n" + post.lstrip("\n")).rstrip("\n") + "\n"
```

- [ ] **Step 3: Extend `merge()` to emit domain buckets**

In `merge()`, after the existing owned-dir insertion into `entries`/`lines` and BEFORE the `_strip_region` call, add domain-bucket insertion; then regenerate both regions. Full replacement of `merge()`:

```python
def merge(project_dir, account_dir):
    brand, _ = _read_brand(project_dir)
    owned = resolve(project_dir)
    domain = resolve_domain_buckets(project_dir)
    schema_path = os.path.join(account_dir, "SCHEMA.md")
    if not os.path.isfile(schema_path):
        print(f"brand={brand} added=none domainBuckets=none")
        return
    with open(schema_path) as f:
        text = f.read()
    entries, start, end = _allowed_block(text)
    added = []
    added_domain = []
    if entries is not None:
        present = set(entries)
        lines = text.split("\n")
        insert = []
        for e in owned:
            if e["dir"] not in present and e["dir"] not in insert:
                insert.append(e["dir"]); added.append(e["dir"])
        for b in domain:
            if b["dir"] not in present and b["dir"] not in insert:
                insert.append(b["dir"]); added_domain.append(b["dir"])
        if insert:
            lines[end:end] = insert
            text = "\n".join(lines)
    # Regenerate both descriptive regions wholesale (idempotent).
    text = _strip_region(text, MARK_START, MARK_END)
    text = _strip_region(text, ONT_START, ONT_END)
    if owned:
        region = [MARK_START, "## Plugin-owned top-level workspaces", "",
                  "A plugin that owns a top-level workspace declares it. The internal",
                  "structure is owned by that plugin's tools and recreated on the next",
                  "write. Never reorganise it.", ""]
        for e in owned:
            desc = e["description"] or f"Owned by the {e['plugin']} plugin."
            region.append(f"- `{e['dir']}/` - {desc}")
        region.append(MARK_END)
        text = text.rstrip("\n") + "\n\n" + "\n".join(region) + "\n"
    if domain:
        region = [ONT_START, "## Domain entity buckets (from the graph ontology)", "",
                  "One bucket per top-level operator-entry node type of this brand's",
                  "business ontology. File a job's artifacts under its entity folder;",
                  "the folder may hold a subtree.", ""]
        for b in domain:
            region.append(f"- `{b['dir']}/` - one folder per {b['label']} record.")
        region.append(ONT_END)
        text = text.rstrip("\n") + "\n\n" + "\n".join(region) + "\n"
    with open(schema_path, "w") as f:
        f.write(text)
    print(f"brand={brand} added={','.join(added) if added else 'none'} "
          f"domainBuckets={','.join(added_domain) if added_domain else 'none'}")
```

- [ ] **Step 4: Extend `reconcile()` to report domain buckets**

Append to `reconcile()` after the owned-dir loop:

```python
    vertical = _read_vertical(project_dir)
    domain = resolve_domain_buckets(project_dir)
    if vertical and not domain:
        print(f"brand={brand} ontologyVertical={vertical} topLevelSection=absent")
    for b in domain:
        is_present = "true" if b["dir"] in present else "false"
        print(f"brand={brand} ontologyEntity={b['label']} bucket={b['dir']} present={is_present}")
```

(`present` is already computed at the top of `reconcile()` from the account's allowed block.)

- [ ] **Step 5: Extend the test — construction derivation, estate-agent derivation, no-vertical fallback, reconcile**

Add to `platform/scripts/__tests__/account-schema-owned-dirs.test.sh` a vertical-aware tree builder and cases. Insert this helper after `make_tree`:

```bash
REAL_MEMORY_REFS="$REAL_PROJECT_DIR/plugins/memory/references"
make_vertical_tree() { # $1=ships  $2=vertical (schema-basename)
  local T; T="$(mktemp -d)"
  mkdir -p "$T/platform/plugins" "$T/platform/config" "$T/platform/templates/account-schema"
  ln -s "$REAL_PREMIUM" "$T/premium-plugins"
  mkdir -p "$T/platform/plugins/memory"
  ln -s "$REAL_MEMORY_REFS" "$T/platform/plugins/memory/references"
  cp "$REAL_TEMPLATE" "$T/platform/templates/account-schema/SCHEMA.md"
  printf '{"installDir":"test-brand","shipsPremiumBundles":%s,"vertical":"%s"}\n' "$1" "$2" > "$T/platform/config/brand.json"
  echo "$T"
}
```

Then the cases (append before the final `echo "PASS=..."`):

```bash
# --- construction vertical: domain buckets derived, no sites-collision ---
T_C="$(make_vertical_tree '["sitedesk"]' 'schema-construction')"; A_C="$(make_account "$T_C")"
export PROJECT_DIR="$T_C/platform"
OUT_C="$(merge_owned_dirs_into_schema "$A_C")"
assert_grep "domainBuckets=" "$OUT_C" "con-domain-log"
ALLOWED_C="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_C/SCHEMA.md")"
for d in jobs customers customer-sites assets visits quotes purchase-orders ppm-contracts; do
  assert_grep "$d" "$ALLOWED_C" "con-allow-$d"
done
assert_nogrep "whatsapp-groups" "$ALLOWED_C" "con-no-transport-bucket"
# The tool-owned published-tree bucket `sites` is present exactly once and Site
# did not overwrite it; `customer-sites` is the Site bucket.
assert_grep "customer-sites" "$ALLOWED_C" "con-site-renamed"
assert_grep "Domain entity buckets" "$(cat "$A_C/SCHEMA.md")" "con-region"
# idempotent second merge is byte-identical
B_C="$(shasum "$A_C/SCHEMA.md" | awk '{print $1}')"
merge_owned_dirs_into_schema "$A_C" >/dev/null
assert_eq "$(shasum "$A_C/SCHEMA.md" | awk '{print $1}')" "$B_C" "con-idempotent"

# --- estate-agent vertical: its own buckets, not SiteDesk's ---
T_E="$(make_vertical_tree '["real-agent"]' 'schema-estate-agent')"; A_E="$(make_account "$T_E")"
export PROJECT_DIR="$T_E/platform"
merge_owned_dirs_into_schema "$A_E" >/dev/null
ALLOWED_E="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_E/SCHEMA.md")"
for d in properties listings viewings offers; do
  assert_grep "$d" "$ALLOWED_E" "ea-allow-$d"
done
assert_nogrep "jobs" "$ALLOWED_E" "ea-no-sitedesk-jobs"

# --- no vertical declared: generic base only ---
T_N="$(make_tree '[]')"; A_N="$(make_account "$T_N")"
export PROJECT_DIR="$T_N/platform"
merge_owned_dirs_into_schema "$A_N" >/dev/null
ALLOWED_N="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_N/SCHEMA.md")"
assert_nogrep "jobs" "$ALLOWED_N" "novert-no-domain"
assert_nogrep "Domain entity buckets" "$(cat "$A_N/SCHEMA.md")" "novert-no-region"

# --- reconcile reports each ontology entity present/absent ---
A_RCD="$(make_account "$T_C")"
export PROJECT_DIR="$T_C/platform"
RCD_BEFORE="$(PROJECT_DIR="$PROJECT_DIR" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" reconcile "$A_RCD")"
assert_grep "ontologyEntity=Job bucket=jobs present=false" "$RCD_BEFORE" "rcd-job-absent"
merge_owned_dirs_into_schema "$A_RCD" >/dev/null
RCD_AFTER="$(PROJECT_DIR="$PROJECT_DIR" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" reconcile "$A_RCD")"
assert_grep "ontologyEntity=Job bucket=jobs present=true" "$RCD_AFTER" "rcd-job-present"
```

- [ ] **Step 6: Run the test**

Run: `bash platform/scripts/__tests__/account-schema-owned-dirs.test.sh`
Expected: `PASS=<n> FAIL=0`. The pre-existing sitedesk/idempotency/reconcile/shape-b cases still pass (their brand.json has no `vertical`, so `domainBuckets=none` and no region).

- [ ] **Step 7: Commit**

```bash
git add platform/scripts/lib/account-schema-owned-dirs.py platform/scripts/__tests__/account-schema-owned-dirs.test.sh
git commit -m "feat: derive domain-entity buckets from the vertical ontology (1622)"
```

---

### Task 4: Guard admits domain buckets at any depth (test only)

**Files:**
- Test: `platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh`

**Rationale:** No guard logic change. A domain bucket is outside `{projects, contacts, documents}`, so it already passes at any depth. This task proves it and guards the regression.

- [ ] **Step 1: Add a domain bucket to the test account's allowed set and add cases**

After the `cp "$TEMPLATE" "$ACCT/SCHEMA.md"` line, inject a domain bucket into the allowed block so the test account resembles a provisioned construction account:

```bash
# Simulate a construction account: jobs/ is a derived domain bucket.
awk '1; /^```allowed-top-level$/{print "jobs"}' "$ACCT/SCHEMA.md" > "$ACCT/SCHEMA.md.tmp" && mv "$ACCT/SCHEMA.md.tmp" "$ACCT/SCHEMA.md"
mkdir -p "$ACCT/jobs/5-oaktree/Quotations"
```

Add these cases beside the existing `run_case` lines:

```bash
run_case "domain bucket deep allow" "$(mkenv Write file_path 'jobs/5-oaktree/Quotations/quote.pdf')" 0 ""
run_case "domain bucket file allow"  "$(mkenv Write file_path 'jobs/5-oaktree/notes.md')"           0 ""
run_case "undeclared still blocks"   "$(mkenv Write file_path 'invoices/x.pdf')"                     2 "reason=top-level"
```

- [ ] **Step 2: Run the guard test**

Run: `bash platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh`
Expected: all PASS including the three new cases. The existing template-parity assertion (case 11) still passes because the shipped template is unmodified.

- [ ] **Step 3: Commit**

```bash
git add platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh
git commit -m "test: guard admits derived domain buckets at any depth (1622)"
```

---

### Task 5: Quotation reifies the chain and records the quote under the Job

**Files:**
- Modify: `premium-plugins/sitedesk/plugins/sitedesk-job/skills/quotation/references/quote-generation.md`
- Modify (if it names the apply post-conditions): `premium-plugins/sitedesk/plugins/sitedesk-job/skills/quotation/SKILL.md`

**Rationale:** Parity with `valuation`, whose job-setup reifies `:Job`/`:Quote`/`:QuoteLine` via the platform `memory-write`/`memory-edge` tools (prose-driven, no tool). Add the same post-render obligations to the quotation apply-path. `quote-render`'s customer-scope client delivery is unchanged; what is added is the operator-facing job binding.

- [ ] **Step 1: Read the apply-path reference and locate the post-render step**

Run: `sed -n '55,120p' premium-plugins/sitedesk/plugins/sitedesk-job/skills/quotation/references/quote-generation.md`
Find where a successful `quote-render` is described (the `op=render` success), which is the insertion point for the reify + file obligations.

- [ ] **Step 2: Add the reification-and-filing obligation**

After the successful-render description, add a section (flat statement register, no em-dashes, no task numbers), stating the post-condition every produced quote satisfies:

```markdown
## Bind the produced quote to its Job

A produced quote is not finished when its documents render. It is finished when
it is bound to the job in the graph and recorded under the job folder, the same
model the `valuation` skill uses for a job's interim valuations.

After `quote-render` succeeds, reify the quote into the graph with the platform
memory tools, idempotent on the construction-schema natural keys:

- the `:Job` on `(accountId, jobId)`, linked to its `:Customer` (`FOR_CUSTOMER`)
  and `:Site` (`AT_SITE`) when those are known;
- the `:Quote` on `(accountId, ref)`, linked from the job by
  `(:Job)-[:HAS_QUOTE]->(:Quote)`;
- each priced row as a `:QuoteLine` on `(accountId, jobId, trade, description)`,
  linked by `(:Quote)-[:HAS_LINE]->(:QuoteLine)`.

The line figures come from the engine result; invent none. This is the same
`:Job -> :Quote -> :QuoteLine` chain a valuation later measures, so a quote
produced here is already the contract schedule a valuation reads.

Record the operator-facing quote under the job folder, `jobs/<jobId>/Quotations/`,
alongside the rest of the job's artifacts. The client-facing copy that
`quote-render` files into the customer's own documents is unchanged; the job
folder is the operator's record of what was issued.

A produced quote that renders documents but writes no `:Quote` node is a defect:
the standing account census (every `:Quote` reconcilable to a filed quote and to
carried state) surfaces it, the same census the `valuation` skill relies on.
```

- [ ] **Step 3: Cross-check the SKILL.md apply summary**

Run: `grep -n "apply\|produce\|render\|graph\|:Quote\|jobs/" premium-plugins/sitedesk/plugins/sitedesk-job/skills/quotation/SKILL.md`
If the SKILL.md summarises the apply-path outcome, add one sentence there that a produced quote is reified as `:Job->:Quote->:QuoteLine` and recorded under `jobs/<jobId>/Quotations/`. If the SKILL.md only lists skills, leave it and rely on the reference.

- [ ] **Step 4: Verify no em-dashes or task numbers were introduced**

Run:
```bash
grep -n "—" premium-plugins/sitedesk/plugins/sitedesk-job/skills/quotation/references/quote-generation.md && echo "EM-DASH FOUND (fix)" || echo "no em-dash"
grep -nE "162[0-9]|task [0-9]" premium-plugins/sitedesk/plugins/sitedesk-job/skills/quotation/references/quote-generation.md && echo "REF FOUND (fix)" || echo "no internal ref"
```
Expected: `no em-dash` and `no internal ref`.

- [ ] **Step 5: Commit**

```bash
git add premium-plugins/sitedesk/plugins/sitedesk-job/skills/quotation/references/quote-generation.md premium-plugins/sitedesk/plugins/sitedesk-job/skills/quotation/SKILL.md
git commit -m "feat: quotation reifies Job/Quote/QuoteLine and records under the Job (1622)"
```

---

### Task 6: Documentation and the design-doc pointer

**Files:**
- Modify: the canonical doc of the account-schema mechanism (found in Step 1).

**Rationale:** The enforce-docs Stop hook (if present) blocks completion when code changes without doc changes. Record the ontology-derivation behaviour where the account-schema mechanism is documented.

- [ ] **Step 1: Locate the canonical doc and the enforce-docs hook**

Run:
```bash
ls .claude/hooks/check-docs.sh 2>/dev/null && echo "enforce-docs ACTIVE" || echo "no enforce-docs hook"
grep -rln "allowed-top-level\|account-owned-dirs\|plugin-owned" platform/plugins/docs .docs README.md 2>/dev/null
```

- [ ] **Step 2: Add a short paragraph** to the located doc (or to `platform/plugins/memory/PLUGIN.md` beside the vertical-declaration paragraph if no closer home exists): the account file-schema derives one top-level bucket per `schema:`-namespaced operator-entry label in the brand vertical's top-level table, mapped kebab-case-plural with `Site`->`customer-sites`; unnormalised verticals fall back to the generic base and `account-schema-owned-dirs.py reconcile` flags them. No em-dashes.

- [ ] **Step 3: Commit**

```bash
git add -A
git commit -m "docs: record ontology-derived account file-schema buckets (1622)"
```

---

## Follow-up tasks (filed at land time, before archiving 1622)

- Normalise 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 node types` heading, so their accounts derive domain buckets. Until then they fall back to generic base and reconcile flags them.
- Single-source `graph-labels.ts FILTER_TOP_LEVEL_LABELS` from the same per-vertical ontology source.
- Backfill existing `quoting/jobs/<jobId>/` figures folders for already-produced quotes into their `jobs/<jobId>/` folders (already deferred by task 1622).

## Self-Review

- **Spec coverage:** A (derivation) -> Tasks 1,3; single-source (vertical file) -> Task 1; label->dir + Site override -> Task 1; guard consumes derived set -> Task 4; B (quotation reify + file under Job) -> Task 5; C (estate-agent reorg) -> Task 2; reconcile observability -> Task 3; reify observability (census/memory-write log, not a tool breadcrumb) -> Task 5 prose; docs -> Task 6; follow-ups -> filed at land. All spec sections mapped.
- **Placeholder scan:** every code and test step carries real content; no TBD/TODO.
- **Type consistency:** `resolve_domain_buckets` returns `{dir,label}` in Tasks 1 and 3; `_label_to_dir`, `_read_vertical`, `_top_level_labels` names consistent; region markers `ONT_START`/`ONT_END` consistent across Task 3 steps.
