---
id: plan-infrastructure-futures-build
type: plan
kind: agency
title: Infrastructure Futures — architecture walkthrough and build guide
project: thetechcapital
date: 2026-09-15
status: draft
links:
  - "[[spec-infrastructure-futures]] — implements"
  - "[[spec-laravel-ddd-backend]] — conforms-to"
---

# Infrastructure Futures — how the system works, and how we build it

A companion to [`spec-infrastructure-futures`](../../../docs/superpowers/specs/2026-09-15-infrastructure-futures-design.md).
The spec records *what we decided*. This document explains *why the system is shaped that way*
and gives the ordered path through building it.

Read Part 1 and Part 2 before writing any code. They are short, and everything in Parts 4 onward
assumes them.

---

## Part 1 — Orientation

### The product

The Tech Capital sells a subscription intelligence product about digital infrastructure: data
centre projects, the markets they sit in, and the capital flowing into them. Four pages —
Overview, Project Pipeline, Market Monitor, Capital Tracker — all reading from one dataset that
journalists maintain.

### The situation we are building into

TTC already has a WordPress website with readers, accounts and a subscription business. They are
not replacing it. So:

**WordPress owns the people. Laravel owns the data.**

That sentence is the architecture. Everything awkward about this build comes from it, and
everything elegant about the solution is a response to it.

Infrastructure Futures is not a feature inside an application, where `auth()->user()` is simply
available. It is a second system that must borrow another system's identity and enforce a paywall
it does not own.

### The requirement that decides the design

From the brief, Subtask 8:

> Unauthorised users should not be able to retrieve premium information through the page source
> or an API request.

Read that twice. It rules out the easy version of this product — render everything, hide the
premium parts with CSS or a JavaScript check. It also rules out "the API is fine because it's on a
private network", because a network boundary is a deployment fact, not an authorization control.
Firewall rules change. The gate has to be real.

Everything else in the ten subtasks is ordinary, enjoyable work. This is the one that decides
whether TTC can sell the thing.

### What a page request actually does

Someone opens `thetechcapital.com/infrastructure-futures/project-pipeline`:

```
 1  Browser ──► WordPress            GET the page, carrying the WP session cookie
 2  WordPress                        resolves the logged-in user (or nobody)
 3  WordPress                        mints a random 32-byte token, stores {user_id, tier}
                                     in a transient keyed by sha256(token), TTL 120s
 4  WordPress                        wraps it: base64url(token) . "." . hmac(token, SECRET)
 5  WordPress ──► Laravel            GET /api/v1/if/projects
                                     Authorization: Bearer <envelope>
 6  Laravel                          splits the envelope, verifies the HMAC
                                     ✗ invalid → tier = public, stop here, no callback
 7  Laravel                          cache hit on sha256(token)?  → skip to 10
 8  Laravel ──► WordPress            POST /wp-json/ttc-if/v1/introspect {token}
                                     (its own service credential)
 9  WordPress ──► Laravel            {active: true, user_id, tier: "subscriber"}
                                     ✗ unreachable / timeout / non-2xx → tier = public
10  Laravel                          binds the tier to the request
11  Laravel                          runs the query scoped to that tier,
                                     serialises through that tier's field allowlist
12  Laravel ──► WordPress            JSON containing only what this tier may have
13  WordPress ──► Browser            rendered HTML
```

The browser never holds a credential and never sees a field the tier is not entitled to. Not
hidden — **absent**.

---

## Part 2 — Five ideas to hold while building

Everything in the codebase follows from these. If a piece of code seems strange, it is almost
always one of these five being enforced.

### 1. WordPress is the first caller, not a trusted one

Step 8 above looks redundant. WordPress already knows who the user is — why does it answer a
question it just asked?

Because the day anything *other* than WordPress can reach Laravel, a Laravel that trusts
`tier=subscriber` from its caller has no paywall at all. A staging WP pointed at production. A
leaked secret. The brief's own line about adding APIs later. Introspection is what makes the gate
*ours* rather than something we take on faith.

