Skip to content

search: a fragment's own index, opt-in (the capability) - #783

Merged
futurepaul merged 23 commits into
masterfrom
search/capability
Oct 31, 2026
Merged

futurepaul merged 23 commits into
masterfrom
search/capability

Conversation

@futurepaul

@futurepaul futurepaul commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

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.

    • The index is the service's table for f<object id>-<incarnation>-members. The cell derives it; no caller names it.
    • Roles are checked before anything is read or sent: viewers search, editors write. A stranger or a removed member is refused.
  • Embedding.

  • Stale writes. replaces is a compare-and-set. A mismatch is 409 with the new, additive ErrorCode::Conflict (conflict), naming current; the CLI maps it to its existing conflict code.

  • Deletes follow the files. When a move of main deletes a file whose path is a source, the source is forgotten:

  • A life's index ends with it. A deleted or wiped fragment's index is ended in clear_ended (the service's end: 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.

    • A config's optional search: {url, token} sets FRAGMENT_SEARCH_URL (https only) and binds SEARCH_TOKEN; the token is never a variable.
    • devstack seeds fragment-search-token.
    • A search upsert's body may be up to 4 MiB; other /api/f bodies stay at 2 MiB.
    • On Cloudflare this needs the service at a URL until 3c runs it in Containers on R2. A self-hosted deployment runs it beside celld today (search/README.md).

Tests

  • Core: checks and limits, chunking that keeps every word (paragraph, then sentence, word, character), chunk naming by input hash, and the service body. Migrate: step 4 migrates from step 3, a restart runs nothing, and a remake empties it. Deploy: the URL variable and token binding, and a bad URL refused. CLI: the new code's mapping.
  • e2e search section (shard 1; the real service image in Docker on a disk store), 9 checks, all passing locally:
    1. Valid write, found by words and by meaning, with location and status.
    2. A replay writes and embeds nothing; a grown source embeds only its new turn.
    3. A stale write gets 409 conflict naming current.
    4. Limits are refused (400 or 413) with nothing embedded.
    5. A viewer reads but doesn't write, a stranger is refused, and a removed member is refused.
    6. A pushed delete of a source's file forgets it: gone from search, from sources, and from the service.
    7. A restarted service still searches what was written.
    8. A deleted fragment's table is dropped from the service's store.
    9. A private deployment works by words alone, with nothing sent to embed.
  • CI: shard 1 builds the service's image with a BuildKit cache (scope 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 when search/ changes.
  • cargo xtask check passes.

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:

  1. wrangler r2 bucket create fragment-search-test
  2. Make an R2 API token, Object Read & Write, scoped to that bucket alone (dashboard: R2 → Manage API tokens). Its S3 access key id is the token's id; its secret is the token's secret.
  3. In search/:
    SEARCH_TEST_STORAGE=s3://fragment-search-test/run1 \
    SEARCH_TEST_STORAGE_AWS_ENDPOINT=https://<account>.r2.cloudflarestorage.com \
    SEARCH_TEST_STORAGE_AWS_REGION=auto \
    SEARCH_TEST_STORAGE_AWS_ACCESS_KEY_ID=<id> SEARCH_TEST_STORAGE_AWS_SECRET_ACCESS_KEY=<secret> \
    cargo test --release --test service on_an_object_store -- --ignored --nocapture
    The test writes, reopens, replaces, deletes, purges and drops its own table. A few hundred requests and kilobytes.
  4. Cost: within R2's free tier (1M Class A, 10M Class B, 10 GB-month), so $0.00.
  5. Teardown: 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

futurepaul and others added 4 commits October 30, 2026 18:55
`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>
@futurepaul
futurepaul requested a review from alexlwn123 October 31, 2026 01:00
@futurepaul
futurepaul added this pull request to stack #773 October 31, 2026 01:01

@alexlwn123 alexlwn123 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. A deferred forget deletes a subsequently successful write. cell/src/searchable.rs:160 treats search_drain as 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.

  2. An in-flight upsert can recreate an index after its life has been cleaned. search_upsert captures 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_life remakes the live tables, and clear_ended drops 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.

futurepaul and others added 2 commits October 30, 2026 20:15
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 alexlwn123 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. A pending forget can erase a successful replacement. cell/src/searchable.rs:160 calls search_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.

  2. A late upsert can recreate a cleaned, ended life's index. cell/src/searchable.rs:183–189 awaits embedding and then writes the previously captured scope and live metadata without a lifecycle fence. Delete/wipe can meanwhile remake the live tables and finish clear_ended (cell/src/ended.rs:350–369). A subsequent upsert with no replaces recreates 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.

@alexlwn123 alexlwn123 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. A deferred forget can erase a successful replacement. cell/src/searchable.rs:161 calls search_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.

  2. A late upsert can recreate an ended life's cleaned index. cell/src/searchable.rs:183–189 awaits embedding, then writes the captured scope and live metadata without a lifecycle fence. Delete/wipe can meanwhile remake the live tables and finish clear_ended (cell/src/ended.rs:350–369). The resumed upsert with no replaces recreates 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.

futurepaul and others added 3 commits October 30, 2026 21:32
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>
futurepaul and others added 7 commits October 30, 2026 21:38
…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>
@futurepaul

Copy link
Copy Markdown
Collaborator Author

Thanks, Alex. Both blockers are fixed in 096aadc, plus #777's b408388. The local e2e search section passes 13/13. I also reran it with the fixes mutated out: no forget-first, no life re-checks, and a plain drop instead of end. With the fixes gone, both forget checks fail and the lifecycle check stops, because the ended life's tombstone never appears.

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 (search_sending, the same pattern as settling in channels.rs).

  • An upsert holds its source from before its first send until its answer. First it sends that source's pending forget, if one exists, regardless of next_at: that covers the 5-minute retry and rows past a pass's 100. If that send fails, the write is refused 502. Only then does the upsert embed and write.
  • A drain try-holds each source. If a source is already held, the holder is a write (which sends that forget first) or another pass, so the drain skips it. Otherwise it re-reads the row under the hold, then sends. A forget already in flight therefore finishes before a write of its source starts, and none is sent after a later write.
  • search_delete holds its source too.
  • The non-barrier drain at the start of an upsert is gone.

e2e (search section):

  • Outage backoff. The service is stopped and the file deleted; a read drains the forget, which fails and is deferred five minutes. The service starts again, and the source is written during that wait. It is found at once and forgetting is 0. Before the fix, the row stayed and hid the new revision until the retry deleted it.
  • Backlog past one pass. 101 files are deleted in one push, and the 101st source is written again right after. It stays; the other 100 go.

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 (CreatedAt) and re-checks it at three points:

  • after holding its source;
  • after embedding, before anything is sent;
  • after the service answers, before setting MetaKey::Search.

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 POST /v1/{scope}/end (#777, b408388). It tombstones the scope before dropping its table, so a write landing after the end is 410, whatever the order and across restarts. A scope is one life of one fragment, so it is never needed again.

e2e:

  • Held at its embedding. The Workers AI fake gained a deterministic hold (hold_embeddings): the call is recorded and its answer waits. The write is held at its embedding; the fragment is deleted, its index ended, and the name made again; then the hold is released. The write gets 404, the old table is never made again, and the new life has no index.
  • Held at the service's door. A forwarding door between the node and the service holds an upsert the cell has already sent. While it is held: delete, the index ended, the name made again; then release. The service answers 410, so the write gets 404. The table is never made again, and the new life's search is empty.

A wipe ends a life through the same end() as a delete (ended.rs: end_life_wiped calls end(false), which records the same ended row that clear_ended ends). So I covered delete plus name recreation, and didn't add an operator wipe to this lane.

Also:

@futurepaul
futurepaul requested a review from alexlwn123 October 31, 2026 03:02

@alexlwn123 alexlwn123 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

futurepaul and others added 3 commits October 30, 2026 22:08
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 alexlwn123 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 alexlwn123 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Base automatically changed from search/service to master October 31, 2026 08:47
@futurepaul
futurepaul added this pull request to the merge queue Oct 31, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 31, 2026
@futurepaul
futurepaul added this pull request to the merge queue Oct 31, 2026
Merged via the queue into master with commit 035b5ea Oct 31, 2026
28 of 29 checks passed
futurepaul added a commit that referenced this pull request Oct 31, 2026
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>
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.

2 participants