Make research an action you can see - #1
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
researchnote; the asking note is neverrewritten and stays editable while a run is in flight.
device cannot buy two reports.
research_status, so the feed says "report pending" on the cardthe report will follow.
captured content.
docs/contracts.mdunder "Known limits", each with the fix it would take.Current state
gap, tracked as kata
wz2g); until it lands, a lost first poll leaves apaid run uncollected.
when a run may be live (kata
9vbt).