The signed envelope in step 4 exists so that garbage dies at step 6 without generating a callback.
The signature answers "is this well-formed and from WordPress". The introspection answers "is this
person a subscriber **right now**".

That distinction is also why we do not use a self-contained JWT carrying the tier. A JWT is
faster, needs no callback, and cannot be revoked. The pressure is always to lengthen the expiry for
performance — and a long-lived token means a cancelled or refunded account keeps premium access
until it expires. Entitlement is a revocable, money-bearing fact.

### 2. Entitlement has two dimensions

Which **records** a tier can see, and which **fields** those records carry. These are separate
mechanisms and conflating them is the usual failure.

Records are handled by a **query scope**, so unentitled rows are never loaded. Fields are handled
by a **per-tier allowlist** in the API resource, so unentitled columns are never serialised.

The allowlist direction matters more than it looks. Under a denylist — "hide these fields from
public" — every new column is public until someone remembers to hide it, and the field nobody
remembers is exactly the one that leaks. Under an allowlist, a new column is invisible until
someone deliberately grants it.

### 3. Visibility is derived, never stored

Each gated record carries `subscriber_published_at` and `public_published_at`. Whether a record is
visible is computed **on read**, from the clock.

The tempting alternative is a boolean `is_public` that a scheduled job flips at the right moment.
Do not build that. A stored flag reads as perfectly correct right up until the job stops running,
and nothing tells you it stopped — records silently never go public, or go public early and stay
that way. Deriving on read means there is no job to monitor and no way to drift out of sync with
our own schedule.

### 4. Aggregates inherit the gate

This is the subtle one, and it quietly defeats the other four if missed.

"London under-construction capacity" computed across *all* London projects hands a public visitor
a number derived from records they are not allowed to see. The dashboard becomes the side channel
that undoes the row-level gating everywhere else.

So every aggregate begins from the same scope as every list. Headline figures therefore *change*
with tier — the prototype shows under-construction capacity reading 3.9 GW to a subscriber and
1.3 GW to the public. That is correct behaviour, not an inconsistency to paper over.

**Corollary:** the tier is part of every cache key. A response cached for a subscriber and served
to an anonymous visitor is the same leak wearing a performance hat.

### 5. One record, one identity

From Subtask 6: a project's operator is a link to a `Company` record, never the string
`"Equinix"`. A project moving Announced → Financed → Under construction **updates its row** — it
never becomes a second project; the transition is appended to history.

This is what makes the cascade in the brief work:

```
project updated → pipeline updates → market aggregates update → overview updates
```

Not because we wire four updates. Because there is one number and four views of it.

---

## Part 3 — Where the code lives

One bounded context to start, following [`spec-laravel-ddd-backend`](../../../specs/laravel-ddd-backend.md):

```
app/Domain/InfrastructureFutures/
├── Actions/                    PublishProjectAction, RecordStatusChangeAction,
│                               PromoteDraftAction, ImportTransactionsAction
├── DTOs/                       ProjectView, MarketSnapshot, PipelineFilters, IntrospectionResult
├── Enums/                      Tier, ProjectStage, DealType, VerificationStatus
├── Http/
│   ├── Controllers/            ProjectController, MarketController, TransactionController
│   ├── Middleware/             ResolveEntitlement
│   ├── Requests/               ProjectIndexRequest, TransactionIndexRequest
│   └── Resources/              ProjectResource, TransactionResource, MarketResource
├── Models/                     Project, Market, Company, Transaction, Source,
│                               ProjectStatus, ProjectStatusChange, Article, RecordDraft
│   └── Concerns/               ScopedToTier
├── Observers/                  ProjectObserver  (writes status history — infra, not intent)
├── Policies/                   ProjectPolicy, RecordDraftPolicy
├── Queries/
│   ├── Public/                 GetMarketSnapshotQuery, GetPipelineAggregatesQuery
│   └── Internal/               ListProjectsQuery, ListTransactionsQuery
├── Services/                   EntitlementResolver, DuplicateSuggester, FxNormaliser
├── Support/                    TierScopeGuard
└── ValueObjects/               Capacity, MonetaryAmount, MarketPath
```

