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 callscommitAndRegenerateBriefing(id). The server:
- Creates a new
BriefingContentrow withstatus = IN_PROGRESS,triggerType = MANUAL. - Snapshots the briefing’s row + item composition into
BriefingContent.briefingSnapshot(so the run is reproducible even if rows change later). - 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.
- 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.
- Recompute the row’s
- Overall review: after all rows complete, run
generateBriefingOverallSummaryover the row outputs + item short summaries. - Set
BriefingContent.status = SUCCESS, writeaggregateTokenUsage, end the run.
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 aBriefingContent 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.
Stale runs
If a commit somehow gets stuck (server crash, network partition), a 30-minute sweeper transitions abandonedIN_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?
| Edit | Title 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 | ✅ | ❌ | ✅ |

