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:
Joel Robotham
2026-08-27 16:43:31 +10:00
parent b622003f74
commit a2f0e11a9f
6 changed files with 174 additions and 12 deletions

View File

@@ -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()?),

View File

@@ -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",

View File

@@ -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)?;

View File

@@ -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(()),

View File

@@ -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,

View File

@@ -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,