Skip to content

Make research an action you can see - #1

Merged
mjacobs merged 26 commits into
mainfrom
research-affordance
Aug 30, 2026
Merged

Make research an action you can see#1
mjacobs merged 26 commits into
mainfrom
research-affordance

Conversation

@mjacobs

@mjacobs mjacobs commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Researching a capture used to mean arming an unlabeled 🔬 mode that silently
reset after every capture, on the only control in the app that spends money.
Research is now a button you press — under Send in the composer, and "Research
this" on any note's own page — and the report always arrives as its own feed
note pointing back at the note that asked.

An earlier revision of this branch instead merged capture-time reports into
the asking note. That meant rewriting a user-editable note exactly once on top
of at-least-once delivery, and successive review rounds showed there is no
safe way to do that without transactions and provider idempotency the stack
doesn't have. The merge was removed; one shape remains.

Design

  • A report is always a separate research note; the asking note is never
    rewritten and stays editable while a run is in flight.
  • The note is claimed before any money is spent, so a double tap or a second
    device cannot buy two reports.
  • Notes carry research_status, so the feed says "report pending" on the card
    the report will follow.
  • Only an explicit user action starts a run — never the model's reading of
    captured content.
  • The crash-window tradeoffs that survived review are recorded in
    docs/contracts.md under "Known limits", each with the fix it would take.

Current state

  • Production has no sweeper for orphaned running operations (a pre-existing
    gap, tracked as kata wz2g); until it lands, a lost first poll leaves a
    paid run uncollected.
  • A kickoff that fails ambiguously reads as a plain failure in the UI even
    when a run may be live (kata 9vbt).

mjacobs added 26 commits August 30, 2026 12:42
Researching as you capture left two notes saying the same thing: the capture,
and minutes later a report whose summary was the capture's summary with a
prefix. When the note exists only because you typed a question and asked for
research on it, the report belongs in that note.

Operations carry merge_into_source, set only by the enrichment path, so a run
started later against a note that already stands on its own — a link saved
three weeks ago, or chat's start_research — still writes a second note and
leaves the first alone.

The user's own words move to original_body rather than being dropped, and a
failed run never touches body at all, so the note goes back to being the
capture it was. That matters more here than it did before: with no second
note behind it, this is the only thing between a dead run and a lost thought.

Notes also carry research_status now, mirroring the operation onto the note it
is about, so a feed card can say a report is coming without joining against
the operations queue.

The note keeps kind=capture while the run is in flight and only becomes
kind=research on completion, which keeps "kind=research" meaning "holds a
report" and leaves nothing to unwind when a run fails.
The backend could already research an existing note — start_research_operation
takes a note id — but only chat's start_research tool could reach it, so the
UI had no way to offer it. This is the same capability over HTTP.

It never merges: a note that already stands on its own keeps its identity and
gets a second note pointing back at it. 409 when a run is already in flight,
so a double tap cannot buy two reports.

Like the capture flag, it starts a run because the owner asked. Nothing a page
said can reach it.
Three changes that only make sense together, because they are one control
moving from a hidden mode to a visible action with visible consequences.

The composer's 🔬 button was an unlabelled emoji that armed an invisible mode,
one tap from Send, on the only control in the app that spends money — and its
armed style was the same accent as Send, so arming it produced two identical
orange circles side by side. Research is now which button you pressed, not a
mode the composer holds: Send says Send, and research sits under it at half
the height, unfilled and without an icon. Nothing to arm, forget, or reset.
The voice path drops the flag rather than keeping a mode only it needed —
arming mid-recording was the awkward case the old ref existed to handle, and a
voice note can be researched from its own page now.

Notes carry their own research state, so a card says "report pending" where
the report will actually appear. That replaces the feed-level banner, which
said the same thing a few pixels higher without saying which note it meant;
the operations poll stays as the refetch trigger, which is all it really was.

Note detail gets "Research this" for the other moment — when you are looking
at something rather than still typing it — and says what it will cost in time
and money before you press it rather than after.

The composer also reports a kickoff that failed instead of dropping the
response field on the floor, which used to make "research never started" look
identical to "research is running".
contracts.md gains research_status, original_body, merge_into_source, and the
new route, plus the rule that decides between them: where a report lands
depends on why the run started.

Writing that rule down is what surfaced the bug fixed here. Link and image
captures carry the research flag too — Tabby stashes up to twenty pages at a
time — and the enrichment path was merging those as well, so researching a
saved page would have consumed the page you meant to keep. The merge is for
captures that exist only to pose a question: typed and spoken ones.
Six issues from the branch review, the first of which could spend money twice.

