4. Tactical Design
Aggregates, entities, value objects, and domain events per context. Part of the Domain Model reference.
An aggregate is a cluster of related data with one entity as its "root", treated as a single unit for every change: one command modifies one aggregate, and its rules (invariants) must hold when the change commits. Eight of the nine contexts own exactly one aggregate root; Operations owns none — it only projects read models from the events the others emit.
Each context below owns the one aggregate root named on the right; its section then lists that root's invariants, entities, value objects, and the domain events it emits.
4.1 Job Discovery Context
Aggregate: Job
Aggregate Root: Job
Identity: (TenantId, JobId)
- TenantId: tenant scope (constant "local" in local-first mode)
- JobId: system-generated UUIDInvariants:
- A Job must have exactly one
PostingUrland oneSource. - A Job must have an
Employer(may be "Unknown" if not extractable at discovery time). JobIdis immutable once assigned.- Duplicate
PostingUrlglobally within aTenantIdis rejected (matches current behavior whereurlis PK). Same URL from different boards is the same job; different boards provide different metadata but not different job identity.
Lifecycle:
- Created when a job posting is scraped from an external source.
- Updated if re-discovered with additional metadata (salary, location).
- Soft-deleted when the user deletes it.
Entities: None (Job is the only entity; all other data is value objects).
Value Objects:
PostingUrl— validated URL string.Source(board: string)— the platform where the job was found (e.g., "linkedin", "greenhouse", "workday"). This is the board only; employer is a separate value object.Employer(name: string)— the hiring company, separated from source board. Maps to currentjobs.company(when extracted) vsjobs.site(which isSource.board).SearchStrategy— enum:jobspy | workday_api | smart_extract | manual.JobMetadata(title, salary, description, location)— discovery-time metadata.
Domain Events (all events carry tenantId):
JobDiscovered { tenantId, jobId, postingUrl, source, employer, metadata, discoveredAt }— Consumed by: Pipeline Orchestration (to initialize stage states), Operations (to update job list).JobUpdated { tenantId, jobId, changedFields }— Consumed by: Operations.JobDeleted { tenantId, jobId, reason, deletedAt }— Consumed by: Operations.JobRestored { tenantId, jobId, restoredAt }— Consumed by: Operations.
Domain Services: None. Discovery logic (scraping, dedup) lives in adapters behind ports; the aggregate is purely data.
4.2 Job Enrichment Context
Aggregate: JobEnrichment
Aggregate Root: JobEnrichment
Identity: (TenantId, JobId)One aggregate instance per job. EnrichmentAttempt is a child entity within the aggregate. This design ensures the invariant "at most one attempt Running per JobId" is enforced within a single aggregate boundary — not across multiple aggregate instances.
Invariants:
- At most one
EnrichmentAttemptmay beRunningat a time (enforced within the aggregate). ExtractionTieris recorded for every attempt (provenance).- Once any attempt succeeds, the aggregate is
Enrichedand further attempts are rejected unless explicitly reset.
Lifecycle:
- Created when Orchestration commands enrichment for a job.
- Accumulates
EnrichmentAttemptchild entities (one per try). - On success of any attempt, transitions to
Enriched. EmitsJobEnriched. - On failure, remains open for retry (new attempt) until exhausted.
Entities (non-root):
EnrichmentAttempt { attemptNumber, extractionTier, status: Running|Succeeded|Failed, startedAt, finishedAt, error? }
Value Objects:
FullDescription(text: string)— the extracted job description.ApplicationUrl— validated URL where the candidate can apply.ExtractionTier— enum:json_ld | css_selectors | llm_assisted.EnrichmentError(code: string, message: string, retryable: bool).
Domain Events (all events carry tenantId):
JobEnriched { tenantId, jobId, fullDescription, applicationUrl, extractionTier, enrichedAt }— Consumed by: Pipeline Orchestration, Scoring (knows it can now score), Operations.EnrichmentFailed { tenantId, jobId, error, attemptNumber }— Consumed by: Pipeline Orchestration (to update stage state), Operations.
Domain Services:
ExtractionStrategySelector— given a job's source and URL, determines which extraction tier to attempt first. Pure function, no I/O.
4.3 Candidate Profile Context
Aggregate: Profile
Aggregate Root: Profile
Identity: (TenantId, ProfileId)
- TenantId: tenant scope
- ProfileId: user identity within a tenant (singleton "default" in local-first mode)Invariants:
- Profile must have at least one
ExperienceEntry. - Every
ExperienceEntrymust have a non-emptyid,title, andcompany. - Every
SkillCategorymust have a non-emptyidandlabel. TailoringPolicyfields are constrained to valid enum values.WritingStylefields are constrained to valid enum values.
Lifecycle:
- Created during first-time setup (wizard) or resume import.
- Updated by user edits through the UI or CLI.
- Never deleted (but can be reset).
Entities (non-root):
ExperienceEntry { id, dateRange, title, company, location, bullets[], achievementEvidence[] }EducationEntry { id, date, degree, institution, location }SkillCategory { id, label, items[] }
Value Objects:
ExecutiveProfile(baselineText: string)AchievementEvidence { sourceText, scope, action, tools[], metrics[], outcome, senioritySignal, evidenceStrength, claimConfidence, userConfirmed }TailoringPolicy { mode, claimMode, autoApprovableClaimModes[], allowSummaryRewrite, allowTitleReframing, allowAchievementRewriting, allowSkillReordering, allowMinorInference, allowAdjacentAchievementDrafts }WritingStyle { tone, bulletStyle, verbosity, keywordDensity, avoidFirstPerson }ApplicationDefaults { ... }— default form field values.ResumeConstraints { realMetrics[], maxExperienceBullets, ... }
Domain Events (all events carry tenantId):
ProfileUpdated { tenantId, changedSections[], updatedAt }— Consumed by: Operations.ProfileImported { tenantId, source, importedSections[], importedAt }— Consumed by: Operations.
Domain Services:
ProfileSnapshot— creates an immutable, validated snapshot of the Profile for consumption by other contexts. This is the anti-corruption boundary: other contexts receive aProfileSnapshotvalue object, not a mutable reference.
Published Language: ProfileSnapshot is a published type — part of the Profile context's Published Language. It lives in a shared types package (packages/domain-types) alongside domain event schemas. Consuming contexts (Scoring, Materials, Apply) import ProfileSnapshot as a read-only value object. They have a compile-time dependency on the type definition, not on the Profile context's internal modules.
4.4 Scoring Context
Aggregate: JobScore
Aggregate Root: JobScore
Identity: (TenantId, JobId, version: int)Invariants:
FitScoremust be in range [1, 10].- A
ScoreBreakdownmust accompany every score. MatchedKeywordsis a non-empty list (if the LLM returns no keywords, the score is invalid).- A
ScoreCorrectionsupersedes the LLM score and must include areason.
Lifecycle:
- Created when Orchestration commands scoring for a job.
- May be rescored (creates a new version).
- May be corrected by the user (records correction alongside original).
Value Objects:
FitScore(value: int)— constrained to [1, 10].ScoreBreakdown { technicalFit, experienceFit, reasoning }— structured explanation.MatchedKeywords(keywords: string[])— ATS-relevant keywords.ScoreCorrection { correctedScore: FitScore, reason: string, correctedAt: Timestamp, correctedBy: UserId? }.ScoringCriteria { ... }— the rubric configuration used.
Domain Events (all events carry tenantId):
JobScored { tenantId, jobId, fitScore, breakdown, keywords, version, scoredAt }— Consumed by: Pipeline Orchestration, Materials Generation (eligibility check), Operations.ScoreCorrected { tenantId, jobId, originalScore, correctedScore, reason, correctedAt }— Consumed by: Pipeline Orchestration (may re-trigger downstream), Operations.
Domain Services:
ScoreParser— validates and parses LLM scoring responses intoFitScore+ScoreBreakdown. Pure function.EligibilityChecker— given aFitScoreand aminScorethreshold, determines if a job is eligible for materials generation. Pure function.
4.5 Materials Generation Context
Aggregate: MaterialsSet
Aggregate Root: MaterialsSet
Identity: (TenantId, JobId, generation: int)The MaterialsSet groups the three related artifacts (tailored resume, cover letter, PDF renderings) that must be internally consistent for one job application.
Invariants:
- A
TailoredResumemust pass structural validation before beingapproved. - A
TailoredResumemust pass deterministic tailoring quality checks before being eligible forapproved. - High-fit resumes (
fitScore >= 8) must not beapprovedwhile adversarial review reports blocker findings. - A
CoverLettercan only be generated after aTailoredResumeisapproved. - PDFs can only be rendered after their source documents exist.
- Banned words must not appear in any generated text.
- Generated content must not fabricate experience entries, companies, or credentials.
- Unsupported metrics and adjacent or draft achievements cannot be auto-approved unless profile evidence or the tailoring policy explicitly supports them.
- Every artifact must be registered with provenance (source job, generation params, timestamp).
Lifecycle:
Entities (non-root):
Artifact { artifactId, type, status, filePath, sizeBytes, metadata, createdAt }
Value Objects:
TailoredResume { executiveProfile, experienceUpdates[], skillCategoryUpdates[] }CoverLetter { text: string }ValidationResult { valid: bool, errors: ValidationError[] }JudgeVerdict { approved: bool, feedback: string, confidence: float }TailoringPlan { claimMode, allowedMetrics[], allowedKeywordPhrases[], requiredEvidenceIds[], highFit, seniorityLevel }AdversarialReview { required: bool, approved: bool, blockers[], repairInstructions[], reviewerFindings[] }ArtifactType— enum:tailored_resume | cover_letter | resume_pdf | cover_letter_pdfArtifactStatus— enum:candidate | approved | rejected | supersededRenderFormat— enum:latex_pdf | html_pdf | text
Domain Events (all events carry tenantId):
ResumeApproved { tenantId, jobId, artifactId, generation, approvedAt }ResumeFailed { tenantId, jobId, validationErrors[], attemptNumber }CoverLetterGenerated { tenantId, jobId, artifactId, generatedAt }PdfRendered { tenantId, jobId, artifactType, artifactId, renderedAt }MaterialsExhausted { tenantId, jobId, stage, attemptCount, maxAttempts }
Design note: Domain events carry
artifactId, notfilePath. The artifact's storage location (local path or S3 key) is an infrastructure concern resolved at read time viaArtifactStoragePort.resolve(artifactId). This ensures events stored injob_eventsremain meaningful across environments (local ↔ cloud) and can be replayed without path translation. — Consumed by: Pipeline Orchestration, Operations.
Domain Services:
ResumeAssembler— given LLM output + profile baseline, assembles the final tailored resume text. Injects fixed structure (header, education) from the master resume. Pure function.ContentValidator— checks for banned words, fabrication, structural integrity. Pure function.TailoringQualityEvaluator— evaluates generated resumes against theTailoringPlanbefore judge approval. Pure function.AdversarialResumeReviewer— prompts reviewer personas for high-fit jobs and converts blocker findings into retry feedback.
Generation lifecycle: When the user re-tailors a job, a new MaterialsSet is created with generation incremented. The earlier MaterialsSet has its artifacts transitioned to superseded status and becomes read-only. The aggregate factory (MaterialsSetFactory) reads the current highest generation for the (tenantId, jobId) and creates generation + 1. This means the aggregate does not grow unboundedly — each generation is a fixed-size set of artifacts. Historical artifacts are queryable through the Operations read model (artifact list projection).
4.6 Apply Automation Context
Aggregate: ApplyRun
Aggregate Root: ApplyRun
Identity: (TenantId, RunId: UUID)Invariants:
- An
ApplyRunmust reference a validJobId. - A job must have an
ApplicationUrl,TailoredResume, andCoverLetterbefore anApplyRuncan be created. - At most one
ApplyRunmay bein_progressfor a givenJobIdat a time. - A
DryRunmust never mark the job asapplied. TokenUsageandCostUsdare recorded on completion.
Lifecycle:
Entities (non-root):
ApplyRunEvent { eventId, eventType, level, message, payload, occurredAt }
Value Objects:
SubmissionResult— discriminated union:Applied { appliedAt, verificationConfidence }|Failed { error, retryable }|Captcha { details }|LoginIssue { details }|Expired {}|Manual { reason }|DryRunComplete { navigatedTo }BrowserWorkerConfig { workerId, cdpPort, headless, userDataDir }ApplyPrompt { text, mcpConfig }TokenUsage { input, output, cacheRead, cacheCreate, costUsd }
Domain Events (all events carry tenantId):
ApplicationSubmitted { tenantId, jobId, runId, appliedAt, verificationConfidence }— Consumed by: Pipeline Orchestration, Operations.ApplicationFailed { tenantId, jobId, runId, result: SubmissionResult, attemptNumber }— Consumed by: Pipeline Orchestration, Operations.ApplyRunStarted { tenantId, jobId, runId, workerId, model, dryRun, startedAt }— Consumed by: Operations (telemetry dashboard).ApplyRunEventRecorded { tenantId, runId, event: ApplyRunEvent }— Consumed by: Operations (live telemetry feed).
Domain Services:
ApplyEligibilityChecker— validates that a job has all prerequisites for apply (application URL, materials, not already applied, within attempt limits). Pure function.
4.7 Pipeline Orchestration Context
Aggregate: JobPipelineState
Aggregate Root: JobPipelineState
Identity: (TenantId, JobId)Invariants:
- Every
JobIdhas exactly oneStageStateperStage(7 stages total). - Stage state transitions must follow the state machine (Section 8).
attempt_countmonotonically increases (never decreases except on explicit reset).- A stage in
Runningstate cannot transition toPending(must go throughFailedorSucceeded). blocked_byis computed from upstream stage states; it cannot be set arbitrarily.
Value Objects:
Stage— enum:discover | enrich | score | tailor | cover | pdf | applyStageState— discriminated union (see Section 8 for full state machine):
StageState =
| Pending { attemptCount, maxAttempts, nextAction? }
| Queued { queuedAt }
| Running { attemptCount, startedAt }
| Succeeded { attemptCount, finishedAt, durationMs }
| Failed { attemptCount, maxAttempts, errorCode, errorMessage, retryable, nextAction? }
| Blocked { blockedBy: Stage[], errorCode, errorMessage }
| Skipped { reason: string }
| Exhausted { attemptCount, maxAttempts, errorCode, errorMessage, nextAction? }
| NeedsVerification { reason: string, nextAction? }
| Stale { reason: string }
| Canceled { canceledAt, reason? }NeedsVerification is where an ambiguous live apply run parks after submit intent (at-most-once apply): the run cannot be safely auto-requeued, so it waits for human resolution rather than risking a duplicate employer submission (see the 2026-07-03 "At-Most-Once Apply" decision in docs/decisions.md). The domain model carries eleven stage-state variants in total.
RetryPolicy { maxAttempts: int, backoffMs?: int }
Domain Events (all events carry tenantId):
StageStarted { tenantId, jobId, stage, attemptNumber, startedAt }StageCompleted { tenantId, jobId, stage, state: Succeeded, finishedAt, durationMs }StageFailed { tenantId, jobId, stage, errorCode, errorMessage, retryable, attemptNumber }StageExhausted { tenantId, jobId, stage, attemptCount, maxAttempts }StageReset { tenantId, jobId, stage, resetAttempts: bool, resetAt }StageBlocked { tenantId, jobId, stage, blockedBy: Stage[] }StageSkipped { tenantId, jobId, stage, reason }— All consumed by: Operations.
Domain Services:
StageStateMachine— enforces valid transitions. Given current state + transition event, produces new state or rejects the transition. Pure function.PipelineScheduler— given all job stage states and a pipeline run request, determines which jobs need processing at which stage. Pure function.
Orchestration guard against premature processing: Processing contexts (Scoring, Materials, etc.) do not independently decide to process jobs. They only act in response to commands dispatched by Orchestration. Orchestration transitions the stage to Running before dispatching the command. If a processing context receives a raw JobEnriched event, it does NOT autonomously start scoring — it waits for Orchestration to evaluate the event, check upstream dependencies, and dispatch a ScoreJob command with the stage already in Running state. This single-dispatcher model eliminates the race condition where a processing context emits results for a stage that Orchestration hasn't acknowledged.
4.8 Operations / Read-Side Context
This context has no aggregates of its own. It maintains projections (read models) built from domain events emitted by other contexts.
Projections: nine denormalised read-model tables (PROJECTION_TABLES in infrastructure/projections/sqlite_projection_store.py):
job_list_projections— denormalized job rows with current stage state, score, artifact status.dashboard_projections— aggregate counts by stage, state, source, score distribution.job_detail_projections— full job view with all stage states, events, and artifacts.artifact_list_projections— all artifacts across jobs with provenance.evidence_usage_projections— career-evidence-map rows inverting profile achievement/skill evidence into resume-bullet, requirement-fit, and generation-time coverage usage, plus missing/blocked/transferable gaps.apply_run_projections— apply run telemetry with event timelines, keyed by the Temporal workflow run id.workflow_run_projections— unified list of all Temporal workflow runs and their terminal status. This projection is Python-sole-writer (folded from theWorkflow*events); the TypeScript API mirrors it read-only.source_quality_stats— per-source discovery health (success/failure attribution, quarantine, circuit-breaker signals).contact_projections— one row per contact (Contact & Outreach): link, role, attribute and confirmed-fact counts, distinct source kinds, and per-attribute provenance metadata; no attribute values (sensitivity).
The retired discovery_run_projections write-only table no longer owns any read-model behaviour; source health is projected through source_quality_stats.
Domain Services:
ProjectionBuilder— subscribes to domain events and updates projections. In the local-first architecture, this is synchronous (direct DB writes after domain operations); both the Python worker and the TypeScript API (apps/api/src/projections.ts) maintain projections idempotently against the sharedevent_watermarks.operations_projectionswatermark. In the hosted future, this becomes an async event consumer.
4.9 Contact & Outreach Context
Aggregate: Contact
Aggregate Root: Contact
Identity: (TenantId, ContactId)Phase 1 realises the Contact aggregate; Phase 2 adds the supervised ContactResearchTask aggregate; Phase 3 adds the OutreachThread aggregate (all below).
Invariants:
- Every
ContactAttributecarries a non-nullContactFactProvenance(INV-2); constructing an attribute without provenance is impossible. - A
Contactlinks to at least one of{employer, jobId}(enforced byContactLink). ContactIdis immutable once assigned.
Lifecycle:
- Created from user input or a CSV import row.
- Updated by user edits (link, role, or attributes;
ContactIdandcreatedAtare immutable). - Soft-deleted when the user deletes it.
Value Objects:
ContactAttribute { attributeId, kind, value, provenance }— one fact (name, title, email, phone, profile URL, note).valueis sensitive.ContactFactProvenance { sourceKind, sourceRef, captureMethod, capturedAt, confidence, userConfirmed }—sourceKind ∈ {user_entered, public_web_page, user_imported_list, derived};captureMethod ∈ {manual, json_ld, css_selectors, llm_assisted}. Modelled onAchievementEvidence.ContactLink { employer?, jobId? }— at least one required.ContactRole— enum:recruiter | hiring_manager | referrer | warm_intro | other.WarmIntroSignal— a value-object placeholder only in Phase 1 (no warm-intro inference; that is a later phase — INV-6).
Domain Events (all carry tenantId; payloads carry only ids, kinds, provenance metadata, and timestamps — never attribute values):
ContactCreated { tenantId, contactId, employer, jobId, role, createdAt }ContactUpdated { tenantId, contactId, changedFields, updatedAt }ContactAttributeRecorded { tenantId, contactId, attributeId, attributeKind, sourceKind, sourceRef, captureMethod, confidence, userConfirmed, recordedAt }ContactDeleted { tenantId, contactId, reason, deletedAt }— All consumed by: Operations (thecontact_projectionsread model).
Domain Services: None in Phase 1. (The pure WarmIntroMatcher lands with warm-intro identification in a later phase.)
CSV import: the import use case tags every imported fact with sourceKind = user_imported_list, sourceRef = <filename>, captureMethod = manual; a row that links to neither an employer nor an application is skipped.
Aggregate: ContactResearchTask
Aggregate Root: ContactResearchTask
Identity: (TenantId, ResearchTaskId)A supervised research run for a company/application, with its own lifecycle, distinct from the durable Contact (mirrors how ApplyRun is separate from Job). Research proposes candidates; the user confirms them.
Status (discriminated union): queued | running | needs_review | completed | failed.
Invariants:
- A task only fetches sources permitted by the source-access policy (INV-3); an attempt against a disallowed source is rejected before any fetch and recorded as a
ResearchSourceAttemptoutcome, never a scrape error. - Proposed candidates land in
needs_review; no candidate becomes a storedContactfact without an explicit user confirmation command (INV-4). The only transition toconfirmedisconfirm_candidate. - Every
ContactCandidateattribute carries provenance (INV-2).
Value Objects / Entities:
ContactCandidate { candidateId, role, attributes, provenance, confidence, status, proposedAt, confirmedContactId?, confirmedAt? }— a proposed contact; each attribute is a provenance-bearingContactAttribute.status ∈ {needs_review, confirmed, dismissed}. Attribute values are sensitive.ResearchSourceAttempt { sourceKind, sourceRef, outcome, attemptedAt, detail }— provenance of the search itself;outcome ∈ {allowed, no_candidates, robots_disallowed, rate_limited, budget_exhausted, manual_capture_required, rejected, extraction_failed}.
Source-access policy (ContactResearchSourcePolicy, INV-3): exactly three allowed source categories — user_entered, public_web_page (unauthenticated GET, robots/rate-limited through the merged politeness gateway), user_imported_list. It reuses the discovery SourcePolicy guardrails (third_party_control_bypass hard-locked false, authentication = none) and a LocatorPolicy with allow_autonomous_broad_discovery = false. No public source is auto-fetched by default (per-source user opt-in); any login-walled / paywalled / bot-protected URL routes to the manual-capture path, never auto-fetched.
Domain Services: ContactResearchService (pure) — authorises each source, fetches allowed public pages through the injected gateway-routed fetcher, and extracts candidates via a schema-driven LlmPort.chat_json.
Domain Events (payloads carry only ids, kinds, provenance metadata, outcomes, and timestamps — never candidate values):
ContactResearchTaskStarted { tenantId, taskId, employer, jobId, startedAt }ContactCandidateProposed { tenantId, taskId, candidateId, role, sourceKind, sourceRef, captureMethod, confidence, proposedAt }ContactResearchTaskNeedsReview { tenantId, taskId, candidateCount, needsReviewAt }ContactResearchTaskCompleted { tenantId, taskId, confirmedCount, completedAt }ContactResearchTaskFailed { tenantId, taskId, errorClass, retryable, failedAt }— All consumed by: Operations (thecontact_research_task_projectionsread model).
Aggregate: OutreachThread
Aggregate Root: OutreachThread
Identity: (TenantId, OutreachThreadId)The outreach state for one (Contact, optional application) — its generation-versioned, reviewable, editable drafts, its user-attested send logs, and its follow-up schedule (a distinct lifecycle from the durable Contact, mirroring how MaterialsSet is separate from Job). Phase 3 realises truthful drafting; Phase 4 adds the user-attested send log (a recorded fact, not a transport) and the follow-up schedule.
Invariants:
- "Sent" only via a user-attested send log (INV-1). The system never sends and exposes no outbound transport. A thread reaches a "sent" state ONLY through an
OutreachSendLog— a user-attested record that the user sent a specific approved draft — and "sent" is a derived property (is_sent = bool(send_logs)), never a stored marker that could drift.log_sendrefuses any draft that is not currently approved, and rehydrating a thread whose send log attests a never-approved or missing draft raises — mirroring theApplyRundry-run/evidence coherence guard (a terminal marker MUST coincide with the evidence that justifies it). "Approve draft" and "log send" are distinct user actions. - Follow-ups are surfaced-only (INV-1, §9). The
FollowUpScheduleholds a suggested next date derived from the application lifecycle (7 days after submission; 14 for a subsequent no-reply nudge), fully user-editable, and is never auto-acted or sent. Whether it is due is a derived read-model signal over schedule + clock, never a stored flag. - An
OutreachDraftcan only beapprovedwhen its persistedDraftGateResultspassed (INV-5); constructing anapproveddraft over failed gates is impossible, so a rehydrated draft can never lie about being approved-but-ungrounded. - Re-drafting mints a new generation and supersedes prior candidate drafts, but the last
approveddraft stays readable until a replacement is itself approved (INV-5) — mirroringMaterialsSetFactory.next_generationand the "never destroy the last accepted artifact" rule. Rejecting a candidate never touches the approved draft. - Every draft carries claim → fact provenance computed against the actual draft text, never inferred from the target (INV-2).
Lifecycle (per draft, reusing the materials ArtifactStatus semantics):candidate → approved, candidate → rejected, or candidate | approved → superseded. suppressed is a materials-only policy state and is never used for a draft.
Entities:
OutreachDraft { draftId, threadId, generation, kind, status, bodyText, gateResults, provenance[], createdAt, approvedAt?, rejectedAt?, reason }— one generation-versioned draft.bodyTextis the reviewable message and is sensitive (the user's own outreach content);gateResultsis the persisted truthfulness-gate outcome and the ONLY authority approval is gated on;provenance[]binds each claim to the confirmed fact it rests on.OutreachSendLog { sendLogId, threadId, draftId, channel, sentAt, loggedAt }(Phase 4) — a user-attested record that the user sentdraftId(an approved draft) onsentAtviachannel(a free-text label, never an address). It is a recorded fact, not a transport; its presence is the only thing that makes a thread "sent" (INV-1).
Value Objects:
OutreachDraftKind— enum:intro_request | follow_up. Phase 3 generates intro requests;follow_upis a valid drafting target, but follow-up scheduling is a later phase.DraftGateResults { passed, fabrications[], validation, judge, computedAgainst }— the aggregated outcome of the reused truthfulness gate stack;passedis true only when the deterministic detector found no fabrications, the content validator passed, and the judge approved.computedAgainstrecords that the gates ran against the rendered draft text.OutreachClaimProvenance { claimId, section, generatedText, contactFactIds[], profileGrounded, rationale }— one claim (paragraph) bound to the confirmed contact attribute ids and the profile evidence it rests on, computed against the rendered draft text.FollowUpSchedule { state, dueAt?, basis }(Phase 4) — the thread's follow-up plan.state ∈ { none | scheduled | completed | dismissed };dueAtis the suggested/scheduled date (required whenscheduled);basis ∈ { application_submitted | no_reply_nudge | manual }records why it was suggested. A plan, never an action — nothing is ever sent on its behalf (INV-1).
Truthfulness gates (INV-5): draft generation and every user edit run the reused Materials gate stack (deterministic never-fabricate detector → content validator → LLM-as-judge → claim → fact provenance) against the actual draft text; the tailoring contract documents the stack in order and the cover-letter reuse precedent.
Domain Events (all carry tenantId; payloads carry only ids, kinds, generation, and timestamps — never the draft body, gate text, or contact PII):
OutreachDraftGenerated { tenantId, threadId, contactId, jobId, draftId, generation, kind, generatedAt }OutreachDraftRevised { tenantId, threadId, draftId, generation, revisedAt }OutreachDraftApproved { tenantId, threadId, draftId, generation, approvedAt }OutreachDraftRejected { tenantId, threadId, draftId, generation, reason, rejectedAt }OutreachSendLogged { tenantId, threadId, draftId, channel, sentAt, loggedAt }(Phase 4) — the user-attested send fact; the channel is a label only.FollowUpScheduled { tenantId, threadId, jobId, dueAt, basis, scheduledAt }(Phase 4)FollowUpCompleted { tenantId, threadId, completedAt }(Phase 4)FollowUpDismissed { tenantId, threadId, reason, dismissedAt }(Phase 4) — All consumed by: Operations (theoutreach_thread_projectionsand, for the send/follow-up events,due_follow_up_projectionsread models).
Sensitivity: the draft bodyText, gate results, and claim provenance live only on the canonical write side (outreach_drafts) and reach the client through the thread detail read; event payloads written to job_events (entity_kind = 'outreach' / entity_ref = <threadId>; application-linked threads also key on the job's job_url) carry only ids, kinds, generation, and timestamps.