cheers-server 0.8.46

Origin-side surface for cheers — secret-key minter, symmetric codecs, UserStore/RefreshStore, refresh rotation, RevocationWriter, and the SessionAuthority facade.
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
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
//! Embedded-ownership persistence — the [`OwnershipStore`] trait.
//!
//! The table this trait backs is the *source of truth* cheers reads at mint
//! time to fill the [`owns`](cheers_core::Owns) claim on an MCP token. See
//! `.yah/docs/working/mcp-auth-and-ownership.md` §Ownership table for the
//! schema and the W159 trust-layer reasoning behind it.
//!
//! Generic shape — `subject × resource × kind` — so cheers does not grow a
//! per-kind table for every resource type yah adds. The subject is a
//! [`Subject`]: one principal (`principal_id`), or a subject set
//! (`subject_kind / subject_id / subject_relation`, "every holder of
//! `subject_relation` on that resource", noisetable W235 §2.1). Exactly one
//! form is populated — a SQL CHECK and [`Subject`]'s own constructors both
//! hold that line. The set-aware checks walk sets through
//! [`OwnershipTuples`] (R732-F2). The two unconditional invariants are encoded
//! both here (parse-time, via [`OwnershipValidationError`]) and at the SQL
//! CHECK level in the backing schema:
//!
//! - **`granted_by` is the writer's verified `sub`, of any kind** (D4,
//!   operator 2026-10-06). The right to grant is a relationship checked at
//!   the write door, not a property of the caller's principal kind.
//! - **`on_behalf_of` (when set) is always a user principal** (`user:<id>`).
//!   It is attribution only: it never drives a row's lifecycle.
//!
//! Writes/deletes do not hard-delete — a soft `revoked_at` timestamp marks a
//! row inactive. **Lifecycle follows the holder:** the cascade for "principal
//! P went away" sweeps every row with `principal_id = P`
//! ([`OwnershipStore::revoke_by_principal`]). Deleting a granter or an
//! `on_behalf_of` user never revokes rows they granted. (No current writer
//! needs a row to die with anyone but its holder, so there is no `bound_to`
//! column; add one explicitly if that changes.)
//!
//! **Ownership version (R732-F4).** The store carries one store-wide counter,
//! [`OwnershipStore::current_version`]. Every write that changes a row —
//! [`insert`](OwnershipStore::insert), a [`revoke_by_id`](OwnershipStore::revoke_by_id)
//! that revokes, a [`revoke_by_principal`](OwnershipStore::revoke_by_principal)
//! that sweeps anything — advances it to [`next_ownership_version`]
//! (`max(v + 1, now)`) **in the same transaction**, so two reads of an equal
//! version saw equal rows. It is the epoch of a membership snapshot
//! (`crate::snapshot`). Store-wide, not per resource, because subject sets make
//! one resource's flattened membership depend on other resources' rows. The
//! clock floor is the revocation epoch's and the binding sequence's: a store
//! restored from an older backup still advances above what edges hold.
//!
//! The trait says nothing about authorisation — composition rule (4) (the
//! `ownership:write` scope being grantable to services only) is enforced at
//! the grant API by [`cheers_core::validate_grant`], and at the HTTP layer
//! by checking the bearer token's `scope` list. The store impl just enforces
//! the row invariants.
//!
//! @yah:ticket(R731-F7, "Ownership rows: granted_by of any kind, on_behalf_of attribution only, revoke by holder (D4 storage)")
//! @yah:status(review)
//! @yah:assignee(agent:bundle-anthropic-ashguard)
//! @yah:at(2026-10-06T22:53:07Z)
//! @yah:phase(P3)
//! @yah:parent(R731)
//! @yah:next("Doc D4; operator decided 2026-10-06. Drop CHECK (granted_by LIKE svc:%) and OwnershipValidationError::GrantedByNotService.")
//! @yah:next("Revocation follows the holder (principal_id), never the granter: revoke_by_on_behalf_of today would revoke every grant an operator made when their account is deleted. Audit its callers; a row that must die with someone other than its holder gets an explicit nullable bound_to column.")
//! @yah:next("Migrations in all three schemas: cheers-sqlx migrations/sqlite and migrations/pg, cheers-turso migrations/sqlite. SQLite cannot drop a CHECK, so it is a table rebuild.")
//! @yah:verify("cheers-test-support store_scenarios cover user-granted rows and the holder cascade on every backend.")
//! @yah:tier(Warrior)
//! @yah:handoff("Dropped OwnershipValidationError::GrantedByNotService and the granted_by kind check in NewOwnership::new (cheers-server/src/ownership.rs); module + trait docs rewritten for D4 (any-kind granter, on_behalf_of attribution only, lifecycle follows holder).")
//! @yah:handoff("OwnershipStore::revoke_by_on_behalf_of REPLACED by revoke_by_principal(principal, now): UPDATE ... WHERE principal_id = ? AND revoked_at IS NULL. Impls: cheers-sqlx ownership_store.rs (pg + sqlite), cheers-turso ownership_store.rs; mocks in cheers-axum/src/camps.rs, cheers-server/src/mcp_authority.rs, cheers-axum/tests/common/mod.rs.")
//! @yah:handoff("bound_to NOT added. Audit: revoke_by_on_behalf_of had zero production callers (only trait impls, mocks and one scenario). The only in-tree ownership writer, enrollment (cheers-axum/src/enrollment.rs, fed by cheers/src/lan_pair/enroll.rs), writes rows HELD by the user, so the holder cascade covers them. camps.rs writes no ownership rows; camp principals die with their user via CampAuthority::revoke_user_cascade on the camp store. Doc comments in enrollment.rs and lan_pair/enroll.rs updated to name the holder cascade.")
//! @yah:handoff("Migration 0008_ownership_any_granter.sql in cheers-sqlx/migrations/sqlite, cheers-turso/migrations/sqlite (byte-identical, registered as version 8 in cheers-turso/src/migrate.rs) and cheers-sqlx/migrations/pg. SQLite: table rebuild without the granted_by CHECK (on_behalf_of CHECK kept, ix_ownership_principal recreated). PG: DROP CONSTRAINT ownership_granted_by_check (Postgres auto-name for 0002's unnamed column CHECK; inferred, not run against a live pg). Both drop ix_ownership_on_behalf_of, which only served the retired cascade.")
//! @yah:handoff("New scenario cheers-test-support store_scenarios::ownership_store_revoke_follows_holder (user-granted row inserts+lists; deleting the operator sweeps only their held row, rows they granted survive; holder cascade sweeps only that holder), wired in cheers-sqlx tests/sqlite.rs + tests/pg.rs and cheers-turso tests/turso.rs. Lifecycle scenario now cascades by holder; GrantedByNotService assertion removed.")
//! @yah:handoff("Extra: cheers-axum/tests/ownership_basic.rs post_with_user_sub_is_rejected_by_defense_in_depth asserted the deleted rule; renamed to post_with_user_sub_records_user_as_granted_by (201, granted_by user:). cheers-sqlx tests/libsql.rs now expects a user grantor to be ACCEPTED. cheers-turso/src/error.rs test string swapped to the surviving CHECK.")
//! @yah:handoff("Left for R731-F8 (router, out of my fence): cheers-axum/src/ownership.rs module doc lines 8-15 still say granted_by is a service; no code change was needed there.")
//! @yah:verify("Baseline before edits: cargo test -p cheers-server -p cheers-sqlx -p cheers-turso -p cheers-test-support -p cheers-axum = 390 passed, 0 failed, plus a cheers-axum doctest compile error (unresolved cheers_core::KeyRole from R731-B1's in-flight jwk work), so that run exited 1.")
//! @yah:verify("After, with --no-fail-fast: 407 passed, 0 failed, 3 ignored (doctests), exit 0. ownership_store_revoke_follows_holder ran OK on sqlx-sqlite and turso. The doctest error had cleared by then (R731-B1's fix landed in between).")
//! @yah:verify("NOT run: pg.rs (feature pg-integration) and libsql.rs (container-gated); the pg migration's constraint name is unverified against a live Postgres.")
//! @yah:verify("Leader re-verify 2026-10-06: cargo test --workspace (oss/cheers) 640 pass / 0 fail / 3 ignored. cargo test -p cheers-sqlx --features pg-integration,libsql-integration --test pg --test libsql: pg 17/17, libsql 7/7, so the Postgres 0008 DROP CONSTRAINT ownership_granted_by_check applies cleanly and the guessed constraint name is confirmed.")
//! @yah:gotcha("The container-gated suites DO run on this machine. Docker is OrbStack, and its socket is not /var/run/docker.sock, so testcontainers fails with SocketNotFoundError unless you prefix DOCKER_HOST=unix:///Users/leif/.orbstack/run/docker.sock TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/Users/leif/.orbstack/run/docker.sock. \"Container-gated, didn't run\" is not a valid skip here.")
//!
//! @yah:ticket(R732-T9, "OwnershipStore::list_for_kind(kind): enumerate every grant row of one resource_kind (needed by noisetable R803-T1 ledger migration)")
//! @yah:status(review)
//! @yah:at(2026-10-07T07:15:43Z)
//! @yah:assignee(agent:bundle-anthropic-ashguard)
//! @yah:parent(R732)
//! @yah:handoff("Landed `async fn list_for_kind(&self, resource_kind: &str) -> Result<Vec<OwnershipRow>, StoreError>` on OwnershipStore (cheers-server/src/ownership.rs:339) with the Arc delegate, turso + sqlx pg/sqlite backends (WHERE resource_kind = ? AND revoked_at IS NULL), and mocks in grants.rs, mcp_authority.rs, camps.rs, axum tests/common. Shared scenario store_scenarios.rs::ownership_store_list_for_kind covers live rows of the kind, other kinds excluded, revoked excluded, empty kind -> empty; wired into turso.rs, sqlite.rs, pg.rs.")
//! @yah:verify("cargo test --workspace (oss/cheers, default features): 793 pass / 0 fail; list_for_kind passes on turso and sqlite.")
//! @yah:gotcha("noisetable requires cheers-server = \"0.8.32\"; this working copy is 0.8.43-pre.1, which a ^0.8.32 requirement does not match (prerelease), so a plain [patch.crates-io] to oss/cheers would be unused unless the burst also bumps the requirement. Not verified end-to-end.")

