File size: 25,634 Bytes
1b623af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
"""Folded-trigram search shadow (v0.5.0 multi-language search, spec §4.2).

A single standalone FTS5 ``trigram`` virtual table (``search_shadow``) holding a
FOLDED rendering of the searchable text across all four tiers (entity / state /
reference / journal), maintained by DB-side triggers and consulted ONLY as a
zero-hit fallback in ``MemoryClient.search()`` (client.py §4.3). The primary
``porter unicode61`` pipeline is never touched, so the fallback is strictly
additive: it fires only where today's answer is ``[]``.

Why a shadow (and why THIS shape):
  * ``trigram`` gives SUBSTRING semantics, which is the only thing that can match
    inside an unbroken indexed token — CJK scriptio-continua (``北京`` inside
    ``北京烤鸭``), Thai fragment glue, Zulu/Bantu locative compounds, German
    compounds — none of which any query-side change to ``porter unicode61`` can
    reach (a prefix ``*`` covers leading substrings only).
  * A FOLDED copy closes the non-decomposable gap (``ł ß ø æ đ ı œ þ ð``): no
    ``remove_diacritics`` setting folds these, so ``Belzyce`` cannot find a stored
    ``Bełżyce`` without an explicit fold map applied on BOTH sides. External-
    content trigram can't carry a divergent folded copy; a standalone table can.
  * ``trigram`` is built into SQLite (>= 3.34; >= 3.45 for ``remove_diacritics``);
    no native/loadable extension, so the plugin keeps shipping pure-Python wheels.

The fold map + runtime tokenizer clause are the single source of truth for both
the trigger/backfill SQL (index side) and the query side (``fold_py``); keeping
them here — not in the static ``schema.sql`` — is why the shadow DDL is generated.

Business-key linkage (NOT ``content_rowid``): shadow rows carry the base-table
business key, so a VACUUM that renumbers rowids can never desync the shadow from
its base table.

TRAP (do not reintroduce): the shadow tables are maintained with PLAIN INSERT and
PLAIN DELETE. The external-content ``INSERT INTO x(x, rowid, ...) VALUES('delete',
...)`` idiom fed by a ``SELECT`` from the shadow FAILS inside a trigger body
("SQL logic error": an FTS5 table cannot be read from within a trigger). A
standalone FTS5 table supports native ``DELETE`` — use it. And NEVER write to the
base tables with ``INSERT OR REPLACE``: with ``recursive_triggers`` off it skips
the DELETE triggers and silently desyncs the shadow. The existing write paths are
safe (set_entity does explicit INSERT-else-UPDATE; state/reference use
``ON CONFLICT ... DO UPDATE`` -> a true UPDATE fires the AU trigger).
"""
from __future__ import annotations

import json as _json
import re
import sqlite3

# ---------------------------------------------------------------------------
# Fold map — non-decomposables ONLY. Decomposable diacritics (é ñ ö ż ...) are
# the tokenizer's job (``remove_diacritics``), and fold_py deliberately does NOT
# touch them so the query side and the index side fold IDENTICALLY (both get the
# tokenizer's decomposable folding, or neither does on pre-3.45 SQLite). This map
# is applied identically by fold_sql (index side, in trigger + backfill SQL) and
# fold_py (query side).
# ---------------------------------------------------------------------------
FOLD_MAP = {
    "ł": "l",  "Ł": "l",    # ł Ł
    "ß": "ss", "ẞ": "ss",   # ß ẞ
    "ø": "o",  "Ø": "o",    # ø Ø
    "æ": "ae", "Æ": "ae",   # æ Æ
    "đ": "d",  "Đ": "d",    # đ Đ
    "ı": "i",                     # ı (dotless i; İ handled by tokenizer fold)
    "œ": "oe", "Œ": "oe",   # œ Œ
    "þ": "th", "Þ": "th",   # þ Þ
    "ð": "d",  "Ð": "d",    # ð Ð
}

SHADOW_TABLE = "search_shadow"

