Tasks: 19a — Apply Copilot (HITL, never auto-submit)
Status legend:
[ ]pending •[~]in-progress •[x]done •[-]dropped
Phase 1 — Draft persistence (application_drafts table)
-
T01 — Add
application_draftstable schema- Files:
packages/db/src/schema/application-drafts.ts(new); export frompackages/db/src/schema/index.ts - Acceptance:
- House style:
integer("id").primaryKey().generatedAlwaysAsIdentity();text("user_id").notNull().references(() => users.id, { onDelete: "cascade" });integer("job_id").notNull().references(() => jobs.id, { onDelete: "cascade" });status text("status", { enum: ["drafting","awaiting_approval","approved","recorded","discarded"] }).notNull().default("drafting");draft jsonb("draft").$type<ApplyDraftPayload>().notNull()(four sections:details,proposal,qa[],terms, plusgaps[]);schemaVersion integer("schema_version").notNull().default(1);approvalGateId integer("approval_gate_id")nullable (refs the #6approval_gatesrow);createdAt/updatedAt timestamp().notNull().defaultNow(). - Constraints/indexes via
(table) => [ unique("application_drafts_user_job_unique").on(table.userId, table.jobId), index("application_drafts_user_idx").on(table.userId), index("application_drafts_user_status_idx").on(table.userId, table.status) ]. ApplyDraftPayloadtype exported for DX;packages/db/src/schema/applications.tsis not modified.- Exported from
schema/index.tsand importable asimport { applicationDrafts } from "@ever-hust/db".
- House style:
- Estimate: 0.5 day
- Files:
-
T02 — Push
application_draftsto the database- Files:
packages/db/src/schema/index.ts(consumes),drizzle.config.ts(schema =./src/schema/index.ts) - Acceptance:
pnpm db:pushapplies the new table against a non-prod DB with no errors and no destructive diff onapplications,users,jobs, oruserJobs.pnpm db:studioshowsapplication_draftswith the columns/unique/indexes from T01.
- Estimate: 0.5 day
- Files:
-
T03 — Unit test the schema/type round-trip (alongside T01)
- Files:
packages/db/src/schema/application-drafts.test.ts(new) —dbJest project - Acceptance:
- A valid
ApplyDraftPayload(details/proposal/qa/terms/gaps) type-checks and round-trips through thedraft$type; an out-of-enumstatusis rejected at the type level. pnpm test -- --selectProjects dbgreen for this file.
- A valid
- Estimate: 0.5 day
- Files:
Phase 2 — applyCopilot assembly tool (grounded, structured, no-invent)
-
T04 — Define the
applyDraftstructured-output schema (#5)- Files:
packages/ai/src/structured/schemas/apply-draft.ts(new); export frompackages/ai/src/structured/index.ts - Acceptance:
defineArtifact("applyDraft", 1, applyDraftSchema)whereapplyDraftSchemais a.max()-bounded Zod object:details(name/email/headline/location, all grounded),proposal(string,.max()),qa(array of{ questionId, question, answer },.max()),terms({ rate?, availability?, location? }), andgaps(string[]) for unknowns.- Exports the inferred
ApplyDraftSummaryTS type; mirrors theApplyDraftPayloadshape used byapplication_drafts. - No free-form blob fields (only whitelisted, queryable fields).
- Estimate: 0.5 day
- Files:
-
T05 — Unit test the
applyDraftschema (alongside T04)- Files:
packages/ai/src/structured/schemas/apply-draft.test.ts(new) - Acceptance:
- Tests: a valid draft parses; an over-
.max()proposal/Q&A is rejected; missing required sections are rejected;schemaVersion === 1. pnpm test -- --selectProjects aigreen for this file.
- Tests: a valid draft parses; an over-
- Estimate: 0.5 day
- Files:
-
T06 — Build the
applyCopilotassembly tool- Files:
packages/ai/src/tools/apply-copilot.ts(new) - Acceptance:
tool({ description, inputSchema, execute })with a.max()-bounded Zod input:userId: z.string().optional()(server-injected, never LLM-supplied),jobId: z.number(),tone?: z.enum([...]),focusAreas?: z.array(z.string().max(200)).max(10).executereads the user profile (existinggetUserProfile-style fields, PII-stripped), the persistedevaluationsrow (#3) for the job, and thegenerateCoverLettercontext+allowedFacts(#10/#6), then assembles the four sections (details,proposal,qa[],terms) plusgaps[]for unknowns.- Builds the result via
defineArtifact("applyDraft", 1, …)and callsassertArtifact(...)before persisting anapplication_draftsrow (statusdrafting); returns{ assembled: true, jobId, draftId, draft }. - Preserves the existing Pro check (
subscriptionStatus in ("active","past_due")) for the record path; drafting surfacesrequiresUpgradeconsistently withapply-job.tsif product gates drafting. - Errors are caught and returned as
{ assembled: false, error }(mirrors existing tool error shape), never thrown to the user.
- Estimate: 1 day
- Files:
-
T07 — Apply the #6 no-invent audit to the assembled draft (alongside T06)
- Files:
packages/ai/src/tools/apply-copilot.ts; consumesassertNoInventedfrompackages/ai/src/policy - Acceptance:
- The proposal text and every Q&A answer are passed through
assertNoInvented({ text, allowedFacts })using the cover-letterallowedFacts+ profile/evaluation facts. - Flagged claims are moved into the draft's
gaps[](and not silently kept as facts); the audit is advisory and never throws / never 500s the user.
- The proposal text and every Q&A answer are passed through
- Estimate: 0.5 day
- Files:
-
T08 — Unit test
applyCopilot(alongside T06/T07)- Files:
packages/ai/src/tools/apply-copilot.test.ts(new) - Acceptance:
- Tests: a grounded draft assembles all four sections + persists a row; a fabricated employer/number
injected via the cover-letter context is flagged into
gaps[](no-invent); missing evaluation → graceful{ assembled: false }(no orphan row);userIdabsent → not-authenticated refusal. pnpm test -- --selectProjects aigreen for this file.
- Tests: a grounded draft assembles all four sections + persists a row; a fabricated employer/number
injected via the cover-letter context is flagged into
- Estimate: 0.5 day
- Files:
-
T09 — Export + register
applyCopilotin the orchestrator- Files:
packages/ai/src/tools/index.ts;packages/ai/src/agents/orchestrator.ts - Acceptance:
export { applyCopilotTool } from "./apply-copilot";added totools/index.ts.- Registered in the
tools: { ... }object increateOrchestratorStreamwith the server-sideuserIdinjection wrapper (execute: (params, opts) => applyCopilotTool.execute!({ ...params, userId }, opts)), matching theapplyJob/submitAnswerspattern;stopWhen: stepCountIs(5)unchanged. pnpm test -- --selectProjects ai(orchestrator.test.ts) green; tool count assertion updated.
- Estimate: 0.5 day
- Files:
Phase 3 — 4-tab review UI + structural approval gate (reuse #6)
-
T10 — Build the 4-tab editable review card
- Files:
apps/web/components/canvas/apply-draft-card.tsx(new) - Acceptance:
- Renders tabs Details / Proposal / Q&A / Terms, modelled on
apps/web/components/canvas/salary-insights-card.tsx; uses@ever-hust/ui/<component>(card, button, badge, tabs/dialog) andcn()from@ever-hust/ui/lib/utils. - Every field is editable;
gaps[]are shown distinctly as "needs your input" (never auto-filled). - A "Submit" button and a manual-submit / deep-link affordance (
applyUrl ?? jobUrl) are present; Submit does not submit directly — it triggers the approval flow (T12).
- Renders tabs Details / Proposal / Q&A / Terms, modelled on
- Estimate: 1 day
- Files:
-
T11 — Wire
applyCopilotinto canvas sync- Files:
apps/web/hooks/use-canvas-sync.ts - Acceptance:
- Adds
case "applyCopilot"tohandleToolResultthat sets a newapplyDraftstate field from the tool result and rendersApplyDraftCard; adds aclearApplyDraftcallback (mirrorsclearSalaryInsights/clearCoverLetter). - The
defaultbranch still logs unknown tools in dev; existing cases are untouched (additive).
- Adds
- Estimate: 0.5 day
- Files:
-
T12 — Draft load/save API route
- Files:
apps/web/app/api/applications/draft/route.ts(new); Zod schema inapps/web/lib/api-schemas.ts; errors viaapps/web/lib/api-response.ts - Acceptance:
GETreturns the session user's draft for ajobId;PATCHsaves edited sections back to theapplication_drafts.draftjsonb (the single source of truth read on approval).requireSessionUser()+applyRateLimit(userId, "authenticated"); body validated with Zod; malformed input →apiBadRequest(); only the owner's draft is mutated.- Default
Cache-Control: private, no-storeheaders applied.
- Estimate: 1 day
- Files:
-
T13 — Unit test the draft API route (alongside T12)
- Files:
apps/web/app/api/applications/draft/route.test.ts(new) —web-libJest project - Acceptance:
- Tests: unauthenticated → 401; bad body → 400; editing another user's draft → rejected; valid
PATCHupdates thedraftjsonb. pnpm test -- --selectProjects web-libgreen for this file.
- Tests: unauthenticated → 401; bad body → 400; editing another user's draft → rejected; valid
- Estimate: 0.5 day
- Files:
-
T14 — Route the Submit affordance through the #6 approval gate
- Files:
apps/web/components/canvas/apply-draft-card.tsx;packages/ai/src/policy/require-approval.ts(add"applyCopilot"toOUTWARD_ACTION_TOOLS); reusesapps/web/app/api/approvals/route.ts - Acceptance:
- "Submit" calls
createApprovalGate({ userId, actionId: "applyCopilot", jobId, summary })and posts the decision toPOST /api/approvals; only an approved gate lets the record path run. - On approval, the approved draft's proposal/Q&A are recorded via the existing
applyJob+submitAnswersmachinery intoapplications.coverLetter/questionsAsked/answersProvided(no new write path; the #2 pipeline inherits the row). "applyCopilot"appears inOUTWARD_ACTION_TOOLS; standalone Hust never auto-submits.
- "Submit" calls
- Estimate: 1 day
- Files:
-
T15 — Render the copilot gate in the approval card (generic #6 path)
- Files:
apps/web/components/chat/tool-approval.tsx - Acceptance:
- The copilot gate renders via the gate's
summary(the genericdefaultpath), with no copilot-specificswitchbranch required; Approve/Deny callPOST /api/approvals. - Existing
applyJobdisplay branch is untouched (additive).
- The copilot gate renders via the gate's
- Estimate: 0.5 day
- Files:
Phase 4 — Prompt, invariant, tests & deferred Gauzy seam
-
T16 — Document the apply-copilot flow in the system prompt
- Files:
packages/ai/src/prompts.ts(DEFAULT_ORCHESTRATOR_PROMPT); mirror in the Langfuse promptorchestrator-system(labelproduction) - Acceptance:
- Adds an
applyCopilotcapability line + an "Apply Copilot" section: assemble a complete 4-section draft, present the Details/Proposal/Q&A/Terms tabs, wait for explicit user approval, then record — never auto-submit, never claim a submit that didn't happen, ground every field (no-invent). - The Langfuse
orchestrator-systemproduction prompt is updated to match (noted in the PR so the DB copy does not override the fallback). pnpm test -- --selectProjects ai(prompts.test.ts) green.
- Adds an
- Estimate: 0.5 day
- Files:
-
T17 — Add
applyCopilotto the no-skip-gate invariant test- Files:
packages/ai/src/policy/approval-invariant.test.ts(extends the #6 invariant) - Acceptance:
- The invariant set includes
"applyCopilot"; asserts the copilot submit/record path cannot run without an approved gate (returns aneedsApprovalrefusal and advances noapplicationsrow). - Includes a prompt-injection "skip approval" case that confirms the record is still blocked.
pnpm test -- --selectProjects aigreen.
- The invariant set includes
- Estimate: 0.5 day
- Files:
-
T18 — Scaffold the deferred Gauzy Seam-A adapter (off by default)
- Files:
packages/ai/src/integrations/gauzy-apply-adapter.ts(new);apps/web/.env.example - Acceptance:
- Exports a thin adapter behind
GAUZY_AUTO_APPLY_ENABLED(default off): when disabled, returns a standalone no-op ({ handedOff: false, reason: "standalone" }); when enabled, documents the per-application approval-gated handoff to Gauzy AI automation (no live call shipped this epic). - No hard Gauzy import at module top level that would break a standalone build;
.env.examplegainsGAUZY_AUTO_APPLY_ENABLEDwith a comment that it is optional + off by default.
- Exports a thin adapter behind
- Estimate: 0.5 day
- Files:
-
T19 — Unit test the Gauzy adapter standalone fallback (alongside T18)
- Files:
packages/ai/src/integrations/gauzy-apply-adapter.test.ts(new) - Acceptance:
- Tests: with the flag unset/false the adapter returns the standalone no-op and requires no Gauzy env; with the flag set the handoff still requires an approved gate (per-application approval preserved).
pnpm test -- --selectProjects aigreen.
- Estimate: 0.5 day
- Files:
-
T20 — Playwright E2E for the apply-copilot flow
- Files:
tests/e2e/apply-copilot.spec.ts(new) - Acceptance:
- Draft appears on the canvas with the 4 tabs (Details/Proposal/Q&A/Terms); editing a field persists
after reload; clicking Deny blocks the submit (no
applicationsrow advances); clicking Approve records the application. pnpm test:e2egreen againsthttp://localhost:8443.
- Draft appears on the canvas with the 4 tabs (Details/Proposal/Q&A/Terms); editing a field persists
after reload; clicking Deny blocks the submit (no
- Estimate: 1 day
- Files:
-
T21 — Full CI green + roadmap update
- Files:
docs/specs/ROADMAP.md - Acceptance:
pnpm lint,pnpm check-types,pnpm test,pnpm test:e2eall green ondevelop.- Epic 19a progress updated in
docs/specs/ROADMAP.md. - Grep confirms zero competitor references in all changed files (Article 11).
- Estimate: 0.5 day
- Files:
Notes
- Write tests alongside each implementation task (T03 with T01, T05 with T04, T08 with T06/T07, T13 with T12, T17 guards Phase 3/4, T19 with T18); do not batch testing into a final task.
userIdis injected server-side by the orchestrator for every tool — never an LLM-supplied param.- New table (
application_drafts) requirespnpm db:push(T02); nevernpm/yarn. - This epic is additive: it reuses
applyJob/submitAnswers/ theapplicationstable and the #6 approval gate — nothing existing is removed or replaced (constitution Article 9). - Standalone Hust never auto-submits; the #6 approval gate is structural. The Gauzy Seam-A
handoff stays optional, off by default (
GAUZY_AUTO_APPLY_ENABLED), and per-application approval-gated (constitution Articles 2 & 4). - Hard upstream contracts: #5 (
defineArtifact/assertArtifact), #6 (requireApproval+OUTWARD_ACTION_TOOLS+assertNoInvented), #3 (evaluations), #10 (cover-letterallowedFacts). - Verify zero competitor references before every commit (constitution Article 11); our own Ever brands (Ever Jobs, Ever Gauzy, Hust, Ever Co.) are fine.
- Update
docs/specs/ROADMAP.mdprogress when this epic's tasks complete.