meerkat-mobkit 0.7.32

Companion orchestration platform for the Meerkat multi-agent runtime
Documentation
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
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
//! Shared admission control for WorkGraph attention-binding mutations.
//!
//! Upstream (meerkat 0.7.23) happily gives one member a second Active
//! attention binding, after which every scoped turn of that member is a hard
//! `MultipleActiveBindings` error until an operator intervenes — a bricked
//! member. MobKit therefore refuses to ADMIT the second binding, and this
//! module is the single place that refusal lives: the occupancy check
//! (with session↔identity alias resolution through the mob roster and the
//! shared session store's member-binding metadata), the in-process gate
//! serializing every check-then-act window, and — for SQLite-backed stores
//! that two processes may share — a cross-process sidecar lock.
//!
//! One [`WorkGraphAdmission`] exists per [`MobRuntime`](crate::MobRuntime).
//! Every surface that can mint an attention binding must go through it:
//! - the `mobkit/workgraph/*` RPC arms (unified stdin + console) for
//!   `goal/create`, `attention/resume` and `attention/reassign`;
//! - the AGENT TOOL plane: `ScopePinnedWorkGraphTools` intercepts
//!   `workgraph_attention_reassign` through a late-bound
//!   [`WorkGraphAdmissionSlot`] that [`MobRuntime::bootstrap`] fills (the
//!   tool wrapper is constructed before the mob — and thus the roster —
//!   exists). An unfilled slot (non-mob embedder) forwards unguarded, as
//!   before.
//!
//! A surface that checked without holding the gate would race the others
//! past the check; a surface that skipped the check (the round-3 tool-plane
//! hole) would both brick the member and invert authority — an agent doing
//! what an ABAC-granted operator is refused.
//!
//! # Target spelling: normalize at write, alias at read (round-4 Q2, round-5 S1)
//!
//! The roster is PROCESS-LOCAL, but the SQLite store is documented as
//! shareable by two processes (gateway + library-mode runtime on one state
//! dir). A guard that needed the roster to equate a session-form row with an
//! identity-form check would be alias-blind in the process that doesn't know
//! the member — and in-process while a member is mid-respawn (absent from
//! the roster). So mobkit normalizes at WRITE instead: every mutation that
//! points a binding at a target (`goal/create` and `attention/reassign` on
//! the RPC arms, `workgraph_attention_reassign` on the tool plane) first
//! lowers a session target that resolves to a member of THIS mob to its
//! OWNER form (`mob/<mob>/agent/<identity>`) via
//! [`WorkGraphAdmission::lower_member_session_target`]. Session→member
//! resolution is roster-first with a SHARED-store fallback (round-5 S1): a
//! mob member's session carries its durable identity on
//! `session_metadata.mob_member_binding` (the exact seam meerkat's schedule
//! identity-recovery reads — meerkat 0.7.23,
//! meerkat-mob/src/runtime/builder.rs `persisted_session_matches_member`),
//! and that metadata lives in the session store both processes share — so a
//! roster-BLIND co-process (and this process mid-respawn) still lowers
//! member session targets instead of minting the session-form rows an
//! identity-form occupancy check cannot see. Only when BOTH the roster and
//! the session metadata miss does a target keep its session form — a
//! genuinely non-member session, for which no aliasing exists. Mobkit-created
//! bindings are therefore owner-form whenever a member is involved, and the
//! occupancy check's roster-FREE layer — primary owner-key equality, which
//! for non-member sessions is raw-session-id equality — refuses duplicates
//! without consulting any roster. The same roster-then-store resolution
//! backs the session↔identity aliasing in
//! [`WorkGraphAdmission::attention_target_alias_keys`], the EXTRA layer for
//! legacy or CLI-created session-form rows (bindings written by the meerkat
//! CLI directly on a shared store bypass write normalization). The residual
//! holes are CLI-written rows for sessions OUTSIDE this mob's session store
//! (no resolution seam exists for them at all), and CLI-written session-form
//! member rows checked from an identity-form target in a process whose
//! roster misses the member — the store carries no identity→session lookup
//! short of a full session scan, so that direction stays roster-only.
//!
//! Round-6: a session can ALSO be spelled as an owner-form target —
//! `{kind:"owner"|"lowered_owner", owner_key:{kind:"session", id:<session>}}`
//! (the store's own `Session`-arm rows canonicalize to exactly that owner
//! key). Write-side lowering therefore keys on the resolved owner key, not
//! on the target VARIANT: [`WorkGraphAdmission::lower_member_session_target`]
//! canonicalizes a session-kind owner key into the same session resolution
//! path — member sessions lower to owner form, non-member sessions come back
//! in the canonical `{kind:"session"}` arm (identical occupancy key), an id
//! that does not parse as a session id is refused, and store-read failures
//! fail closed exactly as for `{kind:"session"}` targets. A session-kind
//! owner key never reaches the store verbatim; letting one through would
//! store a session-spelled `LoweredOwner` row that an identity-form
//! occupancy check in a roster-blind process cannot see — re-opening the
//! duplicate window normalization exists to close.
//!
//! # Occupancy-scan bounds
//!
//! The occupancy check queries `list_attention` once per occupying status
//! (Active, Paused) with the service's realm and namespace pinned, so
//! upstream filters before returning rather than handing back every
//! permanently-accumulating Superseded/Stopped row. The upstream store-level
//! SELECT itself has no WHERE clause (meerkat 0.7.23,
//! meerkat-workgraph/src/store.rs `list_sqlite_attention` filters in Rust
//! after a full scan) — that bound is upstream's; an upstream ask is filed
//! separately.

