ADR-0027 — Three-layer architecture: Catalog / Events-Values / Spine; Canonical vs Derived


Context

The data-model migration that ran 2026-05-02 → 2026-05-15 (Phase 5a closure) collapsed the legacy Tenant/Prospect/Vendor single-phone-keyed topology onto a Person + IdentityClaim + role-relation spine. The migration's load-bearing rule was "100% legacy elimination — only one source of truth" (Jose's GOLDEN/HARD rule, 2026-05-15). Phase 5a closed clean: 13 forbidden compat/shim/shadow patterns CI-pinned dead via src/__tests__/legacy-field-elimination.drift.test.ts.

The full-phase audit filed 2026-05-16 (PR #982) surfaced the next architectural question. Two cross-cutting registries — the Tool Catalog (src/lib/tools/, ~50 entries) and the Metric Catalog (src/lib/data/metric-catalog.ts, hundreds of keys) — sit outside the spine. The catalog defines what's reportable; the corresponding MetricSnapshot DDB rows and AgentTraceStep rows record what actually happened. The question came up: should MetricSnapshot carry a byPersonId: Record<string, number> to answer "who called?" Should ToolInvocation be a first-class entity to make tool calls queryable?

Both proposals would reintroduce the exact dual-write apparatus Phase 5a tore out. The answer is to recognize three distinct classes of data and name + scope them precisely so they can't be conflated again.

The repository today has ~70 interfaces in src/lib/data/types.ts + 2 code registries (Tool Catalog, Metric Catalog). Without a codified taxonomy, every new entity proposal repeats the same architectural conversation. The Phase 5a feedback_legacy_total_elimination.md memory captures the rule narratively but it isn't enforced anywhere — tribal knowledge that fails the moment a swarm or new engineer touches the data layer.

This ADR codifies the taxonomy, names it, sets the enforcement points, and establishes the planning-doc / ADR template convention so every new entity proposal has to explicitly declare which class it belongs to.


Decision

1. Three-layer mental model

The PropFlow data architecture is organized as three distinct layers:

Layer What Lives in Mutability Examples
Catalog Static registries of names + shapes Code (metric-catalog.ts, tool-catalog.ts, tool catalog under src/lib/tools/) Changes only on developer commit Metric Catalog (~hundreds of keys), Tool Catalog (~50 tools)
Events / Values Per-instance records of what happened + cached aggregates DDB rows + embedded in parent entities Changes on every event MetricSnapshot rows; AgentTraceStep rows; tool-use/tool-result blocks inside Conversation.messages[]
Spine Canonical identity + relationships + state DDB Changes when entities are created/updated Person, IdentityClaim, Organization, Property, Unit, Lease, Vendor, TenantOccupancy, ProspectInquiry, VendorContact, plus all artifacts that own their state (WorkOrder, Tour, Conversation, RenewalSaga, …)

The arrows always flow from catalog → events → spine, and from events → spine. Spine never references events; events don't import or call into catalog code at runtime (i.e., events-layer code does not call catalog modules; catalog-key strings stored in event rows like MetricSnapshot.metricKey are opaque identifiers, not runtime catalog references — that's a name reference, not a code-dependency).

A consequence: every "who triggered this?" question routes through the spine. The catalog tells you what exists; the events layer tells you what value/record was produced; the spine tells you who/what is involved. Joining requires walking from events → spine, never spine → events.

2. Two-class taxonomy: Canonical vs Derived

Within the events/values + spine layers, every entity is classified as exactly one of:

Canonical

Definition: Owns its own state. Cannot be reconstructed from other data. Deleting a canonical row + rerunning all producer jobs does NOT recreate it.

Test: "If I delete this row and rerun all producers, is it gone forever?" → Yes → canonical.

Sub-flavors of canonical (informational; same enforcement):

Derived

Definition: Is a projection / cache / aggregate / rollup of canonical data. Can be rebuilt by querying or running a job over canonical data.

Test: "If I delete this row and rerun the producer job, do I get the same answer?" → Yes → derived.

Sub-flavors of derived (informational; reflected in naming suffix):

