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
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,42 @@
# Changelog

## 0.3.0 (2026-09-26)

On macula-go v0.17.0's C ABI (was v0.13.0).

### Added

- UCANs (macula 12, D7): `NodeKey.ucan` mints a token for the node that will
present it; `Pool.call` and `Pool.open_stream` take `ucan=` and `proofs=`;
`Pool.serve` and `Pool.serve_stream` take `policy=` (`UcanRequired`,
`RealmMemberRequired`), and the provider refuses what the policy does not
accept with `unauthorized` (or `malformed_frame` for a proof no token
names). `macula_py.ucan.proof_id` and `key_id`, held to macula's UCAN
vectors.
- `NodeKey.device_request_proof` (realm proof v2, macula-realm#29) and
`macula_py.device_request.device_request_message`, held to the realm's
vector; a proof made here is accepted by the realm's own verifier
(`scripts/interop/device_request.sh`).
- `NodeKey.ownership_proof` (v2, mcl-om#7) and
`macula_py.ownership_proof.ownership_proof_message`, held to mcl_om's
vector; a payload signed here, delivered through a station, is accepted by
mcl_om's own verifier and refused changed or replayed
(`scripts/interop/ownership_proof.sh`).
- `NoProviderError`, for the `no_provider` kind: no provider the realm trusts
advertises the procedure.

### Changed

- Stopping a served procedure answers each call it still holds with the
provider error `handler_error` and the detail "the procedure was
withdrawn", from the library, before the handler's own cancellation.
- A handler's request payload never holds a "caller" its sender wrote: who
called is `request.caller`, the verified signer.
- A device request or ownership-proven payload carrying a "caller" is
refused (`InvalidArgumentError`).
- `open_stream`'s `deadline_ms` bounds the provider's admission of the open,
not the stream's life, as the contract says.

## 0.2.0 (2026-09-26)

A rebuild onto the macula 12 wire. 0.1.0 spoke the retired classical wire,
Expand Down
69 changes: 59 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@
> **Status, 2026-09-26:** on the **macula 12** wire: ML-DSA-87 identities (as
> the ML-DSA-87 + RSA-PSS-4096 composite in `pq_hybrid`, the fleet's profile),
> ML-KEM hybrid key exchange, signed requests. Calls and streams by direct dial,
> serving (under an org or in a node's own namespace), publish/subscribe, the
> DHT and node-served content are tested against two in-process macula 12
> stations on every CI run. 0.1.0 spoke the retired classical wire and cannot
> reach the current fleet.
> serving (under an org or in a node's own namespace, open or gated on a
> post-quantum UCAN), publish/subscribe, the DHT and node-served content are
> tested against two in-process macula 12 stations on every CI run. Device
> request proofs (realm join) and ownership proofs are held to their
> verifiers' vectors, and checked once through the realm's and mcl_om's own
> verifiers. 0.1.0 spoke the retired classical wire and cannot reach the
> current fleet.

## What is this?

Expand Down Expand Up @@ -85,15 +88,55 @@ namespace (`~<node_id>/<name>`, see `Pool.own_procedure`) need none.

| | |
|---|---|
| `NodeKey` | `generate`, `load`, `load_or_create`, `save`, `node_id`, `public_key`, `profile`, `sign`, `verify`, `free` |
| `NodeKey` | `generate`, `load`, `load_or_create`, `save`, `node_id`, `public_key`, `profile`, `sign`, `verify`, `free`; `ucan`, `device_request_proof`, `ownership_proof` (below) |
| `Pool.connect` | seeds, `realm_trust`, and the pool's tuning; `async with` closes it |
| calls | `call`, `providers` |
| serving | `serve(realm, procedure, handler)`: handler(request) returns the result, directly or as an awaitable; an exception reaches the caller as a `ProviderError` of code `handler_error` |
| calls | `call`, `providers`; `call(..., ucan=, proofs=)` presents a UCAN |
| serving | `serve(realm, procedure, handler, policy=None)`: handler(request) returns the result, directly or as an awaitable; an exception reaches the caller as a `ProviderError` of code `handler_error` |
| streams | `open_stream`, `serve_stream`; a `Stream` has `send`, `send_value`, `close_send`, `reply`, `abort`, `close`, `recv`, and iterates its frames |
| pub/sub | `publish`, `subscribe`; a `Subscription` iterates its events, or `next(timeout_ms)` |
| content | `share_content`, `unshare_content`, `get_content` |
| DHT | `find_record`, `find_records`, `find_records_by_type`, `put_record` |

### UCANs

A procedure served with a `policy` answers only callers presenting a UCAN
(macula 12's post-quantum capability token) the policy accepts; the provider
checks each call and stream open before the handler sees it, as macula does,
and answers the rest `unauthorized` (or `malformed_frame` for a proof no
token in the chain names):

```python
from macula_py import UcanRequired
from macula_py.ucan import proof_id

served = await provider.serve(realm, procedure, handler, policy=UcanRequired(root.node_id()))

token = root.ucan(caller.node_id(), [{"with": "mri:org:io.macula/acme", "can": "invoke"}],
exp=int(time.time()) + 3600)
await caller.call(realm, procedure, payload, ucan=token)

# Delegated: alice hands the caller one procedure, naming her grant as its parent.
sub = alice.ucan(caller.node_id(), [{"with": "mri:proc:io.macula/acme/count_v1", "can": "invoke"}],
exp=int(time.time()) + 600, prf=[proof_id(to_alice)])
await caller.call(realm, procedure, payload, ucan=sub, proofs=[to_alice])
```

A token is minted for the node that will present it. `RealmMemberRequired(key_id, can)`
gates on a realm key instead, named by `macula_py.ucan.key_id(realm_public_key, profile)`
(the key as carried, bytes).
macula's `test/vectors/UCAN_V1.md` is the contract.

### Proofs for a realm and for a service

`NodeKey.device_request_proof(realm, procedure, request, rule)` signs a
device's request to a realm (realm proof v2): a join session's body
(`rule="http"`, a mapping or the body text exactly as sent) or a membership
UCAN request over the mesh (`rule="mesh"`). `NodeKey.ownership_proof(realm,
procedure, payload)` returns the payload with the `asserted_by` block that
authorises its fields to a service such as mcl_om; send it as the payload.
Neither signs a `"caller"`: the caller is the verified signer, so a request or
payload carrying one is refused.

Payloads are what macula's wire carries: `str`, `int` within int64, `float`,
`None`, `bytes`, lists and dicts with `str` keys. There is no boolean on the
wire: send 1 and 0. A Python `bool` is refused before it leaves, since
Expand All @@ -109,7 +152,7 @@ calls without one; cancelling stops the waiting and the call runs to its
end.

Errors are typed: `ProviderError`, `RelayError`, `StreamError`,
`NotSharedError`, `ContentUnavailableError`, `MaculaTimeoutError` (also a
`NotSharedError`, `ContentUnavailableError`, `NoProviderError`, `MaculaTimeoutError` (also a
`TimeoutError`), `InvalidArgumentError` (also a `ValueError`),
`ClosedError`, `RefusedError`, all `MaculaError`.

Expand All @@ -129,8 +172,14 @@ function for function, and `build_native.sh` refuses a header that differs
from the ref's.

`scripts/live_check.sh` runs `tests/live/` against one fleet station
(helsinki and io.macula by default) with a key made for the run and never
saved. It publishes once and puts nothing in the DHT. CI never runs it.
(helsinki and io.macula by default) with keys made for the run and never
saved. It publishes once, and advertises one UCAN-gated procedure in its own
namespace under a throwaway realm. CI never runs it.

`scripts/interop/ownership_proof.sh` and `scripts/interop/device_request.sh`
check proofs this binding signs against the verifiers themselves: mcl_om's
(in macula's pinned CI image), after the payload has crossed a station as a
provider receives it, and the realm's. See `scripts/interop/README.md`.

A `v*` tag publishes to PyPI through Trusted Publishing, using only
macula-go's released libraries, each checked against the release's
Expand Down
2 changes: 1 addition & 1 deletion abi/MACULA_GO_REF
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.13.0 b00670a13afc0198dc145831fafbbe66e043de1f
v0.17.0 132d13411440cbe7e86f6fbbd6e5c8383fc659d8
86 changes: 82 additions & 4 deletions abi/macula.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
*
* This header is the contract; cabi/CONTRACT.md says what every call means.
* cabi's tests fail if the exported functions and this header disagree.
* Any change to a declaration here changes MACULA_ABI_VERSION.
* A change to an existing declaration changes MACULA_ABI_VERSION; a new
* function does not, and says since which macula-go version it exists.
*/
#ifndef MACULA_H
#define MACULA_H
Expand Down Expand Up @@ -68,6 +69,57 @@ int32_t macula_verify(const uint8_t *data, size_t data_len, const uint8_t *signa
const uint8_t *public_key, size_t public_key_len, const char *profile, char **err_out);
void macula_key_free(macula_handle key);

/* ---- Device request proofs (realm proof v2, macula-realm#29) ----------- */
/* Since macula-go v0.14.0. */

#define MACULA_REQUEST_HTTP 0 /* an HTTP body, under the realm's JSON rule */
#define MACULA_REQUEST_MESH 1 /* a mesh payload, as it goes on the wire */

/* A v2 proof that key made request_json (a JSON object, its "proof" left
* out) for procedure in realm, now, with a fresh nonce:
* {"v":2,"timestamp","nonce","signature"}. A request with a "caller" field is
* invalid_argument (since macula-go v0.17.0): the caller is the signer. */
char *macula_key_device_request_proof(macula_handle key, const uint8_t realm[32], const char *procedure,
const char *request_json, int32_t rule, char **err_out);
/* The exact bytes such a proof signs, for a given timestamp and nonce: what a
* binding checks the realm's vector with. */
uint8_t *macula_device_request_message(const uint8_t *public_key, size_t public_key_len, const uint8_t realm[32],
const char *procedure, int64_t timestamp_ms, const uint8_t nonce[16],
const char *request_json, int32_t rule, size_t *out_len, char **err_out);

/* ---- Ownership proofs (v2, mcl-om#7) ----------------------------------- */
/* Since macula-go v0.16.0. */

/* payload_json (a JSON object) with an "asserted_by" block by which key's
* node authorises its other fields for procedure in realm, now, with a fresh
* nonce; an asserted_by already there is replaced. A payload carrying
* "caller" is refused (invalid_argument): a station replaces it with the
* caller it authenticated. Send the result as the payload. */
char *macula_key_ownership_proof(macula_handle key, const uint8_t realm[32], const char *procedure,
const char *payload_json, char **err_out);
/* The exact bytes such a proof signs, for a given identity (node_id),
* timestamp and nonce, over fields_json: a payload, of which the fields are
* all but "asserted_by" and a text "caller", as a verifier reads a delivered
* payload. Unlike macula_key_ownership_proof it does not refuse a "caller":
* it mirrors the verifier, not the signer. What a binding checks mcl_om's
* vector with. */
uint8_t *macula_ownership_proof_message(const uint8_t identity[32], const uint8_t realm[32], const char *procedure,
int64_t timestamp_ms, const uint8_t nonce[16], const char *fields_json,
size_t *out_len, char **err_out);

/* ---- UCANs (macula 12, D7) --------------------------------------------- */
/* Since macula-go v0.17.0. */

/* key's token for the node audience_node_id, granting caps_json (a JSON array
* of {"with","can"}, each "with" an MRI) until exp_s (Unix seconds).
* options_json (NULL for none): {"nbf","nnc","fct","prf"}, prf a list of at
* most one parent's proof id. key is an identity key. */
char *macula_ucan_create(macula_handle key, const uint8_t audience_node_id[32], const char *caps_json, int64_t exp_s,
const char *options_json, char **err_out);
/* The proof id a child's "prf" names a token by: lowercase hex SHA-384 of its
* text. */
char *macula_ucan_proof_id(const char *token, char **err_out);

/* ---- Pool -------------------------------------------------------------- */

/* seeds_json: [{"host","port","node_id"}]. options_json (NULL for defaults):
Expand All @@ -90,6 +142,14 @@ char *macula_pool_events_next(macula_handle pool, int64_t timeout_ms, macula_han
/* provider_node_id NULL: any provider the realm trusts. Returns the result. */
char *macula_pool_call(macula_handle pool, const uint8_t realm[32], const char *procedure, const char *payload_json,
const uint8_t *provider_node_id, int64_t timeout_ms, macula_handle cancel, char **err_out);
/* macula_pool_call presenting a UCAN (NULL: none) and its chain's proofs,
* proofs_json a JSON array of tokens (NULL: none). A gated provider that
* refuses it answers a provider error of code "unauthorized", or
* "malformed_frame" for a proof no token in the chain names. Since macula-go
* v0.17.0. */
char *macula_pool_call_with(macula_handle pool, const uint8_t realm[32], const char *procedure,
const char *payload_json, const uint8_t *provider_node_id, const char *ucan,
const char *proofs_json, int64_t timeout_ms, macula_handle cancel, char **err_out);
/* [{"node","station"}], freshest first. */
char *macula_pool_providers(macula_handle pool, const uint8_t realm[32], const char *procedure, int64_t timeout_ms,
macula_handle cancel, char **err_out);
Expand All @@ -114,25 +174,43 @@ void macula_subscription_stop(macula_handle subscription);
macula_handle macula_pool_serve(macula_handle pool, const uint8_t realm[32], const char *procedure, char **err_out);
macula_handle macula_pool_serve_stream(macula_handle pool, const uint8_t realm[32], const char *procedure,
int32_t mode, char **err_out);
/* Serve a procedure only to callers whose UCAN policy_json accepts:
* {"kind":"ucan_required","issuer":"<node_id hex>"} (a chain rooted at that
* node's identity key) or {"kind":"realm_member_required","key_id":"<hex>",
* "can"} (rooted at that realm key, granting that can). Refused calls and
* opens never reach the inbox. Since macula-go v0.17.0. */
macula_handle macula_pool_serve_gated(macula_handle pool, const uint8_t realm[32], const char *procedure,
const char *policy_json, char **err_out);
macula_handle macula_pool_serve_stream_gated(macula_handle pool, const uint8_t realm[32], const char *procedure,
int32_t mode, const char *policy_json, char **err_out);
/* The next call (*out_item: a pending call) or stream session (*out_item: a
* stream), and its request {"caller","realm","procedure","payload","deadline_ms"}. */
char *macula_served_next(macula_handle served, int64_t timeout_ms, macula_handle cancel, macula_handle *out_item,
int32_t *closed, char **err_out);
/* Answer a pending call, once: with a result, or with an error the caller
* receives as a provider error of code "handler_error" and detail message.
* The handle ends with the answer or the call's deadline; it is never freed
* by the caller. */
* An answer after the call's deadline is "answered". The handle ends with its
* first answer, or when its procedure stops; it is never freed by the caller. */
void macula_pending_reply(macula_handle pending, const char *result_json, char **err_out);
void macula_pending_error(macula_handle pending, const char *message, char **err_out);
/* Withdraws the procedure everywhere; frees the handle. */
void macula_served_stop(macula_handle served, char **err_out);

/* ---- Streams ----------------------------------------------------------- */

/* deadline_ms: the stream's life (30 s when 0); timeout_ms: to open it. */
/* deadline_ms: how far ahead the open's signed deadline lies (30 s when 0),
* which bounds the provider's admission, not the stream's life; timeout_ms:
* to open it. */
macula_handle macula_pool_open_stream(macula_handle pool, const uint8_t realm[32], const char *procedure, int32_t mode,
const char *payload_json, const uint8_t *provider_node_id, int64_t deadline_ms,
int64_t timeout_ms, macula_handle cancel, char **err_out);
/* macula_pool_open_stream presenting a UCAN and its chain's proofs, as
* macula_pool_call_with does; a gated provider refuses one with a stream
* error. Since macula-go v0.17.0. */
macula_handle macula_pool_open_stream_with(macula_handle pool, const uint8_t realm[32], const char *procedure,
int32_t mode, const char *payload_json, const uint8_t *provider_node_id,
const char *ucan, const char *proofs_json, int64_t deadline_ms,
int64_t timeout_ms, macula_handle cancel, char **err_out);
char *macula_stream_request(macula_handle stream, char **err_out);
void macula_stream_send_bytes(macula_handle stream, const uint8_t *data, size_t data_len, char **err_out);
void macula_stream_send_json(macula_handle stream, const char *value_json, char **err_out);
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "macula-py"
version = "0.2.0"
version = "0.3.0"
description = "A Python node on the macula 12 mesh: post-quantum identity and key exchange, calls, streams, pub/sub, the DHT and node-served content, over macula-go's C ABI."
readme = "README.md"
license = "Apache-2.0"
Expand Down
Loading
Loading