From 2bc3fb677b734d1f9d6a679d6c96234c131af255 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 1 Jul 2026 21:17:12 +0200 Subject: [PATCH] feat(group-quota): per-group shared-collection quotas (M3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Global env-var ceilings enforced in the group write path (mirrors the per-value caps): PICLOUD_GROUP_KV_MAX_ROWS / _DOCS_MAX_ROWS (per-group row count, checked only on a NEW key/doc — updates exempt) and _FILES_MAX_TOTAL_BYTES (per-group total blob bytes). New group_quota env helpers; count_rows/total_bytes repo methods (trait-default 0 for non-Postgres); QuotaExceeded error variants on the three group error enums; a with_max_rows test builder. Pinned by a group_kv_service unit test (3rd key rejected, update of an existing key at the cap allowed). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 3 + crates/manager-core/src/group_docs_repo.rs | 15 ++++ crates/manager-core/src/group_docs_service.rs | 12 +++ crates/manager-core/src/group_files_repo.rs | 17 ++++ .../manager-core/src/group_files_service.rs | 13 +++ crates/manager-core/src/group_kv_repo.rs | 16 ++++ crates/manager-core/src/group_kv_service.rs | 81 +++++++++++++++++-- crates/manager-core/src/group_quota.rs | 44 ++++++++++ crates/manager-core/src/lib.rs | 1 + crates/shared/src/group_docs.rs | 5 ++ crates/shared/src/group_files.rs | 5 ++ crates/shared/src/group_kv.rs | 6 ++ 12 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 crates/manager-core/src/group_quota.rs diff --git a/CLAUDE.md b/CLAUDE.md index e5561cd..82aea2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,6 +132,9 @@ Environment variables consumed by the `picloud` binary: | `PICLOUD_DOCS_MAX_VALUE_BYTES` | `262144` (256 KB) | Per-document JSON-encoded data cap for `docs::create`/`update`. | | `PICLOUD_PUBSUB_MAX_MESSAGE_BYTES` | `262144` (256 KB) | Per-message JSON-encoded payload cap for `pubsub::publish_durable`. Prevents one publish from amplifying into N outbox rows × M MB. | | `PICLOUD_QUEUE_MAX_PAYLOAD_BYTES` | `262144` (256 KB) | Per-message JSON-encoded payload cap for `queue::enqueue`. | +| `PICLOUD_GROUP_KV_MAX_ROWS` | `100000` | §11.6 M3 per-group quota: max total keys across a group's shared-KV collections. Checked only on a NEW key (`kv::shared_collection().set`); an update is exempt. | +| `PICLOUD_GROUP_DOCS_MAX_ROWS` | `100000` | §11.6 M3 per-group quota: max total docs across a group's shared-docs collections (`docs::shared_collection().create`). | +| `PICLOUD_GROUP_FILES_MAX_TOTAL_BYTES` | `10737418240` (10 GiB) | §11.6 M3 per-group quota: max total stored bytes across a group's shared-files collections (`files::shared_collection().create`). | ## Out of MVP diff --git a/crates/manager-core/src/group_docs_repo.rs b/crates/manager-core/src/group_docs_repo.rs index 9ac9bdf..c9ab2d4 100644 --- a/crates/manager-core/src/group_docs_repo.rs +++ b/crates/manager-core/src/group_docs_repo.rs @@ -81,6 +81,13 @@ pub trait GroupDocsRepo: Send + Sync { cursor: Option<&str>, limit: u32, ) -> Result; + + /// §11.6 quota: total doc count across the group's shared-docs collections. + /// Default `Ok(0)` so non-Postgres impls skip the quota check. + async fn count_rows(&self, group_id: GroupId) -> Result { + let _ = group_id; + Ok(0) + } } pub struct PostgresGroupDocsRepo { @@ -266,6 +273,14 @@ impl GroupDocsRepo for PostgresGroupDocsRepo { Ok(DocsListPage { docs, next_cursor }) } + + async fn count_rows(&self, group_id: GroupId) -> Result { + let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM group_docs WHERE group_id = $1") + .bind(group_id.into_inner()) + .fetch_one(&self.pool) + .await?; + Ok(u64::try_from(n).unwrap_or(0)) + } } fn encode_cursor(last_id: &Uuid) -> String { diff --git a/crates/manager-core/src/group_docs_service.rs b/crates/manager-core/src/group_docs_service.rs index b5430ec..83697f8 100644 --- a/crates/manager-core/src/group_docs_service.rs +++ b/crates/manager-core/src/group_docs_service.rs @@ -29,6 +29,9 @@ pub struct GroupDocsServiceImpl { resolver: Arc, authz: Arc, max_value_bytes: usize, + /// §11.6 per-group quota: max total docs across the group's shared-docs + /// collections (`PICLOUD_GROUP_DOCS_MAX_ROWS`). + max_rows: u64, /// §11.6: fires `shared = true` docs triggers on a shared-collection write. events: Arc, } @@ -59,6 +62,7 @@ impl GroupDocsServiceImpl { ) -> Self { Self { events: Arc::new(NoopEventEmitter), + max_rows: crate::group_quota::group_docs_max_rows_from_env(), repo, resolver, authz, @@ -189,6 +193,14 @@ impl GroupDocsService for GroupDocsServiceImpl { validate_data(&data)?; self.check_data_size(&data)?; self.check_write(cx, group_id).await?; + // §11.6 quota: a new doc must fit under the group's row ceiling. + let count = self.repo.count_rows(group_id).await?; + if count >= self.max_rows { + return Err(GroupDocsError::QuotaExceeded { + limit: usize::try_from(self.max_rows).unwrap_or(usize::MAX), + actual: usize::try_from(count).unwrap_or(usize::MAX), + }); + } let created = self.repo.create(group_id, collection, data.clone()).await?; self.emit_shared( cx, diff --git a/crates/manager-core/src/group_files_repo.rs b/crates/manager-core/src/group_files_repo.rs index 9778b1e..2fbddf4 100644 --- a/crates/manager-core/src/group_files_repo.rs +++ b/crates/manager-core/src/group_files_repo.rs @@ -114,6 +114,13 @@ pub trait GroupFilesRepo: Send + Sync { cursor: Option<&str>, limit: u32, ) -> Result; + + /// §11.6 quota: total stored bytes across the group's shared-files + /// collections. Default `Ok(0)` so non-Postgres impls skip the check. + async fn total_bytes(&self, group_id: GroupId) -> Result { + let _ = group_id; + Ok(0) + } } /// Filesystem-bytes + Postgres-metadata repo for group-shared files. @@ -369,6 +376,16 @@ impl GroupFilesRepo for FsGroupFilesRepo { }; Ok(FilesListPage { files, next_cursor }) } + + async fn total_bytes(&self, group_id: GroupId) -> Result { + let (n,): (i64,) = sqlx::query_as( + "SELECT COALESCE(SUM(size_bytes), 0)::bigint FROM group_files WHERE group_id = $1", + ) + .bind(group_id.into_inner()) + .fetch_one(&self.pool) + .await?; + Ok(u64::try_from(n).unwrap_or(0)) + } } #[derive(sqlx::FromRow)] diff --git a/crates/manager-core/src/group_files_service.rs b/crates/manager-core/src/group_files_service.rs index 6884a04..062fcd4 100644 --- a/crates/manager-core/src/group_files_service.rs +++ b/crates/manager-core/src/group_files_service.rs @@ -30,6 +30,9 @@ pub struct GroupFilesServiceImpl { resolver: Arc, authz: Arc, max_file_size_bytes: usize, + /// §11.6 per-group quota: max total stored bytes across the group's + /// shared-files collections (`PICLOUD_GROUP_FILES_MAX_TOTAL_BYTES`). + max_total_bytes: u64, /// §11.6: fires `shared = true` files triggers on a shared-collection write. events: Arc, } @@ -47,6 +50,7 @@ impl GroupFilesServiceImpl { resolver, authz, max_file_size_bytes, + max_total_bytes: crate::group_quota::group_files_max_total_bytes_from_env(), events: Arc::new(NoopEventEmitter), } } @@ -160,6 +164,15 @@ impl GroupFilesService for GroupFilesServiceImpl { // shape checks pass (same as app files, audit 2026-06-11 C-2). new.content_type = sanitize_stored_content_type(&new.content_type); self.check_write(cx, group_id).await?; + // §11.6 quota: the new file's bytes must fit under the group's total. + let used = self.repo.total_bytes(group_id).await?; + let incoming = new.data.len() as u64; + if used.saturating_add(incoming) > self.max_total_bytes { + return Err(GroupFilesError::QuotaExceeded { + limit: self.max_total_bytes, + actual: used.saturating_add(incoming), + }); + } let meta = self.repo.create(group_id, collection, new).await?; self.emit_shared(cx, group_id, "create", collection, &meta, None) .await; diff --git a/crates/manager-core/src/group_kv_repo.rs b/crates/manager-core/src/group_kv_repo.rs index 0968190..106fbd0 100644 --- a/crates/manager-core/src/group_kv_repo.rs +++ b/crates/manager-core/src/group_kv_repo.rs @@ -61,6 +61,13 @@ pub trait GroupKvRepo: Send + Sync { cursor: Option<&str>, limit: u32, ) -> Result; + + /// §11.6 quota: total key count across all of the group's shared-KV + /// collections. Default `Ok(0)` so non-Postgres impls skip the quota check. + async fn count_rows(&self, group_id: GroupId) -> Result { + let _ = group_id; + Ok(0) + } } pub struct PostgresGroupKvRepo { @@ -208,6 +215,15 @@ impl GroupKvRepo for PostgresGroupKvRepo { Ok(KvListPage { keys, next_cursor }) } + + async fn count_rows(&self, group_id: GroupId) -> Result { + let (n,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM group_kv_entries WHERE group_id = $1") + .bind(group_id.into_inner()) + .fetch_one(&self.pool) + .await?; + Ok(u64::try_from(n).unwrap_or(0)) + } } fn encode_cursor(last_key: &str) -> String { diff --git a/crates/manager-core/src/group_kv_service.rs b/crates/manager-core/src/group_kv_service.rs index da4946c..8bb96e3 100644 --- a/crates/manager-core/src/group_kv_service.rs +++ b/crates/manager-core/src/group_kv_service.rs @@ -13,11 +13,10 @@ //! (anonymous public scripts skip); writes use `script_gate_require_principal` //! (anonymous fails closed — shared mutation always needs an authenticated //! editor+ on the owning group). -//! 4. Per-value size cap (reuses `PICLOUD_KV_MAX_VALUE_BYTES`). -//! -//! No event emission in the MVP: a write to a shared collection has no single -//! app to attribute a trigger to (the "group trigger has no app to watch" -//! problem). Documented as a deferral in docs §11.6. +//! 4. Per-value size cap (reuses `PICLOUD_KV_MAX_VALUE_BYTES`) + a per-group +//! row quota (§11.6 M3, `PICLOUD_GROUP_KV_MAX_ROWS`). +//! 5. §11.6 M2 shared-collection triggers: a write fires `shared = true` +//! triggers on the owning group, under the writer app (`emit_shared`). use std::sync::Arc; @@ -40,6 +39,10 @@ pub struct GroupKvServiceImpl { resolver: Arc, authz: Arc, max_value_bytes: usize, + /// §11.6 per-group quota: max total keys across the group's shared-KV + /// collections (`PICLOUD_GROUP_KV_MAX_ROWS`). Checked only when inserting a + /// NEW key. + max_rows: u64, /// §11.6: fires `shared = true` triggers on a shared-collection write. /// Defaults to the noop emitter; the host wires the outbox emitter via /// [`Self::with_events`]. @@ -64,6 +67,14 @@ impl GroupKvServiceImpl { self } + /// Override the per-group row quota (§11.6). Mainly for tests; the host uses + /// the env-var default. + #[must_use] + pub fn with_max_rows(mut self, max_rows: u64) -> Self { + self.max_rows = max_rows; + self + } + #[must_use] pub fn with_max_value_bytes( repo: Arc, @@ -73,6 +84,7 @@ impl GroupKvServiceImpl { ) -> Self { Self { events: Arc::new(NoopEventEmitter), + max_rows: crate::group_quota::group_kv_max_rows_from_env(), repo, resolver, authz, @@ -196,6 +208,17 @@ impl GroupKvService for GroupKvServiceImpl { // per-app path — the has+set is non-transactional but triggers are // fire-and-forget). let existed = self.repo.has(group_id, collection, key).await?; + // §11.6 quota: a NEW key must fit under the group's row ceiling; an + // update of an existing key is net-zero and exempt. + if !existed { + let count = self.repo.count_rows(group_id).await?; + if count >= self.max_rows { + return Err(GroupKvError::QuotaExceeded { + limit: usize::try_from(self.max_rows).unwrap_or(usize::MAX), + actual: usize::try_from(count).unwrap_or(usize::MAX), + }); + } + } self.repo .set(group_id, collection, key, value.clone()) .await?; @@ -342,6 +365,11 @@ mod tests { next_cursor: None, }) } + + async fn count_rows(&self, group_id: GroupId) -> Result { + let data = self.data.lock().await; + Ok(data.keys().filter(|(g, _, _)| *g == group_id).count() as u64) + } } /// Maps `(app_id, lowercased name) -> owning group`. Anything absent is @@ -417,6 +445,49 @@ mod tests { ) } + #[tokio::test] + async fn per_group_row_quota_rejects_new_keys_but_allows_updates() { + // §11.6: a group's shared-KV store has a row ceiling; a NEW key over the + // cap is rejected, but updating an existing key (net-zero) still works. + let app = AppId::new(); + let group = GroupId::new(); + let mut resolver = FakeResolver::default(); + resolver.map.insert((app, "catalog".into()), group); + let kv = GroupKvServiceImpl::new( + Arc::new(InMemoryGroupKvRepo::default()), + Arc::new(resolver), + Arc::new(DenyingAuthzRepo), + ) + .with_max_rows(2); + let cx = cx_with(app, Some(owner())); + + kv.set(&cx, "catalog", "a", serde_json::json!(1)) + .await + .unwrap(); + kv.set(&cx, "catalog", "b", serde_json::json!(2)) + .await + .unwrap(); + // A third distinct key exceeds the cap. + let err = kv + .set(&cx, "catalog", "c", serde_json::json!(3)) + .await + .unwrap_err(); + assert!( + matches!( + err, + GroupKvError::QuotaExceeded { + limit: 2, + actual: 2 + } + ), + "got {err:?}" + ); + // Updating an existing key is exempt (net-zero). + kv.set(&cx, "catalog", "a", serde_json::json!(11)) + .await + .expect("update of an existing key must be allowed at the cap"); + } + #[tokio::test] async fn unrelated_app_gets_collection_not_shared() { // app_a's chain declares "catalog"; app_b's does not. diff --git a/crates/manager-core/src/group_quota.rs b/crates/manager-core/src/group_quota.rs new file mode 100644 index 0000000..74db1b7 --- /dev/null +++ b/crates/manager-core/src/group_quota.rs @@ -0,0 +1,44 @@ +//! §11.6 per-group quotas — global env-var ceilings on a group's shared +//! collections, enforced in the group write path (mirrors the per-value +//! `PICLOUD_*_MAX_*_BYTES` caps). One default applies to every group; per-group +//! configurable limits are deferred. +//! +//! - KV / docs: a per-group ROW-count ceiling (across the group's collections of +//! that kind). An update of an existing key/doc is net-zero and exempt. +//! - files: a per-group TOTAL-BYTES ceiling (sum of stored blob sizes). + +/// Default per-group shared-KV row ceiling. Override `PICLOUD_GROUP_KV_MAX_ROWS`. +pub const DEFAULT_GROUP_KV_MAX_ROWS: u64 = 100_000; +/// Default per-group shared-docs row ceiling. Override `PICLOUD_GROUP_DOCS_MAX_ROWS`. +pub const DEFAULT_GROUP_DOCS_MAX_ROWS: u64 = 100_000; +/// Default per-group shared-files total-bytes ceiling (10 GiB). Override +/// `PICLOUD_GROUP_FILES_MAX_TOTAL_BYTES`. +pub const DEFAULT_GROUP_FILES_MAX_TOTAL_BYTES: u64 = 10 * 1024 * 1024 * 1024; + +fn u64_from_env(var: &str, default: u64) -> u64 { + if let Ok(v) = std::env::var(var) { + match v.trim().parse::() { + Ok(n) if n > 0 => return n, + _ => tracing::warn!(value = %v, "ignoring invalid {var} (want a positive integer)"), + } + } + default +} + +#[must_use] +pub fn group_kv_max_rows_from_env() -> u64 { + u64_from_env("PICLOUD_GROUP_KV_MAX_ROWS", DEFAULT_GROUP_KV_MAX_ROWS) +} + +#[must_use] +pub fn group_docs_max_rows_from_env() -> u64 { + u64_from_env("PICLOUD_GROUP_DOCS_MAX_ROWS", DEFAULT_GROUP_DOCS_MAX_ROWS) +} + +#[must_use] +pub fn group_files_max_total_bytes_from_env() -> u64 { + u64_from_env( + "PICLOUD_GROUP_FILES_MAX_TOTAL_BYTES", + DEFAULT_GROUP_FILES_MAX_TOTAL_BYTES, + ) +} diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index fb2d39b..dda3c23 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -58,6 +58,7 @@ pub mod group_files_service; pub mod group_kv_repo; pub mod group_kv_service; pub mod group_members_repo; +pub mod group_quota; pub mod group_repo; pub mod group_scripts_api; pub mod groups_api; diff --git a/crates/shared/src/group_docs.rs b/crates/shared/src/group_docs.rs index 6af3c24..591942b 100644 --- a/crates/shared/src/group_docs.rs +++ b/crates/shared/src/group_docs.rs @@ -176,6 +176,11 @@ pub enum GroupDocsError { #[error("group docs: data too large ({actual} bytes; limit {limit})")] ValueTooLarge { limit: usize, actual: usize }, + /// The owning group's shared-docs store is at its row quota + /// (`PICLOUD_GROUP_DOCS_MAX_ROWS`); a new doc is rejected. + #[error("group docs: quota exceeded ({actual} rows; limit {limit})")] + QuotaExceeded { limit: usize, actual: usize }, + #[error("group docs backend error: {0}")] Backend(String), } diff --git a/crates/shared/src/group_files.rs b/crates/shared/src/group_files.rs index 8b5f03d..2bdeb2c 100644 --- a/crates/shared/src/group_files.rs +++ b/crates/shared/src/group_files.rs @@ -160,6 +160,11 @@ pub enum GroupFilesError { #[error("file too large: {size} bytes exceeds limit of {limit} bytes")] TooLarge { size: usize, limit: usize }, + /// The owning group's shared-files store is at its total-bytes quota + /// (`PICLOUD_GROUP_FILES_MAX_TOTAL_BYTES`); the write is rejected. + #[error("group files: quota exceeded ({actual} bytes; limit {limit})")] + QuotaExceeded { limit: u64, actual: u64 }, + #[error("file name too long: {0} bytes exceeds 255")] NameTooLong(usize), diff --git a/crates/shared/src/group_kv.rs b/crates/shared/src/group_kv.rs index 101fed7..253e191 100644 --- a/crates/shared/src/group_kv.rs +++ b/crates/shared/src/group_kv.rs @@ -135,6 +135,12 @@ pub enum GroupKvError { #[error("group kv: value too large ({actual} bytes; limit {limit})")] ValueTooLarge { limit: usize, actual: usize }, + /// The owning group's shared-KV store is at its row quota + /// (`PICLOUD_GROUP_KV_MAX_ROWS`); a new key is rejected (an update of an + /// existing key is exempt). + #[error("group kv: quota exceeded ({actual} rows; limit {limit})")] + QuotaExceeded { limit: usize, actual: usize }, + /// Anything else — Postgres unavailable, serialization failure, etc. #[error("group kv backend error: {0}")] Backend(String),