8–9. Cross-Context Integration & Evolution Paths
Cross-context coordination rules — including the §8.2 mutation-invalidation matrix — plus the named-but-not-built cloud-mode adapters (§9). Part of the Frontend Architecture reference.
Read this if you are wiring a mutation's cache invalidations, composing a cross-context UI surface, or weighing what a hosted deployment would add.
The page has two halves. §8 is about today — how a change in one context reaches the others through shared query keys, the SSE invalidation router, and view composition. §9 is about later — the cloud-mode adapters, each named with a concrete trigger (a "fitness function") that says when it is worth building.
8. Cross-Context Integration
8.1 What "Integration" Means in the Frontend
The backend's bounded contexts integrate via domain events on a synchronous in-process bus (backend domain model §6). The frontend's contexts integrate via:
- Shared query keys — a mutation in
apply/invalidates a key defined injobs/because both contexts reference the same registry (§4.1). - The SSE event stream + invalidation router — server-side state change in any context fans out invalidations across every affected frontend context (§7.4).
- Composition in views — the Job Detail route workspace composes components from
scoring/,materials/,apply/,pipeline/. Composition is the only place contexts "see" each other directly; even there, they see only each other's components, never each other's hooks or stores.
8.2 Mutation → Invalidation Map
For synchronous mutations (return value is the final state):
| Mutation | Invalidates |
|---|---|
useDeleteJobMutation({ jobId }) | jobsKeys.lists(tenantId), jobsKeys.detail(tenantId, jobId), dashboardKeys.summary(tenantId) |
useDeleteJobsBulkMutation(filterOrIds) | jobsKeys.lists(tenantId), dashboardKeys.summary(tenantId) (then optimistically removes from current page) |
useRestoreJobMutation({ jobId }) | Same pair, opposite direction |
useHideJobsBulkMutation / useUnhideJobsBulkMutation | jobsKeys.lists(tenantId), dashboardKeys.summary(tenantId) (optimistic list-page patch) |
usePermanentlyDeleteJobsBulkMutation | jobsKeys.lists(tenantId), jobsKeys.details(tenantId), dashboardKeys.summary(tenantId) |
useCorrectScoreMutation({ jobId, correctedScore, reason }) | jobsKeys.detail(tenantId, jobId), jobsKeys.lists(tenantId), dashboardKeys.summary(tenantId) |
useUpdateProfileMutation(body) | profileKeys.profile(tenantId), jobsKeys.lists(tenantId) (scoring depends on profile, but server-side scoring is async — see below), dashboardKeys.summary(tenantId) |
useUpdateSettingsMutation(body) | profileKeys.settings(tenantId) |
useUpdateCredentialMutation(body) | profileKeys.credentials(tenantId) |
useDeleteCredentialMutation(key) | profileKeys.credentials(tenantId) |
useRetryStageMutation({ jobId, stage, runAfter: false }) | jobsKeys.detail(tenantId, jobId), jobsKeys.lists(tenantId) (because currentStage/currentState change) |
useCancelStageMutation({ jobId, stage }) | Same |
useMarkAppliedMutation({ jobId }) | jobsKeys.detail(tenantId, jobId), jobsKeys.lists(tenantId), dashboardKeys.summary(tenantId) |
useMarkSkippedMutation({ jobId }) | Same |
useRepeatApplicationOverrideMutation({ jobId }) | applyReviewKeys.queue(tenantId), jobsKeys.detail(tenantId, jobId), job lists, dashboard summary, and outcome reads so every related surface refreshes its evidence and audit state |
useRunPipelineStagesMutation({ stages }) without apply | jobsKeys.lists(tenantId), dashboardKeys.summary(tenantId), workflowRunsKeys.lists(tenantId), applyRunsKeys.lists(tenantId) on settle after the HTTP 200 worker action results return |
For async (202) mutation responses (the queued work's final state arrives via SSE):
| Mutation | Immediate invalidation | Eventual invalidation (via SSE) |
|---|---|---|
useGenerateMaterialsMutation({ jobId }) | jobsKeys.detail(tenantId, jobId) (to show "queued" state) | ResumeApproved → invalidate same + artifactsKeys.lists; MaterialsExhausted → same |
useApplyJobMutation({ jobId }) | jobsKeys.detail(tenantId, jobId), applyRunsKeys.list(tenantId) (to show new run as "starting") | ApplyRunStarted → optimistic cache patch with worker info; ApplicationSubmitted/ApplicationFailed → invalidate jobsKeys.detail, dashboardKeys.summary |
useDryRunApplyMutation({ jobId }) | Same | Same |
useRetryStageMutation({ jobId, stage, runAfter: true }) | jobsKeys.detail(tenantId, jobId) | Same as the async equivalent |
useRescoreCurrentPolicyMutation / useRescoreJobMutation | jobsKeys.lists, dashboardKeys.summary, workflowRunsKeys.lists (queued) | JobScored / ScoreRescoreRequested → invalidate jobsKeys + dashboardKeys |
useRetailorCurrentPolicyMutation / useRetailorJobMutation | jobsKeys.lists, workflowRunsKeys.lists (queued) | TailorRetailorRequested, then ResumeApproved / PdfRendered → invalidate jobsKeys + artifactsKeys |
useCancelWorkflowRunMutation({ runId }) | workflowRunsKeys.detail(tenantId, runId), workflowRunsKeys.lists(tenantId) | WorkflowCanceled / WorkflowTerminated → invalidate workflowRunsKeys + jobsKeys + dashboardKeys |
useRunPipelineStagesMutation({ stages }) when apply queues | jobsKeys.lists(tenantId), dashboardKeys.summary(tenantId), workflowRunsKeys.lists(tenantId), applyRunsKeys.lists(tenantId) on settle after the 202 response | Apply events fan out through the invalidation router; preceding non-apply stage results were already returned synchronously |
8.3 The Hybrid Strategy (resolves §6 question 5)
Decision: Hybrid by mutation type:
- Sync mutations: optimistic updates where applicable (§5.3) +
invalidateQuerieson settle. The user sees instant feedback; the cache reconciles to the server response. Global/batch pipeline stage starts withoutapplyare in this bucket: the API returns HTTP 200 with worker action results, and the mutation uses normal settled invalidation. - Async mutations (202 Accepted): no eager invalidation of "the result." A small immediate invalidation of "the request queued" view (so the user sees "queued" state). The real result arrives via the SSE invalidation router. Global/batch pipeline requests enter this bucket only when the
applydispatch actually queues; earlier non-apply stages in the same request have already completed synchronously. - Default mutation options do not invalidate broadly. Each mutation declares its invalidation set. The reasons:
- "Invalidate everything" thrashes the network.
- "Per-mutation" makes the data dependency explicit at each site.
- "SSE-driven only" loses optimistic feedback.
8.4 Event → Invalidation Map (Full)
| Event | Contexts whose queries invalidate |
|---|---|
JobDiscovered | jobs (lists), dashboard (summary) |
JobUpdated | jobs (lists, detail) |
JobDeleted | jobs (lists, detail), dashboard |
JobRestored | jobs (lists, detail), dashboard |
JobEnriched | jobs (lists, detail), dashboard |
EnrichmentFailed | jobs (lists, detail), dashboard |
JobScored | jobs (lists, detail), dashboard |
ScoreCorrected | jobs (lists, detail), dashboard |
ResumeApproved | jobs (lists, detail), artifacts (lists), dashboard |
ResumeFailed | jobs (lists, detail), dashboard |
CoverLetterGenerated | jobs (lists, detail), artifacts (lists), dashboard |
PdfRendered | jobs (lists, detail), artifacts (lists), dashboard |
MaterialsExhausted | jobs (lists, detail), dashboard |
ResumeTemplateVersionSaved | profile (resume templates), jobs (lists), artifacts (lists) |
ResumeTemplateDefaultChanged | profile (resume templates), jobs (lists), artifacts (lists), apply review |
JobResumeTemplateAssigned | jobs (lists, detail), artifacts (lists), apply review |
ResumeTemplateRefreshCompleted | jobs (lists, detail), artifacts (lists), apply review, dashboard |
ResumeTemplateRefreshFailed | jobs (lists, detail), apply review |
ApplyRunStarted | apply-runs (lists), jobs (lists, detail), dashboard |
ApplyRunEventRecorded | apply-runs (detail) — patched via setQueryData, not invalidated |
ApplicationSubmitted | jobs (lists, detail), apply-runs (lists, detail), dashboard |
ApplicationFailed | jobs (lists, detail), apply-runs (lists, detail), dashboard |
StageStarted | jobs (lists, detail), dashboard, pipeline operations |
StageCompleted | jobs (lists, detail), dashboard, pipeline operations |
StageFailed | jobs (lists, detail), dashboard, pipeline operations |
StageBlocked | jobs (lists, detail), dashboard, pipeline operations |
StageSkipped | jobs (lists, detail), dashboard, pipeline operations |
StageReset | jobs (lists, detail), pipeline operations |
StageCanceled | jobs (lists, detail), dashboard, pipeline operations |
StageExhausted | jobs (lists, detail), dashboard, pipeline operations |
ProfileUpdated | profile (profile) |
ProfileImported | profile (profile) |
TailoringPolicyUpdated | profile (profile), jobs (lists) |
ScoreRescoreRequested | jobs (lists, detail), dashboard |
JobSourceObserved, CanonicalJobIdentityResolved, DuplicateJobLinked, DuplicateJobLinkRejected | jobs (lists, detail) |
DiscoveryRunStarted / Completed / Failed, DiscoveryFeedbackRecorded | dashboard, discovery reads |
SourceLocationCandidateDiscovered / Promoted, SourceRegistryEntryCreated / Updated, SourceStateChanged | discovery reads (source registry / locator) |
PostingContentSnapshotCaptured / Failed, JobActiveStateChanged, ContentDuplicateCandidateDetected | jobs (lists, detail) |
CompensationFactsUpdated | jobs (lists, detail), compensation reads |
EmployerAnalyzed, BulletProvenanceRecorded, TailorRetailorRequested, TailoredArtifactsSuppressed | jobs (lists, detail), artifacts (lists) |
PreparationWorkItemQueued / Started / Completed / Failed | jobs (lists, detail), dashboard, pipeline operations |
PipelineStepQueued / Started / Completed / Failed | workflow-runs (lists, workflow-ID detail), pipeline operations |
ApplySubmitIntended, ApplicationEmailFeedbackIngested | apply-runs, jobs (detail), apply review |
DigestReviewed | digest read model |
WorkflowStarted / Completed / Failed / TimedOut / Terminated | workflow-runs (lists, detail), pipeline operations |
WorkflowCanceled | workflow-runs (lists, detail), pipeline operations, apply review, dashboard |
The runtime DOMAIN_EVENT_TYPES registry is canonical (grouped above where several share an invalidation target). This table is representative; the authoritative registry is the set of per-context handlers.ts files wired through contexts/operations/invalidation-router.ts, and the every-event-has-handler.test.ts parity test (§10.2) guarantees every registered type has a handler. The Python registry is separately parity-checked against the same ordered tuple. A new backend event means a handler in the owning context and a matching row (or grouped entry) here.
usePipelineOperationsQuery adds a deliberate polling complement to this event path. Durable stage, preparation-item, pipeline-step, and workflow changes usually refresh immediately through SSE. Worker heartbeat, active-slot, and Temporal task-queue observations do not emit domain events, so the query also polls every 15 seconds while the selected execution is discovering or draining and every 60 seconds otherwise, with a 10-second stale time and no background polling. This is scoped runtime refresh, not a replacement for SSE.
Beyond the per-event targets above, the router appends activityKeys.lists(tenantId) (the Debug activity feed) to every event's invalidation set — except ApplyRunEventRecorded, which returns early to patch applyRunsKeys.detail via setQueryData (§7.5) and so does not invalidate the activity list.
8.5 Composition Patterns
The cross-context UI surfaces (Job Detail workspace, dashboard activity feed, artifacts grouped by job) follow the same rule: the composer owns layout; each context owns its slice. Concretely:
// views/jobs/JobDetailDrawer.tsx — legacy-named route-workspace composer;
// not a bounded context
export function JobDetailDrawer({ jobId }: { jobId: JobId }) {
const { data: job, isLoading } = useJobDetailQuery(jobId); // operations
if (isLoading || !job) return <Empty title="Loading job." />;
return (
<RouteWorkspace
header={
<>
<JobOverview job={job} /> {/* views/jobs (this view) */}
<JobActions jobId={jobId}/> {/* contexts/pipeline composer */}
</>
}
inspector={
<>
<StageTimeline stages={job.stages}/> {/* contexts/pipeline */}
<ApplyHistory jobId={jobId}/> {/* contexts/apply */}
<JobOutcomePanel jobId={jobId}/> {/* contexts/apply */}
<JobAuditHistory jobId={jobId}/> {/* contexts/operations */}
</>
}
>
<CompensationAuditSection job={job}/> {/* contexts/enrichment */}
<EmployerAnalysisPanel analysis={...}/> {/* contexts/materials */}
</RouteWorkspace>
);
}Constraint: the legacy-named JobDetailDrawer file is a route-workspace composer. It imports components from contexts, not their query keys or stores; its direct read hooks remain Operations-owned. The composed UI is a full route workspace with header, ruled content, and inspector, never a detached sheet.
9. Evolution Paths (Cloud-Mode Adapters Named, Not Built)
Each evolution below has a fitness function — a concrete, testable trigger that initiates the swap. We do not build the cloud variant until its trigger fires; we do not make decisions today on calendar dates ("Q4 2026"); we make them on observable conditions.
9.1 SSR / TanStack Start
Local-mode: Vite SPA. Static index.html + JS chunks.
Hosted-mode named adapter: TanStack Start (the SSR framework built on the same router/query primitives).
Why named-not-built:
- Single-user app with cold-start once-per-day usage. SSR pays for itself when many cold loads compete for fast first paint or shareable links that need crawler indexing.
- TanStack Start keeps the same Router and Query primitives when SSR or RSC becomes valuable. The upgrade cost stays low because the architecture is shaped around those primitives.
Fitness function: Trigger when
- p50 cold-load Time-to-Interactive on the dashboard exceeds 1s on a representative network profile (Fast 3G), measured against a hosted deployment, OR
- a feature requires shareable public URLs (e.g., "share this jobs filter view with a recruiter coach"), OR
- SEO becomes a goal (currently no goal).
Bootstrap rule: when this fires, the SPA bootstrap switches to the Start bootstrap in one change. There is no "render both server-side and client-side during transition" period.
9.2 React Server Components (RSC)
Local-mode: Client components only.
Hosted-mode named adapter: RSC under TanStack Start (when Start ships RSC support; today it's experimental). The candidate components are ScoreBreakdown, StageTimeline, the activity feed — render-only, no interactivity.
Why named-not-built:
- RSC reduces client bundle and removes round-trip latency for static panels. JobCtrl's panels are not yet large enough to warrant the added build complexity.
- The architecture (clean separation of read-only renderers from interactive controls) is RSC-shaped already; we will not need to refactor when it lands.
Fitness function: Trigger when the JS bundle gzipped exceeds 500 KB on the largest route, AND TanStack Start RSC is stable.
9.3 AuthProvider for Hosted Auth
Local-mode: LocalSessionAdapter returns LOCAL_TENANT. No <SessionProvider /> is mounted.
Hosted-mode named adapter: JwtSessionAdapter (Auth0 / AWS Cognito — matches the backend domain model's §9 choice). Surfaces: <SessionProvider />, <RequireAuth /> route guard, useSession() hook returning { tenantId, userId, roles, expiresAt }.
Why named-not-built:
- Local app needs no auth.
- The seam —
SessionPort(§6) — already has the right shape. Wiring in JWT-derived values is the implementation.
Fitness function: Trigger when the API is exposed beyond 127.0.0.1. (This is also the trigger for the backend Identity & Access context per backend domain model §9.4.)
9.4 Tenant-Scoped Routing (Multi-Tenant Switcher)
Local-mode: useTenantId() returns LOCAL_TENANT. URL has no tenant segment.
Hosted-mode named adapter: A /t/$tenantId/* route prefix (via TanStack Router's layout routes). <TenantProvider /> reads from the path segment first, JWT default tenant second. A tenant-switcher in the AppShell calls navigate({ to: "/t/$tenantId/...", params: { tenantId: "..." } }).
Why named-not-built:
- Today only one tenant exists.
- Query keys already start with tenant (§4.1), so cache isolation is free. The hosted route layout adds the URL prefix; everything downstream already accepts
tenantIdas input.
Fitness function: Trigger when a single user belongs to more than one tenant.
9.5 Audit-Log Streaming
Local-mode: No audit; the TelemetryPort no-ops.
Hosted-mode named adapter: OpenTelemetryWebAdapter — emits OTLP spans for route navigations, mutation calls, error boundaries; sent to the backend's audit pipeline (backend domain model §9 Audit Log context).
Fitness function: Trigger when SOC2 / GDPR access-log requirements arise. The port exists; the adapter swap is ~50 LOC.
9.6 CDN-Cached Projection Reads
Local-mode: All apiClient.* calls hit 127.0.0.1; cache control is irrelevant.
Hosted-mode named adapter: apps/api sets Cache-Control: private, max-age=10, stale-while-revalidate=60 on projection endpoints. CloudFront or Cloudflare caches per-tenant per-query. The frontend's TanStack Query layer remains the same; the adapter (the API itself) benefits from edge caching.
Why this is in the frontend doc: because the frontend's staleTime defaults (§5.4) interact with the server's cache headers. We document the contract here so API and frontend QA gates can verify both ends.
Fitness function: Trigger when dashboard or jobs-list median latency exceeds 200 ms p50 from the client.
9.7 IndexedDB Persistence for the Query Cache
Local-mode: Query cache lives in memory; refresh re-fetches.
Hosted-mode named adapter: @tanstack/query-sync-storage-persister backed by IndexedDB (via the StoragePort's IDB binding) for instant warm starts.
Fitness function: Trigger when average session duration >5 min and p95 cold-load TTI exceeds 800 ms. Both must hold; persistence has a cost (cache validity bugs become harder to reason about).
9.8 WebSocket EventStreamPort
Local-mode: SseEventStreamAdapter.
Hosted-mode named adapter: WebSocketEventStreamAdapter (same port interface; different transport).
Fitness function: Trigger when SSE proves to drop connections behind common reverse proxies (CDN observation), OR the frontend needs to send messages over the same channel (interactive worker control).
9.9 Summary Table
| Concern | Local-mode | Hosted-mode named adapter | Fitness function |
|---|---|---|---|
| App shell | Vite SPA | TanStack Start (SSR) | p50 cold TTI > 1s on Fast 3G OR shareable URLs needed OR SEO |
| Server components | All client | RSC under TanStack Start | bundle gzipped > 500 KB AND RSC stable |
| Auth | LocalSessionAdapter (LOCAL_TENANT) | JwtSessionAdapter (Auth0/Cognito) | API exposed beyond 127.0.0.1 |
| Tenant routing | implicit | /t/$tenantId/* prefix + switcher | user belongs to > 1 tenant |
| Telemetry / audit | console | OpenTelemetry-Web → OTLP | SOC2/GDPR requirement |
| Edge caching | n/a | server-side cache headers + CDN | dashboard p50 latency > 200 ms |
| Cache persistence | in-memory | IndexedDB-persisted Query | session > 5 min AND cold TTI > 800 ms p95 |
| EventStream | SSE | WebSocket | SSE proxy drops, OR duplex needed |