use async_trait::async_trait;
use cheers_core::{
    AdmissionPolicy, Lease, PrincipalId, PrincipalKind, RevocationKey, StoreError, Subject, SubjectFormError, TupleSource,
};
use serde::{Deserialize, Serialize};

/// Why a [`NewOwnership`] failed to validate before reaching the store.
///
/// The SQL CHECK constraints are the same invariants enforced at the DB
/// level — this enum is the Rust-side guard so a misconfigured insert never
/// makes a round-trip to the database to be rejected.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum OwnershipValidationError {
    /// `on_behalf_of`, when set, must be a user principal — services never
    /// appear here.
    #[error("on_behalf_of must be a user principal when set; got {0}")]
    OnBehalfOfNotUser(PrincipalKind),
    /// A subject given as loose fields (a request body) with both forms or
    /// neither.
    #[error(transparent)]
    SubjectForm(#[from] SubjectFormError),
}

/// Input for [`OwnershipStore::insert`]. Constructed via
/// [`NewOwnership::new`] which enforces the `on_behalf_of` invariant up
/// front.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct NewOwnership {
    /// Who holds the tuple. Serialized flat: `principal_id`, or the three
    /// `subject_*` keys.
    #[serde(flatten)]
    pub subject: Subject,
    pub resource_kind: String,
    pub resource_id: String,
    pub relationship: String,
    pub granted_by: PrincipalId,
    pub on_behalf_of: Option<PrincipalId>,
    /// A guest's posted lease (R734-F4); `None` for a standing tuple.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub lease: Option<TupleLease>,
}

