ECTLogger

Explanation

Roadmap

Last updated: 2026-09-18
Compiled from user feedback: AA1GM, KC1UIX, W1BKW, W1MTW, N1GSK, KC1JMH

Canonical location: docs/ROADMAP.md.

Pruning policy: completed items are removed from this file, not struck through. The changelog is the record of what shipped; this file is the record of what has not. Before deleting an item, confirm its user-facing outcome is in docs/CHANGELOG.md and any convention or decision worth keeping has been moved to docs/DEVELOPMENT.md or docs/DESIGN.md. This file does not keep a per-prune narrative of what was removed or why — that history lives in docs/CHANGELOG.md (user-facing outcome) and git history (implementation detail), not here.


How to Read This Document

Items are grouped by milestone tier, then by theme within each tier. Each item carries a type tag:

Priority within each tier is roughly top-to-bottom. Items from conversations are attributed to their source where useful for context.

Model recommendations for sub-agents

As of rev 25, each item carries a Model: line recommending which Claude model a sub-agent should use to implement it:

Rule of thumb: Haiku and Sonnet can only maintain this codebase safely once files are small and patterns are extracted. That groundwork shipped with Milestone 0.4 (2026-07-06), so Milestone 1 items can now be assigned to smaller models as their Model: lines indicate.

Thinking levels for sub-agents

The model tier decides who does the work. The thinking level decides how much reasoning budget is spent before the first edit. They are independent, and the second one is the more common source of waste: an expensive model on a transcription task burns tokens restating a design that is already settled, while a cheap model with no budget at all on a concurrency question writes something that looks right and passes its own tests.

Items carrying a Model: line but no Think: line predate this convention; read them as think for Sonnet and Opus and none for Haiku.

Documentation coverage for sub-agents

Every item that changes user-facing behavior carries a Docs: line naming which of the help site’s audience paths (see DOC-STYLE.md) the work has to land in: operators, net-control, net-managers, admins, self-hosting, reference, or none.

The line is not a reminder to write documentation afterward. It is part of the item’s scope, the same as its migrations and its tests, and an item is not done until those pages exist. A feature that reaches production undocumented is a feature most of its audience will never find, which is how a net manager ends up asking for something that shipped four months ago.

Two rules make this survive contact with a real build:

Items predating this convention get a Docs: line when they are picked up, not retroactively in bulk. The Public Service Event Support item below already carries the long-form version of this as its own “Documentation deliverables” phase, which is the pattern generalized here.


Milestone 0 — Codebase Health & Maintainability

The bulk of this milestone (0.1 confirmed bugs, 0.2 orphaned code, 0.3 guardrails, 0.4 the modularity and componentization program) completed between 2026-07-03 and 2026-07-06 and has been pruned. What it delivered: a test suite and CI pipeline, React error boundaries, WebSocket resilience, SMTP timeouts, IANA timezone validation, composite indexes, shared frontend hooks, app/permissions.py, the backend router facades, and the frontend page splits. The patterns those splits established are documented in docs/DEVELOPMENT.md (“Backend router-split (facade) pattern”, “Frontend component-split pattern”, “Post-split verification checklist”).

Milestone 0 is complete as of 2026-07-29. Every section has shipped and been pruned. Section numbers are not reused, so commit messages and docs referencing “Milestone 0.4” or “Milestone 0.7” still resolve against the changelog. New codebase-health work should open a new section here rather than reopening a pruned one.

0.8 — Add swap to the production host (operator task — needs root)

⚠️ Manual task for Brad. Not a code change and not something the agent can do: the deploy account’s passwordless sudo covers only the ectlogger service, so these need an interactive sudo password.

Why: on 2026-09-03 a routine npm run build on production was OOM-killed partway through. Vite empties frontend/dist/ before it writes anything, so the kill left the site with no index.html at all — a full outage (Caddy’s try_files {path} /index.html had nothing to serve) whose only symptom was the single word Killed at the end of otherwise normal build output. The API stayed up; the web app did not. Recovery was a rebuild, about three minutes.

The host is 1880 MB with no swap, and the frontend is a single ~2.9 MB chunk that keeps growing. The immediate mitigation is already in place and documented in .github/copilot-instructions.md: builds now run with NODE_OPTIONS=--max-old-space-size=1024 plus a mandatory post-build check that dist/index.html exists. That narrows the window but doesn’t remove it — the bundle will eventually outgrow the cap.

Before running: take a snapshot/backup of the VPS first. These commands write a 2 GB file to / (79 GB total, 63 GB free as of 2026-09-03) and edit /etc/fstab and /etc/sysctl.conf. A bad /etc/fstab line can prevent the host from booting cleanly, so have a way back.

ssh ectlogger@app.ectlogger.us

sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile

# Persist across reboots
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

# Only fall back to swap under real pressure (default 60 is too eager for a server)
sudo sysctl -w vm.swappiness=10
echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.conf

# Verify
swapon --show
free -m

Verify /etc/fstab before rebooting: sudo findmnt --verify --verbose should report no errors. Once swap is live, an unbounded build should no longer be able to take the site down, though the heap cap and post-build check are worth keeping either way.

Follow-up worth considering separately: the 2.9 MB single chunk is the underlying cause. Code-splitting it (manualChunks / dynamic imports) would cut build memory and speed up first load for users. That’s a real refactor with its own regression risk, so it belongs on its own rather than bundled into this.

0.9 — Second test instance on the dev host (partly an operator task — needs root)

🔧 A place for feature/teams to bake that does not cost us beta (KC1JMH) Model: Sonnet for the one code change. Think: think. The rest is configuration and operator steps.

The Teams module is a long-running feature branch measured in months. Under the current setup, testing it on beta means beta stops mirroring production, and every bug report that arrives in the meantime has nowhere to be reproduced. A second instance on this host solves it, and the host is nowhere near its limits: 96 GB of RAM and 12 cores, currently running one instance.

Which branch goes where, and it is the opposite of the obvious assignment. Existing beta stays on main — it is the instance that must keep mirroring production so bug reports can be reproduced against what users are actually running. The new instance tracks feature/teams. The unstable work goes on the new box, not the established one.

Prerequisite code change, and it lands on main rather than the feature branch. BACKEND_PORT is already read from backend/.env, so the backend side is free. The frontend port is hardcoded in three places — --port 3000 in start.sh, and port: 3000 in both the server and preview blocks of frontend/vite.config.ts. Add a FRONTEND_PORT env var following exactly the pattern BACKEND_PORT already uses, defaulting to 3000 so nothing existing changes. Port 3001 is already in use by something outside this container’s process view, so the second instance takes 3002 and 8002.

Check disk before starting. / is 3.7 TB at 100 % with about 7.2 GB free. A second checkout is roughly 660 MB with node_modules and the venv, and a Vite build wants scratch on top of that. It fits, but not comfortably — clear space first rather than discovering this mid-build.

Steps that do not need root (run directly on this host — it is the session’s own host, never an SSH target):

# 1. Clone the feature branch into its own directory
git clone "$(git -C /home/bradb/ectlogger remote get-url origin)" /home/bradb/ectlogger-teams
cd /home/bradb/ectlogger-teams && git checkout feature/teams

# 2. Backend environment
python3 -m venv backend/venv
backend/venv/bin/pip install -r backend/requirements.txt

# 3. Frontend dependencies
cd /home/bradb/ectlogger-teams/frontend && npm ci

# 4. Config: its own ports, its own database, and its own SECRET_KEY.
#    Write backend/.env with BACKEND_PORT=8002, a DATABASE_URL pointing at this
#    instance's own SQLite file, EMAIL_ENABLED=false and SMTP_HOST=127.0.0.1.
#    Write frontend/.env with FRONTEND_PORT=3002, VITE_SERVE_MODE=preview, and
#    VITE_API_URL pointing at whatever hostname this instance ends up served on.

# 5. Build the frontend (vite preview serves a static build; git pull alone is never enough)
npm run build

The email guards are the one step that must not be skipped or improvised. A fresh .env on a new instance is a brand-new way to mail real operators from a test, and Teams introduces reminders, review requests, invitations, and callout notices — every one of them a new sender. EMAIL_ENABLED=false and SMTP_HOST=127.0.0.1 go in before the service ever starts, not after the first send.

Start this instance on a fresh database with de-identified fixtures, not a copy of production. The Teams concept documents already require de-identified fixtures for this module’s testing, and a third copy of production’s real member data is a privacy cost with no matching benefit. Teams migrations must never run against beta’s database, which holds that copy. Where a real-data smoke test is genuinely needed, run it against beta’s existing copy rather than making another one.

Steps that need root, so they are Brad’s (! sudo ... from the prompt runs them in-session). First /etc/systemd/system/ectlogger-teams.service, which is the existing unit with the paths changed:

[Unit]
Description=ECTLogger Teams branch test instance
After=network.target

