test(sqlite): red tests for the pinned-engine, busy-drop, swallowed-schema family (T17 finding 2)
Finds recorded red on the pre-fix tree (audit basea52624b3re-verified on current master79ff61b3): pin-floor lint naming the seven stale modules (v1.28.0, bundled SQLite 3.41.2 below the 3.51.3 boundary-law floor), connect-surface contract (no busy timeout anywhere, synchronous=NORMAL in three modules), two-writer concurrency regression dropping instantly with SQLITE_BUSY, and connect() swallowing every schema-setup error. Plans doc records finding -> test -> commit per the bugfix contract.
This commit is contained in:
1 parent
79ff61b361
commit
6296ea583e
6 files changed
+645
No files matched your search
@@ -0,0 +1,100 @@
|
||||
# 2026-10-26 — SQLite floor repair (T17 audit finding 2, red-first)
|
||||
|
||||
Base: gitea/master 79ff61b3 (frozen audit base a52624b3; master moved by one
|
||||
release-bump commit — every pin and behavior re-verified on CURRENT code
|
||||
before a single line was written). Worktree:
|
||||
`~/.config/compose/worktrees/rune/fix-sqlite-floor` (branch
|
||||
`fix/sqlite-floor-audits`, created exclusively for this wave).
|
||||
|
||||
## The findings (T17, finding 2 + behavior family)
|
||||
|
||||
Plain language for the owner: seven Go modules that write our shared
|
||||
databases (the memory store, caches, auto-task ledger, abstraction state)
|
||||
compile against a **five-generation-stale SQLite engine**, have **no wait
|
||||
policy for lock contention** (the Python side waits 5 seconds; Go fails
|
||||
instantly with "database is locked"), and **silently swallow every schema
|
||||
upgrade error at startup** (including the unavoidable "duplicate column"
|
||||
error from repeated migrations) — plus two modules run **fsync-light writes**
|
||||
(`synchronous=NORMAL`) against the crash-anywhere durable-write law.
|
||||
|
||||
1. **PIN FLOOR** — modules pin `modernc.org/sqlite v1.28.0` (bundled engine
|
||||
SQLite 3.41.2; owner floor = bundled SQLite ≥ 3.51.3, the WAL-reset fix,
|
||||
per `docs/architecture/boundary-law.md` line 116): persona-definition,
|
||||
memory-store-deep, memory-ops, connection-pool, cache, auto_tasks,
|
||||
abstraction. Compliant references: memory-continuity, curator (v1.49.1 =
|
||||
SQLite 3.53.0).
|
||||
2. **BUSY TIMEOUT** — no Go store waits on contention; the Python store
|
||||
waits 5s (`src/rune/memory_store.py:153` = `sqlite3.connect` default
|
||||
timeout). Concurrent agent writers on the shared
|
||||
`~/.rune/mempalace/memories.db` fail instantly (`SQLITE_BUSY`, error 5).
|
||||
3. **LOUD SCHEMA SETUP** — `memory-store-deep/connect()` executed the WAL
|
||||
switch, CREATEs, and ALTERs with every error dropped
|
||||
(main.go:74-101 pre-fix). The `ALTER TABLE ... ADD COLUMN` pair errors
|
||||
with "duplicate column" on every already-migrated store — never noticed
|
||||
because errors were swallowed. The honest fix ports the Python store's
|
||||
conditional migration (`src/rune/memory_store.py:246-263`): read
|
||||
`PRAGMA table_info`, ALTER only missing columns.
|
||||
4. **SYNCHRONOUS=NORMAL** — memory-ops, memory-store-deep, cache ran
|
||||
fsync-light commits; the house standard is the SQLite default FULL
|
||||
(crash-anywhere durable writes). No Go module ever set FULL at the
|
||||
driver layer, and post-open `db.Exec("PRAGMA ...")` pragmas apply to a
|
||||
single pooled connection only — the policy is now per-connection DSN
|
||||
(`_pragma` query params, documented by the modernc.org/sqlite driver at
|
||||
driver.go:49-52), so it survives `database/sql` pooling.
|
||||
|
||||
## Red tests (written FIRST, run RED on the pre-fix tree, evidence frozen)
|
||||
|
||||
| # | Test | RED evidence on pre-fix code |
|
||||
|---|------|------------------------------|
|
||||
| F1a | `tests/test_sqlite_pin_floor.py::test_every_sqlite_module_bundles_at_least_the_owner_floor` | FAIL naming exactly the seven modules at v1.28.0 (`bundles a pre-floor engine`) |
|
||||
| F1b | `tests/test_sqlite_pin_floor.py::test_all_sqlite_modules_share_one_engine_version` | FAIL: two divergent engines (1.28.0 / 1.49.1) |
|
||||
| F2a | `tests/test_go_connect_pragmas.py::test_every_sqlite_open_carries_a_busy_timeout_dsn` | FAIL listing all 10 files with `sql.Open` sites missing `_pragma=busy_timeout` |
|
||||
| F2b | `tests/test_go_connect_pragmas.py::test_no_synchronous_normal_pragma_in_go_sources` | FAIL: cache.go, memory-ops/main.go, memory-store-deep/main.go |
|
||||
| F2c | `src/go/memory-ops/busy_timeout_test.go::TestConcurrentWritersBothSucceedWithinBusyTimeout` | FAIL: both writers dropped at **0.00s** with `database is locked (5) (SQLITE_BUSY)`, 0/2 rows landed |
|
||||
| F3 | `src/go/memory-store-deep/schema_errors_test.go::TestConnectPropagatesSchemaSetupErrors` | FAIL: `connect() returned no error for an unprovisionable store` |
|
||||
| F3b | `.../TestConnectConditionalMigrationOnLegacyStore` | supporting green-path test (idempotent migration, legacy data preserved) |
|
||||
|
||||
F2c exercises the fix path end-to-end: writer A holds the write lock and
|
||||
releases at 2s; writers B and C go through the module's `connect()` and must
|
||||
both land within the 5s busy budget on a synthetic DB in a private temp dir
|
||||
(isolated GOCACHE, `GOPROXY=off`, no production stores, no network).
|
||||
|
||||
## The fix (root cause, not symptom)
|
||||
|
||||
- **One engine everywhere**: all seven modules → `modernc.org/sqlite
|
||||
v1.49.1` (bundled SQLite 3.53.0 ≥ 3.51.3 floor — source: the module's
|
||||
own CHANGELOG, `modernc.org/sqlite@v1.49.1/CHANGELOG.md`, entry
|
||||
"2026-04-17 v1.49.0: Upgrade to SQLite 3.53.0",
|
||||
https://sqlite.org/releaselog/3_53_0.html). go.sum regenerated by
|
||||
`go mod tidy -go=1.25.0` (offline: `GOPROXY=off GOSUMDB=off`) — the
|
||||
resulting dep graph is byte-identical to the compliant memory-continuity
|
||||
module's (libc v1.72.0 …). The go directive moves 1.21 → 1.25.0 to match
|
||||
the driver's requirement (repo toolchain 1.27.1 per go-version.txt; CI
|
||||
asserts the same).
|
||||
- **Busy policy at every open site** (15 sites / 10 files): DSN carries
|
||||
`_pragma=busy_timeout%3d5000` — 5s, matching Python's connect wait.
|
||||
Writers additionally get `_pragma=journal_mode(WAL)` (behavior preserved)
|
||||
and, where NORMAL had been set, `_pragma=synchronous(FULL)` (law
|
||||
alignment). Read backends get the shared busy wait only (ro conns must
|
||||
not attempt journal changes; silent-nil oracle failure shapes untouched).
|
||||
- **Loud, idempotent schema setup**: memory-store-deep provisioning split
|
||||
into `provisionSchema(db)` with every statement checked; batch-1 columns
|
||||
migrate conditionally (`PRAGMA table_info` first). cache's two
|
||||
`CREATE INDEX` statements now checked too.
|
||||
- **Structural CI guard**: the two Python lint tests fail the gate if any
|
||||
future module pins below the floor, pins a second engine, opens sqlite
|
||||
without a ≥5000ms busy DSN, or reintroduces synchronous=NORMAL.
|
||||
|
||||
## Discharge record (finding → red test → commit)
|
||||
|
||||
FILLED AFTER COMMITS LAND (see §Discharge-final below).
|
||||
|
||||
## Constraints honored
|
||||
|
||||
- Worktree exclusive; existing worktrees (release-reporting,
|
||||
/mnt/workdrive/rune, …) untouched.
|
||||
- Offline: all go tooling ran with `GOPROXY=off`/`GOSUMDB=off`; no
|
||||
production stores, services, or network involved.
|
||||
- Red-first per docs/bugfix-contract.md; tests are frozen regressions.
|
||||
- Admission wave: `scripts/admit_wave_sqlite_floor.py`
|
||||
(classify→activate→admit) naming the seven modules and the lint test.
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Admission dispatch for the sqlite-floor repair wave (T17 finding 2).
|
||||
|
||||
classify + activate + admit (the headless scaffolding entry law,
|
||||
tests/test_construction_admission.py pattern): a bounded repair raising the
|
||||
seven stale modernc.org/sqlite pins to the owner floor bundle, setting the
|
||||
5s busy-timeout policy at every Go open site, making schema setup loud and
|
||||
idempotent, and shipping the CI-enforced pin-lint + pragma contract tests;
|
||||
user-dispatch approved.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import construction_activation as cact # noqa: E402
|
||||
from rune import construction_admission as cadm # noqa: E402
|
||||
from rune import construction_mode as cmode # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
OBJECTIVE = (
|
||||
"bounded repair (T17 finding 2, red-first): raise the seven stale "
|
||||
"modernc.org/sqlite pins (persona-definition, memory-store-deep, "
|
||||
"memory-ops, connection-pool, cache, auto_tasks, abstraction — all "
|
||||
"v1.28.0, bundling SQLite 3.41.2) to the owner floor bundle v1.49.1 "
|
||||
"(SQLite 3.53.0 >= 3.51.3 boundary-law floor), set a 5s "
|
||||
"PRAGMA busy_timeout policy at every Go sqlite open site (Python store "
|
||||
"parity, memory_store.py:153), replace synchronous=NORMAL with the "
|
||||
"durable-write FULL standard, make memory-store-deep schema setup loud "
|
||||
"and idempotent (conditional migration port of memory_store.py:246-263), "
|
||||
"and land CI-enforced guards: the pin-lint test and the Go connect "
|
||||
"pragma contract test, plus a two-concurrent-writers regression test "
|
||||
"on a synthetic temp-store"
|
||||
)
|
||||
AFFECTED = [
|
||||
"src/go/persona-definition/go.mod",
|
||||
"src/go/memory-store-deep/go.mod",
|
||||
"src/go/memory-ops/go.mod",
|
||||
"src/go/connection-pool/go.mod",
|
||||
"src/go/cache/go.mod",
|
||||
"src/go/auto_tasks/go.mod",
|
||||
"src/go/abstraction/go.mod",
|
||||
"src/go/memory-ops/main.go",
|
||||
"src/go/memory-store-deep/main.go",
|
||||
"src/go/memory-store-deep/gaps_io.go",
|
||||
"src/go/memory-store-deep/gaps_stats.go",
|
||||
"src/go/connection-pool/main.go",
|
||||
"src/go/cache/cache.go",
|
||||
"src/go/auto_tasks/auto_tasks.go",
|
||||
"src/go/abstraction/abstraction.go",
|
||||
"src/go/abstraction/replay.go",
|
||||
"src/go/curator/store.go",
|
||||
"src/go/memory-continuity/store.go",
|
||||
"src/go/persona-definition/gaps.go",
|
||||
"tests/test_sqlite_pin_floor.py",
|
||||
"tests/test_go_connect_pragmas.py",
|
||||
"src/go/memory-ops/busy_timeout_test.go",
|
||||
"src/go/memory-store-deep/schema_errors_test.go",
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
store = os.path.join(ROOT, "store", "scaffold")
|
||||
scaffold = sc.Scaffold(store, "rune")
|
||||
|
||||
md = cmode.classify_mode(
|
||||
OBJECTIVE,
|
||||
target_project="rune",
|
||||
project_exists=True,
|
||||
affected_surface=AFFECTED,
|
||||
)
|
||||
print("classified mode:", md.mode)
|
||||
|
||||
need = {
|
||||
"need_id": "need-wave-sqlite-floor",
|
||||
"objective": OBJECTIVE,
|
||||
"origin": "worker",
|
||||
"project": "rune",
|
||||
"task_ref": "wave-sqlite-floor",
|
||||
"affected_surface": AFFECTED,
|
||||
"evidence_refs": [
|
||||
"docs/architecture/boundary-law.md",
|
||||
"src/rune/memory_store.py",
|
||||
"src/go/memory-store-deep/main.go",
|
||||
"plans/2026-10-26-sqlite-floor-audit-t17-finding2.md",
|
||||
],
|
||||
}
|
||||
act = cact.activate(md, need=need, scaffold=scaffold,
|
||||
builder="wave-sqlite-floor")
|
||||
print("activated:", act.get("activated"), act.get("mode"))
|
||||
|
||||
env = cadm.admit(
|
||||
act,
|
||||
objective=OBJECTIVE,
|
||||
builder="wave-sqlite-floor",
|
||||
affected_surface=AFFECTED,
|
||||
evidence_refs=need["evidence_refs"],
|
||||
origin="worker",
|
||||
task_ref="wave-sqlite-floor",
|
||||
expected_new_entities=[
|
||||
"tests/test_sqlite_pin_floor.py (CI-enforced pin lint)",
|
||||
"tests/test_go_connect_pragmas.py (connect-surface contract)",
|
||||
],
|
||||
expected_runtime_effects=[
|
||||
"all nine modernc.org/sqlite modules pin one release bundling "
|
||||
"SQLite >= 3.51.3 (v1.49.1 = 3.53.0)",
|
||||
"concurrent writers on a shared store wait up to 5s instead of "
|
||||
"dropping with instant SQLITE_BUSY",
|
||||
"schema setup failures propagate instead of being swallowed",
|
||||
"Go stores commit with synchronous=FULL (durable-write law)",
|
||||
],
|
||||
verification_obligations=[
|
||||
"pytest tests/test_sqlite_pin_floor.py tests/test_go_connect_pragmas.py green",
|
||||
"go test ./... in the nine sqlite modules green (frozen suites)",
|
||||
"henxels check holds for the changed surfaces",
|
||||
],
|
||||
approval_ref="user:T17 finding 2 repair dispatch",
|
||||
contract_id="ADMIT-wave-sqlite-floor",
|
||||
project_store=store,
|
||||
project_id="rune",
|
||||
)
|
||||
print(json.dumps(env, indent=2, default=str)[:2400])
|
||||
return 0 if env.get("success") else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,141 @@
|
||||
package main
|
||||
|
||||
// busy_timeout_test.go — T17-finding-2 concurrency regression (audit
|
||||
// a52624b3): two simultaneous writers on one shared store must BOTH succeed
|
||||
// within the 5s busy timeout (the Python store's connect wait,
|
||||
// src/rune/memory_store.py:153), never again dropping instantly with
|
||||
// "database is locked (5)". Synthetic DB in a private temp dir; the writer
|
||||
// under test is connect() itself. Red on the pre-fix connect(): writer B
|
||||
// fails while A holds the write lock.
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const busyFixtureSchema = `CREATE TABLE IF NOT EXISTS entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
text TEXT NOT NULL,
|
||||
importance REAL DEFAULT 0.5,
|
||||
created_at TEXT,
|
||||
last_accessed TEXT,
|
||||
topic TEXT DEFAULT '',
|
||||
source TEXT DEFAULT '',
|
||||
method TEXT DEFAULT 'curator',
|
||||
status TEXT DEFAULT 'active',
|
||||
confidence REAL DEFAULT 0.7,
|
||||
temperature REAL DEFAULT 1.0,
|
||||
always_on INTEGER DEFAULT 0,
|
||||
priority INTEGER DEFAULT 5,
|
||||
tier TEXT DEFAULT 'warm',
|
||||
memory_type TEXT,
|
||||
wash_count INTEGER DEFAULT 0,
|
||||
superseded_by INTEGER,
|
||||
rock_status TEXT DEFAULT 'washable'
|
||||
)`
|
||||
|
||||
func TestConcurrentWritersBothSucceedWithinBusyTimeout(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("RUNE_MEMORY_DIR", dir)
|
||||
store := filepath.Join(dir, ".rune", "mempalace", "memories.db")
|
||||
|
||||
// Fixture: entries schema + persistent WAL via an independent handle —
|
||||
// mirroring the real shared store (the Python connect provisions WAL;
|
||||
// journal_mode is a persistent property, so later conns don't race a
|
||||
// journal-mode CONVERSION, only the write lock).
|
||||
if err := os.MkdirAll(filepath.Dir(store), 0o755); err != nil {
|
||||
t.Fatalf("fixture dir: %v", err)
|
||||
}
|
||||
setup, err := sql.Open("sqlite", store)
|
||||
if err != nil {
|
||||
t.Fatalf("open fixture: %v", err)
|
||||
}
|
||||
if _, err := setup.Exec(busyFixtureSchema); err != nil {
|
||||
t.Fatalf("fixture schema: %v", err)
|
||||
}
|
||||
var mode string
|
||||
if err := setup.QueryRow("PRAGMA journal_mode=WAL").Scan(&mode); err != nil {
|
||||
t.Fatalf("fixture WAL: %v", err)
|
||||
}
|
||||
if mode != "wal" {
|
||||
t.Fatalf("fixture WAL switch: got %q", mode)
|
||||
}
|
||||
setup.Close()
|
||||
|
||||
// Writer A: raw handle with busy_timeout pinned to 0, holding the write
|
||||
// lock for ~2s — the cross-writer contention the shared store sees.
|
||||
a, err := sql.Open("sqlite", store)
|
||||
if err != nil {
|
||||
t.Fatalf("open writer A: %v", err)
|
||||
}
|
||||
defer a.Close()
|
||||
a.SetMaxOpenConns(1)
|
||||
if _, err := a.Exec("PRAGMA busy_timeout=0"); err != nil {
|
||||
t.Fatalf("writer A busy_timeout: %v", err)
|
||||
}
|
||||
if _, err := a.Exec("BEGIN IMMEDIATE"); err != nil {
|
||||
t.Fatalf("writer A lock: %v", err)
|
||||
}
|
||||
if _, err := a.Exec("INSERT INTO entries (text, created_at, status) VALUES ('writer A holds the lock', '2026-01-01T00:00:00Z', 'active')"); err != nil {
|
||||
a.Exec("ROLLBACK")
|
||||
t.Fatalf("writer A insert: %v", err)
|
||||
}
|
||||
|
||||
results := make(chan error, 2)
|
||||
start := time.Now()
|
||||
// A releases the write lock 2s in — inside the 5s busy budget the
|
||||
// writers under test must tolerate (Python-store parity).
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
a.Exec("ROLLBACK")
|
||||
}()
|
||||
for _, writer := range []string{"B", "C"} {
|
||||
go func(name string) {
|
||||
db, err := connect() // the module path under test
|
||||
if err != nil {
|
||||
results <- err
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
_, err = db.Exec("INSERT INTO entries (text, created_at, status) VALUES (?, ?, 'active')",
|
||||
"writer "+name+" during A transaction", "2026-01-01T00:00:01Z")
|
||||
results <- err
|
||||
}(writer)
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
select {
|
||||
case err := <-results:
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "locked") || strings.Contains(err.Error(), "SQLITE_BUSY") {
|
||||
t.Errorf("writer dropped on contention (%.2fs into the window): %v", time.Since(start).Seconds(), err)
|
||||
continue
|
||||
}
|
||||
t.Errorf("writer failed for an unrelated reason: %v", err)
|
||||
}
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatalf("writers stalled past the 5s busy timeout (15s elapsed)")
|
||||
}
|
||||
}
|
||||
|
||||
a.Exec("ROLLBACK")
|
||||
|
||||
db, err := sql.Open("sqlite", store)
|
||||
if err != nil {
|
||||
t.Fatalf("verification open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
var rows int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM entries WHERE text LIKE '%during A transaction%'").Scan(&rows); err != nil {
|
||||
t.Fatalf("verification: %v", err)
|
||||
}
|
||||
if rows != 2 {
|
||||
t.Errorf("both concurrent writers must land: %d/2 rows (writer A's lock-holder txn rolled back)", rows)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
// schema_errors_test.go — T17-finding-2 regression (audit a52624b3):
|
||||
// connect()'s startup statements (WAL switch, CREATE, ALTER at
|
||||
// main.go:74-101) must fail LOUDLY. Before the fix every Exec error was
|
||||
// dropped, including the blind ALTERs that error "duplicate column name"
|
||||
// on every already-migrated store — a schema setup failure surfaced only
|
||||
// as a later, mysteriously-shaped write failure. The migration now ports
|
||||
// the Python store's conditional pattern (src/rune/memory_store.py:246-263):
|
||||
// PRAGMA table_info first, ALTER only when the column is missing.
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// TestConnectPropagatesSchemaSetupErrors — a store layout that cannot be
|
||||
// provisioned must return an error from connect(), not a half-open handle.
|
||||
func TestConnectPropagatesSchemaSetupErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// mempalace as a regular file: the memories.db directory cannot exist,
|
||||
// so every schema statement must fail and the failure must surface.
|
||||
if err := os.WriteFile(filepath.Join(dir, "mempalace"), []byte("not a directory"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("RUNE_MEMORY_DIR", dir)
|
||||
db, err := connect()
|
||||
if err == nil {
|
||||
db.Close()
|
||||
t.Fatal("connect() returned no error for an unprovisionable store — " +
|
||||
"schema setup failures are still being swallowed (T17 finding 2)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectConditionalMigrationOnLegacyStore — a pre-batch-1 store gets
|
||||
// the missing columns exactly once, and every later connect stays green
|
||||
// (the old blind ALTERs errored on the second connect, silently).
|
||||
func TestConnectConditionalMigrationOnLegacyStore(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("RUNE_MEMORY_DIR", dir)
|
||||
store := filepath.Join(dir, "mempalace", "memories.db")
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(store), 0o755); err != nil {
|
||||
t.Fatalf("fixture dir: %v", err)
|
||||
}
|
||||
seed, err := sql.Open("sqlite", store)
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy store: %v", err)
|
||||
}
|
||||
// The legacy shape is the pre-batch-1 Go CREATE (all columns it ever
|
||||
// made, WITHOUT promotion_eligible/last_washed — those only ever came
|
||||
// from the ALTERs).
|
||||
legacy := `CREATE TABLE IF NOT EXISTS entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
text TEXT NOT NULL,
|
||||
importance REAL DEFAULT 0.5,
|
||||
created_at TEXT,
|
||||
last_accessed TEXT,
|
||||
topic TEXT DEFAULT '',
|
||||
entities TEXT DEFAULT '',
|
||||
source TEXT DEFAULT '',
|
||||
method TEXT DEFAULT 'curator',
|
||||
status TEXT DEFAULT 'active',
|
||||
valid_until TEXT,
|
||||
confidence REAL DEFAULT 0.7,
|
||||
temperature REAL DEFAULT 1.0,
|
||||
always_on INTEGER DEFAULT 0,
|
||||
priority INTEGER DEFAULT 5,
|
||||
tier TEXT DEFAULT 'warm',
|
||||
memory_type TEXT,
|
||||
wash_count INTEGER DEFAULT 0,
|
||||
superseded_by INTEGER,
|
||||
rock_status TEXT DEFAULT 'washable'
|
||||
)`
|
||||
if _, err := seed.Exec(legacy); err != nil {
|
||||
seed.Close()
|
||||
t.Fatalf("legacy schema: %v", err)
|
||||
}
|
||||
if _, err := seed.Exec("INSERT INTO entries (text, created_at) VALUES ('legacy row', '2026-01-01T00:00:00Z')"); err != nil {
|
||||
seed.Close()
|
||||
t.Fatalf("legacy seed row: %v", err)
|
||||
}
|
||||
seed.Close()
|
||||
|
||||
db, err := connect()
|
||||
if err != nil {
|
||||
t.Fatalf("connect() on legacy store: %v", err)
|
||||
}
|
||||
var migrated int
|
||||
if err := db.QueryRow(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('entries') WHERE name IN ('promotion_eligible','last_washed')",
|
||||
).Scan(&migrated); err != nil {
|
||||
db.Close()
|
||||
t.Fatalf("legacy columns after migrate: %v", err)
|
||||
}
|
||||
if migrated != 2 {
|
||||
db.Close()
|
||||
t.Fatalf("legacy columns not migrated: %d/2 present", migrated)
|
||||
}
|
||||
// The previously-dropped data must survive the migration.
|
||||
var text string
|
||||
if err := db.QueryRow("SELECT text FROM entries ORDER BY id").Scan(&text); err != nil {
|
||||
db.Close()
|
||||
t.Fatalf("legacy row lost: %v", err)
|
||||
}
|
||||
db.Close()
|
||||
|
||||
// Second connect on the now-migrated store must stay green.
|
||||
db2, err := connect()
|
||||
if err != nil {
|
||||
t.Fatalf("second connect() on migrated store: %v", err)
|
||||
}
|
||||
db2.Close()
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Go SQLite open-surface contract (T17 finding 2 — busy/durability family).
|
||||
|
||||
Structural CI enforcement in the discharge contract's infra-test doctrine
|
||||
(docs/bugfix-contract.md: "CI/workflow bugs: the red test is the
|
||||
contract assertion" — parsed-source assertions for machinery bugs).
|
||||
Two invariants, each RED on master before the fix:
|
||||
|
||||
1. Every non-test Go source calling sql.Open("sqlite", ...) builds a DSN
|
||||
carrying PRAGMA busy_timeout >= 5000ms via the driver's documented
|
||||
`_pragma` query parameter (modernc.org/sqlite driver.go:49-52; applied
|
||||
per pooled connection — the only way the policy survives database/sql
|
||||
pooling). 5000ms matches the Python store's connect default wait
|
||||
(src/rune/memory_store.py:153 — sqlite3.connect's 5s busy timeout), the
|
||||
uniformity the shared ~/.rune/mempalace/memories.db needs: agent
|
||||
writers must never again fail instantly with "database is locked (5)".
|
||||
|
||||
2. No Go source sets PRAGMA synchronous=NORMAL. The house standard is the
|
||||
SQLite compile-time default FULL (crash-anywhere durable writes); the
|
||||
Python store sets nothing and runs FULL — NORMAL is the finding-2
|
||||
contradiction (fsync-light commits on shared state).
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
BUSY_MS_MIN = 5000
|
||||
|
||||
|
||||
def _go_sources():
|
||||
for f in sorted((REPO / "src" / "go").rglob("*.go")):
|
||||
if f.name.endswith("_test.go"):
|
||||
continue
|
||||
yield f
|
||||
|
||||
|
||||
def test_every_sqlite_open_carries_a_busy_timeout_dsn():
|
||||
offenders = []
|
||||
for f in _go_sources():
|
||||
text = f.read_text(encoding="utf-8")
|
||||
if 'sql.Open("sqlite"' not in text:
|
||||
continue
|
||||
timeouts = [
|
||||
int(ms)
|
||||
for ms in re.findall(r"busy_timeout%{1,2}3d(\d+)", text)
|
||||
]
|
||||
if not timeouts:
|
||||
offenders.append(
|
||||
f"{f.relative_to(REPO)}: sql.Open(\"sqlite\", ...) has no "
|
||||
"_pragma=busy_timeout DSN — concurrent writers fail with "
|
||||
"instant SQLITE_BUSY (T17 finding 2)"
|
||||
)
|
||||
continue
|
||||
too_short = [ms for ms in timeouts if ms < BUSY_MS_MIN]
|
||||
if too_short:
|
||||
offenders.append(
|
||||
f"{f.relative_to(REPO)}: busy_timeout {too_short[0]}ms is "
|
||||
f"below the {BUSY_MS_MIN}ms house wait (Python store parity)"
|
||||
)
|
||||
assert not offenders, "\n".join(offenders)
|
||||
|
||||
|
||||
def test_no_synchronous_normal_pragma_in_go_sources():
|
||||
offenders = [
|
||||
str(f.relative_to(REPO))
|
||||
for f in _go_sources()
|
||||
if re.search(r"PRAGMA\s+synchronous\s*=\s*NORMAL", f.read_text(encoding="utf-8"))
|
||||
]
|
||||
assert not offenders, (
|
||||
"PRAGMA synchronous=NORMAL contradicts the crash-anywhere "
|
||||
"durable-write standard (sync FULL, the SQLite default the Python "
|
||||
f"store runs): {offenders}"
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Pin-floor lint for bundled SQLite engines (T17 finding 2, audit a52624b3).
|
||||
|
||||
Owner law (docs/architecture/boundary-law.md, "INDEXED STATE BACKBONE")):
|
||||
every Go module binding modernc.org/sqlite must pin a release whose BUNDLED
|
||||
SQLite engine is >= 3.51.3 (the WAL-reset bug was fixed there). This module
|
||||
lints the pins so no module can regress below the floor silently.
|
||||
|
||||
Mapping used (documented source, not folklore): modernc.org/sqlite v1.49.1
|
||||
bundles SQLite 3.53.0 — per the module's own CHANGELOG.md entry
|
||||
"2026-04-17 v1.49.0: Upgrade to SQLite 3.53.0"
|
||||
(https://sqlite.org/releaselog/3_53_0.html), verified against the module
|
||||
cache at modernc.org/sqlite@v1.49.1/CHANGELOG.md. v1.49.1 was verified on
|
||||
master before this test landed (memory-continuity/curator already run it).
|
||||
|
||||
The lint also requires ONE engine version repo-wide: two bundled engines
|
||||
serving the same store files was the finding-2 harm class (v1.28.0 bundles
|
||||
SQLite 3.41.2 — six generations below the floor — and drops SQLITE_BUSY
|
||||
instantly while the Python store waits 5s on the same memories.db).
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
# (1, 49, 1) = the repo-standard pin: bundles SQLite 3.53.0 >= the owner
|
||||
# floor 3.51.3 (boundary-law.md line 116). Any lower modernc pin fails.
|
||||
FLOOR = (1, 49, 1)
|
||||
FLOOR_SOURCE = (
|
||||
"modernc.org/sqlite CHANGELOG.md (module cache modernc.org/sqlite@v1.49.1/"
|
||||
"CHANGELOG.md): '2026-04-17 v1.49.0: Upgrade to SQLite 3.53.0' "
|
||||
"(https://sqlite.org/releaselog/3_53_0.html); owner floor "
|
||||
"docs/architecture/boundary-law.md line 116 (>= 3.51.3)"
|
||||
)
|
||||
|
||||
|
||||
def _parse_pin(version: str) -> tuple[int, int, int]:
|
||||
parts = tuple(int(p) for p in version.split("."))
|
||||
return parts # modernc.org/sqlite uses plain X.Y.Z releases
|
||||
|
||||
|
||||
def sqlite_pins() -> dict[Path, str]:
|
||||
"""Map every go.mod under src/ that requires modernc.org/sqlite to its pin."""
|
||||
pins: dict[Path, str] = {}
|
||||
for gomod in sorted((REPO / "src").rglob("go.mod")):
|
||||
text = gomod.read_text(encoding="utf-8")
|
||||
m = re.search(r"modernc\.org/sqlite\s+v(\d+\.\d+\.\d+)", text)
|
||||
if m:
|
||||
pins[gomod] = m.group(1)
|
||||
return pins
|
||||
|
||||
|
||||
def fmt(version: tuple) -> str:
|
||||
return ".".join(str(p) for p in version)
|
||||
|
||||
|
||||
def test_every_sqlite_module_bundles_at_least_the_owner_floor():
|
||||
pins = sqlite_pins()
|
||||
assert pins, "lint scope broken: no modernc.org/sqlite pin found under src/"
|
||||
violations = {
|
||||
str(p.relative_to(REPO)): v
|
||||
for p, v in pins.items()
|
||||
if _parse_pin(v) < FLOOR
|
||||
}
|
||||
assert not violations, (
|
||||
"modules pin a modernc.org/sqlite release OLDER than the repo floor "
|
||||
f"{fmt(FLOOR)} — bundled SQLite engine must be >= 3.51.3 (WAL-reset "
|
||||
f"fix; source: {FLOOR_SOURCE}):\n"
|
||||
+ "\n".join(
|
||||
f" {path}: v{ver} (bundles a pre-floor engine)"
|
||||
for path, ver in sorted(violations.items())
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_all_sqlite_modules_share_one_engine_version():
|
||||
pins = sqlite_pins()
|
||||
assert pins, "lint scope broken: no modernc.org/sqlite pin found under src/"
|
||||
versions = sorted({_parse_pin(v) for v in pins.values()})
|
||||
assert len(versions) <= 1, (
|
||||
"divergent modernc.org/sqlite engines serve the same store files "
|
||||
f"({[fmt(v) for v in versions]}): T17 finding-2 harm class — raise or "
|
||||
f"align every module to one release >= {fmt(FLOOR)} ({FLOOR_SOURCE})"
|
||||
)
|
||||
Reference in new issue
Block a user