Skip to content

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:

  1. Shared query keys — a mutation in apply/ invalidates a key defined in jobs/ because both contexts reference the same registry (§4.1).
  2. The SSE event stream + invalidation router — server-side state change in any context fans out invalidations across every affected frontend context (§7.4).
  3. Composition in views — the jobs detail drawer 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):

MutationInvalidates
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 / useUnhideJobsBulkMutationjobsKeys.lists(tenantId), dashboardKeys.summary(tenantId) (optimistic list-page patch)
usePermanentlyDeleteJobsBulkMutationjobsKeys.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
useRunPipelineStagesMutation({ stages }) without applyjobsKeys.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):

MutationImmediate invalidationEventual 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 })SameSame
useRetryStageMutation({ jobId, stage, runAfter: true })jobsKeys.detail(tenantId, jobId)Same as the async equivalent
useRescoreCurrentPolicyMutation / useRescoreJobMutationjobsKeys.lists, dashboardKeys.summary, workflowRunsKeys.lists (queued)JobScored / ScoreRescoreRequested → invalidate jobsKeys + dashboardKeys
useRetailorCurrentPolicyMutation / useRetailorJobMutationjobsKeys.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 queuesjobsKeys.lists(tenantId), dashboardKeys.summary(tenantId), workflowRunsKeys.lists(tenantId), applyRunsKeys.lists(tenantId) on settle after the 202 responseApply 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) + invalidateQueries on settle. The user sees instant feedback; the cache reconciles to the server response. Global/batch pipeline stage starts without apply are 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 apply dispatch 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)

EventContexts whose queries invalidate
JobDiscoveredjobs (lists), dashboard (summary)
JobUpdatedjobs (lists, detail)
JobDeletedjobs (lists, detail), dashboard
JobRestoredjobs (lists, detail), dashboard
JobEnrichedjobs (lists, detail), dashboard
EnrichmentFailedjobs (lists, detail), dashboard
JobScoredjobs (lists, detail), dashboard
ScoreCorrectedjobs (lists, detail), dashboard
ResumeApprovedjobs (lists, detail), artifacts (lists), dashboard
ResumeFailedjobs (lists, detail), dashboard
CoverLetterGeneratedjobs (lists, detail), artifacts (lists), dashboard
PdfRenderedjobs (lists, detail), artifacts (lists), dashboard
MaterialsExhaustedjobs (lists, detail), dashboard
ResumeTemplateVersionSavedprofile (resume templates), jobs (lists), artifacts (lists)
ResumeTemplateDefaultChangedprofile (resume templates), jobs (lists), artifacts (lists), apply review
JobResumeTemplateAssignedjobs (lists, detail), artifacts (lists), apply review
ResumeTemplateRefreshCompletedjobs (lists, detail), artifacts (lists), apply review, dashboard
ResumeTemplateRefreshFailedjobs (lists, detail), apply review
ApplyRunStartedapply-runs (lists), jobs (lists, detail), dashboard
ApplyRunEventRecordedapply-runs (detail) — patched via setQueryData, not invalidated
ApplicationSubmittedjobs (lists, detail), apply-runs (lists, detail), dashboard
ApplicationFailedjobs (lists, detail), apply-runs (lists, detail), dashboard
StageStartedjobs (lists, detail)
StageCompletedjobs (lists, detail), dashboard
StageFailedjobs (lists, detail), dashboard
StageBlockedjobs (lists, detail), dashboard
StageSkippedjobs (lists, detail), dashboard
StageResetjobs (lists, detail)
StageCanceledjobs (lists, detail)
StageExhaustedjobs (lists, detail), dashboard
ProfileUpdatedprofile (profile)
ProfileImportedprofile (profile)
TailoringPolicyUpdatedprofile (profile), jobs (lists)
ScoreRescoreRequestedjobs (lists, detail), dashboard
JobSourceObserved, CanonicalJobIdentityResolved, DuplicateJobLinked, DuplicateJobLinkRejectedjobs (lists, detail)
DiscoveryRunStarted / Completed / Failed, DiscoveryFeedbackRecordeddashboard, discovery reads
SourceLocationCandidateDiscovered / Promoted, SourceRegistryEntryCreated / Updated, SourceStateChangeddiscovery reads (source registry / locator)
PostingContentSnapshotCaptured / Failed, JobActiveStateChanged, ContentDuplicateCandidateDetectedjobs (lists, detail)
CompensationFactsUpdatedjobs (lists, detail), compensation reads
EmployerAnalyzed, BulletProvenanceRecorded, TailorRetailorRequested, TailoredArtifactsSuppressedjobs (lists, detail), artifacts (lists)
PreparationWorkItemQueued / Started / Completed / Failedjobs (lists, detail), dashboard
ApplySubmitIntended, ApplicationEmailFeedbackIngestedapply-runs, jobs (detail), apply review
DigestRevieweddigest read model
WorkflowStarted / Completed / Failed / Canceled / TimedOut / Terminatedworkflow-runs (lists, detail), jobs (lists, detail), dashboard

