M3-13: Search vectors and search_tsquery¶
Status: ready | Depends on: M1-56 | Milestone: M3
Goal¶
After this ticket every message and every thread carries a full-text vector that PostgreSQL keeps current without trigger or job: messages.search_vector and threads.title_search are STORED generated columns with a GIN index, each holding the word stems of the object's language and the unchanged words of simple with the weights of 14 "What is searchable" (message stems B, message words D, thread title both A). messages.search_language and threads.search_language are set once, when the row is created, from the author's or creator's locale through search.locale_map (14 "Language per object"), and the platform function search_tsquery(langs regconfig[], q text) builds the OR query over every language of search.languages plus simple exactly as 14 "Query construction" writes it. The language tests of 14 "Tests" pass on PostgreSQL 18, the server refuses to start with a language configuration that could never find a stem, and make migrations-lint stays green. Why: ADR 0032 keeps search inside PostgreSQL on one GIN predicate per object, a deleted message must lose its vector at once (DP-T01), and PostgreSQL 18 would create a virtual column without the keyword STORED; the search coordinator (M3-14), file text search (M3-15) and the load test (M3-16) all build on these columns and this function.
Read first¶
docs/spec/14-search.md: the header (examples ran on PostgreSQL 16; "A run against 18 is still pending as a build test": this ticket is that run), 1 "Goals and limits" (deleted messages: empty content, empty vector), 2 "What is searchable" (weights), 3.1 "Language per object", 3.2 "Expressions", 3.3 "Limits of language processing", 4.1 "Input" (a NULL query means codeempty), 4.2 "Query construction", 4.8 "Example: messages" (how M3-14 will call the function), 8 "Index maintenance", 10 "Module and port", 12 "Tests", 13 "Decisions of this chapter" (R-SU-01 to R-SU-03), 14 "Alignment with the core contract" rows 1 and 2, 15 "Configuration values of this chapter" (search.languages,search.locale_map).docs/spec/03-data-model.md: 1 "Principles" item 12 (only the owning module writes a table; others read through its port), tablesthreads(search_language,title_search, GIN(title_search)),messages(search_language,search_vector, the checkdeleted_at IS NULL OR content_md = '', GIN(search_vector)),users(locale, BCP 47),instance_settings(locale.default), 5 "Search vectors" (the expressions;to_tsvector(regconfig, text)isIMMUTABLE; "every migration writesSTOREDexplicitly").docs/spec/02-architecture.md: 4.4 "Allowed dependencies" (rowsidentity,chat,messaging), 4.5 "Cross-cutting ports (internal/kernel)", 6 "Code layout" (migrations/,sqlc.yaml), 7.1 "Module register as the source", 13 "Configuration loading" (items 4, 5 and 7), 15.1 "Start" (steps 1 and 4), 17 "Performance targets" (perf.message_create_p99,perf.startup_ready_max).docs/spec/16-operations.md: 7.1 "Roles" (bothy_ownerruns DDL,bothy_appdoes DML), 7.2 "Settings".docs/spec/20-privacy.md10 "Tests" row DP-T01 ("search hits ... empty");docs/spec/19-security.mdSEC-LOG-4 (search terms never in a log).docs/operations/configuration.md: 2 "Naming rule and formats", 3 "Check", 4 subsection "14 Search", 6 "Instance settings" (locale.default).docs/operations/upgrade.md4 "Rules for migrations (Expand/Contract)", rules M3, M4, M6, M7 and M12.- ADRs: 0032 (Decision Outcome and Confirmation: language tests, compound test, migration lint), 0006, 0007, 0011, 0050.
docs/DECISIONS.md: D-K-G13 and D-K-G14 (vectors andthreads.search_languagetaken into the core contract), D-W-100, the format of section 10.docs/RISKS.mdRK-38 (a change of the vector expressions rewritesmessages); RK-10 anddocs/security/threat-model.mdT-AZ-02 and T-DS-01 concern the query side of M3-14 and are context only.docs/OPEN-QUESTIONS.md: no open question concerns this ticket.docs/api/openapi.yaml: nothing changes.MessageCreateandThreadCreatehave no language member and keep none. Tests use the operationscreateMessage,createThread,closeThread,createChannel,updateMyProfileandputInstanceSetting.searchandsearchThreadTitlesbelong to M3-14.docs/PLAN.md: 3 "Way of working per ticket"; 9 "M3 Platform services" (Goal, Scope, Exit criteria 1 and 2, Test strategy "fuzzing of the search input", Red team, Risks "a change of the search vectors rewrites the table"); rows M3-13 to M3-16; rows M1-53, M1-55, M1-56 and M1-58.- Briefs:
M1-56-messaging-send.md(Scope item 1: "messages.search_language,messages.search_vectorand the GIN index: M3-13 adds them"; D1 flow ofcreateMessage; D6 system line; D9 fixed statement count),M1-55-chat-threads.md(chat.Threads.Create, "Search columnsthreads.search_language,title_search: M3-13"),M1-53-chat-channels.md(queries/threads_main.sql, the same deferral),M1-12-postgres-migrations.md(goose,pgtest, D4 grants, D6 numbering),M1-01-server-gates.md(migration lint),M1-07-config-schema-loader.md,M1-08-config-reference.md(make config-doc-check,tools/configdoc/planned.yaml),M1-11-lifecycle-health.md(wire.Servestart order and exit codes),M1-26-identity-principals.md(identity.Directory,UserView.Locale),M1-31-identity-people.md(users.localein canonical BCP 47 form),M1-46-instance-settings.md(instance.Settings.LocaleDefault),M1-58-messaging-edit-delete.md(row state of a deleted message; not a dependency). - Dependencies implied by M1-56 and therefore not in the header: M1-55, M1-53, M1-46, M1-31, M1-26, M1-12, M1-11, M1-08, M1-07, M1-06 and M1-01 are all in its dependency closure.
- Code (paths under
services/server/unless they start withtools/):migrations/withmigrations.sha256,tools/ci/migrations_lint.pyandtools/ci/tests/test_migrations_lint.py(rulesgenerated-not-storedandno-transaction; the testtest_generated_without_stored_failsalready holds the 14 row "Migration lint"),internal/platform/config(Kind,Values,Bind,Schema.AddCheck),internal/wire(config.gowithconfigKeyGroups,configchapters.go,configchecks.gowithconfigChecks,configkeys_*.go,Serve),internal/platform/pg(CheckSchema) andinternal/platform/pg/pgtest,internal/identity,internal/instance,internal/messaging,internal/chat,architecture/modules.yaml,sqlc.yaml.
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:
messages.search_language regconfig NOT NULL DEFAULT 'simple'andmessages.search_vector tsvector NOT NULL GENERATED ALWAYS AS (setweight(to_tsvector(search_language, content_md), 'B') || setweight(to_tsvector('simple'::regconfig, content_md), 'D')) STOREDwith a GIN index (03messages, 03 5 "Search vectors", 14 3.2, R-SU-01).threads.search_language regconfig NOT NULL DEFAULT 'simple'andthreads.title_search tsvector NOT NULL GENERATED ALWAYS AS (setweight(to_tsvector(search_language, title), 'A') || setweight(to_tsvector('simple'::regconfig, title), 'A')) STOREDwith a GIN index (03threads, 03 5, 14 3.2, D-K-G14).- Every generated column states
STORED; PostgreSQL 18 would create a virtual column otherwise, and the migration lint rejectsGENERATED ALWAYS ASwithoutSTORED(14 3.2, 03 5, ADR 0032 Confirmation, PLAN row M3-13). - The language is set when the row is created and never changed afterwards except by a reindex: a person as author gets
search.locale_map[users.locale](first language subtag), an agent, app, import or system author getssearch.locale_map[instance_settings['locale.default']], a locale without entry getssimple; threads follow the same rule through their creator; there is no automatic language detection (14 3.1, R-SU-02). search.languagesnames every configuration a query is built with; the server does not start when a value ofsearch.locale_mapis missing there (14 3.1; 14 12 row "Start with a language insearch.locale_mapthat is missing fromsearch.languages").search_tsquery(langs regconfig[], q text) RETURNS tsquery LANGUAGE sql STABLEexactly as 14 4.2: an OR over all given languages plussimple, empty sub-queries dropped, NULL means "no term"; created by a platform migration (14 4.1, 4.2, R-SU-03).- Vectors stay current through the generated columns alone; a deleted message (empty
content_md) has an empty vector and is found by no query (03 5, 14 1 and 8, ADR 0032 Consequences, DP-T01 "search hits"). - The documented limits stay visible in tests: no splitting of German compounds, umlaut folding by the German stemmer with the unchanged form kept by
simple, identifiers found through their parts and as a phrase (14 3.3), negation per sub-query only "best effort" (14 4.2).
Scope¶
All paths under services/server/ unless they start with docs/ or tools/. This ticket creates no Makefile target, no operation, no event, no job, no metric, no action and no audit entry.
Create:
migrations/<next number>_search_vectors.sql(one transaction): the four columns of requirements 11 and 12, the functionsearch_tsqueryand its privileges (Design notes 2 and 5).migrations/<next number + 1>_search_vector_indexes.sql(-- +goose NO TRANSACTION): the two GIN indexes withCREATE INDEX CONCURRENTLY(Design note 3).migrations/search_vectors_test.go(packagemigrations,pgtest): catalog, function, privilege, index-use and fuzz tests of the section "Tests".internal/kernel/searchlang(doc.go,searchlang.go, tests): the validated language configuration of Design note 6; standard library only.internal/platform/pg/textsearch.gowith tests:CheckTextSearch(Design note 9).internal/wire/configkeys_search.go:searchConfigKeys()withsearch.languagesandsearch.locale_map.- Integration tests of the language rule and the 14 examples (Design note 13 names the package).
Change:
internal/platform/config: a new kindKindStringMap(namemap(string)), its parser, the file form,Values.StringMap(key) map[string]stringandBindsupport formap[string]stringfields (Design note 8); README section "Interface" and the kind documentation.internal/wire:searchConfigKeysinconfigKeyGroups(config.go);chapter14 = "14-search.md"inconfigchapters.go; the checkssearch_languagesandsearch_locale_mapinconfigChecks(configchecks.go); the two keys in the expected keys ofTestConfigSchemaBuilds; inServe, thesearchlang.Mapbuilt from the loaded values, passed toidentity, and the start steppg.CheckTextSearchdirectly afterpg.CheckSchema.internal/identityportDirectory:SearchLanguage(ctx context.Context, id ids.ID) (string, error)andInstanceSearchLanguage(ctx context.Context) (string, error)(Design note 7); implementation ininternal/identity/app(readsusers.localethrough its repository andlocale.defaultthroughinstance.Settings.LocaleDefault); the fakes ininternal/identity/adapters/identitytest(or the merged test helper package).internal/messaging: the insert inadapters/postgres/queries/messages.sqltakessearch_language; the use casesCreateMessageandCreateThread(and the system line of M1-56 D6) compute it (Design note 7); a static test over the module's query files.internal/chat: the inserts inadapters/postgres/queries/threads.sql(Threads.Create, M1-55) andadapters/postgres/queries/threads_main.sql(main thread at channel creation, M1-53) takesearch_language; the app layer computes it from the creator; a static test over the module's query files.architecture/modules.yaml:searchlanginkernel.core; regenerate.go-arch-lint.yml(go generate ./tools/archgen).sqlc.yaml: type overrides forregconfigandtsvectoronly if the regenerated code needs them; regenerate the sqlc code (make codegen-checkmust show no drift).tools/configdoc/planned.yaml(M1-08): remove a rule that namessearch.languagesorsearch.locale_mapexactly and would become stale; keep a prefix rule that still matches other plannedsearch.*keys.docs/spec/14-search.md: header (the language tests now run on PostgreSQL 18); 3.1 (first language subtag,simpleallowed as map value, the start check of Design note 9); 4.2 (privilege of the function; the results observed on PostgreSQL 18 if they differ from the PostgreSQL 16 example).docs/spec/02-architecture.md: 4.5 a rowkernel/searchlang; 13 item 4 the new cross-check in "Cross conditions today"; 15.1 step 4 extended by the text search check.docs/spec/16-operations.md7.2: a row for the character classification of the database (Design note 9).docs/operations/configuration.md: section 2 a row formap(string); section 4 regenerated withmake config-reference.- READMEs:
migrations/README.md(the platform function, the migration pair, the rewrite note),internal/messaging/README.md,internal/chat/README.md,internal/identity/README.md(new port methods),internal/platform/config/README.md,internal/platform/pg/README.md(start check); package documentation ofinternal/kernel/searchlang. services/server/CHANGELOG.mdunder "Unreleased";docs/DECISIONS.mdsection 10 rowsD-M3-13-1toD-M3-13-10(Design note 14).
Out of scope¶
- The search coordinator
internal/search, the read portsSearchMessages,SearchThreadTitlesandSearchFiles, the operationssearchandsearchThreadTitles, the rights filter, filters, ranking (search.rank_normalization,search.recency_scale), highlighting, cursor, prefix search, input checks (search.query_max_chars, codesemptyandtoo_long),search.statement_timeout,rl.search, the canary search term and fuzzing at the HTTP level: M3-14. Hand-off notes for M3-14 are in Design note 12. files.search_vector(file name and transcript,simpleonly, 03 5): created with thefilestable by M3-03.file_text_extractswith its vector and the language of the instance for file text: M3-15 (it can useidentity.Directory.InstanceSearchLanguagefrom this ticket).- The search load test,
search.p95_targetatsearch.benchmark_messages, and the measured rewrite duration for the upgrade guide (RK-38): M3-16. - The offline search of the client (14 6): M4-23.
- Deleting and editing messages through the API: M1-58. This ticket proves the vector side at row level (Design note 13).
- The language of imported messages (origin
import): M8-03; the static tests of this ticket make its insert namesearch_language. - The job of 14 8 "Reassigning the language" (new language for existing rows, for example after a change of
search.locale_map): no ticket in the plan names it; reported as a plan gap. Rows that exist before this ticket keepsimple(Design note 4). - Any change to the expressions of 03 5 (core contract).
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on the branch
feat/M3-13-search-vectors; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(migrations): ...,feat(identity): ...,feat(config): ...) 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>. - Never connect to a database you did not start. The extra database with a
Ccharacter classification that one test needs is created inside thepgtestcontainer (CREATE DATABASE ... TEMPLATE template0) throughSuperConnand dropped by the same test. - Do not edit the merged migrations of M1-53, M1-55 and M1-56; the columns come only through the new migrations. Do not touch the migration lint (
tools/ci/migrations_lint.py): it already holds theSTOREDrule, and this ticket only has to pass it. - No backfill job and no data change of existing rows in a migration (upgrade.md rule M7).
- Do not add a pgx notice handler that logs notice texts:
websearch_to_tsqueryrepeats the raw input in some notices, and search terms never reach a log (SEC-LOG-4).
Design notes¶
-
Start check.
make checkis green on the fresh worktree, elseVerdict: BLOCKED base branch red. M1-56 is merged (tablemessages,createMessageenabled), elseVerdict: BLOCKED dependency M1-56 missing. Rungit tag -l 'server/v*': if it lists any tag, a server release with the tablesmessagesandthreadsexists and the table rewrite of note 4 needs the owner's decision and a maintenance window; stop withVerdict: BLOCKED server release exists; rewrite of messages needs a maintenance window (RK-38). -
Migration
<next number>_search_vectors.sql, numbered by M1-12 D6, one transaction, each deliberate choice commented in the file:-- +goose Up ALTER TABLE threads ADD COLUMN search_language regconfig NOT NULL DEFAULT 'simple'; ALTER TABLE threads ADD COLUMN title_search tsvector NOT NULL GENERATED ALWAYS AS ( setweight(to_tsvector(search_language, title), 'A') || setweight(to_tsvector('simple'::regconfig, title), 'A') ) STORED; ALTER TABLE messages ADD COLUMN search_language regconfig NOT NULL DEFAULT 'simple'; ALTER TABLE messages ADD COLUMN search_vector tsvector NOT NULL GENERATED ALWAYS AS ( setweight(to_tsvector(search_language, content_md), 'B') || setweight(to_tsvector('simple'::regconfig, content_md), 'D') ) STORED; -- +goose StatementBegin CREATE FUNCTION search_tsquery(langs regconfig[], q text) RETURNS tsquery LANGUAGE sql STABLE AS $$ SELECT string_agg('(' || tq::text || ')', ' | ')::tsquery FROM ( SELECT websearch_to_tsquery(l, q) AS tq FROM unnest(array_append(langs, 'simple'::regconfig)) AS l ) s WHERE numnode(tq) > 0 $$; -- +goose StatementEnd REVOKE ALL ON FUNCTION search_tsquery(regconfig[], text) FROM PUBLIC; GRANT EXECUTE ON FUNCTION search_tsquery(regconfig[], text) TO bothy_app;- The column
search_languagecomes first in its own statement because the generated column reads it; a constant default adds it without a rewrite (upgrade.md rule M3), the generated column then rewrites the table once (note 4). - The expressions are copied character for character from 03 5 and the function body from 14 4.2. If PostgreSQL 18 rejects an expression of 03, stop with
Verdict: BLOCKED <reason>: 03 is the core contract. If it rejects the function body, correct 14 4.2 in the same change (14 is no core contract) and name the change in the report. - Privileges: the migrations run as
bothy_owner(16 7.1), so the function belongs to it. A new function is executable byPUBLICby default; following M1-12 D4 (least privilege, no default privileges) the migration revokes that and grantsEXECUTEtobothy_apponly. If the merged migrations already follow another written convention for functions (grep -n FUNCTION migrations/*.sql), follow it and report the difference. Tables need no new grant: column grants come with the table grants of M1-53 and M1-56. - The
Downsection drops the function and the four columns (generated columns first); goose files carry it although production never runs it (upgrade.md rule M12).
- The column
-
Migration
<next number + 1>_search_vector_indexes.sql, first line-- +goose NO TRANSACTION(the lint'sno-transactionrule, ADR 0007 rule 3, upgrade.md rule M4):-- +goose NO TRANSACTION -- +goose Up DROP INDEX CONCURRENTLY IF EXISTS messages_search_vector_idx; CREATE INDEX CONCURRENTLY messages_search_vector_idx ON messages USING gin (search_vector); DROP INDEX CONCURRENTLY IF EXISTS threads_title_search_idx; CREATE INDEX CONCURRENTLY threads_title_search_idx ON threads USING gin (title_search); -- +goose Down DROP INDEX CONCURRENTLY IF EXISTS threads_title_search_idx; DROP INDEX CONCURRENTLY IF EXISTS messages_search_vector_idx;The
DROP ... IF EXISTSbefore each build removes an invalid index that an interrupted concurrent build leaves behind, so a secondbothy migrate upsucceeds. Use the index naming convention of the merged migrations if it differs (grep -n 'CREATE INDEX' migrations/*.sql). GIN with default settings (14 8).migrate upruns both files withlock_timeout = migrate.lock_timeout(M1-12 D5, upgrade.md rule M6). -
Rewrite and existing rows. Adding a
STOREDgenerated column rewrites the table under an exclusive lock; upgrade.md rule M3 wants new columns without a rewrite, while 03 5 and 14 8 prescribe generated columns and accept the rewrite with a maintenance window (ADR 0032 Consequences, RK-38). The deviation is acceptable only because no server release exists (note 1); record it as a ruling and say inmigrations/README.mdthat a later change of an expression is a release with a maintenance window whose duration M3-16 measures. Existing rows getsearch_language = 'simple'from the column default; there is no backfill in the migration (upgrade.md rule M7) and no reassignment job in this ticket (Out of scope). Before the first release no instance holds real data, so nothing is lost. -
search_tsquerybehaviour. The planning ran the function and the expressions on a throwaway PostgreSQL 16 with a UTF-8 character classification; the tests of this ticket repeat each line on PostgreSQL 18 (pgtest). If PostgreSQL 18 answers differently, keep the function as written, correct the example in 14 4.2 or 3.3 to the observed value and name the difference in the report; never change the function to match an example.search_tsquery('{german,english}', 'Rechnungen')gives'rechnung' | 'rechnungen' | 'rechnungen'(14 4.2).search_tsquery('{german,english}', 'the')gives'the' | 'the': the English branch holds only a stop word and is dropped.- Input with only punctuation (
!!! ...), the empty string, white space only and a NULLqgive NULL (14 4.1: no term). search_tsquery('{}', 'Rechnungen')gives'rechnungen'(onlysimple); a NULL array behaves the same.search_tsquery('{german,english}', '-z')gives!'z' | !'z' | !'z'(see note 12).- The function turns each sub-query into text and parses the joined text again (
tq::textto::tsquery). The text form of atsqueryquotes every lexeme and escapes quotes inside it, so the round trip is expected to be lossless; the fuzz test holds this for arbitrary input. A counterexample (an error or a changed query) breaks 14 4.1 ("every input is safe"): then correct the function and 14 4.2 together in this change, keep the input as a seed and report it; never skip such an input. websearch_to_tsqueryraises a NOTICE for stop-word-only and empty input; for white-space-only input the notice repeats the input. A NOTICE is no error; see the boundary on notice handlers.
-
Kernel package
internal/kernel/searchlang(ruling): the validated pairsearch.languagesandsearch.locale_map, used byidentity(write side),wire(checks and start check) and later by M3-14 (query languages) and M3-15. Standard library only; it joinskernel.coreinarchitecture/modules.yamland the table of 02 4.5.// Simple is the configuration that keeps words unchanged; every vector and every query has it. const Simple = "simple" // Map is the validated language configuration of 14 "Language per object". The zero value maps // every locale to Simple and has no languages. type Map struct{ /* unexported */ } // New validates search.languages and search.locale_map by the rules of design note 8. func New(languages []string, localeMap map[string]string) (Map, error) // For returns the configuration for a BCP 47 locale: the entry of the map for its first // language subtag (the part before the first "-", ASCII lower case), or Simple. func (m Map) For(locale string) string // Languages returns search.languages in configuration order, without Simple. func (m Map) Languages() []string // Configs returns every configuration name the server uses: Languages, the values of the // locale map and Simple, sorted and without duplicates. func (m Map) Configs() []string -
Who writes the language (14 3.1, R-SU-02; ruling for the details):
identity.Directory.SearchLanguage(ctx, id): for a principal of kinduser,Map.For(users.locale); foragentandapp,Map.Forofinstance.Settings.LocaleDefault; unknown id gives thekernel/errskindnot_found.InstanceSearchLanguage(ctx)givesMap.Foroflocale.defaultwithout a principal. Both read inside the caller's unit of work (M1-26 convention: reads join the ambient transaction).identitymay useinstance(02 4.4);messagingandchatmay useidentity.messaging: inside the unit of work ofCreateMessageandCreateThread(M1-56 D1 step 5 and D8), before the insert, originhuman,agentandapptakeSearchLanguage(author); originsystem(the linethread.reopenedof M1-56 D6, whose author is a person) andimporttakeInstanceSearchLanguage. When the actor kind already says the author is no person, callInstanceSearchLanguagedirectly and skip the principal read.chat:Threads.CreatetakesSearchLanguage(created_by); the main thread inserted at channel creation (M1-53) takes the same for the creator of the channel (14 3.1: "Threads get the same rule via the creator"; the main thread's title is empty, the rule still holds for every row).- The language never comes from a request, is not part of any API schema or event payload, and no later write changes it: an edit recomputes the vector with the stored language, a change of
users.localeor oflocale.defaultaffects only new rows, moving a thread (M1-62) keeps it. - The value goes to SQL as text and is cast there (
sqlc.arg(search_language)::text::regconfig), so pgx needs no codec forregconfig. No read query selectssearch_vectorortitle_search(replaceSELECT *,RETURNING *orsqlc.embedon these tables by column lists where the regenerated code would carry the vectors). - Enforcement for later writers (M2 agent messages, M8-03 import, V3-01 DMs): a static test in
internal/messaging/adapters/postgresand one ininternal/chat/adapters/postgresfail when anINSERT INTO messagesorINSERT INTO threadsin the module's query files does not namesearch_language.
-
Configuration (14 15, 02 13; rulings for what 14 leaves open):
- Keys in
internal/wire/configkeys_search.go, chapterchapter14:search.languages,config.KindStringList, default exactly["german","english"];search.locale_map, the newconfig.KindStringMap, default exactly{"de":"german","en":"english"}(the chapter cells;make config-doc-checkcompares them, M1-08 rule D5). KindStringMap(map(string)): text form a JSON object whose values are strings (default, environment, a scalar in the file); in the file also a YAML mapping of plain scalars under the key (like lists, which take a YAML sequence there); duplicate keys, empty keys, non-string values and anything but an object are problems; canonical text is the JSON object with keys sorted. Parser messages never quote the input (M1-07 rule).- Check
search_languages(keyssearch.languages): entries are unique, each an unqualified PostgreSQL identifier in lower case (letters, digits,_, not starting with a digit, at most the PostgreSQL identifier length), and none issimple(always added, 14 4.2). - Check
search_locale_map(keyssearch.languages,search.locale_map): every key is a first language subtag in lower-case ASCII letters; every value is a valid configuration name and is listed insearch.languagesor equalssimple; otherwise the problem namessearch.locale_mapand the position of the entry in key order. This is the start error of 14 3.1:bothy config checkreports it with exit1,bothy servestops at step 1 of 02 15.1 with the configuration error code ofcmd/bothybefore any listener opens. - Both checks call
searchlang.New; problem messages name keys and positions, never values (02 13 item 4).
- Keys in
-
Start check against the database (ruling; extends 02 15.1 step 4, documented in 16 7.2): in
wire.Servefor every role, directly afterpg.CheckSchema,pg.CheckTextSearch(ctx, pool, m.Configs()):- every name must be a text search configuration visible on the search path (
pg_catalog.pg_ts_configwithpg_catalog.pg_ts_config_is_visible(oid)); a missing one would fail every message insert and every search with a SQL error; - the database must fold the case of non-ASCII letters in text search:
to_tsvector('simple', 'A') = to_tsvector('simple', 'a')must be true, where the two letters are U+00C4 (capital A with diaeresis) and U+00E4 (small a with diaeresis). Planning observed on PostgreSQL 16 that a database with the character classificationCkeeps'Arger'and'Uber'(first letters U+00C4 and U+00DC, capital A and U with diaeresis) unchanged, so "arger" (first letter U+00E4) never finds "Arger" (first letter U+00C4), whileen_US.UTF-8gives'arger'and'uber'(first letters U+00E4 and U+00FC, small a and u with diaeresis). The official Debian-based PostgreSQL image, whichpgtestpins and the Compose image builds on, initialises withen_US.utf8;TestTextSearchCaseFoldingconfirms it forpgtest. If PostgreSQL 18 folds the case even underC, keep the probe (it then passes everywhere), replace theCcase ofTestCheckTextSearchby a probe result injected through a test seam, and report the observation. - On failure the start aborts with exit
1(M1-11 start failure) and no public listener; the message names the key and the entry position for a missing configuration, or the character classification of the database, never a value. Add a row to 16 7.2 ("Character classification of the database: UTF-8 capable, for exampleen_US.utf8; reason: upper and lower case in text search") and the check to 02 15.1 step 4.
- every name must be a text search configuration visible on the search path (
-
Transactions and concurrency: the language is computed in the same unit of work as the insert, under
READ COMMITTED(03 1 item 11); a concurrent change of the author's locale may or may not be seen, and both results satisfy 14 3.1 because the value is taken once at creation. No lock is added; the thread row lock and lock order of M1-55 and M1-56 stay unchanged. The generated columns are computed by PostgreSQL inside the insert or update, so a deleted message (M1-58 setscontent_md = '') has an empty vector in the same transaction (03 5). -
Performance: the write path of
createMessagegains one read (the author'susersrow by primary key, or the instance setting); M1-56 D9 (a fixed number of statements) still holds.perf.message_create_p99(02 17) is held byperf/load_messagesof M1-72; if that test exists on the base branch, runmake perfon the reference profile when it is available (PLAN 3 item 6), otherwise list it under "What is not proven". The start check adds two short queries (perf.startup_ready_max). -
Hand-off to M3-14 (write it into
migrations/README.mdunder the function): a NULL result means400 validation_failedwith codeemptyonq(14 4.1); a query of only negations (-z) gives!'z' | !'z' | !'z', which matches nearly every row and is bounded only bysearch.max_candidatesandsearch.statement_timeout(reported as a spec gap); PostgreSQLtextcannot hold a NUL byte and rejects invalid UTF-8, soqmust be checked before it reaches SQL; call the function with the languages ofsearchlang.Map.Languages()cast as$n::text[]::regconfig[]. -
Test placement: catalog, function and fuzz tests live in package
migrations(as M1-12 placedTestPrivilegesAfterAllMigrations) and need no domain rows. Tests with messages and threads use the real modules throughinternal/wire, HTTP through the real chain and people from the identity test helpers, intest/integration(02 6) or next to the M1-56 integration tests if the merged layout keeps them there. Deleted and edited rows are produced with SQL as the owner role on a message created throughcreateMessage, because the delete and edit path of M1-58 is not in this ticket's dependency closure. -
Rulings for
docs/DECISIONS.mdsection 10 (one row each with reason and place):- D-M3-13-1: the language of 14 3.1 is assigned in this ticket by
messagingandchatthroughidentity.Directory.SearchLanguageandInstanceSearchLanguage; the map key is the first language subtag in ASCII lower case; originsystemandimportuse the instance language. - D-M3-13-2: main threads get the creator's language like every thread.
- D-M3-13-3: the kernel package
kernel/searchlangholds the validated language configuration. - D-M3-13-4: configuration kind
map(string)forsearch.locale_map(JSON object; YAML mapping in the file). - D-M3-13-5: validation of
search.languagesandsearch.locale_map(note 8);simplemay be a map value and is never listed insearch.languages. - D-M3-13-6: the start check of note 9 (configurations exist, case folding works).
- D-M3-13-7: columns and function in one transactional migration, GIN indexes concurrently in a second migration that first drops an invalid leftover.
- D-M3-13-8: the table rewrite against upgrade.md rule M3 is accepted only while no
server/v*tag exists; existing rows keepsimple, no backfill. - D-M3-13-9:
EXECUTEonsearch_tsqueryonly forbothy_app. - D-M3-13-10: the language goes to SQL as text with a cast; read queries never select the vectors.
- D-M3-13-1: the language of 14 3.1 is assigned in this ticket by
Tests (write them first)¶
Unit:
internal/kernel/searchlang:TestNewwith a table: the chapter defaults are valid; a map value missing fromsearch.languagesfails and the error namessearch.locale_mapand the entry position but not the value;simpleas map value is valid;simpleinsearch.languagesfails; a duplicate language fails; names with upper case, a dash, a leading digit, empty or longer than the PostgreSQL identifier length fail; map keysde-AT,DEand the empty key fail.TestFor:deandde-ATandDE-atgivegerman,en-USgivesenglish,fr, the empty string and-givesimple.TestConfigs: sorted, unique, containssimpleand every map value.TestLanguages: configuration order, withoutsimple.internal/platform/config:TestKindStringMap(JSON object accepted and canonicalised with sorted keys; array, number value, nested object, duplicate key and empty key rejected; no message contains the input);TestLoadStringMapFromFile(YAML mapping of plain scalars accepted; nested mapping, sequence and alias rejected; a scalar with JSON text accepted);TestLoadStringMapFromEnv;TestBindStringMap;TestValuesStringMapReturnsCopy.internal/wire:TestConfigSchemaBuildslists the two keys;TestConfigKeysMatchChapterTablesandTestDefaultsPassCrossCheckspass with chapter 14;TestSearchLanguagesCheckandTestSearchLocaleMapCheckhold note 8 with one case per rule.internal/identity/app:TestSearchLanguagewith fakes: a person with localedegivesgerman; a person with localeenand a map withoutengivessimple; an agent and an app give the mapping oflocale.default(engivesenglish); an unknown id givesnot_found;InstanceSearchLanguagefollows a changedlocale.default.- Static:
TestMessageInsertsSetSearchLanguage(internal/messaging/adapters/postgres) andTestThreadInsertsSetSearchLanguage(internal/chat/adapters/postgres) parse the module'squeries/*.sql; everyINSERT INTO messagesorINSERT INTO threadsnamessearch_language; a fixture query without it makes the check fail.
Integration (PostgreSQL 18 via pgtest):
TestSearchColumnsOnPostgres18(migrations):current_setting('server_version_num')belongs to major version 18;pg_attribute.attgenerated = 's'(stored, not virtual) andattnotnullformessages.search_vectorandthreads.title_search;search_languageof both tables has typeregconfig, isNOT NULLand defaults tosimple; the indexesmessages_search_vector_idxandthreads_title_search_idxexist, use access methodginand are valid (pg_index.indisvalid). This holds the 14 row "Migration lint" against the real artifact.TestSearchTsqueryExamples(migrations, asbothy_app): every line of note 5 with its exact text result or NULL.TestSearchTsqueryUsesIndex(migrations): withSET LOCAL enable_seqscan = off,EXPLAINofSELECT id FROM messages WHERE search_vector @@ search_tsquery('{german,english}', 'Rechnungen')and of the same onthreads.title_searchshows a bitmap index scan on the GIN index (14 4.2 "the GIN query uses the index").TestSearchTsqueryPrivileges(migrations):has_function_privilegeis true forbothy_appand false forpublicandbothy_maint; the owner isbothy_owner.FuzzSearchTsquery(migrations, Go native fuzzing;go testruns the seed corpus, Test strategy "fuzzing of the search input"): seeds with quotes, backslashes,&,|,!,<->,:*, parentheses,or,-, leading-, Unicode white space, bidi marks, combining characters and long words; inputs that are invalid UTF-8 or contain NUL are skipped (PostgreSQLtextcannot hold them; M3-14 rejects them before SQL). Property: the call never returns an error; the result is NULL or atsquerywithnumnode > 0whose text form parses back to an equal value.TestTextSearchCaseFolding(migrations): in thepgtestdatabaseto_tsvector('simple', 'Arger')equals'arger':1, where the input starts with U+00C4 (capital A with diaeresis) and the lexeme with U+00E4 (small a with diaeresis).TestCheckTextSearch(internal/platform/pg): the defaults pass; a list with an unknown configuration name fails with a message that names its position but not the name; a database created in the container withTEMPLATE template0 LC_COLLATE 'C' LC_CTYPE 'C'fails the case-folding probe; the test drops that database.TestServeAbortsOnUnknownTextSearchConfig(internal/wireorcmd/bothy):bothy serveagainstpgtestwithBOTHY_SEARCH__LANGUAGESholding a configuration the database does not have exits1after the schema check; no public listener was opened (nopublic listener startedlog line); the output namessearch.languages.TestConfigCheckRejectsLocaleMapLanguageMissing(cmd/bothy; 14 12 row "Start with a language insearch.locale_mapthat is missing fromsearch.languages: start error"): withBOTHY_SEARCH__LOCALE_MAP={"de":"german","fr":"french"}and the default languages,bothy config checkexits1andbothy serveexits with the configuration error code before any listener; both outputs namesearch.locale_mapand do not contain the configured valuefrench.
Integration with the real modules (note 13; world: space Support with the text channel Allgemein and the forum channel Fragen; Alex with users.locale de, Sam with en, Robin with de; an agent from the M1 test helpers with an API token and the right to write in Allgemein; Alex is owner):
TestLanguageExamplesFromChapter14(the language rows of 14 12 and the limits of 14 3.3 and 4.2): Alex posts "Rechnung", "Rechnungsadresse", "Fehler ERR_CONN_RESET beim Abruf", "verfugbar" with U+00FC (small u with diaeresis) as the fifth letter, "Rechnung offen" and "Arger mit dem Abruf" with U+00C4 (capital A with diaeresis) as the first letter; Sam posts "invoices are running". Asbothy_app,SELECT id FROM messages WHERE search_vector @@ search_tsquery($1::text[]::regconfig[], $2)with the defaultsearch.languagesasserts: "Rechnungen" finds "Rechnung" (German); "invoice run" finds "invoices are running" (English);err_conn_resetand the phrase"err_conn_reset"find the identifier message; "Rechnung" does not find "Rechnungsadresse" (compound limit, 14 3.3); "verfugbar" (fifth letter U+00FC) finds "verfugbar" (fifth letter U+00FC); "arger" (first letter U+00E4, small a with diaeresis) finds "Arger mit dem Abruf" (first letter U+00C4); "offen -Rechnungen" finds "Rechnung offen" (negation per sub-query, the documented limit of 14 4.2).TestSearchVectorWeights:unnest(search_vector)of Alex's "Rechnung offen" gives German stems with weightB(off,rechnung) and unchanged words with weightD(offen,rechnung); "verfugbar" (fifth letter U+00FC) holdsverfugbarwithBand the unchanged form with U+00FC withD(14 3.3: both forms); a forum post by Alex in Fragen titled "Arger mit Rechnungen" (first letter U+00C4) has only weightAintitle_search, witharg,mit,rechnung,rechnungenandargerwith U+00E4 as the first letter (the German stop wordmitcomes only fromsimple).TestSearchLanguageAssignedAtCreation(14 3.1): Alex's message getsgerman, Sam'senglish; the agent's message gets the mapping oflocale.default(germanwith the default,englishafter Alex setslocale.defaulttoenthroughputInstanceSettingwith step-up, as the M1-46 tests do); aftercloseThreada message of Sam reopens the thread and the system line gets the instance language while Sam's message getsenglish; withsearch.locale_mapset to{"de":"german"}Sam's next message getssimple;createThreadby Sam in Fragen with title "Invoices are running" givesthreads.search_language = 'english'andtitle_search @@ search_tsquery('{german,english}', 'invoice')is true;createChannelby Alex gives the new main threadgerman; after Alex changeslocaletoenthroughupdateMyProfile, her earlier message keepsgerman; a body with a membersearch_languageis rejected as an unknown member (M1-18) and changes nothing.TestDeletedMessageHasEmptyVector(DP-T01 part "search hits"): a message created throughcreateMessageis brought into the row state of M1-58 with SQL as the owner role (content_md = '',deleted_atanddeleted_byset); afterwardssearch_vector = ''::tsvectorand no query of the earlier content finds it. A second message updated with SQL to a newcontent_mdis found by a word of the new text and no longer by a word that only the old text had (14 8: no trigger, no job).- The existing tests of M1-53, M1-55 and M1-56 (channel creation, threads,
createMessage,createThread, replay) stay green unchanged.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./migrations/... ./internal/kernel/searchlang/... ./internal/platform/config/... ./internal/platform/pg/... ./internal/identity/... ./internal/messaging/... ./internal/chat/... ./internal/wire/... ./cmd/bothy/... ./test/...cd tools && uv run --locked --quiet pytest ci/tests/test_migrations_lint.pymake migrations-lint,make codegen-check,make arch,make register-check,make config-doc-checkmake check
Acceptance criteria¶
- The new migrations add
search_languageand theSTOREDgenerated vectors tomessagesandthreadswith the expressions of 03 5 and a valid GIN index each;make migrations-lintis green, and the catalog shows stored, not virtual, columns on PostgreSQL 18. - The language tests of 14 "Tests" are green on PostgreSQL 18: "Rechnungen" finds "Rechnung", "invoice run" finds "invoices are running",
err_conn_resetfinds the identifier, "Rechnung" does not find "Rechnungsadresse", and a language insearch.locale_mapthat is missing fromsearch.languagesstops the start. -
search_tsqueryexists exactly as 14 4.2, gives NULL without a term, uses the GIN index, never fails on fuzzed valid input, and onlybothy_appmay execute it. - Every new message and thread carries the language of 14 3.1, set once at creation and never from a request; the static tests hold this for every insert of
messagesandthreads. - A deleted message has an empty vector and is found by no query; an edited message is found by its new text only.
- The server does not start with an unknown text search configuration or a database without case folding of non-ASCII letters.
- Configuration reference, chapters 02, 14 and 16 and the READMEs describe the new keys, the kind
map(string), the start check and the function. -
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
migrations,messaging,chat,identity,platform/configandplatform/pgupdated (the platform function with the hand-off of note 12, the migration pair and the rewrite note, the language rule and its static tests, the new port methods, the kindmap(string), the start check); package documentation ofkernel/searchlang. - Rulings of note 14 recorded in
docs/DECISIONS.mdsection 10 (one row each with reason). docs/spec/02-architecture.md,14-search.md,16-operations.mdanddocs/operations/configuration.mdupdated as listed in the scope; the configuration reference regenerated withmake config-reference.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); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. Ask the red team to look at the round trip of the query text insidesearch_tsquery, the privileges of the function, the table rewrite and lock of the migrations, whether any request can set or changesearch_language, and whether a start or configuration message prints a configured value. - 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 (for example
perf.message_create_p99whenperf/load_messagesdoes not exist yet or the reference profile is not available; behaviour on managed PostgreSQL services). - Follow-ups that could not be done in this scope, each with its reason, and spec gaps found (including every PostgreSQL 18 result that differed from the PostgreSQL 16 examples of 14).
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.