POST /notes/{id}/research read the note, saw no run in flight, and only then
created the interaction — a window two taps could both pass through, buying
two reports. The note is now claimed with a compare-and-set before anything
is spent, so the loser stops before it costs anything; the route's 409 comes
from losing that claim rather than from a check that preceded the act. A
kickoff that then dies releases the claim, or a note that never started a run
would read as busy forever and could never be researched again.

Merging appended the report's trace over the note's own, erasing how the note
became a note and every owner edit recorded since. The trace is the honesty
surface; the report joins that history rather than replacing it, appended
server-side so a concurrent edit's event survives.

A completed run wrote original_body and nothing rendered it, so the question
the report answers was readable only through the API. Note detail shows it.

Non-merge completion updated the source note unguarded, so a note deleted
mid-run left the report written and the operation re-polling forever.

The feed refetched only when the running count reached zero: a run started
from chat took it 0 -> 1 and its card showed nothing, and one of three runs
finishing took it 3 -> 2 with a report already in the feed. Any change now
triggers a refetch. Note detail polls its own note while a run is in flight
instead of saying "report pending" until you reload.
Second review round, four fixes.

Reserving a result and settling the operation are two writes, and between them
the operation still reads as running — so a second delivery took the status
check as its own to advance and wrote too. That is two report notes on the
split path and two merges on the other. The reservation now conditions on the
result being unset, which makes it the exclusive claim the status check was
standing in for.

Both failure paths settled the operation before marking the note failed. A
worker dying in between left a settled run against a note that still said it
was running, and a redelivery dedupes on the settled operation and never
repairs it — so that note could never be researched again. The note is freed
first now; the worst that order can do is repeat a harmless write.

The feed watched how many runs were going rather than which. One finishing as
another started left the count untouched while two cards changed.

search_notes now reads original_body, so a merged report is still findable by
the words its question was captured with.

One finding is documented rather than fixed: an owner edit landing between the
merge's read and its update is overwritten. Closing it needs a transactional
read-modify-write the store does not do today, and the exposure is one user
editing a note in the instant its report lands.
Third review round.

Once the interaction exists it is billing, so releasing the note's claim
because the operation write failed let a retry buy a second one. The release
now happens only where nothing was spent — if the handle is lost after the
interaction is created, the claim stands and the interaction id goes in the
log, because it is then the only handle anyone has. A note reading as busy is
a worse experience than a duplicate report is a cost.

Making the reservation exclusive last round meant a merge that threw after
reserving found the id taken on redelivery and deduped against its own
reservation, leaving the run permanently running. It resumes when the
operation already owns the reservation.

The terminal note updates swallowed every Firestore error, so a transient one
settled the run against a note stuck reading as running that nothing would
repair. Only a deleted note is tolerated now; anything else propagates and the
poll comes back. The fake client raises NotFound rather than KeyError so that
distinction is testable.

Note detail stops polling while an edit is open. The draft is seeded from the
note as it was, and PATCH sends whole fields, so saving after a poll swapped
the note underneath would have written the old body back over the report.
Fourth review round.

Two deliveries of one poll can both pass the operation's status check, and the
slow one can land after the user has started a second run. Clearing the note
then made it read as free, so the next tap bought an interaction that was
already running — the exact duplicate spend the claim exists to prevent.

The claim now records which operation holds it, and every terminal write goes
through settle_note_research, which only lands for the owning run. A stale
write is dropped rather than raising: it has nothing to say. A transient
failure still raises, so the poll comes back instead of stranding the note.
The merge goes through the same guard, so a superseded run cannot rewrite a
note a newer one has claimed.

The enqueue-failure path settled the operation before freeing the note, unlike
every other terminal path; it now matches.

Note detail no longer offers edit while a run is in flight. Pausing the poll
kept the client's copy stable but did not stop the server from merging, and
PATCH sends whole fields — so an edit begun before the report landed would
save over a report that cost money. The edit waits; nothing is lost.
Fifth review round, and the last two things worth changing.

settle_note_research treated any concurrent write to the note as supersession,
so an unrelated edit landing between its read and its write made a terminal
settle report "not mine". The caller then settled its operation against a note
it had failed to write, leaving it running with nothing to reconcile it. It
re-reads and retries now, and gives up only when the note is gone or another
run genuinely owns it; sustained contention raises rather than lying.