use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;

use meerkat::{
    AttentionBindingRequest, AttentionListRequest, GoalAttentionTarget, WorkAttentionBinding,
    WorkAttentionBindingId, WorkAttentionStatus, WorkAttentionTarget, WorkGraphError,
    WorkGraphService, WorkNamespace, WorkOwnerKey, WorkOwnerKind,
};

/// File name of the cross-process admission lock database, created beside
/// [`WORKGRAPH_STORE_FILE`](crate::workgraph_wiring::WORKGRAPH_STORE_FILE).
/// Deliberately a SEPARATE file: holding a write transaction on the real
/// store across the check-then-mutate window would deadlock against the
/// service's own writes mid-admission.
pub const WORKGRAPH_ADMISSION_SIDECAR_FILE: &str = "workgraph.admission.sqlite3";

/// The sidecar lock path for a workgraph store under `state_dir`.
#[must_use]
pub fn workgraph_admission_sidecar_path(state_dir: &Path) -> PathBuf {
    state_dir.join(WORKGRAPH_ADMISSION_SIDECAR_FILE)
}

/// Late-bound slot through which a tool-plane dispatcher reaches the
/// runtime's [`WorkGraphAdmission`]. Created by
/// [`install_workgraph_tools`](crate::workgraph_wiring::install_workgraph_tools),
/// registered on the [`MobBootstrapSpec`](crate::MobBootstrapSpec), filled by
/// [`MobRuntime::bootstrap`](crate::MobRuntime::bootstrap). `None` (never
/// filled) means the embedder has no mob runtime; the dispatcher then
/// forwards without admission, exactly as before the guard existed.
pub type WorkGraphAdmissionSlot = Arc<std::sync::RwLock<Option<Arc<WorkGraphAdmission>>>>;

/// Why an admission was refused (or could not be decided).
#[derive(Debug)]
pub(crate) enum WorkGraphAdmissionError {
    /// The target already carries an occupying binding; `detail` names the
    /// occupying binding and the way out, and is safe to surface verbatim on
    /// both the RPC (K2 full-disclosure posture) and tool planes.
    Occupied { detail: String },
    /// The occupancy check itself failed against the service.
    Service(WorkGraphError),
    /// The cross-process sidecar lock could not be taken. Fail closed: an
    /// unserialized admission is exactly the race the sidecar exists to
    /// prevent.
    Lock(String),
}

