# Portfolio Analysis

> Run a portfolio-scale decarbonization analysis across all analysis-ready assets in a Soapbox portfolio. For each asset: pulls Audette decarb plan (physics), runs the compute_plan_economics cashflow engine (incremental value bridge + IRR), applies LL/TT allocation decision tree, screens measures by IRR ≥ hurdle. Aggregates to fund-level and portfolio-level summary. Produces presentation-ready HTML report. Works for any client portfolio — parameters are fully configurable per run. Spec 2 of 2 — portfolio ingestion (Spec 1) is a prerequisite. Triggers on: "run portfolio analysis", "analyze the portfolio", "portfolio decarbonization", "run the portfolio", "portfolio summary", "show me the portfolio results", "portfolio IRR", "portfolio CapEx", "run analysis on [client]", after portfolio-ingest completes.

- Skill: `soapboxbuild/portfolio-analysis` (Agent Skill)
- Install (CLI): `npx skillmds@latest add soapboxbuild/portfolio-analysis`
- Raw SKILL.md: https://api.skillmd.com/api/skills/soapboxbuild/portfolio-analysis/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: soapboxbuild (https://skillmd.com/u/soapboxbuild)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/soapboxbuild/portfolio-analysis

---


# Portfolio Analysis

You are running a **portfolio-scale decarbonization analysis** — the workflow that turns
a fully-ingested Soapbox portfolio into a presentation-ready view of required sustainability
capital, value creation, and emissions trajectory across all assets.

**Works for any client portfolio.** All parameters are set per run — there are no
hardcoded client assumptions.

**This skill replaces client-specific helper spreadsheets.** Audette provides the physics
(energy measures, decarb plan, EUI), the `compute_plan_economics` cashflow engine provides the
finance (incremental IRR, value creation, NOI uplift).

**Single-asset engagement:** if the ask is a full asset decarbonization engagement (one
asset, multi-week, gated, client-deliverable), route to the `decarb-plan` skill instead.

---

## Verification & Building-Science Discipline (applies throughout)

This skill runs the **same Data-Verification and Retrofit-Specialist (building-science) agents
that back `decarb-plan`** — the `verifier__*` and `retrofit__*` tools, which are live on every
portfolio agent. It applies them in a **batch-adapted** form, because a portfolio run screens
tens of assets in one pass and cannot human-adjudicate every conflict the way a single-asset
engagement does. These adaptations are deliberate — a full gated single-asset engagement routes
to `decarb-plan`; this is the screening-scale product.

**Ground rules — hold them on every asset:**

1. **No LLM arithmetic on reported numbers — and NEVER reimplement the engine.** Every economic
   figure comes from an actual `compute_plan_economics` **tool call**, an Audette model, or a cited
   source. You never compute a reported number yourself. ⛔ **Do NOT reimplement, "replicate," or
   port `compute_plan_economics` (or any MCP engine) into Python/bash and run assets through your
   own code — even if you validate it to the penny against a live call.** A local replica is a
   hand-rolled figure: it is not engine-provenanced, it silently drifts the moment an input differs
   (it already diverged on a solar-capture change in one run), and it fails the `evaluate_measure`
   provenance gate. Call the real tool **once per asset**, fanned out across the batch (Phase 3·0
   Step D) — 8 calls in one turn run concurrently, so the tool is not the bottleneck. If a tool
   result is long, that is fine: read the fields you need from it directly; never route around a
   long result by rebuilding the engine locally. Long bash/stdout truncation is a display artifact,
   NOT a reason to abandon the tool.
2. **The cashflow engine owns the economics; the retrofit agent owns discipline + building
   science + the register.** ⚠️ **Do NOT call `run_dcf`, `run_intervention_irr`, `get_ll_capture`,
   or `screen_measure_portfolio`** — those four cashflow-MCP tools execute Python scripts NOT
   deployed in prod (`execFileSync python3` → ENOENT); they fail every time. **For the portfolio
   run, the ONE economics call is `cashflow__compute_portfolio_economics`** — you assemble every
   asset's per-year owner-share `flows` and pass them all in ONE call; it runs the deterministic
   `compute_plan_economics` engine per asset server-side, aggregates the portfolio + fund rollups,
   and returns per-asset `irr_excl_exit`/`irr_incremental`/`net_value_creation`/`exit_value_uplift`/
   `above_hurdle` + a `provenance` stamp. Report those numbers verbatim. (Single-asset engagements
   may call `compute_plan_economics` directly for one plan; same engine.) Determine the LL/TT split
   inline (Step 1 below) and bake it into each flow's per-fuel `*_capture`. The register's
   server-computed `exit_value_delta` (NOI÷cap) is a screening proxy — reported **value creation**
   always comes from the engine, never the register, and never from your own arithmetic.
3. **Recommended = screen AND hurdle.** A measure is *recommended in the report* iff
   `retrofit__screen_measures` labels it `recommended` **AND** its DCF IRR ≥ `irr_hurdle`.
   Screen-recommended but IRR-missing → below-hurdle. Screen `defensive` → defensive. Screen
   `needs-data` → needs-data. Compliance-required measures are included regardless of IRR.
4. **Conflicts are logged, not silently picked.** A material data conflict (see Step 5) becomes a
   `verifier__record_finding`; the hierarchy suggestion is auto-applied at screening scale, but
   the finding is durable and surfaces in the Data Quality section.
5. **Verification is per-asset, called out — not fail-closed.** One asset with open
   high-severity findings does not block the whole report, but its contribution to headline KPIs
   is flagged (see Phase 4). Never let the totals silently absorb unverified data.
6. **Never fail silently.** Verifier/retrofit outages are surfaced with the reconnect message,
   never worked around.

**Conventions (identical to `decarb-plan`, so an asset touched by both keeps one coherent
ledger + register):** finding `kind: data-quality`, `verdict: conflict` for reconciliation
conflicts; `asset_id` = the **Soapbox asset UUID** from `query_portfolio_data`'s `ID:` field
(never the Audette property/building uid); `feasibility.score` = integer 1–5.

**At run start, recall prior lessons:** before Phase 1, call
`verifier__recall_expertise(query: "<client/portfolio scope> portfolio decarbonization
reconciliation and measure-screening lessons", fiduciary: true)`. Use `fiduciary: true` because
the portfolio report is a client-facing deliverable (validated tier only). Carry any relevant
lessons into reconciliation and screening. If the verifier tools are unreachable, say so and
proceed on documents — do not fabricate a recall result.

