Skip to main content
Briefings use an explicit save model. Editing rows, adding items, or renaming things only mutates the draft state on the server — they do not call the AI. The AI work happens in one batch when you press Save.

Why explicit save?

The alternative — debounced auto-regen — was rejected because:
  • Each save runs N row-analysis calls + 1 overall summary call. Auto-regen on every keystroke would burn tokens on edits the user is still composing.
  • Briefings are monthly. There’s no “live dashboard” UX expectation; a deliberate commit step matches the cadence.
  • It makes the cost story predictable: one bounded burst per save, no surprises.

What happens on save

When you press Save, the client calls commitAndRegenerateBriefing(id). The server:
  1. Creates a new BriefingContent row with status = IN_PROGRESS, triggerType = MANUAL.
  2. Snapshots the briefing’s row + item composition into BriefingContent.briefingSnapshot (so the run is reproducible even if rows change later).
  3. Title pre-step: if the briefing or any row has unlocked titles AND the title-input hash has changed, makes one batched Haiku call for all titles at once.
  4. Per-row commentary in parallel (bounded to 3 in flight):
    • Recompute the row’s aiInputsHash.
    • If it matches the row’s existing hash, reuse the prior commentary (no LLM call).
    • Otherwise, fetch the row’s items’ latest Pulse output and call generateBriefingRowAnalysis.
  5. Overall review: after all rows complete, run generateBriefingOverallSummary over the row outputs + item short summaries.
  6. Set BriefingContent.status = SUCCESS, write aggregateTokenUsage, end the run.
The mutation returns immediately with the new BriefingContent.id. The composer polls getBriefing every 2 seconds until status leaves IN_PROGRESS.
No PDF is rendered and no destination is contacted on a save. PDF and delivery only run on a snapshot.

Why row edits are blocked while a commit is in flight

While a BriefingContent is IN_PROGRESS for a briefing, every mutateBriefingRow call is rejected with BRIEFING_COMMIT_IN_PROGRESS. The composer disables the row-edit affordances (drag handles, item picker, title fields) for the same reason. The lock exists because:
  • The snapshot was frozen at commit start. Downstream row analyses run against that frozen view.
  • Allowing edits during the run would silently invalidate the row hashes and produce stale-on-arrival results.
  • A typical commit takes <30 seconds. A short read-only window is a clearer UX than letting writes race.
The lock is per-briefing only — other briefings remain freely editable, and any unrelated mutation (sources, connections, settings) is unaffected.

Stale runs

If a commit somehow gets stuck (server crash, network partition), a 30-minute sweeper transitions abandoned IN_PROGRESS runs to FAILED so the briefing isn’t permanently locked.

The needsCommit flag

Briefing.needsCommit is the single source of truth for “this briefing has uncommitted changes”. The server computes it from row-level aiInputsHash mismatches — don’t derive it on the client from updatedAt timestamps. The composer’s “Unsaved changes” badge reads this field directly. Send Snapshot is disabled when:
  • needsCommit === true — there’s nothing fresh to send,
  • isCommitting === true — a save is in flight, or
  • The monthly throttle says next-eligible-date is in the future.

What changes trigger AI work?

EditTitle regen?Row commentary regen?Overall review regen?
Add a row✅ (new row needs a title)✅ (new row only)✅ (always)
Add an item to a row✅ (row title may shift)✅ (just that row)
Remove an item✅ (just that row)
Reorder items within a row
Reorder rows
Type a row title
Edit briefing description
Reset to AI title
The overall review always runs because it’s a single small call and depends on every row’s output.