mirror of
https://github.com/block/buzz.git
synced 2026-11-03 07:51:22 +00:00
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 <noreply@anthropic.com>
Signed-off-by: Joel Robotham <jrobotham@squareup.com>
This commit is contained in:
@@ -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<Self, SdkError> {
|
||||
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<Self, SdkError> {
|
||||
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()?),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
|
||||
@@ -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(()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -79,6 +79,13 @@ fn action_fixtures() -> Vec<ActionArgs> {
|
||||
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> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user