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
//! Wing registry records, default-wing seeding, and the wing entry points.
//!
//! Why (ADR-0027 D2): a Wing is the scope/ownership axis — "who" — as opposed
//! to a Room's topic axis — "what". It is what lets `engineer/Planning` and
//! `pm/Planning` be two rooms without name mangling, and it is where #3064's
//! "two agent types cannot accidentally read/write the same room unless
//! configured to do so" will eventually hang its configuration. This module
//! owns the on-disk row shape and every policy decision about wings.
//!
//! **Migration posture — naming, never reclassification.** Placing existing
//! rooms in the default wing requires writing nothing to `ROOMS` at all:
//! `RoomRecord::wing_id` has carried [`DEFAULT_WING_ID`] since ADR-0027 T1, so
//! the entire migration is inserting ONE row describing a wing that every room
//! already points at. Zero drawer rows change and zero room ids are rewritten,
//! and that is proven byte-for-byte rather than asserted (see
//! `seeding_the_default_wing_changes_no_room_or_drawer_rows`).
//!
//! **Wing is never required of a caller** (ADR-0027 D2). Every palace gets a
//! default wing, every room defaults into it, and no pre-existing call site
//! gains an argument. A caller who never mentions a wing behaves exactly as it
//! did before this module existed.
//!
//! Storage note: the tables live in the palace's `kg.db` beside `ROOMS` and
//! `DRAWERS`, NOT in a JSON sidecar, for the corruption-recovery reason spelled
//! out in ADR-0027 D1.1 — wings, rooms, and drawers corrupt and recover as one
//! unit.
//!
//! Test: `wings_tests.rs`.
use crateDEFAULT_WING_ID;
use crateKnowledgeGraph;
use crateKgStoreRedb;
use crate;
use ;
use ;
use HashSet;
use Arc;
use Uuid;
/// Schema version stamped into the `WINGS` marker row.
///
/// Bump only when the *meaning* of existing rows changes; appending a trailing
/// optional field does not qualify (the decode chain handles that).
pub const WING_SCHEMA_VERSION: u32 = 1;
/// On-disk wing row.
///
/// Why: field order is load-bearing — postcard is positional, so a new field is
/// APPENDED and old bytes are recovered through a fallback chain exactly as
/// `DrawerRecord` and `RoomRecord` already do. Never insert a field in the
/// middle.
///
/// Deliberately minimal: ADR-0027 D2 names a Wing as the eventual anchor for
/// #3064's per-wing access configuration, but no such field is reserved here.
/// The trailing-optional evolution pattern IS the mechanism that keeps it
/// satisfiable later — adding `access: Option<…>` then costs no migration —
/// and a field nothing reads is the exact defect this ADR exists to correct.
///
/// What: the first-seen display spelling, the creation stamp, and an optional
/// human description.
/// Test: `wing_record_round_trip`, `wing_record_decodes_under_a_future_field`.
/// Schema-version marker stored under the nil-UUID key in `WINGS`.
///
/// Why/What: mirrors `RoomSchemaMarker` — it lets a future migration recognise
/// which shape wrote these rows. Seeding idempotency does NOT depend on it; it
/// comes from the by-id existence probe, which is the stronger guarantee
/// because it also preserves a rename.
/// Test: `default_wing_is_seeded_once`.
/// A decoded wing row plus its id and room population — the read-side view.
/// Reject a wing label that cannot address a wing.
///
/// Why: an empty or whitespace-only label normalises to an empty canonical
/// key, which would alias every other empty-labelled create into one wing and
/// produce a wing nobody can name. Failing loud at the boundary is cheaper
/// than a silently-merged scope.
/// What: trims; errors when the result is empty.
/// Test: `wing_create_rejects_a_blank_label`.
/// Seed the palace's default wing if it does not already exist.
///
/// Why (ADR-0027 D2): every palace gets a default wing so that "wing" is never
/// a required concept for a caller. This is the whole wing migration — because
/// `RoomRecord::wing_id` has been [`DEFAULT_WING_ID`] since T1, no room row and
/// no drawer row needs to change for existing rooms to be *in* this wing. They
/// are named, not reclassified.
/// What: probes `WINGS` **by id**, and inserts the row plus its canonical key
/// only when absent. Probing by id rather than by key is what lets a renamed
/// default wing keep its new name — a key probe would resurrect `"default"` as
/// an alias on the next open. Returns `true` when a row was written.
///
/// **An already-seeded palace is left entirely alone**, schema marker included:
/// this function returns before reaching the stamp, so opening a palace costs
/// one read transaction and no write. That is deliberate rather than an
/// oversight — see `set_wing_schema_version`'s call contract. Seeding is not a
/// migration, so it must never restamp a marker over rows it did not convert;
/// a future [`WING_SCHEMA_VERSION`] bump needs a real migration pass that
/// converts the rows and stamps the new version itself.
/// Test: `default_wing_is_seeded_once`, `wing_rename_survives_reseed`,
/// `seeding_the_default_wing_changes_no_room_or_drawer_rows`,
/// `reseeding_does_not_restamp_the_schema_version`.
/// Seed the default wing, swallowing every failure.
///
/// Why (ADR-0027 D1.4, mirroring `backfill_rooms_fail_open`): a palace whose
/// wing registry cannot be written must still open. The cost of failing open is
/// that `wing_list` is empty until the next successful open; the cost of
/// failing closed is an unopenable palace. A read-only (snapshot) palace takes
/// this path every time and that is correct — it has nothing to write to.
/// What: calls [`ensure_default_wing`] and logs any error at `warn!`.
/// Test: `fail_open_seeding_creates_the_default_wing`.
/// Resolve a caller-supplied wing selector to a wing id.
///
/// Why: an MCP caller has a string, not a `Uuid`, and may reasonably hold
/// either a wing id echoed back from `wing_list` or the label a human typed.
/// Accepting both means neither surface has to teach the other's format.
/// What: tries `selector` as a UUID that has a `WINGS` row first, then as a
/// canonical label. `Ok(None)` means "no such wing" — a caller-facing
/// condition, not an error.
/// Test: `wing_selector_accepts_id_or_label`.
/// Every registered wing with its room population, id-ordered.
///
/// Why: the discovery primitive behind `wing_list`. Without it the `WINGS`
/// table would be exactly the dark level ADR-0027 exists to stop shipping.
/// What: joins `list_wings` against `list_rooms`, counting rooms per wing in
/// one pass rather than re-scanning per wing.
/// Test: `wing_list_reports_seeded_and_created_wings`,
/// `wing_list_counts_rooms_per_wing`.
/// The set of room ids belonging to `wing_id`.
///
/// Why: wing-scoped recall is "every room this wing owns", and the drawer table
/// stores only `room_id`. This is the join that turns a scope into a filter.
/// What: scans `ROOMS` and collects the ids whose row names `wing_id`.
///
/// A drawer whose room has no `ROOMS` row yet is NOT in any wing and is
/// therefore excluded from every wing-scoped read. That case is transient: the
/// room backfill (ADR-0027 T2) registers every observed `room_id` at palace
/// open, before any query can run against the handle.
/// Test: `rooms_in_wing_separates_same_named_rooms`.
/// Resolve `label` to a wing id, creating the wing when absent.
///
/// Why: this is `wing_create`'s core, and it is idempotent by construction —
/// creating a wing that exists returns the existing id rather than a second
/// wing, so a caller can call it unconditionally.
/// What: looks the canonical key up; on a hit returns the stored id verbatim;
/// on a miss mints a UUIDv5 and inserts the row and key in one transaction.
/// Returns `(id, created)`.
///
/// Unlike the room write path this does NOT fail open. A room resolution
/// failure must never fail a memory write, so it degrades to the legacy fold;
/// a wing create has no such caller — it is an explicit operation whose only
/// sensible failure mode is telling the caller it failed.
/// Test: `wing_create_is_idempotent`, `wing_create_returns_the_default_wing`.
/// Async wrapper over [`resolve_or_create_wing_sync`].
///
/// Why: the redb work is blocking and every MCP handler is async, so the write
/// runs on the blocking pool — the same posture `resolve_or_create_room` takes.
/// Test: `wing_create_is_idempotent` covers the sync core.
pub async
/// Rename a wing, retiring its old label.
///
/// Why: this is the repair path — and the reason the seeding pass probes by id.
/// A wing's *id* is what every `RoomRecord` references, so renaming the label
/// cannot move a room and provably cannot touch a drawer.
/// What: validates the new label, refuses a label another wing already holds,
/// then applies the row rewrite, the new key, and the old key's retirement in
/// ONE redb write transaction so the previous name stops resolving (a rename,
/// not an alias). Never touches `ROOMS` or `DRAWERS`.
///
/// Atomicity is load-bearing, not incidental: if the old-key removal could
/// commit separately, a crash between the two commits would leave the retired
/// label resolving to the renamed wing forever. In a scope mechanism that is a
/// leak, not cosmetic drift — which is why it is a single transaction.
/// Test: `wing_rename_changes_no_room_or_drawer_rows`,
/// `wing_rename_retires_the_old_label`, `wing_rename_rejects_a_taken_label`,
/// `wing_rename_survives_reseed`, `wing_rename_applies_every_effect_together`.
/// Async wrapper over [`rename_wing_sync`].
pub async
/// Room population of `wing_id`, counted straight off the store.
///
/// Why: `rename_wing_sync` already holds an `Arc<KgStoreRedb>` and has no
/// `KnowledgeGraph` to hand, so it cannot call [`rooms_in_wing`].