| D-M3-13-1 |
2026-10-15 |
The language of 14 3.1 is assigned by messaging (CreateMessage, CreateThread, the reopen line) and chat (Threads.Create, the main thread at channel creation) inside the unit of work of the insert through identity.Directory.SearchLanguage and InstanceSearchLanguage. The map key is the first language subtag (the part before the first -) in ASCII lower case. A person author takes search.locale_map[users.locale]; an actor that is no person (agent, app) takes the instance language without reading its principal; origin system takes the instance language, whoever caused the line; import (M8-03) will do the same. A locale without entry gives simple. |
One rule at one place (identity owns users.locale, instance owns locale.default); the actor kind already says when no person row exists, which saves a read on the write path (M1-56 D9). |
| D-M3-13-2 |
2026-10-15 |
Main threads get the language of the creator of the channel, like every thread, although their title is empty. |
14 3.1 "Threads get the same rule via the creator" holds for every row; a later title (M1-55 forbids it for main threads) would still be indexed in a language. |
| D-M3-13-3 |
2026-10-15 |
The validated pair search.languages and search.locale_map lives in the kernel package internal/kernel/searchlang (kernel.core, standard library only), with New, For, Languages, Configs and LocaleIndex. |
identity (write side), wire (checks, start check) and later M3-14 and M3-15 (query side, file text) need the same rules; a kernel package is the only place all of them may import. LocaleIndex lets start errors name the entry of search.locale_map without the configured name. |
| D-M3-13-4 |
2026-10-15 |
New configuration kind map(string) (config.KindStringMap): a JSON object of strings in defaults, the environment and a file scalar; in the file also a YAML mapping of plain scalars. Duplicate and empty keys, non-string values, nested objects, sequences, aliases and tags are problems that name the entry position, never text; the canonical text is the JSON object with sorted keys; Values.StringMap and Bind into map[string]string hand out copies. An empty text is no object and is refused. |
search.locale_map is a map (14 15); the form follows the list kind (JSON in the environment, native YAML in the file), so bothy config print output can be pasted back. |
| D-M3-13-5 |
2026-10-15 |
Checks search_languages and search_locale_map, both through searchlang.New: languages are unique, unqualified lower-case PostgreSQL identifiers of at most 63 bytes and never simple; map keys are primary language subtags (two to eight lower-case ASCII letters); map values are valid names listed in search.languages or equal to simple. Messages name the key and the entry position (map in key order), never a value. search_locale_map stays silent when search.languages itself is invalid, so that problem is reported once. |
14 3.1: the server must not start when a map value is missing from search.languages; simple is always added by search_tsquery (14 4.2), and a mapping to simple is the documented fallback. |
| D-M3-13-6 |
2026-10-15 |
Start check pg.CheckTextSearch in step schema of wire.Serve for every role, directly after pg.CheckSchema: every name of searchlang.Map.Configs() must be a text search configuration visible on the search path (pg_catalog.pg_ts_config, pg_ts_config_is_visible), and the simple vector of a capital umlaut must equal that of its small form. On failure the start ends with exit code 1 before any public listener; the message names search.languages entry n or search.locale_map entry n, or the character classification, never a configured name. PostgreSQL 18 behaves as planned on 16: a database with LC_CTYPE 'C' does not fold a capital umlaut and fails the probe; the pgtest image (en_US.utf8) passes. |
A missing configuration would fail every message insert with a SQL error; a database without case folding would silently never find capitalised umlaut words. Both are operator errors best seen at start (16 7.2). |
| D-M3-13-7 |
2026-10-15 |
Columns and function in one transactional migration (00035_search_vectors.sql); the two GIN indexes concurrently in a second one (00035_search_vector_indexes.sql, -- +goose NO TRANSACTION), each build preceded by DROP INDEX CONCURRENTLY IF EXISTS to remove an invalid leftover of an interrupted run. Index names follow the merged convention <table>_<column>_idx. |
upgrade.md M4 and ADR 0007 rule 3; a second bothy migrate up after an interrupted build succeeds. |
| D-M3-13-8 |
2026-10-15 |
Adding the STORED generated columns rewrites messages and threads once under an exclusive lock, against upgrade.md rule M3; this is accepted only while no server/v* tag exists (checked at the start of this ticket: none). Existing rows keep search_language = 'simple'; no backfill in the migration (M7), no reassignment job in this ticket. A later change of an expression is a release with a maintenance window whose duration M3-16 measures (RK-38). |
03 5 and 14 8 prescribe generated columns; before the first release no instance holds real data. |
| D-M3-13-9 |
2026-10-15 |
search_tsquery(regconfig[], text) belongs to bothy_owner (it runs the migrations); REVOKE ALL ... FROM PUBLIC and GRANT EXECUTE only to bothy_app. This follows the merged convention of the trigger functions of 00002 and 00004 (REVOKE ALL ... FROM PUBLIC). The function runs as the caller (no SECURITY DEFINER). |
Least privilege of M1-12 D4: a new function is executable by PUBLIC by default. |
| D-M3-13-10 |
2026-10-15 |
The language goes to SQL as text and is cast there (sqlc.arg(search_language)::text::regconfig). The queries of messages.sql, threads.sql and threads_main.sql list their columns instead of *, t.* and RETURNING *, so no read carries search_language, search_vector or title_search; the adapters convert the generated per-query row types through one local row struct each. domain.Message.SearchLanguage and domain.Thread.SearchLanguage are write-only: set before the insert, never read back. Static tests (TestMessageInsertsSetSearchLanguage, TestThreadInsertsSetSearchLanguage) fail when an INSERT INTO messages or INSERT INTO threads of the module's query files does not name search_language, or when generated query code reads a vector. |
pgx needs no codec for regconfig and tsvector; later writers (M2 agent messages, M8-03 import, V3-01 DMs) cannot forget the language. |
| D-M3-13-11 |
2026-10-15 |
Fuzz property of search_tsquery: the result equals the OR of the same non-empty sub-queries joined with the tsquery operator \|\| (no text in between), and its text form is stable under a second parse. The literal property "r::text::tsquery = r" of the brief fails for inputs like a or b, because tsquery equality compares trees and the text form of a nested OR has no parentheses, so it parses back left-associated: the same query with another tree, not a loss. The function is unchanged; a fuzz run during the ticket found no counterexample to the stronger property. |
Holds what 14 4.1 needs (no error, no changed query) without a false alarm on associativity. |
| D-M3-13-12 |
2026-10-15 |
Branch feat/M3-13-search-vectors from int/m1-wave19 (b380a4e) in its own worktree; not merged locally and not pushed, the PR lane ports it (task instruction over the brief's landing step). The base holds the German chapters: chapter14 is "14-search.md", the spec changes go into 14-search.md, 02-architecture.md and 16-operations.md, and text this ticket adds is English. NewChat takes the directory in ChatWrites.Directory and NewMessaging a dir parameter, both Identity.Directory in Serve; wire.NewSearchDirectory builds the same for worlds without identity. The schema tests of 00026 and 00032 (TestChatChannelsMigration_Schema, TestMessagingMigration_Schema) list the new columns and indexes. |
The merged code names the chapters in German; the directory with the language configuration must be the one identity builds, so the wiring cannot forget it (a directory without WithSearch fails closed with internal). |
| D-M3-13-13 |
2026-10-20 |
Port onto main from the integration squash of int/m1-wave22: the nine own commits of the ticket on top of main with M1-56. The German chapter edits go into the English chapters 02, 14 and 16; the code comments name the English chapter files and headings; the migrations become 00035 and 00036 after 00034 of M1-60 (contents otherwise unchanged; their checksums are locked again); cmd/bothy/search_test.go waits with the one patience of PL/8; the German test data of the search tests stand between language-check markers, and the docs describe the case folding without German words; config-schema.json and the configuration reference are generated again. Of the integration fixes, INT-W22/3 applies to the reads of M1-57 (GetVisibleMessage) and M1-60 (ListMyMentions, no longer sqlc.embed) now on main, which list their columns, INT-W22/5 to the static tests, which skip models.gen.go, and INT-W22/6 to the query test of M1-60, which names the search language; the other parts of INT-W22 and INT-W25/3 concern tickets that are not on main. |
The PR lane ports only the ticket and the integration fixes it needs; vet, arch, migrations lint, the configuration check and the tests of the touched server packages, including the integration search tests on PostgreSQL 18, pass locally. |