Skip to main content

tollgate_store_postgres/
lib.rs

1//! PostgreSQL backend.
2//!
3//! Reproduces `MemoryStore`'s settlement rules exactly — the shared
4//! correctness suite (`tests/postgres_suite.rs`, mirroring
5//! `tollgate-store/tests/store_suite.rs` by name) is the proof. Concurrency
6//! control is row-level: `SELECT ... FOR UPDATE` on the account funds
7//! acquire; on the lease it serializes release/ingest/reclaim against each
8//! other. Fencing tokens come from the account row's `next_fence` counter,
9//! so allocation is strictly monotonic per account across any number of
10//! servers sharing the database. Each token remains a capability for its own
11//! lease record; the sequence is not an account-wide validity epoch.
12//!
13//! Representation choices (PoC-pragmatic, documented):
14//! - u128 ids as 16-byte `BYTEA` (big-endian);
15//! - units as `BIGINT` with checked u64↔i64 conversion in both directions
16//!   (a balance beyond i64::MAX is refused, not wrapped; a negative stored
17//!   value is refused, not clamped) and schema-level CHECK constraints
18//!   keeping every unit column non-negative;
19//! - lease and credential expiry as floor `BIGINT` microseconds plus a
20//!   `SMALLINT` nanosecond remainder; informational timestamps as `BIGINT`
21//!   microseconds since the Unix epoch;
22//! - snapshots as storage-local `JSONB`: ids in the legacy u64 range remain
23//!   numeric for rollback, larger ids use canonical text, and the public
24//!   HTTP/Serde contract always uses text.
25//!
26//! Snapshot pushes broadcast in-process only; cross-process push
27//! (LISTEN/NOTIFY or the server's future SSE) is a documented seam in
28//! `docs/DESIGN.md`.
29
30pub(crate) mod instant;
31
32#[cfg(feature = "test-support")]
33pub mod test_support;
34
35use instant::StoredInstant;
36use std::num::{NonZeroI64, NonZeroUsize};
37use std::sync::Arc;
38
39use async_trait::async_trait;
40use jiff::{SignedDuration, Timestamp};
41use serde::{Deserialize, Serialize};
42use sqlx::postgres::PgPoolOptions;
43use sqlx::{PgPool, Postgres, Row, Transaction};
44use tokio::sync::broadcast;
45
46use tollgate_core::{
47    AccountId, AccountSnapshot, AccountStatus, BudgetSchedule, BudgetView, CapacityClass,
48    CostTable, CostUnits, EnforcementMode, FencingToken, Generation, KeyId, LeaseGrant, LeaseId,
49    Period, PermissionBits, PolicyRevision, Principal, PublishableSnapshot, ResolvedLimits,
50    Rollover, UsageEvent,
51};
52use tollgate_store::{
53    AccountConfig, AccountView, AdminReceipt, AdminState, AdminStore, AllocateError, Allocation,
54    BudgetError, Conservation, CreateAccountError, GrantPolicy, IngestError, IngestReport,
55    KeyDirectory, KeyError, KeyRecord, KeySnapshotError, KeySummary, LeaseAllocator,
56    PUSH_CHANNEL_CAPACITY, PublishSnapshotError, ReclaimBatch, ReclaimedLease, Revocation,
57    RolledAccount, RolloverBatch, SetStatusError, SnapshotPush, SnapshotResolution, SnapshotSource,
58    StatusChange, StoreError, StoreHealth, UsageSink, pushes_exceed_capacity,
59    validate_key_page_limit,
60};
61
62const STATE_ACTIVE: i16 = 0;
63const STATE_RELEASED: i16 = 1;
64const STATE_EXPIRED: i16 = 2;
65
66/// One storage-local identifier. Values the previous codec could represent
67/// stay numeric for rollback; the rest of the promised u128 domain uses
68/// canonical text because serde_json's default number type rejects it.
69#[derive(Debug, Clone, Copy)]
70struct StoredId(u128);
71
72impl Serialize for StoredId {
73    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
74    where
75        S: serde::Serializer,
76    {
77        match u64::try_from(self.0) {
78            Ok(value) => serializer.serialize_u64(value),
79            Err(_) => serializer.collect_str(&format_args!("{:032x}", self.0)),
80        }
81    }
82}
83
84impl<'de> Deserialize<'de> for StoredId {
85    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
86    where
87        D: serde::Deserializer<'de>,
88    {
89        struct StoredIdVisitor;
90
91        impl serde::de::Visitor<'_> for StoredIdVisitor {
92            type Value = StoredId;
93
94            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95                formatter.write_str("a legacy u64 number or a canonical 128-bit identifier string")
96            }
97
98            fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
99                Ok(StoredId(u128::from(value)))
100            }
101
102            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
103            where
104                E: serde::de::Error,
105            {
106                value
107                    .parse::<AccountId>()
108                    .map(|id| StoredId(id.0))
109                    .map_err(E::custom)
110            }
111        }
112
113        deserializer.deserialize_any(StoredIdVisitor)
114    }
115}
116
117/// A digest column that is not exactly 32 bytes is corruption, never
118/// something to pad or truncate into shape: the comparison it feeds decides
119/// authentication, and a silently reshaped digest would either refuse a
120/// legitimate credential forever or, worse, shorten what is compared.
121fn digest_from(bytes: &[u8], key_id: KeyId) -> Result<[u8; 32], StoreError> {
122    <[u8; 32]>::try_from(bytes).map_err(|_| {
123        StoreError(format!(
124            "credential {key_id} has a {}-byte digest in storage, expected 32",
125            bytes.len()
126        ))
127    })
128}
129
130#[async_trait]
131impl KeyDirectory for PostgresStore {
132    async fn credential_activity(
133        &self,
134        keys: &[KeyId],
135    ) -> Result<Vec<tollgate_store::CredentialActivity>, StoreError> {
136        use tollgate_store::{CredentialActivity, CredentialActivityState};
137        let mut result = Vec::with_capacity(keys.len());
138        // Reuse the bulk-work budget, not a total operator-list cap. Each
139        // input has one row, including duplicates and missing credentials.
140        for chunk in keys.chunks(tollgate_store::MAX_INGEST_BATCH) {
141            let ids: Vec<_> = chunk.iter().map(|id| id_bytes(id.0)).collect();
142            let rows = sqlx::query(
143                // Each lateral lookup is bounded by the credential PK. The
144                // explicit one-row bound prevents a bulk join from choosing
145                // a catalogue-wide hash scan for a moderately sized directory.
146                "SELECT evidence.key_id IS NOT NULL, evidence.last_committed_at_us
147                 FROM UNNEST($1::bytea[]) WITH ORDINALITY AS requested(key_id, ordinal)
148                 LEFT JOIN LATERAL (
149                    SELECT k.key_id, a.last_committed_at_us
150                    FROM tollgate_credential_keys k
151                    LEFT JOIN tollgate_credential_activity a ON a.key_id = k.key_id
152                    WHERE k.key_id = requested.key_id LIMIT 1
153                 ) AS evidence ON true
154                 ORDER BY requested.ordinal",
155            )
156            .bind(&ids)
157            .fetch_all(&self.pool)
158            .await
159            .map_err(storage)?;
160            if rows.len() != chunk.len() {
161                return Err(StoreError("incomplete credential activity read".into()));
162            }
163            for (&key_id, row) in chunk.iter().zip(rows) {
164                let state = if !row.get::<bool, _>(0) {
165                    CredentialActivityState::Unknown
166                } else if let Some(at) = row.get::<Option<i64>, _>(1) {
167                    CredentialActivityState::Committed {
168                        last_committed_at: micros_ts(at, "credential activity")?,
169                    }
170                } else {
171                    CredentialActivityState::Unobserved
172                };
173                result.push(CredentialActivity { key_id, state });
174            }
175        }
176        Ok(result)
177    }
178
179    async fn insert_key(&self, record: KeyRecord) -> Result<(), KeyError> {
180        self.insert_credential(record, None)
181            .await
182            .map(|receipt| receipt.outcome)
183    }
184
185    async fn revoke_key(&self, key_id: KeyId, now: Timestamp) -> Result<Revocation, KeyError> {
186        self.revoke_key_audited(key_id, now)
187            .await
188            .map(|receipt| receipt.outcome)
189    }
190
191    async fn revoke_key_audited(
192        &self,
193        key_id: KeyId,
194        now: Timestamp,
195    ) -> Result<AdminReceipt<Revocation>, KeyError> {
196        let mut tx = self.pool.begin().await.map_err(storage)?;
197        let result = async {
198            // The row lock captures the actual predecessor, including a prior
199            // retirement committed while this call waited. No account lock is
200            // acquired after the credential lock.
201            let row = sqlx::query(
202                "SELECT account_id, revoked_at_us IS NOT NULL
203                 FROM tollgate_credential_keys WHERE key_id = $1 FOR UPDATE",
204            )
205            .bind(id_bytes(key_id.0))
206            .fetch_optional(&mut *tx)
207            .await
208            .map_err(storage)?
209            .ok_or(KeyError::UnknownKey)?;
210            let account_bytes: Vec<u8> = row.get(0);
211            let account_bytes: [u8; 16] = account_bytes
212                .try_into()
213                .map_err(|_| StoreError("credential account identifier is not 16 bytes".into()))?;
214            let account_id = AccountId(u128::from_be_bytes(account_bytes));
215            let revoked: bool = row.get(1);
216            let before = AdminState::Credential {
217                account_id,
218                key_id,
219                revoked,
220            };
221            let outcome = if revoked {
222                Revocation::AlreadyRetired
223            } else {
224                sqlx::query(
225                    "UPDATE tollgate_credential_keys SET revoked_at_us = $2 WHERE key_id = $1",
226                )
227                .bind(id_bytes(key_id.0))
228                .bind(ts_micros(now))
229                .execute(&mut *tx)
230                .await
231                .map_err(storage)?;
232                Revocation::Retired
233            };
234            Ok(AdminReceipt::new(
235                outcome,
236                before,
237                AdminState::Credential {
238                    account_id,
239                    key_id,
240                    revoked: true,
241                },
242            ))
243        }
244        .await;
245        finish_transaction(tx, result).await
246    }
247
248    async fn publish_key_snapshot(
249        &self,
250        account: AccountId,
251        key: KeyId,
252        snapshot: PublishableSnapshot,
253    ) -> Result<AdminReceipt<()>, KeySnapshotError> {
254        let generation = i64::try_from(snapshot.generation.0).map_err(|_| {
255            StoreError("snapshot generation exceeds PostgreSQL BIGINT range".into())
256        })?;
257        let mut tx = self.pool.begin().await.map_err(storage)?;
258        let result = async {
259            let (principal, revoked) = lock_account_key(&mut tx, account, key).await?;
260            if revoked {
261                return Err(KeySnapshotError::Retired { key_id: key });
262            }
263            if snapshot.key_id != Some(key) {
264                return Err(PublishSnapshotError::CredentialMismatch { key_id: key }.into());
265            }
266            let published = publish_in_tx(&mut tx, principal, generation, snapshot).await?;
267            Ok((principal, published))
268        }
269        .await;
270        let (principal, (written, published, before, after)) =
271            finish_transaction(tx, result).await?;
272        if written {
273            self.push_to_subscribers(SnapshotPush {
274                principal,
275                resolution: SnapshotResolution::Present(published),
276            });
277        }
278        Ok(AdminReceipt::new((), before, after))
279    }
280
281    async fn remove_key_snapshot(
282        &self,
283        account: AccountId,
284        key: KeyId,
285    ) -> Result<AdminReceipt<()>, KeySnapshotError> {
286        let mut tx = self.pool.begin().await.map_err(storage)?;
287        let result = async {
288            let (principal, _revoked) = lock_account_key(&mut tx, account, key).await?;
289            Ok::<_, KeySnapshotError>((principal, remove_in_tx(&mut tx, principal).await?))
290        }
291        .await;
292        let (principal, receipt) = finish_transaction(tx, result).await?;
293        self.announce_removal(principal, &receipt);
294        Ok(receipt)
295    }
296
297    async fn active_keys(&self, now: Timestamp) -> Result<Vec<KeyRecord>, StoreError> {
298        let cutoff = StoredInstant::from(now);
299        // Expiry is applied here, beside revocation, so this backend answers
300        // "active" exactly as `MemoryStore` does and a projection built from
301        // either sees the same live set. Ordering is explicit for the same
302        // reason: two instances must not build tables that differ by row
303        // order alone.
304        let rows = sqlx::query(
305            "SELECT key_id, account_id, principal, digest, not_after_floor_us, not_after_submicro_ns, not_after_is_lower_bound
306             FROM tollgate_credential_keys
307             WHERE revoked_at_us IS NULL
308               AND (not_after_floor_us IS NULL OR not_after_submicro_ns IS NULL
309                    OR (not_after_floor_us, not_after_submicro_ns) > ($1, $2))
310             ORDER BY key_id",
311        )
312        .bind(cutoff.micros)
313        .bind(cutoff.submicro_nanos)
314        .fetch_all(&self.pool)
315        .await
316        .map_err(storage)?;
317
318        rows.into_iter().map(credential_from_row).collect()
319    }
320
321    async fn account_keys(
322        &self,
323        account: AccountId,
324        after: Option<KeyId>,
325        limit: NonZeroUsize,
326    ) -> Result<Vec<KeySummary>, StoreError> {
327        validate_key_page_limit(limit)?;
328        // Separate SQL shapes preserve an indexable range in prepared plans,
329        // as `active_keys_page` does for the same reason.
330        let sql = if after.is_some() {
331            "SELECT key_id, not_after_floor_us, not_after_submicro_ns,
332                    not_after_is_lower_bound, revoked_at_us
333             FROM tollgate_credential_keys
334             WHERE account_id = $1 AND key_id > $3
335             ORDER BY key_id LIMIT $2"
336        } else {
337            "SELECT key_id, not_after_floor_us, not_after_submicro_ns,
338                    not_after_is_lower_bound, revoked_at_us
339             FROM tollgate_credential_keys
340             WHERE account_id = $1
341             ORDER BY key_id LIMIT $2"
342        };
343        let mut query = sqlx::query(sql)
344            .bind(id_bytes(account.0))
345            .bind(i64::try_from(limit.get()).unwrap_or(i64::MAX));
346        if let Some(cursor) = after {
347            query = query.bind(id_bytes(cursor.0));
348        }
349        let rows = query.fetch_all(&self.pool).await.map_err(storage)?;
350        rows.into_iter().map(summary_from_row).collect()
351    }
352
353    async fn insert_key_within(
354        &self,
355        record: KeyRecord,
356        max_active: NonZeroUsize,
357        now: Timestamp,
358    ) -> Result<(), KeyError> {
359        self.insert_key_within_audited(record, max_active, now)
360            .await
361            .map(|receipt| receipt.outcome)
362    }
363
364    async fn insert_key_within_audited(
365        &self,
366        record: KeyRecord,
367        max_active: NonZeroUsize,
368        now: Timestamp,
369    ) -> Result<AdminReceipt<()>, KeyError> {
370        self.insert_credential(record, Some((max_active, now)))
371            .await
372    }
373}
374
375impl PostgresStore {
376    /// Both issuance APIs enter the same account-first transaction. The lock
377    /// covers the optional bound check, unique-index insertion, foreign-key
378    /// check and revision trigger through commit. Acquiring it after inserting
379    /// would invert the bounded issuer's order and permit a deadlock.
380    async fn insert_credential(
381        &self,
382        record: KeyRecord,
383        bound: Option<(NonZeroUsize, Timestamp)>,
384    ) -> Result<AdminReceipt<()>, KeyError> {
385        let mut tx = self
386            .pool
387            .begin()
388            .await
389            .map_err(|e| KeyError::Storage(storage(e)))?;
390        // Take the account row first. Counting and inserting without it is the
391        // race this method exists to prevent: under READ COMMITTED neither
392        // transaction sees the other's uncommitted credential, so both count
393        // `max_active - 1`, both insert, and the account ends up over the
394        // bound with no error raised anywhere. This is the same row
395        // `set_account_status` and `acquire` serialise on, so an issuance in
396        // flight also orders against a suspension.
397        let account = sqlx::query(ACCOUNT_LOCK_SQL)
398            .bind(id_bytes(record.account_id.0))
399            .fetch_optional(&mut *tx)
400            .await
401            .map_err(|e| KeyError::Storage(storage(e)))?;
402        if account.is_none() {
403            return Err(KeyError::UnknownAccount);
404        }
405
406        if let Some((max_active, now)) = bound {
407            // Identity before the bound, and the order is load-bearing. A caller
408            // that lost the response resends the same `key_id`; by then its own
409            // successful write may have filled the bound, and answering
410            // `ActiveKeyLimit` would tell it to retire a credential when in fact
411            // its first call worked. `AlreadyExists` is both true and what makes
412            // the retry safe (GL-121).
413            let existing: Option<i32> = sqlx::query_scalar(
414                "SELECT 1 FROM tollgate_credential_keys WHERE key_id = $1 OR principal = $2",
415            )
416            .bind(id_bytes(record.key_id.0))
417            .bind(id_bytes(record.principal.0))
418            .fetch_optional(&mut *tx)
419            .await
420            .map_err(|e| KeyError::Storage(storage(e)))?;
421            if existing.is_some() {
422                return Err(KeyError::AlreadyExists);
423            }
424
425            let cutoff = StoredInstant::from(now);
426            let live: i64 = sqlx::query_scalar(LIVE_KEY_COUNT_SQL)
427                .bind(id_bytes(record.account_id.0))
428                .bind(cutoff.micros)
429                .bind(cutoff.submicro_nanos)
430                .fetch_one(&mut *tx)
431                .await
432                .map_err(|e| KeyError::Storage(storage(e)))?;
433            if u128::from(live.max(0).unsigned_abs())
434                >= u128::try_from(max_active.get()).unwrap_or(u128::MAX)
435            {
436                return Err(KeyError::ActiveKeyLimit { limit: max_active });
437            }
438        }
439
440        let expiry = record.not_after.map(StoredInstant::from);
441        let result = sqlx::query(
442            "INSERT INTO tollgate_credential_keys
443             (key_id, account_id, principal, digest, not_after_floor_us,
444              not_after_submicro_ns, not_after_is_lower_bound, revoked_at_us)
445             VALUES ($1, $2, $3, $4, $5, $6, FALSE, NULL)
446             ON CONFLICT (key_id) DO NOTHING",
447        )
448        .bind(id_bytes(record.key_id.0))
449        .bind(id_bytes(record.account_id.0))
450        .bind(id_bytes(record.principal.0))
451        .bind(record.digest.to_vec())
452        .bind(expiry.map(|expiry| expiry.micros))
453        .bind(expiry.map(|expiry| expiry.submicro_nanos))
454        .execute(&mut *tx)
455        .await;
456        let outcome = match result {
457            Ok(done) if done.rows_affected() == 0 => Err(KeyError::AlreadyExists),
458            Ok(_) => Ok(()),
459            Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
460                Err(KeyError::AlreadyExists)
461            }
462            // No arm for the foreign key, deliberately. The account row was
463            // taken `FOR UPDATE` above and its absence already answered
464            // `UnknownAccount`; nothing in this crate deletes an account, so a
465            // violation here cannot mean "no such account". It would mean the
466            // row vanished under a held lock, and reporting that as a caller
467            // error would absorb storage corruption as a routine refusal --
468            // the caller would retire a credential over a broken database.
469            // `Storage` is the truthful answer, and the mutation gate is what
470            // noticed the old arm could not be reached to be tested.
471            Err(e) => Err(KeyError::Storage(storage(e))),
472        };
473        outcome?;
474        tx.commit()
475            .await
476            .map_err(|e| KeyError::Storage(storage(e)))?;
477        Ok(AdminReceipt::new(
478            (),
479            AdminState::Absent,
480            AdminState::Credential {
481                account_id: record.account_id,
482                key_id: record.key_id,
483                revoked: false,
484            },
485        ))
486    }
487}
488
489fn summary_from_row(row: sqlx::postgres::PgRow) -> Result<KeySummary, StoreError> {
490    let bytes: Vec<u8> = row.get(0);
491    let fixed: [u8; 16] = bytes
492        .as_slice()
493        .try_into()
494        .map_err(|_| StoreError("credential identifier is not 16 bytes".into()))?;
495    let not_after = match (row.get::<Option<i64>, _>(1), row.get::<Option<i16>, _>(2)) {
496        (None, None) if !row.get::<bool, _>(3) => None,
497        (Some(micros), Some(submicro_nanos)) => Some(
498            StoredInstant {
499                micros,
500                submicro_nanos,
501            }
502            .timestamp()?,
503        ),
504        _ => return Err(StoreError("incomplete stored credential expiry".into())),
505    };
506    Ok(KeySummary {
507        key_id: KeyId(u128::from_be_bytes(fixed)),
508        not_after,
509        revoked_at: row
510            .get::<Option<i64>, _>(4)
511            .map(|micros| {
512                StoredInstant {
513                    micros,
514                    submicro_nanos: 0,
515                }
516                .timestamp()
517            })
518            .transpose()?,
519    })
520}
521
522fn credential_from_row(row: sqlx::postgres::PgRow) -> Result<KeyRecord, StoreError> {
523    let id = |index| -> Result<u128, StoreError> {
524        let bytes: Vec<u8> = row.get(index);
525        let fixed: [u8; 16] = bytes
526            .as_slice()
527            .try_into()
528            .map_err(|_| StoreError("credential identifier is not 16 bytes".into()))?;
529        Ok(u128::from_be_bytes(fixed))
530    };
531    let key_id = KeyId(id(0)?);
532    let not_after = match (row.get::<Option<i64>, _>(4), row.get::<Option<i16>, _>(5)) {
533        (None, None) if !row.get::<bool, _>(6) => None,
534        (Some(micros), Some(submicro_nanos)) => Some(
535            StoredInstant {
536                micros,
537                submicro_nanos,
538            }
539            .timestamp()?,
540        ),
541        _ => return Err(StoreError("incomplete stored credential expiry".into())),
542    };
543    Ok(KeyRecord {
544        key_id,
545        account_id: AccountId(id(1)?),
546        principal: Principal(id(2)?),
547        digest: digest_from(row.get::<Vec<u8>, _>(3).as_slice(), key_id)?,
548        not_after,
549    })
550}
551
552#[async_trait]
553impl tollgate_store::KeySource for PostgresStore {
554    async fn active_keys_page(
555        &self,
556        now: Timestamp,
557        after: Option<KeyId>,
558        limit: NonZeroUsize,
559    ) -> Result<tollgate_store::KeyPage, StoreError> {
560        validate_key_page_limit(limit)?;
561        let cutoff = StoredInstant::from(now);
562        // One short snapshot per page, never a transaction held across HTTP
563        // requests. Revision and records therefore cannot describe two commits.
564        let mut tx = self.pool.begin().await.map_err(storage)?;
565        sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
566            .execute(&mut *tx)
567            .await
568            .map_err(storage)?;
569        let revision: i64 =
570            sqlx::query_scalar("SELECT revision FROM tollgate_credential_revision WHERE singleton")
571                .fetch_one(&mut *tx)
572                .await
573                .map_err(storage)?;
574        let revision = u64::try_from(revision)
575            .map_err(|_| StoreError("credential revision is negative".into()))?;
576        // Separate SQL shapes preserve an indexable range in prepared plans.
577        let sql = if after.is_some() {
578            "SELECT key_id, account_id, principal, digest, not_after_floor_us, not_after_submicro_ns, not_after_is_lower_bound
579             FROM tollgate_credential_keys WHERE revoked_at_us IS NULL
580             AND (not_after_floor_us IS NULL OR not_after_submicro_ns IS NULL
581                  OR (not_after_floor_us, not_after_submicro_ns) > ($1, $2)) AND key_id > $3
582             ORDER BY key_id LIMIT $4"
583        } else {
584            "SELECT key_id, account_id, principal, digest, not_after_floor_us, not_after_submicro_ns, not_after_is_lower_bound
585             FROM tollgate_credential_keys WHERE revoked_at_us IS NULL
586             AND (not_after_floor_us IS NULL OR not_after_submicro_ns IS NULL
587                  OR (not_after_floor_us, not_after_submicro_ns) > ($1, $2)) AND key_id >= $3
588             ORDER BY key_id LIMIT $4"
589        };
590        let rows = sqlx::query(sql)
591            .bind(cutoff.micros)
592            .bind(cutoff.submicro_nanos)
593            .bind(id_bytes(after.unwrap_or(KeyId(0)).0))
594            .bind((limit.get() + 1) as i64)
595            .fetch_all(&mut *tx)
596            .await
597            .map_err(storage)?;
598        tx.commit().await.map_err(storage)?;
599        // Decode lookahead too: corrupt data cannot masquerade as a normal
600        // continuation and silently defer a failure to another request.
601        let mut records = rows
602            .into_iter()
603            .map(credential_from_row)
604            .map(|row| row.map(tollgate_store::CredentialRecord::from))
605            .collect::<Result<Vec<_>, _>>()?;
606        let next_after = if records.len() > limit.get() {
607            records.pop();
608            records.last().map(|record| record.key_id)
609        } else {
610            None
611        };
612        tollgate_store::KeyPage::try_new(revision, now, after, limit, records, next_after)
613    }
614}
615
616#[cfg(test)]
617mod stored_id_tests {
618    use super::StoredId;
619
620    #[test]
621    fn malformed_storage_id_explains_both_accepted_representations() {
622        let error = serde_json::from_value::<StoredId>(serde_json::Value::Bool(true)).unwrap_err();
623        assert!(
624            error
625                .to_string()
626                .contains("a legacy u64 number or a canonical 128-bit identifier string"),
627            "unexpected diagnostic: {error}"
628        );
629    }
630}
631
632/// PostgreSQL's snapshot JSON predates the portable HTTP identifier contract.
633/// Keep this boundary explicit so existing rows remain readable and ordinary
634/// legacy-range writes remain rollback-safe while high-bit ids finally work.
635///
636/// **Two facts in this table follow opposite rules, deliberately.** The
637/// generation is *not* here — it lives only in the `generation` column, because
638/// that column is what the `ON CONFLICT ... WHERE` monotonicity guard compares
639/// and what a tombstone reports after its snapshot is gone (GL-54). `account_id`
640/// *is* here and must stay: its column is `GENERATED ALWAYS AS` a function of
641/// this JSON (migration 0006), so deleting it here would silently NULL that
642/// column, unmatch the partial index, and make an account-wide status change
643/// republish nothing.
644///
645/// Both rules say "one writer per fact". They differ on which copy survives
646/// because the generation needs a BIGINT comparison the JSON cannot give.
647#[derive(Serialize)]
648struct StoredSnapshotRef<'a> {
649    account_id: StoredId,
650    key_id: Option<StoredId>,
651    status: &'a AccountStatus,
652    /// The account-owned execution-capacity class (GL-99). Rides the JSONB
653    /// document; omitting it here would drop it on every publish.
654    capacity_class: &'a CapacityClass,
655    enforcement_mode: &'a EnforcementMode,
656    valid_until: &'a Timestamp,
657    permissions: &'a PermissionBits,
658    limits: &'a ResolvedLimits,
659    cost_table: &'a Arc<CostTable>,
660    /// Stored so a *pulled* snapshot carries what a pushed one does. Without
661    /// it an instance that refreshed instead of receiving a push would report
662    /// no budget at all, and the two would disagree about the same account.
663    budget: Option<&'a BudgetView>,
664    /// The consuming application's policy identity (GL-94). Rides the JSONB
665    /// column, so it needs no schema change of its own — but it does need to
666    /// be here: this DTO is the whole of what storage writes, and a field
667    /// omitted from it is dropped on every publish without a word.
668    policy_revision: &'a PolicyRevision,
669}
670
671impl<'a> From<&'a AccountSnapshot> for StoredSnapshotRef<'a> {
672    fn from(snapshot: &'a AccountSnapshot) -> Self {
673        StoredSnapshotRef {
674            account_id: StoredId(snapshot.account_id.0),
675            key_id: snapshot.key_id.map(|id| StoredId(id.0)),
676            status: &snapshot.status,
677            capacity_class: &snapshot.capacity_class,
678            enforcement_mode: &snapshot.enforcement_mode,
679            valid_until: &snapshot.valid_until,
680            permissions: &snapshot.permissions,
681            limits: &snapshot.limits,
682            cost_table: &snapshot.cost_table,
683            budget: snapshot.budget.as_ref(),
684            policy_revision: &snapshot.policy_revision,
685        }
686    }
687}
688
689/// Rows written before GL-54 still carry a `generation` key. Serde ignores
690/// unknown fields, so those rows decode unchanged and the vestigial key is
691/// simply not read — which is why this needed no backfill.
692#[derive(Deserialize)]
693struct StoredSnapshot {
694    account_id: StoredId,
695    key_id: Option<StoredId>,
696    status: AccountStatus,
697    /// Absent from every row written before GL-99, and `default` for the reason
698    /// the fields below are: a required field would make every pre-existing
699    /// row fail to decode and deny every principal until the whole catalogue
700    /// was republished. `Assured` is the safe default — the availability every
701    /// account already has.
702    #[serde(default)]
703    capacity_class: CapacityClass,
704    /// Absent from every row written before elastic mode existed, and those
705    /// rows are the overwhelming majority the first time this ships.
706    ///
707    /// `default` rather than a required field, because the alternative is not
708    /// a loud failure but a silent outage: a required field makes every
709    /// pre-existing row fail to decode, `SnapshotSource::snapshot` returns a
710    /// store error for each, and the request path denies every principal until
711    /// someone republishes the whole catalogue. Defaulting to
712    /// [`EnforcementMode::Strict`] is also the safe direction — an
713    /// undecided account enforces, it does not extend credit.
714    #[serde(default)]
715    enforcement_mode: EnforcementMode,
716    valid_until: Timestamp,
717    permissions: PermissionBits,
718    limits: ResolvedLimits,
719    cost_table: Arc<CostTable>,
720    /// Absent from every row written before periodic budgets, and `default`
721    /// for the reason `enforcement_mode` is: a required field would make every
722    /// pre-existing row fail to decode and deny every principal until the
723    /// whole catalogue was republished. `None` is "the control plane said
724    /// nothing", which readers report as such rather than as a zero balance.
725    #[serde(default)]
726    budget: Option<BudgetView>,
727    /// Absent from every row written before GL-94, and `default` for the reason
728    /// the two fields above are. The unstated revision is a value rather than
729    /// an absence, so a pre-existing row decodes to "this account's publisher
730    /// stated no policy identity" — which is true, and is what a consumer
731    /// reading it back should be told.
732    #[serde(default)]
733    policy_revision: PolicyRevision,
734}
735
736impl StoredSnapshot {
737    /// Rebuild the snapshot around a generation the JSON no longer carries.
738    ///
739    /// Deliberately not a `From` impl: the generation has to come from the
740    /// row's column, and a conversion that could be written without it would
741    /// be a conversion someone writes without it.
742    fn into_snapshot(self, generation: Generation) -> AccountSnapshot {
743        let builder = AccountSnapshot::builder(
744            AccountId(self.account_id.0),
745            generation,
746            self.status,
747            self.valid_until,
748            self.permissions,
749            self.limits,
750            self.cost_table,
751        )
752        .enforcement_mode(self.enforcement_mode)
753        .capacity_class(self.capacity_class)
754        .policy_revision(self.policy_revision);
755        match self.key_id {
756            Some(key_id) => builder.key_id(tollgate_core::KeyId(key_id.0)).build(),
757            None => builder.build(),
758        }
759    }
760}
761
762/// The reconciliation query's active-lease sum, named because two callers must
763/// agree on it: `conservation` runs it, and `explain_active_lease_sum` asks the
764/// planner what it does with it. A test that copied the text would keep
765/// reporting an index scan after the real predicate had drifted away from
766/// migration 0005's index (GL-12).
767///
768/// `state = 0` is spelled out rather than bound, so the predicate is a literal
769/// the partial index can match.
770///
771/// Deliberately still its own statement. Folding it into the account read as a
772/// lateral subquery would make the reconciliation read atomic without a
773/// transaction, but it also destabilises the plan: measured over twelve runs
774/// of `the_account_filter_is_answered_by_an_index_not_by_discarding_rows`, the
775/// correlated form kept the index 4 times in 5 and the uncorrelated form 10
776/// times in 12, against 12 in 12 for this shape. Atomicity is bought with a
777/// transaction instead (GL-56), which leaves this predicate — and the plan GL-12
778/// pinned — untouched.
779const ACTIVE_LEASE_SUM_SQL: &str =
780    "SELECT COALESCE(SUM(granted), 0)::BIGINT, COALESCE(SUM(used), 0)::BIGINT
781     FROM tollgate_leases WHERE account_id = $1 AND state = 0";
782
783/// The expiry sweep's selection, named for the same reason as the sum above:
784/// `reclaim_expired_batch` runs it and `explain_reclaim_due_leases` asks the
785/// planner what it does with it. A test that copied the text would keep
786/// reporting an index-ordered walk after the real `ORDER BY` had drifted away
787/// from `tollgate_leases_expiry` — which is exactly the drift GL-65 found.
788///
789/// The `ORDER BY` is the index's own column order, and that is the whole
790/// point. It was `(account_id, lease_id)`, which no index answers, so the
791/// `LIMIT` could not stop an index walk: every batch read and sorted the
792/// entire remaining backlog to return 256 rows, making a drain quadratic in
793/// the backlog it exists to clear (GL-65). Ordering by the expiry pair makes
794/// the `LIMIT` a range stop, and settles oldest-due-first like the reference
795/// backend does.
796/// Takes the account row before an issuance counts against its bound.
797///
798/// Hoisted so the test that demonstrates the race can drive the *same*
799/// statements this method does, rather than a copy that could drift from them
800/// — the convention `RECLAIM_DUE_LEASES_SQL` established.
801pub(crate) const ACCOUNT_LOCK_SQL: &str =
802    "SELECT 1 FROM tollgate_accounts WHERE account_id = $1 FOR UPDATE";
803
804/// Credentials that can still authenticate at the given instant: not revoked,
805/// and not past `not_after`. The predicate the bound counts with.
806pub(crate) const LIVE_KEY_COUNT_SQL: &str = "SELECT count(*) FROM tollgate_credential_keys
807     WHERE account_id = $1
808       AND revoked_at_us IS NULL
809       AND (not_after_floor_us IS NULL OR not_after_submicro_ns IS NULL
810            OR (not_after_floor_us, not_after_submicro_ns) > ($2, $3))";
811
812const RECLAIM_DUE_LEASES_SQL: &str = "SELECT lease_id, account_id, granted, used
813     FROM tollgate_leases
814     WHERE state = 0 AND (expires_at_floor_us, expires_at_submicro_ns) <= ($1, $2)
815     ORDER BY expires_at_floor_us, expires_at_submicro_ns
816     LIMIT $3 FOR UPDATE SKIP LOCKED";
817
818/// The rollover sweep's selection, hoisted and fixed for the same reason
819/// (GL-65's sibling) — but it needed two changes, not one.
820///
821/// `ORDER BY account_id` sorted every due account to return one bounded page,
822/// and served the lowest ids rather than the most overdue boundaries.
823/// `budget_period = $1` is an equality, so within that prefix of
824/// `tollgate_accounts_due_rollover (budget_period, period_start_us)` the scan
825/// is already ordered by `period_start_us`: ordering by it is index-native and
826/// crosses the oldest boundary first.
827///
828/// That alone changed nothing, because the index is *partial* on
829/// `budget_allowance IS NOT NULL` and this query never said so — the planner
830/// cannot apply a partial index it cannot prove applies, so the sweep had
831/// never once used the index added for it. Restating the predicate is free and
832/// selects exactly the same rows: `tollgate_accounts_budget_all_or_nothing` already
833/// requires the allowance, period and rollover columns to be all null or all
834/// non-null, and `budget_period = $1` has ruled out all-null. Measured on 400
835/// accounts with five due: seq scan and sort at cost 25.02, versus an index
836/// scan with no sort at 5.92.
837/// The crossed-from boundary is returned so the caller can order the batch.
838///
839/// `RETURNING` has no defined row order: PostgreSQL emits rows in whatever
840/// order the update's join produced, which is physical order, not the boundary
841/// order the CTE selected by. Ordering it *here* would mean a trailing
842/// `ORDER BY` and a `Sort` node, which is the cost
843/// `the_rollover_sweep_reaches_its_index_instead_of_sorting_the_due_set`
844/// forbids (GL-65) — so the page is ordered in Rust instead, where it is bounded
845/// by the batch limit rather than by how many accounts are due.
846const DUE_PERIODS_SQL: &str = "WITH due AS (
847         SELECT account_id, allowance_balance AS prior, budget_allowance AS allowance,
848                period_start_us AS crossed_from
849         FROM tollgate_accounts
850         WHERE budget_allowance IS NOT NULL
851           AND budget_period = $1 AND period_start_us < $2
852         ORDER BY period_start_us LIMIT $3 FOR UPDATE SKIP LOCKED
853     )
854     UPDATE tollgate_accounts AS account SET
855         deposited = account.deposited + due.allowance,
856         expired = account.expired + due.prior,
857         balance = account.balance - due.prior + due.allowance,
858         allowance_balance = due.allowance,
859         period_start_us = $2
860     FROM due
861     WHERE account.account_id = due.account_id
862     RETURNING due.account_id, due.allowance, due.prior, due.crossed_from";
863
864fn id_bytes(id: u128) -> Vec<u8> {
865    id.to_be_bytes().to_vec()
866}
867
868fn id_from(bytes: &[u8]) -> u128 {
869    let mut buf = [0u8; 16];
870    buf.copy_from_slice(bytes);
871    u128::from_be_bytes(buf)
872}
873
874fn to_i64(units: CostUnits, what: &str) -> Result<i64, StoreError> {
875    i64::try_from(units.get()).map_err(|_| StoreError(format!("{what} exceeds i64 range")))
876}
877
878/// Persisted capabilities are minted from counters seeded at one. Invalid
879/// storage must not become a caller mismatch or mint a zero capability.
880fn stored_fence(value: i64) -> Result<FencingToken, StoreError> {
881    u64::try_from(value)
882        .ok()
883        .filter(|value| *value > 0)
884        .map(FencingToken)
885        .ok_or_else(|| StoreError(format!("stored fencing token is not positive: {value}")))
886}
887
888/// A negative unit column is ledger corruption, never a value to normalise:
889/// clamping it to zero would let the conservation equation pass over exactly
890/// the discrepancy it exists to expose.
891fn to_units(value: i64, what: &str) -> Result<CostUnits, StoreError> {
892    u64::try_from(value)
893        .map(CostUnits)
894        .map_err(|_| StoreError(format!("{what} is negative in storage: {value}")))
895}
896
897fn ts_micros(ts: Timestamp) -> i64 {
898    ts.as_microsecond()
899}
900
901/// The inverse of [`ts_micros`]. A stored value outside the representable
902/// range is corruption to report, never an instant to clamp to.
903fn micros_ts(value: i64, what: &str) -> Result<Timestamp, StoreError> {
904    tollgate_store::clock::timestamp_from_micros(value).map_err(|e| {
905        StoreError(format!(
906            "{what} is not a representable instant: {value} ({e})"
907        ))
908    })
909}
910
911fn storage(e: sqlx::Error) -> StoreError {
912    StoreError(format!("postgres: {e}"))
913}
914
915fn alloc_storage(e: sqlx::Error) -> AllocateError {
916    AllocateError::Storage(storage(e))
917}
918
919/// Finish a transaction before making its result observable.
920///
921/// `sqlx::Transaction` only queues a rollback when it is dropped. A caller can
922/// therefore start a competing transaction after this future returns but
923/// before PostgreSQL has released the first transaction's row locks. That is
924/// especially visible to reclaim's `SKIP LOCKED` query, which may otherwise
925/// miss a lease owned by an operation that has already reported failure.
926///
927/// Generic over the error type rather than duplicated per error: this crate
928/// now finishes transactions that fail with `StoreError`, `AllocateError` and
929/// `SetStatusError`, and three copies of the rollback-and-report logic would
930/// be three places for it to drift.
931async fn finish_transaction<T, E>(
932    tx: Transaction<'_, Postgres>,
933    result: Result<T, E>,
934) -> Result<T, E>
935where
936    E: From<StoreError> + std::fmt::Display,
937{
938    match result {
939        Ok(value) => {
940            tx.commit().await.map_err(|e| E::from(storage(e)))?;
941            Ok(value)
942        }
943        Err(error) => {
944            if let Err(rollback_error) = tx.rollback().await {
945                return Err(E::from(StoreError(format!(
946                    "operation failed ({error}); transaction rollback failed ({})",
947                    storage(rollback_error)
948                ))));
949            }
950            Err(error)
951        }
952    }
953}
954
955/// Fixture reset is available only in the opt-in `test_support` module,
956/// never as a method on a normal store handle.
957///
958/// ```compile_fail,E0599
959/// # use tollgate_store_postgres::PostgresStore;
960/// fn reset(store: &PostgresStore) {
961///     let _ = store.truncate_all();
962/// }
963/// ```
964///
965/// Query-plan inspection, which refreshes database statistics, is likewise
966/// absent from the operational handle:
967///
968/// ```compile_fail,E0599
969/// # use tollgate_store_postgres::PostgresStore;
970/// # use tollgate_core::AccountId;
971/// fn explain(store: &PostgresStore) {
972///     let _ = store.explain_active_lease_sum(AccountId(1));
973/// }
974/// ```
975///
976/// The same holds for the two sweep plans (GL-65):
977///
978/// ```compile_fail,E0599
979/// # use tollgate_store_postgres::PostgresStore;
980/// fn explain_sweep(store: &PostgresStore) {
981///     let _ = store.explain_reclaim_due_leases(jiff::Timestamp::UNIX_EPOCH, 256);
982/// }
983/// ```
984///
985/// ```compile_fail,E0599
986/// # use tollgate_store_postgres::PostgresStore;
987/// fn explain_rollover(store: &PostgresStore) {
988///     let _ = store.explain_due_periods("daily", 0, 256);
989/// }
990/// ```
991pub struct PostgresStore {
992    pool: PgPool,
993    policy: GrantPolicy,
994    push: broadcast::Sender<SnapshotPush>,
995}
996
997/// Connection-pool bounds. Callers that hold background tasks open against
998/// this store rely on `acquire_timeout`: when every connection is checked out
999/// by a stalled query, it is the only thing that turns "wait forever" into an
1000/// error the caller can report (INVARIANTS.md GL-18).
1001#[derive(Debug, Clone, Copy)]
1002pub struct PoolConfig {
1003    pub max_connections: u32,
1004    /// How long a caller may wait for a free connection before the call
1005    /// fails. Must be positive.
1006    pub acquire_timeout: std::time::Duration,
1007}
1008
1009impl Default for PoolConfig {
1010    fn default() -> Self {
1011        PoolConfig {
1012            max_connections: 16,
1013            acquire_timeout: std::time::Duration::from_secs(5),
1014        }
1015    }
1016}
1017
1018impl PoolConfig {
1019    pub fn validate(&self) -> Result<(), StoreError> {
1020        if self.max_connections == 0 {
1021            return Err(StoreError("max_connections must be positive".into()));
1022        }
1023        if self.acquire_timeout.is_zero() {
1024            return Err(StoreError("acquire_timeout must be positive".into()));
1025        }
1026        Ok(())
1027    }
1028}
1029
1030impl PostgresStore {
1031    /// Connect with default pool bounds and run pending migrations.
1032    pub async fn connect(url: &str, policy: GrantPolicy) -> Result<Arc<Self>, StoreError> {
1033        Self::connect_with(url, policy, PoolConfig::default()).await
1034    }
1035
1036    /// Connect and run pending migrations (versioned under ./migrations,
1037    /// tracked by sqlx's _sqlx_migrations table — review finding GL-11).
1038    ///
1039    /// Note the limits of what a pool bound can promise: `acquire_timeout`
1040    /// covers waiting for a connection, including establishing one, but a
1041    /// query already in flight on a healthy connection is bounded only by a
1042    /// server-side `statement_timeout`. Callers must still bound their own
1043    /// calls (INVARIANTS.md GL-18).
1044    pub async fn connect_with(
1045        url: &str,
1046        policy: GrantPolicy,
1047        pool_config: PoolConfig,
1048    ) -> Result<Arc<Self>, StoreError> {
1049        policy
1050            .validate()
1051            .map_err(|e| StoreError(format!("invalid grant policy: {e}")))?;
1052        pool_config.validate()?;
1053        let pool = PgPoolOptions::new()
1054            .max_connections(pool_config.max_connections)
1055            .acquire_timeout(pool_config.acquire_timeout)
1056            .connect(url)
1057            .await
1058            .map_err(storage)?;
1059        sqlx::migrate!("./migrations")
1060            .run(&pool)
1061            .await
1062            .map_err(|e| StoreError(format!("migrate: {e}")))?;
1063        let (push, _) = broadcast::channel(PUSH_CHANNEL_CAPACITY);
1064        Ok(Arc::new(PostgresStore { pool, policy, push }))
1065    }
1066
1067    /// Broadcast a control-plane change to in-process subscribers. No
1068    /// receivers is not a failure — a pull still observes the change — but
1069    /// how many instances the push reached is the difference between
1070    /// propagating in milliseconds and propagating at the next refresh, so
1071    /// it is reported rather than discarded.
1072    fn push_to_subscribers(&self, push: SnapshotPush) {
1073        let principal = push.principal;
1074        let subscribers = self.push.send(push).unwrap_or(0);
1075        tracing::debug!(
1076            %principal,
1077            subscribers,
1078            "snapshot pushed to subscribers"
1079        );
1080    }
1081
1082    /// Push a tombstone only when a committed removal changed something.
1083    fn announce_removal(&self, principal: Principal, receipt: &AdminReceipt<()>) {
1084        if receipt.before != receipt.after
1085            && let AdminState::Snapshot { generation, .. } = receipt.after
1086        {
1087            self.push_to_subscribers(SnapshotPush {
1088                principal,
1089                resolution: SnapshotResolution::Revoked { generation },
1090            });
1091        }
1092    }
1093
1094    // ---- reconciliation / test surface (mirrors MemoryStore) ---------
1095
1096    pub async fn balance(&self, account: AccountId) -> Result<CostUnits, StoreError> {
1097        let row = sqlx::query("SELECT balance FROM tollgate_accounts WHERE account_id = $1")
1098            .bind(id_bytes(account.0))
1099            .fetch_optional(&self.pool)
1100            .await
1101            .map_err(storage)?;
1102        row.map(|r| to_units(r.get::<i64, _>(0), "account balance"))
1103            .transpose()
1104            .map(|units| units.unwrap_or(CostUnits::ZERO))
1105    }
1106
1107    pub async fn usage_recorded(&self, account: AccountId) -> Result<CostUnits, StoreError> {
1108        let row = sqlx::query("SELECT usage_recorded FROM tollgate_accounts WHERE account_id = $1")
1109            .bind(id_bytes(account.0))
1110            .fetch_optional(&self.pool)
1111            .await
1112            .map_err(storage)?;
1113        row.map(|r| to_units(r.get::<i64, _>(0), "account usage_recorded"))
1114            .transpose()
1115            .map(|units| units.unwrap_or(CostUnits::ZERO))
1116    }
1117
1118    pub async fn conservation(
1119        &self,
1120        account: AccountId,
1121    ) -> Result<Option<Conservation>, StoreError> {
1122        // One snapshot for both halves. The account's stored totals and the
1123        // sums over its live leases move together — `ingest` raises
1124        // `usage_recorded` and the lease's `used` in one transaction — so
1125        // reading them on two pooled connections let a commit land between
1126        // them: reconciliation then paired a pre-write total with a post-write
1127        // sum and reported corruption on a correct ledger, or underflowed the
1128        // subtraction outright (GL-56).
1129        //
1130        // `READ COMMITTED` is not enough, because it takes a fresh snapshot
1131        // per statement; `REPEATABLE READ` takes one at the first read and
1132        // holds it for the rest of the transaction. Read-only, so it cannot
1133        // hit the serialization failures that make `SERIALIZABLE` a retry
1134        // contract rather than a snapshot.
1135        let mut tx = self.pool.begin().await.map_err(storage)?;
1136        let result = async {
1137            sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
1138                .execute(&mut *tx)
1139                .await
1140                .map_err(storage)?;
1141            let account_row = sqlx::query(
1142                "SELECT deposited, balance, usage_recorded, settlement_loss, overage_recorded,
1143                        expired
1144                 FROM tollgate_accounts WHERE account_id = $1",
1145            )
1146            .bind(id_bytes(account.0))
1147            .fetch_optional(&mut *tx)
1148            .await
1149            .map_err(storage)?;
1150            let lease_row = sqlx::query(ACTIVE_LEASE_SUM_SQL)
1151                .bind(id_bytes(account.0))
1152                .fetch_one(&mut *tx)
1153                .await
1154                .map_err(storage)?;
1155            Ok::<_, StoreError>((account_row, lease_row))
1156        }
1157        .await;
1158        let (account_row, lease_row) = finish_transaction(tx, result).await?;
1159
1160        let Some(row) = account_row else {
1161            return Ok(None);
1162        };
1163        let active_grants = to_units(lease_row.get::<i64, _>(0), "active lease grants")?;
1164        let active_used = to_units(lease_row.get::<i64, _>(1), "active lease usage")?;
1165        let recorded = to_units(row.get::<i64, _>(2), "usage_recorded")?;
1166        Ok(Some(Conservation {
1167            deposited: to_units(row.get::<i64, _>(0), "deposited")?,
1168            overage_recorded: to_units(row.get::<i64, _>(4), "overage_recorded")?,
1169            balance: to_units(row.get::<i64, _>(1), "balance")?,
1170            active_lease_grants: active_grants,
1171            // Surfaced, never panicked on. These are two stored columns read
1172            // from a database this process does not exclusively own, so
1173            // `recorded < active_used` is corruption to report — the same
1174            // class as a negative unit column (INVARIANTS.md GL-11) — not an
1175            // internal invariant a caller could not violate. `MemoryStore`
1176            // keeps its `expect` because there the counters are maintained by
1177            // one process under one lock and the state is unrepresentable.
1178            settled_usage: recorded.checked_sub(active_used).ok_or_else(|| {
1179                StoreError(format!(
1180                    "active lease usage {} exceeds recorded usage {} for account {account}",
1181                    active_used.get(),
1182                    recorded.get()
1183                ))
1184            })?,
1185            settlement_loss: to_units(row.get::<i64, _>(3), "settlement_loss")?,
1186            expired: to_units(row.get::<i64, _>(5), "expired")?,
1187        }))
1188    }
1189}
1190
1191/// One locked lease row, named so its accounting fields cannot be confused at
1192/// the call site and tooling does not manufacture a Cartesian product of
1193/// arbitrary replacements for an anonymous seven-field tuple.
1194struct LockedLeaseRow {
1195    account_id: Vec<u8>,
1196    fencing_token: i64,
1197    granted: i64,
1198    used: i64,
1199    credited: i64,
1200    expires_at: StoredInstant,
1201    state: i16,
1202    /// The half of `granted` drawn from the account's periodic allowance, and
1203    /// the period that funded it. Settlement needs both: the split says which
1204    /// bucket each unspent unit belongs to, the period says whether the
1205    /// allowance half still exists (GL-97).
1206    from_allowance: i64,
1207    period_start_us: i64,
1208}
1209
1210/// Settlement evidence from the account update inside the current transaction.
1211/// Consolidation uses the credit that survived the period boundary as its floor.
1212struct ReleasedCredit {
1213    account: AccountId,
1214    restored: CostUnits,
1215    preserves_funding: bool,
1216}
1217
1218/// Lock one lease row for a settlement transition.
1219async fn lock_lease(
1220    tx: &mut Transaction<'_, Postgres>,
1221    lease_id: LeaseId,
1222) -> Result<Option<LockedLeaseRow>, sqlx::Error> {
1223    let row = sqlx::query(
1224        "SELECT account_id, fencing_token, granted, used, credited, expires_at_floor_us, state,
1225                from_allowance, period_start_us, expires_at_submicro_ns
1226         FROM tollgate_leases WHERE lease_id = $1 FOR UPDATE",
1227    )
1228    .bind(id_bytes(lease_id.0))
1229    .fetch_optional(&mut **tx)
1230    .await?;
1231    Ok(row.map(|row| LockedLeaseRow {
1232        account_id: row.get(0),
1233        fencing_token: row.get(1),
1234        granted: row.get(2),
1235        used: row.get(3),
1236        credited: row.get(4),
1237        expires_at: StoredInstant {
1238            micros: row.get(5),
1239            submicro_nanos: row.get(9),
1240        },
1241        state: row.get(6),
1242        from_allowance: row.get(7),
1243        period_start_us: row.get(8),
1244    }))
1245}
1246
1247/// What a consolidation's release half hands the grant half of the same
1248/// transaction. A plain acquire returns nothing: [`Exchange::ACQUIRE`].
1249struct Exchange {
1250    /// The credit this transaction restored to the account, which the grant
1251    /// policy's shrink cap may not size the result below (see
1252    /// [`LeaseAllocator::consolidate`]).
1253    floor: CostUnits,
1254    /// The largest quote the returned lease refused, which the grant may grow
1255    /// to when the restored balance funds it (GL-131).
1256    needed: CostUnits,
1257    /// Whether the settlement left total funding unchanged, so a refusal may
1258    /// attest the ledger it reads (GL-130).
1259    preserves_funding: bool,
1260}
1261
1262impl Exchange {
1263    const ACQUIRE: Exchange = Exchange {
1264        floor: CostUnits::ZERO,
1265        needed: CostUnits::ZERO,
1266        preserves_funding: true,
1267    };
1268}
1269
1270impl PostgresStore {
1271    /// One grant, inside a caller-owned transaction.
1272    async fn acquire_in_tx(
1273        &self,
1274        tx: &mut Transaction<'_, Postgres>,
1275        account: AccountId,
1276        requested: CostUnits,
1277        expires_at: Timestamp,
1278        exchange: Exchange,
1279    ) -> Result<Allocation, AllocateError> {
1280        let Exchange {
1281            floor,
1282            needed,
1283            preserves_funding: settlement_preserves_funding,
1284        } = exchange;
1285        let row = sqlx::query(
1286            "SELECT balance, status, next_fence, allowance_balance, period_start_us
1287             FROM tollgate_accounts WHERE account_id = $1 FOR UPDATE",
1288        )
1289        .bind(id_bytes(account.0))
1290        .fetch_optional(&mut **tx)
1291        .await
1292        .map_err(alloc_storage)?
1293        .ok_or(AllocateError::UnknownAccount)?;
1294
1295        // Suspended and Closed both refuse, under one deny reason: no
1296        // client acts on the distinction, and splitting it would widen
1297        // `AllocateError`'s per-reason tally for nothing (GL-51).
1298        if decode_status(row.get::<String, _>(1)).map_err(AllocateError::Storage)?
1299            != AccountStatus::Active
1300        {
1301            return Err(AllocateError::AccountInactive);
1302        }
1303        let balance =
1304            to_units(row.get::<i64, _>(0), "account balance").map_err(AllocateError::Storage)?;
1305        // The floor and demand are applied to the policy's answer, not to
1306        // the balance test: the units the caller returned rejoined `balance`
1307        // earlier in this same transaction, and both are capped by it, so
1308        // they can only re-select capacity the account demonstrably has, and
1309        // an account with nothing left still refuses.
1310        let granted = match self
1311            .policy
1312            .consolidation_grant(requested, balance, floor, needed)
1313        {
1314            Some(granted) => granted,
1315            None => {
1316                // A refused consolidation rolls its settlement back. Only
1317                // attest when that settlement did not remove funding itself.
1318                if settlement_preserves_funding && !requested.is_zero() {
1319                    let budget = sqlx::query(BUDGET_VIEW_SQL)
1320                        .bind(id_bytes(account.0))
1321                        .fetch_one(&mut **tx)
1322                        .await
1323                        .map_err(alloc_storage)?;
1324                    let evidence = budget_view(&budget)
1325                        .map_err(AllocateError::Storage)?
1326                        .shortfall();
1327                    return Err(match evidence.exhaustion() {
1328                        Some(exhausted) => AllocateError::BalanceExhausted(exhausted),
1329                        None => AllocateError::BalanceInsufficient(evidence),
1330                    });
1331                }
1332                return Err(AllocateError::InsufficientBalance);
1333            }
1334        };
1335        // Allowance first: the units with an expiry date are spent
1336        // before the manual credits sitting beside them, and the lease
1337        // remembers the split so settlement can return each half to where
1338        // it came from (GL-97).
1339        let granted_i = to_i64(granted, "grant").map_err(AllocateError::Storage)?;
1340        let allowance_balance = row.get::<i64, _>(3);
1341        let from_allowance = granted_i.min(allowance_balance);
1342        let period_start_us = row.get::<i64, _>(4);
1343        let fence = row.get::<i64, _>(2);
1344        let fence_token = stored_fence(fence).map_err(AllocateError::Storage)?;
1345
1346        let lease_id = LeaseId(uuid::Uuid::new_v4().as_u128());
1347
1348        // The grant commits with this transaction, and a consolidation's
1349        // settlement has already applied inside it, so the row this returns is
1350        // the ledger the grant lands in: loss and expiry included.
1351        let budget = sqlx::query(GRANT_DEBIT_SQL)
1352            .bind(id_bytes(account.0))
1353            .bind(granted_i)
1354            .bind(from_allowance)
1355            .fetch_one(&mut **tx)
1356            .await
1357            .map_err(alloc_storage)?;
1358        let funding = budget_view(&budget)
1359            .map_err(AllocateError::Storage)?
1360            .shortfall();
1361        sqlx::query(
1362            "INSERT INTO tollgate_leases
1363             (lease_id, account_id, fencing_token, granted, used, credited, expires_at_floor_us,
1364              state, from_allowance, period_start_us, expires_at_submicro_ns, expiry_is_upper_bound)
1365             VALUES ($1, $2, $3, $4, 0, 0, $5, 0, $6, $7, $8, FALSE)",
1366        )
1367        .bind(id_bytes(lease_id.0))
1368        .bind(id_bytes(account.0))
1369        .bind(fence)
1370        .bind(granted_i)
1371        .bind(StoredInstant::from(expires_at).micros)
1372        .bind(from_allowance)
1373        .bind(period_start_us)
1374        .bind(StoredInstant::from(expires_at).submicro_nanos)
1375        .execute(&mut **tx)
1376        .await
1377        .map_err(alloc_storage)?;
1378
1379        Ok(Allocation {
1380            grant: LeaseGrant {
1381                lease_id,
1382                account_id: account,
1383                fencing_token: fence_token,
1384                units: granted,
1385                expires_at,
1386            },
1387            funding: Some(funding),
1388        })
1389    }
1390
1391    /// One release, returning its account and the credit the account update
1392    /// actually restored, inside the caller-owned transaction.
1393    async fn release_in_tx(
1394        &self,
1395        tx: &mut Transaction<'_, Postgres>,
1396        lease_id: LeaseId,
1397        fencing_token: FencingToken,
1398        unspent: CostUnits,
1399        now: Timestamp,
1400    ) -> Result<ReleasedCredit, AllocateError> {
1401        let LockedLeaseRow {
1402            account_id,
1403            fencing_token: fence,
1404            granted,
1405            used,
1406            credited: _credited,
1407            expires_at,
1408            state,
1409            from_allowance,
1410            period_start_us,
1411        } = lock_lease(tx, lease_id)
1412            .await
1413            .map_err(alloc_storage)?
1414            .ok_or(AllocateError::UnknownLease)?;
1415
1416        if stored_fence(fence).map_err(AllocateError::Storage)? != fencing_token {
1417            return Err(AllocateError::Fenced);
1418        }
1419        // Releases are accepted through the grace window (see GrantPolicy::
1420        // reclaim_grace) — only a settled or grace-exhausted lease refuses.
1421        let expires_at = expires_at.timestamp().map_err(AllocateError::Storage)?;
1422        if state != STATE_ACTIVE
1423            || self
1424                .policy
1425                .reclaim_cutoff(now)
1426                .is_some_and(|cutoff| expires_at <= cutoff)
1427        {
1428            return Err(AllocateError::LeaseNotActive);
1429        }
1430        // Decoded before the byte form is consumed by the credit below, and
1431        // returned so a consolidation draws its replacement grant from the
1432        // account this lease named rather than one a caller supplied.
1433        let account = AccountId(id_from(&account_id));
1434        let unspent_i = to_i64(unspent, "unspent").map_err(AllocateError::Storage)?;
1435        let loss = granted
1436            .checked_sub(
1437                used.checked_add(unspent_i)
1438                    .ok_or(AllocateError::InvalidRelease)?,
1439            )
1440            .filter(|l| *l >= 0)
1441            .ok_or(AllocateError::InvalidRelease)?;
1442
1443        sqlx::query("UPDATE tollgate_leases SET state = $2, credited = $3 WHERE lease_id = $1")
1444            .bind(id_bytes(lease_id.0))
1445            .bind(STATE_RELEASED)
1446            .bind(unspent_i)
1447            .execute(&mut **tx)
1448            .await
1449            .map_err(alloc_storage)?;
1450        // The lease's usage is charged in the order the account spends,
1451        // allowance first, so the top-up half is what survives a partly
1452        // spent lease. Crediting the allowance half back first would close
1453        // the equation just as well while moving durable credits into the
1454        // bucket that expires at the next boundary (GL-97).
1455        let from_topup = granted
1456            .checked_sub(from_allowance)
1457            .filter(|t| *t >= 0)
1458            .ok_or_else(|| {
1459                AllocateError::Storage(StoreError(format!(
1460                    "lease allowance funding {from_allowance} exceeds its grant {granted}"
1461                )))
1462            })?;
1463        let to_topup = from_topup.min(unspent_i);
1464        let to_allowance = unspent_i - to_topup;
1465
1466        // One statement, and the `CASE` is the whole boundary rule: if the
1467        // account has moved on to a later period, the allowance half is
1468        // expired instead of returned. Deciding it in SQL against the
1469        // row's own `period_start_us` keeps the read and the write in one
1470        // atomic step, so a rollover committing between them cannot make
1471        // this credit an allowance the account no longer has.
1472        let restored: i64 = sqlx::query_scalar(
1473            "UPDATE tollgate_accounts SET
1474                 balance = balance + $2
1475                     + CASE WHEN period_start_us > $5 THEN 0 ELSE $3 END,
1476                 allowance_balance = allowance_balance
1477                     + CASE WHEN period_start_us > $5 THEN 0 ELSE $3 END,
1478                 expired = expired + CASE WHEN period_start_us > $5 THEN $3 ELSE 0 END,
1479                 settlement_loss = settlement_loss + $4
1480             WHERE account_id = $1
1481             RETURNING $2 + CASE WHEN period_start_us > $5 THEN 0 ELSE $3 END",
1482        )
1483        .bind(account_id)
1484        .bind(to_topup)
1485        .bind(to_allowance)
1486        .bind(loss)
1487        .bind(period_start_us)
1488        .fetch_one(&mut **tx)
1489        .await
1490        .map_err(alloc_storage)?;
1491        Ok(ReleasedCredit {
1492            account,
1493            preserves_funding: loss == 0 && restored == unspent_i,
1494            restored: to_units(restored, "restored release credit")
1495                .map_err(AllocateError::Storage)?,
1496        })
1497    }
1498
1499    /// The expiry a grant issued now would carry, clamping to the policy's
1500    /// `max_ttl`. Fallible before any transaction opens, so a bad TTL never
1501    /// settles a lease it cannot replace.
1502    fn grant_expiry(
1503        &self,
1504        ttl: SignedDuration,
1505        now: Timestamp,
1506    ) -> Result<Timestamp, AllocateError> {
1507        if ttl <= SignedDuration::ZERO {
1508            return Err(AllocateError::InvalidTtl);
1509        }
1510        now.checked_add(ttl.min(self.policy.max_ttl))
1511            .map_err(|e| AllocateError::Storage(StoreError(format!("ttl overflow: {e}"))))
1512    }
1513}
1514
1515#[async_trait]
1516impl LeaseAllocator for PostgresStore {
1517    async fn acquire(
1518        &self,
1519        account: AccountId,
1520        requested: CostUnits,
1521        ttl: SignedDuration,
1522        now: Timestamp,
1523    ) -> Result<Allocation, AllocateError> {
1524        let expires_at = self.grant_expiry(ttl, now)?;
1525        let mut tx = self.pool.begin().await.map_err(alloc_storage)?;
1526        let result = self
1527            .acquire_in_tx(&mut tx, account, requested, expires_at, Exchange::ACQUIRE)
1528            .await;
1529        finish_transaction(tx, result).await
1530    }
1531
1532    async fn release(
1533        &self,
1534        lease_id: LeaseId,
1535        fencing_token: FencingToken,
1536        unspent: CostUnits,
1537        now: Timestamp,
1538    ) -> Result<(), AllocateError> {
1539        let mut tx = self.pool.begin().await.map_err(alloc_storage)?;
1540        let result = self
1541            .release_in_tx(&mut tx, lease_id, fencing_token, unspent, now)
1542            .await
1543            .map(|_| ());
1544        finish_transaction(tx, result).await
1545    }
1546
1547    async fn consolidate(
1548        &self,
1549        lease_id: LeaseId,
1550        fencing_token: FencingToken,
1551        unspent: CostUnits,
1552        requested: CostUnits,
1553        needed: CostUnits,
1554        ttl: SignedDuration,
1555        now: Timestamp,
1556    ) -> Result<Allocation, AllocateError> {
1557        let expires_at = self.grant_expiry(ttl, now)?;
1558        let mut tx = self.pool.begin().await.map_err(alloc_storage)?;
1559        // One transaction for both halves is the whole point. Lease row first
1560        // and account row second, which is the order `release` already takes,
1561        // so a consolidation cannot invert the lock order against a concurrent
1562        // release of a sibling lease on the same account.
1563        //
1564        // The new grant is drawn from the account the released lease named,
1565        // never one the caller supplied, so the two halves cannot disagree
1566        // about whose balance moved.
1567        let result = async {
1568            let released = self
1569                .release_in_tx(&mut tx, lease_id, fencing_token, unspent, now)
1570                .await?;
1571            self.acquire_in_tx(
1572                &mut tx,
1573                released.account,
1574                requested,
1575                expires_at,
1576                Exchange {
1577                    floor: released.restored,
1578                    needed,
1579                    preserves_funding: released.preserves_funding,
1580                },
1581            )
1582            .await
1583        }
1584        .await;
1585        finish_transaction(tx, result).await
1586    }
1587
1588    async fn reclaim_expired_batch(
1589        &self,
1590        now: Timestamp,
1591        limit: NonZeroUsize,
1592    ) -> Result<ReclaimBatch, StoreError> {
1593        let limit_i = i64::try_from(limit.get())
1594            .map_err(|_| StoreError(format!("reclaim batch limit exceeds i64 range: {limit}")))?;
1595        let Some(cutoff) = self.policy.reclaim_cutoff(now) else {
1596            return ReclaimBatch::try_new(Vec::new(), limit);
1597        };
1598        let cutoff = StoredInstant::from(cutoff);
1599        let mut tx = self.pool.begin().await.map_err(storage)?;
1600        let result = async {
1601            // Reclaim only once the grace window past expiry has fully lapsed:
1602            // expires_at + grace <= now  iff  expires_at <= now - grace.
1603            // Both tuple components preserve the exact timestamp ordering.
1604            // SKIP LOCKED lets concurrent sweepers cooperate; the limit keeps
1605            // both the lease locks and the transaction's row work bounded.
1606            let rows = sqlx::query(RECLAIM_DUE_LEASES_SQL)
1607                .bind(cutoff.micros)
1608                .bind(cutoff.submicro_nanos)
1609                .bind(limit_i)
1610                .fetch_all(&mut *tx)
1611                .await
1612                .map_err(storage)?;
1613
1614            // A holder that never released cannot prove any unit unspent, so
1615            // nothing is credited: each remainder becomes provisional
1616            // settlement loss, which later usage for the lease converts into
1617            // billed usage (GL-136). `credited` stays zero so that usage fits.
1618            let mut reclaimed = Vec::with_capacity(rows.len());
1619            let mut lease_ids = Vec::with_capacity(rows.len());
1620            let mut forfeits: std::collections::BTreeMap<Vec<u8>, i64> =
1621                std::collections::BTreeMap::new();
1622            for row in rows {
1623                let lease_bytes: Vec<u8> = row.get(0);
1624                let account_bytes: Vec<u8> = row.get(1);
1625                let granted = row.get::<i64, _>(2);
1626                let used = row.get::<i64, _>(3);
1627                // Validate the whole batch before either set-wise UPDATE: a
1628                // negative remainder is corruption, and recording it would
1629                // shrink the account's loss rather than account for a lease.
1630                let forfeited = granted.checked_sub(used).ok_or_else(|| {
1631                    StoreError(format!(
1632                        "reclaim remainder overflow: granted {granted}, used {used}"
1633                    ))
1634                })?;
1635                let forfeited_units = to_units(forfeited, "reclaim remainder")?;
1636                let total = forfeits.entry(account_bytes.clone()).or_default();
1637                *total = total
1638                    .checked_add(forfeited)
1639                    .ok_or_else(|| StoreError("reclaim loss sum overflow".into()))?;
1640                lease_ids.push(lease_bytes.clone());
1641                reclaimed.push(ReclaimedLease {
1642                    lease_id: LeaseId(id_from(&lease_bytes)),
1643                    account_id: AccountId(id_from(&account_bytes)),
1644                    forfeited: forfeited_units,
1645                });
1646            }
1647
1648            let batch = ReclaimBatch::try_new(reclaimed, limit)?;
1649            if batch.is_empty() {
1650                return Ok(batch);
1651            }
1652
1653            let (account_ids, account_forfeits): (Vec<_>, Vec<_>) = forfeits.into_iter().unzip();
1654            let expected_lease_rows = u64::try_from(lease_ids.len())
1655                .map_err(|_| StoreError("reclaim lease row count exceeds u64 range".into()))?;
1656            let expected_account_rows = u64::try_from(account_ids.len())
1657                .map_err(|_| StoreError("reclaim account row count exceeds u64 range".into()))?;
1658
1659            // A set-wise UPDATE does not promise row-lock order. Lock every
1660            // affected account explicitly in byte-sorted order first (the
1661            // BTreeMap above), matching release and ingest's lease-then-account
1662            // order and preventing concurrent multi-account sweeps from
1663            // forming a deadlock cycle.
1664            let locked_accounts = sqlx::query(
1665                "SELECT account_id FROM tollgate_accounts
1666                 WHERE account_id = ANY($1) ORDER BY account_id FOR UPDATE",
1667            )
1668            .bind(&account_ids)
1669            .fetch_all(&mut *tx)
1670            .await
1671            .map_err(storage)?;
1672            if locked_accounts.len() != account_ids.len() {
1673                return Err(StoreError(format!(
1674                    "reclaim locked {} of {} referenced account rows",
1675                    locked_accounts.len(),
1676                    account_ids.len()
1677                )));
1678            }
1679
1680            let updated_leases = sqlx::query(
1681                "UPDATE tollgate_leases
1682                 SET state = $2, credited = 0
1683                 WHERE lease_id = ANY($1) AND state = $3",
1684            )
1685            .bind(&lease_ids)
1686            .bind(STATE_EXPIRED)
1687            .bind(STATE_ACTIVE)
1688            .execute(&mut *tx)
1689            .await
1690            .map_err(storage)?;
1691            if updated_leases.rows_affected() != expected_lease_rows {
1692                return Err(StoreError(format!(
1693                    "reclaim updated {} of {} locked lease rows",
1694                    updated_leases.rows_affected(),
1695                    lease_ids.len()
1696                )));
1697            }
1698
1699            let updated_accounts = sqlx::query(
1700                "UPDATE tollgate_accounts AS account
1701                 SET settlement_loss = account.settlement_loss + delta.forfeited
1702                 FROM UNNEST($1::bytea[], $2::bigint[]) AS delta(account_id, forfeited)
1703                 WHERE account.account_id = delta.account_id",
1704            )
1705            .bind(&account_ids)
1706            .bind(&account_forfeits)
1707            .execute(&mut *tx)
1708            .await
1709            .map_err(storage)?;
1710            if updated_accounts.rows_affected() != expected_account_rows {
1711                return Err(StoreError(format!(
1712                    "reclaim updated {} of {} locked account rows",
1713                    updated_accounts.rows_affected(),
1714                    account_ids.len()
1715                )));
1716            }
1717
1718            Ok(batch)
1719        }
1720        .await;
1721        finish_transaction(tx, result).await
1722    }
1723}
1724
1725#[async_trait]
1726impl UsageSink for PostgresStore {
1727    async fn ingest(
1728        &self,
1729        events: &[UsageEvent],
1730        _now: Timestamp,
1731    ) -> Result<IngestReport, IngestError> {
1732        // One transaction per *batch* (review finding GL-8): leases are locked
1733        // in a single sorted ANY() query (sorted to keep concurrent batches
1734        // deadlock-free), duplicates are detected with one lookup, events are
1735        // classified in memory against the locked rows, and the accepted set
1736        // lands via one bulk insert plus set-wise per-lease/per-account
1737        // updates. Classification in application code preserves the partial
1738        // acceptance contract without savepoints.
1739        let mut report = IngestReport {
1740            unattributed: Some(0),
1741            ..IngestReport::default()
1742        };
1743        if events.is_empty() {
1744            return Ok(report);
1745        }
1746
1747        // Encode each ID and timestamp exactly once before opening the
1748        // transaction. The prepared values are reused by the lock, dedup,
1749        // classification, insert, and aggregate-update phases. Units are
1750        // checked once later, after duplicate and capability classification, to
1751        // preserve the partial-acceptance ordering.
1752        struct PreparedEvent<'a> {
1753            event: &'a UsageEvent,
1754            request_id: Vec<u8>,
1755            account_id: Vec<u8>,
1756            key_id: Option<Vec<u8>>,
1757            /// `None` for overage, which names no lease. Every phase below
1758            /// keys off this rather than re-matching on the source, so a lease
1759            /// id can never be conjured for an event that has none.
1760            lease_id: Option<Vec<u8>>,
1761            occurred_at_us: i64,
1762        }
1763        let prepared: Vec<PreparedEvent<'_>> = events
1764            .iter()
1765            .map(|event| {
1766                Ok(PreparedEvent {
1767                    event,
1768                    request_id: id_bytes(event.request_id.0),
1769                    account_id: id_bytes(event.account_id.0),
1770                    key_id: event.key_id.map(|id| id_bytes(id.0)),
1771                    lease_id: event.source.lease_id().map(|id| id_bytes(id.0)),
1772                    occurred_at_us: ts_micros(event.occurred_at),
1773                })
1774            })
1775            .collect::<Result<_, StoreError>>()?;
1776
1777        let mut tx = self.pool.begin().await.map_err(storage)?;
1778        let result: Result<_, IngestError> = async {
1779            // Lock every referenced lease in one global account/lease order.
1780            // A cycle needs two waiters, and this is the path that waits:
1781            // release touches a single lease, and reclaim takes its leases
1782            // with SKIP LOCKED, so it abandons a contended row instead of
1783            // queueing behind it. Concurrent ingests are therefore what this
1784            // order is for -- reclaim selects in expiry order (GL-65) and is
1785            // still safe, because the lease-then-account phase order below is
1786            // what keeps the two from crossing.
1787            let lease_ids: Vec<Vec<u8>> = prepared
1788                .iter()
1789                .filter_map(|event| event.lease_id.clone())
1790                .collect::<std::collections::BTreeSet<_>>()
1791                .into_iter()
1792                .collect();
1793            struct LeaseRow {
1794                account_id: Vec<u8>,
1795                fence: i64,
1796                granted: i64,
1797                used: i64,
1798                used_delta: Option<NonZeroI64>,
1799                credited: i64,
1800                settled: bool,
1801            }
1802            let rows = sqlx::query(
1803                "SELECT lease_id, account_id, fencing_token, granted, used, credited, state
1804                 FROM tollgate_leases
1805                 WHERE lease_id = ANY($1)
1806                 ORDER BY account_id, lease_id FOR UPDATE",
1807            )
1808            .bind(&lease_ids)
1809            .fetch_all(&mut *tx)
1810            .await
1811            .map_err(storage)?;
1812            let mut leases: std::collections::BTreeMap<Vec<u8>, LeaseRow> =
1813                std::collections::BTreeMap::new();
1814            for row in rows {
1815                let lease_id: Vec<u8> = row.get(0);
1816                let fence: i64 = row.get(2);
1817                let granted: i64 = row.get(3);
1818                let used: i64 = row.get(4);
1819                let credited: i64 = row.get(5);
1820                leases.insert(
1821                    lease_id,
1822                    LeaseRow {
1823                        account_id: row.get(1),
1824                        fence,
1825                        granted,
1826                        used,
1827                        used_delta: None,
1828                        credited,
1829                        settled: row.get::<i16, _>(6) != STATE_ACTIVE,
1830                    },
1831                );
1832            }
1833
1834            // Existing request ids in one lookup.
1835            let request_ids: Vec<Vec<u8>> = prepared
1836                .iter()
1837                .map(|event| event.request_id.clone())
1838                .collect();
1839            let mut seen: std::collections::HashSet<Vec<u8>> = sqlx::query(
1840                "SELECT request_id FROM tollgate_usage_events WHERE request_id = ANY($1)",
1841            )
1842            .bind(&request_ids)
1843            .fetch_all(&mut *tx)
1844            .await
1845            .map_err(storage)?
1846            .into_iter()
1847            .map(|row| row.get::<Vec<u8>, _>(0))
1848            .collect();
1849
1850            // Which accounts referenced by *overage* events actually exist.
1851            //
1852            // A leased event proves its account exists by resolving a lease
1853            // row, whose `account_id` is a foreign key. An overage event has
1854            // no such proof, and the memory backend rejects one naming an
1855            // unknown account -- so this backend must too, or the two
1856            // classify the same batch differently.
1857            //
1858            // Read without `FOR UPDATE`, deliberately. The accounts these
1859            // events touch are locked in account order further down, after
1860            // the leases, and taking that lock here instead would invert the
1861            // lease-then-account order every writing transaction agrees on.
1862            // A row that vanished between this probe and that lock would fail
1863            // the lock's own count check and roll the batch back, which is the
1864            // fail-closed outcome; nothing in this store deletes accounts.
1865            let overage_account_ids: Vec<Vec<u8>> = prepared
1866                .iter()
1867                .filter(|event| event.lease_id.is_none())
1868                .map(|event| event.account_id.clone())
1869                .collect::<std::collections::BTreeSet<_>>()
1870                .into_iter()
1871                .collect();
1872            let known_overage_accounts: std::collections::HashSet<Vec<u8>> =
1873                if overage_account_ids.is_empty() {
1874                    std::collections::HashSet::new()
1875                } else {
1876                    sqlx::query(
1877                        "SELECT account_id FROM tollgate_accounts WHERE account_id = ANY($1)",
1878                    )
1879                    .bind(&overage_account_ids)
1880                    .fetch_all(&mut *tx)
1881                    .await
1882                    .map_err(storage)?
1883                    .into_iter()
1884                    .map(|row| row.get::<Vec<u8>, _>(0))
1885                    .collect()
1886                };
1887
1888            // Classify in memory against the locked rows (identical rules to
1889            // MemoryStore: capability triple, then the conservation fit that
1890            // also converts a released lease's provisional loss into billed usage).
1891            struct Accepted {
1892                event_index: usize,
1893                settled: bool,
1894                /// The lease's stored fence, already validated positive;
1895                /// acceptance required the event's token to equal it, so this
1896                /// is the event's fence in storage form with no reconversion.
1897                /// `None` for overage, whose row carries no capability.
1898                fence: Option<i64>,
1899                /// True when these units were extended as unfunded credit, so
1900                /// they fund the ledger as well as bill it.
1901                overage: bool,
1902                /// Checked once during classification and reused by every
1903                /// aggregate and insert array.
1904                units: i64,
1905            }
1906            let mut accepted: Vec<Accepted> = Vec::with_capacity(prepared.len());
1907            for (event_index, event) in prepared.iter().enumerate() {
1908                if seen.contains(event.request_id.as_slice()) {
1909                    report.duplicate += 1;
1910                    continue;
1911                }
1912                let Some(lease_key) = event.lease_id.as_deref() else {
1913                    // Overage: no capability to verify and no lease capacity
1914                    // to fit inside, so the only question is whether the
1915                    // account exists. It is accepted regardless of the
1916                    // account's current enforcement mode -- the ledger does
1917                    // not record which mode a request was admitted under, and
1918                    // discarding a charge because the account was switched
1919                    // back to `Strict` after the work ran would be fail-open
1920                    // on accounting.
1921                    if !known_overage_accounts.contains(event.account_id.as_slice()) {
1922                        report.rejected += 1;
1923                        continue;
1924                    }
1925                    let Ok(units) = i64::try_from(event.event.units.get()) else {
1926                        // Caller data outside this backend's storage domain
1927                        // rejects this event, not the valid neighboring work.
1928                        report.rejected += 1;
1929                        continue;
1930                    };
1931                    seen.insert(event.request_id.clone());
1932                    accepted.push(Accepted {
1933                        event_index,
1934                        // Overage belongs to no lease, so there is no
1935                        // provisional settlement loss for it to convert and
1936                        // it is settled the moment it is recorded.
1937                        settled: false,
1938                        fence: None,
1939                        overage: true,
1940                        units,
1941                    });
1942                    report.accepted += 1;
1943                    continue;
1944                };
1945                let Some(lease) = leases.get_mut(lease_key) else {
1946                    report.rejected += 1;
1947                    continue;
1948                };
1949                if Some(stored_fence(lease.fence)?) != event.event.source.fencing_token()
1950                    || lease.account_id.as_slice() != event.account_id.as_slice()
1951                {
1952                    report.rejected += 1;
1953                    continue;
1954                }
1955                to_units(lease.granted, "lease granted")?;
1956                to_units(lease.used, "lease used")?;
1957                to_units(lease.credited, "lease credited")?;
1958                let Ok(units) = i64::try_from(event.event.units.get()) else {
1959                    report.rejected += 1;
1960                    continue;
1961                };
1962                let committed = lease
1963                    .used
1964                    .checked_add(lease.used_delta.map_or(0, NonZeroI64::get))
1965                    .and_then(|used| used.checked_add(lease.credited))
1966                    .ok_or_else(|| {
1967                        StoreError(format!(
1968                            "lease accounting overflow for {:#034x}",
1969                            id_from(lease_key)
1970                        ))
1971                    })?;
1972                let remaining = lease.granted.checked_sub(committed).ok_or_else(|| {
1973                    StoreError(format!(
1974                        "lease accounting exceeds grant for {:#034x}: granted {}, committed {committed}",
1975                        id_from(lease_key), lease.granted
1976                    ))
1977                })?;
1978                if units > remaining {
1979                    report.rejected += 1;
1980                    continue;
1981                }
1982                let used_delta = lease
1983                    .used_delta
1984                    .map_or(0, NonZeroI64::get)
1985                    .checked_add(units)
1986                    .ok_or_else(|| {
1987                        StoreError(format!(
1988                            "lease usage delta overflow for {:#034x}",
1989                            id_from(lease_key)
1990                        ))
1991                    })?;
1992                lease.used_delta = NonZeroI64::new(used_delta);
1993                seen.insert(event.request_id.clone());
1994                accepted.push(Accepted {
1995                    event_index,
1996                    settled: lease.settled,
1997                    fence: Some(lease.fence),
1998                    overage: false,
1999                    units,
2000                });
2001                report.accepted += 1;
2002            }
2003
2004            if accepted.is_empty() {
2005                return Ok(report);
2006            }
2007
2008            #[derive(Default)]
2009            struct AccountDelta {
2010                usage: i64,
2011                loss: i64,
2012                /// Units that fund themselves: overage bills and funds in the
2013                /// same transaction, so the equation closes by construction
2014                /// rather than by a later reconciliation step.
2015                overage: i64,
2016            }
2017
2018            // Bulk insert the accepted events.
2019            let (mut rid, mut acct, mut lease, mut fence, mut units, mut at, mut revision, mut keys) = (
2020                Vec::with_capacity(accepted.len()),
2021                Vec::with_capacity(accepted.len()),
2022                Vec::with_capacity(accepted.len()),
2023                Vec::with_capacity(accepted.len()),
2024                Vec::with_capacity(accepted.len()),
2025                Vec::with_capacity(accepted.len()),
2026                Vec::with_capacity(accepted.len()),
2027                Vec::with_capacity(accepted.len()),
2028            );
2029            let mut account_deltas: std::collections::BTreeMap<Vec<u8>, AccountDelta> =
2030                std::collections::BTreeMap::new();
2031            for accepted_event in &accepted {
2032                let event = &prepared[accepted_event.event_index];
2033                rid.push(event.request_id.clone());
2034                acct.push(event.account_id.clone());
2035                keys.push(event.key_id.clone());
2036                lease.push(event.lease_id.clone());
2037                fence.push(accepted_event.fence);
2038                units.push(accepted_event.units);
2039                at.push(event.occurred_at_us);
2040                // Carried verbatim: 32 bytes in, 32 bytes out, and the
2041                // schema's length CHECK says so. Unlike the identifiers
2042                // above this needs no width conversion — the Rust type is
2043                // already the stored representation.
2044                revision.push(event.event.policy_revision.as_bytes().to_vec());
2045                if accepted_event.overage {
2046                    debug_assert!(
2047                        event.lease_id.is_none() && accepted_event.fence.is_none(),
2048                        "an overage row must carry neither half of a capability"
2049                    );
2050                }
2051
2052                let entry = account_deltas.entry(event.account_id.clone()).or_default();
2053                entry.usage = entry.usage.checked_add(accepted_event.units).ok_or_else(|| {
2054                    IngestError::Refused(StoreError(format!(
2055                        "usage delta overflow for account {:#034x}",
2056                        event.event.account_id.0
2057                    )))
2058                })?;
2059                if accepted_event.settled {
2060                    entry.loss = entry.loss.checked_add(accepted_event.units).ok_or_else(|| {
2061                        IngestError::Refused(StoreError(format!(
2062                            "settlement loss delta overflow for account {:#034x}",
2063                            event.event.account_id.0
2064                        )))
2065                    })?;
2066                }
2067                if accepted_event.overage {
2068                    entry.overage =
2069                        entry.overage.checked_add(accepted_event.units).ok_or_else(|| {
2070                            IngestError::Refused(StoreError(format!(
2071                                "overage delta overflow for account {:#034x}",
2072                                event.event.account_id.0
2073                            )))
2074                        })?;
2075                }
2076            }
2077            let inserted = sqlx::query(
2078                "INSERT INTO tollgate_usage_events
2079                 (request_id, account_id, lease_id, fencing_token, units, occurred_at_us, policy_revision, key_id)
2080                 SELECT * FROM UNNEST($1::bytea[], $2::bytea[], $3::bytea[], $4::bigint[], $5::bigint[], $6::bigint[], $7::bytea[], $8::bytea[])",
2081            )
2082            .bind(&rid)
2083            .bind(&acct)
2084            .bind(&lease)
2085            .bind(&fence)
2086            .bind(&units)
2087            .bind(&at)
2088            .bind(&revision)
2089            .bind(&keys)
2090            .execute(&mut *tx)
2091            .await
2092            .map_err(storage)?;
2093            let expected_event_rows = u64::try_from(accepted.len())
2094                .map_err(|_| StoreError("accepted event count exceeds u64 range".into()))?;
2095            if inserted.rows_affected() != expected_event_rows {
2096                return Err(IngestError::Unavailable(StoreError(format!(
2097                    "ingest inserted {} of {} accepted usage rows",
2098                    inserted.rows_affected(),
2099                    accepted.len()
2100                ))));
2101            }
2102
2103            // Every lease row was already locked by the sorted SELECT above,
2104            // so one set-wise statement can apply the grouped usage deltas
2105            // without adding a round trip per distinct lease.
2106            let (lease_update_ids, lease_used_deltas): (Vec<Vec<u8>>, Vec<i64>) = leases
2107                .iter()
2108                .filter_map(|(lease_id, row)| {
2109                    row.used_delta
2110                        .map(|used_delta| (lease_id.clone(), used_delta.get()))
2111                })
2112                .unzip();
2113            if !lease_update_ids.is_empty() {
2114                let updated_leases = sqlx::query(
2115                    "UPDATE tollgate_leases AS lease
2116                     SET used = lease.used + delta.used
2117                     FROM UNNEST($1::bytea[], $2::bigint[]) AS delta(lease_id, used)
2118                     WHERE lease.lease_id = delta.lease_id",
2119                )
2120                .bind(&lease_update_ids)
2121                .bind(&lease_used_deltas)
2122                .execute(&mut *tx)
2123                .await
2124                .map_err(storage)?;
2125                let expected_lease_rows = u64::try_from(lease_update_ids.len())
2126                    .map_err(|_| StoreError("ingest lease row count exceeds u64 range".into()))?;
2127                if updated_leases.rows_affected() != expected_lease_rows {
2128                    return Err(IngestError::Unavailable(StoreError(format!(
2129                        "ingest updated {} of {} locked lease rows",
2130                        updated_leases.rows_affected(),
2131                        lease_update_ids.len()
2132                    ))));
2133                }
2134            }
2135
2136            let mut account_ids = Vec::with_capacity(account_deltas.len());
2137            let mut account_usage_deltas = Vec::with_capacity(account_deltas.len());
2138            let mut account_loss_deltas = Vec::with_capacity(account_deltas.len());
2139            let mut account_overage_deltas = Vec::with_capacity(account_deltas.len());
2140            for (account_id, delta) in &account_deltas {
2141                account_ids.push(account_id.clone());
2142                account_usage_deltas.push(delta.usage);
2143                account_loss_deltas.push(delta.loss);
2144                account_overage_deltas.push(delta.overage);
2145            }
2146
2147            // A set-wise UPDATE does not promise row-lock order. Lock every
2148            // affected account explicitly in byte order first. BTreeMap made
2149            // account_ids sorted, preserving ingest's lease-then-account
2150            // order and preventing concurrent multi-account batches from
2151            // forming a deadlock cycle.
2152            let locked_accounts = sqlx::query(
2153                "SELECT account_id, usage_recorded, settlement_loss, overage_recorded
2154                 FROM tollgate_accounts
2155                 WHERE account_id = ANY($1)
2156                 ORDER BY account_id FOR UPDATE",
2157            )
2158            .bind(&account_ids)
2159            .fetch_all(&mut *tx)
2160            .await
2161            .map_err(storage)?;
2162            if locked_accounts.len() != account_ids.len() {
2163                return Err(IngestError::Unavailable(StoreError(format!(
2164                    "ingest locked {} of {} referenced account rows",
2165                    locked_accounts.len(),
2166                    account_ids.len()
2167                ))));
2168            }
2169
2170            // Validate every account before the set-wise mutation. The
2171            // per-lease fit check bounds each straggler by the provisional
2172            // loss its own release recorded, so a short account loss means
2173            // ledger corruption and the entire transaction must roll back.
2174            for row in locked_accounts {
2175                let account_id: Vec<u8> = row.get(0);
2176                let usage_recorded: i64 = row.get(1);
2177                let settlement_loss: i64 = row.get(2);
2178                let overage_recorded: i64 = row.get(3);
2179                to_units(usage_recorded, "account usage_recorded")?;
2180                to_units(settlement_loss, "account settlement_loss")?;
2181                to_units(overage_recorded, "account overage_recorded")?;
2182                let delta = account_deltas.get(&account_id).ok_or_else(|| {
2183                    StoreError(format!(
2184                        "ingest locked unexpected account {:#034x}",
2185                        id_from(&account_id)
2186                    ))
2187                })?;
2188                usage_recorded.checked_add(delta.usage).ok_or_else(|| {
2189                    IngestError::Refused(StoreError(format!(
2190                        "usage_recorded overflow for account {:#034x}",
2191                        id_from(&account_id)
2192                    )))
2193                })?;
2194                // The funding half of the same units. Both terms must be
2195                // representable or neither may move, or the batch would bill
2196                // overage it did not fund and leave the equation open.
2197                overage_recorded.checked_add(delta.overage).ok_or_else(|| {
2198                    IngestError::Refused(StoreError(format!(
2199                        "overage_recorded overflow for account {:#034x}",
2200                        id_from(&account_id)
2201                    )))
2202                })?;
2203                if settlement_loss < delta.loss {
2204                    return Err(IngestError::Unavailable(StoreError(format!(
2205                        "settlement_loss underflow for account {:#034x}: settled straggler \
2206                         usage {} exceeds recorded loss",
2207                        id_from(&account_id),
2208                        delta.loss
2209                    ))));
2210                }
2211            }
2212
2213            let updated_accounts = sqlx::query(
2214                "UPDATE tollgate_accounts AS account
2215                 SET usage_recorded = account.usage_recorded + delta.usage,
2216                     settlement_loss = account.settlement_loss - delta.loss,
2217                     overage_recorded = account.overage_recorded + delta.overage
2218                 FROM UNNEST($1::bytea[], $2::bigint[], $3::bigint[], $4::bigint[])
2219                      AS delta(account_id, usage, loss, overage)
2220                 WHERE account.account_id = delta.account_id
2221                   AND account.settlement_loss >= delta.loss",
2222            )
2223            .bind(&account_ids)
2224            .bind(&account_usage_deltas)
2225            .bind(&account_loss_deltas)
2226            .bind(&account_overage_deltas)
2227            .execute(&mut *tx)
2228            .await
2229            .map_err(storage)?;
2230            let expected_account_rows = u64::try_from(account_ids.len())
2231                .map_err(|_| StoreError("ingest account row count exceeds u64 range".into()))?;
2232            if updated_accounts.rows_affected() != expected_account_rows {
2233                return Err(IngestError::Unavailable(StoreError(format!(
2234                    "ingest updated {} of {} locked account rows",
2235                    updated_accounts.rows_affected(),
2236                    account_ids.len()
2237                ))));
2238            }
2239
2240            // Legacy/unscoped batches cannot name activity. Their complete
2241            // attribution result is already known, so avoid an empty database
2242            // round trip while the ledger transaction still commits normally.
2243            if keys.iter().all(Option::is_none) {
2244                report.unattributed = Some(expected_event_rows);
2245                return Ok(report);
2246            }
2247
2248            // Only newly accepted rows supply evidence. Count events before
2249            // grouping: two events for one key are both attributable, even
2250            // if neither advances an already-newer maximum. Sort writes to
2251            // share one activity-lock order across concurrent transactions.
2252            let attributed: i64 = sqlx::query_scalar(
2253                "WITH matched AS MATERIALIZED (
2254                    SELECT k.key_id, b.occurred_at_us
2255                    FROM UNNEST($1::bytea[], $2::bytea[], $3::bigint[])
2256                        AS b(key_id, account_id, occurred_at_us)
2257                    JOIN LATERAL (
2258                        SELECT key_id FROM tollgate_credential_keys
2259                        WHERE key_id = b.key_id AND account_id = b.account_id LIMIT 1
2260                    ) k ON true
2261                 ), updated AS (
2262                    INSERT INTO tollgate_credential_activity AS activity (key_id, last_committed_at_us)
2263                    SELECT key_id, MAX(occurred_at_us) FROM matched GROUP BY key_id ORDER BY key_id
2264                    ON CONFLICT (key_id) DO UPDATE
2265                    SET last_committed_at_us = EXCLUDED.last_committed_at_us
2266                    WHERE activity.last_committed_at_us < EXCLUDED.last_committed_at_us
2267                    RETURNING key_id
2268                 ) SELECT COUNT(*) FROM matched"
2269            ).bind(&keys).bind(&acct).bind(&at).fetch_one(&mut *tx).await.map_err(storage)?;
2270            report.unattributed = Some(expected_event_rows.checked_sub(
2271                u64::try_from(attributed).map_err(|_| StoreError("negative attribution count".into()))?
2272            ).ok_or_else(|| StoreError("attribution count exceeds accepted events".into()))?);
2273
2274            Ok(report)
2275        }
2276        .await;
2277        // Monotonic accounting overflow is a permanent batch refusal. Database
2278        // and stored-corruption errors remain retryable, and rollback must
2279        // complete before either outcome becomes observable.
2280        finish_transaction(tx, result).await
2281    }
2282}
2283
2284/// Decode a stored status column into the enum, refusing anything the
2285/// vocabulary does not contain.
2286///
2287/// Never defaults to `Active`. A value the `CHECK` constraint should have made
2288/// impossible means the row was written outside this code, and admitting it as
2289/// "active" would turn corruption into service (the rule INVARIANTS.md GL-11
2290/// applies to the ledger's numbers, applied to its status).
2291fn decode_status(stored: String) -> Result<AccountStatus, StoreError> {
2292    match stored.as_str() {
2293        s if s == AccountStatus::Active.as_str() => Ok(AccountStatus::Active),
2294        s if s == AccountStatus::Suspended.as_str() => Ok(AccountStatus::Suspended),
2295        s if s == AccountStatus::Closed.as_str() => Ok(AccountStatus::Closed),
2296        other => Err(StoreError(format!("unrecognized account status {other:?}"))),
2297    }
2298}
2299
2300/// The ledger's execution-capacity class, or a refusal for a spelling the
2301/// vocabulary does not contain (GL-99).
2302///
2303/// Never defaults to `Assured`, for the reason [`decode_status`] never defaults
2304/// to `Active`. A value the `CHECK` constraint should have made impossible
2305/// means the row was written outside this code, and admitting it as `Assured`
2306/// would turn corruption into unconditional capacity — the permissive answer,
2307/// arrived at by accident.
2308fn decode_capacity_class(stored: String) -> Result<CapacityClass, StoreError> {
2309    match stored.as_str() {
2310        s if s == CapacityClass::Assured.as_str() => Ok(CapacityClass::Assured),
2311        s if s == CapacityClass::BestEffort.as_str() => Ok(CapacityClass::BestEffort),
2312        other => Err(StoreError(format!("unrecognized capacity class {other:?}"))),
2313    }
2314}
2315
2316/// Rebuild an account's schedule from its three stored columns.
2317///
2318/// `None` is "no schedule", and it is only reachable when all three are NULL:
2319/// the row's all-or-nothing CHECK makes half a schedule unstorable, so a
2320/// half-populated row read here is corruption to report rather than a shape to
2321/// interpret. Unknown names are refused for the same reason `decode_status`
2322/// refuses them — a period this binary cannot evaluate must not be presented
2323/// as if it were monthly.
2324fn decode_schedule(
2325    allowance: Option<i64>,
2326    period: Option<String>,
2327    rollover: Option<String>,
2328) -> Result<Option<BudgetSchedule>, StoreError> {
2329    let populated = [allowance.is_some(), period.is_some(), rollover.is_some()];
2330    let (Some(allowance), Some(period), Some(rollover)) = (allowance, period, rollover) else {
2331        if populated.iter().any(|present| *present) {
2332            return Err(StoreError(
2333                "stored budget schedule is partially populated".into(),
2334            ));
2335        }
2336        return Ok(None);
2337    };
2338    let period = match period.as_str() {
2339        s if s == Period::UtcCalendarMonth.as_str() => Period::UtcCalendarMonth,
2340        other => return Err(StoreError(format!("unrecognized budget period {other:?}"))),
2341    };
2342    let rollover = match rollover.as_str() {
2343        s if s == Rollover::None.as_str() => Rollover::None,
2344        other => {
2345            return Err(StoreError(format!(
2346                "unrecognized budget rollover {other:?}"
2347            )));
2348        }
2349    };
2350    Ok(Some(BudgetSchedule {
2351        allowance: to_units(allowance, "budget allowance")?,
2352        period,
2353        rollover,
2354    }))
2355}
2356
2357/// The ledger row [`budget_view`] decodes, for allocator evidence read inside
2358/// the grant's own transaction.
2359const BUDGET_VIEW_SQL: &str = "SELECT account_id, deposited, overage_recorded, usage_recorded,
2360            settlement_loss, expired, budget_allowance, budget_period,
2361            budget_rollover, period_start_us
2362     FROM tollgate_accounts WHERE account_id = $1";
2363
2364/// Debit a grant and return the committed-to-be row [`budget_view`] decodes,
2365/// in the same column order as [`BUDGET_VIEW_SQL`].
2366const GRANT_DEBIT_SQL: &str = "UPDATE tollgate_accounts
2367     SET balance = balance - $2,
2368         allowance_balance = allowance_balance - $3,
2369         next_fence = next_fence + 1
2370     WHERE account_id = $1
2371     RETURNING account_id, deposited, overage_recorded, usage_recorded,
2372               settlement_loss, expired, budget_allowance, budget_period,
2373               budget_rollover, period_start_us";
2374
2375/// What the account could still spend, for a snapshot's budget view (GL-97).
2376///
2377/// Balance *plus* the unspent remainder of every active lease, because units
2378/// out on lease are still the account's. Derived from the account row alone:
2379/// the conservation equation makes `balance + active grants` equal to what the
2380/// account was funded with minus what it consumed, so no join over
2381/// `tollgate_leases` is needed at every publication to answer the same
2382/// number.
2383///
2384/// Reads the columns of the `FOR SHARE` row in `publish_snapshot`, positions 1
2385/// through 9. Corruption is reported rather than saturated: unlike
2386/// `MemoryStore`, this backend does not exclusively own the ledger it reads,
2387/// so an underflow here is the same class of event as a negative unit column
2388/// (INVARIANTS.md GL-11) — see the note in `PostgresStore::conservation`.
2389fn budget_view(row: &sqlx::postgres::PgRow) -> Result<BudgetView, StoreError> {
2390    let deposited = to_units(row.get::<i64, _>(1), "deposited")?;
2391    let overage = to_units(row.get::<i64, _>(2), "overage_recorded")?;
2392    let usage = to_units(row.get::<i64, _>(3), "usage_recorded")?;
2393    let loss = to_units(row.get::<i64, _>(4), "settlement_loss")?;
2394    let expired = to_units(row.get::<i64, _>(5), "expired")?;
2395    let schedule = decode_schedule(row.get(6), row.get(7), row.get(8))?;
2396    let period_start = micros_ts(row.get::<i64, _>(9), "period_start_us")?;
2397
2398    let funded = deposited
2399        .checked_add(overage)
2400        .ok_or_else(|| StoreError("account funding total overflows".into()))?;
2401    let consumed = usage
2402        .checked_add(loss)
2403        .and_then(|spent| spent.checked_add(expired))
2404        .ok_or_else(|| StoreError("account consumption total overflows".into()))?;
2405    Ok(BudgetView {
2406        balance_at_publish: funded.checked_sub(consumed).ok_or_else(|| {
2407            StoreError(format!(
2408                "consumption {} exceeds funding {}",
2409                consumed.get(),
2410                funded.get()
2411            ))
2412        })?,
2413        // The period the account is *in*, which is what it can spend against.
2414        // An account whose schedule was set but whose first rollover has not
2415        // run yet is still in its previous period, and says so, until the next
2416        // sweep tick moves it.
2417        period_end: schedule.map(|schedule| schedule.period.end_after(period_start)),
2418    })
2419}
2420
2421/// Decode one stored snapshot row into the publication proof.
2422///
2423/// Shared by `SnapshotSource::snapshot` and the status republish so the latter
2424/// reads back what it wrote through exactly the reader's path -- a row this
2425/// refuses is one the request path would refuse too, and finding that out at
2426/// write time is the point.
2427///
2428/// Since GL-54 that is literally true of the generation as well: both callers
2429/// hand this the row's `generation` column, so a live read and a republish
2430/// resolve it identically. Before, the JSON carried a second copy that only the
2431/// live path consulted.
2432fn decode_publishable(
2433    principal: Principal,
2434    generation: i64,
2435    value: serde_json::Value,
2436) -> Result<PublishableSnapshot, StoreError> {
2437    let generation = generation_from(generation)?;
2438    let snapshot: StoredSnapshot =
2439        serde_json::from_value(value).map_err(|e| StoreError(format!("snapshot decode: {e}")))?;
2440    // Carried across the rebuild rather than through the builder: the builder
2441    // has no setter for a budget on purpose, so that a publisher cannot supply
2442    // one. Re-attaching what this store itself wrote is the store writing it
2443    // again, which is the same rule and not an exception to it.
2444    let budget = snapshot.budget;
2445    let publishable = PublishableSnapshot::try_new(Arc::new(snapshot.into_snapshot(generation)))
2446        .map_err(|error| {
2447            StoreError(format!(
2448                "invalid stored snapshot for principal {:#034x}: {error}",
2449                principal.0
2450            ))
2451        })?;
2452    Ok(match budget {
2453        Some(budget) => publishable.with_budget(Some(budget)),
2454        None => publishable,
2455    })
2456}
2457
2458/// Read a stored generation column.
2459///
2460/// Takes the raw `i64` and converts here rather than at each call site, so
2461/// every caller shares one refusal. That matters for the republish
2462/// specifically: it must not fail an account-wide status change over one
2463/// unreadable row, and a caller-side conversion is one `?` away from doing
2464/// exactly that.
2465///
2466/// A negative value is corruption to surface, never to clamp (INVARIANTS GL-11).
2467/// Migration 0007's CHECK is what keeps it from being written in the first
2468/// place; this is the read-side backstop.
2469fn generation_from(column: i64) -> Result<Generation, StoreError> {
2470    u64::try_from(column)
2471        .map(Generation)
2472        .map_err(|_| StoreError("stored snapshot generation is negative".into()))
2473}
2474
2475#[async_trait]
2476impl SnapshotSource for PostgresStore {
2477    async fn snapshot(&self, principal: Principal) -> Result<SnapshotResolution, StoreError> {
2478        let row = sqlx::query(
2479            "SELECT generation, snapshot, deleted FROM tollgate_snapshots WHERE principal = $1",
2480        )
2481        .bind(id_bytes(principal.0))
2482        .fetch_optional(&self.pool)
2483        .await
2484        .map_err(storage)?;
2485        match row {
2486            // Both branches now resolve the generation from the same column.
2487            // They did not before: the tombstone read it here while a live read
2488            // decoded a second copy out of the JSON, so one row could answer
2489            // two different generations depending on which branch you reached
2490            // (GL-54).
2491            Some(row) if row.get::<bool, _>(2) => Ok(SnapshotResolution::Revoked {
2492                generation: generation_from(row.get::<i64, _>(0))?,
2493            }),
2494            Some(row) => Ok(SnapshotResolution::Present(decode_publishable(
2495                principal,
2496                row.get::<i64, _>(0),
2497                row.get(1),
2498            )?)),
2499            None => Ok(SnapshotResolution::Unknown),
2500        }
2501    }
2502
2503    fn subscribe(&self) -> broadcast::Receiver<SnapshotPush> {
2504        self.push.subscribe()
2505    }
2506
2507    /// Tombstones included, for the reason `MemoryStore::principals` gives:
2508    /// forgetting a revoked principal is how one gets resurrected.
2509    ///
2510    /// A primary-key scan, so no new index — the table holds one row per
2511    /// principal, not per request, and `ORDER BY` makes the result stable so
2512    /// a caller diffing two enumerations sees real changes rather than
2513    /// storage order.
2514    async fn principals(&self) -> Result<Option<Vec<Principal>>, StoreError> {
2515        let rows = sqlx::query("SELECT principal FROM tollgate_snapshots ORDER BY principal")
2516            .fetch_all(&self.pool)
2517            .await
2518            .map_err(storage)?;
2519        Ok(Some(
2520            rows.iter()
2521                .map(|row| Principal(id_from(row.get::<Vec<u8>, _>(0).as_slice())))
2522                .collect(),
2523        ))
2524    }
2525}
2526
2527#[async_trait]
2528impl StoreHealth for PostgresStore {
2529    async fn ping(&self) -> Result<(), StoreError> {
2530        sqlx::query("SELECT 1")
2531            .execute(&self.pool)
2532            .await
2533            .map_err(storage)?;
2534        Ok(())
2535    }
2536}
2537
2538/// Re-stamp every live snapshot of `account`, patching one JSON key, and
2539/// report which principals to push and how many rows could not be decoded.
2540///
2541/// Shared by the two account-owned facts that republish — status (GL-51) and
2542/// execution-capacity class (GL-99). They differ in their precondition and their
2543/// ledger column; everything below is identical, and it is the part where the
2544/// subtlety lives, so it is written once.
2545///
2546/// `jsonb_set` rather than read-modify-write in Rust, for three reasons any
2547/// one of which decides it:
2548///
2549/// 1. RMW reintroduces GL-51's own bug. A concurrent `publish_snapshot` landing
2550///    between the read and the write makes `generation + 1` no longer greater
2551///    than stored, and the monotonic guard then *silently drops the change*
2552///    for that principal.
2553/// 2. RMW loses fields. `StoredSnapshot` has no `flatten`, so decoding and
2554///    re-serialising a row written by a newer binary discards what this one
2555///    does not know about.
2556/// 3. RMW fails whole on one bad row. A safety operation must not be blockable
2557///    by one unrelated corrupt credential.
2558///
2559/// Only the named key is patched. The generation lives in the column alone
2560/// (GL-54), and `RETURNING generation` carries the new value out to the push.
2561///
2562/// `deleted = FALSE` leaves tombstones alone: republishing one would resurrect
2563/// a revoked principal (INVARIANTS.md GL-15), and revocation stays its own
2564/// per-credential mechanism. `IS DISTINCT FROM` makes a repeat converge,
2565/// bumping nothing — and note that a document predating the key has SQL NULL
2566/// there, so the first change of a newly added fact rewrites every row once.
2567async fn republish_patched_snapshots(
2568    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
2569    account: AccountId,
2570    json_path: &'static str,
2571    value: &str,
2572) -> Result<(Vec<(Principal, PublishableSnapshot)>, usize), SetStatusError> {
2573    let rows = sqlx::query(
2574        "UPDATE tollgate_snapshots
2575            SET generation = generation + 1,
2576                snapshot   = jsonb_set(snapshot, $3::text[], to_jsonb($2::text))
2577          WHERE account_id = $1
2578            AND deleted = FALSE
2579            AND snapshot #>> $3::text[] IS DISTINCT FROM $2::text
2580        RETURNING principal, generation, snapshot",
2581    )
2582    .bind(id_bytes(account.0))
2583    .bind(value)
2584    .bind(json_path)
2585    .fetch_all(&mut **tx)
2586    .await
2587    .map_err(storage)?;
2588
2589    let mut republished = Vec::with_capacity(rows.len());
2590    let mut unreadable = 0usize;
2591    for row in rows {
2592        let principal = Principal(id_from(row.get::<Vec<u8>, _>(0).as_slice()));
2593        match decode_publishable(
2594            principal,
2595            row.get::<i64, _>(1),
2596            row.get::<serde_json::Value, _>(2),
2597        ) {
2598            Ok(snapshot) => republished.push((principal, snapshot)),
2599            // Skipped for push, not fatal: this row was already unreadable
2600            // before the change touched it, and refusing to suspend or
2601            // reclassify an account because one of its credentials is corrupt
2602            // is the worse outcome. Reported, never silent (INVARIANTS.md
2603            // GL-19). Counted as well as logged: the row changed durably but
2604            // will not be pushed, so those principals converge only at their
2605            // next refresh.
2606            Err(error) => {
2607                unreadable += 1;
2608                tracing::warn!(
2609                    %principal,
2610                    %error,
2611                    "restamped snapshot could not be decoded for push"
2612                );
2613            }
2614        }
2615    }
2616    // Ordered, so both backends emit the same sequence and a mirrored test
2617    // need not assert on incidental ordering.
2618    republished.sort_unstable_by_key(|(principal, _)| *principal);
2619    Ok((republished, unreadable))
2620}
2621#[async_trait]
2622impl AdminStore for PostgresStore {
2623    async fn create_account(
2624        &self,
2625        config: AccountConfig,
2626    ) -> Result<tollgate_store::AdminReceipt<()>, CreateAccountError> {
2627        let result = sqlx::query(
2628            "INSERT INTO tollgate_accounts
2629             (account_id, balance, deposited, status, capacity_class, next_fence,
2630              usage_recorded, settlement_loss, overage_recorded)
2631             VALUES ($1, $2, $2, $3, $4, 1, 0, 0, 0)
2632             ON CONFLICT (account_id) DO NOTHING",
2633        )
2634        .bind(id_bytes(config.account_id.0))
2635        .bind(to_i64(config.initial_balance, "balance").map_err(CreateAccountError::Storage)?)
2636        .bind(config.status.as_str())
2637        .bind(config.capacity_class.as_str())
2638        .execute(&self.pool)
2639        .await
2640        .map_err(|e| CreateAccountError::Storage(storage(e)))?;
2641        if result.rows_affected() == 0 {
2642            return Err(CreateAccountError::AlreadyExists);
2643        }
2644        Ok(AdminReceipt::new(
2645            (),
2646            AdminState::Absent,
2647            AdminState::AccountCreated {
2648                initial_balance: config.initial_balance,
2649                status: config.status,
2650                capacity_class: config.capacity_class,
2651            },
2652        ))
2653    }
2654
2655    async fn deposit(
2656        &self,
2657        account: AccountId,
2658        units: CostUnits,
2659    ) -> Result<AdminReceipt<()>, AllocateError> {
2660        let row = sqlx::query(
2661            "UPDATE tollgate_accounts
2662             SET balance = balance + $2, deposited = deposited + $2
2663             WHERE account_id = $1
2664             RETURNING balance - $2 AS old_topup, deposited - $2 AS old_deposited,
2665                       balance AS new_topup, deposited AS new_deposited",
2666        )
2667        .bind(id_bytes(account.0))
2668        .bind(to_i64(units, "deposit").map_err(AllocateError::Storage)?)
2669        .fetch_optional(&self.pool)
2670        .await
2671        .map_err(alloc_storage)?
2672        .ok_or(AllocateError::UnknownAccount)?;
2673        let state = |topup: &str, deposited: &str| -> Result<AdminState, StoreError> {
2674            Ok(AdminState::Funding {
2675                topup: to_units(row.get(topup), "audit topup")?,
2676                deposited: to_units(row.get(deposited), "audit deposited")?,
2677            })
2678        };
2679        Ok(AdminReceipt::new(
2680            (),
2681            state("old_topup", "old_deposited")?,
2682            state("new_topup", "new_deposited")?,
2683        ))
2684    }
2685
2686    async fn set_budget_schedule(
2687        &self,
2688        account: AccountId,
2689        schedule: Option<BudgetSchedule>,
2690    ) -> Result<AdminReceipt<()>, BudgetError> {
2691        // No deposit here. Were setting a schedule also a funding operation,
2692        // an operator correcting a mistyped allowance would fund the account
2693        // twice, and there would be no way to describe next month's budget
2694        // without paying it today. The first allowance arrives at the first
2695        // `roll_period` after this lands.
2696        //
2697        // All three columns are written together, NULL together, which is what
2698        // the row's all-or-nothing CHECK enforces: half a schedule satisfies
2699        // neither branch of the rollover.
2700        let allowance = schedule
2701            .map(|s| to_i64(s.allowance, "budget allowance"))
2702            .transpose()
2703            .map_err(BudgetError::Storage)?;
2704        let mut tx = self.pool.begin().await.map_err(storage)?;
2705        let result = async {
2706            // A self-join's `previous` alias retains its statement snapshot
2707            // after waiting for a writer. Lock first so the receipt observes
2708            // the committed predecessor, then update under that same lock.
2709            let row = sqlx::query(
2710                "SELECT budget_allowance, budget_period, budget_rollover
2711                 FROM tollgate_accounts WHERE account_id = $1 FOR UPDATE",
2712            )
2713            .bind(id_bytes(account.0))
2714            .fetch_optional(&mut *tx)
2715            .await
2716            .map_err(storage)?
2717            .ok_or(BudgetError::UnknownAccount)?;
2718            let before = decode_schedule(row.get(0), row.get(1), row.get(2))?;
2719            sqlx::query(
2720                "UPDATE tollgate_accounts
2721                 SET budget_allowance = $2, budget_period = $3, budget_rollover = $4
2722                 WHERE account_id = $1",
2723            )
2724            .bind(id_bytes(account.0))
2725            .bind(allowance)
2726            .bind(schedule.map(|s| s.period.as_str()))
2727            .bind(schedule.map(|s| s.rollover.as_str()))
2728            .execute(&mut *tx)
2729            .await
2730            .map_err(storage)?;
2731            Ok(AdminReceipt::new(
2732                (),
2733                AdminState::Budget { schedule: before },
2734                AdminState::Budget { schedule },
2735            ))
2736        }
2737        .await;
2738        finish_transaction(tx, result).await
2739    }
2740
2741    async fn account_view(&self, account: AccountId) -> Result<Option<AccountView>, StoreError> {
2742        // One `REPEATABLE READ, READ ONLY` snapshot over the account row and
2743        // its live leases, for the reason `conservation` takes one (GL-56): the
2744        // stored totals and the sums over active leases move together in a
2745        // single `ingest` transaction, so reading them under separate
2746        // snapshots can pair a pre-write total with a post-write sum and
2747        // report corruption on a correct ledger. Status and schedule join that
2748        // same snapshot here, so a view cannot straddle a suspension or a
2749        // rollover and describe a state the account was never in.
2750        let mut tx = self.pool.begin().await.map_err(storage)?;
2751        let result = async {
2752            sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
2753                .execute(&mut *tx)
2754                .await
2755                .map_err(storage)?;
2756            let account_row = sqlx::query(
2757                "SELECT deposited, balance, usage_recorded, settlement_loss, overage_recorded,
2758                        expired, status, capacity_class, budget_allowance, budget_period,
2759                        budget_rollover, period_start_us
2760                 FROM tollgate_accounts WHERE account_id = $1",
2761            )
2762            .bind(id_bytes(account.0))
2763            .fetch_optional(&mut *tx)
2764            .await
2765            .map_err(storage)?;
2766            let lease_row = sqlx::query(ACTIVE_LEASE_SUM_SQL)
2767                .bind(id_bytes(account.0))
2768                .fetch_one(&mut *tx)
2769                .await
2770                .map_err(storage)?;
2771            Ok::<_, StoreError>((account_row, lease_row))
2772        }
2773        .await;
2774        let (account_row, lease_row) = finish_transaction(tx, result).await?;
2775
2776        let Some(row) = account_row else {
2777            return Ok(None);
2778        };
2779        let active_grants = to_units(lease_row.get::<i64, _>(0), "active lease grants")?;
2780        let active_used = to_units(lease_row.get::<i64, _>(1), "active lease usage")?;
2781        let recorded = to_units(row.get::<i64, _>(2), "usage_recorded")?;
2782        Ok(Some(AccountView {
2783            account_id: account,
2784            status: decode_status(row.get::<String, _>(6))?,
2785            capacity_class: decode_capacity_class(row.get::<String, _>(7))?,
2786            schedule: decode_schedule(
2787                row.get::<Option<i64>, _>(8),
2788                row.get::<Option<String>, _>(9),
2789                row.get::<Option<String>, _>(10),
2790            )?,
2791            period_start: StoredInstant {
2792                micros: row.get::<i64, _>(11),
2793                submicro_nanos: 0,
2794            }
2795            .timestamp()?,
2796            conservation: Conservation {
2797                deposited: to_units(row.get::<i64, _>(0), "deposited")?,
2798                overage_recorded: to_units(row.get::<i64, _>(4), "overage_recorded")?,
2799                balance: to_units(row.get::<i64, _>(1), "balance")?,
2800                active_lease_grants: active_grants,
2801                // Surfaced rather than panicked on, as `conservation` does:
2802                // two stored columns from a database this process does not
2803                // exclusively own, so this is corruption to report.
2804                settled_usage: recorded.checked_sub(active_used).ok_or_else(|| {
2805                    StoreError(format!(
2806                        "active lease usage {} exceeds recorded usage {} for account {account}",
2807                        active_used.get(),
2808                        recorded.get()
2809                    ))
2810                })?,
2811                settlement_loss: to_units(row.get::<i64, _>(3), "settlement_loss")?,
2812                expired: to_units(row.get::<i64, _>(5), "expired")?,
2813            },
2814        }))
2815    }
2816
2817    async fn roll_due_periods(
2818        &self,
2819        now: Timestamp,
2820        limit: NonZeroUsize,
2821    ) -> Result<RolloverBatch, StoreError> {
2822        let limit_i = i64::try_from(limit.get())
2823            .map_err(|_| StoreError(format!("rollover batch limit exceeds i64 range: {limit}")))?;
2824        let mut rolled = Vec::new();
2825        // One statement per period kind, because the boundary is a property of
2826        // the period: a weekly schedule and a monthly one are due at different
2827        // instants, and a single comparison could only be right for one of
2828        // them. The exhaustive match in `every_period_is_swept` is what makes
2829        // a new variant a compile error here rather than a silently unswept
2830        // schedule.
2831        for period in Period::ALL {
2832            let boundary_us = ts_micros(period.start_of(now));
2833            let remaining = limit_i
2834                - i64::try_from(rolled.len())
2835                    .map_err(|_| StoreError("rollover batch row count exceeds i64 range".into()))?;
2836            if remaining <= 0 {
2837                break;
2838            }
2839            // One statement, so the selection and the crossing cannot be
2840            // separated. `FOR UPDATE SKIP LOCKED` is what lets replicas
2841            // cooperate: a concurrent pass skips the rows this one holds, and
2842            // once this commits their `period_start_us < boundary` test is
2843            // false — so a boundary is crossed exactly once however many
2844            // passes race it.
2845            //
2846            // The prior `allowance_balance` is read in the CTE because the
2847            // UPDATE cannot return it: PostgreSQL's RETURNING sees the new row
2848            // only, and `expired` has to be reported as the delta it is.
2849            let rows = sqlx::query(DUE_PERIODS_SQL)
2850                .bind(period.as_str())
2851                .bind(boundary_us)
2852                .bind(remaining)
2853                .fetch_all(&self.pool)
2854                .await
2855                .map_err(storage)?;
2856
2857            for row in rows {
2858                rolled.push((
2859                    row.get::<i64, _>(3),
2860                    RolledAccount {
2861                        account_id: AccountId(id_from(&row.get::<Vec<u8>, _>(0))),
2862                        deposited: to_units(row.get::<i64, _>(1), "budget allowance")?,
2863                        expired: to_units(row.get::<i64, _>(2), "expiring allowance")?,
2864                    },
2865                ));
2866            }
2867        }
2868        // Oldest boundary first, account id breaking ties, which is the order
2869        // `MemoryStore` reports and therefore the one this backend owes. The
2870        // rows arrive unordered from `RETURNING` and, across more than one
2871        // period kind, in per-period groups; sorting the assembled page is what
2872        // makes the report a function of the stored state rather than of the
2873        // planner. Bounded by `limit`, not by how many accounts are due.
2874        rolled
2875            .sort_unstable_by_key(|(crossed_from, account)| (*crossed_from, account.account_id.0));
2876        RolloverBatch::try_new(
2877            rolled.into_iter().map(|(_, account)| account).collect(),
2878            limit,
2879        )
2880    }
2881
2882    async fn set_account_status(
2883        &self,
2884        account: AccountId,
2885        status: AccountStatus,
2886    ) -> Result<tollgate_store::AdminReceipt<StatusChange>, SetStatusError> {
2887        let mut tx = self.pool.begin().await.map_err(storage)?;
2888        let result = async {
2889            // The account row first, and its lock is the serialization point:
2890            // two concurrent status changes cannot interleave their snapshot
2891            // updates, so "ledger says Active, snapshots say Suspended" is
2892            // unrepresentable rather than merely unlikely.
2893            let row = sqlx::query(
2894                "SELECT status FROM tollgate_accounts WHERE account_id = $1 FOR UPDATE",
2895            )
2896            .bind(id_bytes(account.0))
2897            .fetch_optional(&mut *tx)
2898            .await
2899            .map_err(storage)?
2900            .ok_or(SetStatusError::UnknownAccount)?;
2901
2902            let before = decode_status(row.get::<String, _>(0))?;
2903            if before == AccountStatus::Closed && status != AccountStatus::Closed {
2904                // Terminal, and the refusal changes nothing: no ledger write,
2905                // no generation bump. Rolling back here is what makes that so.
2906                return Err(SetStatusError::AccountClosed);
2907            }
2908
2909            sqlx::query("UPDATE tollgate_accounts SET status = $2 WHERE account_id = $1")
2910                .bind(id_bytes(account.0))
2911                .bind(status.as_str())
2912                .execute(&mut *tx)
2913                .await
2914                .map_err(storage)?;
2915
2916            let (republished, unreadable) =
2917                republish_patched_snapshots(&mut tx, account, "{status}", status.as_str()).await?;
2918            Ok((before, republished, unreadable))
2919        }
2920        .await;
2921
2922        let (before, republished, unreadable) = finish_transaction(tx, result).await?;
2923        // Publish only after commit, as with snapshot publication itself.
2924        if pushes_exceed_capacity(republished.len()) {
2925            tracing::warn!(
2926                %account,
2927                principals = republished.len(),
2928                capacity = PUSH_CHANNEL_CAPACITY,
2929                "status change emitted more pushes than the channel holds; subscribers will resync"
2930            );
2931        }
2932        let count = republished.len();
2933        for (principal, snapshot) in republished {
2934            self.push_to_subscribers(SnapshotPush {
2935                principal,
2936                resolution: SnapshotResolution::Present(snapshot),
2937            });
2938        }
2939        Ok(AdminReceipt::new(
2940            StatusChange {
2941                // Rows that changed durably: the ones pushed, plus any that could
2942                // not be decoded to push. The caller is told both numbers.
2943                republished: count + unreadable,
2944                unreadable,
2945            },
2946            AdminState::Status { status: before },
2947            AdminState::Status { status },
2948        ))
2949    }
2950
2951    async fn set_capacity_class(
2952        &self,
2953        account: AccountId,
2954        class: CapacityClass,
2955    ) -> Result<tollgate_store::AdminReceipt<StatusChange>, SetStatusError> {
2956        let mut tx = self.pool.begin().await.map_err(storage)?;
2957        let result = async {
2958            // `FOR UPDATE` for the reason `set_account_status` takes it: the
2959            // account row's lock is the serialization point, so two concurrent
2960            // class changes cannot interleave their snapshot updates and leave
2961            // the ledger saying one thing while some snapshots say another.
2962            // It also serialises against a concurrent status change, which is
2963            // what keeps the two account-owned facts from racing each other's
2964            // republications.
2965            let row = sqlx::query(
2966                "SELECT status, capacity_class FROM tollgate_accounts WHERE account_id = $1 FOR UPDATE",
2967            )
2968            .bind(id_bytes(account.0))
2969            .fetch_optional(&mut *tx)
2970            .await
2971            .map_err(storage)?
2972            .ok_or(SetStatusError::UnknownAccount)?;
2973
2974            let before = decode_capacity_class(row.get::<String, _>(1))?;
2975            // Closed is terminal, so reclassifying is meaningless. Unlike a
2976            // status change there is no "already at the target" escape: every
2977            // class is equally meaningless on a closed account. Rolling back
2978            // here is what makes the refusal change nothing.
2979            if decode_status(row.get::<String, _>(0))? == AccountStatus::Closed {
2980                return Err(SetStatusError::AccountClosed);
2981            }
2982
2983            sqlx::query("UPDATE tollgate_accounts SET capacity_class = $2 WHERE account_id = $1")
2984                .bind(id_bytes(account.0))
2985                .bind(class.as_str())
2986                .execute(&mut *tx)
2987                .await
2988                .map_err(storage)?;
2989
2990            let (republished, unreadable) =
2991                republish_patched_snapshots(&mut tx, account, "{capacity_class}", class.as_str()).await?;
2992            Ok((before, republished, unreadable))
2993        }
2994        .await;
2995
2996        let (before, republished, unreadable) = finish_transaction(tx, result).await?;
2997        if pushes_exceed_capacity(republished.len()) {
2998            tracing::warn!(
2999                %account,
3000                principals = republished.len(),
3001                capacity = PUSH_CHANNEL_CAPACITY,
3002                "capacity class change emitted more pushes than the channel holds; \
3003                 subscribers will resync"
3004            );
3005        }
3006        let count = republished.len();
3007        for (principal, snapshot) in republished {
3008            self.push_to_subscribers(SnapshotPush {
3009                principal,
3010                resolution: SnapshotResolution::Present(snapshot),
3011            });
3012        }
3013        Ok(AdminReceipt::new(
3014            StatusChange {
3015                // Rows that changed durably, whether or not they could be decoded
3016                // for a push — the same accounting `set_account_status` reports.
3017                republished: count + unreadable,
3018                unreadable,
3019            },
3020            AdminState::CapacityClass {
3021                capacity_class: before,
3022            },
3023            AdminState::CapacityClass {
3024                capacity_class: class,
3025            },
3026        ))
3027    }
3028
3029    async fn publish_snapshot(
3030        &self,
3031        principal: Principal,
3032        snapshot: PublishableSnapshot,
3033    ) -> Result<tollgate_store::AdminReceipt<()>, PublishSnapshotError> {
3034        let generation = i64::try_from(snapshot.generation.0).map_err(|_| {
3035            StoreError("snapshot generation exceeds PostgreSQL BIGINT range".into())
3036        })?;
3037
3038        let mut tx = self.pool.begin().await.map_err(storage)?;
3039        let result = publish_in_tx(&mut tx, principal, generation, snapshot).await;
3040
3041        let (written, published, before, after) = finish_transaction(tx, result).await?;
3042        if written {
3043            // The stamped snapshot, not the submitted one: a subscriber must
3044            // receive exactly what was stored, or a pushed instance and a
3045            // pulling one would disagree about the account's balance.
3046            self.push_to_subscribers(SnapshotPush {
3047                principal,
3048                resolution: SnapshotResolution::Present(published),
3049            });
3050        }
3051        Ok(AdminReceipt::new((), before, after))
3052    }
3053
3054    async fn remove_snapshot(&self, principal: Principal) -> Result<AdminReceipt<()>, StoreError> {
3055        let mut tx = self.pool.begin().await.map_err(storage)?;
3056        let result = remove_in_tx(&mut tx, principal).await;
3057        let receipt = finish_transaction(tx, result).await?;
3058        self.announce_removal(principal, &receipt);
3059        Ok(receipt)
3060    }
3061}
3062
3063/// Resolve `account`'s credential `key` to its principal and retirement,
3064/// holding the credential row `FOR SHARE` until the transaction ends (GL-143).
3065///
3066/// `FOR SHARE` conflicts with `revoke_key_audited`'s `FOR UPDATE`, so a
3067/// key-bound publication and a revocation serialize: the publish either sees
3068/// the retirement and is refused, or commits first.
3069///
3070/// **Lock order: credential, then account, then snapshot.** A cycle needs a
3071/// path that holds an account lock and then waits on a credential row lock.
3072/// None does:
3073/// - `revoke_key_audited` locks only the credential row, never an account.
3074/// - `insert_credential` takes the account `FOR UPDATE`, then inserts a *new*
3075///   credential row; it never locks an existing one.
3076/// - `ingest` locks accounts `FOR UPDATE`, then reads credentials with a plain
3077///   `SELECT` and writes activity rows whose foreign key takes only
3078///   `FOR KEY SHARE`, which `FOR SHARE` does not block.
3079/// - Every other account-locking path — `set_account_status`,
3080///   `set_capacity_class`, `set_budget_schedule`, `deposit`, rollover,
3081///   reclaim and lease acquisition — touches account, lease, usage and
3082///   snapshot rows only; `tollgate_credential_keys` appears in none of them.
3083///
3084/// After the credential row, this path takes the account and snapshot rows in
3085/// the same order `publish_snapshot` and `set_account_status` do.
3086async fn lock_account_key(
3087    tx: &mut Transaction<'_, Postgres>,
3088    account: AccountId,
3089    key: KeyId,
3090) -> Result<(Principal, bool), KeySnapshotError> {
3091    let row = sqlx::query(
3092        "SELECT principal, revoked_at_us IS NOT NULL
3093         FROM tollgate_credential_keys WHERE key_id = $1 AND account_id = $2 FOR SHARE",
3094    )
3095    .bind(id_bytes(key.0))
3096    .bind(id_bytes(account.0))
3097    .fetch_optional(&mut **tx)
3098    .await
3099    .map_err(storage)?
3100    .ok_or(KeySnapshotError::UnknownCredential)?;
3101    let principal: Vec<u8> = row.get(0);
3102    let principal: [u8; 16] = principal
3103        .try_into()
3104        .map_err(|_| StoreError("credential principal is not 16 bytes".into()))?;
3105    Ok((Principal(u128::from_be_bytes(principal)), row.get(1)))
3106}
3107
3108/// A publication's checks and write inside the caller's transaction: the
3109/// stated credential binding (GL-35), the ledger status and capacity-class
3110/// guards (GL-51, GL-99), the budget stamp (GL-97), and the generation-ordered
3111/// write. Returns whether a row was written, the stamped snapshot to push
3112/// after commit, and the audited predecessor and successor.
3113async fn publish_in_tx(
3114    tx: &mut Transaction<'_, Postgres>,
3115    principal: Principal,
3116    generation: i64,
3117    snapshot: PublishableSnapshot,
3118) -> Result<(bool, PublishableSnapshot, AdminState, AdminState), PublishSnapshotError> {
3119    if let Some(key_id) = snapshot.key_id {
3120        let matches: bool = sqlx::query_scalar(
3121            "SELECT EXISTS(SELECT 1 FROM tollgate_credential_keys
3122                 WHERE key_id = $1 AND principal = $2 AND account_id = $3)",
3123        )
3124        .bind(id_bytes(key_id.0))
3125        .bind(id_bytes(principal.0))
3126        .bind(id_bytes(snapshot.account_id.0))
3127        .fetch_one(&mut **tx)
3128        .await
3129        .map_err(storage)?;
3130        if !matches {
3131            return Err(PublishSnapshotError::CredentialMismatch { key_id });
3132        }
3133    }
3134    // The ledger decides an account's status; a publish may carry it
3135    // but not change it, or the two records `set_account_status`
3136    // unified could be pulled apart again one principal at a time
3137    // (GL-51). FOR SHARE, not FOR UPDATE: this only has to hold the
3138    // status still, and a status change takes FOR UPDATE on the same
3139    // row, so the two serialize without publishes blocking each other.
3140    //
3141    // The budget columns ride along on the read that was already being
3142    // taken, under the same lock, so the view stamped below is the
3143    // ledger as of this publication rather than a second read that
3144    // could straddle a lease or a rollover (GL-97).
3145    let ledger = sqlx::query(
3146        "SELECT status, deposited, overage_recorded, usage_recorded, settlement_loss,
3147                    expired, budget_allowance, budget_period, budget_rollover, period_start_us,
3148                    capacity_class
3149             FROM tollgate_accounts WHERE account_id = $1 FOR SHARE",
3150    )
3151    .bind(id_bytes(snapshot.account_id.0))
3152    .fetch_optional(&mut **tx)
3153    .await
3154    .map_err(storage)?;
3155
3156    // An account the ledger does not hold publishes unchanged: this
3157    // adds no account-existence requirement to publication, and an
3158    // unstamped snapshot reports no budget rather than a zero.
3159    // Unconditional, including the `None` arm: a snapshot arrives here
3160    // having crossed a wire, where nothing stops a publisher putting a
3161    // balance in the JSON. Overwriting always is what makes the store
3162    // the field's only writer, rather than only usually.
3163    let view = match &ledger {
3164        Some(row) => {
3165            let ledger = decode_status(row.get::<String, _>(0))?;
3166            if ledger != snapshot.status {
3167                return Err(PublishSnapshotError::StatusMismatch {
3168                    ledger,
3169                    submitted: snapshot.status,
3170                });
3171            }
3172            // The capacity class is the same kind of fact and gets the
3173            // same guard (GL-99): the ledger owns it, a publish may
3174            // carry it, and only `set_capacity_class` may change it.
3175            let ledger_class = decode_capacity_class(row.get::<String, _>(10))?;
3176            if ledger_class != snapshot.capacity_class {
3177                return Err(PublishSnapshotError::CapacityClassMismatch {
3178                    ledger: ledger_class,
3179                    submitted: snapshot.capacity_class,
3180                });
3181            }
3182            Some(budget_view(row)?)
3183        }
3184        None => None,
3185    };
3186    let published = snapshot.with_budget(view);
3187    let value = serde_json::to_value(StoredSnapshotRef::from(published.as_snapshot()))
3188        .map_err(|e| StoreError(format!("snapshot encode: {e}")))?;
3189
3190    let (written, before, after) = write_snapshot_audited(tx, principal, generation, value).await?;
3191    Ok((written, published, before, after))
3192}
3193
3194/// Tombstone a live snapshot inside the caller's transaction, keeping its
3195/// generation as the watermark.
3196async fn remove_in_tx(
3197    tx: &mut Transaction<'_, Postgres>,
3198    principal: Principal,
3199) -> Result<AdminReceipt<()>, StoreError> {
3200    let before = snapshot_audit_row(tx, principal).await?;
3201    let after = match before {
3202        AdminState::Snapshot {
3203            generation,
3204            revoked: false,
3205        } => {
3206            sqlx::query("UPDATE tollgate_snapshots SET deleted = TRUE WHERE principal = $1")
3207                .bind(id_bytes(principal.0))
3208                .execute(&mut **tx)
3209                .await
3210                .map_err(storage)?;
3211            AdminState::Snapshot {
3212                generation,
3213                revoked: true,
3214            }
3215        }
3216        state => state,
3217    };
3218    Ok(AdminReceipt::new((), before, after))
3219}
3220
3221/// Holding the snapshot row while both observing and replacing it makes the
3222/// receipt exact even when another server publishes or revokes concurrently.
3223async fn snapshot_audit_row(
3224    tx: &mut Transaction<'_, Postgres>,
3225    principal: Principal,
3226) -> Result<AdminState, StoreError> {
3227    let row = sqlx::query(
3228        "SELECT generation, deleted FROM tollgate_snapshots WHERE principal = $1 FOR UPDATE",
3229    )
3230    .bind(id_bytes(principal.0))
3231    .fetch_optional(&mut **tx)
3232    .await
3233    .map_err(storage)?;
3234    row.map(|row| {
3235        Ok(AdminState::Snapshot {
3236            generation: generation_from(row.get(0))?,
3237            revoked: row.get(1),
3238        })
3239    })
3240    .unwrap_or(Ok(AdminState::Absent))
3241}
3242
3243async fn write_snapshot_audited(
3244    tx: &mut Transaction<'_, Postgres>,
3245    principal: Principal,
3246    generation: i64,
3247    value: serde_json::Value,
3248) -> Result<(bool, AdminState, AdminState), StoreError> {
3249    let mut before = snapshot_audit_row(tx, principal).await?;
3250    let after = AdminState::Snapshot {
3251        generation: generation_from(generation)?,
3252        revoked: false,
3253    };
3254    if before == AdminState::Absent {
3255        let inserted = sqlx::query(
3256            "INSERT INTO tollgate_snapshots (principal, generation, snapshot, deleted)
3257            VALUES ($1, $2, $3, FALSE) ON CONFLICT (principal) DO NOTHING",
3258        )
3259        .bind(id_bytes(principal.0))
3260        .bind(generation)
3261        .bind(&value)
3262        .execute(&mut **tx)
3263        .await
3264        .map_err(storage)?;
3265        if inserted.rows_affected() == 1 {
3266            return Ok((true, before, after));
3267        }
3268        // Another creator won the unique constraint. READ COMMITTED sees its
3269        // row here; lock and observe that actual predecessor before replacing.
3270        before = snapshot_audit_row(tx, principal).await?;
3271    }
3272    let AdminState::Snapshot {
3273        generation: previous,
3274        ..
3275    } = before
3276    else {
3277        return Err(StoreError("snapshot disappeared during publication".into()));
3278    };
3279    if previous >= generation_from(generation)? {
3280        return Ok((false, before, before));
3281    }
3282    sqlx::query("UPDATE tollgate_snapshots SET generation = $2, snapshot = $3, deleted = FALSE WHERE principal = $1")
3283        .bind(id_bytes(principal.0)).bind(generation).bind(value)
3284        .execute(&mut **tx).await.map_err(storage)?;
3285    Ok((true, before, after))
3286}
3287
3288#[cfg(test)]
3289mod tests {
3290    use super::*;
3291
3292    #[test]
3293    fn stored_fences_use_the_exact_positive_bigint_domain() {
3294        for invalid in [i64::MIN, -1, 0] {
3295            assert!(stored_fence(invalid).is_err());
3296        }
3297        for valid in [1, 2, i64::MAX] {
3298            assert_eq!(stored_fence(valid).unwrap(), FencingToken(valid as u64));
3299        }
3300    }
3301
3302    /// A readiness probe is evidence that PostgreSQL answered, not merely that
3303    /// a store object exists (INVARIANTS.md GL-19).
3304    #[tokio::test]
3305    async fn ping_surfaces_a_closed_pool() {
3306        let pool = PgPoolOptions::new()
3307            .connect_lazy("postgres://localhost/tollgate")
3308            .unwrap();
3309        pool.close().await;
3310        let (push, _) = broadcast::channel(1);
3311        let store = PostgresStore {
3312            pool,
3313            policy: GrantPolicy::default(),
3314            push,
3315        };
3316
3317        assert!(store.ping().await.is_err());
3318    }
3319}