# The four searchable tiers this shadow mirrors.
_TIERS = ("entity", "state", "reference", "journal")

# The complete set of shadow-maintenance triggers (single source of truth,
# consumed by drop_shadow + the migration fast-path completeness check). entity/
# state/reference each get AI/AU/AD (3x3=9); journal is append-only (AI only) =
# 10 total. F1 (Fable hardening 2026-08-06): the v4 migration fast path must
# require ALL 10 to be present, not just the shadow TABLE — an out-of-band
# trigger drop would otherwise pass the table-only precondition and leave the
# shadow silently un-maintained (writes stop propagating). Mirrors how the v3
# FTS triggers self-heal on every open.
SHADOW_TRIGGER_NAMES = (
    "entities_ai_shadow", "entities_au_shadow", "entities_ad_shadow",
    "state_documents_ai_shadow", "state_documents_au_shadow",
    "state_documents_ad_shadow",
    "reference_documents_ai_shadow", "reference_documents_au_shadow",
    "reference_documents_ad_shadow",
    "journal_events_ai_shadow",
)


def fold_sql(expr: str) -> str:
    """SQL expression applying ASCII ``lower()`` + FOLD_MAP to ``expr``.

    Wraps ``lower(expr)`` in one nested ``replace()`` per FOLD_MAP entry. SQLite's
    built-in ``lower()`` folds ASCII only; the trigram tokenizer applies unicode
    case folding (and, on >= 3.45, diacritic removal) on top when it indexes the
    stored text, which is exactly the symmetry ``fold_py`` preserves on the query
    side. FOLD_MAP sources/targets are fixed ASCII/Latin-1 literals (never user
    input), so this string interpolation is injection-safe.
    """
    out = f"lower({expr})"
    for src, dst in FOLD_MAP.items():
        out = f"replace({out}, '{src}', '{dst}')"
    return out


def fold_py(text: str) -> str:
    """Query-side twin of ``fold_sql``: ASCII-lower + FOLD_MAP, nothing else.

    NEVER strips decomposable accents — that stays the tokenizer's job on BOTH
    sides (see FOLD_MAP note). ASCII case-folds; non-ASCII letters are left as-is
    for the trigram tokenizer to case/diacritic fold, keeping query and index
    folding identical.
    """
    out = text.lower() if text.isascii() else "".join(
        c.lower() if c.isascii() else c for c in text)
    for src, dst in FOLD_MAP.items():
        out = out.replace(src, dst)
    return out


def trigram_tokenizer_clause() -> str:
    """Runtime-selected tokenizer clause across the SQLite 3.45 boundary.

    ``remove_diacritics`` reached the ``trigram`` tokenizer in 3.45.0. On older
    SQLite the option is rejected at vtable construction ("unrecognized"), so we
    fall back to a bare ``trigram``. The only degradation on pre-3.45 SQLite is
    accent-insensitive SUBSTRING matching in the fallback; whole-word accented
    matching still works via the primary porter-unicode61 index, and fold_py's
    non-decomposable map is unaffected.
    """
    if sqlite3.sqlite_version_info >= (3, 45, 0):
        return "tokenize = 'trigram remove_diacritics 1'"
    return "tokenize = 'trigram'"


# ---------------------------------------------------------------------------
# DDL / DML generators (single source of truth, consumed by storage.py migration)
# ---------------------------------------------------------------------------
def create_table_sql() -> str:
    """CREATE for the unified shadow table with the runtime tokenizer clause."""
    return (
        f"CREATE VIRTUAL TABLE IF NOT EXISTS {SHADOW_TABLE} USING fts5(\n"
        "  txt, tier UNINDEXED, k1 UNINDEXED, k2 UNINDEXED, tenant_id UNINDEXED,\n"
        f"  {trigram_tokenizer_clause()}\n"
        ")"
    )


# Per-tier fold expressions, IDENTICAL on the trigger side (new./old.) and the
# backfill side (bare columns). Mirrors the base-table columns each tier's
# primary FTS5 index feeds on; journal reuses the exact concat of
# journal_events_ai_fts.
def _entity_txt(p: str) -> str:
    return fold_sql(f"{p}name || ' ' || {p}category || ' ' || {p}body")