**On splitting:** one context is right for the MVP. The split line, when it comes, runs between
*Pipeline* (projects, markets, companies) and *Capital* (transactions, investors). Split when
`Actions/` passes roughly fifteen classes or when the two halves start needing different
invariants — not before. Premature splitting costs more than it saves.

**Value objects are required** for domain primitives with invariants. `Capacity` refuses a negative
megawatt figure. `MonetaryAmount` carries value, currency, normalised USD, the FX rate and the rate
date together, so those five can never drift apart. We do not pass a raw `float $mw` across a layer
boundary when `Capacity` can enforce correctness.

---

## Part 4 — Phase 0: find out what is actually there

**We have never seen the TTC site.** Not the repository, not a staging environment, not the admin.
Every assumption in the spec is a hypothesis until this phase closes. Phase 0 is not
administration — it is the phase where we discover whether Decision 1 survives.

Answer these, and record the answers back into the spec:

| # | Question | How to answer it | What changes if the answer surprises us |
|---|---|---|---|
| 1 | WordPress version, and self-hosted or managed? | Admin footer; `wp core version`; ask for host details | Managed hosting may forbid custom plugins — the whole render seam moves |
| 2 | Can we deploy a custom plugin and does a staging site exist? | Ask; confirm deploy path | No staging means no safe place to test the seam |
| 3 | **Where are subscriptions actually administered?** | Plugin list — WooCommerce Subscriptions, MemberPress, Stripe direct? | **If billing is not in WP, the introspection target moves.** The shape survives; the endpoint does not |
| 4 | How is "is this person a subscriber" currently expressed? | User meta, role, plugin API | This becomes the body of the introspect endpoint |
| 5 | Can WP reach a private Laravel host, and with what latency? | `curl` from the WP box | High latency changes the introspection cache TTL |
| 6 | Where will Laravel be hosted, and who operates it? | Ask | Decides deployment, queue and monitoring |
| 7 | Is there existing structured data to migrate? Spreadsheets? | Ask editorial | Decides whether CSV import (Phase 7) moves earlier |
| 8 | Are the screenshots agreed design or a first draft? | Ask | Decides how literally to follow them |

Output: a short findings note in this folder, plus spec updates. Then the plan gate.

### Phase 0 findings (2026-09-16)

Answered directly from the `ttc` repository on disk (not a hypothetical — we now have it):

| # | Question | Answer |
|---|---|---|
| 1 | WP version / hosting | Self-hosted **Bedrock**, WP 6.9.1 |
| 2 | Custom plugin deployable? Staging? | Yes — precedent already exists (private VCS Composer packages). Deploy is SSH + `git reset --hard` via a manual-dispatch GH Action. No staging environment confirmed yet — still ask |
| 3 | Where are subscriptions administered? | Real **Paid Memberships Pro 3.8.4**, but tier resolution is a bespoke mu-plugin, not raw PMPro |
| 4 | How is "is this person a subscriber" expressed? | `web/app/mu-plugins/ttc-membership-access-control.php` — `ttc_membership_get_user_tier()` / `ttc_membership_user_can_access_tier()`, tiers `none < free < pro < institutional` mapped from PMPro level IDs. **Introspection endpoint should call these directly** |
| 5 | Can WP reach a private Laravel host, latency? | Unverified — `.env` only shows local Docker. Ask TTC for production topology |
| 6 | Where will Laravel be hosted? | Unknown — ask TTC |
| 7 | Existing structured data to migrate? | **None found.** No infra/markets/capital data anywhere in the repo — this is a from-scratch dataset, not a migration |
| 8 | Are the screenshots agreed design? | Unverified — ask TTC |

