Skip to content

Complete Local TypeScript API Contract ​

The local TypeScript API (the process the web app talks to) is the runnable backend app under apps/api.

Read this if you are wiring a UI feature to the backend, adding or changing a route, or tracing how a web action reaches the Python worker.

It owns product-facing JSON endpoints, reads the local SQLite database, and invokes Python automation through the JSON-RPC 2.0 protocol over a long-lived jobctrl rpc subprocess. It is intentionally local-first and binds to 127.0.0.1 by default. As a DNS-rebinding defense, an onRequest hook rejects any request whose Host header is not a loopback host (127.0.0.1, localhost, or [::1], with an optional port) with 403 forbidden_host; local browser and CLI callers always send a loopback Host, so legitimate access is unaffected. A second guard rejects cross-site mutations: DELETE, PATCH, POST, and PUT requests require either a first-party local web Origin/Referer (the default Vite ports, plus JOBCTRL_WEB_PORT / JOBCTRL_E2E_WEB_PORT) or a valid local capability token on a request without browser origin/fetch metadata. Arbitrary loopback web origins and no-token headerless mutations fail with 403 cross_site_request before any handler runs. When browser fetch metadata is present, unsafe mutations also reject Sec-Fetch-Site: cross-site; same-site is accepted only after the first-party Origin/Referer check has passed, so the default Vite web app on another loopback port still works while foreign browser origins do not. Authenticated browser-extension routes are additive to that model: /v1/extension/* routes still require a loopback Host, but trusted chrome-extension:// origins get route-scoped CORS and may satisfy the mutation origin/fetch-metadata guards with Authorization: Bearer <local capability token>.

API at a glance ​

Each route family below has its own heading on this page. Jump to the area you need:

AreaWhat it covers
Profile and preferencesReading and writing the candidate profile, autosave, and the Preferences / Discovery / Settings control split.
Artifacts and tailoring auditRead-model projections, artifact preview/open routes, resume templates, and canonical tailoring evidence.
Jobs read model and lifecycleScore evidence, requirement-fit, career evidence map, audit history, list filters, and delete / hide / restore routes.
Feedback learning and policy historyNormalized scoring-keyword aggregation, privacy-safe recommendations and evidence, explicit review, Materials policy history, and append-only rollback.
Dashboard, analytics, and operational metricsGET /v1/dashboard/summary, GET /v1/analytics/outcomes, source health, and operational attempt counters.
Pipeline operations snapshotGET /v1/pipeline/operations: immutable Discover execution lineage, scoped backlog, orchestration steps, runtime capacity, active work, task-queue observations, and typed ETA.
Discovery controlsSource registry, locator / quarantine / manual-capture queues, and feedback endpoints.
CompensationPosted-salary and reported-market inspection routes plus the refresh triggers.
Workflow runs/v1/workflow-runs list, detail, and cancel across every workflow type.
Profile resume previewThe baseline profile resume HTML and PDF preview endpoints.
Apply review and outcomesReview queue, resume-review drafts, decisions, and bounded Gmail outcome ingestion.
ContactsThe /v1/contacts routes: list (with jobId/employer filter), detail, create, update, delete, CSV/vCard import, the supervised-research run / list / detail / confirm-candidate routes, the outreach draft read / generate / revise / approve / reject routes, and the Phase 4 send-log / follow-up (schedule / complete / dismiss) / due-follow-ups routes.
Pipeline and preparation actionsGlobal and per-job stage runs, rescore / re-tailor, retry, and per-job actions.
Discovery target searchHow Discover honors the profile Target search and location / work-model filters.
Worker runtime and healthGET /v1/health, the worker-readiness gate, and JSON-RPC transport hardening.
Settings and credentials/v1/settings, /v1/providers/models, extension pairing token routes, and native-store-backed /v1/credentials.
Server-Sent EventsThe GET /v1/events/stream realtime contract.

Profile and preferences ​

GET /v1/profile and PATCH /v1/profile use the normalized Candidate Profile tables in jobctrl.db as the source of truth. When the profile tables are empty, GET /v1/profile returns an empty profile with default rendering settings. Explicit profile saves and resume-PDF imports create or update the SQLite rows. Profile reads optionally include legacyEvidenceReview: { status: "review_required", count: number }. Each affected achievement retains all saved fields, including user_confirmed, and adds read-computed confirmation_review_required: true; client writes cannot resolve this flag. Evidence Map freshness adds optional confirmationReviewRequired: boolean. Unresolved legacy confirmations produce legacy_evidence_review_required before canonical evidence-based generation. Explicit interview selection returns 400 legacy_evidence_review_required and retains the draft. Review/recovery use the local bounded commands, not a new HTTP write route. LegacyEvidenceReviewRecorded private event payloads are excluded from generic SSE.

Profile reads include profileVersion: number | null. PATCH /v1/profile accepts optional expectedProfileVersion; when supplied, a mismatch returns 409 stale_profile_version and the same transaction writes neither canonical rows nor ProfileUpdated. The web form sends its actual saved base version on every full-profile manual or autosave write. It omits the field only for the first save when profileVersion is null; a conflict keeps the draft for explicit rebase.

POST /v1/profile/required-bullet-suggestions accepts only expectedProfileVersion (positive integer) and optional maximumSuggestions (1–24, default 12). It inspects saved Required experience bullets using one configured LLM call through profile_required_bullet_suggestions. The provider receives only the selected bullet text, role/company context and matching saved achievement evidence; personal, compensation and unrelated profile fields are excluded. The worker checks these inputs against canonical saved rows before calling the provider in the profile accounting lane, subject to daily-spend and profile-lane token admission. Input/response sizes are bounded, but the current agent SDKs cannot enforce a hard per-call output-token or maximum-cost ceiling. Response validation happens after generation and cannot bound provider spend; an in-flight call may cross the remaining daily/lane budget. This route explicitly uses observed-usage admission; target-role suggestions retain their separate hard-ceiling requirement and make no production provider call. Neither inspection nor rejection writes the profile.

A successful response contains ok: true, profileVersion, strategy: "model_v1", modelUsed, truncated, and up to 24 suggestions. modelUsed: true means the configured model completed the inspection, including when it returns no findings. With no inspectable sources, no model is called and modelUsed is false. All grammar, relevance, achievement-framing and evidence findings are model determinations; there are no phrase lists or semantic fallback rules. The prompt asks the model to distinguish a stated outcome from supporting evidence and read the actual linked evidence rather than treating a strength flag as proof. These are coaching goals, not verified guarantees of the free-text advice. Code verifies structure, canonical source binding and automatic cleanup; it does not establish that the model's guidance is factually correct.

Each suggestion contains id, kind (grammar, relevance, achievement_framing, missing_evidence), originalText (at most 2,000 characters), guidance (at most 500), canApply, nullable proposedText, and source. Source fields are sourceId, identityKind (canonical_achievement or snapshot_bullet), excerpt (at most 500), fieldPath, experienceId, experienceTitle, experienceCompany, bulletIndex, and requiredBulletIndex. The model supplies references, kinds, guidance and nullable whitespace proposals; code binds canonical identities and original text. Unknown references, duplicate findings, extra fields and invalid model output fail closed with 502 required_bullet_suggestions_failed. An unavailable dispatcher returns 503 required_bullet_suggestions_unavailable. There is no heuristic fallback. A typed budget denial returns 429 required_bullet_suggestions_budget_exceeded with budgetScope (daily, profile_lane, or both) and reset/Settings guidance. Missing or unauthenticated providers return 503 required_bullet_suggestions_provider_unready with connection guidance. Invalid model output and other provider failures return 502 required_bullet_suggestions_failed with distinct safe retry guidance; raw exception/provider text is excluded. A stale version before or after generation returns 409 stale_profile_version; the SQLite read transaction ends before the call. Malformed saved profile/evidence rows return 422 invalid_saved_profile.

Only a model-returned grammar finding whose proposal equals a safe exact whitespace cleanup has an applicable replacement. Other findings require manual editing. Acceptance uses ordinary PATCH /v1/profile with the saved version fence. Duplicate bullet or pin occurrences are skipped. Ambiguous/reused/blank/overlong achievement IDs, cleanup collisions, or an unsaveable __proto__ entry prevent automatic cleanup. Own-key pin lookup handles prototype-shaped IDs without inherited values.

Before loading source rows, the API bounds profiles to 256 experience entries and 4,096 normalized child rows. Preparation bounds inspection to 512 Required occurrences, each complete source to 8,000 serialized characters and the complete prepared source set to 24,000. Oversized fields, orphan pins and omitted sources report truncated: true; an empty truncated result is not a completed inspection. Output beyond the requested maximum is truncated. These are input/output bounds, not hard provider token or cost ceilings. An unchanged retry can omit the same sources. Provider failures and failed refreshes preserve saved facts and the last reviewed suggestions; a source/version change requires a fresh review. The offline public demo reports this capability unavailable because it has no configured model runtime.

POST /v1/profile/target-role-suggestions accepts the strict object below:

json
{ "expectedProfileVersion": 7, "maximumSuggestions": 3 }

maximumSuggestions defaults to 3, bounded to 1–5. The response includes profileVersion, determinationId, status: pending_confirmation, strategy: model, warnings, role suggestions and preference suggestions. Every suggestion carries shared taxonomy codes, canonical evidence IDs, verbatim citations and a rationale. The worker uses the configured Profile provider, checks the spend preflight, persists the candidate interpretation and fences the profile version again after the call. Provider, budget, schema and citation failures are distinct safe errors; there is no saved-title fallback.

Suggestions do not change the profile. The owner accepts selected fields through the normal version-fenced profile save, supplying acceptedCandidateInterpretationId. The API confirms that exact pending suggestion against the saved profile version in the same transaction. Historical locations and work models remain suggestions until accepted. The offline demo reports this capability unavailable.

Profile-data writes record ProfileUpdated in job_events atomically with the profile version. They never dispatch Score, Tailor or Cover or reset accepted scores/materials. API startup does not replay historical profile-update continuations. Jobs list/detail expose scoreProfileFreshness from the canonical saved profile and score trace; absent source versions remain unknown. Explicit score refresh checks older-profile inputs through a cited LLM score_profile_impact determination. Insignificant edits retain the original score and attach its exact review ID; significant edits run a full score. Failed checks preserve the previous score. New profile versions require a new review; identical inputs reuse the accepted determination. See Scoring. The web Profile, Profile Target Search, Preferences, Discovery, and Settings forms autosave five seconds after the last edit using the same profile/settings mutation paths as the explicit Save buttons; failed validation or mutation errors stay on the local form surface. Profile Target Search owns the canonical user-editable location and work-model filters and persists them through the profile mutation path. The Preferences Tailoring controls section owns generated-material policy such as the adjacent-experience invention toggle, revision-gate minimum fit score, must-have coverage, and revision-attempt limits in the profile tailoring_rules. Verified facts and evidence-backed reframing are baseline non-inventing behavior, along with supported adjacent-experience translation; the Preferences toggle only allows or forbids invented adjacent-experience drafts. Draft adjacent claims require review and are not user-configured as an auto-approval bypass. The Discovery page (/discovery) composes the canonical Profile Target Search with sources, scheduling, quarantine, and capture. Settings General (/settings) owns spend/capacity, scoring, apply runtime, and compensation source policy. Credentials (/settings/credentials), model selection and AI execution policy (/settings/models), and browser capabilities plus extension pairing (/settings/browser) are separate tabs. When VITE_GOOGLE_MAPS_API_KEY is available to the web dev process, the Profile Address field progressively enhances into a Google Maps Places address search. Selecting a Google result updates the existing address, city, state/province, country, and postal-code profile fields; without the key, the field remains a normal editable street-address input.

Artifacts and tailoring audit ​

Read-model endpoints (/v1/dashboard/summary, /v1/jobs, /v1/jobs/:key, /v1/artifacts, /v1/workflow-runs) read from the local *_projections tables maintained by apps/api/src/projections.ts (TS-side mirror) and the Python ProjectionBuilder (workers/automation/src/jobctrl/infrastructure/projections/). Every artifact detail includes determinations: the recorded claim-verification and independent quality envelopes, or an empty array when no verification was recorded. Each envelope is checked against the artifact owner and generation; cover letters expose these receipts as well as resumes.

Both processes refresh projections idempotently via the shared event_watermarks.operations_projections watermark. Artifact detail routes include GET /v1/artifacts/:artifactId for metadata and GET /v1/artifacts/:artifactId/preview.pdf for inline access to registered PDF artifacts. HTML/CSS-rendered resume PDFs also expose GET /v1/artifacts/:artifactId/preview.html, which streams the generated sibling HTML file used by the Plate-backed Apply Review editor. That route is limited to render_format = 'html_pdf' resume PDF artifacts and rejects historical latex_pdf rows with 415; operators can create HTML siblings for approved legacy resume PDFs with jobctrl migrate-resume-html after checking --dry-run, or refresh already-HTML PDFs after renderer CSS changes with --force (a source checkout uses the uv prefix documented in Local Development). PDF pages are rendered in the web client through PDF.js; the retired GET /v1/artifacts/:artifactId/preview/page/:pageNumber.png Poppler route is not exposed. The preview routes serve only known PDF artifact files from the local artifact projection; they return 404 for missing metadata/files and 415 for unsupported artifact/renderer formats. The separate POST /v1/artifacts/:artifactId/open route delegates to the local OS opener; for resume text/PDF artifacts it first runs the resume-template ensure-current path and opens the newest approved same-type artifact when a render-only template refresh succeeds. Every preview and open route only touches a path that is a registered artifact in the projection AND whose realpath stays inside the JobCtrl app directory; a path that resolves outside the app directory (for example a tampered projection row or a symlink escape) is rejected with 403artifact_path_forbidden before any file is streamed or handed to the OS opener. PDF artifact detail responses also include layoutBoxes when the Materials projection has generated resume layout metadata. Boxes use page-relative percent coordinates plus optional resume line numbers. Apply Review passes them into the HTML editor for line/page anchoring; PDFs or artifacts without a layout map return an empty array.

Resume template editing is exposed through local JSON endpoints: GET /v1/resume-templates lists the built-in and saved templates plus the current default, GET /v1/resume-templates/:templateId returns one template, POST /v1/resume-templates saves a style/layout-only template version, PATCH /v1/resume-templates/default selects the default template, and PATCH /v1/jobs/:jobKey/resume-template sets or clears a per-job override. POST /v1/jobs/:jobKey/resume-template/ensure-current lazily creates a render-only materials generation when the latest accepted resume text exists but was rendered with an older effective template. The refreshed resume_pdf is rendered from the resume HTML through the same Playwright HtmlResumePdfAdapter (render_format = 'html_pdf'), so it is a full multi-page document rather than a truncated fallback. The endpoint preserves the existing accepted artifacts and records refresh status (including a failed attempt when the render fails) instead of modifying profile data or replacing reviewable output in place. Tailored resume artifact detail responses include safe tailoring evidence only: verifier-recorded requirement coverage counts and lists, evidence and quality summaries, judge/adversarial-review results, warning-repair status, annotated source-vs-tailored resume changes, high-fit persona prompt/response audit, and model selection metadata. Persona warning cards expose an expandable audit trail for the stored LLM request and structured response behind that judgment. The keywords summary block (planned / covered / missing + counts and coverageRecorded) is derived from the canonical coverage audit (see below), not recomputed from the resume file or the job description at read time; when the generation recorded no coverage the block is honestly empty with coverageRecorded: false. Responses do not expose raw generator prompts, raw profile payloads, or raw job text; annotation and persona prompt snippets are bounded excerpts of the selected source, tailored resume, and adversarial-review request.

The tailoring explanation is served exclusively from canonical projection rows. The read model parses each artifact's own metadata_json projection column for the non-coverage audit fields and attaches the per-bullet provenance, coverage, and voice from their canonical projection columns. There is no sibling-file fallback (an artifact whose own audit metadata is a shell is honestly flagged incomplete rather than synthesised from a neighbouring artifact or a sibling .txt file) and no TypeScript-side keyword recompute. Generation-time coverageAudit counts verifier-declared served requirement IDs for the accepted final lines. coveredBy joins those line anchors; keyword appearance is not proof. voicePass records a model voice determination and its separate final claim-verification and quality determinations. Buzzword-density and structural-variety proxies are removed. Unrecorded audit metadata is absent and labeled incomplete. PDFs bind to their accepted parent generation. Each line anchor records its line ID, evidence IDs, requirement IDs, transform and reason; verifier findings join by line ID. No read-side overlap reconstructs a source.

Jobs read model and lifecycle ​

:key on every job-scoped route is the tenant-scoped stable JobId. A posting URL, retained posting locator, or application URL is accepted only as an external locator, and posting identity resolves first. An application URL resolves only when exactly one job in the tenant owns it as its canonical job_enrichments.application_url value or retained job_application_locators alias; a shared application endpoint resolves to no job and the route returns 404 { ok: false, error: "job_not_found" }. The application URL authority inventory lists the readers of these values.

/v1/jobs and /v1/jobs/:key expose the latest persisted scoring evidence from job_scores as additive read-model fields: scoreBreakdown, scoreKeywords, scoreVersion, scoredAt, scoreTrace, and scoreStaleness. scoreTrace includes policy-facing metadata such as scoring policy version, rubric version, calibration adjustment, and policy anchor counts without exposing raw policy anchor payloads. scoreStaleness reports unresolved stale markers, including the stale reason, current and target policy versions, marked time, and whether the score is waiting for explicit rescore reset. scoreReasoning remains on the wire as a compact compatibility summary. scoreAnalysisFreshness separately compares the latest requirement-fit report's analysis generation and score version with current canonical analysis and score rows. Its status is no_analysis, not_assessed, outdated, or current; the current and assessed generation and score-version fields remain visible for audit. This read-time comparison covers older mismatched rows without changing the scoring-policy stale marker or triggering a model run. /v1/jobs/:key also exposes requirementFitReport when the latest score has canonical requirement-level assessments. The report is projected from job_requirement_fit_reports and ordered job_requirement_fit_items rows and shows the requirement weights, match status, score contribution, and tailoring directive that explain the resolved fit score. It is null for jobs without a current generation-bound assessment, including legacy scores and scores assessed against an earlier employer analysis. GET /v1/evidence-map returns the career evidence map projected from the same canonical sources already used by scoring and materials audit: profile achievement evidence and skills, latest bullet provenance, requirement-fit items, and generation-time artifact coverage audits. The response contains entries[] for achievement/skill evidence, attached resume-bullet, requirement-fit, and coverage usages, plus gaps[] for missing skills and missing/blocked/transferable requirements. It never recomputes claims from job descriptions at read time and never introduces a separate preparation pipeline. /v1/jobs/:key also returns auditHistory[], a user-facing timeline assembled from allow-listed job_events entries plus append-only apply review and outcome feedback records. The timeline summarizes discovery, enrichment, scoring, materials, pipeline, apply, outcome, and job visibility changes without returning raw event payloads, debug messages, local paths, raw notes, or email body text. Job summaries also include source provenance. discoverySource is the observed source registry id where the job was found and falls back to the discovery strategy/source pair when canonical source identity is absent. postingSource and postingSourceUrl come from canonical identity evidence when a broad-board result points at a known ATS or employer-owned posting. The jobs list accepts minFitScore, maxFitScore, discoveredSince, and scoredSince query parameters, plus applyStatus=applied for jobs with an actual applied outcome (applied_at present or apply status applied). The timestamp filters accept ISO UTC timestamps and are used by digest deep links; when discoveredSince and scoredSince are both present, the jobs list matches rows where either timestamp is at or after its threshold. Active filters and sort remain URL state in the web app. The same score and applied-outcome filters are accepted by all-matching bulk job mutations. Timestamp comparisons use JavaScript millisecond precision and normalize timezone offsets and fractional notation. Missing or invalid stored timestamps do not match their threshold. Lists and all-matching bulk mutations share this comparison before counting and pagination, using precomputed millisecond values in the rebuildable query index. The list and all-matching bulk filters accept optional stages membership on projected currentStage. Allowed values are discover, enrich, score, tailor, cover, and apply; they do not select currentSubstage. Current projections group preparation under Discover or Apply, so other stage values can legitimately match no rows. The JSON body form is an array. Query strings accept comma serialization, repeated values, or repeated comma groups: stages=discover,apply and stages=discover&stages=apply are equivalent. Whitespace is trimmed. At most six input values are accepted before deduplication; duplicate values retain their first occurrence. Invalid members, malformed values, and arrays longer than six are ignored as a whole, matching the existing optional-filter fallback convention. They fall back to scalar stage, if supplied. Valid stages overrides scalar stage; [] (query stages=) explicitly removes any stage restriction. Omission or explicit TypeScript stages: undefined preserves scalar behavior. Stage membership is ORed and then intersected with every other list filter before count, global sorting, and pagination. All-matching bulk selection uses the same membership and also accepts exact normalizedScoreKeyword.

pagination.total is the globally filtered count, pages is max(1, ceil(total / pageSize)), and page is clamped to pages. Empty results return items: [], total: 0, pages: 1, and page: 1. Sort ties use ascending canonical job ID regardless of the requested direction. All searches and sorts use SQLite count/order/page selection over summary-equivalent scalar values. Index refresh, totals and the clamped page, including selected-summary joins, share one SQLite transaction. Only the returned IDs receive DTO hydration, availability and template reads. The app-owned TEMP query index stores Unicode search values and compatible sort ordinals, preserves exact-v14 admission, and rebuilds after connection close or database replacement. A failed refresh fails the request without publishing a fresh index marker. The architecture reference owns lifecycle and source-snapshot details. Text sorting lowercases values before locale comparison, including searched lists; equal values retain ascending job IDs in either direction. State sorting uses the public currentState, so stored exhaustion shares the failed rank. Discovery sorting uses locale comparison of the original timestamp strings, matching the browser merger even with mixed fractional or offset notation; it does not compare normalized instants. Missing values sort first ascending and last descending, with ascending job-ID ties in either direction. Source/company filters use literal substring matching after Unicode lowercasing; percent, underscore, and backslash are ordinary characters. Source matches the rendered source, discovery source, posting source, or posting URL. Lists and all-matching bulk selection apply these same rules before counting or slicing. Response filter.stages is the effective deduplicated stage array, or [] for unrestricted membership. The legacy filter.stage still echoes scalar stage (or an empty string), even when overridden by stages. Other filter and sort metadata retain their existing meanings.

GET /v1/digest returns the local daily digest read model for the dashboard and CLI. It composes projection-backed counts for new matches, blocked sources, apply-review materials, stale scores, pending approvals, derived follow-ups due, and budget status. Passive reads never advance digest_state.last_acknowledged_at; only the explicit acknowledge flow may move the watermark. The digest uses a timestamp watermark and a UTC follow-up cutoff, resolving the plan's local-vs-UTC day-boundary inconsistency in favor of UTC. Match dates and follow-up outcomes are compared as instants at millisecond precision, including stored offsets. Each digest job must belong to the local tenant's canonical jobs table. blockedSources.sources[] keeps its stable sourceId and may include an optional registry-resolved displayName, which clients prefer for presentation. POST /v1/digest/acknowledge accepts an optional acknowledgedAt ISO timestamp, advances the watermark monotonically, and returns the updated digest_state. Independent Python and API writers serialize before reading the prior watermark; the watermark and DigestReviewed event commit together. An acknowledgement within a caller-owned transaction leaves its commit or rollback to that caller. The event lets the SSE invalidation router refresh the dashboard digest query without any external delivery channel. POST /v1/jobs/:key/score-correction writes a new corrected job_scores version, records ScoreCorrected, and updates the versioned scoring_policies table with a correction-derived calibration anchor. It mirrors the Python CorrectScoreUseCase policy update path because this TypeScript API mutation writes directly to SQLite instead of crossing the Python JSON-RPC boundary. When the policy version changes, the API also marks comparable latest uncorrected scores stale in job_score_staleness; corrected score versions are not marked stale. POST /v1/scoring/stale-scores/actions/reset-for-rescore resets the score stage to pending for an explicit rescore while retaining the prior score and its unresolved stale markers. A completed replacement resolves only the source-matching policy markers observed before its inference, in the same transaction as the score, determination binding and requirement-fit report. Failed or canceled work and score reuse leave the markers unresolved; a later invalidation is not resolved by an earlier in-flight assessment. The body accepts jobKeys for selected stale scores or an empty list for all active stale scores, plus optional limit for bounded resets of distinct eligible jobs. Hidden, deleted, closed, expired, removed, and location-incompatible jobs are excluded, including when explicitly selected; they do not consume the limit. jobKeys and count report only jobs actually reset. Queued, running, and pending work stays untouched; skippedActiveJobKeys identifies jobs owned by ongoing work, while alreadyPendingJobKeys identifies work already waiting. After active work finishes, retry the reset only if the score remains stale. The backend command that consumes those reset jobs is jobctrl score --rescore or the batch API action with stage: "score" and rescore: true. The jobs list and all-matching bulk filters accept optional jobStates values active, deleted, and hidden. Multiple values have OR semantics and are applied before count and pagination. The displayed Job state is derived from tombstones: hidden means an active hide tombstone and takes precedence when both tombstones exist; deleted means a delete tombstone without an active hide tombstone; otherwise the displayed state is Active. A closed or otherwise unavailable posting inspected through a legacy deleted=closed link therefore still displays Active. Filter eligibility is stricter: jobStates=active requires no delete or hide tombstone and excludes postings whose active-state verification marked them closed, expired, removed, or location-incompatible. When jobStates is present it takes precedence over the legacy deleted filter. The legacy deleted filter still accepts active, closed, deleted, hidden, or all when jobStates is absent, preserving old URLs and saved views. Closed jobs are non-deleted postings whose active-state verification marked them unavailable, expired, removed, or location-incompatible; they are excluded from both jobStates=active and legacy deleted=active lists, dashboard totals, and worker queues while remaining inspectable through old links. Deleted jobs are temporary removals: discovery clears the delete tombstone when the same posting is observed again. Hidden jobs use a separate jobctrl_hidden_jobs tombstone and remain suppressed from legacy active/deleted/closed lists, dashboard totals, artifacts, workflow runs, and activity until an unhide mutation clears that hidden tombstone. Soft delete and restore are exposed at DELETE /v1/jobs/:key and POST /v1/jobs/:key/restore, plus bulk POST /v1/jobs/bulk-delete and POST /v1/jobs/bulk-restore. The API exposes bulk hide/unhide routes at POST /v1/jobs/bulk-hide and POST /v1/jobs/bulk-unhide, plus single-job POST /v1/jobs/:key/hide and POST /v1/jobs/:key/unhide. Permanent delete is available at POST /v1/jobs/bulk-delete-permanent and DELETE /v1/jobs/:key/permanent; it removes the job row plus job-scoped state, projection rows, and delete/hide tombstones. It does not write a new suppression record, so rediscovery can add the same posting again later.

The Jobs UI's removed-selection operation has an additional review fence. GET /v1/jobs/reviewable-list accepts the normal list query and returns the page with an observationId, or null when review capacity is unavailable. The observation freezes the matching removed cohort and expires after one minute. POST /v1/jobs/permanent-delete-selection accepts observationId, selections (normal bulk selectors), and, for explicit keys, expected entries containing jobKey, deletedAt, and hiddenAt. It returns an exact canonical jobKeys list and a single-use selectionId. More than 5,000 matching removed jobs cannot be selected all at once; explicit visible-page selections remain available. Explicit reviewed selections use canonical Job IDs from the observed list; external URL aliases are refused for this operation so a later identity rebinding cannot redirect a confirmation to a different job. Unguarded compatibility endpoints retain their existing locator behavior.

The confirmed bulk permanent-delete request supplies allMatching: false, those exact jobKeys, and selectionId. If the selection is missing, expired, changed, or its recorded lifecycle revision has changed, the API returns 409 permanent_delete_selection_conflict without deleting any member. A client must refresh and select again. Unrelated workspace writes do not invalidate the selection. A receipt is process-local and single-use, including when refused. Existing direct permanent-delete requests without selectionId and the explicitly authorized job-data purge retain their existing behavior. POST /v1/jobs/bulk-retry-failed accepts the same selected-job or all-matching bulk mutation body and resets each active failed job's failed stage to pending; non-failed selected jobs are ignored. A persisted legacy exhausted row is exposed as state: "failed" with failureReason: "attempt_budget_exhausted", remains retryable, and has its attempt count reset atomically by this action. List and all-matching bulk state=failed filters include both persisted failed and exhausted rows. The legacy state=exhausted selector restricts membership to persisted exhaustion, including when combined with q or stages. Public summaries keep the failed-state presentation and failure reason above.

POST /v1/jobs/import-url accepts the strict body { url: string, awaitResult?: boolean }. The URL must be public HTTP(S); credential-bearing, private, loopback, unresolved and unsafe redirect destinations fail closed. With awaitResult: false, the API returns 202 and { ok: true, status: "started", workflowId, executionId } after the worker has acknowledged the durable import. The Jobs UI uses this mode. Omitting the flag or setting it to true retains the awaited 200 response for existing callers.

GET /v1/jobs/import-url/:workflowId/:executionId reads only that exact local import history and returns 200 with status: "pending" while it is running. It never waits for running work and cannot start an import. The worker validates the tenant, expected app/database, workflow type, logical workflow ID and exact Temporal execution ID. It refuses foreign or mismatched histories and does not follow retries or a later run. A temporary read error returns an unavailable response; the UI keeps the acknowledged import pending and retries the read. Terminal failed, canceled, timed_out and terminated responses include a safe message and may include a closed interpretation failureCode; none is reported as an imported job.

A completed status read and an awaited POST return one of:

  • { ok: true, status: "imported", jobKey, importedAt, alreadyExisted } after deterministic page extraction, canonical Discovery ingestion, and snapshot capture; or
  • { ok: true, status: "manual_capture_required", itemId, reason } when a login, bot control, rate limit, paywall, or ambiguous page requires user-provided content.

The fallback appends or reopens a pending Manual Capture item and writes no placeholder Job. Replaying a URL already owned by a Job returns its stable JobId without fetching it again. Remote page fetching remains in the Python worker, not the TypeScript request process.

Feedback learning and policy history ​

GET /v1/scoring/keywords returns { ok: true, keywords }. Each item contains normalizedKeyword, displayKeyword, the positive current scoreVersion, and positive jobCount. GET /v1/jobs?normalizedScoreKeyword=<key> performs an exact match against that normalized key on each job's current score version; historical score keywords are not mixed into either result.

GET /v1/learning/recommendations?page=<n>&pageSize=<1..100> returns a paginated list of pending Materials tailoring_rule recommendations. Each summary exposes only an allowlisted rule key/value, derivation and evaluation fixture versions, signal kind, threshold/observed counts, bounded supporting/contradicting/tombstone counts, the explicit sample_gated_no_population_inference confidence limit, and timestamps. It never exposes feedback free text, raw notes, resumes, job descriptions, prompts, mail bodies, or model output.

GET /v1/learning/recommendations/:recommendationId/evidence returns paginated structured evidence links: signal ID, supporting/contradicting role, source revision, stable JobId, and recorded time. An invalid canonical recommendation ID returns 400; an unknown tenant-scoped recommendation returns 404.

POST /v1/learning/recommendations/:recommendationId/reviews accepts exactly { decision: "accepted" | "rejected" }. Acceptance creates a new versioned Materials tailoring-policy revision and returns its positive policyVersion. Rejection records the review and returns policyVersion: null; it changes no policy. An identical structured replay returns the same idempotent success; conflicting or stale review attempts return a bounded error rather than applying behavior twice.

GET /v1/learning/policies/materials?page=<n>&pageSize=<1..100> returns allowlisted current/superseded revision summaries: version, learned rules, recommendation/review provenance when present, rollback provenance when present, and creation time. For new restores, history also exposes restoreCommandId and restoredFromCurrentVersion.

POST /v1/learning/policies/materials/rollbacks accepts exactly { targetVersion, expectedCurrentVersion, commandId, reasonCode: "user_requested" }. Versions are positive integers, and commandId is a canonical lowercase UUIDv4. The tenant-scoped target must exist and precede the reviewed current revision. A new command appends one revision only if the current version still equals expectedCurrentVersion. A changed head returns 409 with tailoring_policy_stale_review; refresh and review history before sending a new command ID. Reusing an accepted ID with a different payload returns 409 with tailoring_policy_command_conflict. Invalid bodies return 400, while invalid or foreign targets are refused without exposing another tenant.

The success response contains the accepted version, rollbackOfVersion, rollbackReasonCode, expectedCurrentVersion, commandId, createdAt, learnedRules, replayed, and currentVersion, plus the existing context and provenance fields. status is current only when the accepted version matched currentVersion at the transaction; otherwise it is superseded. The same command and reviewed payload return the original accepted revision and timestamp after response loss, restart, or intervening writes, with the head observed by that retry. The receipt and revision are committed atomically. Neither review nor rollback starts scoring, tailoring, Apply, workflow, or artifact work.

Dashboard, analytics, and operational metrics ​

/v1/dashboard/summary includes sourceHealth[], sourced from source_quality_stats. The projection is rebuilt from discovery run, source-observation, duplicate, content snapshot, enrichment, apply-URL, and active-state events and user discovery feedback. It is the read-side signal the web dashboard uses for source health. The same response also includes optional displayName on each source-health entry. Historical board keys keep their identity while displaying their current JobStreaming provider name; renaming does not reset health, failure, or quarantine data. It also includes operationalMetrics, sourced from operational_attempt_metrics, plus per-source operational/scrape/retryable failure counts. These counters use structured stage/source/apply attempt rows, not label math over free-text event messages. Its work object classifies current queued/running job-stage work from canonical job_stage_states: active counts queued work plus running work not classified as stuck, while stuck and the bounded stuckItems[] list identify running stages whose updated_at/started_at timestamp is older than 150 seconds while canonical worker health is missing, stale (including an invalid timestamp), or mismatched to the API runtime. The 45-second heartbeat threshold comes from the worker-health check; the 150-second stage-age threshold is an explicit dashboard presentation policy returned as stuckAfterSeconds. Requiring both stage age and unhealthy worker state keeps a healthy long-running activity from being mislabeled as stuck.

Each sourceHealth[] entry also carries an additive politeness object with per-source crawl-politeness outcome counts recorded by the R10 politeness gateway — robotsDisallowedCount, rateLimitedCount, budgetExhaustedCount, plus lastBlockedReason and lastBlockedAt. These are sourced from operational_attempt_metrics rows written with outcome = "blocked" and is_scrape_failure = 0, so a robots-disallowed / rate-limited / budget-exhausted source shows why it produced nothing without being counted as a scrape failure. All counts 0 with a null last reason means no politeness outcome was recorded.

GET /v1/analytics/outcomes returns the outcome analytics read model sourced from dashboard_projections.outcome_conversion_json. It exposes integer counts and read-time rates for totals plus bySource, byScoreBand, byFitBand, and byApplyMode, plus Phase 4 fields byTemplate, byPolicy, timeToResponse, and suggestionAccuracy. Every row carries n (applied) beside the rate fields, and all rates are null until n >= MIN_CONVERSION_SAMPLE (5 by default in apps/api/src/read-model.ts). byScoreBand uses the existing score-band vocabulary, while byFitBand is a separate canonical requirement-fit breakdown. Template/policy groups come from accepted material artifact metadata; response-time minutes come from applied_at and response-kind application_outcomes.occurred_at; suggestion counts come from decided application_outcome_suggestions rows. The endpoint is read-only and stays outside scoring, ranking, thresholds, and apply eligibility.

Pipeline operations snapshot ​

GET /v1/pipeline/operations takes no query parameters and returns a PipelineOperationsSnapshot with generatedAt and etaEstimatorVersion: "pipeline-eta-v1". It is the current operational view, not a historical reconstruction API. Selection prefers the newest Discover workflow projection that is active or still draining, then falls back to the latest terminal execution. On the wire, exact identity is { discoverWorkflowId, discoverRunId }; the latter is the Temporal run ID, and workflow ID alone is not unique across later starts of the deterministic Discover workflow.

When selected, execution includes workflow status, derived phase, membership closure, timestamps/error code, and two cohort summaries: currentExecution for observed_this_run and sweptExistingBacklog for existing_backlog. Each membership has one immutable (tenant, workflow, run, job) row. A swept job later observed in the same execution is promoted to the current cohort rather than counted twice. sourceFamilies reports only planned source-family crawls; reconciliation separately reports enrichment_pass and preparation_fanout steps.

The root wire shape is strict; unknown fields are rejected by the shared schema:

FieldContract
generatedAtISO timestamp for this request-time snapshot.
etaEstimatorVersionLiteral pipeline-eta-v1.
freshnessDiscriminated fresh, stale, unsupported, or unavailable; every arm carries asOf and staleAfterSeconds, and non-fresh arms carry a bounded reason.
executionnull or exact Discover identity/status/phase/membership closure, timestamps/error code, and currentExecution / sweptExistingBacklog cohort summaries. Each cohort has members, planned, notEligible, pending, failedPlan, terminal, and remaining.
capacityDiscriminated available, stale, or unavailable; see below.
sourceFamiliesnull or { planned, counts, eta, asOf, providerProgress? }. The optional progress object contains { site, phase, unit, completedUnits, totalUnits, rawItemsSeen, jobsEmitted, hasMore }; nullable provider facts remain null.
reconciliationnull or { enrichment, preparationFanout, asOf }, where both values use the canonical stage-count shape.
projectionCoveragenull only when no execution is selected and fresh available telemetry proves zero active slots; otherwise the ready, recovering, retrying, or terminal incomplete exact-lineage checkpoint described below.
stagesOrdered PipelineOperationalStage[]; each has stage, label, scope, currentExecution, existingBacklog, capacity, eta, and asOf.
activeStageCountsnull without fresh runtime telemetry, otherwise positive exact runtime-activity totals grouped by operational stage. These are shared-pool facts, not selected-execution completion proof.
activeItemsOldest-first PipelineActiveItem[], maximum 20.
activeItemsTotalExact allowlisted active-detail count, or null when inventory cannot make an exact statement.
activeItemsTruncatedWhether safe detail was omitted, or null when inventory is unavailable/stale.
overallEtaThe ETA discriminated union below.

sourceFamilies.providerProgress is a cursor-free observation from the latest JobStreaming provider event for the selected execution's exact { discoverWorkflowId, discoverRunId }. It is projected only while that execution's family:jobspy source step is running and is omitted after the source step becomes terminal, so an older event cannot look current. The field never contains a provider cursor, checkpoint, resume token, raw provider payload, or other continuation state.

projectionCoverage.status = "ready" carries mode (native or reconstructed), decoderVersion, historyEventId, verified membershipCount, verified stepCount, and updatedAt. recovering and retrying carry nullable mode/decoder/watermark/expected counts, non-negative persisted membership and step counts, and a nullable update timestamp; retrying additionally carries a non-empty bounded errorCode. incomplete carries verified persisted partial counts, unknown expected counts, and a bounded terminal errorCode; it represents immutable legacy history that cannot identify every original target and is not automatically retried. Without a selectable execution, projectionCoverage is null only when fresh available telemetry proves zero active slots. Occupied, stale, or unavailable runtime inventory returns recovering, never an invented idle or ready checkpoint. A ready row is accepted only when its exact persisted counts and canonical key digest still match the selected execution.

For mode = "native", later execution-membership writes and pipeline-step projection folds carrying the same exact workflow/run identity advance the ready checkpoint's counts and digest atomically with those canonical keys. The historyEventId remains the last reconciled history boundary; it is not fabricated from a native database write. Reconstructed checkpoints can advance only through history reconciliation. Reading a newer history snapshot does not temporarily downgrade an already-verified ready checkpoint; recovering is published only when the durable proof actually needs rebuilding.

Reconstructed decoder v2 treats append-only workflow-start events—not folded workflow projections—as preparation-lineage evidence. Each legacy fanout's causal workflow count must equal its declared target count before overlapping passes are unioned and their full workflow/run summaries are accepted. A retry attempt without a separate legacy queue event retains its exact attempt and start/completion facts with an unknown queue timestamp. A terminal failed fanout preserves its exact partial dispatch set as incomplete.

Decoder v3 additionally reconciles native activity lifecycle from the exact Temporal history. When a workflow is terminal but an activity process stopped before its final durable event was recorded, the decoder appends the proven failed or completed terminal event and refreshes the projection. It never promotes a queued or running event to terminal from workflow status alone.

A run-owned successful preparation receipt that completed Score and stopped at Tailor can resolve a canonical eligibility hold for cohort completion and ETA. The reader also requires the current successful Score owner and accepted score version, no unresolved score staleness, and exact non-retryable SCORE_ELIGIBILITY_BLOCKED gate provenance. Tailor/Cover counts remain blocked; only effective remaining work, issue classification, paths and swept-backlog contention exclude the proven completed hold. Generic/retryable blocks, missing/foreign receipts, later scores or owners and reset stages remain unresolved. An actual PDF lifecycle is authoritative. This is a read-only interpretation; the immutable plan and canonical rows are preserved.

The canonical stage-count object contains non-negative integers for eligible, waiting, processing, succeeded, skipped, blocked, failed, canceled, needsVerification, stale, and unknown. The exhausted numeric member remains in the wire shape for compatibility but is always zero; persisted exhausted-attempt rows contribute to failed. existingBacklog is either { kind: "domain_jobs", counts } or { kind: "not_separate", reason }; it is never an unlabeled zero.

stages[] uses the fixed operational order source_planning, source_family, reconciliation, pdf_render, enrich, score, tailor, cover. Each row has one of three scopes:

  • current_execution: the selected run's observed-job cohort plus its orchestration steps;
  • execution_sweep: the selected run's swept pre-existing jobs; stages without separately attributable sweep work return existingBacklog.kind = "not_separate";
  • global_outside_execution: canonical job-stage backlog outside both selected cohorts. Orchestration and PDF work are not synthesized as global queues.

Stage counts distinguish eligible, waiting, processing, succeeded, skipped, blocked, failed, canceled, needsVerification, stale, and unknown. Job-stage counts come from job_stage_states; orchestration and PDF counts come from attempt-aware pipeline_step_projections keyed by the exact execution identity. The execution phase is one of discovering, draining, completed, completed_with_issues, failed, or canceled; unresolved members in either execution cohort can keep an otherwise successful workflow in draining.

capacity is a typed available/stale/unavailable state aggregated from worker heartbeats. The operations reader derives the expected app directory from the configured database path, filters rows to that resolved database/app-directory identity, selects the task queue named by the newest matching heartbeat, and aggregates fresh schema-valid rows from that queue. Available capacity reports configured, active, and available activity slots, executor threads, worker validity/freshness counts, saturation, and its nested task-queue observation. Its kind is shared_activity_pool or shared_activity_pool_with_internal_parallelism; the latter additionally includes positive internalParallelism. All available arms include asOf, staleAfterSeconds, nullable taskQueue, freshWorkerCount, staleWorkerCount, invalidWorkerCount, configuredSlots, activeSlots, availableSlots, executorThreads, nullable slotSaturation, and approximateTaskQueue. Stale/unavailable capacity carries asOf, staleAfterSeconds, nullable taskQueue, a bounded reason, and the typed task-queue observation; it does not expose misleading slot zeros. The task-queue observation separately reports workflow/activity pollers and approximate backlog count/age plus add/dispatch rates, or a typed unsupported, unavailable, or stale state; these are infrastructure units, not domain jobs.

activeItems[] is the oldest-first bounded view of allowlisted active activity kinds, with at most 20 entries. activeItemsTotal is the exact allowlisted count when available and activeItemsTruncated says whether detail was omitted; activeSlots still counts every activity. Manual URL intake is included as job_url_import with the fixed job-url-import operational kind, nullable safe workflow identity and no pipeline stage or Discover membership. Active items are either resolved jobs, source families, orchestration, or unresolved runtime activities. The worker never reads activity arguments for telemetry. It returns only validated safe workflow/run references and non-reversible local opaque identifiers; raw job URLs, descriptions, profile data, prompts, provider output, artifact paths, payloads, credentials, and exception text are excluded.

Every active item carries activityType, nullable workflowId and executionId, positive attempt, and startedAt. Variant fields are: resolved_job (jobKey, nullable title/company, stage), source_family (sourceFamily), orchestration (operation), or unresolved_runtime_activity (opaqueId). jobKey and opaqueId are local non-reversible operational identifiers, never filesystem paths or raw job URLs.

overallEta and each estimable row use a discriminated union. available returns low/high seconds, low/medium/high confidence, basis (source_rate, stage_throughput, or cohort_throughput), sample size, timestamp, and caveat. Non-numeric states are calibrating (insufficient samples or, for overallEta, open membership), paused (worker/budget/blocking/dispatch reason), stale, or unavailable (including no work, unsupported telemetry, unknown scope, or unbounded contention). Every numeric estimate requires at least five recent successful samples for each relevant remaining stage and fresh usable capacity. overallEta additionally requires closed execution membership; per-stage and source-family rows can estimate their already-known scoped backlog while membership remains open. The estimator uses canonical job-stage and pipeline-step durations, falling back to operational attempt metrics only when the primary source is empty; it never blends the two. A source-family estimate may use recent completed-family durations even when provider totalUnits is null; it does not synthesize a remaining-page count. It uses observed source concurrency and requires fresh, complete runtime inventory plus a bounded Temporal queue. Selected-run sweep work reserves one slot per remaining preparation workflow, not one per sequential stage, and suppresses the numeric source estimate only when that demand exceeds spare capacity. Unbounded retry demand or shared-queue contention still suppresses it; dormant global rows do not until queued or observed as active work.

The Operations query polls every 15 seconds while phase is discovering or draining, every 60 seconds otherwise, and never in the background. SSE invalidates it after durable Stage*, PreparationWorkItem*, PipelineStep*, and Workflow* events; polling remains the freshness path for heartbeat and task-queue changes.

Discovery controls ​

Discovery product-control endpoints are local-first and share DTOs from packages/contracts. The web Discovery page composes these endpoints and the profile-backed Target search settings into source-registry, source-locator, quarantine-review, role-matching, and manual-capture surfaces; the source registry renders as a paginated, filterable, sortable table so source type and policy metadata are visible as columns instead of compact badges:

  • GET /v1/discovery/settings returns the SQLite-backed runtime discovery settings used by board discovery: boards, per-site and age limits, schedule, bounded source-family parallelism, and crawl user-agent product/contact. Managed fields include effective source, editability, and activation metadata.
  • PATCH /v1/discovery/settings updates those runtime settings without dropping the worker search-contract fields stored in the same row. This is the scheduling mutation boundary; /v1/settings does not own discovery cadence.
  • GET /v1/discovery/sources lists source registry entries merged with source_quality_stats. Each entry also carries the same additive politeness object as sourceHealth[] (robots-disallowed / rate-limited / budget-exhausted counts plus the last block reason/time), sourced from operational_attempt_metrics, so the registry table can show why a source was blocked instead of a silent zero.
  • POST /v1/discovery/sources upserts a local source registry entry and emits SourceRegistryEntryCreated or SourceRegistryEntryUpdated.
  • PATCH /v1/discovery/sources/:sourceId/state changes local source state and emits SourceStateChanged.
  • GET /v1/discovery/sources/:sourceId/preview returns local preview leads from recent JobSourceObserved history for that source; it does not perform live scraping.
  • GET /v1/discovery/locator-candidates is strictly read-only, including for parseable ATS candidates. It returns { ok, candidates }; existing candidate fields are unchanged. History proposals add optional proposal with coordinateKey, sourceId, nullable atsKind/boardToken, completeness: "unknown", optional nullable promotionBlockedReason, overlaps[] (sourceId, state, seedUrl) and evidence[]. Each evidence receipt contains originJobId, originPostingUrl, inputUrl, urlKind (posting_url, canonical_url, application_url), observations[] (observation/source/run IDs, observed URL and time), extraction (nullable tier/time/snapshot version and description/ snapshot hashes and optional snapshotEvidence[]) and sample (title, company, bounded description excerpt). Reads join saved receipts by candidate ID and never fetch, learn or promote.
  • GET /v1/discovery/quarantine and GET /v1/discovery/manual-capture expose their existing local review queues. Direct URLs from broad-board discovery retain their normal learning path, fenced by pending history proposals, durable rejection and disabled/quarantined source intent.
  • POST /v1/discovery/locator-candidates/:candidateId/promote explicitly promotes the candidate, reusing a history proposal's active registry overlap without changing its configuration. It records SourceLocationCandidateReviewed (decision: promote) and SourceLocationCandidatePromoted atomically with queue/registry writes. Public destination validation runs before the writer transaction; a changed candidate requires review again. A disabled/non-active overlap returns 409 source_review_conflict; malformed/private coordinates return 400 invalid_source_candidate. The optional decision reason is retained.
  • POST /v1/discovery/locator-candidates/:candidateId/reject atomically records SourceLocationCandidateReviewed (decision: reject, candidate ID, coordinateKey, optional submitted reason and decidedAt) and removes pending aliases for that coordinate. It never changes source state or application data. Suppression survives retries/restarts until the explicit source-proposals reset CANDIDATE_ID CLI command records decision: reset. source-proposals from-history is the only history-scan trigger; no new automatic import/apply hook or schedule is introduced.
  • POST /v1/discovery/quarantine/:jobKey/decision approves or rejects a quarantined lead and records feedback for source-quality aggregation.
  • POST /v1/discovery/manual-capture/:itemId/import records user-mediated capture provenance for copied URLs, current-page URLs, pasted text, saved HTML, and email imports. Raw pasted or saved content is not copied into domain events; the API stores only local metadata such as content length and hash.
  • POST /v1/discovery/manual-capture/:itemId/dismiss dismisses a pending manual-capture item.
  • POST /v1/discovery/feedback records DiscoveryFeedbackRecorded with IDs, source, kind, and timestamp only; free-form notes stay out of the domain event payload.
  • GET /v1/discovery/role-match-feedback derives a local review queue from newly scored jobs with very low fit scores and role-fit evidence. Suggestions are exact title-exclusion rules, not automatic prompt or query changes.
  • POST /v1/discovery/role-match-feedback/:suggestionId/decision approves or declines a suggestion. Approved exact-title exclusions are visible in the Discovery page and are consumed by future discovery title matching; declined suggestions remain recorded but inactive.

Compensation ​

GET /v1/compensation/sources returns the compensation source policy registry used by the Settings General policy panel. This is not a feed connection. The response contains safe policy metadata only: source id, display name, source type, access mode, availability, license status, terms/source URLs, freshness policy, attribution requirement, supported field names, disabled reason, configured flag, Europe coverage notes, safe operator notes, and safe control metadata. The control metadata distinguishes fixed local/public sources from the user-owned Levels.fyi and Glassdoor preferences and lists only the permitted access-mode choices. It does not return credentials, raw provider payloads, private-account state, local paths, feed URLs, or salary observations.

PATCH /v1/compensation/sources persists one user-owned source preference in the local dashboard settings file and returns the refreshed registry. A Levels.fyi update contains sourceId: "levels_fyi", enabled, accessMode, and europeCoverageConfirmed; a Glassdoor update contains sourceId: "glassdoor", enabled, and accessMode. Enabling Levels.fyi is accepted with tokenless public_markdown and no coverage confirmation. The licensed_api, licensed_data_feed, and enterprise_mcp modes still require Europe coverage confirmation. Enabling Glassdoor is rejected unless the access mode is partner_api or written_permission. Disabling either source remains allowed and takes precedence over a legacy environment-variable configuration.

The registry read and preference write are network-free. They do not fetch, cache, or return provider payloads. The registry lists posted salary text, Euro Top Tech, Levels.fyi, Glassdoor, and the temporary manual reported-compensation import as safe policy entries. An explicit saved preference is the source-policy gate for Levels.fyi or Glassdoor. Until a preference exists, JOBCTRL_LEVELS_FYI_ACCESS_MODE, JOBCTRL_LEVELS_FYI_EUROPE_COVERAGE, and JOBCTRL_GLASSDOOR_ACCESS_MODE retain their compatibility behavior. When public_markdown is enabled, refresh paths derive public job-family/location routes from the current jobs, read Levels.fyi's documented Markdown representation, and fall back to the same public page's structured data when the Markdown response is empty. Requests need no credential, and derived evidence carries the provider-required attribution and canonical URL. When a licensed mode is enabled, refresh paths load Levels.fyi rows from JOBCTRL_LEVELS_FYI_OBSERVATIONS_PATH or JOBCTRL_LEVELS_FYI_OBSERVATIONS_URL and Glassdoor rows from JOBCTRL_GLASSDOOR_OBSERVATIONS_PATH or JOBCTRL_GLASSDOOR_OBSERVATIONS_URL. JSON and CSV feeds are accepted. The source registry does not expose secrets, row contents, local paths, or feed URLs.

GET /v1/jobs/:jobKey/compensation/posted returns the read-only inspection contract for canonical posted-compensation facts. The endpoint reads job_posted_compensation_facts only; it does not parse, backfill, update, persist, refresh projections, call external providers, or run React-side normalization during a GET. For an existing job with a canonical row, the response is { ok: true, recordStatus: "recorded", fact }, where fact contains the parse state (missing, unparseable, ambiguous, or parsed_range), bounded source text, raw salary fallback, parser version, source hash, parse timestamp, confidence, warnings, and normalized currency/period/component/min/max/annualized fields only for legal parsed ranges. For an existing job without a canonical row, the response is recordStatus: "not_recorded" plus the current raw jobs.salary fallback. Unknown jobs return 404.

The projection-backed /v1/jobs and /v1/jobs/:key responses include compensationSummary, sourced from job_list_projections / job_detail_projections JSON rather than API read-time salary parsing. The detail response also includes top-level compensationAudit, which keeps posted and market inspection payloads separate. The raw JobSummary.salary string remains present for compatibility. Compensation summary/audit data does not change fit score, apply readiness, apply-review handoff, or apply mutation behavior. The Jobs list exposes dedicated sortable and filterable scan columns for posted salary minimum and maximum normalized to EUR/year, reported market estimate, market confidence, and compensation warnings; these sort through compensation_min_eur, compensation_max_eur, compensation_market, compensation_confidence, and compensation_warnings alongside the existing sortable job columns. The Salary min, Salary max, and Market headers carry the EUR/year unit so row values stay compact. The compensation_posted sort remains accepted as a compatibility alias for the posted minimum. Currency normalization is a projection concern: supported parsed currencies are converted to EUR/year; unsupported or missing currencies leave the normalized min/max empty instead of guessing.

The compact market summary includes benchmarkKind: "direct" | "extrapolated" | null. The value is derived from the canonical fact lineage, not from source labels or display text. null means the recorded estimate does not carry a valid canonical benchmark reference; it does not relabel legacy or malformed evidence as direct.

GET /v1/jobs/:jobKey also includes top-level interviewPrep, loaded from the canonical latest accepted job_interview_prep generation. Projection JSON mirrors prep, but projection lag and history pagination cannot replace the current accepted result. The value is null when no accepted generation exists. When present, it is the latest accepted stored prep generation with item text, item kind, evidence IDs, requirement IDs, joined profile source snippets, gate/judge summary, and accepted-residual warnings. Optional generationContext, per-item questionMetadata, and read-derived staleReasons are defined by the interview contract. They retain relevant original input excerpts and bindings, not raw prompts or full profile JSON. Legacy rows do not receive fabricated card/rubric associations.

GET /v1/jobs/:jobKey/compensation/market returns the read-only inspection contract for canonical company-role reported compensation estimate rows. The endpoint reads job_market_compensation_estimates only; it does not estimate, backfill, update, persist, refresh projections, call providers, scrape pages, or normalize anything in React during a GET. Existing jobs without a market row return { ok: true, recordStatus: "not_requested", jobKey }; unknown jobs return 404.

Recorded market estimates expose one explicit state: unsupported, source_unavailable, insufficient_evidence, or estimated_range. Range fields are present only for estimated_range. Non-range states carry inspectable reasons, confidence factors, safe source snapshots, and warnings instead of nullable precision. Automatic materialization persists insufficient_evidence with weak_role_match or weak_location_match when current job evidence cannot resolve a supported role family or country, and it never emits missing_role for a present title. A source attempt with no supported benchmark also persists a no-range result. Source failure with no benchmark uses source_unavailable; an all-level benchmark still uses insufficient_evidence and weak_level_match for a known-level job unless a compatible accepted range is retained. Explicit refreshes use current accepted enrichment text to classify the requested job role when available. A present title whose role family remains unresolved receives weak_role_match and no range, including when enrichment is missing or failed. A supported role family rejects reported observations from another or no supported family even without enrichment. A previously accepted v4 Principal software same-location fallback with adjacent infrastructure-engineering evidence is retained through weak refreshes only while its title, level and country remain compatible; new cross-family ranges remain rejected. Provider observations are never classified from the job's enriched description. Source scope is reported compensation evidence keyed by company and role: Euro Top Tech public community-reported rows, Levels.fyi, Glassdoor, and manual local reported-compensation imports. The estimate includes company name, normalized company, role title, normalized role, match scope, total compensation component, source count, sample count, confidence factors, selected evidence rows, and trimodal company-tier context. Each source snapshot carries an explicit provenance discriminator (public, licensed, manual, or employer_posted) together with its persisted attribution and snapshot version; public and licensed Levels.fyi rows remain distinct throughout the read model. Unknown sample support remains null and is never promoted to a one-sample claim. Selected evidence rows are the sanitized reported observations used to choose the range, including source id/display name, company, role, location, level, component, EUR/year range, sample count, release year, source URL when the observation has a safe public URL, and match scores. Raw benchmark pages, credentials, private account payloads, local paths, unsafe/private URLs, and user compensation preferences are not returned.

A recorded estimate also carries benchmarkLineage. It is null for a non-canonical estimator reference or when its referenced immutable fact graph cannot be read safely. A direct lineage identifies the benchmark fact, taxonomy, role family, seniority, target geography, compensation component, evidence/freshness dates, and its exact direct salary input. An extrapolated lineage adds the anchor geography and fact, method and formula version, raw factor, company-evidence shrinkage weight, supported factor bounds and bound state, matched-company count, every direct salary input, and every official price-level input. Each input carries its immutable fact id, role and weight, source snapshot, geography, range or index, sample/confidence data where applicable, and freshness dates. The API resolves this graph tenant-locally from exact-v8 benchmark authorities; it does not fetch providers, expose raw payloads, or infer lineage from the final numeric range.

Temporary write trigger: jobctrl compensation-refresh reparses posted salary facts from existing jobs.salary values and description compensation text, imports all configured reported compensation observations, estimates matching existing jobs, and refreshes projections without running discovery, scoring, tailoring, cover, or apply automation. Explicit --observations-json <file> imports are additive with enabled tokenless public Levels.fyi pages, configured licensed Levels.fyi and Glassdoor feeds, and public Euro Top Tech rows. When reported evidence does not match exactly, the estimator broadens through same company/role, same-location role, same-company adjacent-role, trimodal company-tier, and broad market-baseline tiers so sparse real evidence still produces a best estimate with a wider confidence interval. Fallback matching is seniority-aware, so executive CTO/VP roles do not reuse staff, principal, or director observations unless the selected source row is also executive-level. It does not label those rows as Levels.fyi, Glassdoor, Euro Top Tech, or manual reported-compensation data unless that source actually contributed observations. Employer-posted compensation remains available only through the separate posted fact and is never used as market evidence. --url <job-url> narrows the refresh to one existing job.

Manual web/API triggers: POST /v1/jobs/:jobKey/actions/refresh-compensation dispatches the same focused compensation refresh through the local worker for one existing job, while POST /v1/jobs/actions/refresh-compensation refreshes every existing job. Both paths avoid rerunning discovery, scoring, tailoring, cover, or apply automation. The request body may include { "observationsJsonPath": "/path/to/export.json" } to import additional reported-compensation observations before estimating market evidence, or { "includeEuroTopTech": false } to disable only the public Euro Top Tech import. Enabled tokenless public Levels.fyi pages and configured licensed Levels.fyi and Glassdoor feeds still load by default. When no permitted reported source supports a market range, the estimate remains visibly insufficient instead of copying the employer's posted number into the market authority. The refresh dispatches refresh_compensation, which starts CompensationRefreshWorkflow; the response is the standard action response with the queued workflow ID.

Market estimates also project into the same job list/detail compensation summary and detail audit fields, separate from posted facts. The compact summary carries range, market confidence band/score, source count, sample count, and warning count for list surfaces; detail audit carries the source trail, selected evidence rows with safe source URLs, confidence factors, warnings, and reasons. The web Jobs table, selected-job detail workspace, and Apply Review render these persisted fields without parsing salary text in React. This projection and rendering do not change fit score, apply readiness, apply-review handoff, or apply mutation behavior.

When Python compensation repositories save posted facts or market estimates, they append CompensationFactsUpdated to job_events. The SSE payload contains only safe state markers: job id, changed section (posted or market), posted record/parse state, market record/estimate state, and timestamp. It does not contain source text, market source snapshots, profile compensation preferences, credentials, local paths, or provider payloads. The frontend Operations invalidation router uses the event to refresh job list/detail queries.

Interview catalog, preparation, and notes ​

Public editorial guidance is an installed asset; personal prep and notes are private tenant/job-scoped canonical data. Browsing never dispatches a workflow or provider call. Normal loopback Host/origin protections apply to every route.

EndpointSuccess shape / behavior
GET /v1/interviews/catalog{ ok: true, catalog, page, pageSize, total }; catalog.questions is the filtered page.
GET /v1/interviews/questions/:questionId{ ok: true, catalogBinding, question }.
POST /v1/jobs/:jobKey/actions/generate-interview-prepNormal action response: 202 when queued, 200 for a completed dispatch response.
GET /v1/jobs/:jobKey/interview-prep/history{ ok: true, jobId, generations, page, pageSize, total }.
GET /v1/jobs/:jobKey/interview-notes{ ok: true, jobId, notes, page, pageSize, total }.
POST /v1/jobs/:jobKey/interview-notes{ ok: true, note } after an expected-revision save.

Catalog list and detail ​

The global GETs are direct TypeScript handlers and do not open SQLite or require a job, live worker, or provider. The source loader and installed JOBCTRL_PAYLOAD_DIR/worker/site-packages/jobctrl/assets/interview/catalog.v1.json reader consume the same tracked bytes; absent or invalid assets return 503 interview_catalog_unavailable without a docs/plugin fallback.

List query fields are optional topic (1–80 characters), role (a role lens), answerFormat, source (1–20 characters), and search (at most 200 characters). Search covers ID, title, variants, intent, responsibility tags, and competency tags. Filters combine with AND. page is 1–1000 (default 1); pageSize is 1–121 (default 121). Unknown topic/source values yield an empty matching page. total counts all matching active cards before pagination. Catalog metadata, sources, authors, retired IDs, and relationships remain available with the page. The returned catalog binding names the full immutable revision; filtering does not create a new authoritative catalog digest.

The schema-v1 catalog contains 121 active research-draft cards and reserves C08 as retired. Cards include stable ID, topic, active status, maturity, cardRevision/cardDigest, rubricRevision/rubricDigest, explicit role lenses, responsibility/competency tags, supported/default answer formats, variants, intent, guidance/adaptation/alternatives/probes/failure modes, rubric dimensions, worked examples, source refs, and attribution kind. Source records expose URL, author, actual reading coverage/kind, and linked question IDs. Relationships are explicit editorial_related edges. Source citations do not prove candidate facts, full-book reading, author endorsement, or validated assessment.

Question IDs contain at most 12 characters and match uppercase letters followed by two digits. A malformed detail ID fails with 400 invalid_interview_question_id; an unknown valid ID returns 404 unknown_question; a reserved retired ID returns 410 retired_question. Selection errors include the safe questionId when relevant.

Generation request and input binding ​

The generate body remains optional for older callers. Its strict schema accepts:

FieldContract
llmModelOptional nonempty model string, at most 120 characters.
selectedQuestionIdsOptional ordered array of 1–16 unique active IDs.
catalogBindingOptional { catalogRevision, catalogDigest }; revision is 1–100 characters and digest is lowercase SHA-256.
evidenceSelectionsOptional array of at most 16 { questionId, evidenceIds } entries. Question IDs are unique and belong to the selected set; each ordered evidence list has at most eight unique canonical IDs, each 1–200 raw characters.
evidenceProfileVersionPositive integer required whenever evidenceSelections is present. It must match the current canonical profile version.
interviewStageunknown, recruiter, behavioral, management, technical, executive, or mixed; omission preserves unknown stage.
interviewFormatphone, video, onsite, written, or unspecified.
roleLensunknown, ic, senior_ic, staff_principal, first_time_manager, engineering_manager, director, or executive.
roleResponsibilitiesAt most 20 nonempty entries, each at most 160 characters.
knownCriteriaAt most 20 nonempty user/employer-supplied criteria, each at most 1000 characters.
selectionRationaleOptional text at most 2000 characters.

Supported answer formats are historical, situational, principle, negotiation, narrative, and preference. Explicit responsibility metadata guides applicability; job titles/prefixes cannot invent applicability or known employer criteria. Requests without selected IDs use a cached model selection determination. Unknown/retired/duplicate/over-budget IDs and catalog mismatch are rejected before provider spending. Semantic errors use 404 unknown_question, 410 retired_question, or 409 catalog_mismatch; malformed bodies, duplicate IDs, and array-budget violations fail validation with 400. Worker-backed dispatch retains its readiness and spend preflight.

An omitted question evidence entry uses a cached model evidence-selection determination. An explicit entry with evidenceIds: [] requests gaps without automatic replacement. The owning path validates tenant/profile ownership, accepted factual status, current profile version, question membership, unique IDs, and bounds before provider spending. Evidence IDs retain their exact canonical identity: the schema does not trim, normalize, or change case, and rejects blank-only IDs. The same 1–200 raw-character bound applies to returned evidence links, outline evidence IDs, and retained selected evidence IDs. Eligible IDs identify current-profile achievement evidence with explicit user_confirmed = 1 and no unresolved legacy confirmation review. The system does not manufacture evidence strength or seniority flags. Stale or invalid choices return 409 evidence_profile_changed or 400 invalid_evidence_selection; the client keeps the draft for reselection. Notes and new recollections are not accepted facts. The same conditional version fence applies to HTTP and worker RPC schemas.

Each stored prep contains jobId, generation, status accepted | failed | superseded, generatedAt, model, gate audit, and items. The new question_outline kind coexists with legacy theme, star_draft, gap_drill, and company_note. questionMetadata adds question/card/rubric bindings, answer format, rationale, evidence links (evidenceId, sourceRef, excerpt, scope: direct | transferable), structured outline segments, gaps (id, prompt, reason), probes, sourceGuidanceRefs, factual support, and userEditStatus: generated | user_edited. Each outline segment has heading, text, evidenceIds, and factualSupport. Outline support is accepted_profile_fact, hypothetical, new_user_statement, or needs_clarification.

For generated segments, accepted_profile_fact requires nonempty evidenceIds from that question's selected canonical evidence and claims within those excerpts. hypothetical and needs_clarification require evidenceIds: [], even when referring to a separate factual anchor. These IDs mean accepted personal proof; contextual question links remain separately in evidenceLinks. A clarification may refer to an accepted_profile_fact anchor without carrying its proof IDs. Incompatible support/ID combinations reject the candidate rather than dropping IDs or relabeling the original segment.

Generic behavioral invitations and open questions request recollection or clarification without accepting an event as fact. Genuinely hypothetical or prospective guidance, including first-time-manager scenarios and advertised job responsibilities, may lack historical evidence. Generic planned missing-answer slots request particulars; questions about the canonical advertised role's expectations clarify job context rather than assert candidate history.

Personal assertions and specific past presuppositions require that question's selected canonical support, including factual headings and assertions embedded in future or conditional text. A factual-support label or unrelated supported body cannot substantiate another claim. Another question's evidence, authored examples, and job responsibilities are not personal evidence. An explicit empty selection stays empty; missing evidence remains a focused question or marked gap without automatic replacement.

Optional nullable generationContext retains these generation-time fields:

FieldStored shape / meaning
schemaVersion, catalogBinding, contextDigestSchema "2", catalog revision/digest, and immutable context SHA-256.
selectedQuestionIds, selectedQuestionsOrdered IDs and 1–16 selected snapshots; each has question ID, card/rubric revision/digest, answer format, rationale, full snapshot card, evidenceSelectionMode: user_selected | model, and ordered selectedEvidenceIds (at most eight).
selectionModeuser_selected | model.
interviewStage, interviewFormat, roleLens, roleResponsibilities, knownCriteriaRecorded interview context; inferred guidance cannot become known employer criteria.
profile{ profileId, version, evidence } with canonical evidence ID/source/excerpt and direct/transferable scope.
jobContext{ jobId, title, company, descriptionExcerpt, snapshotHash }; title/company are at most 500 characters each, excerpt at most 12,000. The hash covers the full uncapped canonical job description.
employerAnalysisNullable { generation, snapshotHash, snapshot }; snapshot retains role framing (at most 4000 characters), inferred seniority (at most 500), and at most 20 relevant { requirementId, requirementText, sourceExcerpt } entries with text/excerpt at most 3000 characters each.
fitReportNullable { generation, employerAnalysisGeneration, profileSnapshotVersion, status: current | stale_excluded }; excluded fit cannot support a personal fact.
approvedMaterials{ materialId, generation, sha256 } refs bound to the actual current approved tailored-resume artifact and its raw bytes.
model{ model, promptVersion, gateVersion }.

catalogDigest is semantic SHA-256 over canonical sorted-key compact UTF-8 JSON excluding itself; it differs from raw asset-byte SHA-256. Approved-material reads are bounded to 1 MiB. Unavailable or mismatched artifacts are excluded and labeled, and a maximum bullet-provenance generation is not approval proof.

Current job-detail reads retain the latest accepted generation during pending or failed refresh, independently of projection lag or the number of later failed attempts. History query accepts optional positive generation, page 1–1000 (default 1), and pageSize 1–100 (default 20). It exposes prior accepted/superseded and failed attempts, newest generation first. Relevant generation-time excerpts and snapshots stay immutable when current inputs change. Read-derived staleReasons are catalog_changed, profile_changed, job_changed, employer_analysis_changed, approved_materials_changed, or legacy_unbound. They ask for user-controlled regeneration rather than rewriting history. Missing legacy context remains unbound with legacy_unbound semantics and no fabricated question/rubric association. The workflow/run's completed retry reuses the persisted generation rather than spending again.

Independent revisioned notes ​

Note GET accepts optional questionId, history: true | false, and the same page bounds/defaults as prep history. history: true requires questionId and returns that question's append-only revision history; ordinary reads return the latest note per question. Rows sort by question ID ascending, then revision descending. Reads remain tenant/job scoped.

Save accepts required questionId, integer expectedRevision >= 0, and noteText of at most 20,000 characters (empty text is valid). Revision 0 creates the first note; subsequent saves compare the loaded revision and append the next revision. Optional factualSupport is unverified_user_statement (default), needs_clarification, or hypothetical; input cannot self-declare supported. Optional sourceGeneration is positive or null and must belong to the same job and retain that question in its selected-card context. Historical and retired-card origins are allowed. The server derives matching catalog, card, and context bindings from the retained selected question; optional supplied bindings are checked against that authority. bindings: null supplies no claims; it does not clear the server-derived origin. A source generation explains origin but never transfers a passed generation audit to the user edit.

Omitting sourceGeneration normally preserves an existing origin; an ordinary existing-note edit omitting origin claims safely detaches to null source/context if that generation was deleted. The new revision uses current card/catalog bindings when available, otherwise null; its earlier revision retains the original provenance. It never rebinds implicitly to the newest prep. A fresh or explicitly supplied missing origin still fails validation, as does a malformed or mismatched retained origin.

Explicit null creates an independent note with the current active card/catalog binding and null context; it cannot claim a generation context. Existing orphaned or retired notes remain editable, with unavailable bindings represented as null. A new independent note requires an active card; a retained origin can bind a historical retired card.

The returned note has canonical jobId/questionId, positive revision, noteText, factual support, editStatus: user_edited, nullable source generation and bindings, and updatedAt. Response vocabulary reserves supported for an owning grounded path; ordinary user saves cannot produce it. Existing notes remain editable when a later catalog retires their card. Save revision, history append, and safe event commit together.

A stale expectedRevision returns 409 with { ok: false, error: "interview_note_revision_conflict", message, currentNote }. currentNote is the current saved note or null when no saved baseline exists. The client exposes JobCtrlApiError.responseBody so the form can reconcile with the current saved note while retaining its dirty draft. An invalid source generation, missing/legacy-unbound context, or absent selected question returns 400 invalid_interview_note_source. Forged/mismatched bindings or non-null context without an origin return 400 invalid_interview_note_bindings. An unknown job returns 404 job_not_found. Failed save/generation cannot delete independent notes or overwrite a newer revision. These actions write no Profile, fit, approved resume, Discovery, or Apply state.

Events, privacy, and maturity ​

InterviewPrepGenerated carries { jobId, generation, itemCount, generatedAt }; InterviewPrepFailed carries { jobId, generation, failedAt, reasonCount }; InterviewQuestionNoteSaved carries { jobId, questionId, revision, sourceGeneration, updatedAt }. Events, telemetry, and broad read models never carry outline/note/profile text, evidence excerpts, or note/context bindings. Private history and note content stay in authorized detail reads and follow the job graph's tenant/purge boundary.

Prep is explicit pre-interview material, not an automatic pipeline stage. Manual post-interview reflections keep the Apply outcome path and may link to a prep generation. This release has no reusable no-job preparation, practice attempt, validated grading/readiness score, transcript, microphone, streaming, or live assistance contract. Synthetic fixtures and generation judges do not establish content efficacy or calibrated assessment.

Workflow runs ​

/v1/workflow-runs reads the unified workflow_run_projections table (the Python-sole-writer projection folded from the Workflow* lifecycle events) and projects each row to a WorkflowRunSummary across all workflow types — Discover, job preparation, Apply, and future workflows. Those workflow families share one status vocabulary, ordered timeline, terminal-state interpretation, and cancellation boundary. Apply rows are enriched with job context (title/company/dry-run/model) via a LEFT JOIN to apply_run_projections; non-apply rows surface their workflowType instead of a job title. The runId equals the Temporal workflow id, so the web Workflow Runs view at /runs deep-links each row to the local Temporal Web UI (http://127.0.0.1:8233). The list query supports exact workflowType, inclusive UTC startedSince, and exclusive UTC startedBefore filters. The read model applies them to the folded lifecycle summary before sorting and pagination, and echoes the effective filters in the response. GET /v1/workflow-runs/:runId returns a WorkflowRunDetail for one run — its status, input summary, failure cause (errorCode / errorMessage / retryable), Temporal run id, and the folded lifecycle timeline — or 404 ({ ok: false, error: "workflow_run_not_found" }) for an unknown id. The /runs/$runId detail workspace renders it for every workflow type. Apply details also return applyEvents, a separate array of summaries from the selected tenant/job/run's durable Apply event rows. Each has a sourceEventId, a nullable monotonic runEventId, type, at, level, and message. No event payload is returned; non-Apply workflows return an empty array. The browser can use the run-event ID to reconcile an already delivered live event with its durable copy. The optional canonical jobId query parameter scopes the Apply history to that job. If the Apply projection is still absent, a matching durable start must confirm the run/job association before any Apply rows are returned. POST /v1/workflow-runs/:runId/actions/cancel dispatches a worker-backed cancel_run request for in-flight workflow IDs that are not tied to a concrete job row, such as global Discover or Apply runs started from the Pipelines tab. The command is cooperative, asynchronous, and idempotent. Repeating it cannot replace an already-terminal Apply result or emit another cancellation transition. Whether cancellation wins or loses its race with completion, the terminal result and folded timeline remain inspectable through the same detail route. GET /v1/dashboard/summary also carries recent apply-run timeline summaries from apply_run_projections.events_json (type, level, message, at).

Dashboard pipeline progress can include an optional sourceProgress.recoveredUnits: number | null. For broad-board discovery it is the number of JobStreaming query/location/board units reclaimed after an interrupted activity. The field is omitted for older progress payloads and non-applicable sources; positive values render as N resumed in Pipelines.

Profile resume preview ​

GET /v1/profile/preview.html renders the baseline Candidate Profile resume HTML used by the Profile page Plate editor. The renderer is the same HTML/CSS resume renderer used for generated materials. GET /v1/profile/preview.pdf remains a compatibility endpoint for callers that need a baseline PDF, but the Profile web route no longer uses a PDF iframe and no longer has a Profile-level alternate render override.

Apply review and outcomes ​

Apply review and outcome feedback endpoints power the local web /apply-review queue and the job-detail outcome timeline. Gmail feedback ingestion is Gmail-only and runs through the Python worker, not through the verification-code MCP server:

  • GET /v1/apply/review-queue returns active apply-stage jobs that are ready or close enough for human review, plus materials readiness, latest apply-run context, blockers, latest review state, compensationSummary from the job list projection, and applyAudit, the canonical readiness/blocker/eligibility DTO used by Apply Review. The materials preview includes the selected resume text artifact, selected resume PDF artifact, and resumePdfLayoutBoxes for generation-time line/page anchoring when the selected final PDF was rendered through the HTML/CSS resume renderer. Requirement-led materials can also include requirementLedAudit: bounded coverage, generated-claim, pinned content, bullet-overflow, revision, and review-blocker summaries derived from artifact metadata without projecting raw prompts, full profile/job text, local paths, PDFs, logs, browser data, or SQLite contents. The revision summary labels its coverageBasis (grounded_shipped_text_v1 vs judge_claimed_legacy), carries claimedOnlyRequirementIds, and reports revisionsUsed (attempt - 1) alongside the raw attempt ordinal; when the artifact was generated by the grounded gate the audit also carries an optional shippedFit (the lifecycle-labeled post_generation_fit_final record) with the grounded shipped must-have coverage, pass state, and post-acceptance warnings, so legacy judge-claimed numbers are labeled rather than hidden. Apply Review uses the selected PDF artifact to fetch the sibling HTML preview for the Plate editor and keeps the PDF as the final file link.
  • GET / POST /v1/jobs/:jobKey/resume-review/draft loads or creates the separate editable draft for the selected resume artifact generation. Draft revisions, structured edit deltas, comment threads, comment replies, and feedback signals are stored append-only in the TypeScript API tables and do not mutate approved materials in place.
  • POST /v1/resume-review/drafts/:draftId/revisions saves manual or autosaved Plate document revisions and extracts bounded feedback signals from changed lines. POST /v1/resume-review/drafts/:draftId/comment-threads seeds deterministic JobCtrl line-comment threads from audit pins, and POST /v1/resume-review/comment-threads/:threadId/replies records the user's reply without suppressing the original source pointer or risk label. GET /v1/jobs/:jobKey/resume-review/feedback lists the bounded feedback signals extracted from that job's draft edits.
  • POST /v1/resume-review/drafts/:draftId/render validates the latest saved draft, writes a new job_materials generation with tailored_resume and resume_pdf artifacts plus layout boxes, and marks the draft promoted. It returns structured validation errors instead of replacing materials when the edited resume is empty, too short, renderer-incompatible, or carries explicit unsupported-claim markers. The resume_pdf artifact is produced by rendering the edited resume HTML through the same Playwright HtmlResumePdfAdapter the worker uses (render_format = 'html_pdf'), so it captures every edited line/section across as many pages as the content needs rather than a truncated single page. If that render fails the promotion is aborted and the previous approved generation is left intact.
  • GET /v1/jobs/:jobKey returns the same applyAudit DTO on job detail payloads so the Jobs detail workspace and Apply Review consume the same readiness facts. The DTO includes state, label, summary, missing prerequisites, hard blockers, eligibility concerns, source metadata, and whether review evidence remains available.
  • Both the review-queue item and job detail expose repeatApplication. Its status is clear, blocked, confirmation_required, override_ready, or override_consumed; the payload also includes a summary, evaluation time, nullable SHA-256 evidence fingerprint, related confirmed prior applications, relationship reasons and identity evidence, the matching override when one exists, and a bounded append-only audit trail. Exact relationships are canonical_job, canonical_identity, or accepted_duplicate; the only probabilistic relationship is same_employer_equivalent_role.
  • The same job-detail payload exposes activeApplyRun (or null when none) from the complete job-scoped run history, rather than the dashboard's bounded recent-run feed, so the cancel control retains the exact Temporal run target under unrelated load.
  • POST /v1/jobs/:jobKey/apply-review/decision appends an approve_submit, approve_dry_run, defer, decline, or reset decision. approve_submit records the reviewed materials generation, profile version, and application URL, and it is accepted only when matching full dry-run evidence exists or the request explicitly names a matching partial dry-run run. approve_submit requests must include the displayed materialsGeneration, profileVersion, and applicationUrl; if any value no longer matches the current review row, the API returns 409 with approval_stale_materials, approval_stale_profile, or approval_stale_url before inserting a decision row. Approval records intent only in this slice; it does not dispatch the apply worker.
  • POST /v1/jobs/:jobKey/repeat-application/override accepts the displayed evidenceFingerprint, one priorJobKey present in the current matches, a 10–400 character reason, and confirmedBy (default user). It appends an immutable override and audit row, then returns the refreshed assessment. It does not dispatch Apply. Changed evidence returns 409 repeat_application_evidence_stale; a missing prior match returns 409 repeat_application_prior_mismatch; and a clear target returns 409 repeat_application_confirmation_not_required.
  • GET /v1/outcomes and GET /v1/jobs/:jobKey/outcomes return reviewed outcomes and any outcome suggestions.
  • POST /v1/jobs/:jobKey/outcomes requires UUID requestId and writes a manual reviewed outcome. For kind: "interview", callers may include interviewPrepGeneration to link a post-interview reflection to an accepted or superseded prep generation for the same job. The link is nullable and invalid generations are rejected.
  • POST /v1/outcome-suggestions/:suggestionId/decision accepts, corrects, or ignores a pending suggestion and writes a reviewed outcome for accepted or corrected suggestions.
  • POST /v1/outcomes/gmail/scan runs a bounded Gmail feedback scan over known application anchors. The request accepts optional recipientEmail, limit, maxResultsPerAnchor, and windowDays values. The response returns counts plus evidence/suggestion IDs, canonical jobId values, kinds, and confidence values only; it never returns raw Gmail body text.

The web review queue records approval facts only. approve_submit is bound to the material, profile, URL, and dry-run evidence shown in Apply Review; stale bindings, missing dry-run evidence, and invalid partial-run overrides are refused before the decision row is written. The live-apply gate is enforced again at the Python worker's claim transaction. approve_submit does not dispatch browser submission, and approve_dry_run does not start a dry run. Live POST /v1/jobs/:jobKey/actions/apply also performs an early repeat check and returns 409 repeat_application_blocked, repeat_application_confirmation_required, or repeat_application_override_consumed before dispatch. This is usability feedback, not the authority: the Python launcher recomputes the same evidence inside its BEGIN IMMEDIATE claim transaction and atomically consumes one matching override. Dry-run dispatch bypasses only the repeat check and cannot establish an application fact. Manual outcomes and suggestion corrections require canonical ISO-8601 UTC occurredAt timestamps when the field is supplied. Application outcome responses include nullable interviewPrepGeneration; it is set only for linked manual interview reflections.

Manual Action Retry Contract ​

Manual outcomes (including prep-linked reflections) and Outreach send logs require requestId as a UUID. It is scoped by the trusted local tenant, operation (manual-outcome, interview-reflection, or outreach-send-log) and canonical target (job or thread). Job URL aliases resolve to the same canonical job scope. The API never takes tenant authority from the request body.

The first accepted request atomically persists its canonical fact and one domain event bearing the durable receipt. Internal SQLite metadata binds validated fields and the accepted fact; the event payload exposes safe references rather than a guessable digest of private notes. Equal retries, including concurrent requests, lost HTTP responses and API restarts, return the original result IDs and timestamps. An omitted occurredAt remains omitted in the fingerprint; it does not become the generated first-acceptance timestamp on retry.

Different validated fields under the same scope and identity return 409 { ok: false, error: "action_identity_conflict", message } without applying any mutation. Invalid or inconsistent persisted receipt bindings also fail closed. Missing/malformed identities return 400 before mutation. An identity in another tenant, operation or target cannot retrieve this receipt. A deliberate new action uses a new identity and may append another fact/event.

Replay reads the immutable canonical fact before current approval, offer-source or prep checks, and cannot overwrite later thread/review/reminder state. Fresh actions retain those checks. Reflections accept only stored accepted or superseded prep generations for the same job. Private note/reflection text stays in canonical private history, outside receipt events and conflict errors. Suggestion-decision semantics and external delivery authority are unchanged.

These routes create application_review_decisions, application_repeat_overrides, application_repeat_override_consumptions, application_repeat_audit, application_outcomes, application_email_evidence, and application_outcome_suggestions idempotently in SQLite. Gmail scanning searches only bounded post-application windows for anchors already known locally, using recipient, employer/ATS, title/company, application URL/domain, and application timing signals. Full Gmail bodies are read and stored only after metadata confidently links to a known application, with provider message ID dedupe. Outcome notes and linked email bodies may be stored locally, but job_events.payload_json stores only safe IDs, kinds, sources, timestamps, confidence values, prep-generation links, link signals, and note/body presence flags.

Contacts ​

The Contact & Outreach context exposes local, loopback-only JSON routes for contact records and supervised research. There is still no send or drafting route, and nothing here sends anything. Contact-record writes are hosted directly in the TypeScript API (apps/api/src/contacts.ts): they write the canonical contacts / contact_attributes rows, append durable, SSE-visible events to job_events (entity_kind = 'contact', entity_ref = <contactId>), and refresh the contact_projections read model. The Python worker's SqliteContactRepository writes the same tables and event types.

Sensitivity: attribute VALUES (names, emails, notes) live only in contact_attributes.value_json and reach the client solely through the read DTOs below — never in event payloads, projections, logs, or telemetry. Every rendered fact carries provenance (sourceKind, sourceRef, captureMethod, capturedAt, confidence, userConfirmed).

  • GET /v1/contacts lists contact summaries from contact_projections. Optional jobId and employer query parameters filter by the linked application or company. Each ContactSummary carries contactId, displayName, role, employer, jobId, attributeCount, confirmedCount, sourceKinds, allConfirmed, and timestamps.

  • GET /v1/contacts/:contactId returns a ContactDetail — the summary fields plus attributes[], each with its value and full provenance. An unknown id returns { ok: false, error: "contact_not_found" }.

  • POST /v1/contacts creates a contact (ContactCreateRequest: role, optional employer / jobId, and attributes[]). A contact must link to at least one of employer / jobId, or the route returns invalid_contact. Created facts are tagged sourceKind = user_entered.

  • PATCH /v1/contacts/:contactId updates a contact's link, role, and/or attributes (ContactUpdateRequest). Replacing attributes re-tags them as user_entered; an unknown id returns contact_not_found.

  • DELETE /v1/contacts/:contactId soft-deletes a contact and returns { ok, contactId, deletedAt }.

  • POST /v1/contacts/import previews or imports a contact list. The reviewed request is { filename, format: "csv" | "vcard", mode: "preview" | "commit", content }. Preview parses and checks duplicates without writing contacts, attributes, events, or projections. Commit reparses and checks duplicates against current canonical contacts and the input batch in its transaction; preview is not a reservation. The legacy { filename, csvText } request remains a direct CSV commit for existing callers. Every saved fact retains sourceKind = user_imported_list, sourceRef = <filename>, captureMethod = manual, confidence = 1, and userConfirmed = true. Records linking to neither employer nor application are invalid/skipped. The contact import guide defines the supported vCard subset and unsupported-field behavior. No remote URLs or embedded media are fetched.

    Responses include { ok, format, mode, imported, skipped, contactIds, summary, items }. summary contains total, ready, duplicates, invalid, and unsupported. Each item includes its input index, status (ready, duplicate, or invalid), displayName, employer, jobId, role, parsed attributes, issues, optional duplicate match, and nullable importedContactId. Issues carry code, message, severity (warning or error), and nullable property. Duplicate matches identify scope (existing or batch) and the corresponding contactId or itemIndex. unsupported counts records with at least one unsupported-property, parameter, version, or encoding issue, so it can overlap other counts. skipped counts records not imported by this call (all records in preview). Preview returns no imported IDs; item facts are response-only preview data. Unsupported properties are not copied into events or canonical facts.

Contact DTOs and request/response schemas live in packages/contracts (ContactSummary, ContactDetail, ContactAttributeDto, ContactFactProvenance, and the create / update / import / delete request and response shapes).

Contact research ​

Supervised research (Phase 2) adds four routes. The run is not a direct SQLite write — the API dispatches the run_contact_research JSON-RPC method to the Python worker, which starts ContactResearchWorkflow on Temporal. Fetching routes only through the merged politeness gateway against the conservative per-source opt-in allowlist (no public source is auto-fetched by default; login-walled URLs route to manual capture). Candidates land needs_review and require an explicit confirm command before becoming stored contact facts (INV-4).

  • GET /v1/contacts/research lists ContactResearchTaskSummary rows from contact_research_task_projections; optional jobId / employer filters.
  • GET /v1/contacts/research/:taskId returns a ContactResearchTaskDetail — the summary plus sourceAttempts[] (the per-source outcome audit) and candidates[], each with its proposed attributes[] (value + provenance). An unknown id returns { ok: false, error: "research_task_not_found" }. Acquisition failures use the distinct outcomes response_too_large, acquisition_timeout, redirect_failed, transport_failed, and http_error, with an actionable detail. They are not no_candidates.
  • POST /v1/contacts/research (RunContactResearchRequest: optional employer / jobId — at least one required — plus opted-in sources[]) mints a taskId, pre-creates a queued task, dispatches run_contact_research, and returns 202 { ok, taskId, runId, workflowId, status }.
  • POST /v1/contacts/research/:taskId/candidates/:candidateId/confirm (ConfirmContactCandidateRequest, optional role) promotes a needs_review candidate into a stored Contact (preserving its research provenance, marking it user-confirmed) and returns { ok, contact, task }. A candidate that is unknown or already resolved returns research_candidate_not_found / invalid_confirm.

The run_contact_research JSON-RPC method (params taskId, optional employer / jobUrl, sources[], llmModel) is a workflow-mode method returning the { runId, workflowId, firstExecutionRunId } start shape.

Outreach drafts ​

Outreach drafting (Phase 3) generates truthful, reviewable messages. Draft generation and revision are not direct SQLite writes — the API dispatches the synchronous generate_outreach_draft JSON-RPC method to the Python worker, which runs structured synthesis, a separate claim-verification determination and the existing quality judge, then persists the accepted draft and source anchors as one generation. Mechanical checks validate IDs, verbatim citations and exact values; semantic failures use the existing repair/fail path. Draft approval and rejection are simple lifecycle transitions hosted in the TypeScript API (apps/api/src/outreach.ts); approval is HARD-gated on the persisted gate_results_json.passed (INV-5). There is no send route anywhere — an approved draft is copied out by the browser clipboard, never sent (INV-1).

Sensitivity: published draft bodyText, gate results, and claim provenance live in canonical outreach_drafts and reach the client through the thread detail read below. A stale completion returns its private unpublished proposal directly to its caller; it does not enter canonical history or event payloads; the draft-lifecycle events written to job_events (entity_kind = 'outreach', entity_ref = <threadId>) carry only ids, kinds, generation, and timestamps.

  • GET /v1/contacts/:contactId/outreach returns the OutreachThreadDetail for a contact (optional jobId query resolves the application-scoped thread), or { ok: true, thread: null } when no thread exists yet. The detail carries the thread summary plus each generation's bodyText, gateResults, and claim provenance.
  • POST /v1/contacts/:contactId/outreach/drafts generates a new draft (GenerateOutreachDraftRequest: optional kind, jobId, applicationRole, llmModel). It resolves or mints the thread id, dispatches generate_outreach_draft, and returns { ok, thread } with the freshly gated candidate. Requires a ready worker (503 otherwise).
  • POST /v1/outreach/threads/:threadId/drafts revises the thread (ReviseOutreachDraftRequest: editedBodyText, optional kind, applicationRole, llmModel). The edited body becomes a new generation and re-runs the identical gates. The response returns { ok, thread, publication }; publication contains the worker's threadId, draftId, and generation for this operation. The thread readback can be null or fail to contain that exact draft; callers must treat an absent or mismatched acknowledgement as unconfirmed and inspect the current history before retrying.
  • POST /v1/outreach/threads/:threadId/drafts/:draftId/approve approves a candidate draft. It returns 409 { error: "draft_gates_not_passed" } when the persisted gates did not pass (INV-5), 404 { error: "outreach_draft_not_found" } for an unknown thread/draft, and 400 { error: "invalid_outreach_transition" } when the draft is not awaiting approval. Approving supersedes the previously approved draft.
  • POST /v1/outreach/threads/:threadId/drafts/:draftId/reject rejects a candidate draft (RejectOutreachDraftRequest: optional reason); the last approved draft is left untouched (INV-5).

Both generation and revision can return HTTP 409:

json
{
  "ok": false,
  "error": "outreach_publication_conflict",
  "message": "Outreach changed while this draft was being verified. Your edits and completed checks are retained. Compare the current thread before deliberately retrying.",
  "outcome": "conflict",
  "threadId": "thread-id",
  "unpublishedDraft": {
    "bodyText": "The completed proposal",
    "kind": "intro_request",
    "gateResults": {
      "passed": true,
      "computedAgainst": "rendered_draft",
      "determinationIds": ["claim-determination-id", "quality-determination-id"],
      "lineAnchors": [],
      "sourceContext": null,
      "fabrications": [],
      "validation": { "passed": true, "errors": [], "warnings": [] },
      "judge": { "approved": true, "score": 0.9, "criterionScores": {}, "issues": [], "notes": "" }
    },
    "provenance": []
  }
}

The registered RPC returns the strict { outcome, threadId, unpublishedDraft } variant for this case; successful results keep their existing shape and all six draft kinds. The proposal has no accepted draftId, generation or review status. Its gate results retain their determination IDs, line anchors and source context; provenance retains recorded source citations. Neither the conflict nor a failed publication creates a successful lifecycle event. Completed determinations may remain recorded independently of publication.

Refetch by contact and optional job to inspect the current thread; a competing first creation may have a different thread ID. Reconcile explicitly before retrying against that state. This protocol does not automatically regenerate or make generation and revision commands idempotent. Send-log retries are described below. The web editor and generation control retain unpublished text, completed checks and provenance separately from a mutation error across query/SSE refresh and a failed deliberate retry. The normal generation caller re-reads by contact/job after conflict, so a competing creation can resolve to the winner's different thread ID. Retained sessions belong to their recipient/job; switching targets suspends the previous controls until returning to that target. Generation additionally scopes purpose and offer revision. A comparison is required before retrying; no automatic generation retry occurs. Private proposals clear on success or explicit discard. They are not persisted across panel unmount, tenant change, page reload or navigation.

Send logging and follow-ups (Phase 4) are simple state transitions hosted directly in the TypeScript API. No route sends anything (INV-1) — send-logs records a user-attested fact, and the follow-up routes manage a surfaced-only reminder:

  • POST /v1/outreach/threads/:threadId/send-logs records that the user sent an approved draft (LogOutreachSendRequest: UUID requestId, draftId, channel, sentAt). It is the ONLY way a thread reaches a "sent" state. A fresh action rejects a draft that is not approved with 400 { error: "invalid_outreach_transition" }; an identical accepted retry still returns its original result if approval later changes. Approving and logging a send are distinct actions. The route returns { ok: true, sendLog: OutreachSendLogDto }. The immutable sendLog contains sendLogId, threadId, draftId, channel, sentAt and loggedAt. Read the current thread through the contact Outreach GET. The written OutreachSendLogged event carries safe references, the channel label, timestamps and receipt metadata.
  • POST /v1/outreach/threads/:threadId/follow-up/schedule schedules the next follow-up (ScheduleFollowUpRequest: optional dueAt, basis, submittedAt, hasLoggedReply). When dueAt is omitted the date is DERIVED from the application lifecycle (7 days after submittedAt for the first follow-up, 14 for a subsequent no-reply nudge) — a suggestion the user can edit. Emits FollowUpScheduled.
  • POST /v1/outreach/threads/:threadId/follow-up/complete and .../follow-up/dismiss mark a scheduled follow-up done or dismissed (emitting FollowUpCompleted / FollowUpDismissed); both return 400 when no follow-up is scheduled.
  • GET /v1/outreach/follow-ups/due returns { ok, followUps: DueFollowUpSummary[] } — the projected scheduled follow-ups whose due_at has arrived (isDue computed over the clock at read time). It never sends and never auto-acts.

The generate_outreach_draft JSON-RPC method (params threadId, contactId, optional jobId / kind / applicationRole / llmModel, plus editedBodyText to select the revise path) is a synchronous method — like analyze_job, it runs the LLM + gate stack inline and returns { threadId, contactId, jobId, draftId, generation, kind, status, gatePassed }. Outreach DTOs and request/response schemas live in packages/contracts (OutreachThreadSummary, OutreachThreadDetail, OutreachDraftDto, OutreachDraftGateResults, OutreachClaimProvenanceDto, OutreachSendLogDto, OutreachFollowUp, DueFollowUpSummary, and the generate / revise / reject / log-send / schedule-follow-up request shapes).

Pipeline and preparation actions ​

POST /v1/pipeline/actions/run-stage starts global/batch pipeline stage runs from the UI. The product-facing stage order is discover -> apply: the stage trigger sends discover for preparation and apply for browser automation. The low-level contracts still accept internal score, tailor, and cover values for compatibility, diagnostics, and maintenance commands, but they are not primary user-facing stages.

The request accepts stages, limit, workers, minScore, validationMode, dryRun, the default pipeline LLM model (llmModel, default default, resolved through a ready Codex/Claude/Google provider), tailoring LLM controls (tailorModels, tailorJudgeModel, tailorJudgeMinScore), and apply flags (headless, model, continuous). Discover requests may also pass sourceIds, a deduplicated list of source-registry IDs from GET /v1/discovery/sources; when present, the Python runner filters the discovery schedule before any provider crawl starts. model remains apply-only; scoring, tailoring, and cover generation use llmModel unless a tailoring generator or judge override is supplied. Low-level internal requests can still pass rescore and retailor flags for the score and tailor maintenance stages. The route dispatches the ordered stage list to JSON-RPC run_stage, which starts JobPipelineWorkflow; when discover runs, the Python runner discovers jobs, drains detail enrichment, and then drains internal preparation work for scoring, tailoring, cover generation for successfully tailored jobs, and artifact suppression. When the selected stage is apply, the same run_stage request remains inside JobPipelineWorkflow, which delegates to ApplyWorkflow as a child workflow. The dedicated apply JSON-RPC method is used by per-job apply actions, not by this global/batch run-stage route. The route uses the command key pipeline only as the local action response handle, not as a fake job URL. Successful workflow starts return 202 with the queued workflow ID. Workflow-start failures return 200 with the dispatcher-derived failed action. Queued or accepted starts expose that workflow ID to the Pipelines tab so the user can stop the in-flight run without resolving a fake pipeline job row. dryRun defaults to true, preserving apply safety. The apply model defaults to default, which omits --model and lets the local Claude Code configuration choose the active model.

POST /v1/jobs/:jobKey/actions/run-stage starts one job-scoped preparation pickup without resetting stage state. It accepts the internal preparation stages enrich, score, tailor, and cover, rejects product-stage starts such as apply, resolves the route key at the API boundary to the stable JobId, checks worker readiness, and dispatches run_stage for the remaining preparation sequence from the requested substage. Before dispatch, the route refreshes projections and checks that the requested substage is still pending and observably eligible; known-ineligible rows return status: "not_eligible" without starting worker work. The Jobs page uses this route for eligible viewed rows that are pending on a visible preparation substage, and starts at most one pickup per unchanged list snapshot so pending preparation continues autonomously without page-render fanout.

  • POST /v1/jobs/bulk-run-pending-preparation accepts selected jobs or all matching jobs. It does not reset stages. It selects the first eligible pending preparation substage per job (enrich, score, tailor, or cover), groups those jobs by substage, and dispatches bounded run_stage workflows with selected JobIds and the requested worker count. Known-ineligible, failed, exhausted, already-complete, low-fit, and apply-pending rows are ignored. The Jobs toolbar exposes this as continue pending prep so pending preparation backlogs are not dependent on page-local pickup.

The limit field is forwarded to every selected stage. For discover, the Python runner passes it into JobStreaming broad-board search, Workday, Smart Extract, Discovery's detail enrichment, and preparation derivation. Source families run in deterministic batches bounded by the saved max_parallel_families setting (default 1); families inside one batch may crawl concurrently, while the batch's enrichment and preparation fan-out run once afterward. The source limit still skips remaining work when the cap has been consumed, so limit: 1 remains useful for local debugging. If sourceIds is provided, unselected provider groups are not executed or recorded as skipped; selected sources that the scheduler disables or quarantines still emit skipped source-run telemetry. workers is internal activity concurrency, not the source-family batch cap. enrich remains an internal retry/diagnostic stage, not a top-level product run-stage value.

Discovery creates an immutable DiscoveryExecutionRef from the running Discover workflow ID and Temporal run ID, and threads it through source, enrichment, preparation, and PDF work. discovery_execution_jobs records each execution's one membership row per job with cohort (observed_this_run or existing_backlog), safe work-plan state, required steps, and the child preparation workflow ID when planned. Pending/failed plans do not mean "no work"; only an explicit planned or not-eligible decision is terminal planning evidence. The active path starts one durable JobPreparationWorkflow per planned job. The legacy preparation_work_items table and its PreparationWorkItem* events remain compatibility/read-model inputs, but they are not the ownership boundary for current Discover execution membership.

Execution-owned orchestration emits PipelineStepQueued, PipelineStepStarted, PipelineStepCompleted, and PipelineStepFailed for source planning, source families, enrichment passes, preparation fan-out, existing-backlog sweep, and PDF rendering. These events fold into pipeline_step_projections; owning contexts still emit their events such as JobScored, TailorRetailorRequested, TailoredArtifactsSuppressed, and TailoringPolicyUpdated.

Current-version preparation maintenance actions are separate endpoints:

  • POST /v1/jobs/:jobKey/actions/rescore-current-policy dispatches rescore_job for one job.
  • POST /v1/scoring/actions/rescore-current-policy dispatches rescore_jobs_not_on_current_scoring_policy for selected or bounded active jobs.
  • POST /v1/jobs/:jobKey/actions/retailor-current-policy dispatches retailor_job for one job and can suppress the prior active artifacts; successful re-tailoring continues into cover generation for that job.
  • POST /v1/materials/actions/retailor-current-policy dispatches retailor_current_policy for selected or bounded eligible jobs and can suppress prior active artifacts.
  • POST /v1/jobs/:jobKey/actions/retry-stage resets the selected stage. With runAfter: true, preparation retries dispatch a job-scoped pipeline workflow for the remaining preparation stages (enrich -> score -> tailor -> cover, starting at the retried stage). apply retry still dispatches the explicit apply action; retries do not auto-submit applications unless the requested stage is apply. Because runAfter: true requires dispatch, the route checks worker readiness before changing stage state. If the worker is missing, stale, or mismatched, it returns 503 { ok: false, error: "worker_runtime_unavailable", worker }; the prior stage row, attempt/error metadata, and event history remain unchanged and no dispatcher call occurs. With runAfter: false, the route is an intentional local-only reset and does not require a Temporal activity worker. Enrichment resets require the local Python RPC runtime and return 200 only after the reset commits. They preserve accepted enrichment content and attempt history, clear terminal stage fields and execution ownership, and retain the stage attempt count unless resetAttempts is true or the stage was exhausted. An RPC or persistence failure returns an error rather than a successful reset; a rejected persistence transaction leaves the previous state and events intact. Mixed bulk cohorts containing Enrich commit through the same service; if a selected row is no longer failed/exhausted, the complete cohort is rejected. For an already enriched LinkedIn job with no application URL, send { "stage": "enrich", "runAfter": true, "refreshApplyUrl": true } to inspect only that posting through the live extension. This bounded action does not reset Enrich or dispatch Score, Tailor, Cover, or Apply. It requires an accepted description and an HTTPS linkedin.com or www.linkedin.com/jobs/view/:id locator; invalid or ineligible requests return 400 or 409. A failed target check appends an application-target outcome while retaining the accepted description, score, and materials. The rendered page must identify the selected posting. The extension reports only controls whose browser-computed visibility and narrow selected-header job identity it can verify; unbound, hidden, or ambiguous controls cannot supply a target.
  • POST /v1/jobs/bulk-retry-failed accepts selected jobs or all matching jobs. With the default runAfter: false it only resets each retryable failed stage to pending. Attempt-budget failures are included even when their legacy stored marker is non-retryable; the same transaction resets the attempt count to zero. With runAfter: true, the API groups reset preparation stages (enrich, score, tailor, cover) and dispatches bounded batch run_stage workflows with the selected job URLs and requested worker count. The route records pipeline workflow metadata plus per-job StageQueued events with source: "bulk_retry_failed" so later debugging can tell which action picked up the reset rows. Enrich cohorts can reset and dispatch while the extension is offline. Worker readiness still precedes reset; an unavailable worker returns 503 worker_runtime_unavailable without those mutations. Acquisition later prefers a connected extension or selects guarded anonymous access before fetching. apply failures are reset but not auto-run from the bulk retry route.
  • The active Jobs bulk toolbar exposes retry all failed outside the failed state filter. It posts the current Jobs filters with state: failed and deleted: active, sets runAfter: true, and sends the saved pipeline worker controls so users can recover failed substages from pending or mixed-state views without selecting each failed row first.
  • The active Jobs bulk toolbar also exposes continue pending prep, posting the current Jobs filters with state: pending and deleted: active to the bulk pending-preparation endpoint. The endpoint still filters out application work, so this control never auto-submits applications. Pending Enrich cohorts can dispatch with an offline extension; worker readiness and stage eligibility remain required. Accepted work returns the normal queued/accepted response.

First-time manual tailoring is not a re-tailor action. The job detail stage timeline exposes POST /v1/jobs/:jobKey/actions/tailor on the internal tailor stage, dispatching JSON-RPC tailor_job for the selected job only. That explicit user action records a TailorRequested audit-history event, overrides the default low-fit auto-tailoring gate for the selected job without changing the batch minScore behavior, and immediately continues into cover generation when tailoring succeeds.

Other per-job action routes: POST /v1/jobs/:jobKey/actions/apply dispatches the apply JSON-RPC method (and so ApplyWorkflow) for one job; POST /v1/jobs/:jobKey/actions/generate-materials resolves omitted stages from saved coverLetterGeneration: Tailor by default, Tailor → Cover when enabled. While disabled, Cover is omitted from mixed requests and a cover-only request returns 409 cover_letter_generation_disabled without dispatch. Accepted requests return 202 when the worker is ready; POST /v1/jobs/:jobKey/actions/generate-interview-prep dispatches the explicit generate_interview_prep workflow action for one job and returns 202 when queued (200 for a completed dispatch response); POST /v1/jobs/:jobKey/actions/cancel requests cooperative cancellation of that job's in-flight work; and POST /v1/jobs/:jobKey/actions/mark-applied / POST /v1/jobs/:jobKey/actions/mark-skipped record manual pipeline outcomes without browser automation.

The analyze_job JSON-RPC method (params jobUrl, optional force) produces or returns the canonical employer analysis for one job independently of a full tailor. Unlike the workflow-mode tailor methods, it runs synchronously: it executes the three-SDK analysis ensemble to completion (no wall-clock timeout) and persists the canonical analysis, then returns { jobUrl, generation, cacheKey, cached, legsAttempted, legsSucceeded, degraded }. The same analysis is produced automatically as the front-half sub-step of tailoring, so a re-tailor reuses the cached analysis (keyed by posting snapshot hash) rather than re-reasoning; force: true recomputes and supersedes the prior generation. The standalone inspector surface can build on the same method, persistence, and read path.

The generate_interview_prep JSON-RPC method starts InterviewPrepWorkflow and returns the normal workflow-start shape (runId, workflowId, firstExecutionRunId). It forwards the bounded selection/context fields from the interview contract, preserves workflow/run identity and completed-run reuse, and uses standard spend preflight and activity heartbeats/retries. Current approved materials use verified artifact bindings; the maximum bullet-provenance generation is not approval proof. Failed generations remain audit history and retain the last accepted prep. This is an explicit Materials action, outside automatic Discovery/Tailor and without a practice or live-assistance contract.

The minimum fit score is a live eligibility threshold, not a scoring policy version. Lowering it can make existing persisted scores eligible for tailor_resume; raising it can make active artifacts ineligible and enqueue suppress_tailored_artifacts. Neither threshold path invokes the scoring LLM.

The existing saved profile and Discovery settings are authoritative. Literal role titles and location/work-model controls become board parameters directly; planning needs no model call or additional confirmation endpoint. Saved exact title exclusions remain literal user-authorized filters. Saved criteria travel verbatim to posting and scoring determinations. No alias table expands titles, geographic token list removes a source, or lexical recall floor decides admission.

Saved location and work-model rows remain paired. Work models are exact taxonomy codes or product labels, with comma-separated selections within a row. A location-less Remote row keeps an empty board location. Unsupported controls produce the non-retryable invalid_saved_work_model planning failure; readers, scoring and network identity do not materialize board controls.

JobStreaming executes supported source search/filter parameters. Support varies by provider; the current JobCtrl consumer does not display the SDK’s capability warnings. Structurally valid fetched listings are stored under exact identity and proceed to full-posting analysis/scoring; there is no separate metadata-only intake model, listing-decision endpoint or batch/model setting. Direct company ATS adapters may return a whole listed feed. Literal exact-title exclusions, source-native listing flags, security checks and run limits remain mechanical. Broad-board capture, ingestion and filtered receipts are durable before provider checkpoint acknowledgement; retries preserve idempotency and lease fencing. Historical intake captures and envelopes stay under the frozen native schema. Each runnable family re-ingests up to 100 unconsumed source payloads before fetching, honoring literal exclusions and the remaining new-job limit. Consumption follows successful ingestion and retries use stable observation/event IDs; historical verdicts and preferences are never read.

Explicit URL import fetches and interprets the requested page, then creates or re-observes its canonical job. Saved search exclusions do not override an explicit import. A blocked page can still require manual capture; page/extraction model failures remain distinct and cannot create a guessed job.

Worker runtime and health ​

The JSON-RPC worker is launched with the API runtime appDir as JOBCTRL_DIR, so API reads, SSE, and Python automation all use the same local SQLite database. The API also passes expectedAppDir and expectedDbPath into worker-started workflows. Worker activities verify those runtime values before writing, and fail non-retryably if the Python worker is connected to a different local app directory or SQLite database. The worker writes worker_runtime_heartbeats into the same database; GET /v1/health returns the API app/database identity plus the latest Python worker heartbeat status (healthy, missing, stale after 45 s, or mismatched when the worker points at a different app dir/database) and an llmSpend block (status: ok | over_budget, today's estimatedUsd from the local llm_spend metering table, and configured dailyBudgetUsd from config.json — default 25, 0 = unlimited). Its lanes object reports every known product lane with input, output, total, limit, remaining, unlimited, and ok | over_budget status. Lane limits are read from laneTokenLimits; unknown keys and non-integral or negative values are rejected by PATCH /v1/settings. The top-level spend status continues to represent the global USD ceiling so an exhausted lane does not make unrelated lanes unavailable. The response also includes worker startup concurrency metadata (maxConcurrentActivities, activityExecutorMaxWorkers). The web topbar surfaces missing or stale worker heartbeats, the Settings page surfaces the worker activity-slot configuration, and the pipeline stage trigger blocks new worker-backed actions until the worker is healthy. Server-side, worker-backed action routes are gated by the same check and return 503 { ok: false, error: "worker_runtime_unavailable", worker } while the heartbeat is missing, stale, or mismatched. Action routes share dispatch semantics: a queued Temporal workflow start returns 202 with the workflow ID, while synchronous local results return 200.

When exact-schema admission fails at startup, /v1/health returns 503 with { ok: false, error: "database_migration_required" | "database_schema_incompatible", currentVersion: number | null, expectedVersion: number, message: string }. The first code is reserved for supported older version numbers; a newer, unknown, or malformed schema uses the second. All product /v1/* requests fail with the same diagnostic while the API remains in this mode. Its response contains no workspace path, and reopening the workspace after repair requires a restart. Non-apply pipeline runs also emit pipeline-level StageStarted / StageCompleted / StageFailed rows, and Discover emits the same lifecycle rows plus DiscoveryRunStarted, DiscoveryRunCompleted, and DiscoveryRunFailed for its broad-board, Workday, and Smart Extract source steps. Those event types are part of the SSE domain catalog, so the dashboard can refresh recent activity and source health while a long synchronous stage request is still running.

Every Temporal workflow additionally emits a Workflow* lifecycle event (WorkflowStarted at the top; WorkflowCompleted / WorkflowFailed on exit), recorded by a finalize activity and folded into workflow_run_projections. A describe-based reconciler in the worker heartbeat loop (15s) terminalizes any open run whose Temporal execution has closed or vanished (dev-server restart → WorkflowTerminated), so a killed or timed-out worker's runs terminalize on their own without a reaper. The Workflow* types are in the SSE catalog, so the /runs view refreshes as runs start and terminalize.

The JSON-RPC transport is hang-hardened end to end: the Python jobctrl rpc server dispatches each request on a bounded thread pool (responses serialized under a stdout lock and correlated by JSON-RPC id), so a slow or hung handler no longer head-of-line-blocks cancel_run or other fast calls; the TS subprocess adapter applies a per-request timeout so a dead handler rejects instead of hanging forever; and the api-client wraps every fetch in an AbortController timeout so a stuck request fails cleanly rather than freezing the browser tab. (POST /v1/_internal/rpc is a separate internal seam that validates a JSON-RPC 2.0 envelope and acknowledges it with {status: "accepted"} without dispatching anything; malformed envelopes get a JSON-RPC error response.)

GET /v1/dashboard/summary includes a bounded recent activity[] slice with activity[].eventType so the web app can render started, completed, and failed stage states from backend events instead of local button state alone. activity[].workflowId is nullable and comes from canonical workflow ownership in the event payload; it is not inferred from the job. The Dashboard renders a bounded subset of those events alongside active runs from the unified /v1/workflow-runs read model. The top-level Debug tab uses GET /v1/debug/activity for the full activity log as a paginated, sortable table; its free-text q filter also searches the canonical workflow ID. This keeps Dashboard lightweight without imposing an event-history cap and lets a run link to its exact activity stream.

Settings And Credentials ​

  • GET /v1/settings returns the non-secret values stored in the local Settings file (config.json under the app dir): spend/capacity, scoring guidance, analysis legs, tailoring generator/judge policy, and related preferences. Managed fields include effective source and activation timing. The response's preferredModels is a strict partial mapping keyed only by codex, claude, and google, and contains only nonempty saved model IDs. PATCH /v1/settings updates them; a non-null preferred ID must match the current ready-provider catalog, while null clears that provider without requiring readiness. The canonical file key is preferred_models; it stores provider/model IDs only. Settings General and Model selection use these routes; discovery scheduling uses /v1/discovery/settings instead.

  • GET /v1/credentials lists the fixed guided Claude/Google/CapSolver credential and cloud-mode keys plus the legacy OpenAI-key removal entry, with a label, storage kind, an effectiveSource (environment, native_store, config, absent, or inspection_unknown), editability, and a configured presence state (true, false, or null) only — values are never returned. Environment-owned entries are read-only. false means confirmed absent; null means presence is unknown and must not be interpreted as missing. Its top-level store capability reports kind: "config_and_native_credential_store", nativeStore (macos_keychain, windows_credential_manager, linux_secret_service, or null), whether that backend is available on the API host, unavailableReason (unsupported_platform, inspection_failed, or null), the platform's maxSecretBytes (or null when unsupported), and requiresWorkerRestart: true. Secret entries use storage: "native_store"; non-secret provider metadata uses storage: "config". Unsupported hosts return unknown secret presence without failing the endpoint. A failed native inspection returns inspection_failed and unknown presence rather than misreporting an entry as absent. Metadata can establish that a locked item exists without proving its secret is currently readable. PATCH /v1/credentials receives one submitted value and passes it to the host's native store; its response returns the same presence-only shape. PATCH /v1/credentials/batch validates the complete allowlisted operation plan, atomically applies it, and restores the exact pre-change state on failure without exposing values. The schemas reject unknown or duplicate keys with 400. An input exceeding the host helper's secure byte limit returns sanitized 400 credential_value_unsupported before any mutation; the response includes maxBytes and never the submitted value. The limits are 128 UTF-8 bytes on macOS, 2,560 UTF-16 bytes on Windows, and 8,191 UTF-8 bytes on Linux. DELETE /v1/credentials/:key removes an allowlisted entry and returns 400 invalid_credential_key for an unknown path key. Mutations on an unsupported host return sanitized 409 credential_store_unavailable; operational native-store failures return sanitized 503 credential_store_unavailable with a sanitized operational or rollback failure reason. Neither path returns raw native-store errors. GET and DELETE never return stored values; private batch snapshots are used only for compensating rollback, not provider runtime. If inspection or a mutation fails, unlock or restore access to the store and retry rather than replacing an unknown status with a new credential blindly. Separately, each Python CLI/worker process performs a bounded native-store lookup once at startup for allowlisted environment values that remain missing or empty after env-file loading. A non-empty environment value wins. A running Python worker does not hot-reload API edits, so saving or removing a native entry requires the relevant Python worker or provider process to restart before Claude, Google, or CapSolver work observes the change. Codex verification, preferred-model edits, browser capability changes, and extension pairing do not share that restart requirement.

  • GET /v1/providers/status returns sanitized status for exactly Codex, Claude, and Google: configured, ready, detected mode, and a bounded secret-free message. It is read-only and never copies ambient Codex auth. POST /v1/providers/codex/verify is the explicit mutation that may validate and copy a reusable normal Codex CLI auth.json once when isolated auth is absent, then runs the isolated Codex CLI's login status command without making a generation request. This is the same copy-once behavior retained by setup and generation: existing isolated auth is not overwritten, and the normal Codex home is unchanged. The response contains only secret-free connected, not_configured, or failed results. RPC errors and malformed provider responses become sanitized 502/503 provider-operation errors.

  • GET /v1/providers/models returns {ok: true, providers} in stable Codex, Claude, Google order. Each item includes provider, configured, ready, source, models, and an optional bounded status message. Unready providers return no models. Every ready provider uses the authenticated runtime's live model catalog (source: "live"): Codex App Server model/list, Claude Agent SDK initialization metadata, or the Google SDK model list. Model entries expose only id, displayName, and optional isDefault; account, auth, path, and secret metadata never cross the JSON-RPC/API boundary. It is read-only and never copies ambient Codex auth. A preferred-model patch returns 409 when its provider is not ready, 400 when the ID is not offered, and sanitized 503 when validation cannot obtain a live catalog.

  • GET /v1/extension/pairing-token returns the local browser-extension capability token for the Settings pairing surface; POST /v1/extension/pairing-token/rotate replaces it. The token is generated under the app dir (~/.jobctrl/extension-capability-token by default) with restrictive file permissions. These pairing routes are for CLI callers and same-origin Settings requests only; arbitrary loopback web origins and extension-origin CORS cannot mint or rotate the token.

  • GET /v1/discovery/browser-extension/status is the secret-free live bridge read model used by Settings and Pipelines. It reports connected, installationBound, installationIdSuffix, lastSeenAt, extensionVersion, pendingTasks, and activeTasks; a pairing token without a selected, heartbeating installation remains offline. Rotating the pairing token clears both the installation binding and connection immediately.

  • POST /v1/extension/discovery/claim selects one extension-local installation UUID. The first selection and every replacement require the explicit claim sent when the token is saved from that Chrome profile; automatic polling cannot select a profile.

  • The memory-only Discovery browser broker uses GET /v1/extension/discovery/tasks/next for extension heartbeat/lease, POST /v1/extension/discovery/tasks for local-worker task creation, GET /v1/extension/discovery/tasks/:taskId for worker result polling, POST /v1/extension/discovery/tasks/:taskId/result for exact-lease completion, and DELETE /v1/extension/discovery/tasks/:taskId for worker cancellation. Tasks bind exact workflow/run identity to a public HTTP(S) GET, POST, or rendered page request, cap timeouts and payloads, reject browser-owned cookie/user-agent/Sec-*/Proxy-* headers, and expire rather than persisting response bodies. Browser-origin callers cannot create/read/cancel worker tasks; every lease/result operation still requires the same local capability token and selected installation ID used by the paired extension. GET /v1/extension/discovery/tasks/:taskId/lease?leaseId=… is the active-lease heartbeat/cancellation channel. The broker admits four active/pending tasks; overflow receives 429 discovery_browser_capacity. Pending admission has a 30-second bound, while the task timeout begins on lease. The API revalidates DNS before returning a lease. The extension runs HTTP/API tasks in its service worker with redirects disabled and applies tab-scoped exact-origin allow/default-block DNR rules to rendered-page navigation. Timeout/cancel hard-aborts work, closes an inactive tab when one exists, and streams results under UTF-8 byte limits.

  • Discover requests to POST /v1/pipeline/actions/run-stage and job-scoped or bulk Enrich runs/retries do not require an extension heartbeat. Worker-ready, eligible requests can reset and dispatch offline and return the normal 202 queued/accepted response; worker readiness, stage and authorization gates still apply. Each acquisition setup uses a bounded status probe and prefers the selected installation only for literal connected: true; unavailable or malformed status selects guarded public HTTP or anonymous Playwright. A fetch failure or cancellation never selects a second transport. Neither mode uses an adopted executable or copied profile. Broker task authentication, execution authorization, installation binding and connected-mode bounds remain required. Anonymous broad-board Requests sessions validate every destination/redirect and pin direct sockets to public addresses; proxy routing is rejected because the worker cannot pin proxy-side destination DNS.

  • GET /v1/browser-capabilities returns core-browser, auto-apply-browser, and authenticated-linkedin-browser state without returning a saved executable or source-profile path. It also returns detectedBrowsers: Array<{ id: "google-chrome" | "chromium", label: string, defaultProfileAvailable: boolean, profiles: Array<{ id: string, label: string }> }> for currently detected supported installations. Profile IDs are opaque and transient; labels come only from bounded Chrome display metadata. Detection does not read cookies or session contents, launch, adopt, copy, or persist the browser, and no filesystem path crosses the RPC/API boundary. The core browser is managed and read-only. POST /v1/browser-capabilities/:capabilityId/enable accepts exactly one of { detectedBrowserId } or { executablePath }; the strict union rejects both/neither. The path is write-only. The detected-ID arm resolves the transient candidate again at mutation time, and a stale/missing ID fails closed with sanitized 400 browser_capability_failed without adopting any fallback. The matching /disable route applies immediately. The legacy-compatible POST /v1/browser-capabilities/authenticated-linkedin-browser/profile-copy requires explicit consent and accepts an explicit { detectedBrowserId, detectedProfileId }, the compatible browser-only Default selection, or { sourceProfilePath }. The explicit detected-profile arm resolves both opaque IDs again at mutation time, copies only that profile plus sanitized root metadata required by Chrome, normalizes it to the owned Default directory, and safely replaces a prior consented copy only after the new copy has staged successfully. Sibling profile directories and metadata are excluded. The manual path is cleared after the request; JobCtrl never returns, logs, or persists it. A detected candidate becomes adopted only through the separate explicit enable mutation. Current Settings does not expose this profile-copy route, and making a copy does not resolve or dispatch LinkedIn Enrich work; the selected live-profile extension owns that path.

  • Authenticated extension routes under /v1/extension/* require Authorization: Bearer <token>. A valid token allows a chrome-extension:// origin through the route-scoped CORS and unsafe-mutation guards, but only after the loopback Host check has passed.

  • Non-browser local clients can also use Authorization: Bearer <token> for unsafe local API mutations when they send no browser Origin, Referer, or Sec-Fetch-* metadata. Browser-origin requests from arbitrary loopback ports are still rejected even if they present the token.

  • POST /v1/extension/captures is the Phase 1 browser-extension capture endpoint. It accepts the manual-capture import fields plus originatingUrl, an optional stable captureId retry id, captureClient: "browser_extension", and extensionVersion, seeds a pending manual_capture_queue row with reason browser_extension_capture and source manual_capture:extension, then delegates to the existing manual-capture worker importer. Re-sending the same captureId while import is still pending updates the same queue row instead of creating a duplicate; replays of an imported row return the original ManualCaptureImportResponse; replays of a dismissed row return a terminal 2xx no-op so the extension can clear local retry state without reopening the dismissed capture.

  • GET /v1/extension/autofill/profile returns the explicitly saved, whitelisted profile facts available to the extension. The response includes profileVersion and whitelisted fields[] with path, label, value, and profile source metadata. It intentionally excludes profile password, resume content, generated materials, and free-text answer drafts.

  • POST /v1/extension/autofill/mapping submits the captured form snapshot ID, profile version, question IDs/text and native option IDs. The worker returns model mappings to supplied fact IDs, with citations and a determination ID. Filling requires the user's confirmation against the same DOM snapshot; JobCtrl does not submit the form. Unavailable or invalid determinations do not produce lexical autofill proposals.

  • apps/api: Fastify API app.
  • apps/web: the React/Vite web app.
  • packages/contracts: shared schemas, DTOs, enums, JSON-RPC envelopes, and re-exported @jobctrl/domain-types.
  • packages/domain-types: pure TypeScript mirror of the Python domain model.
  • packages/api-client: typed API client.
  • apps/extension: Manifest V3 local capture extension.

The dependency direction is:

text
apps/api -> packages/contracts -> packages/domain-types
apps/api -> packages/domain-types
apps/web -> packages/api-client -> packages/contracts

The API must not depend on packages/api-client at runtime (it appears only as a devDependency of apps/api for API tests).

Commands ​

bash
corepack pnpm api:dev
corepack pnpm api:check
corepack pnpm api:test
corepack pnpm qa:test
corepack pnpm web:dev
corepack pnpm web:build
corepack pnpm extension:check
corepack pnpm extension:e2e

The API defaults to http://127.0.0.1:8766. The web app proxies /v1/* to that origin unless VITE_JOBCTRL_API_BASE_URL is set. The Vite dev-server proxy target itself is controlled by VITE_DEV_API_PROXY_TARGET (default http://127.0.0.1:8766); isolated or multi-worktree stacks override it to point the proxy at a non-default API port.

Server-Sent Events — GET /v1/events/stream ​

ArchivedSmartPostingPrepared is an internal private receipt and is omitted from SSE, including replay. Its captured posting and model envelopes are never part of the public stream. Completion and source-owner events contain references only. Generic Activity event summaries expose no receipt payload.

The API exposes a Server-Sent Events endpoint that the frontend's SseEventStreamAdapter (apps/web/src/shared/adapters/local/) consumes via the browser's native EventSource. Per docs/architecture/frontend/realtime.md §7.1, this endpoint is the single realtime channel from the worker / API write-side to the frontend's TanStack Query cache; the frontend's InvalidationRouter translates each DomainEvent into the right invalidateQueries / setQueryData calls.

setQueryData is used only when the tenant-scoped event contains enough canonical fields for an exact truthful patch: active job detail, already-registered artifact detail, workflow-run detail, or an ordered Apply-run event. Filtered/list membership, dashboards, incomplete payloads, and uncertain artifact registration use bounded tenant-scoped invalidation. Cache reconciliation does not reset view-owned filters, selection, pagination, or scroll state and never synthesizes an artifact that is absent from persisted registration.

Request ​

GET /v1/events/stream?tenantId=<tenantId>&since=<lastEventId>
Accept: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
Last-Event-ID: <lastEventId>      # set by EventSource auto-reconnect

tenantId is recommended; in local mode (apps/api/src/event-stream.tsresolveTenantId) it defaults to LOCAL_TENANT and any non-LOCAL_TENANT value is silently overridden to LOCAL_TENANT. In hosted mode (named-not-built), the server resolves tenantId from the JWT, the JWT-derived tenant is canonical, and a mismatched query-string value returns 403 Forbidden.

since is optional and is used only by the planned IndexedDB warm-start path (docs/architecture/frontend/integration.md §9.7) to resume from a persisted watermark on first connect; the browser's native EventSource auto-reconnect uses the Last-Event-ID header instead.

Response framing ​

The header block below is abbreviated; the live response also carries charset=utf-8 on the Content-Type, Cache-Control: no-cache, no-transform, Connection: keep-alive, and CORS headers.

HTTP/1.1 200 OK
Content-Type: text/event-stream
X-Accel-Buffering: no

retry: 5000

id: 12345
event: JobScored
data: {"tenantId":"local","jobId":"job-...","fitScore":8,"version":1,"scoredAt":"..."}

id: 12346
event: ResumeApproved
data: {"tenantId":"local","jobId":"job-...","artifactId":"...","generation":2,"approvedAt":"..."}

: keepalive

event: heartbeat
data: {"watermark":12346}

# Sent when database access or polling fails:
event: error
data: {"code":"event_stream_unavailable"}

Each frame:

  • id: <event_id> — the row's event_id from job_events. The browser echoes this as Last-Event-ID on auto-reconnect.
  • event: <event_type> — a discriminator accepted by the shared runtime DOMAIN_EVENT_TYPES registry (e.g., JobScored, ResumeApproved, PipelineStepStarted, ApplyRunStarted). The parser also requires data to decode to a JSON object before dispatch.
  • data: <payload_json> — the payload, ready for JSON.parse.

Dry-run completion ​

event: DryRunCompleted carries the existing launcher payload inside the SSE { tenantId, occurredAt, payload } envelope. Canonical payload.jobId identifies the job. Lifecycle keys are preserved as emitted: run_id, result: "dry_run_complete", finished_at, nullable duration_ms, dry_run: true, nullable numeric worker_id and nullable string model. Evidence fields are coverage (full or partial), blocked_channels (strings), allowed_navigations (evidence objects), nullable materials_generation, application_url, and profile_version.

The shared event factory and browser use these names without camelCase normalization. The event invalidates affected projection reads and remains separate from ApplicationSubmitted: it never establishes submission.

Tenant filtering (COALESCE on the row, not the request) ​

The server filters job_events with the COALESCE on the event row's extracted tenant — falling back to the literal 'local' string when the row's payload_json lacks a $.tenantId key:

sql
SELECT event_id, event_type, payload_json, occurred_at
FROM job_events
WHERE event_id > ?
  AND COALESCE(JSON_EXTRACT(payload_json, '$.tenantId'), 'local') = ?
ORDER BY event_id ASC
LIMIT ?

The right-hand ? is the resolved request tenantId (per resolveTenantId above — LOCAL_TENANT in local mode; JWT-derived in hosted). The COALESCE guarantees that tenantless local rows still match the local-mode filter without a write-side backfill.

Tenant scope is mandatory; there is no "all tenants" mode (docs/architecture/frontend/realtime.md §7.8).

Resume-position precedence ​

  1. Last-Event-ID HTTP header (preferred — populated by EventSource auto-reconnect).
  2. ?since=<lastEventId> query string (fallback for IndexedDB warm-start).
  3. MAX(event_id) on job_events for the resolved tenantId (default if neither is supplied — first connect streams from the current tail with no backfill).

If both header and query are present, Last-Event-ID wins. When the database is absent or unavailable at first connection and no resume position is supplied, the cursor starts at zero because the current tail cannot be read; restoration can therefore replay historical events. The server retains an established subscriber's cursor across a database failure and resumes after it when the observed database path identity and schema remain unchanged.

Database failures ​

If opening, validating, or polling the event database fails, the API sends each subscriber an error event with code event_stream_unavailable and keeps the stream open. The browser adapter treats this event as disconnected. The event contains no SQLite error, path, or event data. Keepalive comments continue, but heartbeats are suppressed while the stream is unavailable. The server retries database access at most once every five seconds while subscribers remain; after a successful poll of the same database, it resumes ordered, tenant-scoped delivery and sends a heartbeat. The stream retains its first accepted SQLite connection across read failures and checks the path identity and schema before and after reads before publishing their results. If no valid database was ever opened, the first valid database can be admitted automatically. Closing the last subscriber stops polling. If the database has never existed since the stream was registered, it remains a quiet stream until it appears.

Once a valid database has been opened, an observed file identity or schema change cannot prove that its event history continues the old one. The stream stops polling, sends event: error with code event_stream_workspace_changed, and stays degraded for all subscribers without replay or heartbeat. That frame includes an empty id: field so an EventSource that receives it clears its saved Last-Event-ID for native reconnects. It cannot clear an explicit ?since in the request URL; callers must discard that cursor when creating a fresh subscription.

The web adapter keeps the old subscription in workspace_changed status even if its transport reconnects, and ignores its domain events. The UI instructs the user to stop JobCtrl and inspect the workspace using the stopped restore procedure, then restart the JobCtrl runtime and reload the page for a fresh subscription. A machine reboot is not part of this recovery. If a tab was offline and never received the reset frame, its old native cursor can survive an API restart; numeric event IDs cannot establish history continuity across that restart or an unsupported in-place database rewrite. SQLite schema rejection remains enforced; this behavior does not repair or modify the database.

Path checks do not prove the identity of the SQLite handle opened during first admission. If the live path changes from database A to B and back to A while the connection is opening, SQLite can open B while both path checks see A; the stream can then publish B's events without a workspace-change warning. Replacing or restoring a database while JobCtrl is running is unsupported. Stop the runtime before using the restore procedure, then restart JobCtrl and reload the page for a fresh subscription.

Cadences ​

  • retry: 5000 — 5 s reconnect baseline (the browser respects this; no application code).
  • : keepalive — comment line every 15 s, keeps reverse proxies and CDNs from idling the connection out.
  • event: heartbeat with data: {"watermark":<event_id>} — every 30 s, so the client can verify liveness even when no domain events fire. The frontend's AppShell renders a "connection lost" banner if no heartbeat or domain event arrives for 30 s.

Reconnect backstop ​

On reconnect after a "closed" status of more than 30 s, the frontend's EventStreamProvider fires a one-shot queryClient.invalidateQueries() to recover from any events lost during the gap. Last-Event-ID covers the common case; the full invalidation is a backstop.

Saved posting availability ​

POST /v1/jobs/:jobId/actions/check-availability requires a canonical UUID and an empty strict JSON body. It resolves the saved local job, releases its read-only connection, and dispatches JSON-RPC check_posting_availability with tenantId, jobId, expectedAppDir, and expectedDbPath. It returns 202 { ok: true, status: "queued", runId, workflowId }; invalid input, missing jobs, unavailable dispatch and malformed acknowledgements return errors. A repeated start uses the existing availability-{tenantId}-{jobId} workflow.

JobSummary.availability is additive on Jobs list/detail. It contains:

FieldAuthority
jobId, postingUrlCanonical job and the URL bound to this observation.
verdict, reason, method, lastAttemptedAt, evidenceRefLatest completed attempt; unknown is never replaced by an earlier success.
lastSuccessfullyVerifiedAt, lastSuccessfulState, lastSuccessfulEvidenceRefLast successful verification, preserved across unknown attempts.
nextDueAtPersisted active/unavailable cadence or failure backoff.
overdue, checkInProgressRead-time wall-clock calculation from due time and the fenced job lease.
Optional requestAn explicit deferred command's status, reason, requestedAt, and nullable retryAt, independent of actual attempt/success clocks. Unchanged refusals coalesce; automatic/preflight refusals create no user request. A later observation clears older feedback.
lineageAt most 24 entries containing sourceUrl, nullable finalUrl/status/rawHash, acquisition method, and optional bounded signals (kind, value, optional past). Response-less failures have no fabricated hash.

The raw observation also retains source and exact provider identity in the Enrichment ledger. No raw page or accepted material text is exposed by this field. GET computes freshness without employer requests, dispatch or writes. JobAvailabilityObserved carries jobId, verdict, lastAttemptedAt and nextDueAt as an additive SSE event. Internal AvailabilityLeaseChanged records reservations and deferred command results. Records with a canonical jobId invalidate that detail and tenant lists; workspace/host records do not.

Preparation refreshes active evidence older than six hours and permits usable content with unknown availability. The automated unknown path requires an exact review binding approved within the preceding 15 minutes. Dry-run or bound human-reviewed Apply may proceed while unknown; unattended Apply requires active evidence within 15 minutes. Owned email intent rechecks the original posting and current bound review for unknown evidence. Confirmed closure or a changed posting stops work. Local admission refusals do not change observation clocks or evidence backoff; accepted content, materials, decisions and outcomes remain intact. The Enrichment guide owns the complete acquisition, cohort and retry policy.

Accepted Material DOCX Exports ​

POST /v1/jobs/:jobId/material-exports requires a canonical UUID job owned by the local tenant. It accepts a strict operation union; extra fields are refused:

OperationRequest fields
historyoperation: "history"
exportoperation: "export", sourceArtifactId (1–240 characters), positive integer expectedGeneration, lowercase 64-character expectedSourceSha256 and expectedMetadataSha256, and paper: "A4" | "Letter"

Both return 200 with tenantId, jobId, sources, exports and exportedArtifactId (null for history, the new ID for export). Sources contain artifactId, generation, kind (tailored_resume or cover_letter), nullable locale, source sha256, metadataSha256 and unavailableReason. Missing or invalid source files remain listed with an unavailable reason and null hashes; only approved, unsuperseded text sources are offered.

Each export receipt has contract: "accepted-material-docx-v1", artifactId, tenantId, jobId, sourceArtifactId, generation, kind, sourceSha256, sourceMetadataSha256, settings, settingsSha256, output sha256, createdAt and warnings. Settings contain format: "docx", layout: "semantic-v1", paper and the resolved source locale (und if unrecorded). No file paths, source prose or private source metadata cross this boundary. The UI does not request translation or override locale.

Synchronous RPC accepted_material_exports takes exactly tenantId, jobId, expectedAppDir, expectedDbPath and request. Runtime identity is checked before opening the database. The API validates response tenant/job binding and, for export, the returned receipt's selected source, generation, hashes and paper. No provider, model or Temporal workflow is involved.

Invalid requests return 400; missing jobs return 404; stale generation, source edits, lost acceptance and missing source bytes return 409; unsupported source content/settings return 400 or 422; forbidden paths return 403. Transport, malformed results or unknown worker errors return safe 503 codes. Unknown exceptions never pass private worker diagnostics to the browser.

GET /v1/artifacts/:artifactId/download.docx accepts a registered numeric export ID. It reads the local tenant's approved material_docx receipt from job_artifacts, checks tenant/job binding, refuses symlink/foreign paths, and verifies the file's SHA-256 before returning any bytes. It returns the DOCX media type, Cache-Control: no-store, X-Content-Type-Options: nosniff, and attachment filename resume-gN-PAPER.docx or cover-letter-gN-PAPER.docx. Missing IDs/files return 404, forbidden paths 403, and malformed receipts or changed bytes 409. An export remains downloadable after its source is superseded. This read never changes an approval or registers another artifact.

The endpoint and browser download are unavailable in the public demo. See Editable DOCX Export for content preservation and layout limits.

Material Locale Variants ​

POST /v1/jobs/:jobId/material-locales uses a canonical UUID Job ID and a strict operation union. Extra fields, unknown operations and malformed revisions fail before worker dispatch. The runtime-bound synchronous RPC is material_locale_variants; params contain tenantId, jobId, expectedAppDir, expectedDbPath and request.

OperationRequired request fields besides operation
historyNone
generatesourceArtifactId, sourceLocale, targetLocale, positive expectedGeneration, positive expectedProfileVersion
reviewUUID revisionId, positive expectedVersion, dimension (terminology or formatting), decision (accepted or rejected), note (at most 2,000 characters)
accept / rejectUUID revisionId, positive expectedVersion
exportUUID revisionId, positive expectedVersion, format (txt, html, pdf, docx)

Success returns { supportedLocales, profileVersion, sources, variants }. sources lists approved resume/cover-letter artifact IDs, kinds and generations. Each variant records the versioned contract, revision/version, tenant/job, source artifact/generation/hash/text, source/target locales, canonical facts and profile version, translation/verification IDs, provider/model, prompt/schema, ordered line mappings, verifier reasons, findings, independent reviews, acceptance history, exact localized text/hash and registered export references. authorityStatus is recorded or unavailable, derived by joining persisted IDs. It does not recompute semantic judgments. Unsupported-language revisions have null determination IDs and explicit findings, never fabricated equivalence.

Successful review/accept/reject/export operations increment version. A stale source or revision returns 409, an absent job 404, invalid requests or failed acceptance gates 400, and unavailable transport/invalid worker results 503. Errors use { ok: false, error }; source/model prose is not an error code. Accepted source files, prior accepted locale revisions and existing export bytes survive every failed operation. Application approval is separate.

For language capabilities, review flow and CLI examples, see Reviewed Locale Variants.

Screening Answer Contract ​

GET/POST /v1/jobs/:jobId/screening-answers use ScreeningReadResponseSchema, ScreeningCommandSchema and ScreeningWriteResponseSchema from shared contracts. Read responses retain questions, history, library, facts, nullable sourceBinding/sourceFailure persisted determinations and safe failures. Commands reject unknown fields, bound text to 16,000 characters and require expected revisions/idempotency keys; capture questions/context are each limited to 4,000 characters. The owning route reference and action fields define behavior. Runtime identity comes from the API, never the request body. Transport failures return 503, invalid worker parameters 409, and malformed/foreign completions 502 with safe errors; body-schema failures return 400.

Company Briefings ​

GET /v1/jobs/:jobId adds optional companyBriefing: CompanyBriefingView | null. Missing configuration is null. status is current, stale, missing, needs_review or unavailable. The view carries company (URL identity, name, selected sources, configuration time/revision), nullable accepted (company/configuration IDs, observed/accepted times, source snapshots with URL/final URL/text/hash, determination ID/provider/model/prompt version and typed result), nullable lastAttempt (start/finish/failure code/revision, nullable determination ID, and rejected-identity reason/source snapshots when recorded), and staleReasons. A provenance read failure returns unavailable with null content; it never rewrites stored state.

The result contains identity, identity_reason, identity_citations and up to twelve claims. A claim has kind: company_fact | reported_event | hypothesis, text, uncertainty, nullable literal event_date, and one to three canonical source_id/verbatim quote/exact_values citations. Every source includes its observation date and the SHA-256 of its bounded captured text.

GET /v1/digest adds optional companyBriefings, at most five distinct company summaries from the latest 100 explicit job links. Each includes companyId, name, canonical jobId, nullable observedAt, status, staleReasons, and nullable failureCode. No matching by name, source fetching or model call occurs on either GET. CLI commands own configuration/refresh; there are no new write routes or JSON-RPC methods.

Provider Transport And Error Details ​

The provider routes retain their methods and paths under the shared endpoint specification. Requests ignore query/body values; the generated verification client sends an empty JSON object. Success responses are strict:

  • Status: { ok: true, providers }, with each item restricted to provider, configured, ready, nullable mode and optional bounded message.
  • Models: { ok: true, providers }, exactly three entries in Codex, Claude, Google order. Unready providers have empty model arrays.
  • Verification: { ok: true, verification: { provider: "codex", ok, status, message } }; inner ok: false with not_configured/failed remains HTTP 200.

Status and verification map RPC errors or invalid results to 502 and transport failures to 503. Models maps all three failure classes to 503. Errors are { ok: false, error, message } with the operation-specific code and fixed message from the provider compatibility table. Status RPC/transport failures say “Provider status is temporarily unavailable.”; invalid results say “Provider status returned an invalid response.” Models failures say “Provider models are temporarily unavailable.” Verification RPC errors say “Codex verification could not be completed.”, invalid results say “Codex verification returned an invalid response.”, and transport failures say “Codex verification is temporarily unavailable.” Extra/private result fields fail validation. Existing HTTP security rejection precedes provider dispatch.

This changes no #954 JSON-RPC envelope policy or durable workflow result. See the incremental migration recipe and synthetic test seams.

Cover-Letter PDF Review ​

The registered checked-client methods renderCoverLetterPreview(jobKey, body) and acceptCoverLetterPreview(jobKey, body) use these synchronous routes:

RouteRequest / result
POST /v1/jobs/:jobKey/cover-letter/preview{source, expectedAcceptedVersion, settings} → {ok:true, preview}
GET /v1/jobs/:jobKey/cover-letter/previews/:previewId.pdfTenant/job-owned immutable PDF bytes, application/pdf, Cache-Control: no-store
POST /v1/jobs/:jobKey/cover-letter/accept{previewId, expectedAcceptedVersion} → {ok:true, accepted}

source contains tenantId, canonical jobId, accepted text artifactId, its original generation and full-file textSha256. preview adds a UUID previewId, expectedAcceptedVersion, normalized settings, rendererVersion and pdfSha256. accepted contains source, acceptedVersion, pdfArtifactId, pdfSha256, pdfSourceCurrent, settings and rendererVersion. Historical unformatted PDFs use the existing defaults and a null renderer version, with pdfSourceCurrent:false unless recorded source/PDF hashes, settings and renderer establish their identity. Generation equality alone is insufficient. An unreadable canonical source blocks rendering, saving and approval. A saved receipt keeps its last accepted source identity, settings and PDF viewable; pdfSourceCurrent is false until the canonical source is readable again. materialsPreview.coverLetterText reads that canonical source ID and verifies its full-file hash. Projection ordering cannot select a different cover alias; an unreadable bound source exposes the source error instead of substitute text. Cover-only saves carry the coherent resume text/PDF selection already displayed in Apply Review, preserving its metadata and recorded provenance even when a newer partial resume generation exists.

acceptedVersion binds the accepted source/PDF identities, bytes, settings and renderer. A rejected attempt does not change it. Generation allocation still advances beyond every attempt. Unchanged material slots retain IDs across generations; DOCX export requests select the exact ID/generation pair.

Settings are strict objects with paper: Letter|A4, fontFamily: georgia|times|arial, fontSizePt: 9..14, marginMm: 8..28, lineHeight: 1..2, and paragraphSpacingPx: 0..24. Numeric values must be finite; CSS strings and extra keys are rejected. Defaults are Letter/georgia/11/17.78/1.6/14. Canonical sources must be nonempty readable UTF-8 and at most 512,000 bytes. The worker render_cover_letter_pdf checks the snapshot hash and renderer version before rendering with CSS paper-size preference enabled. Its settings must be an object; explicit null is an invalid JSON-RPC parameter, not defaults. The source and output must name source.txt and a new letter.pdf in the same owned UUID preview directory. Source, manifest and PDF reads reject symlinks and bound descriptor reads. Canonical source paths outside the workspace return 403; unowned preview IDs/manifests return 404.

pdfSha256 comes from persisted acceptance metadata. Actual bytes must match it for approval and accepted downloads; a mismatch returns 422 on download and marks the accepted source/PDF relationship noncurrent without adopting the new bytes. Normal generation persists both source-text and PDF hashes, settings, renderer and source occurrence; old unbound PDFs require explicit preview/save.

Malformed requests return 400; stale source/version/save snapshots return 409 cover_letter_version_conflict; renderer/transport failure returns 502 cover_letter_render_failed; unreadable/oversized sources or missing/corrupt PDFs return 422. A preview is readable only by its owning tenant/job. Save rechecks source bytes, accepted version and preview PDF hash inside an immediate SQLite transaction, then appends a complete material generation. Preview and accepted download bytes are identical. Existing generations and files remain historical. A repeated save of the still-current preview reconciles an uncertain acknowledgement without creating another generation. A failed save retains the preview for retry and leaves accepted rows and files untouched.

POST /v1/jobs/:jobKey/apply-review/decision with approve_submit and a cover source requires the current coverLetterAcceptedVersion alongside existing material/profile/URL/email bindings. The decision event retains the exact cover binding; API and worker approval checks reject later source, settings or PDF changes. Manual decisions and jobs without a cover retain their existing input contract. Preview-only activity and failures do not change accepted authorization.

Selected-cohort demand and milestones ​

GET /v1/evidence-map/learning returns { ok, preview: null, cohorts, milestones, truncated } through career_learning RPC. Requests bind tenantId, expectedAppDir, expectedDbPath and an optional strict command. GET accepts no query parameters. POST accepts one of:

  • { action: "preview", jobIds }: 1–30 canonical job UUIDs, including repeated selections for disclosure. Returns preview with job/source snapshots, profileVersion, selectedCount, cohortSize, analyzedCount, missingDescriptions, duplicateSelections, duplicateIdentities, confirmedEvidenceCount, excludedEvidenceCount and source fingerprint. No model, journal or profile write occurs.
  • { action: "compare", cohortId?, expectedRevision, title, asOf, jobIds, aspirations, expectedSnapshotFingerprint? }: new cohorts require revision 0; existing cohorts require their current revision. asOf is an ISO date and labels selection. The browser always supplies the preview fingerprint. Changed reviewed sources fail before spend. Description sources are bounded to 64,000 characters each and 120,000 total source characters, with at most 200 usable confirmed achievements. No-description cohorts are previewable but cannot be compared. A successful accepted revision contains immutable sources, snapshotFingerprint, determinationId, the full determination envelope and typed comparison (demands, proposals, uncertainty). Posting/candidate citations retain source_id, verbatim quote, and exact_values.
  • { action: "choose", cohortId, expectedRevision, determinationId, proposalId }: requires a current accepted comparison and exact proposal; repeated selection within the same cohort revision is idempotent. Creates a planned milestone with immutable origin, cohortRevision, proposal/completion criterion and selection time.
  • { action: "update", milestoneId, expectedRevision, status, completionEvidence }: status is planned, in_progress, completed or abandoned. Completed activity requires a nonblank completion statement. Writes retain revision history, never update Profile, fit or readiness.

Successful writes return the canonical read response. Cohorts expose staleReasons and lastAttempt; milestones expose staleReasons, immutable origin and activity history. Reads show at most 20 latest cohorts and 100 latest milestones (truncated discloses more retained entities), with 50 recent activity revisions each. Raw private CareerLearningRecorded events are suppressed from SSE. All cohorts and milestone revisions remain in the local journal.

Invalid HTTP bodies return 400. Worker validation, stale revision/source and provider failures return 409 career_learning_failed with an allowlisted safe message; worker transport unavailable returns 503, invalid worker results 502. Accepted comparisons and milestone history survive every failed refresh.

Actual Offer Terms And Decisions ​

All routes use a canonical UUID jobId, local tenant and the normal local mutation-origin boundary. No endpoint sends mail or changes pipeline/submit approval. Reads need no worker; only outreach generation/revision uses one.

RouteRequestResponse
GET /v1/jobs/:jobId/offerNoneOfferReadResponse: ok, jobId, currentTerms, currentDecision, termsHistory, decisions
POST /v1/jobs/:jobId/offer/termsexpectedRevisionId: UUID or null, terms, optional correctionReasonComplete OfferReadResponse
POST /v1/jobs/:jobId/offer/decisionsexpectedDecisionId: UUID or null, current termsRevisionId, decision: accepted or declined, occurredAt, source, optional correctionReasonComplete OfferReadResponse

OfferSource is strict: reference (1–1000 characters), quote (1–8000), capturedAt (canonical ISO timestamp) and userConfirmed: true. This is a human attestation, not a parser's semantic finding. terms.source records the overall offer; every known term carries its own source as well. The web form uses one explicitly reconfirmed source for all entered terms in a revision.

terms.cash contains at most 20 strict entries with component (base_salary, bonus, signing_bonus, commission, other), finite nonnegative amount (up to 1e12), three-uppercase-letter currency or null, period (year, month, week, hour, one_off, unknown) and source. equity and benefits are { value: 1–4000 characters, source } or null; decisionDeadline is { value: ISO timestamp, source } or null. Null and unknown remain unknown; no conversion, annualization, equity valuation or implicit total is performed.

Terms history adds revisionId and recordedAt; decisions add decisionId and recordedAt. Expected IDs must match the current record. Every terms revision after the first and every decision correction requires a nonempty reason (up to 1000 characters). A decision must reference current terms. A later terms revision leaves the prior decision attached to its original revision; it does not silently reconfirm it. History arrays retain commit order. The first terms write atomically adds one offer milestone with no prep link when none already exists.

Invalid schema/JobId returns 400 invalid_offer/invalid_job_id; absent jobs return 404 not_found; stale expected IDs, missing correction reasons and noncurrent decision terms return 409 offer_conflict. Failure preserves the last committed record.

Outreach request kind additionally accepts offer_negotiation, offer_clarification, offer_acceptance, offer_decline; offerRevisionId optionally fences the current revision. These kinds require an existing offer for the linked job. Acceptance/decline require a matching current user decision. The worker assembles canonical sources; request bodies cannot inject sources. Generated and edited drafts pass the existing claim and independent quality gates. gateResults.sourceContext contains { binding: { jobId, termsRevisionId, decisionId }, fingerprint, sources: [{ source_id, text }] }. The offer detail remains the canonical user-facing terms record.

A draft read includes offerSourcesCurrent for offer kinds. Terms/decision changes prevent approval and send logging with 409 draft_gates_not_passed; copying rechecks this current binding. Draft bodies/history remain readable. Generation failures expose safe provider_unavailable (503), budget_denied (429), offer_* source/decision conflicts (409), or structural verification codes (502); they do not forward source or provider prose. The previous approved message survives every failed replacement. No send transport exists.

OfferRecordChanged SSE carries only { jobId, revisionId, decisionId } and the ordinary tenant/time envelope. Private terms, quotations and correction reasons never enter this public payload, activity summaries or curated audit.