Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Inbox

A same-machine companion bus: one **box** owns one messaging-product session and exposes it as **ICP**. This glossary is the language for that bus, not for any particular consumer (REPL, agent, appliance).

## Language

**ICP**:
Inbox Client Protocol: JSON-RPC 2.0 pub/sub on stdio (NDJSON). The client-facing contract every box speaks.
_Avoid_: IPC, MCP, ACP, A2A, the native product API.

**Box**:
One process, one store, one product session. Speaks only ICP on stdin/stdout.
_Avoid_: daemon-as-product, gateway, service, adapter-as-the-protocol.

**Adapter**:
A box for a specific product (`whatsbox`, `pubsubbox`). The adapter is not ICP. `pubsubbox` omits `profile`; a later hub surface would set `profile: "hub"` (`profile` omitted ≡ chat).
_Avoid_: calling the managed NuGet host “the protocol.”

**Client**:
Anything that speaks ICP to a box (`InboxClient`, a REPL, an appliance). Product HTTP, Bonjour, and operator chrome belong to the client, not the box.
_Avoid_: treating an appliance image as this repository’s product.

**Me**:
The paired product identity string on the session (`SessionSnapshot.me`, event `by: "me"`). Opaque. Chosen according to **me binding**, never `deviceName`, never the operator’s Entra UPN.
_Avoid_: self, userId-as-a-protocol-type, handle, deviceName.

**Me binding**:
How `me` is bound at pair time. **Issued**: the product supplies `me` after auth (WhatsApp LID, Discord bot snowflake). Passing `me` on initialize/pair → `invalid_params`. **Claimed**: the client supplies `me` as input on `initialize` and/or `session.pair`. Omit when claimed → error token `me_required` (not a `$session` kind). `me` on `initialize` (even with `connect: false`) is remembered for the following pair; both set and different → `invalid_params`.
_Avoid_: asserted, impersonating, self-registering, externally registered (those phrases collapse into issued vs claimed).

**Roster**:
The directory’s set of chats this `me` already belongs to. `directory.list` / `directory.get` read the roster. Create and join write it; find does not.
_Avoid_: address book as a second store, live product search.

**Find**:
Live product lookup of chats this `me` could join. Same params as `directory.list` (`query?`, `kind?`, `limit?`, `cursor?`). Returns rows (canonical `topic` + labels). Does not write the roster. When `membership` is `join` or `create`, `subscribe` of a topic with no roster row is `not_found`.
_Avoid_: overloading `directory.list` query, search-as-join.

**Create**:
`directory.create` `{name, topic?}` — always a group. Product assigns the topic if omitted. Writes the roster; does not subscribe. Result is `{topic}`. Same `me` + same topic → no-op. Another occupant → `topic_taken`. `$` / garbage → `invalid_topic`.
_Avoid_: 1:1 create, participant lists on create, join-or-create.

**Join**:
`directory.join` `{id}` — `id` is a canonical topic from find, create, or the roster. Names are `invalid_topic`. Already a member → no-op `{topic}`. A `kind: "user"` find row opens (or reuses) a 1:1; the result topic may `remap`. Writes the roster; does not subscribe. Leave of a non-member → `not_found`.
_Avoid_: alias join, send-to-create-DM.

**Membership**:
Product-side add/remove of this `me` from a chat. Not subscribe. `capabilities.membership` is `"none"` | `"join"` | `"create"` (total order: `create` ⊃ `join` ⊃ roster). `none`: find/join/leave/create → `unsupported`. `join`: find/join/leave. `create`: those plus `directory.create`. Join writes the roster and does not subscribe. Leave ends product membership, `$directory` `remove`s the row, and drops a held subscription. `unsubscribe` never leaves. Find/join/leave/create are **online-only**.
_Avoid_: using subscribe to join, per-product verbs (`rooms.join`), a second boolean for create.

**Subscribe**:
Client intent to receive live `event`s on a canonical topic. Newly subscribed topics get no replay. Unrelated to product membership.
_Avoid_: watch, join, follow.

