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>
85 lines
3.3 KiB
Rust
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),
|
|
}
|