/// Held for the whole check-then-mutate window of one admission decision.
/// Dropping it releases the in-process gate and (when configured) the
/// cross-process sidecar transaction.
pub(crate) struct WorkGraphAdmissionPermit {
    _in_process: tokio::sync::OwnedMutexGuard<()>,
    _cross_process: Option<SidecarLock>,
}

/// A `BEGIN IMMEDIATE` transaction held open on the sidecar database.
/// SQLite's RESERVED lock admits exactly one holder per file across
/// processes; dropping the connection rolls the (empty) transaction back and
/// releases the lock.
struct SidecarLock {
    _connection: rusqlite::Connection,
}

impl SidecarLock {
    /// Generous timeout: cross-process contention is rare (operator-paced
    /// goal/attention mutations), and failing closed on a busy sidecar
    /// refuses a legitimate admission.
    const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);

    fn acquire(path: &Path) -> Result<Self, String> {
        let connection = rusqlite::Connection::open(path)
            .map_err(|error| format!("open admission sidecar {}: {error}", path.display()))?;
        connection
            .busy_timeout(Self::BUSY_TIMEOUT)
            .map_err(|error| format!("set admission sidecar busy timeout: {error}"))?;
        connection
            .execute_batch("BEGIN IMMEDIATE")
            .map_err(|error| {
                format!(
                    "could not lock the workgraph admission sidecar {} within the {}s busy \
                     timeout: {error}. The lock is held by another process sharing this state \
                     dir (in the documented deployment: a gateway and a library-mode runtime on \
                     one workgraph.sqlite3) — most likely a co-process is wedged mid-admission \
                     or under heavy binding-mutation load; retry, or check that co-process",
                    path.display(),
                    Self::BUSY_TIMEOUT.as_secs(),
                )
            })?;
        Ok(Self {
            _connection: connection,
        })
    }
}

/// Runtime-wide admission authority for attention-binding mutations: gate +
/// occupancy check. See the module docs for the invariants.
pub struct WorkGraphAdmission {
    mob_handle: meerkat_mob::MobHandle,
    /// Session-metadata read seam for session→member resolution when the
    /// PROCESS-LOCAL roster misses (see the module docs): a member session
    /// carries `session_metadata.mob_member_binding`, and the session store
    /// is shared with any co-process on the same state dir. `None` only for
    /// `MobRuntime::from_handle` runtimes, which have no session service to
    /// read through — those keep roster-only resolution.
    session_service: Option<Arc<dyn meerkat_mob::MobSessionService>>,
    /// Serializes every check-then-act window in this process. `Arc` so
    /// permits can hold an owned guard (the tool plane keeps one across the
    /// forwarded dispatch).
    gate: Arc<tokio::sync::Mutex<()>>,
    /// Cross-process lock database, set only for SQLite-backed stores —
    /// `workgraph.sqlite3` is documented as shareable by a gateway and a
    /// library-mode runtime on one state dir, and two processes means two
    /// in-process gates. Memory-backed runtimes are single-process by
    /// construction and keep the in-process gate only.
    sidecar: Option<PathBuf>,
    /// Memo of POSITIVE session→member resolutions through the session-store
    /// fallback of [`Self::resolve_member_identity`].
    /// `load_persisted_session` is a FULL authoritative session
    /// deserialization — multi-GB for long-lived members — and resolution
    /// runs while the runtime-wide gate (and, on shared stores, the
    /// sidecar's `BEGIN IMMEDIATE` transaction) is held, so paying it on
    /// every roster-miss would spike latency/memory and starve a co-process
    /// into the sidecar's 30s busy timeout.
    ///
    /// The mapping is NOT immutable: session ADOPTION is a legitimate flow
    /// (a free-floating session — or a member of another mob — resumed into
    /// a member build via `resume_session`; the factory re-stamps
    /// `mob_member_binding` and persists it). So:
    /// - NEGATIVE results are never cached — a stale non-member entry would
    ///   silently re-open the roster-blind duplicate window when the session
    ///   is adopted (and negative lookups are the rare path: goals
    ///   overwhelmingly target members).
    /// - POSITIVE entries carry a short TTL
    ///   ([`Self::MEMBER_RESOLUTION_TTL`]) bounding the adoption-away
    ///   window (a member session re-adopted elsewhere would otherwise
    ///   lower session targets to the stale identity).
    ///
    /// Bounded by [`Self::MEMBER_RESOLUTION_CACHE_MAX`], cleared wholesale
    /// on overflow. The FIRST resolution of each session (and each negative
    /// lookup) pays the full-session read: the store exposes no
    /// metadata-only seam (upstream ask candidate, noted on ask 24's
    /// mobkit-interim line in docs/design/upstream-asks.md).
    member_resolution_cache: std::sync::Mutex<
        HashMap<meerkat::SessionId, (std::time::Instant, meerkat_mob::ids::AgentIdentity)>,
    >,
    /// TTL for positive member-resolution entries; overridable in tests.
    member_resolution_ttl: std::time::Duration,
}