def _state_txt(p: str) -> str:
    return fold_sql(f"{p}document_key || ' ' || {p}body")


def _reference_txt(p: str) -> str:
    return fold_sql(f"{p}doc_key || ' ' || COALESCE({p}body, '')")


def _journal_txt(p: str) -> str:
    return fold_sql(
        f"COALESCE({p}evaluated, '') || ' ' || COALESCE({p}acted, '') || ' ' || "
        f"COALESCE({p}forward, '') || ' ' || COALESCE({p}extra, '')"
    )


def trigger_sqls() -> list[str]:
    """The shadow-maintenance triggers.

    entity / state / reference each get AFTER INSERT / UPDATE / DELETE; journal
    gets AFTER INSERT only (append-only — mirrors ``journal_events_ai_fts``, which
    also has no AU/AD). All use PLAIN INSERT / PLAIN DELETE keyed on the tier's
    BUSINESS key (see module docstring TRAP note). Ordering the DELETE before the
    re-INSERT in the AU triggers keeps a rename/rekey clean.
    """
    stmts: list[str] = []

    # --- entities: business key (tenant_id, category, name) = (tenant, k1, k2)
    stmts.append(f"""
CREATE TRIGGER IF NOT EXISTS entities_ai_shadow
AFTER INSERT ON entities BEGIN
  INSERT INTO {SHADOW_TABLE}(txt, tier, k1, k2, tenant_id)
  VALUES ({_entity_txt('new.')}, 'entity', new.category, new.name, new.tenant_id);
END""")
    stmts.append(f"""
CREATE TRIGGER IF NOT EXISTS entities_ad_shadow
AFTER DELETE ON entities BEGIN
  DELETE FROM {SHADOW_TABLE}
   WHERE tier = 'entity' AND k1 = old.category AND k2 = old.name
         AND tenant_id = old.tenant_id;
END""")
    stmts.append(f"""
CREATE TRIGGER IF NOT EXISTS entities_au_shadow
AFTER UPDATE ON entities BEGIN
  DELETE FROM {SHADOW_TABLE}
   WHERE tier = 'entity' AND k1 = old.category AND k2 = old.name
         AND tenant_id = old.tenant_id;
  INSERT INTO {SHADOW_TABLE}(txt, tier, k1, k2, tenant_id)
  VALUES ({_entity_txt('new.')}, 'entity', new.category, new.name, new.tenant_id);
END""")

    # --- state_documents: business key (tenant_id, document_key) = (tenant, k2)
    stmts.append(f"""
CREATE TRIGGER IF NOT EXISTS state_documents_ai_shadow
AFTER INSERT ON state_documents BEGIN
  INSERT INTO {SHADOW_TABLE}(txt, tier, k1, k2, tenant_id)
  VALUES ({_state_txt('new.')}, 'state', '', new.document_key, new.tenant_id);
END""")
    stmts.append(f"""
CREATE TRIGGER IF NOT EXISTS state_documents_ad_shadow
AFTER DELETE ON state_documents BEGIN
  DELETE FROM {SHADOW_TABLE}
   WHERE tier = 'state' AND k2 = old.document_key AND tenant_id = old.tenant_id;
END""")
    stmts.append(f"""
CREATE TRIGGER IF NOT EXISTS state_documents_au_shadow
AFTER UPDATE ON state_documents BEGIN
  DELETE FROM {SHADOW_TABLE}
   WHERE tier = 'state' AND k2 = old.document_key AND tenant_id = old.tenant_id;
  INSERT INTO {SHADOW_TABLE}(txt, tier, k1, k2, tenant_id)
  VALUES ({_state_txt('new.')}, 'state', '', new.document_key, new.tenant_id);
END""")

    # --- reference_documents: business key (tenant_id, doc_key) = (tenant, k2)
    stmts.append(f"""
CREATE TRIGGER IF NOT EXISTS reference_documents_ai_shadow
AFTER INSERT ON reference_documents BEGIN
  INSERT INTO {SHADOW_TABLE}(txt, tier, k1, k2, tenant_id)
  VALUES ({_reference_txt('new.')}, 'reference', '', new.doc_key, new.tenant_id);
END""")
    stmts.append(f"""
CREATE TRIGGER IF NOT EXISTS reference_documents_ad_shadow
AFTER DELETE ON reference_documents BEGIN
  DELETE FROM {SHADOW_TABLE}
   WHERE tier = 'reference' AND k2 = old.doc_key AND tenant_id = old.tenant_id;
END""")
    stmts.append(f"""
CREATE TRIGGER IF NOT EXISTS reference_documents_au_shadow
AFTER UPDATE ON reference_documents BEGIN
  DELETE FROM {SHADOW_TABLE}
   WHERE tier = 'reference' AND k2 = old.doc_key AND tenant_id = old.tenant_id;
  INSERT INTO {SHADOW_TABLE}(txt, tier, k1, k2, tenant_id)
  VALUES ({_reference_txt('new.')}, 'reference', '', new.doc_key, new.tenant_id);
END""")

    # --- journal_events: append-only, AI only. Business key (tenant_id, id).
    stmts.append(f"""
CREATE TRIGGER IF NOT EXISTS journal_events_ai_shadow
AFTER INSERT ON journal_events BEGIN
  INSERT INTO {SHADOW_TABLE}(txt, tier, k1, k2, tenant_id)
  VALUES ({_journal_txt('new.')}, 'journal', '', new.id, new.tenant_id);
END""")

    return stmts


