04

Evidence-Gated Tailoring & the LaTeX Sandbox

The two hardest promises in the product: the AI can't invent your experience, and a resume template can't own your server.

TL;DR

Every AI-proposed resume bullet must carry evidenceRefs β€” citations into your actual profile. A bullet whose citations don't resolve is discarded before any human sees it: hallucination becomes a type error. The approved LaTeX then compiles in a sandbox: shell-escape blocked, secrets absent, network off, 15-second kill switch β€” because a resume template is a program, and programs can be hostile.

Resume tools have a credibility problem: ask an AI to "tailor" your resume and it will cheerfully award you three years of Kubernetes you've never touched. Wera's founding guardrail (SPEC Β§12.4) is blunt: "Do not invent experience" β€” and crucially, it's "structurally enforced, not prompt-only."

The mechanism is academic citation. A claim in a paper means nothing without a footnote, and a footnote pointing at a source that doesn't exist gets the paper retracted. In Wera, each bullet is a claim, its evidenceRefs are the footnotes, and your profile is the only citable literature. The review screen then shows each bullet next to its source β€” so approving a resume is checking receipts, not vibes.

The build journey

1
Stage 4 Β· The loop

Make citations mandatory in the schema

The zod schema requires every bullet to have at least one evidence reference β€” an uncited bullet can't even parse.

What

resumeTailoringBulletSchema: evidenceRefs: z.array(z.string().min(1)).min(1), plus a required rationale per bullet.

Why

The first gate is shape: a response with an uncited bullet fails validation at the AI boundary (Module 3) and triggers the corrective retry β€” the model is told which bullet lacked receipts.

How

The prompt supplies an explicit Allowed evidence: list of {id, label, detail} items flattened from the profile β€” the model can only cite IDs it was handed.

2
Stage 4 Β· The loop

Resolve every citation β€” or reject the batch

Shape isn't truth: the model could cite an ID that doesn't exist. resolveEvidenceRefs looks up every ref and throws on the first fake.

This is the heart of the anti-hallucination gate, from packages/core/src/index.ts:

packages/core/src/index.ts

const evidence = bullet.evidenceRefs.map((ref) => {
  const item = evidenceById.get(ref);
  if (!item) {
    throw new Error(`Unresolved evidence ref: ${ref}`);
  }
  return item;
});
PLAIN ENGLISH

For each citation this bullet makes…

…look it up in the map of evidence that actually exists in your profile.

If the citation points at nothing β€”

β€” stop everything, loudly, naming the fake reference. The bullet (and the whole proposal) never reaches the review screen.

Otherwise, keep the real evidence item…

…so the UI can display the bullet and its receipt side by side.

πŸ’‘
Two gates, two failure meanings

Gate one (zod, Module 3) rejects bullets with no citations β€” a shape problem, retried with feedback. Gate two (resolveEvidenceRefs) rejects citations to nonexistent evidence β€” a truth problem, surfaced as errorKind: "evidence_validation" with no retry, because a model that fabricates references needs a human, not another attempt.

3
Stage 2 Β· Proven first (M1!)

Escape everything, then compile in a locked room

User strings are LaTeX-escaped in a single pass; the template compiles in a throwaway directory with no secrets, no network, and a 15-second SIGKILL timer.

LaTeX is a full programming language β€” historically it can even run shell commands via \write18. So Wera treats every template like a stranger's code and compiles it the way you'd run a stranger's code:

What

compileLatexSource in packages/resume: blocklist check β†’ per-run temp dir with fake HOME β†’ tectonic --untrusted β†’ typed CompileResult.

Why

A malicious template could read environment secrets, phone home, or spin forever. Each defense kills one class: allowlisted env (no secrets exist in the child process), --untrusted + regex blocklist (no shell escape), SIGKILL timeout (no infinite loops).

How

buildSecretlessCompileEnv hands the subprocess exactly four variables: PATH, a fake HOME, TMPDIR, and the package cache dir. OPENAI_API_KEY isn't hidden from the compiler β€” it simply doesn't exist in its universe.

4
Stage 2 Β· Proven first (M1!)

Bake the LaTeX universe into the deploy image

The deploy image downloads every LaTeX package at build time, then re-compiles with the network forbidden to prove nothing is missing.

What

A custom Trigger.dev build extension in trigger.config.ts: install pinned Tectonic 0.16.9, compile the real template to warm the cache, then compile again with --only-cached.

Why

Without this, the first production compile downloads ~40 MB of packages inside a 15-second timeout β€” or worse, a missing package fails a user's compile at 6 a.m. The --only-cached re-compile turns that into a deploy-time failure instead.

How

Runtime sets LATEX_ONLY_CACHED=1: production compiles never touch the network at all. The comment in the code names the property: hermetic.

The central idea: make the bad thing unrepresentable

You can ask a courtroom witness to please tell the truth β€” or you can require every statement to come with a document the judge can hold. Wera does the second. The AI isn't trusted to be honest about your experience; it's prevented from being dishonest, because a claim without a verifiable document never enters the record. Same philosophy in the print shop downstairs: the compiler isn't trusted to behave β€” it's locked in a room where misbehaving is physically impossible.

Both halves of this module are the same principle: move safety properties from behavior to structure. Anti-hallucination is enforced by a data invariant (every bullet β‡’ resolvable refs) checked by pure code at a boundary β€” not by prompt phrasing, which is a request, not a law. Compile safety is enforced by capability removal: the subprocess can't leak secrets it doesn't have, can't reach a network that isn't there, can't outlive a SIGKILL. Defense in depth: engine flag (--untrusted) + static blocklist (\write18|\input| |openout18) + env allowlist + timeout β€” four independent layers, any one of which failing still leaves three.

Tradeoff and failure mode: evidence IDs are positional (projects.0 = "first item in the projects array"). They're resolved at generation time and stored denormalized, so they're sound for the version that was approved β€” but reorder your profile arrays later and stored refs point at different items. The stable-key migration (real profile tables, Module 2's deep dive) is the known exit.

Apply it

A malicious template tries \input{|env}-style tricks to print the server's OPENAI_API_KEY into the PDF. What actually happens?

The model returns 5 bullets; 4 cite real profile items, 1 cites projects.7 β€” which doesn't exist. What reaches the review screen?

A teammate adds \usepackage{fontawesome5} to the resume template but forgets everything else. Where does this surface first?

πŸŽ“ Level Up

Sandboxing & structural guarantees

Two concepts to keep forever. Capability removal: the strongest sandbox isn't one that detects attacks, it's one where the attack's target doesn't exist (no secrets in env, no network in the runtime). Grounded generation: when AI output must be true, require it to cite sources you control and verify the citations mechanically β€” the pattern behind serious RAG systems, and here, behind a resume tool you can actually trust.

Best practices
  • Allowlist subprocess environments β€” never inherit process.env into anything that runs user input.
  • Layer independent defenses (flag + blocklist + env + timeout); assume any one layer fails.
  • Verify AI citations against a closed set of IDs you issued β€” and reject, don't repair, fabrications.
  • Prove hermeticity at build time (--only-cached) so missing dependencies fail deploys, not users.
Common pitfalls
  • Prompt-only guardrails ("do not invent experience") with no structural check β€” a request, not a law.
  • Escaping user strings after assembling the document β€” order matters; escape at the data boundary.
  • Positional IDs as long-lived references β€” fine denormalized-at-approval, dangerous as live pointers.
  • Timeouts without SIGKILL β€” a polite signal to a hung process is a suggestion.