impl WorkGraphAdmission {
    /// Bound on [`Self::member_resolution_cache`]. Sized past any plausible
    /// roster (OB3's eternal fleet is ~600 members) while capping worst-case
    /// growth from admissions against arbitrary non-member session ids.
    const MEMBER_RESOLUTION_CACHE_MAX: usize = 4096;

    /// TTL for positive session→member memo entries. Long enough to absorb
    /// admission bursts against the same eternal member session; short
    /// enough that an adoption-away (legitimate: sessions can be resumed
    /// into other members/mobs, re-stamping the binding) converges quickly.
    const MEMBER_RESOLUTION_TTL: std::time::Duration = std::time::Duration::from_mins(1);

    pub fn new(
        mob_handle: meerkat_mob::MobHandle,
        session_service: Option<Arc<dyn meerkat_mob::MobSessionService>>,
        sidecar: Option<PathBuf>,
    ) -> Self {
        Self {
            mob_handle,
            session_service,
            gate: Arc::new(tokio::sync::Mutex::new(())),
            sidecar,
            member_resolution_cache: std::sync::Mutex::new(HashMap::new()),
            member_resolution_ttl: Self::MEMBER_RESOLUTION_TTL,
        }
    }

    /// Test hook: shrink the positive-entry TTL so expiry is observable.
    #[cfg(test)]
    pub(crate) fn with_member_resolution_ttl(mut self, ttl: std::time::Duration) -> Self {
        self.member_resolution_ttl = ttl;
        self
    }

    /// The mob whose roster backs alias resolution (and whose definition id
    /// scopes identity-target lowering on the RPC surface).
    pub(crate) fn mob_handle(&self) -> &meerkat_mob::MobHandle {
        &self.mob_handle
    }

    /// Resolve the mob member owning `session_id`: the PROCESS-LOCAL roster
    /// first, then — on a roster miss — the session's persisted metadata. A
    /// member session carries its durable identity on
    /// `session_metadata.mob_member_binding` (the seam meerkat's schedule
    /// identity-recovery reads), and the session store is SHARED across
    /// co-processes on one state dir, so this resolves members the roster
    /// has never seen (a roster-blind co-process) or has momentarily dropped
    /// (mid-respawn). `Ok(None)` means both missed — a genuinely non-member
    /// session, or a member of some OTHER mob (the binding is checked
    /// against THIS mob's id). A session-store read failure fails CLOSED
    /// (surfaced as a store error, never cached): treating it as a miss
    /// would silently re-open the roster-blind aliasing hole this fallback
    /// exists to plug. POSITIVE results are memoized (with a TTL) in
    /// [`Self::member_resolution_cache`]; negative results are re-read every
    /// time — session adoption can turn a non-member session into a member
    /// session at any moment, and a stale negative would re-open the
    /// duplicate window.
    async fn resolve_member_identity(
        &self,
        session_id: &meerkat::SessionId,
    ) -> Result<Option<meerkat_mob::ids::AgentIdentity>, WorkGraphError> {
        if let Some(entry) = self
            .mob_handle
            .roster()
            .await
            .find_by_bridge_session_id(session_id)
        {
            return Ok(Some(entry.agent_identity.clone()));
        }
        if let Some((stamped_at, identity)) = self
            .member_resolution_cache
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .get(session_id)
            && stamped_at.elapsed() < self.member_resolution_ttl
        {
            return Ok(Some(identity.clone()));
        }
        let Some(service) = self.session_service.as_ref() else {
            return Ok(None);
        };
        let session = service
            .load_persisted_session(session_id)
            .await
            .map_err(|error| {
                WorkGraphError::Store(format!(
                    "workgraph admission could not read session {session_id} from the session \
                     store while resolving its mob member: {error}"
                ))
            })?;
        let resolved = session
            .and_then(|session| session.session_metadata())
            .and_then(|metadata| metadata.mob_member_binding)
            .filter(|binding| binding.mob_id == self.mob_handle.definition().id.as_str())
            .map(|binding| meerkat_mob::ids::AgentIdentity::from(binding.member.as_str()));
        if let Some(identity) = resolved.as_ref() {
            let mut cache = self
                .member_resolution_cache
                .lock()
                .unwrap_or_else(std::sync::PoisonError::into_inner);
            if cache.len() >= Self::MEMBER_RESOLUTION_CACHE_MAX {
                cache.clear();
            }
            cache.insert(
                session_id.clone(),
                (std::time::Instant::now(), identity.clone()),
            );
        }
        Ok(resolved)
    }

    /// WRITE-side target normalization (see the module docs): lower a
    /// session-addressed target that addresses a member of THIS mob to the
    /// member's owner form (`mob/<mob>/agent/<identity>`), so the stored
    /// binding row matches identity-form occupancy checks WITHOUT a roster —
    /// in the co-process sharing the store, and in this process while the
    /// member is mid-respawn. Member resolution is roster-first with the
    /// shared-store session-metadata fallback
    /// ([`Self::resolve_member_identity`]), so the lowering itself is
    /// roster-free for persisted member sessions.
    ///
    /// "Session-addressed" covers BOTH spellings (round-6): the
    /// `{kind:"session"}` arm and an owner-form target whose owner key has
    /// kind `session` — the two carry the same canonical occupancy key, so
    /// both are canonicalized through the same resolution path. Non-member
    /// sessions come back in the canonical `{kind:"session"}` arm whatever
    /// spelling they arrived in (their occupancy equivalence is
    /// raw-session-id equality; no aliasing exists for them), as does a
    /// member whose identity refuses to lower — a session-kind owner key
    /// never reaches the store verbatim. A session-kind owner key whose id
    /// does not parse as a session id is refused, and a session-store read
    /// failure refuses the mutation (fail closed).
    pub(crate) async fn lower_member_session_target(
        &self,
        target: GoalAttentionTarget,
    ) -> Result<GoalAttentionTarget, WorkGraphAdmissionError> {
        let session_id = match &target {
            GoalAttentionTarget::Session { session_id } => session_id.clone(),
            GoalAttentionTarget::Owner { owner_key }
                if owner_key.kind == WorkOwnerKind::Session =>
            {
                meerkat::SessionId::parse(&owner_key.id).map_err(|error| {
                    WorkGraphAdmissionError::Service(WorkGraphError::InvalidInput(format!(
                        "attention target owner key '{}' has kind 'session' but its id does not \
                         parse as a session id: {error}",
                        owner_key.canonical(),
                    )))
                })?
            }
            _ => return Ok(target),
        };
        let Some(identity) = self
            .resolve_member_identity(&session_id)
            .await
            .map_err(WorkGraphAdmissionError::Service)?
        else {
            return Ok(GoalAttentionTarget::Session { session_id });
        };
        Ok(
            match meerkat_mob::lower_agent_identity_attention_target(
                &self.mob_handle.definition().id,
                &identity,
            ) {
                Ok(lowered) => lowered,
                Err(_) => GoalAttentionTarget::Session { session_id },
            },
        )
    }