def backfill_sqls() -> list[str]:
    """One INSERT..SELECT per tier, folding with the SAME expressions the
    triggers use so a backfilled row is byte-identical to a trigger-written one."""
    return [
        f"INSERT INTO {SHADOW_TABLE}(txt, tier, k1, k2, tenant_id) "
        f"SELECT {_entity_txt('')}, 'entity', category, name, tenant_id FROM entities",
        f"INSERT INTO {SHADOW_TABLE}(txt, tier, k1, k2, tenant_id) "
        f"SELECT {_state_txt('')}, 'state', '', document_key, tenant_id FROM state_documents",
        f"INSERT INTO {SHADOW_TABLE}(txt, tier, k1, k2, tenant_id) "
        f"SELECT {_reference_txt('')}, 'reference', '', doc_key, tenant_id FROM reference_documents",
        f"INSERT INTO {SHADOW_TABLE}(txt, tier, k1, k2, tenant_id) "
        f"SELECT {_journal_txt('')}, 'journal', '', id, tenant_id FROM journal_events",
    ]


def shadow_table_exists(conn: sqlite3.Connection) -> bool:
    """True iff the shadow virtual table is present (cheap sqlite_master lookup)."""
    try:
        row = conn.execute(
            "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
            (SHADOW_TABLE,),
        ).fetchone()
    except sqlite3.Error:
        return False
    return row is not None


def shadow_trigger_count(conn: sqlite3.Connection) -> int:
    """Count how many of the canonical shadow triggers currently exist.

    Cheap ``sqlite_master`` lookup restricted to the known trigger names, so a
    stray user trigger can never inflate the count. Returns 0 on any read error
    (the safe direction: it forces the migration to re-run apply_shadow_migration
    rather than short-circuit on a bad read)."""
    placeholders = ", ".join("?" for _ in SHADOW_TRIGGER_NAMES)
    try:
        row = conn.execute(
            f"SELECT COUNT(*) FROM sqlite_master "
            f"WHERE type='trigger' AND name IN ({placeholders})",
            SHADOW_TRIGGER_NAMES,
        ).fetchone()
    except sqlite3.Error:
        return 0
    return int(row[0]) if row else 0