/// The lease a tuple keeps from the `Admit` that wrote it (R734-F4,
/// `knock.md` "Posted lease"). Past `lease.exp` the tuple confers nothing:
/// [`OwnershipTuples`] drops it, so the server stops counting it as held and
/// the snapshot mint stops listing it. `iat` is the `Admit`'s, kept so the
/// lease re-validates on read.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct TupleLease {
    pub iat: i64,
    #[serde(flatten)]
    pub lease: Lease,
}

impl TupleLease {
    /// From stored columns; a lease that breaks its invariant is a
    /// data-integrity error.
    pub fn from_columns(iat: Option<i64>, refresh_after: Option<i64>, exp: Option<i64>) -> Result<Option<Self>, StoreError> {
        match (iat, refresh_after) {
            (Some(iat), Some(refresh_after)) => Lease::new(iat, refresh_after, exp)
                .map(|lease| Some(Self { iat, lease }))
                .map_err(|e| StoreError::Backend(format!("invalid ownership lease: {e}"))),
            (None, None) if exp.is_none() => Ok(None),
            _ => Err(StoreError::Backend("partial ownership lease columns".into())),
        }
    }

    /// `true` once the lease has lapsed at `now`.
    pub fn lapsed_at(&self, now: i64) -> bool {
        self.lease.exp().is_some_and(|exp| exp <= now)
    }
}

impl NewOwnership {
    /// Construct + validate. The kind invariant on `on_behalf_of` is checked
    /// here so an impl never has to; `granted_by` may be any kind.
    pub fn new(
        subject: impl Into<Subject>,
        resource_kind: impl Into<String>,
        resource_id: impl Into<String>,
        relationship: impl Into<String>,
        granted_by: PrincipalId,
        on_behalf_of: Option<PrincipalId>,
    ) -> Result<Self, OwnershipValidationError> {
        if let Some(ref obo) = on_behalf_of {
            if obo.kind != PrincipalKind::User {
                return Err(OwnershipValidationError::OnBehalfOfNotUser(obo.kind));
            }
        }
        Ok(Self {
            subject: subject.into(),
            resource_kind: resource_kind.into(),
            resource_id: resource_id.into(),
            relationship: relationship.into(),
            granted_by,
            on_behalf_of,
            lease: None,
        })
    }

    /// Attach a guest's lease.
    pub fn with_lease(mut self, lease: Option<TupleLease>) -> Self {
        self.lease = lease;
        self
    }
}

