Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .slop/ceilings.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
schema: 1
scopes:
go_production:
active_clones_ceiling: 103
active_clones_ceiling: 101
go_tests:
active_clones_ceiling: 314
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
# Career Agent Core - Changelog

## 2026-08-19 — "I don't have this" is a first-class answer the vault can now represent (improvements.md #545)

The Approved Answer Vault gains a third state: intentional absence. Previously, the
vault distinguished only "resolved with a value" from "unresolved" — so when the operator
genuinely had no Twitter/X account, there was no way to record that fact. The question sat
in the inbox permanently, competing for attention with questions that actually needed
deciding.

`KindAbsence` (`answer_kind = 'absence'`) is now a first-class `Kind` alongside `text`,
`boolean`, `choice`, `number`, and `url`. The `answer_text` stores a human-readable reason
("No Twitter/X account") rather than an empty string, preserving the non-NULL invariant.
`Resolution.IntentionalAbsence` tells the fill path to leave the control untouched rather
than typing anything.

Safety invariants:
- Absence auto-fills only optional fields. A required field with an absence answer surfaces
as needing operator attention (`applyAbsenceSafety` in `resolve.go`).
- Sensitive questions (work auth, sponsorship, privacy consent, legal attestations) require
the same two-decision explicit approval path as value answers.
- `GeneratePerJob` questions refuse absence outright (they need per-employer answers).
- Canonicalization boundaries are preserved: Twitter absence does not spread to LinkedIn,
GitHub, or portfolio (separate question keys, separate patterns).

`Readiness.AbsenceResolved` tracks absence-resolved fields separately from value-filled
fields so metrics remain truthful. The dashboard exposes `POST /api/knowledge/absence` for
the operator to declare absence, and the existing revocation path restores an absent
question to unresolved when the operator's circumstances change.

20 regression tests cover the full surface: storage, resolution, required-field safety,
cross-account isolation, sensitive-question refusal, revocation, metrics, and policy
classification.

## 2026-08-19 — Education-summary questions now resolve from the configured profile after operator approval

The production dogfood audit found that factual education-summary questions were
Expand Down
98 changes: 84 additions & 14 deletions cmd/dashboard/knowledge_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,30 +81,63 @@ func serveKnowledge(w http.ResponseWriter, r *http.Request) {
writeJSON(w, snapshot)
}

// openKnowledgeServiceOrRespond loads the knowledge service; on failure it
// writes a 500 and returns false.
func openKnowledgeServiceOrRespond(w http.ResponseWriter, label string) (*knowledge.Service, bool) {
service, err := openKnowledgeService()
if err != nil {
log.Printf("%s: %v", label, err)
http.Error(w, "application knowledge is unavailable", http.StatusInternalServerError)
return nil, false
}
return service, true
}

// knowledgeApproveRequest is the POST body for approving a value answer.
type knowledgeApproveRequest struct {
GroupKey string `json:"group_key"`
Answer string `json:"answer"`
SaveForReuse bool `json:"save_for_reuse"`
AllowSensitiveReuse bool `json:"allow_sensitive_reuse"`
ConfirmedEquivalent bool `json:"confirmed_equivalent"`
Scope string `json:"scope"`
}

// knowledgeAbsenceRequest is the POST body for declaring an intentional absence.
type knowledgeAbsenceRequest struct {
GroupKey string `json:"group_key"`
Reason string `json:"reason"`
ConfirmedEquivalent bool `json:"confirmed_equivalent"`
Scope string `json:"scope"`
}

// decodeKnowledgeRequest decodes a bounded JSON body into dst, writing a 400 on failure.
func decodeKnowledgeRequest(w http.ResponseWriter, r *http.Request, dst interface{}) bool {
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxKnowledgeRequestBytes))
decoder.DisallowUnknownFields()
if err := decoder.Decode(dst); err != nil {
http.Error(w, "request could not be decoded", http.StatusBadRequest)
return false
}
return true
}

