# Domain reference distilled from the client's POC

**Status: architecture decision, confirmed 2026-09-18.** The client sent a separately-built Laravel
implementation (`Infrastructure-Futures-POC`, reviewed 2026-09-17). It does not meet requirements —
it has no real WordPress integration; auth, admin, and page rendering are all self-contained inside
Laravel. Decision: do not adopt its codebase. Continue building this project (`infrastructure-features`)
from its own spine per `docs/build-guide.md`, using the POC only as a reference for domain modeling —
the entity shapes, relationships, and business-logic patterns below are worth reusing; none of its code
is being copied in.

## Architecture decision this supersedes

`docs/build-guide.md`'s original CMS decision proposed **a Laravel-hosted admin**, reachable only to
internal TTC users. That is now superseded:

- **Laravel** stores all data and is the source of truth. It is exposed **only** via REST API. It does
  not need its own admin UI.
- **WordPress** is where editors manage that data — admin screens that call Laravel's API to
  create/edit/archive projects, companies, transactions, markets, sources, and events — and where the
  public frontend templates render (the four Infrastructure Futures pages).
- All WP↔Laravel communication goes over REST APIs, using the entitlement/introspection seam already
  designed (opaque token + `POST /wp-json/ttc-if/v1/introspect`, fail-closed to public — see
  `docs/build-guide.md` Part 2 §1 and the `ttc-infrastructure-futures` plugin).

Everything else in `docs/build-guide.md` (the tier model, the visibility-derived-not-stored rule,
tier-scoped aggregates, one-record-one-identity) still holds. Only *where the CMS lives* changes.

## The entity shapes worth reusing

The POC's six core tables map cleanly onto what this project already planned to build. Field names
below are the POC's; adapt naming to this project's own conventions (ULIDs, `Domain/InfrastructureFutures`
namespacing, etc.) rather than copying literally.

**`companies`** — one company can play multiple roles (operator on a project, buyer on a transaction).
Deliberately no fixed "role" column — the role is implied by which foreign key points at it. Fields:
`name, slug, type(operator|investor|developer|lender|hyperscaler|utility|adviser), website,
headquarters, is_public, exchange, ticker, description, verification_status, internal_notes,
subscriber_publish_at, public_publish_at`.

**`markets`** — self-referencing (`parent_id`) so a metro can roll up into a country/region. Fields:
`name, slug, country, region, metro, summary, power_status, power_note, policy_note,
power_price_mwh, vacancy_rate, verification_status, subscriber_publish_at, public_publish_at`. The
per-market page (`market-monitor/{slug}`) uses route-model binding on `slug`, so every market row
gets a working page automatically with no template/routing change per market — worth keeping.

**`projects`** — the center of the model. One persistent row for the project's whole life; a status
change (`announced → early_stage → financed → under_construction → operational`) updates the row in
place and never creates a second record. Belongs to one `market` and one `operator` company
(required, restrict-on-delete); optionally an `owner` company and a `utility` company. Fields:
`name, slug, market_id, operator_company_id, owner_company_id, location, asset_type, capacity_mw,
status_code, project_state, announced_date, construction_date, expected_live_date, actual_live_date,
capital_value, capital_currency, capital_usd, power_status, utility_company_id, verification_status,
last_verified_at, internal_notes, subscriber_publish_at, public_publish_at`.

**`transactions`** — capital activity (M&A, credit, project finance). Up to three company relationships
(`buyer`, `seller`, `target_company`) plus an optional `target_project` — a transaction can be "about"
either a company or a specific project. Fields: `title, slug, transaction_type, status,
buyer_company_id, seller_company_id, target_company_id, target_project_id, market_id,
original_value, currency, normalised_usd_value, announced_date, signed_date, closed_date,
verification_status, internal_notes, subscriber_publish_at, public_publish_at`.

**`sources` + `sourceables` (polymorphic)** — a reusable citation (URL, publisher, verification
status) attachable to any other record (project, transaction, market event) via a polymorphic pivot.
One source can back multiple records; one record can have multiple sources.

**`power_policy_events`** — belongs to a market (and optionally a specific project); a grid/permitting/
policy signal. Feeds the "market signals" section of a market page.

## Patterns worth reusing exactly

**Publication-window visibility (the actual paywall).** Every gated table carries
`subscriber_publish_at` and nullable `public_publish_at`. A trait/concern (POC's
`HasPublicationWindow`) provides a `visibleTo($user)` query scope and an `isVisibleTo($user)`
instance check, used everywhere: internal sees everything; subscriber tiers see records past
`subscriber_publish_at`; everyone else sees records past `public_publish_at`; a null
`public_publish_at` means permanently subscriber-only. This is exactly `docs/build-guide.md`'s
"visibility is derived, never stored" principle (Part 2 §3) — confirms that design, don't deviate
from it.

**Aggregates always start from the same scoped query.** Never compute a dashboard number from
`Model::all()`. Every aggregate (`AggregateService` in the POC) starts from
`Project::query()->visibleTo($user)` — same rule as `docs/build-guide.md` Part 2 §4. This is why an
anonymous visitor and an internal user correctly see different totals on the same page.

**Append-only status history + audit log, written by model events.** A `Project` model event
(`booted()`, on `created`/`updating`/`updated`) appends a row to a `project_status_history` table
before a status-changing update, and a diff to an `audit_logs` table (polymorphic `auditable`,
`before`/`after` JSON, actor, timestamp) after any update. Written automatically by the model, never
by hand in a controller — keeps every write path (form, CSV import, future AI-assisted entry)
consistent.

**CSV import: validate-then-commit, all-or-nothing.** Upload → every row's validation errors shown
before anything is written → commit only if the whole batch is clean. No partially-applied import
state is reachable.

**AI-assisted entry: propose, never persist.** A mock/real AI extraction provider returns a
typed proposal with per-field confidence and `requires_human_review = true`. It is never written
directly to a record — a human must transfer/correct it through the normal validated entity form.
There is no code path from extraction straight to a published record.

**Soft-delete + status flag for archival, not hard delete.** "Archiving" sets
`verification_status = archived` and soft-deletes the row — it stays in the database (and in the
audit trail) but is excluded from default queries. Internal list views that need to show archived
records alongside live ones use `withTrashed()` explicitly.

## What does NOT carry over

- The POC's own session-based login, its hardcoded synthetic accounts, and its Laravel-hosted
  `/admin` Blade views — none of this is being reused; WordPress replaces all of it per the
  architecture decision above.
- Its exact route names/shapes (`/api/v1/overview`, `/api/v1/projects`, etc.) — this project defines
  its own API surface per `docs/build-guide.md`.
- Its tier vocabulary (`registered_free/intelligence/institutional/internal`) — reconcile against
  this project's own `Tier` enum and the real TTC PMPro levels
  (`none/free/pro/institutional` — see `ttc_membership_get_user_tier()` in the real `ttc` repo)
  when building the WP↔Laravel tier mapping; do not assume the POC's four names are the final ones.

## Where the fuller write-up lives

A complete walkthrough of the POC's implementation (with an ER-style diagram and file-by-file
references) was written to `Infrastructure-Futures-POC/HOW-IT-WORKS.md` on the machine this was
built on, if more implementation detail is ever needed than what's distilled above.