/// One row in the ownership table.
///
/// `id` is an opaque 128-bit identifier (UUIDv4 in the cheers-sqlx impls,
/// matching the existing `mint_user_id` shape — the doc spec calls for "ULID"
/// but the concrete crypto-random 128-bit shape is what's load-bearing, not
/// the encoding).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct OwnershipRow {
    pub id: String,
    /// Who holds the tuple. Serialized flat, as in [`NewOwnership`]: a
    /// principal row keeps its `principal_id` key on the wire.
    #[serde(flatten)]
    pub subject: Subject,
    pub resource_kind: String,
    pub resource_id: String,
    pub relationship: String,
    pub granted_by: PrincipalId,
    pub on_behalf_of: Option<PrincipalId>,
    pub granted_at: i64,
    /// `None` while the row is live; the unix-second timestamp the soft delete
    /// landed on once revoked. Rows with `Some(_)` are excluded from
    /// [`list_for_principal`](OwnershipStore::list_for_principal).
    pub revoked_at: Option<i64>,
    /// The tuple's lease, if it was admitted for a while (R734-F4).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub lease: Option<TupleLease>,
}

impl OwnershipRow {
    /// Build a row from its constituent fields. Use from `OwnershipStore`
    /// impls that need to assemble a row from a SQL query result.
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        id: String,
        subject: Subject,
        resource_kind: String,
        resource_id: String,
        relationship: String,
        granted_by: PrincipalId,
        on_behalf_of: Option<PrincipalId>,
        granted_at: i64,
        revoked_at: Option<i64>,
    ) -> Self {
        Self {
            id,
            subject,
            resource_kind,
            resource_id,
            relationship,
            granted_by,
            on_behalf_of,
            granted_at,
            revoked_at,
            lease: None,
        }
    }

    /// Attach the stored lease.
    pub fn with_lease(mut self, lease: Option<TupleLease>) -> Self {
        self.lease = lease;
        self
    }

    /// `true` while the row confers its relation at `now`: not revoked and
    /// not past its lease.
    pub fn is_live_at(&self, now: i64) -> bool {
        !self.is_revoked() && !self.lease.is_some_and(|l| l.lapsed_at(now))
    }

    /// `true` iff the row has been soft-revoked.
    pub fn is_revoked(&self) -> bool {
        self.revoked_at.is_some()
    }
}

impl cheers_core::RelationTuple for OwnershipRow {
    fn subject(&self) -> &Subject {
        &self.subject
    }
    fn resource_kind(&self) -> &str {
        &self.resource_kind
    }
    fn resource_id(&self) -> &str {
        &self.resource_id
    }
    fn relation(&self) -> &str {
        &self.relationship
    }
    fn is_live(&self) -> bool {
        !self.is_revoked()
    }
}

/// One bootstrap tuple for [`seed_ownership`]: the first grant-holder of a
/// resource, which the grant door (D4) cannot create because nobody yet
/// holds a relation on it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SeedTuple {
    pub principal_id: PrincipalId,
    pub resource_kind: String,
    pub resource_id: String,
    pub relationship: String,
}

/// D4 bootstrap: write each seed tuple unless an identical live row already
/// exists, attributed to `granted_by` (the deployment's own `svc:` identity).
/// Idempotent — a deployment calls it at every startup. Returns how many rows
/// were inserted.
///
/// This is the ONE path into the table that skips the `may_grant` check, so
/// it must never be reachable from an HTTP route; it takes the store, not a
/// request.
pub async fn seed_ownership<O: OwnershipStore + ?Sized>(
    store: &O,
    seeds: &[SeedTuple],
    granted_by: &PrincipalId,
    now: i64,
) -> Result<usize, StoreError> {
    let mut inserted = 0;
    for s in seeds {
        let live = store.list_for_principal(&s.principal_id).await?;
        if live.iter().any(|r| {
            !r.is_revoked()
                && r.resource_kind == s.resource_kind
                && r.resource_id == s.resource_id
                && r.relationship == s.relationship
        }) {
            continue;
        }
        let new = NewOwnership {
            subject: s.principal_id.clone().into(),
            resource_kind: s.resource_kind.clone(),
            resource_id: s.resource_id.clone(),
            relationship: s.relationship.clone(),
            granted_by: granted_by.clone(),
            on_behalf_of: None,
            lease: None,
        };
        store.insert(&new, now).await?;
        inserted += 1;
    }
    Ok(inserted)
}

/// The ownership version a write advances to: `max(prev + 1, now)`.
/// Strictly monotonic whatever the clock does; the floor keeps a store
/// restored from an older backup above the versions edges already hold. The
/// same rule as `next_epoch` and [`next_binding_seq`](crate::next_binding_seq).
pub fn next_ownership_version(prev: u64, now: i64) -> u64 {
    prev.saturating_add(1).max(u64::try_from(now).unwrap_or(0))
}

/// What [`OwnershipStore::insert`] wrote: the row, and the ownership version
/// the write advanced the store to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Inserted {
    pub row: OwnershipRow,
    pub version: u64,
}

/// Persistence for the ownership table — the embedded-ownership source of
/// truth cheers reads at mint time.
///
/// All time arguments are unix seconds, signed — same as
/// [`Claims::issued_at`](cheers_core::Claims) and the refresh-token rows.
/// Every write that changes a row advances the store-wide ownership version
/// in its own transaction (module docs); `now` is its clock floor.
#[async_trait]
pub trait OwnershipStore: Send + Sync {
    /// Insert a fresh row and advance the ownership version, atomically. The
    /// impl mints `id` and sets `granted_at = now`; the returned row carries
    /// both, `revoked_at` is `None`, and [`Inserted::version`] is the version
    /// this insert produced.
    async fn insert(&self, ownership: &NewOwnership, now: i64) -> Result<Inserted, StoreError>;

