Skip to content

5–6. State Architecture & Frontend Ports ​

The three state layers (server / URL / client) in detail (§5) and the hexagonal frontend ports with local + hosted adapters (§6). Part of the Frontend Architecture reference.

Read this if you are deciding where a value belongs, or how feature code should reach the network, storage, clipboard, or OS without depending on them directly.

Two questions, one per half of the page: §5 answers where does this value live? — the server cache, the URL, or a client store; §6 answers how does feature code reach the outside world? — through a port, never a raw browser or network API.

5. State Architecture (Detailed) ​

5.1 Layer Boundaries — What Goes Where ​

The three-layer model from §2.1 needs concrete rules. The table below is the canonical decision matrix.

DatumLayerWhy
Jobs list filters (stage, state, q, deleted)URLBookmarkable; survives refresh; copy-paste shareable.
Discovery source-review column filters and sortURLBookmarkable review state; applying a named view navigates to its snapshot, while reload honors the current URL.
Sort field & directionURLSame reasons.
Page index, page sizeURLSame.
Selected job (detail workspace active)URLRefresh restores the bookmarkable detail route.
Currently active routeURL (path)Trivially.
Global text search ("Filter jobs, errors, companies...")URLBookmarkable searches.
Bulk-selection set (checked job keys)Component (useState)Intentionally ephemeral; selecting 50 jobs and refreshing should not preserve the selection. Documented exception.
Saved table view definitions, active view id, column visibility/order/widths, and table density overrideClient (Zustand+persist)Local UI preference/template data. Applying a view writes URL-owned filters/sort through navigate; active filters/sort are not shadowed in the store.
Theme (light/dark)Client (Zustand+persist)User preference; not URL-bound; persists across sessions.
Density (compact/regular/comfy)Client (Zustand+persist)Same.
Desktop navigation expansionClient (Zustand+persist)User preference; the icon-only or expanded rail choice survives reloads. Mobile navigation remains transient sheet state.
Tenant identityClient (context fed by Zustand+session in cloud)Determined by session; not navigation-controlled.
Toast queueClient (Zustand)Transient, cross-cutting.
Profile dataServer (Query)Fetched, cached, mutation-invalidated.
Settings / credentialsServer (Query)Same.
Dashboard summaryServer (Query)Same.
Daily digest and digest acknowledge stateServer (Query + mutation)GET /v1/digest is a passive local read. Only explicit acknowledge advances digest_state; digest deep links carry filters/sort in the URL.
Jobs list responseServer (Query)Same.
Job detailServer (Query)Same.
Saved resume review draftServer (Query)The apply-context reconciler publishes create/save/seed/render/reply responses to the initiating tenant/job key. Draft identity and base generation precede revision/state/time ordering; threads, replies and feedback signals merge independently by their IDs.
Unsaved resume review editsApply-owned mounted sessionuseApplyReviewSession owns the document, autosave, submitted/saved baselines, render attempts, accepted comparison and approval preparation. Document identities compare the normalized persistence form, so acknowledging equivalent pasted blocks stops autosave without replacing the live editor; later text, formatting or undo B stays dirty. Saved text includes unnumbered pasted paragraphs alongside anchored lines. Materials renders controlled state; comment-only updates do not reset focus or selection. Tenant/job changes replace the session; replacement drafts reset document identity independently of revision counters. Every cache publication obeys the current material source fence; the session's own accepted render can retain its original base after promotion.
Pending resume comment seedsApply-owned mounted sessionSeed payloads and publisher callbacks bind to the initiating text/PDF material IDs. A material switch discards incompatible retained seeds before a replacement draft loads; delayed callbacks cannot attach foreign pins. Materials publishes empty pin lists too, so an empty source never replays an earlier payload.
Interviews filters, graph/list mode, selected card, and optional canonical jobURLThe library view is bookmarkable and remains distinct from generated prep; navigation and browser Back/Forward use this state.
Interviews Questions/Sources perspective, author drill-down, graph pan/zoom, and inspector tabComponent (useState / tabs)Transient exploration controls; they do not shadow URL-owned card/job/filters, accepted evidence selections, or private drafts.
Installed interview catalog and job prep/history/notesServer (Query)Public Operations read hooks use the API port; catalog browsing does not require a job/worker/provider.
Unsaved interview question selection/order, context, and note textMaterials session store + Form library stateTanStack Form validates mutations; a tenant/job/question-keyed in-memory Zustand store preserves drafts through navigation. Dirty edits survive delayed responses, save failures, and revision conflicts; a newer query result cannot overwrite them. Personal text is not persisted in browser storage or URL state.
Manual outcome, reflection and send-log retryMounted form sessionEach form retains the submitted payload and request identity in memory until acknowledgement or an explicit new action. Send-log reconciliation updates only the initiating thread; current thread reads remain authoritative. See the retry contract.
Artifacts list / detailServer (Query)Same.
Apply run live timelineServer (Query) — appended via setQueryData from SSEHigh-frequency; see §7.5.
Resume import wizard selection and optionsClient (Zustand + browser storage)The filename and PDF are accepted together only after a successful storage write; pending reads and failed replacements cannot publish a stale choice. Current-version tabs compare and update the versioned record in a single IndexedDB read-write transaction, including a tombstone for cleanup. A stale snapshot or unavailable transaction fails closed and offers recovery. The earlier v1 key is read-only and separately recoverable; older tabs cannot erase the v2 record, but still need reloading because they cannot coordinate v1 writes with each other. Selection and options survive navigation, refresh, and Cancel, until explicit removal or successful import cleanup. In-flight and completed imports within a tab are matched by exact source and options; successful removal retires completed-request authority without bypassing an in-flight request. Request identity and returned drafts stay in session memory and are not staged into Profile. The URL identifies the step, not its data.
Form drafts (profile, settings)Form library state (TanStack Form)Profile and resume style stay object-valued until the existing string-valued update request; validation does not replace the edited object with normalized schema output. Owned by the form until submit; mutates the server via the mutation hook. The shared Profile, Cost and capacity, and Discovery Automation autosave controller attempts an unchanged dirty snapshot once after five seconds; an explicit Save or a different draft can retry, and an in-flight submit does not erase newer edits. Apply and Discovery runtime forms keep a tenant-scoped, non-refetchable acknowledged baseline in the QueryClient: successful reads and their own save responses advance it, while optimistic writes and rollbacks do not; newer edits remain pending. Within one QueryClient, runtime writes for the same tenant and panel are ordered across remounts, and saves write only deliberately edited fields so an unacknowledged commit cannot be overwritten by a stale draft. Direct Profile Plate edits with an unambiguous semantic owner project into this same draft rather than creating a second profile store.
Connection status to API (the inline "live"/"offline" status)Server (Query: useHealthQuery({ refetchInterval: ... }))Polling the health endpoint, not a manual useState.

