@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.
Files changed (107) hide show
  1. package/LICENSE +98 -0
  2. package/README.md +231 -0
  3. package/dist/cli.js +446 -0
  4. package/dist/config.js +104 -0
  5. package/dist/daemon.js +353 -0
  6. package/dist/http.js +31 -0
  7. package/dist/log-store.js +48 -0
  8. package/dist/presence.js +157 -0
  9. package/dist/provision.js +29 -0
  10. package/dist/push.js +6 -0
  11. package/dist/web-push-sender.js +41 -0
  12. package/mufl_code/20E39399689579D5AFA5132C59DEDDD328C3BCA1A74D546F2193B36585DDD283.muflo +0 -0
  13. package/mufl_code/config.mufl +21 -0
  14. package/mufl_code/core/.github/FUNDING.yml +3 -0
  15. package/mufl_code/core/.github/workflows/docs.yml +39 -0
  16. package/mufl_code/core/.github/workflows/tests.yml +66 -0
  17. package/mufl_code/core/COMMERCIAL-LICENCE.md +25 -0
  18. package/mufl_code/core/COMPATIBILITY.md +221 -0
  19. package/mufl_code/core/CONTRIBUTING.md +37 -0
  20. package/mufl_code/core/LICENSE +98 -0
  21. package/mufl_code/core/NOTIFICATIONS_CONTRACT.md +290 -0
  22. package/mufl_code/core/README.md +91 -0
  23. package/mufl_code/core/SECURITY.md +40 -0
  24. package/mufl_code/core/TRADEMARKS.md +24 -0
  25. package/mufl_code/core/a2a_capabilities.mm +449 -0
  26. package/mufl_code/core/a2a_cluster.mm +601 -0
  27. package/mufl_code/core/a2a_control.mm +90 -0
  28. package/mufl_code/core/a2a_messaging.mm +4817 -0
  29. package/mufl_code/core/a2a_monitoring.mm +94 -0
  30. package/mufl_code/core/a2a_notification_integration.mm +144 -0
  31. package/mufl_code/core/a2a_notifications.mm +1127 -0
  32. package/mufl_code/core/a2a_protocol.mm +246 -0
  33. package/mufl_code/core/a2a_versions.mm +801 -0
  34. package/mufl_code/core/config.mufl +29 -0
  35. package/mufl_code/core/docs/.vitepress/config.mts +58 -0
  36. package/mufl_code/core/docs/citations.json +688 -0
  37. package/mufl_code/core/docs/guide/01-vendor-the-core.md +50 -0
  38. package/mufl_code/core/docs/guide/02-configure-and-compile.md +100 -0
  39. package/mufl_code/core/docs/guide/03-wire-the-host.md +154 -0
  40. package/mufl_code/core/docs/guide/04-connect-and-message.md +156 -0
  41. package/mufl_code/core/docs/guide/05-test-your-app.md +75 -0
  42. package/mufl_code/core/docs/guide/index.md +63 -0
  43. package/mufl_code/core/docs/how-it-works/capabilities-and-control.md +77 -0
  44. package/mufl_code/core/docs/how-it-works/cluster.md +65 -0
  45. package/mufl_code/core/docs/how-it-works/identity.md +56 -0
  46. package/mufl_code/core/docs/how-it-works/invites-and-contacts.md +76 -0
  47. package/mufl_code/core/docs/how-it-works/messaging.md +55 -0
  48. package/mufl_code/core/docs/how-it-works/monitoring-and-config.md +72 -0
  49. package/mufl_code/core/docs/how-it-works/overview.md +45 -0
  50. package/mufl_code/core/docs/how-it-works/versioning.md +116 -0
  51. package/mufl_code/core/docs/index.md +27 -0
  52. package/mufl_code/core/docs/package-lock.json +3467 -0
  53. package/mufl_code/core/docs/package.json +15 -0
  54. package/mufl_code/core/docs/reference/contributing.md +26 -0
  55. package/mufl_code/core/docs/reference/glossary.md +108 -0
  56. package/mufl_code/core/docs/reference/implementations.md +23 -0
  57. package/mufl_code/core/docs/reference/modules.md +19 -0
  58. package/mufl_code/core/docs/scripts/check-citations.mjs +36 -0
  59. package/mufl_code/core/docs/scripts/check-denylist.mjs +30 -0
  60. package/mufl_code/core/docs/scripts/gen-llms.mjs +32 -0
  61. package/mufl_code/core/docs/workflows/cluster.md +89 -0
  62. package/mufl_code/core/docs/workflows/contact-exchange.md +60 -0
  63. package/mufl_code/core/docs/workflows/contact-restore.md +60 -0
  64. package/mufl_code/core/docs/workflows/control-verbs.md +70 -0
  65. package/mufl_code/core/docs/workflows/index.md +57 -0
  66. package/mufl_code/core/docs/workflows/introductions.md +60 -0
  67. package/mufl_code/core/docs/workflows/messaging.md +75 -0
  68. package/mufl_code/core/docs/workflows/monitoring.md +84 -0
  69. package/mufl_code/core/docs/workflows/notifications.md +109 -0
  70. package/mufl_code/core/release-notes/0.1.md +60 -0
  71. package/mufl_code/core/release-notes/0.3.md +22 -0
  72. package/mufl_code/core/release-notes/0.4.md +97 -0
  73. package/mufl_code/core/tests/README.md +83 -0
  74. package/mufl_code/core/tests/compat/README.md +82 -0
  75. package/mufl_code/core/tests/compat/compat.mjs +245 -0
  76. package/mufl_code/core/tests/compat/compat_peer.mjs +146 -0
  77. package/mufl_code/core/tests/compat/run_compat.sh +96 -0
  78. package/mufl_code/core/tests/corpus.mjs +139 -0
  79. package/mufl_code/core/tests/corpus_actor.mu +242 -0
  80. package/mufl_code/core/tests/fatcompile_actor.mu +52 -0
  81. package/mufl_code/core/tests/mig.mjs +805 -0
  82. package/mufl_code/core/tests/mig_actor.mu +518 -0
  83. package/mufl_code/core/tests/migapp.mjs +431 -0
  84. package/mufl_code/core/tests/migapp_actor.mu +294 -0
  85. package/mufl_code/core/tests/mufl_semantics/run.sh +69 -0
  86. package/mufl_code/core/tests/mufl_semantics/t11_three_version.mufl +47 -0
  87. package/mufl_code/core/tests/mufl_semantics/t2_extra_fields.mufl +11 -0
  88. package/mufl_code/core/tests/mufl_semantics/t7_runtime.mufl +30 -0
  89. package/mufl_code/core/tests/mufl_semantics/t9_passthrough.mufl +19 -0
  90. package/mufl_code/core/tests/mufl_semantics/t_str_order.mufl +19 -0
  91. package/mufl_code/core/tests/notif.mjs +755 -0
  92. package/mufl_code/core/tests/notif_actor.mu +450 -0
  93. package/mufl_code/core/tests/protocol_container.mm +18 -0
  94. package/mufl_code/core/tests/run.sh +65 -0
  95. package/mufl_code/core/tests/run_corpus.sh +51 -0
  96. package/mufl_code/core/tests/run_fatcompile.sh +40 -0
  97. package/mufl_code/core/tests/run_mig.sh +51 -0
  98. package/mufl_code/core/tests/run_migapp.sh +51 -0
  99. package/mufl_code/core/tests/run_notif.sh +57 -0
  100. package/mufl_code/core/tests/test.mjs +750 -0
  101. package/mufl_code/core/tests/test_actor.mu +564 -0
  102. package/mufl_code/core/tests/test_common.mjs +93 -0
  103. package/mufl_code/core/version.mm +24 -0
  104. package/mufl_code/packet.mu +170 -0
  105. package/mufl_code/protocol_container.mm +18 -0
  106. package/package.json +43 -0
  107. 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.