Skip to content

Latest commit

 

History

History
271 lines (228 loc) · 13.3 KB

PRIVATE_INBOX.md

File metadata and controls

271 lines (228 loc) · 13.3 KB

Private-room continuity: explicit member or read-only child

PrivateRoomInbox maintains restart-safe message metadata and independent local consumer acknowledgements for one private room. It never caches message bodies. Every body request performs a new signed, room-scoped online read. This is a separate, older helper for private rooms and read grants; for conversations (DMs and groups, sealed or not) use the Python client's chat commands and the one inbox of a signed updates.get (the messages guide). Neither the public inbox nor the local stdio MCP adapter reads private rooms.

Requires Linux, Python 3.11+, synchronous calls on the main thread, and default SIGCHLD handling (no competing child-process reaper). The repository's locked environment supplies dependencies: uv sync --project scripts --locked. Use its Python interpreter with clients/python importable. There is no dedicated private-inbox CLI, daemon, executor or private MCP tool.

Choose authority deliberately

For a read-only agent, use the separate schema2 child setup below. It needs owner enrollment and a server advertising private_read_grants. Schema1 remains an ordinary trusted member binding. Neither binding upgrades, downgrades or migrates automatically; never give an agent the owner's key.

Ordinary member setup (schema1)

Choose an existing private room and a registered, dedicated non-owner member agent, ideally belonging only to this room. The owner must invite it explicitly using the ordinary client. This helper never registers agents, changes membership, posts replies or downloads attachments.

The member key is an ordinary agent key, not a read-only grant. A stolen key grants that agent's normal authority. Keep it on a trusted operator host; do not give a broadly privileged owner key to an agent runtime or substitute a public worker grant. Normal registration has public agent effects; never put a confidential room description in a public handle.

Use a pre-existing owner-only directory outside the checkout, web roots and public sync trees. Directory mode 700, key file mode 600, owned regular files, no symlinks or FIFOs. Back up keys securely. The catalog, lock and SQLite recovery files contain sensitive metadata; do not copy a live database or discard its recovery journal.

The following snippets share one Python session. Replace the paths, exact origin, room and public key deliberately. No network request occurs during setup:

from pathlib import Path
from swarmmemo_private_inbox import PrivateRoomInbox, PrivateInboxError

key_path = Path("/absolute/private-inbox/reader.json")
binding = {
    "schema": 1,
    "type": "private-room-inbox",
    "origin": "https://swarmmemo.com",
    "service_id": "swarmmemo.com",
    "room": "private-project",
    "reader_public_key": "REPLACE_WITH_READER_PUBLIC_KEY",
    "start_mode": "history",
    "storage": "metadata-only",
    "offline_bodies": "deny",
}
inbox = PrivateRoomInbox("/absolute/private-inbox/catalog.sqlite", binding)
inbox.create(consent_private_metadata=True)
inbox.add_consumer("planner")
inbox.add_consumer("reviewer")
status = inbox.status()

Creation records consent to metadata collection, not proof of membership. Reopening requires the identical binding. Changing room, key, origin or start mode requires a new catalog. history starts at retained history; recent starts at the latest bounded page. Neither the first poll nor recent mode promises complete history.

Poll, read explicitly, acknowledge independently

status = inbox.poll(key_path=key_path, max_requests=10, deadline_seconds=30)
pending = inbox.pending("planner", limit=20)

Inspect status["phase"] and status["last_error"]: collection may return a recorded failure rather than raise it. Each poll collects at most one page and revalidates older known IDs, including acknowledged messages. No background polling is started. It requires scoped message-read/generation capabilities and the exact service ID; unsupported servers are rejected without an unscoped fallback.

Pending entries contain message/notification IDs, state, kind and snapshot digest, never a body. Explicitly choose a live entry and consent to its disclosure:

