feat(modules): GroupDocsService + docs::shared_collection handle (§11.6 docs C3)

The runtime for shared group docs collections, mirroring the group-KV vertical
with the docs surface (filter parsing, JSON-object validation, value-size cap).

- shared: GroupDocsService trait + GroupDocsError (+ CollectionNotShared) +
  NoopGroupDocsService; group_docs field on Services + with_group_docs().
- manager-core: GroupDocsServiceImpl — owning_group resolves kind='docs' from
  cx.app_id's chain (CollectionNotShared off-chain); reads-open (script_gate
  GroupDocsRead) / writes-authed (script_gate_require_principal GroupDocsWrite);
  reuses parse_filter + docs value-size cap; no events. Unit tests cover
  off-chain CollectionNotShared, anon-read-OK/anon-write-Forbidden, non-object
  data rejected.
- executor-core: GroupDocsHandle + docs::shared_collection constructor + the
  create/get/find/find_one/update/delete/list methods (dispatch by receiver
  type), reusing doc_to_map/parse_doc_id.
- picloud: wire PostgresGroupDocsRepo + GroupDocsServiceImpl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-30 08:04:30 +02:00
parent 61352c7e5e
commit 78a468de83
7 changed files with 897 additions and 19 deletions

View File

@@ -0,0 +1,181 @@
//! `GroupDocsService` — the §11.6 shared group-collection DOCS contract.
//!
//! The cross-app-sharing counterpart to [`crate::DocsService`], analogous to
//! [`crate::GroupKvService`]. A script reaches it via the explicit
//! `docs::shared_collection("name")` handle. The owner of the data is not
//! `cx.app_id`: the impl resolves the **owning group** by walking the calling
//! app's ancestor chain for the nearest group that declares the collection
//! shared with `kind='docs'`. That ancestry walk is the isolation boundary —
//! the trait surface takes **no** group id (owner derivation stays in the impl).
//!
//! Trust model (§11.6): **reads are open** to any subtree script (anonymous
//! public HTTP included); **writes require an authenticated principal** with
//! editor+ on the owning group.
use async_trait::async_trait;
use thiserror::Error;
use crate::{DocId, DocRow, DocsListPage, SdkCallCx};
#[async_trait]
pub trait GroupDocsService: Send + Sync {
async fn create(
&self,
cx: &SdkCallCx,
collection: &str,
data: serde_json::Value,
) -> Result<DocId, GroupDocsError>;
async fn get(
&self,
cx: &SdkCallCx,
collection: &str,
id: DocId,
) -> Result<Option<DocRow>, GroupDocsError>;
async fn find(
&self,
cx: &SdkCallCx,
collection: &str,
filter: serde_json::Value,
) -> Result<Vec<DocRow>, GroupDocsError>;
async fn find_one(
&self,
cx: &SdkCallCx,
collection: &str,
filter: serde_json::Value,
) -> Result<Option<DocRow>, GroupDocsError>;
async fn update(
&self,
cx: &SdkCallCx,
collection: &str,
id: DocId,
data: serde_json::Value,
) -> Result<(), GroupDocsError>;
async fn delete(
&self,
cx: &SdkCallCx,
collection: &str,
id: DocId,
) -> Result<bool, GroupDocsError>;
async fn list(
&self,
cx: &SdkCallCx,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<DocsListPage, GroupDocsError>;
}
/// Stub for test bundles that don't exercise shared docs collections.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopGroupDocsService;
#[async_trait]
impl GroupDocsService for NoopGroupDocsService {
async fn create(
&self,
_cx: &SdkCallCx,
_collection: &str,
_data: serde_json::Value,
) -> Result<DocId, GroupDocsError> {
Err(GroupDocsError::Backend("group docs is not wired in".into()))
}
async fn get(
&self,
_cx: &SdkCallCx,
_collection: &str,
_id: DocId,
) -> Result<Option<DocRow>, GroupDocsError> {
Err(GroupDocsError::Backend("group docs is not wired in".into()))
}
async fn find(
&self,
_cx: &SdkCallCx,
_collection: &str,
_filter: serde_json::Value,
) -> Result<Vec<DocRow>, GroupDocsError> {
Err(GroupDocsError::Backend("group docs is not wired in".into()))
}
async fn find_one(
&self,
_cx: &SdkCallCx,
_collection: &str,
_filter: serde_json::Value,
) -> Result<Option<DocRow>, GroupDocsError> {
Err(GroupDocsError::Backend("group docs is not wired in".into()))
}
async fn update(
&self,
_cx: &SdkCallCx,
_collection: &str,
_id: DocId,
_data: serde_json::Value,
) -> Result<(), GroupDocsError> {
Err(GroupDocsError::Backend("group docs is not wired in".into()))
}
async fn delete(
&self,
_cx: &SdkCallCx,
_collection: &str,
_id: DocId,
) -> Result<bool, GroupDocsError> {
Err(GroupDocsError::Backend("group docs is not wired in".into()))
}
async fn list(
&self,
_cx: &SdkCallCx,
_collection: &str,
_cursor: Option<&str>,
_limit: u32,
) -> Result<DocsListPage, GroupDocsError> {
Err(GroupDocsError::Backend("group docs is not wired in".into()))
}
}
/// Failure modes surfaced to the Rhai bridge. Mirrors [`crate::DocsError`] plus
/// the §11.6 `CollectionNotShared` boundary.
#[derive(Debug, Error)]
pub enum GroupDocsError {
#[error("collection name must not be empty")]
InvalidCollection,
/// No group on the calling app's ancestor chain declares this collection
/// shared with `kind='docs'` — the structural "not shared with you"
/// boundary (also the outcome for a foreign app).
#[error("collection {0:?} is not a shared group docs collection for this app")]
CollectionNotShared(String),
#[error("document data must be a JSON object")]
InvalidData,
#[error("invalid filter: {0}")]
InvalidFilter(String),
#[error("unsupported operator: {0}")]
UnsupportedOperator(String),
#[error("document not found")]
NotFound,
/// For reads this is only raised when `cx.principal.is_some()`; for WRITES
/// it is also raised when the principal is absent (writes fail closed).
#[error("forbidden")]
Forbidden,
#[error("group docs: data too large ({actual} bytes; limit {limit})")]
ValueTooLarge { limit: usize, actual: usize },
#[error("group docs backend error: {0}")]
Backend(String),
}

View File

@@ -16,6 +16,7 @@ pub mod exec_summary;
pub mod execution_log;
pub mod files;
pub mod group;
pub mod group_docs;
pub mod group_kv;
pub mod http;
pub mod ids;
@@ -66,6 +67,7 @@ pub use files::{
SAFE_RENDER_FALLBACK,
};
pub use group::Group;
pub use group_docs::{GroupDocsError, GroupDocsService, NoopGroupDocsService};
pub use group_kv::{GroupKvError, GroupKvService, NoopGroupKvService};
pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService};
pub use ids::{

View File

@@ -20,12 +20,12 @@
use std::sync::Arc;
use crate::{
DeadLetterService, DocsService, EmailService, FilesService, GroupKvService, HttpService,
InvokeService, KvService, ModuleSource, NoopDeadLetterService, NoopDocsService,
NoopEmailService, NoopEventEmitter, NoopFilesService, NoopGroupKvService, NoopHttpService,
NoopInvokeService, NoopKvService, NoopModuleSource, NoopPubsubService, NoopQueueService,
NoopSecretsService, NoopUsersService, NoopVarsService, PubsubService, QueueService,
SecretsService, ServiceEventEmitter, UsersService, VarsService,
DeadLetterService, DocsService, EmailService, FilesService, GroupDocsService, GroupKvService,
HttpService, InvokeService, KvService, ModuleSource, NoopDeadLetterService, NoopDocsService,
NoopEmailService, NoopEventEmitter, NoopFilesService, NoopGroupDocsService, NoopGroupKvService,
NoopHttpService, NoopInvokeService, NoopKvService, NoopModuleSource, NoopPubsubService,
NoopQueueService, NoopSecretsService, NoopUsersService, NoopVarsService, PubsubService,
QueueService, SecretsService, ServiceEventEmitter, UsersService, VarsService,
};
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
@@ -122,6 +122,11 @@ pub struct Services {
/// `NoopGroupKvService` so test bundles that don't exercise sharing build
/// unchanged.
pub group_kv: Arc<dyn GroupKvService>,
/// Shared cross-app group DOCS collections (§11.6). Scripts get
/// `docs::shared_collection(name)`. Wired via [`Services::with_group_docs`];
/// defaults to `NoopGroupDocsService`.
pub group_docs: Arc<dyn GroupDocsService>,
}
impl Services {
@@ -165,17 +170,25 @@ impl Services {
// in via `with_group_kv`. Keeps the ~14 `Services::new` call sites
// (mostly tests) unchanged — a field addition, not a param.
group_kv: Arc::new(NoopGroupKvService),
group_docs: Arc::new(NoopGroupDocsService),
}
}
/// Set the §11.6 shared group-collection service (the picloud binary wires
/// the Postgres-backed impl; tests leave the noop default).
/// Set the §11.6 shared group-KV service (the picloud binary wires the
/// Postgres-backed impl; tests leave the noop default).
#[must_use]
pub fn with_group_kv(mut self, group_kv: Arc<dyn GroupKvService>) -> Self {
self.group_kv = group_kv;
self
}
/// Set the §11.6 shared group-DOCS service (picloud binary; tests leave noop).
#[must_use]
pub fn with_group_docs(mut self, group_docs: Arc<dyn GroupDocsService>) -> Self {
self.group_docs = group_docs;
self
}
/// All-noop bundle for tests that build an `Engine` but don't
/// exercise the stateful services. Returns the same shape as
/// `Services::new` so callers can't accidentally rely on a stub