Tasks: 07 — Posting-Legitimacy / Ghost-Job Radar
Status legend:
[ ]pending •[~]in-progress •[x]done •[-]dropped
Phase 1 — Upstream contract (Ever Jobs) + forward-compatible client type
-
T01 — Scaffold the
legitimacy-corpusplugin and add additivelegitimacyto the Ever Jobs publicJobPostDto(upstream repo)- Files: (separate
ever-jobsrepo) newlegitimacy-corpusfeature plugin alongside dedup/liveness/merge; theJobPostDtoreturned by/api/jobs/searchand job-by-id gains optionallegitimacy: { tier: 'active'|'caution'|'suspicious', score: number, reasons: string[] }. Reuses the dedup/recheck ledger + liveness inputs (apply-control, posting age, reposting pattern, salary-vagueness, employer red flags) per spec §3.1. - Acceptance:
- Field is optional / additive — no required field added to the DTO; existing consumers verified unaffected.
tieris only everactive | caution | suspicious;reasons[]is a string array of contributing signals.- A fixture posting with off-platform redirect + perpetual age yields
tier: suspiciouswith reasons (spec §6). - Change tracked in the
ever-jobsrepo per its ownAGENTS.md(not in this tree).
- Estimate: 1 day
- Files: (separate
-
T02 — Extend Hust client
JobPostDtowith optionallegitimacy(forward-compatible)- Files:
packages/jobs-api/src/types.ts(addlegitimacy?: { tier: "active" | "caution" | "suspicious"; score?: number; reasons?: string[] }to theJobPostDtointerface);packages/jobs-api/src/types.test.ts. - Acceptance:
- A
JobPostDtowithoutlegitimacystill type-checks and is accepted (forward-compatible). - A
JobPostDtowithlegitimacyexposes the typedtier | score | reasons. - Unit test covers both present and absent
legitimacy. pnpm test -- --selectProjects jobs-apigreen.
- A
- Estimate: 0.5 day
- Files:
Phase 2 — Persist legitimacy on the synced jobs row
-
T03 — Add nullable legitimacy columns to the
jobsschema- Files:
packages/db/src/schema/jobs.ts(addlegitimacyTier: text("legitimacy_tier", { enum: ["active","caution","suspicious"] }),legitimacyScore: numeric("legitimacy_score"),legitimacyReasons: jsonb("legitimacy_reasons").$type<string[]>()— all nullable; optionalindex("jobs_legitimacy_tier_idx").on(table.legitimacyTier)). No change needed inpackages/db/src/schema/index.ts(jobsalready exported). - Acceptance:
- All three columns are nullable (no
.notNull(), no default that breaks existing rows). - Column names follow house style (snake_case
legitimacy_*); tier uses thetext(..., { enum })pattern; reasons usesjsonb(...).$type<string[]>(). - Named distinctly from any
liveness*columns (no collision with epic #4). pnpm check-typesgreen forpackages/db.
- All three columns are nullable (no
- Estimate: 0.5 day
- Files:
-
T04 — Push the schema change to the database
- Files: apply via
pnpm db:push(drizzle.config.ts → schema./src/schema/index.ts). No file edit beyond T03. - Acceptance:
pnpm db:pushcompletes;jobstable shows the three nullablelegitimacy_*columns.- Existing rows are unaffected (tier
NULLfor pre-existing jobs). - No destructive/altering of existing columns reported in the push diff.
- Estimate: 0.5 day
- Files: apply via
-
T05 — Map legitimacy in
mapJobToDband carry it through both sync paths- Files:
packages/triggers/src/map-job.ts(mapJobToDb: addlegitimacyTier: dto.legitimacy?.tier ?? null,legitimacyScore: safeNumericString(dto.legitimacy?.score),legitimacyReasons: dto.legitimacy?.reasons ?? null); verify pass-through inpackages/triggers/src/sync-jobs.tsandapps/web/app/api/jobs/sync/route.ts(both spread...mappedinto the upsert — no change expected);packages/triggers/src/map-job.test.ts. - Acceptance:
- A DTO with
legitimacymaps all three columns; a DTO withoutlegitimacyyieldsnullfor all three (no throw). - Invalid / non-finite
scorecoalesces tonullvia the existingsafeNumericStringhelper. - Existing
mapJobToDbfields unchanged (additive only). - Unit tests cover present, absent, and bad-
scorecases;pnpm test -- --selectProjects triggersgreen.
- A DTO with
- Estimate: 0.5 day
- Files:
Phase 3 — Render the benign trust badge (canvas card + both detail views)
-
T06 — Create the presentational benign
TrustBadgecomponent- Files: new
apps/web/components/canvas/trust-badge.tsx(usesBadgefrom@ever-hust/ui/badge+cnfrom@ever-hust/ui/lib/utils; props{ tier: "active"|"caution"|"suspicious"|null; reasons?: string[] | null }). - Acceptance:
- Renders benign, explained copy:
active→ "Verified-active",caution→ "Worth a quick check",suspicious→ "Some signals to review", each with a one-line "why" fromreasons[0]. - Never renders red, never the word "scam" (use neutral/amber tones — e.g. green/secondary for active, amber for caution/suspicious). A unit test asserts the rendered strings contain no "scam" and the suspicious variant is not a destructive/red variant.
- Renders nothing when
tieris null/undefined (no layout shift). - The "why" line surfaces only reasons actually present in
reasons[]— never an invented reason (Article 7); pure/presentational (memo), accessible label. pnpm lint+pnpm check-typesgreen; render unit test alongside (trust-badge.test.tsxor via the web-lib jest project).
- Renders benign, explained copy:
- Estimate: 1 day
- Files: new
-
T07 — Surface legitimacy columns through the search API and
JobCardData- Files:
apps/web/app/api/jobs/search/route.ts(addlegitimacyTier,legitimacyScore,legitimacyReasonsto thedb.select({...})block);apps/web/components/canvas/job-card.tsx(add the three fields to theJobCardDatainterface; render<TrustBadge>in the card's badge/meta row). - Acceptance:
- Search response includes the three legitimacy fields for each job.
JobCardDatacarries the fields (nullable);JobCardshows the badge; jobs withNULLtier render with no badge and no layout shift.suspiciousjobs are still rendered in the list (never filtered out) — the badge informs, it does not hide.pnpm check-typesgreen.
- Estimate: 0.5 day
- Files:
-
T08 — Render the badge on both detail surfaces (canvas overlay + jobs page)
- Files:
apps/web/components/canvas/job-detail-panel.tsx(render<TrustBadge>in the detail header with the full "why");apps/web/app/(dashboard)/jobs/[id]/page.tsx(render badge in the header badges row and/or sidebar "Job Details" card; extend the page'sgetJobselect onjobsto include the three legitimacy columns). - Acceptance:
- Badge + "why" appear on the canvas detail overlay and the server-rendered job detail page.
- Detail page keeps existing
JobPostingJSON-LD unchanged; legitimacy is presentational only. suspicious/cautionjobs remain fully viewable; copy stays benign (no red, no "scam");pnpm lint+pnpm check-typesgreen.
- Estimate: 0.5 day
- Files:
Phase 4 — Chat narration + Block-G orthogonality (fit score untouched)
-
T09 — Return legitimacy from
getJobDetailsTooland surface it via canvas-sync- Files:
packages/ai/src/tools/get-job-details.ts(addlegitimacyTier,legitimacyScore,legitimacyReasonsto thedb.select({...})and return them);apps/web/hooks/use-canvas-sync.ts(in the result-handling switch, carry the legitimacy fields through so an opened single job surfaces the badge — extend the relevantcaserather than adding a tool). - Acceptance:
getJobDetailsreturns the three legitimacy fields (null when absent);userIdstays server-injected (n/a here — this tool takes onlyjobId); long-text capping behaviour unchanged.- The canvas reflects the legitimacy badge when a job is opened from chat.
- Unit test in
packages/ai/src/tools/get-job-details.test.ts(new alongside) covers legitimacy-present and legitimacy-absent paths;pnpm test -- --selectProjects aigreen.
- Estimate: 1 day
- Files:
-
T10 — Document benign legitimacy phrasing + orthogonality in the orchestrator system prompt
- Files:
packages/ai/src/prompts.ts(getOrchestratorPrompt; mirror in Langfuse promptorchestrator-system) — add a short "Posting legitimacy / trust" note instructing the model to describe the tier benignly ("worth a quick check" / "some signals to review", never "scam"), to cite only returned reasons, and that legitimacy is separate from the fit score — it is never folded into an evaluation's number. - Acceptance:
- Prompt explains the three tiers in benign language and forbids alarmist/"scam" wording.
- Prompt states legitimacy is orthogonal to the fit score (#3) and must not change it.
pnpm test -- --selectProjects ai(prompts.test.ts) green; Langfuseorchestrator-systemupdated to match the local fallback.
- Estimate: 0.5 day
- Files:
-
T11 — Render legitimacy as evaluation Block G — beside the fit score, never inside it
- Files:
packages/ai/src/tools/evaluate-job.ts(from #3) + the evaluation drawer renderer — render Block G from the job'slegitimacyTier/legitimacyReasonscolumns when present; do not read legitimacy into any weighted dimension or the 0–100 score. - Acceptance:
- Block G shows the benign tier + "why" beside the fit score; absent when legitimacy is
NULL. - The evaluation's
score/score5/dimensions are byte-identical whether or not legitimacy is present (a unit/snapshot test asserts the score is unchanged by legitimacy). - If #3's drawer is not yet merged, this task is deferred to a follow-up and tracked in
docs/specs/ROADMAP.md(Phases 1–3 + T09/T10 still ship). pnpm test -- --selectProjects aigreen.
- Block G shows the benign tier + "why" beside the fit score; absent when legitimacy is
- Estimate: 1 day
- Files:
Phase 5 — E2E + roadmap
-
T12 — Playwright coverage for the benign badge + score orthogonality
- Files:
tests/e2e/jobs.spec.ts(extend; baseURLhttp://localhost:8443). - Acceptance:
- A
suspiciousfixture shows the benign "Some signals to review" badge with a one-line "why"; acautionfixture shows "Worth a quick check"; anactivefixture shows "Verified-active". - The rendered badge text contains no "scam" and uses no red/destructive styling.
- A
suspiciousjob is still present in the list (asserts it is not hidden). - With an evaluation present (#3), the visible fit score is the same whether legitimacy is present or null (orthogonality assertion).
pnpm test:e2egreen locally.
- A
- Estimate: 1 day
- Files:
-
T13 — Update roadmap progress
- Files:
docs/specs/ROADMAP.md. - Acceptance:
- Epic 07 progress reflects completion of the shipped phases (and notes any Block-G follow-up if #3 lagged).
- Zero competitor references confirmed across all changed files (Article 11) before commit.
- Full CI (lint, type-check, unit, E2E) green on
developbefore the PR merges (Article 10.4).
- Estimate: 0.5 day
- Files:
Notes
- Write tests alongside each implementation task; do not batch testing into a final task (T02/T05/T06/T09 carry their own unit tests; T12 is the dedicated E2E).
- Verify zero competitor references before every commit (see constitution Article 11).
- Ship forward-compatible: every Hust change tolerates a missing/null
legitimacyso Hust merges and runs green even before the Ever Jobs DTO field is deployed (Article 2 standalone-first). - Badge copy is benign and explained — never red, never "scam"; the "why" surfaces only reasons Ever Jobs returned (no invented reasons, Article 7).
- Legitimacy stays orthogonal to the fit score (#3): Block G renders beside the number and must never enter the weighted dimensions or the 0–100 result.
suspiciousis shown, never auto-hidden — do not add any filter that drops a tier.- Update
docs/specs/ROADMAP.mdprogress when an epic's tasks complete.