Enqueue failure freed the note, which let a retry buy a second interaction
while the first ran unpolled — the same trade already settled for a lost
operation write, decided the other way. The operation still fails, because
nothing will poll it, but the claim stands.

Two findings are documented in place rather than fixed, both needing an
idempotency key on the interactions API or a durable kickoff record
reconciled afterwards. A timeout during interaction creation may have been
accepted and billed, and we release the claim as though it was not; and two
deliveries resuming a reserved merge can both append the report's trace.
Neither is worth a note stuck busy forever, or a run that can never finish,
which are the alternatives available without provider support.
Three findings I had documented as limits rather than fixed. The reviewer was
right that documenting duplicate billing does not prevent it.

The operation is now written before the interaction is created, so the durable
record is always at least as old as the spend. That makes the failure
classifiable: a refused connection or a 4xx is known to precede acceptance, so
nothing was bought and the note is handed back; a timeout, a 5xx, or a
response with no id may have been accepted and billed, so the claim stands. An
operation with no handle has nothing to poll, so it counts down against a
short cap instead of the two-hour one — a couple of minutes, long enough that
an immediate retry cannot buy a second report, short enough that a deliberate
one later is not blocked. That is the escape hatch the old "keep the claim"
choice was missing.

Enqueue failure previously failed the operation while keeping the note
claimed, which was the worst of both: a paid run nothing would poll, a note
busy forever, and redeliveries deduping against the failed operation so
nothing could ever reconcile it. The enqueue is retried, and if every attempt
fails the operation stays running — consistent with the note, visible in the
queue, and finishable by anything that later polls it.

Two deliveries resuming a reserved merge could both write the report's
reasoning, because the trace events carry their own timestamps and Firestore's
value dedupe could not see they were the same. The append filters against what
the note already carries, so resuming adds nothing.
Three fixes from the last review round that carry no tradeoff, so they are
worth taking regardless of where the argument about ambiguous kickoffs lands.

PATCH refused an edit to a note under research only in the SPA, which a stale
page or another client would not know about. The refusal moves to the server,
where every caller meets it.

A failed write of the interaction id threw away a handle we were holding,
which made the poll treat a real paid run as one that never started. It is
retried.

Research trace events are stamped with the operation's creation time rather
than the wall clock, so two deliveries resuming one merge produce identical
events and the array union treats the second as nothing to add. The pre-read
filter stays as the coarser guard.
Seven review rounds ended at findings that are horns of dilemmas rather
than bugs: release an ambiguous kickoff and a retry can double-spend,
hold it and the note is stuck; fail an unpolled operation and nothing
reconciles it, keep it running and nothing polls it. Without an
idempotency key on the interactions API or a production sweep of running
operations there is no answer that satisfies both horns, so the choices
made are now recorded in contracts.md as known limits instead of being
retraded one review round at a time.

The production sweep is filed as kata wz2g and dissolves two of the
three when it lands.
A research run used to land two different ways. Started from a note you were
already looking at, the report arrived as a second note. Started from a capture
that asked as it was written, the report replaced that capture in place, with
what you typed tucked into original_body.

The in-place rewrite is being dropped. Rewriting a note the owner can also edit
means a paid report and a human edit racing for the same fields, and the
exactly-once machinery that needs — on top of at-least-once poll delivery — was
never going to be worth what it bought. Two notes, one asking and one
answering, needs none of it: the terminal write touches only the note's
research status, so an edit and a report can never clobber each other, and
editing a note while its run is in flight is now allowed rather than refused
with a 409.

Capture-time research therefore behaves like every other run: the capture note
lands as soon as it is enriched, and the report shows up beside it later.

Known limits is unchanged — it was written about the kickoff path, which this
does not touch.
Completion now takes one path: reserve a note id on the operation, write the
report as its own `research` note, and settle the asking note's status. The
merge branch, the `merge_into_source` flag it hung off, and the capture-kind
gate that decided whether a capture was "a question" are gone.

The merge had to mutate a note the owner can also edit, exactly once, on top
of at-least-once poll delivery. That bought edit-vs-report races, a trace it
had to dedupe by hand, and a reservation that had to be resumable without
deduping against itself. The report being a separate document makes all of it
unnecessary: the only thing completion writes on the asking note is its
research status.

The trace stamping in _trace_from_steps goes back to the wall clock — the
fixed timestamp existed so two merge deliveries produced byte-identical events
for array-union dedupe, and nothing appends to an existing trace now.
It held what a note said before a report overwrote it — the safety net for a
rewrite that no longer happens. Nothing writes it, so the Note field, the
search fallback that read it, and the note-detail "What you asked" section all
go with it.