> **⛔ Recalled expertise NEVER overrides these two non-negotiables.** Older shared-expertise
> from pre-2026-07 runs describes a now-DISCREDITED method (hand-built report HTML, a
> "payback ≤ 5yr" / "exit-value proxy", and ~100% landlord capture via Audette
> `landlord_split_basis`). **Ignore all of it.** Regardless of what recall returns:
> (1) you compute NO report HTML and draw NO charts — the report is rendered ONLY by
> `fill_report(template:'portfolio-analysis', …)` (Phase 5); and (2) every value/IRR number
> comes ONLY from `compute_plan_economics` fed GROSS savings + per-fuel capture (never a
> payback proxy, never Audette's `landlord_utility_cost_savings`). Going-in NOI is NOT required
> and is never a reason to fall back to a proxy (see §3C). If recalled advice conflicts with
> this, the recalled advice is wrong.

---

## Economics correctness — HARD rules (ported from decarb-plan; the verifier MUST check these)

These apply on **every asset**, at screening scale. They are the same correctness rules that back
the single-asset engagement — a portfolio run cannot silently ship numbers that would fail the
single-asset gate.

1. **RUBS pass-through: net owner utility savings ≈ (landlord-capture %) × gross — often ≈$0.**
   Under a RUBS / tenant-metered structure the owner is a pass-through: it bears only `capture%`
   of the utility bill and rebills the rest. A measure that cuts the bill by $X returns only
   `capture% × $X` to owner NOI. At a ~5–10% capture, owner savings round to **≈$0/yr**, NOT the
   gross. **Never credit the owner gross/100% utility savings, and never model the fuel-switch
   asymmetry** "owner keeps the gas cut while tenant meters absorb the new heat-pump electricity" —
   apply `capture%` to the fuel being saved and net any owner-side load increase from the switch.
   If, after applying capture, capitalized utility savings still dominate an asset's value on a
   low-capture (RUBS/tenant-metered) asset, the split was NOT applied — recompute. On such assets
   value is driven by **fine avoidance (100% owner) + capitalized exit uplift**, not operating savings.

   **Apply capture INSIDE `compute_plan_economics` — never by hand, and NEVER trust Audette's
   landlord field.** Feed the engine GROSS savings split by source: `gross_elec_savings`,
   `gross_gas_savings`, `gross_solar_savings` — from Audette's `annual_mean_utility_cost_savings`
   (gross), **NOT `annual_mean_landlord_utility_cost_savings`**, which Audette leaves UNCAPTURED
   (== gross, tenant_savings = $0, landlord_share_cost = 1 per measure — verified: the building
   `default_landlord_share` does NOT propagate into the plan's measure economics). Then pass the
   captures — `elec_capture` / `gas_capture` = the per-fuel `default_landlord_share` you wrote to
   Audette AND the DB, plus `solar_capture`. The engine returns owner savings = Σ capture×gross.
   **Solar under BTM/VNM captures at 0.80** (the LL owns the array + allocates the credit): set
   `solar_capture: 0.80` where VNM/export is permitted (rule 1b), else the displaced-load share.
   Sub-metering/billing revenue and BPS fine avoidance stay 100% owner. Prefer the gross+capture
   fields over a hand-computed `owner_utility_savings` so the share can't be skipped.
   - **VERIFY the RUBS and VNM legislation per jurisdiction — never assume it.** The ~10% RUBS
     capture and the 80% VNM solar credit are CONDITIONAL on the jurisdiction actually permitting
     them. For each asset's jurisdiction, check (reference library → `brave-search`/web + `web_fetch`
     → **cite the statute/PUC rule + URL**): (a) whether RUBS / submetering pass-through is permitted
     and any allocation cap — **if RUBS is BARRED, the owner bears the utility → ~100% on
     master-metered, NOT ~10%**; (b) whether **Virtual Net Metering / aggregated NEM / community-solar
     export** is available — **if only behind-the-meter (BTM) net metering exists (no virtual/export
     aggregation), Scenario-C solar value = BTM self-consumption offset only** (owner-share on the
     loads it displaces), NOT the 80% VNM credit. Record each RUBS + VNM determination with its
     source as a `verifier__record_finding` (kind `data-quality`); an unconfirmed jurisdiction
     assumption is flagged in Data Quality, never silently applied.
2. **Landlord-capture is PER END-USE and turns on who BEARS the cost — not who pays the meter,
   not one blended number per asset.** Master-metered / landlord-paid loads (central heating/DHW
   plant, elevators, garage/common ventilation, common lighting, amenity): the owner pays the
   master bill but that is NOT 100% capture. **If the jurisdiction ALLOWS RUBS, assume the owner
   rebills up to ~90% to tenants → net owner capture ≈ 10%**, unless docs show the owner absorbs it
   (true gross lease / no RUBS → ~100%). In-unit tenant-metered loads carry the tenant % (~0–5%).
   Do NOT price a common/central load at the in-unit *blended* split (the elevator-regen −6%→+12%
   error) — but the right figure is the RUBS-recovery split (~10% when RUBS applies), **not an
   automatic 100%; never read "master-metered" as "100% owner."** Never inherit Audette's 15%
   account default either. **Solar under Virtual Net Metering (VNM): assume 80% of solar savings
   flows to the landlord.** BPS **fine avoidance is always 100% owner**. Tenant-side savings are a
   separate figure and do NOT capitalize into the value bridge.
3. **One value number = capitalized exit uplift.** The headline value is the capitalized exit-value
   uplift = (stabilized annual owner-NOI improvement ÷ exit cap), where NOI improvement =
   net-owner utility savings (post-capture, rule 1) + owner-share ancillary + annual avoided fine.
   `compute_plan_economics` returns this. Report ONE value number per asset/plan — do not present a
   PV-of-cashflows `net_value_creation` next to a contradicting capitalized `exit_value_delta`.
   Fine avoidance may also be shown cumulative + PV for context.
4. **CRREM provenance — real curve, never hand-built from Audette fields.** Pull the pathway from
   the **`crrem` MCP `get_pathway`** for each asset's actual country/property-type/region; put those
   points in the trajectory and set `crrem_meta` (country/property_type/region/scenario). Do NOT
   interpolate, eyeball, or reuse an Audette `crrem_pathway_target_*` model field as the plotted
   curve. Portfolio-weight the per-asset tool-fetched curves for the aggregate pathway. If the tool
   is unreachable, say so — never fabricate the curve.
5. **Fine avoidance assessed honestly against the governing metric.** Assess each BPS against the
   metric it actually uses. **Dual-pathway standards (comply via EITHER site-EUI OR GHG-intensity —
   e.g. CO Reg 28) require failing the GOVERNING/elected pathway**, not merely the harder one —
   don't manufacture a penalty off the EUI pathway if the asset clears the GHG pathway. A compliant
   asset gets fine avoidance **null, not 0-that-reads-as-a-number**. No phantom penalties in the
   headline compliance-exposure KPI.
6. **Sanity checks (reject + recompute if violated):** emissions trajectories are **non-increasing**
   (a rising with-plan/BAU carbon curve is a sign/axis bug); at-RUL / bundled-capital-event
   incremental cost is **positive** (only the upgrade spec above the mandatory like-for-like is
   incremental — a re-roof is baseline, only added insulation is incremental); **ancillary/DR revenue
   is NOT capitalized as a perpetuity** (risk-adjust / PV over term); **subscription measures judged
   on annual net**, not capitalized-fee-vs-savings; every headline % equals the underlying
   tonnage/energy math on the **same basis** (never mix grid-inclusive vs measure-only in one figure).
7. **On a re-run, REGENERATE — never `read_file` the prior rendered report HTML** to "get the
   structure." Rebuild the data object from `state` + live tool outputs + the template **schema**;
   re-call `crrem get_pathway`. A stored data object may predate template/rule changes.

   **BPS fine avoidance — use the Regulations & Fines engine, don't hand-estimate.** For each BPS-liable asset call `compute_bps_fine_avoidance` (bps-compliance / Regulations & Fines connector) with the asset's `gross_floor_area_m2` + before/after annual emissions; it returns the fine avoided (100% owner) for carbon-based standards (NYC LL97, Boston BERDO) and `null` + the needed input for EUI-based ones (DC BEPS, Denver — supply the EUI target/gap). Feed the result as `bps_fine_avoidance`. NOTE: fine avoidance tracks the measures' CARBON cut, so it is chiefly a deep-decarb (Scenario D) lever — under an in-hold Scenario-B screen it is often ~$0 (revenue/O&M measures don't cut carbon, and many liable assets already sit under the near-term cap). Never assume a fine number.

