Spec #4 — Per-Job Liveness on the Search DTO
Status: Done (shipped 2026-06-15) · Owner: Ever Jobs → Hust · Effort: S (EJ) + S (Hust) · Phase 1 · Depends on: —
1. Problem & user value
A job board's #1 quality problem is dead listings. Ever Jobs already computes a liveness
verdict internally (active | expired | uncertain, via its liveness-http checker) — but that
verdict is not carried per-job on the public /api/jobs/search DTO that Hust consumes. So Hust
can't warn a user before they spend effort applying to a closed role.
2. Scope
In: Ever Jobs surfaces the existing liveness verdict (+ code + checkedAt) on each job in the
search DTO; Hust renders a liveness badge and warns before apply when a listing is
expired/uncertain.
Out: building new liveness detection (it already exists in Ever Jobs); posting legitimacy (different signal — #7).
3. Design
- Ever Jobs (small): add
liveness: { verdict: 'active'|'expired'|'uncertain', code, checkedAt }to theJobPostDtoreturned by/api/jobs/search(and job-by-id). The data already exists internally; this exposes it on the public contract. - Hust (small): thread the field through
packages/jobs-apitypes + the syncedjobsrow (add nullableliveness*columns), render a badge on the canvas card + job detail, and surface a non-blocking warning in the apply flow when notactive.uncertainis shown, never auto-hidden (don't silently drop possibly-live jobs).
4. Data / API
- Ever Jobs: extend
JobPostDto(additive, backward-compatible). - Hust:
packages/jobs-apiDTO type +jobstable nullable columns (livenessVerdict,livenessCheckedAt); sync writes them.
5. Plan & tasks
- EJ: add liveness to the search/by-id DTO (additive). Verify existing consumers unaffected.
- Hust: extend the client DTO type +
jobscolumns (migration) + sync mapping. - Hust: liveness badge (active=green / expired=grey / uncertain=amber) on card + detail.
- Hust: apply-flow warning when not
active(non-blocking; user can proceed). - Tests: DTO mapping (unit), badge rendering (E2E), apply warning shows for expired fixture.
6. Acceptance
- A job with a non-
activeverdict shows the badge + an apply-flow warning;uncertainis visible, never hidden; the EJ DTO change is backward-compatible; CI green; zero competitor references.
Implementation (shipped)
Hust shipped a leaner, forward-compatible version of this epic: rather than persisting
liveness columns and threading a dedicated badge component through every surface, Hust derives a
freshness signal from data it already stores (datePosted / expiresAt) and lets an explicit
Ever Jobs liveness signal override the heuristic when present. The corpus side (the actual
liveness computation + DTO field) is produced upstream by Ever Jobs.
- Client DTO contract —
packages/jobs-api/src/types.ts:JobPostDto(interface) carries an optionalliveness?: { state?: "active" | "expired" | "uncertain"; checkedAt?: string }(plusexpiresAt?). Optional/additive, so Hust type-checks and runs even when the field is absent. - Freshness engine —
apps/web/lib/freshness.ts:computeFreshness()is a pure, deterministic (nowis injectable) function returning{ state, ageDays, label }. An explicit Ever Jobslivenesssignal wins over the date heuristic; otherwisedatePosted/expiresAtderivefresh ≤14d,active ≤45d,stale,expired, oruncertain. ExportsFreshnessState,LivenessSignal,CAUTION_STATES, andisCaution(). - Spec invariant honoured — a missing posted date or an explicit
uncertainverdict is labelled "Unverified" / "Date unknown" and never hidden; only the user decides. - UI badge —
apps/web/components/canvas/job-card.tsxcallscomputeFreshness({ datePosted, expiresAt, liveness })and renders a cautionBadge(expired = red, stale/uncertain = amber, title "Freshness signal — verify before applying") only forCAUTION_STATES;active/freshjobs show no badge and no layout shift. - Tests —
apps/web/lib/freshness.test.ts(unit: fresh/active/stale/expired/uncertain paths, liveness override, missing-date) andpackages/jobs-api/src/types.test.tscover the contract. - Corpus-side production (upstream) — the per-job liveness verdict on
JobPostDtois produced by Ever Jobs Spec 740 (liveness exposed on the search/by-id DTO via?liveness=true, sourced from the existingliveness-httpchecker) in the separateever-jobsrepo, not in this tree.
Intentionally deferred (vs. the original tasks.md plan)
The richer plan in tasks.md (T03–T11) was not shipped as drafted; the freshness approach
above covers the acceptance criteria more cheaply. Still open for a future enhancement:
- Persisted liveness column (now shipped, simplified) — rather than the planned
liveness_verdict/liveness_code/liveness_checked_attriple, a single nullablejobs.livenesstext column now persists the corpus verdict (migrationdrizzle/0002_jittery_talisman.sql). The jobs-api client requests?liveness=trueby default,packages/triggers/src/map-job.tspersists it, and the read paths (searchJobs,/api/jobs/search,/api/jobs/[id],/api/user/favorites/list) select it so the card's freshness badge can prefer the explicit signal over the date heuristic.checked_atis not persisted separately (theexpiresAtcolumn, now also mapped, plusdatePostedsuffice). - Apply-flow caution (now shipped on the card) —
apps/web/lib/apply-caution.ts(applyCaution, composing freshness #4 + legitimacy #7) drives a non-blocking amber warning icon next to the job card's Apply action (tooltip explains why; Apply is never disabled). Unit-tested inapply-caution.test.ts. Still open: surfacing the samelivenessWarningthrough the AIapply-jobtool /tool-approvalcard path. - Dedicated detail-surface badge — no standalone
liveness-badge.tsxcomponent; the badge is inlined in the job card only (job detail surfaces are a future enhancement).