5.2 URL ↔ Query Cache Binding ​

Search params drive query keys. The pattern is:

ts
// routes/artifacts.tsx: validate/map URL input, then delegate to its read owner.
loader: ({ deps, context }) =>
  prefetchArtifactsList(context, artifactsListInput(deps.search)),

Operations, Profile and Outreach expose read helpers in their own readQueries.ts modules. Helpers accept structural tenant, API-port and query client dependencies independent of the router/generated tree. Routes validate search and orchestrate these helpers; they never operate on API or QueryClient capabilities. Contexts never import views. Shared key/read factories are reused by the observers; hook-specific enabled, select, freshness and refetch policies stay on the hooks.

The existing prefetches await best-effort prefetchQuery reads using the query client's freshness defaults. Activity detail instead awaits ensureQueryData, unwrapping the event and propagating errors (including 404). Its observer's 404-to-null policy is separate. An accepted Activity cache entry is reused even when stale; no automatic background revalidation is requested by that helper.

Jobs has no loader: its view owns the effective-query handoff after saved-view and URL state are resolved. useJobsListQuery consumes that effective input; adding an independent route prefetch would risk a competing cache key.

Pagination + sort changes mutate the URL (via navigate({ search: { ..., page: 2 } })); the URL change triggers a re-render with new search params; the new search params produce a new query key; React Query fetches the new page (or returns it from cache if visited recently).

5.3 Optimistic Updates ​

The mutation invalidation strategy (§8.3) separates synchronous HTTP results from async (202) actions. Synchronous mutations (delete job, restore job, mark applied, mark skipped, retry stage with runAfter: false, cancel stage, update profile, update settings, update/delete credential) take the optimistic update path:

  1. onMutate: snapshot affected query data; apply the optimistic patch.
  2. mutationFn: call the API.
  3. onError: roll back to the snapshot.
  4. onSettled: invalidate the affected keys (forces a re-fetch to reconcile any drift with the server).

