Skip to main content

Phase 3 — Design Plan (CTO Call, 2026-08-05)

Written 2026-08-06, following the /system-design workflow (docs/.claude/skills/system-design/). This is the design plan for the changes discussed on a CTO call, worked through before any of the actual Phase 3 docs, DBML, or diagrams were touched. The hand-off from this plan: update Overview, Model Specifications, and Use Cases, update jodapp-api/docs/db/billing.dbml, add new model-spec pages under Billing Model Specifications, update diagrams, and add decision entries to Billing domain decisions.

Status: implemented 2026-08-06, then carried further. All of the hand-off items below are done — see the Next Steps section at the end of this page. This page stays published as the record of why the CTO call's changes were made, not as an in-progress plan.

Updated 2026-08-18 — several specifics below are now historical, not current

Design sessions on 2026-08-10, 2026-08-12, and 2026-08-14 revised parts of this call's design further — most visibly, the Billing::EntitlementHold table this page describes was removed entirely, and Billing::CreditGrant was replaced by the broader Billing::CreditAction. The current, authoritative record of every change is Billing domain decisions D2 through D12. This page's terminology is updated in place below so nothing here is factually wrong, but for current mechanics, read the decisions page and the Use Cases doc — this page is kept as the record of the call that started the change, not as a mechanics reference.

Renumbered 2026-08-19: the Use Cases page's numbering now starts at UC-1 for Phase 3's own use cases, instead of continuing from Phase 2's UC-9. Every UC-9–UC-16 reference below is this call's original numbering, unchanged, since this page is a historical record of the call itself — see the Use Cases doc for the current numbers (UC-9→UC-1, UC-10→UC-2, UC-11→UC-3, UC-12→UC-4, UC-13→UC-5, UC-15→UC-6).

Five things came out of the call. Each section below: what was asked, the design, and what's still open.


Scope confirmed with Imam (2026-08-06)​

Before drafting options, the first step was checking which of these are full Phase 3 implementation vs design-only vs out of scope. Answers:

ItemScope this phase
Free/trial credit grant without an invoiceMechanism + model only. No change to the signup/trial-day flow. Admin-triggered manual grant. Wiring it into signup is a later phase.
Careers::Job consuming placement credits (immediate recognition)Out of scope. Phase 3 stays Ads-only for reserve/consume/release. Careers::Job posting keeps working exactly as it does today (day-based trial, not credit-based). Documented here as a forward-looking note only.
Credit expiry (12 months default, per lot)Full implementation. Schema + the background job that actually expires lots and writes ledger entries.
Backfilling ads already live in production (no billing check at all)Phase 3 use case (UC-14).

1. Placement entitlement policy change: pooled/proportional_avg → fifo_lots/lot_based​

Straightforward per the CTO's before/after Billing::Entitlement dump — update the seeded placement row:

ColumnBeforeAfter
allocation_policypooledfifo_lots
recognition_policyproportional_avglot_based
unit_name, is_reservable, instrumentunchangedunchanged

(Later simplified: the shipped allocation_policy enum has one value, plain :lots, not :fifo_lots — the pooled value was dropped from the enum instead of kept alongside it. See Billing D2.)

This alone is a one-line data change. The actual work is everything downstream of it — see §2. This is the reason Billing::EntitlementLot moves from Phase 4 into Phase 3 — once allocation_policy says fifo_lots, there must be lots to allocate from, for both instruments.

