CODEX // oaoisme wiki

§ unswayed-backend

Checkpoint r2 — applicant-side completion

updated 2026-07-04

Checkpoint r2 — applicant-side completion

Branch: checkpoint/final-review (continuing past merged PR #42) · Date: 2026-07-05 · Memory: repo docs/ai/memory/0042-checkpoint-r2-applicant-side.md · Sibling page: Checkpoint final-review

What this was

Round 2 of the completion pass, now on the applicant side: a product bug list of six items (PDF extensions, ignored resume settings, a smarter enhance, a summary generator, empty match-score cards, dead application fields) plus two verify-only claims (feedback schema readiness, billing scoping). The method from round 1 repeated: recon before building — eight read-only agents verified every claim against source — and the recon materially rewrote the brief three times before a line of implementation was written.

The three recon corrections that shaped the build

1. The delete path never used the deletable seam. The brief said fixing the URL parser would restore resume-PDF cleanup. In truth, resume.service.deleteAsset, education media, and both profile-photo services pass the stored URL as the public id to storage.delete() — Cloudinary's destroy can never match a URL-shaped id, so every "replace" since launch silently orphaned the old asset. The URL parser fix alone would have fixed none of them. All four call sites now route through deleteByUrl, whose parser also learned to read extensionless legacy raw URLs so pre-fix assets can finally be cleaned too.

2. Match-score computation wasn't missing — it was mis-plumbed. Applicant-side compute+persist already existed (the lexi match/score endpoints; career-areas already background-enqueues on miss). The real gaps were just the career feed and job detail. And the cost model is asymmetric: scoring is pure local math (~3 DB reads) if the job's AI skill-extraction cache (extractedSkills) is warm, but a cold cache means an OpenAI round-trip. That asymmetry is the design: job-detail computes synchronously on miss (single job); the feed fills only the returned page slice — up to 3 warm-cache jobs synchronously, everything cold or over-cap enqueued through the existing score-candidates queue job. A resume-less applicant (no minimum profile) gets neither compute nor queue traffic. Recon also caught a real divergence bug while reading: the queue worker scored with declared skills only, ignoring the same cached extractedSkills the on-demand path uses — background and on-demand scores disagreed for identical (job, candidate) pairs. A red test proved 100-vs-50; the worker now reads the cache (no LLM added).

3. Feedback anonymity was cosmetic. is_anonymous set a flag; the row always snapshotted identity, and — the subtle part — every read surface resolves the author from the live user relation, not the snapshot columns. So the admin wire (which deliberately never masks) fully identified "anonymous" submitters, and nulling name/email alone would have changed nothing on the wire. The fix (ADR-0054) nulls userId itself at write time: anonymous rows now store no identity, admins see user: null, and the existing null-userId skip means responding to anonymous feedback notifies no one. Two deliberate keeps, documented in code and the ADR: ip (an abuse trail that is never serialized on any wire resource; the rate limiter keys off the session anyway) and userRole (a coarse flag feeding admin aggregates).

The raw-PDF extension fix, properly understood

Cloudinary serves raw assets at their public_id verbatim — no .{format} gets appended the way it does for images and videos. That single rule explains everything: the adapter uploads base64 data URIs (no filename reaches Cloudinary), never sets a public_id, and so every raw upload — which is every PDF — got a random extensionless id and an extensionless delivery URL. The fix is scoped to resolved-raw uploads only: derive <sanitizedBase>_<6hex>.<ext> (basename only, traversal stripped, whitespace collapsed, charset-restricted; extension from the filename or pdf when the mime says PDF; random suffix because Cloudinary overwrites on identical public_ids). Two deliberate negatives worth remembering: no extension derivable → no public_id at all (never guess), and the SDK's format option is not used on raw uploads — it's an image/video transformation concern; for raw, extension-in-public_id is the only mechanism. Image/video/auto uploads are pinned byte-identical by exact-options tests. One honest caveat: e2e runs the stub storage adapter (which always returned extension-bearing URLs), so this section's proof is unit-level by design.

Resume settings that finally apply

Saved ResumeSettings were consulted by exactly one path (the settings download()); the four other render sites — upload, update, and both Affinda paths (the sync endpoint and the async worker both funnel through runGenerate) — rendered hardcoded classic/Helvetica/green every time. A new ResumeRenderSettingsService.presentationOptionsFor(userId) does one lookup and returns the presentation subset: template, font, size (mapped to points), theme color. A missing row returns {} — byte-identical to the old defaults, never a throw. The interesting decision is the PII gate: these paths write the anonymised filteredResume artifact, so showPersonalDetails is never taken from stored settings here even when the user opted in — rendering PII stays exclusive to download(), and tests pin that. (The mapping constants are mirrored locally rather than imported from lexi-ai, preserving the repo's one-way lexi-ai→applicant-profile dependency direction; the duplication is documented as a sync duty.)

Enhance grows a dialect; summaries get generated

/enhance (both the top-level and the applicant-nested route) accepts an optional section hint — education | job_duties | project_description | summary — selecting a prompt tuned to that kind of prose (action verbs and quantifiables for duties, scope/stack/outcome for projects, and a hard rule against inventing numbers). Omitting it keeps the original generic prompt byte-for-byte, so the frontend can adopt it incrementally. The new POST /applicant/resume/generate-summary is a different verb entirely: ResumeData has no summary field, so this synthesizes one (2–4 sentences) from the wizard-state sections via a fourth port method, returns { data: { summary } }, and persists nothing — the frontend owns the text. The deterministic stub composes a role-plus-top-skills sentence (mirroring the pdfkit templates' crude heuristic) so stubbed e2e runs can never trip the falsy-response 500 guard.

Small fix, big trap: the dead application fields

is_interview/received_job_offer were hard-wired 'no' because the dashboard query never included the relations — but the naive fix (add the include) would have silently done nothing, because the resource read jobInterviews/jobOffers while the Prisma relations are named interviews/offers. Both sides changed together: ids-only includes ({ select: { id: true } } — emptiness is all the derivation consumes) and the resource renamed to the real relation names, with the stale "Phase 6 pending" comments rewritten. Both consumers (my-applications and application-tracking) share the include constant, so the second was fixed for free.

Verified, unchanged

Billing scoping (section H) survived adversarial re-verification: getPlans() filters server-side by the authenticated userType, both purchase paths 403 cross-type attempts with the exact legacy message, and the controller's absence of a UserTypeGuard is documented and reflection-pinned. The "wrong billing page" complaint is a frontend link-routing bug. Nothing was changed — and nothing should be: adding a UserTypeGuard would break the deliberately shared surface.

Left on the table (flagged, not fixed)

Cover letters orphan their PDFs on update/remove (no cleanup exists at all — a decision, not a patch); lexi's persistComputedScore duplicates the versioned insert without a P2002 guard and can now conflict under the new unique triple; career-areas still enqueues sequentially over every published job per call; pre-existing anonymous feedback rows keep their old identity snapshots pending an owner-approved backfill; and the product list's "user data export PDF" simply does not exist as a feature — that's new-feature work, not a bug fix.