    /// Take the admission for one check-then-mutate window. The in-process
    /// gate is taken first so at most one task per process waits on the
    /// sidecar; the sidecar (when configured) then serializes against other
    /// processes sharing the store.
    pub(crate) async fn acquire(
        &self,
    ) -> Result<WorkGraphAdmissionPermit, WorkGraphAdmissionError> {
        let in_process = Arc::clone(&self.gate).lock_owned().await;
        let cross_process = match &self.sidecar {
            None => None,
            Some(path) => {
                let path = path.clone();
                let lock = tokio::task::spawn_blocking(move || SidecarLock::acquire(&path))
                    .await
                    .map_err(|error| {
                        WorkGraphAdmissionError::Lock(format!(
                            "admission sidecar lock task failed: {error}"
                        ))
                    })?
                    .map_err(WorkGraphAdmissionError::Lock)?;
                Some(lock)
            }
        };
        Ok(WorkGraphAdmissionPermit {
            _in_process: in_process,
            _cross_process: cross_process,
        })
    }

    /// Refuse a `goal/create`/`attention/reassign` whose target already
    /// carries an Active or Paused attention binding. Matching is primary
    /// owner-key equality first (roster-free — the write side normalizes
    /// member targets to owner form, see the module docs), with
    /// session↔identity aliasing (roster, then shared-store session
    /// metadata) as an extra layer for rows some other writer left in
    /// session form. `exclude` names the binding a reassign is superseding,
    /// which cannot conflict with its own move. Must be called with a permit
    /// held — the caller holds it across the mutation too.
    pub(crate) async fn check_target_free(
        &self,
        service: &WorkGraphService,
        namespace: Option<WorkNamespace>,
        target: &WorkAttentionTarget,
        exclude: Option<&WorkAttentionBindingId>,
        action: &str,
    ) -> Result<(), WorkGraphAdmissionError> {
        let aliases = self
            .attention_target_alias_keys(target)
            .await
            .map_err(WorkGraphAdmissionError::Service)?;
        let bindings = list_occupying_attention(service, namespace)
            .await
            .map_err(WorkGraphAdmissionError::Service)?;
        let Some(existing) = bindings.iter().find(|binding| {
            exclude != Some(&binding.binding_id)
                && binding_occupies_target(&binding.status)
                && binding
                    .target
                    .owner_key()
                    .is_ok_and(|key| aliases.contains(&key.canonical()))
        }) else {
            return Ok(());
        };
        let target_key = target
            .owner_key()
            .map_err(WorkGraphAdmissionError::Service)?;
        Err(WorkGraphAdmissionError::Occupied {
            detail: match existing.status {
                WorkAttentionStatus::Paused { .. } => format!(
                    "target '{}' already has a paused attention binding {} that will reactivate \
                     when its pause expires; resume it or close its goal instead of {action}",
                    target_key.canonical(),
                    existing.binding_id,
                ),
                _ => format!(
                    "target '{}' already has an active attention binding {}; reassign it or \
                     close its goal before {action}",
                    target_key.canonical(),
                    existing.binding_id,
                ),
            },
        })
    }

