Repository navigation
search: a fragment's own index, opt-in (the capability) - #783
Conversation
`POST /api/models/v1/embeddings` (an agent's computer's `/v1/embeddings`), OpenAI's shape, on the deployment's `models.embeddings`: BGE-M3 on Workers AI unnamed, or a model on an OpenAI-compatible API (a self-hosted deployment's own), with its dimensions and an optional version. Every answer names its space; a vector of another length, or another count than asked, is never passed on (502, charged: it was paid for); vectors are unit length. Charged after the answer at its input tokens (BGE-M3's $0.012 a million; a self-hosted slot names its price), counted from the input when the API reports none, as Workers AI does for embeddings. `models::embedded` is the call the search capability will make. The fake answers BGE-M3 on its Workers AI face and `/v1/embeddings` on its OpenAI-compatible one; the ledger lane checks the route, its refusals, its charge and its pass-through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nly privately A slot names `max_input_tokens` (512 unnamed, BGE-M3's on Workers AI): an input past it, counted at a token every 3 bytes, is refused, never cut, so its caller splits a long text (the search capability will embed a section's chunks). A slot may be marked private (a confidential API, never Workers AI or OpenRouter); a deployment whose tiers are private embeds only on one, refusing 403 otherwise, as Finite Private never falls back to a model that is not. Its deploy is not refused: search answers by words alone there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fragment-search, its own workspace and image (search/README.md): one LanceDB table a scope (a name the platform makes from a fragment's identity, incarnation and read scope), its rows a source's sections' chunks, so a section's vectors are its chunks'. Upsert replaces a source whole in one commit (merge_insert, its old chunks deleted in it); the same revision again writes nothing; `replaces` is a compare-and-set; a table's space is pinned by its first write and another is refused; vectors are reused by their text's hash. Search is BM25F over title, heading, context and text, and cosine over the vectors, each leg's sections ranked by their best chunk and fused by reciprocal rank, with snippets and signals. What a write removes leaves the store's files within about three minutes (compaction, then a prune). Bearer-token auth; a body read to its limit and no further. The same image runs in Cloudflare Containers on R2 (its S3 API) and beside celld on S3 or a disk: only its storage URI and keys change. Tests: limits, fusion, snippets; then on a directory, valid, replay, stale, reuse, spaces, restart, sources paging, purge (no deleted word in any file) and drop, and the HTTP API's token; and, by hand, the same on an S3-compatible store (passed on SeaweedFS's gateway). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`/api/f/{name}/search*` (docs/api.md, "A fragment's search"): an editor
writes a source whole (`upsert`, `delete`), lists them (`sources`); a
reader searches (`POST …/search`) and reads its `status`. The index is the
search service's table of the fragment's scope (its identity, its
incarnation, `members`), which no caller names; the role is checked before
anything is read or sent. A deployment names its service with
`search: {url, token}` (FRAGMENT_SEARCH_URL, SEARCH_TOKEN); unnamed, these
routes answer 404, and a fragment that never writes one holds nothing.
Sections are cut into chunks the embeddings slot reads whole and embedded
once each (models::embedded; a chunk the index holds already is reused),
a write paid for by the fragment's owner and a question by its asker; an
anonymous question, and everything on a deployment whose tiers are
private with no private embeddings slot, go by words alone. A stale
`replaces` is 409 `conflict` (a new, additive ErrorCode) naming the
current revision. A push that deletes a source's file forgets it (an
outbox, step 4 of a fragment's schema, sent by the alarm, left out of
answers until gone); a deleted or wiped life's index is dropped with its
database and blobs.
The wire types are fragment_proto::search (the Session Archive's writer
takes them). The e2e's `search` section runs the service's image in
Docker on a disk store, the node started naming it: valid, replay and
reuse, a grown source, stale, invalid, roles and revocation, a deleted
file forgotten, the service restarted, a deleted fragment's index
dropped, and a private deployment by words alone. CI builds the image on
shard 1, its layers cached.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alexlwn123
left a comment
There was a problem hiding this comment.
Fail for now at a87c220b8384ef6ae8cbda6f35cac4bfc88c5b87, reviewing this layer against #777 at 956d111df1afe379400e9bf20cc9ae7bc07d5097.
The opt-in capability fits the service layer, and the diff's API, deployment, migration and test surfaces are appropriate. Two blocking failures remain in ordering writes against deletion. These are source-traced interleavings, not failures reproduced by the existing e2e.
Blocking:
-
A deferred forget deletes a subsequently successful write.
cell/src/searchable.rs:160treatssearch_drainas a barrier, but it returns no result and selects only due entries, at most 100 (:373–382). Delete a file while the service is unavailable: its forget is deferred five minutes (:365). Restore/reimport that path after the service recovers but before the retry is due. The upsert skips the pending delete, succeeds, and leaves the outbox row untouched. Queries and sources hide the new revision; the alarm subsequently deletes it unconditionally. The same failure applies to a source beyond the 100-entry drain batch. Resolve the target source's prior forget before accepting its new write, or refuse/retry the write until that ordering is established. Coordinate concurrent drains too; merely removing the local row after an upsert does not cancel a delete already in flight. Cover recovery during backoff and a backlog larger than one pass. -
An in-flight upsert can recreate an index after its life has been cleaned.
search_upsertcaptures scope/owner, awaits embedding (cell/src/searchable.rs:183), then writes that scope (:186) without a lifecycle fence. While embedding is pending, the owner can delete the fragment (or an operator wipe it);end_liferemakes the live tables, andclear_endeddrops the index and marks storage cleaned (cell/src/ended.rs:358–369). When embedding returns, the old request creates the old scope again, and no remaining cleanup row is obliged to remove it. Deleted/wiped content can therefore remain in the service indefinitely. Order the final external write against lifecycle cleanup, including writes already dispatched, and prevent stale requests from marking a new life's metadata. Cover a paused embedding and a paused service write across delete/wipe, including recreation of the fragment name.
Validation: all five cargo test -p fragment-core searchable --lib tests and the cargo test -p fragment-core search_forget --lib migration test passed. CI check and images check passed; e2e and real-Hermes Docker checks were still running. I did not run local e2e or the paid R2 proof. The engineering-style file named by AGENTS.md is absent on this machine.
Every setting the service reads may be a file's instead (SEARCH_TOKEN_FILE, SEARCH_STORAGE_<OPTION>_FILE): a secret mounted in, never on a command line. The object-store test reads its keys so too, and says how long each step took (first write, a cold search after a reopen, a warm one, a replace, a purge): the latencies R2 is measured by. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alexlwn123
left a comment
There was a problem hiding this comment.
Fail for now at dc73e4e179f0712c7aca0615285be2c7241ad608, against #777 at 108d4eea652dd8cad52f1bdf70f0a7d86ee918d0.
Both blockers from my previous review remain. This head only merges the base's environment-file support and object-store test changes; the capability's write/deletion paths are unchanged. The scope and implementation size remain appropriate for this capability.
Blocking:
-
A pending forget can erase a successful replacement.
cell/src/searchable.rs:160callssearch_drain, which selects only due entries, at most 100 (:373–382), and does not signal incomplete deletion. After a file deletion fails to reach the service, its forget waits five minutes. Restore/reimport that source during the backoff: the upsert succeeds, but the retained outbox row hides the new revision and later deletes it. A source beyond the drain batch has the same problem. Establish ordering for the target source before accepting a new write, including already-running drains; cover backoff recovery and more than one batch. -
A late upsert can recreate a cleaned, ended life's index.
cell/src/searchable.rs:183–189awaits embedding and then writes the previously captured scope and live metadata without a lifecycle fence. Delete/wipe can meanwhile remake the live tables and finishclear_ended(cell/src/ended.rs:350–369). A subsequent upsert with noreplacesrecreates the dropped table (search/src/store.rs,upsert/create), leaving deleted content without further cleanup. Order external writes against lifecycle cleanup, including dispatched writes, and prevent stale requests from updating a new life's metadata. Cover paused embedding and paused service writes across delete/wipe and name recreation.
Validation: re-traced both failures on this head and inspected the complete delta from my prior reviewed head. These are source-traced failures, not fresh runtime reproductions. Local tests were not rerun for the unchanged capability paths; CI checks and e2e are currently pending. No local e2e or paid R2 proof was run. The engineering-style file named by AGENTS.md is absent on this machine.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alexlwn123
left a comment
There was a problem hiding this comment.
Fail for now at e169538feea81ffb48902a6586ffd3e923942819, against #777 at 2fdfc1ae9377a4456c63c417d4924dc54a8c5955.
Both blockers from my previous review remain. The complete delta since that review only adds automatic temporary-directory cleanup in search/tests/service.rs; the capability and service write/deletion paths are unchanged. That small test cleanup is appropriate and adds no material complexity.
Blocking:
-
A deferred forget can erase a successful replacement.
cell/src/searchable.rs:161callssearch_drain, which selects only due entries, at most 100 (:373–382), without reporting unfinished deletion. After a file deletion fails to reach the service, its forget waits five minutes. Reimport that source after recovery but before the retry: the upsert succeeds, yet the retained outbox row hides the replacement and later deletes it. A source beyond the drain batch has the same problem. Establish ordering for the target source before accepting its write, including deletes already in flight. Cover recovery during backoff and a backlog larger than one batch. -
A late upsert can recreate an ended life's cleaned index.
cell/src/searchable.rs:183–189awaits embedding, then writes the captured scope and live metadata without a lifecycle fence. Delete/wipe can meanwhile remake the live tables and finishclear_ended(cell/src/ended.rs:350–369). The resumed upsert with noreplacesrecreates the dropped table (search/src/store.rs:257–262), leaving deleted content without further cleanup. Order external writes against lifecycle cleanup, including writes already dispatched, and prevent stale requests from updating a new life's metadata. Cover paused embedding and paused service writes across delete/wipe and name recreation.
Validation: inspected the full delta since my previous verdict and re-traced both failures at this head. These are source-traced failures, not fresh runtime reproductions. Local tests were not rerun for the unchanged capability; CI check, e2e, images check and real-Hermes Docker checks remain pending. No local e2e or paid R2 proof was run. The engineering-style file named by AGENTS.md is absent on this machine.
fragment-search, its own workspace and image (search/README.md): one LanceDB table a scope (a name the platform makes from a fragment's identity, incarnation and read scope), its rows a source's sections' chunks, so a section's vectors are its chunks'. Upsert replaces a source whole in one commit (merge_insert, its old chunks deleted in it); the same revision again writes nothing; `replaces` is a compare-and-set; a table's space is pinned by its first write and another is refused; vectors are reused by their text's hash. Search is BM25F over title, heading, context and text, and cosine over the vectors, each leg's sections ranked by their best chunk and fused by reciprocal rank, with snippets and signals. What a write removes leaves the store's files within about three minutes (compaction, then a prune). Bearer-token auth; a body read to its limit and no further. The same image runs in Cloudflare Containers on R2 (its S3 API) and beside celld on S3 or a disk: only its storage URI and keys change. Tests: limits, fusion, snippets; then on a directory, valid, replay, stale, reuse, spaces, restart, sources paging, purge (no deleted word in any file) and drop, and the HTTP API's token; and, by hand, the same on an S3-compatible store (passed on SeaweedFS's gateway). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every setting the service reads may be a file's instead (SEARCH_TOKEN_FILE, SEARCH_STORAGE_<OPTION>_FILE): a secret mounted in, never on a command line. The object-store test reads its keys so too, and says how long each step took (first write, a cold search after a reopen, a warm one, a replace, a purge): the latencies R2 is measured by. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nterrupted tables Physical erasure: a pass of maintenance, holding the table's writes, rewrites every file holding a deleted row (threshold 0: Lance's 10% kept low-fraction deletes for good), rebuilds the text indexes after a removal, and prunes; passes repeat until one version deleting no row is left, a failed pass is retried with backoff, and a write meanwhile gets a pass after it. The store's own state says what is left, so the service sweeps every table after a restart and finishes it. Writes fold rows into indexes only; maintenance alone compacts. Revisions: every source has its own row holding its revision, written in the same merge_insert, so sections: [] keeps it (replay is a no-op, CAS holds, across restarts, from a source's first write). Initialization: a table is opened only with its text indexes, made when missing, so a first write cut off between table and index is finished by its retry or the next open before it is served. Tests check the store's files after a delete of a twentieth of a file, a delete between passes, a restart and a failed pass, and inject the interrupted first write with a read-only index directory. SEARCH_LISTEN reads _FILE too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…bility # Conflicts: # .github/workflows/ci.yml # crates/core/src/embed.rs # crates/e2e/src/lanes/mod.rs # docs/api.md # search/README.md # search/src/main.rs # search/src/store.rs # search/tests/service.rs
Chunks were cut at 3 bytes a token, which the route no longer admits. They are now measured as tokens_max measures them, header and the model's own tokens included, so every chunk is one the route takes, whatever its script (a test asks the route). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /v1/{scope}/end makes the scope's tombstone, then drops its table;
a write after it is refused 410 ended, however late it comes (one sent
before the end), across restarts. A scope is one life of one fragment, so
it is never needed again. A write that waited on a table dropped under it
starts over (made again after a drop, refused after an end).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Forgets: the DO sends one source one thing at a time (a per-source lock, as settling). A write of a source holds it to its answer and sends its pending forget first, whenever it is due (backoff, past a pass's 100), or is refused 502; a drain try-holds each source and re-reads its row. A forget in flight finishes before the write starts. Lifecycle: a write re-checks its life after holding, after embedding and after the service answers; a changed life is 404 and marks nothing of the next. A life's end calls the service's end, which tombstones the scope, so a write dispatched before and landing after is refused. e2e: a forget held back by an outage and the source written during its retry's wait; 101 forgets in one push; a write held at its embedding (the Workers AI fake's new hold) and one held at the service's door (a forwarding door in the lane) while the fragment is deleted and its name made again. With the fixes mutated out, these fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks, Alex. Both blockers are fixed in 096aadc, plus #777's b408388. The local e2e 1. A forget never lands after a later write. The DO sends one source one thing at a time, using a per-source async lock (
e2e (
One window the cell can't close: a delete sent by a DO instance that is reset mid-call, which then reaches the service after the new instance's later write. The service takes a scope's writes in arrival order, so this needs the old request delayed in transit for longer than the new instance's start plus its embedding. A per-source generation fenced at the service would close it. I left that out for its complexity; say if you want it. 2. The lifecycle fence. A write captures its life (
A changed life gets 404, and the write marks nothing of a life made again under the name. For writes already dispatched: a life's end now calls the service's e2e:
A wipe ends a life through the same Also:
|
alexlwn123
left a comment
There was a problem hiding this comment.
Pass for this stack layer at 096aadc8a9b2d3697e82e4667fc02016a6f09c29, against #777 at b40838865cb9bfc550a79a8b3fb52f5ba66f745e.
Both earlier blockers are addressed: per-source serialization and a mandatory target-source forget order replacements after pending/in-flight deletion, including backoff and batches over 100; incarnation checks and the service's persistent end tombstone prevent a late upsert from recreating a cleaned life or marking the next life. I traced these paths and inspected the new regression scenarios for backoff, 101 deletes, paused embedding and a dispatched write across deletion/name recreation.
The opt-in capability remains appropriately scoped. Its API, deployment, migration and test surfaces are justified, and the added synchronization stays local to search. The revised chunker also uses the embeddings route's token bound. I found no remaining blocking regression in this layer.
Validation: all six cargo test -p fragment-core searchable --lib tests and the cargo test -p fragment-core search_forget --lib migration test passed. CI check and images check passed; e2e and real-Hermes Docker checks were still pending. I did not run local e2e or the paid R2 proof. The engineering-style file named by AGENTS.md is unavailable on this machine.
Lance prunes by when a version was made, so a pass pruned the version it had just superseded (made long before) from under a search still reading it: CI's debug run caught one read failing on a rebuilt index's pruned files. Prune every version older than the newest made more than keep ago (lancedb's absolute cutoff). Tests keep a second, as the service keeps a minute. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alexlwn123
left a comment
There was a problem hiding this comment.
Pass for this stack layer at 1ee3d9adb3b2dff3c3126c1ddc2472ba3af6b376, against #777 at 3214c72a2c25bd3200f4b8c6834eff72007dc798.
Both earlier blockers remain fixed as accepted in my previous approval: target-source serialization orders pending forgets before replacements, and incarnation checks plus the service's persistent end tombstone fence writes across deletion. Those capability paths are unchanged.
I inspected the complete delta since that approval: the inherited embeddings refusal fixture and service pruning changes. The pruning cutoff retains a superseded version for ongoing reads; I checked its strict timestamp boundary against the pinned LanceDB/Lance implementation. The small helper and test adjustments are appropriate in scope and complexity. I found no new blocking regression in this layer.
Validation: all six cargo test -p fragment-core searchable --lib tests and the cargo test -p fragment-core search_forget --lib migration test passed. CI check, images check, e2e and real-Hermes Docker checks were still pending. I did not run the service suite, local e2e or paid R2 proof. The engineering-style file named by AGENTS.md is unavailable on this machine.
…bility # Conflicts: # search/README.md # search/src/store.rs # search/tests/service.rs
alexlwn123
left a comment
There was a problem hiding this comment.
Pass for this stack layer at 2cb3a0fe2ac17ba711c4d8a5ca1093fcb1ccab08, against #777 at d5b9a4dd61ab903ef574311c245afce4394a465b.
Both earlier blockers remain fixed as accepted in my previous approval: per-source serialization orders forgets before replacements, and incarnation checks plus persistent end tombstones fence writes across deletion. The capability paths are unchanged since that approval. I inspected the inherited service changes that schedule cleanup before optional index folding, retry failed startup sweeps, and finish interrupted ends; they preserve the capability's deletion boundary. The added retry machinery and regression scenarios are appropriate to that purpose. I found no new blocking regression in this layer.
Validation: all six cargo test -p fragment-core searchable --lib tests and the cargo test -p fragment-core search_forget --lib migration test passed. CI images check passed. CI check failed in front::tests::a_read_is_seen_before_it_is_passed_on; its file is identical to the base branch, so this is inherited rather than introduced by this layer. E2e shards and real-Hermes Docker checks were pending. I did not run the service suite, local e2e or paid R2 proof. The engineering-style file named by AGENTS.md is unavailable on this machine.
The search capability (#783) is on master, with its wire types in fragment_proto::search and its checks in fragment_core::searchable. The archive's search writer now uses them, and its own copies are gone (the debt entry with them): - `session_search::upsert_of` builds a `fragment_proto::search::Upsert` within the platform's limits: sections and text as before; title, fields and context bounded in bytes (`searchable`); no control character but a line's end and a tab in a section, and none (nor a filter's `=`) in a name or a filter. - The import runs `searchable::check` on each upsert before it sends it (one the platform would refuse is said, unsent), deletes with `search::Delete`, and pages sources by `SOURCES_PAGE_MAX`. - `fragment_fakes::search` checks an upsert as the cell does (the same type and check), and answers as the cell does. - The e2e's search section imports the sessions section's export against the real service: each conversation kept becomes a source, its person finds one by its words, and the same import again writes nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR 3b of the search stack, on #777. It adds the capability: a fragment's own index, served by the search service (#777) beside the deployment. It is opt-in, and a deployment that names no service is unchanged.
What it adds
Routes (docs/api.md, "A fragment's search"; wire types in
fragment_proto::search):POST /api/f/{name}/search/upsert,/delete: an editor writes or removes a source whole.GET …/search/sources?after=&limit=: an editor pages its sources, ascending (for reconciliation).GET …/search/status: a reader sees what the index holds (semantic,forgetting).POST /api/f/{name}/search: a reader searches; hits carry source, section, location, snippet and signals.The shapes are the ones the Session Archive's writer (sessions: an import writes its archive's search #774) already sends.
Scope and roles.
f<object id>-<incarnation>-members. The cell derives it; no caller names it.Embedding.
max_input_tokens), so nothing is truncated.models::embedded. A chunk the index already holds (known, by the input's sha256) isn't re-embedded, so a grown conversation embeds only its new turns.semantic: "off"), per the coordinator's decision for models: a metered embeddings slot, one vector space #772.Stale writes.
replacesis a compare-and-set. A mismatch is 409 with the new, additiveErrorCode::Conflict(conflict), namingcurrent; the CLI maps it to its existingconflictcode.Deletes follow the files. When a move of main deletes a file whose path is a source, the source is forgotten:
search_forget, the Fragment's schema step 4, in the move's turn;A life's index ends with it. A deleted or wiped fragment's index is ended in
clear_ended(the service'send: tombstoned, then dropped), with its database and blobs, under that machinery's retries. A write re-checks its life after each wait, so one the end overtook writes nothing and marks nothing of a life made again under the name.One source, one send at a time. A write of a source sends that source's pending forget first, whatever its retry time, and a drain never sends a source a write holds. So no forget lands after a later write.
Deploy and dev.
search: {url, token}setsFRAGMENT_SEARCH_URL(https only) and bindsSEARCH_TOKEN; the token is never a variable.fragment-search-token./api/fbodies stay at 2 MiB.Tests
searchsection (shard 1; the real service image in Docker on a disk store), 9 checks, all passing locally:conflictnamingcurrent.sources, and from the service.e2e-search) and has a 50-minute limit (others stay at 20). A cold LanceDB compile on a runner takes about 25 minutes, and only whensearch/changes.cargo xtask checkpasses.The R2 one-off (waiting on Paul's OK)
This proves #777 on R2 itself (conditional-put commits, cold reads), with synthetic data only, from this machine. Steps on the
claude-*preview account:wrangler r2 bucket create fragment-search-testsearch/:wrangler r2 bucket delete fragment-search-test(the test leaves it empty), then revoke the token.No secret goes into the Secrets Store for this. That step (
secret set, the bucket's keys) comes with 3c, which runs the service in Containers on R2 and is Paul's to approve.🤖 Generated with Claude Code