// serveKnowledgeApprove stores one answer for every application waiting on it.
func serveKnowledgeApprove(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var request struct {
GroupKey string `json:"group_key"`
Answer string `json:"answer"`
SaveForReuse bool `json:"save_for_reuse"`
AllowSensitiveReuse bool `json:"allow_sensitive_reuse"`
ConfirmedEquivalent bool `json:"confirmed_equivalent"`
Scope string `json:"scope"`
var request knowledgeApproveRequest
if !decodeKnowledgeRequest(w, r, &request) {
return
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxKnowledgeRequestBytes))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil || strings.TrimSpace(request.GroupKey) == "" {
if strings.TrimSpace(request.GroupKey) == "" {
http.Error(w, "a question and an answer are required", http.StatusBadRequest)
return
}
service, err := openKnowledgeService()
if err != nil {
log.Printf("serveKnowledgeApprove: %v", err)
http.Error(w, "application knowledge is unavailable", http.StatusInternalServerError)
service, ok := openKnowledgeServiceOrRespond(w, "serveKnowledgeApprove")
if !ok {
return
}
result, err := service.Approve(knowledge.ApproveRequest{
Expand All @@ -131,6 +164,43 @@ func serveKnowledgeApprove(w http.ResponseWriter, r *http.Request) {
writeJSON(w, result)
}

// serveKnowledgeAbsence stores an intentional-absence decision for a group.
func serveKnowledgeAbsence(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var request knowledgeAbsenceRequest
if !decodeKnowledgeRequest(w, r, &request) {
return
}
if strings.TrimSpace(request.GroupKey) == "" {
http.Error(w, "a question group and a reason are required", http.StatusBadRequest)
return
}
service, ok := openKnowledgeServiceOrRespond(w, "serveKnowledgeAbsence")
if !ok {
return
}
result, err := service.ApproveAbsence(knowledge.ApproveAbsenceRequest{
GroupKey: request.GroupKey,
Reason: request.Reason,
SaveForReuse: true,
ConfirmedEquivalent: request.ConfirmedEquivalent,
Scope: normalizeRequestedScope(request.Scope),
}, time.Now().UTC())
if err != nil {
switch {
case errors.Is(err, answers.ErrNotReusable):
http.Error(w, "this answer is written for one employer and cannot be marked absent", http.StatusConflict)
default:
http.Error(w, err.Error(), http.StatusConflict)
}
return
}
writeJSON(w, result)
}

// serveKnowledgeField answers a single-field query.
//
// This is the endpoint ADR-005's normal-browser companion needs, so that
Expand Down
1 change: 1 addition & 0 deletions cmd/dashboard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,7 @@ func main() {
// per-row work for every queued application.
mux.HandleFunc("/api/knowledge", requireSameOrigin(serveKnowledge))
mux.HandleFunc("/api/knowledge/approve", requireSameOrigin(serveKnowledgeApprove))
mux.HandleFunc("/api/knowledge/absence", requireSameOrigin(serveKnowledgeAbsence))
mux.HandleFunc("/api/knowledge/preflight", requireSameOrigin(servePreflight))
mux.HandleFunc("/api/knowledge/field", requireSameOrigin(serveKnowledgeField))
mux.HandleFunc("/api/knowledge/profile", requireSameOrigin(serveKnowledgeProfile))
Expand Down
52 changes: 52 additions & 0 deletions docs/adrs/ADR-007-Application-Knowledge.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ Automatic Apply and adds `fill_source` (`bugs.md` #551) in `pkg/storage/question
`config.PII.EducationSummary()` (`pkg/answers/patterns.go`, `pkg/config/pii.go`,
`pkg/answers/resolve.go`).

**Amended 2026-08-19** with decision 11, which adds first-class intentional absence
(`improvements.md` #545) in `pkg/answers/model.go`, `pkg/answers/store.go`,
`pkg/answers/resolve.go`, `pkg/knowledge/knowledge.go`, and
`cmd/dashboard/knowledge_api.go`.

## Context

The Approved Answer Vault (ADR-adjacent; `pkg/answers`, `improvements.md` #497) already solved the
Expand Down Expand Up @@ -468,3 +473,50 @@ decision path used for work authorization and sponsorship. Free-text essay promp
("Describe how your education prepared you...", "Why did you choose your field of study?") and
skill-scoped or possession questions ("Do you have a degree in computer science?") are rejected or
miss the pattern's `RequireAll` groups, so they do not inherit the generic summary.

### 11. Intentional absence is a first-class answer state, distinct from both "value" and "unanswered"

*Added 2026-08-19. Closes `improvements.md` #545.*

The vault previously had two states: a question has an approved answer (value), or it does not
(unresolved). Production evidence showed a third: the operator has decided, and the decision is
that an optional field gets nothing. Five out of six inspected applications asked for a Twitter/X
URL the operator does not have. The vault rejected empty answers by design (`answer_text TEXT NOT
NULL` and `Store.Save`'s empty-answer refusal), so there was no way to record this truthful,
reusable fact.

**Representation.** `KindAbsence` (`answer_kind = 'absence'`) is added to the existing `Kind`
enum. The `answer_text` column stores a human-readable reason (e.g. "No Twitter/X account")
rather than an empty string, preserving the non-NULL invariant and giving the vault management
view something meaningful to display. `Resolution` gains an `IntentionalAbsence bool` field that
the fill path uses to distinguish "leave the control untouched" from "type this value".

**Optional-field safety invariant.** The central rule: absence may only auto-fill (resolve
without operator intervention) when the live field is optional. If `Question.Required == true`,
`applyAbsenceSafety` in `resolve.go` forces `AutoFill = false`, so the field surfaces as needing
operator attention. The safety is enforced at the resolution boundary, not at storage time,
because the same absence answer must correctly resolve an optional Twitter field on one
application while surfacing as a conflict on another that marks the same field required.

**Canonicalization boundaries.** Absence reuse follows the same deterministic alias/key/scope
model as value answers. A Twitter absence does not spread to LinkedIn, GitHub, or portfolio
because those are separate question keys and separate patterns. No semantic similarity or broad
account-type inference is added.

**Sensitive-question safety.** Sensitive questions (work authorization, sponsorship, privacy
consent, legal attestations, EEO/demographic) require the same explicit two-decision approval
path for absence as for value answers. `SaveAbsence` refuses `GeneratePerJob` outright and
demands `ReuseDecisionMade && ReuseAllowed` for `Sensitive`.

**Metrics.** `Readiness.AbsenceResolved` counts fields handled by operator absence decisions,
as a subset of `Resolved`. The UI can accurately report "18/20 fields resolved (2 intentionally
left blank)" without inflating the "fields filled" count. The fill path skips the control
entirely rather than typing an empty string; `AssistedFillSummary.FilledCount` is not incremented.

**Approval path.** `knowledge.ApproveAbsence` stores the absence, binds aliases for equivalent
phrasings, and immediately re-evaluates the queue, following the same machinery `Approve` uses.
`POST /api/knowledge/absence` exposes this to the dashboard. Absence always grants reuse (an
absence without reuse resolves nothing and serves no purpose).

**Revocation.** The existing `Store.Revoke` applies unchanged: revoking an absence row
restores the question to unresolved, and the next re-evaluation surfaces it in the inbox.
28 changes: 2 additions & 26 deletions documentation/backlog/improvements.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Scores apply to Pending rows only; Done and Closed rows show `—`.
| 550 | [Preparation runs log under an `[Auto-Submit]` prefix, so a read-only inspection reads like a submission](#550-preparation-runs-log-under-an-auto-submit-prefix-so-a-read-only-inspection-reads-like-a-submission) | Done (2026-08-18) | — | mechanical | Fixed: `clickApplyIfPresent` and `resolveConsentGateIfPresent` accept optional contextual log prefixes; preflight logs under `[Preflight]`. |
| 557 | [`distinctiveRoleWords`'s broadest single-word entries admit clearly off-track titles that happen to share one generic word with a configured role](#557-distinctiverolewordss-broadest-single-word-entries-admit-clearly-off-track-titles-that-happen-to-share-one-generic-word-with-a-configured-role) | Done (2026-08-17) | 1.0 = 3×1.0÷3 | standard | Done — see `documentation/backlog_history/improvements_done_details.md#557`. |
| 558 | [`ClassifyTitle`'s management-word detection has no representation for "Lead", and normalizes commas away, letting reporting-line text leak into the check](#558-classifytitles-management-word-detection-has-no-representation-for-lead-and-normalizes-commas-away-letting-reporting-line-text-leak-into-the-check) | Pending | 0.7 = 2×1.0÷3 | deep-reasoning | Filed 2026-08-17 by bugs.md #556's independent review (Reviewers A and B). `managementTitleWords` (`pkg/config/title_policy.go`) has no entry for `lead`, `supervisor`, `president`, `founder`, `owner`, C-suite acronyms, or `product owner`/`scrum master` — genuine false positives for e.g. "Founder & DevOps Lead". Not a quick add: Reviewer B independently found `lead` is also a common senior-IC (not people-manager) title at many companies ("DevOps Lead", "Platform Lead"), so blanket-adding it trades one error class for another rather than removing it — needs real-market evidence (a sample of actual `Lead`-titled postings and their seniority pages) before deciding, which is why this is `deep-reasoning`. Separately, `normalizeForRemoteCheck` turns commas into spaces, so a title carrying trailing reporting-line text ("DevOps Engineer, reporting to the VP of Engineering") is rejected as management-track by the `vp` it picks up from that suffix — no evidence yet that a real feed publishes such titles, but worth a targeted fix (stop normalizing at the first comma) if one ever is. |
| 545 | ["I don't have this" is a real answer the vault cannot represent](#545-i-dont-have-this-is-a-real-answer-the-vault-cannot-represent) | Pending | 2.0 = 4×1.0÷2 | standard | Found 2026-08-13 on the real queue: 5 optional Twitter fields the operator has no account for. `Store.Save` refuses an empty answer by design, so the only options are typing "N/A" into four employers' optional fields or letting the question sit in the inbox forever. Recurs for every optional field the operator does not have. |
| 545 | ["I don't have this" is a real answer the vault cannot represent](#545-i-dont-have-this-is-a-real-answer-the-vault-cannot-represent) | Done (2026-08-19) | — | standard | See `documentation/backlog_history/improvements_done_details.md` item #545 for the full account. |
| 543 | [Gated semantic suggestions for equivalent questions](#543-gated-semantic-suggestions-for-equivalent-questions) | Pending | 0.4 = 2×1.0÷5 | deep-reasoning | ⚠️ below floor — needs explicit user confirmation. Deduplication deliberately stops at curated determinism (ADR-007 decision 2). A model could propose that two differently-worded questions match, but the failure mode is a wrong answer on a real application, so it would need confirmation below a very high threshold and must never touch declarations. No evidence yet that the curated layers leave enough on the table to justify it. |
| 544 | [Predict application effort from this installation's own history](#544-predict-application-effort-from-this-installations-own-history) | Pending | 0.5 = 2×1.0÷4 | standard | `EstimateAssistedEffort` is a hand-tuned additive model. `human_interactions` now records real durations per application, and preflight records real field counts, so the inputs for a measured estimate exist. Worth doing only once enough sessions have accumulated to fit against; today there are too few. |
| 536 | [Normal-browser Career Agent companion](#536-normal-browser-career-agent-companion) | Pending | 0.6 = 3×1.0÷5 | deep-reasoning | Designed in `docs/adrs/ADR-005-Browser-Companion.md`, deliberately not built. The Copy Application Packet already recovers most of the operator time in the handoff case at none of the complexity, so the remaining benefit does not yet justify shipping a browser extension that handles PII. |
Expand Down Expand Up @@ -306,31 +306,7 @@ itself.

### 545. "I don't have this" is a real answer the vault cannot represent

**Found 2026-08-13**, on the real queue rather than in the abstract. Six inspected applications ask for a Twitter URL five times. The operator has no Twitter account. Every one of those fields is `required = 0`.

The honest answer is "leave it blank", and there is no way to say it.

* `answers.Store.Save` refuses an empty answer (`an approved answer needs an answer`), deliberately — an empty approved answer would be indistinguishable from a mistake.
* So the operator's only choices today are to type something like `N/A` into four employers' optional fields, or to leave the question in the Application Knowledge inbox permanently, where it competes for attention with questions that actually need deciding.
* Neither is right, and it is not specific to Twitter: it recurs for **every optional field the operator does not have** — a portfolio they have not built, a second phone number, a referral code.

**What is missing conceptually.** The vault currently distinguishes *unresolved* (nobody has decided) from *resolved* (there is a value). There is a third state: **the operator has decided, and the decision is that this field gets nothing.** That is reusable knowledge in exactly the way an approved answer is, and it is what stops the inbox nagging.

**Fix direction.**

* A declined answer is a first-class row, not an empty `answer_text`. Store the decision explicitly (a kind, a flag, or a sentinel the store understands) so `Save`'s existing refusal stays intact for genuine mistakes.
* It resolves to *auto-fill nothing*: `Resolved` true, `AutoFill` true, value empty — Career Agent types nothing, reports the field as handled, and stops asking. `applyAnswerToControl` must be checked: it should skip the control entirely, not write an empty string into it.
* `knowledge.Policy` gains a name for it. It is not `suggest_ask` and not `unknown`.
* The inbox offers it as an explicit control on an answerable question, worded as a decision ("I don't have this — leave it blank") rather than a dismissal.

**Constraints, all of which have already caused a bug in this area once.**

* **Never offer it for a required field.** A required field left blank does not submit, and reporting it as handled would make readiness lie in the direction that costs an application.
* **Never offer it for a declaration.** "Decline to answer" on an attestation is a legal choice with consequences and is not the same act as "I have no Twitter"; it must not be reachable through the same control. Sensitivity is decided by `Classify`, so gate on that, not on the operator's word.
* **Never offer it for `generate_per_job`.** Those are not reusable at all.
* A declined answer must be visible and revocable in the vault management view like any other, showing as a decision rather than as a blank row.

**Verification.** Beyond unit coverage: on the real queue the five Twitter fields should move from *needs you* to handled, the inbox should lose that group, and a live assisted run should show Career Agent leaving the control untouched rather than writing an empty value into it.
**Done (2026-08-19).** See `documentation/backlog_history/improvements_done_details.md` item #545 for the full account.

### 543. Gated semantic suggestions for equivalent questions

Expand Down
Loading
Loading