Interview retro & post-interview loop (technical)¶
The post-interview loop: draft a thank-you note, capture a quick self-report retro, log the questions you were actually asked, log what you shared with the interviewer, and — once feedback arrives — compare your self-report to what the company said. Everything except the thank-you draft generator is backed by local-only storage; the thank-you drafts are draft text only, same boundary as outreach-drafting.md.
Plain-language version: ../plain/interview-retro.md
The two guardrails, stated precisely¶
| Guardrail | Enforced by |
|---|---|
Thank-you notes are draft-only. ThankYouDraftGenerator has no dependency on BrowserSession, no HttpClient to any mail/messaging endpoint, and no method whose name suggests transmission. The user copies the draft into their own email client or LinkedIn and sends it themselves. Approved only records that a human reviewed it — never that it was sent. |
ThankYouDraftGeneratorTests.Draft_DoesNotExposeAnySendMethod fails loudly if a Send/Submit/Post/Scrape method is ever added. The frontend page has no send button — only "Copy". |
Retro/question-log/common-ground data is local only. InterviewRetroService is constructed with a single LocalJsonStore — no network client, no Drive/Sheets mirror (unlike ApplicationRecord, which is mirrored). Each kind lives under its own store key: interview-retros, asked-questions, common-ground. |
InterviewRetroServiceTests.InterviewRetroService_HasNoNetworkOrSyncDependency asserts the constructor's only parameter is LocalJsonStore. DataWrittenByService_LivesOnlyUnderLocalStoreDirectory confirms every file the service writes lands under the local store directory. |
"Local only" means it never leaves the machine via a sync/upload path. Separately — and unlike the app's other read endpoints — this controller is also token-gated. Because the retro data is a candid personal self-report on interview performance (feelings, what was hard, a gut read), it's materially more sensitive than the resume-grade job/profile metadata the other reads expose, so
InterviewRetroControlleris decorated[RequireLocalToken]at the class level: every route, read and write, requires the per-launchX-WorkWingman-Token(or the trusted dev origin), else401. A hostile page in a browser on the same machine can therefore neither read this data nor silently seed/overwrite it over loopback. The gate reuses the sameApiToken.IsTrustedcheck that guardsPOST /api/dependencies/install, via a reusable filter (RequireLocalTokenAttribute) — see security.md § 3b and security.md § T1. The gate is scoped to interview-retro by deliberate sensitivity tiering, not by a limit of the mechanism: the renderer forwards the token on every API call, so extending it to another sensitive controller later is a one-line attribute with no new plumbing.
Interview needed a stable Id¶
This whole feature keys every record off InterviewId, which required adding
Interview.Id — ApplicationRecord's
interviews didn't previously need to be individually addressable. That created a migration hazard,
caught in review: Interview.Id deliberately defaults to string.Empty, not a fresh Guid
(unlike ApplicationRecord.Id, whose owner always saves immediately after construction — seeding
or RecordSubmissionAsync). If Id auto-generated a Guid via a property initializer instead, an
interview loaded from JSON that predates this field would get a new, different Guid on every
single deserialize, since System.Text.Json never touches an absent property and nothing would
force a save to make the generated value stick — silently orphaning any retro/question/common-ground
record a caller already saved against the previous transient id. Instead,
ApplicationTrackerService.GetAllAsync explicitly detects any interview with an empty Id,
assigns a fresh Guid, and persists the whole collection immediately — the same "seed once, persist
immediately" pattern the seed-id fix already used a few lines above it.
ApplicationTrackerServiceTests.GetAll_BackfillsMissingInterviewIds_AndPersistsThem writes JSON
with the id field absent directly to the store, then asserts two independent service instances
see the same backfilled id — proving it was actually saved, not just generated in-memory for
that one call.
Hardening driven by /codex-review¶
A review pass flagged the open GET routes as a real residual gap: this data is a candid personal
interview self-report, materially more sensitive than the job-queue metadata every other open read
exposes, so a hostile local page reading it over loopback is a worse outcome here than elsewhere.
Rather than leave it documented-but-open, the whole controller is now [RequireLocalToken] gated
(reads and writes) via a reusable RequireLocalTokenAttribute that shares the installer's
ApiToken.IsTrusted check. Scoped to interview-retro by deliberate sensitivity tiering; app-wide
extension is a one-line attribute per controller since the renderer already forwards the token on
every API call. See security.md § 3b / § T1.
Covered by ApiSmokeTests.InterviewRetroGet_* (401 without trust, 200 with the launch token or the
dev origin) and InterviewRetroControllerTests.Controller_IsGatedByRequireLocalToken.
Bugs caught by /codex-review before commit¶
Eight review passes surfaced real issues, each fixed (not just noted) before this landed:
- The
Interview.Idmigration hazard above — fixed by defaultingIdto empty instead of a fresh Guid, and havingApplicationTrackerService.GetAllAsyncbackfill + persist explicitly. - Common ground keyed by interview only, not interviewer. A panel interview has multiple
interviewers; the first implementation upserted
CommonGroundEntrybyInterviewIdalone, so logging common ground for a second interviewer silently overwrote the first one's row. Fixed by keying the upsert on(InterviewId, InterviewerName)(case-insensitive) inInterviewRetroService.LogCommonGroundAsync, and the frontend now requires an interviewer name before it will log anything, showing every interviewer's entry for the selected interview side by side. Covered byLogCommonGround_SameInterview_DifferentInterviewers_BothPersistandLogCommonGround_SameInterviewAndInterviewer_StillUpdatesInPlace. ThankYouDraft.InterviewIdinferred instead of passed explicitly. The first implementation derived the taggedInterviewIdfromRetro?.InterviewIdfalling back to the first logged question's — both legitimately absent on the most common path (drafting a thank-you before saving a retro or logging any question for that interview), which meant fresh drafts were tagged with an empty id. Fixed by addingThankYouDraftInput.InterviewIdas an explicit field the controller always populates from the request, and the generator just uses it directly — no inference. Covered byDraft_TagsInterviewId_EvenWhenNoRetroOrQuestionsLoggedYet.- Copy button read the original generated text, not the user's edits. The page's whole
premise is "copy, edit, and send it yourself" — but the Copy buttons originally read
drafts.email.body/drafts.linkedInMessage.bodystraight from the immutable API response, so an edit made in the textarea was silently discarded the moment the user clicked Copy. Fixed by introducingeditedEmailBody/editedLinkedInBodysignals, seeded from the generated text and two-way-bound to the textareas; Copy now reads from those signals. Covered bycopy uses the edited body signal, not the original generated textininterview-retro.spec.ts. - New retros persisted with a blank Id. The frontend's
blankRetro()helper postsid: ''for a brand-new retro (there's nothing to upsert onto yet).SaveRetroAsync's "new" branch originally stored that blank id unchanged, so every from-scratch retro shared the same emptyId— breaking any consumer keyed off it, notablyRetroCalibration.RetroId, which the UI tracks its list by. Fixed by assigning a fresh Guid on that branch whenIdis blank. Covered bySaveRetro_NewRetroWithBlankId_GetsARealIdAssigned. - Thank-you drafts could pull the wrong interviewer's common ground.
GetCommonGroundAsync()returns every interviewer's entry for an interview, ordered byCreatedAt; the controller originally took the first match byInterviewIdalone, so on a panel interview a note drafted to Taylor could silently include Morgan's shared detail if Morgan's entry happened to sort first. Fixed by filteringInterviewRetroController.GenerateThankYouDrafton(InterviewId, InterviewerName)— the same compound key the service upserts by. Covered byInterviewRetroControllerTests.GenerateThankYouDraft_MultipleInterviewersOnSameInterview_UsesOnlyTheRequestedInterviewersCommonGround. - Answer-draft textarea saved on every keystroke. The initial binding fired
updateAnswerDraftonngModelChange, starting a newPUTper keystroke; an earlier request completing after a later one could revert the saved draft to stale, partial text on a slow/flaky local API response. Fixed by saving on(blur)instead — one save per focus-out rather than one per keystroke — using a template reference variable to read the textarea's current value. - Thank-you drafting 404'd for the interview the UI actually links to.
ApplicationRecord.JobIdis empty for every seeded application (and for any application whose originalJobPostinghas since scrolled out of the queue), sojobs.GetJobAsync("")always returned null — meaning the "log & thank-you" link from Applied & interviews 404'd on a fresh install, before the user had ever done anything wrong. Fixed by addingInterviewRetroController.ResolveJobAsync: it prefers the live queued job, and falls back to a minimalJobPostingbuilt from the matchingApplicationRecord'sTitle/Company(found by matchingInterviewId) when the queued job isn't found. Covered byGenerateThankYouDraft_EmptyJobId_FallsBackToApplicationRecord_ForSeededOrLegacyData. - Interviewer-name comparisons weren't trimmed.
InterviewRetroService.LogCommonGroundAsync's upsert andInterviewRetroController.GenerateThankYouDraft's lookup both matched(InterviewId, InterviewerName)case-insensitively but without trimming — so a request whose interviewer-name field had stray leading/trailing whitespace (a common typing slip) could fail to match an entry saved under the clean name, silently dropping saved common ground from a generated draft. Fixed by extracting the comparison into one sharedCommonGroundEntry.NamesMatch(a, b)(trimmed + case-insensitive) that both call, so the upsert key and every lookup against it can never disagree. Covered byLogCommonGround_InterviewerNameWithStrayWhitespace_StillMatchesForUpdateandGenerateThankYouDraft_InterviewerNameWithStrayWhitespace_StillMatches. - A candid personal self-report was reachable by any local page, unlike the app's other reads.
api/interview-retro/*was open like every otherGETendpoint, but this data (interview feelings, what was hard, a gut read) is materially more sensitive than the resume-grade metadata those other endpoints expose. Fixed by gating the whole controller with a new reusable[RequireLocalToken]filter that reusesApiToken.IsTrusted(the same check already guarding the installer endpoint) — see security.md § 3 for the full mechanism. Covered byApiSmokeTests' without-token / with-token / trusted-dev-origin theories across every route, reads and writes alike. - A failed answer-draft save could look like it succeeded.
saveAnswerDraft'scatchError(() => of(null))drove the same subscription callback on both success and failure, so a failedPUTstill updated the local question as if it had saved — the user could navigate away believing an edit was persisted when a reload would lose it. Fixed by mapping success totrue/failure tofalseexplicitly and only updating local state (and only then) ontrue; a toast surfaces the failure instead. Covered bysaveAnswerDraft does NOT update local state when the save actually fails. - Saving company feedback could discard unsaved retro text. If the user typed
whatWentWell/whatWasHard/surprises/gutFeelbut hadn't clicked "Save retro" yet, and then clicked "Save feedback,"RecordCompanyFeedbackAsync's response — a brand-new retro with onlyCompanyFeedbackset, on the "no retro exists yet for this interview" branch — replaced the entire localretrosignal, silently discarding the unsaved fields. Fixed by merging onlyCompanyFeedback/CompanyFeedbackReceivedAtfrom the server response into the existing local retro object rather than replacing it wholesale. Covered bysaveCompanyFeedback preserves unsaved retro fields the user has not clicked "Save retro" for yet.
Models¶
InterviewRetro—JobId,InterviewId,HowItFelt(1–5, 0 = unanswered),WhatWentWell,WhatWasHard,Surprises,GutFeel— all optional, quick to fill right after an interview while it's fresh. Also carriesCompanyFeedback+CompanyFeedbackReceivedAt, filled in later once the company actually responds — this is the field point 5 of the spec calls "a field on the record" that enables the calibration view.AskedQuestion—Text,Category(Behavioral/Cultural/Technical/SystemDesign/Leadership),JobId,InterviewId, optionalMyAnswerDraft. Every question logged across every interview accumulates into one growing personal bank — nothing here is scoped or capped per-job.CommonGroundEntry—InterviewerName,WhatWeShared(a list). Upserted server-side by(InterviewId, InterviewerName)inInterviewRetroService.LogCommonGroundAsync— a second logging call for the same interviewer on the same interview updates that entry in place (the frontend reads the existing list and appends before re-posting the full list); a different interviewer on the same interview gets their own separate entry, so panel interviews with multiple interviewers don't collide.InterviewLearnings— a read-only aggregate: everyAskedQuestion+CommonGroundEntrylogged, a count of questions perQuestionCategory, andTotalInterviewsCovered(distinct interview ids across both). This is the "Questions I've been asked" study surface — it is computed on read from the two underlying stores, not persisted separately.RetroCalibration— one row per retro that has either a self-report or company feedback:SelfReportGutFeel/SelfReportHowItFeltnext toCompanyFeedback/HasCompanyFeedback, so the UI can render them side by side.
ThankYouDraft/ThankYouDraftSet— mirrorsOutreachDraft/OutreachDraftSetexactly: anEmaildraft (withSubject) and aLinkedInMessagedraft (no subject), both startingApproved = false.ThankYouDraftInput— everything the generator draws on, all optional exceptJob/Intake/Story:InterviewId— tagged onto both generated drafts as-is. Passed explicitly by the caller (the controller always populates it from the request) rather than inferred fromRetro/QuestionsAsked, which are legitimately both empty on the most common first-use path: drafting a thank-you before saving a retro or logging a question for that interview.InterviewerName— falls back to a generic greeting when blank.Retro(InterviewRetro?) — the user's own self-report;WhatWentWellbecomes a specific, genuine callback ("I especially enjoyed digging into ...").QuestionsAsked+QuestionIdToReinforce— lets the note circle back on one specific logged question ("I've thought more about your question on X — I'd add..."). The id must match an entry inQuestionsAsked; if it doesn't, the generator silently skips the sentence rather than inventing a topic (seeDraft_QuestionIdToReinforce_NotInLoggedList_NeverFabricatesATopic).CommonGround(CommonGroundEntry?) — a light personal touch, prioritized overInterviewerResearchwhen both are present.InterviewerResearch(InterviewerResearchSummary?) — a stub input contract for the not-yet-built interviewer-research feature.ThankYouDraftGeneratoronly ever readsTalkingPointsfrom it; it never fetches, scrapes, or guesses interviewer background itself. When that feature lands, it populates this summary from whatever the user has already reviewed/approved — the generator doesn't change.
Personalization without fabrication¶
Every optional field on ThankYouDraftInput is checked before use in
ThankYouDraftGenerator.cs.
When a field is blank or absent, the corresponding sentence is omitted, never replaced with
invented content:
flowchart TD
A[ThankYouDraftInput] --> B{Retro.WhatWentWell set?}
B -- yes --> B1["...especially enjoyed digging into {WhatWentWell}."]
B -- no --> B2[sentence omitted]
A --> C{QuestionIdToReinforce present in QuestionsAsked?}
C -- yes --> C1["I've thought more about your question on {Text}..."]
C -- no / not found --> C2[sentence omitted — never guesses a topic]
A --> D{CommonGround set?}
D -- yes --> D1["On a personal note, ...{WhatWeShared}."]
D -- no, but InterviewerResearch set --> D2["I also enjoyed our exchange about {TalkingPoints[0]}."]
D -- neither --> D3[sentence omitted]
A --> E{Story.VoiceSummary set?}
E -- yes --> E1[use VoiceSummary verbatim]
E -- no, TopSkills non-empty --> E2["my background in {TopSkills}..."]
E -- neither --> E3[sentence omitted]
ThankYouDraftGeneratorTests covers every branch: the "everything blank" case
(Draft_EveryOptionalFieldBlank_StillProducesAValidDraft_NeverFabricates) still produces a valid,
well-formed draft that mentions only the job/company/name — nothing invented.
Local-only storage: InterviewRetroService¶
InterviewRetroService.cs
is the single implementation of IInterviewRetroService. Three independent LocalJsonStore keys:
| Key | Holds | Grows |
|---|---|---|
interview-retros |
List<InterviewRetro> |
One per interview — saving again for the same InterviewId upserts in place (SaveRetroAsync), so filling in more of the survey later doesn't duplicate the row. |
asked-questions |
List<AskedQuestion> |
Unbounded — every LogQuestionAsync call appends. This is the personal question bank; it compounds as interviews increase (see below). |
common-ground |
List<CommonGroundEntry> |
One entry per (interview, interviewer) — LogCommonGroundAsync upserts by that compound key, not InterviewId alone (see below). The frontend reads the existing entry for that interviewer, appends the new "thing you shared" to WhatWeShared client-side, then re-POSTs the full list, so the row updates in place instead of duplicating. |
GetLearningsAsync computes InterviewLearnings on read: groups asked-questions by
QuestionCategory, and counts distinct interview ids seen across both questions and common-ground
to report TotalInterviewsCovered. GetCalibrationAsync filters interview-retros down to rows
that have either a self-report or company feedback and shapes them into RetroCalibration.
How the question bank compounds¶
There is no per-job cap and no expiry: LogQuestionAsync always appends. After N interviews across
M jobs, GetQuestionBankAsync returns every question ever logged, and GetLearningsAsync groups
them by category regardless of which job/interview they came from — this is what makes "Questions
I've been asked" a genuine personal study bank rather than a per-job scratchpad. Covered by
InterviewRetroServiceTests.QuestionBank_AccumulatesAcrossMultipleInterviews, which logs three
questions against three different interview ids and asserts all three persist and are
distinguishable.
Endpoints¶
InterviewRetroController.cs
under api/interview-retro — the whole controller is [RequireLocalToken] gated, so every route
below requires the per-launch token or the trusted dev origin (see the guardrail note above):
| Route | Purpose |
|---|---|
POST /retros |
Upsert a retro survey (by InterviewId). |
GET /retros · GET /retros/{interviewId} |
List all retros / fetch one. |
POST /retros/{interviewId}/company-feedback |
Record company feedback; creates the retro if none exists yet. |
GET /calibration |
Self-report vs. company feedback, side by side. |
POST /questions |
Log an asked question. |
GET /questions |
The full personal question bank. |
GET /questions/by-interview/{interviewId} |
Questions scoped to one interview (used when generating a thank-you draft). |
PUT /questions/{questionId}/answer-draft |
Save/update the user's own draft answer for later reuse. |
POST /common-ground · GET /common-ground |
Log / list common-ground entries. |
GET /learnings |
The "Interview learnings" study aggregate. |
POST /thank-you-drafts |
Draft only. Composes ThankYouDraftInput from the job (resolved via ResolveJobAsync — the live queued job if present, else a minimal JobPosting built from the matching ApplicationRecord), the user's IntakeProfile/StoryProfile, the retro for that interview, the questions logged for that interview, and the common-ground entry for that specific interviewer, then calls ThankYouDraftGenerator.Draft(...). Returns a ThankYouDraftSet; nothing is sent. |
DI registration¶
builder.Services.AddSingleton<IInterviewRetroService, InterviewRetroService>();
builder.Services.AddSingleton<IThankYouDraftGenerator, ThankYouDraftGenerator>();
Both interfaces live in WorkWingman.Core.Interfaces, matching the rest of the app's
interface-in-Core / implementation-in-Infrastructure split.
Frontend¶
frontend/src/app/features/interview-retro/ (InterviewRetroPage, route /interview-retro and
/interview-retro/:interviewId) renders one page per interview: the thank-you draft panel (with
"Copy" buttons, no send button anywhere), the retro survey, the question log with per-question
answer-draft textareas, the common-ground log, and a sidebar with the growing "Questions I've been
asked" aggregate and the self-report-vs-company-feedback calibration list. Applied (applied.ts)
links each tracked interview here via a new "log & thank-you" action.
Tests¶
InterviewRetroServiceTests.cs— retro upsert/round-trip, a brand-new blank-id retro getting a real id assigned, company-feedback recording + calibration shaping, question-bank accumulation across interviews, answer-draft updates, common-ground upsert-by-(interview, interviewer)(round-trip, same-interviewer update-in-place without duplicating,CreatedAtpreserved across updates, different interviewers on the same interview both persisting separately — the panel-interview regression case), the learnings aggregate (category grouping, distinct-interview counting), and the two local-only structural guarantees described above.InterviewRetroControllerTests.cs— the thank-you-draft endpoint's own composition logic, isolated with fakes: 404 when neither the queued job nor a matching application resolves, theResolveJobAsyncfallback succeeding for a seeded/legacy application with an emptyJobId, and the panel-interview regression (multiple interviewers' common-ground entries on one interview; the controller must select only the requested interviewer's, case-insensitively), andController_IsGatedByRequireLocalToken, which pins the[RequireLocalToken]attribute on the controller so the gate can't be silently removed.ApplicationTrackerServiceTests.cs—GetAll_BackfillsMissingInterviewIds_AndPersistsThemcovers theInterview.Idmigration fix above.ThankYouDraftGeneratorTests.cs— both channels produced, no send method exposed, personalization from each optional input (retro/question/common-ground/voice-summary/top-skills), graceful omission when each is blank or absent, the no-fabrication guarantee when aQuestionIdToReinforcedoesn't match any logged question, andInterviewIdtagging both from explicit input and on the empty-retro/no-questions first-use path.ApiSmokeTests— the five interview-retro listGETroutes are token-gated:401with no token and no trusted origin (hostile-page simulation),200when carrying the per-launch token or thelocalhost:4200dev origin, against an empty local store; a gatedPOSTis likewise401without trust. These routes are deliberately kept out of the file's open-GETtheory.- Frontend:
interview-retro.spec.ts(component logic: loading, selecting an interview, saving a retro, logging a question/common-ground entry per interviewer, generating drafts, that Copy reads the edited draft signal rather than the original generated text, that a failed answer-draft save does NOT update local state, the "no send method" structural check) and an added case inapplied.spec.tsfor the newgoRetronavigation.
Related docs¶
- Plain-language version: ../plain/interview-retro.md
- outreach-drafting.md — the same draft-only pattern, applied to outreach
- application-question-taxonomy.md — a different kind of
"question": ATS form fields, not interview questions.
AskedQuestionis unrelated toApplicationQuestion. - architecture.md · security.md
- Doc index: ../README.md