**Two findings not in the original question list, both load-bearing:**

- `web/app/mu-plugins/tc-rest-guard.php` blocks anonymous WP REST access by default except an
  allowlist. `/wp-json/ttc-if/v1/introspect` must be added to it, on top of its own service-credential
  check, or the route 401s before our own auth ever runs.
- No existing shared-secret/HMAC convention for service-to-service calls exists in this codebase
  (Mailchimp webhook keys are the nearest precedent). `TTC_LARAVEL_SHARED_SECRET` is a new
  introduction, not a reuse of an existing pattern.

---

## Part 5 — Phase 1: the spine

The order matters. Each step is verifiable before the next begins.

### Step 1 — Project skeleton

Laravel 13, PHP 8.3+, Pint configured, the domain folder from Part 3 created. Run
`scripts/ddd-lint.sh <repo>` to confirm the layout before anything else goes in. Getting a green
layout check on an empty skeleton takes two minutes; fixing it later touches every namespace.

### Step 2 — Migrations, in dependency order

```
companies → markets → project_statuses → projects → project_status_changes
          → transactions → sources → sourceables → articles → article_associations
          → record_drafts → import_batches → import_rows
```

ULIDs throughout. Index from the start on `market_id`, `status_id`, `operator_id`,
`public_published_at`, `subscriber_published_at`, and `occurred_on` — these are the columns every
gate and every aggregate touches.

**Ship both publication columns now, on every gated table.** They cost nothing today. Retrofitting
a publication model onto live data with an established public/private split is genuinely painful.

**On money:** store `value_original`, `currency`, `value_usd`, `fx_rate`, `fx_rate_date` — five
columns, not one. If USD is derived live from today's rate, last year's deal values change every
morning and a chart of historical activity quietly rewrites itself. The rate is frozen at the deal
date.

**On statuses:** `project_statuses` is a table, because the brief wants them configurable. But
aggregates cannot depend on editor-authored slugs, so each row carries a typed `stage`
(`operational | under_construction | financed | pipeline`). An editor adds "Paused", maps it to
`pipeline`, and every dashboard keeps working. That mapping is what makes configurability safe
rather than a time bomb.

### Step 3 — Models and relationships

Plain Eloquent, direct access from Actions and Queries — no repositories, per the house spec.

`Company` has **no `role` column**. A company is an operator because a project points at it, and
an investor because a transaction does. Storing the role invites the same company to exist twice,
which is precisely what Subtask 6 asks us to prevent.

`ProjectObserver` writes `project_status_changes` when `status_id` changes. That belongs in an
observer because it is infrastructure — an audit trail — not domain intent. Domain events come
from Actions.

### Step 4 — The Tier enum and the scope

```php
// app/Domain/InfrastructureFutures/Enums/Tier.php
enum Tier: string
{
    case Public      = 'public';
    case Registered  = 'registered';
    case Subscriber  = 'subscriber';
    case Internal    = 'internal';

    public function seesUnreleased(): bool
    {
        return $this === self::Subscriber || $this === self::Internal;
    }
}
```

```php
// app/Domain/InfrastructureFutures/Models/Concerns/ScopedToTier.php
public function scopeVisibleTo(Builder $query, Tier $tier): Builder
{
    return $tier->seesUnreleased()
        ? $query->whereNotNull('subscriber_published_at')
                ->where('subscriber_published_at', '<=', now())
        : $query->whereNotNull('public_published_at')
                ->where('public_published_at', '<=', now());
}
```

### Step 5 — Make forgetting the scope impossible

A decision in a document is not a constraint until the code refuses what the document ruled out.
Six months from now someone adds a model, forgets `visibleTo()`, and nothing complains.

So gated models register a global scope that throws when no tier is bound to the request:

```php
// app/Domain/InfrastructureFutures/Support/TierScopeGuard.php
protected static function bootScopedToTier(): void
{
    static::addGlobalScope('tier-guard', function (Builder $builder) {
        if (! app()->bound(Tier::class) && ! app()->runningInConsole()) {
            throw new MissingTierScopeException(static::class);
        }
    });
}
```

