> Approved 2026-10-15. Copied verbatim from the approved plan file so later sessions have it.
# Packet Station rebuild: phased plan
## Context
Packet Station runs today as a browser-only app on the M: drive (plain JavaScript, PowerShell sync, files as locks and records). It has 720 real packets in it, all still at draft or warehouse. The brief (docs/REBUILD-PROMPT.md in the old repo) asks for a from-scratch production web application: Docker Compose on docker-1, PostgreSQL 16, a Python server that enforces every rule, the browser as a pure client, VISUAL through VisualKit and M-Files through MFilesKit. Feature parity with the M: install is the acceptance bar, minus the "Drop" list. No data migration: the new system starts empty behind a go-live boundary date.
This plan was built from a full read of the old repo (README, guard.js, sync.js, visual.js, packet_rules.py, databook.js, print.js, itp.js, auth.js, security.js, depts.js, SECURITY.md, PRODUCTION-PLAN.md, the AD scripts, the 38 Playwright scenarios), the live record shapes on M:, VisualKit (CLAUDE.md, INSTALL.md, PLAN.md 8b/8c/9.7, the feed example and its API) and MFilesKit (INSTALL.md, packet-station.md, drawings.py). Facts from those reads that change the design are called out where they matter.
## Where the new repo lives
The brief says start in a new, empty folder. This session is rooted in the old repo, which stays read-only. Proposed: `C:\Users\spencerm\Coding\PacketStation2` (its own Gitea repo later). The first action after approval is to add that folder to the session and `git init` there. Nothing in the old repo is modified; the old repo's untracked docs (PRODUCTION-PLAN.md, REBUILD-PROMPT.md, docs/ad) are left for Spencer to commit.
## Decisions taken as given (from the brief)
Python 3.12 + FastAPI + uvicorn; psycopg 3; PostgreSQL 16 with plain SQL migrations and three database roles (superuser for bootstrap, migrate, app); relational core with JSONB at the edges; LDAP sign-in with ldap3 and roles from the 50 PS-* AD groups; optional server-side Packet Station PIN (Argon2id) for registered devices, unlock and signatures; SSE over LISTEN/NOTIFY; server-authoritative sessions, locks and timeouts; files on a volume with metadata in PostgreSQL; append-only audit; HMAC seals; test and production stacks from one image; no published port, NPM on npm_default; worker on APScheduler; server-side PDFs; TypeScript + React + Vite frontend with the PESCO dark theme; no CDN.
Challenges to the brief, after reading the code (none change the architecture):
1.**The old policy is looser than the old UI in places** (leads may write welds and BOM in shop statuses; `worksOn` ignores who is assigned; a transition patch may carry any field). The port follows the UI where the UI is stricter, and the README where it describes intent ("only the assigned worker"). Each such tightening is listed in docs/POLICY.md so Seth can veto one.
2.**Clerical "lead" has no AD group.** The brief maps it to PS-Clerical-Supervisor. The old code gates rev-mismatch approval and "move back from VISUAL review" on CL lead; both move to Clerical supervisor. CO verification already needs supervisor level, which the shop Supervisor groups give.
3.**"Shipping" route has no path to filed** in the old build. The rebuild gives complete packets routed to Shipping the same Clerical close and file path (vclosed warning instead of block applies to all routes). Flagged for Seth.
4.**VisualKit's feed takes "first open order, newest" per project.** The rebuild stores every open order per project (orders_by_project) and picks the order whose `top_jobs` or `lines` includes the packet's work order base when it can, else the project default, and shows the source. Open question 5 covers the rest.
5.**Labor tickets are not in the feed example.** Adding them is a new `v.labor.tickets_for_work_orders` read in the worker, gated by the timer decision (open question 1).
6.**MFilesKit's docs are stale in two lines** (drafting rev precedence and the project-folder rule); the code is the rule. docs/FEED.md states the code behaviour.
Each phase ends with passing tests, docs updated (README, CHANGELOG, SECURITY.md, DEPLOY.md, FEED.md as applicable) and a commit. Opus 5.5 subagents build each phase's pieces in parallel where files do not overlap (server vs frontend vs tests); the main session integrates and runs the suite. Sizes are rough and relative.
### Phase 0: Skeleton, image, doctor (small)
- New repo, CLAUDE.md in VisualKit's shape (safety rules, layout, running things, related projects: PacketStation on M:, VisualKit, MFilesKit, pesco-ncr), README, CHANGELOG, docs/ skeleton.
- Multi-stage Dockerfile: python:3.12-slim-bookworm, Microsoft Debian 12 repo for msodbcsql18, pip from the Gitea index with `--index-url`, frontend build stage, non-root user.
-`packetstation doctor`: runs `visualkit doctor` and `mfileskit doctor`, checks DB roles and migrations, assets volume, LDAP bind and that the service account reads memberOf, employeeID, userAccountControl on both controllers, SMTP STARTTLS verify. This is the first real Linux test of both libraries; it gates everything after.
- Migration runner (`packetstation migrate`) as the migrate role; 0001 creates roles and grants (app: insert+select on audit, no update/delete).
- compose.test.yml (app, worker, postgres, internal network, npm_default external with alias, secrets), compose.ci.yml (adds samba-ad-dc), healthchecks, logs to stdout.
- Gitea Actions: build image, run pytest, lint. Playwright comes in phase 1.
- Settings table and environment loading; X-Forwarded-* trust only from npm_app_1's resolved address, re-resolved on change; security headers and CSP; build version endpoint.
- Closes: H1, H2, H4, A4 (role), S1 (headers; CSP tuned again in phase 10). H3 is NPM's TLS plus the app's HSTS header, closed here.
- Tables: users (objectGUID key), sessions, devices, pins, signin_attempts, audit, settings. ldap3 ServerPool FIRST over LDAP_URL; search-then-bind; nested groups via LDAP_MATCHING_RULE_IN_CHAIN; disabled accounts refused; roles = union over groups; derived group-to-role map from the department table.
- Rate limits per account and per address before the bind; PIN rules (6 to 12 digits, Argon2id, 5 strikes disables, 3 wrong at unlock or signature ends the session); device registration (Admin or shop Supervisor, password session); PIN sign-in by employee ID on registered devices only; session kinds and timeouts as settings; Origin check; idle and background lock reported by the browser and enforced by the server; step-up unlock.
- Signatures table and the `sign` helper used by every sign-off action later.
- SSE endpoint `/api/events` (session required, X-Accel-Buffering: no) with LISTEN/NOTIFY fan-out and presence.
- Frontend: Vite + React + TS, PESCO dark theme port from app.src.html, sign-in (password and PIN keypad tabs), lock screen, role switcher, Account settings (AD details, PIN create/change with password, sessions, own access log), Admin shell with the read-only AD view, devices, PIN clearing, group map and missing-group flags.
- Samba AD DC container for CI, seeded with the 50 groups and test users (employeeID set); Playwright sign-in scenario runs in CI.
- Server tests: bind flow against the CI DC, rate limits, PIN lifecycle, session timeouts, Origin check.
### Phase 2: Core domain, policy engine, packet API (large)
- Schema for departments (seeded; code and slug immutable), stations, user profiles (stamp, station limits), projects, units (aliases, renumber history, soft delete), packets and child tables (see Schema section), packet_log, assets metadata.
- Policy port in Python: Action enum, custody, transition table, CO hold, field-edit rules, the UI-only preconditions now server-side, `can: [...]` on every packet read. Tests try every transition as every role in every status (the matrix), plus custody, short-parts and CO exceptions.
- Write pipeline: session, policy, transition, validation, version check, audit row, one transaction, NOTIFY. Compare-and-set on status and dept for workflow steps (409 "someone else already took this step"); row-level versions on child rows; append-only log.
- Admin edits with reason and IT-edit marking; filed lock; `cleanText` equivalents; input limits.
- Frontend: home layout (headline, KPIs, attention, find bar, boards or list), Clerical screen (boards, projects and units with two-step pickers, renumbering, move packet), packet page (header, tabs, history, drawing viewer with pdf.js, documents), hand-made draft creation, send to warehouse or department or Q.C.
- Closes: A1, A2, A3, M1, S5.
### Phase 3: VISUAL feed and drawings in the worker (large)
- Cache tables: visual_batches, visual_rows, visual_materials, visual_orders, visual_labor (if kept). Worker process with APScheduler, settings re-read each tick, shop-hours intervals, pause, job_requests + NOTIFY "run now", advisory lock per job, job_runs with results.
- Feed job: the five reads (released for the window firmed only, open packets' WOs by key, materials, order links, labor), batch diff, packet rules (new drafts, update drafts in place, retire moved drafts, never recreate removed, flag WO-gone, observe Closed, projects and units auto-created with customer, PO and dates from order links with provenance and Clerical override), go-live boundary with the ignored list and "pull in by hand", fingerprint function with tests that show which fields open a change order and which are minor, change orders opened or re-aimed on in-shop packets, auto-release to warehouse.
- Drawing job: queue (needed, recheck due, rev changed, CO lookups), budget, new DrawingLookup per tick, decision table (attach, rev mismatch, not released, not found, failed), H-sheet base rule, download with sha256 into assets, pages, hand drawing removed on new VISUAL rev, newer released rev on an in-shop packet opens a CO.
- Clerical UI: From VISUAL panel (run now, status, last ten runs, ignored list, not set up, projects without names), flag kinds, rev-mismatch approval by Clerical supervisor with signature and reason, hand upload of drawing and BOM on flagged drafts (server-side XLSX and CSV parse), change-order approve, reject, hold.
- Admin: Feed settings tab, resource map editor, department picker.
- Tests: packet rules against recorded DEV feed output (local only) and the synthetic generator (CI); fingerprint table; drawing decision table with a fake lookup.
- Integration run against VISUAL DEV and the production vault from the developer machine.
### Phase 4: Warehouse (medium)
- Task board (incoming, pulling, hold, pulled, released 3 days), hold reasons, pull table (pulled, issued, location, heat with scan, N/A, MTR upload), shorts with receipts, send back with reason, release to supervisor with the server-side gate.
- Upload pipeline: type by content, size limits, sanitised names, stored by id, Content-Disposition and nosniff, optional ClamAV service, volume quota alert.
- Closes: D5.
### Phase 5: Shop: supervisor and stall (large)
- Supervisor screen (KPIs, station grid, CO on hold, boards, assign station and welder with station limits, pull back, send back, review: send to Q.C. with signature, return to station, skip Q.C. route), stall screen (my packets, start, pause, resume, submit with signature, checklist, weld record fields).
- Weld map and heat table on the drawing (pdf.js + canvas, positions as fractions, server-assigned W<n> and H<n> ids), heat map borrowing from a sibling packet, timer with the server as the clock of record, ticket hours beside it (if kept), stages on routing.
- Change-order verification by department supervisors, VOID sheets, carried marks.
- Closes: S8 (print stamps come in phase 7, so S8 closes there).
### Phase 6: Q.C. (medium)
- Inspection tab (checks, visual exam per weld, NDE, hardness with NACE label, notes), accept with route and signature, reject with reason or NCR and signature, Q.C. project folders with superseding revisions, packet documents, digital ITP (template, lines, N/A, rejects with NCR number, block signatures, sign-off that files the ITP PDF), hold-point warnings by department page group, NCR validation hook reserved (pesco-ncr `GET /api/ncrs/{ref}` needs a token; later phase).
### Phase 7: Close, file, PDFs and prints (large)
- Clerical close: closed-in-VISUAL observed state, vclosed confirm, warning on filing with open WOs, mark filed with signature and HMAC seal, reopen by Admin with reason and new seal keeping the old.
- Server PDFs: data book (reportlab + pypdf + pypdfium2, page for page after databook.js: cover, index, S1 drawings with VOID stamps, S2 traceability and heat-map rasters, S3 welding records, S4 Q.C., S5 Q.C. docs, S6 MTRs, S7 history), traveler with QR codes, ITP PDF, traceability CSV with the formula guard, print kinds with "printed by" stamps and print logging, controlled-copy stamp signed with the seal key, metadata. Data book filed automatically when the packet is filed.
- Closes: M2, S6, S7, S8.
### Phase 8: Project Management, find bar completion, presence (medium)
- PM screen tabs (overview, projects, aging, hours, quality, find), periods, health, CSV and PDF exports logged.
- Find bar full search set, status and flag filters, list view sorts, filters saved per browser; presence in packets via SSE; "Mine" flag; motion with reduced-motion switch; iPad viewport layout pass.
- Departments tab (display name, stations; add department shows the three group names), profiles with CSV import by employee ID, "refresh from AD now", nightly ad-reconcile, 15-minute role refresh, Security tab (settings, alerts), access log with export, integrity job (seals, asset hashes, log to audit links) with alert.
- Notifications tab (rules per event type, recipients: group, user, mailbox, dynamic department Supervisor group; immediate or digest; exclusions; test send; sent log; dry run on test), SMTP via app-03 with STARTTLS and CA verify, subject prefixes, seed to PS-Admins.
- scripts/backup.sh + systemd timer (America/Denver 02:00), 7 dumps, marker; worker backup-check job; restore script; restore rehearsed on the test stack and written up.
- Closes: I6 (app part), M3, O1, O2 (first rehearsal), S3 (pip-audit and npm audit in CI).
- Done 2026-10-21. The eight Admin tabs, the Account notifications section, the six worker jobs, the notify package with SMTP over STARTTLS, migration 0010, the backup and restore scripts with the systemd units, and the CI dependency scan are built and tested. The backup and the restore were rehearsed by hand on the test stack on docker-1 (DEPLOY.md, "Restore rehearsal 2026-10-21"). The first scan found two pins to bump (pypdf 6.21.1, certifi 2026.10.19). The separate `notification_state` table was not needed: the open `notification_events` row is the state. The timer is installed by Spencer with sudo (DEPLOY.md, "Backup"). Decisions taken are open questions 43 to 50.
- Rewrite the 38 scenarios against the new UI with synthetic fixtures; run in Gitea Actions against compose.ci.yml. Policy matrix, merge rules and packet-rule tests already run from phase 2 and 3.
- CSP final pass, dependency scan, build-version banner and reload, DEPLOY.md with the NPM proxy host settings, SECURITY.md rewritten with the ID table, GO-LIVE.md (date, boundary, groups, tablets, URL, M: retirement as read-only archive), PAPER-FALLBACK.md, incident notes.
- Production and test each on their own Linux VM, natively, with no Docker (Spencer, 2026-10-27, Gitea issue 37; see "Host layout"). The test VM is built first from the same steps, so the production install, backups, restore and doctor are rehearsed before go-live. The app and the worker run as systemd services from a venv built from the same `server/requirements.txt` and `server/requirements-pesco.txt` pins as the image, with PostgreSQL 16, ODBC Driver 18 and the PESCO CA installed on the VM. Settings from an env file, secrets as root-owned files read through the same `*_PASSWORD_FILE` settings, VISUAL_SERVER and VISUAL_DATABASE set explicitly; first start is Spencer's approval. NPM on docker-1 proxies `packetstation.pescoinc.biz` and `packetstation-test.pescoinc.biz` to their VMs over the LAN. Backups, the doctor and the restore are rewritten for the VMs (no `docker exec`). DEPLOY.md gets a VM section; the compose files stay for CI. The docker-1 test stack keeps running until the test VM is ready, then it is stopped.
- Archive location (Gitea issue 7, Spencer 2026-10-27). Spencer creates an NFS share on the QNAP and mounts it on docker-1 (root, steps in DEPLOY.md), with write access for uid 10001. Compose bind-mounts it into the app and the worker at a fixed root, `ARCHIVE_ROOT` (unset turns the feature off). The Admin page gets a File locations section with one setting for now, the archive folder, as a path under `ARCHIVE_ROOT`. The server refuses a path that leaves the root (`..`, absolute paths elsewhere, symlinks out). "Check" writes a probe file, reads it back, removes it and shows the result, and saving runs the same check first. The setting change and every check are audited. `doctor` runs the check too. What is written to the archive and when (for example the sealed data book, its documents and a manifest at filing) is decided on issue 7 before it is built; until then the setting and the check exist and nothing is copied.
| D6, D7 | Deferred (retention period and customer copy need a decision from Seth and Q.C.) |
| M1 | Closed, phase 2 |
| M2 | Closed, phase 7 |
| M3 | Closed, phase 9 |
| M4, M5 | Deferred (logs to stdout now; SIEM shipping is IT's) |
| S1 | Closed, phase 0, tuned phase 10 |
| S2 | Closed, phase 1 |
| S3 | Closed, phase 9 (CI scanning) |
| S4 | Deferred to IT (pentest before go-live) |
| S5 | Closed, phase 2 |
| S6, S7, S8 | Closed, phase 7 |
| O1, O2 | Closed, phase 9 (pg_dump timer plus PBS; one rehearsed restore); quarterly restore IT's |
| O3, O4 | Closed, phase 10 |
| O5 | Superseded (no migration; M: retired read-only) |
## Schema (phase 1 to 3 migrations; full DDL goes in docs/SCHEMA.md)
Conventions: identity bigints except users (uuid = AD objectGUID); Postgres enums for packet_status (declaration order = workflow order), wh_stage, co_review, role_kind, session_kind, sig_factor; every timestamp timestamptz; every "who" column references users; rows backed by a signature carry signature_id. Soft delete = removed_at, removed_by, removed_reason with partial unique indexes `WHERE removed_at IS NULL`. Append-only tables get INSERT and SELECT for the app role plus a BEFORE UPDATE OR DELETE trigger that raises.
- **Identity:** users (id uuid, sam_account, upn, display_name, emp_no unique, email, welder_stamp, active, directory_flags, ad_synced_at); user_roles (user_id, role, dept_code, synced_at; rebuilt at sign-in and by the reconcile); sessions (token_hash, kind password|pin, device_id, expires_at, last_seen_at, locked_since, revoked_at, ip, user_agent, roles_snapshot jsonb); devices (name, token_hash, registered_by, revoked_at); user_pins (argon2id hash, failed_count, disabled_at); signin_attempts (append-only: account, ip, kind, ok, reason; drives both rate limits); signatures (append-only: user, account and name snapshots, session kind, factor, action, meaning, packet_id, subject, content_hash).
- **Projects and units:** projects (number, name, customer, po, due, status, notes, source, needs_details, version, order-derived columns with `*_src` and `*_visual` companions); project_aliases (alias PK, project_id, kind); project_renumbers (append-only); units (project_id, lot char(3), own due and details, same companions). Unit number is a view column `number || lot`.
- **Notifications (per the brief):** notification_rules (event_type, enabled, delivery immediate|digest, digest_time); notification_recipients (rule_id, kind group|user|mailbox|dept_supervisor, group_dn, user_id, address); notification_exclusions (rule_id, user_id); notification_outbox; notifications_sent (event, recipients as resolved, subject, result). The planned notification_state (dedupe and recovery) was not needed: the open notification_events row under its unique index is the dedupe, and a recovery row points back at it (phase 9).
- **VISUAL and drawing caches:** visual_batches; visual_rows (typed key wo, lot, split, sub, seq int; all contract fields; row_hash; first, last and gone batch); visual_materials (plus piece int, mat_hash); visual_orders and visual_order_links (base or project, rank, lines, top_jobs, order_date); visual_labor; visual_row_changes and visual_material_changes (per batch, added|changed|gone); visual_ignored_wos (go-live list with pulled_in_*); visual_resource_map; drawing_lookups (dwg PK, checked_at, ok, rev, name, object and file ids and versions, base, draft_rev, draft_where, in_drafting, note, failing_since, asset_id, visual_rev_seen).
- **JSONB only here:** foreign-shaped feed payloads (source, visual, partspecs, vis, waiting, staged, raw), template-driven answers (checklist, checks, itp template, settings), and frozen snapshots (roles_snapshot, qc_history, audit detail, job counts). Everything the policy or the boards filter on is a real column.
- **Versioning:** packets.version goes up in every transaction that touches the packet or a child; it is the ETag and the NOTIFY payload. Child rows carry row_version. Every write takes `SELECT ... FOR UPDATE` on the packet first, which replaces the old 8-second lease.
## Policy port and concurrency rules (phase 2; docs/POLICY.md)
- **Inputs:** `Principal` (user, admin, support set, clerical_supervisor, shop dept to level map, station limits, session kind, or `system="visual-feed"`) and `PacketState` (status, dept, station, wh_skipped, routed_to, vclosed, removed, open COs, open shorts). Pure functions in `server/packetstation/policy/`, no I/O.
- **Custody** `holds()` is a direct port: draft CL; warehouse WH; ready and review dept lead or above; assigned and in_progress dept lead or above or works_on (worker in dept whose station limits include the station, and, tightened per the README, the assigned welder for start and submit); qc QC; complete CL; filed nobody.
- **Action enum** with a rule per action: who, from and to status, dept change, CO hold, preconditions, signature meaning, reason required. Transitions: SEND_TO_WAREHOUSE, RELEASE_TO_SHOP, SEND_DRAFT_TO_QC, RETURN_TO_CLERICAL, WH_RELEASE, WH_SEND_BACK, SEND_BACK_TO_WAREHOUSE, ASSIGN, UNASSIGN, PULL_BACK, START, SUBMIT, RETURN_TO_STATION, SEND_TO_QC, ROUTE_FROM_REVIEW, QC_REJECT, QC_ACCEPT, QC_ACCEPT_AND_ROUTE, FILE, ADMIN_REOPEN. Edits: EDIT_BOM, WH_RECEIVE_SHORT (a narrow endpoint that replaces onlyShortLines), EDIT_HEATS, EDIT_WELDS, EDIT_CHECKLIST, EDIT_STAGE_FIELDS, CLOCK, EDIT_QC, SET_WH_STAGE, EDIT_DEPT, MOVE_UNIT, EDIT_WOS, EDIT_DOCUMENT_SET, EDIT_HEADER, REV_APPROVE (clerical supervisor), SET_VCLOSED, SET_DATABOOK_AT, DOC_ADD, DOC_REMOVE, CO_ISSUE, CO_APPROVE, CO_REJECT, CO_HOLD_TOGGLE, CO_VERIFY, ITP_*, VIS_* (worker only).
- **Preconditions** as named predicates, now server-side: heat_map_answered, drawing_attached, bom_all_pulled_or_short, welder_set, checklist_complete, welds_recorded, filler_set, qc_book_spool_location, qc_missing_empty, reason_given, vclosed_if_databook (warning only when work orders are still open in VISUAL; block only when vclosed is unset and the route is Data Book), co_staged_if_dwg_change.
- **Order of checks** keeps the old semantics: removed, wrong status, CO hold (applies to admins too), admin bypass of custody with a required IT reason, filed lock (only SET_DATABOOK_AT and ADMIN_REOPEN), who and custody, then preconditions (never bypassed).
- **`can` on reads:** every packet read returns `can: [...]` (passes role, custody, status and preconditions), `blocked: {action: [reasons]}` (passes role and custody but fails a precondition or the CO hold, so the UI can show a disabled control with the reason), `holder`. Boards evaluate transitions only from header columns plus an open-CO count.
- **Signatures:** required for SUBMIT, SEND_TO_QC, QC_ACCEPT, QC_ACCEPT_AND_ROUTE, QC_REJECT, FILE, ROUTE_FROM_REVIEW, ADMIN_REOPEN, CO_ISSUE, CO_VERIFY, CO_APPROVE, CO_REJECT, REV_APPROVE, hand uploads, ITP_BLOCK_SIGN, ITP_SIGN_OFF. The body carries `sign: {factor, secret}`; the server verifies the factor against the session's own user inside the transaction (PIN allowed in any session on any device, as the brief says; password everywhere), logs the attempt, applies the five-strike PIN disable and the three-strike session end, and writes the signatures row with a content hash over what was signed.
- **Concurrency per table:** (a) transitions are compare-and-set on `expect: {status, dept}` under the row lock, 409 "someone else already took this step" with the current state, no packet-version check so a weld edit never blocks a send; side effects (open or close stage, qc_inspection, wh reset, seal) run in the same transaction. (b) keyed rows: bom_lines and qc_inspections PATCH with If-Match row_version; welds, heat tags and pages get server-assigned numbers from the packet counters so concurrent adds never collide; shorts and CO actions are state CAS; co_verifications are inserts (PK violation = already verified); checklist keys use jsonb_set per key; heat table position is an idempotent upsert; work orders are a draft-only full replace with If-Match packet version. (c) append-only tables never conflict. (d) header scalars use field-level CAS (`set` plus `base`), the old merge3 made explicit; databook_at, clock start and stop, and system-written visual sub-keys need no check.
- **API shape:** resource endpoints under `/api/packets/{id}` (transitions, bom, bom/{item}/short, welds, heat-tags, heat-table, stage, stage/checklist/{key}, stage/clock, wh, qc, work-orders, pages, docs, change-orders/{no}/verify|approve|reject|hold, seal, reopen, log), plus /projects, /units (+renumber, itp), /qc-docs, /assets, /settings, /admin/*, /account/*, /events (SSE). Every mutating response returns the new packet version; every 409 returns the current state.
- **Worker writes** use the same helpers with `Principal(system="visual-feed")`: VIS_UPDATE_DRAFT, VIS_OPEN_CO, VIS_AUTO_RELEASE (same preconditions as SEND_TO_WAREHOUSE plus rev confirmed and no flags), VIS_RETIRE_DRAFT. It never writes shop-side tables; in-shop packets only get a change order.
## Worker, feed and drawings (phase 3; docs/FEED.md)
- **Process:** `packetstation worker` runs APScheduler (BlockingScheduler, 4 threads, coalesce, max_instances 1) in site time for scheduling, UTC for storage. One psycopg pool as the worker role, one LISTEN connection, one more connection per run holding `pg_try_advisory_lock(7310, hashtext(job))`. One `Visual.from_env()` per run in the job thread, with a stack guard (test stack refuses a production target; production stack refuses an implicit DEV target). One MFiles Vault per process, a new DrawingLookup per run. Heartbeat row and file for the healthcheck.
- **Jobs:** visual-feed (in-shop and out-of-shop intervals from settings), drawing-lookup (chained after every feed run, plus its own cadence), session-refresh (15 min), ad-reconcile (nightly 01:30 site time plus "refresh from AD now"), integrity (04:00), backup-check (07:15), notify-send (60 s), notify-digest (daily at the digest time), housekeeping (prune caches at 90 days, job_runs at 180). A `SettingsTrigger` reads settings on every fire; the app NOTIFYs `ps_settings` on save so a new interval or pause applies at once. Pause stops scheduled feed and drawing runs only. `job_schedule` holds the next run for the Admin tab.
- **Run now:** app inserts job_requests and NOTIFYs `ps_job_request` in the same transaction; the listener reschedules the job to now; the run claims all pending requests so ten clicks make one run; a request during a run fires another run after.
- **Feed reads (outside any transaction):** window from site-local today with `until` always passed; (1) `released()` firmed only; (2) `get_many` for every work order key on an unfiled packet plus pulled-in ignored ones, no status or date filter, missing keys collected; (3) operations, partspecs, parts and materials for the union, with `mat_known` per leg so a closed leg does not look like a removed BOM; (4) order links by base and by project, degraded on failure with last cache kept; (5) labor tickets at most every 30 minutes when the setting is on. A suspect batch (zero window rows after a full one, or missing keys above 10% and 5) is applied without the "gone" step and marked degraded.
- **Cache:** COPY into temp tables, MERGE into visual_rows and visual_materials keyed on typed fields, change rows per batch for the Admin "what VISUAL changed" view. The apply recomputes candidates for everything in play (cheap) and writes only where fingerprints or state differ.
- **Apply (one transaction, lock_timeout 5 s, FOR UPDATE SKIP LOCKED with retry next run):** candidates per the old rules (fold and ignore, unmapped counted, project from the base prefix, unit = project + 3-digit lot, visual_key unit|res|dwg, legs deduped, rel earliest, want latest, rev highest numeric, folded, partspecs, BOM best status per piece with group headers and sub/na rules, existing packets keep their tracked legs whatever the date); go-live boundary writes visual_ignored_wos unless pulled in; match on visual_key across live and removed packets; new → draft with fingerprint; removed → nothing, ever; draft → update in place (BOM unless hand, drawing removed and flags cleared when dwg or rev moved, hand drawing taken off); past draft → fingerprint compare, open or re-aim the single pending visual CO, withdraw it if VISUAL reverted, follow an approved one with a new CO; minor = only tgt or want changed, needs Clerical approval only; retire untouched drafts whose legs all moved (soft-removed with reason "moved", visual.moved set, hidden from boards); work order gone flagged after 2 consecutive misses, cleared on return, never removed; closed observed per leg and on the packet when all legs are C, cancelled flagged; projects and units auto-created with order-derived customer, PO, order and ship date carrying `*_src` provenance, Clerical override with reason never overwritten; auto-release up to 25 with heat_map defaulted; one audit row per packet change plus a run summary; NOTIFY on commit.
- **Fingerprint:** `compute(candidate) -> (full, parts)` with canonical JSON (NFC, LF, trimmed, null and "" alike, IDs upper-cased, lot/split/sub text, seq and piece int, Decimal-normalised numbers, ISO dates, sorted lists). Parts: legs, qty, rev, drawing, bom (major) and tgt, want (minor). Never in it: rel, folded, partspecs, title, status, issued, whs, loc, orders, labor. A parametrised test per field shows whether a change opens a CO and whether it is minor, plus invariance tests (2 vs 2.0, "" vs null, order, m00543 vs M00543, lot "7A").
- **Drawing job:** queue over unfiled, unretired packets with a drawing and no hand drawing (unless the rev changed), distinct by dwg, ordered: rev changed or CO target rev; drafts needing a drawing; failed lookups with backoff; rechecks due. Budget 60 per run. Download only when object id and version or file id and version changed; PDF magic and size check, streamed sha256, fsync and rename into assets, dedupe by sha256 plus source_ref, page count via pypdfium2. Decision table: lookup failed → flag and keep what is attached; not released → flag with drafting note; not found → flag "check the drawing number in VISUAL"; non-integer VISUAL rev → flag, not attached; attach → attach on drafts (H sheet noted), on in-shop packets nothing when same rev and file, a drawing-origin CO when the same rev has a new file with a different hash; rev mismatch → flag and stage the M-Files copy for Clerical supervisor approval with signature and reason; newer released rev than the attached one on an in-shop packet → drawing-origin CO, never a silent swap; a feed CO with a rev change gets its staged drawing from the lookup of the target rev. "Drawing needed" blocks the warehouse step through the policy precondition.
- **Other jobs:** ad-reconcile (list the OU, report missing and extra groups, enumerate members with the IN_CHAIN filter paged at 500, upsert users on objectGUID, mark disabled or gone users inactive and end their sessions, flag missing or duplicate employeeID); session-refresh (re-resolve groups and userAccountControl per live user); integrity (seals, asset hashes 7 days nightly and full on Sundays, missing and orphan files, audit hash chain); backup-check (marker and newest dump under 26 h); notify-send and notify-digest (recipients resolved at send time, STARTTLS verified, dry run on test, one digest per recipient).
- **Several open orders:** proposed rule (b) unit match on `top_jobs` (unit number is the top-job name), fall back to open-first-newest, plus (c) a Clerical pin per project or unit with reason; the project page lists every open order. Open question 5.
## Deployment and CI (phase 0, 9, 10; docs/DEPLOY.md)
- **Dockerfile:** frontend stage on node:22-bookworm-slim; builder on python:3.12-slim-bookworm with a venv, `pip install --require-hashes --index-url <gitea>/simple/ -r server/requirements.lock` then the package; runtime with the Microsoft Debian 12 repo (msodbcsql18, unixodbc, tini, tzdata), non-root uid 10001, `PS_BUILD` label. entrypoint exports MFILES_PASSWORD from the secret file and dispatches app (uvicorn with no proxy headers; the app's TrustedProxyMiddleware re-resolves npm_app_1 every 60 s), worker, migrate, doctor, job run, fixtures.
- **Compose:** base file with postgres (16-bookworm by digest, `backend` internal network only, initdb creates ps_migrate and ps_app from secrets; the app and the worker both log in as ps_app, the worker with application name `ps-worker`), one-shot migrate, app and worker (backend plus egress, assets volume, CA bundle bind, secrets, healthchecks, restart unless-stopped, json-file logs, 1 GB limits; worker also mounts the backup folder read-only). Proxy overlay joins app to npm_default with `APP_ALIAS`. CI overlay adds the Samba DC, fixture sources and dry-run mail. Env files per stack in /etc/packetstation; secrets root:10001 0440. Deploy: `docker compose -p packetstation --env-file ... -f compose.yaml -f compose.proxy.yaml up -d`.
- **NPM proxy host** (recorded in deploy/npm-proxy-host.md): http to the alias on 8000, Force SSL, HTTP/2, NPM HSTS off (the app sends it), `client_max_body_size 30m`, a `location /api/events` block with buffering off and 3600 s timeouts using the `$server` variables so the alias is re-resolved.
- **Doctor:** config and stack guard, `visualkit doctor`, `mfileskit doctor`, DB connect and migrations at head and an audit UPDATE that must fail, LISTEN/NOTIFY round trip, assets volume and free space, backup folder, LDAP TLS bind reading memberOf, employeeID and userAccountControl with the 50-group count and one IN_CHAIN query on both controllers, SMTP STARTTLS verify, tzdata.
- **Backups:** scripts/backup/ps-backup.sh per stack via `docker exec pg_dump -Fc` plus globals, verified with `pg_restore --list`, 7 kept, last-success and last-failure markers in /var/backups/packetstation/<stack>/; systemd timer `OnCalendar=*-*-* 02:00:00 America/Denver`; restore script and rehearsal recorded in DEPLOY.md.
- **Gitea Actions:** ci.yml with server (ruff, mypy, pytest against a postgres service: migrations from zero, role privileges, policy matrix, fingerprint, rules on generated feeds, scheduling, drawing decision table), frontend (lint, tsc, vitest, build), image (buildx, push `:sha-<sha>`), e2e (compose up with the CI overlay, fixture seed, Playwright scenarios switching fixture states through `packetstation fixtures use`, report upload, down -v). release.yml re-tags the tested sha image as `vX.Y.Z`; never latest.
- **Samba AD DC for CI:** built in the repo from debian bookworm with samba, provisioned as PSTEST.LOCAL with its generated CA exported for LDAP_CA_BUNDLE so the TLS path is real; seed.sh creates the OU tree, the 50 groups from `packetstation ad-groups --list` (one deliberately missing and one extra to test the report), and test users per role plus multi-role, nested, disabled, no-employeeID, duplicate-employeeID and no-role users with employeeID set by ldbmodify.
## Open questions
Need an answer before the named phase. Defaults apply if none arrives in time.
1.**Station timer** (Seth, before phase 5): keep, cross-check against labor tickets, or drop. Default: keep the timer, show ticket hours beside it. Phase 5 (2026-10-16) applied the default: the station timer is the clock of record and the VISUAL ticket hours are shown beside it.
2.**PDFs on the server** (confirm before phase 7): reportlab + pypdf + pypdfium2. Default: yes. Phase 7 (2026-10-17) applied the default: reportlab 5.0.1, pypdf and pypdfium2, with Pillow 13.0.0 for the rasters.
3.**Frontend stack** (confirm before phase 1): TypeScript + React + Vite. Default: yes. Plain JavaScript would not be faster for parity: the old 7,000 lines are screen code that must be rewritten either way, and typed API models cut rework.
4.**PIN defaults** (confirm): 6 to 12 digits, five attempts, 10-hour shop session, device cookie. Was open (Seth and Q.C., before phase 6): whether Q.C. accept and filing require the domain password even with a PIN set. Default: PIN is enough. Confirmed by SethD on 2026-10-26, Gitea issue 2: the PIN is enough for every sign-off, Q.C. accept and filing included. IT: device certificate later. The device cookie stays and no device certificate is planned: confirmed by Spencer (IT) on 2026-10-18, Gitea issue 3.
5.**Several open orders on a project** (before phase 3): which order a packet shows. Proposed rule: the open order whose line's top job is the packet's work order base, else the newest open order, else the newest closed one; the packet shows "via base" or "via project" and Clerical can override. Needs Seth's view. Phase 3 (2026-10-16) built the default in one function (`feed/orders.py`): a Clerical pin, else the base's own linked order, else the project order whose top job is the unit, else the project's first open order, else its newest; the source (`via`) is stored on the project and unit and shown on the project page, which lists every open order. Seth may change the rule later in that one place.
7.**Policy tightenings** listed under "Challenges" 1 to 3 (Seth, before phase 2). Confirmed by SethD on 2026-10-26, Gitea issue 6: "good as shown". Every row is kept, filing for packets routed to Shipping included (Spencer read the answer that way on 2026-10-27).
9.**Retention period** (D6) and customer-facing data book copy (D7): deferred, but a decision from Seth and Q.C. lets a later phase close them. Gitea issue 7: SethD expects all data to be kept (2026-10-26). Spencer decided on 2026-10-27 that project files are also archived to an NFS share he sets up on the QNAP, with an Admin setting for the folder path that checks read and write (phase 10, "Archive location"). Nothing is deleted from Packet Station; the archive is a copy. Still open on issue 7: a minimum retention period, if any, and what goes into the archive and when.
11. Cancelled (X) work orders: default flag only, like gone; drafts are not removed. SethD on 2026-10-26, Gitea issue 10: the default is fine, left to Spencer. Kept as built.
13. Work-order-gone confirmation after 2 consecutive runs and the 10% suspect-batch guard: defaults as stated. SethD left both numbers to Spencer on 2026-10-26, Gitea issue 12. The defaults stay unless Spencer changes them.
15. Gitea runner: whether the Samba DC container can run there (overlayfs xattrs, possibly cap SYS_ADMIN). Checked in phase 1; if not, CI uses a runner with a Docker socket on docker-1. Phase 1 finding (2026-10-16): the container needs `cap_add: [SYS_ADMIN]` (Samba 4.17 sets sysvol NT ACLs through `security.*` xattrs) and runs that way on docker-1; the Gitea runner itself is still to be tried. Phase 2 (2026-10-16): an act_runner with the host Docker socket is registered on docker-1 as `docker-1` (deploy/runner/); resolved: the runner passes `cap_add` through, and the e2e job (stack with the DC plus the Playwright scenarios) passed on it on 2026-10-16.
17. SET_DATABOOK_AT is writable by anyone in the old build, even when filed: default restrict to Clerical and Q.C. Confirmed by SethD on 2026-10-26, Gitea issue 14.
18. CO verifications cannot be withdrawn; ITP line signatures can be undone by the same inspector or an Admin before the block is signed. Confirmed by SethD on 2026-10-26, Gitea issue 15.
19. The release gate has two server rules: every line not N/A is pulled or on an open short, and every pulled line that issued pieces has a heat unless it is N/A or a sub-assembly. The old build enforced both on the screen only. Default: both on the server. Confirmed by SethD on 2026-10-26, Gitea issue 16: both stay, so the warehouse fills the heat map for the parts it sends.
20. Hold and short reasons: the old build accepted any text. Default: closed sets on the server; "Other" on a hold needs a note.
21. Receiving a short on a packet the warehouse no longer holds: default it stays a warehouse right (WH_RECEIVE_SHORT, any status but filed), and the holder of the packet (department lead or supervisor, Q.C., Clerical) can receive too, through edit_bom.
22. Upload kinds and limits: default an MTR is PDF, PNG or JPG up to 25 MB, a hand BOM is XLSX or CSV up to 5 MB. ClamAV is optional and off unless CLAMAV_HOST is set; when set, an unreachable scanner refuses uploads (fail closed). Confirmed by Spencer (IT) on 2026-10-18, Gitea issue 19: the limits stay, and ClamAV stays off in both stacks because every workstation that uses Packet Station runs Microsoft Defender. The `clamav` profile stays in the build, unused.
23. The quota alert: default a `notification_events` row (phase 9 builds the delivery on this table) at 85 percent of the assets volume. Confirmed by Spencer (IT) on 2026-10-18, Gitea issue 20: 85 percent stays, and the alert goes to PS-Admins. Phase 9 seeds the `assets_quota` notification rule with PS-Admins as its recipient.
24. Carried marks: when a packet is routed to another department, the welds, heat tags and heat table stay on the packet. The read model computes a `carried` flag. Nothing is copied. Default: computed, not copied.
25. The old build's manual "copy marks to the new sheet" button on a change order is not ported. On a page replacement only the heat table placement is copied to the new page. Welds and heat tags stay on the VOID page. Default: not ported in phase 5; a follow-up. Confirmed by SethD on 2026-10-26, Gitea issue 22: not ported. After a change order the department re-verifies the work, since a change can alter what was marked.
26. Checklist templates live in the `checklist_templates` settings row, with the old lists as defaults. Default: no screen edits them in phase 5; Admin editing comes in phase 9.
From phase 6 (2026-10-17), for Seth and Q.C. to veto:
27. Q.C. reject: the old build took any non-empty reason. Default: a reason of at least 8 characters, or a well-formed NCR number (`NCR-YYYY-NNNN`) in its place, or both. The NCR is stored on the inspection and linked to pesco-ncr. Applied in phase 6.
28. A filed packet in a unit and the unit's ITP: the ITP belongs to the unit, not to a packet, so ITP writes never look at a packet's status. Default: a filed packet does not lock the ITP. This resolves the phase 5 follow-up. Applied in phase 6.
29. ITP status: the 0003 enum had `open`, `signed` and `reopened`. Default: `itp_status` gains `complete` for a signed-off ITP; a reopened ITP is `open` again with the reopen recorded. `signed` and `reopened` stay in the enum unused, since PostgreSQL cannot drop an enum value. Applied in phase 6.
30. Removing an ITP block signature: the old build let any Q.C. editor undo it. Default: the signer or an admin, while the ITP is open. The block's QN, remarks and customer fields stay. Applied in phase 6. Confirmed by SethD on 2026-10-26, Gitea issue 26: a fix to customer fields or an earlier step's remarks goes back to that step or to Clerical.
31. The signed ITP PDF on sign-off: default a hook (`pdf.itp.file_signed_itp_pdf`) that phase 7 builds. Today it files nothing, the sign-off log says `PDF filing reserved for phase 7`, and the automatic `qc_docs` ITP row is written only when the hook returns a file. Applied in phase 6. Phase 7 (2026-10-17) built the hook: the sign-off files the signed ITP PDF and the automatic ITP document in the same transaction, and a PDF that cannot be built fails the sign-off.
From phase 7 (2026-10-17), for Seth and Clerical to veto:
32. Prints: the old build pushed page images into the browser's print dialog and offered the same PDF when the dialog was blocked. Default: a print opens as a stamped PDF in a new browser tab, and the person prints it from the PDF viewer (Ctrl+P). One path is simpler and prints the same stamped pages. Phase 7 (2026-10-17) applied the default.
33. When the data book is built: default on the server when the packet is filed, inside the filing transaction, stored as an asset on the seal. A data book that cannot be built fails the filing, so nothing is filed half way. Before filing, the data book can be viewed or downloaded at any time from status complete on; that copy is stamped "not yet filed". Phase 7 (2026-10-17) applied the default. Confirmed by SethD on 2026-10-26, Gitea issue 28.
34. Who may read the PDFs and the CSV: default every signed-in role that can open a packet, PM included. Every print and every export is logged with who, when, what and how many pages, in the append-only `print_log` and in the audit. Phase 7 (2026-10-17) applied the default. Confirmed by SethD on 2026-10-26, Gitea issue 29: everyone may view the whole packet; only the holder edits, and missing work goes back to the department that owes it.
35. The old "Original packet PDF" print kind: the rebuild has no VISUAL packet PDF. The drawing pages come from M-Files or a hand upload, and "Drawing (no markup)" prints them as attached. Default: the kind is gone. Phase 7 (2026-10-17) applied the default.
36. Filing while the feed still shows a work order open in VISUAL: default a second confirmation (`confirm_open_wos`), a warning and not a block, on both routes. The work orders are kept on the seal and in the log note. A hand packet has no feed state and no warning. Phase 7 (2026-10-17) applied the default.
37. The controlled-copy stamp carries a short signature made with the seal key, so a printed or exported copy can be checked against the server later. Default: phase 7 ships the signature, the check function and the print log; phase 9 adds the Admin screen that checks a copy. Phase 7 (2026-10-17) applied the default.
From phase 8 (2026-10-18), for Seth to veto. The phase 8 design numbered these 35 to 39; 35 to 37 were already taken by phase 7, so they follow on from 38.
38. Hours vs target: the station timer stays the clock of record. "Over target" means timer hours over the work-order target. VISUAL ticket hours are shown beside the timer in every hours table and never decide the flag. Phase 8 (2026-10-18) applied the default.
39. PM exports and who reads the reports: every PM export is logged as `report exported` in the audit and in `print_log`, with the scope and the period. Default: any signed-in role may read the PM reports (as every role may open every packet), not only PS-ProjectMgmt. Phase 8 (2026-10-18) applied the default.
40. Presence shows the display name and the role (the screen the person has open). Default: locked sessions drop out of presence at once; closing the tab drops out within the stream's ping cycle (25 seconds). Phase 8 (2026-10-18) applied the default.
41. The find bar filters are applied by the server on every board and list, so a board never fetches more than it shows. Default: the KPI numbers follow the project pick only, not the other filters. Phase 8 (2026-10-18) applied the default.
42. Mine is decided from the person's AD roles and the screen they are on. Default: the stall's Mine needs the picked station and shows nothing without one. Phase 8 (2026-10-18) applied the default.
From phase 9 (2026-10-21), for Seth or Spencer to veto:
43. The daily digest: default one message per address per digest time, listing every due event of every digest rule that reaches that address, not one message per rule. An address that already got an event is not sent it again after a partial failure. Phase 9 (2026-10-21) applied the default.
44. The AD reconcile guard: default the reconcile refuses and changes nothing when no mapped group is in the OU (a wrong `LDAP_GROUP_BASE_DN`) or when every mapped group is empty while active users exist. The run is recorded as failed with the reason in its errors, and an `ad_reconcile_report` condition "AD reconcile did not run" goes to the rule once, cleared by the next good run. A wholesale sign-out needs a person to look first. Phase 9 (2026-10-21) applied the default.
45.`profile_active`: default an inactive profile only hides the person from the welder picker and the worker filter. It does not change their roles or rights, and an assign made through the API still checks only the role. AD decides who may do what. Phase 9 (2026-10-21) applied the default.
46. Account opt-out: default any person may opt out of an event type on Account settings, in a password session, except the security event types (security alert, sign-in burst, integrity failure). An Admin cannot leave anyone out of those either. Phase 9 (2026-10-21) applied the default.
47. Version deployed: default the event is raised by the first `notify-send` run in each worker process when `PS_BUILD` differs from the build last announced (settings row `notify.last_build`), not at worker start, since the scheduler may start before PostgreSQL answers. A restart on the same build sends nothing. Phase 9 (2026-10-21) applied the default.
48. "Refresh from AD now": default the button asks the worker for an `ad-reconcile` run and answers 202 at once, instead of running the reconcile inside the web request. The Admin sees "Refresh requested" and the report when the run finishes. Phase 9 (2026-10-21) applied the default.
49. New departments: default only shop departments can be added in the app (422 `support_fixed` for a support department). The support roles are fixed in the `role_kind` enum and the group map, so a new support department would map to no role. Phase 9 (2026-10-21) applied the default.
50. The profile CSV `stations` cell: default the cell is the person's whole list of station limits across departments and replaces every limit they had; `-` clears them all; an empty cell leaves them. Phase 9 (2026-10-21) applied the default.
51. A direct welder assign: default it does not check `profile_active`, so an Admin's inactive switch hides a person from the picker but a typed assign still works. Phase 9 (2026-10-21) applied the default; say if an inactive profile should be refused there too.
52. A checklist item added while a stage is open: default it applies to that open stage too, because completeness reads the live template, so the item must be ticked before submit. Phase 9 (2026-10-21) applied the default.
- Every phase: `pytest` server suite, `npm test` and `npm run build` for the frontend, `docker compose -f deploy/compose.ci.yml up` with the Samba DC and the Playwright scenarios for that phase, `packetstation doctor` inside the image.
- Phase 3: a recorded DEV feed run compared with the packets the old build made for the same window (counts of drafts, units, projects; same keys), run from the developer machine against VISUAL DEV only.
- Phase 7: server tests for close and file against the policy oracle, the open work order confirm, the seal with its data book, reopen and refile, a failed data book build, the data book sections and index, the traveler, the ITP PDF, every print kind with its stamp and `print_log` row, the CSV formula guard and the stamp signature; frontend tests for the close and file panel, the print menu, the prints list and the ITP buttons; the Playwright close scenario (close, PDFs and CSV, prints, file with the seal, reopen and refile, the signed ITP PDF) in the CI stack on docker-1.
- Phase 8: the data book of a synthetic filed packet compared page by page with a data book exported from the old build on the same fixture (moved from phase 7). Done in phase 8 (2026-10-18); the page table, the fixes and what is left are in the phase 8 comparison note.
- Phase 9: restore rehearsal on the test stack from a pg_dump and an assets copy. Done 2026-10-21: every table's row count and all 189 asset hashes matched (DEPLOY.md, "Restore rehearsal 2026-10-21").
OPS2 (Gitea issue 38): the test stack's files stay under `/home/spencerm/packetstation/test`, like the other compose stacks on docker-1 (gitea, npm, pesco-ncr and the rest live under `/home/spencerm`). Decided by Spencer (IT) on 2026-10-18. The move to `/etc/packetstation/test` is dropped. Whether the production stack follows the same layout is asked on issue 38. Until it is answered, DEPLOY.md keeps `/etc/packetstation/prod`.
Production host (Gitea issue 37): decided by Spencer on 2026-10-27. Production runs on its own Linux VM as native services, not in Docker and not on docker-1. The test stack moves to its own VM too, built the same way, so test rehearses production; docker-1 keeps CI only (the Gitea runner and the CI e2e stack). The docker-1 test stack runs until the test VM is ready. This replaces OPS2 for both stacks once the VMs exist: the production folder question on issue 38 no longer applies, and the VM file layout is set in phase 10. The docker-1 address pool (OPS1) then only has to fit the runner and the CI e2e stack. Still open on issue 37: each VM's name and address, the OS (Debian 12 matches the image), who builds them, their size, and how NPM reaches them (HTTP on the LAN or TLS to the VM).