This pattern is implemented in each context's mutation hooks via the createOptimisticMutation helper (shared/lib/createOptimisticMutation.ts), which has 23 call sites today: 19 supply a real onMutate patcher + rollback, and 4 are deliberately settle-only (applyJob, dryRun, cancelApply, importResume — no meaningful pre-response patch, so they invalidate on settle only). The onMutate patcher is a pure function; it is unit-tested separately from the hook.

Synchronous mutations that do not patch a specific cached row still reconcile through onSettled invalidation. Non-apply-only global/batch pipeline stage starts use that path: the API returns HTTP 200 with worker action results, then the mutation invalidates operational reads on settle.

Interview note saves compare expectedRevision at the canonical owner. The browser reconciles the returned saved revision with its current form draft, keeping edits made after submission dirty. A 409 includes currentNote through JobCtrlApiError.responseBody; displaying it does not overwrite the draft or retry on a newer revision automatically. Concurrent mutation rollback must not restore an older whole-query snapshot over a newer saved note. Prep generation does not rebind an existing note: ordinary edits omit origin claims, and new bound notes use their retained selected-card origin or server-derived bindings. Generation is asynchronous: terminal events reconcile history/current prep, while the last accepted result stays visible through pending or failed replacement.

5.4 Stale Time and Garbage Collection ​

Defaults:

  • staleTime: 30_000 — 30s. After 30s, a remount triggers a background refetch; the user sees stale data instantly while the network catches up.
  • gcTime: 5 * 60_000 — 5 min. Inactive cache entries garbage-collect after 5 minutes.
  • refetchOnWindowFocus: true, refetchOnReconnect: true, refetchOnMount: "always" only for the dashboard query (it is the landing surface and freshness matters most).

These defaults are overridable per-query. The dashboard summary uses staleTime: 0 because it is the highest-touch surface; the artifacts list uses staleTime: 60_000 because it changes less.

Realtime (§7) is the primary freshness mechanism, not polling. The refetchOnWindowFocus is a backstop for cases where the SSE connection dropped silently.


6. Hexagonal Boundaries — Frontend Ports & Adapters ​

The frontend has its own hexagonal architecture. Components and feature hooks depend only on ports (interfaces); concrete adapters bind to the ports in shared/providers/. This makes feature code testable without hitting the network and gives a clear seam for cloud evolution.

6.1 Port Inventory ​

PortPurposeLocal-mode adapterHosted-mode adapter (named, not built)
ApiClientPortHTTP requests against apps/apiJobCtrlApiClient from @jobctrl/api-clientTransport with hosted authentication when implemented
RequestIdentityPortCreate an identity for each deliberate manual actionBrowserRequestIdentityAdapter (UUID)Same adapter
EventStreamPortSubscribe to a stream of DomainEventsSseEventStreamAdapter (new EventSource(...))WebSocketEventStreamAdapter (if SSE proves limiting at scale) or same SSE adapter behind CDN with edge buffering
StoragePortPersist client preferences and wizard draftsLocalStorageAdapter (browser localStorage)IndexedDbAdapter (when client-side cache exceeds 5 MB)
SessionPortResolve TenantId and UserId for the current requestLocalSessionAdapter (returns LOCAL_TENANT + a stub user)JwtSessionAdapter (Auth0 / Cognito; reads JWT, exposes useSession())
ArtifactDownloadPortDownload retained, verified DOCX bytesBrowserArtifactDownloadAdapter calls the API client, creates a temporary object URL and starts a browser downloadNo hosted implementation; public demo reports unavailable
ClipboardPortCopy CLI commands to clipboardNavigatorClipboardAdapter (navigator.clipboard.writeText)Same adapter
OpenInOsPortOpen an artifact in the OS default app (local-only feature)OpenArtifactAdapter (POSTs to /v1/artifacts/:id/open)Disabled in hosted mode — port returns Unsupported; the UI surfaces a "download" affordance instead. (Cloud users get presigned S3 URLs.)
TelemetryPortEmit frontend telemetry (errors, route timings, mutation latencies)ConsoleTelemetryAdapter (no-op + dev-tools logs)OpenTelemetryWebAdapter → OTLP collector → backend tracing pipeline
FeatureFlagPortRead feature-gate valuesStaticFeatureFlagAdapter (always returns the default; the seam exists, no flags ship today)Backend-served via apiClient.featureFlags(); cached in Query

Resolves §6 question 15: the FeatureFlagPort seam exists today as a static no-op adapter; no feature flags are introduced now. When the first flag becomes useful, swap the local adapter for the backend-served adapter without touching feature code.

