What changed
Every release of Coffee Table Tactics, generated from the repository changelog at build time so the page and the code can never disagree.
Loading…
Every release of Coffee Table Tactics, generated from the repository changelog at build time so the page and the code can never disagree.
Loading…
The milestone that makes a new department's first ten minutes in
/append with a real incident logged and replayed without help. Chartered 2026-09-03 from Mike's "CTT Unturned Stones" review (.planning/seeds/charter-v40-first-ten-minutes.md); issue-driven rather than phase-driven — twenty[v40]GitHub issues, each shipped as its own PR ontomainbetween 2026-09-05 and 2026-09-06, then five human eye gates walked on production (all five passed; the walk produced two fixes, #485 and #486). Record of the OpenMHz spike:.planning/ops/OPENMHZ-COVERAGE.md.
transfer_department_admin RPC promotes a member and steps the caller down in one transaction,
surfaced as a Members action.utils/essentialsChecklist.ts) replaces the empty dashboard, and a pre-save
reminder fires when an incident needs missing roster data. Prod migration 20260905200000 applied
2026-09-06; eleven legacy departments stay unlocated until an admin adds a station location..env, deptDriftGuard stops interpolating raw Postgres text, describeAsyncFailure separates
permission from connectivity, and renderer jargon that collides with the trade's own words is
retired from the UI.department_admin via deptAdminRouteAllowed; a ? help menu in the command bar
(audio guide, keyboard legend, report a problem, email) and an in-editor feedback button.departments.preferences.openmhz,
through the existing update_department_preferences RPC) prefills every import.
api.openmhz.com sits behind a Cloudflare bot challenge, so embedded browsers fail it — real
Chrome works.support@ with CTT templates; the smtp_* config is one block, never a single-field patch.Every finalized incident now produces its own analytics row and tactic record with zero extra typing, and the VISION Critical-Flow-#3 dashboard — admin → trend → filter by tactic → compare periods — is built on data that now exists. Chartered 2026-09-02 (three rulings: one-tap milestones on REAL incidents in edit mode; trailing baseline first, explicit targets optional and NULL by default; keep recharts under the CeoDashboard neutral-metal rule, fire-red only for a target line and values that miss it). No forecasting of any kind (VISION Principle 5); training excluded by default; all aggregation server-side; every derived write merge-preserving and idempotent, hand-entered wins. Built in tandem with v38 on
milestone/v39-trend-analytics(ROADMAP-v39.md/REQUIREMENTS-v39.md, 26 v1 REQ-IDs), shipped as six phase PRs (#429–#433) merged 2026-09-06. The Phase-262 D-3 eye gate — whether five charts read at a glance, a human judgement test-green cannot discharge — was walked on staging 2026-09-05 → 2026-09-06 by Mike with Claude driving: PASS WITH NOTES (.planning/phases/262-trend-dashboard-v2/262-EYE-GATE.md). Fold record: PR #492 (archive,.planning/phases/v39/v39-GATE.mdprod-apply record, STATE pointer, CLAUDE.md deltas). Still open at close: SHIP-04, thecapabilities.mdpromotion at the Oct 15 analytics reprise.
incident_metrics row as a by-product of the workflow: dispatch/on-scene/water/fire-out
timestamps from CAD unit_arrived and metric_* events, one migration. One-tap milestones open
to REAL incidents in edit mode against the audio clock (the training arm byte-identical). Review
fixes: fetchIncidentMetricsStrict returns row | none | error so a failed read can never be
mistaken for "no row yet" against the full-row-replacement RPC — a read error writes nothing;
persistMetricAggregation fixed the same way.tactics_state.custom, the 27th state-mutating replay event
custom_tactic_toggle), and a trigger-rebuilt incident_tactics projection flattens the blob for
SQL — derived, never raising into its caller, empty for drafts. customTacticId.ts is the sole id
rule, mirrored in SQL. Review fix: useTacticsAutoCheck no longer wipes custom on auto-check.department_benchmarks gains
nullable target_sec / target_rate_pct / metric_key, edited per row in Admin → Benchmarks;
get_department_baseline returns the department's own trailing median and sample size per metric
(a row for every key, n = 0 included); custom tactics reach benchmarks under custom:<slug>.
CTT never invents a target.percentage >= 50 ? green : fire-red verdict literal is deleted —
no judgement without a target.n
(reportN.ts, exactN / UNKNOWN_N); averages over fewer than MIN_N_FOR_AVERAGE = 3
finalized incidents are withheld with the reason, counts never are; per-tab empty states name the
action that fills them; "Show sample data" renders bundled fixtures under a persistent banner and
per-card badge with drill-down and export disabled.reportCsv.ts (pure, RFC 4180,
CRLF, UTF-8 BOM) behind an Export CSV button on all five tabs, serialising what the tab has loaded
under the current filters; a print stylesheet; the PDF metrics section deliberately NOT shipped
(exportIncidentPDF is synchronous and touches no database — seed
264-pdf-metrics-section.md); component coverage plus e2e/reports.spec.ts.Subtraction plus one feature lane. Every "close before multi-dept" deferral that self-serve signup made due is closed with an executed real-JWT probe or a dated, named finding — never a re-deferral — and the go-to-market lane gets its first surfaces: a leads viewer, a department profile captured once, duplicate detection,
hello@inbound mail, and the runbook that makes the Stripe LIVE cutover a walk instead of a debugging session. Built as a batch of stacked PRs (#415–#428), walked on staging 2026-09-05 (14/14 pass, findings F-1…F-12 in.planning/phases/v38-UAT-FINDINGS.md), merged 2026-09-05, prod migrations and seventeen edge functions applied and probed green 2026-09-06. Gate of record:.planning/phases/v38-GATE.md. Backfilled entry — written 2026-09-06 from the merged PR record; v38 shipped without a tag or changelog line at the time.
dept-svg-markers found and fixed), the matching-thresholds admin gate. Part B (client lane):
SEC-07 re-addressing verified as already shipped in Phase 223 rather than rebuilt, SEC-08
refused-tactical-write Sentry breadcrumbs (scalars only, never the payload), SEC-09 client drift
check.CONTINUITY-RUNBOOK.md, value never printed), recorded the Monroe
GIS licence and PITR answers, re-recorded the bundle baseline at CI figures, and gave
TacticalScreen-*.js its first size-limit row.DemoRequestModal through Modal, SVG clock glyphs, the condensed-panel overlap
(F-46), staged contribution photo expands, the 167 cleanup batch, District/Assistant Chief ranks
as supervisors.demo_requests plus a lead_messages thread table (migration 20260904950000,
five DEFINER RPCs); demo-request gains email-shape validation and a rate-limit stamp on its
502 path.AddressAutocomplete, so the station inherits lat/lng), and a signup on a lead's own domain
linking the two. setup_new_user re-authored exactly once, carrying the address block and the
invite max_uses cap.normalize_department_name +
trigram similarity at a 0.72 normalized threshold (the shipped 0.55 raw threshold was measured
anti-correlated with the signal: Greece/Gates flagged, Ogden FD/Ogden VFC missed), an
AFTER-INSERT flag trigger with a super_admin review queue, a pre-auth role="status" hint on the
signup form, and Overture coverage recorded per department with a CEO Dashboard coverage card.hello@ inbound (Phase 257, PR #425 — INBOX-01..06). A signature-verifying hello-inbound
function ledgers every Resend delivery, resolves it to a lead (thread first, sender second, new
lead third), drops auto-replies and bounces, and drafts an AI reply for the operator to send.docs/runbooks/stripe-live-cutover.md plus the CI price-secret gate it had claimed since Phase
183b; one shared price-secret table whose builder throws on two secrets holding the same price
id; T-7 / T-1 trial-end reminders through a separate idempotent trial-notify function on
pg_cron (the 30 s pg_net timeout fix, #473, is the last v38 code merge); a LIVE-mode dunning
read-back checklist with a code gate proving CTT implements none of it.stripe-webhook ordered-write ops accept thenables under deno check (#471).The milestone that gives a department a real, editable model of the fire building and its exposures from just an address — free USGS 3DEP lidar reconstructed to LOD2.2 roofs by roofer, stored with provenance as data, rendered in the tactical 3D view, seeded into the pascal scene editor, and scoped to the hero plus the exposures a responder actually includes. Eight phases, one dev UAT walk on staging (five stations, fourteen findings F-W1…F-W14, every code finding fixed in-walk, three ruled by Mike), then a live ship. Records of authority:
.planning/v37-UAT-WALK.md,tools/lidar-bake/RUNBOOK.md,~/spikes/lidar-lod2-roofer/SPIKE-VERDICT.md.
gis.building_models — one row per
footprint carrying BOTH the LOD2.2 sections and the LOD1.2 massing, provenance as
COLUMNS (capture year, 3DEP resource, point density in the projected CRS, method
including skipped as a first-class refusal), geography-cast GIST index, unexposed
schema read only through get_building_model / get_building_model_by_ref and
written only through a service-role upsert_building_model. tools/lidar-bake/
bakes a district (targets → EPT fetch → roofer → reduce → verify → upload) and is
resumable on the same key the table upserts on.lidarLod2 rung in the seed precedence, below any human edit, above the Overture
extrusion — with the View-panel honesty line (capture year · density · method) that
must describe what is on screen. Automatic rings orient side A to the street the
address names (F-W10, corner-lot correct). No coverage ⇒ byte-identical pre-v37 frame.find_building_for_address gained a
clamped max_results).gis.parcels (NYS statewide + Monroe
County's own distribution, one normalised shape, containment lookup), occupancy-driven
exterior material defaults with a scene-editor Materials rail (paint is scene-editor
only — the attach card says so), and intake answers meeting the generated model on one
incident record.verify:baseline caught the stale glob as a 254 kB drift); three gis migrations applied
to prod and body-verified against staging; the 25-footprint Stone Rd district uploaded
to prod (24 lod22 + 1 honest skip, 9/9 neighbours join). Open: Monroe County GIS
licence confirmation before any parcel ingest go-live; .env's prod service-role key is
stale (the upload used the CLI's live key).The milestone that gives Street View real depth and editability, drains ~100 fit seeds, and makes the pilot go-live gates code-complete. Nine phases, two live eye gates, closed by a full UAT walk (one finding, F-1, fixed and re-judged same-session) and a live-gated ship (
eval:report11/11,eval:ladder3/3,audit:namedclear). Records of authority:.planning/v35-UAT-FINDINGS.md,.planning/phases/230-golive-readiness/230-RUNBOOK.md(the operator gates that remain open post-merge).
showingCommit set, one undo step on the FOUND-01 derived snapshot, eave locus at the
painted storey. Edits propagate to the 2D map, floor plan, and 3D scene through the
shared showing record.tools/apparatus-bake/ pipeline (Kenney Car Kit CC0 + Blender, 7 hulls, provenance +
dual-hash contract). Corrected heading resolution, content-extent fade, soft-light
relief pass for dark liveries; the ladder degrades sprite → flat quad → billboard,
all-or-nothing per vehicle.buildFullTacticalPayload
(superset-pinned — floorPlanConditions + envelopeShowings now ride undo); /app
entry preserves location.search (the four opt-out params AND Stripe's
?checkout=success both land); resolveFacadeAnchorAtClick extracted as the one
anchor resolver.$125 literals gone);
trial→free caps applied at transition per D-v35-4 with CapDeniedDialog on every
bypass route; member role floor on incidents INSERT (migration + probe); cancellation
grace capture + admins-only ending banner; replay-link revoke UI + dept-scoped list
RPC; UpgradeModal on shared Modal; storage-orphan audit tool; external gates tracked
in the 230-RUNBOOK.test:perf lane; the fire ceiling recalibrated at the
ship PR from the CI runner's own worst); pdfjs-dist 6.2.108 + dompurify +
Playwright/actions pins with the reachability contradiction reconciled in writing;
~20 chores drained with structural gates; incident_photos drop migration authored
(runbook row 15b).The milestone that pays down v33's deferred table and makes the free-tier cap honest. Six phases, closed by a full 6-station live UAT walk (every finding fixed and re-checked same-session) and a prod-probed ship gate. Record of authority:
.planning/phases/224-look-polish-close/224-UAT-RESULTS.md.
close_incident DEFINER RPC + a
txn-local app.close_ctx carve-out on enforce_incident_lifecycle add ONE audited
shortcut edge — report_finalized onward → closed — on the REAL lifecycle graph, so a
1–2 member department can actually reach the 182b incident cap's only release. Every
refusal is 23514 prefixed incident_close:; training and drafts are refused by name;
authority is author OR current_incident_owner OR admin. Migration 20260825120000
applied to prod at the ship gate with a full live probe (positive, three negatives,
idempotence, and the valve leg: create refused at 5/5 → one close → create accepted).
CapDeniedDialog gains a performable "view incidents" remediation route.lines; exit/attach dialog copy pass.buildingDetails; INTAKE-02 discharged as
already shipped in v33.scenePaintPresence seam retired (D-v34-6).createdBy
stamp at create, picker UUID fallback, snap-seed one-shot marker, room-row id collision,
hover-highlight channel, and D-v34-10 (S1 drops the eave lick — single window flame
everywhere). Public replay gained fit-to-extent (D-v34-7); the Building edit group hides
with Structure over plain photoreal (D-v34-8). SV condition fine-tuning and apparatus
depth seeded for v35 (D-v34-11/12).The milestone that makes a painted condition a fact about the building instead of a mark on a surface — and ships it default-ON. Before v33 a painted fire was a point on a floor plan and a glow on the 2D map; the 3D scene drew a column at the room's centroid and Street View drew nothing. v33 stores a condition once as a showing — anchor + state + modifiers: fire at this window, smoke venting from side C, fire seated on this bed — and every tactical surface derives its own presentation from that one record at draw time. Nothing is stored per surface, so the surfaces cannot disagree. The app still never simulates or computes fire (VISION: illustrative before predictive); it presents what the instructor painted, from wherever you are standing.
Phases 208–212b built the grammar and wired it into all four lanes behind
?envelopePaint=1; Phase 213 repaired the severity bands underneath it; PR #400 promoted the flag default-ON with the tilt / buildingrecon / conditionsV3 opt-out ladder after Mike's live PROMOTE verdict on 2026-08-24. Phases 215–218 are the other half of the milestone and were not in the original plan: the conditions work assumed a good building model, and the first UAT walk proved the scene-authoring path underneath it needed real work, so it was pulled in.Bookkeeping, stated rather than smoothed over. Phase 214 (AI still lane) is PARKED on a Google Maps ToS ruling and shipped nothing. Phase 213 merged only after its own enumeration contradicted its SPEC's premise and the phase halted, re-scoped and re-ruled (D-213-A, option C) rather than spending the milestone's one sanctioned golden re-baseline on a change that would not have moved its own gate. A large share of this entry is UAT fix-wave work (56 numbered findings across three walks), which is recorded here as shipped behaviour rather than filed away as test debt; the full ledger is
.planning/v33-UAT-FINDINGS.mdand the retrospective is.planning/v33-RETRO.md.
conditions_v2.envelopeShowings — persisted since v32 with no renderer — gets its consumer.
A pure resolvePresentation turns showings plus the building's envelope geometry into
intents (openingVent, eaveSeep, roofSurface, stairwellFlow, fixtureSeat) at a
resolved world locus; every surface consumes intents and none reads showings directly. With
fire armed in the 3D view, tapping a window renders fire jetting out of that opening
rather than a column at the building's centre, and the same tap lands on the 2D map (engine
cell) and the floor plan (row) in one commit. A room painted with no showing keeps the
legacy centroid column, so nothing pre-v33 changes shape. Zero migration.map3d and aerial
present the same model the same way.perimeterOpenings on the floor they were authored on. The perimeter is the scene
ring after attach adoption, so the edge an opening was drawn on is a perimeter edge by
construction — no decision about which room owns a shared exterior wall. The interior half
stays open and is now counted and named on the attach card rather than silently absent.mapPersonnel becomes a replayed slice: crew_placed /
crew_location_changed / crew_removed — three event types rather than one, because the
equal-timestamp tiebreak ranks by the event name's suffix and a single type let a removal
apply before its own placement about one run in three. Positions are logged, never
interpolated: a firefighter's movement inside a structure is not observed as a continuous
path, and tweening it would invent a fireground fact.?envelopePaint=1 becomes
a shipped runtime resolver following the three established precedents. Ladder, highest first:
?envelopePaint=0 (one-shot URL opt-out — its meaning is unchanged by the flip, so a
support instruction written before the promotion still reads correctly) →
localStorage['ctt:envelopepaint_disabled'] === 'true' (persistent per-device, written by
the new EnvelopePaintDisableToggle, the fourth Dashboard Map settings row at both
mount sites) → default TRUE (?envelopePaint=1 survives as a truthy bookmark alias, and
it does not beat the persistent opt-out). The judging-window opt-in key
ctt:envelopepaint is now inert and is a different key from the opt-out: re-purposing it
would have silently inverted every device that opted in during judging. No session circuit
breaker was invented, because Phase 208 shipped none.INITIAL_INTENSITY_BY_LABEL's heavy seed of 0.65 sits below the 0.70 high-band edge
and derives moderate. The fix is a floor at the render boundary —
max(engineCellSeverity, paintedLabelSeverity), so precedence becomes ramp → max(cell,
painted). Adjacent resolved-alpha deltas move from 0.1375 / 0.1375 (both under the 0.15 floor)
to 0.1650 / 0.1925 on all five customer-visible surfaces. Nothing the engine reads changes:
cell.fireIntensity is a spread input, so raising the seed would have altered how existing
incidents replay. One resolver feeds both 2D lanes deliberately, so the sprite jobs and the seep
binding cannot disagree about a cell.role="status", wherever the choice is not offered
— so no frame is ever unlabelled. Nearby structures, Building display, Capture and the clean-
capture toggle move into the same panel; the chip is always mounted, so its tap target cannot
move when the coverage probe settles.mapPersonnel slice, so the crew marker survived every clear, and its removal set was the
pre-P-b 18 event types, so even a cleared marker replayed straight back. It now clears the slice
and 21 event types atomically, resets the timeline to 0:00 and stops playback, and an active
recording absorbs the clear instead of echoing it as a burst of removal events. Replay
annotations and metric_* milestones deliberately survive — they are commentary and drill
benchmarks, not tactical marks. The confirm copy now says what the button actually does.ReferenceError: global is not defined into a
discarded promise (fixed with a host alias shim, never by patching @pascal-app and never
with a graph-wide define); loading an already-open scene was rejected instead of no-oping.wall-dropped and are counted and named on the attach card. Its own phase.tsc -b clean, re-run on main at the close rather than quoted from a phase record.npm test
(paintedSeverityDistinctness.test.ts), and it source-scans the top-down seep's alpha constants
so a retune there reds the suite instead of silently dating the record.git worktree has no .env, so Vite dead-code-eliminates the conditions-v3 subtree and the bundle
reads lighter than reality (scene3d.js −12.32 kB on one measured pair). That phantom cost
this milestone three wrong measurements and one wrong PR-body claim before it was named; it is
seeded as v33-worktree-bundle-measurement-measures-a-phantom.md.claude-review CI workflow's secret was broken for most of
this milestone, so many of these PRs were admin-merged past a red required check rather than
passing it. The check is cosmetic to correctness — per-PR test and typecheck runs were green —
but the merge discipline was weaker than it reads.Tag correction (Phase 249, 2026-09-02, BOOK-01): the
v32.0git tag on origin pointed ate2e18c88, a v31.0-era docs commit, not this milestone's own close. It has been force-moved to5ca1f7c5(PR #339, the verified Phase-207 "pascal conditions → tactical bridge" closer) and re-pushed;git ls-remote --tags origin v32.0now dereferences to5ca1f7c5. See.planning/MILESTONES.md's v32.0 entry for the same note.
The milestone that makes the conditions renderer real for customers, and makes the scene editor's output load-bearing. v32 ran as the ratified four-lane replan (
.planning/seeds/v32-replan-four-lanes.md, 2026-08-12 — it supersedes the original ROADMAP-v32 phase ladder 186–194): Lane A folded the standalone scene-studio app into CTT and rebuilt the tactical screen's chrome (Command Deck, theme); Lane C built the map-lane surface conditions (eave-seep smoke, recon storey fidelity, the footage/licensing contract) and closed with the Phase 194 CLOSEOUT; Lane B paid down the 2D floor-plan-editor debt; Lane D (masked AI painting) was deferred by ranking and never started. The wrap-up phases (195–205) then closed every named?conditionsEngine=v3promotion blocker one at a time — smoke-renders-fire, panel parity, delete teardown, realism, legibility, autonomous growth, copy honesty — ratified a vantage-based render policy (Phase 204, doctrinedocs/conditions-vantage-matrix.md), and flipped v3 default-ON (Phase 205) — the first customer-visible conditions-renderer change since v2 shipped. Phases 206 and 207 then closed the loop the fold-in opened: a scene authored in the editor becomes the geometry the tactical 3D views render, and the fire and smoke painted in it reach the tactical and replay views.Bookkeeping notes, stated rather than smoothed over. Phases 187/188 (pascal spike + editor adoption) shipped early inside the fold-in PR #281. Phase 186 never merged as itself — it stalled on the unmerged branch
milestone/scene-authoringand only cherry-picked fragments are ancestors ofmain(196's polygon rooms; PR #309's lookdev-loops relocation credits "Phase 186-05/06"). It is recorded in the retrospective, not claimed as a shipped entry here. Phase 193 shipped nothing; Phase 196 ran as two agent worktrees (196a/196b) inside one PR, so the letter gap is not a missing phase. v31 phases 182b–185 merged interleaved onmainduring this window and are documented in the v31.0 section, not here.
src/utils/sceneTranscode/ transcodes a stored pascal scene into the
existing BuildingRecon geometry — transcode, not native rendering (ruling R3), which is
why the 154–157 capability bar (per-wall peel, dollhouse and cross-section clipping, ABCD
identity) is met structurally: BuildingReconModel is untouched. The seam is pure — zero
@pascal-app imports (the snapshot is read structurally, with a type-only parity test
pinning schema drift), zero three, zero store imports, no clock or RNG. Attaching writes
conditions_v2.attachedScene on the existing CAS payload (zero migration), and it is a
snapshot at attach: editing the scene later changes the incident only on an explicit
re-attach, so replay integrity holds by construction. Every lossy decision is enumerated in
a TranscodeReport and surfaced on the attach card rather than dropped silently; a lossless
transcode emits none.condition_ignited_user events, and anchored showings, plus growth ramps. Scene provenance
is what makes attach replace and detach remove without touching hand-painted rows.conditions_v2.envelopeShowings — the ninth sub-key (Phase 207). Minted with the
user-intent-gated strip discipline of its siblings. ⚠ It has no renderer in v32 by
ruling: the vantage-aware conditions milestone consumes it. It is persisted and
store-resident today and nothing draws from it.resolveFidelity gains a third outcome, 'photorealistic+model', and the
in-scene toggle becomes a 3-option Photoreal / Model / Both radiogroup. The opaque model
depth-occludes Google's building blob, with the Google Maps ToS condition-3 disclosure line
shipping alongside it (206-R5-TOS-VERDICT.md — permitted with conditions). This closes the
long-standing gap where the reconstruction was unreachable at exactly the photoreal-covered
addresses a covered department would use.num_floors.
Declining, absent coordinates, no coverage and an RPC failure all degrade silently to the
blank canvas. Hero matching and side-A orientation were extracted from the neighborhood
builder into shared helpers — reuse, not a fourth Overture path.<Cloud> failed the read gate (it cannot rise), so a
purpose-built deterministic emitter ships instead — no new dependency, pause and
reduced-motion reproduce exactly. Phase 191 binds it to the painted conditions: severity
through the same shared resolver as the map and floor plan, emission at the painted floor's
height, and ABCD side filtering from the placed side markers — an interior fire emits on all
sides by default and the operator narrows explicitly; the app never guesses which wall.ConditionsInScene, which was this
seam's only publisher, so per-cell suppression never fires today and glyphs always
render. The dormancy is deliberate and structurally pinned so a publisher cannot regrow
silently (seed 204-scenepaintpresence-dormant-publish-half.md).public/ (it was 1.39 MB in every
deploy AND a live public URL serving a licensed asset); manifest rows gain "shipped"
dispositions and --verify reds on either direction of drift; --out scratch bakes no
longer rewrite the licence record; the resident-pack cap is enforced by the type system.?conditionsEngine=v3 becomes default-ON for every user; real production incidents carry
conditionsEngineVersion: 'v2', so painted conditions now present through the Phase-204
policy with no flag. The opt-out ladder mirrors the tilt and buildingrecon promotions:
session circuit breaker (unchanged, highest) → ?conditionsEngine=v2 one-shot URL opt-out →
a persistent per-device toggle (ConditionsV3DisableToggle, the third Dashboard
Map-settings row at both mount sites) → default ON (?conditionsEngine=v3 survives as a
truthy bookmark alias). The first-actual-draw latch is unchanged and remains the safety net —
v2 keeps rendering until v3 actually paints, and a failed v3 chunk load falls back for the
session. Marketing re-adds the three removed 3D-conditions claims in policy-honest wording.
Proven live in both directions (no flag → v3 latch true; =v2 → v2 renders).docs/conditions-vantage-matrix.md. On the 2D
satellite map, interior painted conditions no longer stamp a sprite over the roof: a
top-down eave-seep presentation (smoke effluent hugging the roofline ring; severity
drives opacity, scale and extent, never hue) plus roof-applied conditions are the only draws,
and a smoke-only painted room can no longer render flames. In the 3D scene, interior
fire and smoke render via the promoted pascal VFX flipbook layer (CC0 sheets). Live gate: 2D
pass after one opacity tune ("better"), 3D scene "MUCH better", aerial "beautiful". Frozen
render kernel untouched; scene3d.js −4.55 kB.src/scene-editor/ — pascal-native scenes in the new scenes table, one Supabase session,
WebGPU canvas beside the map lane's WebGL (two three.js builds by design) — and promoted from
?sceneEditor=1 to the route /app/incident/:id/scene with a back-to-incident exit
control it previously lacked. Zero pascal weight in the eager bundle. Stated plainly: the
route is incident-scoped and is entered from the incident's LeftPanel, so it is reachable
by any user with an incident open rather than being a general-purpose destination.data-theme="light" with a WCAG AA
contrast gate that parses the shipped CSS (it caught and fixed one of its own author's rungs);
a Light/Dark/System toggle ships under a new Dashboard "Appearance" group. Default rendering
stays dark everywhere — light activates only via the toggle, and the tactical screen and
marketing pages force-dark by design. ⚠ Recorded, not fixed, and deliberately not framed as an
accessibility win: dark-mode muted text (--metal-600) measures below AA today. Its own
follow-up.fireIntensity × SMOKE_GENERATION_RATIO, toward saturation in ~1 s) now runs only
on training incidents — wired from incident.isTraining through both tick paths. Real replays
hold the painted value; training keeps simulation (Mode 3, sanctioned). Accepted behaviour
change: a real incident's painted fire cell no longer resolves at saturated near-black smoke
on replay. Zero re-baseline — an absent flag means the old behaviour.proceduralRenderer.ts, whose
pixel-producing bodies the gate had been missing.
⚠ Phase 201c merged still titled "DRAFT: visual verdict pending", and that gate is discharged by SUPERSESSION, not by an eye-gate. The Phase-204 render-policy ruling retired the entire 200→201c alpha-tuning line, and the surface #333 was tuning — interior 2D condition sprites — no longer renders under the new policy. Recorded here so a future reader does not find a dangling unmet gate.
✓ indicator
sites (editor option strip, fixture catalog, theme toggle, OpenMHZ import) convert to a shared
CheckGlyph; OpenMHZ controls gain their missing aria-pressed state. Text copy ("Saved ✓")
deliberately kept.fireIntensity and drew a full fire — misrepresenting the most consequential size-up
cue a crew reads. The painted type is now carried on the ignition and persisted
(conditions_v2.ignitions[].conditionType), so smoke seeds smoke, survives reload, and never
becomes a fire-spread source. Accepted behaviour change: existing incidents with smoke-painted
cells stop producing a fire core on replay — that is the fix.FloorPlanThumbnail, which had no condition draw path at all — for any type, on any
floor. It now renders conditions through the same projector and scale as its geometry,
including outdoor: rows (which previously landed off-viewBox).main). The transcode bound the hero building's level origin
to the incident point and dropped the building's authored canvas offset. A quickplan or
Overture-seeded building is centred on its own origin, so the ring landed correctly and the
Phase-206 gate looked right; a hand-drawn building carries whatever level-local metres the
author drew at, and the ring — with every condition inside it — sat that far off in real
geography. The projection math was never wrong; the frame was. The ring translation is now
normalized on the polygon area centroid inside the single conversion helper, so scene metres
and lat/lng cannot disagree, and the offset is a required argument to the condition bridge
so the compiler names every caller that must account for it. ⚠ Under snapshot-at-attach,
existing attachments keep their stored ring until re-attached — re-attaching a hand-drawn
scene will visibly move the building, and that movement is the correction.cellId round-trips into conditions_v2
and the shadowed hand ramp is destroyed by the next detach. No behavioural test could see
this. Fixed and sabotage-verified. In the same review: a clean first attach showed no
confirmation dialog at all, so the residual went undisclosed on exactly the direction where
the loss begins; it is now refusable, named in the copy, and counted in the toast.moderate under a lit chip, and stage 3 was unreachable from the map lane.
Selected intensity now flows through; the three preset positions are byte-identical.building_details and floor_plans as whole objects from its open-time snapshot — two
sessions editing one pre-plan last-write-won at whole-object granularity, silently reverting
the other's recon geometry or concurrently added floors. Saves now fetch-and-merge (only the
editor-owned keys come from the form; per-floor, per-field for overlaid floors). Also: the save
toast was optimistic — "Saved" and modal close fired at dispatch even when the write failed;
success now waits for the confirmed write, and a failure keeps the modal open with the user's
changes. Street-suffix normalization ("Road"/"Rd") stops minting a duplicate pre-plan row per
address variant.Material.clippingPlanes evaluate in world space, so the two recon
clip planes — authored in the canonical frame — were wrong inside the hybrid mount's rotation
and ground lift: Dollhouse clipped the entire model away and the vertical cross-section cut the
mirrored side. A world-space clip transform now maps both planes through the frame's matrix,
and a null transform returns the same plane object so every unrotated mount stays referentially
byte-identical. Confirmed in real GL at the live gate..dark class was declared
but never applied — the editor had rendered its light palette inside the dark app since the
fold-in.ConditionsInScene
(all four mounts) and ConditionsPlume are deleted; the pascal VFX layer and the presence
glyphs replace their reads. The shared floor-plane helpers survive in three/floorPlaneY.ts;
the pure planner is retained consumer-less as frozen-adjacent evidence.src/components/floorplan3d/ (~1,700 lines, Lane A). The legacy 3D floor-plan preview is
deleted; the scene editor is the 3D lane. Its removal re-chunked the bundle (OrbitControls
inlined into scene3d.js, well under cap; the dead size-limit row retired rather than left as
a zero-match glob).?sceneEditor=1
(promoted to a route, PR #302), ?eaveSeep=1 (promoted onto the v3 gate, Phase 195), and
?vfxFlipbook=1 (promoted, Phase 204). With Phase 205's flip, CTT's dark-flag count reaches
zero.main at the close: 7,812 passed / 0 failed (25 skipped; 626 files passed,
4 skipped), tsc -b clean, both re-run for this close-out rather than quoted from a phase
record. All seven size-limit rows green — index.js 148.08 / 148.6 (the tight row; the
cap moved 147.8 → 148.6 riding PR #338, per the standing rule that the merge adding the weight
carries the cap raise) and scene3d.js 98.83 / 124.1 after Phase 204's −4.55 kB.
verify:baseline's red set is exactly the three documented machine-lane rows.?conditionsEngine=v2, so every committed baseline stays byte-valid.Phase 184 (Cosmetic Sweep) — paid features now render locked instead of vanishing. Not deployed. On a free department the four AAR actions (Assign / Publish / Lock / Unlock) and the Lessons Learned and Training Queue sections used to disappear, giving no signal the capability existed; the Dashboard swapped two of those sections for an upgrade ad outright. They now render visible-but-locked with a plain hint, and the locked control stays clickable so it opens the upgrade path rather than being a dead button. The upgrade banner is now admin-only — a plain member sees the locked feature with no price and no call to action.
canUnlockAARgained the plan term its three neighbours already carried, closing a real asymmetry where a free department could unlock an AAR it could not lock. TwoTierLimitsfields that nothing read were deleted.Scope, stated plainly: this moves no money and gates no data. Every gate it touches is advisory rendering over a posture the server already had — a live read of production confirms all four AAR functions carry no plan term, and exactly one policy in the whole database references entitlement. ⚠ The roadmap's phrasing "advisory UX over a server control that already exists" overclaims and should not be quoted: for these four families there is no plan-scoped server control, which the phase's own gate-server map records explicitly rather than leaving blank.
Timing is what makes it non-trivial. Phase 185 flipped every trial department to free, so these gates evaluate false for 39 live departments today — this is the rendering layer for a restriction that is already switched on.
Two known open items at ship, neither silent. A free
department_adminsees the advisory notice and the upgrade banner as two near-identical stacked cards carrying the same sentence (found by the UI audit, rated a blocker there). And a locked AAR on a free department can be unlocked by nobody in-app, super_admin included — latent today (production holds one AAR, in draft) but it strands exactly the cohort the cutover created; the recovery path is recorded.
Phase 185 (Cutover) — the last stop on the money path, staged not yet applied. The in-app upgrade copy now says the ratified $125/mo (five sites; the old $99 figure is gone, gate-tested). A named-list migration is authored — with per-row audit and exact rollback — that moves the 34 pilot trial departments to the free tier; it applies only at the ship gate, after the operator approves the customer email that names what ends (new AI runs, 3D views) and what stays (everything already made). The Stripe live-key runbook gained an ordered ingress-quiesce window (close checkout + test webhook FIRST, clean residue, swap keys, reopen) so a stray test checkout can't corrupt freshly-cleaned rows.
Not a release. v31.0 runs phases 177–185. No free tier is live, no public gate, no pricing exposure yet. Documented here so the eventual v31.0 entry doesn't need reconstruction from git history.
Phases 177–179 change nothing you can see, and that is the point. They put the machinery for paid-feature access in place while every department keeps exactly the access it has today. 178 moved AI spend metering to the server so it can't be skipped or double-counted; 179 added the database-level checkpoint that later phases will use to actually gate a feature. Both are deliberately behaviour-neutral — nothing is denied to anyone yet.
Phase 182a adds no feature either — it changes what existing failures SAY. Its own spec is blunt about this: it "builds no user-visible feature… a precondition, not a product increment." Worth saying why it arrived before the feature it serves. Gating a feature means refusing things, and this app's single worst failure mode is a refusal the user never learns about — work that looks saved and isn't. So the refusals were made honest before anything started refusing.
Two honest qualifications on the entries below. Most of what 182a repairs was silent long before the free tier existed, so those fixes reach every department on every plan — they are not free-tier behaviour. And the one entry that is about the incident limit corrects the wording of a refusal that, today, no department can actually reach: the only free-plan department has no incidents. That copy is right for when the limit goes live in a later phase; it is not a change anyone will see this week.
Phase 182b is where the free-tier limits actually start refusing things — and the same qualification mostly still applies. The limits are real, they are enforced by the database rather than by the browser, and they are switched on in production. They bind only departments on the
freeplan, and of 42 departments exactly one is on it — a test department created to exercise this feature. No customer is on the free plan; every real department is on a trial or a paid plan, and the phase that moves anyone onto the free plan is 185.That one test department is worth naming rather than rounding away, because it is the reason we know the limit works: it sits at five open incidents and its next one is genuinely refused, in production, today. The storage limit has not been exercised — that department holds no files — so the 2 GB figure is proven by construction and by test, not yet by use.
Free departments get 5 open incidents and 2 GB of storage (Phase 182b). Both limits are enforced by the database, so they hold no matter what asks — the app, a script, or a direct API call. "Open" is the operative word on the incident limit: closing an incident frees the slot. The limit is on how many incidents you are running at once, not on how many you may ever create, so a department that closes its work as it finishes never meets it. Storage works the same way in the other direction: deleting files gives the space back, counted down rather than only up.
One consequence worth stating plainly, because it is the other side of the same rule: if you are already at the limit, reopening a closed incident is refused too — reopening would put you over. Close something else first, and the reopen goes through.
Nothing you already have is affected by being over a limit (Phase 182b). This was the requirement the phase was built around and the one it is most careful about. A department past either limit can still open, edit, export, and delete every incident and every file it already has. Nothing is archived, hidden, locked, or removed. What is refused is the next create, the next upload, and reopening a closed incident while already at the limit — never anything you have already got.
A refusal now tells you which limit you hit and what to do about it (Phase 182b). Being refused because of your plan and being refused because something broke are different problems, and they now read differently. A plan limit names the limit, says what was not created or not uploaded, and points at the way forward. It also removes the "Try again" button — retrying cannot succeed, and offering it teaches the wrong thing about why you were stopped.
The sample incident and the three training scenarios respect the limit too (Phase 182b). These four are generated for you rather than typed in, and they used to sit outside the check entirely. A department at its limit is now refused the same way whichever route it uses. Each one creates an incident and is checked on its own, so a department with only a couple of slots left gets as many as fit and is refused the rest — the check is per request, not per batch.
AI usage totals stay accurate after deleting an incident. A department admin's month-to-date AI usage number no longer silently drops when someone deletes an incident — the spend record survives, so the monthly AI budget display is always truthful.
AI spend is now metered by the server, not the browser (Phase 178). Every AI call is recorded on the way through, and the result is held back until the record is safely written — so a department's usage number can no longer drift from what was actually spent. The browser can no longer write usage records at all.
Groundwork for paid-feature access (Phase 179). A single database checkpoint now answers "is this department entitled to this feature?", and incident creation asks it. Today it answers yes for every department on every plan, so nothing changed for anyone — the checkpoint exists so a later phase can turn one answer to no in one place instead of scattering the decision across the app. New signups also now go through a seat-count check; the limit applies only to free-plan departments, of which there are currently none.
The free plan's storage figure was wrong in the app and now matches what is enforced (Phase 182b). The app carried 500 MB as the free storage allowance. The real allowance is 2 GB. Nothing was ever enforced against the old number, so nobody was cut off early, but any place that showed it was showing roughly a quarter of the real allowance.
The server has the final say on the limits; the app's figure is advisory (Phase 182b). What you are shown early is a courtesy so you find out before a long upload rather than after. The answer that counts is the database's, and it is the one that actually refuses.
Actions that fail now say so (Phase 182a). Several places in the app used to let a write fail quietly and leave you believing it had worked. They now tell you. Specifically: a photo upload that is refused no longer keeps showing the photo as though it were saved; a radio transmission import that fails now says which part failed, and says whether your existing transmissions were cleared or left alone; and a blueprint that fails to upload no longer leaves an empty reference behind.
The rule the phase held itself to is that every refusal has to name what did not happen — not just that something went wrong, and not just what to do next. "Couldn't save" and "nothing was saved" are different sentences to someone deciding whether to re-enter the last ten minutes of work.
A refusal at the incident limit now says your incident wasn't created (Phase 182a). Previously, hitting the limit produced an upgrade prompt and nothing else — you had to work out from the form still sitting in front of you that nothing had been created. It now says so.
"Couldn't confirm this saved" no longer appears when nothing was ever sent (Phase 182a). If your account has no active department yet — including the brief moment while your profile is still loading — creating an incident used to show a message asking you to retry and check with the server, about a request that had never left your browser. It now says what actually happened, so you aren't sent to check something that was never asked.
A slow save that eventually succeeds now tells you (Phase 182a). When an incident takes longer than expected and then saves, you used to be left on the form with the button stuck reading "Saving…", no confirmation, and no way back to the incident that had in fact been created. You now get told it saved and where to find it. The button deliberately stays disabled — pressing it again would create a second copy of an incident that already exists.
Deleting incident files now actually removes them. Removing your department's incident audio, photos, or thumbnails from the app now truly deletes the files — previously the delete silently did nothing in production, leaving orphaned files in storage with no error shown. (Files deleted before 2026-08-05 remain orphaned in storage; an audit/purge tool is tracked as a seed.)
⚠ Not customer-visible. Every feature below is behind the DEV flag
?buildingrecon=1(the conditions layer additionally needs?conditionsEngine=v3), and production is byte-identical with the flags off — the flag resolver is DEV-only and issues zero RPCs in a prod build. This entry exists so thev27.0tag has a record; it is not a release note.↳ SUPERSEDED 2026-07-31 by Phase 171 (v30.0). The paragraph above was true at the v27.0 tag and is kept as written — but it no longer describes production. The reconstruction target, its scene controls, the peel / roof-off / cross-section gestures, time-of-day, weather, neighbour massing and the per-incident parameter editor are now on by default for every user, behind a
?buildingrecon=0or per-device toggle opt-out. Three parts of this entry did NOT ship with it and are still developer-only: the in-scene painted conditions layer (still needs?conditionsEngine=v3), the per-addresspre_planswrite-through that reuses a saved shape at the same address next time, and the isolation harness overlay. Photoreal-covered addresses on opted-in departments also keep the photoreal mesh rather than the reconstructed target. See the v30.0 entry above for the full picture and how to turn it off. Phases 153–158, run as the isolatedscene-reconworkstream in parallel with v28/v29 — which is why it lands above v29.1 by date while sitting below it by version.What it's for: the fireground reconstruction mode. A department should be able to take a real address and rebuild the structure and its neighbors as editable 3D geometry, peel a wall open, and watch the incident's painted conditions play back inside the building on the same timeline the audio drives. R-I is the parametric foundation: no AI, no migrations minted by the milestone itself.
A real building, not a box (Phase 154). The 3D target renders as editable parametric geometry — per-wall meshes with a 12×8 facade opening grid, straight-skeleton roofs (gable/hip/flat/shed) with an oriented-bounding-box fallback for the skewed, notched footprints real Overture rings actually have. Params seed from the building's own Overture record.
The neighborhood, at true separation (Phase 155). The parametric target renders in the aerial fly-around as well as map3d, and surrounding buildings render as massing volumes at their real distances — the exposure picture. An Exposures control includes or excludes individual neighbors, and a failed load reads as "couldn't load" with a retry, never as "no neighbors."
Open the building up (Phase 156). Peel individual walls away, take the roof off, halve it dollhouse-style, or cut an adjustable cross-section — all target-only, so Google's photoreal mesh is left honestly untouched. Lighting derives from the incident's own timestamp (a 02:14 call renders at night), and rain or snow derives from incident data only — never inferred from the season.
Edit it, and keep it (Phase 157). A Building group in the scene controls edits stories, roof archetype, per-wall openings, and colors. Geometry persists two ways: per-incident on the existing versioned tactical-state save, and per-address into the department's pre-plan for reuse next time. Training drills read the shared pre-plan but never write back to it, so fictional drill geometry can't poison operational data.
Conditions inside the reconstructed scene (Phase 158 — the closer). The painted fire, smoke, and water the 2D map already shows now paint as world-anchored planes on each floor of the 3D building, visible through a peeled wall and driven by the same event-sourced replay clock. Pause the replay, peel side A, and inspect a fire placed at 2:30 — it appears at 2:30 and disappears when you scrub before it. Severity is read from one shared resolver across all three surfaces, so a stage-3 fire is stage-3 everywhere.
Flat ?tilt=1 in production (Phase 153). The deployed Vector Map ID had been orphaned from its GCP project after an earlier alignment check passed — Google then silently served raster tiles with no console error, and Maps JS ≥3.65 has no 45° oblique on raster, so tilt was permanently flat. Re-pointed to a live Map ID. The lesson is in CLAUDE.md: verify the Map ID's project and the API key's project separately, because a one-time "same project" check can't catch a Map ID that leaves afterward.
Two Google Maps loaders stranding each other (Phase 153). The tactical map and the public replay page each ran their own script bootstrap at different versions with different library sets, and whichever mounted first won. A replay-first session reached the tactical map missing the drawing library, which broke hoseline pen-draw. Collapsed to one bootstrap at one version with the superset library set.
A black Canvas on hydration (Phase 158). A dashed data-* attribute on a 3D primitive — inert since Phase 154 — became fatal once Phase 157 made its value change at runtime: React-Three-Fiber splits prop names on every dash and walks into the object, so it corrupted and threw. Swept 12 attributes to the safe single-dash form and added a source gate so it can't come back.
The 3D scene showing a smaller fire than the map (Phase 158). The scene resolved severity from a frozen enum while the map resolved from the live engine cell, so the same row rendered as a stage-1 wisp in 3D and a stage-3 fire in 2D. All lanes now read one input under an explicit precedence law.
The v3 dormancy gate, red on main since PR #247 (Phase 158). Fixed at the cause — the reduced-motion probe was extracted out of the flag resolver so the gate's own rule stops matching it — rather than by widening the allowlist.
tsc -b clean. All four bundle caps green: index.js 105.75/106 kB unchanged (zero eager leak — the entire milestone's weight is lazy), scene3d.js 120.23/121 kB..planning/workstreams/scene-recon/phases/158-*/158-LEARNINGS.md.Closes the loop on the public contribution portal: officers control whether contributors are named, can see and revoke every live link, and photos submitted from a phone carry their metadata onto the tactical map — and survive a reload. Phase 167, one PR off
milestone/v29.1-contribution-ux. The deploy runs the 4-step edge-function gate deferred from v29.0 (redeploycontribution-upload/-input/-resolve→ quota reconcile → live endpoint probe → real-phone portal check); migration20260721190000is already applied live.
Officers set an attribution policy when minting a contribution link (Phase 167). A three-way choice in the mint modal — anonymous, name optional (default), or name required — drives the portal's name field (hidden, optional, or required) and is enforced server-side in contribution-input with the same opaque 403 as every other rejection, so the policy can't be bypassed by talking to the endpoint directly.
The mint modal shows every live link for the department — and revokes them. A department-scoped list (member+ role floor; viewers get zero rows) with per-link revoke including an in-flight state. Revoking the link you just minted collapses the success view, so a dead token can no longer be copied, QR'd, or sent. This is the revoke path's first live call site.
Mint from the tactical screen. A contribution-link chip on the tactical screen for the officer already working the incident; the Dashboard flow is untouched.
Phone contributors get two explicit buttons: "Take photo" and "Choose from library." Library picks retain the photo's EXIF metadata instead of silently stripping it behind a camera-only capture input.
Photo metadata now reaches the map: GPS position, compass heading, and timestamp. EXIF is parsed once at accept (contributionExif.ts, keys pinned by a real embedded-JPEG fixture). Photos with a heading render a field-of-view cone on the map that counter-rotates with map heading — natively captured photos gain the cone too.
Photos finally persist (Phase 167 / CONTRIB-06). Incident photos had no persistence path at all — they lived in memory and vanished on reload (the incident_photos table was never written). The photos slice now rides the versioned tactical-state save; pre-167 rows are byte-identical, and a snapshot reload merges rather than clobbers concurrently accepted photos.
Makes v28's human-gated AI after-action report reachable and trustworthy end-to-end, and hardens the live public contribution intake against abuse. Phases 163–166. Ships as one PR off
milestone/report-pipeline-completion; the deploy runs the blocking 4-step edge-function gate (migrations → redeploycontribution-upload/-input/-resolve→ quota reconcile → live probe) before the milestone is called done.
The AI Incident Report is now reachable from the app (Phase 164). v28 shipped the full report backend but the review panel had no way to open it — the feature was live in the database and invisible in the UI. The AAR panel now has an "AI Incident Report" button (AI-permission-gated): generate → review/edit → approve → publish → share a tokenized public link at /report/:token, proven live end-to-end.
A safety-critical quality gate now stands between the AI report and every release (Phase 166). npm run eval:report synthesizes reports against the real model over an 11-fixture hand-labeled reference dataset (mayday, evacuation, collapse scenarios signed off by the pilot IC + ISO) and scores them deterministically — safety-event completeness is a 100% categorical bar, with citation integrity and terminology checks alongside. A sabotage mode proves the gate actually fails when a mayday is dropped (it does — exit 1 naming the missing event). The gate is a BLOCKING row on every milestone ship and imports the production prompt-assembly directly, so prompt drift can't slip past it.
Reports read better (Phase 166). Three ratified prompt rules: the model is instructed to keep radio terminology verbatim (e.g. "emergency traffic" rather than a loose paraphrase), thin data is hedged as insufficient data rather than silently omitted, and report sections run in chronological order.
A failed report save can no longer strand a half-written draft (Phase 163). The report row and its citation provenance rows are now inserted in one atomic transaction — if any part fails, the whole draft rolls back instead of leaving an orphaned report.
Admins working in a switched department now act in that department (Phase 163). The four report RPCs (approve, publish, share-token create/revoke) resolved the caller's HOME department instead of their active one; an admin helping another department was silently scoped wrong. All four now follow the department switcher.
Editing an approved report now really reopens it (Phase 163). The old client-side "revert to draft" was cosmetic; a gated reopen_report RPC (author or department_admin+, approved-only) makes the approved→draft edge a real, audited database transition. Edits await the reopen before persisting, and rapid edits coalesce into a single reopen (Phase 166 review fix: debounced, with deduped failure toasts).
The report button can no longer die silently (Phase 166 review). If the review panel's mount gate suppresses it (e.g. no active department), the trigger resets its in-flight flag and tells you why, instead of a dead button.
✅ SHIPPED (deployed + verified 2026-07-18, PR #239 / merge
3aac192c). Live in production end-to-end: Migration B applied, the full v28 edge set deployed (openai-proxy, contribution-curate, contribution-upload/input/resolve/email — correct verify_jwt postures), and every runbook gate green — STEP 3 real-JWT/service-role probe 14/14 (REPORT-02 status-lock, REPORT-05 token no-oracle incl. byte+latency identity, REPORT-06 cross-dept RLS), STEP 6 D6–D9 rubric passed incl. D9 safety-event completeness over the 11-fixture reference dataset (signed by the pilot IC+ISO).deliverable_ships: yes. Phases 159–162.One tokenized contribution link (159, hardened quarantine substrate) → crowdsourced media portal + officer curation (160) → responder eval/SWOT portal (161) → human-gated AI report synthesis (162). AI drafts a source-attributed report from events + radio + curated inputs; a server-enforced
draft → approved → publishedgate, per-claim citations (discriminated-union schema, schema-impossible to omit a citation), tokenized share + PDF export. AI never auto-publishes (VISION failure-mode #2). Crowdsourced free-text reaches the model only inside a per-request nonce fence with a bounded token budget (OWASP-LLM01).
✅ SHIPPED (code-complete + archived 2026-07-15, tag
v26.0). Reliability & Trust Hardening — closed the silent-data-loss + latent-security cluster so a real department's "I saved that" always survives an F5 reload and a low-priv user cannot cross the tenant boundary, proven by live real-JWT RLS probes (never MCP/service-role). Phases 150–152; 18/18 v1 reqs, 0 code gaps. Ships PR #231/#232/#233.
Personnel icons you place on the tactical map now survive a reload (Phase 150 / PERSIST-01). Map personnel markers were never saved to the server — they lived only in memory and disappeared on the next F5. They now persist as a first-class tactical slice alongside vehicles, hoselines, and everything else.
Opening a brand-new incident no longer throws a hidden error (Phase 150 / PERSIST-05). Loading the tactical state for an incident that had never been saved returned a 406 from the database read; the load now handles the empty case cleanly.
The last edit you make right before closing the tab is no longer silently lost (Phase 150 / PERSIST-02). When you changed something on the tactical map and then closed the tab within a split second — while the previous auto-save was still in flight — that final edit could vanish: the unload "flush" fired with a stale version number, the server rejected it as out-of-date, and the change was dropped without a trace. The unload flush now routes through its own server-side save path (save_tactical_beacon) that recognizes when the only newer version on the server is your own in-flight save and safely lands your final edit on top of it. The important safety property is preserved: a genuinely stale tab belonging to another user still cannot overwrite newer work. One honest, accepted limitation (D-05a): if the same user has the same incident open in two tabs and closes one mid-save, one tab's edit can still win over the other's — this is a bounded, deliberately-accepted residual (the guard proves same-user, not same-tab), documented here rather than hidden; a fully tab-precise fix is deferred.
A floor plan that fails to save now tells you, instead of vanishing (Phase 150 / PERSIST-03). A malformed floor-plan payload is rejected before the write, and a failed pre-plan save (including a permission-denied write that returned zero rows with no error) now surfaces the error banner instead of optimistically showing a save that never landed.
Two fireground events logged at the exact same second now replay in a stable, correct order (Phase 150 / PERSIST-04). During a paused recording, a place-then-move-then-lock burst can share one timestamp; replay now always applies the placement before its move and lock, so an apparatus reconstructs at its final position, locked — deterministically, regardless of the order events came back from the database. (Two rare same-second same-entity edge cases are a documented, deferred follow-up.)
The incident narrative and building details you type now survive a reload (Phase 150 / PERSIST-06). Both were dropped on save — the incidents table had no columns for them — so they lived only in memory and vanished on F5. They now persist. (The non-author change-request path deliberately still does not stage these two fields.)
Approving someone's proposed edit no longer silently overwrites a concurrent change to a metrics or AAR field (Phase 150 / PERSIST-07). The approval now compares each proposed field against the current live value and rejects the approval (asking for a re-review) if the specific field being changed has drifted since the request was made — while an unrelated concurrent edit to the same record no longer trips a false conflict.
Replay ordering correctness (code-review H-1). The first PERSIST-04 tiebreak ordered same-second events by a random id, which could apply a vehicle's move before its placement (a no-op) and reconstruct it at the wrong spot, unlocked. Corrected to order by causal class (creates before mutations before removals) so replay evidence is trustworthy.
The metrics/AAR concurrent-edit guard actually runs now (codex-challenge CRITICAL, migration 20260715140000). The PERSIST-07 field-level check was reading the wrong level of the staged payload and silently checking nothing — so the very clobber it was meant to prevent could still happen. It now reads the real fields; a drifted field correctly blocks the approval.
departments UPDATE role-gate (RLS-02/03). Closed a latent tenant-write hole: the one role-less permissive UPDATE policy (USING (id = auth_department_id()), no role check) let any dept member bare-update the department row via direct PostgREST (bypassing the dept_admin+ admin-route gate). Now role-gated to department_admin+ on both USING and a new WITH CHECK; the super_admin cross-dept bypass is preserved. Proven by real low-priv JWT probe: member own-dept + cross-dept UPDATE denied (value unchanged); super_admin any-dept + dept_admin own-dept allowed (no RosterEditor-rename regression).image-proxy outbound host allow-list (RLS-04). Added a curated host allow-list (maps.googleapis.com + .openmhz.com) layered on top of the existing resolved-IP SSRF blocklist — a non-allow-listed outbound host is rejected with a 403 before any DNS resolve, on the origin URL and every redirect hop. src↔Deno byte-identical mirror + drift test; adversarially verified against punycode/homoglyph/userinfo/IP-literal bypasses.Report-export toggles no longer lie (TRUTH-01). The admin "report section toggles" listed six sections, but the PDF exporter only ever rendered four — toggling Metrics or AAR off did nothing, a silent false signal to the admin. Those two dead toggles are removed; the admin now sees exactly the four sections the export actually produces (summary, timeline, tactics, photos). Legacy saved toggles are ignored harmlessly (no migration). Verified live: admin preferences shows exactly 4 report checkboxes.
The "report first" / "training first" landing preferences now actually do something (TRUTH-02). A department could set its post-login landing to a report-first or training-first view, but both silently fell through to the same default dashboard — the preference did nothing. report_first now lands on the incident list and training_first lands on the dashboard with the training filter pre-activated (the filter is now URL-addressable via ?view=training, so it also works as a direct deep-link). The absent-param default still resets to real incidents, so a drill session never hides a user's real incidents next login. Verified live: ?view=training opens the Training filter; bare /app keeps the Real default.
An ambiguous status-change failure now re-syncs from the server instead of showing stale state (TRUTH-03). Changing an incident's lifecycle status did an optimistic update and, on any error, blindly reverted. But if the database actually committed the change and only the response dropped (a network blip), the client reverted to a stale status until a manual refresh. It now distinguishes a real database denial (which correctly reverts) from an ambiguous/network failure (which refetches the incident's true status and reconciles to it, with a softer "couldn't confirm — refreshing" message) — mirroring the existing tactical-save conflict-reconcile pattern.
A flaky network tick no longer breaks the notification poller (TRUTH-04). The 60-second notification poll had no error handling, so a single transient "Failed to fetch" threw uncaught and could leave the bell stuck in a loading state. The poll now catches a failed tick, logs it quietly, and recovers on the next interval.
Captured the RPC re-author trap as durable dev guidance (TRUTH-05). Re-authoring a large SECURITY DEFINER RPC by regex/sed is the exact column-drift / import-path trap that caused a critical bug in Phase 150; the hand-verify checklist + worked example are now a permanent dev note (.planning/notes/2026-07-15-rpc-re-author-trap.md).
🔧 PATCH RELEASE. v17.1.1 closes the three floor-plan overlay polish items deferred from Phase 91's
/gsd-ui-review(seeded as 91.1). All additive to the v17.0.1 matrix3d overlay — no projection-math, schema, or dependency changes.
useMapFloorPlanOverlay's new overlayOpacity param and updates the overlay div opacity in place — a separate useEffect keyed on opacity (held out of the main rebuild deps via an opacityRef) so changing opacity never tears down the overlay or re-solves the matrix3d homography. Global + session-only (resets to 60% on reload). [Phase 91.1 R1]e2e/floor-plan-legibility.spec.ts asserts room-label rendered glyph size + WCAG contrast at tilt 67°/heading 180° (a legibility proxy via getBoundingClientRect + getComputedStyle, no OCR dependency). Closes the gap between FLOOR-PATCH-01b's polygon-position gate and actual rendered readability. [Phase 91.1 R3]--fire-red (#DC2626). The draggable reposition handle was rgba(59,130,246,0.9) (blue-500) — the third blue in the overlay alongside the perimeter polygon and window strokes. Now red, so the one interactive affordance reads distinctly and shares the accent with the opacity slider. [Phase 91.1 R2]🔧 PATCH RELEASE. v17.0.1 fixes the floor-plan SVG OverlayView drift that landed in v17.0.0 under non-zero map tilt/heading. The previous fix attempt (PR #159, merged 2026-05-29T21:10, reverted by PR #160 at 21:15) misdiagnosed the bug as a plumbing gap; the real bug was the axis-aligned-bbox rendering in
useMapFloorPlanOverlay.ts:46-66. Seefeedback_canonical_consumer_before_plumbingfor the misdiagnosis lesson.
useMapFloorPlanOverlay.draw() now renders via a true 4-corner homography → CSS matrix3d transform instead of the v17.0.0 axis-aligned bounding box of projected corners. The gray floor-plan box and the blue perimeter polygon visually coincide at any (tilt, heading) — verified by the 16-cell Playwright matrix (tilt 0/22/45/67° × heading 0/90/180/270°) at ≤ 1 px max corner/edge delta. [Phase 91 plans 91-01 + 91-02, FLOOR-PATCH-01a + 01b]svgW × svgH feet) so a 24'×18' room in a 27'×65' perimeter renders at 88.8% × 27.7% of the projected polygon area — matching the floor-plan editor view. Surfaced at Mike's visual GO checkpoint (the matrix3d math was correct but the prior 1×1 div design caused interior-content letterboxing). [Phase 91 plan 91-03 FLOOR-PATCH-01f]<path>, room <polygon>, hoseline <line>, opening <line> / <path> elements) carries vector-effect="non-scaling-stroke". DOMPurify's default config strips vector-effect; the hook explicitly allowlists it via ADD_ATTR: ['vector-effect']. Fixture icons and text labels accept mild foreshortening. [Phase 91 plan 91-02, FLOOR-PATCH-01c]sans-serif (Helvetica/Arial fallback) to Inter, sans-serif. Closes the typographic seam between in-overlay text and the rest of CTT's UI chrome. [Phase 91 plan 91-03 ui-review Warning 1]src/utils/homography.ts — inline 4-corner homography solver (solveHomography) + CSS matrix3d serializer (toCssMatrix3d). 176 LOC of partial-pivoted Gaussian elimination + 8-DOF solve with degenerate-case detection (collinear corners → null + AABB fallback). No new runtime dep — gl-matrix would have added ~14 KB gz, failing the size-limit 100 KB cap on index.js. Mean per-call cost 2.37 µs (M-series); 211× under the 0.5 ms SPEC delta budget. Joins the shared-utility layer alongside isTiltEnabled, tiltProjection, sceneProjection, streetViewProjection. [Phase 91 plan 91-01, D-01]e2e/floor-plan-perspective-matrix.spec.ts — dedicated 16-cell tilt × heading visual matrix for FLOOR-PATCH-01b; Docker-baked baselines committed at e2e/floor-plan-perspective-matrix.spec.ts-snapshots/floor-plan-tilt{0,22,45,67}-heading{0,90,180,270}-chromium-linux.png. Picked up automatically by the visual-matrix CI job via Playwright testDir discovery (no workflow edit needed). [Phase 91 plan 91-03, D-03]e2e/support/floor-plan-perspective.ts — canvas-readback raster delta + stroke-width helpers with fail-fast contract (no null fallback, no soft-pass). Cycle-3/4 design closes Codex HIGH-2 fully. [Phase 91 plan 91-03 cycle-3 + cycle-4]e2e/support/maps-overlay-shim.ts Polygon extensions — getPath() returns an MVCArray-compatible vertex accessor matching the real google.maps.Polygon API; _repaint() paints stroke into a sibling <canvas> inside .gm-style in parallel with the existing SVG overlayLayer (Phase 89 32-cell SVG visual baselines unchanged). [Phase 91 plan 91-03 cycle-4 harness extension]hotfix/v17.0.1 branch off the v17.0.0 tag (46ecb9f) per the CTT hotfix convention (project_ctt_deploy_topology); planning files (.planning/phases/91-*) live on milestone/v17.0 per D-05.tiltProjection.ts:67 mapTilt === 0 early-return) and Bug C (orthographic cos(tilt_rad) vs true pinhole) remain documented at CLAUDE.md "Tilt projection" KNOWN LIMITATION + SHARED_WITH_V16.md §1; both stayed out of scope per 91-SPEC.md..planning/seeds/91-1-floor-plan-overlay-polish.md: user-adjustable floor-plan opacity slider, move handle color disambiguation (currently shares hue with perimeter Polygon), end-to-end text-legibility OCR test at extreme tilt. Promotion criteria documented in the seed.### Next: v18.0); v17.0.1 does not affect v18.0 scope.⚠ ONE-WAY DOOR. v17.0.0 flips two defaults globally (tilt-unlock = ON for the 2D Vector satellite view, polygon-rooms = ON). This is a one-way door — rollback requires
git revertof the release PR (the staged-revert branchrevert/v17.0-default-flipis cut + verified pre-merge per Phase 90 plan 90-13). Per-user escape hatch:?tilt=0URL param (one-shot) or check "Disable map tilt (this device)" in the user/profile menu (persistent, per-device).
Phase 86 shipped Street View tactical projection (preserved verbatim below). Phase 87 + 88 + 88.1 + 89 + 89.1 + 90 close the v17.0 milestone — 3D scene baseline (Three.js + R3F), photorealistic 3D Tiles probe + glTF prop pipeline, E2E visual + replay-identity gates, and the default-flip closure. See subsections below for the full delta.
isTiltEnabled() (src/utils/isTiltEnabled.ts) now defaults to true. The Phase 85 PARKED tilt feature now ships as the canonical viewing mode for all google.maps.OverlayView consumers (ladders, conditions, floor-plan overlay, markers) — every consumer routes through the single-source tilt resolver and the tiltProjection.ts pixel-math hinge. Pairs with v=weekly Vector Maps + the cameraControl widget so the native compass + tilt slider appears top-right. Scope clarification: this is the 2D Vector satellite tilt (flat tiles at an angle, NOT 3D building meshes) — Photorealistic 3D Tiles building meshes remain unshipped, tracked in .planning/seeds/88-photorealistic-render-path.md. See CLAUDE.md "Google Maps: two different features keep getting conflated" for the architectural distinction. [Phase 90 plans 90-04 + 90-05]useRoomPolygonTool has been the only room-creation tool wired into src/components/floorplan/FloorPlanEditor.tsx since Phase 82; SMOKE-09 closes this documentarily in v17.0.0. The legacy src/components/floorplan/useRoomTool.ts ships on disk as the subject of SMOKE-10's 30-day deferred-deletion gate. v15-vintage incidents that only stored rect-room x/y/width/height continue to render byte-identically via the read-time polygon = room.polygon ?? rectToPolygon(room) compat shim — no data migration required. [Phase 80 FLOOR-01 + Phase 82 FLOOR-03 — closure per Phase 90 plan 90-07]Phase 86 — Street View Tactical Projection. All 8 canonical tactical
marker types (vehicles, hoselines, ladders, conditions, hydrants, side
markers, photos, personnel) are now click-placeable from inside Street View
at zoom ≥ 2 in tactical edit mode. Markers forward-project into the pano
viewport and track stably across pan/zoom/nav. Replay drives in-pano
markers automatically — markers appear/disappear at event timestamps. A
~5 ft uncertainty halo on every back-projected marker communicates the
inherent GPS floor (Google's reported pano camera position carries 3–5 m
intrinsic error; "approximately within 5 ft" is the locked accuracy
language). Off-viewport front-of-camera markers render as edge chevrons.
StreetViewPanel.tsx was replaced by <TacticalStreetView>; all four
snapshot operations (save / restore / rename / delete + Static API photo
capture) are preserved 1:1. The math hinge src/utils/streetViewProjection.ts
is the single source of truth for back- and forward-projection — both
functions are tested for 0.001 m / 0.01 px roundtrip accuracy and the math
is copied verbatim from the validated 4-spike series in .planning/spikes/.
Inherited constraint: Vector Map ID + API key MUST share the same GCP project (Phase 85 footgun — see CLAUDE.md "Google Maps: Map ID and API key MUST share a GCP project"). Pano construction does NOT depend on Map ID, but the surrounding GoogleMapView does — so the v17 invariant still holds.
Phase 87 — Three.js 3D scene baseline + Overture extrude. New top-level view mode viewMode === 'map3d' rendered via <ThreeDScene> at src/components/map/scene3d.tsx — a React Three Fiber <Canvas> composing <ambientLight> + <directionalLight> + Drei <OrbitControls maxPolarAngle={Math.PI/2 - 0.05}> + <ExtrudedFootprint> (Overture polygon → R3F mesh) + <GroundPlane> (Static Maps satellite drape) + <TacticalBillboards> (tactical sprites). Lazy-imported via React.lazy() so the entire R3F + drei + Three.js weight rides in sibling chunks (scene3d-*.js + OrbitControls-*.js) — index.js baseline (~97 kB gzipped) untouched. Polygon vertices reconstructed from useIncidentStore.floorPlans[firstFloor].perimeter via makeSceneProjector(origin).unproject(...) (src/utils/sceneProjection.ts) — no Overture RPC re-query, fully offline. Toolbar exposes a 3-state view selector (map2d → map3d → streetView) via uiStore.viewMode; switching preserves tactical state (3D-08). [Phase 87 plans 01–10]
Phase 88 + 88.1 — Photorealistic 3D Tiles probe + glTF scenery pipeline. src/lib/tileset3DProbe.ts runs a fail-closed coverage probe against tile.googleapis.com/v1/3dtiles/root.json with a 30-day localStorage cache; on positive coverage the 3D scene mounts <Photorealistic3DTiles> for real Google building meshes. src/lib/gltfCache.ts (DRACOLoader-equipped glTF LRU cache) backs a Sketchfab-sourced prop placement UX with attribution. Coverage-probe P0 resolved; the photorealistic render path itself shelved + deferred to .planning/seeds/88-photorealistic-render-path.md (the Cesium-based render pipeline rides the Phase 90 spike ~/spikes/maps-vector-tilt-clean-room/SPIKE-VERDICT.md forward). [Phase 88 + Phase 88.1]
Phase 89 + 89.1 — E2E visual + replay-identity gates. Playwright visual-regression.yml workflow ships with 32 byte-stable Docker-pinned baselines (*-chromium-linux.png via mcr.microsoft.com/playwright:v1.59.1-noble, 3× SHA-256 byte-stability verified). visual-matrix leg now an enforceable required-status-check (no continue-on-error: true). e2e/replay-identity.spec.ts proves 3 v15 fixtures replay through v17 computeStateAtTime and DEEP-EQUAL TRUE v15.0-tagged goldens (provenance-stamped). e2e/perf.spec.ts ships the relative ≤25% 3D-on-vs-off frame-time delta as the HARD CI perf gate (absolute ≥30 FPS advisory). Offline google.maps overlay-pane shim (e2e/support/maps-overlay-shim.ts) lets the matrix run at network=0 with zero src/ change. [Phase 89 plans 01–04 + Phase 89.1 plans 01–04]
Phase 90 — tilt escape hatches. ?tilt=0 URL param (one-shot per-page-load, highest precedence) and localStorage['ctt:tilt_disabled'] === 'true' (persistent, per-device off-switch) both short-circuit isTiltEnabled() to false. Legacy ?tilt=1 retained as a back-compat truthy alias. [Phase 90 plan 90-04]
Phase 90 — "Disable map tilt (this device)" user-menu toggle. New <TiltDisableToggle> at src/components/incident/TiltDisableToggle.tsx writes/clears the ctt:tilt_disabled localStorage key from the Dashboard header user/profile dropdown (desktop) + mobile hamburger surface. Direct localStorage read/write (no Zustand wrapper, no Supabase round-trip — per-device by design). Uncheck calls removeItem (NOT setItem('false')) so the canonical "tilt enabled" state is the absence of the key. try/catch around all localStorage access for private-browsing safety. [Phase 90 plan 90-05]
Phase 90 — Block useRoomTool re-import CI gate. New step in .github/workflows/ci.yml greps src/ for from.*useRoomTool['"] / useRoomTool() patterns and exits 1 on match. Enforceable from day one — no continue-on-error: true. Mirrors the Phase 89.1 R7 lesson. [Phase 90 plan 90-06]
Phase 90 — ROOMTOOL-DELETION-GATE.md 30-day deferred-deletion gate doc. Four-section gate at .planning/phases/90-default-flip-polish-v17-0-release/ROOMTOOL-DELETION-GATE.md: (a) calendar gate dated from the merged release PR, (b) static CI grep gate staying clean, (c) one-shot SQL count against incident_tactical_state.floor_plans JSONB for rect-room signatures (@.width != null && @.height != null && @.polygon == null), (d) deletion PR opens only after both gate results land in the file. Replaces a hypothetical PostHog/Sentry telemetry install. [Phase 90 plan 90-07]
Phase 90 — CLAUDE.md v17.0 architecture sections + SHARED_WITH_V16.md handoff cross-link. Five new CLAUDE.md sections — Tilt projection, Street View projection, Polygon rooms, 3D views (scene3d), v17.0 Shared-utility layer. SHARED_WITH_V16.md (the post-v17.0 v16.0 fix-phase handoff doc) cross-linked from ROADMAP.md + CLAUDE.md + STATE.md (3-file discoverability gate per R9). Stale "Code lives in table-top-tactics/" layout line fixed to Code lives in src/ at repo root (override per feedback_ctt_claude_md_layout). [Phase 90 plan 90-08]
?tilt=1 boot flag. Retained as a back-compat alias that evaluates truthy under the new default-ON behavior. New code should rely on the default-ON path; existing bookmarks and tests carrying ?tilt=1 continue to work. Drop in a future v17.x cleanup once no external consumers reference the param. [Phase 90 plan 90-04]src/components/floorplan/useRoomTool.ts deletion deferred to v17.0.x per SMOKE-10's 30-day gate. See .planning/phases/90-default-flip-polish-v17-0-release/ROOMTOOL-DELETION-GATE.md.)admin_delete_department SECURITY DEFINER RPC, neutralizes seven NO ACTION foreign-key blockers inside the RPC (profiles → orphaned to NULL dept; pre_plans / AARs / notifications → deleted; feedback_reports / dispatched_units source → nulled), preserves audit history through the cascade by relaxing audit_log.department_id to ON DELETE SET NULL, and writes a department_deleted audit row with affected counts in details. Migrations 060 + 061 + 062. AdminDepartments swaps the raw .delete() (which silently returned 0 rows under deny-by-default RLS) for the new RPC.addDepartment upserts successfully, the active dept auto-switches to the new one so auth_department_id() follows — without this, subsequent addStation / addApparatus writes silently failed RLS and the rows vanished on the next sync. RosterEditor also disables Add Station / Add Apparatus / From Template buttons on non-active depts (relevant for super-admins viewing all-dept rosters) with a "(read-only — switch dept to edit)" affordance, so the inability is surfaced before the click instead of after the rows vanish.useSupabaseSync previously treated "active id missing from latest fetch result" as definitive proof of deletion and called setActiveIncident(null), which wiped every tactical slice and unblocked the TacticalScreen redirect. The fetch can legitimately exclude the active id during a write-through race, an active dept switch, or a transient RLS/auth race — in all three the URL still references a real incident. Fixed by checking window.location.pathname for /app/incident/<activeId> before clearing; preserves anchor + tactical state and lets TacticalScreen render its loading state. Belt-and-suspenders: TacticalScreen refuses to redirect when the fetched list is empty, IncidentSetup waits for incidentsHydrated before resolving the route id (closes the parallel /edit regression), and the edit-route shows a loading spinner during hydration to prevent a one-render flash through the new-incident "real/training scenario selector".git revert of the release PR. Per-user escape hatches are documented above (?tilt=0 URL param + localStorage['ctt:tilt_disabled'] + "Disable map tilt (this device)" user-menu toggle); no feature-flag rollback path is provided beyond those.revert/v17.0-default-flip — see .planning/phases/90-default-flip-polish-v17-0-release/PRECONDITION-B.md (post-flip fill via plan 90-13) for the pre-flip HEAD SHA + staged-revert HEAD SHA + git diff pre-flip..revert confirmation that the revert undoes ONLY the flip commits. The branch is cut from the POST-FLIP milestone/v17.0 release-candidate HEAD (NOT the pre-flip HEAD; REVIEWS HIGH-02 cycle-2 fix).package.json size-limit caps are authoritative): index.js 97.02 kB / 100 kB cap; scene3d.js 63.7 kB / 75 kB cap; OrbitControls-*.js 238.73 kB / 300 kB cap. No regression > 2 kB vs these baselines is the Phase 90 plan 90-10 halt threshold.VITE_GOOGLE_MAPS_MAP_ID and VITE_GOOGLE_MAPS_API_KEY must live in the same GCP project before v17.0.0 ships — verified per .planning/phases/90-default-flip-polish-v17-0-release/GCP-PROJECT-ALIGNMENT.md. Phase 85's parking lesson is structurally honored. See CLAUDE.md "Google Maps: two different features keep getting conflated" for the runtime debugging guide.mapId + cameraControl). It does NOT introduce 3D building meshes — those are Google Photorealistic 3D Tiles (Map Tiles API rendered via Cesium/three.js), tracked separately in .planning/seeds/88-photorealistic-render-path.md. If you are debugging a "tilt/3D is broken" report, distinguish which feature the user expects before changing config.Follow-up iteration on Phase 63's Tactical Screen IA. Tools stay reachable when you collapse panels, tooltips fire instantly, the toolbar is flat and centered, and the feedback button lives inside the right sidebar on the tactical screen instead of floating over the map.
title="" delay to kick in./app, /admin, and everywhere else it still floats bottom-right
as before.ToolbarDropdown.tsx. Deleted after the flat-toolbar migration. Phase 63's
Equipment/Conditions/View dropdown groups were replaced by the two-zone layout during
Phase 63-03.OverflowSection in the right panel. Right panel now uses the RightIconSidebar
pattern instead of promoted-tabs + overflow collapsible.Major release driven by demo feedback from battalion chiefs, captains, and training officers. CTT now captures structured post-incident data and surfaces department-wide analytics.
Fixes from a full QA pass and design review. Touch targets now meet the 44px minimum for firefighters using phones and tablets. Mobile layout finally has a hamburger menu. Modals animate in. Landing page content is visible even without JavaScript animations.
loading=async. Removes the synchronous load warning.color-scheme: dark on root. Native browser controls (scrollbars, selects, date
pickers) now render in dark theme to match the app.color-scheme: dark to the Design Tokens section.UX overhaul, floor plan power-ups, fire tactics tracking, and a real permissions matrix. Six phases shipped in one session.
usePermissions() covering all
7 roles. Viewer gets read-only everywhere (edit tools disabled, create/import hidden,
LeftPanel overlay). AdminPanel enforces role hierarchy (can't assign roles at or above
your own). FeedbackButton gated to tester+.usePlayback triggered redundant state updates on every
tick, causing useMapMarkers to rebuild DOM content. Fix: snapshot memoization in
usePlayback + per-marker state hash in useMapMarkers skips unchanged content.removeEvent() per
event (which caused stale references mid-loop).The big one. Two SME demo sessions with BCs and training officers generated a wave of feedback. This release addresses all of it: floor plans, sharing, replay polish, lessons learned, training pipeline, pricing, and a pile of tactical detail improvements.
usePermissions() hook. Viewers get read-only access
(toolbar edit tools disabled, create/import hidden). Department admins manage roster and
members. Full 7-role hierarchy enforced across the UI.CREATE TABLE IF NOT EXISTS
and DROP POLICY IF EXISTS to avoid errors on re-run.5 new Supabase migrations:
011_floor_plans — floor_plans + floor_plan_conditions JSONB columns012_shared_incidents — mutual aid sharing with permission levels013_lessons_learned — lessons database with RLS014_waitlist — email capture (public insert, admin-only read)015_replay_annotations — replay_annotations + quick_notes JSONB columnsDepartment switcher, AI cost tracking, contributor tracking, replay UX overhaul, and the firematic design system. Everything needed to put the product in front of real fire officers.
ai_cost_log table---AI_RAW--- markerMigrated everything to Supabase as the single source of truth. No more localStorage. Timeline replay system, pen-draw hoselines, realistic vehicle SVGs, and the full design system.
The core tactical mapping system. Place vehicles, deploy equipment, mark conditions, track crews, and manage incidents.
First working version. Incident creation, apparatus placement, hoseline deployment, condition marking, and basic timeline.