Tasks: 06 — Guardrails (HITL approval, cost gating, no-invent)
Status legend:
[ ]pending •[~]in-progress •[x]done •[-]dropped
Phase 1 — Structural approval gate (requireApproval)
-
T01 — Add
approval_gatestable schema- Files:
packages/db/src/schema/approval-gates.ts(new); export frompackages/db/src/schema/index.ts - Acceptance:
- Table follows house style:
integer("id").primaryKey().generatedAlwaysAsIdentity();text("user_id").notNull().references(() => users.id, { onDelete: "cascade" });actionId text("action_id", { enum: [...outward actions] }).notNull();jobId integer().references(() => jobs.id)nullable;applicationId integer()nullable;status text("status", { enum: ["pending","approved","denied","expired"] }).notNull().default("pending");summary jsonb("summary").$type<{ title: string; description: string }>();createdAt/updatedAt timestamp().notNull().defaultNow(). - Indexes via
(table) => [ index("approval_gates_user_idx").on(table.userId), index("approval_gates_user_status_idx").on(table.userId, table.status) ]. - Exported from
schema/index.tsand importable asimport { approvalGates } from "@ever-hust/db".
- Table follows house style:
- Estimate: 0.5 day
- Files:
-
T02 — Push
approval_gatesto 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 on existing tables.pnpm db:studioshowsapproval_gateswith the columns/indexes from T01.
- Estimate: 0.5 day
- Files:
-
T03 — Build the
requireApprovalpolicy primitive + outward-action registry- Files:
packages/ai/src/policy/require-approval.ts(new);packages/ai/src/policy/index.ts(new barrel); re-export frompackages/ai/src/index.ts - Acceptance:
- Exports
OUTWARD_ACTION_TOOLSconstant (e.g.["applyJob","submitAnswers"]). - Exports
createApprovalGate({ userId, actionId, jobId?, applicationId?, summary })inserting apendingrow and returning its id. - Exports
assertApproved({ userId, gateId })that returns the row only whenstatus === "approved"and belongs to the user; throws/returns a typed refusal otherwise. userIdis always server-supplied — never an LLM input param.
- Exports
- Estimate: 1 day
- Files:
-
T04 — Unit test the approval primitive (alongside T03)
- Files:
packages/ai/src/policy/require-approval.test.ts(new) - Acceptance:
- Tests: pending gate is not approved; approved gate for the right user passes; approved gate for a different user is rejected; denied/expired gate is rejected.
pnpm test -- --selectProjects aigreen for this file.
- Estimate: 0.5 day
- Files:
-
T05 — Route
applyJobthroughrequireApproval- Files:
packages/ai/src/tools/apply-job.ts; wrapper inpackages/ai/src/agents/orchestrator.ts - Acceptance:
applyJob.executecallsassertApprovedbefore the side-effecting transaction; absent/denied gate returns{ applied: false, error: "Awaiting your approval", needsApproval: true, gateId }without creating anapplicationsrow.- Existing
applicationsstatus machine (pending → in_progress → submitted) and subscription gate (subscriptionStatus in ("active","past_due")) are preserved (additive, not replaced). userIdstill injected by the orchestrator wrapper.
- Estimate: 1 day
- Files:
-
T06 — Route
submitAnswersthroughrequireApproval- Files:
packages/ai/src/tools/submit-answers.ts; wrapper inpackages/ai/src/agents/orchestrator.ts - Acceptance:
submitAnswers.executerequires an approved gate before marking the applicationsubmitted; absent/denied gate returns{ submitted: false, needsApproval: true, gateId }.- Existing required-question validation + Pro-subscription check preserved.
- Estimate: 1 day
- Files:
-
T07 — Approval API route (approve / deny a gate)
- Files:
apps/web/app/api/approvals/route.ts(new); Zod schema inapps/web/lib/api-schemas.ts; errors viaapps/web/lib/api-response.ts - Acceptance:
POSTauthenticates viarequireSessionUser()andapplyRateLimit(userId, "authenticated").- Validates
{ gateId: number, decision: "approve" | "deny" }with Zod; rejects malformed input viaapiBadRequest(). - Updates the gate
statusonly when it belongs to the session user; returns the updated gate. - Default
Cache-Control: private, no-storeheaders applied.
- Estimate: 1 day
- Files:
-
T08 — Unit test the approval API route (alongside T07)
- Files:
apps/web/app/api/approvals/route.test.ts(new) —web-libJest project - Acceptance:
- Tests: unauthenticated → 401; bad body → 400; approving another user's gate → rejected; valid approve/deny → gate status updated.
pnpm test -- --selectProjects web-libgreen for this file.
- Estimate: 0.5 day
- Files:
-
T09 — Generalize the approval card to read gate
actionId/summary- Files:
apps/web/components/chat/tool-approval.tsx;apps/web/hooks/use-canvas-sync.ts - Acceptance:
getToolDisplayrenders title/description from the gate'ssummary(falling back to the generic card for unknown actions), so a new outward tool needs no UI edit.use-canvas-sync.tsgains anapplyJob/approvalcasethat surfaces a pending gate (needsApproval/gateId) to the chat UI; thedefaultbranch still logs unknowns in dev.- Approve/Deny buttons call
POST /api/approvals.
- Estimate: 1 day
- Files:
-
T10 — Approval invariant test (prompt-injection bypass guard)
- Files:
packages/ai/src/policy/approval-invariant.test.ts(new) - Acceptance:
- Iterates
OUTWARD_ACTION_TOOLS; asserts each tool'sexecutereturns aneedsApprovalrefusal (and creates no side-effecting row) when called with no approved gate. - Includes a case where the params simulate an injected "skip approval" instruction and confirms the side effect is still blocked.
pnpm test -- --selectProjects aigreen.
- Iterates
- Estimate: 0.5 day
- Files:
Phase 2 — No-invent grounding validator (assertNoInvented)
-
T11 — Implement
assertNoInventedvalidator- Files:
packages/ai/src/policy/assert-no-invented.ts(new); export frompackages/ai/src/policy/index.ts - Acceptance:
- Signature
assertNoInvented({ text, allowedFacts }: { text: string; allowedFacts: string[] }): { grounded: boolean; flaggedClaims: string[] }. - Flags employers / numbers / dates / credentials in
textnot traceable toallowedFacts; returns them asflaggedClaims(advisory — never throws, never blocks generation). - Empty/grounded text returns
{ grounded: true, flaggedClaims: [] }.
- Signature
- Estimate: 1 day
- Files:
-
T12 — Unit test
assertNoInvented(alongside T11)- Files:
packages/ai/src/policy/assert-no-invented.test.ts(new) - Acceptance:
- Tests: fully grounded prose passes; fabricated employer is flagged; an unverifiable salary/number
is flagged; a real CV skill from
allowedFactsis not flagged. pnpm test -- --selectProjects aigreen.
- Tests: fully grounded prose passes; fabricated employer is flagged; an unverifiable salary/number
is flagged; a real CV skill from
- Estimate: 0.5 day
- Files:
-
T13 — Surface
allowedFactsfrom the cover-letter tool- Files:
packages/ai/src/tools/generate-cover-letter.ts - Acceptance:
- The tool's return object includes an
allowedFacts: string[]derived from its existing groundedcontext(user name/headline/skills, job title/company/skills/location) so callers can audit prose. - No change to existing
generated/context/instructionfields (additive). - Existing
generate-cover-letter-related tests still pass.
- The tool's return object includes an
- Estimate: 0.5 day
- Files:
-
T14 — Document the no-invent + structural-approval posture in the system prompt
- Files:
packages/ai/src/prompts.ts(DEFAULT_ORCHESTRATOR_PROMPT); mirror in Langfuse promptorchestrator-system(labelproduction) - Acceptance:
- Prompt gains a "Grounding / no-invent" section (never fabricate employers, numbers, dates, credentials; mark unknowns as gaps) and a note that outward actions are blocked server-side until the user approves.
- The Langfuse
orchestrator-systemproduction prompt is updated to match (noted in PR so the DB copy does not override the fallback). pnpm test -- --selectProjects ai(prompts.test.ts) green.
- Estimate: 0.5 day
- Files:
Phase 3 — Cost gate + follow-up cadence policy
-
T15 — Implement
withCostGatewrapper- Files:
packages/ai/src/policy/cost-gate.ts(new);packages/ai/src/policy/limits.ts(new); export frompackages/ai/src/policy/index.ts - Acceptance:
withCostGate({ scoreFloor?, quota? })(execute)returns a wrappedexecutethat: blocks with a typed refusal when a passed fitscoreis belowscoreFloor; blocks when the user is overquota(reusingcheckRateLimitfrompackages/ai/src/rate-limit.tswith a distinct prefix); otherwise calls through.- Per-tier quota constants live in
limits.tsor reuseFREE_LIMITSfrom@ever-hust/stripe. userIdis server-supplied, never an LLM param.
- Estimate: 1 day
- Files:
-
T16 — Unit test
withCostGate(alongside T15)- Files:
packages/ai/src/policy/cost-gate.test.ts(new) - Acceptance:
- Tests: score above floor passes; score below floor blocks; under quota passes; over quota blocks;
quota prefix is isolated from
search/coverlimiters. pnpm test -- --selectProjects aigreen.
- Tests: score above floor passes; score below floor blocks; under quota passes; over quota blocks;
quota prefix is isolated from
- Estimate: 0.5 day
- Files:
-
T17 — Implement
followUpPolicycadence cap- Files:
packages/ai/src/policy/follow-up-policy.ts(new); export frompackages/ai/src/policy/index.ts - Acceptance:
- Exports
followUpPolicy(max follow-ups per application, min interval) andcanSendFollowUp({ sentCount, lastSentAt, now }) => boolean. - Blocks when
sentCount >= maxor whennow - lastSentAt < minInterval. - Pure function with no I/O; ready for epic #9 to consume.
- Exports
- Estimate: 0.5 day
- Files:
-
T18 — Unit test
followUpPolicy(alongside T17)- Files:
packages/ai/src/policy/follow-up-policy.test.ts(new) - Acceptance:
- Tests: under cap + past interval allowed; at cap blocked; within min interval blocked.
pnpm test -- --selectProjects aigreen.
- Estimate: 0.5 day
- Files:
Phase 4 — Terms copy + E2E policy verification
-
T19 — Update Terms copy to match the HITL / advisory posture
- Files:
apps/web/app/(marketing)/terms/page.tsx - Acceptance:
- Terms state: Hust never auto-submits / applies / sends on the user's behalf without explicit approval; AI output is advisory and must be reviewed; the user owns their data.
- Contact addresses use
ever.co; noeverjobs.aireferences in product copy; no competitor names.
- Estimate: 0.5 day
- Files:
-
T20 — Playwright E2E for the approval gate + Terms
- Files:
tests/e2e/guardrails.spec.ts(new) - Acceptance:
- An outward action surfaces the approval card; clicking Deny blocks the action (no submitted application); clicking Approve allows it.
- The Terms page renders the no-auto-submit / advisory language.
pnpm test:e2egreen againsthttp://localhost:8443.
- 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 06 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 (T04 with T03, T08 with T07, T10 guards Phase 1, T12 with T11, T16 with T15, T18 with T17); 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 (
approval_gates) requirespnpm db:push(T02); nevernpm/yarn. - Verify zero competitor references before every commit (constitution Article 11); our own Ever brands (Ever Jobs, Ever Gauzy, Hust, Ever Co.) are fine.
- Keep the Gauzy auto-apply seam optional + per-action approval-gated (constitution Article 2/4).
- Update
docs/specs/ROADMAP.mdprogress when this epic's tasks complete.