    /// Look up a row by id. `None` if no such row exists (live or revoked).
    async fn get(&self, id: &str) -> Result<Option<OwnershipRow>, StoreError>;

    /// Soft-delete by id — sets `revoked_at = now` if the row is live and
    /// advances the ownership version in the same transaction. Returns the
    /// version after the call: the one this revoke produced, or, when the row
    /// was already revoked (a no-op that changes nothing and advances
    /// nothing), the current one. Returns [`StoreError::NotFound`] when the id
    /// is unknown.
    async fn revoke_by_id(&self, id: &str, now: i64) -> Result<u64, StoreError>;

    /// Cascading revoke — sweep `revoked_at = now` across every live row
    /// *held* by `principal` (`principal_id = principal`), advancing the
    /// ownership version in the same transaction when it sweeps any. Returns
    /// the version after the call: the one the sweep produced, or, when
    /// nothing was live, the current one. Rows the principal merely granted
    /// (`granted_by`) or was attributed on (`on_behalf_of`) are untouched, by
    /// design (D4). A deletion cascade goes through
    /// [`revoke_principal_ownership`](crate::revoke_principal_ownership), which
    /// also tells offline edges.
    async fn revoke_by_principal(
        &self,
        principal: &PrincipalId,
        now: i64,
    ) -> Result<u64, StoreError>;

    /// Every row held by `principal` directly — subject
    /// [`Subject::Principal`] — **live or revoked**, in unspecified order.
    /// What a principal held after its rows are gone, for
    /// [`revoke_principal_ownership`](crate::revoke_principal_ownership).
    /// Unbounded and unindexed for revoked rows: a cascade read, not a request
    /// path.
    async fn list_history_for_principal(
        &self,
        principal: &PrincipalId,
    ) -> Result<Vec<OwnershipRow>, StoreError>;

    /// The store-wide ownership version: `0` for a fresh store, then whatever
    /// the last row-changing write advanced it to. Equal versions read equal
    /// rows (module docs).
    async fn current_version(&self) -> Result<u64, StoreError>;

    /// Live (non-revoked) rows held by `principal` directly — subject
    /// [`Subject::Principal`]. Rows reaching it through a subject set are not
    /// included. Unspecified order.
    async fn list_for_principal(
        &self,
        principal: &PrincipalId,
    ) -> Result<Vec<OwnershipRow>, StoreError>;

    /// Live (non-revoked) rows over one resource — every principal currently
    /// holding a relationship on `(resource_kind, resource_id)`, in
    /// unspecified order.
    ///
    /// Exists for eviction-parity sweeps (R593-F9, W268 Q6): when a device
    /// (`resource_kind = "node"`) is re-enrolled under a *different* owner —
    /// the device changed hands — the enrollment writer must find and revoke
    /// the previous owner's still-live row, which `list_for_principal` cannot
    /// see (it is keyed on the *old* principal, which the new ceremony does
    /// not know).
    async fn list_for_resource(
        &self,
        resource_kind: &str,
        resource_id: &str,
    ) -> Result<Vec<OwnershipRow>, StoreError>;

    /// Live (non-revoked) rows whose subject is a set on `(kind, id)` — the
    /// tuples conferring their relation on holders of some relation on that
    /// resource (`<resource>#<rel> ← kind/id#<subject_relation>`), whatever
    /// `subject_relation` is. Unspecified order. Backed by a partial index on
    /// `(subject_kind, subject_id)`.
    async fn list_for_subject_set(
        &self,
        kind: &str,
        id: &str,
    ) -> Result<Vec<OwnershipRow>, StoreError>;

    /// Live (non-revoked) rows over every resource of `resource_kind`,
    /// whatever the subject form, in unspecified order. Unbounded, like its
    /// siblings: meant for one-shot ledger migrations (noisetable R803-T1
    /// re-keys every `publish-scope` row to `namespace`), not request paths.
    async fn list_for_kind(&self, resource_kind: &str) -> Result<Vec<OwnershipRow>, StoreError>;

    /// The membership-revocation key of `(resource_kind, resource_id)`,
    /// created on first use (R732-T10; `cheers_core::revocation`, "Membership
    /// privacy"). **Immutable**: once a key exists every call returns it, so
    /// an edge holding an old snapshot keeps matching new entries — a changed
    /// key would fail revocation open, silently. Get-or-create is insert-or-
    /// ignore of a fresh [`new_revocation_key`] followed by a reread, so two
    /// concurrent creators converge on the one row that won. Kept beside the
    /// ownership rows so a restore cannot separate the two. Secret: it goes
    /// out only inside signed snapshots, never on an unauthenticated route.
    async fn revocation_key(&self, resource_kind: &str, resource_id: &str) -> Result<RevocationKey, StoreError>;

