Skip to content

Harden first-run: an honest exit code, a 12x faster first ingest, and two storage guards - #11

Merged
mgoldey merged 9 commits into
mainfrom
feat/onboarding-hardening
Sep 17, 2026
Merged

mgoldey merged 9 commits into
mainfrom
feat/onboarding-hardening

Conversation

@mgoldey

@mgoldey mgoldey commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Nine commits aimed at one question: what does a researcher hit in their first
five minutes, and does it lie to them? Every number here is measured against
live Ollama/embeddinggemma on real corpus items, not fixtures.

What a new user gets

before after
First ingest (~3000 items) 20.8 min ~1.7 min
Ollama down empty feed, exit 0, cron logs success forever exit 1, named cause
Empty feed [] with ok: true says which of the two causes
Blank interests box free text only three archetypes, nothing autoseeded
Agent hits a dead backend "internal error in list_feed" the real cause and remedy
Python without loadable sqlite extensions every command dies ledger and claims still work
A database opened under a different embed model silently wrong similarity refused, both models named

The two that matter most

attest ingest exited 0 after ingesting nothing. cmd_ingest never read the
embedder_down that run_ingest latches. That 0 was believed by install.py's
step_first_data and by the generated hourly refresh script, so a user whose
Ollama was not running got an empty feed, a successful install, and a cron job
logging success indefinitely. Silent, self-confirming failure on first run.

Batched embedding, measured 9.9x. 416.2 -> 42.1 ms/item on 96 real items,
then 47.5 ms/item at the widened chunk. embed_many sorts the response by each
element's index, because the OpenAI contract does not guarantee data order
and mispairing vectors with items would corrupt the index invisibly. The
embedder-down latch is preserved: one honest message, not 22 tracebacks.

Storage guards

Migration 010 records which EMBED_MODEL produced each vec table's vectors.
Dimensionality was already guarded; model identity was recorded nowhere, so a
same-width different-model reopen produced garbage similarity with no error.
Legacy databases are never refused -- the configured model is backfilled on
first open -- which means first-open-wins, so any database we ever distribute
must ship with embedding_model already populated or the guard is disarmed for
exactly the users it protects.

The sqlite-vec extension load no longer kills the tool on a Python built
without --enable-loadable-sqlite-extensions. This was not hypothetical: a bare
3.12 in .python-version bound a pyenv build lacking that support and broke
every command while CI stayed green, because actions/setup-python builds do
have it. Hence the exact-patch pin. Policy follows CLAUDE.md verbatim: the
ledger and claim checker need no model, so they keep working.

Notes for review

  • MCP_SQL_BASELINE stays at 21. The empty-state check needed a COUNT(*);
    rather than raise the ratchet main deliberately reverted in cbac191, it moved
    into a rank.item_count() domain reader, which is what that commit
    demonstrates.
  • Counts are 29 sqlite_master / 18 application, re-measured on the merged
    tree. Main reached 28/17 via reference_fulltext (009); embedding_model
    (010) makes it 29/18. Both pinning tests and CLAUDE.md updated together --
    they police the same claim from opposite directions and had already drifted
    apart once.
  • One commit records a premise that was false. The --check filesystem
    mutation I was asked to fix does not exist; the guard is correctly ordered and
    the ~/.hermes/ creation came from the real hermes binary during read-only
    calls. The regression test landed anyway.
  • A cross-file break that linters could not see. Making vector_search raise
    EmbedderUnavailable silently stopped it matching library.py's
    (httpx.HTTPError, OSError) fallback. Repo-wide ruff check and ty check
    both passed while that was broken. Only pytest found it.

Verification

Full gate green on the pushed tree, all eight hooks, read line by line rather
than trusted from the exit code -- an earlier run exited 0 while the complexity
hook had failed inside it:

ruff format / ruff check / ty / uv.lock / complexity ratchet / bandit / xenon / pytest
all Passed

Full suite 1841 passed, 2 skipped, 3 deselected.

🤖 Generated with Claude Code

