textmachine/backend/internal/store/migrate.go

717 lines
45 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package store
import (
"context"
"database/sql"
"fmt"
)
// migrations are applied in order; schema_version records the highest applied
// version so re-runs are no-ops. Every step is also idempotent (CREATE TABLE
// IF NOT EXISTS) so a half-applied database still converges. Append-only:
// never edit an earlier migration (vojo discipline).
var migrations = []string{
// v1: the Phase-0 operational schema.
`
-- Snapshot контекста джобы (Р6, implementation-notes §3.2): материализованная
-- запись; snapshot_id входит в request-hash, так что волатильность контекста
-- заморожена на джобу. payload — JSON (версии промптов per-role, модели,
-- версия чанкера, style sheet, резюме на момент старта).
CREATE TABLE IF NOT EXISTS snapshots (
snapshot_id TEXT PRIMARY KEY,
brief_hash TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Durable jobs: джоба = глава×стадия (Р6). Гранулярность СОХРАННОСТИ — чанк
-- (checkpoints), джоба лишь группирует их и несёт snapshot_id.
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY,
book_id TEXT NOT NULL,
chapter INTEGER NOT NULL,
stage TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending|running|done|failed
snapshot_id TEXT NOT NULL REFERENCES snapshots(snapshot_id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (book_id, chapter, stage)
);
-- Чанк-чекпоинты: сырой ответ LLM персистится атомарно вместе с settle
-- (одна транзакция — SettleWithCheckpoint), ключ — request_hash от
-- детерминированного рендера (§3.1). kill -9 теряет максимум один
-- in-flight вызов. Найденный чекпоинт на resume = вызов не повторяется и
-- не оплачивается второй раз.
CREATE TABLE IF NOT EXISTS checkpoints (
request_hash TEXT PRIMARY KEY,
job_id INTEGER NOT NULL REFERENCES jobs(id),
chunk_idx INTEGER NOT NULL,
attempt INTEGER NOT NULL DEFAULT 0, -- регенерации Фазы 1: attempt входит в request-hash
stage TEXT NOT NULL,
role TEXT NOT NULL,
model_requested TEXT NOT NULL,
model_actual TEXT NOT NULL,
response_text TEXT NOT NULL,
usage_json TEXT NOT NULL,
cost_usd REAL NOT NULL,
finish_reason TEXT NOT NULL DEFAULT '',
provider_request_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS checkpoints_job_idx ON checkpoints (job_id, chunk_idx);
-- Дневной/книжный ledger с резервированием (порт семантики vojo spend):
-- потолки считают committed + reserved на допуске, поэтому burst
-- конкурентных вызовов не проскакивает потолок (TOCTOU-дисциплина).
CREATE TABLE IF NOT EXISTS spend (
book_id TEXT NOT NULL,
date TEXT NOT NULL, -- UTC YYYY-MM-DD
committed_usd REAL NOT NULL DEFAULT 0,
reserved_usd REAL NOT NULL DEFAULT 0,
PRIMARY KEY (book_id, date)
);
-- request_log: одна строка на LLM-вызов (Р7: per-книга/глава/чанк/стадия/
-- роль/модель, $, latency, cache-поля). Телеметрия, не источник денег —
-- деньги живут в spend/checkpoints.
CREATE TABLE IF NOT EXISTS request_log (
id INTEGER PRIMARY KEY,
ts TEXT NOT NULL DEFAULT (datetime('now')),
trace_id TEXT NOT NULL DEFAULT '',
book_id TEXT NOT NULL,
chapter INTEGER NOT NULL DEFAULT 0,
chunk_idx INTEGER NOT NULL DEFAULT 0,
stage TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT '',
model_requested TEXT NOT NULL DEFAULT '',
model_actual TEXT NOT NULL DEFAULT '',
request_hash TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL DEFAULT 0,
cached_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
cost_usd REAL NOT NULL DEFAULT 0,
latency_ms INTEGER NOT NULL DEFAULT 0,
finish_reason TEXT NOT NULL DEFAULT '',
tm_hit INTEGER NOT NULL DEFAULT 0, -- ответ взят из чекпоинта, вызова не было
degraded TEXT NOT NULL DEFAULT '',
err TEXT NOT NULL DEFAULT '',
ok INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX IF NOT EXISTS request_log_ts_idx ON request_log (ts);
`,
// v2: per-chunk×stage disposition (Milestone 2, D2). The resolve sits ON TOP OF
// append-only checkpoints (keyed by request_hash-with-attempt) — not a column on
// checkpoints: on resume the row is rebuilt from checkpoints, so its loss on kill -9
// self-heals (classify from the checkpoint's text, without re-billing). jobs.status
// is NOT extended with content — failed stays an infra failure; a bad chunk lives
// here as a disposition, and the loop continues.
`
CREATE TABLE IF NOT EXISTS chunk_status (
book_id TEXT NOT NULL,
chapter INTEGER NOT NULL,
chunk_idx INTEGER NOT NULL,
stage TEXT NOT NULL,
snapshot_id TEXT NOT NULL, -- под каким снапшотом резолвнут (stale после --resnapshot игнорируется)
content_hash TEXT NOT NULL DEFAULT '',-- сигнатура отрендеренных msgs (исходник НЕ в снапшоте): правка src делает позиционную строку недействительной, а не подаёт устаревший перевод
disposition TEXT NOT NULL, -- ok | flagged | skipped
flag_reason TEXT NOT NULL DEFAULT '', -- '' когда ok; Go-константа FlagReason
attempts INTEGER NOT NULL DEFAULT 0,
final_hash TEXT NOT NULL DEFAULT '', -- request_hash авторитетного чекпоинта (ok-путь подаёт его текст дальше)
cost_usd REAL NOT NULL DEFAULT 0, -- сумма по ВСЕМ попыткам (F3-честно: ретраи учтены)
detail TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (book_id, chapter, chunk_idx, stage)
);
CREATE INDEX IF NOT EXISTS chunk_status_book_idx ON chunk_status (book_id, disposition);
`,
// v3: escalation flag on checkpoints (Milestone 2.5, D12 single-hop). The fallback
// draft (a deterministic content-failure routed to another model ONCE) is tagged
// so the book's escalation.budget_usd can be enforced by summing ONLY escalation
// spend, independent of the primary translation cost. On the checkpoint (written
// in the same tx as the settle), NOT request_log, so the sum is durable across
// resume / kill -9 (telemetry is fire-and-forget; money-path budgeting is not).
`ALTER TABLE checkpoints ADD COLUMN escalation INTEGER NOT NULL DEFAULT 0;`,
// v4: ruby/furigana readings captured on ingest (step 3a, 04-unhappy §4 / D9). A
// ja epub's <ruby>base<rt>reading</rt></ruby> carries the AUTHOR's reading of a
// name/term; epub-v1 drops inline markup (02-mvp:25), which would silently kill
// this layer, so ingest EXTRACTS the readings into this table instead. It is NOT
// injected into any prompt (that is memory v2 + determinism-load-bearing) — this
// step only CAPTURES + PERSISTS; memory v2 (step 4) consumes it into a glossary
// name-lock (base→dst via reading, first_chapter→since_ch — D7 schema). PK
// (book_id, base, reading): the same name may carry several readings across a
// book (variant yomi), each a distinct row. first_chapter = the MIN chapter the
// pair appears in (a glossary "since"); occurrences = the pair's full-book count
// (a memory-v2 confidence signal). The persist is idempotent AND a full replace:
// ingest aggregates each (base,reading) once over the WHOLE book, and persistRuby
// DELETEs the book's set before re-inserting (store.ReplaceRubyReadings), so
// re-ingesting the same source re-derives identical rows and a source edit
// converges every column — including DROPPING a pair the edit removed (never a
// lingering phantom, never a MIN-pin/increment that drifts on resume).
`
CREATE TABLE IF NOT EXISTS ruby_readings (
book_id TEXT NOT NULL,
base TEXT NOT NULL, -- the ruby BODY: the kanji/base surface form
reading TEXT NOT NULL, -- the <rt> reading (furigana) captured for it
first_chapter INTEGER NOT NULL,-- 1-based full-book MIN chapter the pair appears in (glossary "since"; idempotent REPLACE)
occurrences INTEGER NOT NULL DEFAULT 0, -- full-book count (idempotent REPLACE, not increment)
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (book_id, base, reading)
);
CREATE INDEX IF NOT EXISTS ruby_readings_book_idx ON ruby_readings (book_id);
`,
// v5: the memory bank v2 glossary (step 4, registry architecture/06 §Гейты + D7).
// The DETERMINISTIC substrate the hot-path matcher, the injection surface and the
// post-check are built on. Rows are the frozen-at-job-start injectable pool whose
// approved subset the F1 memoryVersion() hashes into the snapshot. Like ruby_readings
// this is "dumb storage" (aggregation/classification live in the pipeline); the
// pipeline REPLACES a book's whole glossary from its deterministic inputs (seed file
// + classified ruby), so re-seeding is idempotent and a seed edit converges every
// column. No cross-table FK (mirrors the ruby/chunkstatus dumb-storage style): both
// child tables are scoped by book_id and replaced in the same transaction, term_id is
// a logical link (glossary.id is a fresh autoincrement each replace and is NEVER hashed
// into memoryVersion — only content columns are, ORDER BY-stable).
`
CREATE TABLE IF NOT EXISTS glossary (
id INTEGER PRIMARY KEY,
book_id TEXT NOT NULL,
src TEXT NOT NULL, -- source key (raw; normalized in Go for matching, §3.6)
dst TEXT NOT NULL DEFAULT '', -- approved Russian translation (LEMMA/base form); '' for a ruby candidate with no dst yet
type TEXT NOT NULL DEFAULT '', -- name|term|title|… alias-graph node type (B3)
sense TEXT NOT NULL DEFAULT '', -- A3 polysemy disambiguator (part of the uniqueness key)
gender TEXT NOT NULL DEFAULT '', -- male|female|hidden|'' (C2; hidden = gender-avoidant rendering until reveal)
speech TEXT NOT NULL DEFAULT '', -- DEPRECATED (pack-19): superseded by the address_pairs journal (v13); never read, never hashed
decl TEXT NOT NULL DEFAULT '', -- JSON {"invariant":bool,"forms":[...]}: the dst declension forms the post-check accepts (E1/E2 safety, decl = grammaticality mechanism, not a market feature)
translit_policy TEXT NOT NULL DEFAULT '', -- B5 western-name-via-katakana (field only in v1; validator = Phase 2)
first_person TEXT NOT NULL DEFAULT '', -- D7 first_person field (field only)
nickname_translation TEXT NOT NULL DEFAULT '', -- D7 (keep in v1: a column + a "translate once" lock)
since_ch INTEGER NOT NULL DEFAULT 0, -- spoiler window start; 0 = valid from the beginning (C1 hard reject-gate below since_ch)
until_ch INTEGER NOT NULL DEFAULT 0, -- spoiler window end; 0 = no end (valid forever)
status TEXT NOT NULL DEFAULT 'auto',-- auto|draft|approved (term status machine; only approved is CONFIRMED-injected)
allow_short INTEGER NOT NULL DEFAULT 0, -- rare escape hatch: permit a below-min_key_len key (A3 single-key ban override)
source TEXT NOT NULL DEFAULT '', -- provenance: seed|ruby|auto (who created the row)
ruby_reading TEXT NOT NULL DEFAULT '', -- ruby-seeded rows: the furigana reading (bridge to Polivanov dst validation, B6 Phase 2 — NOT a dst)
ruby_class TEXT NOT NULL DEFAULT '', -- name|gloss|ambiguous (ruby classifier §8: phonetic-name → name-lock candidate, unrelated reading → gloss/footnote, else flag)
confidence INTEGER NOT NULL DEFAULT 0, -- ruby occurrences count = confidence prior (NOT a direct promotion to approved)
note TEXT NOT NULL DEFAULT '',
-- B2: at most one row per (source key, sense, spoiler window). since_ch/until_ch are
-- NOT NULL with default 0, so the SQLite "NULLs are distinct in UNIQUE" gotcha cannot
-- silently admit a duplicate. A term whose approved rendering changes across a spoiler
-- boundary is two rows with different windows (legitimately distinct), not a collision.
UNIQUE (book_id, src, sense, since_ch, until_ch)
);
CREATE INDEX IF NOT EXISTS glossary_book_idx ON glossary (book_id, status);
CREATE TABLE IF NOT EXISTS glossary_aliases (
id INTEGER PRIMARY KEY,
book_id TEXT NOT NULL,
term_id INTEGER NOT NULL, -- logical link to glossary.id within the SAME replace tx (no hard FK — dumb storage, scoped+replaced by book_id)
alias TEXT NOT NULL, -- an alternative source surface (raw; normalized in Go for matching)
alias_type TEXT NOT NULL DEFAULT '', -- name|zi|hao|nickname|title (B3 alias-graph node type)
UNIQUE (book_id, term_id, alias)
);
CREATE INDEX IF NOT EXISTS glossary_aliases_term_idx ON glossary_aliases (term_id);
CREATE INDEX IF NOT EXISTS glossary_aliases_book_idx ON glossary_aliases (book_id);
-- B1 editorial-time axis, ORTHOGONAL to the spoiler window (since_ch/until_ch is the
-- STORY timeline; this is the EDITORIAL timeline). Append-only journal of approved-dst
-- changes so a stale first-translation-wins rendering is DETECTABLE (DelTA PNR generates
-- stale/drift; this is the substrate that catches it). Not injected → not in any hash;
-- datetime('now') here is fine (telemetry, like request_log), never feeds memoryVersion.
CREATE TABLE IF NOT EXISTS glossary_revisions (
id INTEGER PRIMARY KEY,
book_id TEXT NOT NULL,
src TEXT NOT NULL,
sense TEXT NOT NULL DEFAULT '',
old_dst TEXT NOT NULL DEFAULT '',
new_dst TEXT NOT NULL,
editorial_ts TEXT NOT NULL DEFAULT (datetime('now')),
reason TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS glossary_revisions_book_idx ON glossary_revisions (book_id, src);
-- Per-chunk retrieval-state (registry gate #4 / A1/F2/G2/E1): observability of memory
-- degradation FROM DAY ONE — the mechanism that converts silent degradation into loud.
-- Recomputed deterministically each run (the matcher is $0), so it self-heals on resume;
-- NOT wire-load-bearing (it records what the injection did, it does not change the request).
CREATE TABLE IF NOT EXISTS retrieval_state (
book_id TEXT NOT NULL,
chapter INTEGER NOT NULL,
chunk_idx INTEGER NOT NULL,
snapshot_id TEXT NOT NULL,
n_exact_hits INTEGER NOT NULL DEFAULT 0, -- entries matched by an exact key/alias in this chunk
n_sticky INTEGER NOT NULL DEFAULT 0, -- entries carried by scene-inertia (A5), not re-matched here
n_ambiguous_flagged INTEGER NOT NULL DEFAULT 0, -- injected AMBIGUOUS (auto/draft) → "unverified" + forced post-check (A2)
n_spoiler_blocked INTEGER NOT NULL DEFAULT 0, -- matched but hard-rejected by the since_ch/until_ch window (C1)
n_evicted INTEGER NOT NULL DEFAULT 0, -- dropped by the token budget (F2 — logged, never silent)
embedding_tier_used INTEGER NOT NULL DEFAULT 0, -- always 0 in v1 (no embeddings on the hot path, Р3)
n_postcheck_miss INTEGER NOT NULL DEFAULT 0, -- E1 flags: a confirmed src matched but no accepted dst form is in the output
postcheck_detail TEXT NOT NULL DEFAULT '', -- JSON [{src,dst}] of the misses, for the report / human
injected_ids TEXT NOT NULL DEFAULT '', -- JSON of exact-matched entity keys (audit + sticky reconstruction)
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (book_id, chapter, chunk_idx)
);
`,
// v6: surface single-hop escalation on the chunk_status RESOLVE (D15.3 status/redrive).
// The fallback fact lived only on the checkpoint (escalation flag, v3); folding it into
// chunk_status lets `tmctl status` project escalation-on-flagged-resume WITHOUT re-reading
// checkpoints (the deferred Milestone-2.5 finding #6 — the escalation column the status
// read-model needs). Derivable from checkpoints, so a lost/rebuilt row self-heals on the
// next resolve. escalation_model is the fallback that answered (empty when not escalated).
`
ALTER TABLE chunk_status ADD COLUMN escalated INTEGER NOT NULL DEFAULT 0;
ALTER TABLE chunk_status ADD COLUMN escalation_model TEXT NOT NULL DEFAULT '';
`,
// v7: the cheap deterministic style flaggers (dialogue-dash, yofikator, translit-interjection
// blocklist, 万/億 magnitude gate — cheapgates.go) ride the same per-chunk observability row as
// the glossary post-check. n_style_flags is the total hit count; style_detail is the JSON
// breakdown for the report. Recomputed deterministically each run (self-heals on resume), NOT
// wire-load-bearing (observability, never a disposition).
`
ALTER TABLE retrieval_state ADD COLUMN n_style_flags INTEGER NOT NULL DEFAULT 0;
ALTER TABLE retrieval_state ADD COLUMN style_detail TEXT NOT NULL DEFAULT '';
`,
// v8 (D39 layer 4): the disposition-gated suppressor's LOUD record. n_trust_gated_suppress counts
// the times a longer but LOWER-trust glossary key (draft/ambiguous) was REFUSED from suppressing a
// nested HIGHER-trust key (approved/confirmed) — the term-drift code root, converted from a silent
// drop into a visible signal (research/13 §7). A nonzero count means the seed has a draft term
// nesting over an approved one to reconcile. trust_gate_detail is the JSON of the refused pairs.
// Recomputed deterministically each run (self-heals on resume), NOT wire-load-bearing.
`
ALTER TABLE retrieval_state ADD COLUMN n_trust_gated_suppress INTEGER NOT NULL DEFAULT 0;
ALTER TABLE retrieval_state ADD COLUMN trust_gate_detail TEXT NOT NULL DEFAULT '';
`,
// v9 (WS4): the banknote channel's per-chunk telemetry (integration point 10) — the accepted line
// count and the parse-fail / truncation flags of the translator draft's ⟦TM-BANK-v1⟧ block, riding
// the same per-chunk observability row as the glossary post-check and the cheap style flaggers.
// Recomputed deterministically each run (self-heals on resume), NOT wire-load-bearing (observability,
// never a disposition). Zero for every channel-off / non-banknote chunk (the common case).
`
ALTER TABLE retrieval_state ADD COLUMN n_banknote_lines INTEGER NOT NULL DEFAULT 0;
ALTER TABLE retrieval_state ADD COLUMN banknote_parse_fail INTEGER NOT NULL DEFAULT 0;
ALTER TABLE retrieval_state ADD COLUMN banknote_truncated INTEGER NOT NULL DEFAULT 0;
`,
// v10 (pack-13 point-9, research/21 §1.10): the CostSource marker on the telemetry row. When a paid
// 2xx settles at the RESERVATION ESTIMATE (a billed decode failure, or a paid-but-zero-usage 2xx) the
// row's cost_usd is an estimate, not provider-reported. Without a flag those rows are indistinguishable
// from real zero-token calls and skew token-based COGS analytics. estimated=1 makes the estimated share
// queryable; est_tokens is a DISPLAY-ONLY fertility (est_out) estimate of the output tokens (never the
// char/4 CJK-mine). Additive columns, default 0 → every prior row and the WIRE are unchanged (the pack
// invariant); an estimated row keeps its real settled cost_usd — the flag/estimate are display-only.
`
ALTER TABLE request_log ADD COLUMN estimated INTEGER NOT NULL DEFAULT 0;
ALTER TABLE request_log ADD COLUMN est_tokens INTEGER NOT NULL DEFAULT 0;
`,
// v11 (mini-run findings Д1 + Д4, 25.07): two facts the mini-run proved are produced and then lost.
//
// • chunk_status.first_flag_reason — the disposition reason of the FIRST attempt, kept whenever the
// row's own verdict is no longer that failure (a regenerate or the hop recovered the chunk, the
// re-attack failed differently, or a run STOPPED over the position and left a mark). «Recovered»
// alone stood here and was too narrow: a reader who takes a non-empty value for a recovery counts
// cures that did not happen, which is why pipeline/quality.go also asks whether the row produced
// text (its final_hash). Without it a recovered
// row is written `ok` with an empty flag_reason and the primary failure vanishes from every
// durable surface: the mini-run's echo (1 of 20 drafts, cjk_artifact, escalation-recovered) was
// reported as echo_draft=0.0%. Since the echo rate is the only numeric watchman of the DeepSeek
// echo mine (D18/D19), losing it hides the class it exists to watch. Re-derived every run from the
// stored checkpoints (self-heals on resume); observability only, never a verdict, never wire.
//
// • retrieval_state.banknote_detail — the PARSED ⟦TM-BANK-v1⟧ entries (src/dst/type) of the chunk,
// the WHAT channel D39.36 found is parsed and thrown into `_`. It mirrors the three *_detail
// JSON columns already on this row (postcheck / style / trust-gate): re-derived deterministically
// each run from the raw checkpoint, self-healing, never wire-load-bearing. The bank-mining stop
// reads it to attach the model's PROPOSED dst to the owner's signature map — proposals stay
// status:auto, so delivering the WHAT grants no trust (the owner still signs).
//
// Both are additive with defaults ⇒ every prior row and the WIRE are unchanged.
`
ALTER TABLE chunk_status ADD COLUMN first_flag_reason TEXT NOT NULL DEFAULT '';
ALTER TABLE retrieval_state ADD COLUMN banknote_detail TEXT NOT NULL DEFAULT '';
`,
// v12 (pack-20 / D39.42 п.4): the $0 observability channel of the UNVERIFIED wire.
//
// In the auto mode the bank legitimately carries renderings nobody signed, and the owner's decision is
// that they reach the model. That makes a question answerable that never was: when an unsigned
// rendering is actually PUT IN FRONT of the model, does the model go along with it? (⚠ The pair was
// born measuring a wire that MARKED such a row ⟨проверить⟩ and invited the model to reject it; row 134
// took the mark off, so it now measures agreement with a row shown as law — still the only channel
// that says whether an unsigned rendering survives into the text, and no longer a measure of granted
// judgement. The columns and their arithmetic are unchanged.)
//
// • n_unverified_shown — unsigned rows whose src fired in THIS chunk (the denominator);
// • n_unverified_followed — of those, the ones whose rendering appears in the output.
//
// ⚠ The question above is narrower than the pair answers, and the bullets are the exact rule: a STICKY
// carry is put in front of the model and is NOT counted, because its src is in the previous chunk and
// the output is not expected to answer for it (membank.PostcheckResult). "Fired here", not "shown".
//
// The pair is deliberately measured ON THE WIRE THAT SHOWED THE ROW: before pack-20 the ambiguous
// counter was collected on the editor wire, whose block was CONFIRMED-only — it counted deviations
// from something the editor was never shown. Neither number gates anything: an unverified row is a
// candidate the model is entitled to reject (external-review major #1), so this is a measurement of
// the channel, never a judgement of the output. Additive with defaults ⇒ prior rows and the WIRE are
// unchanged.
`
ALTER TABLE retrieval_state ADD COLUMN n_unverified_shown INTEGER NOT NULL DEFAULT 0;
ALTER TABLE retrieval_state ADD COLUMN n_unverified_followed INTEGER NOT NULL DEFAULT 0;
`,
// v13 (pack-19 / D39.55): the two D21 record types the bank was missing — the per-character VOICE
// profile and the ordered ADDRESS pair. They are TABLES rather than glossary columns for one hard
// reason: a character's term row and its voice profile share (src, sense, since_ch, until_ch), which
// is the glossary UNIQUE key, so the profile could not coexist with the term it describes without
// rebuilding that constraint — forbidden by the append-only discipline above.
//
// They are nevertheless BANK CONTENT, not a store beside the bank: their rows are replaced in the SAME
// transaction as the glossary and fold into the SAME memory_version (conditionally — see
// membank.ComputeVersionScopedIn). Giving them a snapshot key of their own would classify a signature
// as moveOther and cost a whole edit wave instead of the touched units (pipeline/repin.go).
//
// address_pairs is the MATERIALIZATION of the ты/вы transition journal (D7-Amendment-1, D21 п.2), not a
// second source of truth: a row IS a journal entry (register valid from since_ch to until_ch) and the
// "current register at chapter N" is the projection membank computes, never a stored state. A ты↔вы
// switch is a second row with a non-overlapping window, exactly like a spoiler handoff.
//
// Both reference characters by the STABLE key (src, sense) rather than glossary.id: that id is a fresh
// autoincrement on every replace (see v5), so an id link would silently re-point after any re-seed.
`
CREATE TABLE IF NOT EXISTS voice_profiles (
id INTEGER PRIMARY KEY,
book_id TEXT NOT NULL,
src TEXT NOT NULL, -- character identity, half 1 (the glossary term's src)
sense TEXT NOT NULL DEFAULT '', -- character identity, half 2
register TEXT NOT NULL DEFAULT '', -- one line: register + tone
self_ref TEXT NOT NULL DEFAULT '', -- the character's own self-designation (D7 first_person)
address_default TEXT NOT NULL DEFAULT '', -- informal|formal|'' — the T/V default outside the pair registry
lexicon_markers TEXT NOT NULL DEFAULT '', -- JSON []string: characteristic words
ng_lexicon TEXT NOT NULL DEFAULT '', -- JSON []string: words this character never says
exemplars TEXT NOT NULL DEFAULT '', -- JSON []string: 3-5 approved target replies
brightness TEXT NOT NULL DEFAULT '', -- injection priority hint (D21 п.1в: hypothesis, not a rule)
since_ch INTEGER NOT NULL DEFAULT 0, -- manner-change window, same axis as the glossary spoiler window
until_ch INTEGER NOT NULL DEFAULT 0,
UNIQUE (book_id, src, sense, since_ch, until_ch)
);
CREATE INDEX IF NOT EXISTS voice_profiles_book_idx ON voice_profiles (book_id);
CREATE TABLE IF NOT EXISTS address_pairs (
id INTEGER PRIMARY KEY,
book_id TEXT NOT NULL,
speaker_src TEXT NOT NULL,
speaker_sense TEXT NOT NULL DEFAULT '',
addressee_src TEXT NOT NULL,
addressee_sense TEXT NOT NULL DEFAULT '',
register TEXT NOT NULL, -- informal|formal — ABSTRACT; the surfaces are target data
form TEXT NOT NULL DEFAULT '', -- the address form («молодой господин», a title)
closeness TEXT NOT NULL DEFAULT '', -- one word of hierarchy/closeness (why the register is what it is)
since_ch INTEGER NOT NULL DEFAULT 0, -- a ты<->вы switch is a STORY event: a second row, new window
until_ch INTEGER NOT NULL DEFAULT 0,
UNIQUE (book_id, speaker_src, speaker_sense, addressee_src, addressee_sense, since_ch, until_ch)
);
CREATE INDEX IF NOT EXISTS address_pairs_book_idx ON address_pairs (book_id);
`,
// v14 (pack-19): the per-chunk observability of the two new $0 flaggers, riding the same
// recomputed-every-run row as the glossary post-check and the cheap style gates.
//
// • n_voice_flags / voice_detail — the deterministic T/V + voice-marker flagger (gates.voice);
// • n_spoiler_leaks / spoiler_leak_detail — a rendering the spoiler window REJECTED for this
// chapter that appeared in the output anyway (the reveal half of D21 п.3).
//
// Neither is ever a disposition, and both are re-derived deterministically each run (self-healing on
// resume), so they are observability exactly like their siblings. Additive with defaults ⇒ every prior
// row and the WIRE are unchanged.
`
ALTER TABLE retrieval_state ADD COLUMN n_voice_flags INTEGER NOT NULL DEFAULT 0;
ALTER TABLE retrieval_state ADD COLUMN voice_detail TEXT NOT NULL DEFAULT '';
ALTER TABLE retrieval_state ADD COLUMN n_spoiler_leaks INTEGER NOT NULL DEFAULT 0;
ALTER TABLE retrieval_state ADD COLUMN spoiler_leak_detail TEXT NOT NULL DEFAULT '';
`,
// v15 (row 103): the run-event OUTBOX. A line of `events.jsonl` is a projection of a row committed
// here — that is the ratified form of the seam (D39.106 §2), and it is what lets the money event ride
// the very transaction that settled the money. The number is assigned inside that transaction, so a
// rollback leaves no hole in a stream whose reader treats a hole as fatal; the exact bytes are kept,
// so a retry after a failed journal write re-projects the identical line rather than a fresher one.
//
// It is a BUFFER, not an archive: outbox.go drops every other run's rows at open, so a book that has
// been resumed a hundred times carries one run's worth of them.
`
CREATE TABLE IF NOT EXISTS events_outbox (
engine_run_id TEXT NOT NULL, -- the PROCESS's id (trace id); seq restarts at 1 per process
seq INTEGER NOT NULL,
-- Non-empty for an event that must be announced ONCE for the life of the book however many
-- processes it takes (today: one per unit per wave). Those rows OUTLIVE their run — they are the
-- emission ledger, not a buffer — which is what makes a crash between a unit's disposition and
-- its announcement recoverable instead of a count lost for good.
once_key TEXT NOT NULL DEFAULT '',
line TEXT NOT NULL, -- the NDJSON line, verbatim, without its newline
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (engine_run_id, seq)
);
CREATE UNIQUE INDEX IF NOT EXISTS events_outbox_once ON events_outbox (once_key) WHERE once_key <> '';
`,
// v16 — the bank-stop memory (the D39.144 flag model): every normalized surface a --verify-bank stop
// has already PRESENTED to the owner — each presented cluster's src and aliases. The stop fires only
// on a cluster whose WHOLE surface set is outside this set (pipeline/mining.go hasUnpresentedCluster —
// one shared member means "seen"; keyed on the representative alone, a cluster would pay one extra
// stop every time it grows or re-ranks a member), so
// the flag pays with one stop per novelty instead of re-stopping on everything still undecided. Book
// state, like the reject list, and under the same growth law: memory ⊆ the book's mined vocabulary,
// and it is never trimmed — a trim is a re-stop through the back door.
`
CREATE TABLE IF NOT EXISTS bank_stop_presented (
book_id TEXT NOT NULL,
surface TEXT NOT NULL, -- text.NormalizeSourceKey form, the key the emission compares surfaces in
PRIMARY KEY (book_id, surface)
);
`,
// v17 (row 417): WHAT EACH WAVE WAS SHOWN — the axis retrieval_state does not have.
//
// The two waves select over DIFFERENT banks: the draft over the BASE bank with mined rows excluded, the
// editor over the ENRICHED one. retrieval_state is keyed (book_id, chapter, chunk_idx), one row per
// chunk, so their traces land on the same row and the editor's half is simply lost — the unit merge
// writes the editor's post-check onto the LEADER's DRAFT row and leaves injected_ids, n_exact_hits and
// n_evicted carrying the draft's numbers (waverun.go mergeUnitRetrievalState). The editor wave's
// EVICTIONS were recorded nowhere at all. So "did the bank reach the editor's prompt" — the question
// the paid run of 11.09 could not answer about four terms whose shipped rendering was not the bank's —
// was unanswerable from the schema, not from a missing counter.
//
// This is a SEPARATE table rather than a wider key on the old one, and the reason is not migration risk
// (a neighbouring table bumps the version just the same): changing a PRIMARY KEY REWRITES the rows that
// exist, and every stored run would have to be re-derived to mean what the new key says. An additive
// table leaves them alone and answers the new question for every run from here on.
//
// Recomputed deterministically each run like its neighbour ($0 — the matcher is free), so it self-heals
// on resume and is never wire-load-bearing: it records what the injection DID, it does not change the
// request.
`
CREATE TABLE IF NOT EXISTS wave_selection (
book_id TEXT NOT NULL,
chapter INTEGER NOT NULL,
chunk_idx INTEGER NOT NULL, -- the draft chunk, or the edit unit's LEADER chunk
wave TEXT NOT NULL, -- draft | edit — the axis the old key is missing
snapshot_id TEXT NOT NULL,
n_exact_hits INTEGER NOT NULL DEFAULT 0,
n_sticky INTEGER NOT NULL DEFAULT 0,
n_ambiguous_flagged INTEGER NOT NULL DEFAULT 0,
n_spoiler_blocked INTEGER NOT NULL DEFAULT 0,
n_evicted INTEGER NOT NULL DEFAULT 0,
injected_srcs TEXT NOT NULL DEFAULT '', -- JSON array of the SOURCE surfaces this wave showed
evicted_srcs TEXT NOT NULL DEFAULT '', -- JSON array of the surfaces the token budget dropped
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (book_id, chapter, chunk_idx, wave)
);
CREATE INDEX IF NOT EXISTS wave_selection_book_idx ON wave_selection (book_id, wave);
`,
// v18 (row 422): WHAT ATE THE BUDGET. `reasoning_tokens` means «thinking billed ON TOP of the
// completion», which is 0 by definition on a subset-billing provider — so on DeepSeek, the only
// provider the shipping configs call, it has carried 0 for the entire life of the project, and a
// reader asking «did thinking eat max_tokens» got a zero that looks measured and is not.
//
// This column answers the other question: how much of the completion WE ALREADY PAID FOR was
// thinking rather than the answer. It is the provider's own number (DeepSeek reports it in
// completion_tokens_details on every reply), it prices nothing, and it is the evidence behind the
// cold run of 11.09, where three calls bought 8496/8496/16000 completion tokens and returned zero
// characters of text.
//
// ⚠ NULLABLE ON PURPOSE, against the NOT NULL DEFAULT 0 of every column around it. A subset
// provider that reports the field and a call that genuinely did not think both produce 0, and a
// provider that reports nothing must not be spelled the same way — that is the defect this
// migration exists to remove, and a DEFAULT 0 would re-introduce it on the first row. NULL = the
// question was not answered; every row written before this column is NULL for exactly that reason.
`
ALTER TABLE request_log ADD COLUMN reasoning_in_completion INTEGER;
`,
}
// DESIGN NOTE (D21.10 → BUILT by pack-19 / D39.55; kept as the record of what each type is FOR).
//
// research/15 (D21) ratified three memory MECHANISMS extending the bank. Two are now schema (v13); the
// third turned out to need none.
//
// • voice_profile (D21.1) — BUILT as `voice_profiles` (v13), keyed by the STABLE (src, sense) rather
// than the reserved (book_id, term_id): glossary.id is a fresh autoincrement per replace. Still
// FROZEN per-job (self-populating exemplars = mid-run memory append = book re-pay under D8/D15.1);
// the condition for lifting it — D15.2 landing — has NOT happened (verdictSnapshotID/guard_hash do
// not exist in this engine, only in comments).
//
// • address_pair (D21.2) — BUILT as `address_pairs` (v13). The reserved shape guessed a derived
// projection over a separate `speech_transitions` journal; the built one collapses the two, because a
// windowed row IS a journal entry and the "register at chapter N" projection is computed, never
// stored. So there is still exactly one source of truth, with one table instead of two.
// `glossary.speech` (the static v5 note) is superseded and DEPRECATED — see its column comment.
//
// • reveal_ch + pre/post-reveal aliases (D21.3) — NOT built, and deliberately: the pre/post rendering
// pair is already expressible as two glossary rows with NON-OVERLAPPING windows (the UNIQUE key
// admits them, membank.windowsOverlap permits them, spoilerBlocked picks exactly one), and aliases
// inherit their row's window, so a phase tag would be a third way to say what two already say. What
// was genuinely missing is the LEAK CHECK — a rejected rendering appearing in the output anyway —
// which is membank.SpoilerLeaks over the selection's Rejected set, no schema at all. Scope amended
// by D39.55 (form, not intent).
// SchemaHead is the schema version this binary migrates a project database to. It is the version every
// read-only open demands (OpenReadOnly) and the version `tmctl migrate` brings a project to.
func SchemaHead() int { return len(migrations) }
// SchemaMismatchError is a project database whose recorded schema version is not this binary's. It is a
// TYPE rather than a message because it is the one open failure a caller can repair by itself, and it
// could not act on the difference while every failure arrived as the same text and the same exit 1.
//
// The engine's read-only opens never migrate — only a write open does — so upgrading the binary leaves
// every existing book unreadable until something writes to it. The platform calls `tmctl status --json`
// before each spawn and for the money, that call refuses the older schema, and the write command that
// would have migrated the project never comes: the deploy deadlock of backlog row 174.
//
// The numbers therefore travel BOTH in the type (for a Go caller) and in the message (for the shell),
// where they carry the stable token
//
// schema_mismatch found=<N> expected=<M>
//
// Found < Expected is the direction `tmctl migrate` repairs — "caught it, migrated, retried" instead of
// stopping the world. Found > Expected is a binary OLDER than the project, which no command here can fix
// (the operator upgrades tmctl), so a caller that blindly re-ran the migration would loop forever.
type SchemaMismatchError struct {
Path string
Found int
Expected int
}
func (e *SchemaMismatchError) Error() string {
repair := "the database is NEWER than this tmctl — update the binary (an old build must neither read nor write a newer schema)"
if e.Stale() {
repair = "the database is older than this tmctl — run `tmctl migrate --config <book.yaml>` (read-only opens never migrate)"
}
return fmt.Sprintf("store: schema_mismatch found=%d expected=%d path=%s — %s", e.Found, e.Expected, e.Path, repair)
}
// Stale reports whether the database is BEHIND the binary — the one direction a migration repairs.
func (e *SchemaMismatchError) Stale() bool { return e.Found < e.Expected }
// Migration is the schema transition a write open performed: From is the version the project database
// was recorded at, To the version it is at now. From == To is a database that was already at head.
type Migration struct {
From int
To int
}
// Migrate applies a project database's pending migrations and reports the transition — the WRITE open
// (exclusive flock · migration chain · stale-reservation recovery) with no run behind it, which is all
// `tmctl migrate` is (backlog row 174). It sends nothing, spends nothing and holds the flock only for
// as long as the migration takes.
//
// A database already at head is a no-op; one that does not exist is CREATED at head, exactly as the
// first touch of a project by any other write command creates it. A database a NEWER binary wrote is
// refused (SchemaMismatchError) before anything of this process reaches the file.
//
// beforeApply, when non-nil, runs INSIDE the project's exclusive lock, after the pending set is known
// and before the first step is applied — and only when there IS a step to apply. That is where a caller
// takes its restore point, and the placement is the whole point of the parameter: a backup taken before
// the lock is a full copy written for a migration that a live run may then refuse (litter on every
// retry of an exit-12), and one taken after the steps is not a restore point at all. An error from it
// aborts the migration with nothing applied.
func Migrate(path string, beforeApply func(Migration) error) (Migration, error) {
s, err := open(path, beforeApply)
if err != nil {
return Migration{}, err
}
applied := s.applied
if err := s.Close(); err != nil {
return applied, fmt.Errorf("store: close %s after migrating: %w", path, err)
}
return applied, nil
}
// schemaVersion reads the recorded schema version through db.
//
// A database that exists but has no schema_version TABLE — a fresh file, or a process killed between
// creating one and committing the first migration — is version 0, which is what it is: nothing has been
// applied. That is asked as a separate question rather than read off a failed SELECT, so a file that is
// not a database at all still fails loudly instead of reading as an empty project.
func schemaVersion(ctx context.Context, db *sql.DB) (int, error) {
var recorded int
if err := db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'schema_version'`).Scan(&recorded); err != nil {
return 0, err
}
if recorded == 0 {
return 0, nil
}
var current int
if err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(&current); err != nil {
return 0, err
}
return current, nil
}
// migrate runs all pending migrations on the write pool, one transaction per
// step, recording each in schema_version.
//
// Every phase gets its own opTimeout budget, and the caller's seam gets NONE. That is not tidiness: the
// seam exists to take a restore point, whose cost scales with the book, and while it shared one deadline
// with the steps a large project made the migration fail on the copy it had just paid for (see open).
func (s *Store) migrate(path string, beforeApply func(Migration) error) error {
current, err := s.schemaBaseline()
if err != nil {
return err
}
// A project a NEWER binary wrote is REFUSED rather than opened. The loop below would silently do
// nothing with it — there is no step left to apply — and the caller would then read and, worse,
// WRITE a schema through code that predates it. OpenReadOnly has always refused that direction
// ("reading a newer schema with old code is unsafe"); the write path used to accept it, which is
// the more dangerous half of the same fact. It is refused here, BEFORE recoverReservations, so a
// refused open leaves the file exactly as it found it.
if current > len(migrations) {
return &SchemaMismatchError{Path: path, Found: current, Expected: len(migrations)}
}
// The caller's seam (Migrate): the lock is held, the pending set is known, nothing has been applied,
// and no deadline of this package is running — the work is the caller's and so is its bounding.
if beforeApply != nil && current < len(migrations) {
if err := beforeApply(Migration{From: current, To: len(migrations)}); err != nil {
return err
}
}
for v := current; v < len(migrations); v++ {
if err := s.applyStep(v); err != nil {
return err
}
}
s.applied = Migration{From: current, To: len(migrations)}
return nil
}
// schemaBaseline makes sure the version table exists and reports what is recorded in it.
func (s *Store) schemaBaseline() (int, error) {
ctx, cancel := opContext()
defer cancel()
if _, err := s.w.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)`); err != nil {
return 0, fmt.Errorf("store: schema_version: %w", err)
}
current, err := schemaVersion(ctx, s.w)
if err != nil {
return 0, fmt.Errorf("store: read version: %w", err)
}
return current, nil
}
// applyStep applies migrations[v] and records v+1 in ONE transaction (DDL is transactional in SQLite),
// so a process that dies mid-step leaves the database at its previous version rather than half-applied.
func (s *Store) applyStep(v int) error {
ctx, cancel := opContext()
defer cancel()
tx, err := s.w.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("store: begin migration %d: %w", v+1, err)
}
if _, err := tx.ExecContext(ctx, migrations[v]); err != nil {
_ = tx.Rollback()
return fmt.Errorf("store: apply migration %d: %w", v+1, err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_version (version) VALUES (?)`, v+1); err != nil {
_ = tx.Rollback()
return fmt.Errorf("store: record migration %d: %w", v+1, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("store: commit migration %d: %w", v+1, err)
}
return nil
}