Loud in development, rather than silently returning everything in production. Console is exempt so
seeders and imports still work.

### Step 6 — The WordPress plugin: mint and introspect

Two functions. This is the part with the least prior art, so it is worth writing carefully.

```php
// ttc-infrastructure-futures/src/Token.php
function ttc_if_mint_token(): string {
    $user = wp_get_current_user();
    $token = random_bytes(32);
    set_transient('if_tok_' . hash('sha256', $token), [
        'user_id' => $user->ID ?: null,
        'tier'    => ttc_if_resolve_tier($user),   // ← Phase 0, question 4
    ], 120);

    return rtrim(strtr(base64_encode($token), '+/', '-_'), '=')
         . '.' . hash_hmac('sha256', $token, TTC_IF_SHARED_SECRET);
}
```

```php
// registered on rest_api_init — authenticated, rate-limited
function ttc_if_introspect(WP_REST_Request $request) {
    $stored = get_transient('if_tok_' . hash('sha256', $request['token']));
    if (! $stored) {
        return ['active' => false];
    }
    return ['active' => true, 'user_id' => $stored['user_id'], 'tier' => $stored['tier']];
}
```

The introspection endpoint must require the service credential and be rate-limited. An open
introspection endpoint is a tier oracle that anyone can probe.

### Step 7 — `ResolveEntitlement` middleware

The order is the security property:

1. Split the envelope. Verify the HMAC with `hash_equals` — constant-time, always.
2. Invalid signature → bind `Tier::Public`, **return without calling WordPress**.
3. Cache lookup on `sha256(token)`, 60s TTL. One page, six modules, one callback.
4. Miss → `POST` to introspect with a **2-second timeout**.
5. Timeout, non-2xx, unreachable, or `active: false` → bind `Tier::Public`.
6. Bind the resolved `Tier` into the container.

**Step 5 is the one to get right.** The instinct when an upstream call fails is to throw a 500 or
to let the request through. Both are wrong here. A degraded Infrastructure Futures showing public
data beats a white screen, and it certainly beats a leak. **Fail closed.**

And never, ever read the tier from the inbound payload. It arrives from introspection or it does
not arrive.

### Step 8 — The first endpoint

`GET /api/v1/if/projects` — `ResolveEntitlement`, then `ListProjectsQuery` starting from
`Project::visibleTo($tier)`, serialised through `ProjectResource::forTier($tier)`.

One endpoint, fully correct, is the milestone. The remaining endpoints are repetitions of it.

---

## Part 6 — The tests to write first

Before Step 8 is called done. These three are not coverage — they are the specification of the
security property, executable.

```php
it('never returns unreleased records to a public caller', function () {
    Project::factory()->create(['public_published_at' => null]);           // subscriber-only
    Project::factory()->create(['public_published_at' => now()->addDay()]); // not yet public

    $body = $this->withTier(Tier::Public)->getJson('/api/v1/if/projects')->json();

    expect($body['data'])->toBeEmpty();
});

it('never serialises premium fields to a public caller', function () {
    Project::factory()->released()->create(['capital_value_usd' => 12_000]);

    $body = $this->withTier(Tier::Public)->getJson('/api/v1/if/projects')->json();

    expect($body['data'][0])->not->toHaveKey('capital_value_usd');   // absent, not null
});

it('computes aggregates over the entitled set only', function () {
    Project::factory()->released()->create(['capacity_mw' => 100]);
    Project::factory()->create(['public_published_at' => null, 'capacity_mw' => 900]);

    $public = $this->withTier(Tier::Public)->getJson('/api/v1/if/aggregates/pipeline')->json();

    expect($public['total_capacity_mw'])->toBe(100);   // not 1000
});
```

The second one asserts **absence**, not a null value. A null tells the caller the field exists and
that they are not allowed to have it, which is a smaller leak but still a leak.