The DomainEventUnion has 69 arms today (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 one of the 69 has a handler. A new backend event means a handler in the owning context and a matching row (or grouped entry) here.

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 (jobs drawer, dashboard activity feed, artifacts grouped by job) follow the same rule: the composer owns layout; each context owns its slice. Concretely:

tsx
// views/jobs/JobDetailDrawer.tsx — view composer; not a bounded context
export function JobDetailDrawer({ jobId }: { jobId: JobId }) {
  const { data: job, isLoading } = useJobDetailQuery(jobId);   // operations
  if (isLoading || !job) return <DrawerSkeleton />;

  return (
    <Sheet open onOpenChange={(open) => !open && navigate({ to: "/jobs" })}>
      <SheetContent side="right">
        <JobOverview job={job} />                {/* views/jobs (this view) */}
        <JobActions jobId={jobId}/>              {/* contexts/pipeline composer */}
        <CompensationAuditSection job={job}/>    {/* contexts/enrichment */}
        <StageTimeline stages={job.stages}/>     {/* contexts/pipeline */}
        <EmployerAnalysisPanel analysis={...}/>  {/* contexts/materials */}
        <ApplyHistory jobId={jobId}/>            {/* contexts/apply */}
        <JobOutcomePanel jobId={jobId}/>         {/* contexts/apply */}
        <JobAuditHistory jobId={jobId}/>         {/* contexts/operations */}
      </SheetContent>
    </Sheet>
  );
}

Constraint: the drawer file (a view composer) imports components from contexts. It does not import their hooks, query keys, or stores. The components encapsulate their own data dependencies. The drawer's only direct hook call is useJobDetailQuery from Operations — the view's read side.


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 tenantId as 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

ConcernLocal-modeHosted-mode named adapterFitness function
App shellVite SPATanStack Start (SSR)p50 cold TTI > 1s on Fast 3G OR shareable URLs needed OR SEO
Server componentsAll clientRSC under TanStack Startbundle gzipped > 500 KB AND RSC stable
AuthLocalSessionAdapter (LOCAL_TENANT)JwtSessionAdapter (Auth0/Cognito)API exposed beyond 127.0.0.1
Tenant routingimplicit/t/$tenantId/* prefix + switcheruser belongs to > 1 tenant
Telemetry / auditconsoleOpenTelemetry-Web → OTLPSOC2/GDPR requirement
Edge cachingn/aserver-side cache headers + CDNdashboard p50 latency > 200 ms
Cache persistencein-memoryIndexedDB-persisted Querysession > 5 min AND cold TTI > 800 ms p95
EventStreamSSEWebSocketSSE proxy drops, OR duplex needed

Documentation screenshots and examples use synthetic data unless noted.