refactor: make the filter-polarity trap inexpressible service.SpaceFilter meant "empty membership selects nothing" — a newly created project has no members — while search.Query.Spaces was a bare []core.SpaceRef whose empty case meant every space. Passing a project's members into a query therefore turned an empty project into the whole corpus: a silent scope inversion, invisible when it happens, and passing every test written with a non-empty project. The filter moves to core.SpaceFilter with unexported fields, and search.Query takes it whole. There is no slice to hand over any more, so the inversion cannot be written. Its three states are distinct: every space (EverythingFilter), exactly these — possibly none — (SpacesFilter), and the zero value, which is neither. Search refuses the zero value rather than defaulting it, because both plausible defaults are wrong for one of the two callers that can produce one, and returns no hits for a filter that selects no space without asking the index. service.SpaceFilter is now an alias for the core type, so ResolveProject and its callers keep their names. Tests that built a Query without a scope now say core.EverythingFilter(), which is what they always meant.
feat: service.Archive — one accessor, one tree walk, one link graph web/ and mcpsrv/ both needed a *doc.Archive and each invented its own way to get one. web/ handed sp.Repo to doc.Scan — reaching past service/ into gitx, which the layering rule forbids — and then listed the documents a second time for their bodies, two tree walks per page view. mcpsrv/ converted []service.Document back into []gitx.Document, rebuilding hashes service/ had already stringified. Two workarounds around one missing accessor is how three agent-facing surfaces stop being identical. Service.Archive resolves the revision, walks the tree once, and returns the archive with the bodies. ArchiveFrom is the same construction over a Document set a caller already holds — the one place a hex object name is converted back to a hash, malformed ids refused rather than zeroed. doc.Page.Links and Page.WordCount were documented as "filled in by a render pass" and nothing filled them, so Archive.Backlinks always returned empty; web/ worked around that by re-rendering every document of the space on each page view. doc.Archive.LinkPass now owns the pass and the accessor runs it, so the link graph exists wherever the archive does. web/ and mcpsrv/ no longer import gitx or go-git, in tests either. doc.DirOf replaces the three copies of the same fromDir helper.
feat: search — one global bleve index with per-line ru/en routing Absorbs warren's index/ and search/ packages, keyword half only, with the three structural changes the design calls for: - One global index, not one per project. Every document carries its space; a project is a term filter over that field (Query.Spaces), so a merge touches one index and the meta-project is a filter that excludes nothing. - Rebuilds, not incremental updates. RebuildSpace replaces one space at a revision, RebuildAll replaces the corpus by building beside the live index and swapping. Both report duration in Stats. - Keyword only. warren's vector store and RRF fusion are not ported, not even as dead code; Search returns ranked hits a later ranker can fuse. Resolves the design's open mixed Russian/English question. Per-document routing is not sufficient: the ru analyzer passes English through unstemmed and vice versa, so a Russian spec quoting English requirements loses singular/plural matching on whichever half is the minority. Text is routed per line into ru- and en-analyzed field pairs and queried across both. Not ported: chunking (an embedding concern, and the vector path is Phase 5), the graph/pages/meta JSON artifacts, and the vault-commit shell-out.