6.2 Port Wiring ​

Ports are bound at the application root via dependency-injection through React context:

ts
// shared/providers/ports.tsx
const PortsContext = createContext<{
  api: ApiClientPort;
  requestIdentity: RequestIdentityPort;
  eventStream: EventStreamPort;
  storage: StoragePort;
  session: SessionPort;
  clipboard: ClipboardPort;
  openInOs: OpenInOsPort;
  artifactDownload: ArtifactDownloadPort;
  telemetry: TelemetryPort;
  featureFlags: FeatureFlagPort;
} | null>(null);

export function PortsProvider({ children, ports }: ...) { /* ... */ }
export function usePorts(): Ports { /* throws if missing */ }

Concrete adapters are constructed in demo/portFactory.ts, selected by main.tsx:

ts
const api = new JobCtrlApiClient(apiBaseUrl);
const eventStream = new SseEventStreamAdapter(apiBaseUrl);
const storage = new LocalStorageAdapter("jh:");
const session = new LocalSessionAdapter();
// ...

In tests, PortsProvider accepts mocks. Components depend on the port interfaces, not concrete classes.

6.3 ApiClientPort Detail ​

ts
export interface ApiClientPort {
  jobs(query?: Partial<JobListQuery>): Promise<PaginatedResponse<JobSummary>>;
  job(jobKey: string): Promise<JobDetail>;
  // ... the capabilities consumed by the frontend
}

ApiClientPort spans the API surface: read (dashboardSummary, jobs/job, artifacts/artifact, activity, workflowRuns/workflowRun, applyReviewQueue, applicationOutcomes, health), job lifecycle (delete/hide/restore/ permanent-delete + bulk), discovery-source administration, scoring (correctScore, rescoreJob, retailorJob, …), materials + resume-review drafts + resume templates, apply (applyJob, cancelJobAction, markApplied/markSkipped), pipeline (runPipelineStages, retryStage, runJobStage), and workflow-run control (cancelWorkflowRun) — the last group landed with the Temporal work (P5). Sync URL helpers (artifactPreviewPdfUrl, profilePreviewHtmlUrl, …) return strings, not promises. LLM spend/budget is not a dedicated method; it rides on health() (ApiHealthResponse.llmSpend). Methods take jobKey: string, not a branded JobId (see R13).

JobCtrlApiClient already satisfies ApiClientPort and is bound directly. The interface provides the test and demo seam: tests can inject mocks into PortsProvider, and demo mode supplies DemoApiClientAdapter. A forwarding class adds no boundary or behavior. Future authentication belongs in the selected transport; it does not require a second implementation of every client method today.

6.4 EventStreamPort Detail ​

ts
type EventStreamStatus = "connecting" | "open" | "closed";

export interface DomainEventEnvelope {
  eventType: string;
  tenantId: TenantId;
  payload: unknown;
}
export interface EventStreamSubscription {
  on(handler: (event: DomainEventEnvelope) => void): () => void;
  readonly status: EventStreamStatus;
  onStatusChange(callback: (status: EventStreamStatus) => void): () => void;
  close(): void;
}
export interface EventStreamPort {
  subscribe(opts: { tenantId: TenantId }): EventStreamSubscription;
  readonly status: EventStreamStatus;
}

The SseEventStreamAdapter (shared/adapters/local/) opens an EventSource against GET /v1/events/stream. It dispatches each parsed DomainEventEnvelope to all subscribed handlers and exposes connection status, which the legacy-named <ConnectionStatusPill> component (in shared/layout/, rendered in the Topbar) presents as a compact dot/glyph plus "live"/"reconnecting" text, not a colored capsule.

The hosted-mode WebSocketEventStreamAdapter (if the SSE adapter proves limiting under cross-region or CDN-buffered conditions, see fitness function §9.4) preserves the same interface; only the transport changes.

6.5 Canonical DTOs And Frontend Input Refinements ​

Import unchanged wire DTOs, Zod schemas and wire constants directly from @jobctrl/contracts. Import domain vocabulary and the domain-event union from @jobctrl/domain-types. Those packages own the shapes; transparent aliases or re-exports do not isolate consumers from a wire-shape change. A canonical shape change requires updating its affected consumers.

contexts/operations/types.ts owns genuine frontend input refinements: JobsListInput accepts readonly stage selections, list inputs allow partial queries, and stage filters include the UI's "all" sentinel. It also retains string JobId compatibility, the indexed event-type union and the endpoint-derived availability acknowledgement. It does not mirror canonical DTOs, schemas or constants. The Operations public barrel exposes its hooks, query keys, infrastructure and frontend refinements.

