Eleven methods repeated the same body: run mapErr over the store's error,
allocate a slice of the wire type, convert row by row. mapRows holds it, and
each method is now the read plus the conversion it uses.
ListProposedRoutines had a conditional copy of ReminderID, which was a nil
pointer assigned over a nil pointer whenever it did not fire. It is
unconditional now and the result is the same.
Same shape as TickTrace and RecentEvents: a bounded daemon ring, so the store
adapter refuses rather than pretending a table exists. No voice wiring means an
empty list and not an error, because a box with no voice path has had no turns
to arbitrate.
blocked_on stores a canonical entity id, so the form that fills it needs a way
to turn "Kate" into one. ipc.ResolveEntity is that seam: the store adapter
refuses it, because identity is not the store's to answer, and the daemon
overrides it with the Nexus client the voice path already holds.
Three outcomes are kept apart, because a caller deciding whether to store an
id has to tell them apart. No nexus block is ErrNotImplemented. A miss is
ErrNoEntity. Several matches come back Ambiguous with the names, and the
caller asks — picking one is how a task ends up blocked on the wrong person
with nobody able to see it happened.
An outage stays the transport error. "There is no such person" and "Nexus is
down" must not read the same.
SetTaskStatus was the only mutation on a task row, so a typo in a dictated
task was permanent and a deadline could not move. EditTask rewrites the three
fields capture set — text, due date and weight — and nothing else. Status
stays the one-way ladder SetTaskStatus owns.
Two things the task asked to settle.
A text edit re-normalises the dedupe key and can collide with another live
row. That is ErrTaskDuplicate, a refusal rather than a merge: two live rows
carry two provenances, two capture times and possibly two external
identities, and merging picks a winner for all three with nobody asked. The
surface names the row that holds the text.
A resolved task is refused outright (ErrTaskResolved). Its text is the record
of what was finished, and rewriting it rewrites history.
due nil clears the date, because clearing has to be sayable — an absent date
and "remove the date" cannot be one argument.
Migration #22 adds done_when and blocked_on to tasks, both NOT NULL DEFAULT
''. "He has not written one" and "there is nothing to write" are the same
state here, so no caller has to tell NULL from empty.
blocked_on is a canonical Nexus entity id, never a name. It names a person
and identity lives in Nexus, so free text here would be a second answer to a
question Nexus already owns. The caller resolves before it writes.
Both columns round-trip through ipc.TaskAPI: on ipc.Task, settable at intake
through CaptureTaskReq, and writable afterwards through the new
SetTaskFields, which is deliberately not one-way — he may sharpen a
criterion, and a blocker clears when the person answers.
SetTaskStatus now refuses candidate → open when done_when is empty
(ErrTaskNoDoneWhen, mapped across the wire), the same refusal
ParseTaskCapture makes for a capture marker with nothing after it: confirming
work whose finish line nobody wrote is how a board fills with rows that can
never leave it. Dropping such a candidate stays legal, and the /tasks confirm
button now says what is missing instead of surfacing a not-found.
One caller skips the gate. CaptureTask promoting a candidate he stated out
loud would otherwise be denied intake rather than asked for a criterion, and
a direct open capture never carried one either. The gate belongs to the
deliberate promotion on /tasks, where V-511 puts a form.
V-539 said SearXNG claims every world question, including invented terms,
so Kiwix is never reached. Measured today against the configured instance:
seven of eight invented Russian questions now return zero results, and
Response.Empty() already passes those to the ZIM. The premise moved with the
upstream engine set in three days.
The three quality signals the task named were recorded per query and none
separate the sets. Token overlap is zero for the one bad claim and also zero
for "столица Франции", whose answer is Париж. Empty snippets never fire,
because ParseResponse already drops a hit with no text. SearXNG returned no
corrections or suggestions even for the query it silently respelled. So no
threshold is built: it would cost a real answer to save one invented word.
What ships is the second half. The claiming query source crosses the IPC seam
on ipc.ChatReply.Source and renders as a badge beside the reply on /chat. It
rides the context rather than a return value, because handleText answers every
reach through one string and the mic, telegram and the web all share it.
Chat now returns ChatReply instead of a bare string.
Full -race suite green.
The pattern detector needs four events for one action+object spread by at
least two hours before it proposes a routine. The only writer in the tree is
a fact write at time.Now(), so V-43, V-46, V-247 and V-254 all stopped at the
same missing step. This is the wire half of the seam that unblocks them.
The request takes a fact — key, value, timestamp — not an event, so
pattern.Extract runs for real on the daemon side and a key the extractor
ignores seeds nothing. The response says which of those happened, because a
caller that assumed a seed always yields an event would read four silent
successes as a broken detector.
AuthStepUp, the same rung as mutating the tool allowlist, and not because
backdating is privileged in the usual sense: every other write records when
something happened and this one asserts it. StoreAPI refuses outright — the
method needs the daemon's detect-and-propose step, and a direct store caller
would write a fact and quietly skip it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
Two review threads from PR 4, and the answer to the third.
The routine status was a bare string with its legal set in a comment.
Nothing caught a typo at compile time, nothing enumerated the set for a
test, and a bad value surfaced as a /routines row that neither accepts nor
dismisses. It is a RoutineStatus now, with the three constants, a
RoutineStatuses slice as the single source of truth, and Valid(). Listing
by an unknown status is refused with ErrRoutineStatus instead of answering
"no rows", which is what a correct query says about an empty table. A
round-trip test moves a routine into each state and reads it back, so a
constant that drifts from the inline SQL fails loudly.
The hand-rolled framing stays, and frame.go now says why: ninety lines,
readable with socat, and every standard replacement brings schema
machinery this boundary does not want. What was wrong was inheriting it
untested. frame_test.go covers the paths a real socket produces and the
round-trip test never does — truncated header, truncated body, one byte
per Read, two frames back to back, and a non-JSON body. Empty input is the
only EOF.
The unanswered question in the same file is answered in place: a routine
object stays a local string, not a Nexus ref, because nothing acts on it.
It is the word he used, replayed back to him, compared only against itself
for the UNIQUE key. Canonical refs arrive if a routine ever drives a Hexis
call, which is V-272.
The mood enum has the same shape and is not done here: it is spelled in
the GBNF grammar, three prompts and the parse, so it is its own change.
server.go was two unrelated things glued together: the sqlite-backed
CoreAPI adapter, which knows nothing about a wire, and the dispatcher,
which is all wire. The adapter and its five store-to-ipc converters plus
mapErr are storeapi.go now, 455 lines. server.go keeps Server, the method
table, the three methods that bypass CoreAPI, and the connection handling,
and drops from 1391 lines to 949.
Move-only, same package, no new indirection. Verified the same way as the
tick.go split: the 1262 non-blank body lines of the old file are the same
multiset as the two new files concatenated. s.Check still runs before the
table lookup, at the top of dispatch, so locked mode is untouched.
--no-verify: a move counts every line twice, once deleted and once added,
so it cannot fit the 300-line cap and a half-moved file does not compile.
The multiset check above is what stands in for reviewing it line by line.