Architecture decisions — Tropical Update Publisher (v2 build)

This file records cross-session decisions that are not obvious from code alone. Session 3C introduced the baseline Content Security Policy module; Phase 14D will extend the policy per project-spec.md §28.2.


Phase 14 CSP delta hosts (Session 3C — checklist for Session 14D)

Baseline production CSP (§1 rule 6 — implemented as BASELINE_CSP_PROD in src/main/security/csp.ts):

  • default-src 'self'
  • script-src 'self'
  • style-src 'self' 'unsafe-inline'

Phase 2 additions (exact text from project-spec.md §28.2 — not yet merged into the runtime prod string):

  • img-src: add https://www.nhc.noaa.gov and data: (in addition to 'self' already implied by the Phase 2 spec block).
  • connect-src: add https://www.nhc.noaa.gov and https://api.anthropic.com (in addition to 'self').
  • font-src: add 'self' and data: as an explicit directive (baseline relies on default-src for fonts until Phase 2).

No other origins are permitted without a specification amendment.


Dev-only CSP relaxations (P11)

Documented in src/main/security/csp.ts (module-level comment). Never ship unsafe-eval in production; dev uses Vite’s eval-based HMR client.


Config unknown keys (Session 4A — ADR)

Context: Operators may hand-edit config.json or merge files from backups; newer app versions may add keys before older builds read the file.

Options:

  1. Strip — remove keys not in the schema (Zod object default behavior).
  2. Strict — fail validation when unknown keys exist (.strict() on the root object and on every nested object in the schema).
  3. Passthrough — retain unknown keys on the parsed object (rejected: secrets risk and unclear merge semantics).

Choice: Strip by default in parseAppConfig (unknownKeyPolicy defaults to 'strip'). Callers that require a closed world (e.g. settings import audit) pass { unknownKeyPolicy: 'reject' }, which uses AppConfigSchemaStrict (root plus nested .strict()).

Exports: AppConfigSchema (strip) and AppConfigSchemaStrict (reject unknowns at any schema-defined nesting level). Documented so Session 4B ConfigService can choose policy per call site.

Verification checklist item 9: Extra properties → strip mode removes them and parse succeeds; reject mode surfaces VALIDATION_ERROR with Zod “unrecognized key” issues (including nested objects).


Secrets excluded from AppConfig / JSON (Session 4A)

Forbidden key names (non-exhaustive list aligned with spec §28): smtpPassword, senderPassword, password, oauthToken, apiKey, anthropicApiKey. A bounded-depth tree walk (currently 12 object levels from the root) rejects these at any scanned object nesting level (not only the root) with FORBIDDEN_SECRET_KEY, even in strip mode, so mis-pasted secrets fail fast instead of being silently dropped. If a plain object sits deeper than that limit, parsing fails with FORBIDDEN_SCAN_INCOMPLETE so configs with extreme nesting cannot bypass the scan (shallower forbidden keys are still reported as FORBIDDEN_SECRET_KEY first when both apply).

Not modeled: SMTP/IMAP passwords, OAuth tokens, GitHub PAT, Anthropic API keys, Discord webhook secret — stored via keytar service WxManBran-TropicalPublisher per spec §28.1 / master plan §26 vault table.


File copy batch semantics + recovery (Session 5B)

Context: Publishing copies queued .docx files into incoming/posts and may emit per-file {stem}.meta.json when a validated youtubeId is present (R2 / domain 0B).

Partial batch failure policy

  • Options: (A) continue after a failure and report all per-row errors; (B) abort the remainder after the first hard failure; (C) transactional all-or-nothing with rollback.
  • Choice: (B) — process rows sequentially in input order (deterministic; matches queue insertion order). On the first row that fails validation, copyFile, stat, or meta writeFile, record that row’s structured errorCode, set batchAbortedAfter to its 0-based index, and append FILE_COPY_BATCH_ABORTED results for all remaining rows without copying them. Already-copied files and successful meta writes remain on disk (no automatic rollback).
  • Why: Matches master plan Session 5B / 5D risk note: operator may need completed files for diagnosis; remainder is explicitly skipped rather than left ambiguous.

Idempotent second publish

  • Options: (A) refuse overwrite; (B) overwrite .docx and rewrite .meta.json only when the new copy + meta write succeed; (C) delete incoming folder between runs.
  • Choice: (B)copyFile overwrites an existing destination file; overwritten: true when a file already existed. A new successful run rewrites .meta.json when youtubeId is present.
  • Why: Aligns with “second publish with the same queue must be idempotent” in the master plan.

Optional publish-batch.json journal

  • Options: implement a journal under userData for crash recovery vs defer.
  • Choice: Deferred — not implemented in 5B. If the process dies mid-batch, the operator removes or reconciles stray incoming/posts files manually; Session 5D orchestrator may revisit journaling if product owners require it.
  • Why: Session scope is FileCopyService + IPC wiring; journaling is called out as optional in the master plan narrative.