def shadow_triggers_complete(conn: sqlite3.Connection) -> bool:
    """True iff ALL 10 shadow-maintenance triggers are present (F1).

    The v4 migration fast path uses this alongside ``shadow_table_exists`` so an
    out-of-band trigger drop self-heals: a mismatch (count != 10) falls through
    to the idempotent ``apply_shadow_migration``, which recreates every trigger
    (CREATE TRIGGER IF NOT EXISTS) and re-backfills the shadow to consistency."""
    return shadow_trigger_count(conn) == len(SHADOW_TRIGGER_NAMES)


def apply_shadow_migration(conn: sqlite3.Connection) -> None:
    """Create the shadow table + triggers and (re)backfill all four tiers.

    Runs via INDIVIDUAL ``execute`` statements (NOT ``executescript``, which
    issues an implicit COMMIT and would break the caller's BEGIN IMMEDIATE). The
    caller (storage.py) wraps this in one transaction and stamps the schema
    marker in the SAME transaction, so a crash anywhere rolls the whole thing
    back and the next open retries — idempotent by construction. Clearing with
    ``DELETE`` before the backfill keeps a re-run (heal / crash-retry)
    duplicate-free.
    """
    conn.execute(create_table_sql())
    for stmt in trigger_sqls():
        conn.execute(stmt)
    conn.execute(f"DELETE FROM {SHADOW_TABLE}")
    for stmt in backfill_sqls():
        conn.execute(stmt)


def rebuild_shadow(conn: sqlite3.Connection) -> None:
    """Clear + re-backfill the shadow from the base tables (heal/rebuild path).

    No-op when the shadow table is absent (e.g. a fresh DB whose shadow is
    created by the later v4 migration step, so the v3 FTS-rebuild that also calls
    this must not fail). Idempotent."""
    if not shadow_table_exists(conn):
        return
    conn.execute(f"DELETE FROM {SHADOW_TABLE}")
    for stmt in backfill_sqls():
        conn.execute(stmt)


def drop_shadow(conn: sqlite3.Connection) -> None:
    """Drop the shadow table + all its triggers. Used by the portability heal and
    documented as the rollback recovery (restores exact v3 behaviour when paired
    with ``PRAGMA user_version = 3``). Base-table data is never touched."""
    for name in SHADOW_TRIGGER_NAMES:
        conn.execute(f"DROP TRIGGER IF EXISTS {name}")
    conn.execute(f"DROP TABLE IF EXISTS {SHADOW_TABLE}")


# Portability / corruption error markers: a DB created on SQLite >= 3.45 (tokenizer
# clause 'trigram remove_diacritics 1') opened on < 3.45 fails vtable construction
# with an "unrecognized"-class message; a corrupt shadow surfaces as a malformed /
# vtable-constructor error. Both are HEALABLE by dropping and recreating the
# shadow with the locally-supported clause. Matched case-insensitively.
_HEALABLE_MARKERS = (
    "unrecognized", "no such tokenize", "no such module",
    "vtable constructor", "malformed", "not a database",
)


def _is_healable(err: Exception) -> bool:
    msg = str(err).lower()
    return any(m in msg for m in _HEALABLE_MARKERS)


def _heal(conn: sqlite3.Connection) -> None:
    """Best-effort portability heal: rebuild the shadow with the local tokenizer
    clause. Runs inside the §4.2 containment so a failure here can never crash
    search. Wrapped in its own transaction; swallows any error."""
    try:
        conn.execute("BEGIN IMMEDIATE")
        drop_shadow(conn)
        conn.execute(create_table_sql())
        for stmt in trigger_sqls():
            conn.execute(stmt)
        for stmt in backfill_sqls():
            conn.execute(stmt)
        conn.execute("COMMIT")
    except sqlite3.Error:
        try:
            conn.execute("ROLLBACK")
        except sqlite3.Error:
            pass


_LIKE_ESC = re.compile(r"([%_\\])")


