Files
PiCloud/crates/shared/src/group_pubsub.rs
MechaCat02 9626d15863 feat(shared-topics): GroupPubsubService + shared publish fan-out + SDK handle (D2)
The runtime for shared topics:
- pubsub_repo: fan_out_publish gains `AND t.shared = FALSE` (a shared
  trigger never fires on a per-app publish); new fan_out_shared_publish
  matches `shared = true` pubsub triggers on the resolved owning group,
  each delivery stamped with the writer app_id (M2 model).
- GroupPubsubService trait (shared) + GroupPubsubServiceImpl: resolve the
  owning group (kind='topic') from cx.app_id's chain, require editor+
  (GroupPubsubPublish, fails closed for anon), size-cap, fan out.
- SDK: `pubsub::shared_topic("name")` -> GroupTopicHandle with
  `.publish(subtopic, msg)` / `.publish(msg)`; wired through Services +
  the picloud binary (reuses the one PubsubRepo).
- authz: Capability::GroupPubsubPublish(GroupId), editor+ / script:write.

The owning-group chain walk is the isolation boundary; a sibling-subtree
app never resolves the topic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:48:59 +02:00

85 lines
3.3 KiB
Rust

//! `GroupPubsubService` — the §11.6 D2 shared group-TOPIC publish contract.
//!
//! The pub/sub counterpart to [`crate::GroupKvService`]. A script reaches it via
//! the explicit `pubsub::shared_topic("name")` handle (distinct from the app-
//! private `pubsub::publish_durable`). A shared topic is **storeless**: there is
//! no message log — a publish fans out to the `shared = true` pubsub triggers on
//! the OWNING GROUP (resolved by walking the calling app's ancestor chain for
//! the nearest group that declares the topic shared). That ancestry walk is the
//! isolation boundary — a foreign app's chain never contains the owning group,
//! so the topic does not resolve. The trait takes **no** group id (owner
//! derivation stays inside the impl), matching `GroupKvService`.
//!
//! Trust model (§11.6): a publish is a WRITE to shared state, so it requires an
//! authenticated principal with editor+ on the owning group (fails closed for
//! an anonymous caller), matching `GroupKvService` writes.
use async_trait::async_trait;
use thiserror::Error;
use crate::SdkCallCx;
#[async_trait]
pub trait GroupPubsubService: Send + Sync {
/// Publish `message` to the group shared topic `namespace` (optionally under
/// `subtopic`, so `("events", "created")` publishes `events.created`).
/// Resolves the owning group (kind `topic`) from `cx.app_id`'s chain,
/// requires editor+, and fans out to `shared = true` pubsub triggers on that
/// group. Returns the number of delivery rows written (0 when no trigger
/// matched — the publish still succeeds).
async fn publish(
&self,
cx: &SdkCallCx,
namespace: &str,
subtopic: &str,
message: serde_json::Value,
) -> Result<u32, GroupPubsubError>;
}
/// Stub for test bundles that don't exercise shared topics. Every call errors so
/// accidental use surfaces clearly.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopGroupPubsubService;
#[async_trait]
impl GroupPubsubService for NoopGroupPubsubService {
async fn publish(
&self,
_cx: &SdkCallCx,
_namespace: &str,
_subtopic: &str,
_message: serde_json::Value,
) -> Result<u32, GroupPubsubError> {
Err(GroupPubsubError::Backend(
"group pubsub is not wired in".into(),
))
}
}
/// Failure modes surfaced to the Rhai bridge.
#[derive(Debug, Error)]
pub enum GroupPubsubError {
/// Empty topic namespace; rejected at the SDK boundary.
#[error("shared topic name must not be empty")]
InvalidTopic,
/// No group on the calling app's ancestor chain declares this topic shared —
/// the structural "not shared with you" boundary. Also the outcome for a
/// foreign app naming another subtree's topic.
#[error("topic {0:?} is not a shared group topic for this app")]
CollectionNotShared(String),
/// Caller lacked the required capability. A publish always needs an
/// authenticated editor+ on the owning group (fails closed for anon).
#[error("forbidden")]
Forbidden,
/// Message exceeds the per-message JSON-encoded size cap.
#[error("message too large: {actual} bytes exceeds the {limit}-byte cap")]
MessageTooLarge { limit: usize, actual: usize },
/// Storage / backend failure.
#[error("backend error: {0}")]
Backend(String),
}