[Service]
Type=simple
User=bradb
Group=bradb
WorkingDirectory=/home/bradb/ectlogger-teams
Environment="PATH=/home/bradb/ectlogger-teams/backend/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
Environment="NODE_PATH=/home/bradb/ectlogger-teams/frontend/node_modules"
ExecStart=/bin/bash /home/bradb/ectlogger-teams/start.sh --service
KillMode=mixed
KillSignal=SIGTERM
TimeoutStopSec=30
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

Then a /etc/sudoers.d/ectlogger-teams granting the same five verbs the existing file grants for ectlogger (start, stop, restart, is-active, status), so this instance can be managed without a password the way the first one is. Then systemctl daemon-reload && systemctl enable --now ectlogger-teams. Finally, nothing on this host proxies ectbeta.lynwood.us, so a hostname for the new instance is a change wherever that reverse proxy actually lives.

Verify with systemctl is-active ectlogger-teams, a request to :8002/docs, and a request to :3002/ — then confirm the original instance is still answering on 3000 and 8000 and still on main.

This does not change how production is built. Building production’s frontend here and shipping the artifact has been standard since 2026-09-15 because production’s 1.8 GB VPS gets OOM-killed building its own. The one rule when Teams eventually ships: the production build comes from main after the merge, never from the teams instance’s branch checkout, and still with VITE_API_URL=https://app.ectlogger.us/api passed explicitly on the build command.


Milestone 1 — Medium-term

Meaningful new capabilities that don’t require architectural changes.

Public Service Event Support

✨ Tactical-callsign posts, shift staffing, and event management for public service events (KC1JMH, from a Manchester ARES request via Ken)

Model: Opus for Phase 1-2 (new domain model, and a change to what CheckIn.callsign means app-wide); Sonnet for Phases 3-6 build-out against the settled design; Opus review gate on Phase 1 before merge, since every statistics, export, and dedup path reads that column.

Full spec and design notes: docs/concepts/PUBLIC-SERVICE-EVENTS.md

Marathons, bicycle rides, dog sled races, and parades are a structurally different kind of net from an ARES exercise or a SKYWARN spotter net. Amateur operators ride alongside public volunteers in SAG vehicles, at checkpoints, and at start and finish lines. A station is a place, not a person: “AID 3” is a table at mile 14 that exists from 06:30 to 16:00 and is worked by whoever is standing there — three different licensed operators over a fourteen-hour ride, a dozen over a four-day sled race.

ECTLogger cannot serve those events today. A check-in typed as a tactical designator links to no user, so the operator earns no credit for their work — the original complaint that prompted this item. There is also no way to plan or track who staffs a position across a rotation, which is what an event manager spends their weeks doing.

The two facts that shape the design. The position and the operator are separate, and both must be recorded: the position is what the net calls on the air, while the operator is who gets credit and who is legally responsible, since FCC 97.119 permits tactical calls but still requires each station to identify with its own FCC callsign every ten minutes. And the event manager is a distinct user with distinct needs — staffing the net rather than running it, weeks before the event and again after it.

Vocabulary decision. The words coverage, tactical callsign, operating position, staff, and position are all already spent in this codebase (RF propagation, the per-person User.callsigns alias list, the Home/Field classifier, the NCS eligibility pool, and a rotation sort integer respectively). This feature uses Post and Shift throughout, and never a bare post identifier in backend code.

Key decisions, argued in full in the concept doc:

Phase 1 — Posts on a net, and honest operator credit (not started)

Phase 2 — Shifts, Assignment Board, and gap detection (not started)

Phase 3 — Live board, sign-in and sign-out, day-of accountability (not started)

Phase 4 — Reusable event plans and notifications (not started)

Phase 5 — ICS-204, ICS-205, and hours reporting (not started)

Phase 6 — Multi-day series, adoption, and polish (not started)

Documentation deliverables (not started) — these ship with the phases, not after. Until then no user-facing guide may describe this feature as available.

Trigger: Phase 1 alone satisfies the original request and can ship independently; it is also the phase carrying the CheckIn.callsign semantics change, so it wants the Opus review gate before merge. Phases 2-3 are what make the feature usable for a real event. Phases 4-6 are what make it reusable year over year. Sequence Phase 4’s multi-period materialization before any dog sled race pilot.

Supporter / Funding Integration

✨ Optional Ko-fi supporter integration, admin-configured per deployment (sustainability)

Model: Phase 1 (config + support page) Sonnet; Phase 2 webhook handler Sonnet with Opus review (inbound payment webhooks are an attack surface — token verification, replay, refund handling); Phase 3 (progress bar/copy) Haiku; Phase 4 (About-modal credits) Sonnet.

Prerequisite: Help Menu and About modal shipped in rev 22. The subtle “Support” entry point lives inside the About modal.

A subtle, never-obtrusive, never-gated way for operators to help cover an instance’s hosting and development costs. ECTLogger stays 100% free; support is a quiet opt-in side door, not a paywall, modal, or nag. Because ECTLogger is open source and self-hosted by others, this ships as a generic integration any operator can point at their own Ko-fi account from the Admin panel — never hardcoded to one account, and disabled by default so a fresh clone shows nothing until configured.

Platform decision — Ko-fi, single platform.
Ko-fi is the choice for US-based operators specifically: it lets a creator link both Stripe and PayPal side-by-side, while Buy Me a Coffee is Stripe-only and Stripe US cannot process PayPal — disqualifying it for the older, PayPal/Venmo-trusting ham audience. Ko-fi also takes 0% on one-time tips (only the ~2.9% + $0.30 processor fee) and 5% on recurring memberships unless the operator pays Ko-fi Gold ($6/mo). Running two platforms at once was explicitly rejected: it creates donor choice-paralysis, double webhook maintenance, and fragmented goal tracking. (Source: design conversation, June 2026.)

Deployment-configurable settings (open-source requirement).
New columns on the AppSettings singleton (see DEVELOPMENT.md “AppSettings singleton pattern”), all editable from a new Support / Funding section in the Admin panel (gated by the existing role != ADMIN → 403 check):

Setting Type / default Public read? Purpose
kofi_enabled bool, default false yes Master switch; gates the entire feature
kofi_username string, nullable yes Builds the Ko-fi page / widget URL
kofi_webhook_token string, nullable no — write-only Verifies inbound webhooks
kofi_hosting_goal_amount int, nullable yes Monthly progress-bar target (per deployment)
kofi_hosting_goal_currency string, default USD yes Goal display currency
kofi_support_message text, nullable (may be blank) yes Operator’s own pitch; blank renders a sensible default

