What changed, and why
"Compose a fresh objective with AI" already guarded against repeats — but only by normalized title. Fold the case and punctuation, compare as a set. That catches a verbatim or lightly-reskinned curated title, and nothing more. The same counterfactual under a new title sailed through: "Save the Library of Alexandria" and "Keep Alexandria's great library from the flames" read as two different goals.
This PR adds the check a title match can't do — a cheap model judge that decides whether a fresh candidate is essentially an existing objective reworded — plus a smarter reroll that tells the model what it just drew so the second attempt diverges.
Blast radius is small and server-only: one file carries the logic (server/utils/objective-generator.ts), one adds a routing lane, one adds an env knob. The route contract, the client, and the composed-objective shape are all unchanged. The whole thing is gated so it can be turned off or run in observe-only mode.
A new routing lane
Every model call in everwhen names a task in one routing table. The judge gets its own lane, objective-dedup, alongside the sibling bounds check: Haiku at temperature 0, a cheap classifier off the critical path. Haiku rejects the effort and thinking params, so — like its sibling — the lane carries neither. It is added to the AiTask union too, which forces the table to define it (the map is keyed by the union), so a missing route is a compile error, not a runtime surprise.
The new lane — same shape as the objective-check bounds classifier.
server/utils/ai-routing.ts · 270 lines
⋯ 212 lines hidden (lines 1–212)
⋯ 45 lines hidden (lines 226–270)
Orchestration: the free check first, then two judges in parallel
generateObjective is the loop that composes, validates, and — at most once — rerolls before falling back to curated. Read it top to bottom:
- The free normalized-title set still runs first (line 114). An exact or reskinned collision short-circuits here and spends nothing — no bounds call, no judge call. - Only a candidate that clears that free check reaches the paid checks, and they run together (lines 119-122): objectiveWithinBounds (safety) and objectiveIsDuplicate (freshness), concurrently. Acceptance needs both to pass: inBounds && !dup.duplicate (line 123). - Whatever the reason for a rejection — out of bounds, or a duplicate — it is captured and pushed onto rejected (lines 129-133), which feeds the next attempt.
Compose → free-title check → parallel bounds+dedup → reroll, capped at 2 attempts.
server/utils/objective-generator.ts · 541 lines
⋯ 99 lines hidden (lines 1–99)
⋯ 396 lines hidden (lines 146–541)
The judge, and why it fails open
objectiveIsDuplicate is the heart of the change. Three things make it safe:
1. Mode gate (line 332). off returns before importing the gateway or spending a token. Only shadow/enforce make the call. 2. Fail open (line 372). content && mapDedupWire(JSON.parse(content)) || fresh — a null/empty answer short-circuits to fresh, a malformed body throws into the catch which also returns fresh, and an unusable shape maps to null which || fresh absorbs. Every failure path resolves to "not a duplicate." 3. Shadow observes without biting (line 386). When the verdict is a duplicate it logs a [objdedup] line, then — in shadow — returns fresh anyway, so the composition still ships. Only enforce returns the real verdict.
Signature + mode gate, then the fail-open tail (the JSON schema between is folded).
server/utils/objective-generator.ts · 541 lines
⋯ 320 lines hidden (lines 1–320)
⋯ 37 lines hidden (lines 333–369)
⋯ 152 lines hidden (lines 390–541)
The mode reader mirrors the moderation seam exactly — off | shadow | enforce, defaulting to enforce — and the wire guard is a pure, exported function so the fail-open mapping is unit-testable on its own.
EVERWHEN_OBJECTIVE_DEDUP — the same three-mode shape as EVERWHEN_MODERATION.
server/utils/objective-generator.ts · 541 lines
⋯ 390 lines hidden (lines 1–390)
⋯ 145 lines hidden (lines 397–541)
mapDedupWire: null on any unusable shape → the caller treats it as not-a-duplicate.
server/utils/objective-generator.ts · 541 lines
⋯ 403 lines hidden (lines 1–403)
⋯ 120 lines hidden (lines 422–541)
Reroll feedback, and the byte-identical default prompt
The second half of the change is small but easy to get wrong. A rejected attempt is now fed back into the compose prompt so the reroll diverges. That means the prompt gained a new conditional clause — and the reviewer's fair question is whether the common path (a first compose, nothing seen, nothing rejected) still produces the exact same bytes it did before.
It does. The new rejectedClause is spliced immediately after avoidClause on the same line (line 481: ${avoidClause(seenTitles)}${rejectedClause(rejected)}). Both return "" when empty, so an un-rerolled, un-seeded compose renders the identical string in the identical slot the single avoidClause occupied before.
The interpolation slot — both clauses empty on the first attempt.
server/utils/objective-generator.ts · 541 lines
⋯ 470 lines hidden (lines 1–470)
⋯ 54 lines hidden (lines 488–541)
rejectedClause: empty ⇒ no bytes; otherwise names what was rejected, and why.
server/utils/objective-generator.ts · 541 lines
⋯ 518 lines hidden (lines 1–518)
⋯ 15 lines hidden (lines 527–541)
The tests that pin the risky claims
The suite drives the real generateObjective and mocks only the model boundary, dispatching canned responses by JSON-schema name — so a test says "these composes, these bounds verdicts, these de-dup verdicts" without depending on call order. The de-dup judge defaults to off in the harness (mirroring how moderation is off by default), and the dedicated tests flip it on.
Two of the new tests are the ones a skeptic wants, both under enforce:
- Fail open end-to-end — the judge returns blank content, and the composition still ships (composed === true). This exercises the fail-open path through the real function, not just the mapDedupWire unit. - Short-circuit holds under enforce — a verbatim curated title is caught by the free set and spends neither a bounds nor a de-dup call; only the clean reroll consults each. This is the regression guard for the "free check first" claim.
Fail-open-through-the-real-function, and the enforce-mode short-circuit.