corepack pnpm web:lint permits canonical package imports without contract exemptions. It independently enforces capability ports, context-owned API/query/ store usage and dependency direction, including edges hidden by local barrels. Remaining capability and dependency exemptions require exact file/symbol reasons and fail when stale. Canonical imports grant no capability to call the API client or query client from a view.

6.6 No Direct DOM Access from Feature Code ​

A pattern visible in the current App.tsx:

ts
window.dispatchEvent(new CustomEvent("jobctrl:set-jobs-filter", { detail: target }));

This is the canonical anti-pattern the target eliminates. Cross-component coordination goes through:

  • The URL (navigate({ to: "/jobs", search: { state: "failed" } })) for filters, sort, and detail-route selection — anything that should be shareable or restorable.
  • Zustand stores for ephemeral cross-cutting state (toasts).
  • The query cache and its invalidations for data dependencies.
  • Ports for browser APIs (clipboard, notifications, OS-open).

Browser capabilities belong behind ports (e.g., ports.clipboard.write(text)). The lint checker guards direct storage, fetch, EventSource, clipboard, and browser event-bus access. Existing exceptions are scoped to the three persisted context stores' storage access and ResumeAuditPins' HTML-preview fetch; they do not authorize those capabilities in other feature modules.

Automated Boundary Checks ​

corepack pnpm web:lint uses TypeScript syntax, resolved symbols and capability provenance to check dependency and capability boundaries:

  • Contexts cannot import views, and views cannot add another view's modules. The existing Analytics-to-Dashboard KPI composition has an exact-edge relocation exception.
  • Aggregate contexts cannot import another aggregate's hooks or stores; Operations read hooks remain the shared read-side exception.
  • Views and routes cannot use runtime Query or Zustand APIs or call the API client. Type-only imports remain valid; API error values and URL-only preview helpers have scoped exceptions.
  • Loader context, API and QueryClient origins remain protected through aliases, casts, fallback/conditional expressions, parameter and destructuring defaults, array spread/rest positions, for...of bindings and assignments, comma-expression results, await and member assignments even with any or unresolved context declarations. API/cache capabilities and their containers cannot escape through arbitrary calls, route/view return values, class-field initializers, constructor parameter properties or receiver-field assignments. Method receivers containing capabilities are handoffs too, including array extraction and iteration calls. Passing them to a helper requires a resolved context-owned implementation (including immutable aliases). The shared syntax tracer follows computed keys through containers and bindings; an unresolved key on a known capability container cannot grant access. Local shadowing, known non-capability keys and context helper calls remain valid. Context helper results retain returned capability provenance across module boundaries and any annotations; ownership permits dependency handoff, not returning raw API/cache authority to a route or view. DTO results remain valid. Runtime re-exports cannot hide Query/API imports in exported containers or destructured aliases, including mixed default/named imports and missing package declarations. web:lint:test overlays negative controls on the real tsconfig/generated route program and requires source-located boundary diagnostics independently of compiler errors.
  • Production code cannot import test scaffolding. Tests, stories, and generated source are excluded from feature checks.

The checker preserves ordinary useState, useEffect, and useRef use; it does not prove state lifetime, mutation ownership, or runtime behavior. Review still determines whether state belongs in a context, the URL, or a persistent store. Runtime/computed module imports are unsupported at these boundaries; use statically resolvable dependencies so ownership can be checked.

6.7 Driving Ports (Use Cases) — Implicit ​

The backend names "driving ports" as use cases (ScoreJobUseCase). The frontend's equivalent is the hooks: useApplyJobMutation, useDeleteJobMutation, etc. They are the application's driving surface because they are what the user (through clicks) drives. We do not formalize a UseCase interface for them — the React conventions (hook + mutation function) are the de facto driving-port representation.


Posting Availability State ​

The Enrichment context owns PostingAvailability and useCheckAvailabilityMutation; Job Detail composes its public component. Persisted attempt/success evidence, next due and read-time freshness remain server state. The mutation calls the API port, snapshots the tenant-first job detail, marks a check optimistically and rolls back on failure, then invalidates that detail and tenant job lists. It exposes worker-offline failure and retry without erasing older successful evidence or accepted artifacts. A queued acknowledgement is not a verified active posting. Persisted request deferral/retry feedback replaces the queued acknowledgement separately from acquisition attempt/success clocks.