mgoldey and others added 9 commits September 16, 2026 11:08
…n.env`

`.gitignore` had `.env`, a literal path rather than a glob, so a `neon.env`
holding a live Postgres password and S3 secret keys sat untracked but
unignored -- one `git add .` from entering history. Verified with
`git check-ignore -v`: no match before, `.gitignore:15:*.env` after.
`.env.sample` is tracked and the glob does not match it, so it stays tracked.

First in the series on purpose: every later `git add` here is only safe once
this rule is in place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing nothing

`cmd_ingest` printed its stats dict and returned 0 unconditionally, never
reading the `embedder_down` that `run_ingest` latches. MEASURED against a dead
backend: `{'added': 0, 'skipped': 0, 'failed_feeds': 1, 'embedder_down': True}`
and exit 0. The control is measured too, because a careless fix breaks it:
against live Ollama, `{'added': 1343, 'skipped': 1, 'failed_feeds': 0}`, exit 0.

That 0 was believed in two places. install.py's `step_first_data` shells out to
`attest ingest`, and the generated hourly refresh script tests
`if uv run attest ingest`. So a user whose Ollama was not running got an empty
feed, an install that reported success, and a cron job logging success
indefinitely -- silent, self-confirming failure on first run.

`cmd_tag` five lines below already did this correctly for `chat_down`; this
copies that shape. A partial run still exits 1: once the latch trips
`run_ingest` stops, so the remaining feeds were never attempted -- a degraded
run, not a clean one.

Also threads `need_vectors=True` through this path's `open_db` (the sqlite-vec
guard in the following commit needs it), and fixes three test doubles whose
`run_ingest` lambdas predated main's `clients` kwarg: they raised TypeError
once `cmd_ingest` began passing it, a break that merged textually clean and
surfaced only under test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n not 20.8

`EmbeddingClient.embed` sent one string per HTTP round trip and `run_ingest`
called it from a plain list comprehension, so a first ingest paid one request
per item. The OpenAI-compatible `/embeddings` endpoint already accepts a list.

MEASURED on 96 real corpus items (titles + summaries from a 1343-item
database, not a fixture) against live Ollama and embeddinggemma:

    serial   39.95s = 416.2 ms/item
    batched   4.04s =  42.1 ms/item      9.9x
    identical vectors in identical order: True

For a ~3000-item first ingest that is 20.8 min -> 2.1 min, which is the single
largest cost in getting a new user to a ranked feed.

`embed_many` sorts the response by each element's `index` before returning.
The OpenAI contract does not guarantee `data` comes back in request order, and
mismatching vectors to items would corrupt the index in a way nothing else
would notice -- hence the order assertion in the measurement above and a test
that feeds a deliberately out-of-order stub response.

The embedder-down latch is preserved deliberately: `embed_documents` raises
straight out of the chunk loop exactly as the per-item call used to, so
`run_ingest`'s handler still classifies it and still stops after one honest
message rather than 22 tracebacks. A chunk that fails mid-feed abandons that
feed's whole embedding pass and never reaches the write transaction -- the
same all-or-nothing per feed as before. No partial-chunk salvage was added;
that would be a behaviour change rather than a preservation.

EMBED_BATCH_SIZE is 16. A live sweep on the same texts found 32 at 45.4
ms/item and 96 at 40.8 ms/item, so there is ~17% left on the table, but the
failure unit grows with the chunk and 16 already delivers the speedup that
matters. Left as a one-line follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…internal error"

`rank.py` raised a carefully worded `EmbedderUnavailable` and the web UI
rendered it well, but the MCP path turned it into `"internal error in
list_feed; see server logs"` -- so the two most likely agent first-contacts,
`feed.list` and `feed.search`, told the caller nothing it could act on. Per
CLAUDE.md an unreachable model server is an expected, actionable refusal, so it
belongs in the ToolError category rather than the bug path.

