Apply Feedback & Projections
Domain writes become durable events and canonical rows; projection builders turn them into stable reads for the API and web app. Apply decisions and outcomes use the same pattern, with extra safeguards around approval, submission, and private feedback.
Read this if you are changing Apply Review, outcomes, analytics, contacts, outreach, or any projection-backed list/detail view.
At A Glance
The read path never reconstructs domain state from raw events during a request. It serves precomputed projections with authorized canonical detail overlays, including the latest accepted interview prep. SSE tells the client which reads to refetch or safely patch.
Apply Review And Outcome Feedback
Approval And At-Most-Once Submission
With applyApprovalRequired enabled (the default), the Python launcher checks the latest approve_submit decision inside the atomic claim transaction. A UI, API, or RPC caller cannot bypass that committed decision. Dry-run claims do not need approval because browser-layer guards block submission.
Model-driven browser runs are transport-locked and final browser submit remains manual. For an exact-approved email candidate, the saga rechecks the active capability and records ApplySubmitIntended immediately before invoking the owned Gmail sender. If the run dies after that checkpoint without a terminal result, or the provider raises with an ambiguous outcome, JobCtrl parks it in needs_verification instead of retrying. A run that never reached submit intent can return safely to pending.
Live submission is an explicit trust boundary
Turning off applyApprovalRequired removes the claim-time review gate, but it does not grant browser-submit authority or bypass the owned email sender's exact recipient/attachment approval. The Preferences UI keeps that state visibly warned.
Resume Review Drafts
Apply Review edits are a feedback layer, not in-place mutations of an accepted Materials generation. The API stores revisions, line deltas, comment threads, replies, and feedback signals in resume_review_* and tailoring_feedback_signals tables.
Promotion follows three steps:
- validate and render the saved draft;
- write a new
job_materialsgeneration with replacement HTML/PDF artifacts and layout boxes; - mark unresolved comments as residual after acceptance.
The previous accepted artifact stays visible until step 2 succeeds. A failed validation or render therefore cannot destroy reviewable materials.
Outcomes And Gmail Suggestions
application_outcomes stores reviewed manual or suggestion-derived outcomes. Interview reflections may link to the prep generation they followed. Notes stay in that local table; events carry presence flags and safe references, not note text.
Manual outcomes, prep-linked reflections and user-attested Outreach send logs use required UUID request identities. An immediate SQLite writer transaction resolves canonical target authority, checks the existing receipt, and only then performs fresh-action validation. The event's internal idempotency_key has a versioned tenant/operation/target/request prefix and a digest binding the validated request, accepted fact and event fields. This SQLite metadata is not part of the streamed event payload; the payload carries only the safe scope and fact reference. The digest may reflect private notes and belongs to the same private database boundary as those notes. No schema extension or separate receipt table is needed in exact v15.
Fact, event and receipt commit together. Independent writers serialize before prefix lookup; rollback removes all three. A malformed or duplicate prefix fails closed. Nested calls retain the outer transaction's ownership, including the internal first-offer milestone. Equal replay joins the recorded fact ID and returns its original IDs/timestamps before mutable gates. Changed payloads, accepted facts or event bindings fail closed with a safe conflict. The receipt fact ID must agree with the outcomeId or sendLogId recorded on that same domain event; an equal-payload fact from another action cannot substitute for the original result. An omitted occurrence timestamp remains distinct from an explicitly supplied one in the fingerprint.
Outreach send-log responses contain the immutable send log only. Current thread, review and reminder state comes from the existing read path. Web mutations reconcile only the optimistic log associated with that request and invalidate current reads; they never install a historical thread response. Both optimistic insertion and settlement check the initiating thread ID against the current cached thread. A replacement thread under the same contact key stays unchanged, including when it replaces the cache while query cancellation is pending. Form retry payloads live only in component memory, with explicit new-action identity after acknowledgement or a deliberate user choice. No external send or suggestion terminal-decision authority is introduced.
The Gmail feedback scanner searches bounded post-application windows using known application anchors. It reads a body only after metadata reaches the link-confidence threshold, then stores evidence locally and proposes a classification. The user accepts, corrects, or declines the suggestion.
Raw mail bodies never enter events, broad projections, logs, telemetry, or the scan API response.
Interview Preparation And Notes
The public catalog is an installed immutable asset served directly by the TypeScript API. Its global list/detail reads require no SQLite, job, live worker, or provider. It is authored guidance, not a projection over personal facts and not a second evidence store.
Job detail's interviewPrep remains the latest accepted canonical generation, including while a replacement is pending or failed. The detail handler reads that canonical row independently of projection lag and the history page, even when many later attempts failed. Python and TypeScript project the same nullable generationContext and per-item questionMetadata. Retained card snapshots, evidence excerpts, and input bindings describe the original generation; reads must not replace them with current profile prose. Unbound legacy rows stay explicitly legacy. A history read includes accepted, superseded, and failed attempts instead of pretending every attempt became the active generation.
Stale diagnostics compare the recorded catalog/profile/job/employer-analysis/ approved-material bindings with current canonical inputs. They are a read-side annotation, not a mutation of prep history or an automatic regeneration. A stale or missing fit report cannot authorize personal factual evidence.
Independent per-question note reads use canonical tenant/job/question revisions. Latest reads and append-only note history stay separate from prep-item lifecycle. Expected-revision saves preserve newer edits on conflict; generated metadata cannot confer passed-audit status on a user edit. If an existing origin was deleted, an ordinary edit without origin claims detaches the new revision to independent provenance while retaining the old revision's original binding. It does not bind the note to a newer prep generation. Notes and relevant source excerpts are private authorized detail data. They never enter broad lists, curated audit timelines, SSE frames, or telemetry.
InterviewPrepGenerated, InterviewPrepFailed, and InterviewQuestionNoteSaved signal which tenant/job/generation/question reads need reconciliation. They carry IDs, versions, times, and counts, not outline, note, evidence, or binding text. The frontend refetches through Operations and keeps the accepted prep and unsaved form draft through failure. Post-interview reflections retain the separate Apply outcome boundary.
See Materials for authority and generation rules and the complete API contract for history, pagination, and conflict responses.
Projection Catalog
| Projection | Main read responsibility |
|---|---|
job_list_projections | One compact row per job: stage, score, materials, apply state, template, and policy metadata. |
job_detail_projections | Description, stage timeline, employer/requirement audit, compensation, accepted interview prep, and curated history. |
dashboard_projections | Counts, funnel, source/score distribution, and outcome-conversion counts. |
artifact_list_projections | Registered artifacts and their tailoring explanation. |
evidence_usage_projections | Profile evidence inverted into resume, requirement, and coverage usage/gaps. |
apply_run_projections | Apply-run context and event timeline. |
workflow_run_projections | Status, input summary, failure cause, and lifecycle timeline for every Temporal workflow type. |
pipeline_step_projections | Attempt-aware execution-owned source planning/family, reconciliation, fan-out, backlog-sweep, and PDF lifecycle. |
source_quality_stats | Rolling source-health facts used by discovery and the dashboard. |
contact_projections | Contact references, counts, and provenance metadata—never attribute values. |
contact_research_task_projections | Research status, counts, source outcomes, and candidate provenance/kinds—never candidate values. |
outreach_thread_projections | Draft lifecycle, generation, status, and persisted gate outcome—never draft bodies. |
due_follow_up_projections | Scheduled follow-up references and due time; isDue is derived at read time. |
operational_attempt_metrics | Append-only attempt outcomes, failure class, retryability, counts, and duration. |
Job-list SQLite selection
job-list-query.ts owns connection-local SQLite TEMP query tables, independent of the canonical projection owners. /v1/jobs retains its connection for the app lifetime and closes it with the app. A replaced database file gets a new exact-v14 admission and a fresh index; a missing file closes the retained handle. Changes to the retained file's schema/version headers also require readmission. Restart discards all query state. Other read endpoints retain their existing connection lifecycle.
After the normal projection refresh, index refresh, SQL count/order/page selection and selected-row hydration share one SQLite read transaction. Index inputs are tenant/JobId and the scalar portion of the summary mapping: title and company defaults, trimmed location, canonical posting/source-observation overlays, public failure presentation, timestamps and projected compensation. Orphaned projections without a canonical Job are excluded. Index construction does not build DTOs or read availability or resume templates. Those reads occur only for selected IDs, at most pageSize jobs.
Search uses JavaScript Unicode lowercase values, literal SQLite instr, and an indexed three-code-point substring candidate set for queries of at least three code points. Long filters start from tenant/gram postings and intersect their candidate IDs before reading scalar fields for the literal recheck. Zero candidate IDs require no literal field checks. Shorter queries scan scalar values. Fields stay separate, so substrings cannot bridge fields; percent, underscore and backslash are literal characters. Source filters include the same four provenance fields as summaries, while company filters use only the displayed company. Timestamp filters use precomputed Date.parse milliseconds with the existing two-cutoff OR rule. Discovery sorting still compares raw timestamp strings.
During refresh, the shared scalar comparator computes directional ordinal keys for every supported sort, including ICU locale ordering, public state keys, compensation fallbacks and ascending JobId ties in either direction. Requests walk SQLite ordinal indexes for unrestricted and short-query pages; long-query pages order only the posting candidates by their precomputed integer ordinals inside SQLite. No request sorts DTOs. Canonical tenant/stage and score-keyword filters remain on their existing indexed owners. Totals and deep offsets can still visit many scalar rows; page hydration alone is bounded by page size.
Freshness combines PRAGMA data_version for external commits with total_changes() for writes on the retained connection, including rolled-back writes. A changed connection token first compares a SHA-256 fingerprint of the tenant's ordered scalar source rows within the read snapshot. Unchanged source values renew only the marker, avoiding ordinal sorting and trigram replacement for heartbeat or other-tenant commits. Source comparison still reads that tenant's scalar inputs; changed values rebuild its index. The freshness marker and fingerprint live in TEMP SQLite alongside its rows, so a failed build or enclosing transaction rollback restores both. A failed refresh fails the request and cannot serve the old index as fresh. Caller-owned older snapshots never publish a marker reusable after that transaction. Refresh checks the tenant's source snapshot; it does not infer freshness from event cursors or timestamps. jobQueryMetrics reports TEMP schema/index initialization time, source-check count/time, rebuild count, indexed rows, rebuild time, selection time and selected rows separately. Rebuild time includes its source check; the cumulative source-check time also includes checks that avoid rebuilding, so the two durations must not be summed. These are work observations, including builds that a later caller rollback may discard.
Cross-Runtime Consistency
Python's ProjectionBuilder and TypeScript's refreshProjections consume their tenant's job_events using independent python:operations_projections:<tenant> and typescript:operations_projections:<tenant> cursors in event_watermarks. Each reads canonical state, rebuilds projections, and advances its cursor in one transaction. SQLite serializes standalone passes from the initial snapshot read; nested passes preserve the caller's transaction. The consistency contract owns replay and failure recovery.
TypeScript exclusively materializes job_detail_projections using its own typescript:job_detail_projections:v1:<tenant> cursor. API startup repairs that family from canonical state; incremental reads fold bounded event batches and Job Detail and Apply Review repair only their requested jobs when necessary and read list metadata with detail evidence in a coherent transaction. Python CLI/workflow consumers obtain dashboard stages directly from canonical rows.
Shared parity fixtures guard the remaining dual-written list, artifact, contact, research, outreach, follow-up, compensation and audit projections. The same fixture continues to verify the surviving TypeScript detail shape. That makes a one-sided column or shape change a test failure instead of a client surprise.
With both development toolchains installed, the disposable mixed-runtime fixture exercises the real builders in both orders, recovers existing open run rows whose terminal events are behind the legacy cursor, and verifies repeated refreshes:
uv --project workers/automation run --no-sync --locked python \
workers/automation/tests/projection_cross_runtime_fixture.pyThe ordinary per-language suites cover tenant isolation and transaction rollback without requiring the other runtime's installed dependencies.
Pipeline Operations Read Model
GET /v1/pipeline/operations composes durable read state with current runtime telemetry; it is not one more aggregate projection table. The durable half is:
workflow_run_projectionsselects the newest Discover execution that is active/draining, otherwise the latest terminal execution;discovery_execution_jobssupplies exact(workflow ID, Temporal run ID)membership inobserved_this_runandexisting_backlog;pipeline_step_projectionssupplies attempt-aware execution-owned orchestration lifecycle;job_stage_statessupplies canonical per-job enrich/score/tailor/cover lifecycle.
The request-time half derives the expected app directory from the configured database path, filters worker_runtime_heartbeats to that resolved database/app-dir identity, selects the task queue named by the newest matching heartbeat, and aggregates fresh schema-valid workers from that queue. It then selects the freshest typed task-queue observation, resolves the bounded safe active inventory, and computes the conservative ETA. This overlay is why the endpoint is a current snapshot, not a historical run reconstruction. No API claim is made about what capacity or queue pressure existed at an earlier timestamp.
The read model keeps three scopes explicit: current execution, that execution's pre-existing-backlog sweep, and global work outside both cohorts. Source-family progress and reconciliation are separate summaries rather than a shared denominator. Per-job stage counts use domain-job units; task-queue backlog uses approximate infrastructure units and is never relabeled as jobs.
Projection failure and telemetry uncertainty degrade independently. Durable step rows can be rebuilt from job_events; stale, invalid, unsupported, or unavailable runtime observations remain typed states. The ETA union likewise returns calibrating, paused, stale, or unavailable instead of fabricating a number when samples, worker freshness, or contention is unknown. Open membership gates the overall ETA only; scoped per-stage and source-family estimates can use already-known backlog.
Sensitive Projection Families
Contacts, Research, And Outreach
Events and broad projections carry IDs, controlled kinds, counts, timestamps, and provenance metadata. Contact names, emails, notes, candidate values, fetched page bodies, draft bodies, gate internals, and claim provenance stay in their canonical tables and are joined only for authorized detail reads.
Research outcomes such as robots_disallowed, rate_limited, and budget_exhausted are first-class provenance. A proposed candidate becomes a contact only after confirmation. Outreach has no send transport: an approved draft can be copied/exported, and a later send log is an explicit user attestation.
Follow-up projections contain reminders only. GET /v1/outreach/follow-ups/due computes isDue from the stored due time and the clock; it never sends or acts.
Evidence, Analytics, And Compensation
The evidence map derives from canonical profile evidence, requirement-fit, bullet-provenance, and coverage rows. It creates no second generation pipeline and uses conservative defaults for older databases missing optional metadata. Reordering profile bullets preserves canonical achievement IDs and their source associations. Material audit lookups prefer canonical evidence over positional compatibility pointers so an earlier usage link continues to identify the same achievement after reordering.
Outcome conversion stores integer counts. The API derives rates and medians at read time using one minimum-sample threshold, so small groups keep counts but return null rates/medians. Analytics remain read-only and cannot alter scoring, ranking, thresholds, or apply eligibility.
Posted compensation and deterministic market estimates are persisted before they are projected. Projection/API reads do not parse salary text or fetch a provider. Events carry only the affected job/section/state markers; raw feeds, credentials, private account state, and local paths stay out.
User-Facing Audit History
Job detail assembles an allow-listed timeline from lifecycle events and append-only review/outcome records. It is an audit explanation, not a debug log: raw event payloads, debug messages, local paths, raw notes, email bodies, and contact values are excluded.
Offer Read Model
Outcomes owns immutable private entity_kind = offer ledger entries in job_events, keyed by tenant and JobId. readOffer folds event order into current terms/decision plus separate immutable histories; it does not infer meaning or write on GET. Each correction names the prior revision/decision, and writes use BEGIN IMMEDIATE plus expected-ID comparisons. If no reviewed offer already exists, the first terms write and one ordinary offer outcome commit atomically. Later decisions do not rewrite milestone counts, Apply approvals, submit evidence or stage rows.
Operations queries invalidate on OfferRecordChanged. Its SSE envelope is redacted to JobId/revision/decision references; general activity and curated audit never expose terms, quotations or correction prose. Offer-specific UI reads the private ledger directly, so an unrelated projection refresh cannot replace a corrected decision with older cached state.
Outreach owns all offer communication generations, claim and quality receipts, source snapshots, approval and manual-send attestations. A persisted offer source context binds the exact terms revision and decision IDs. Read responses mark stale drafts; approval, copy revalidation and send logging refuse them. The prior accepted message remains inspectable after a source correction or failed replacement. No interview-prep artifact participates in offer decisions.