mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
feat(core): bind owner-private placement intent to signed event order
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -633,6 +633,11 @@ pub const KIND_GIT_STATUS_DRAFT: u32 = 1633;
|
||||
/// announcement, never a project. See `docs/nips/NIP-MP.md`.
|
||||
pub const KIND_PROJECT: u32 = 30621;
|
||||
|
||||
/// Immutable owner-self-encrypted placement intent (not NIP-33).
|
||||
/// Reserved codec only: relay ingest remains disabled until private transport
|
||||
/// authorization and history are implemented. See [`crate::placement_wire`].
|
||||
pub const KIND_PLACEMENT_INTENT: u32 = 50003;
|
||||
|
||||
/// All registered kind constants — used for duplicate detection and iteration.
|
||||
pub const ALL_KINDS: &[u32] = &[
|
||||
KIND_PROFILE,
|
||||
@@ -659,6 +664,7 @@ pub const ALL_KINDS: &[u32] = &[
|
||||
KIND_MANAGED_AGENT,
|
||||
KIND_TEAM_CATALOG,
|
||||
KIND_PRIVATE_MANAGED_AGENT,
|
||||
KIND_PLACEMENT_INTENT,
|
||||
KIND_REPORT,
|
||||
KIND_PRODUCT_FEEDBACK,
|
||||
KIND_NIP29_PUT_USER,
|
||||
|
||||
@@ -34,6 +34,8 @@ pub mod observer;
|
||||
pub mod pairing;
|
||||
/// Arrival-independent desired placement, separate from lifecycle execution.
|
||||
pub mod placement;
|
||||
/// Owner-private authenticated wire binding for placement projection.
|
||||
pub mod placement_wire;
|
||||
/// Presence status types shared across crates.
|
||||
pub mod presence;
|
||||
/// NIP-PMA owner-encrypted private managed-agent wire codec.
|
||||
|
||||
193
crates/buzz-core/src/placement_wire.rs
Normal file
193
crates/buzz-core/src/placement_wire.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
//! Owner-private immutable placement intent. No transport or execution is enabled.
|
||||
//!
|
||||
//! Like NIP-PMA, encryption is owner-to-self: trusted Desktop instances already
|
||||
//! holding that owner's keys can observe intent for OTHER hosts. Host keys alone
|
||||
//! cannot decrypt or log in. Never export owner keys to an executor/runtime.
|
||||
//! The caller supplies current authoritative agent/host bindings; this codec
|
||||
//! verifies signed scope, not ownership records, revocation or admission.
|
||||
|
||||
use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, PublicKey, Tag, Timestamp};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::{
|
||||
kind::KIND_PLACEMENT_INTENT,
|
||||
placement::{EventOrder, PlacementAction, PlacementIntent},
|
||||
relay::normalize_relay_url,
|
||||
};
|
||||
|
||||
/// Separate from the obsolete exact-run `buzz.host.execution.v1` protocol.
|
||||
pub const NAMESPACE: &str = "buzz.placement.v1";
|
||||
const MAX_PLAINTEXT: usize = 2048;
|
||||
const MAX_CIPHERTEXT: usize = 4096;
|
||||
|
||||
/// Placement contributions only. Restart is a separate current-host one-shot;
|
||||
/// Move may issue Start only after ordinary Stop success and validity checks.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Action {
|
||||
/// Select the destination; not evidence of a running process.
|
||||
Start,
|
||||
/// Stop this host without cancelling desired placement on another host.
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// All semantic fields are encrypted and bound to ONE signed event identity.
|
||||
/// No shell, configuration, credentials, run nonce or future Start template.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Payload {
|
||||
/// Wire version, currently exactly 1.
|
||||
pub v: u8,
|
||||
/// Canonical community relay identity, captured from the active session.
|
||||
pub community: String,
|
||||
/// Authenticated owner, identical to the event author.
|
||||
pub owner: PublicKey,
|
||||
/// Agent identity, not a local display name.
|
||||
pub agent: PublicKey,
|
||||
/// Target executor, not necessarily the observing Desktop's executor.
|
||||
pub host: PublicKey,
|
||||
/// Non-nil request identity; durable deduplication is a separate boundary.
|
||||
pub request: Uuid,
|
||||
/// Agent + host intent, never an implicitly broadened legacy command.
|
||||
pub action: Action,
|
||||
}
|
||||
|
||||
/// Fail-closed codec errors; never include decrypted content or keys.
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
/// Wrong kind/tags, size, event hash or signature.
|
||||
#[error("invalid placement envelope")]
|
||||
Envelope,
|
||||
/// Malformed/unsupported plaintext, including legacy exact-run payloads.
|
||||
#[error("invalid placement payload")]
|
||||
Payload,
|
||||
/// Owner, community, agent or authorized target differs from captured scope.
|
||||
#[error("placement scope mismatch")]
|
||||
Scope,
|
||||
/// Encryption, decryption or signing failed.
|
||||
#[error("placement cryptography failed")]
|
||||
Crypto,
|
||||
}
|
||||
|
||||
impl Payload {
|
||||
fn validate(&self) -> Result<(), Error> {
|
||||
if self.v != 1
|
||||
|| self.request.is_nil()
|
||||
|| self.community.len() > 512
|
||||
|| normalize_relay_url(&self.community).ok().as_deref() != Some(&self.community)
|
||||
{
|
||||
return Err(Error::Payload);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an inert owner-self-encrypted candidate using the supplied sender
|
||||
/// seconds (no clock adjustment). Persist and retry this EXACT event; rebuilding
|
||||
/// randomizes ciphertext and changes order. This function grants no authority
|
||||
/// and does not publish. The producer must validate current bindings first.
|
||||
pub fn build_event(owner: &Keys, payload: &Payload, created_at: u64) -> Result<Event, Error> {
|
||||
payload.validate()?;
|
||||
if payload.owner != owner.public_key() {
|
||||
return Err(Error::Scope);
|
||||
}
|
||||
let plaintext = Zeroizing::new(serde_json::to_string(payload).map_err(|_| Error::Payload)?);
|
||||
if plaintext.len() > MAX_PLAINTEXT {
|
||||
return Err(Error::Payload);
|
||||
}
|
||||
let content = nip44::encrypt(
|
||||
owner.secret_key(),
|
||||
&owner.public_key(),
|
||||
plaintext.as_str(),
|
||||
nip44::Version::V2,
|
||||
)
|
||||
.map_err(|_| Error::Crypto)?;
|
||||
EventBuilder::new(Kind::Custom(KIND_PLACEMENT_INTENT as u16), content)
|
||||
.tags([Tag::parse(["L", NAMESPACE]).map_err(|_| Error::Envelope)?])
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.sign_with_keys(owner)
|
||||
.map_err(|_| Error::Crypto)
|
||||
}
|
||||
|
||||
/// Signed, scoped contribution plus separate request identity. Construction is
|
||||
/// private so callers cannot accidentally substitute fields from another event.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DecodedIntent {
|
||||
payload: Payload,
|
||||
placement: PlacementIntent,
|
||||
}
|
||||
|
||||
impl DecodedIntent {
|
||||
/// Authenticated request metadata; not permission to execute or replay it.
|
||||
pub fn payload(&self) -> &Payload {
|
||||
&self.payload
|
||||
}
|
||||
|
||||
/// Bind M01 order/host/action to the same verified event, never arrival time.
|
||||
pub fn placement(&self) -> PlacementIntent {
|
||||
self.placement
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify hash AND signature before decrypting, strictly parse, then bind scope.
|
||||
///
|
||||
/// `community`, `agent`, and `authorized_hosts` must come from the captured
|
||||
/// authenticated owner's authoritative bindings, NOT this event or a profile.
|
||||
/// Hosts includes every authorized target whose history contributes to this
|
||||
/// agent, not just the local executor: otherwise X cannot learn Start Y.
|
||||
/// The caller must recheck authorization at effect boundaries. Historical
|
||||
/// decoding is read-only projection, never command replay; no expiry/skew gate
|
||||
/// is imposed on desired state. Errors are not evidence of absent intent.
|
||||
pub fn decode_event(
|
||||
event: &Event,
|
||||
owner: &Keys,
|
||||
community: &str,
|
||||
agent: PublicKey,
|
||||
authorized_hosts: &[PublicKey],
|
||||
) -> Result<DecodedIntent, Error> {
|
||||
if event.pubkey != owner.public_key() {
|
||||
return Err(Error::Scope);
|
||||
}
|
||||
if event.kind.as_u16() as u32 != KIND_PLACEMENT_INTENT
|
||||
|| event.tags.len() != 1
|
||||
|| !event
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| tag.as_slice() == ["L", NAMESPACE])
|
||||
|| event.content.len() > MAX_CIPHERTEXT
|
||||
|| !event.verify_id()
|
||||
|| !event.verify_signature()
|
||||
{
|
||||
return Err(Error::Envelope);
|
||||
}
|
||||
let plaintext = Zeroizing::new(
|
||||
nip44::decrypt(owner.secret_key(), &owner.public_key(), &event.content)
|
||||
.map_err(|_| Error::Crypto)?,
|
||||
);
|
||||
if plaintext.len() > MAX_PLAINTEXT {
|
||||
return Err(Error::Payload);
|
||||
}
|
||||
let payload: Payload = serde_json::from_str(&plaintext).map_err(|_| Error::Payload)?;
|
||||
payload.validate()?;
|
||||
if payload.owner != owner.public_key()
|
||||
|| payload.community != community
|
||||
|| payload.agent != agent
|
||||
|| !authorized_hosts.contains(&payload.host)
|
||||
{
|
||||
return Err(Error::Scope);
|
||||
}
|
||||
let placement = PlacementIntent {
|
||||
order: EventOrder::from_event(event),
|
||||
host: payload.host,
|
||||
action: match payload.action {
|
||||
Action::Start => PlacementAction::Start,
|
||||
Action::Stop => PlacementAction::Stop,
|
||||
},
|
||||
};
|
||||
Ok(DecodedIntent { payload, placement })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
257
crates/buzz-core/src/placement_wire/tests.rs
Normal file
257
crates/buzz-core/src/placement_wire/tests.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
use super::*;
|
||||
use crate::placement::{PlacementProjection, TargetIntent};
|
||||
use nostr::JsonUtil;
|
||||
|
||||
fn payload(owner: &Keys) -> Payload {
|
||||
Payload {
|
||||
v: 1,
|
||||
community: "wss://relay.example".into(),
|
||||
owner: owner.public_key(),
|
||||
agent: Keys::generate().public_key(),
|
||||
host: Keys::generate().public_key(),
|
||||
request: Uuid::new_v4(),
|
||||
action: Action::Start,
|
||||
}
|
||||
}
|
||||
|
||||
fn decode(event: &Event, owner: &Keys, p: &Payload) -> Result<DecodedIntent, Error> {
|
||||
decode_event(event, owner, &p.community, p.agent, &[p.host])
|
||||
}
|
||||
|
||||
// Deliberately bypass the producer's validation to exercise the receive boundary.
|
||||
fn raw(owner: &Keys, text: &str) -> Event {
|
||||
let content = nip44::encrypt(
|
||||
owner.secret_key(),
|
||||
&owner.public_key(),
|
||||
text,
|
||||
nip44::Version::V2,
|
||||
)
|
||||
.unwrap();
|
||||
resign(
|
||||
owner,
|
||||
Kind::Custom(KIND_PLACEMENT_INTENT as u16),
|
||||
content,
|
||||
vec![Tag::parse(["L", NAMESPACE]).unwrap()],
|
||||
)
|
||||
}
|
||||
|
||||
fn resign(owner: &Keys, kind: Kind, content: String, tags: Vec<Tag>) -> Event {
|
||||
EventBuilder::new(kind, content)
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(100))
|
||||
.sign_with_keys(owner)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_desktops_observe_the_same_cross_host_identity_without_host_keys() {
|
||||
let owner = Keys::generate();
|
||||
let x = Keys::generate();
|
||||
let y = Keys::generate();
|
||||
let mut p = payload(&owner);
|
||||
p.host = x.public_key();
|
||||
let start_x = build_event(&owner, &p, 100).unwrap();
|
||||
p.host = y.public_key();
|
||||
p.request = Uuid::new_v4();
|
||||
let start_y = build_event(&owner, &p, 101).unwrap();
|
||||
p.host = x.public_key();
|
||||
p.request = Uuid::new_v4();
|
||||
p.action = Action::Stop;
|
||||
let stop_x = build_event(&owner, &p, 102).unwrap();
|
||||
let hosts = [x.public_key(), y.public_key()];
|
||||
let read = |event: &Event| {
|
||||
decode_event(event, &owner, &p.community, p.agent, &hosts)
|
||||
.unwrap()
|
||||
.placement()
|
||||
};
|
||||
let a = [read(&start_x), read(&start_y), read(&stop_x)];
|
||||
let b = [read(&stop_x), read(&start_y), read(&start_x)];
|
||||
for intents in [&a, &b] {
|
||||
let projection = PlacementProjection::new(intents);
|
||||
assert_eq!(
|
||||
projection.desired(),
|
||||
Some((y.public_key(), EventOrder::from_event(&start_y)))
|
||||
);
|
||||
assert!(matches!(
|
||||
projection.target(x.public_key()),
|
||||
TargetIntent::Stopped(_)
|
||||
));
|
||||
assert!(projection.retains_start(y.public_key(), EventOrder::from_event(&start_y)));
|
||||
}
|
||||
// Exact persisted bytes survive retry; no per-recipient re-signing.
|
||||
let retry = Event::from_json(start_y.as_json()).unwrap();
|
||||
assert_eq!(read(&retry), read(&start_y));
|
||||
for host in [&x, &y] {
|
||||
assert_eq!(decode(&start_y, host, &p), Err(Error::Scope));
|
||||
assert!(nip44::decrypt(host.secret_key(), &owner.public_key(), &start_y.content).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_each_foreign_scope_and_unbound_target() {
|
||||
let owner = Keys::generate();
|
||||
let p = payload(&owner);
|
||||
let event = build_event(&owner, &p, 100).unwrap();
|
||||
let decoded = decode(&event, &owner, &p).unwrap();
|
||||
assert_eq!(decoded.payload(), &p);
|
||||
assert_eq!(decoded.placement().order.event_id(), event.id);
|
||||
assert_eq!(decoded.placement().host, p.host);
|
||||
assert_eq!(decoded.placement().action, PlacementAction::Start);
|
||||
let foreign = Keys::generate();
|
||||
assert_eq!(build_event(&foreign, &p, 100), Err(Error::Scope));
|
||||
assert_eq!(decode(&event, &foreign, &p), Err(Error::Scope));
|
||||
assert_eq!(
|
||||
decode_event(&event, &owner, "wss://other.example", p.agent, &[p.host]),
|
||||
Err(Error::Scope)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_event(
|
||||
&event,
|
||||
&owner,
|
||||
&p.community,
|
||||
foreign.public_key(),
|
||||
&[p.host]
|
||||
),
|
||||
Err(Error::Scope)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_event(&event, &owner, &p.community, p.agent, &[]),
|
||||
Err(Error::Scope)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_event(
|
||||
&event,
|
||||
&owner,
|
||||
&p.community,
|
||||
p.agent,
|
||||
&[foreign.public_key()]
|
||||
),
|
||||
Err(Error::Scope)
|
||||
);
|
||||
let mut spoof = p.clone();
|
||||
spoof.owner = foreign.public_key();
|
||||
assert_eq!(
|
||||
decode(
|
||||
&raw(&owner, &serde_json::to_string(&spoof).unwrap()),
|
||||
&owner,
|
||||
&p
|
||||
),
|
||||
Err(Error::Scope)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_hash_signature_ciphertext_and_envelope_tampering() {
|
||||
let owner = Keys::generate();
|
||||
let p = payload(&owner);
|
||||
let event = build_event(&owner, &p, 100).unwrap();
|
||||
let mut bad_hash = event.clone();
|
||||
bad_hash.created_at = Timestamp::from(101);
|
||||
assert!(bad_hash.verify_signature()); // signature alone is insufficient
|
||||
assert_eq!(decode(&bad_hash, &owner, &p), Err(Error::Envelope));
|
||||
let mut bad_sig = event.clone();
|
||||
bad_sig.sig = build_event(&owner, &p, 102).unwrap().sig;
|
||||
assert!(bad_sig.verify_id());
|
||||
assert_eq!(decode(&bad_sig, &owner, &p), Err(Error::Envelope));
|
||||
for (kind, tags) in [
|
||||
(Kind::TextNote, event.tags.clone().to_vec()),
|
||||
(event.kind, vec![]),
|
||||
(
|
||||
event.kind,
|
||||
vec![Tag::parse(["L", "buzz.host.execution.v1"]).unwrap()],
|
||||
),
|
||||
(
|
||||
event.kind,
|
||||
vec![Tag::parse(["L", NAMESPACE, "extra"]).unwrap()],
|
||||
),
|
||||
(event.kind, vec![Tag::parse(["L", NAMESPACE]).unwrap(); 2]),
|
||||
] {
|
||||
assert_eq!(
|
||||
decode(
|
||||
&resign(&owner, kind, event.content.clone(), tags),
|
||||
&owner,
|
||||
&p
|
||||
),
|
||||
Err(Error::Envelope)
|
||||
);
|
||||
}
|
||||
let forged_ciphertext = resign(
|
||||
&owner,
|
||||
event.kind,
|
||||
"not ciphertext".into(),
|
||||
event.tags.clone().to_vec(),
|
||||
);
|
||||
assert_eq!(decode(&forged_ciphertext, &owner, &p), Err(Error::Crypto));
|
||||
let oversized = resign(
|
||||
&owner,
|
||||
event.kind,
|
||||
"a".repeat(MAX_CIPHERTEXT + 1),
|
||||
event.tags.clone().to_vec(),
|
||||
);
|
||||
assert_eq!(decode(&oversized, &owner, &p), Err(Error::Envelope));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_legacy_versions_duplicates_unknown_fields_and_invalid_payloads() {
|
||||
let owner = Keys::generate();
|
||||
let p = payload(&owner);
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
let cases = [
|
||||
json.replacen("\"v\":1", "\"v\":2", 1),
|
||||
json.replacen("\"v\":1", "\"v\":1,\"v\":1", 1),
|
||||
json.replacen("\"v\":1", "\"v\":1,\"run\":\"old-generation\"", 1),
|
||||
json.replace("\"start\"", "{\"action\":\"stop\",\"run\":\"old\"}"),
|
||||
json.replace("\"start\"", "\"restart\""),
|
||||
json.replace("wss://relay.example", "WSS://relay.example/"),
|
||||
json.replace("wss://relay.example", "wss://user@relay.example"),
|
||||
json.replace(&p.request.to_string(), &Uuid::nil().to_string()),
|
||||
json.replace(&p.agent.to_hex(), "invalid-key"),
|
||||
format!("{json} trailing"),
|
||||
" ".repeat(MAX_PLAINTEXT + 1),
|
||||
];
|
||||
for text in cases {
|
||||
assert_eq!(decode(&raw(&owner, &text), &owner, &p), Err(Error::Payload));
|
||||
}
|
||||
for invalid in [
|
||||
Payload { v: 2, ..p.clone() },
|
||||
Payload {
|
||||
community: "https://relay.example".into(),
|
||||
..p.clone()
|
||||
},
|
||||
Payload {
|
||||
community: format!("wss://relay.example/{}", "x".repeat(512)),
|
||||
..p.clone()
|
||||
},
|
||||
Payload {
|
||||
request: Uuid::nil(),
|
||||
..p.clone()
|
||||
},
|
||||
] {
|
||||
assert_eq!(build_event(&owner, &invalid, 100), Err(Error::Payload));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_seconds_and_lower_id_not_arrival_or_request_id_order() {
|
||||
let owner = Keys::generate();
|
||||
let p = payload(&owner);
|
||||
let first = build_event(&owner, &p, 100).unwrap();
|
||||
let second = build_event(&owner, &p, 100).unwrap();
|
||||
assert_ne!(first.id, second.id); // rebuilding is NOT a retry
|
||||
let a = decode(&first, &owner, &p).unwrap().placement();
|
||||
let b = decode(&second, &owner, &p).unwrap().placement();
|
||||
assert_eq!(a.order > b.order, first.id < second.id);
|
||||
let future = build_event(&owner, &p, u64::MAX).unwrap();
|
||||
let f = decode(&future, &owner, &p).unwrap().placement();
|
||||
assert!(f.order > a.order); // desired state is not expired command admission
|
||||
let intents = [f, b, a, f];
|
||||
assert_eq!(
|
||||
PlacementProjection::new(&intents).desired(),
|
||||
Some((p.host, f.order))
|
||||
);
|
||||
assert!(!crate::kind::is_replaceable(KIND_PLACEMENT_INTENT));
|
||||
assert!(!crate::kind::is_parameterized_replaceable(
|
||||
KIND_PLACEMENT_INTENT
|
||||
));
|
||||
assert!(!crate::kind::is_ephemeral(KIND_PLACEMENT_INTENT));
|
||||
}
|
||||
80
docs/multiverse-intent-wire.md
Normal file
80
docs/multiverse-intent-wire.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Multiverse M02: authenticated intent, not execution
|
||||
|
||||
Stacks on M01 (`docs/multiverse-placement.md`). This is an inert codec for
|
||||
`kind:50003`, `L=buzz.placement.v1`, with owner-self NIP-44 v2 ciphertext.
|
||||
The relay's `required_scope_for_kind` still rejects this kind. No producer,
|
||||
receiver, new login, key distribution, runtime effect or configuration is enabled.
|
||||
|
||||
## Representation and trust
|
||||
|
||||
Reuse the **audience model**, not the replaceable semantics, of
|
||||
`crates/buzz-core/src/private_managed_agent.rs::build_event` and
|
||||
`validate_and_decrypt`: the owner encrypts to itself and signs once. Authorized
|
||||
Desktop instances already holding that owner's keys can decrypt the same event,
|
||||
including Start Y observed by X. Executor keys alone cannot read it. Neither a
|
||||
profile nor possession of a host key grants owner credentials. There is no
|
||||
blanket group key, per-recipient re-signing, generic signer or runtime key export.
|
||||
An executor without an existing authenticated owner Desktop context is unsupported;
|
||||
this work does not invent credentials for it.
|
||||
|
||||
The encrypted payload is exactly version, canonical community, owner, agent,
|
||||
target host, request UUID and Start/Stop action. Only author, kind, namespace and
|
||||
signed timestamp are exposed outside ciphertext. Host means stable executor,
|
||||
not process/run. Unknown fields, duplicate struct fields, invalid/noncanonical
|
||||
community, nil request, unknown action/version and legacy exact-run objects fail
|
||||
closed. Restart is not accepted as Start: it remains a separately deduplicated
|
||||
current-host operation. No Move future-Start template is serialized.
|
||||
|
||||
`decode_event` checks event hash **and** signature before decryption and binds
|
||||
owner/community/agent/target to caller-supplied authoritative scope. It returns
|
||||
request metadata separately from an M01 contribution whose host, action and
|
||||
order come from that single verified event. Caller-supplied bindings are not
|
||||
proof created by the codec. M01 remains explicitly non-authorizing.
|
||||
|
||||
Newer signed sender seconds wins, lower event ID breaks ties. There is no
|
||||
expiry or skew check on historical desired state, no relay order, lease or
|
||||
logical clock. Rebuilding randomizes ciphertext and changes event identity:
|
||||
persist exact signed bytes before publication, retry those bytes only. A shared
|
||||
request UUID does not make two differently signed events the same ordering fact;
|
||||
future admission must reject request collisions, not pick by arrival.
|
||||
|
||||
## Concrete integration path (not implemented here)
|
||||
|
||||
1. Native producer: reuse the captured owner/relay pattern in
|
||||
`desktop/src-tauri/src/managed_agents/retention.rs::active_retention_scope`
|
||||
and `persona_events.rs::flush_pending_events_at`. Canonicalize with
|
||||
`buzz_core::relay::normalize_relay_url`, validate authoritative agent ownership
|
||||
and executor binding, build once, persist exact bytes. Do **not** reuse persona
|
||||
NIP-33 upserts or redating: immutable commands are not replaceable definitions.
|
||||
2. Relay: extend existing author-only filter/result/live/COUNT gates, storage
|
||||
search exclusion and owner/global ingest validation together, before admitting
|
||||
50003. `handlers/req.rs::{author_only_filters_authorized,is_author_only_event}`
|
||||
already implement the audience shape. Ciphertext is not metadata privacy.
|
||||
Transport owner authentication and community isolation remain unchanged.
|
||||
3. Receiver/history: capture the same native owner context, not host-key login.
|
||||
`relay.rs::query_relay_at_with_keys` is the existing explicit-key query seam;
|
||||
add bounded no-redirect private queries and honest errors. Query explicit kind
|
||||
+ owner across **all** relevant targets, not destination X only. Agent/host
|
||||
fields are encrypted: paginate owner history before local scoped decoding,
|
||||
never treat a page without matching agent rows as exhaustion.
|
||||
`buzz-db/src/store/event.rs::EventQuery` already has `until` + `before_id`
|
||||
(`created_at DESC, id ASC`); complete dense-tie paging and overlapping live
|
||||
intake remain required. No new history database or relay sequencer is needed.
|
||||
4. Authoritative bindings must distinguish valid historical intent from current
|
||||
effect authorization. Revocation/compaction must not drop an old stop or
|
||||
selection and revive a prior host. Preserve authenticated projection fences;
|
||||
never turn read/binding errors into absence. Current bindings are rechecked
|
||||
before effects. Scope switches discard decrypted state and fence in-flight work.
|
||||
5. Durable admission/retention and ordinary Desktop controls precede enablement:
|
||||
history projects state, never replays Start/Stop/Restart. Reconnect can stop a
|
||||
superseded copy, not resume interrupted operations. Move waits for ordinary
|
||||
Stop success; failed/unconfirmed/late results never release automatic Start Y.
|
||||
|
||||
Next executable slice is relay owner-private admission/read coverage plus its
|
||||
schema search exclusion, retaining disabled runtime consumers. Then combine
|
||||
native scoped history + binding adapter where the measured diff permits, before
|
||||
journal/control integration. The earlier M01–M17 outline is not a fixed PR count;
|
||||
profile/inventory and sender/result splits should be remeasured, not scaffolded
|
||||
in advance. Preserve the existing `remote-start-preview` convention when native
|
||||
wiring first appears; keyless lifecycle compatibility and native acceptance are
|
||||
release gates, not claims of this codec PR.
|
||||
Reference in New Issue
Block a user