3. Naming convention (enforcement point #1)

Canonical entities: bare name. Person, Lease, WorkOrder, Conversation, AgentTrace, BackfillReviewQueue.

Derived entities: suffix that signals derivation. MetricSnapshot, StatusCache, DashboardStats, LeasingStats, future *Aggregate / *Rollup / *Index.

The naming-suffix list is fixed: Snapshot, Cache, Stats, Summary, Aggregate, Rollup, Index, View. New derived entities must use one. New canonical entities must not.

Rejected names (signal a class confusion at PR review time):

4. Type-level annotation (enforcement point #2)

Every export interface in src/lib/data/types.ts carries exactly one of two JSDoc tags:

/**
 * @canonical Spine entity — owns identity attribute state.
 *   Spine trace: self
 *   See ADR-0018 §"Identity model" + ADR-0027 §"Canonical".
 */
export interface Person { … }

/**
 * @canonical Event log — append-only audit of every agent-tool call.
 *   Spine trace: Conversation.personId via conversationId
 *   See ADR-0027 §"Canonical · Event log".
 */
export interface AgentTrace { … }

/**
 * @derived from Conversation / WorkOrder / Tour rows in [windowStart, windowEnd].
 *   Rebuilt by: scripts/backfill-metric-snapshots.ts (per-property, cron-driven)
 *   Drift tolerance: ≤24h (dashboard polls every 3-5s — reads cached scalar)
 *   See ADR-0027 §"Derived · Snapshot".
 */
export interface MetricSnapshot { … }

Required slots:

Forbidden combinations:

5. Drift guard (enforcement point #3)

A new vitest at src/__tests__/entity-classification.drift.test.ts enforces the rules above. The test:

  1. Reads src/lib/data/types.ts source
  2. Parses every export interface declaration + its preceding JSDoc block
  3. Asserts each interface carries exactly one @canonical or @derived tag
  4. Asserts @canonical declarations carry a Spine trace: line with self / a FK path / none
  5. Asserts @derived declarations carry from:, Rebuilt by:, Drift tolerance: lines
  6. Asserts name ↔ tag agreement (canonical names match bare convention; derived names match suffix convention)
  7. Self-check: every fixture in the test carries a stated rationale (≥40 chars)

Failure mode: a new entity added without proper classification breaks CI. The error message points at this ADR and the convention.

This is the same shape as legacy-field-elimination.drift.test.ts (Phase 5a) and load-bearing-migration-infra.drift.test.ts (full-phase audit). Drift-guard-as-architecture-enforcement is the established pattern.

6. Diagram convention (enforcement point #4)

In architecture/entity-model.md Mermaid ER diagrams:

Visual separation in the diagram makes the architectural claim visible at a glance. A new entity drawn on the canonical side that should be derived will look wrong immediately.

7. Atlas treatment (enforcement point #5)

The /admin/dev/atlas/ file-explorer surfaces the architecture as the operator sees it:

8. ADR + planning-doc template (enforcement point #6)

Every new ADR proposing an entity must answer in its ## Decision section:

  1. Class: canonical or derived?
  2. If canonical: what's the spine trace path? (self / FK chain / none with operational rationale)
  3. If derived: what does it derive from, who rebuilds it, what's the drift tolerance?

The ADR template at docs/adr/template.md is updated in this same PR to include these required fields.

Planning docs scoped at docs/data-model-migration/planning/ or its successor for Phase 6+ inherit the same convention: any new entity proposed in a planning doc must declare class before code lands.


Consequences

Positive

Negative / Costs

Neutral


Alternatives considered

Alternative A — No taxonomy; keep "single source of truth" as memory-file convention

Rejected because: the Phase 5a audit + this conversation already surfaced the failure mode. MetricSnapshot.byPersonId and ToolInvocation were both proposed as "obvious" next-step entities; without an enforced taxonomy, they'd land via PR review judgment calls rather than CI gates. Tribal knowledge erodes over years; the migration the next team inherits would look like the one Phase 5a just spent two weeks undoing.

Alternative B — Many-class taxonomy (separate classes for spine / role-relation / event-log / operational-state / snapshot / cache / aggregate / …)

Rejected because: sub-flavors are informational, not enforcement-worthy. The architectural test that matters is "can this be reconstructed from other data?" — a binary. Splitting into 7-8 classes adds vocabulary without changing the enforcement rule; reviewers and CI need only the binary call. Sub-flavors live as docstring conventions and naming suffixes — not as separate tags.

Alternative C — Event-sourcing as the canonical pattern

Rejected because: PropFlow's existing data shape (DDB single-table, role-relation entities, lazy aggregation) is not event-sourced. Retrofitting CQRS / event-sourcing post-Phase-5a would be a full architectural rewrite. The taxonomy in this ADR is compatible with our existing pattern (some entities are append-only event logs; others are state-bearing); it doesn't require event sourcing.

Alternative D — Defer the taxonomy until Phase 6 closes

Rejected because: the Phase 6 work (User-domain bridge via Person) is exactly the kind of work that NEEDS the taxonomy to avoid drift. Without ADR-0027, Phase 6 has the same risk Phase 5a inherited from the legacy model — accumulated denormalization patterns that take a future migration to undo. Lock the convention before the work starts, not after.


Phase A adoption (this ADR's rollout)

The adoption work that operationalizes this ADR splits across three sequential PRs:

PR Scope Files LOC est.
A.1 — ADR + entity-model.md rewrite This ADR + architecture/entity-model.md rewritten to 4 tiers (K / MS / College / Catalogs+cross-cutting) with all ~70 entities drawn, visual separator for derived docs/adr/0027-three-layer-architecture-canonical-derived.md (new), docs/data-model-migration/architecture/entity-model.md (full rewrite), docs/adr/template.md (class-declaration fields added) ~1050
A.2 — JSDoc annotations + classification drift guard Every interface in types.ts tagged @canonical or @derived; drift guard enforces src/lib/data/types.ts (~70 JSDoc blocks added), src/__tests__/entity-classification.drift.test.ts (new, ~200 LOC) ~400
A.3 — Atlas root tiles + spine-trace-paths inventory Atlas surfaces Tool Catalog + Metric Catalog at root; architecture/spine-trace-paths.md documents every entity's trace path to Person src/app/(workspace)/(operations)/admin/dev/atlas/_components/* (~5 files touched), docs/data-model-migration/architecture/spine-trace-paths.md (new) ~600

Each PR ships independently with /review-turns, merges on clean verdict, then the next PR rebases on main and proceeds.


References