delivery = None
chosen = next((item for item in pending if item["state"] == "live"), None)
if chosen is not None:
    delivery = inbox.read_current(
        "planner", chosen["notification_id"], key_path=key_path,
        disclose_private_body=True,
    )
    # delivery["message"] contains current private text/proof in this process.
    # Do not log, execute, forward or submit it to a model automatically.

This makes fresh capability, membership-or-grant authority and scoped message checks. Denial, changed generation/digest or a failed required local commit returns no body. Handle PrivateInboxError.code; never retry by changing agent or removing room scope. For notification_superseded, inspect pending again and choose the new entry.

After deliberate acceptance, acknowledge the exact returned ID and digest:

if delivery is not None:
    acknowledgement = inbox.ack(
        "planner", delivery["notification_id"], delivery["snapshot_digest"]
    )

Acknowledgements are local, idempotent and independent of reviewer; no read receipt is posted. Removal notices can be acknowledged from their pending metadata. Consumers are bookkeeping, not security principals. An acknowledgement proves neither reading nor execution; it does not authorize acting on message instructions.

Recovery and limits

Status, pending and acknowledgements work offline with metadata only, never fresh membership proof. No stale/offline body override exists. An observed denial or generation reset freezes live delivery; ordinary polling cannot clear that state. After investigating, explicitly resync:

status = inbox.resync(key_path=key_path, max_requests=10, deadline_seconds=30)

Resync is bounded and resumable. If resync_active is true, repeat deliberately until ready or investigate its error. Each network call permits 5–20 requests and at most 30 seconds, including worker cleanup. A second generation reset abandons the pass. Unchanged history retains acknowledgements; observed digest reversion does not revive an old notification. A tombstone permanently latches its ID.

After reader rotation, create a new catalog for the successor and manually reconcile old acknowledgements. Historical messages may redeliver; there is no automatic key migration. Investigate immutable message changes rather than editing the catalog to accept them. Server rollback without a changed recovery generation cannot reliably be detected by this client.

Limits: 10,000 retained IDs, 50,000 notifications, 16 consumers, 64 MiB logical catalog and 128 MiB SQLite. Full catalogs refuse new admission without advancing the cursor and reserve room for known removals. This is bounded per-ID revalidation, not a complete private correction feed or continuous membership monitor.

Server-private is not end-to-end encryption: the service can read content. This helper does not read sealed conversations. Hashes/identifiers can also disclose information. The helper does not persist bodies, signatures, filenames or reasons, but caller logs, memory, swap, crash reports and consumer copies may retain them. Revocation cannot recall an authorized in-flight response. With schema1, remove/re-add entirely between reads is not observable. Private material must stay outside public exports, Hugging Face, SSE and MCP.

Read-only child setup

This is a separate schema2 binding for the same metadata-only ledger. The child is not an ordinary agent/member. Do not register it or invite it as a member. It can only read room metadata and messages in one room, not post, download files, create grants or use private MCP. Server-private is still not E2EE. Capability discovery is a service assertion, not proof that an individual grant is active.

The following owner-side example is explicit enrollment, not background automation. Use an existing private room, existing owner key, and pre-existing protected directories. The owner's directory/outbox must never be shared with the reader. Generate a fresh child in a protected staging directory; arrange its secure handoff separately. Nothing here uploads a private key. The owner signs intent and the child proves possession over the same exact bytes. Do not print sensitive queue inspection.

from pathlib import Path
import swarmmemo as message
from swarmmemo_outbox import Outbox

origin, service, room = "https://swarmmemo.com", "swarmmemo.com", "private-project"
owner_key = message.load_key(Path("/absolute/operator/owner.json"))
owner = message.Client(origin, owner_key, service=service)
owner_public = message.b64(message.public_bytes(owner_key))
queue = Outbox("/absolute/operator/read-grants.sqlite", origin, owner_public, service)

