@team-agent/installer 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +82 -22
  4. package/crates/team-agent/src/cli/diagnose.rs +6 -2
  5. package/crates/team-agent/src/cli/emit.rs +301 -38
  6. package/crates/team-agent/src/cli/mod.rs +7 -14
  7. package/crates/team-agent/src/cli/status_port.rs +12 -1
  8. package/crates/team-agent/src/cli/tests/base.rs +6 -2
  9. package/crates/team-agent/src/cli/tests/lane_c.rs +1 -1
  10. package/crates/team-agent/src/cli/tests/leader_watch.rs +5 -3
  11. package/crates/team-agent/src/cli/tests/main_preserved.rs +28 -2
  12. package/crates/team-agent/src/cli/tests/peer_allow.rs +19 -0
  13. package/crates/team-agent/src/cli/tests/repair_state_byte_lock.rs +72 -0
  14. package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -3
  15. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  16. package/crates/team-agent/src/cli/types.rs +35 -2
  17. package/crates/team-agent/src/coordinator/runtime_detectors.rs +7 -2
  18. package/crates/team-agent/src/diagnose/comms.rs +10 -2
  19. package/crates/team-agent/src/leader/lease.rs +98 -19
  20. package/crates/team-agent/src/leader/rediscover/tests.rs +21 -7
  21. package/crates/team-agent/src/leader/rediscover.rs +16 -7
  22. package/crates/team-agent/src/leader/start.rs +25 -12
  23. package/crates/team-agent/src/leader/tests/byte_findings.rs +11 -2
  24. package/crates/team-agent/src/leader/tests/lease_claim.rs +248 -7
  25. package/crates/team-agent/src/lifecycle/launch.rs +116 -100
  26. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +5 -12
  27. package/crates/team-agent/src/lifecycle/tests/core.rs +1 -1
  28. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +16 -15
  29. package/crates/team-agent/src/lifecycle/types.rs +1 -1
  30. package/crates/team-agent/src/messaging/leader_receiver.rs +22 -8
  31. package/crates/team-agent/src/messaging/send.rs +21 -4
  32. package/crates/team-agent/src/provider/adapter.rs +22 -0
  33. package/crates/team-agent/src/provider/session/capture.rs +228 -0
  34. package/crates/team-agent/src/state/identity.rs +42 -12
  35. package/crates/team-agent/src/state/identity_keys.rs +134 -0
  36. package/crates/team-agent/src/state/mod.rs +8 -0
  37. package/crates/team-agent/src/state/owner_gate.rs +11 -1
  38. package/crates/team-agent/src/state/ownership.rs +556 -0
  39. package/crates/team-agent/src/state/paths.rs +358 -0
  40. package/crates/team-agent/src/state/persist.rs +43 -5
  41. package/crates/team-agent/src/state/projection.rs +13 -5
  42. package/crates/team-agent/src/tmux_backend.rs +12 -0
  43. package/package.json +4 -4
  44. package/skills/team-agent/SKILL.md +3 -3