    /// The admission policy row of `(resource_kind, resource_id)` (R734-F3,
    /// migration 0016). `None` when the resource has none, which reads as
    /// closed for offline admits.
    async fn admission_policy(
        &self,
        resource_kind: &str,
        resource_id: &str,
    ) -> Result<Option<AdmissionPolicy>, StoreError>;

    /// Set (`Some`) or delete (`None`) the admission policy row, advancing the
    /// ownership version in the same transaction, so the next snapshot carries
    /// the change at a higher epoch. Always advances, even when the value is
    /// unchanged. Returns the version it produced.
    async fn set_admission_policy(
        &self,
        resource_kind: &str,
        resource_id: &str,
        policy: Option<&AdmissionPolicy>,
        now: i64,
    ) -> Result<u64, StoreError>;
}

/// An [`AdmissionPolicy`] as its stored column: the JSON wire form.
pub fn encode_admission_policy(policy: &AdmissionPolicy) -> String {
    serde_json::to_string(policy).expect("AdmissionPolicy serializes")
}

/// Read a stored policy column back. A malformed row is a data-integrity
/// error, never a silent `None` (that would read as closed, or worse, mask a
/// tightening).
pub fn decode_admission_policy(kind: &str, id: &str, column: &str) -> Result<AdmissionPolicy, StoreError> {
    serde_json::from_str(column)
        .map_err(|e| StoreError::Backend(format!("invalid admission policy for {kind}/{id}: {e}")))
}

/// A fresh random [`RevocationKey`] for a store's get-or-create.
pub fn new_revocation_key() -> RevocationKey {
    let mut bytes = [0u8; 32];
    getrandom::fill(&mut bytes).expect("OS CSPRNG must be available");
    RevocationKey::from_bytes(bytes)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn user(id: &str) -> PrincipalId {
        PrincipalId::user(id)
    }
    fn svc(id: &str) -> PrincipalId {
        PrincipalId::service(id)
    }
    fn camp(id: &str) -> PrincipalId {
        PrincipalId::camp(id)
    }

    #[test]
    fn new_ownership_accepts_service_granted_by_and_user_on_behalf_of() {
        let n = NewOwnership::new(
            camp("c-1"),
            "service",
            "svc-abc",
            "owns",
            svc("yubaba"),
            Some(user("alice")),
        )
        .unwrap();
        assert_eq!(n.granted_by, svc("yubaba"));
        assert_eq!(n.on_behalf_of, Some(user("alice")));

        // Self-grant: on_behalf_of=None is valid.
        NewOwnership::new(camp("c-2"), "service", "s-1", "owns", svc("yubaba"), None).unwrap();
    }

    #[test]
    fn new_ownership_accepts_granted_by_of_any_kind() {
        for granter in [user("alice"), camp("c-1"), svc("yubaba")] {
            let n = NewOwnership::new(
                camp("c-1"),
                "service",
                "svc-abc",
                "owns",
                granter.clone(),
                Some(user("alice")),
            )
            .unwrap();
            assert_eq!(n.granted_by, granter);
        }
    }

    #[test]
    fn new_ownership_rejects_non_user_on_behalf_of() {
        let err = NewOwnership::new(
            camp("c-1"),
            "service",
            "svc-abc",
            "owns",
            svc("yubaba"),
            Some(svc("yubaba")),
        )
        .unwrap_err();
        assert_eq!(
            err,
            OwnershipValidationError::OnBehalfOfNotUser(PrincipalKind::Service)
        );

        let err = NewOwnership::new(
            camp("c-1"),
            "service",
            "svc-abc",
            "owns",
            svc("yubaba"),
            Some(camp("c-9")),
        )
        .unwrap_err();
        assert_eq!(
            err,
            OwnershipValidationError::OnBehalfOfNotUser(PrincipalKind::Camp)
        );
    }

    #[test]
    fn ownership_row_is_revoked_tracks_revoked_at() {
        let mut row = OwnershipRow::new(
            "id-1".into(),
            camp("c-1").into(),
            "service".into(),
            "svc-a".into(),
            "owns".into(),
            svc("yubaba"),
            Some(user("alice")),
            100,
            None,
        );
        assert!(!row.is_revoked());
        row.revoked_at = Some(150);
        assert!(row.is_revoked());
    }

    #[test]
    fn ownership_row_wire_keeps_principal_id_and_flattens_sets() {
        let principal = OwnershipRow::new(
            "id-1".into(),
            user("alice").into(),
            "doc".into(),
            "d1".into(),
            "reader".into(),
            svc("cheers"),
            None,
            100,
            None,
        );
        let v = serde_json::to_value(&principal).unwrap();
        assert_eq!(v["principal_id"], "user:alice");
        assert!(v.get("subject_kind").is_none());
        assert_eq!(serde_json::from_value::<OwnershipRow>(v).unwrap(), principal);

        let set = NewOwnership::new(
            Subject::set("namespace", "n1", "member"),
            "doc",
            "d1",
            "reader",
            user("alice"),
            None,
        )
        .unwrap();
        let v = serde_json::to_value(&set).unwrap();
        assert!(v.get("principal_id").is_none());
        assert_eq!(v["subject_kind"], "namespace");
        assert_eq!(v["subject_id"], "n1");
        assert_eq!(v["subject_relation"], "member");
        assert_eq!(serde_json::from_value::<NewOwnership>(v).unwrap(), set);
    }
}