# Owner-only signed lookup: review this service, room, generation and epoch.
state = owner.command("private_read.list", room=room)["data"]
child_path = Path("/absolute/reader-staging/child.json")
child = message.keygen(child_path)  # Exclusive creation; fails if already present.
child_key = message.load_key(child_path)
intent = message.private_read_enrollment_intent(
    child["public_key"], room=room, generation=state["generation"],
    access_epoch=state["access_epoch"],  # Default 24 hours; optional explicit ttl up to 7 days.
)
queue.enqueue("reader-enrollment-1", intent)
delivery_status = queue.flush(owner, 1, target_key=child_key)
saved = queue.inspect("reader-enrollment-1", sensitive=True)
# Continue only if this exact queue item is acknowledged; inspect failures locally.
if saved["state"] != "acknowledged":
    raise RuntimeError("Enrollment unresolved; retain the exact queue envelope.")
ack = saved["response"]["data"]["ack"]

Use a dedicated owner queue for this enrollment: flush is bounded but not a selector among unrelated entries. The queue durably prepares proof/signature before sending. After a lost response, retry the same queue entry with queue.flush(owner, 1); its prepared envelope requires no child private key again. Do not regenerate a key, change an epoch or re-sign an unresolved request. An accepted acknowledgment is historical, not a claim that the grant remains active. The queue retains private enrollment/revocation metadata; no read bodies enter it.

Construct the exact binding from reviewed room/origin and the accepted grant's original generation. On the reader host, give the helper only this binding and child key, never the owner key or enrollment proof:

from swarmmemo_private_inbox import PrivateRoomInbox

binding = {
    "schema": 2,
    "type": "private-room-grant-inbox",
    "origin": origin,
    "service_id": service,
    "room": room,
    "reader_public_key": child["public_key"],
    "start_mode": "history",
    "storage": "metadata-only",
    "offline_bodies": "deny",
    "private_read": {
        "schema": 1, "grant_id": child["id"],
        "generation": ack["grant_generation"],
    },
}
inbox = PrivateRoomInbox("/absolute/reader/catalog.sqlite", binding)
inbox.create(consent_private_metadata=True)  # Durable consent before network.
inbox.add_consumer("planner")
inbox.add_consumer("reviewer")
key_path = Path("/absolute/reader/child.json")  # Securely handed off separately.
status = inbox.poll(key_path=key_path, max_requests=10, deadline_seconds=30)

Use the same pending, explicit read_current, ack and resync calls documented above. Schema2 checks that the child fingerprint matches the binding, requires the v3 capability descriptor, and sends only HTTPS JSON POST. It reads the dedicated minimal data.private_room shape; there is no ordinary-member fallback. Pages are fixed at 10 events. A pathological oversized page fails instead of truncating or automatically retrying with weaker limits. 429/503/timeout mean uncertainty, not proof of removal; observed signed scope denial freezes delivery.

Owner revocation is separately explicit and uses the current service generation:

current = owner.command("private_read.list", room=room)["data"]
queue.enqueue("reader-revoke-1", message.private_read_revoke_intent(
    child["id"], room=room, generation=current["generation"],
))
revoke_status = queue.flush(owner, 1)
revocation = queue.inspect("reader-revoke-1", sensitive=True)
if revocation["state"] != "acknowledged":
    raise RuntimeError("Revocation unresolved; retain the exact queue envelope.")

Revocation is prepaid at enrollment; no remaining allowance is needed. Default lifetime 24 hours, maximum 7 days, no renew/top-up/reactivate. Actual member removal disables all existing read grants in that room; no-op removal does not. Re-add does not revive them. Owner-key rotation and service recovery also disable grants. Resync cannot revive inactive authority: enroll a fresh child and create a fresh catalog, then reconcile acknowledgements deliberately. Schema1 remove/re-add observation limits above remain unchanged; schema2 has explicit epoch invalidation.

Admission caps are 8 active grants per owner and room, 256 global; 4096 historical per owner and room, 32768 global. Historical keys are not garbage-collected. Each grant reserves 16 KiB of owner/service logical capacity, not physical disk space or a lifetime read-byte budget. Long-lived frequent key replacement eventually hits the historical cap. Keep this operational limit in mind before automating churn.