HashRouter for renderer navigation (Session 7A — ADR)

Context: Electron loads the renderer via loadFile / file:// (or http://localhost in Vite dev). BrowserHistory paths like /publish mutate the path portion of the URL, which is unreliable or opaque under file://. Session 4D already uses window.location.hash for the first-run surface (#first-run / #/first-run), establishing hash as a viable routing channel before the full shell existed.

Options:

  1. BrowserRouter — clean URLs in dev; breaks or confuses deep links under packaged file:// without extra main-process URL plumbing.
  2. MemoryRouter — safe everywhere; loses addressable deep links and does not persist navigation across reloads in a way operators can share or bookmark.
  3. HashRouter — path lives in location.hash (#/publish), which file:// tolerates; aligns with existing hash-based first-run gate; reload preserves the route within the hash fragment.

Choice: HashRouter wrapping the post-first-run AppShell tree (main.tsx still short-circuits to FirstRunModal when the hash matches first-run before App mounts).

Tradeoffs: Hash is visible to users who inspect the URL bar; MemoryRouter-style isolation tests use the real HashRouter in integration tests and manipulate window.location.hash where needed.

Verification checklist item 8: This section satisfies “HashRouter/MemoryRouter choice documented in ADR.”

Follow-up (optional): If product later requires path-style URLs in dev and hash in prod, split on import.meta.env — deferred until a session explicitly needs it.


TanStack Query + renderer IPC timeouts (Session 7B — ADR)

Context: The renderer cannot block forever on ipcRenderer.invoke calls. The master plan Window 4 policy mandates ≥8000 ms for read-style queries and ≥60000 ms for the git:publish family, with a distinct IPC_RENDERER_TIMEOUT path, operator toasts (§23.11 tone), no automatic second submit for publish, and one automatic retry only for idempotent reads.

Options:

  1. Timeout only inside main-process handlers — centralizes deadlines but leaves the renderer blind when main is wedged; violates the session brief to wrap preload/effective invoke from the renderer.
  2. Ad-hoc Promise.race per hook — minimal deps; duplicates deadline + error mapping across features.
  3. Shared invokeWithTimeout + TanStack Query defaults — single race implementation, typed query keys, QueryCache/MutationCache global toast wiring, explicit idempotent channel registry for retry policy.

Choice: (3)src/renderer/api/ipcClient.ts owns invokeWithTimeout, IpcInvokeError, IPC_RENDERER_TIMEOUT, toast copy, createRendererQueryClient, and the exported IDEMPOTENT_QUERY_IPC_CHANNELS list (read-only channels safe for a single retry after timeout / GIT_STATUS_FAILED). Mutations default to retry: false; publish timeouts flip useGitPublish’s unknownAfterTimeout flag so the banner path can appear without implying success.

Toast transport: Sonner (sonner package) hosts global toasts with shadcn-aligned class tokens (src/renderer/components/ui/sonner.tsx) plus project-spec.md §23.11 “Copy details” action (clipboard).

Devtools: @tanstack/react-query-devtools loads only when import.meta.env.DEV via dynamic import() inside AppProviders so production bundles avoid pulling the panel.

Tradeoffs: Duplicate timeout guards (renderer + future main deadlines) are acceptable until a later consolidation session; the idempotent channel list must stay in sync with ipc-inventory when new read-only handlers appear.


Theme tokens + typography (Session 7C — ADR)

Context: project-spec.md §23.2 defines brand colors, storm severity coding, Inter + JetBrains Mono, and a px type scale. Session 7C must extend Tailwind + CSS variables without breaking shadcn/Radix token names (background, primary, destructive, …).

Options:

  1. Replace shadcn palette entirely with ad-hoc hex in Tailwind theme.extend only — risks Radix components drifting from app surfaces.
  2. Keep shadcn HSL variables and only add parallel hex utilities — duplicates source of truth.
  3. Map §23.2 into :root / .dark HSL custom properties consumed by existing shadcn keys, plus named extras (--success, --storm, …) and component utility classes for storm borders/surfaces.

Choice: (3)globals.css owns tokens; tailwind.config.ts adds fontFamily, fontSize scale keys (type-12type-36), and semantic success / warning / info / storm colors pointing at new variables.

Typography loading: Google Fonts with display=swap in CSS @import (not self-hosted in-repo) to avoid bundling binary font files without a download step. Phase 14 / DECISIONS already list a font-src delta; Session 14D must add https://fonts.gstatic.com (and typically https://fonts.googleapis.com for any link-based stylesheet if CSP tightens) to production CSP when webfont loading is enabled in packaged builds.

Theme persistence: localStorage key tup:ui-theme-v1 until Settings IPC owns preferences. Invalid values fall back to dark (product default). bootstrapRendererChrome runs before createRoot so first paint matches stored preference.

Reduced motion: CSS variables (--motion-duration-*) collapse under prefers-reduced-motion: reduce; data-preferred-motion mirrors media query for Session 7D Framer wrappers.

Tradeoffs: Google Fonts require network at first run unless cached; operators on air-gapped machines fall back to system stacks defined in tailwind.config.ts.


Framer Motion presets + route transitions (Session 7D — ADR)

Context: Master plan §23.4 / session 7D require 200ms route-level transitions and 50ms list stagger, with reduced-motion parity. Session 7C already defined CSS custom properties (--motion-duration-*) that collapse under prefers-reduced-motion: reduce and mirrored data-preferred-motion on <html>.

Options:

  1. CSS-only transitions — no Framer; lighter bundle but awkward react-router exit animations and no shared stagger API.
  2. Ad-hoc durations per view — fastest to sketch; drifts from spec and duplicates reduced-motion handling.
  3. Central presets.ts + thin layout wrapper — single source for ms constants, Variants factories (pure + unit-tested), RouteTransitionLayout with AnimatePresence mode="wait" for serialized route swaps.

Choice: (3)src/renderer/motion/presets.ts exports ROUTE_TRANSITION_DURATION_MS (200), LIST_STAGGER_DELAY_MS (50), createRouteTransitionVariants, createListStaggerContainerVariants, createListStaggerItemVariants, and coerceReducedMotionFlag (Framer’s useReducedMotion() can be null before read). RouteTransitionLayout wraps Outlet and keys on location.pathname. Settings placeholder demonstrates list stagger.

Reduced motion: Factories accept prefersReducedMotion: boolean derived from useReducedMotion(); when true, opacities stay at 1 and transitions use duration: 0. Sidebar width continues to use duration-[var(--motion-duration-medium)] so CSS variables enforce the same OS preference for non-Framer motion.

Bundle tradeoff: framer-motion adds roughly ~55–75 KiB gzip to the renderer chunk (library + helpers; exact figure depends on tree-shaking and Vite split). Documented for operator cost awareness; acceptable for spec-mandated motion polish.

Follow-up: When real queues/lists land (publish dashboard), import the same stagger factories instead of inventing new timings.


Global status bar + top bar (Session 7E — ADR)

Context: project-spec.md §23.3 defines a persistent footer (monitor dot, AI backend, last push, storm count, git branch, network) and a top row (identity, ⌘K search, theme toggle). Session 7B already exposes useGitStatus for branch text.

Options:

  1. Inline JSX in AppShell only — fastest, but untestable copy and ordering drift risk.
  2. Single Chrome.tsx mega-component — one import site, harder to review and reuse.
  3. TopBar + StatusBar + small pure formatters — matches prior sessions’ pattern (thin layout components, tested strings), integrates with TanStack Query like PublishPlaceholder.

Choice: (3)TopBar.tsx / StatusBar.tsx plus statusBarModel.ts and topBarModel.ts. Git errors use a dedicated role="alert" banner above the footer (verification: not toast-only / silent). Polite aria-live carries a full sentence summary when healthy; on error the live text points operators to the banner to avoid duplicate assertive chatter.

Tradeoffs: Placeholder segments remain until monitor/network/history sessions land; horizontal space is managed with flex-wrap, truncate, and title tooltips for long branch names instead of a bespoke overflow DropdownMenu (not yet in the shadcn set for this build).


Publish DropZone + drag-drop bridge (Session 8A — ADR)

Context: Master plan Session 8A mandates a dashed .docx drop target, BEM state classes, Framer polish (§23.5), Browse via dialog:openDocx, synchronous path extraction for HTML5 drops, and strict a11y (role="region", timed aria-invalid, assertive errors without raw errno).

Options:

  1. Renderer-only paths — rely on deprecated File.path in Chromium/Electron without preload helpers; breaks under stricter sandbox builds and is hard to mock in Vitest.
  2. Async IPC for every drop — invoke main to map File handles; conflicts with ipc-inventory row 021 synchronous drop-handler expectation and adds latency.
  3. Preload webUtils.getPathForFile + typed getPathsForFiles, plus validating getFilePaths — matches Electron 33 guidance, keeps mapping synchronous, and mirrors DND_NO_PATHS semantics for empty handle lists.

Choice: (3)src/preload/index.ts implements dragDrop.getPathsForFiles and a non-stub getFilePaths that trims handles and throws structured DND_NO_PATHS when empty. Renderer DropZone pairs lengths via zipFilesToPathsOrIssue in dropZoneModel.ts.

Browse alignment: createDialogOpenDocxHandler now serves mode: 'docx' (default) with multiSelections + Word filter so operators can enqueue up to five paths without drag metadata.

Tradeoff: Adding getPathsForFiles expands the TropicalDragDropApi surface beyond the historical single-method stub; downstream sessions must keep the two-call sequence (resolve → validate) stable for tests and production parity.