Skip to content

Commit 2fbd0c4

Browse files
jwesleyeclaude
andcommitted
0.3.3: validate recipient is live before send/reply
Agents were sending postcards to 3-word addresses from long-ended peer sessions. Since addresses are per-session and don't persist across restarts, those sends silently landed in orphan inboxes that cleanup would later garbage-collect — making it look like the message went through when no peer ever read it. `send` and `reply` now check the recipient against the live directory and error out with a message pointing the agent at `oat-postcard directory` to see who's actually reachable. `--force` added to both verbs for the rare case you want to drop a postcard into an orphan inbox intentionally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3fa38b1 commit 2fbd0c4

8 files changed

Lines changed: 115 additions & 8 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55
},
66
"metadata": {
77
"description": "Asynchronous 1-to-1 postcard messaging between AI agent sessions via a Git-backed ledger",
8-
"version": "0.3.2"
8+
"version": "0.3.3"
99
},
1010
"plugins": [
1111
{
1212
"name": "postcard",
1313
"source": "./",
1414
"description": "Postcard protocol for cross-session AI agent messaging. Ships a CLI, slash commands, a model-invoked skill, a Clerk subagent for inbox triage, and SessionStart / Stop / UserPromptSubmit hooks.",
15-
"version": "0.3.2",
15+
"version": "0.3.3",
1616
"license": "MIT",
1717
"repository": "https://github.com/Open-Agent-Tools/Postcard",
1818
"keywords": ["messaging", "agents", "ledger", "git", "async", "multi-agent"]

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "postcard",
3-
"version": "0.3.2",
3+
"version": "0.3.3",
44
"description": "Asynchronous 1-to-1 postcard messaging between AI agent sessions via a Git-backed ledger.",
55
"author": {
66
"name": "Open Agent Tools"

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,25 @@
22

33
All notable changes to oat-postcard. Dates are UTC.
44

5+
## [0.3.3] - 2026-04-22
6+
7+
### Changed
8+
- `send` and `reply` now validate the recipient against the live
9+
directory and error out by default if the address is not active.
10+
Previously sends to dead addresses silently wrote to an orphan
11+
inbox that `cleanup` would later garbage-collect — making it look
12+
like the message went through when in fact no peer ever read it.
13+
Since 3-word addresses are per-session and don't persist across
14+
restarts, a dead-address send is almost always a bug.
15+
- Error format: `error: address 'foo-bar-baz' is not in the live
16+
directory. Run 'oat-postcard directory' to see active peers;
17+
--force to send anyway.` (Or the equivalent "parent sender no
18+
longer in the live directory" variant for `reply`.)
19+
- `--force` flag added to both `send` and `reply` to bypass the check
20+
when you intentionally want to drop a postcard into an orphan
21+
inbox (testing, debugging).
22+
- New helper `directory.is_active(address) -> bool`.
23+
524
## [0.3.2] - 2026-04-22
625

726
### Changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "oat-postcard"
3-
version = "0.3.2"
3+
version = "0.3.3"
44
description = "Asynchronous 1-to-1 postcard messaging between AI agent sessions via a Git-backed ledger."
55
readme = "README.md"
66
license = { text = "MIT" }

src/oat_postcard/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.3.2"
1+
__version__ = "0.3.3"

src/oat_postcard/cli.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,21 +45,38 @@ def _reply_title(parent_title: str) -> str:
4545

4646

4747
def _cmd_send(args: argparse.Namespace) -> int:
48-
from . import ledger, session
48+
from . import directory, ledger, session
4949

50+
if not args.force and not directory.is_active(args.address):
51+
print(
52+
f"error: address {args.address!r} is not in the live directory. "
53+
"Run 'oat-postcard directory' to see active peers; --force to "
54+
"send anyway.",
55+
file=sys.stderr,
56+
)
57+
return 1
5058
sender = session.resolve_or_init()
5159
pc = ledger.send(sender, args.address, args.title, args.body)
5260
print(f"sent {pc.id[:8]} to {args.address}")
5361
return 0
5462

5563

5664
def _cmd_reply(args: argparse.Namespace) -> int:
57-
from . import ledger, session
65+
from . import directory, ledger, session
5866

5967
parent = ledger.get_postcard(args.parent_id)
6068
if parent is None:
6169
print(f"error: no postcard matching {args.parent_id!r}", file=sys.stderr)
6270
return 1
71+
if not args.force and not directory.is_active(parent.sender):
72+
print(
73+
f"error: parent sender {parent.sender!r} is no longer in the live "
74+
"directory (their session has ended). Addresses are per-session "
75+
"and don't persist across restarts. Use --force to reply into "
76+
"the orphan inbox anyway.",
77+
file=sys.stderr,
78+
)
79+
return 1
6380
sender = session.resolve_or_init()
6481
title = _reply_title(parent.title)
6582
pc = ledger.send(sender, parent.sender, title, args.body, reply_to=parent.id)
@@ -315,6 +332,11 @@ def build_parser() -> argparse.ArgumentParser:
315332
p_send.add_argument("address")
316333
p_send.add_argument("title")
317334
p_send.add_argument("body")
335+
p_send.add_argument(
336+
"--force",
337+
action="store_true",
338+
help="send even if the recipient is not in the live directory",
339+
)
318340
p_send.set_defaults(func=_cmd_send)
319341

320342
p_reply = sub.add_parser(
@@ -323,6 +345,11 @@ def build_parser() -> argparse.ArgumentParser:
323345
)
324346
p_reply.add_argument("parent_id", help="parent postcard id (full or 8-char prefix)")
325347
p_reply.add_argument("body")
348+
p_reply.add_argument(
349+
"--force",
350+
action="store_true",
351+
help="reply even if the parent sender is no longer active",
352+
)
326353
p_reply.set_defaults(func=_cmd_reply)
327354

328355
p_inbox = sub.add_parser(

src/oat_postcard/directory.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ def resolve(address: str) -> Entry | None:
7373
return Entry(**data)
7474

7575

76+
def is_active(address: str) -> bool:
77+
return any(e.address == address for e in list_active())
78+
79+
7680
def list_active(prune: bool = True) -> list[Entry]:
7781
paths.ensure_root()
7882
entries: list[Entry] = []

tests/test_cli.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
1+
import os
12
from datetime import datetime, timezone
3+
from pathlib import Path
24

35
import pytest
46

5-
from oat_postcard import cli, ledger, session
7+
from oat_postcard import cli, directory, ledger, session
8+
9+
10+
def _register_live_peer(address: str) -> None:
11+
directory.register(
12+
address=address,
13+
session_id=f"test-{address}",
14+
pid=os.getpid(),
15+
cwd=Path.cwd(),
16+
)
617

718

819
def test_parse_time_window_accepts_shorthand():
@@ -37,6 +48,7 @@ def test_reply_title_truncates_to_max():
3748

3849
def test_reply_cmd_sends_with_reply_to(tmp_root, session_env, capsys):
3950
me = session.init_session()
51+
_register_live_peer("peer")
4052
parent = ledger.send("peer", me, "parent title", "parent body")
4153

4254
rc = cli.main(["reply", parent.id[:8], "acknowledged"])
@@ -115,6 +127,7 @@ def test_log_rejects_bad_time_spec(tmp_root, session_env, capsys):
115127

116128
def test_send_oversized_body_returns_clean_error(tmp_root, session_env, capsys):
117129
session.init_session()
130+
_register_live_peer("peer")
118131
big = "x" * (ledger.BODY_MAX + 1)
119132
rc = cli.main(["send", "peer", "short title", big])
120133
assert rc == 1
@@ -125,8 +138,52 @@ def test_send_oversized_body_returns_clean_error(tmp_root, session_env, capsys):
125138

126139
def test_send_oversized_title_returns_clean_error(tmp_root, session_env, capsys):
127140
session.init_session()
141+
_register_live_peer("peer")
128142
rc = cli.main(["send", "peer", "x" * (ledger.TITLE_MAX + 1), "body"])
129143
assert rc == 1
130144
err = capsys.readouterr().err
131145
assert "title exceeds" in err
132146
assert "Traceback" not in err
147+
148+
149+
def test_send_rejects_dead_address(tmp_root, session_env, capsys):
150+
session.init_session()
151+
rc = cli.main(["send", "ghost-dead-address", "t", "b"])
152+
assert rc == 1
153+
err = capsys.readouterr().err
154+
assert "not in the live directory" in err
155+
assert "'ghost-dead-address'" in err
156+
assert "--force" in err
157+
# No postcard should have been written
158+
assert ledger.log() == []
159+
160+
161+
def test_send_force_bypasses_dead_address_check(tmp_root, session_env, capsys):
162+
session.init_session()
163+
rc = cli.main(["send", "--force", "ghost-dead-address", "t", "b"])
164+
assert rc == 0
165+
assert "sent" in capsys.readouterr().out
166+
cards = ledger.log()
167+
assert len(cards) == 1 and cards[0].recipient == "ghost-dead-address"
168+
169+
170+
def test_reply_rejects_dead_parent_sender(tmp_root, session_env, capsys):
171+
me = session.init_session()
172+
# Parent came from a peer who is no longer in the directory
173+
parent = ledger.send("ghost-peer", me, "parent", "body")
174+
rc = cli.main(["reply", parent.id[:8], "acknowledged"])
175+
assert rc == 1
176+
err = capsys.readouterr().err
177+
assert "no longer in the live directory" in err
178+
assert "'ghost-peer'" in err
179+
assert "--force" in err
180+
181+
182+
def test_reply_force_bypasses_dead_parent_sender_check(tmp_root, session_env, capsys):
183+
me = session.init_session()
184+
parent = ledger.send("ghost-peer", me, "parent", "body")
185+
rc = cli.main(["reply", "--force", parent.id[:8], "acknowledged"])
186+
assert rc == 0
187+
cards = ledger.log()
188+
reply = next(c for c in cards if c.reply_to == parent.id)
189+
assert reply.recipient == "ghost-peer"

0 commit comments

Comments
 (0)