Add a fourth once introspection is wired: **WordPress unreachable resolves to public, not to an
error and not to the previous tier.** That is the failure mode nobody tests and everybody assumes.

---

## Part 7 — Then the pages

| Phase | Build | Note |
|---|---|---|
| **2** | Project Pipeline — API, WP template, filters, pagination | The full loop on one page. Slowest phase, and the one that makes 3–5 fast |
| **3** | Market Monitor — aggregates, market rollups | First real test of aggregate scoping; metros roll up to countries |
| **4** | Capital Tracker — transactions, charts, investor rankings | Repeats the established pattern |
| **5** | Overview — composes 2–4 | Cheap once the others exist |
| **6** | CMS — CRUD, sources, duplicate prevention, audit trail | Journalists take over from seeders |
| **7** | CSV import with a validation preview | Nothing writes until the whole batch validates |
| **8** | AI-assisted extraction into `record_drafts` | Drafts only — there is no code path from extraction to a published record |
| **9** | Editorial integration — WP publish webhook | Laravel mirrors `wp_post_id` + association; it never scrapes |
| **10** | Delayed-release administration | The mechanism exists from Phase 1; this is the UI over it |

The prototype in `../prototype/infrastructure-futures.html` is the build target for Phases 2–5.
The tier switcher is the entitlement specification expressed as behaviour.

---

## Part 8 — Traps

Each of these has bitten a real project.

| Trap | What it looks like | The guard |
|---|---|---|
| New model, forgotten scope | Everything returns everything | `TierScopeGuard` throws (Step 5) |
| Cache without tier in the key | Subscriber response served to anonymous | Tier in every cache key, always |
| Export bypasses the list scope | Paywalled table, unpaywalled CSV | Export calls the *same* query object |
| FX recomputed on read | Last year's chart changes overnight | Rate frozen at deal date |
| New column added | Silently public | Allowlist resources — new = invisible |
| Introspection fails open | WP hiccup grants everyone premium | Fail closed to public; test it |
| Aggregate over all records | Public dashboard leaks subscriber totals | Aggregates start from `visibleTo()` |
| Duplicate companies | "Equinix" and "Equinix Inc" split a portfolio | Suggest existing records at creation |
| Status as an enum in code | Editors cannot add one without a deploy | Table + typed `stage` mapping |
| Premium hidden with CSS | Present in page source | Never render what the tier cannot have |

---

## Part 9 — How we work through it

Per the academy pipeline, each phase runs:

1. **`/task`** — create the task with an ETA decision (`--eta`, `--no-eta`, or `--needs-estimate`)
2. **Plan gate** — the architect's plan is approved before code. Catching a wrong direction here
   costs minutes; catching it after the build costs days
3. **Build** on a feature branch, TDD, with `environmentalist` standing up an isolated env
4. **Review gate** — `scripts/precommit.sh`, then a teach-back. Nothing is done until both
5. **`/commit`** — mechanical gate, then a Conventional Commit
6. **`/closeout`** — the framed memory, including what we learned. Required even on a clean run

Phases 0 and 1 are worth over-investing in. Everything afterward is repetition of a pattern we
will have already proven.

---

## Part 10 — Questions to take back to TTC

The spec proposes answers to the first two. TTC owns all five.

1. At field level, what exactly is public versus subscriber? We have proposed a split.
2. Is the 24-hour delay per record, per record type, or global?
3. Which statuses are genuinely editor-configurable, and which are structural?
4. What are the internal roles — journalist, editor, admin — and what may each publish?
5. Does "Download data" mean CSV, or does it eventually mean an API key?

And one recommendation on the references: "Deal activity by quarter" plots deal value and
transaction count on two y-axes in one frame. Where the bars and the line cross is an artifact of
the chosen axis ranges rather than anything about the data, and readers reliably read meaning into
it. Two charts, or one indexed to a common base, says the same thing without the false signal.
Built as shown for now — worth raising.