/// Shared handle: lets [`SchemaGrantStore`](crate::SchemaGrantStore) and the
/// mint authority read one ownership table.
#[async_trait]
impl<T: OwnershipStore + ?Sized> OwnershipStore for std::sync::Arc<T> {
    async fn insert(&self, o: &NewOwnership, now: i64) -> Result<Inserted, StoreError> {
        (**self).insert(o, now).await
    }
    async fn get(&self, id: &str) -> Result<Option<OwnershipRow>, StoreError> {
        (**self).get(id).await
    }
    async fn revoke_by_id(&self, id: &str, now: i64) -> Result<u64, StoreError> {
        (**self).revoke_by_id(id, now).await
    }
    async fn revoke_by_principal(&self, p: &PrincipalId, now: i64) -> Result<u64, StoreError> {
        (**self).revoke_by_principal(p, now).await
    }
    async fn list_history_for_principal(&self, p: &PrincipalId) -> Result<Vec<OwnershipRow>, StoreError> {
        (**self).list_history_for_principal(p).await
    }
    async fn current_version(&self) -> Result<u64, StoreError> {
        (**self).current_version().await
    }
    async fn list_for_principal(&self, p: &PrincipalId) -> Result<Vec<OwnershipRow>, StoreError> {
        (**self).list_for_principal(p).await
    }
    async fn list_for_resource(
        &self,
        kind: &str,
        id: &str,
    ) -> Result<Vec<OwnershipRow>, StoreError> {
        (**self).list_for_resource(kind, id).await
    }
    async fn list_for_subject_set(
        &self,
        kind: &str,
        id: &str,
    ) -> Result<Vec<OwnershipRow>, StoreError> {
        (**self).list_for_subject_set(kind, id).await
    }
    async fn list_for_kind(&self, kind: &str) -> Result<Vec<OwnershipRow>, StoreError> {
        (**self).list_for_kind(kind).await
    }
    async fn revocation_key(&self, kind: &str, id: &str) -> Result<RevocationKey, StoreError> {
        (**self).revocation_key(kind, id).await
    }
    async fn admission_policy(&self, kind: &str, id: &str) -> Result<Option<AdmissionPolicy>, StoreError> {
        (**self).admission_policy(kind, id).await
    }
    async fn set_admission_policy(
        &self,
        kind: &str,
        id: &str,
        policy: Option<&AdmissionPolicy>,
        now: i64,
    ) -> Result<u64, StoreError> {
        (**self).set_admission_policy(kind, id, policy, now).await
    }
}

/// In-process ownership table — tests and single-process deployments. Clones
/// share one table. Each write takes one lock, so the row change and the
/// version advance are atomic as the trait requires.
#[derive(Debug, Clone, Default)]
pub struct MemoryOwnershipStore(std::sync::Arc<std::sync::Mutex<MemoryOwnership>>);

#[derive(Debug, Default)]
struct MemoryOwnership {
    rows: Vec<OwnershipRow>,
    version: u64,
    revocation_keys: std::collections::HashMap<(String, String), RevocationKey>,
    policies: std::collections::HashMap<(String, String), AdmissionPolicy>,
}

impl MemoryOwnershipStore {
    pub fn new() -> Self {
        Self::default()
    }

    fn lock(&self) -> std::sync::MutexGuard<'_, MemoryOwnership> {
        self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
    }

    fn live(&self, keep: impl Fn(&OwnershipRow) -> bool) -> Vec<OwnershipRow> {
        self.lock().rows.iter().filter(|r| !r.is_revoked() && keep(r)).cloned().collect()
    }
}

