fix(acp): ACK-gated sentinel lifecycle, D7 admission, NIP-AO docs

Implements the frozen ACK-waiter contract for the ask permission policy:

- Publishing→Pending→Writing/Terminal lifecycle: entry inserted as
  Publishing before publish; transitions to Pending only on relay
  OK accepted=true.  Non-accepted outcomes (Rejected, timeout at
  min(10s, expiresAt), socket failure) deny immediately (fail closed).
- Registration-before-send via register_publish_ack: background relay
  task inserts the ACK waiter before sending the EVENT frame.
- Early-decision buffering: an authorized decision arriving while still
  in Publishing is stored in early_decision and applied on admission
  with no additional round trip.
- Exactly-once terminal consumption: ack_result_rx arm, timeout
  check, and cancel path each consume the record exactly once via
  finish_permission / finish_permission_sync.
- Remove the D7 !relay_active escape hatch: admission now requires
  publisher AND byte-equal initiator==owner; missing relay context
  denies synchronously with the D7 diagnostic, zero card events.
- Store wire expiresAt once in PermissionEntry at build time;
  kind-40003 resolved edit reuses it with no recompute drift.
- Add test_pair_rejecting, test_pair_silent, test_pair_dead to
  RelayEventPublisher for the ACK lifecycle test matrix.
- Fix test compile errors: IdleTimeout(Duration), remove
  test_from_cmd_tx dependency, add allow(collapsible_match).
- NIP-AO: replace published-immediately with ACK-gated admission
  semantics including fail-closed paths and early-decision note.

Tests: 746 pass / 0 fail (cargo test -p buzz-acp)
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-08 15:56:39 -04:00
co-authored by Will Pfleger
parent a16b059836
commit fe7efd8aab
3 changed files with 1236 additions and 121 deletions
+1125 -118
View File
File diff suppressed because it is too large Load Diff
+99 -1
View File
@@ -641,6 +641,34 @@ impl RelayEventPublisher {
Ok(ack_rx.await.unwrap_or(AckOutcome::Uncertain))
}
/// Register an ACK waiter for a signed event and return the receiver
/// **without** awaiting the outcome.
///
/// The background task sends the EVENT frame and resolves the waiter
/// exactly once (accepted, rejected, or uncertain). The caller owns the
/// returned [`oneshot::Receiver`] and must poll or await it — typically
/// in a `tokio::select!` arm alongside other loop futures.
///
/// Registration-before-send is guaranteed: the background task inserts the
/// waiter into `ack_waiters` before writing the EVENT frame.
///
/// # Errors
/// Returns `RelayError::ConnectionClosed` if the command channel is closed.
pub async fn register_publish_ack(
&self,
event: Event,
) -> Result<oneshot::Receiver<AckOutcome>, RelayError> {
let (ack_tx, ack_rx) = oneshot::channel();
self.cmd_tx
.send(RelayCommand::PublishEventAcked {
event: Box::new(event),
ack_tx,
})
.await
.map_err(|_| RelayError::ConnectionClosed)?;
Ok(ack_rx)
}
/// Test-only publisher pair: published events are forwarded to the
/// returned receiver instead of a live relay socket.
#[cfg(test)]
@@ -666,7 +694,77 @@ impl RelayEventPublisher {
});
(Self { cmd_tx }, event_rx)
}
}
/// Test publisher that rejects every `PublishEventAcked` command with
/// `AckOutcome::Rejected`. Used to test the rejected-ACK deny path.
#[cfg(test)]
#[allow(clippy::collapsible_match)]
pub(crate) fn test_pair_rejecting() -> (Self, mpsc::Receiver<Event>) {
let (cmd_tx, mut cmd_rx) = mpsc::channel::<RelayCommand>(64);
let (event_tx, event_rx) = mpsc::channel(64);
tokio::spawn(async move {
while let Some(cmd) = cmd_rx.recv().await {
match cmd {
RelayCommand::PublishEvent { event } => {
if event_tx.send(*event).await.is_err() {
break;
}
}
RelayCommand::PublishEventAcked { event, ack_tx } => {
let _ = event_tx.send(*event).await;
let _ = ack_tx.send(AckOutcome::Rejected {
message: "rate-limited".to_string(),
});
}
_ => {}
}
}
});
(Self { cmd_tx }, event_rx)
}
/// Test publisher that never sends an ACK for `PublishEventAcked` commands
/// (simulates a relay that accepts the command but never responds with OK).
/// Used to test the timeout path.
#[cfg(test)]
#[allow(clippy::collapsible_match)]
pub(crate) fn test_pair_silent() -> (Self, mpsc::Receiver<Event>) {
let (cmd_tx, mut cmd_rx) = mpsc::channel::<RelayCommand>(64);
let (event_tx, event_rx) = mpsc::channel(64);
tokio::spawn(async move {
while let Some(cmd) = cmd_rx.recv().await {
match cmd {
RelayCommand::PublishEvent { event } => {
if event_tx.send(*event).await.is_err() {
break;
}
}
RelayCommand::PublishEventAcked { event, ack_tx: _ } => {
// Intentionally drop ack_tx without sending — simulates
// a relay that never confirms the event.
let _ = event_tx.send(*event).await;
// ack_tx is dropped here → ack_rx.await returns Err(RecvError) → Uncertain
}
_ => {}
}
}
});
(Self { cmd_tx }, event_rx)
}
/// Test publisher whose command channel is dead on arrival (receiver dropped
/// before the first send). Any [`RelayCommand`] sent through this publisher
/// returns `Err(SendError)`, which the production code maps to
/// [`RelayError::ConnectionClosed`] — the same error path as a real socket failure.
///
/// Used by `sentinel_ack_socket_failure_denies_synchronously_map_empty`.
#[cfg(test)]
pub(crate) fn test_pair_dead() -> Self {
let (cmd_tx, cmd_rx) = mpsc::channel::<RelayCommand>(1);
drop(cmd_rx); // close the channel immediately
Self { cmd_tx }
}
} // end impl RelayEventPublisher
impl HarnessRelay {
/// Connect to relay and authenticate via NIP-42.
+12 -2
View File
@@ -334,8 +334,18 @@ observer feed. The sentinel lifecycle is:
### Sentinel event structure
**PENDING card (kind 9)** — published immediately when the harness registers the
request in the pending map.
**PENDING card (kind 9)** — published after the relay acknowledges the event with
`OK accepted=true`. The harness registers the request in the `Publishing` state and
sends the event to the relay; only on relay `OK accepted=true` does the entry
transition to `Pending` and the card become visible to the owner.
If the relay rejects the publish (`OK accepted=false`), the relay does not respond
within `min(10 s, expiresAt)`, or the relay connection fails, the request is denied
immediately with no card shown (fail closed).
An authorized owner decision that arrives while the entry is still in `Publishing`
state is buffered and applied as soon as the relay `OK` is received, with no
additional round trip.
The event content is a compact JSON object that matches the D6 frozen schema
(`requestNonce`, `optionIds`, `labels`, `expiresAt`, `hasDurableRule`, …). Desktop