For a plain-English walkthrough of what pooled vs fifo_lots and proportional_avg vs lot_based actually mean, with worked numbers, see billing-note/08-pooled-vs-fifo-lots-explained.md (Imam's personal notes, not on this docs site).


2. Billing::EntitlementLot for placement — the real scope increase​

This is the part worth being explicit about: this pulls the FIFO lot engine that was scoped for "Phase 4 — gig" into Phase 3, exercised by placement first. It's not just "add one model" — it changes UC-10/11/12 (reserve/consume/release), which the Phase 3 docs previously specified as pure balance-level pooled math with no lot awareness at all. Flagging this clearly because it's a meaningfully bigger build than the original Phase 3 docs described, even though it's exactly what was asked for on the call.

Why lots, not pooling — restating the CTO's reasoning​

  • Credits need to expire relative to when they were purchased — a pooled balance has no concept of "which purchase this credit came from," so it can't expire correctly.
  • Finance hasn't confirmed whether free and paid credit revenue can blend (see §6). Even if they say yes, expiry still needs per-purchase tracking — blending doesn't remove the need for lots, it only changes the recognition formula on top of them.

What changes in the reserve → consume → release flow​

The original Phase 3 UC-10/11/12 read/write Billing::EntitlementBalance directly, with no lot involvement — that model stays correct as a fast projection, but it's no longer where the recognition math happens. New mechanics, mirroring the already-designed gig lot pattern:

Reserve (UC-10): when a campaign submits, walk the account's placement lots oldest purchased_at first, skipping any with expired_at set, and draw credit_cost_snapshot units across as many lots as needed. For each lot touched, write one Billing::EntitlementLotAllocation row (allocation_type: :reserve). This locks the hold's lot composition — which lots, and how many units from each — at reserve time.

Consume (UC-11, daily): the day's units_to_consume draws from the same lots the hold was reserved against, in the order they were reserved (not re-run through FIFO — the composition is already fixed). Each lot's units_reserved decreases; each lot's deferred_revenue_remaining_cents decreases by that lot's own fixed per-unit rate:

lot_unit_rate_cents  = lot.deferred_revenue_total_cents / lot.units_purchased   (fixed at lot creation, never recomputed)
recognised_from_lot = units_consumed_from_lot × lot_unit_rate_cents

Rounding rule: every unit except the lot's last one uses floor(lot_unit_rate_cents); the last unit consumed from a lot takes whatever deferred_revenue_remaining_cents has left over. This guarantees a lot's remaining balance hits exactly 0 when fully consumed, with no drift — the same integer-safety reasoning used elsewhere in Billing for tax rounding on invoice lines (round down, never up, so the customer is never overcharged by a rounding fraction).

Release (UC-12): same idea — release returns units to the specific lots they came from (units_reserved -= N, units_available += N per lot), recorded via allocation_type: :release rows.

This means billing_ledger_entries.pool_units_before / pool_deferred_revenue_before_cents become dead columns. They existed to snapshot pool state for the old proportional formula. Under lot-based recognition (now true for both instruments), nothing reads a "pool state before" — the authoritative record of what happened is the EntitlementLotAllocation rows themselves. Both columns are removed from the DBML rather than carried as dead schema — nothing had shipped against them yet.

(The shipped recognition formula also differs from this page's fixed-rate design — see the correction note at the end of §2.)

Schema gap found: gig-only columns were NOT NULL​

billing_entitlement_lots.platform_fee_rate_bps / platform_fee_total_cents / platform_fee_remaining_cents were NOT NULL in the pre-Phase-3 DBML — written when the table was gig-only. Placement lots never populate these. Made nullable, gig-only. New columns added, placement's mirror of the same pattern:

  • deferred_revenue_total_cents (bigint, nullable — placement only)
  • deferred_revenue_remaining_cents (bigint, nullable — placement only)

Same on billing_entitlement_lot_allocations: added deferred_revenue_recognised_cents (nullable, placement only, consume allocations only) alongside the existing platform_fee_recognised_cents (gig only).

source_reference_type previously noted polymorphic(Billing::InvoiceLine) only — broadened to also allow Billing::CreditGrant (see §4).

Corrections made after this call (2026-08-10 through 2026-08-14)
  • No hold table. Billing::EntitlementHold — described in this section as "opens a hold" — was removed entirely. A placement's reserved credits are computed from the ledger, not stored in a summary row (Billing D8).
  • reference_type/reference_id renamed to source_type/source_id across billing_ledger_entries, billing_entitlement_lots, and (its polymorphic column) billing_credit_actions — same naming as billing_outlet_budget_transfers.source_type.
  • The recognition formula is not a fixed rate set at lot creation. It is recomputed on every movement from what remains on the lot: units_moved × deferred_revenue_remaining_cents ÷ (units_available + units_reserved). This reaches the same end state (every lot settles to exactly 0) without a special-cased "last unit absorbs the remainder" rule — see Billing::EntitlementLot — The deferred revenue on a lot.
  • Spend order is soonest-expires_at-first (FEFO), not plain oldest-purchased_at-first (FIFO). A lot with no expiry sorts after every lot that does.

3. Credit expiry​

New columns on billing_entitlement_lots:

ColumnTypeNotes
expires_attimestamptz, nullableSet at lot creation. purchased_at + Entitlement.default_expiry_months. Nullable so a lot can be created with no expiry (e.g. if a future instrument never expires).
expired_attimestamptz, nullableSet by the expiry job when it actually sweeps the lot. Null = not yet expired (even if expires_at has passed and the job hasn't run yet).

New column on billing_entitlements:

  • default_expiry_months (integer, nullable) — 12 for placement. null for gig (not decided, and gig grants aren't implemented yet, so moot for now).

New entry_type: :expire. A daily job finds every lot where expires_at <= now and expired_at IS NULL, and for each:

expired_units = lot.units_available            # only the UNRESERVED remainder expires
LedgerEntry(
entry_type: :expire,
available_delta: -expired_units,
reserved_delta: 0,
reference_type: "Billing::EntitlementLot", reference_id: lot.id,
...revenue fields, see below
)
lot.units_available = 0
lot.expired_at = now

Business rule — units currently on hold from an expiring lot are not clawed back. If a campaign has already reserved units from a lot whose expires_at passes while the campaign is still running, that reservation is honoured — the campaign keeps consuming normally. Only the lot's still-unreserved units_available expires. Reasoning: clawing back an active campaign's reserved credits mid-flight would break a live campaign over a purchase-date technicality the employer has no visibility into. Confirmed with Imam as the intended behaviour — see Decisions below.

Open question — what happens to a lot's deferred revenue when it expires (breakage)? Two options:

  • Option A — write off. deferred_revenue_delta_cents: -remaining and it simply disappears — neither recognised nor deferred anymore.
  • Option B — recognise as "breakage revenue" (chosen default). SFRS(I) 15 permits recognising unredeemed stored value as revenue once redemption is judged remote — which expiry is, by definition. recognised_revenue_cents: +remaining, deferred_revenue_delta_cents: -remaining. This keeps Jod's recognised-revenue figure accurate (the money was collected and is never coming back to the customer as credits) instead of quietly vanishing from every report.

The design defaults to Option B, confirmed with Imam, but this is a finance-facing accounting policy question, same category as §6's blending question — it still needs formal finance confirmation before ship, not something settled unilaterally by this plan. Flagged in the Open Questions section below.


4. Free credit grant without an invoice — Billing::CreditGrant​

Renamed after this call

This model shipped as Billing::CreditAction (table billing_credit_actions), not Billing::CreditGrant. It also grew a third action_type this section doesn't cover (expiry_extension, for giving a lot more time) and its reason enum below did not ship as designed — see the correction after this section.

New model, new table billing_credit_grants. Shaped like Billing::InvoicePosting (idempotent posting record) crossed with a minimal Billing::InvoiceLine (it carries the units itself, since there's no invoice line to read from):

ColumnTypeNotes
billing_account_idbigint, FK
billing_entitlement_idbigint, FKWhich instrument — Phase 3 only exercises placement
units_grantedbigint, not null, > 0
reasonstring, not nullrails_enum(:trial, :goodwill, :promotional, :backfill, :correction)
notestring, nullableFree-text detail for the admin doing the grant
granted_attimestamptz, not nullBecomes the created lot's purchased_at
expires_attimestamptz, nullableOverride. If null, defaults to granted_at + Entitlement.default_expiry_months when the lot is created
admin_created_bybigint, FK identities_admins, nullableNullable the same way InvoicePosting.posted_by_admin_id is — a manual Team Portal grant always has an admin; the ads backfill (§7, run once as a script) does not
idempotency_keystring, unique, not null

Actor: Admin (Team Portal), manual only, this phase — matches the scope decision above. When created, in one transaction:

  1. Create one Billing::EntitlementLot (source_reference_type: "Billing::CreditGrant", source_reference_id: grant.id, units_purchased/available: units_granted, deferred_revenue_total_cents: 0, deferred_revenue_remaining_cents: 0 — it's free, so there's nothing to recognise later; expires_at per above).
  2. Write a grant Billing::LedgerEntry (available_delta: +units_granted, deferred_revenue_delta_cents: 0, reference_type: "Billing::CreditGrant").
  3. Update Billing::EntitlementBalance same as any other grant.

This deliberately reuses the exact grant mechanics UC-9 already specifies for invoices — only the source and the ($0) money differ. admin_created_by is nullable even though Phase 3's UI-facing use case is always admin-triggered, so the same underlying model/service can be called by the UC-14 ads backfill script without inventing a second grant table later, and so the eventual trial-signup automation (a future phase) doesn't need a schema change either — it becomes a second caller of the same service, with admin_created_by: nil.

Corrections made after this call (2026-08-10 through 2026-08-14)
  • The model shipped as Billing::CreditAction (table billing_credit_actions), not Billing::CreditGrant — a naming call made because the table is not only a grant record: its third action_type, expiry_extension, moves a date and no units.
  • reason did not ship as the five-value enum below (trial | goodwill | promotional | backfill | correction). The shipped action_type enum is trial_grant | goodwill_grant | expiry_extension only — promotional, backfill, and correction were never built. admin_created_by also lost its nullability: every Billing::CreditAction row names exactly one admin, always. This directly follows from dropping the ads production backfill — see the note after §7 — the one caller this call designed for a nullable, script-triggered row.
  • Full current spec: Billing::CreditAction.

5. Revenue recognition timing — Careers::Job (immediate) vs Ads (daily)​

Out of scope this phase per the confirmed scope table above. Documenting the shape of the future design so it's not lost:

  • The mechanism already supports this with no schema change: "immediate recognition" just means a future Careers::Job consume use case calls consume once, for the full credit_cost_snapshot, at post time — versus Ads' daily loop consuming a fraction each day. Both are just callers of the same lot-based consume mechanics in §2.

  • Multiple campaign placements, same start date, different durations — confirmed this is already fully supported at the Ads::CampaignPlacement level today (start_time is set campaign-wide, end_time comes from each placement's own Ads::PlacementPrice.duration_days, no uniqueness constraint blocks mixed durations). Billing consumption is already per CampaignPlacement, not per Campaign, so this needs no additional billing design — each placement runs its own independent daily consume loop against its own duration_days.

  • Correction to the original UC-11 formula: the original Phase 3 use-cases doc said units_to_consume = credit_cost_snapshot / duration_days, implying duration_days is a column on Ads::CampaignPlacement. It is not — duration_days lives on Ads::PlacementPrice, reached via campaign_placement.placement_price.duration_days. Fixed in the current Use Cases doc.

  • No-decimals daily distribution — chosen algorithm. The CTO's example (10 credits over 7 days → 1,2,1,2,1,2,1) is illustrative, not a specific required sequence. Chosen deterministic algorithm (cumulative-floor, guarantees the days sum to exactly credit_cost_snapshot with no fractions and no drift):

    allocated_through_day(d) = floor(d × credit_cost_snapshot / duration_days)
    day_d_amount = allocated_through_day(d) - allocated_through_day(d - 1)

    Worked (10 credits, 7 days): 1,1,2,1,2,1,2 — same shape as the CTO's example (three days get 2, four days get 1), spread rather than front- or back-loaded. Exact position of the "+1" days isn't a business rule, just a side effect of the formula — any deterministic no-decimal distribution summing to credit_cost_snapshot satisfies the requirement.


6. Open question restated: blend free and paid credit revenue?​

Not a design decision this plan can make — it's explicitly with finance (Mei Chun), per the CTO's message. Restating why it doesn't block building the lot mechanism, and what changes depending on the answer:

  • Consumption order is FIFO regardless of the answer — free credits granted at signup are chronologically first, so they're drawn down first either way. The CTO's blending example ("deducted first from the 50 free credits") is consistent with plain FIFO, not evidence of blending by itself.
  • What blending actually changes is the recognition formula, not the schema. §2's design (fixed per-lot rate, deferred_revenue_total_cents / units_purchased) is the non-blended answer — a free lot has deferred_revenue_total_cents: 0, so consuming it recognises $0, exactly as if free and paid credits were never mixed.
  • If finance says blend: the recognition formula would need to look at the combined pool of all currently-unexpired lots' remaining deferred revenue and remaining units at consume time (closer to the old pooled formula, but computed across lots instead of one account-wide number). This is a formula change inside the consume step, not a schema change — the lot table, the allocation table, and expiry all stay exactly as designed here either way.

Recommendation: build to the non-blended (fixed per-lot rate) design now, since it's the resolvable option, and it does not block adding the blended formula later if finance approves it. Flagged clearly in the docs as pending finance confirmation.

This question closed itself

The lot mechanism that shipped is the non-blended design — there is no code path for a blended, account-wide recognition formula, and none was built. A free lot's deferred_revenue_total_cents is always 0 and never shares a rate with a paid lot. Reviving blending later would be new design work, not a pending decision Phase 3 was waiting on.


7. UC-14: Backfill billing for ads already live in production​

Dropped after this call (2026-08-07)

This use case does not exist in the shipped design. Billing D6 decided the placement launch instead: business stops accepting new free campaigns some days before launch, campaigns already running finish naturally, and launch day has nothing left in flight. No migration code exists for it, or is needed — draining replaced backfilling. The open question below about lump-sum vs retroactive recognition is moot for the same reason.

New use case. Ads previously ran with zero billing enforcement — Ads Use Cases documented "No Billing Required (Early Access)". Once Phase 3 ships, existing Ads::CampaignPlacement rows need billing history that matches what already happened, split by their current status:

Current placement statusBackfill action
completedGrant (via Billing::CreditGrant, reason: :backfill) exactly credit_cost_snapshot units, then immediately consume all of it in one entry — the service already ran and finished, so its revenue is already earned. No daily replay of history.
activeGrant credit_cost_snapshot, reserve it (opens a Billing::EntitlementHold, same as a fresh UC-10 reserve), then let the normal daily UC-11 job continue consumption from today forward only — not retroactively for days already elapsed before the backfill ran.
pending / scheduledGrant + reserve, same as active — billing enforcement didn't exist when these were submitted, so they were never reserved against. Treat as if submitting today.
cancelled / rejectedNo backfill — nothing was ever consumed, no credit is owed.

Each affected company needs its own Billing::CreditGrant sized to cover its placements' total backfilled cost — one grant per company, summing all its placements needing backfill, rather than one grant per placement, to avoid a flood of near-duplicate grant rows (per-placement audit granularity still exists at the ledger/consume-entry level). Confirmed with Imam — see Decisions below.

Run once, ordered and idempotent the same way UC-13 is (each placement processed in its own transaction, skips anything already backfilled — detect via existing LedgerEntry referencing that placement). admin_created_by: nil on the grants it creates (system/script-triggered, no individual admin approving each one) — this is exactly why §4 made that column nullable.

Open question: does finance want backfilled "already completed" revenue recognised immediately in one lump entry (as designed above), or spread back across the days the placement actually ran, dated retroactively? The simpler one-lump-sum approach is what's designed here; retroactive daily entries would require reconstructing which days each placement was actually live, which the existing schema doesn't currently make provable (no daily audit trail exists for pre-Phase-3 placements). Going with lump-sum for exactly that reason.


Schema sketch — DBML deltas summary​

This sketch shows what this call designed on 2026-08-06. It was implemented as sketched, then revised further by the design sessions listed in the notice at the top of this page — most visibly, billing_entitlement_holds no longer exists and billing_credit_grants shipped as billing_credit_actions with a different shape. This block is not maintained and will drift further from schema truth over time — see jodapp-api/docs/db/billing.dbml for the current source of truth, and Billing domain decisions for why each change happened.

Table billing_entitlements {
...
default_expiry_months integer [null, note: 'e.g. 12 for placement. null = never expires. gig: not yet decided.']
}

Table billing_entitlement_lots {
...
expires_at timestamptz [null, note: 'purchased_at + entitlement.default_expiry_months, set at creation']
expired_at timestamptz [null, note: 'set by the expiry job when swept. null = not yet swept']

platform_fee_rate_bps integer [null, note: 'gig only (was NOT NULL — bug, placement lots never populate this)']
platform_fee_total_cents bigint [null, note: 'gig only']
platform_fee_remaining_cents bigint [null, note: 'gig only']

deferred_revenue_total_cents bigint [null, note: 'placement only. Copied from InvoiceLine.amount_cents, or 0 for a free CreditGrant lot.']
deferred_revenue_remaining_cents bigint [null, note: 'placement only. Starts equal to total. Decreases on each consume by that lot\'s fixed per-unit rate.']

source_reference_type string [not null, note: 'polymorphic(Billing::InvoiceLine | Billing::CreditGrant)']
}

Table billing_entitlement_lot_allocations {
...
deferred_revenue_recognised_cents bigint [null, note: 'placement only, consume allocations only']
}

Table billing_ledger_entries {
...
entry_type string [not null, note: 'rails_enum(:grant, :reserve, :release, :consume, :expire, :adjust)']
// REMOVE: pool_units_before, pool_deferred_revenue_before_cents — dead once both instruments are lot-based;
// the allocation rows are now the authoritative per-lot record.
}

Table billing_credit_grants {
Note: 'Grants entitlement units without an invoice — free/trial/goodwill credits. Mirrors InvoicePosting but $0 and no commercial document behind it.'

id bigint [pk, increment]
uuid string [not null, unique]
billing_account_id bigint [not null, ref: > billing_accounts.id]
billing_entitlement_id bigint [not null, ref: > billing_entitlements.id]

units_granted bigint [not null, note: '> 0']
reason string [not null, note: 'rails_enum(:trial, :goodwill, :promotional, :backfill, :correction)']
note string [null]

granted_at timestamptz [not null]
expires_at timestamptz [null, note: 'override. null = default_expiry_months from granted_at']

admin_created_by bigint [null, ref: > identities_admins.id, note: 'null = system/script-triggered (e.g. UC-14 backfill)']

idempotency_key string [not null, unique]

created_at timestamptz
updated_at timestamptz
}

Also checked while editing billing.dbml: the long-flagged billing_entitlement_holds partial-unique-index annotation. jodapp-api was checked out on branch billing-entitlement-hold-partial-unique-index-fix, and the [unique, ...] flag was already present in that branch's billing.dbml. So this was already fixed, just not yet merged to main — no DBML change was needed here.

Overtaken by events (2026-08-12)

billing_entitlement_holds was removed from the design entirely — see Billing D8. The partial-unique-index question this paragraph closed out is moot: there is no longer a table for the index to exist on. (main's D6 is now a different, unrelated decision — opening balances for the placement launch — so the old anchor to "D6" above no longer points at this topic.)


Decisions confirmed with Imam (2026-08-06)​

#QuestionDecision
2Expired credits: write off, or recognise as breakage revenue?Option B — recognise as breakage revenue. §3. This shipped as designed — see Billing D2. Auditor sign-off on the accounting treatment is queued before the first Xero export, not still being decided.
3Reserved units from an expiring lot: honour or claw back?Honour the reservation. §3. Only the lot's unreserved units expire; a running campaign is never disrupted by a purchase-date technicality. ("Hold" was this concept's name at the time — the word, and the table, were retired on 2026-08-12; see Billing D8.)
4UC-14 backfill: one CreditGrant per company or per placement?Moot. UC-14 was dropped entirely on 2026-08-07 — see the note after §7.

Still genuinely open — not this plan's to decide​

  1. Blend free + paid credit revenue recognition? — §6. Closed by construction once lots shipped; see the note after §6.
  2. UC-14 backfill: lump-sum recognition on backfill day, or retroactively dated daily entries? — moot; see the note after §7.

Next steps​

All done as of 2026-08-06 except the diagram re-export, which is tracked separately. Two items below (UC-14, UC-16) describe what this call asked for, not what shipped — see the corrections throughout this page and Billing domain decisions D2 through D12 for what actually shipped.

  • Confirm this plan with Imam.
  • Update jodapp-api/docs/db/billing.dbml per the schema sketch, later revised further (D2–D12).
  • Rewrite Phase 3 Use Cases UC-9–UC-12 for lot mechanics; add UC-13 (Phase 2 posting backfill), UC-15 (expiry job). A planned UC-14 (ads production backfill) and UC-16 (Billing::CreditGrant) were both dropped before shipping — see the notes after §7 and after UC-15 on the Use Cases page.
  • Update Overview assumptions (placement now uses lots; Careers::Job explicitly out of scope; note the pulled-forward-from-Phase-4 scope change).
  • Update Model Specifications summary table.
  • Write new model-spec pages: Billing::EntitlementLot, Billing::EntitlementLotAllocation, Billing::CreditAction (shipped name for the planned Billing::CreditGrant). Update Billing::Entitlement (policy change), Billing::LedgerEntry (add :expire/:refund/ :adjust, remove pool snapshot fields) — all under Model Specifications.
  • Add dated decision entries to Billing domain decisions — D2 through D12, not just D2–D6 as first planned.
  • Re-export the Phase 3 domain diagram (phase-3-domain.drawio → .svg) — still needs a manual pass through the draw.io editor to drop the hold node and rename Billing::CreditGrant to Billing::CreditAction, matching the Overview page's relations table, updated 2026-08-18. No CLI tool was available to automate this pass.