---

## Design System

All RSRA HTML output must conform to these rules. Claude must apply them on every run — never drift.

**Colors**
- Navy: `#12253A` — headers, section titles, strong text
- Green: `#4CAF82` — eyebrows, accents, positive signals, chart fills
- Muted: `#64748B` — secondary text, axis labels
- Page bg: `#F8F9FB`
- Section bg: `#fff`
- Border: `#E2E8F0`
- Warn: `#F59E0B` · Danger: `#EF4444`

**Typography**
- Font stack everywhere: `-apple-system,'Helvetica Neue',Arial,sans-serif`
- Zero `Georgia`, zero `serif`, zero `@import`, zero web fonts
- Section label: 9px, weight 600, `letter-spacing:.15em`, `text-transform:uppercase`, color `#1F6B45`
- Section title: 18px, weight 700, color `#12253A`, `border-bottom:1.5px solid #12253A`, `padding-bottom:8px`

**Section chrome pattern**
```html
<div class="section">
  <div class="section-label">EYEBROW LABEL</div>
  <h2 class="section-title">Section Title</h2>
  <!-- content -->
</div>
```

**Charts — inline SVG only**
- Zero external charting libraries (no Chart.js, D3, Plotly, etc.)
- Zero `<canvas>` elements
- Zero CDN `<script>` tags
- All SVG coordinates computed at generation time from the data being reported
- If data is unavailable for a chart, omit the chart entirely — no placeholder SVG

**Hard prohibitions**
- `Paged.js` — never reference or import
- `Georgia` or any serif font
- Any `@import url(...)` for fonts
- Any `<link rel="stylesheet">` or `<script src="...">` pointing to an external host
- External `<img src="https://...">` — all images must be inline SVG or data URIs

**Artifact output rules**
- The report artifact is produced **ONLY** by `fill_report(template:'portfolio-analysis', data)` (Phase 5). You author NO report HTML and edit NO HTML — ever. There is no hand-written artifact and nothing to "update in place".
- Do NOT emit a loading skeleton or any placeholder HTML. While you work, show progress by narrating in chat (the run streams your steps); the FIRST and ONLY artifact you create is the completed `fill_report` render.
- On any revision, recompute the data object and call `fill_report` again — never edit HTML.
- Numeric precision: 2 significant figures (`$1.4M` not `$1,427,000`; `42 kgCO₂e` not `41.7`)
- Mark all benchmark-derived estimates inline with `(est.)`
- The portfolio **report is the design-forward deliverable** (Reports/, gate-only). All
  working/checklist material — per-asset readiness, financial-parameter provenance, open
  questions, adjudication log, verification findings — goes in the ONE growing **helper file**
  per the shared pattern in `skills/helper-files/SKILL.md`: `save_file` to folder `Helper Files`
  as `[start date] - Helper Files - Portfolio Analysis.html` (start date fixed, stored in
  `state.helper`), regenerated from state at each phase. Phase/checklist sections:
  Config · Readiness+Params · Per-Asset · Aggregation · Verification gate · Report. Do not create
  standalone intermediate HTML.

---

## Step 0: Resolve Run Configuration

### 0A — Kickoff gate (run this before anything else)

Check for a prior kickoff file:
```
search_portfolio("portfolio analysis kickoff parameters IRR hurdle")
```

**If a kickoff file is found:** extract the confirmed parameters from it (IRR hurdle, exit
params, utility escalation, value method, add-ons, Audette account, scope). Skip to the
"Confirm before proceeding" block below — present the kickoff params as a summary and ask
the user to confirm or adjust before running.

**If no kickoff file exists:** do not proceed with the analysis yet. Tell the user:
> "Before I start the analysis, let me collect the run parameters. This will only take a minute."

Then follow the **`project-kickoff` skill** for project type **`portfolio-analysis`** — read
`skills/project-kickoff/project-types/portfolio-analysis.md` and work through all 6 questions
one at a time. The kickoff skill will save a parameter file; once it's saved, return here and
continue from "Confirm before proceeding."