#[async_trait]
impl OwnershipStore for MemoryOwnershipStore {
    async fn insert(&self, o: &NewOwnership, now: i64) -> Result<Inserted, StoreError> {
        let mut t = self.lock();
        let row = OwnershipRow::new(
            format!("own-{}", t.rows.len() + 1),
            o.subject.clone(),
            o.resource_kind.clone(),
            o.resource_id.clone(),
            o.relationship.clone(),
            o.granted_by.clone(),
            o.on_behalf_of.clone(),
            now,
            None,
        )
        .with_lease(o.lease);
        t.rows.push(row.clone());
        t.version = next_ownership_version(t.version, now);
        Ok(Inserted { row, version: t.version })
    }
    async fn get(&self, id: &str) -> Result<Option<OwnershipRow>, StoreError> {
        Ok(self.lock().rows.iter().find(|r| r.id == id).cloned())
    }
    async fn revoke_by_id(&self, id: &str, now: i64) -> Result<u64, StoreError> {
        let mut t = self.lock();
        let row = t.rows.iter_mut().find(|r| r.id == id).ok_or(StoreError::NotFound)?;
        if row.revoked_at.is_none() {
            row.revoked_at = Some(now);
            t.version = next_ownership_version(t.version, now);
        }
        Ok(t.version)
    }
    async fn revoke_by_principal(&self, p: &PrincipalId, now: i64) -> Result<u64, StoreError> {
        let mut t = self.lock();
        let mut swept = false;
        for row in t.rows.iter_mut().filter(|r| r.revoked_at.is_none() && r.subject.principal() == Some(p)) {
            row.revoked_at = Some(now);
            swept = true;
        }
        if swept {
            t.version = next_ownership_version(t.version, now);
        }
        Ok(t.version)
    }
    async fn list_history_for_principal(&self, p: &PrincipalId) -> Result<Vec<OwnershipRow>, StoreError> {
        Ok(self.lock().rows.iter().filter(|r| r.subject.principal() == Some(p)).cloned().collect())
    }
    async fn current_version(&self) -> Result<u64, StoreError> {
        Ok(self.lock().version)
    }
    async fn list_for_principal(&self, p: &PrincipalId) -> Result<Vec<OwnershipRow>, StoreError> {
        Ok(self.live(|r| r.subject.principal() == Some(p)))
    }
    async fn list_for_resource(&self, kind: &str, id: &str) -> Result<Vec<OwnershipRow>, StoreError> {
        Ok(self.live(|r| r.resource_kind == kind && r.resource_id == id))
    }
    async fn list_for_subject_set(&self, kind: &str, id: &str) -> Result<Vec<OwnershipRow>, StoreError> {
        Ok(self.live(|r| r.subject.as_set().is_some_and(|(k, i, _)| k == kind && i == id)))
    }
    async fn list_for_kind(&self, kind: &str) -> Result<Vec<OwnershipRow>, StoreError> {
        Ok(self.live(|r| r.resource_kind == kind))
    }
    async fn revocation_key(&self, kind: &str, id: &str) -> Result<RevocationKey, StoreError> {
        let fresh = new_revocation_key();
        let mut t = self.lock();
        Ok(t.revocation_keys.entry((kind.to_owned(), id.to_owned())).or_insert(fresh).clone())
    }
    async fn admission_policy(&self, kind: &str, id: &str) -> Result<Option<AdmissionPolicy>, StoreError> {
        Ok(self.lock().policies.get(&(kind.to_owned(), id.to_owned())).cloned())
    }
    async fn set_admission_policy(
        &self,
        kind: &str,
        id: &str,
        policy: Option<&AdmissionPolicy>,
        now: i64,
    ) -> Result<u64, StoreError> {
        let mut t = self.lock();
        let key = (kind.to_owned(), id.to_owned());
        match policy {
            Some(p) => t.policies.insert(key, p.clone()),
            None => t.policies.remove(&key),
        };
        t.version = next_ownership_version(t.version, now);
        Ok(t.version)
    }
}

/// An [`OwnershipStore`] read as the schema's [`TupleSource`] at instant
/// `now`, for the set-aware checks: `schema.may_grant(&OwnershipTuples::at(&*store, now), …)`,
/// [`holds`](cheers_core::SchemaRegistry::holds),
/// [`held_by`](cheers_core::SchemaRegistry::held_by),
/// [`members`](cheers_core::SchemaRegistry::members). Rows whose lease has
/// lapsed at `now` are dropped (R734-F4). A newtype because the orphan rule
/// forbids a blanket impl of cheers-core's trait for every store.
pub struct OwnershipTuples<'a, O: ?Sized> {
    store: &'a O,
    now: i64,
}

impl<'a, O: ?Sized> OwnershipTuples<'a, O> {
    pub fn at(store: &'a O, now: i64) -> Self {
        Self { store, now }
    }

    fn live(&self, rows: Vec<OwnershipRow>) -> Vec<OwnershipRow> {
        rows.into_iter().filter(|r| r.is_live_at(self.now)).collect()
    }
}

#[async_trait]
impl<'a, O: OwnershipStore + ?Sized> TupleSource for OwnershipTuples<'a, O> {
    type Tuple = OwnershipRow;
    async fn list_for_principal(&self, p: &PrincipalId) -> Result<Vec<OwnershipRow>, StoreError> {
        Ok(self.live(self.store.list_for_principal(p).await?))
    }
    async fn list_for_resource(
        &self,
        kind: &str,
        id: &str,
    ) -> Result<Vec<OwnershipRow>, StoreError> {
        Ok(self.live(self.store.list_for_resource(kind, id).await?))
    }
    async fn list_for_subject_set(
        &self,
        kind: &str,
        id: &str,
    ) -> Result<Vec<OwnershipRow>, StoreError> {
        Ok(self.live(self.store.list_for_subject_set(kind, id).await?))
    }
}