@@ -0,0 +1,556 @@
1
+ //! Stage 2 of identity-boundary unified plan (architect direction 2026-06-23):
2
+ //! `state::ownership` — single read entry point for `team_owner` lookups.
3
+ //!
4
+ //! Today the project has ~30 hand-rolled `state.get("team_owner")` sites and
5
+ //! a separate `team_owner_value` helper in `cli/mod.rs` that re-implements the
6
+ //! teams.<key> → top-level precedence. Stage 2 collapses owner *reads* (not
7
+ //! writes) into a single resolver so:
8
+ //!
9
+ //! 1. The owner gate, restart/start/stop/reset/send/diagnose all see the
10
+ //! SAME owner regardless of whether the caller passed a raw or projected
11
+ //! state — Stage 5 will swap the data source to per-team canonical state
12
+ //! without touching call sites.
13
+ //! 2. Stale `teams.<key>.team_owner` can no longer single-handedly decide
14
+ //! the gate when the canonical truth lives elsewhere (architect §verdict).
15
+ //! 3. Read-only diagnostic CLI / status JSON can ask the repository for the
16
+ //! `OwnershipSource` so legacy duplicates are visible as migration
17
+ //! warnings, not hidden truth.
18
+ //!
19
+ //! What Stage 2 does NOT do (those land in Stage 3):
20
+ //! - Does NOT change writers. `lease.rs` / `start.rs` / `launch.rs` still
21
+ //! write owner at multiple locations; Stage 3 migrates them.
22
+ //! - Does NOT remove duplicate persisted fields. The legacy top-level and
23
+ //! `teams.<key>.team_owner` remain on disk.
24
+ //! - Does NOT add a new file path. Stage 5 introduces
25
+ //! `.team/runtime/<team_key>/state.json` as the canonical source.
26
+ //!
27
+ //! Precedence rule (architect §3 migration precedence; today no canonical
28
+ //! file exists so steps 1+4 are inert):
29
+ //!
30
+ //! 1. canonical `.team/runtime/<team_key>/state.json` (Stage 5 — not wired)
31
+ //! 2. `state.teams.<team_key>.team_owner`
32
+ //! 3. top-level `state.team_owner` IFF `team_state_key(state) == team_key`
33
+ //! 4. snapshot is diagnostic only (not consulted)
34
+
35
+ use serde_json::Value;
36
+
37
+ use crate::state::projection::{read_owner as projection_read_owner, team_state_key};
38
+
39
+ /// Where the repository found the owner record. Diagnostic-only; the owner
40
+ /// gate and other gating callers do not branch on this — they only need the
41
+ /// owner value. CLI status / diagnose use it to surface legacy duplicate
42
+ /// warnings.
43
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
44
+ pub enum OwnershipSource {
45
+ /// Stage 5 canonical path: `.team/runtime/<team_key>/state.json` (not
46
+ /// yet emitted — placeholder for forward compatibility).
47
+ CanonicalPerTeamState,
48
+ /// Legacy projection: `state.teams.<team_key>.team_owner` —
49
+ /// the value the projection layer would have surfaced.
50
+ LegacyTeamsProjection,
51
+ /// Legacy top-level: `state.team_owner`, used when the state's
52
+ /// `team_state_key` matches the requested `team_key`.
53
+ LegacyTopLevel,
54
+ }
55
+
56
+ /// Result of a repository read. The `value` is the JSON object the gate /
57
+ /// caller consumes (same shape as `state.get("team_owner")` returned today);
58
+ /// the `source` is diagnostic.
59
+ #[derive(Debug, Clone)]
60
+ pub struct OwnershipRead<'a> {
61
+ pub value: &'a Value,
62
+ pub source: OwnershipSource,
63
+ }
64
+
65
+ /// Stage 2 entry point: read the team owner for the given `team_key` from an
66
+ /// in-memory state value, honouring the legacy precedence. Returns `None` if
67
+ /// no source carries a non-empty, valid owner record.
68
+ ///
69
+ /// `team_key` may be empty — that path defers to the legacy top-level lookup
70
+ /// only (no teams-projection branch). Callers who already resolved a
71
+ /// `team_key` should pass it; arbitrary projected state can pass `""`.
72
+ ///
73
+ /// This deliberately reuses `projection::read_owner` so the pane-id validity
74
+ /// check (`%N` or all-digits) stays in one place. The repository's value-add
75
+ /// is the precedence ordering and the `OwnershipSource` tag.
76
+ pub fn read_owner_for_team<'a>(state: &'a Value, team_key: &str) -> Option<OwnershipRead<'a>> {
77
+ // Step 1 (Stage 5): canonical per-team state. Stage 2 does not consult
78
+ // a separate file yet — owner truth still lives in the in-memory state.
79
+ // Once Stage 5 wires `.team/runtime/<team_key>/state.json` as canonical,
80
+ // a new top-level reader at `state::ownership::read_owner_for_team_at(
81
+ // workspace, team_key)` will check that file first and only fall
82
+ // through to this in-memory resolver for legacy migration.
83
+
84
+ // Step 2: `state.teams.<team_key>.team_owner` (the projection branch).
85
+ if !team_key.is_empty() {
86
+ if let Some(owner) = projection_read_owner(state, Some(team_key)) {
87
+ return Some(OwnershipRead {
88
+ value: owner,
89
+ source: OwnershipSource::LegacyTeamsProjection,
90
+ });
91
+ }
92
+ }
93
+
94
+ // Step 3: top-level `state.team_owner`, only when the state's
95
+ // `team_state_key` agrees with the requested team_key (or when the
96
+ // caller passed an empty key — they're explicitly asking for the
97
+ // arbitrary-projection view, which is the pre-Stage-2 owner_gate
98
+ // shape).
99
+ let top_level_matches = team_key.is_empty() || team_state_key(state) == team_key;
100
+ if top_level_matches {
101
+ if let Some(owner) = projection_read_owner(state, None) {
102
+ return Some(OwnershipRead {
103
+ value: owner,
104
+ source: OwnershipSource::LegacyTopLevel,
105
+ });
106
+ }
107
+ }
108
+
109
+ None
110
+ }
111
+
112
+ /// Convenience: just the JSON value, no diagnostic source. Returns `None`
113
+ /// when no owner is found. Stable across Stage 5 — only the lookup
114
+ /// implementation will move.
115
+ pub fn read_owner_value<'a>(state: &'a Value, team_key: &str) -> Option<&'a Value> {
116
+ read_owner_for_team(state, team_key).map(|read| read.value)
117
+ }
118
+
119
+ /// Stage 3 (identity-boundary unified plan, architect direction 2026-06-23):
120
+ /// single write entry point for owner mutations. Pre-Stage-3, owner writes
121
+ /// were spread across 13 sites (claim/attach/readopt/managed-leader/quick-
122
+ /// start/identity/send), each independently inserting `team_owner` +
123
+ /// `leader_receiver` + `owner_epoch` into the in-memory state. Stage 3a
124
+ /// collapsed those writers into this API; Stage 3b removed the projection
125
+ /// top-level promote; Stage 3c removed the persist top-level copy-back;
126
+ /// Stage 3d (this final flip) drops the top-level write entirely.
127
+ ///
128
+ /// Architect §1.A: the three fields form ONE ownership record. They must
129
+ /// be written together (no half-writes that race in projection/persist).
130
+ ///
131
+ /// 3d behaviour: writes ONLY to `state.teams.<team_key>.{team_owner,
132
+ /// leader_receiver, owner_epoch}`. The top-level
133
+ /// `state.{team_owner,leader_receiver,owner_epoch}` is no longer touched
134
+ /// by writers — readers that still consult those fields go through
135
+ /// `state::ownership::read_owner_for_team`, which applies the migration
136
+ /// precedence (teams > top-level only when `team_state_key` matches).
137
+ ///
138
+ /// Empty `team_key` is the legacy delivery-view backfill path
139
+ /// (`messaging::send::backfill_leader_binding_for_delivery_view`): an
140
+ /// already-projected state with no team_key context. That path still
141
+ /// writes top-level so the delivery view stays consistent with pre-3a
142
+ /// readers that haven't migrated to the repository. The remaining
143
+ /// top-level write is exactly the one architectural seam Stage 4/5 will
144
+ /// close when CLI/MCP commands carry a team_key explicitly.
145
+ pub fn write_owner(state: &mut Value, team_key: &str, record: OwnershipWrite) {
146
+ if !state.is_object() {
147
+ *state = serde_json::json!({});
148
+ }
149
+ // Empty team_key: legacy in-memory delivery-view backfill. Preserve
150
+ // top-level dual-write semantics for this narrow caller.
151
+ if team_key.is_empty() {
152
+ if let Some(root) = state.as_object_mut() {
153
+ if let Some(receiver) = record.leader_receiver.as_ref() {
154
+ root.insert("leader_receiver".to_string(), receiver.clone());
155
+ }
156
+ if let Some(owner) = record.team_owner.as_ref() {
157
+ root.insert("team_owner".to_string(), owner.clone());
158
+ }
159
+ if let Some(epoch) = record.owner_epoch {
160
+ root.insert("owner_epoch".to_string(), serde_json::json!(epoch));
161
+ }
162
+ }
163
+ return;
164
+ }
165
+ // Canonical teams.<key> write. Stage 5 will move this to a per-team
166
+ // state file; the call sites do not change.
167
+ let teams = state
168
+ .as_object_mut()
169
+ .and_then(|root| {
170
+ root.entry("teams")
171
+ .or_insert_with(|| serde_json::json!({}))
172
+ .as_object_mut()
173
+ });
174
+ let Some(teams) = teams else { return };
175
+ let entry = teams
176
+ .entry(team_key.to_string())
177
+ .or_insert_with(|| serde_json::json!({}));
178
+ let Some(entry_obj) = entry.as_object_mut() else {
179
+ return;
180
+ };
181
+ if let Some(receiver) = record.leader_receiver.as_ref() {
182
+ entry_obj.insert("leader_receiver".to_string(), receiver.clone());
183
+ }
184
+ if let Some(owner) = record.team_owner.as_ref() {
185
+ entry_obj.insert("team_owner".to_string(), owner.clone());
186
+ }
187
+ if let Some(epoch) = record.owner_epoch {
188
+ entry_obj.insert("owner_epoch".to_string(), serde_json::json!(epoch));
189
+ }
190
+ }
191
+
192
+ /// Stage 3 top-level cleanup (architect direction 2026-06-24,
193
+ /// .team/artifacts/stage3-toplevel-cleanup-fix.md): strip the legacy
194
+ /// top-level `team_owner / leader_receiver / owner_epoch` fields from a
195
+ /// state root after a canonical `teams.<key>` ownership write has been
196
+ /// published. Required because `save_claim_team_scoped_state` builds the
197
+ /// to-be-persisted root from `value_object(state)` which still carries
198
+ /// stale top-level owner copies from older saves (or from a projected
199
+ /// state that promoted legacy values into the top-level view). Without
200
+ /// this strip, every claim-leader save leaves a stale duplicate at the
201
+ /// root and the dual-source bug returns.
202
+ ///
203
+ /// Used only at owner-mutation save boundaries; do NOT call from a
204
+ /// load-time path — legacy single-team states may still have only
205
+ /// top-level owner and no canonical `teams.<key>` owner, and a blind load
206
+ /// strip would lose ownership before migration.
207
+ pub fn strip_top_level_ownership(root: &mut serde_json::Map<String, Value>) {
208
+ for key in ["team_owner", "leader_receiver", "owner_epoch"] {
209
+ root.remove(key);
210
+ }
211
+ }
212
+
213
+ /// Stage 3 save-output canonical-aware strip (architect direction
214
+ /// 2026-06-24, .team/artifacts/stage3-save-strip-fix.md): conditional
215
+ /// version of `strip_top_level_ownership` used by `state::persist`'s
216
+ /// persistence boundary. Only strips when a canonical
217
+ /// `teams.<active_team_key>.team_owner` record exists; legacy single-team
218
+ /// states whose only owner copy still lives at the root are left
219
+ /// untouched so the `read_owner_for_team` legacy fallback can keep
220
+ /// serving them through the migration window.
221
+ ///
222
+ /// This is the SINGLE call point for owner cleanup at save time. Every
223
+ /// `save_runtime_state*` path funnels through here, so claim / restart /
224
+ /// shutdown / start-agent / stop-agent / coordinator tick / promote
225
+ /// sibling are all uniformly cleaned without per-call-site patches (the
226
+ /// scatter-patching approach S3-OWNER-002 showed was incomplete).
227
+ pub fn strip_top_level_ownership_if_canonical_present(state: &mut Value) {
228
+ let canonical_key = active_team_key_for_strip(state);
229
+ if canonical_key.is_empty() {
230
+ return;
231
+ }
232
+ let teams_entry_has_owner = state
233
+ .get("teams")
234
+ .and_then(Value::as_object)
235
+ .and_then(|teams| teams.get(&canonical_key))
236
+ .and_then(Value::as_object)
237
+ .is_some_and(|entry| entry.contains_key("team_owner"));
238
+ if !teams_entry_has_owner {
239
+ return;
240
+ }
241
+ if let Some(root) = state.as_object_mut() {
242
+ strip_top_level_ownership(root);
243
+ }
244
+ }
245
+
246
+ /// Resolve the canonical team_key for the strip predicate. Architect's
247
+ /// §Modification block keys the strip on `active_team_key`, falling back
248
+ /// to `team_state_key(state)` for the legacy unscoped flow — same
249
+ /// precedence as `canonical_owner_write_key` in `leader/lease.rs` so the
250
+ /// write/strip pair stay aligned on the same key.
251
+ fn active_team_key_for_strip(state: &Value) -> String {
252
+ if let Some(active) = state
253
+ .get("active_team_key")
254
+ .and_then(Value::as_str)
255
+ .filter(|key| !key.is_empty())
256
+ {
257
+ return active.to_string();
258
+ }
259
+ crate::state::projection::team_state_key(state)
260
+ }
261
+
262
+ /// The ownership write payload. All fields optional so callers can update a
263
+ /// subset — receiver-only attach paths don't need to re-emit team_owner.
264
+ #[derive(Debug, Clone, Default)]
265
+ pub struct OwnershipWrite {
266
+ pub team_owner: Option<Value>,
267
+ pub leader_receiver: Option<Value>,
268
+ pub owner_epoch: Option<u64>,
269
+ }
270
+
271
+ impl OwnershipWrite {
272
+ pub fn new() -> Self {
273
+ Self::default()
274
+ }
275
+
276
+ pub fn with_team_owner(mut self, owner: Value) -> Self {
277
+ self.team_owner = Some(owner);
278
+ self
279
+ }
280
+
281
+ pub fn with_leader_receiver(mut self, receiver: Value) -> Self {
282
+ self.leader_receiver = Some(receiver);
283
+ self
284
+ }
285
+
286
+ pub fn with_owner_epoch(mut self, epoch: u64) -> Self {
287
+ self.owner_epoch = Some(epoch);
288
+ self
289
+ }
290
+ }
291
+
292
+ #[cfg(test)]
293
+ #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
294
+ mod tests {
295
+ use super::*;
296
+ use serde_json::json;
297
+
298
+ fn owner_with_pane(pane: &str) -> Value {
299
+ json!({
300
+ "pane_id": pane,
301
+ "leader_session_uuid": format!("uuid-for-{pane}"),
302
+ "owner_epoch": 1,
303
+ })
304
+ }
305
+
306
+ #[test]
307
+ fn returns_none_when_no_owner_anywhere() {
308
+ let state = json!({"active_team_key": "alpha"});
309
+ assert!(read_owner_for_team(&state, "alpha").is_none());
310
+ }
311
+
312
+ #[test]
313
+ fn reads_top_level_when_team_state_key_matches() {
314
+ let state = json!({
315
+ "team_key": "alpha",
316
+ "team_owner": owner_with_pane("%top"),
317
+ });
318
+ let read = read_owner_for_team(&state, "alpha").expect("owner found");
319
+ assert_eq!(read.source, OwnershipSource::LegacyTopLevel);
320
+ assert_eq!(read.value["pane_id"], json!("%top"));
321
+ }
322
+
323
+ #[test]
324
+ fn refuses_top_level_when_team_state_key_disagrees() {
325
+ // Architect §verdict point 2: stale top-level owner from a different
326
+ // team must not be returned for `read_owner_for_team(state, "beta")`.
327
+ let state = json!({
328
+ "team_key": "alpha",
329
+ "team_owner": owner_with_pane("%alpha"),
330
+ });
331
+ assert!(read_owner_for_team(&state, "beta").is_none());
332
+ }
333
+
334
+ #[test]
335
+ fn teams_projection_wins_over_top_level_when_both_present() {
336
+ // Architect §3 migration precedence step 2 > step 3: legacy projection
337
+ // outranks legacy top-level. This is the shape the existing
338
+ // `cli::team_owner_value` helper already encoded; lifting it into
339
+ // the repository preserves the behaviour for all callers.
340
+ let state = json!({
341
+ "team_key": "alpha",
342
+ "team_owner": owner_with_pane("%top"),
343
+ "teams": {"alpha": {"team_owner": owner_with_pane("%teams")}},
344
+ });
345
+ let read = read_owner_for_team(&state, "alpha").expect("owner found");
346
+ assert_eq!(read.source, OwnershipSource::LegacyTeamsProjection);
347
+ assert_eq!(read.value["pane_id"], json!("%teams"));
348
+ }
349
+
350
+ #[test]
351
+ fn empty_team_key_only_reads_top_level() {
352
+ // The owner-gate today calls `read_owner(state, None)` — that pre-Stage-2
353
+ // shape maps to `read_owner_for_team(state, "")`. The empty-key path
354
+ // must NOT branch into teams.<key> (the gate caller is feeding an
355
+ // already-projected state in the legacy flow).
356
+ let state = json!({
357
+ "team_owner": owner_with_pane("%top"),
358
+ "teams": {"alpha": {"team_owner": owner_with_pane("%teams")}},
359
+ });
360
+ let read = read_owner_for_team(&state, "").expect("owner found");
361
+ assert_eq!(read.source, OwnershipSource::LegacyTopLevel);
362
+ assert_eq!(read.value["pane_id"], json!("%top"));
363
+ }
364
+
365
+ #[test]
366
+ fn rejects_invalid_pane_id_consistent_with_projection_read_owner() {
367
+ // The pane-id validity check (must start with `%` or be all-digits)
368
+ // is preserved — this is the legacy `valid_owner_pane_id` behaviour
369
+ // we inherit from projection::read_owner.
370
+ let state = json!({
371
+ "team_key": "alpha",
372
+ "team_owner": json!({"pane_id": "not-a-pane"}),
373
+ });
374
+ assert!(read_owner_for_team(&state, "alpha").is_none());
375
+ }
376
+
377
+ #[test]
378
+ fn read_owner_value_returns_just_the_value() {
379
+ let state = json!({
380
+ "team_key": "alpha",
381
+ "team_owner": owner_with_pane("%top"),
382
+ });
383
+ let value = read_owner_value(&state, "alpha").expect("owner found");
384
+ assert_eq!(value["pane_id"], json!("%top"));
385
+ }
386
+
387
+ // ───────────── Stage 3a: write_owner API tests ─────────────
388
+
389
+ #[test]
390
+ fn write_owner_writes_only_teams_projection_for_named_team() {
391
+ // 3d contract: a named team_key writes ONLY teams.<key>. Top-level
392
+ // is no longer touched by canonical writers — readers go through
393
+ // the repository which applies migration precedence.
394
+ let mut state = json!({});
395
+ let record = OwnershipWrite::new()
396
+ .with_team_owner(owner_with_pane("%new"))
397
+ .with_owner_epoch(7)
398
+ .with_leader_receiver(json!({"pane_id": "%new", "mode": "direct_tmux"}));
399
+ write_owner(&mut state, "alpha", record);
400
+ // Top-level: NOT written.
401
+ assert!(state.get("team_owner").is_none());
402
+ assert!(state.get("owner_epoch").is_none());
403
+ assert!(state.get("leader_receiver").is_none());
404
+ // Canonical teams.<key>: written.
405
+ assert_eq!(state["teams"]["alpha"]["team_owner"]["pane_id"], json!("%new"));
406
+ assert_eq!(state["teams"]["alpha"]["owner_epoch"], json!(7));
407
+ assert_eq!(
408
+ state["teams"]["alpha"]["leader_receiver"]["pane_id"],
409
+ json!("%new")
410
+ );
411
+ }
412
+
413
+ #[test]
414
+ fn write_owner_supports_partial_updates() {
415
+ // Receiver-only attach path doesn't need to re-emit team_owner.
416
+ let mut state = json!({
417
+ "teams": {"alpha": {"team_owner": owner_with_pane("%existing")}},
418
+ });
419
+ let record = OwnershipWrite::new()
420
+ .with_leader_receiver(json!({"pane_id": "%existing", "mode": "direct_tmux"}));
421
+ write_owner(&mut state, "alpha", record);
422
+ // Existing team_owner untouched.
423
+ assert_eq!(
424
+ state["teams"]["alpha"]["team_owner"]["pane_id"],
425
+ json!("%existing")
426
+ );
427
+ // Receiver written only to teams.<key>.
428
+ assert!(state.get("leader_receiver").is_none());
429
+ assert_eq!(
430
+ state["teams"]["alpha"]["leader_receiver"]["pane_id"],
431
+ json!("%existing")
432
+ );
433
+ }
434
+
435
+ #[test]
436
+ fn write_owner_with_empty_team_key_only_writes_top_level() {
437
+ // The owner-gate-style attach with no team scope: still preserve the
438
+ // legacy single-source top-level write. Stage 5 will refuse this
439
+ // path; for now it remains compatible.
440
+ let mut state = json!({});
441
+ let record = OwnershipWrite::new().with_team_owner(owner_with_pane("%top"));
442
+ write_owner(&mut state, "", record);
443
+ assert_eq!(state["team_owner"]["pane_id"], json!("%top"));
444
+ assert!(
445
+ state.get("teams").is_none_or(|teams| teams
446
+ .as_object()
447
+ .is_some_and(|map| map.is_empty())),
448
+ "empty team_key must NOT touch teams.<key>"
449
+ );
450
+ }
451
+
452
+ #[test]
453
+ fn strip_if_canonical_present_strips_when_teams_entry_has_owner() {
454
+ // Canonical-aware strip: both root AND teams.<active> have owner →
455
+ // root gets stripped, canonical preserved.
456
+ let mut state = json!({
457
+ "active_team_key": "alpha",
458
+ "team_owner": owner_with_pane("%stale"),
459
+ "leader_receiver": {"pane_id": "%stale"},
460
+ "owner_epoch": 1,
461
+ "teams": {
462
+ "alpha": {
463
+ "team_owner": owner_with_pane("%canonical"),
464
+ "owner_epoch": 2,
465
+ }
466
+ },
467
+ });
468
+ strip_top_level_ownership_if_canonical_present(&mut state);
469
+ assert!(state.get("team_owner").is_none());
470
+ assert!(state.get("leader_receiver").is_none());
471
+ assert!(state.get("owner_epoch").is_none());
472
+ assert_eq!(
473
+ state["teams"]["alpha"]["team_owner"]["pane_id"],
474
+ json!("%canonical")
475
+ );
476
+ assert_eq!(state["teams"]["alpha"]["owner_epoch"], json!(2));
477
+ }
478
+
479
+ #[test]
480
+ fn strip_if_canonical_present_preserves_legacy_only_state() {
481
+ // Legacy-only state: root owner exists but no canonical
482
+ // teams.<active> owner. The strip MUST NOT remove root — that
483
+ // would lose ownership before the migration window completes.
484
+ // `read_owner_for_team` fallback still serves these readers.
485
+ let mut state = json!({
486
+ "active_team_key": "alpha",
487
+ "team_owner": owner_with_pane("%legacy"),
488
+ "leader_receiver": {"pane_id": "%legacy"},
489
+ "owner_epoch": 1,
490
+ });
491
+ strip_top_level_ownership_if_canonical_present(&mut state);
492
+ assert_eq!(state["team_owner"]["pane_id"], json!("%legacy"));
493
+ assert_eq!(state["leader_receiver"]["pane_id"], json!("%legacy"));
494
+ assert_eq!(state["owner_epoch"], json!(1));
495
+ }
496
+
497
+ #[test]
498
+ fn strip_if_canonical_present_falls_back_to_team_state_key() {
499
+ // No active_team_key → fall back to team_state_key (here:
500
+ // session_name "team-agent-x"). If that bucket has canonical
501
+ // owner, strip root.
502
+ let mut state = json!({
503
+ "session_name": "team-agent-x",
504
+ "team_owner": owner_with_pane("%stale"),
505
+ "teams": {
506
+ "team-agent-x": {"team_owner": owner_with_pane("%canonical")},
507
+ },
508
+ });
509
+ strip_top_level_ownership_if_canonical_present(&mut state);
510
+ assert!(state.get("team_owner").is_none());
511
+ assert_eq!(
512
+ state["teams"]["team-agent-x"]["team_owner"]["pane_id"],
513
+ json!("%canonical")
514
+ );
515
+ }
516
+
517
+ #[test]
518
+ fn strip_top_level_ownership_removes_legacy_fields_only() {
519
+ // Stage 3 top-level cleanup: stripping legacy root owner fields
520
+ // must not touch unrelated state keys or the canonical teams.<key>
521
+ // ownership record.
522
+ let mut state = json!({
523
+ "session_name": "team-agent-x",
524
+ "team_owner": owner_with_pane("%stale"),
525
+ "leader_receiver": {"pane_id": "%stale", "mode": "direct_tmux"},
526
+ "owner_epoch": 1,
527
+ "teams": {"alpha": {"team_owner": owner_with_pane("%canonical"), "owner_epoch": 2}},
528
+ });
529
+ let root = state.as_object_mut().expect("root object");
530
+ strip_top_level_ownership(root);
531
+ assert!(root.get("team_owner").is_none());
532
+ assert!(root.get("leader_receiver").is_none());
533
+ assert!(root.get("owner_epoch").is_none());
534
+ // Unrelated fields preserved.
535
+ assert_eq!(root.get("session_name"), Some(&json!("team-agent-x")));
536
+ // Canonical teams.<key> untouched.
537
+ assert_eq!(
538
+ root["teams"]["alpha"]["team_owner"]["pane_id"],
539
+ json!("%canonical")
540
+ );
541
+ assert_eq!(root["teams"]["alpha"]["owner_epoch"], json!(2));
542
+ }
543
+
544
+ #[test]
545
+ fn write_owner_then_read_round_trip() {
546
+ // 3a write + Stage 2 read agree: after writing for "alpha", reading
547
+ // for "alpha" returns the LegacyTeamsProjection branch (teams wins
548
+ // over top-level per the precedence rule).
549
+ let mut state = json!({"team_key": "alpha"});
550
+ let record = OwnershipWrite::new().with_team_owner(owner_with_pane("%w1"));
551
+ write_owner(&mut state, "alpha", record);
552
+ let read = read_owner_for_team(&state, "alpha").expect("owner found");
553
+ assert_eq!(read.source, OwnershipSource::LegacyTeamsProjection);
554
+ assert_eq!(read.value["pane_id"], json!("%w1"));
555
+ }
556
+ }