The last step of a composer is a model writing prose and actions: “Review the 12 late orders”, with a query attached so the person can open them. The model is good at that sentence, and just as good at inventing a query that looks right but never ran, or a report id that doesn’t exist.

So the writer may only cite what the evidence step produced.

const known = new Set(findings.flatMap((f) => (f.query ? [f.query] : [])));

const actions = parsed.actions
  .map((a) => ({
    ...a,
    query: typeof a.query === "string" && known.has(a.query) ? a.query : undefined,   // cited, or dropped
  }))
  .slice(0, MAX_ACTIONS);                                                             // caps after parsing

const reports = idsIn(reply).filter((id) => listedReports.has(id)).slice(0, MAX_REPORTS);
  plan ──▶ evidence: questions → queries that ran, with rows ──▶ known = { q1, q2, q3 }
                                                                    │
  write: "Review the 12 late orders" + query ──── q2? keep ─────────┤
                                       + query ──── q9? strip, keep the action without it

Rules

  • Exact match. A reference survives only if it’s character-for-character something the evidence produced.
  • Strip, don’t fail. An invented reference is removed and the rest of the output survives. The sentence is still useful without its link.
  • The prompt asks, the code enforces. The prompt says “copy queries exactly from the findings, or leave them out”. The filter makes that true.
  • Caps after parsing, so a model that writes too many actions can’t push the cited ones out.

What it gives up

An exact match rejects a harmless reformat of a valid query. That’s the cheap side to fail on.

Why it works

Every link the person can click goes to something that ran. The writer keeps its job, phrasing, and loses the one it was never good at, which is being the source of truth.

How this fits the larger system: Generation you can check.