M3-14: Search coordinator and prefix search¶
Status: ready | Depends on: M1-57, M3-07, M3-13 | Milestone: M3
Goal¶
After this ticket GET /api/v1/search (search) and GET /api/v1/search/thread-titles (searchThreadTitles) answer people, agents and apps with hits from messages, thread titles and files that the caller may read, and from nothing else. The module search owns no tables: it takes the visible channels from Authorizer.Scope (cut down to the triggering person for an agent in a run, SR9), lets messaging, chat and files run the full-text query with channel_id = ANY($visible) through their own read operations, ranks the newest search.max_candidates candidates, highlights the delivered page as plain text segments split at random markers, pages with an opaque cursor bound to the request, and checks every delivered item with authorize again, so a revocation between building the channel set and running the query cannot leak. The quick switcher gets a prefix search over thread titles with the same filter. No total, no per-channel count, no candidates_capped flag and no cursor depends on a channel the caller cannot read (T-AZ-02, AZ-M06, AZ-P5), and no search term reaches logs, traces, metrics or the audit log (SEC-LOG-4, SEC-T10). This is the server side of "Full-text search with permission filter" and "Quick switcher" in 01 3.5 and of ADR 0032 ("PostgreSQL full text behind a port").
Read first¶
docs/spec/14-search.md: the whole chapter; in particular 1 "Goals and limits" (not searchable), 2 "What is searchable", 3.1 "Language per object" (search.languages), 4.1 "Input", 4.2 "Query construction" (search_tsquery), 4.3 "Permission filter" (items 1 to 5 and the residual risk), 4.4 "Filters", 4.5 "Ranking and sorting", 4.6 "Highlighting", 4.7 "Paging", 4.8 "Example: messages", 4.9 "Prefix search for the quick switcher", 7 "Performance targets and load test" (statement timeout,search_timeout), 9 "Limit and outlook", 10 "Module and port", 11 "Endpoints", 12 "Tests", 13 (R-SU-01 to R-SU-09), 15 "Configuration values of this chapter".docs/spec/07-permissions.md: 2.8 "Direct messages", 3 "Principal context" (acting_for,view_as), 4.2 "Flow" (filter mode, denials into the audit), 4.3 "Visibility", SR6, "SR9 Acting-for binding (confused deputy)", 7.2 "Permission state (snapshot)", 7.3 "Validity of the cache", 7.4 "Query pattern" (rules 1 to 6), 11.1 row AZ-M06, 11.2 rows AZ-P5, AZ-P11 and AZ-P12.docs/spec/05-actions.md: 1 "Binding rules" rules 1, 2 and 9; 2.4 (column "Audit"); 2.6 "Scopes for apps"; 2.7 "Effect (effect)"; 3 "Rate-limit classes" (rl.search); 4.6 rowthread.read; 4.7 rowmessage.read; 4.8 "Files and search" (rowsfile.read,search.query).docs/spec/03-data-model.md: 1 "Principles" items 7, 12 and 13; 2 "Modules" (rowsearch); tableschannels,threads(with the paragraph on moving),thread_tags,messages,files,message_attachments,file_text_extracts; 5 "Search vectors"; 8 row R-DM-43.docs/spec/02-architecture.md: 4.1 rule 7; 4.2; 4.3 (rowssearch,messaging,chat,files); 4.4 (rowsearch); 4.5 (kernel table,kernel/authorize,kernel/txn); 5 "Layers within a module" (rule 5, metrics); 7 (table ownership gate); 8.1 "Flow"; 14.1 "Error kinds"; 14.2 "Deadlines".docs/spec/06-api.md: 5 "Errors" (validation_failedwitherrors[],cursor_invalid,unavailablewithsearch_timeout), 6 "Pagination", 15 "Contract tests and gate" (G7, G8), 16 rowssearchandsearchThreadTitles.docs/spec/12-files.md: 2 "States of a file" (definition of deliverable), 3.7 "Purpose of a file", 10 "Attaching and agent invocation".docs/spec/01-product.md: 3.5 "Notification, search, navigation", D6 (quick switcher), D7 (search).docs/spec/19-security.md: SEC-LOG-4, SEC-T10.docs/spec/20-privacy.md: DP-T01.docs/api/openapi.yaml: operationssearchandsearchThreadTitles; schemasSearchHitPage,SearchHit,SearchHitKind,ThreadTitleHitList,Message,Thread,File,ThreadState,Cursor,PageLimit,FieldError,Problem; parametersCursor,Limit; responseBadRequest.- ADRs 0008, 0013, 0015, 0017, 0018, 0032, 0050, 0053, 0065.
docs/DECISIONS.md: D-W-19 (Scope cuts withActingFor, also for the candidate limit,candidates_cappedand the cursor), D-W-100.docs/security/threat-model.md: T-AZ-02, T-DS-01.docs/RISKS.md: RK-10 (time side channel of the search).docs/observability/signals.md: 2 "Cardinality", 4.1bothy.authz.errors(filter_mismatch), 4.4bothy.search.duration, 5.3;docs/observability/alerting.md:BothyAuthzError.docs/operations/configuration.md: everysearch.*key of chapter 14 exceptsearch.extract.*,search.p95_target,search.prefix_p95_targetandsearch.benchmark_messages;ratelimit.rl.search.per_minute,ratelimit.rl.search.burst;message.list_content_max_bytes;database.statement_timeout;http.unavailable_retry_after.docs/PLAN.md: 3 "Way of working per ticket"; 9 "M3 Platform services" (Goal, Scope, Out of scope, Exit criteria 1, 2 and 4, Test strategy "fuzzing of the search input", Red team "T-AZ-02 for search" and "timing side channel of the search", Risks) and rows M3-03, M3-04, M3-07, M3-13, M3-15, M3-16; M2 row M2-14; M1 rows M1-57, M1-58, M1-69, M1-70, M1-71; M4 row M4-23.- Briefs: M1-13 (unit of work,
SET LOCAL), M1-18 (cursor codecEncodeCursor/DecodeCursor, problem writer), M1-28 (Authorizer.Scope,Filter(ctx),WithRequestScope,actor.Principal.ActingForandViewAs), M1-53, M1-55, M1-56, M1-57 (list representation,filter_mismatchhandling), M1-62, M1-68 (instrument registry), M1-69 (canary scenario), M1-70 (leak tests, SQL lint), M1-71 (shared test harness); the briefs of M3-03, M3-04, M3-07 and M3-13. - Code (follow the merged names):
internal/kernel/authorize,internal/kernel/actor,internal/kernel/txn,internal/kernel/errs,internal/kernel/ids,internal/kernel/clock,internal/authz,internal/chat,internal/messaging,internal/files,internal/search(skeleton of M1-05),internal/platform/pgandpgtest,internal/platform/httpserver,internal/platform/otel/metrics,internal/wire(configkeys_*.go,configchapters.go,configchecks.go),api/operations.yaml,architecture/modules.yaml,test/harness,test/canary,test/leak, and at the repository roottools/lint/sql_visibility.yaml(M1-70). - Dependencies: the header names M3-13 (search vectors,
threads.search_language,search_tsquery), M3-07 and M1-57; M1-58, M1-69 and M1-70 come through M3-07 (D-W-106); M3-13 implies M1-56 and its chain (M1-55, M1-53, M1-52, M1-28, M1-18, M1-13 and the rest of its closure). It builds on M3-07 (and through it M3-03 and M3-04: tablesfilesandmessage_attachments, the attachment facts of 03 R-DM-43, the livefile.readentry, theScannerport), M1-57 (list representation of messages), M1-58 (deleting messages), M1-69 (canary scenario) and M1-70 (leak tests and SQL lint; through it M1-19 rate-limit classes, M1-65, M1-71 harness, and through M1-69 the M1-68 instrument registry). D-W-106 added them to the plan column, because this brief cannot be built without them; design note 0 checks that they are merged.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
- Hits, excerpts and flags come only from channels the principal may read. Every query carries
channel_id = ANY($visible)with the list thatAuthorizer.Scopebuilds per request; threads filter throughthreads.channel_id, files through the channel of the message they are attached to; the items of the delivered page run throughauthorizewithmessage.read,thread.readorfile.readagain, and a denied item drops silently (14 4.3 items 1 to 3; 07 7.4 rules 1 to 3; 05 rules 1 and 2). - No total and no count per channel; the answer says only whether more pages exist (
next_cursor) and whether the candidate limit was reached (candidates_capped) (14 4.3 item 4; 06 6 item 4; R-SU-05). - DM channels count only for participants, also for the owner (14 4.3 item 1; 07 2.8, SR6).
- An agent that searches in a run (
acting_for) searches on the intersection with the triggering person; candidate limit,candidates_capped, more pages and cursor come only from channels both may read; apps need the scopemessages:read(14 4.3 item 5; 07 SR9 and 7.4 rule 1; D-W-19; 05 2.6). - Input
qin web search syntax never produces a syntax error; more thansearch.query_max_charsgives400 validation_failedwitherrors[]onqand codetoo_long; no term left gives400 validation_failedwith codeempty; filters are parameters, the server never interpretsq(14 4.1). - The query is an OR over the configurations of
search.languagesplussimple, built with the platform functionsearch_tsquery(14 4.2, R-SU-03). - Filters of 14 4.4 (
space_id,channel_id,author_id,agent_id,after,before,has_file,thread_state,tag_id) andthread_idofopenapi.yaml; an unknown or invisible channel, space, thread or tag in a filter gives no hits and no error (14 4.4). - Ranking: the newest
search.max_candidateshits after filters and rights are the candidates,candidates_cappedreports the limit;relevance=ts_rank(vector, query, search.rank_normalization)divided by1 + age / search.recency_scale, ties to the higher id;newestbycreated_atthen id (14 4.5, R-SU-04). - Highlighting only for the delivered page, input cut to
search.headline_input_max_chars, options fromsearch.headline_max_fragments,search.headline_max_words,search.headline_min_wordsandShortWordas 14 4.6 fixes it;StartSelandStopSelare random hex strings of 128 bits per request; the result is split intosegments[{text, match}]; HTML in the content stays text (14 4.6, R-SU-06). - Pages: opaque cursor bound to
q, filters and sort; a cursor that does not fit the request gives400 cursor_invalid; page sizesearch.page_size_default, at mostsearch.page_size_max(14 4.7; 06 6). - Every search statement runs under
SET LOCAL statement_timeout=search.statement_timeout; a timeout gives503 unavailablewithreasonsearch_timeout(14 7; 06 5). - Prefix search for the quick switcher over thread titles: words shorter than
search.prefix_min_charsdrop, the remaining words become prefix terms joined with AND; nothing left gives an empty answer; same rights filter as 14 4.3; newest activity first (14 4.9;searchThreadTitles). searchowns no tables; it reads messages, thread titles and files only through read operations ofmessaging,chatandfiles(SearchMessages,SearchThreadTitles,SearchFiles); the coordinator owns the portSearchIndex; shared parts (query types, segmentation, cursor positions) live in a stateless library (14 10; 03 1 item 12; R-SU-09; 02 4.1 rule 7 and 4.4).- Not searchable: run steps and live box contents, channel prompts, deleted messages, files that are not deliverable or never attached, drafts (14 1 and 2).
search.query: resourceinstance, rightauth, rate-limit classrl.search, effectread(it keeps working in maintenance mode, ADR 0065), no audit entry for an allow; denials go to the audit as for every action (05 4.8, 2.4, 2.7).- Search terms never appear in logs, spans, metrics or audit details; the canary scenario carries a search term (SEC-LOG-4, SEC-T10).
Scope¶
All paths under services/server/ unless noted.
Create:
internal/kernel/searchkit/(new kernel core package; standard library andinternal/kernel/idsonly): the stateless library of 14 10, used by the ports ofsearch,messaging,chatandfiles. Filesdoc.go,searchkit.go,segments.go, tests. Contract (names may follow merged conventions; the semantics may not change):
type Sort string // SortRelevance = "relevance", SortNewest = "newest"
// Position is the keyset of the last row of a page (14 4.7).
type Position struct {
Score float64 // sort relevance only
CreatedAt time.Time // sort newest only
ID ids.ID
}
// Limits carries the configured values; the coordinator fills it from the search.* keys.
type Limits struct {
MaxCandidates int
RankNormalization int
RecencyScale time.Duration
HeadlineInputMaxChars int
HeadlineMaxFragments int
HeadlineMaxWords int
HeadlineMinWords int
PageSize int // already capped by the coordinator
}
type Markers struct{ Start, Stop string } // random hex, 128 bits each (14 4.6)
func NewMarkers(r io.Reader) (Markers, error)
type Segment struct{ Text string; Match bool }
func Split(headline string, m Markers) []Segment
// ThreadFilter holds the filters that need chat's tables (14 4.4: agent_id, thread_state, tag_id).
type ThreadFilter struct {
State string // "", "open" or "closed"
TagIDs []ids.ID // any of them
OperatorAgentID *ids.ID
}
type Query struct {
TSQuery string // tsquery text built by the search module (07 7.4 example)
ChannelIDs []ids.ID // visible channels after all channel filters; ascending; never empty
ThreadIDs []ids.ID // nil: no restriction; otherwise only these threads
Thread ThreadFilter // applied by chat for type threads; resolved into ThreadIDs otherwise
AuthorID *ids.ID
After *time.Time // created_at >= After
Before *time.Time // created_at < Before
HasFile *bool // messages only
Sort Sort
RefTime time.Time // reference time of the first page (design note 7)
From *Position // nil on the first page
Limits Limits
Markers Markers
}
type Hit struct {
ID, ThreadID, ChannelID ids.ID
MessageID ids.ID // files only: the message the file is attached to
Score float64
CreatedAt time.Time
Segments []Segment
}
type Page struct {
Hits []Hit
Next *Position // from the last row the statement returned, nil on the last page
CandidatesCapped bool
}
type PrefixQuery struct { TSQuery string; ChannelIDs []ids.ID; Limit int }
type TitleHit struct { ThreadID, ChannelID ids.ID; Title string; LastActivityAt time.Time }
// ErrTimeout reports that a read operation hit search.statement_timeout.
var ErrTimeout = errors.New("searchkit: statement timeout")
internal/search(port, extend the skeleton of M1-05): the inbound portSearchwithQuery(ctx, p actor.Principal, r Request) (Result, error)andThreadTitles(ctx, p actor.Principal, r PrefixRequest) ([]searchkit.TitleHit, error); DTOsRequest(type,q, sort, every filter of 14 4.4 plusthread_id, limit, decoded cursor position and reference time),Result(Hits []ResultHit,Next *searchkit.Position,RefTime,CandidatesCapped) andResultHit(kind, ids, optional score, segments,ThreadTitle,ChannelName,SpaceName; empty strings mean absent). The port imports onlykernel.internal/search/domain: validation and sanitising ofq(design note 2), the table of filters per type (14 4.4 column "Types"), limit capping, the canonical cursor binding (design note 7).internal/search/app:Coordinatorimplementingsearch.Search; outbound portsSearchIndex(14 10) andMetrics(02 5 rule 5) with a no-op default;Configfilled from the keys of design note 12.
// SearchIndex is the coordinator's index port (14 10); the PostgreSQL adapter delegates to the
// read operations of messaging, chat and files; an external engine would replace only it.
type SearchIndex interface {
BuildQuery(ctx context.Context, q string) (tsq string, ok bool, err error) // ok=false: no term
BuildPrefix(ctx context.Context, q string) (tsq string, ok bool, err error) // ok=false: empty answer
Messages(ctx context.Context, q searchkit.Query) (searchkit.Page, error)
Threads(ctx context.Context, q searchkit.Query) (searchkit.Page, error)
Files(ctx context.Context, q searchkit.Query) (searchkit.Page, error)
TitlePrefix(ctx context.Context, q searchkit.PrefixQuery) ([]searchkit.TitleHit, error)
}
internal/search/adapters/postgres: implementsSearchIndex.BuildQueryrunssearch_tsquery(<search.languages>, q)::textandBuildPrefixthe statement of 14 4.9 withsearch.prefix_min_chars, both in the ambient unit throughplatform/pg(no table is read);Messages,Threads,FilesandTitlePrefixcall the read operations of the module ports and resolveQuery.ThreadintoQuery.ThreadIDsthroughchatfor messages and files (design note 5).internal/search/adapters/httpapi: the strict-server methods forsearchandsearchThreadTitles; parameter mapping, cursor encoding with the M1-18 codec, rendering of hit objects through the module ports (design note 9), problem mapping.internal/search/adapters/otelmetrics:bothy.search.duration(design note 13).
Change:
internal/messagingport: read interface for the coordinator withSearchMessages(ctx, searchkit.Query) (searchkit.Page, error)andHitMessages(ctx, messageIDs, channelIDs []ids.ID) ([]Message, error)(the list representation of M1-56 and M1-57, content cut atmessage.list_content_max_byteswithcontent_truncated, in input order, missing ids absent); implementation inapp, SQL ininternal/messaging/adapters/postgres/queries/search.sql.internal/chatport: read interface withSearchThreadTitles(ctx, searchkit.Query) (searchkit.Page, error),PrefixThreadTitles(ctx, searchkit.PrefixQuery) ([]searchkit.TitleHit, error),ThreadsMatching(ctx, channelIDs []ids.ID, f searchkit.ThreadFilter) ([]ids.ID, error),ChannelsInSpaces(ctx, spaceIDs []ids.ID) ([]ids.ID, error),Labels(ctx, visibleSpaceIDs, channelIDs, threadIDs []ids.ID) (Labels, error)(channel name, space name, thread title per id) andHitThreads(ctx, threadIDs, channelIDs []ids.ID) ([]Thread, error)(theThreadrepresentation of M1-55); SQL ininternal/chat/adapters/postgres/queries/search.sql.internal/filesport: read interface withSearchFiles(ctx, searchkit.Query) (searchkit.Page, error)andHitFiles(ctx, fileIDs, channelIDs []ids.ID) ([]File, error)(theFilerepresentation of M3-03); SQL ininternal/files/adapters/postgres/queries/search.sql.internal/kernel/txn: optionStatementTimeout(d time.Duration) Option(outermost unit only, likeRightsChange);internal/platform/pg: apply it withSET LOCAL statement_timeoutinstead ofdatabase.statement_timeout, andIsStatementTimeout(ctx context.Context, err error) bool(SQLSTATE57014whilectx.Err() == nil).internal/wire:configkeys_search.go(keys of design note 12) with the constantchapter14inconfigchapters.go; cross-checks inconfigchecks.go; build the coordinator, its index and metrics adapters; embed the search handler in the strict-server struct.internal/platform/otel/metrics(M1-68 registry): the instrumentbothy.search.durationwith its closedbothy.outcomeset.api/operations.yaml: enablesearchandsearchThreadTitles; rerun the route generator.architecture/modules.yaml: addsearchkittokernel.core; regenerate.go-arch-lint.ymlwith the archgen generator of M1-05.test/canary/scenario.go: both operations with canary search terms (design note 14).test/leak(world.go,counters_test.go): search surfaces in AZ-P5 (Tests);test/harness: additive helpers only if a test needs one.docs/api/openapi.yaml: descriptions ofsearchandsearchThreadTitlesname the page size keys (search.page_size_default,search.page_size_max, 14 4.7); nothing else.docs/spec/14-search.md: 4.4 rowthread_idand the rule for filters outside their types; 4.3 the page check of design note 8; 4.7 the reference time of design note 7; 10 the composition of thread filters (design note 5) and the read operations that render hit objects.docs/spec/02-architecture.md: 4.3 rowsmessaging,chat,files(read operations forsearch); 4.5 rowkernel/searchkitand the optionStatementTimeoutin the rowkernel/txn; 5 the list ofkernel-corepackages; 14.2 a row for search statements withsearch.statement_timeout. The archgen test of chapter 02 must stay green.docs/spec/07-permissions.md: AZ-M06 "Expected" reads "0 hits, no counters" (the search has no total, 14 4.3 item 4).docs/observability/signals.md: thebothy.outcomevalues ofbothy.search.duration(design note 13).- READMEs of
search,messaging,chat,filesand the package doc ofkernel/searchkit;services/server/CHANGELOG.md;docs/DECISIONS.mdsection 10 rowsD-M3-14-<n>(design note 16).
This ticket adds no migration and no new top-level Makefile target.
Out of scope¶
- Search vectors and their generated columns,
messages.search_language,threads.search_language,threads.title_search, the GIN indexes, the platform functionsearch_tsquery, the migration lint rule againstGENERATED ALWAYS ASwithoutSTORED, the language tests of 14 12 and the start check forsearch.locale_map(M3-13). - File text extraction,
file_text_extractsand its weights C and D inSearchFiles(M3-15, which extendsSearchFiles). - The search load test (
perf/search, make targetbench-search),search.p95_target,search.prefix_p95_target,search.benchmark_messagesandmake perffor search (M3-16). - Upload, scan, attachment,
message_attachmentsand the attachment facts that design note 15 needs (M3-03, M3-04, M3-07); deleting and purging files (M3-08). - Reading the invocation mark from the request header and filling
acting_for(M2-14); this ticket consumesactor.Principal.ActingForas M1-28 defines it. - Direct messages and
dm_participants(V3-01). - Client search, offline search with SQLite FTS5 and the purge of the local index after
access.changed(M4-12, M4-23). - An external search engine,
pg_trgmor any other extension (14 9, ADR 0032). - Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M3-14-search-coordinator; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(search): ...,feat(messaging): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools and Go modules.
- Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 or M2 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - The same rule applies to the merged code of M3-03, M3-04, M3-07 and M3-13, whose briefs were written together with this one.
- No migration: the search columns are M3-13, the attachment facts M3-07. If you find that a migration is needed, stop with
Verdict: BLOCKED <reason>. - No SQL of one module names a table of another module (03 1 item 12, 02 7); compose across modules through ports only.
- No search term, excerpt or content in logs, span attributes, metric attributes, audit details or problem bodies (SEC-LOG-4); IDs only in spans and logs, never in metrics.
- No real third-party service in tests; files reach their scan state through the scanner the M3-04 tests use (throwaway clamd container or its fake
Scanner).
Design notes¶
-
Start check.
make checkgreen on the fresh worktree, elseVerdict: BLOCKED base branch red. Merged and present, elseVerdict: BLOCKED dependency <id> missing: M3-13 (columnsmessages.search_vector,threads.title_search,threads.search_language, functionsearch_tsquery), M3-07 (tablesfilesandmessage_attachmentswith the copiesmessage_attachments.thread_idandmessage_attachments.channel_id, thechat.ThreadMovedsubscriber offilesthat movesmessage_attachments.channel_id, the columnmessages.has_attachmentswith its check,file.readno longer deferred ininternal/authz), M1-57, M1-58, M1-69 (test/canary), M1-70 (test/leak,tools/lint/sql_visibility.py). The core contract defines the attachment facts of design note 15 (03message_attachmentsandmessages, R-DM-43, D-W-104); a copy, column or subscriber of them missing from the merged M3-07 code meansVerdict: BLOCKED dependency M3-07 missing. -
Operations.
operationId Route Action Resource given to authorizeClass searchGET /api/v1/searchsearch.queryinstance rl.searchsearchThreadTitlesGET /api/v1/search/thread-titlessearch.queryinstance rl.searchThe plan row names only the operation
search;searchThreadTitlesis the prefix search of the same row (14 11) and is enabled here. -
Order of checks for
search(each step only if the previous passed):- Parameter rules without any lookup:
qlonger thansearch.query_max_chars(counted in runes of the raw value, an invalid byte counts as one) gives400 validation_failedwith pointer#query/qand codetoo_long. A filter outside its types (14 4.4 column "Types";thread_idapplies to all types) gives400 validation_failedwith pointer#query/<name>and codenot_applicable.limitabovesearch.page_size_maxis capped, absent meanssearch.page_size_default(06 6 item 3). Before building the query, invalid UTF-8 sequences and control characters inqbecome a space: they cannot be part of a term, and PostgreSQL rejects a NUL byte. - Decode the cursor with the M1-18 codec against the canonical binding of design note 7; any mismatch gives
400 cursor_invalid. authorize(p, search.query, instance): unauthenticated, pending and missing app scope (messages:read) end here with the M1-28 mapping.- Open one read unit (design note 11).
SearchIndex.BuildQuery; no term gives400 validation_failedwith pointer#query/qand codeempty. Authorizer.Scope(ctx, p, read)withread=message.readfor typemessages,thread.readforthreads,file.readforfiles. Scope already cuts withViewAsandActingFor(07 7.4 rule 1, SR9, D-W-19); the coordinator adds no rights logic (05 rule 1).- Channel set:
Scope.ChannelIDs, intersected withchannel_idwhen given and withchat.ChannelsInSpaces(space_id ∩ Scope.SpaceIDs)whenspace_idis given. An empty set runs no query and answers an empty page withcandidates_cappedfalse; a filter on an unknown or invisible channel or space therefore answers exactly as an empty filter result (14 4.4). SearchIndex.Messages,ThreadsorFileswith thesearchkit.Queryof the request.- Page check (design note 8), labels (
chat.Labels), close the unit, record the metric, return.
- Parameter rules without any lookup:
-
Rights. Never join
grantsor compute visibility outsideauthz; the only inputs areScopeandauthorize(03 1 item 12, B7). Owners see DM channels only as participants (Scope does this). For an agent in a run,Scopereturns only channels the triggering person may read, so the candidate set,candidates_capped,next_cursorand the reference time are the same as for that person (D-W-19). Apps get an emptyfile.readscope withoutfiles:read, so typefilesreturns nothing for them; that is the expected result of 05 2.6, not an error. -
Query building. The tsquery is built once per request by the search module, inside the unit:
SELECT search_tsquery($1::regconfig[], $2)::textwithsearch.languages(14 4.2), and for the prefix search the statement of 14 4.9 withsearch.prefix_min_chars. NULL means no term. Module queries take the text as@tsq::tsquery(07 7.4 example: "search expression, built by the search module"). If the merged M3-13 code did not registersearch.languages, register it here together with the start check of 14 3.1 and its test, and name that in the report. -
Composition across modules (07 7.4 rule 6, 14 10). Filters on thread facts (
thread_state,tag_id,agent_id) for typesmessagesandfilesare resolved by the PostgreSQL index adapter throughchat.ThreadsMatching(channelIDs, filter)in the same unit intoQuery.ThreadIDs, intersected withthread_idwhen given; an empty result runs no further query. For typethreadschat appliesQuery.Threaditself. Severaltag_idvalues match a thread that has at least one of them.agent_idmatchesthreads.operator_agent_id(main threads never match).author_idmeansmessages.author_principal_idorfiles.uploaded_by;afterandbeforefiltercreated_atof the hit's own row (messages, threads, files) in the half-open interval. -
Read operations (one statement per call, the shape of 14 4.8):
- Candidates: rows matching
@tsq,channel_id = ANY(@channel_ids::uuid[])(the AZ-P12 lint needs exactly this form), the filters,created_at <= @ref_time, orderedcreated_at DESC, id DESC,LIMITsearch.max_candidatesplus one;candidates_cappedis true when the extra row exists; the extra row is never ranked or returned. The limit applies to both sorts (R-SU-04). messages:deleted_at IS NULL; vectormessages.search_vector; headline overleft(content_md, search.headline_input_max_chars)withmessages.search_language.threads: vectorthreads.title_search; headline over the title withthreads.search_language; main threads never match (empty title).files: deliverable files only (upload_state = 'complete',scan_status IN ('clean','skipped'),deleted_at IS NULL,purpose = 'attachment', 12 2) that are attached to a message (design note 15); vectorfiles.search_vector; headline over the name followed by the transcript, cut tosearch.headline_input_max_chars, withsimple.- Score for
relevance:ts_rank(vector, tsq, search.rank_normalization) / (1 + age / search.recency_scale)with age measured from@ref_time, notnow(); orderscore DESC, id DESC;newest:created_at DESC, id DESC. Keyset afterQuery.From(strictly after the position). Fetch the page size plus one to know whether a next page exists;Page.Nextis the position of the last row of the page. - Headline only for the returned rows (14 4.6), options built from the configured values and the markers; split with
searchkit.Split. - A statement timeout maps to
searchkit.ErrTimeoutthroughpg.IsStatementTimeout. - Every other new read carries the visibility predicate too, so the SQL lint of M1-70 needs no new exception:
chat.ThreadsMatchingandPrefixThreadTitleswitht.channel_id = ANY(...),chat.ChannelsInSpaceswithc.space_id = ANY(...)(spaces already cut toScope.SpaceIDs),chat.Labelswithc.space_id = ANY(@visible_space_ids)for channels andt.channel_id = ANY(...)for threads, and everyHit*operation withchannel_id = ANY(...)over the channels of the kept hits.
- Candidates: rows matching
-
Cursor. The position (
ScoreorCreatedAt, andID) and the reference time of the first page (the request time fromkernel/clock) travel in the cursor; later pages use the same reference time for the score and as the upper bound of the candidates, so a new message between two pages neither shifts the scores nor pushes a candidate out.created_atand the reference time must come from the same server clock (03 1 item 2); if the merged modules writecreated_atwith the database default instead ofkernel/clock, take the reference time from the database inside the read unit and name the difference in the report. Onlysearchhas a cursor;searchThreadTitleshas none. Encode with the M1-18 codec (list namesearch) and a canonical binding frominternal/search/domain: type, rawq, sort and every filter with defaults applied, id lists sorted and without duplicates, times in UTC with millisecond precision;limitis not bound. A cursor of another request, of another list, of another principal or a tampered one gives400 cursor_invalid(06 6 item 2, 14 4.7). The M1-18 codec binds the caller's principal, so the cursor strings of Alex and of an agent in Alex's run differ while their decoded positions and reference times are equal; "same cursor" in 14 12 and in the plan row means that. -
Page check (14 4.3 item 3; 07 7.4 rule 3). After the read operation, in a new request scope
fctx := authorize.Filter(authorize.WithRequestScope(ctx)), so the rights version is read again (M1-28 "Permission version"): callScope(fctx, p, read)once, then for each hit:- its channel is not in the fresh scope: the rights changed between the first scope and the query; drop the hit silently (no metric);
- otherwise
Authorize(fctx, p, read, resource); a denial is a mismatch between SQL filter andauthorize: drop it, log at error level with only the action and the resource kind (no ids, no content), countbothy.authz.errorswithbothy.reason = filter_mismatchthe way M1-57 does. Resources carry only ids that never change (message:ID,ThreadID; thread:ID; file:IDand what M3-03 defines), never the channel copy, so a concurrent thread move cannot contradict the path thatauthzresolves (M1-28 "Resolution"). Dropping never movesPage.Next, so the next page continues after the last row the statement returned. Filter mode writes no denial into the audit (07 4.2).
-
Rendering in
adapters/httpapi.SearchHitPage.itemsin page order;kindmessage,threadorfile;scoreonly forrelevance;segmentsalways;thread_titlewhen the thread is not a main thread;channel_nameandspace_namefromchat.Labels(absent for DM channels, which have no space); the object from the owning module:messagefrommessaging.HitMessages,threadfromchat.HitThreads,filefromfiles.HitFiles, each called with the ids and channels of the kept hits; a hit whose object vanished in between is dropped.next_cursorabsent on the last page;candidates_cappedalways present. No JSONnull(03 1 item 13, ADR 0053). The mapping from the module DTO to the API type must render exactly what the owning module renders (test below). -
searchThreadTitles.qlonger thansearch.query_max_charsgives400 validation_failed(#query/q,too_long);authorize(search.query, instance); in one read unitBuildPrefix(no term:200with emptyitems),Scope(thread.read),chat.PrefixThreadTitleswitht.channel_id = ANY(@channel_ids::uuid[]),t.title_search @@ @tsq::tsquery, orderlast_activity_at DESC, id DESC, limitsearch.page_size_defaultup tosearch.page_size_max; page check as in note 8; items{thread_id, channel_id, title, last_activity_at}. Example from 14 4.9: "rechnungs okt" finds "Rechnungslauf Oktober". -
Unit of work and timeout. Each search request runs its database work in one
txn.UnitOfWork.Do(ctx, fn, txn.StatementTimeout(search.statement_timeout))(READ COMMITTED as every unit, so the version read of note 8 sees a revocation committed meanwhile). Nothing is written, so no counter is locked (M1-16).searchkit.ErrTimeoutbecomes503 unavailablewithreasonsearch_timeoutandRetry-Afteras the M1-18 problem writer sets it for kindunavailable(02 14.1). No retry of a timed out search. -
Configuration (register in
internal/wire/configkeys_search.gowith chapter 14 and the kinds the M1-07 schema offers):search.query_max_chars,search.max_candidates,search.rank_normalization,search.recency_scale,search.page_size_default,search.page_size_max,search.headline_input_max_chars,search.headline_max_fragments,search.headline_max_words,search.headline_min_words,search.prefix_min_chars,search.statement_timeout. Start checks inconfigchecks.go:search.page_size_defaultnot abovesearch.page_size_max, and the headline values thatts_headlinewould reject (for examplesearch.headline_min_wordsnot belowsearch.headline_max_words); an integration test proves each rejected combination with a realts_headlinecall.docs/operations/configuration.mdalready lists the keys; the M1-08 check keeps schema and reference equal. -
Telemetry.
bothy.search.duration(signals.md 4.4) forsearchonly, measured in the coordinator from the start of the use case to its end, withbothy.outcomefrom the closed setok,client_error(any4xx),timeout(search_timeout),server_error; recorded throughinternal/search/app/metrics.goandadapters/otelmetrics(02 5 rule 5). The prefix search is measured byhttp.server.request.durationwith its route. No span attribute or log field carriesq, excerpts or names (SEC-LOG-4). -
Canary (SEC-T10). Extend
test/canary/scenario.go:searchwith each type and withsearchThreadTitles, each with a canary value of classcontentinq; messages, thread titles and file names of the world carry the same canary so the search finds them;TestSEC_T10_ScenarioCoversEveryEnabledOperationlists both operations; the scan finds the canary in no log, span, metric, audit row or problem body. -
Attachment facts (03
message_attachmentsandmessages, R-DM-43, D-W-104).filesmay not readmessagesandmessagingmay not readmessage_attachments(03 1 item 12), so the core contract gives each module the fact in its own table:message_attachments.thread_idandmessage_attachments.channel_idcopy the thread and channel of the message, written byfilesat attach, andfilesmoveschannel_idin the move transaction onchat.ThreadMovedlikemessages,read_statesanddrafts;messages.has_attachmentsis true exactly when the message was created with at least one attachment and is not deleted (set bymessagingin the creating transaction,falseafter deletion, checkdeleted_at IS NULL OR NOT has_attachments). BuildSearchFilesoverfilesjoined withmessage_attachmentsonfile_id, withchannel_id = ANY(@channel_ids::uuid[])on the attachment side (the AZ-P12 lint form) andQuery.ThreadIDsagainstmessage_attachments.thread_id, never withfiles.scope_channel_id(it holds only before attaching, 05 2.2); buildhas_fileinSearchMessagesonmessages.has_attachments; use the names of the merged M3-07 code.has_file=truekeeps messages with at least one attachment,has_file=falsemessages without. Never fall back to filtering after the candidate limit (14 4.5 item 1: limit after filter and rights). -
Rulings for
docs/DECISIONS.md(one row each,D-M3-14-<n>, with reason):- Shared library
internal/kernel/searchkitinkernel.core(14 10 "internal library without state"). - The search module builds the tsquery once per request and passes its text to the read operations.
- Thread facts as filters are resolved through
chat.ThreadsMatchinginto a thread id list in the same unit (07 7.4 rule 6). - Page check with a fresh request scope and a fresh
Scope: channel missing from it drops silently, otherwise a denial countsfilter_mismatch; the next cursor follows the last returned row. - Cursor carries the reference time of the first page; scores and the candidate bound use it.
- "Same cursor" for SR9 means the same decoded position and reference time; the strings differ because the M1-18 codec binds the principal.
- The candidate limit applies to both sorts.
- A filter outside its types gives
400 validation_failedwith codenot_applicable; severaltag_idmatch any of them;has_file=falsemeans without attachments. - Invalid UTF-8 and control characters in
qbecome spaces; length counted in runes of the raw value. searchThreadTitlesusessearch.page_size_defaultandsearch.page_size_maxandsearch.query_max_chars.- Hit objects: list representation of messages,
Thread,File;thread_titleabsent for main threads; names absent for DM channels. txn.StatementTimeoutoption forsearch.statement_timeout.bothy.outcomevalues ofbothy.search.duration; measured forsearchonly.- Start checks of the search keys.
- Shared library
Tests (write them first)¶
Unit:
internal/kernel/searchkit:TestSplit(table: no marker gives one segment withmatchfalse; adjacent and repeated matches; content with<b>,<and a 32-character hex string that is not the marker stays text unchanged; concatenated texts equal the headline without markers);TestNewMarkers(two hex strings of 128 bits, different from each other and between calls, error from the reader propagates);FuzzSplit(seed corpus ingo test: never panics, texts concatenate to the input without markers).internal/search/domain:TestValidateQ(one rune oversearch.query_max_charsgivestoo_long, multi-byte runes count once, invalid bytes and control characters become spaces);TestFilterApplicability(every parameter of 14 4.4 times every type,not_applicableexactly where 14 4.4 says so,thread_ideverywhere);TestBinding(permuted and duplicated id lists give the same binding; any change of type,q, sort or a filter changes it;limitdoes not);TestLimitCap.internal/search/appwith fakeSearchIndex, fakeAuthorizerand fake chat port:TestCoordinator_EmptyChannelSetRunsNoQuery;TestCoordinator_SpaceAndChannelFilterIntersect;TestCoordinator_PageCheckRaceDropsSilently(fresh scope without the hit's channel: item gone, no metric, no log);TestCoordinator_PageCheckMismatchCounts(fresh scope with the channel,Authorizedenies: item gone,filter_mismatchplus one, log without ids);TestCoordinator_NextFollowsLastRow;TestCoordinator_TimeoutIs503SearchTimeout;TestCoordinator_MetricOutcomes;TestCoordinator_EmptyQueryIs400Empty.
Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain and the M1-71 harness with BuildMatrixWorld; where a test compares two answers, it freezes the harness clock after its test data exists; G8 validates every response against openapi.yaml):
TestAZ_M06_SearchShowsNothingFromMarketing(AZ-M06): three messages with "campaign" in Marketing/Kampagnen by Sam; Alex searches "campaign":200, zero items, nonext_cursor,candidates_cappedfalse, no member that counts anything; Sam gets the three.TestSearch_TwoPrincipalsOnlyOwnChannels: the same term in Support/Allgemein and Marketing/Kampagnen; Alex gets only Support hits, Sam only Marketing hits; neither response names the other channel, its name or its space anywhere; no counts.TestSearch_OwnerNoForeignDMHits: DMs arrive with V3-01, so a test fixture inserts one channel of kinddmwith its main thread and a message containing the term through the test database pool (comment why); the owner, who is no participant, gets no hit from it with any type and filter, also withchannel_idset to it, and the response equals the one for a random channel id.TestSearch_RevokeThenSearch: after the owner revokes G-1, Alex gets no hit from Support.TestSearch_RevokeBetweenScopeAndQuery: a test decorator around the realAuthorizerrevokes G-1 (committed in its own unit) after the firstScopereturned; the read operation still returns Support rows; the response contains none of them;bothy.authz.errorswithfilter_mismatchis unchanged.TestSearch_SR9_AgentInRunEqualsAlex(SR9, AZ-P11 for search): the owner grants Lotsereadon Engineering; Engineering/Projekt-A holds more matching messages thansearch.max_candidates(set low through the configuration), Support/Allgemein a few. Through thesearch.Searchport: Alex; Lotse withActingFor{UserID: Alex, RunID, ThreadID: a Support thread, JTI}; Lotse withoutActingFor. Alex and Lotse in Alex's run get identical items and scores,candidates_cappedfalse,next_cursorpresent or absent alike and, decoded, the same position and reference time; Lotse without the run getscandidates_cappedtrue (proves the setup would leak without the cut).TestSearch_ViewAsAlexEqualsAlex: the owner withViewAsAlex gets Alex's answer.TestSearch_CursorWithOtherRequestIs400: anext_cursorof a first page reused with anotherq, another filter, another sort, another type, by another principal, or with one byte changed: each400 cursor_invalid; with anotherlimitit works.TestSearch_PagesAreStable: three pages withrelevanceand withnewest: no item twice, none missing, order as 14 4.5; a new matching message created between page one and two does not appear on page two and moves nothing.TestSearch_FilterInvisibleChannelEqualsUnknown(14 12):channel_idandspace_idof an invisible and of a random id give the same status, headers and body after normalisingCorrelation-IdandDate.TestSearch_Filters: each filter of 14 4.4 andthread_idper type with a world where exactly the expected hits match; a filter outside its type gives400 validation_failedwithnot_applicable;tag_idwith two tags matches either.TestSearch_NotSearchable(14 12, DP-T01 search part): a deleted message (M1-58), a filepending,infected,error,failed, a file never attached and a file of a deleted message give no hit in any type; a draft containing the term gives none either.TestSearch_SegmentsNoHTML(14 12): content with<b>,</b>and a hex string looking like a marker gives segments whose texts appear in the content in order, no added markup, matches only on the term.TestSearch_RankingAndCap: equal texts of different age rank newer first; equal score ranks the higher id first; with more matches thansearch.max_candidatesonly the newest are reachable andcandidates_cappedis true.TestSearch_TypesThreadsAndFiles: typethreadsfinds a forum post by its title; typefilesfinds an attached clean file by name and by transcript; hits carrythread_title,channel_name,space_name.TestSearch_HitObjectsEqualModuleRepresentation:messageof a hit equals the list representation of that message fromlistThreadMessages,threadequalsgetThread,fileequalsgetFile.TestSearchFiles_FollowsThreadMove: aftermoveThread, the attached file of a message in the moved thread is found by readers of the target and not by readers of the source only.TestSearchThreadTitles: "rechnungs okt" finds "Rechnungslauf Oktober"; words belowsearch.prefix_min_charsdrop; only short words give200with no items; order bylast_activity_at; invisible channels never appear; too longqgivestoo_long.TestSearch_LimitCapped:limitabovesearch.page_size_maxreturns at most that many.TestUnitOfWork_StatementTimeoutOption(internal/platform/pg): inside a unit with the optionSHOW statement_timeoutshows the given value;pg_sleepbeyond it makesIsStatementTimeouttrue; a cancelled context makes it false.TestSearchReads_MapStatementTimeout(one per module read operation): a query layer that returns a*pgconn.PgErrorwith SQLSTATE57014while the context is alive givessearchkit.ErrTimeout; a cancelled context gives the context error.TestSearchHTTP_TimeoutIs503(internal/search/adapters/httpapiwith the real coordinator and aSearchIndexthat returnssearchkit.ErrTimeout):503 unavailable,reasonsearch_timeout, withRetry-After, andbothy.search.durationrecordstimeout.TestSearchConfig_StartChecks: each inconsistent combination of note 12 stops the start with the key names.TestSearch_LintClean(AZ-P12): the SQL visibility lint of M1-70 passes on the tree with the new queries and no new exception.
Fuzz:
FuzzSearchQuery(internal/search/app, real database, seed corpus ingo test: punctuation only, quotes,-,or,:*,&|!(), backslashes, NUL, invalid UTF-8, very long input): the result is a page or400 validation_failedwith pointer#query/qand codeemptyortoo_long, never anything else (14 12).TestSearch_HTTPNeverServerError: the same seeds URL-encoded through HTTP forsearchandsearchThreadTitles: status200or400, never5xx.
Property (extend M1-70):
TestAZ_P5_ListsAndCountersMatchAuthorizeandTestAZ_P5_NoiseInvariance(AZ-P5 for search): the world adds terms to messages, thread titles and attached files in visible and invisible channels; surfacessearch(each type) andsearchThreadTitles; property 1: every hit's thread and channel is visible by the truth; property 2: noise with the same terms only in channels P cannot read changes no item, nocandidates_cappedand not the presence ofnext_cursor(search.max_candidatesset low so that the noise would flip the flag without the rights filter).
Canary:
TestSEC_T10_*of M1-69 with the extended scenario (design note 14).
Contract: the Schemathesis run of M1-71 (G7, part of make check) covers both operations once they are enabled.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/search/... ./internal/kernel/... ./internal/messaging/... ./internal/chat/... ./internal/files/... ./internal/platform/pg/... ./internal/wire/... ./test/...make codegen-check,make arch,make api-lint,make api-checks,make register-check,make config-doc-checkmake check
Acceptance criteria¶
-
searchandsearchThreadTitlesare enabled and registered; AZ-P1 passes. - AZ-M06: Alex gets no hit for "campaign" from Marketing and no count of any kind.
- Two principals with the same query get hits only from their own channels.
- The owner gets no hit from DMs he does not take part in.
- A revocation between building the channel set and the query is filtered by the page check.
- An agent in Alex's run gets the same answer as Alex for
search.query, with the samecandidates_cappedand the same cursor position (14 4.3, SR9). - A cursor reused with another request gives
400 cursor_invalid. - Fuzzing never yields
500;qerrors are onlyvalidation_failedwithemptyortoo_long. - The canary scenario contains a search term, and the canary appears in no output.
- Filters, ranking, highlighting as segments without HTML, pages, the candidate limit and the statement timeout behave as 14 4.4 to 4.7 and 7; deleted, not deliverable and never attached content gives no hit.
- The prefix search for the quick switcher behaves as 14 4.9 with the same rights filter.
-
searchowns no tables; all reads go through the read operations ofmessaging,chatandfiles; the SQL lint passes without a new exception. - AZ-P5 covers the search surfaces and passes.
-
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
search,messaging,chatandfilesupdated (ports, read operations for the coordinator, the composition of note 5, the page check of note 8, the residual time side channel RK-10); package doc ofkernel/searchkit. - Rulings of note 16 recorded in
docs/DECISIONS.md(one row each with reason). services/server/CHANGELOG.mdentry under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN), which also checks T-AZ-02 for the search and the time side channel of the search (PLAN 9 "Red team"); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven.
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.