def _tier_filter(allowed: set[str]) -> tuple[str, list[str]]:
    """Return (`` AND tier IN (?,...)``, params) restricting to ``allowed`` tiers,
    or ("", []) when all four tiers are allowed (no clause needed)."""
    if not allowed or allowed >= set(_TIERS):
        return "", []
    ordered = [t for t in _TIERS if t in allowed]
    placeholders = ", ".join("?" for _ in ordered)
    return f" AND tier IN ({placeholders})", ordered


def _shape_hit(conn: sqlite3.Connection, tenant_id: str, tier: str,
               k1: str, k2: str, txt: str, rank) -> dict | None:
    """Join a shadow row back to its base table by business key and return the
    exact dict shape ``_search_strict`` produces for that tier. Returns None when
    the base record no longer resolves (the shadow raced a delete) — the caller
    skips it. ``snippet`` = first ~120 chars of the folded text; ``rank`` = shadow
    BM25 but POSITIONAL-ONLY to the caller: since F2 (2026-08-12) shadow hits are
    APPENDED after the primary hits in MemoryClient.search (never re-sorted into
    them), this rank orders shadow candidates only among themselves and is never
    cross-compared with the primary BM25 index — so the two rank scales never mix."""
    snippet = txt[:120]
    if tier == "entity":
        row = conn.execute(
            "SELECT body, updated_at FROM entities "
            "WHERE tenant_id = ? AND category = ? AND name = ?",
            (tenant_id, k1, k2),
        ).fetchone()
        if row is None:
            return None
        return {"tier": "entity", "key": k2, "category": k1,
                "body": _json.loads(row[0]), "snippet": snippet,
                "rank": rank, "ts": row[1]}
    if tier == "state":
        row = conn.execute(
            "SELECT body, updated_at FROM state_documents "
            "WHERE tenant_id = ? AND document_key = ?",
            (tenant_id, k2),
        ).fetchone()
        if row is None:
            return None
        return {"tier": "state", "key": k2, "category": None,
                "body": _json.loads(row[0]), "snippet": snippet,
                "rank": rank, "ts": row[1]}
    if tier == "reference":
        row = conn.execute(
            "SELECT body, updated_at FROM reference_documents "
            "WHERE tenant_id = ? AND doc_key = ?",
            (tenant_id, k2),
        ).fetchone()
        if row is None:
            return None
        return {"tier": "reference", "key": k2, "category": None,
                "body": row[0], "snippet": snippet,
                "rank": rank, "ts": row[1]}
    if tier == "journal":
        row = conn.execute(
            "SELECT ts, evaluated, acted, forward, extra FROM journal_events "
            "WHERE tenant_id = ? AND id = ?",
            (tenant_id, k2),
        ).fetchone()
        if row is None:
            return None
        return {"tier": "journal", "key": k2, "category": None,
                "body": {
                    "evaluated": _json.loads(row[1]) if row[1] else None,
                    "acted": _json.loads(row[2]) if row[2] else None,
                    "forward": _json.loads(row[3]) if row[3] else None,
                    "extra": _json.loads(row[4]) if row[4] else None,
                },
                "snippet": snippet, "rank": rank, "ts": row[0]}
    return None


