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
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
//! ADR-0028 Tier C — "current facts": slot admission, the default TTL, and
//! atomic retire-on-write.
//!
//! Why: a point-in-time fact fails by the OPPOSITE mechanism from a standing
//! rule (ADR-0028 §C7). A standing rule is never ranked high enough; a current
//! fact stays ranked high long after it stopped being true, because nothing
//! retires it. The estate's most-injected drawer is a 19-day-old session
//! checkpoint reaching 44.8% of turns and asserting a `origin/main` SHA that
//! has been wrong for four weeks. Mandatory retirement (D4) is the invariant
//! that prevents that, and the writer-chosen `fact_key` slot (D5) is what makes
//! the store self-limiting: `pr:4818/state` can be written fifty times and
//! still occupy exactly one slot.
//! What: [`admit_tier_c`] is the fail-closed admission gate — a write that names a
//! slot but cannot declare a valid retirement condition is refused Tier C and
//! degrades to an ordinary Tier E drawer, never admitted-and-warned.
//! [`persist_with_retirement`] is the write half: it resolves the slot's
//! current occupant through the `DRAWERS_BY_FACT_KEY` index (#4884) and commits
//! the incumbent's retirement and the newcomer's arrival in ONE redb
//! transaction, so no reader and no crash can observe a slot with two claimants
//! or with an incumbent retired and no replacement landed.
//! Test: the inline `tests` module below covers admission; the retirement
//! invariant and the concurrency guarantee live in `retrieval::tier_c_tests` —
//! `tier_c_write_retires_the_prior_slot_occupant`,
//! `tier_c_retirement_clears_the_displaced_drawers_own_fact_key`, and
//! `concurrent_tier_c_writes_to_one_slot_leave_exactly_one_claimant`.
use ;
use ;
use RwLock;
use Arc;
use OwnedMutexGuard;
use Uuid;
use crate;
use crateKnowledgeGraph;
/// Default Tier C lifetime when the writer names a slot but no `expires_at`
/// (ADR-0028 D4, retirement condition 3).
///
/// Why: the decay half-life is 90 days and a point-in-time fact has a useful
/// life measured in hours — a mismatch of roughly three orders of magnitude
/// (§C7). 24 hours is the ADR's chosen floor: short enough that a forgotten
/// fact stops being asserted within a day, long enough that a fact written at
/// the start of a working session survives it.
pub const TIER_C_DEFAULT_TTL_HOURS: i64 = 24;
/// Upper bound on a `fact_key`'s encoded length.
///
/// Why: the key is a redb table key in `DRAWERS_BY_FACT_KEY`. An unbounded
/// caller-supplied key is an unbounded index entry; capping it keeps a
/// pathological writer from bloating the slot index. 128 bytes is ~4x the
/// longest key the ADR gives as an example.
pub const FACT_KEY_MAX_LEN: usize = 128;
/// Why a Tier C write was refused the privileged tier.
///
/// Why: the caller needs to distinguish "you did not ask for Tier C" from "you
/// asked and were refused", because only the second is a defect the writer can
/// fix. Carrying the reason lets the MCP surface report it instead of silently
/// downgrading — fail-closed must be observable, or writers never learn that
/// their slot never took effect.
/// What: a refusal always means the drawer is written as ordinary Tier E, i.e.
/// exactly today's behaviour.
/// Test: `refuses_a_bare_unnamespaced_key`, `refuses_an_already_elapsed_ttl`.
/// The outcome of the ADR-0028 D4 admission decision.
///
/// Test: the `admits_*` / `refuses_*` tests in this module.
/// Validate a `fact_key` against the ADR-0028 D5 namespaced grammar.
///
/// Why: D5 rejected the subject string and the tag set as replacement keys and
/// requires namespacing (`<domain>:<id>/<aspect>`) explicitly "to prevent
/// unrelated facts clobbering each other — a bare key like `state` would
/// collide across every workstream". Admitting a bare key would let one
/// workstream's write retire another's live fact, which is a worse failure than
/// the staleness this tier exists to fix. Enforcing the grammar at admission is
/// what makes that unreachable.
/// What: requires exactly one `:` and, after it, exactly one `/`; each of the
/// three resulting segments must be non-empty and drawn from
/// `[A-Za-z0-9._-]`. Returns the reason on failure so the refusal can name it.
/// Test: `accepts_the_adr_example_keys`, `refuses_a_bare_unnamespaced_key`,
/// `refuses_keys_with_empty_segments`, `refuses_an_over_long_key`.
/// Decide whether a write may enter Tier C (ADR-0028 D4).
///
/// Why: D4 is the load-bearing invariant — "a fact cannot enter Tier C without
/// declaring how it ends" — and its safety property is that the worst case
/// degrades to today's behaviour, never below it. That is only true if the gate
/// fails CLOSED: a fact with no valid retirement condition must never become
/// privileged, because a privileged fact that goes stale is worse than an
/// ordinary stale drawer (it surfaces every turn). Admitting-and-warning would
/// leave exactly that failure mode reachable, so every refusal here writes an
/// ordinary drawer instead.
/// What: `fact_key = None` is [`TierCAdmission::NotRequested`] — the caller
/// never asked. Otherwise the key must satisfy [`validate_fact_key`], and the
/// retirement instant resolves as: an explicit `expires_at` strictly after
/// `now` is used as given; an `expires_at` at or before `now` is refused (a
/// fact born expired declares no live window, and admitting it would retire a
/// possibly-live incumbent in exchange for nothing); absent `expires_at`, the
/// [`TIER_C_DEFAULT_TTL_HOURS`] default applies. `live_while` (D4 condition 2)
/// is deliberately absent — the ADR's Implementation-scope section puts it out
/// of the first wave, since no GitHub-state checker exists in the workspace.
/// `now` is a parameter so one write judges key and TTL against a single
/// instant and tests can pin the clock.
/// Test: `admits_a_well_formed_key_with_the_default_ttl`,
/// `admits_an_explicit_future_expiry_unchanged`,
/// `refuses_a_bare_unnamespaced_key`, `refuses_an_already_elapsed_ttl`,
/// `no_fact_key_is_not_a_tier_c_request`.
/// Stamp the admission decision onto a drawer about to be written (#4886).
///
/// Why: this is the ONE place in the workspace where a drawer acquires a
/// `fact_key`. Every write path — the MCP tools, the HTTP handlers, the chat
/// tool surface, the importer, bootstrap/scan, kg_extract, and the dream cycle
/// — reaches storage through `remember_with_options`, and `RememberOptions` is
/// the only type that can carry a slot name, so routing the decision here makes
/// the D4 gate unbypassable rather than merely conventional. Paths that build a
/// `Drawer` by hand and call `kg.upsert_drawer` directly (the kuzu migration,
/// the git narrative writer, semantic consolidation) never set `fact_key`, so
/// they cannot claim a slot at all.
/// What: on admission, writes the resolved slot and retirement instant onto the
/// drawer. On refusal, writes NOTHING and logs — the drawer stays an ordinary
/// Tier E drawer, which is the fail-closed guarantee D4 rests on. When no slot
/// was requested, an explicitly supplied `expires_at` is still honoured as a
/// plain TTL (the field's pre-ADR-0028 meaning); absent one, whatever policy
/// `Drawer::with_type` applied is left intact.
/// Test: `tier_c_write_retires_the_prior_slot_occupant`,
/// `malformed_fact_key_degrades_to_tier_e`,
/// `already_elapsed_expiry_degrades_to_tier_e`,
/// `explicit_expiry_is_honoured_without_a_fact_key`.
pub
/// Mirror a completed retirement into the in-memory drawer table (#4886).
///
/// Why: `PalaceHandle::drawers` is a full mirror of `DRAWERS` and is what
/// `list_drawers` and the L1 refresh read. Leaving the displaced drawer's
/// `fact_key` set there would reproduce, in memory, the exact two-claimants
/// state the durable write just avoided. The caller holds the drawer table's
/// write lock across this and the newcomer's push, so the two changes land
/// together or not at all.
/// What: clears `fact_key` and `expires_at` on the retired drawer, matching the
/// record `persist_with_retirement` committed. No-op when nothing was retired.
/// The table is not guaranteed to hold every retired id (#6438) — a redb-only
/// incumbent leaves this call a no-op even though the durable retirement
/// happened.
/// Test: `tier_c_retirement_clears_the_displaced_drawers_own_fact_key`.
pub
/// Persist `drawer`, atomically retiring whatever drawer currently holds its
/// `fact_key` slot (ADR-0028 D5).
///
/// Why: retire-on-write is a read-decide-write sequence — ask the index who
/// holds the slot, decide the incumbent, retire it, write the newcomer. If
/// those steps are not atomic with respect to a concurrent writer on the same
/// slot, two drawers end up claiming one slot, or an incumbent is retired
/// without a replacement landing. Two things make that unreachable here.
/// First, [`commit_and_mirror`] holds the per-palace commit-order guard across
/// this call AND the in-memory mirror that follows it, so no second writer on
/// this palace interleaves. That guard, not the write mutex (#154), is what
/// orders writers: #6366 releases the write mutex as soon as a write exhausts
/// its budget, while the commit it already dispatched keeps running.
/// Second — and this is the guarantee no lock can give — both drawer rows are
/// written in ONE redb transaction, so a crash between them is impossible and
/// no reader can observe the intermediate state. The guard orders writers; the
/// transaction makes each write indivisible.
///
/// Why the displaced drawer's own field is cleared: #4884's storage layer moves
/// the INDEX entry to the new owner but deliberately leaves the displaced
/// drawer's `DrawerRecord.fact_key` reading the old slot name — correct for
/// storage groundwork, since nothing read `fact_key` as a liveness signal then.
/// It is wrong once a write path exists: `load_drawers()` would show two
/// drawers both claiming `pr:4818/state` while only one is indexed, and any
/// future consumer that trusts the field rather than the index would read the
/// retired fact as live. Clearing the field makes the row agree with the index.
/// `expires_at` is cleared with it because on a Tier C drawer `expires_at` IS
/// the retirement condition, and supersession has already discharged it —
/// leaving it set would make the demoted record self-destruct at the next
/// open-time sweep, contradicting D6 ("demoted, never deleted") and orphaning
/// the supersession pointer #4887 will hang off it. What is left is an ordinary
/// Tier E drawer: permanent, ranked, readable — exactly D6.
///
/// What: no-ops to a plain `upsert_drawer` when the drawer claims no slot.
/// Otherwise looks the slot up via `drawer_id_for_fact_key`, and when a
/// different drawer holds it, commits `[retired_incumbent, newcomer]` through
/// `upsert_drawers_atomic`. Returns the retired drawer's id so the caller can
/// mirror the change into the in-memory drawer table. An incumbent the
/// in-memory table does not hold is fetched from redb via `kg.load_drawer`
/// (#6438) and retired like any other; only an incumbent absent from redb TOO
/// — a dangling index entry — is logged and skipped, and there the newcomer's
/// write still moves the index, so the slot keeps one indexed claimant. A row
/// present in redb but unreadable fails the write instead: admitting the
/// newcomer would leave a second live claimant nothing later retires.
/// Test: `tier_c_write_retires_the_prior_slot_occupant`,
/// `tier_c_retirement_clears_the_displaced_drawers_own_fact_key`,
/// `concurrent_tier_c_writes_to_one_slot_leave_exactly_one_claimant`,
/// `an_on_disk_incumbent_absent_from_the_mirror_is_still_retired`,
/// `a_fact_key_index_naming_no_row_still_admits_the_newcomer`,
/// `an_undecodable_incumbent_row_fails_the_write_instead_of_admitting_a_second_claimant`.
pub async
/// The owned slice of a palace the durable commit needs (#6366).
///
/// Why: [`commit_and_mirror`] runs in a task the caller's write budget cannot
/// cancel, so it must own what it touches rather than borrow a `PalaceHandle`.
/// Every field here is already an `Arc` on the handle, so this is three clones,
/// not a copy of the palace.
/// What: the palace id (for diagnostics), the KG handle (the durable write),
/// and the in-memory drawer table (the mirror).
/// Test: `an_abandoned_commit_still_leaves_one_claimant_for_the_slot`.
pub
/// Commit `drawer` durably and mirror the outcome into the in-memory table, as
/// one step no caller's timeout can split (#6366).
///
/// Why: #6366 bounds the write pipeline so an over-budget write releases the
/// palace write mutex. Dropping the pipeline future does NOT stop the commit it
/// dispatched — `upsert_drawers_atomic` runs on `spawn_blocking`, which is
/// uncancellable, and `KgWriter::upsert_drawer` hands the op to an actor task
/// the future does not own. So the durable half landed while the in-memory
/// mirror (`retire_in_memory` + `push`) never ran. The next writer then read
/// the moved `DRAWERS_BY_FACT_KEY` index, failed to find that id in `drawers`,
/// and took [`persist_with_retirement`]'s "absent from the in-memory table"
/// branch: it wrote its own newcomer WITHOUT retiring the incumbent, leaving
/// two drawer rows carrying one live `fact_key`. That is the two-claimant state
/// this module's header says no reader may observe.
///
/// What: takes `order` — the palace's commit-order guard, acquired by the
/// caller while still cancellable — and holds it across BOTH the durable commit
/// and the mirror, then releases it. Running inside `tokio::spawn` makes the
/// pair uncancellable; holding the guard across it makes the next writer's
/// incumbent read wait for the mirror rather than race it. A write abandoned
/// mid-commit therefore still converges: it lands in redb and in `drawers`
/// together, and the writer behind it retires it normally.
///
/// Panic contract: `commit_mutex` is a `tokio::sync::Mutex` and does NOT
/// poison. A panic between the durable commit and the mirror unwinds the task,
/// drops the guard, and releases the mutex with no signal — leaving the write
/// durable but unmirrored and the next writer free to walk into the fallback
/// this function exists to close. The mirror block does no fallible work today,
/// so this is latent. Code inserted between the commit and the mirror must stay
/// panic-free, or must re-verify the slot on the panic path.
///
/// Two writers abandoned in the same window commit in guard-acquisition order,
/// which is the order their callers reached the commit — not necessarily the
/// order they took the write mutex. Either order leaves exactly one claimant,
/// which is the invariant; which of the two wins the slot is not.
/// Test: `an_abandoned_commit_still_leaves_one_claimant_for_the_slot`,
/// `a_second_writer_waits_for_an_abandoned_commit_to_mirror`.
pub async