**If the user explicitly provides all parameters inline** (e.g. "run with 15% hurdle, 2028
floor, CRREM on, account slug: greystar") and there is no prior kickoff file: accept the inline
values, skip the kickoff Q&A, but still present the "Confirm before proceeding" summary before
starting the analysis.

---

### Parameters

| Parameter | Default | Description |
|-----------|---------|-------------|
| `client_name` | (required) | Client name — used in report header and file naming |
| `portfolio_id` | (required) | Soapbox portfolio ID or name to query |
| `fund_filter` | all funds | Comma-separated fund names to include, or "all" |
| `irr_hurdle` | 15% | Minimum IRR for a measure to be recommended |
| `utility_escalation` | 3%/yr | Annual escalation applied to energy savings |
| `discount_rate` | 8% | Discount rate for NPV calculations |
| `exit_year_floor` | 2028 | Assets exiting before this year are moved to floor date |
| `retrofit_lead_months` | 18 | Measures needing > this many months to implement are deferred for near-exit assets |
| `target_years` | [2030, 2035, 2040] | Emissions scenario target years for CRREM comparison |
| `value_method` | inclusive | `inclusive` = NOI uplift capitalised at exit cap + added to terminal CF; `standalone` = IRR on savings only without exit value |
| `top_n_assets` | 10 | Number of assets shown in "Top N by value creation" table |
| `audette_account` | (ask if not known) | Audette customer account slug for `switch_customer_account` |
| `include_crrem` | false | Include CRREM pathway analysis: emissions trajectory chart, stranding analysis, pathway-alignment KPIs |
| `include_bps` | false | Include Building Performance Standards exposure analysis: BPS liability per asset, compliance cost if no action, fine avoidance as a measure benefit |
| `org_goal` | null | Custom organizational sustainability goal (e.g. "net zero by 2040", "50% emissions reduction by 2035"). If not provided, search Portfolio Docs for ESG policy statements, fund mandates, or investor commitments before asking. When set, all report sections that reference emissions trajectory or CRREM add a line showing gap/progress vs. this goal. |

Prompt for each required parameter. Save the confirmed set to a comment in the portfolio
thread so future runs can reuse them without re-entering.

### Confirm before proceeding

Display the resolved parameters in a compact table and ask: "Run with these parameters? (y to proceed, or change any value)"

```
Client:         [client_name]
Portfolio:      [portfolio name]
Funds:          [all / fund list]
IRR hurdle:     [X]%
Exit year floor: [YYYY]
Target years:   [YYYY, YYYY, ...]
Utility escal.: [X]%/yr
Discount rate:  [X]%
Value method:   [inclusive / standalone]
Audette acct:   [slug]
CRREM analysis: [yes / no]
BPS analysis:   [yes / no]
Org goal:       [goal statement / none]
```

---

## Phase 1: Readiness Check & Financial Parameter Collection

**Before prompting the user for any parameters, search Portfolio Docs for existing data.**
Exit years, cap rates, fund assignments, hold periods, and IRR targets are often already
uploaded as spreadsheets, IC memos, or fund term sheets. Extract what you can before asking.

### 1A — Search Portfolio Docs first

**If the user attaches a file inline in the thread** (e.g. a spreadsheet with exit years), its content is already in the message — read it directly. Do NOT web_fetch the attachment URL or the Supabase signed URL.

For documents already uploaded to the portfolio (not attached inline), two tools — pick by content type:

- **`read_portfolio_file(file_name)`** — for SPREADSHEETS and any file where exact
  cell values matter (exit years, cap rates, asset registers, utility tables).
  Returns row-aligned CSV per sheet. Semantic search chunks flatten tables and lose
  row alignment — never rely on `search_portfolio` for per-asset numeric parameters.
- **`search_portfolio(query)`** — for narrative documents (IC memos, ESG policies,
  audits) where you need relevant passages, not exact rows.

**Never web_fetch any URL to access portfolio docs.**

Workflow: `list_portfolio_files()` to see what exists → `read_portfolio_file` for each
financial spreadsheet → `search_portfolio` for narrative parameters.

Call `search_portfolio` with specific terms to find financial parameters:
```
search_portfolio("exit year cap rate hold period")
search_portfolio("IRR hurdle rate fund")
search_portfolio("acquisition model underwriting")
search_portfolio("ESG sustainability net zero emissions target")
```

Extract from the returned chunks:
- `exit_year` per asset
- `exit_cap_rate` per asset or fund
- `fund_name` assignments
- IRR hurdle rate
- Hold period assumptions
- Any sustainability/emissions goal (e.g. "net zero by 2040")

Use `list_portfolio_files` only to see what documents exist — it does not return file content. Use `search_portfolio` for all content access.

Only ask the user for parameters that couldn't be found in the docs. If you found partial data (e.g. exit years but no cap rates), confirm what you found and ask only for what's missing.

### 1B — Load all assets and build the UUID map

Call `query_portfolio_data()` to get every asset's UUID, name, and current metadata in one call. **Do this before anything else — the UUID map is required for all write-back operations.**

```
query_portfolio_data(include_metadata: true, analysis_ready_only: false)
```

The tool returns a **pipe-delimited text block**, one asset per line, in this format:
```
ID: <uuid> | Asset: <name> | Address: <addr> | Type: <type> | Built: <year> | GFA: <m²> | Audette: <audette_property_id> | ESPM: <espm_property_id> | Fund: <fund> | Exit: <year> @ <cap_rate>% | Lease: <lease_structure> | Metering: <metering_config> | Analysis ready: yes/no
```

Fields only appear when they have a value — a missing `Fund:` or `Exit:` field means that metadata has not been set yet.

**Parse each line** and build an internal map:
```
{ asset_name → { uuid, audette_property_id, espm_property_id, fund_name, exit_year, exit_cap_rate, lease_structure, metering_config, analysis_ready } }
```

Critical rules:
- `ID:` is always the first field — that is the asset UUID to use for all write-back calls.
- Asset UUIDs come ONLY from the `ID:` field in this response. Never extract UUIDs from file paths, URLs, Audette IDs, or any other source.
- `audette_property_id` is a top-level field (prefixed `Audette:` in the output), NOT inside the metadata block.
- `exit_year` and `exit_cap_rate` are in the metadata section (prefixed `Exit:`). If absent, these fields are unset and must be collected from docs or the user.
- Do NOT call `get_asset_record` per asset — `query_portfolio_data` already returns everything in one call.

Partition assets into:
- **Analysis-ready** (`metadata.analysis_ready: true`) — proceed
- **Missing params** — collect before proceeding
- **Disposed** (`metadata.status: 'disposed'`) — emissions inventory only

### 1C — Bulk-fill from register (if available)

If the user attaches a spreadsheet in the thread, its content is already in the message — read it directly from the message context. Do NOT web_fetch any URL to access an attached file.

If a spreadsheet or asset register exists in the portfolio files, read it with
`read_portfolio_file(file_name)` — this returns actual rows, so each asset's exit
year/cap rate stays glued to its name. Match rows to the UUID map from 1B by asset
name (fuzzy match). Then write parameters back:

```
update_asset_metadata(asset_id: "<uuid-from-1B>", updates: { fund_name: "<fund>", exit_year: <year>, exit_cap_rate: <rate> })
```

Or in bulk when the same value applies to multiple assets:
```
bulk_update_metadata(asset_ids: ["<uuid1>", "<uuid2>", ...], updates: { exit_year: <year> })
```

**Always use UUIDs from the 1B map. Never guess or construct a UUID from any other source.**

Auto-populate any field found in the register. Only prompt for what's still missing.

### 1D — Collect missing parameters asset-by-asset

For each asset still missing required fields, present a focused card:

```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[N/total] — [Asset Name]   [fund] · [type]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✓ Audette linked: [building name]
  Exit year       → ?  (e.g. 2030, or 'disposed')
  Exit cap rate   → ?  (e.g. 4.5%)
  Lease structure → gross / nnn / modified-gross / rubs / green-lease
  Metering config → master / individual / submeter-passthrough
  Jurisdiction    → [auto-detected or blank]
  BPS liable      → [yes / no / unknown]   ← only shown if include_bps: true
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```

Only show missing fields. `skip` leaves null and excludes the asset from analysis.
`disposed` marks the asset and includes it in emissions inventory only.

**READ `metadata.utility_split` FIRST (canonical, persisted).** If the asset already has
`metadata.utility_split`, USE it — do not re-derive and never default to 100% / Audette's
landlord-share. Only when it is ABSENT do you run the derivation below; then **persist the result
back** via `update_asset_metadata(asset_id, {utility_split: {...}})` (allowlisted key — if the tool
reports a REJECTED key the write failed, surface it) so the next thread reads it instead of
re-defaulting. See the persistence contract in the `utility-split-estimation` skill.

**Audette landlord-share is NOT optional — WRITE it to Audette AND the DB, then VERIFY.** Audette's
measure model applies its OWN per-building landlord-share to compute owner savings, and it re-defaults
to ~100% on every model re-pull. So the confirmed split must be written into Audette
(`default_landlord_share_electricity` / `default_landlord_share_natural_gas`, per building) AND into
`metadata.utility_split` — BOTH — and read back to confirm it persisted (see `utility-split-estimation`
→ "Audette landlord-share settings"). **Before trusting any asset's value/IRR, confirm its Audette
landlord-share equals the confirmed split; if Audette is at its default (~100%), the owner savings,
IRR, and value creation are overstated — do not use them.** If Audette is unreachable, STOP and ask the
user to reconnect; never compute at 100%.

**Check the leasing brochures per asset (part of the workflow, not optional).** Before setting the
split, pull each asset's current leasing marketing — `apartments.com`, the property's own site,
Zillow rentals (via `brave-search`/`web_fetch`) — and read it for two things: (1) **whether
utilities are included** in rent or itemized as resident-paid ("utilities included", "resident pays
electric/gas/water", a RUBS/flat-fee line) — this is current, market-facing evidence of who bears
each fuel and directly sets/confirms the capture split; and (2) the **amenity set** (pool, spa,
clubhouse, fitness, common laundry, EV stalls, garage) — amenities are landlord-paid common loads
that carry their own 100%-owner capture AND surface measure opportunities (pool-heat HP, common-area
controls, EV). Cite the listing + URL; a brochure statement outranks a building-form inference.

Then determine LL/TT allocation **inline** (do NOT call `get_ll_capture` — it is broken in prod; the
hosted runtime ships no Python). Set `ll_capture_pct` per the **end-use capture map** in economics
correctness rules 1–2 above: master-metered/landlord-paid loads (central plant, elevators, common,
amenity) at their RUBS-recovery capture (~10% net owner where RUBS applies — verified per rule 1b —
or ~100% if the owner absorbs); in-unit tenant-metered ≈ 0.0–0.05; BPS fine avoidance = 1.0 always;
net owner utility savings = `capture% × gross` (≈$0 on low-capture/RUBS assets — never the gross).
If `include_bps: false`, treat fine avoidance as "not assessed" and omit it from owner NOI.

Write results to asset metadata:
```
update_asset_metadata(asset_id: "<asset_id>", updates: <params_object>)
```

Show edge-case warnings inline (NNN paradox, solar consent, RUBS recovery, BPS liability).

### 1E — Readiness summary

Report before proceeding:
```
[N_ready] assets ready · [N_missing] skipped (missing params) · [N_disposed] disposed
```

If `N_ready = 0`: stop and ask the user to provide financial parameters.

Confirm:

### 1E-bis — Load confirmed analysis-ready assets for the run

```sql
SELECT id, name, address, property_type, metadata
FROM assets
WHERE portfolio_id = '<portfolio_id>'
  AND metadata->>'analysis_ready' = 'true'
ORDER BY name;
```

Count: `N_ready` assets ready to analyze.

Confirm:
> "Found [N_ready] analysis-ready assets. [N_skipped] assets skipped — missing [fields].
> Ready to run? (y to proceed, or list specific assets to exclude)"

### 1F — Identify Audette gaps

For each asset loaded in 1B, check the `audette_property_id` column (NOT `metadata.audette_building_id` — that field does not exist). Assets without an Audette link will have lower-quality energy data.

| Status | Count | Treatment |
|--------|-------|-----------|
| `audette_property_id` not null | N | Full physics model from Audette MCP |
| `audette_property_id` is null | N | Use documents (PCA/audit) + BPD MCP benchmark — label all values `(est.)` |

Report the gap count before proceeding. Do not stop — assets without Audette are included with
lower confidence, clearly labeled.

---

## Phase 2: Begin the analysis (NO loading artifact)

Do **not** emit a loading skeleton or any hand-written HTML. Announce in chat that the analysis is
starting and stream your progress there as you work the phases. **The only artifact you ever create
is the final report, and it is produced solely by `fill_report(template:'portfolio-analysis', data)`
in Phase 5.** You never author or edit report HTML — there is no skeleton to fill in, no file to
update in place. (Authoring HTML by hand is the #1 cause of a broken, off-template deliverable and is
prohibited.)

---

## Phase 3: Per-Asset Analysis

**Audette is the mandatory primary data source. Call it for EVERY linked asset. Do not skip Audette and proceed on docs alone — if Audette is skipped, the analysis is incomplete and must say so.**

**Do NOT process assets one-at-a-time — but do NOT fan out more than 6 tool calls in a single
turn either.** Issue the same step's tool call for a group of assets in ONE message (a "burst"),
read the whole set of results, then move on. The dependency is **step → step, not asset → asset**
(e.g. `get_building_model_details` needs the `find_buildings` result, but asset B's
`find_buildings` does not need asset A's), so grouping is safe.
⚠️ **Hard cap: ≤ 6 tool calls per turn.** The managed-agents runtime drains a turn's parallel
tool calls through a one-at-a-time `requires_action` handshake; bursts larger than ~6 churn badly
and can stall the run before it ever renders. So a burst of 6 is the sweet spot: it kills the
~200-serial-round-trip problem of one-call-per-turn WITHOUT tripping the runtime's churn. If a
batch has more than 6 assets, split the step into back-to-back 6-call bursts.

After each burst, stream a progress line as the set completes:
`✓ Batch 1 economics: 6/6 assets — 24 measures above hurdle` (not one line per asset).

**Progress lines are mandatory, not optional.** After each burst (find_buildings, decarb plans,
compute_plan_economics), emit a short text line — e.g. `Pulled decarb plans 6/6 (12/39 total)…`.
Never go more than one burst without a visible line of text; a silent tool storm looks like a hang.

### 3·0 — BATCHED EXECUTION (required for portfolios > ~8 assets — this is how the run converges to a render)

A large portfolio (e.g. 39 assets) does NOT fit in one session's working context: holding every
asset's raw Audette plan + CRREM curve + legislation at once bloats the model's context until it
can no longer emit `fill_report` — the run grinds for an hour and then summarizes instead of
rendering. **Process assets in BATCHES and persist a compact result per asset, so the final render
turn reads small saved summaries, not the raw data.**

Protocol:
1. **Batch size ≤ 6 assets.** Split the analysis-ready set into batches of at most 6, so each
   fan-out step is a single ≤6-call burst (matches the runtime's clean drain size).
2. **Run 3A–3D as ≤6-CALL BURSTS over the batch, then PERSIST a compact `pa_result` per
   asset and DROP the raw data.** Do not walk the batch asset-by-asset. Instead sweep the batch
   step-by-step, issuing that step's call for the (≤6) assets in ONE message:
   - **Step A (burst):** `find_buildings` for the ≤6 assets in one message → read all → then
     `get_building_model_details` for the resolved building models (again ≤6 per turn).
   - **Step B (burst):** `get_reported_carbon_reduction_plan` (+ any doc `search_portfolio`) for
     the ≤6 in one message.
   - **Step C (reason, no tool):** for each asset determine the per-fuel LL capture map **inline**
     from correctness rules 1–2 (RUBS/VNM/master-meter) — do **NOT** call `get_ll_capture` (broken
     in prod); assemble each asset's owner-share `flows`.
   - **Step D (ASSEMBLE flows — do NOT compute economics here):** for each asset build owner-share
     `flows` **PER MEASURE** (`year` 2026…exit_year, each with `incremental_capex`,
     `gross_elec_savings`/`gross_gas_savings`/`gross_solar_savings` + `elec_capture`/`gas_capture`/
     `solar_capture`, `ancillary_revenue`, `bps_fine_avoidance`) and store on each `measures[].flows`.
     These per-measure flows are the SINGLE source of truth: Phase 4 sums them for the asset-level
     economics AND screens them per measure for the A/B/C/D emissions scenarios — so the headline
     value and the trajectory can never diverge. Do NOT also build an asset-level `flows` aggregate by
     hand. Additionally capture, for the trajectory: `scope2_fraction` (the electricity share of the
     asset's baseline emissions) and `grid_ef_annual` (the annual electricity emission-factor series
     from `crrem get_emission_factors` for the asset's country) — these drive the deterministic BAU
     grid-decay curve.
     **Do NOT call `compute_plan_economics` per asset, and NEVER compute or aggregate IRR / value /
     capitalization yourself (no bash, no Python, no spreadsheet).** The deterministic engine runs
     exactly ONCE for the whole portfolio in Phase 4 via `cashflow__compute_portfolio_economics` —
     that single call, and its `provenance` stamp, is the ONLY valid source of every economic number.
   Then write each asset's result via `update_asset_metadata(asset_id, {pa_result: {...}})` and
   do NOT carry that asset's raw Audette/CRREM/legislation payloads forward into the next batch.
   The `pa_result` is the ONLY thing that must survive to Phase 4. Compact shape:
   ```
   pa_result = {
     baseline_ghgi_kg_m2, baseline_tco2e, gfa_m2, jurisdiction, fund,
     rubs_status, vnm_status, capture_map_summary,          // cited determinations (rule 1b)
     crrem_meta,                                            // MUST include the resolved CRREM region code + country + property_type — Phase 4 passes {region, gfa_m2} to get_portfolio_pathway (do NOT fetch a full per-asset curve in Phase 3)
     scope2_fraction, grid_ef_annual: [{year, factor}],     // BAU grid decay (crrem get_emission_factors)
     bps: {liable, governing_metric, annual_fine_by_year},
     measures: [{ name, family, install_year, annual_tco2e_reduction, capex_net, capture_pct,
                  is_solar, compliance_required,            // is_solar → forced into Scenario C; compliance_required → forced into all scenarios
                  flows: [{ year, incremental_capex, gross_elec_savings, gross_gas_savings,
                            gross_solar_savings, elec_capture, gas_capture, solar_capture,
                            ancillary_revenue, bps_fine_avoidance }],  // SINGLE source: summed for economics + screened for trajectory
                  screen: recommended|below|defensive|needs-data }],  // economics + scenario membership FILLED by the Phase-4 engine call, not here
     exit_year, exit_cap_rate, data_confidence
   }
   ```
   (The retrofit register already persists the measures durably; `pa_result` is the compact
   analysis rollup — its `flows` feed the Phase-4 engine call.)
3. **Between batches, emit a progress line** (`Batch 3/5 complete — 24/39 analyzed`) and keep only
   `pa_result`s in context.
4. **Phase 4 computes ALL economics AND the emissions trajectory via ONE `cashflow__compute_portfolio_economics` call — never in code.**
   Read all assets' persisted `pa_result` (NOT by re-pulling Audette). First call
   `crrem__get_portfolio_pathway(country, propertyType, assets:[{region, gfa_m2}])` ONCE to get the
   blended `blended_pathway`. Then call
   `cashflow__compute_portfolio_economics(crrem_target_annual: <blended_pathway>, irr_hurdle,
   d_exit_year: 2040, assets: [{asset_id, asset_name, fund, exit_cap_rate, exit_year, gfa_m2,
   baseline_intensity_2025, scope2_fraction, grid_ef_annual,
   measures: [{install_year, annual_tco2e_reduction, is_solar, compliance_required, flows}]}, …])`
   **ONCE** with every analysis-ready asset — pass `measures[]` (the tool sums them), NOT an
   asset-level `flows` aggregate; pass the blended CRREM curve as the top-level `crrem_target_annual`
   (do NOT hand in per-asset `crrem_annual`). Use its output **verbatim**: per-asset `irr_excl_exit` / `irr_incremental` /
   `net_value_creation` / `exit_value_uplift` / `above_hurdle`, the portfolio + fund aggregates, AND
   `trajectory.emissions_trajectory` + `trajectory.crrem_trajectory` → 

…(truncated)
