From a2f0e11a9f336dea51f4687b9c2e67b72df8b83c Mon Sep 17 00:00:00 2001 From: Joel Robotham Date: Thu, 27 Aug 2026 16:43:31 +1000 Subject: [PATCH] broker: add storage.get and storage.put to the action contract Memory read/write graduates from a deferred operation into v1. A keyless agent could address an encrypted-memory record (storage.address) but not read or write one, so a keyless runtime stays amnesiac across wakes. Both actions are slug-addressed and mirror the existing nine: - storage.get { slug } -> { value? } (value absent = no record, not an error) - storage.put { slug, value } -> EventPublished The host derives the address, encrypts on put, and decrypts on get, so the secret never leaves the key holder -- the same reason storage.address routes through the interface. value is non-empty and bounded by MAX_CONTENT_BYTES. No patch/rm: a client composes those from read-modify-write, as profile.set already does for partial updates. storage.address is left in place; whether get/put make it redundant is an open question for review. Contract crate only. buzz-cli's exhaustive matches gain the two variants when the keyless-client branch rebases onto this. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Joel Robotham --- crates/buzz-sdk/src/broker/actions/args.rs | 81 ++++++++++++++++++- crates/buzz-sdk/src/broker/actions/mod.rs | 13 ++- .../buzz-sdk/src/broker/actions/outcomes.rs | 30 ++++++- crates/buzz-sdk/src/broker/correlate.rs | 2 + crates/buzz-sdk/src/broker/mod.rs | 13 +-- crates/buzz-sdk/src/broker/tests.rs | 47 ++++++++++- 6 files changed, 174 insertions(+), 12 deletions(-) diff --git a/crates/buzz-sdk/src/broker/actions/args.rs b/crates/buzz-sdk/src/broker/actions/args.rs index 1be4546ee1..b8ec727309 100644 --- a/crates/buzz-sdk/src/broker/actions/args.rs +++ b/crates/buzz-sdk/src/broker/actions/args.rs @@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize}; use super::{ absent_or_valued, absent_or_valued_hex64, channel, channel_id, content, cursor, event_id, hex64_field, is_false, limit, mentions, optional, required, respond_to, validate_slug, Action, - PubkeyHex, DEFAULT_PAGE_LIMIT, MAX_ABOUT_CHARS, MAX_EMOJI_CHARS, MAX_NAME_CHARS, - MAX_PROMPT_CHARS, MAX_SCALAR_CHARS, + PubkeyHex, DEFAULT_PAGE_LIMIT, MAX_ABOUT_CHARS, MAX_CONTENT_BYTES, MAX_EMOJI_CHARS, + MAX_NAME_CHARS, MAX_PROMPT_CHARS, MAX_SCALAR_CHARS, }; use crate::SdkError; @@ -285,6 +285,73 @@ impl StorageAddressArgs { } } +/// Arguments for `storage.get`. +/// +/// Slug-addressed like [`StorageAddressArgs`]: the host derives the record's +/// address from the slug and the secret, fetches it, and decrypts. The keyless +/// caller sends a name and receives plaintext — never a key or a relay filter. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StorageGetArgs { + /// Memory slug — `core` or `mem/…`, per NIP-AE. + pub slug: String, +} + +impl StorageGetArgs { + /// Validate and normalize. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] when the slug fails the NIP-AE + /// grammar. + pub fn validated(&self) -> Result { + let slug = required(&self.slug, "slug", 255)?; + validate_slug(&slug).map_err(|e| SdkError::InvalidInput(e.to_string()))?; + Ok(Self { slug }) + } +} + +/// Arguments for `storage.put`. +/// +/// The caller supplies plaintext; the host encrypts it under the record's key +/// and publishes it. Encryption stays host-side for the same reason addressing +/// does — the secret is the thing this contract exists to avoid handing over. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StoragePutArgs { + /// Memory slug — `core` or `mem/…`, per NIP-AE. + pub slug: String, + /// Plaintext record body; the host encrypts it before publishing. + pub value: String, +} + +impl StoragePutArgs { + /// Validate and normalize. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] when the slug fails the NIP-AE grammar + /// or the value is empty, and [`SdkError::ContentTooLarge`] past + /// [`super::MAX_CONTENT_BYTES`]. + pub fn validated(&self) -> Result { + let slug = required(&self.slug, "slug", 255)?; + validate_slug(&slug).map_err(|e| SdkError::InvalidInput(e.to_string()))?; + if self.value.trim().is_empty() { + return Err(SdkError::InvalidInput("value must not be empty".into())); + } + if self.value.len() > MAX_CONTENT_BYTES { + return Err(SdkError::ContentTooLarge { + max: MAX_CONTENT_BYTES, + got: self.value.len(), + }); + } + Ok(Self { + slug, + value: self.value.clone(), + }) + } +} + // ── Agent arguments ───────────────────────────────────────────────────────── /// Which agent an update or delete targets — exactly one selector, so a host @@ -511,6 +578,12 @@ pub enum ActionArgs { /// Derive an encrypted-memory address. #[serde(rename = "storage.address")] StorageAddress(StorageAddressArgs), + /// Read an encrypted-memory record. + #[serde(rename = "storage.get")] + StorageGet(StorageGetArgs), + /// Write an encrypted-memory record. + #[serde(rename = "storage.put")] + StoragePut(StoragePutArgs), /// Mint a managed agent. #[serde(rename = "agents.create")] AgentsCreate(AgentsCreateArgs), @@ -533,6 +606,8 @@ impl ActionArgs { Self::ReactionAdd(_) => Action::ReactionAdd, Self::ProfileSet(_) => Action::ProfileSet, Self::StorageAddress(_) => Action::StorageAddress, + Self::StorageGet(_) => Action::StorageGet, + Self::StoragePut(_) => Action::StoragePut, Self::AgentsCreate(_) => Action::AgentsCreate, Self::AgentsUpdate(_) => Action::AgentsUpdate, Self::AgentsDelete(_) => Action::AgentsDelete, @@ -555,6 +630,8 @@ impl ActionArgs { Self::ReactionAdd(args) => Self::ReactionAdd(args.validated()?), Self::ProfileSet(args) => Self::ProfileSet(args.validated()?), Self::StorageAddress(args) => Self::StorageAddress(args.validated()?), + Self::StorageGet(args) => Self::StorageGet(args.validated()?), + Self::StoragePut(args) => Self::StoragePut(args.validated()?), Self::AgentsCreate(args) => Self::AgentsCreate(args.validated()?), Self::AgentsUpdate(args) => Self::AgentsUpdate(args.validated()?), Self::AgentsDelete(args) => Self::AgentsDelete(args.validated()?), diff --git a/crates/buzz-sdk/src/broker/actions/mod.rs b/crates/buzz-sdk/src/broker/actions/mod.rs index c6f15cf42e..c14eec8cd8 100644 --- a/crates/buzz-sdk/src/broker/actions/mod.rs +++ b/crates/buzz-sdk/src/broker/actions/mod.rs @@ -14,10 +14,11 @@ pub mod outcomes; pub use args::{ ActionArgs, AgentTarget, AgentsCreateArgs, AgentsDeleteArgs, AgentsUpdateArgs, ChannelReadArgs, MessagePostArgs, MessageReplyArgs, ProfileSetArgs, ReactionAddArgs, StorageAddressArgs, + StorageGetArgs, StoragePutArgs, }; pub use outcomes::{ ActionOutcome, AgentsCreateOutcome, AgentsDeleteOutcome, AgentsUpdateOutcome, BrokerMessage, - EventPublished, MessagePage, StorageAddress, + EventPublished, MessagePage, StorageAddress, StorageRecord, }; /// Maximum characters in a display name or agent name. @@ -144,6 +145,10 @@ pub enum Action { ProfileSet, /// Derive the address of one encrypted-memory record. StorageAddress, + /// Read one encrypted-memory record by slug. + StorageGet, + /// Write one encrypted-memory record by slug. + StoragePut, /// Mint a managed agent owned by the requester. AgentsCreate, /// Patch a managed agent the requester owns. @@ -154,7 +159,7 @@ pub enum Action { impl Action { /// Every action in this protocol version, in wire-name order. - pub const ALL: [Self; 9] = [ + pub const ALL: [Self; 11] = [ Self::AgentsCreate, Self::AgentsDelete, Self::AgentsUpdate, @@ -164,6 +169,8 @@ impl Action { Self::ProfileSet, Self::ReactionAdd, Self::StorageAddress, + Self::StorageGet, + Self::StoragePut, ]; /// Stable wire name. @@ -176,6 +183,8 @@ impl Action { Self::ReactionAdd => "reaction.add", Self::ProfileSet => "profile.set", Self::StorageAddress => "storage.address", + Self::StorageGet => "storage.get", + Self::StoragePut => "storage.put", Self::AgentsCreate => "agents.create", Self::AgentsUpdate => "agents.update", Self::AgentsDelete => "agents.delete", diff --git a/crates/buzz-sdk/src/broker/actions/outcomes.rs b/crates/buzz-sdk/src/broker/actions/outcomes.rs index 064f3ed3aa..5e1b676c09 100644 --- a/crates/buzz-sdk/src/broker/actions/outcomes.rs +++ b/crates/buzz-sdk/src/broker/actions/outcomes.rs @@ -165,6 +165,23 @@ pub struct StorageAddress { pub d_tag: String, } +/// Outcome of `storage.get`. +/// +/// Carries the record's plaintext, decrypted host-side. `value` is absent when +/// no record exists at the slug — a normal first-read state, not a failure, so +/// an agent tells "empty memory" from "call failed" without reading an error. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StorageRecord { + /// Decrypted record body; absent when the slug holds no record. + #[serde( + default, + deserialize_with = "absent_or_valued", + skip_serializing_if = "Option::is_none" + )] + pub value: Option, +} + /// Outcome of a successful `agents.create`. /// /// Carries the new agent's **public** identity only — there is no field for @@ -226,6 +243,12 @@ pub enum ActionOutcome { /// `storage.address` succeeded. #[serde(rename = "storage.address")] StorageAddress(StorageAddress), + /// `storage.get` succeeded. + #[serde(rename = "storage.get")] + StorageGet(StorageRecord), + /// `storage.put` succeeded. + #[serde(rename = "storage.put")] + StoragePut(EventPublished), /// `agents.create` succeeded. #[serde(rename = "agents.create")] AgentsCreate(AgentsCreateOutcome), @@ -248,6 +271,8 @@ impl ActionOutcome { Self::ReactionAdd(_) => Action::ReactionAdd, Self::ProfileSet(_) => Action::ProfileSet, Self::StorageAddress(_) => Action::StorageAddress, + Self::StorageGet(_) => Action::StorageGet, + Self::StoragePut(_) => Action::StoragePut, Self::AgentsCreate(_) => Action::AgentsCreate, Self::AgentsUpdate(_) => Action::AgentsUpdate, Self::AgentsDelete(_) => Action::AgentsDelete, @@ -278,12 +303,15 @@ impl ActionOutcome { Self::MessagePost(published) | Self::MessageReply(published) | Self::ReactionAdd(published) - | Self::ProfileSet(published) => { + | Self::ProfileSet(published) + | Self::StoragePut(published) => { event_id(&published.event_id, "eventId")?; } Self::StorageAddress(address) => { event_id(&address.d_tag, "dTag")?; } + // A record body has no identifier or cursor to check. + Self::StorageGet(_) => {} Self::AgentsCreate(outcome) => { channel(&outcome.channel_id)?; required(&outcome.display_name, "display name", MAX_NAME_CHARS)?; diff --git a/crates/buzz-sdk/src/broker/correlate.rs b/crates/buzz-sdk/src/broker/correlate.rs index b6d4baf6ef..2138dfaf53 100644 --- a/crates/buzz-sdk/src/broker/correlate.rs +++ b/crates/buzz-sdk/src/broker/correlate.rs @@ -85,6 +85,8 @@ pub(super) fn correlate_identities( | (ActionArgs::ReactionAdd(_), _) | (ActionArgs::ProfileSet(_), _) | (ActionArgs::StorageAddress(_), _) + | (ActionArgs::StorageGet(_), _) + | (ActionArgs::StoragePut(_), _) | (ActionArgs::AgentsCreate(_), _) | (ActionArgs::AgentsUpdate(_), _) | (ActionArgs::AgentsDelete(_), _) => Ok(()), diff --git a/crates/buzz-sdk/src/broker/mod.rs b/crates/buzz-sdk/src/broker/mod.rs index e1117cc810..4951ed5202 100644 --- a/crates/buzz-sdk/src/broker/mod.rs +++ b/crates/buzz-sdk/src/broker/mod.rs @@ -45,11 +45,11 @@ //! //! # Deferred operations //! -//! Not in v1, all purely additive later: memory read/write (intent-level -//! operations over the encrypted store — until then [`Action::StorageAddress`] -//! only addresses a record, and the key holder remains the only reader/writer), -//! `presence.set`, `typing.set`, and streaming reads (waking on a mention is -//! `channel.read` with `mentionsOnly`, polled). +//! Not in v1, all purely additive later: `presence.set`, `typing.set`, and +//! streaming reads (waking on a mention is `channel.read` with `mentionsOnly`, +//! polled). Memory read/write graduated into v1 as `storage.get`/`storage.put`, +//! both slug-addressed; the host encrypts and decrypts, so the key holder stays +//! the only party that ever sees the record's key. //! //! # Non-goals //! @@ -72,7 +72,8 @@ pub use actions::{ Action, ActionArgs, ActionOutcome, AgentTarget, AgentsCreateArgs, AgentsCreateOutcome, AgentsDeleteArgs, AgentsDeleteOutcome, AgentsUpdateArgs, AgentsUpdateOutcome, BrokerMessage, ChannelReadArgs, EventPublished, MessagePage, MessagePostArgs, MessageReplyArgs, - ProfileSetArgs, PubkeyHex, ReactionAddArgs, StorageAddress, StorageAddressArgs, + ProfileSetArgs, PubkeyHex, ReactionAddArgs, StorageAddress, StorageAddressArgs, StorageGetArgs, + StoragePutArgs, StorageRecord, }; pub use client::{ BrokerClient, BrokerClientExt, BrokerFuture, BrokerTransportError, Dispatch, ValidatedFuture, diff --git a/crates/buzz-sdk/src/broker/tests.rs b/crates/buzz-sdk/src/broker/tests.rs index 14a55005ae..e8bb5bb529 100644 --- a/crates/buzz-sdk/src/broker/tests.rs +++ b/crates/buzz-sdk/src/broker/tests.rs @@ -79,6 +79,13 @@ fn action_fixtures() -> Vec { ActionArgs::StorageAddress(StorageAddressArgs { slug: "mem/broker-foundation".into(), }), + ActionArgs::StorageGet(StorageGetArgs { + slug: "core".into(), + }), + ActionArgs::StoragePut(StoragePutArgs { + slug: "mem/broker-foundation".into(), + value: "a remembered fact".into(), + }), ActionArgs::AgentsCreate(AgentsCreateArgs { channel_id: CHANNEL.into(), display_name: "Research helper".into(), @@ -119,12 +126,16 @@ fn outcome_fixtures(keys: &Keys) -> Vec { ActionOutcome::MessagePost(published.clone()), ActionOutcome::MessageReply(published.clone()), ActionOutcome::ReactionAdd(published.clone()), - ActionOutcome::ProfileSet(published), + ActionOutcome::ProfileSet(published.clone()), ActionOutcome::StorageAddress(StorageAddress { author_pubkey: pubkey(), kind: 30174, d_tag: EVENT.into(), }), + ActionOutcome::StorageGet(StorageRecord { + value: Some("a remembered fact".into()), + }), + ActionOutcome::StoragePut(published), ActionOutcome::AgentsCreate(AgentsCreateOutcome { agent_pubkey: pubkey(), display_name: "Research helper".into(), @@ -250,6 +261,20 @@ fn every_outcome_round_trips_through_a_response_envelope() { } } +/// A `storage.get` outcome omits `value` when the slug holds no record, and the +/// absence survives the round trip as `None` — never a stored empty string. +#[test] +fn storage_get_outcome_absent_value_round_trips() { + let empty = ActionOutcome::StorageGet(StorageRecord { value: None }); + let json = serde_json::to_value(&empty).expect("outcome serializes"); + assert!( + json["outcome"].get("value").is_none(), + "an absent record must not put a value key on the wire" + ); + let parsed: ActionOutcome = serde_json::from_value(json).expect("outcome deserializes"); + assert_eq!(parsed, empty); +} + /// Args and outcome share the `action` discriminator, so a payload can never /// pair one action's name with another's shape. #[test] @@ -936,6 +961,8 @@ fn every_payload_has_an_exact_and_secret_free_wire_schema() { ), ("profile.set/args", vec!["about", "displayName", "picture"]), ("storage.address/args", vec!["slug"]), + ("storage.get/args", vec!["slug"]), + ("storage.put/args", vec!["slug", "value"]), ( "agents.create/args", vec![ @@ -974,6 +1001,8 @@ fn every_payload_has_an_exact_and_secret_free_wire_schema() { "storage.address/outcome", vec!["authorPubkey", "dTag", "kind"], ), + ("storage.get/outcome", vec!["value"]), + ("storage.put/outcome", vec!["createdAt", "eventId", "kind"]), ( "agents.create/outcome", vec!["agentPubkey", "channelId", "displayName"], @@ -1354,6 +1383,22 @@ fn validators_accept_and_reject_at_their_boundaries() { assert!(!slug("secrets")); assert!(!slug("mem/Bad Slug")); + // storage.put carries a body: non-empty, bounded, and still slug-gated. + let put = |slug: &str, value: String| { + StoragePutArgs { + slug: slug.into(), + value, + } + .validated() + }; + assert!(put("core", "remember this".into()).is_ok()); + assert!(put("core", " ".into()).is_err()); + assert!(matches!( + put("core", "x".repeat(actions::MAX_CONTENT_BYTES + 1)).unwrap_err(), + SdkError::ContentTooLarge { .. } + )); + assert!(put("Core", "v".into()).is_err()); + // Patch-shaped writes must change something, and reject unknown modes. let profile_error = ProfileSetArgs { display_name: None,