Skip to content
Draft
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
16 changes: 16 additions & 0 deletions .github/workflows/group-change-request-fixtures.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: Group change request fixtures
on:
pull_request:
push:
branches: [master]
permissions:
contents: read
jobs:
fixtures:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Run bounded reference fixtures
run: python3 -m unittest discover -s tests -p 'test_group_change_requests.py' -v
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@
*.swp
*.swo
*~

# Python reference fixtures
__pycache__/
*.pyc
4 changes: 4 additions & 0 deletions app-components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,10 @@ group-level components default to the same active-admin role that may commit it.
component change, if a feature defines one, is carried as a Marmot app payload or feature-owned request flow rather than
as an MLS AppDataUpdate proposal.

The optional draft [group change requests](../features/group-change-requests.md) feature defines member requests
for ordinary group settings. Approval uses the owning component's existing update bytes and authorization; the
request does not relax either proposal-sender or Commit authorization.

For a Commit, a Marmot client evaluates the single AppDataUpdate operation, if any, for each component. The component
validates the proposal sender, the committer, the prior state, and the operation. It returns the new state bytes, removes
the component, or returns an invalid result. If any component operation is invalid, the Commit is invalid.
Expand Down
2 changes: 2 additions & 0 deletions features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ they used to be described in a MIP. The old-to-new MIP map lives in [../mip-cove
## Current feature docs

- [content-moderation.md](./content-moderation.md) - draft group reports, admin dismissal labels, and admin deletion.
- [group-change-requests.md](./group-change-requests.md) - draft member requests for invitations and group settings,
with authenticated admin decisions.
- [encrypted-media.md](./encrypted-media.md) - current v2 message-attached encrypted blobs.
- [encrypted-media-v1.md](./encrypted-media-v1.md) - frozen legacy encrypted-media v1 wire behavior.
- [agent-text-streams-quic.md](./agent-text-streams-quic.md) - experimental QUIC-backed live previews for agent text
Expand Down
239 changes: 239 additions & 0 deletions features/group-change-requests.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions foundation/application-messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ or decline to render unsupported application semantics after delivering the acce
The optional [group content moderation](../features/content-moderation.md) feature defines reports (kind `1984`),
admin dismissal labels (kind `1985`), and admin deletion (kind `4891`).

The optional draft [group change requests](../features/group-change-requests.md) feature defines kind `458` requests
and authenticated decisions. Its requests are not MLS proposals and do not confer group-state authority.

Polls use the standard [NIP-88](https://github.com/nostr-protocol/nips/blob/master/88.md) app-event shapes: kind `1068`
for a poll and kind `1018` for a response. Both are ordinary Marmot app events inside MLS and remain subject to the
encoding and receiver-authentication rules above. Their inner `relay` tags, when present, never affect delivery;
Expand Down
4 changes: 4 additions & 0 deletions foundation/registries.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ seal), kind `10002` (NIP-65 relay list), and kind `10050` (NIP-17 DM inbox relay
| `449` | Push token removal | Marmot app payload | [push-notifications.md](../features/push-notifications.md) |
| `450` | Account identity proof v2 event | Local signing template, not relayed | [account-identity-proof-v2.md](../app-components/account-identity-proof-v2.md) |
| `451` | Push owner proof event | Local signing template, not relayed | [push-notifications.md](../features/push-notifications.md) |
| `458` | Group change request control v1 | Marmot app payload | [group-change-requests.md](../features/group-change-requests.md) |
| `1009` | Message edit | Marmot app payload | [application-messages.md](application-messages.md) |
| `1018` | Poll response (NIP-88) | Marmot app payload | [application-messages.md](application-messages.md) |
| `1068` | Poll (NIP-88) | Marmot app payload | [application-messages.md](application-messages.md) |
Expand All @@ -111,6 +112,9 @@ seal), kind `10002` (NIP-65 relay list), and kind `10050` (NIP-17 DM inbox relay

Kind `4891` is allocated by Marmot for admin deletion inside MLS app payloads.

Kind `458` identifies the draft optional group-change request, rejection, withdrawal, and applied-receipt actions.
It is an inner app-event allocation only; it does not add a public Nostr transport event.

The experimental agent text stream QUIC feature claims kind `1200` for durable stream start app events. Live stream
chunks are transient QUIC records.

Expand Down
1 change: 1 addition & 0 deletions layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ transports/
features/
README.md
content-moderation.md
group-change-requests.md
encrypted-media.md
encrypted-media-v1.md
agent-text-streams-quic.md
Expand Down
3 changes: 3 additions & 0 deletions protocol-core/group-messaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ Non-admin members MAY send standalone MLS proposals only where the spec explicit
that proposal flow is SelfRemove. A request for an admin-gated group-state change is an application payload or
feature-owned request flow, not a standalone MLS proposal.

The optional draft [group change requests](../features/group-change-requests.md) feature defines one such flow.
Its request or decision alone does not authorize or apply a Commit; existing authorization and convergence still apply.

## Publish before apply

A locally generated Commit MUST NOT become the sender's canonical local state until its publish obligation succeeds.
Expand Down
280 changes: 280 additions & 0 deletions tests/test_group_change_requests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
"""Bounded encoding/projection fixtures, not an MLS or component implementation.

Source-authority and Commit-matching facts are supplied inputs. The model checks
their use, not their cryptographic derivation. Component decoding, account-key
validity and package discovery are outside this reference model.
"""
import base64
import hashlib
import itertools
import json
from pathlib import Path
import re
import unittest


HEX = re.compile(r"[0-9a-f]{64}\Z")
DECIMAL = re.compile(r"(?:0|[1-9][0-9]*)\Z")
PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
NONCE = bytes(range(32)).hex()


def canonical(value):
return json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(",", ":"))


def unique_object(pairs):
obj = {}
for key, value in pairs:
if key in obj:
raise ValueError("duplicate key")
obj[key] = value
return obj


def require(ok):
if not ok:
raise ValueError("invalid fixture")


def text(value, limit):
require(isinstance(value, str))
require(len(value.encode("utf-8")) <= limit)


def hex64(value):
require(isinstance(value, str) and HEX.fullmatch(value))


def content_shape(raw):
require(len(raw.encode("utf-8")) <= 65536)
value = json.loads(raw, object_pairs_hook=unique_object)
require(isinstance(value, dict))
require(type(value.get("v")) is int and value["v"] == 1)
action = value.get("action")
if action == "request":
require(set(value) == {"v", "action", "nonce", "operation", "data"})
hex64(value["nonce"])
data = value["data"]
require(isinstance(data, dict))
operation = value["operation"]
if operation == "invite_account":
require(set(data) == {"pubkey"})
hex64(data["pubkey"])
# Mathematical key validity is the foundation identity check.
else:
require(set(data) == {"expected", "value"})
require(data["value"] is not None)
if operation in {"set_name", "set_description"}:
limit = 256 if operation == "set_name" else 4096
text(data["value"], limit)
if data["expected"] is not None:
text(data["expected"], limit)
elif operation == "set_retention":
for item in (data["expected"], data["value"]):
if item is not None:
require(isinstance(item, str) and DECIMAL.fullmatch(item))
require(int(item) <= 2**64 - 1)
elif operation in {"set_avatar_url", "set_blossom_image"}:
for item in (data["expected"], data["value"]):
if item is not None:
require(isinstance(item, str) and item != "")
decoded = base64.b64decode(item, validate=True)
require(len(decoded) <= 4096)
require(base64.b64encode(decoded).decode("ascii") == item)
# Owning component validation is an external input.
else:
raise ValueError("unknown operation")
elif action == "rejected":
require(set(value) == {"v", "action", "request", "reason"})
hex64(value["request"])
text(value["reason"], 1024)
elif action == "withdrawn":
require(set(value) == {"v", "action", "request"})
hex64(value["request"])
elif action == "applied":
require(set(value) == {"v", "action", "request", "commit"})
hex64(value["request"])
hex64(value["commit"])
else:
raise ValueError("unknown action")
require(raw == canonical(value))
return value


def request(operation="invite_account", data=None, nonce=NONCE):
return {"v": 1, "action": "request", "nonce": nonce, "operation": operation,
"data": {"pubkey": PUBKEY} if data is None else data}


def event_id(content, created_at=1700000000):
# NIP-01 fixture with no unsupported numeric or string values.
preimage = json.dumps([0, PUBKEY, created_at, 458, [], content],
ensure_ascii=False, separators=(",", ":"))
return hashlib.sha256(preimage.encode("utf-8")).hexdigest()


def evidence(action, **changes):
result = {"action": action, "request": "request-a", "group": "group-a", "sender": "admin-account",
"source_admin": True, "selected_branch": True, "request_seen": True,
"commit_matches": True, "commit_accepted": True, "receipt_causal": True}
result.update(changes)
return result


def project(records, target="request-a", group="group-a", requester="member-account", committer="admin-account"):
effects = set()
for item in records:
if (item["request"] != target or item["group"] != group
or not item["selected_branch"] or not item["request_seen"]):
continue
action = item["action"]
if action == "applied":
if (item["source_admin"] and item["sender"] == committer
and item["commit_matches"] and item["commit_accepted"] and item["receipt_causal"]):
effects.add("matching_applied")
elif action == "rejected" and item["source_admin"]:
effects.add("rejected")
elif action == "withdrawn" and item["sender"] == requester:
effects.add("withdrawn")
for effect, status in (("matching_applied", "Applied"),
("withdrawn", "Withdrawn"), ("rejected", "Rejected")):
if effect in effects:
return status
return "Pending"


class Fixtures(unittest.TestCase):
def test_document_examples_are_canonical_shapes(self):
doc = Path(__file__).resolve().parents[1] / "features/group-change-requests.md"
examples = re.findall(r"```json\n(.*?)\n```", doc.read_text(), re.S)
self.assertEqual(len(examples), 4)
for example in examples:
content_shape(example)

def test_invitation_has_only_account_data(self):
for extra in ("key_package", "key_package_ref", "publication", "relay", "device"):
value = request()
value["data"][extra] = "ignored"
with self.assertRaises(ValueError):
content_shape(canonical(value))

def test_version_and_unknown_actions(self):
for field, item in (("v", True), ("v", 2), ("action", "approved"),
("operation", "promote_admin")):
value = request()
value[field] = item
with self.assertRaises(ValueError):
content_shape(canonical(value))

def test_duplicate_keys_and_noncanonical_content(self):
for raw in ('{"v":1,"v":1}', json.dumps(request()),
canonical(request()).replace('"pubkey":', '"pubkey": ')):
with self.assertRaises(ValueError):
content_shape(raw)

def test_unicode_bounds_and_no_normalization(self):
good = request("set_name", {"expected": "e\u0301", "value": "é" * 128})
self.assertNotEqual(good["data"]["expected"], "é")
content_shape(canonical(good))
good["data"]["value"] += "é"
with self.assertRaises(ValueError):
content_shape(canonical(good))

def test_non_scalar_strings_are_invalid(self):
# UnicodeEncodeError is a ValueError; escaped and raw surrogates both fail.
value = request("set_name", {"expected": "", "value": "\ud800"})
for raw in (json.dumps(value, sort_keys=True, separators=(",", ":")), canonical(value)):
with self.assertRaises(ValueError):
content_shape(raw)

def test_component_limit_alignment(self):
root = Path(__file__).resolve().parents[1]
profile = (root / "app-components/group-profile-v1.md").read_text()
retention = (root / "app-components/message-retention-v1.md").read_text()
self.assertIn("opaque name<0..256>", profile)
self.assertIn("opaque description<0..4096>", profile)
self.assertIn("uint64 disappearing_message_secs", retention)
for filename, expected in (("group-avatar-url-v1.md", 2566),
("group-blossom-image-v1.md", 242)):
source = (root / "app-components" / filename).read_text()
schema = re.search(r"```text\n(.*?)\n```", source, re.S).group(1)
limits = [int(n) for n in re.findall(r"opaque \w+<0\.\.(\d+)>", schema)]
maximum = sum(n + (1 if n <= 63 else 2 if n <= 16383 else 4) for n in limits)
self.assertEqual(maximum, expected)
self.assertLessEqual(maximum, 4096)

def test_description_named_escapes_fit_envelope(self):
content_shape(canonical(request("set_description",
{"expected": "\n" * 4096, "value": "\t" * 4096})))

def test_existing_control_characters_round_trip(self):
for operation, limit in (("set_name", 256), ("set_description", 4096)):
value = request(operation, {"expected": "\0" * limit, "value": "\x01" * limit})
self.assertEqual(content_shape(canonical(value)), value)

def test_decimal_full_range_and_null(self):
for old in (None, "0"):
content_shape(canonical(request("set_retention",
{"expected": old, "value": str(2**64 - 1)})))
for bad in ("00", "+1", " 1", "1.0", str(2**64), 1, None):
with self.assertRaises(ValueError):
content_shape(canonical(request("set_retention", {"expected": "0", "value": bad})))

def test_absent_differs_from_present_empty(self):
absent = request("set_name", {"expected": None, "value": "club"})
empty = request("set_name", {"expected": "", "value": "club"})
self.assertNotEqual(event_id(canonical(absent)), event_id(canonical(empty)))

def test_image_base64_encoding_not_component_validity(self):
# URL-avatar empty state: three zero-length vectors.
content_shape(canonical(request("set_avatar_url", {"expected": None, "value": "AAAA"})))
for bad in ("", "AA", "AB==", "AA==\n", "_A=="):
with self.assertRaises(ValueError):
content_shape(canonical(request("set_avatar_url", {"expected": None, "value": bad})))

def test_same_second_intents_and_retries(self):
first = canonical(request())
second = canonical(request(nonce="f" * 64))
self.assertEqual(event_id(first), event_id(first))
self.assertNotEqual(event_id(first), event_id(second))

def test_fixed_app_event_id(self):
self.assertEqual(event_id(canonical(request())),
"e5ab295cece5e1a951e8d0c591f42f4a0666c26f9f7f7d34d9e09fe21ab36d31")

def test_delivery_order_does_not_select_status(self):
effects = [evidence("applied"), evidence("rejected"),
evidence("withdrawn", sender="member-account")]
for order in itertools.permutations(effects):
self.assertEqual(project(order), "Applied")

def test_unresolved_or_invalid_authority_is_not_applied(self):
for field, value in (("request", "request-b"), ("group", "group-b"), ("sender", "another-admin"),
("source_admin", False), ("selected_branch", False),
("request_seen", False), ("commit_matches", False),
("commit_accepted", False), ("receipt_causal", False)):
self.assertEqual(project([evidence("applied", **{field: value})]), "Pending", field)
self.assertEqual(project([evidence("rejected", source_admin=False)]), "Pending")
self.assertEqual(project([evidence("withdrawn", sender="another-member")]), "Pending")
self.assertEqual(project([evidence("rejected"),
evidence("withdrawn", sender="member-account")]), "Withdrawn")

def test_two_requests_have_independent_status(self):
records = [evidence("applied", request="request-a"),
evidence("rejected", request="request-b")]
self.assertEqual(project(records, target="request-a"), "Applied")
self.assertEqual(project(records, target="request-b"), "Rejected")
self.assertEqual(project(records, target="request-c"), "Pending")

def test_reorg_withdraws_only_supported_effects(self):
effects = [evidence("applied"), evidence("rejected")]
self.assertEqual(project(effects), "Applied")
effects[0]["commit_accepted"] = False
self.assertEqual(project(effects), "Rejected")
# Restart from the same retained facts produces the same view.
self.assertEqual(project(json.loads(json.dumps(effects))), "Rejected")


if __name__ == "__main__":
unittest.main()
Loading