def shadow_search(conn: sqlite3.Connection, tenant_id: str, query: str,
                  *, limit: int = 20, tiers: tuple[str, ...] | None = None) -> list[dict]:
    """Folded-trigram substring fallback. Returns ``_search_strict``-shaped dicts.

    Substring semantics via the trigram MATCH (>=3-char folded tokens) with a
    bounded LIKE post-filter/scan for 1-2 char non-ASCII tokens (the CJK 2-char
    case — trigram MATCH cannot see below 3 chars). Short ASCII tokens are
    stopword-grade noise and dropped. A no-op ``[]`` when the shadow table is
    absent (pre-migration DB) so it is safe to call unconditionally.
    """
    if limit <= 0:  # CORE-5: a non-positive limit must never broaden — mirror clamp
        return []
    if not shadow_table_exists(conn):
        return []
    allowed = set(tiers) if tiers else set(_TIERS)
    allowed &= set(_TIERS)
    if not allowed:
        return []

    folded = fold_py(query)
    toks = [t for t in re.findall(r"\w+", folded) if t]
    match_toks = [t for t in toks if len(t) >= 3]
    # short non-ASCII tokens are real words (2-char CJK/Hangul); short ASCII is noise
    like_toks = [t for t in toks if len(t) < 3 and not t.isascii()]
    if not match_toks and not like_toks:
        return []

    tier_clause, tier_params = _tier_filter(allowed)
    fetch = max(limit, 1) * 4

    try:
        rows: list = []
        if match_toks:
            mq = " ".join('"' + t.replace('"', '""') + '"' for t in match_toks)
            # !!! CORE-3 TENANT-ISOLATION LOCK (2026-06-25 pre-launch audit) !!!
            # `AND tenant_id = ?` is the ONLY thing keeping this query inside the
            # caller's tenant. tenant_id is UNINDEXED in the shadow FTS5 table, so
            # this is a trailing post-filter, NOT index-enforced isolation. DO NOT
            # remove, reorder, or make this clause conditional. Covered by the
            # cross-tenant leak test (test_trigram_shadow_2026_08_06).
            rows = conn.execute(
                f"SELECT txt, tier, k1, k2, rank FROM {SHADOW_TABLE} "
                f"WHERE {SHADOW_TABLE} MATCH ? AND tenant_id = ?" + tier_clause +
                " ORDER BY rank LIMIT ?",
                [mq, tenant_id, *tier_params, fetch],
            ).fetchall()
            if like_toks:
                rows = [r for r in rows if all(t in r[0] for t in like_toks)]
        elif like_toks:
            conds = " AND ".join(f"txt LIKE ? ESCAPE '\\'" for _ in like_toks)
            like_params = ["%" + _LIKE_ESC.sub(r"\\\1", t) + "%" for t in like_toks]
            # !!! CORE-3 TENANT-ISOLATION LOCK (2026-06-25 pre-launch audit) !!!
            # `tenant_id = ?` is the ONLY tenant boundary (tenant_id UNINDEXED ->
            # trailing post-filter, not index-enforced). DO NOT remove/reorder/
            # conditionalize. Covered by test_trigram_shadow_2026_08_06.
            rows = conn.execute(
                f"SELECT txt, tier, k1, k2, 0.0 AS rank FROM {SHADOW_TABLE} "
                f"WHERE tenant_id = ?" + tier_clause + " AND " + conds + " LIMIT ?",
                [tenant_id, *tier_params, *like_params, fetch],
            ).fetchall()
    except (sqlite3.OperationalError, sqlite3.DatabaseError) as err:
        # A broken shadow must NEVER take down search: contain the error, return
        # the primary path's empty result, and heal the shadow if the failure is
        # the portability/corruption class (next query then succeeds).
        if _is_healable(err):
            _heal(conn)
        return []

    # Hit shaping. Journal is capped at max(1, limit//4) for symmetry with
    # _search_strict (contentless journal rows share many common terms and would
    # otherwise dominate). Rows whose base record no longer resolves are skipped.
    journal_cap = max(1, limit // 4) if limit > 0 else 0
    journal_used = 0
    hits: list[dict] = []
    for txt, tier, k1, k2, rank in rows:
        if tier == "journal":
            if journal_used >= journal_cap:
                continue
        # F3 (Fable robustness 2026-08-06): shape ONE row at a time behind a
        # try/except so a single undecodable base-table JSON body (corrupt row,
        # partial write, manual edit — _shape_hit calls json.loads) is SKIPPED,
        # not allowed to void the entire fallback result set. Mirrors the §4.2
        # containment stance: a broken row must never take down search. Skips on
        # any per-row error (decode or a transient row-level sqlite error).
        try:
            shaped = _shape_hit(conn, tenant_id, tier, k1, k2, txt, rank)
        except Exception:
            continue
        if shaped is None:
            continue
        if tier == "journal":
            journal_used += 1
        hits.append(shaped)
        if len(hits) >= limit:
            break
    return hits