MEASURED against a dead LLM_BASE_URL, what an agent now receives:

    ok=False, message='embedding model unreachable
    (LLM_BASE_URL=http://127.0.0.1:9/v1) -- is ollama running?
    (`attest install --check` diagnoses this)'

`_tool.py` imports the type lazily. Doing it at module scope pulls sklearn
through `rank.py` and costs ~929ms on every MCP tool call, which
`test_cli_help_stays_fast` exists to catch.

Includes a cross-file fix this change caused, and which is the reason the
pytest gate is not optional: `library.py:_semantic` caught
`(httpx.HTTPError, OSError)` to degrade to substring search, and
`EmbedderUnavailable` is a plain RuntimeError, so it silently stopped matching
and escaped uncaught. Repo-wide `ruff check` and `ty check` both passed while
that was broken -- a RuntimeError ceasing to match an except tuple in another
module is invisible to a linter and a type checker. Only a test found it.

Both policies are correct and both are kept: feed.* SURFACES the condition to
the agent, library search DEGRADES to substring per `_semantic`'s own
docstring ("None when the wire could not be reached"). Same failure, two
legitimate readings, so the type appears in both places. The import there is
function-local too, matching that file's existing idiom.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An empty result returned `[]` with `ok: true` whether the database held
nothing at all or simply nothing in the last 14 days. Those are different
problems with different fixes, and an agent could not tell them apart -- the
documented worst case for a new user, who sees an empty feed and no reason.
MEASURED on a fresh database, what a caller now gets:

    ok=True, n_items=0,
    message='0 item(s) -- the database has no items yet; run `attest ingest`
    to fetch some'

`ok: true` is deliberate: an empty feed is a legitimate answer, not a refusal,
so the explanation rides in the existing `message` field rather than raising.

The existence check lives in a new `rank.item_count()` reader rather than a raw
`SELECT COUNT(*)` in the MCP layer, so MCP_SQL_BASELINE stays at 21. Main
reverted that same ratchet 22 -> 21 in cbac191, whose message calls a raw
SELECT up here "the exact pattern MCP_SQL_BASELINE exists to push back on";
raising it again would have silently reversed a decision made four commits
later, and the domain reader is what that commit demonstrates instead.

`rank.py` also gains the `EmbedderUnavailable` raise sites for the previous
commit: `vector_search` now converts `httpx.ConnectError`/`ConnectTimeout`
into it, which is what `feed.search` needed -- a raw transport error used to
escape and never reach the handling that already existed. The except clause is
narrow on two concrete types, so it needs no BLE001 suppression and the
inventory stays at 7. The warm-cache-serves-stale policy is untouched: a down
embedder with a cached vector still degrades rather than raising, and only the
genuinely cold case raises.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rests string

With no clicks `blend_weight(0)` is 0.000, so the classifier never fires and
the whole first session is cosine similarity between one free-text interests
string and each item. The onboarding form asked for that string with an empty
textarea and a placeholder, which makes the blank box the product on day one.

Three archetypes (researcher, bench-chemist, ml-engineer) are now offered on
the web form and from `feed.persona_suggest_interests`. They come from the
existing `db.SEED_USERS` rather than new prose, and the text is passed through
VERBATIM: CLAUDE.md records that repetitive interests text embeds BETTER than
hand-tightened text (0.588 vs 0.568 mean similarity), because the string IS
the profile vector, so tidying it for display would make it worse.

Nothing is autoseeded. Clicking a template prefills the textarea, still fully
editable; a persona exists only when someone submits. MEASURED on a fresh
empty database: `GET /onboard` returns 200 with all three rendered and
`personas created: []`. That property is load-bearing -- this repo already had
the failure where `/` opened the author's persona and created it for whoever
arrived.

The tool side closes a real hole: `_propose_interests` previously offered only
the corpus's most common tags, and `item_tags` is empty until the model
tagging pass runs, so it had nothing to suggest exactly when a new user needed
it. On an empty corpus it now returns `prevalent_tags: []` plus the three
archetypes, 800 characters against a 7000 ceiling.

The buttons carry `data-interests` and read `this.dataset`, not `| tojson`
into an inline `onclick`. Jinja's tojson only JSON-escapes, so a `"` in the
text breaks out of the HTML attribute -- verified live, producing
`onclick="x = "he said \"hi\"""`. Same class of bug as this repo's `hx-vals`
scar, where a reader named `ann\` silently broke both vote buttons.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…on without loadable extensions

Two storage guards, plus the count update their arrival forces.

MIGRATION 010, `embedding_model`: one row per vec0 table naming the
EMBED_MODEL that produced its vectors. `_ensure_vec_tables` already refused a
DIMENSIONALITY mismatch, but model identity was recorded nowhere --
`item_features.model` names the TAGGING model only. So a database embedded
with embeddinggemma, opened by a user configured for a different 256-dim
model, passed the dims guard and produced silently incoherent similarity: same
width, different vector space, no error. MEASURED, the refusal now fires:

    RuntimeError: database has item_vectors vectors recorded from
    model='embeddinggemma' but EMBED_MODEL=some-other-256d-model
    -- re-ingest into a fresh database or set matching EMBED_MODEL

Legacy databases are never refused: vectors with no recorded model get the
configured EMBED_MODEL backfilled as the row of record on first open, because
there is no way to recover what actually produced old vectors and refusing
would break every existing user. The consequence is first-open-wins, which
matters for any database we ever DISTRIBUTE -- a shipped file must arrive with
`embedding_model` already populated or the guard is disarmed for exactly the
users it protects.

Numbered 010, not 009: main's research branch claimed 009 concurrently. The
ladders interleave rather than collide, so the merged ladder is
`...(8), (9, research), (10, embedding_model)`.

THE EXTENSION GUARD: `get_db` called `enable_load_extension` unconditionally.
On a Python built without `--enable-loadable-sqlite-extensions` that attribute
does not exist, and `AttributeError` is neither OSError nor sqlite3.Error, so
it escaped `open_db`'s filter and killed EVERY subcommand -- including
`attest runs scan` and `attest claims`, the model-free tier the README leads
with. This was not hypothetical: a bare `3.12` in `.python-version` bound a
pyenv build lacking that support and broke the whole tool while CI, which uses
`actions/setup-python`, stayed green.

Policy follows CLAUDE.md verbatim -- "the ledger and claim checking need no
model; the feed does". `_ensure_vec_tables` returns early instead of
attempting `CREATE VIRTUAL TABLE ... USING vec0`; the relational schema and
the whole ladder still run. `open_db` gained `need_vectors`, true on the six
call sites that actually query a vector table, because `library.*` and
`ingest` query them unconditionally even on fielded-only paths -- so merely
leaving the tables absent would surface as an obscure `no such table` deep
inside those modules rather than an actionable refusal.

COUNTS: main reached 28 sqlite_master / 17 application via `reference_fulltext`
(009); `embedding_model` makes it 29 / 18. Re-measured on the merged tree
rather than reasoned about, and written into CLAUDE.md with both pinning tests
updated -- `test_db.py` and `test_library_db.py` police the same claim from
opposite directions, and when 010 first landed one was updated and the other
was not, which is exactly the drift they exist to catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…all cost is disclosed

THE DOCTOR: on a clean HOME with no hermes binary, `attest install --check`
reported BROKEN for `skill_copy` and so exited 1, even though the other three
agent-wiring steps already degraded to SKIPPED via `_find_agent_binary()`.
`step_skill_copy` never took an `agent` parameter and always ran. It now skips
without one, matching its three siblings, so a user who wants only the local
feed and ledger gets a clean exit. Verified both ways: with no agent and a
seeded database `--check` exits 0; with an empty database only `first_data` is
BROKEN and it exits 1, which is a real local gap and should fail.

A premise I was given for this work turned out to be FALSE and is recorded
here rather than quietly dropped: `--check` was said to create `~/.hermes/`
because a `mkdir` preceded the check guard. It does not -- the guard is
correctly ordered, as are all four other mutation sites. The `~/.hermes/`
creation came from invoking the real `hermes` binary during read-only
`hermes mcp list`/`cron list` calls. Reproduced with a no-op stub: zero
mutation. The regression test was added anyway, snapshotting the whole fake
home tree before and after, since it would catch the bug the premise
described including an empty-directory variant a file listing would miss.

THE DISCLOSURE: install.md said models "are pulled for you" with no size.
MEASURED: gemma4:e2b-it-q4_K_M 7.2 GB + embeddinggemma 621 MB = ~7.8 GB of
download, ~5.4 GB held resident while warm. Ollama is now named as a separate
prerequisite with its download link, and the no-model tier is stated FIRST --
the run ledger and claim checker need neither, reproduced from a clean clone
with the backend unreachable in 2.2s, so a reader who only wants provenance
can skip the 7.8 GB entirely. The documented `ollama >= 0.32.9` floor is
marked as a manual requirement, because nothing in this repo checks it.

`.python-version` pins `3.12.12`, the exact patch, not a bare `3.12`. A bare
minor version is a lookup, not a pin: uv resolved it to a local pyenv 3.12.4
built without loadable sqlite extensions, which broke every command while
passing CI. uv-managed 3.12.12 and 3.13.11 both have the support; this keeps
CI's 3.12 leg honest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t still small

The batching commit left EMBED_BATCH_SIZE at 16 because that was the width the
first measurement happened to use, and said so. Swept it properly on 96 real
corpus items against live Ollama/embeddinggemma, best of three runs each:

    16 -> 57.6 ms/item   (6 requests)   the original choice
    32 -> 47.5 ms/item   (3 requests)   +21.4%
    48 -> 43.5 ms/item   (2 requests)   +32.6%
    64 -> 45.5 ms/item   (2 requests)   +26.6%
    96 -> 42.3 ms/item   (1 request)    +36.1%

Per-item time flattens after about 32 -- 48 and 96 differ by roughly what two
runs of the same width differ by, and 64 came in slower than 48 -- while the
cost of a failure grows linearly with the chunk, since a chunk fails or
succeeds together and `_embed_entries` raises out of the loop, abandoning that
feed's whole embedding pass. So this is a blast-radius choice, not a hunt for
the minimum.

The endpoint imposes no ceiling worth designing around: 256 texts in a single
request returned 256 vectors at 40.7 ms/item, so nothing here is working
around a server limit.

32 takes the bulk of the remaining gain and keeps the unit of loss small.
Verified at the new width: a dead backend still latches and exits 1 with one
message rather than one per feed, and a live run added 1363 items, exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mgoldey
mgoldey merged commit 5204771 into main Sep 17, 2026
7 checks passed
@mgoldey
mgoldey deleted the feat/onboarding-hardening branch September 17, 2026 00:30
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