    /// Resume-side twin of [`check_target_free`](Self::check_target_free):
    /// pause A, create B on the same member, resume A = two Active bindings.
    /// Siblings occupy exactly as on create/reassign — Active OR Paused (a
    /// timed pause auto-reactivates at expiry, so resuming "into" it just
    /// schedules the second Active); the resumed binding itself is excluded.
    /// An unknown `binding_id` falls through so the service reports its
    /// canonical not-found error.
    pub(crate) async fn check_resume_target_free(
        &self,
        service: &WorkGraphService,
        namespace: Option<WorkNamespace>,
        binding_id: &WorkAttentionBindingId,
    ) -> Result<(), WorkGraphAdmissionError> {
        let resumed = match service
            .attention_binding(AttentionBindingRequest {
                binding_id: binding_id.clone(),
                realm_id: None,
                namespace: namespace.clone(),
            })
            .await
        {
            Ok(result) => result.attention,
            Err(WorkGraphError::AttentionNotFound { .. }) => return Ok(()),
            Err(error) => return Err(WorkGraphAdmissionError::Service(error)),
        };
        let aliases = self
            .attention_target_alias_keys(&resumed.target)
            .await
            .map_err(WorkGraphAdmissionError::Service)?;
        let siblings = list_occupying_attention(service, namespace)
            .await
            .map_err(WorkGraphAdmissionError::Service)?;
        let Some(other) = siblings.iter().find(|binding| {
            binding.binding_id != *binding_id
                && binding_occupies_target(&binding.status)
                && binding
                    .target
                    .owner_key()
                    .is_ok_and(|key| aliases.contains(&key.canonical()))
        }) else {
            return Ok(());
        };
        let target_key = resumed
            .target
            .owner_key()
            .map(|key| key.canonical())
            .unwrap_or_default();
        Err(WorkGraphAdmissionError::Occupied {
            detail: match other.status {
                WorkAttentionStatus::Paused { .. } => format!(
                    "resuming attention binding {binding_id} would give target '{target_key}' a \
                     second occupying binding: {} is paused and will reactivate when its pause \
                     expires; close its goal first",
                    other.binding_id,
                ),
                _ => format!(
                    "resuming attention binding {binding_id} would give target '{target_key}' a \
                     second active binding ({} is already active); reassign it or close its \
                     goal first",
                    other.binding_id,
                ),
            },
        })
    }

    /// Every canonical owner-key spelling that addresses the same member as
    /// `target`. Upstream `attention_target_matches_session` (meerkat 0.7.23,
    /// meerkat/src/surface.rs) matches BOTH a member's bridge session id and
    /// its lowered `mob/<mob>/agent/<identity>` owner key to the same
    /// member's turns, so a session-form binding and an identity-form
    /// binding on one member are still two bindings on one member. The
    /// primary key is always present; the other spelling is added when the
    /// target resolves to a member — session→identity through the roster or
    /// the shared store's session metadata
    /// ([`Self::resolve_member_identity`]), identity→session through the
    /// roster only (the store has no identity-keyed lookup; see the module
    /// docs' residual note). An unresolvable target simply has one spelling.
    async fn attention_target_alias_keys(
        &self,
        target: &WorkAttentionTarget,
    ) -> Result<BTreeSet<String>, WorkGraphError> {
        let mob_handle = &self.mob_handle;
        let primary = target.owner_key()?;
        let mut keys = BTreeSet::from([primary.canonical()]);
        match primary.kind {
            // session → identity: roster first, then shared-store metadata.
            WorkOwnerKind::Session => {
                if let Ok(session_id) = meerkat::SessionId::parse(&primary.id)
                    && let Some(identity) = self.resolve_member_identity(&session_id).await?
                    && let Ok(key) = meerkat_mob::lower_agent_identity_owner_key(
                        &mob_handle.definition().id,
                        &identity,
                    )
                {
                    keys.insert(key.canonical());
                }
            }
            // identity → session: only for THIS mob's lowered agent keys.
            WorkOwnerKind::Agent => {
                if let Some((mob_id, identity)) = mob_agent_owner_key_parts(&primary.id)
                    && mob_id == mob_handle.definition().id.as_str()
                    && let Some(session_id) = mob_handle
                        .resolve_bridge_session_id_observation(
                            &meerkat_mob::ids::AgentIdentity::from(identity),
                        )
                        .await
                    && let Ok(key) = WorkOwnerKey::session(session_id.to_string())
                {
                    keys.insert(key.canonical());
                }
            }
            _ => {}
        }
        Ok(keys)
    }
}

/// Whether `status` occupies its target: Active now, or Paused — a pause
/// auto-reactivates at expiry, and upstream's Active listing is
/// eligibility-at-now, so a paused binding is a scheduled second Active.
fn binding_occupies_target(status: &WorkAttentionStatus) -> bool {
    matches!(
        status,
        WorkAttentionStatus::Active | WorkAttentionStatus::Paused { .. }
    )
}

