Naming the target company without reopening the fabrication hole¶
Status: DESIGN — NOT IN FORCE. Nothing here is implemented. This records three independent designs and the reasoning behind the recommendation, so the eventual implementation starts from a decided position rather than re-deriving it. Ticket: WING-369.
The problem¶
DraftHonestyGuard rejects a draft that names an organization the profile does not attest. The
regex that catches employment claims is roughly (at|for|from|@)\s+(CapitalizedPhrase), checked
against an allowlist built from the user's profile.
The company being applied to is by definition absent from that profile. So an ordinary cover-letter sentence —
I'm excited about the Platform Engineer role at Kohler Group.
— trips the guard, the entire draft is rejected, and the user silently receives an untailored template even with a healthy, authenticated LLM. Cover letters name the target company constantly, so this fires on most drafts. It is a large part of why WING-333 was reported as "the app doesn't actually generate resumes and coverletters".
WING-333 solved this for text we generate: the deterministic composer declares the exact whole
lines it emits as address rather than autobiography (its salutation, its Target role: label), and
the guard neutralizes those lines by exact whole-line comparison before scanning. That works only
because we know our own sentences verbatim. The LLM writes its own prose, so we do not.
What must be distinguished¶
| Must PASS | Must be CAUGHT |
|---|---|
| "the Platform Engineer role at Kohler Group" | "I worked at Kohler Group for three years" |
| "Dear Kohler Group team," | "I hold a degree from Kohler Group University" |
| "I'm applying to Kohler Group" | "As an ex-Kohler Group engineer…" |
Getting it wrong one way ships a fabricated employment claim to the very company being applied to. Getting it wrong the other way permanently denies users the tailored documents the product exists to produce.
Designs already rejected, and why¶
These were tried during WING-333 and broken in review. They are recorded so they are not proposed again.
-
Exempt the target company name wherever it appears. Also exempts "I worked at Kohler Group" and "a degree from Kohler Group University" — employment and education claims are written with exactly the prepositions the scan looks for. Title validation is no backstop: it only judges titles bound to an employer the profile already knows.
-
Mask spans by value (the company name, or the posting's job title) before scanning. Both values are scraped from third-party postings and therefore attacker-influenced, which turns them into a redaction pattern. Observed consequences: text appended to a masked line rode through unscanned; a posting titled "Senior Software Engineer" laundered an unattested promotion at a real employer; a long enough title could blank a large span of the document before scanning.
The principle that falls out of both, and which the rest of this design obeys:
Using the target value to REJECT output is safe. Using it to REDACT or EXEMPT output is not.
Recommendation¶
All three seats independently proposed the same shape: stop treating the cover letter as one free-form blob. The model returns labelled fields; the application owns assembly; the target company is inserted by the renderer and never trusted from model prose.
Interim (small, shippable, closes WING-369)¶
- Instruct the model to produce a target-free body — it never writes the company name at all.
- The deterministic composer supplies the target-referencing sentences (salutation, opening, closing) from reviewed templates, exactly as it already does for its own documents.
- Any occurrence of the target name or title in model-authored text is a rejection, not an exemption. This uses the attacker-influenced value only as a rejection input, which is safe.
- Bound the comparison input so a maliciously long posting value cannot become a DoS pattern.
This is a prompt contract plus a mechanical check, not a classifier, and it is the smallest change that removes the false-rejection problem without widening the honesty boundary.
Target state¶
A constrained document plan instead of prose. The model selects facts, ordering, emphasis and template variants; it does not supply arbitrary text inside a target-company sentence:
abstract record DraftNode;
record ApplicationIntentNode(ApplicationTemplate Template) : DraftNode;
record ProfileClaimNode(ProfileFactId FactId, ProfileClaimTemplate Template) : DraftNode;
record PostingRequirementNode(PostingFactId FactId, RequirementTemplate Template) : DraftNode;
record TransitionNode(TransitionTemplate Template) : DraftNode;
Enforcement details that make this more than a schema:
- Fact IDs are server-issued opaque IDs, never posting-derived strings.
- Every profile fact is verified to belong to the current user and profile snapshot.
- Templates are compatible only with their fact type — an
AssistedWithfact cannot render through aLedtemplate. - Strict JSON: no unknown node kinds, no additional properties, no duplicate keys, bounded node count, no arbitrary template strings.
- The target company and title are inserted only by the renderer, normalized, length-bounded, stripped of control/bidi characters, and encoded.
- A target value that fails display-name validation renders as generic wording ("the advertised role") rather than being interpolated.
- The existing flattened-text guard still runs as a final invariant, with its exemptions coming exclusively from exact renderer output (the WING-333 mechanism).
The larger argument for this shape: an entity regex cannot catch unsupported claims of the form "I led a migration", "I increased revenue 20%", or "I have ten years of experience". Those are the same class of promise and the current guard misses all of them.
Explicitly rejected¶
- Teaching the regex to classify intent. Natural-language classification is too ambiguous to carry a hard honesty guarantee.
- Multi-turn "rewrite until the guard passes." It optimizes the model into the guard's blind spots — the failures you keep are exactly the ones the guard cannot see.
- Any design where the posting-controlled value redacts or exempts text. See above.
Known residual risks in the recommendation¶
Recorded from the adversarial pass so they are designed against rather than discovered later.
| # | Attack | Notes |
|---|---|---|
| A | Relative-clause laundering in one sentence | "…the role at Kohler Group, where I led the platform team" — one mention, two relations |
| B | Anaphora with no second capitalized org | "I applied to Kohler Group. I spent three years there." |
| C | Inverted syntax the lexicon misses | "Kohler Group is where I built the pipeline" |
| D | Unattested claims about other employers | Pre-existing gap, not unique to this ticket |
| E | Target-string games via the posting | A company "name" chosen to collide with profile text |
| F | Prompt-injected field packing | The JD is attacker-controlled and reaches the model |
A sentence-level classifier alone does not survive A–C; that is the core reason the structured plan is preferred over a smarter scanner.
Response policy¶
Worth deciding alongside the mechanism: today any finding rejects the whole draft and silently falls back. Dropping the offending sentence is better for the user, but only where the guard can localize the finding — the composer path already distinguishes locatable from unlocatable findings (a scan timeout has no quoted phrase and cannot be attributed to a line). Whatever is chosen, the user must be told which happened; WING-333 made that reason visible and it should stay visible.
Provenance¶
Design consultation 2026-08-06 with three independent seats, no Claude seat (usage governor RED): Cedric (gpt-5.6-sol, high) — structured plan, renderer-owned target values, the reject-vs-redact principle, and the interim patch. Jenny (gemini-3.1-pro, high) — field contract plus a default-deny sentence classifier. Gronktayvius (grok, adversarial) — relation-typed mention matrix, and the A–F defeats above, including against his own proposals.
Synthesis by Clahadore. Not implemented; no code in this repo behaves this way yet.