**Topic**:
Opaque chat id (or `$session` / `$directory`). The client copies it; it does not parse product suffixes.
_Avoid_: JID, snowflake, roomId as client-visible types.
File renamed without changes.
333 changes: 259 additions & 74 deletions docs/INBOX.md

Large diffs are not rendered by default.

226 changes: 226 additions & 0 deletions docs/PUBSUBBOX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
# PubSubBox

PubSubBox is the **Azure Web PubSub Chat adapter** for Inbox Client Protocol (ICP): binary `pubsubbox`.

**Status:** v0.1 (not shipped)
**License (product):** MIT
**Chat API:** [Web PubSub chat](https://learn.microsoft.com/azure/azure-web-pubsub/chat-overview) (`2026-02-01-preview`)

`pubsubbox` owns one Chat hub session (one claimed `userId`) and exposes it as JSON-RPC 2.0 NDJSON on stdio. It is **not** ICP, **not** the GrokBox appliance, and it does **not** bind HTTP or mDNS.

Wire, methods, events, files, errors, client-once rules, and capabilities are specified in **INBOX.md** §20. This document is the Chat mapping: Entra/`az-cli`, hub selection, room ids, the blob overlay, and invoke names that are **not** implemented. It does **not** restate the method table.

Hosted ingress in INBOX.md §2.5 (Azure Web PubSub as a **bus** for Graph/Slack webhooks) is a different use of the same Azure product. This adapter is a **Chat hub participant**, not that bus.

---

## 1. Product

### 1.1 What it is

A locked Chat hub companion that is:

1. An **address book** (rooms this `me` belongs to; find/join/create when advertised).
2. A **live pub/sub** of chats the client asked for.
3. A **same-machine blob channel** (paths on disk; Chat stores a URL, not bytes).

One binary. One process. One store. One Chat `userId`. One hub.

### 1.2 Who it is for

The same ICP **client** as WhatsBox (`InboxClient`, a REPL, an appliance). Pairing UX (device code, claimed name, hub file) is the client’s job. The box does not host `grokbox.local`.

### 1.3 v1 does

- Pair via Entra device code (`az-cli`) and/or a token already in the store.
- Claim `me` (Chat `userId`) on `initialize` / `session.pair`.
- Connect, auto-reconnect, disconnect, logout.
- Directory populate + list/get + live `$directory`.
- `directory.find` / `join` / `leave` / `create` (`membership: "create"`).
- Subscribe by canonical room topic (roster row required).
- Send text; send one blob + optional caption via the storage overlay.
- Receive live `kind: message` / `meta` (membership).

### 1.4 v1 does not

- Quotes, reactions, mark-read, acks (`chat.createMessage` / `markRead` / `react` are `UnsupportedOperation` on the service).
- Message history as live events (Chat history exists; ICP v1 does not emit it except short reconnect catch-up on **current** subscriptions).
- Base-hub groups/connections (`profile: "hub"` — later; omit `profile` ≡ this Chat surface).
- HTTP operator UI, Bonjour, ARM picker RPCs.
- SAS blob URLs (public blobs in v1).
- More than one blob part per send.

---

## 2. Chat profile (on the wire)

| Field | Value |
|---|---|
| `product` | `webpubsub` |
| `identity` | `user` |
| `profile` | **omit** (≡ Chat hub) |
| `capabilities.auth` | `["device_code", "token"]` |
| `me` | `"claimed"` |
| `membership` | `"create"` |
| `reply` | `"none"` |
| `react` | `false` |
| `read` | `"none"` |
| `ack` | `false` |
| `files` | `true` |
| `attachments` | `"single"` |

Advertise this object on `initialize` / `session.status`. Never emit `context`. Outbound `context` / `reply` / `reaction` parts → `unsupported` as INBOX.md requires.

---

## 3. Process and store

Invocation is INBOX.md §2.1 (`pubsubbox [--store ABSOLUTE_PATH]`).

| Path | Contents |
|---|---|
| `<store>/LOCK` | Exclusive lock |
| `<store>/az/` | `az-cli` token cache (Entra). **Not** `~/.azure`. Logout wipes it. |
| `<store>/credentials.json` | Hub name, optional client access URL, optional connection string / Entra app settings. Never on JSON-RPC. |
| `<store>/directory.db` | Roster only. No messages. |

`session.logout` deletes Entra cache, credentials identity, directory, subscriptions. The Chat service is **not** told to delete rooms.

---

## 4. Pairing

### 4.1 Claimed `me`

Required on `initialize` and/or `session.pair`. Omit → `me_required` immediately (no `$session` event). Issued products passing `me` is N/A here. `me` is the Chat `userId` (opaque string the token will stamp). Not the Entra UPN, not `deviceName`.

Initialize `{me:"alice", connect:false}` remembers `alice` for the later pair. Both set and different → `invalid_params`.

### 4.2 Entra device code

Inside `pair({me})` after `me` is known:

1. `az-cli` `StartDeviceCode` (implementation PackageReference; not an ICP adapter).
2. Emit `$session` `device_code` `{user_code, verification_uri, expires_in, interval}`.
3. Poll until token or `pair_error`.

Scopes: ARM as needed to list Web PubSub / Chat hubs; Chat data plane as required to mint a client access URL.

If `credentials.json` already has a usable **client access URL** for this `me` + hub, skip device code (`auth` includes `"token"`).

### 4.3 Hub / resource

Store-only. After Entra:

- Exactly one Chat hub on the reachable resource → write it and continue.
- Zero → `pair_error`.
- Many, and no `hub` in the store → `$session` `token_required` `{path:"credentials.json", hint:"hub"}` and wait (existing file-watch). The client (appliance, REPL) writes `{"hub":"…"}`. No `hub` param on initialize.

Mint the Chat client access URL with `userId = me`. Then Chat WebSocket login → `paired` `{me}` → `online`.

---

## 5. Topics

| Entity | Canonical topic | `kind` |
|---|---|---|
| Room | Chat `roomId` (1–64: letters, digits, `_`, `-`) | `group` |
| User (find only) | Chat `userId` | `user` |
| 1:1 after join | The DM **room** id | `user` or `group` per Chat’s shape; roster key is the **room** |

`directory.find` `{kind:"user"}` may return `{topic: userId, kind: "user"}`. `directory.join` of that topic opens or reuses the 1:1 room, then `$session` `remap` `{from: userId, to: roomId}`. Send/subscribe after that use `roomId`.

`directory.create` `{name, topic?}` → Chat `createRoom`. Same `me` + same `topic` → no-op. Other occupant → `topic_taken`. Always a group.

`directory.join` / `leave` `{id}`: canonical only. Already a member → no-op. Leave of a non-member → `not_found`. Leave drops a held subscription.

Subscribe without a roster row → `not_found`. Join does not subscribe.

---

## 6. Live path

Chat client WebSocket after login. Filter by the current subscribe set. Unsubscribed rooms: protocol-ack if required, then drop; no download.

First login / history pages are **not** live (INBOX.md §7.4). Persist whatever cursor Chat gives; emit only subsequent events. Reliable reconnect may fill **current** subscriptions (catch-up), not a history API.

Membership events → chat `kind: meta` (`join` / `leave`) when already subscribed; `$directory` upsert/remove always as INBOX.md §7.2.

No `ack` events. No `kind: reaction`.

---

## 7. Files overlay

Chat has **no** upload API (`chat.upload`, `chat.getUploadUrl`, `CreateMessage` External → `UnsupportedOperation`). `content.text` and `content.binary` **cannot coexist** (one table `Body` + `BodyType`). Message content cap is 64 KB.

**Convention (adapter-internal; not on JSON-RPC):**

1. Upload the file to the **same storage account** bound to the Chat hub (v1: public container, `publicAccess: blob`).
2. Set blob `Content-Type` from the file; `Content-Disposition` filename when known; **one** metadata key `x-ms-meta-text` = the ICP caption (UTF-8). Azure allows many `x-ms-meta-*` keys; this profile uses **only** `text` so caption round-trips in one header.
3. Chat message: `content.binary = base64(utf8(blobUrl))`. No `content.text`. A raw URL in `binary` is **400**.
4. Text-only send: `content.text`, `BodyType` text. No blob.

**Send (ICP → Chat):**

| ICP `contents` | Chat |
|---|---|
| `[{type:text}]` | `content.text` |
| one blob part, no text | upload; `binary` = base64(url); no `x-ms-meta-text` |
| text + one blob | upload; `x-ms-meta-text` = text; `binary` = base64(url) |
| two blob parts | `unsupported` (`attachments`) |
| blob without `initialize.files` | `files_required` |

Download inbound **immediately** into `{files}/in/{safeTopic}/{id}[.ext]`, then emit the event (SAS later must not race). Public URL in v1; the client still uses the local path after notify.

**Receive (Chat → ICP):**

1. If `content.text` set and no usable `binary` → `[{type:text}]`.
2. If `binary` decodes as UTF-8 `https://…` URL:
- HEAD (need `x-ms-version` for metadata).
- Map `Content-Type` → part `type`: `image/*` → `image` (optional: `image/webp` → `sticker`), `video/*` → `video`, `audio/*` → `audio`, else `document`.
- GET into `files`; `path` on the part.
- If `x-ms-meta-text` present → also a `text` part (caption).
3. Else `binary` that is not a URL → `unknown` (no blob), or write raw bytes as `document` if it is clearly a small in-table payload. Prefer not to pretend in-table bytes are files.
4. Failed download → part with `error`, no `path`.

Unsubscribed inbound: never download (INBOX.md §9).

v1 does **not** use SAS. A later profile may. Do not put the Azure URL on the JSON-RPC event.

---

## 8. Chat invoke whitelist (live `grokbox` hub)

Implemented: `chat.login`, `chat.createRoom`, `chat.getRoom`, `chat.sendTextMessage`, `chat.queryMessageHistory`, member add/remove as the SDK exposes.

**Not implemented** (`UnsupportedOperation` — same as a made-up `chat.*` name): `chat.createMessage`, `chat.sendMessage`, `chat.markRead`, `chat.react`, `chat.upload`, `chat.getUploadUrl`, and the rest of the upload/attachment family.

Do not advertise capabilities that require those invokes. REST PATCH can set `content.binary`; that is an adapter implementation detail for the overlay, not a client RPC.

---

## 9. Errors (adapter)

All INBOX.md tokens. Additionally:

| Situation | Token |
|---|---|
| Claimed pair, no `me` | `me_required` |
| Create topic held by someone else | `topic_taken` |
| Find/join/leave/create while not `online` | `disconnected` |
| Reply / react / read | `unsupported` + `error.data.capability` |

`az-cli` failures during pair → `pair_error`.

---

## 10. What stays off the wire

Azure resource ids, connection strings, `content.binary`, blob URLs, `x-ms-meta-text`, ARM, `az-cli`, hub names (except via `token_required` hint + store file). The JSON-RPC client copies opaque topics and `me`, renders `device_code` / `token_required`, and calls find/join/create as INBOX.md.

---

## 11. Difficulty

**2 / 5.** Second-product proof for claimed `me` and membership verbs. Pairing is Entra + hub file, not QR. Files are a storage convention, not a Chat feature.
2 changes: 2 additions & 0 deletions docs/WHATSBOX.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ Agents and local apps that can spawn a process, speak newline-delimited JSON-RPC
| `identity` | `user` |
| `profile` | omit (same as `identity`) |
| `capabilities.auth` | `["qr"]` |
| `me` | `"issued"` |
| `membership` | `"none"` |
| `reply` | `"quote"` |
| `react` | `true` |
| `read` | `"message"` |
Expand Down
5 changes: 5 additions & 0 deletions docs/adr/0001-claimed-me-is-pair-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Claimed `me` is pair input, not a `$session` wait

QR and device-code complete off-RPC (phone scan, Microsoft’s page). A claimed `me` cannot: the product does not know the name until the client sends it, and JSON-RPC cannot patch an in-flight `pair`. We require `me` on `initialize` / `session.pair` when `capabilities.me` is `claimed`; omit → error token `me_required`. No `me_required` event and no `token_required`-style file watch for a nickname. Issued products that receive `me` must not pretend to honor it.

**Considered:** hang `pair` and inject `me` later; clone `token_required`; a second `session.claim` RPC. All are extra channels for a field the client can already pass, and they fight `connect:true` one-shot.
5 changes: 5 additions & 0 deletions docs/adr/0002-membership-is-a-total-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Membership is `none` | `join` | `create`, and subscribe is not join

Some products have no join API (WhatsApp — the phone is the registrar). Some can join but not create. Pubsubbox has no external registrar, so ICP must create. One string enum with implication (`create` ⊃ `join` ⊃ roster) advertises that without a boolean soup or an `auth`-style array. `directory.find` / `join` / `leave` / `create` are the verbs; `subscribe` stays live-event intent (RFC-1). Join does not subscribe; leave drops a held subscription because the topic is gone.

**Considered:** `membership: join` plus a separate `create` boolean (can contradict); a membership array (`find`/`join`/`leave`/`create`); overloading `subscribe` or `messages.send` to create chats (ghost topics, RFC-1).
5 changes: 5 additions & 0 deletions docs/adr/0003-pubsub-blobs-are-url-in-content-binary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Pubsubbox files are a blob URL in Chat `content.binary`

Chat has no upload API (`chat.upload` / `CreateMessage` External are `UnsupportedOperation`). `content.text` and `content.binary` cannot coexist (one table `Body` + `BodyType`). We upload the file to the hub’s storage account, put `base64(utf8(url))` in `content.binary`, and put the ICP caption on the blob as `x-ms-meta-text`. Pubsubbox maps that to `files: true` / `attachments: "single"` in both directions. The JSON-RPC client never sees Azure URLs or metadata keys.

**Considered:** base64 of the whole file in `content.binary` (64 KB cap); URL in `content.text` and sniff (collides with ordinary links); advertise `files: false`.
3 changes: 3 additions & 0 deletions docs/adr/0004-omit-profile-means-chat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Pubsubbox omits `profile`; omitted ≡ chat

`product: "webpubsub"` with no `profile` is the Chat hub. A later base-hub adapter on the same product string would advertise `profile: "hub"`. Telegram still uses `profile` when one binary is bot or user. We do not emit `profile: "chat"` just to have a string.
5 changes: 4 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,16 @@ no connect. Pass `InitializeOptions` for blobs, initial topics, or
|---|---|---|
| `InitializeAsync` | `initialize` | `SessionSnapshot` |
| `ConnectAsync` | `session.connect` | `SessionSnapshot` |
| `PairAsync` | `session.pair` | `SessionSnapshot` |
| `PairAsync` | `session.pair` | `SessionSnapshot` (`PairAsync(me)` when `capabilities.me` is `claimed`) |
| `DisconnectAsync` | `session.disconnect` | `SessionSnapshot` |
| `LogoutAsync` | `session.logout` | `SessionSnapshot` (`new`) |
| `StatusAsync` | `session.status` | `SessionSnapshot` |
| `SubscribeAsync` / `UnsubscribeAsync` | `subscribe` / `unsubscribe` | `TopicsResult` (canonical topics) |
| `ListDirectoryAsync` | `directory.list` | `DirectoryListResult` |
| `GetDirectoryAsync` | `directory.get` | `DirectoryRow` |
| `FindDirectoryAsync` | `directory.find` | `DirectoryListResult` |
| `JoinDirectoryAsync` / `LeaveDirectoryAsync` | `directory.join` / `directory.leave` | `TopicResult` |
| `CreateDirectoryAsync` | `directory.create` | `TopicResult` |
| `SendAsync` / `ReactAsync` | `messages.send` | `SendResult` (`Id`, canonical `Topic`) |
| `ReadAsync` | `messages.read` | `ReadResult` |

Expand Down
Loading
Loading