/// The bindings that currently occupy a target, queried once per occupying
/// status with the service scope pinned so upstream filters BEFORE returning
/// — an unfiltered `list_attention` would hand back every
/// permanently-accumulating Superseded/Stopped row on each admission, while
/// the global gate and sidecar are held. Upstream's `Active` filter is
/// eligibility-at-now (Active status, plus Paused past its deadline) and its
/// `Paused` filter is paused-and-not-yet-eligible, so the two scans are
/// disjoint and their union is exactly the Active-or-Paused set
/// [`binding_occupies_target`] admits; the callers keep that predicate as an
/// in-memory recheck so occupancy semantics do not silently follow upstream
/// filter drift. The store-level SELECT under these calls is still a full
/// scan (bounded by upstream — see the module docs).
async fn list_occupying_attention(
    service: &WorkGraphService,
    namespace: Option<WorkNamespace>,
) -> Result<Vec<WorkAttentionBinding>, WorkGraphError> {
    let namespace = namespace.unwrap_or_else(|| service.default_namespace().clone());
    let mut bindings = Vec::new();
    for status in [
        WorkAttentionStatus::Active,
        WorkAttentionStatus::Paused { until: None },
    ] {
        let result = service
            .list_attention(AttentionListRequest {
                realm_id: Some(service.default_realm_id().to_string()),
                namespace: Some(namespace.clone()),
                target: None,
                status: Some(status),
            })
            .await?;
        bindings.extend(result.attention);
    }
    Ok(bindings)
}

/// Mirror of upstream `mob_agent_owner_key_parts` (meerkat 0.7.23,
/// meerkat/src/surface.rs — private there): split a lowered
/// `mob/<mob>/agent/<identity>` owner id into its parts.
fn mob_agent_owner_key_parts(owner_id: &str) -> Option<(&str, &str)> {
    let rest = owner_id.strip_prefix("mob/")?;
    let (mob_id, agent_identity) = rest.split_once("/agent/")?;
    if mob_id.is_empty()
        || agent_identity.is_empty()
        || mob_id.contains('/')
        || agent_identity.contains('/')
    {
        return None;
    }
    Some((mob_id, agent_identity))
}

#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
    use super::*;

    /// The sidecar mechanism itself: SQLite's `BEGIN IMMEDIATE` on one file
    /// admits exactly one holder — a second acquirer waits (busy handler)
    /// until the first releases. This is what serializes two PROCESSES that
    /// share one workgraph.sqlite3; the in-process gate cannot see them.
    #[tokio::test(flavor = "multi_thread")]
    async fn sidecar_lock_admits_one_holder_and_makes_the_second_wait() {
        let dir = tempfile::tempdir().expect("temp dir");
        let path = workgraph_admission_sidecar_path(dir.path());

        let first = SidecarLock::acquire(&path).expect("first lock");
        assert!(path.exists(), "acquire must create the sidecar database");

        let contended = path.clone();
        let second = tokio::task::spawn_blocking(move || SidecarLock::acquire(&contended));
        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
        assert!(
            !second.is_finished(),
            "second holder must wait while the first transaction is open"
        );

        drop(first);
        let second = second.await.expect("join");
        assert!(second.is_ok(), "released lock must admit the waiter");
    }

    /// The sidecar is a separate file from the store — holding a write
    /// transaction on workgraph.sqlite3 itself would deadlock the service's
    /// own writes mid-admission.
    #[test]
    fn sidecar_is_a_separate_file_from_the_store() {
        assert_eq!(
            WORKGRAPH_ADMISSION_SIDECAR_FILE,
            "workgraph.admission.sqlite3"
        );
        assert_ne!(
            WORKGRAPH_ADMISSION_SIDECAR_FILE,
            crate::workgraph_wiring::WORKGRAPH_STORE_FILE
        );
        let dir = Path::new("/state");
        assert_eq!(
            workgraph_admission_sidecar_path(dir),
            dir.join("workgraph.admission.sqlite3")
        );
    }
}