Secret handling: the public GET /settings response (readable before login) must never return kofi_webhook_token. Expose a boolean kofi_webhook_configured instead; the raw token is settable only via the admin PUT. The Admin panel also displays this deployment’s fixed webhook URL (https://<your-host>/api/webhooks/donations) for the operator to paste into their own Ko-fi dashboard.

Phase 1 — Config + subtle surface (not started)

Phase 2 — Supporter recognition (“sparkle”) (not started)

Phase 3 — Transparency (not started)

Phase 4 — Recognition in the About modal (not started)

Top financial supporters and community contributors are acknowledged directly in the About modal — visible to every user, no login required. Two categories:

Data model notes:

Implementation checklist (not started)

Trigger: implement after Phase 2 (donor tracking) is in place. Honorable Mentions can ship independently of Phase 2 since they are admin-curated.

Trigger: start after the Help Menu / About modal ships (done). Phase 1 alone satisfies the original goal (a subtle support link); Phases 2–4 are additive and carry no risk to core net logging.

Schedule Visibility & Calendar

✨ Month calendar view on the Schedule page, and calendar subscription (KC1JMH)
Re-requested 2026-09-08 as “calendar view of schedule” — that is Phase 1 below and nothing more, so this section already covers it. Treat the repeat ask as a priority signal: Phase 1 is the whole of what was wanted, and it stands alone without Phases 2-3.
Model: Sonnet for the month view and the single-event link (Phases 1-2, established UI patterns against one new read endpoint); Opus for Phase 3, the subscribable feed — its URL must be fetchable by Google’s unauthenticated servers, which makes it an auth design task rather than a UI one.

Two related capabilities. The first is a month grid on the Schedule page with next/previous month arrows, showing which nets fall on which day and, where the schedule has a rotation, who is NCS. The second is getting a net onto the operator’s own calendar so it shows up next to the rest of their week.

Today the Schedule page (frontend/src/pages/Scheduler.tsx) lists schedules, not occurrences — it answers “what nets exist” but not “what is happening this month.” The view-mode ToggleButtonGroup at :582-600 already offers card and list, so calendar is a third button in an affordance that exists. (Note CalendarMonthIcon is already imported at :51 and in use for the date-sort toggle at :577 — the calendar view needs a different icon, or two adjacent groups show the same glyph.)

The month grid is a union of two sources, and the seam is today. Past days come from real Net rows — what actually happened, whether it was held, who actually ran it, how many checked in. Today and forward come from materialized Net rows where they exist, and from projections where they do not, since _get_or_create_scheduled_net only materializes about 24 hours ahead. Projections use the existing calculate_schedule_dates plus compute_anchored_ncs_schedule (backend/app/routers/ncs_schedule.py).

Two rules fall out of that and both are load-bearing:

One aggregate endpoint, not the current fan-out. fetchSchedules (Scheduler.tsx:119-143) already issues one getNextNCS request per schedule. That is N requests to answer one question, and a month view asks it for every occurrence of every schedule — repeating the pattern means N requests on every arrow click. This needs a single GET returning the whole window across all visible schedules in one response.

Performance is the non-obvious risk. compute_anchored_ncs_schedule regenerates every occurrence from the template’s creation date up to the requested window on each call (ncs_schedule.py:174-179), because the rotation index is a count of elapsed occurrences. Paging a three-year-old weekly schedule out to a month next year generates hundreds of dates, per template, per click — and the cost grows with both the template’s age and how far the user pages. Compute one window for all templates in a single pass, cap the paging range, and treat the anchor-to-index offset as cacheable.

Naming: this is the third collision in this codebase, and the worst one. ICS here means Incident Command System — ICS-309 has shipped, ICS-204 and ICS-205 are planned above. An “Export ICS” button or an ics.py module would be read as an incident form export by precisely the emergency-management audience this product serves. Use iCalendar and Calendar Feed in labels and ical_feed.py in code. Never “ICS file.”

Phase 1 — Month view (not started)

Phase 2 — Add one net to a calendar (not started)

Phase 3 — Subscribable calendar feed (not started)

Documentation deliverables (not started)

Trigger: Phase 1 stands alone and delivers most of the value. Phase 2 is small and independent. Phase 3 should not start until someone actually asks for a live-updating subscription, since it adds a permanently reachable unauthenticated URL to the attack surface for a convenience the first two phases mostly cover.

Net View Usability

🔧 Separate “stepped away” from “no answer when called”, and flash the row on return (KC1JMH, 2026-09-08)
Model: Sonnet — a new StationStatus member touches the status dropdowns, the row tint logic, exports, and statistics, so it is a small-but-wide change rather than a one-file one.

Two different facts currently share one status. A station that sets itself away is saying “I am stepping out, call me later.” NCS marking a station away after calling it with no response is recording something else entirely: the station may be off the air, out of range, or gone. Both render as the same yellow row, so a net log cannot distinguish an operator who told the net they were leaving from one who vanished mid-net — a distinction ARES/SKYWARN after-action review actually cares about.

Return-from-away flash. When a station comes back — its status leaves away — briefly highlight the row so NCS notices without watching the table, then let it fade. Reuse sneakInFade / SNEAK_IN_HIGHLIGHT_MS from sneakInHighlight.ts rather than inventing a second flash: the app should have exactly one “something just happened in this row” animation.

Reopening a Closed Net

✨ Reopen a net that was closed by mistake, or auto-closed while it was still running (KC1JMH, 2026-09-19)
Model: Sonnet. One endpoint and one confirmation dialog, but it writes lifecycle fields that the report, the statistics pages and the ICS-309 all read, so the blast radius is wider than the diff. Think: think hard. Deciding which fields a reopen clears and which it must preserve is the entire problem; the code that follows from the decision is short. Docs: /docs/net-control/closing-the-net/ gains the reopen path and what it does to the log that was already emailed. /docs/reference/roles-and-permissions/ gains a grid row for who may do it.

Two real situations ask for this. A net gets closed by a misclick, thirty seconds into a two-hour activation. Or the inactivity auto-close (shipped 2026-09-15, off by default) fires on a net that was genuinely quiet but still running, because a long SKYWARN or ARES activation can go an hour between check-ins. In both cases the net is still happening on the air and there is currently no way to say so in the app: check-ins, chat, and traffic all refuse a closed net, so the operator either loses the rest of the net or starts a second one and hand-merges the logs afterward.

This used to happen accidentally. start_net listed the statuses it rejects rather than the ones it accepts, so CLOSED, ARCHIVED and CANCELLED fell through and the net was silently reopened with started_at recomputed. That was closed on 2026-09-19 (the endpoint now accepts DRAFT and SCHEDULED only, tests in backend/tests/test_net_start_terminal_status.py), deliberately leaving reopening unsupported rather than half-supported. close_net was tightened in the same commit for the same reason (it rejected CLOSED alone, so an ARCHIVED net could be closed back down to CLOSED and its log re-emailed); it now accepts ACTIVE and LOBBY, which a reopened net will satisfy, so nothing here needs relaxing again. The test file’s docstring is the best surviving description of what a careless reopen damages, and is worth reading before building the careful one.

Incident Operations Log & Situational Awareness Feed

✨ Log what you hear once, and let it render as an ICS-214, a spreadsheet row, and a Slack post (KC1JMH — from the statewide drill of 2026-09-17, see USER-STORIES.md)
Model: Opus for the record-type boundaries, the provenance model, and the incident record’s schema — the failure here is an unverified overheard report reaching a decision-maker as a fact, which is a judgment problem, not a CRUD problem. Sonnet for the log UI, the ICS-214 exporter, and the outbound dispatcher, all of which follow the Traffic module’s existing patterns. Haiku for additional export field mappings once the first one is built and verified. Think: ultrathink for the record-type boundaries, the provenance model, and the incident schema; think for the log UI and exporters; think hard for the dispatcher, where a retry must not repost; none for additional field mappings.

The drill that produced this. A county EOC ran a statewide exercise with the team split across two buildings. Every sitrep heard from MEMA or another county was handwritten, then typed into a spreadsheet, then typed into Slack, then typed again into an ICS-214. One thing heard, four places written. Separately, a shelter supply request was passed to MEMA as a verbalized list relayed through the team’s other site.

Start by using what already ships. The supply request was an ICS-213 and the app already files them, with the full originated/received/relayed/delivered chain of custody that would have recorded both hops of that relay and put metadata-only rows on the net’s ICS-309. That is not a gap; that is a feature nobody reached for under pressure, which is a training and UI-discoverability finding rather than a build. Fix the discoverability before building anything below.

Four record types, and collapsing any two of them is the whole risk. Doctrine already separates them and the app should too:

Record Question it answers Status
Traffic (Radiogram, ICS-213, RRI strip) A message we handled — we are in its chain of custody Shipped
ICS-309 Communications Log What our station sent and received on a net Shipped, per-net, fed by traffic
ICS-214 Activity Log What our unit did — notable activities, the reference for the after-action report Gap
Situational awareness entry Something we heard about somebody else, logged because our county needs to know Gap, and the bulk of the drill’s work

The fourth is the discovery, and it is its own record rather than a kind of traffic — it has no addressee, no precedence, no custody chain, and no delivery, which is four of the things a traffic form exists to carry. An overheard report from another county is not our traffic, not our station’s message log, and not our activity. It is an observation about a third party, its value is its content rather than its handling, and the app has nowhere to put it. That is what was being retyped into a spreadsheet and Slack all day.

And the source is frequently not amateur radio at all. The EMA calls on the team during big storms to monitor public service radio and scanners and report storm damage. The observer is in no communications chain whatsoever; they are listening to somebody else’s operational traffic, or looking out a window, and producing intelligence from it. So an SA entry’s source types span at least: direct observation, scanner or public-service monitoring, overheard amateur traffic, a report relayed to us, and a coordinator’s own briefing. The source type governs how far the entry may travel — scanner-derived content in particular carries sharing constraints that an amateur net’s traffic does not, agencies have their own policy about it, and nothing derived from monitoring should be pushed to an outbound channel automatically. Confirm the local rule with the EMA and record it as a team setting rather than assuming one.

An SA entry carries its provenance or it is actively dangerous. This is the one place in the feature where a cheap model will do the wrong thing confidently. Every entry records who reported it, how we came by it — heard direct, relayed to us, or overheard — and whether it is confirmed or unconfirmed. A third-hand “shelter at capacity” copied off an HF net and a coordinator’s own confirmed report must never render identically, because the consumer is somebody allocating supplies. The module’s standing rule against inventing a fact to fill a blank applies with money and people attached.

One capture, many renderings. The entry is written once, at the radio, by the person who heard it. Everything after that is a rendering of the same record: an ICS-214 row, a CSV row shaped for the county’s spreadsheet, and an outbound post. No path may require retyping, and no rendering may be the system of record.

Outbound notifications, Slack included — a courtesy, never the record. Hard rules, because this is where an integration quietly becomes load-bearing:

These records belong to an incident, not to a net, and an incident must be something you can stand up on its own. A storm is three days, six nets, two tag boards, forty SA entries, and a dozen ICS-213s. Today the app’s only container is the net, so nothing ties one operation’s records together — and worse, an operation with no net has nowhere to live at all. That is not an edge case. Monitoring public safety radio during a storm involves no net. A shelter tag board with three people in it involves no net. A single supply request relayed by phone involves no net. The net is one thing a team might do during an incident, not the thing an incident is.

This is a deliberate decision to cross a tripwire this document set out itself. An earlier revision resolved the incident question as a correlation label that records carry, and stated that the moment it grew a lifecycle it would have become the top-level Incident entity TEAM-INCIDENT-PLANNER.md refuses. Standing an incident up before any record exists is a lifecycle. So the tripwire has done its job: this is the decision it asked for, made in the open rather than reached by accretion.

What survives the change is the actual refusal, which was never about lifecycle. The planner refuses a container that owns staffing — a second shift table, assignment board, or attendance clock. That stands, and it is what keeps this from becoming a parallel Events module:

This feature owns the incident record; Teams adopts it. The Incident Operations Log ships well before the Teams module, so it builds the record and every consumer that exists at that point. Two forward-compatibility requirements, because they are cheap now and migrations later: do not assume a single team (the participation join is added by Teams phase M1A, and the schema must not preclude it), and do not assume every incident has a net, which is the whole point above.

The ICS form inventory, and who owns each one. Verified against the code on 2026-09-17 rather than from memory:

Form What it is Status
ICS-213 General Message A message we handled, with originator, addressee, and reply Ships now. Full form definition, the originated/received/relayed/delivered chain of custody, and a form-accurate PDF
ICS-309 Communications Log What a station sent and received during an operational period Ships now. Generated from the net’s traffic log, CSV and JSON, per net, gated on that net’s own toggle
ICS-214 Activity Log What our unit did — the reference for the after-action report Gap, and this item closes it. Rendered from log entries, never generated from a roster
ICS-205 Incident Radio Communications Plan The channel plan for an operational period Teams phase M6, reusing the Events builder. Its inputs are the approved PACE and channel records from Teams M3B
ICS-205A Communications List Who is reachable how, by assignment Teams phase M6
ICS-211 Check-In List Resources checking in to an incident Teams phase M6, and it is resource-oriented, not the EMA’s attendance roster — the attendance work in Teams M1A is a different document with a different purpose
ICS-202 / 204 Objectives, Assignment List Incident objectives and tactical assignments Teams phase M6 via the Events builders
ICS-217A Communications Resource Availability What channels are available to be planned with Candidate only; not committed until a pilot shows it is needed

The application helps build the ICS-205; what it must never do is approve it. An earlier revision of this entry said the 205 is received rather than authored, which is too narrow. It is true of a large incident with a staffed communications unit. It is not true of the ordinary county activation, where no such unit exists, the EC is effectively the communications lead for the amateur resources, and the team is the only party in the room who can say which repeaters, simplex frequencies, and digital paths are actually available. Drafting it is the team’s job. The distinction that matters is not authored versus received — it is drafted versus approved, and that is the line the app enforces.

We author and hand over the ICS-213, 214, and 309 outright. The 205 and 205A we draft, and someone else may or may not approve.

The incident carries its document set, for history and for re-export — and the hard part is that a re-export must reproduce what was handed over, not what the data says today. Attaching a form to an incident is not a new mechanism: the form carries the incident reference like every other record, and “the incident’s documents” is a view over that reference rather than a container that owns them. What is new, and what has to be designed rather than assumed, is what a second export means.

Teams phase M6 already plans versioning, approval, and issued snapshots for the plan package. That is the same mechanism and must reuse it rather than building a second one.

Exercise marking is not cosmetic, and a SimCell makes it load-bearing. The drill was driven by a simulation cell injecting scenario traffic, and every form leaving the team during an exercise has to be unmistakably an exercise document. The traffic module already carries TrafficTestCategory.DRILL, which deliberately exempts nothing — same chain of custody, same appearance in the ICS-309 — and only labels. Every form family added here inherits that behavior, and the label has to survive into the rendered output and the outbound post, not just the database. An exercise sitrep forwarded into a Slack channel and screenshotted is exactly how a drill message becomes a real one.

One open design question this drill exposed. ICS-309 is a log for a station and an operational period, and the app currently generates one per net. When one team operates two stations on one net — the old office and the new office, as happened here — that produces a single merged log where the field practice was two, one kept at each site. The other site kept its own 309 by hand. Whether the app should render per-station logs from one net’s traffic is a real question with a doctrinal answer, and it should be settled when the incident work lands rather than discovered during the next activation.

Observations belong on a map, and the map is theirs — this is the attendance spreadsheet again. During the drill, an EMA staff member in the EOC built the county’s common operating picture in ArcGIS: road closures, shelters and their status, trees and wires down, hazards. Her inputs were her peers’ reports and our hand-transcribed radio notes, retyped by her. That is the same disease as the master attendance sheet, and it has the same cure: not a prettier form on our side, but an export in the schema her system already reads.

Inviting the EMA staff member as a user is a second, larger step — and the export may make it unnecessary. The cheapest version of “help them manage their situational awareness” is that she keeps ArcGIS and stops retyping. If an account is still wanted after that, it is a new kind of principal and needs its own design rather than a role added to an existing one: someone outside the organization, without a callsign, who can see one incident’s observations and contribute her own, and who must never reach the team roster, whereabouts detail, member contact information, or net staffing. Her contributions are attributed to her with their own provenance — an EOC report is not something we heard on the radio — and her access is scoped to the incident and expires with it. That is a permission boundary between two organizations, which puts it in the same tier as the Teams permission helper, and it is tracked as its own item below rather than folded in here.

Do not manufacture entries. An ICS-214 is written from what happened, never generated from assignments, a plan, or a shift roster — the same rule TEAM-INCIDENT-PLANNER.md already states for ICS-211 and ICS-214 mappings. A fabricated activity log is worse than none, because it is signed and filed.

Reuse, do not rebuild. The Traffic module already owns form definitions, chain of custody, per-net export integration, and form-accurate PDFs. This feature is a fifth form family and a dispatcher beside it, not a second traffic system. ICS-309 stays where it is.

Open questions — and the answers are settings, not constants. Cumberland County EMA is the source of the questions below, and it does not speak for the next county, for MEMA, or for any other served agency. An answer obtained from one agency becomes that team’s configured value with that answer as its default, never a hardcoded rule, column set, or validation. The test is simple: if standing this up for the next county over requires a code change, the answer was written in the wrong place. See the policy register in TEAM-MANAGEMENT-NOTES.md section 5.20 — settings that are genuinely the agency’s policy rather than the team’s attach to the served-agency record, so a team serving both a county EMA and the state does not have to average two answers into one.

Agency Liaison Access

✨ An account for served-agency staff, scoped to one incident, that can never reach the roster (KC1JMH — from the statewide drill of 2026-09-17) Model: Opus for the principal type and the permission boundary; Sonnet for the invitation flow, the scoped views, and the contribution form. Think: ultrathink for the boundary and the expiry semantics; think for everything else. Opus review gate before merge, on the same reasoning as the Teams permission helper: a scoping bug here exposes a roster to another organization and nothing in the interface shows it.

Do the situational awareness export first and see whether this is still wanted. The EMA staff member’s actual problem is that she retypes our radio notes into her ArcGIS map. An export in her layer’s schema solves that without giving anyone an account. This item exists because Brad asked whether she could be invited to help manage situational awareness including our input, and that is a reasonable thing to want — but it is the larger and riskier of the two answers, and the cheaper one may close the case.

What makes this a new principal rather than a new role. Every existing actor in this application is either a member of the organization or a guest checking into a net. An agency liaison is neither: an employee of the served agency, usually without a callsign or any amateur licence, who is a peer to the team rather than a part of it, and who needs write access to shared operational data while being permanently outside the organization’s private data.

Open questions. Does the EMA’s own policy permit its staff to hold accounts in a volunteer organization’s system, and who decides that — her, her director, or county IT? Is one liaison per incident realistic, or does a real activation need several with different agencies? And if she contributes an observation that later proves wrong, whose correction is it — hers, or the team’s log’s?

Account Deletion, Anonymization & Right to Erasure

🔒 Replace hard user deletion with anonymization, and add a separate erasure action Model: Opus for the design and the erasure semantics; Sonnet for the implementation once the shape is agreed.

The problem. DELETE /users/{id} (routers/users.py::delete_user) is a bare await db.delete(user) — no cleanup, no reassignment, no anonymization. Everything that referenced that user is left pointing at an id that no longer exists.

Two things make that worse than it looks:

Chosen approach: anonymize in place, don’t hard-delete. Blank the identifying fields on the users row rather than removing it — name, location, avatar_url, skywarn_number, sms_gateway, live location; email to a unique non-identifying value (the column is NOT NULL UNIQUE); callsign to a placeholder; clear gmrs_callsign, callsigns, previous_callsigns, oauth_id, unsubscribe_token, password and MFA material. Set is_active = False and add a deleted_at column.

Why this over hard-delete plus SET NULL everywhere:

Callsigns in historical logs: keep by default, scrub on explicit request. A callsign is personal data — it maps to a named licensee in public FCC records — and check_ins.callsign is a denormalized string that survives account deletion regardless of what happens to the users row. Decision (2026-09-03):

Open questions to resolve before building:


Trivia Integration

✨ Net trivia support (back-burner, pending spec)
Model: Sonnet once a spec exists; the spec itself is a human/Opus conversation.
Load trivia questions from a CSV file or URL. During a net, NCS can click a trivia icon on a check-in row to pose a question to that station and log correct/incorrect. Include trivia results in the net log, PDF report, and email summaries. Needs detailed spec before development begins.


Milestone 2 — Longer-term / Architectural

Items that require significant new infrastructure, platform expansion, or external integrations.

Database Migration Path

✨ Migrate from SQLite to PostgreSQL ahead of expected growth (KC1JMH)
Model: Opus — data migration with zero-loss requirements, plus an audit-found landmine: several columns store JSON as Text (e.g. User.callsigns) and enums via SQLAlchemy Enum — both need an explicit porting decision for Postgres. The schema-tooling question (Alembic or not) is its own prerequisite section below.
ECTLogger runs SQLite today, which is appropriate for a low-concurrency single-server deployment. SQLite serializes all writes; under concurrent net sessions and real-time check-ins from multiple NCS operators at once, this will become a bottleneck. The ORM layer (SQLAlchemy async with aiosqlite) already supports PostgreSQL via asyncpg — the DATABASE_URL env var is the primary code-level change.

Migration plan:

Trigger: migrate before the user base exceeds ~300 accounts or before any feature requiring high concurrent write throughput (e.g., simultaneous multi-net operation). The expected inbound migration from ham.live’s closure makes this a near-term planning item rather than a back-burner one.

Schema Tooling Decision (prerequisite for the PostgreSQL migration above)

🔧 Decide whether to adopt Alembic before the Postgres cutover (formerly roadmap item 0.5, “Migration hygiene policy”)
Model: Opus for the decision, Sonnet for execution.

Migrations today are hand-numbered Python scripts in backend/migrations/, each run individually against each deployment. That works for SQLite schema tweaks but leaves no versioning record, so a fresh Postgres database has no defined “current schema” to build from. Decide and execute one of two paths before the cutover:

Whichever path is taken, the PostgreSQL migration plan above must be rewritten to match: it currently assumes Alembic.

Already settled (2026-07-07), keep enforcing: migrations carry schema changes only, never instance-specific data fixes — a self-hoster must never inherit another deployment’s roster seeding. The standing rule lives in the “Migration content guidelines” section of backend/migrations/README.md, and it constrains whichever tooling path is chosen. The 013_ numbering collision that prompted the rule is resolved.

UTC-Aware Datetime Hardening (prerequisite for the PostgreSQL migration above)

🔧 Standardize on timezone-aware UTC datetimes end-to-end (KC1JMH)

Model: Sonnet for the mechanical sweep, Opus review before merge (naive/aware bugs pass tests that don’t cross a DST boundary). Audit 2026-07-03 verified scope: 34 utcnow references across 8 files — auth.py, main.py, whats_new_service.py, ncs_reminder_service.py, routers/chat.py, routers/check_ins.py, routers/nets.py, routers/ncs_rotation.py. Frontend 'Z'-append workarounds live in Admin.tsx (6 sites), CreateNet.tsx, and NetView.tsx.

Background. The app already stores concrete net instants (Net.scheduled_start_time, started_at, closed_at, etc.) in UTC and renders them per-user in local time. The fragility is how UTC is represented in the code: today it is naive UTC by convention. On SQLite, DateTime(timezone=True) silently drops the offset, so a value written as “UTC” comes back as a naive datetime. The backend leans on this (boundary helpers return naive UTC; comparisons use the deprecated datetime.utcnow()), and the frontend papers over it by appending 'Z' before parsing (CreateNet.tsx, NetView.tsx). The June 2026 reminder bug was one symptom of this naive/aware ambiguity.

Why this blocks PostgreSQL. SQLite ignores timezone info; PostgreSQL does not. A DateTime(timezone=True) column maps to timestamptz, which stores a true instant and returns tz-aware datetimes. Under Postgres:

In other words, the SQLite→Postgres migration will break time handling app-wide unless this is resolved first. This item is therefore a prerequisite, not a nice-to-have.

Target design (works on both SQLite and PostgreSQL):

Implementation checklist (not started)

Trigger: complete alongside (and ahead of) the PostgreSQL migration. Low user-visible risk if done carefully; high risk if deferred until after the Postgres cutover.

Team Management Module

✨ Teams — ARES/SKYWARN team roster, readiness, equipment custody, callouts, and ARRL Form 2 support (KC1JMH — back-burner)

Full spec and design notes, in four interlinked documents. The hub is the entry point; section numbers are global across all four:

Summary: a new Teams section (menu between Schedule and Stats) to replace spreadsheet-based ARES/SKYWARN team tracking with a role-controlled, self-service platform. Members manage their own profiles; team managers handle roster, approvals, and reporting. Net participation rolls up to team records. Designed to facilitate ARES Form 2 and EMA hour reporting.

The concept has since grown well past a roster. It now also covers station capabilities and demonstrated readiness, team equipment inventory and custody, per-item maintenance and antenna sweep records, versioned procedures and PACE plans, radio and optional SMS callouts, a tag board for presence accountability, task books and training schedules, deployment packets and personnel accountability, and a communications-planning layer that shares the Events staffing workflow. It is the largest single feature proposed for this app.

The tag board (phase M1A) is the cheapest operationally useful piece and should ship right after the roster. The app currently has no way to record who is physically where unless a net is running, and a net check-in records a station being on the air, not a person being in a place — a non-radio volunteer or an unlicensed helper has no reason to be in a net log at all but still has to be accounted for. A tag board answers “who is where” with no net, no event, no plan, and no radio. It depends on M1 alone. See TEAM-ACTIVATION-CALLOUTS.md section 5.19. Its one hard invariant: a tag never creates a check-in and a check-in never creates a tag, and nobody is ever automatically tagged out. M1A also feeds the served agency’s attendance records from the board. Cumberland County EMA collects attendance on a custom local participant roster (not ICS-211, though EMPG’s cost match is why it asks for name, date, times, and activity), and every sheet is then transcribed into a master spreadsheet holding every team’s attendance — so the retyping is the real cost, and exporting the board’s own rows in the agency’s column mapping is the real deliverable; the rendered paper sheet is the secondary one. Three things make it non-trivial, all designed rather than deferred: round-trip travel time is carried alongside a presence duration and never added to it, re-exporting a board must read as a re-run rather than silently doubling rows in a spreadsheet this app cannot see, and the agency staff filling half the room are recorded as external participants without joining the roster.

Guided setup for the EC standing the team up (phase M1B). This module silently defaults roughly two dozen per-team policy decisions the moment a team record is created — whether the en-route tag state exists, who may open a tag board, how long whereabouts detail is retained, what the organizational levels are called, which training rules apply. A default nobody was shown is not a policy. M1B adds a policy register listing every one of those settings with its value, its default, and the section that governs it, plus a catalog of sourced ICS and ARES hints attached to the decisions they bear on. Three rules make it safe: setup is always skippable and a team that never opens it works on safe defaults; a hint may never block a save, disable a field, or be consulted by a permission check; and each hint states whether its source requires, recommends, delegates, or merely exemplifies — never collapsing those four, because rendering a delegated question as a requirement invents a national standard that does not exist. The register itself is M1 schema (a setting added after teams exist is a migration plus a guess); M1B is the stepper, the catalog, and the decision log over it. See TEAM-MANAGEMENT-NOTES.md section 5.20.

Also carries the Teams-dependent half of “can hear” station-to-station coverage logging (shipped 2026-08-02, see CHANGELOG.md) — named team locations (shelters, EOCs), location-to-location coverage, and Coverage Assessment reporting for team managers. See TEAM-MANAGEMENT-NOTES.md section 5.6; the per-net capture it builds on has already shipped and is not blocked by this module.

Phases and model assignment. The concept’s section 10 is authoritative and carries the exit criteria; this is the index. Phase labels are M0-M6 (plus M1A, M1B, M3A, M3B), phases of this module — not roadmap tiers. “Teams phase M3” is unambiguous; “Milestone 3” is not, since this whole module sits inside Milestone 2.

One tier for a module this size is wrong in both directions: it overpays for the mechanical parts and underinvests in the six places where a silent bug is expensive. The split follows Opus writes the schema and the invariants, Sonnet builds against them, Haiku fills in repeated instances of an established pattern.

Phase Delivers Model
M0 Discovery: data dictionary, permission matrix, sample import, pilot scenarios Human conversation with Opus; not an implementation task
M1 Team/unit records, membership lifecycle, scoped grants, audited claims, the policy register and its defaults Opus schema, permission helper, and register; Sonnet UI/CRUD; Opus review gate
M1A Tag board: presence occasions, tag in/out, places, live view, guarded close Opus presence state model and its relation to the canonical hours sources; Sonnet board UI, tagging, live updates, exports; Opus review gate on the presence model. Depends on M1 alone
M1B Guided team setup, doctrine hint catalog, policy decisions, SOP-draft export Sonnet throughout — a stepper over an existing settings table, a read-only catalog, and a decision log are established patterns here; Haiku for further hint entries once the four-strength shape is verified. Depends on M1 alone
M2 Intake, assisted maintenance, CSV import catalog, freshness/reminders Opus import/identity engine; Sonnet forms and batch UI; Haiku template and field-guide files; Opus review gate on commit path
M3 Training, task books, station configurations and capabilities, roster search Opus capability model and match semantics; Sonnet catalogs, views, exports; Haiku additional saved views
M3A Asset register, kits, custody, maintenance schedules, SWR sweeps Opus containment and the checkout transaction; Sonnet registration, manifests, queues; Opus review gate on handoff
M3B Procedures, PACE cards, alert stages, radio callouts; then optional Twilio SMS Sonnet for everything except SMS; Opus for consent, check-at-send, and webhook validation; Opus review gate on the webhook
M4 Net/team association, participation attribution, report adapters, coverage Opus attribution rule (time handling, now over three canonical actual-time sources: net check-in, Events shift, and M1A tag); Sonnet adapters, exports, coverage rollups
M5 Plan objectives, requirements, candidate matching, reservations, packets Opus reservation conflict model (reuse M3A’s answer); Sonnet wizard and Events wiring
M6 Reviewed ICS-202/204/205/205A package, versioning, after-action actions Sonnet reusing Events builders; Haiku additional form mappings

M1-M3 are the membership MVP and independently replace the spreadsheet. M1A is the shortest path from a roster to something a team can run an activation with, and needs nothing but M1. M1B is the cheapest useful thing for the person standing the team up, and is almost entirely content and forms over a register M1 already had to build. M1A, M1B, M3A, and M3B are each independently shippable and depend on none of the others. Only M5 and M6 require Events.

Sequencing against the two prerequisites above. Schema Tooling Decision should be settled before M1 creates the first Teams tables, and UTC-Aware Datetime Hardening should land first — Teams adds dozens of dated columns, and adding them naive means they join the sweep that item exists to end. Neither blocks M0, which is pure discovery and can start any time.


Build Plan

The phase table above says what each phase delivers and which tier should build it. This is the execution index: the same work broken into packages small enough to hand to one sub-agent in one sitting, each with its model, its thinking level, and the specific sections it needs to read. Section 10 of TEAM-MANAGEMENT-NOTES.md remains authoritative for exit criteria; nothing below replaces them, and where this index and a phase’s exit criteria disagree, the criteria win.

Package IDs are stable and are meant to appear in commit messages and branch names. M1A-3 means the same thing a year from now, and a phase that gains a package appends rather than renumbering.

Before package one. Three things are true before any of this starts, and none of them is negotiable by a sub-agent that does not know about them:

  1. Schema Tooling Decision is settled and UTC-Aware Datetime Hardening has landed. Teams adds dozens of dated columns; adding them naive means they join the sweep that item exists to end.
  2. M0 has exited. Its output is the specification every later package is measured against, and it is a conversation, not an implementation task.
  3. All of it lands on a long-running feature/teams branch per the Long-Running Feature Branches rules in .github/copilot-instructions.md: every phase is commits on that branch, beta tests from the branch, and the merge to main happens only once beta confirms. No changelog entry is written until that merge deploys to production, dated for the actual deploy day. Incidental bug fixes made to get a phase shippable are not separate changelog items.

What a package prompt contains, and what it must not. The four concept documents run about 2,700 lines. Pasting the set into every sub-agent prompt is the largest avoidable cost in this module, and it also buries the two paragraphs that actually govern the work. The Reads column below names the sections a package needs; the prompt carries those sections, not the set. Section numbers are global across the four documents, so a bare section number resolves through any of their Document Maps. Alongside the named sections, every prompt regardless of tier carries:

A package that cannot be briefed this way is too large. Split it.

M0 — Discovery.

# Package Model Think Reads
M0-1 Data dictionary, permission matrix, sample import and mapping, pilot scenarios, prioritized report/form checklist Opus, in conversation ultrathink 10 (M0), 11

M1 — Team and Membership Foundation. The foundation every other phase builds on, and the one place where a quiet mistake is unrecoverable rather than expensive.

# Package Model Think Reads
M1-1 Schema: team, unit, membership, grant, claim, audit, and the policy register with every default it decides Opus ultrathink 6.1, 6.2, 5.2, 5.8, 5.20
M1-2 Team permission helper: scoped grants, unit delegation, field-level read/write, lifecycle suppression Opus ultrathink 7, 8, 5.2
M1-3 Additive migrations valid on both upgraded and fresh installations Sonnet think hard 6.1, migration template
M1-4 Router facade plus membership and application lifecycle endpoints, written against the helper Sonnet think 5.2, 5.3
M1-5 Teams navigation, discovery and privacy settings, roster, member detail, manager-created records Sonnet think 5.1, 5.7, 5.8
M1-6 Audited record claims, concurrency checks, core export and retention controls Sonnet think hard 5.3, 8
M1-G Gate: the permission helper, probed directly through the API with revoked grants and altered team identifiers Opus ultrathink 7, 8, release checklist

M1A — Tag Board and Presence Accountability. Depends on M1 alone and is the shortest path from a roster to something usable on activation day.

# Package Model Think Reads
M1A-1 Presence state model and its relationship to CheckIn and the Events shift; the board and tag invariants Opus ultrathink 5.19, 5.5, 6.1
M1A-2 Named TeamLocation records pulled forward from M4, names only, no coverage Sonnet none 5.6 (named locations)
M1A-3 Board open and guarded close with keeper handover; tag in and out; assisted tagging carrying recorder and channel Sonnet think 5.19
M1A-4 Live board over ConnectionManager, server-originated events only, broadcast by the route handler after the write Sonnet think 5.19, WebSocket table
M1A-5 Raw row export with a stable board-plus-membership row identity and a recorded export receipt Sonnet think hard 5.19, 6.1
M1A-6 Rendered agency roster: populated from membership, printable blank with expected attendees, agency text verbatim Sonnet think 5.19, 5.8
M1A-7 Overdue surfacing with no automatic state change; external-participant stage Sonnet think 5.19
M1A-8 Team participation join on the incident record, and the cross-team record visibility setting, default off Opus ultrathink 5.19, 7, 8, Incident Operations Log
M1A-G Gate: the presence model; tag and check-in independence tested in both directions; smoke test against “at most one open tag per person per board” Opus ultrathink ACT validation focus

M1B — Guided Setup, Policy Register, and Doctrine Hints. Depends on M1 alone. Almost entirely content and forms over a register M1 already had to build, which is why it carries no Opus package.

# Package Model Think Reads
M1B-1 Setup stepper over the register: skippable, resumable, every step re-enterable later, deputy or successor first Sonnet think 5.20
M1B-2 Hint catalog schema and the first hints, one of each strength, rendering visibly differently from one another Sonnet think hard 5.20 (hint shape)
M1B-3 Decision log: value, responding hint, deciding member, date, rationale; a decision to differ is a complete outcome Sonnet think 5.20
M1B-4 Quick-Start scaffold against the 5.14 agency and PACE records, and the draft SOP export Sonnet think 5.20, 5.14, 5.18
M1B-5 Remaining hint entries and the 5.16 training catalog seed, with the IS-200/IS-800 edition conflict left visible Haiku none 5.20 (catalog), 5.16
M1B-6 Adversarial check: attempt to make a hint of each strength block a save, disable a field, or change a permission outcome, and report what was attempted Sonnet think hard M1B exit criteria

M2 — Onboarding, Import, and Freshness.

# Package Model Think Reads
M2-1 Import engine: identity matching, idempotent re-import, blank-means-unknown, reversal semantics Opus ultrathink 5.11
M2-2 Mapping, preview, and reconciliation flow with typed local fields and correction downloads Sonnet think 5.11
M2-3 Self-service intake, progressive profile editing, saved drafts, optional invites, assisted update Sonnet think 5.7
M2-4 Last-confirmed indicators, review queues, reminders on the existing email patterns Sonnet think 5.11, email patterns
M2-5 Versioned blank and example CSV files and field guides, once the columns are settled Haiku none 5.11 (import catalog)
M2-G Gate: the commit path, with a reversal after later edits, a partial batch, and a concurrent edit Opus ultrathink 5.11, release checklist

M3 — Training, Readiness, and Capability Search.

# Package Model Think Reads
M3-1 Capability and configuration model with AND/OR match semantics Opus ultrathink 5.9, 5.10
M3-2 Training catalog, records, reviewer workflow, task books, equivalencies, training calendar Sonnet think 5.9, 5.16
M3-3 Personal equipment and operating configurations: Home, Vehicles, Deployable, with shared physical-item references Sonnet think hard 5.9 (equipment)
M3-4 Roster search, AND/OR filters, match explanations, scoped exports, indexes from representative queries Sonnet think 5.10
M3-5 Additional saved views and the training, equipment, and capability CSV templates Haiku none 5.10, 5.11

M3A — Team Asset Register and Custody.

# Package Model Think Reads
M3A-1 Containment, custody state, and the checkout, transfer, and return transaction Opus ultrathink 5.13 (custody)
M3A-2 Registration, ownership distinctions, manifests, condition, audit and history views Sonnet think 5.13 (inventory)
M3A-3 Maintenance tasks, recurring and triggered schedules, due queue, deferrals, return-to-service rules Sonnet think hard 5.13 (maintenance)
M3A-4 Antenna sweep metadata, structured summaries, private attachments, baselines; guides and printable quick tests Sonnet think 5.13 (sweeps, guides)
M3A-5 Location, asset, kit-content, initial-assignment, and sweep CSV templates Haiku none 5.13, 5.11
M3A-G Gate: the handoff transaction, with concurrent checkout, partial return, and a containment cycle Opus ultrathink AST validation focus

M3B — Procedures, Radio Callout, and Optional SMS. The first six packages are the first release; the provider work is separable and optional.

# Package Model Think Reads
M3B-1 Versioned procedures, owner and deputy handover, adoption workflow, review reminders, open-action dashboard Sonnet think 5.18
M3B-2 Agency records, alert stages, approved PACE and rendezvous cards, manual callout and response recording Sonnet think 5.14
M3B-3 PACE-aware frequency ordering in team-linked net creation and editing Sonnet think 5.4 (PACE)
M3B-4 SMS consent model, the check at send time, suppression scope, delivery separated from acknowledgment Opus ultrathink 5.15 (consent)
M3B-5 Queued individual sends, recipient previews, budget and expiry controls, simulated-provider pilot with synthetic recipients Sonnet think hard 5.15 (workflow)
M3B-6 Signed status and reply callbacks: signature validation, replays, out-of-order events, recycled numbers Opus ultrathink 5.15
M3B-7 Channel and PACE-entry CSV templates, imported plans staying drafts Haiku none 5.11
M3B-G Gate: the webhook handler and the consent check at the queue/send boundary Opus ultrathink ACT validation focus

M4 — Participation, Coordinator Reports, and Coverage.

# Package Model Think Reads
M4-1 Attribution rule across the three canonical actual-time sources, reporting periods, and timezone boundaries Opus ultrathink 5.5, 5.19, Events reporting contract
M4-2 Net and schedule association to a team on every creation path, including the background scheduler Sonnet think hard 5.4, Feature Registry creation paths
M4-3 Reporting periods, drill versus real-world distinction, source drill-downs, ARES and EMA preparation adapters Sonnet think 5.5
M4-4 NH timecard adapter with export-only rounding, and the agency attendance column mapping over M1A’s raw rows Sonnet think 5.5, 5.19
M4-5 Coverage rollups, maps, and exports from the existing per-net CanHearReport observations Sonnet think 5.6
M4-6 Historical participation and manual-activity CSV template Haiku none 5.11
M4-G Gate: attribution reconciled against hand-calculated samples with overlaps, transfers, and missing hours Opus ultrathink M4 exit criteria

M5 — Incident and Drill Requirements with Staffing Integration. Requires Events.

# Package Model Think Reads
M5-1 Reservation conflict model across physical dependency sets, reusing M3A-1’s transaction rather than inventing a second Opus ultrathink 5.13 (reservations), 5.12
M5-2 Plan context, objectives, operational periods, requirements, reusable task templates Sonnet think 5.12
M5-3 Candidate matching and availability confirmation against Events posts, shifts, and offers Sonnet think hard 5.12 (staffing)
M5-4 Tailored checklists, packing templates, and the revisioned deployment packet with disclosure and expiry Sonnet think 5.17
M5-5 Travel, duty, relief, release, and return events extending the 5.19 tag record, never a parallel ledger Sonnet think hard 5.17, 5.19

M6 — Reviewed ICS Package and Exercise Results. Requires M5 and the Events form builders.

# Package Model Think Reads
M6-1 ICS-202 and package assembly, reusing the Events 204 and 205 builders Sonnet think 5.12 (forms)
M6-2 Plan versioning, approval, restricted distribution, issued snapshots, copy and replan Sonnet think hard 5.12
M6-3 Remaining form field mappings and the attachment checklist, once the first form’s pattern is verified Haiku none 5.12
M6-4 Plan distribution and disclosure policy Opus think hard 8, 5.17
M6-5 After-action observations and corrective actions that close only on evidence Sonnet think 5.12 (after-action)

The review gates are packages, not a reading pass. Each -G package is its own sub-agent invocation with no feature code to write and one question to answer: does the phase’s invariant survive an attempt to break it? A gate that only reads the diff will approve code that is wrong in exactly the way the gate exists to catch, because the diff looks like what the spec asked for. A gate runs the exit test, attempts the failure, and reports what it attempted rather than only what passed. Six gates come from the concept document (M1, M1A, M2, M3A, M3B, M4). M1B-6 is the same discipline at Sonnet tier, because what is being attacked there is a rendering and permission-consultation rule rather than a data invariant.

A package is done when its exit test passes and is named in the commit; tests exist under backend/tests/ following the existing naming; the docs that describe the shipped behavior are updated; and, where the package rests on a data-shape assumption, the real-data smoke test in .github/copilot-instructions.md has been run against beta’s database and production’s copy. This module is unusually full of those assumptions — at most one current membership per user per team, at most one open tag per person per board, at most one current parent container per item, exactly one current assignment per asset, a resolvable identity per import row — and every one of them passes hand-built fixtures by construction.

Three points where this is worth shipping, and little in between.

  1. M1 plus M1A. A roster and a tag board: who is where on activation day, with no import, no capability model, no Events, and no radio. The smallest thing the team can actually use in the field.
  2. M1 plus M2 plus M3. The membership MVP. The spreadsheet can be retired.
  3. M4. The coordinator’s monthly report stops being assembled by hand.

M1B, M3A, and M3B attach to any of those and gate none of them. M5 and M6 wait on Events regardless of everything above.

One cross-tier dependency worth naming now. The Milestone 1 Incident Operations Log & Situational Awareness Feed ships long before any of this and correctly does not wait for it. It owns the incident record: a first-class record with an open and closed state that holds identity and status and owns no people. Teams adopts that record rather than defining a second one, and M1A-8 adds only the team participation join and the cross-team visibility setting on top of it. Two things follow for whoever builds the Milestone 1 item first: do not assume an incident has a net, and do not assume a single team, because the join is coming and a schema that precludes it is a migration.

M1A-8 is the one Opus package in this phase that is not the presence model, and it is Opus for a specific reason: cross-team visibility is a privacy boundary between two organizations that did not choose each other, reached through a shared incident. Getting it wrong exposes one county’s roster to another with nothing in the UI to show it, which is the same failure mode as the M1 permission helper.


Blocked on: core web app stability, self-hosting, and Docker packaging being in good shape first. That gating is unchanged; the build plan above is what to execute when it lifts, not a signal to start.

Offline-Capable Web Client (PWA)

✨ Keep logging a net when connectivity drops, and sync on reconnect (KC1JMH)
Model: Opus for the sync and conflict design — this is a distributed-state problem, and the conflict rule below has a real data-loss failure mode. Sonnet for implementation once the design is settled.

An NCS running a net from a field site, an EOC on generator, or a rural home should be able to keep logging check-ins through a connectivity outage, with queued changes replayed when the link returns. Other participants should see that the NCS has gone offline and that net updates will resume once connectivity is restored, rather than silently watching a frozen net.

Offline operation in the browser requires a service worker to cache the app shell, which makes this a PWA. This resolves the PWA-vs-native question previously flagged under Native Desktop Client below: the offline requirement is the strongest driver for that work, and a PWA satisfies it without maintaining three native packages. Treat the PWA as the path forward and the native desktop client as likely redundant.

Groundwork already in place. Every check-in mutation now applies the server’s authoritative single-row response to local state rather than re-reading the whole list (frontend/src/components/netview/checkInActions.ts), and a status change paints optimistically and rolls back on failure. “Refetch everything after a write” cannot work offline — there is nothing to refetch from — so that single-row apply is the reconcile primitive this feature builds on. Optimistic update is the same mechanism with the round trip deferred from milliseconds to minutes.

Reconnect resilience also shipped separately: the net socket now reconnects indefinitely, reconnects immediately when the browser reports online, and resyncs everything on reconnect via the netResync event — check-ins, roles, stats, can-hear, chat, activity log, and traffic. See DEVELOPMENT.md “Reconnect and resync”. That covers recovery from an outage; it does not cover working through one.

The PWA is the last piece, not the first. A service worker is strictly required for only two things: cold-starting the app with no network, and surviving a reload mid-outage. Everything else people actually want during a net works in a plain page with the tab already open — IndexedDB needs no service worker, and neither do online/offline detection or the resync above. The realistic ARES/SKYWARN failure is not “the NCS opens a laptop with no internet”; it is “the NCS is mid-net and the link drops for ten minutes with the tab already open.” Stage the work accordingly: durable queue and conflict handling first, service worker and installability last. Doing it in the other order spends the expensive effort on the rarer case.

Prerequisites, in rough dependency order:

  1. Client-generated IDs. The server assigns check_in.id today. A check-in created offline has no id, so a queue cannot reference it and it cannot be edited before reconnect. A client-side UUID has to be carried through the model and honored by the create endpoint — this is a schema and API change, not a frontend detail.
  2. A durable queue. Optimistic state is in-memory and does not survive a refresh, tab close, or crash — precisely the conditions a field deployment hits. Needs IndexedDB with an explicit replay order.
  3. A conflict rule. This is the sharp edge, not a detail. create_check_in (backend/app/routers/check_ins.py) rejects with 400 “already checked in” when a callsign’s latest row is not CHECKED_OUT. Two NCS operating offline on the same net will both log the same callsigns, and on reconnect the second operator’s queued creates get rejected wholesale. This needs a merge policy decided up front; retry-with-backoff makes it worse, not better.
  4. Deferred-rollback UX. A rollback 200 ms after a click is invisible. A rollback twenty minutes later, after the operator has moved on and the net has advanced, needs a reconciliation review screen showing what could not be applied and why. A toast is useless at that timescale.

The offline-presence signal. backend/app/net_pause.py already computes “no NCS present” and broadcasts net_pause_change, with a banner surface on the net view. Reuse that pattern rather than inventing a parallel one — but note the trigger differs: it keys off check-in status, not connectivity, so an NCS whose link drops still reads as present. The new signal should be driven by socket liveness, which ConnectionManager (backend/app/main.py) already observes when a WebSocket drops.

Relationship to the TUI/packet client below: that item also specifies offline command queuing and replay. The conflict rule and queue semantics should be designed once and shared, not solved twice with different answers.

Native Desktop Client

✨ Standalone NCS client application (Windows / macOS / Linux) (KC1JMH — back-burner)
Model: Opus (framework selection and packaging architecture). Decision recorded: the PWA question flagged here is resolved in favor of the PWA — see Offline-Capable Web Client above. Offline operation is the strongest driver for a dedicated client, a PWA satisfies it, and maintaining three native packages alongside it is hard to justify. Treat this section as likely superseded; revisit only if a concrete requirement emerges that a PWA genuinely cannot meet.
A packaged desktop GUI application for NCS operators connecting to a hosted or self-hosted ECTLogger instance. Intended for single-operator NCS use; not a server. Targets scenarios where a browser is impractical but a full GUI is available. Proposed repo layout: clients/windows/, clients/macos/, clients/linux/ with installable packages per release. Technology decision pending — evaluate Electron, Tauri, or native framework.

TUI / Packet Client

✨ Terminal-first NCS client for low-bandwidth and degraded-link operations (KC1JMH — back-burner)
Model: Opus (protocol design for the packet command mode is the hard part; the TUI itself is Sonnet work afterward).

Full spec and design notes: docs/concepts/TUI-PACKET-CLIENT.md

Summary: a terminal UI (TUI) client and packet-optimized command protocol for running nets over SSH, local console, or packet radio links (~1200 baud). Two command modes — full terminal and abbreviated packet — with offline command queuing and replay on reconnect. Future phase includes a Winlink gateway for form-based check-in submission. Distinct from the desktop GUI client above: this is the degraded-connectivity and emergency deployment path.

This is separate from the standalone desktop client above. Both are back-burner until the web app and self-hosting are stable.

Its offline command queuing and replay overlaps directly with the Offline-Capable Web Client above. Design the queue semantics and the conflict rule once and share them across both clients — solving the same problem twice invites two different answers to “what happens when two operators logged the same callsign offline.”

SSH-Hosted TUI

✨ Server-hosted terminal UI reachable over SSH (KC1JMH — idea capture)
Model: Opus (lands in the auth path and stands up a new internet-facing network service; both are squarely in the Opus tier per the model guidance above).

Full concept notes: docs/concepts/SSH-HOSTED-TUI.md

Summary: an operator runs ssh ectlogger.us, authenticates against the existing user database, and gets a Textual TUI for checking into and running nets. Nothing is installed on their machine — the app runs on our server, one forked PTY per SSH session. Auth is password plus TOTP first (registered SSH public keys later), reusing POST /auth/login verbatim so the existing lockout, rate limiting, and Fail2Ban jail all apply unchanged.

Not the same thing as the TUI/Packet Client above, despite both being terminal UIs. That one is installed on the operator’s machine and targets packet radio and ~1200 baud links; this one is hosted by us and needs a working IP link end to end, so it does nothing for the degraded-connectivity scenario. Different transport, different auth model, no offline queue. The packet client’s API-key decision does not carry over. Do not merge the two documents.

Prerequisite worth knowing before scoping: only one of the 68 users in the current database has a password set, since everyone else logs in by magic link — which has no meaning without a browser. Any milestone that ships SSH access needs a companion push to get operators to set passwords, or it ships to an audience of one.

Self-Hosting Enhancements

✨ Docker image for self-hosters
Model: Sonnet. Note: a fresh Docker install must never execute another deployment’s roster fixes — the schema-changes-only rule in backend/migrations/README.md (“Migration content guidelines”) is what keeps that true, so verify the image’s migration step honors it.
Official Dockerfile / docker-compose.yml for a one-command self-hosted deployment. Publish to Docker Hub alongside each release.

✨ Net template portability between hosted and self-hosted
Model: Opus design (export format, identity/attribution model), then Sonnet.
Allow net templates created on app.ectlogger.us to be copied to a self-hosted instance (and vice versa), preserving origin metadata for attribution. Opt-in sharing of logs and net stats between instances.

✨ Cross-instance user stats sync
Model: Opus — federated identity/token exchange is an architecture decision with security consequences.
Users who participate in nets on both hosted and self-hosted instances can opt in to aggregating their check-in stats across both. Requires a federated identity or token-exchange design.

✨ Resilience against hosted server unavailability
Model: Sonnet (mostly an audit task: verify no hard-coded dependencies on the hosted instance, then fix what’s found).
Self-hosted instances should degrade gracefully if app.ectlogger.us is unreachable or permanently offline. No hard dependency on the hosted server for core net logging functionality.


Parking Lot — Needs More Information

Items that were raised but need clarification, reproduction steps, or a design decision before they can be scheduled.

Item Source Blocker
ham.live closure — onboarding displaced users KC1JMH ham.live is shuttering. No action needed, but inbound user migration is expected. Infrastructure scaling items (DB indexes, PostgreSQL migration path) have been added to the roadmap in anticipation. Worth monitoring signup rate in coming weeks.

Out of Scope (Decided)

Item Rationale
Disabling web self-check-in globally Net managers can already configure this per-net if needed; a global kill switch is not warranted.
Mobile station sort removal Confirmed intentional and appreciated; making it optional (Milestone 1) is sufficient.

Backburner Ideas

Well-specified but deprioritized — not blocked on information (see Parking Lot for those), just not worth building right now.

✨ Auto-check-in for subscribed stations on certain nets (field request, 2026-07-30)
Let a station be automatically checked in on nets they’re subscribed to (surfaced via the existing subscription icon), for operators who reliably check into the same recurring net every week.

Design decision (2026-07-30, KC1JMH): if revisited, scope is belt-and-suspenders — the NCS/schedule owner must enable auto-check-in on the schedule and the individual station must opt in on their subscription. Neither side alone is sufficient.

Why backburnered: this request traces back to stations not realizing they needed to manually check in after opening the net view. That underlying discoverability problem has since been addressed directly — the command-bar rewrite, the flashing Check In button, and the check-in prompt dialog all now surface the action itself, which was the actual gap. Revisit only if reports of missed check-ins continue despite those fixes.


Feedback Attribution

Handle Net role
AA1GM — Joel Huntress Net manager, Maine Dirigo DMR Net
KC1UIX — David Lounsbury YCECT multi-repeater SKYWARN
W1BKW — Brian Wall Regular participant, ham.live nets
W1MTW — Mark Carlson Net participant (mobile user)
N1GSK Net participant (mobile user), Maine Dirigo Net
KC1JMH — Brad Brown Developer / net manager / WSSM Club Secretary / Cumberland County ARES EC