@ours.network/notifications 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +98 -0
- package/README.md +231 -0
- package/dist/cli.js +446 -0
- package/dist/config.js +104 -0
- package/dist/daemon.js +353 -0
- package/dist/http.js +31 -0
- package/dist/log-store.js +48 -0
- package/dist/presence.js +157 -0
- package/dist/provision.js +29 -0
- package/dist/push.js +6 -0
- package/dist/web-push-sender.js +41 -0
- package/mufl_code/20E39399689579D5AFA5132C59DEDDD328C3BCA1A74D546F2193B36585DDD283.muflo +0 -0
- package/mufl_code/config.mufl +21 -0
- package/mufl_code/core/.github/FUNDING.yml +3 -0
- package/mufl_code/core/.github/workflows/docs.yml +39 -0
- package/mufl_code/core/.github/workflows/tests.yml +66 -0
- package/mufl_code/core/COMMERCIAL-LICENCE.md +25 -0
- package/mufl_code/core/COMPATIBILITY.md +221 -0
- package/mufl_code/core/CONTRIBUTING.md +37 -0
- package/mufl_code/core/LICENSE +98 -0
- package/mufl_code/core/NOTIFICATIONS_CONTRACT.md +290 -0
- package/mufl_code/core/README.md +91 -0
- package/mufl_code/core/SECURITY.md +40 -0
- package/mufl_code/core/TRADEMARKS.md +24 -0
- package/mufl_code/core/a2a_capabilities.mm +449 -0
- package/mufl_code/core/a2a_cluster.mm +601 -0
- package/mufl_code/core/a2a_control.mm +90 -0
- package/mufl_code/core/a2a_messaging.mm +4817 -0
- package/mufl_code/core/a2a_monitoring.mm +94 -0
- package/mufl_code/core/a2a_notification_integration.mm +144 -0
- package/mufl_code/core/a2a_notifications.mm +1127 -0
- package/mufl_code/core/a2a_protocol.mm +246 -0
- package/mufl_code/core/a2a_versions.mm +801 -0
- package/mufl_code/core/config.mufl +29 -0
- package/mufl_code/core/docs/.vitepress/config.mts +58 -0
- package/mufl_code/core/docs/citations.json +688 -0
- package/mufl_code/core/docs/guide/01-vendor-the-core.md +50 -0
- package/mufl_code/core/docs/guide/02-configure-and-compile.md +100 -0
- package/mufl_code/core/docs/guide/03-wire-the-host.md +154 -0
- package/mufl_code/core/docs/guide/04-connect-and-message.md +156 -0
- package/mufl_code/core/docs/guide/05-test-your-app.md +75 -0
- package/mufl_code/core/docs/guide/index.md +63 -0
- package/mufl_code/core/docs/how-it-works/capabilities-and-control.md +77 -0
- package/mufl_code/core/docs/how-it-works/cluster.md +65 -0
- package/mufl_code/core/docs/how-it-works/identity.md +56 -0
- package/mufl_code/core/docs/how-it-works/invites-and-contacts.md +76 -0
- package/mufl_code/core/docs/how-it-works/messaging.md +55 -0
- package/mufl_code/core/docs/how-it-works/monitoring-and-config.md +72 -0
- package/mufl_code/core/docs/how-it-works/overview.md +45 -0
- package/mufl_code/core/docs/how-it-works/versioning.md +116 -0
- package/mufl_code/core/docs/index.md +27 -0
- package/mufl_code/core/docs/package-lock.json +3467 -0
- package/mufl_code/core/docs/package.json +15 -0
- package/mufl_code/core/docs/reference/contributing.md +26 -0
- package/mufl_code/core/docs/reference/glossary.md +108 -0
- package/mufl_code/core/docs/reference/implementations.md +23 -0
- package/mufl_code/core/docs/reference/modules.md +19 -0
- package/mufl_code/core/docs/scripts/check-citations.mjs +36 -0
- package/mufl_code/core/docs/scripts/check-denylist.mjs +30 -0
- package/mufl_code/core/docs/scripts/gen-llms.mjs +32 -0
- package/mufl_code/core/docs/workflows/cluster.md +89 -0
- package/mufl_code/core/docs/workflows/contact-exchange.md +60 -0
- package/mufl_code/core/docs/workflows/contact-restore.md +60 -0
- package/mufl_code/core/docs/workflows/control-verbs.md +70 -0
- package/mufl_code/core/docs/workflows/index.md +57 -0
- package/mufl_code/core/docs/workflows/introductions.md +60 -0
- package/mufl_code/core/docs/workflows/messaging.md +75 -0
- package/mufl_code/core/docs/workflows/monitoring.md +84 -0
- package/mufl_code/core/docs/workflows/notifications.md +109 -0
- package/mufl_code/core/release-notes/0.1.md +60 -0
- package/mufl_code/core/release-notes/0.3.md +22 -0
- package/mufl_code/core/release-notes/0.4.md +97 -0
- package/mufl_code/core/tests/README.md +83 -0
- package/mufl_code/core/tests/compat/README.md +82 -0
- package/mufl_code/core/tests/compat/compat.mjs +245 -0
- package/mufl_code/core/tests/compat/compat_peer.mjs +146 -0
- package/mufl_code/core/tests/compat/run_compat.sh +96 -0
- package/mufl_code/core/tests/corpus.mjs +139 -0
- package/mufl_code/core/tests/corpus_actor.mu +242 -0
- package/mufl_code/core/tests/fatcompile_actor.mu +52 -0
- package/mufl_code/core/tests/mig.mjs +805 -0
- package/mufl_code/core/tests/mig_actor.mu +518 -0
- package/mufl_code/core/tests/migapp.mjs +431 -0
- package/mufl_code/core/tests/migapp_actor.mu +294 -0
- package/mufl_code/core/tests/mufl_semantics/run.sh +69 -0
- package/mufl_code/core/tests/mufl_semantics/t11_three_version.mufl +47 -0
- package/mufl_code/core/tests/mufl_semantics/t2_extra_fields.mufl +11 -0
- package/mufl_code/core/tests/mufl_semantics/t7_runtime.mufl +30 -0
- package/mufl_code/core/tests/mufl_semantics/t9_passthrough.mufl +19 -0
- package/mufl_code/core/tests/mufl_semantics/t_str_order.mufl +19 -0
- package/mufl_code/core/tests/notif.mjs +755 -0
- package/mufl_code/core/tests/notif_actor.mu +450 -0
- package/mufl_code/core/tests/protocol_container.mm +18 -0
- package/mufl_code/core/tests/run.sh +65 -0
- package/mufl_code/core/tests/run_corpus.sh +51 -0
- package/mufl_code/core/tests/run_fatcompile.sh +40 -0
- package/mufl_code/core/tests/run_mig.sh +51 -0
- package/mufl_code/core/tests/run_migapp.sh +51 -0
- package/mufl_code/core/tests/run_notif.sh +57 -0
- package/mufl_code/core/tests/test.mjs +750 -0
- package/mufl_code/core/tests/test_actor.mu +564 -0
- package/mufl_code/core/tests/test_common.mjs +93 -0
- package/mufl_code/core/version.mm +24 -0
- package/mufl_code/packet.mu +170 -0
- package/mufl_code/protocol_container.mm +18 -0
- package/package.json +43 -0
- package/scripts/gen-vapid.mjs +16 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Introductions (core.connect)
|
|
2
|
+
|
|
3
|
+
A control plane bound to **both** parties can connect two nodes without a new out-of-band
|
|
4
|
+
invite: it already holds each managed node's self-signed address document (captured in
|
|
5
|
+
`peer_ads` when the node was established as its contact), so an introduction is simply sending
|
|
6
|
+
each node the *other's* signed document. There is no SAS and no confirmation step — the
|
|
7
|
+
bound-CP channel is the authorization, and the node-side capability gate is the authoritative
|
|
8
|
+
"do I accept introductions".
|
|
9
|
+
|
|
10
|
+
Traced from [`a2a_messaging.mm`](https://github.com/adapt-toolkit/ours-mufl-core/blob/main/a2a_messaging.mm)
|
|
11
|
+
(`introduce`, `introduce_to_group`, `emit_pair`, `handle_ingest_connect_descriptor`,
|
|
12
|
+
`require_cluster_cp_or_abort`).
|
|
13
|
+
|
|
14
|
+
```mermaid
|
|
15
|
+
sequenceDiagram
|
|
16
|
+
autonumber
|
|
17
|
+
participant H as CP host
|
|
18
|
+
participant CP as Control-plane packet
|
|
19
|
+
participant A as Node A packet
|
|
20
|
+
participant B as Node B packet
|
|
21
|
+
|
|
22
|
+
H->>CP: introduce ($peer_a, $peer_b)
|
|
23
|
+
Note over CP: both must be established contacts<br/>(peer_ads holds their signed address documents)
|
|
24
|
+
CP->>A: ingest_connect_descriptor ($peer_ad of B, $peer_name) - encrypted channel
|
|
25
|
+
CP->>B: ingest_connect_descriptor ($peer_ad of A, $peer_name) - encrypted channel
|
|
26
|
+
|
|
27
|
+
Note over A: gates (same on both nodes):<br/>1. require_cluster_cp_or_abort - relay came from MY bound CP,<br/>or from the CP my root designated (verified against pinned root keys)<br/>2. my own live manifest advertises core.connect (self_supports)<br/>3. the peer document's self-signature verifies (proof-of-possession)
|
|
28
|
+
Note over A: new contact registered immediately under the CP-supplied<br/>display name - already-known peer just refreshes its stored document
|
|
29
|
+
A-->>A: notify $introduced (or $reintroduced) to its host
|
|
30
|
+
B-->>B: notify $introduced (or $reintroduced) to its host
|
|
31
|
+
|
|
32
|
+
Note over A,B: A and B are now direct contacts - messages flow peer-to-peer,<br/>the CP is not on the path
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Variants and entry points
|
|
36
|
+
|
|
37
|
+
- **`introduce`** — the 1:1 pair, host-fired on the CP.
|
|
38
|
+
- **`introduce_to_group`** — fan-out: one joiner is introduced to every member of a list (the
|
|
39
|
+
cluster-root case: a new subagent meets all existing ones). Same `emit_pair` relays, once per
|
|
40
|
+
member, in a single transaction.
|
|
41
|
+
- **`core.connect` / `introduce` verb** — the same composition reached through the
|
|
42
|
+
[control-envelope dispatch](./control-verbs.md) (`connect_handler`), so a remote controller
|
|
43
|
+
can trigger it.
|
|
44
|
+
|
|
45
|
+
## Trust details
|
|
46
|
+
|
|
47
|
+
- The **CP-supplied display name is unauthenticated by design** — a receiver-chosen label. The
|
|
48
|
+
document's self-signature is the only identity the receiver trusts; the cid is key-derived,
|
|
49
|
+
so an existing contact's keys can never be silently overwritten by a different keyset.
|
|
50
|
+
- The gate accepts a relay on **either** of two grounds: the node ran the 6-digit ceremony
|
|
51
|
+
itself (sender is its own `monitoring_proxy`), or — for cluster children — the sender is the
|
|
52
|
+
CP the node's *root* designated, re-verified against the pinned root identity on every call
|
|
53
|
+
(`root_cp_binding` + `root_ad`), with no per-child ceremony.
|
|
54
|
+
- The node-side manifest check makes "supports introductions" **enforced**, not advisory: a
|
|
55
|
+
node whose manifest does not advertise `core.connect` rejects the relay regardless of who
|
|
56
|
+
sent it. The CP-side pre-check (don't try to introduce a node that doesn't support it) is a
|
|
57
|
+
courtesy the daemon performs via `get_manifest`.
|
|
58
|
+
|
|
59
|
+
For how a cluster child receives its CP contact in the first place (host-injected, not
|
|
60
|
+
network-introduced), see [Cluster lifecycle](./cluster.md).
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Send & receive messages
|
|
2
|
+
|
|
3
|
+
The message path is the protocol's chokepoint: every text and file between contacts flows
|
|
4
|
+
through `send_message` / `handle_receive_message` (and their file twins), which is also where
|
|
5
|
+
the forced monitoring copy is generated. Message **storage is app-side**: the core validates,
|
|
6
|
+
resolves the sender, and hands the record to the app-injected hooks (`on_message_sent`,
|
|
7
|
+
`on_message_received`, …) wired at `a2a_messaging::init`.
|
|
8
|
+
|
|
9
|
+
Traced from [`a2a_messaging.mm`](https://github.com/adapt-toolkit/ours-mufl-core/blob/main/a2a_messaging.mm)
|
|
10
|
+
(`send_message`, `handle_receive_message`, `send_file`, `handle_receive_file`,
|
|
11
|
+
`monitor_copy_actions`).
|
|
12
|
+
|
|
13
|
+
## Text message
|
|
14
|
+
|
|
15
|
+
```mermaid
|
|
16
|
+
sequenceDiagram
|
|
17
|
+
autonumber
|
|
18
|
+
participant SH as Sender host
|
|
19
|
+
participant S as Sender packet
|
|
20
|
+
participant P as Peer packet
|
|
21
|
+
participant PH as Peer host
|
|
22
|
+
participant CP as Bound control plane (optional)
|
|
23
|
+
|
|
24
|
+
SH->>S: send_message ($contact, $text, $reply_to?)
|
|
25
|
+
Note over S: resolve_contact - mint fresh $wire_id
|
|
26
|
+
alt peer address document present
|
|
27
|
+
S->>P: receive_message ($text, $wire_id, $reply_to) - encrypted channel
|
|
28
|
+
Note over S: on_message_sent hook (app storage)
|
|
29
|
+
S->>CP: receive_monitoring_copy (direction "out") - forced, if bound
|
|
30
|
+
S-->>SH: $sent_to, $wire_id
|
|
31
|
+
Note over P: check encrypted - sender from envelope $from -<br/>contact lookup gives $sender_name (NIL if unknown)
|
|
32
|
+
Note over P: on_message_received hook - the app decides<br/>storage or rejection of an unknown sender
|
|
33
|
+
P->>CP: receive_monitoring_copy (direction "in") - forced, if the PEER has a CP bound
|
|
34
|
+
P-->>PH: app-hook actions (store, notify)
|
|
35
|
+
else degraded contact (no peer address document)
|
|
36
|
+
Note over S: queue in deferred_msgs (cap 50 per contact)
|
|
37
|
+
S->>P: request_contact_restore - see the contact-restore flow
|
|
38
|
+
S-->>SH: $deferred TRUE, $queued
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The inbound name is `::actor::receive_message` (`receive_message_tx`) — kept for compatibility
|
|
43
|
+
with pre-migration clients; consumers keep a one-line `::actor::` shim that delegates to this
|
|
44
|
+
library.
|
|
45
|
+
|
|
46
|
+
## File transfer
|
|
47
|
+
|
|
48
|
+
`send_file` mirrors `send_message` exactly — same `wire_id` namespace (a reply can point across
|
|
49
|
+
messages and files), same hook pattern (`on_file_sent` / `on_file_received`), and the inbound
|
|
50
|
+
rides `::a2a_messaging::receive_file` (`receive_file_tx`, a library-routed name — no legacy
|
|
51
|
+
shim). Two deliberate differences:
|
|
52
|
+
|
|
53
|
+
- **No queueing**: `send_file` to a degraded contact aborts fast with an explicit error instead
|
|
54
|
+
of queueing bulk binary; a `send_message` to the same contact queues and drives the restore.
|
|
55
|
+
- **Metadata-only monitoring**: the forced copy for a file carries `file_monitor_summary` —
|
|
56
|
+
name, mime, size — never the bytes.
|
|
57
|
+
|
|
58
|
+
## Reading, receipts, and reply threading
|
|
59
|
+
|
|
60
|
+
There is **no mark-read or read-receipt transaction in the core**. The inbox lifecycle (unread /
|
|
61
|
+
processed, receipts, retention) belongs to the consuming app, built on the storage hooks. What
|
|
62
|
+
the core does provide cross-side is:
|
|
63
|
+
|
|
64
|
+
- `$wire_id` — a stable id stamped on every message and file by the sender; the receiver's own
|
|
65
|
+
message ids stay local to its inbox.
|
|
66
|
+
- `$reply_to` — an optional pointer (`$wire_id` + optional sentence index) that threads a reply
|
|
67
|
+
to an earlier message on either side.
|
|
68
|
+
|
|
69
|
+
## Forced monitoring copies
|
|
70
|
+
|
|
71
|
+
Both the send and receive paths append `monitor_copy_actions` *after* the app hook, as
|
|
72
|
+
unconditional core code. It self-gates on `monitoring_proxy` — nothing is emitted when no
|
|
73
|
+
control plane is bound; when one is, the copy rides a distinct transaction name
|
|
74
|
+
(`receive_monitoring_copy`), so copy traffic is never itself monitored. See
|
|
75
|
+
[Monitoring bind & copies](./monitoring.md).
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Monitoring bind & forced copies
|
|
2
|
+
|
|
3
|
+
Monitoring is bound by a **6-digit ceremony** and enforced at the message chokepoint: once a
|
|
4
|
+
control plane is bound, every send and receive emits one re-encrypted copy to it as
|
|
5
|
+
unconditional core code. The gate state (`monitoring_proxy`, `proxy_pending`) is `hidden` in
|
|
6
|
+
`a2a_messaging`, so only that library can mutate it — an app cannot switch monitoring off by
|
|
7
|
+
assignment.
|
|
8
|
+
|
|
9
|
+
Traced from [`a2a_messaging.mm`](https://github.com/adapt-toolkit/ours-mufl-core/blob/main/a2a_messaging.mm)
|
|
10
|
+
(`set_proxy_pending`, `do_verify_proxy_code`, `monitor_copy_actions`, `disable_monitoring`),
|
|
11
|
+
[`a2a_cluster.mm`](https://github.com/adapt-toolkit/ours-mufl-core/blob/main/a2a_cluster.mm)
|
|
12
|
+
(`monitoring_handler`), and
|
|
13
|
+
[`a2a_monitoring.mm`](https://github.com/adapt-toolkit/ours-mufl-core/blob/main/a2a_monitoring.mm)
|
|
14
|
+
(`handle_receive_monitoring_copy`).
|
|
15
|
+
|
|
16
|
+
## The bind ceremony
|
|
17
|
+
|
|
18
|
+
The code is generated host-side (MUFL has no random source), expires after 300 seconds, and
|
|
19
|
+
allows 3 attempts. Both entry paths — the legacy host-relayed `verify_proxy_code` transaction
|
|
20
|
+
and the `core.monitoring` / `bind` capability verb — run the **same** ceremony function,
|
|
21
|
+
`do_verify_proxy_code`.
|
|
22
|
+
|
|
23
|
+
```mermaid
|
|
24
|
+
sequenceDiagram
|
|
25
|
+
autonumber
|
|
26
|
+
participant H as Node host
|
|
27
|
+
participant N as Node packet
|
|
28
|
+
participant CP as Control-plane node
|
|
29
|
+
|
|
30
|
+
Note over H: operator initiates - host generates a 6-digit code<br/>and shows it to the user out-of-band
|
|
31
|
+
H->>N: set_proxy_pending ($code, $proxy)
|
|
32
|
+
Note over N: proxy_pending = code + proxy cid + created_at + attempts
|
|
33
|
+
|
|
34
|
+
CP->>N: control_message carrying core.monitoring / bind ($code)
|
|
35
|
+
Note over N: dispatch - auth class "bootstrap" (pre-bind, code possession IS the auth)
|
|
36
|
+
Note over N: monitoring_handler - do_verify_proxy_code:<br/>expiry 300s - sender must be the pending proxy -<br/>wrong code burns one of 3 attempts (returned as data,<br/>not abort, so the counter persists)
|
|
37
|
+
alt code verifies
|
|
38
|
+
Note over N: monitoring_proxy = ($proxy_cid, $bound_at) - pending cleared
|
|
39
|
+
N-->>CP: response: $manifest, $members, $config, $version (one-shot bootstrap)
|
|
40
|
+
else wrong code / expired / wrong sender
|
|
41
|
+
N-->>CP: response: bind_failed + reason
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Forced copies at the chokepoint
|
|
46
|
+
|
|
47
|
+
```mermaid
|
|
48
|
+
sequenceDiagram
|
|
49
|
+
autonumber
|
|
50
|
+
participant N as Monitored node
|
|
51
|
+
participant P as Peer
|
|
52
|
+
participant CP as Bound control plane
|
|
53
|
+
|
|
54
|
+
N->>P: send_message - encrypted channel
|
|
55
|
+
N->>CP: receive_monitoring_copy (direction "out", $peer_cid, $date, $body)
|
|
56
|
+
P->>N: receive_message from the peer
|
|
57
|
+
N->>CP: receive_monitoring_copy (direction "in")
|
|
58
|
+
Note over CP: gate: sender must be a known contact - copy $version must be 1
|
|
59
|
+
Note over CP: on_monitoring_copy_received hook - storage is the CP app's
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Properties, all visible in `monitor_copy_actions`:
|
|
63
|
+
|
|
64
|
+
- **Self-gating**: with no `monitoring_proxy` bound the function returns no actions — zero
|
|
65
|
+
overhead for unmonitored nodes.
|
|
66
|
+
- **No recursion**: copies ride the distinct name `::a2a_monitoring::receive_monitoring_copy`
|
|
67
|
+
(`receive_monitoring_copy_tx`), never `send_message`, so copy traffic is not itself monitored.
|
|
68
|
+
- **Fire-and-forget**: no local queue, no liveness wait — an offline CP's copies sit with the
|
|
69
|
+
ADAPT broker.
|
|
70
|
+
- **Files are metadata-only**: name, mime, and size; never the bytes.
|
|
71
|
+
- **App hooks cannot suppress it**: the copy is appended after the app's storage hook, in core
|
|
72
|
+
code.
|
|
73
|
+
|
|
74
|
+
## Disable
|
|
75
|
+
|
|
76
|
+
Disabling is **CP-authenticated**: the request must arrive external and encrypted, and the
|
|
77
|
+
sender must *be* the bound control plane — there is deliberately no user-origin, app-callable
|
|
78
|
+
clear. Both `disable_monitoring` (direct transaction) and the `core.monitoring` / `disable`
|
|
79
|
+
verb clear the binding via `do_disable_monitoring`. For hosted cluster children there is one
|
|
80
|
+
host-mediated exception, `host_clear_child_monitoring` — a child's monitoring was propagated
|
|
81
|
+
from the root's ceremony, so the root operator revokes it; see
|
|
82
|
+
[Cluster lifecycle](./cluster.md).
|
|
83
|
+
|
|
84
|
+
`get_monitoring_status` is the readonly view: `$monitored`, `$proxy_pending`, `$proxy_cid`.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Notifications
|
|
2
|
+
|
|
3
|
+
The notification path lets a **sender** wake a **recipient's** device without being able to
|
|
4
|
+
read anyone else's traffic. A recipient registers WebPush bindings with a notification
|
|
5
|
+
**service** and asks it to mint one **scoped token** per contact; each contact receives only its
|
|
6
|
+
own handout (`service cid + its scoped token`). A sender posts with that token as a **bare signed
|
|
7
|
+
send** — the service is typically a packet it has never met — and the service validates the token,
|
|
8
|
+
checks the sender binding and the receive-mute flag, then hands the payload to its host, which
|
|
9
|
+
does the WebPush egress. There is **no reply leg**: an aborted post is invisible to the sender, so
|
|
10
|
+
a mute is unprobeable.
|
|
11
|
+
|
|
12
|
+
Traced from
|
|
13
|
+
[`a2a_notifications.mm`](https://github.com/adapt-toolkit/ours-mufl-core/blob/main/a2a_notifications.mm)
|
|
14
|
+
(`notify_register`, `notify_issue_tokens`, `export_notify_address`, `send_notification` →
|
|
15
|
+
`post_notification`, `notify_mark_read`, `notify_set_sender_muted`, `notify_rotate_token`,
|
|
16
|
+
`notify_revoke_contact_tokens`).
|
|
17
|
+
|
|
18
|
+
## Register and issue per-contact tokens
|
|
19
|
+
|
|
20
|
+
The recipient registers over the encrypted channel (the service is a normal contact of the
|
|
21
|
+
recipient), then asks the service to mint a scoped token for each of its contacts.
|
|
22
|
+
|
|
23
|
+
```mermaid
|
|
24
|
+
sequenceDiagram
|
|
25
|
+
autonumber
|
|
26
|
+
participant RH as Recipient host
|
|
27
|
+
participant R as Recipient packet
|
|
28
|
+
participant NS as Service packet
|
|
29
|
+
participant NSH as Service host
|
|
30
|
+
|
|
31
|
+
RH->>R: notify_register ($service, $bindings)
|
|
32
|
+
R->>NS: register ($bindings) - encrypted channel
|
|
33
|
+
Note over NS: store registration + WebPush bindings
|
|
34
|
+
NS->>R: confirm_registration ($ok) - encrypted channel
|
|
35
|
+
Note over R: registration confirmed (app state)
|
|
36
|
+
|
|
37
|
+
RH->>R: notify_issue_tokens ($service, $contacts)
|
|
38
|
+
R->>NS: issue_tokens ($contacts) - encrypted channel
|
|
39
|
+
Note over NS: mint one scoped token per (recipient, sender);<br/>idempotent, re-issue keeps the existing token
|
|
40
|
+
NS->>R: confirm ($sender_tokens) - the per-contact map
|
|
41
|
+
Note over R: my_notify_contact_tokens updated
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Hand a contact its notification address
|
|
45
|
+
|
|
46
|
+
The recipient exports the per-contact handout and delivers it to that contact out-of-band (in the
|
|
47
|
+
reference stack, over the normal messaging channel).
|
|
48
|
+
|
|
49
|
+
```mermaid
|
|
50
|
+
sequenceDiagram
|
|
51
|
+
autonumber
|
|
52
|
+
participant RH as Recipient host
|
|
53
|
+
participant R as Recipient packet
|
|
54
|
+
|
|
55
|
+
RH->>R: export_notify_address ($service, $contact)
|
|
56
|
+
R-->>RH: $address (service cid + that contact's scoped token)
|
|
57
|
+
Note over RH: deliver $address to the contact out-of-band
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Send a notification
|
|
61
|
+
|
|
62
|
+
The sender holds the recipient's handout and posts a bare signed send. The service validates and
|
|
63
|
+
never replies.
|
|
64
|
+
|
|
65
|
+
```mermaid
|
|
66
|
+
sequenceDiagram
|
|
67
|
+
autonumber
|
|
68
|
+
participant SH as Sender host
|
|
69
|
+
participant S as Sender packet
|
|
70
|
+
participant NS as Service packet
|
|
71
|
+
participant NSH as Service host
|
|
72
|
+
participant Dev as Recipient device
|
|
73
|
+
|
|
74
|
+
SH->>S: send_notification ($address, $payload)
|
|
75
|
+
S->>NS: post_notification ($token, $recipient_cid, $payload) - bare signed send
|
|
76
|
+
Note over NS: validate in order - minted by THIS service, live index entry,<br/>sender binding ($from == $scope), byte-equality, not muted
|
|
77
|
+
NS-->>NSH: $notify_agent (deliver payload to the recipient's bindings)
|
|
78
|
+
NSH->>Dev: WebPush (aes128gcm + VAPID) to each stored binding
|
|
79
|
+
Note over NS: no reply leg - an aborted post is invisible to the sender
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Read, mute, rotate, revoke
|
|
83
|
+
|
|
84
|
+
Runtime management from the recipient. Muting is a runtime flag only (the token is unchanged
|
|
85
|
+
across a mute/unmute cycle); rotation replaces one sender's slot or all of them (a panic button);
|
|
86
|
+
revocation drops the index entry and the slot with no re-mint, so old handouts abort at the index
|
|
87
|
+
lookup.
|
|
88
|
+
|
|
89
|
+
```mermaid
|
|
90
|
+
sequenceDiagram
|
|
91
|
+
autonumber
|
|
92
|
+
participant RH as Recipient host
|
|
93
|
+
participant R as Recipient packet
|
|
94
|
+
participant NS as Service packet
|
|
95
|
+
|
|
96
|
+
RH->>R: notify_mark_read ($service, $notif_ids)
|
|
97
|
+
R->>NS: mark_read ($notif_ids)
|
|
98
|
+
|
|
99
|
+
RH->>R: notify_set_sender_muted ($service, $contact, $muted)
|
|
100
|
+
R->>NS: set_sender_muted ($sender, $muted)
|
|
101
|
+
Note over NS: runtime-only; token unchanged
|
|
102
|
+
|
|
103
|
+
RH->>R: notify_rotate_token ($service, $contact?)
|
|
104
|
+
R->>NS: rotate_token ($sender?) - one slot, or all (panic button)
|
|
105
|
+
|
|
106
|
+
RH->>R: notify_revoke_contact_tokens ($service, $contacts)
|
|
107
|
+
R->>NS: revoke_sender_tokens ($senders)
|
|
108
|
+
Note over NS: delete index entry + slot, no re-mint;<br/>old handouts abort at the index lookup
|
|
109
|
+
```
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# core 0.1 — MIN (additive): `a2a_notifications` — protocol-level Notifications-as-a-Service
|
|
2
|
+
|
|
3
|
+
**Version:** 0.1.0 · **Bump:** MIN (additive) · One new library defining the
|
|
4
|
+
notification-service node surface + a `send_notification` transaction parallel
|
|
5
|
+
to `send_message`; no existing wire shape or verification path touched.
|
|
6
|
+
|
|
7
|
+
## What shipped
|
|
8
|
+
|
|
9
|
+
- **`a2a_notifications.mm`** — one library, two halves:
|
|
10
|
+
- *Client half* (every consumer): `notify_register`, `notify_update_bindings`,
|
|
11
|
+
`notify_rotate_token`, `notify_unregister`, `notify_mark_read`,
|
|
12
|
+
`send_notification`, `export_notify_address` (readonly), inbound
|
|
13
|
+
`confirm_registration`.
|
|
14
|
+
- *Service half* (a notifier packet): `set_vapid_public_key`, inbound
|
|
15
|
+
`register`, `update_bindings`, `rotate_token`, `unregister`,
|
|
16
|
+
`post_notification`, `mark_read`; app hooks for storage/egress
|
|
17
|
+
(`on_notification_posted`, `on_notifications_marked_read`,
|
|
18
|
+
`on_unregistered`; client-side `on_notify_registration`).
|
|
19
|
+
- `cap_notifications = "core.notifications"` reserved in `a2a_capabilities.mm`
|
|
20
|
+
(constant only — no control verbs, no dispatch change).
|
|
21
|
+
- `config.mufl` exports the library; `export_notify_state` /
|
|
22
|
+
`import_notify_state` persistence helpers.
|
|
23
|
+
- Contract doc: [`NOTIFICATIONS_CONTRACT.md`](../NOTIFICATIONS_CONTRACT.md).
|
|
24
|
+
|
|
25
|
+
## New/changed transactions
|
|
26
|
+
|
|
27
|
+
All new, library-routed `::a2a_notifications::<name>` (no `::actor::` shims).
|
|
28
|
+
Nothing existing changed. `post_notification` is the first non-invite inbound
|
|
29
|
+
accepted as a **bare signed send** from an unknown packet — the shared token
|
|
30
|
+
is the sole authorization; validation is version → minted-by-me → live index
|
|
31
|
+
entry → `_value_id` byte-equality vs the stored token → payload cap (≤ 4000).
|
|
32
|
+
|
|
33
|
+
## Integration TODO for consumers
|
|
34
|
+
|
|
35
|
+
Nothing required — consumers recompile unchanged (new inbound names are never
|
|
36
|
+
sent to packets that don't play a half). To adopt: call
|
|
37
|
+
`a2a_notifications::init` beside `a2a_messaging::init`, compose
|
|
38
|
+
`export_notify_state`/`import_notify_state` into your state wrappers, and (as
|
|
39
|
+
a client) drive register → subscribe → `notify_update_bindings` →
|
|
40
|
+
`export_notify_address`.
|
|
41
|
+
|
|
42
|
+
## Security notes
|
|
43
|
+
|
|
44
|
+
- **No E2E to the device:** the service reads every payload.
|
|
45
|
+
The contract doc carries the loud warning; operators and app authors must
|
|
46
|
+
not put service-hidden secrets in payloads.
|
|
47
|
+
- **No anti-spam in v1:** any token holder can post without limit; rotation
|
|
48
|
+
(`notify_rotate_token`) is the recovery path and invalidates the old
|
|
49
|
+
handout atomically (index delete).
|
|
50
|
+
- Sender `$from` on a post is recorded but never used for authorization (the
|
|
51
|
+
token is); the recipient surface is encrypted-channel-gated; the VAPID
|
|
52
|
+
private key never enters packet state.
|
|
53
|
+
|
|
54
|
+
## Tests
|
|
55
|
+
|
|
56
|
+
`tests/run.sh` scenarios N1–N8 (register/confirm; bare-send post happy path
|
|
57
|
+
from a non-contact; adversarial token matrix incl. a `$scope` flip that only
|
|
58
|
+
the byte-equality check catches; rotation; unregister; mark_read ids/NIL;
|
|
59
|
+
export/import round-trip incl. secret-absence; oversize payload on both
|
|
60
|
+
sides), alongside the pre-existing T1–T10 — all green.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# core 0.3 — PATCH (rebase): `a2a_notifications` rebased onto core 0.2.0
|
|
2
|
+
|
|
3
|
+
**Version:** 0.3.0 · **Bump:** PATCH (rebase, no wire change) · The
|
|
4
|
+
`a2a_notifications` library (originally introduced in core 0.1.0 on a
|
|
5
|
+
separate branch) is now rebased onto the core 0.2.0 lineage, producing a
|
|
6
|
+
single unified commit on `feat/a2a-notifications` that downstream consumers
|
|
7
|
+
(ours-notifications daemon, ours-control-plane) can build against.
|
|
8
|
+
|
|
9
|
+
No wire shape was changed: all `$field` names in `notify_token_*`,
|
|
10
|
+
`webpush_binding_t`, `notify_address_t`, `registration_t`,
|
|
11
|
+
`my_registration_t`, and `notification_t` are byte-identical to the core
|
|
12
|
+
0.1.0 form. The library surface (`register`, `update_bindings`,
|
|
13
|
+
`rotate_token`, `unregister`, `mark_read`, `confirm_registration`,
|
|
14
|
+
`post_notification`, `send_notification`, `export_notify_address`,
|
|
15
|
+
`set_vapid_public_key`, `export_notify_state`, `import_notify_state`, all
|
|
16
|
+
`init` hooks) is unchanged.
|
|
17
|
+
|
|
18
|
+
The conflict resolution merged the core 0.2.0 additions (`qa_strip_peer_ads`,
|
|
19
|
+
`qa_send_restore_request`, `qa_send_fake_restore_response` — contact-survival
|
|
20
|
+
tests) with the notification test helpers (`qa_notify_state`, `qa_post_tampered`,
|
|
21
|
+
`qa_send_fake_confirm`) so the full T1–T11e + N1–N8 suite now runs against
|
|
22
|
+
core 0.3.0 — all green.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# core 0.4 — `a2a_notifications` v2: per-contact notification tokens (shared token removed)
|
|
2
|
+
|
|
3
|
+
**Version:** 0.4.0 · **Bump:** breaking rewrite of the notifications library (no deployed
|
|
4
|
+
v1-notifications clients exist; consumers are (re)built against 0.4). Per-contact scoped
|
|
5
|
+
tokens are the ONLY model — the v1 shared-token feature is deleted, not deprecated.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## What changed
|
|
10
|
+
|
|
11
|
+
**Per-contact scoped tokens only.** Every token names exactly one (recipient, sender) pair:
|
|
12
|
+
`$scope == _str(sender_cid)`. Token `$version` stays `1` and `notify_address_t` stays
|
|
13
|
+
version `1` (the `$scope` field always existed in the shape). Tokens are minted exclusively
|
|
14
|
+
by `issue_tokens`; **registration mints nothing**.
|
|
15
|
+
|
|
16
|
+
**Shared-token feature removed entirely:**
|
|
17
|
+
|
|
18
|
+
- `registration_t` and `my_registration_t` no longer carry a `$token` field; the
|
|
19
|
+
`confirm_registration` targ no longer carries `$token`.
|
|
20
|
+
- `handle_register` stores the registration record only — no mint, no index entry.
|
|
21
|
+
- `retire_shared` / `notify_retire_shared` (a transitional retirement path drafted during
|
|
22
|
+
development) do not exist.
|
|
23
|
+
- `post_notification` has a single validation path: index lookup → **sender binding**
|
|
24
|
+
(envelope `$from` must equal the token's `$scope` — abort
|
|
25
|
+
`"Token is not bound to this sender."`) → byte-equality against
|
|
26
|
+
`notify_sender_tokens[recipient][sender]` → mute check (abort
|
|
27
|
+
`"Notifications from this sender are disabled."`) → payload cap.
|
|
28
|
+
- `rotate_token` with `$sender` absent rotates ALL scoped slots (panic button);
|
|
29
|
+
with `$sender` present rotates exactly one.
|
|
30
|
+
- `unregister` is a full teardown: registration, every scoped token (slot + index entry)
|
|
31
|
+
and the mute map are purged.
|
|
32
|
+
|
|
33
|
+
**Service-side inbounds (all encrypted-channel, registered-recipient gate):**
|
|
34
|
+
|
|
35
|
+
- `issue_tokens ($senders -> global_id[]+)` — mint or keep (idempotent, E8, byte-stable) a
|
|
36
|
+
scoped token per requested sender cid; capped at `issue_max_senders = 256` per call
|
|
37
|
+
(the client wrapper pages larger lists). Re-confirms with the full per-sender map.
|
|
38
|
+
- `set_sender_muted ($sender -> global_id, $muted -> bool)` — runtime receive-mute toggle
|
|
39
|
+
(present entry = muted, absent = enabled). No token change, no redistribution.
|
|
40
|
+
- `revoke_sender_tokens ($senders -> global_id[])` — delete each sender's scoped token from
|
|
41
|
+
the index and `notify_sender_tokens`, without re-minting (the contact-removal path).
|
|
42
|
+
- `rotate_token` gains optional `$sender -> global_id` (per-sender vs rotate-all).
|
|
43
|
+
|
|
44
|
+
**Client-side transactions:**
|
|
45
|
+
|
|
46
|
+
- `notify_issue_tokens ($service, $contacts -> global_id[])` — pages into batches of 256.
|
|
47
|
+
- `notify_set_sender_muted ($service, $contact -> global_id, $muted -> bool)`.
|
|
48
|
+
- `notify_revoke_contact_tokens ($service, $contacts -> global_id[])`.
|
|
49
|
+
- `notify_rotate_token` gains optional `$contact -> str+` (resolved via `resolve_contact`).
|
|
50
|
+
- `export_notify_address ($service, $contact)` — exports the PER-CONTACT handout blob from
|
|
51
|
+
`my_notify_contact_tokens[service][contact]`; aborts if issuance hasn't confirmed yet.
|
|
52
|
+
|
|
53
|
+
**Confirm shape.** Every service-side mutator replies the same idempotent confirm:
|
|
54
|
+
`($vapid_pub, $bindings, $sender_tokens, $sender_muted)`. The client replaces
|
|
55
|
+
`my_notify_contact_tokens[service]` wholesale on each confirm (single source of truth;
|
|
56
|
+
re-confirms are idempotent replays). The `on_notify_registration` hook now receives
|
|
57
|
+
`$sender_tokens` and `$sender_muted` beside `$service_cid`/`$registration`, so a consumer
|
|
58
|
+
(the messenger's distribution engine) can diff/mirror per-contact state.
|
|
59
|
+
|
|
60
|
+
**State maps:**
|
|
61
|
+
|
|
62
|
+
- Service: `notify_sender_tokens (global_id ->> (global_id ->> notify_token_t))`,
|
|
63
|
+
`notify_sender_muted (global_id ->> (global_id ->> bool))`.
|
|
64
|
+
- Client: `my_notify_contact_tokens (global_id ->> (global_id ->> notify_token_t))`.
|
|
65
|
+
|
|
66
|
+
`export_notify_state`/`import_notify_state` carry all fields with the individually-guarded
|
|
67
|
+
optional-read pattern — a pre-0.4 export imports with the new maps empty.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Security model (why per-contact)
|
|
72
|
+
|
|
73
|
+
| Property | shared token (removed) | per-contact (0.4) |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| Leak blast radius | any leak ⇒ anyone can spam the recipient | one relationship's channel only |
|
|
76
|
+
| Attribution | none — all contacts hold identical bytes | `$scope` names the relationship |
|
|
77
|
+
| Authorization | possession alone | possession + sender binding (`$from == $scope`) — a stolen handout is useless without the named sender's signing key |
|
|
78
|
+
| Recovery granularity | rotate-all + full redistribution | rotate/revoke one contact; rotate-all retained as panic button |
|
|
79
|
+
| Per-relationship control | none | receive-mute at the service (unprobeable), plus consumer-side send gating |
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Deployment
|
|
84
|
+
|
|
85
|
+
There is no migration path from the 0.3 shared-token PoC — deployments are recreated:
|
|
86
|
+
the service daemon ships first (new inbound names), clients register + `issue_tokens`
|
|
87
|
+
against it. Old handout blobs from any pre-0.4 deployment die at validation (their
|
|
88
|
+
token_ids are not in the new service's index; unscoped tokens additionally fail sender
|
|
89
|
+
binding).
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Non-goals (unchanged)
|
|
94
|
+
|
|
95
|
+
Payload content remains **plaintext to the service** (transport legs are encrypted, but the
|
|
96
|
+
service decrypts and stores the notification). There is **no rate limiting or anti-spam**
|
|
97
|
+
beyond per-contact rotation/revocation.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# core 3.0 ephemeral-invite — loopback test suite
|
|
2
|
+
|
|
3
|
+
End-to-end behavioural tests for the slim ephemeral-key invite + two-message boxed
|
|
4
|
+
redeem (legs 1/2/3) shipped in core 3.0 (`release-notes/3.0.md`).
|
|
5
|
+
|
|
6
|
+
The core repo is pure mufl libraries with no standalone build, so these tests run
|
|
7
|
+
through a small **self-contained test actor** (`test_actor.mu`) that loads the
|
|
8
|
+
shared core and supplies the minimum host wiring (storage hooks, a tiny inbox, the
|
|
9
|
+
identity-hierarchy helpers, export/import wrappers) plus `qa_*` probe transactions
|
|
10
|
+
the driver uses to inject adversarial inputs and read state. **No consumer/daemon
|
|
11
|
+
source is vendored** — the actor is derived locally for this repo.
|
|
12
|
+
|
|
13
|
+
## Files
|
|
14
|
+
|
|
15
|
+
- **`test_actor.mu`** — the derived test actor (pure mufl; compiles against this
|
|
16
|
+
repo's `core/` via the toolkit).
|
|
17
|
+
- **`test.mjs`** — the loopback driver: spins up multiple packets on one broker and
|
|
18
|
+
runs the scenarios, asserting **receiver-side state** (not just send emission).
|
|
19
|
+
- **`run.sh`** — builds a throwaway harness, compiles, boots a local broker, runs
|
|
20
|
+
the driver. Exit 0 = all green.
|
|
21
|
+
- **`corpus.mjs` / `run_corpus.sh`** — the **golden-wire corpus gate**
|
|
22
|
+
(`COMPATIBILITY.md`): replays one fixture per registered wire version per
|
|
23
|
+
registry through the `a2a_versions` dispatch and asserts the branch taken.
|
|
24
|
+
Fast (single packet); a release is green only if this passes.
|
|
25
|
+
- **`mufl_semantics/run.sh`** — toolchain-behavior pins the registry rests on
|
|
26
|
+
(safe-cast extra-field strip, disjunction canonical-order rebuild, 3-version
|
|
27
|
+
dispatch), run against the vendored `@adapt-toolkit/mufl` package.
|
|
28
|
+
|
|
29
|
+
## Prerequisites (external to this repo)
|
|
30
|
+
|
|
31
|
+
1. **ADAPT toolkit** — the `mufl-compile` binary + `mufl_stdlib` / `meta` /
|
|
32
|
+
`transactions` (default root `…/adapt-toolkit`).
|
|
33
|
+
2. **`@adapt-toolkit` Node SDK** — from any consumer checkout's `node_modules`
|
|
34
|
+
(e.g. `ours-mcp/node_modules`); used only to drive packets in the loopback.
|
|
35
|
+
3. **`dev-broker.mjs`** — the local relay launcher (ships in `ours-mcp/scripts`).
|
|
36
|
+
|
|
37
|
+
Node 18+ (tested on v20).
|
|
38
|
+
|
|
39
|
+
## Run
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
./tests/run.sh
|
|
43
|
+
# or override paths:
|
|
44
|
+
ADAPT_TOOLKIT=/path/to/adapt-toolkit \
|
|
45
|
+
OURS_SDK_NODE_MODULES=/path/to/ours-mcp/node_modules \
|
|
46
|
+
DEV_BROKER=/path/to/ours-mcp/scripts/dev-broker.mjs \
|
|
47
|
+
PORT=9799 ./tests/run.sh
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The SDK's leak-tracker prints `###`/`Leak for AdaptValue` lines at exit and inbound
|
|
51
|
+
aborts surface as `EVAL_ERROR` on stderr — both are expected noise (the adversarial
|
|
52
|
+
scenarios deliberately trigger aborts; `run.sh` forwards only the meaningful
|
|
53
|
+
`inbound rejected` lines). The verdict is the final `SCORECARD` + exit code.
|
|
54
|
+
|
|
55
|
+
## Scenarios asserted (10 scenarios, 36 assertions)
|
|
56
|
+
|
|
57
|
+
| # | Scenario | What it proves |
|
|
58
|
+
|---|----------|----------------|
|
|
59
|
+
| T1 | happy-flat | leg-2 consumes the invite + registers the responder; **leg-3 receiver-side**: the responder decrypts the boxed leg-3 with its kept eph priv and registers the inviter; both `list_contacts` show the other; `send_message` round-trips **both** directions over `encrypted_channel`. |
|
|
60
|
+
| T2 | happy-role | both sides are delegated roles; the delegation chain verifies and `contact_roots` is pinned on **both** legs. |
|
|
61
|
+
| T3 | single-use | a 2nd leg-1 for the same `invite_id` aborts (`already-redeemed`); inviter state unchanged. |
|
|
62
|
+
| T4 | invalid-then-valid | a bad box aborts at decrypt and does **not** consume the invite; a subsequent valid redeem then consumes it. |
|
|
63
|
+
| T5 | tamper | a tampered box aborts and mutates **no** state. |
|
|
64
|
+
| T6 | cid-bind leg-2 | a boxed AD whose `container_id` ≠ sender aborts; nothing registered/consumed. |
|
|
65
|
+
| T7 | PoP leg-2 | an AD with stripped self-signatures aborts in `process_address_document`. |
|
|
66
|
+
| T8 | leg-3 gates | unexpected-inviter (sender ≠ pinned cid), cid-bind leg-3, and PoP leg-3 each abort and register nothing. |
|
|
67
|
+
| T9 | export-secrecy | `export_core_state` contains **neither** `pending_invite_keys` **nor** `pending_redemption_keys`; `pending_invites` carries only public `{assigned, eph_pub, scheme}` (INV-4). |
|
|
68
|
+
| T10 | import migration | `export_state`→`import_state` preserves contacts/peer_ads and resets `pending_invites` (plan §4.4). |
|
|
69
|
+
|
|
70
|
+
Hard invariants exercised: **INV-5** (no `contacts`/`peer_ads`/`contact_roots`
|
|
71
|
+
write before lookup → decrypt → cid-bind → PoP → chain) on both legs; **single-use**
|
|
72
|
+
consumes both per-side ephemeral stores; **INV-4** (ephemeral secrets never
|
|
73
|
+
exported).
|
|
74
|
+
|
|
75
|
+
The R- and N-series (contact restore, notifications v1/v2) and the 0.5.0
|
|
76
|
+
**V-series** follow the same pattern. V-series (versioned type registry,
|
|
77
|
+
`COMPATIBILITY.md`): V1 the exact 0.2.0 leg-1 shape (no `$name` — the incident)
|
|
78
|
+
registers under the sender cid with **no abort**; V2 the 0.3.0 shape honors
|
|
79
|
+
`$name`; V3 the v5 shape learns `contact_pv`/`contact_caps`; V4 a below-floor
|
|
80
|
+
peer (`$pv=1`) produces the **error-as-data** `$protocol_error` notify at the
|
|
81
|
+
inviter, consumes **nothing**, and the same invite redeems after the peer
|
|
82
|
+
"updates"; V5 the CAP-1 capability gate denies as data only on positive
|
|
83
|
+
evidence; V6 stamped `$targ`s deliver normally and are learned passively.
|