Search loses nothing: the question is still the body of the note that asked,
and that note is still there.
The 409 on PATCH, and the disabled edit button and paused poll behind it,
existed to protect an in-place merge from a half-finished edit. Nothing merges
now: a completing run writes its report to a different document and touches
only `research_status` and `research_operation_id` on the note that asked. An
edit and a report can no longer reach the same fields, so waiting out a run
that takes hours would cost the user something and protect nothing.

The note-detail poll stays — it is what makes the "report pending" badge clear
itself — and now runs during an edit too, since a refetch can only change the
status the badge reads.
"what you wrote is still here" answered a fear the merge created — that a run
could eat your note. Nothing rewrites a note now, so the reassurance points at
a risk that no longer exists.
The note-detail page now refetches while an edit is open, so the note under
the form can change mid-edit. Comparing the draft against the *current* note
would read someone else's change as a field this user edited, and save the
stale value over it.

The draft carries the note as it looked when the edit opened, and only the
difference from that is sent — a field the user never touched is never part
of the PATCH.
Claiming a note and writing the operation that owns it are two writes, and so
are settling a finished operation and releasing its note. A crash between
either pair leaves a note that says a report is coming with nothing left to
deliver it.

Making the pairs atomic would need a cross-document transaction the store does
not do. The decided answer is reconciliation instead: the wz2g sweep already
has to walk running operations, and finding notes whose owning operation is
missing or terminal is one more query on the same pass. Recorded as an
accepted limit so the next reader does not re-litigate it.
By the time the note is re-read to pick up its "report pending" badge, the
note is written, the capture is marked enriched, and a paid research run is
under way. That read sat inside the enrichment try, so a moment of Firestore
flakiness marked the whole capture failed — and the composer hands the text
back on failure, so the natural retry writes a second note and buys a second
run.

The re-read is best-effort now: on failure it logs and returns the note as it
was written. The kickoff's own outcome already rides back in `research`, so
the caller loses nothing but a badge it will see on the next fetch.
The report note's id is reserved on the operation before the note exists, so
a delivery that dies between writing the note and settling the operation is
replayed onto the same id. That write was a `put` — an unconditional set — so
the replay rewrote the report, throwing away anything the user had done to it
in the meantime. A report is an ordinary note once it lands: it can be edited,
retagged, or trimmed.

`store.create` writes only when the id is free and reports False when it is
taken, so the replay finds the report already published and hands back what is
stored. The run still settles, so a redelivery cannot wedge it either.
"One more query" left the shape open, and the obvious reading is wrong: a
sweep that lists running operations and releases every claimed note it does
not see would free notes whose operation merely fell outside that page.

The bullet now names the direction — start from notes that read as running,
look up the operation each one names, release only the ones whose own
operation is gone or finished. Same accepted limit; the note is for whoever
implements the sweep.
The task hydration for the capture response sat after the research kickoff and
inside the enrichment try, so a flaky task read had the same cost as the note
re-read did: an already-enriched capture marked failed, and a retry that buys
a second run.

Moving the read above the kickoff is better than guarding it. Before any money
is spent, failing the capture is the honest answer — the client retries and
nothing is lost. The rule the function now follows is that the kickoff is the
last thing that can fail loudly.
A report deleted between the note write and the operation settling is
recreated by a redelivery, because create-not-set cannot tell "never
written" from "deleted". The window is seconds wide and the cost is a
resurrected report to delete again, so Matt accepted the risk rather
than adding a published marker to the operation contract. Recorded in
Known limits with the shape of the real fix, should it ever be worth
taking.
The 4xx refusal path was the one terminal path that settled the
operation before releasing the note — the order every other path
deliberately avoids, because dying between the two writes leaves a
terminal operation against a note stuck reading as busy, which nothing
polls. It now frees the note first, and if even that cleanup fails the
operation is left running and handleless, where the existing countdown
frees the note without help.

Both kickoff-failure settles are also wrapped so a store error there
cannot raise out of a function whose contract is that a kickoff never
fails its caller.
It lasts until the operation settles, which failures can stretch past
the seconds the bullet promised.
@mjacobs mjacobs changed the title Make research an action you can see, and one note instead of two Make research an action you can see Aug 30, 2026
@mjacobs
mjacobs merged commit 6a66f80 into main Aug 30, 2026
1 check passed
@mjacobs
mjacobs deleted the research-affordance branch August 31, 2026 07:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant