Skip to content

Add contact-based group admission across maintained clients - #26

Draft
vessenes wants to merge 98 commits into
mainfrom
feature/contact-group-welcome
Draft

vessenes wants to merge 98 commits into
mainfrom
feature/contact-group-welcome

Conversation

@vessenes

@vessenes vessenes commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Adding a verified contact now admits their full public key, rotates group keys, and sends a recipient-encrypted welcome through the existing relay. Python CLI/MCP, the browser, terminal and OpenClaw support the flow; recipients receive a public locator without keys and can open their welcomes in a different order. Current-key refresh and retry preserve removal boundaries, reconcile confirmed delivery, and retain uncertain ciphertext across restart.

Matching TypeScript and Python welcome/session APIs check replay coverage and authenticated membership before delivering plaintext. Host journals, encrypted browser backups and private receive checkpoints retain the state needed for current-key refresh, interrupted addition/removal recovery, and explicit local release of an unproven stale retry. README compatibility guidance, CLI help and metadata documentation describe these behaviors and their remaining boundaries.

Validation includes shared protocol fixtures and actual browser, Python CLI/MCP, terminal PTY and installed OpenClaw journeys with TypeScript peers and local relay/gateway Workers. Full Linux CI passed on 516935e after fixing a test race between receiving a reply and finalizing its sender journal. All 14 native contact journeys also pass locally. These journeys include current-key exclusion after removal and recovery through a fresh local agent turn after restart. This passing result does not approve the feature's unresolved design choices or the separate unfinished control branches.

This remains a draft, outside the v0.6.2 release, pending user design review. Further feature work is paused: privacy/usability tradeoffs require the user's decision before implementation. Python release support is included here; unfinished browser/terminal and native controls are preserved separately in WIP commits 0e47c85 and 08611f9 and are not merged into this PR (beads qntm-ra0e). Contact-group promotion into gateway governance and incomplete legacy roster migration remain explicitly unsupported; their follow-up is qntm-jg3x. No release tag or production deployment has been made from this branch.

PV added 30 commits September 9, 2026 23:52
PV and others added 27 commits September 10, 2026 13:32
…fresh retry

The four generic founder refresh journeys expected one physical relay row
after a committed-but-unacknowledged welcome POST and its exact retry. The
relay is append-only: worker/src/index.ts assigns a new sequence per accepted
POST and never deduplicates by message ID, so identical ciphertext occupies two
rows. Receivers deduplicate by message ID (docs/QSP-v1.0.md).

Assert one row per recorded attempt, one distinct ciphertext and message ID,
no other posts after the anchor, and that the duplicate row cannot rotate keys,
add an admission, decrypt as plaintext, or mark history missing on either the
fresh TypeScript founder or the issuing Python member. Keep the pre-POST
journal evidence checks, the original optional challenge, and the reply
exchange. No relay, protocol, or client behavior changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The native callback acceptance asserted the close code that Node's bundled
undici reported for the failed socket. Wire captures (CI 34524097914 artifact,
plus a byte-logging proxy in front of the direct workerd fixture on Node
22.23.2/undici 6.28.0 and Node 24.20.0/undici 7.29.0) show the maintained
client sends Close(4000, "receive callback failed") and the relay echoes
Close(4000) before FIN, yet undici reports 1006 in a few percent of runs: the
relay's frames are compressed, undici inflates them asynchronously, and the
Close echo is still queued behind that inflate (parser writableLength 102,
closingInfo null) when the socket close event fires.

Record the Close frames the client actually sends through a recording
WebSocket for this test and assert exactly one 4000 close before redelivery.
Keep the observed close event, but only reject codes that would mean the
relay ended the failed subscription. Retain sent and observed closes plus the
Node/undici versions in native-callback-reconnect.json. This is fixture
isolation of an undici reporting race, not a change to the relay or client.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…Socket

Accepting 1006 in the native callback test left the relay's close handshake
untested through undici. Add a separate raw TCP WebSocket regression: it
requests no extensions, verifies the upgrade, waits for the ready frame,
sends a masked Close, and parses the exact Close code and reason the relay
returns and the FIN that follows, with a bounded timeout and the socket
destroyed on settle. Red proof: with the Worker's webSocketClose echo removed
the test times out with close null and no FIN; restored, it passes for
4000 "receive callback failed" and 1000 "client closed".

Correct the native callback commentary: recording close() proves the client's
invocation, not wire bytes; undici's 4000-or-1006 report is accepted as
unexplained rather than attributed to the relay; the wire evidence was local
and CI only observed the 1006. Create the subscription inside the try that
installs the recording WebSocket so the global is always restored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Latch a bounded private receipt (message ID, canonical ciphertext digest,
source epoch, verified relay sequence, branch validity) in the same group
checkpoint write when authenticated receive accepts the exact ciphertext of
a saved control. Observed delivery stays separate from canonical effect: a
competing-rekey rewind invalidates the losing rotation and every receipt on
its descendants, and a challenged welcome replacement invalidates all prior
receipts. An explicit invalidation overrides a duplicate marker the rewind
left in the bounded seen cache; without a receipt only the retained marker
counts and older evicted evidence stays unknown. No ACK, bare ID, target
absence or expected-root inference is treated as proof.

Retry review and resume bind the evidence: a fully receipted remove, rekey
or send finishes locally as accepted_cleanup with zero POST after real cache
eviction or a later legitimate rotation, while cache-only evidence keeps the
exact expected-state requirement. Receipts are validated against the pending
exact wires, conversation and cursor on every load and write, follow the
operation through saveRetry/cleanup, and join the review fingerprint so proof
that changes between prepare and commit needs a new review. Writer locking
and operation CAS are unchanged.

Also fix the a735bfb regression: prepare('refresh') built the generic
expected checkpoint without the admission map while the sealed welcome
carried it, so every fresh generic refresh with known admissions failed the
signed-intent comparison and broke the native journey.

Tests: real authenticated traffic pressure against the full legal seen bound,
exact cleanup after restart with zero POST, later canonical rotation, losing
source rekey left in seen, losing descendant, challenged welcome replacement,
malformed/mismatched receipts, changed proof between prepare and commit, and
an installed OpenClaw/Python/TypeScript journey through the existing native
review path (staged inside an admitted agent turn via the test-only hook).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Latch exact authenticated rekey/remove/create evidence in a bounded private
host checkpoint so retry can finish after real seen eviction without a
duplicate POST, later legal rotation, or predicted-key install. Explicit
invalidation on rewind and challenged welcome replacement overrides leftover
seen markers. Missing legacy evidence stays unknown.
Retry a completed rekey after synthetic legal-bound cache pressure and a
real signed eviction event, restart, later rotation, and the Retry button.
Invalidation wins across duplicate receipt identities; backups reject them.
Preserve the failed 8192-message receive experiment as a non-gate follow-up.
…groups

While an ordinary-group operation is pending, inbound messages are correctly
deferred and cannot start a turn, so after a crash nothing could reach the
reviewed qntm_group retry. The pinned OpenClaw CLI already documents a
Gateway-backed, session-scoped turn: `openclaw agent --channel qntm --to
<binding>`. Verified against the real 2026.9.3 host, that turn lands in the
group's own session (this plugin's outbound session route) inside the running
Gateway process, marked owner-initiated, with a Gateway-resolved delivery
route `qntm:<conversation>` but no platform-native conversation id, so the
tool's scope check hid it.

resolveGroupToolScope now derives the conversation only from runtime context:
the native inbound id as before, or, solely when the run is marked owner
initiated and addressed to this channel, the delivery route; both ids must
agree when present. Host-scheduled turns carry neither signal. Everything
else is unchanged: configured account and binding, action permissions,
contact pins, in-process prepare/commit reviews with their fingerprint, the
writer lock, relay replay, membership, removal, recovery and expiry guards.
The route starts a normal agent turn and commits nothing itself; qntm_gateway
scope is untouched. No new endpoint, nonce or approval protocol is added.

Tests: scope units for the operator route and its rejections (owner bit or
channel missing, other channel, unprefixed or unknown target, other account,
missing session, disagreeing ids, foreign review, denied action); a fixture
that leaves a rotation saved but never posted with the host stopped; a
harness helper that runs the host CLI from a fresh process; and a native
journey: host restart with a crash-before-POST state, an inbound Python
message stays deferred, a wrong conversation target hides the tool, the
operator turn commits the reviewed retry which publishes the exact control
once, then the deferred message is released exactly once and delivery
resumes. README documents the command, its trust boundary and transcript
footprint without claiming a universal restart wake-up.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ership

An accepted group_remove whose saved completing rekey expired, was never
posted, or no longer fits the verified roster left the remover (including a
sole surviving creator) stuck: retry raised "message has expired" and the
pending journal blocked any new rotation. Retry now proves the original
removal only from exact authenticated receive history (_control_accepted:
history ID, canonical digest, source epoch, positive verified sequence and a
still-valid branch binding; invalidated history outranks a stale seen entry),
then saves a fresh current-roster rotation as a removal_rekey journal before
any POST. The original remove ciphertext stays as flat bounded evidence under
origin/superseded_operations with the existing revision and byte limits.
Release rechecks the journal, the removal proof, source epoch, needsRekey,
current authority and the roster under the receive lock; verified replay, not
an acknowledgement, finishes the operation. Once any verified rotation left
the removal's epoch (a helper's completion, a later readmission or another
removal), retry finishes without posting and never re-removes. A removal that
was never proven accepted is preserved with a precise error when it expires
or its epoch is superseded (follow-up qntm-ra0e).

New remove journals pin the target's full public key, canonical member record
and admission incarnation; exact release refuses a later readmission of that
identity, and journals saved before pinning refuse a target admitted at the
current source epoch. Standalone rekey journals keep exact valid bytes, are
replaced from current membership when expired, on a rewound branch or after a
roster change at the same epoch, and finish without posting once a verified
later rotation exists. Old controls are never republished into obsolete
epochs and no predicted roots are installed.

Tests: 41 new Python cases cover sole survivor and helper rosters, restart and
lost ACK, helper completion before and during release, later and same-epoch
readmission, losing replacement branches, lower-branch invalidation, authority
races between staging and POST, repeated repairs with evidence bounds, proof
after cache eviction (tampered history and real eviction pressure), unaccepted
expired/superseded removals, standalone rotation variants and CLI/MCP
surfaces. Nine receipts tests now expect a superseded standalone rotation to
finish rather than stay blocked. A real relay journey runs the accepted
removal, expired rekey, lost ACK and fresh CLI and MCP retries against two
TypeScript peers, asserting the durable journal before POST, append-only relay
rows, survivor rotation and target exclusion. Full Python suite: 817 passed,
1 skipped, 3 subtests. README, MCP docstring and generated CLI help updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…bership

An accepted group_remove whose saved completing rekey expired, was never
posted, or no longer fit the verified roster left the browser remover stuck:
Retry saved operation raised "The saved operation expired" and the pending
journal blocked any new rotation, including for a sole surviving creator.
Retry now proves the original removal only from the private control receipt
(exact id and digest, source epoch, verified positive sequence, still-valid
branch binding; invalidation outranks a leftover seen entry), then saves a
fresh current-roster rotation as a removal_rekey journal before any POST. The
original remove and rekey ciphertext stays as flat bounded evidence under the
existing revision and byte limits, and the original receipt remains pinned
while only the repair is in the journal's controls. Release under the group
lock rechecks the journal, the removal proof, source epoch, needsRekey,
current authority and the roster; verified replay finishes the operation, an
acknowledgement never installs keys. Once any verified rotation left the
removal's epoch (a helper's completion, a later readmission or another
removal) retry finishes without posting and never re-removes. A removal that
was never proven accepted stays preserved with a precise error when it
expires or its epoch is superseded (qntm-ra0e).

New remove journals pin the target's full public key, canonical roster record
and admission provenance; exact release refuses a later readmission of that
identity, and older journals refuse a target admitted at the current source
epoch. Standalone rekey journals keep exact valid bytes, are replaced from
current membership when expired, on another branch or after a same-epoch
roster change, and finish without posting once a verified later rotation
exists. Encrypted backups validate the pinned target, the removal repair's
origin and epoch bindings and superseded rotation evidence, and still accept
older journals without those fields. Opening a group link no longer re-feeds
rows at or before the saved cursor into the receive path, which had raised a
false missing-history barrier when a removed member who never observed its
removal opened a readmission link.

Tests: 40 new host units (sole survivor and helper rosters, lost ACK with
encrypted-backup restart, helper completion before retry and during release,
later and same-epoch readmission, pinned and legacy readmission refusal,
losing replacement branch with recovery and proof invalidation, lower-branch
invalidation, authority races immediately before POST, repeated repairs and
evidence bounds, tampered and evicted receipts, real eviction by authenticated
controls with receipt pinning, unaccepted removals, standalone rotation
variants, backup corruption). Seven receipt units now expect a superseded
standalone rotation to finish rather than stay blocked. A new Playwright
journey runs the accepted removal, expired rekey, real dropped ACK, restart
and retry against the relay with a fresh Python CLI peer and a TypeScript
peer, asserting the durable pre-POST journal, one relay row, no repeated
removal, exclusion and readable replies; it passes on the relay stub and the
real Worker (12 passed, 1 skipped). UI units 232, build and integration
typecheck clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…mbership

An accepted native removal whose completing rotation expired, or a standalone
rotation that went stale, previously left the journal preserved forever and
blocked the group. Port the reviewed Python design (e5cf409) to the host.

Removal journals pin the target's key ID, full public key, canonical member
record and admission incarnation, so an exact retry never publishes an old
removal against a later readmission; older journals refuse a target admitted
at the current epoch. The original removal is proven only by exact
authenticated acceptance of its saved wire. When it is proven but its rotation
expired, targets an obsolete roster or cannot apply, retry reviews a fresh
rotation for the current remaining roster as phase removal_rekey with the
original encrypted intent and pin as its origin, saved before POST; repeated
repairs join the flat bounded superseded history. Receipts now bind to the
current controls plus a repair's original removal, still at most two, so the
removal receipt survives the journal transition and is invalidated by the
same rewind and challenged-welcome rules; no sequence is invented from the
cache. If any verified rotation already left the removal's epoch, the journal
finishes locally with no POST; a same-epoch readmission stays in the repaired
roster. Release rechecks proof, epoch, pending-rotation state, authority and
the exact rotation immediately before POST. Unproven expired or superseded
removals stay preserved with precise reasons. A standalone rotation is kept
exact, fulfilled by any verified later rotation, or replaced after review.

Tests: 28 store/tool units (sole survivor and helper, lost ACK and restart,
helper completion before and between review and commit, later and same-epoch
readmission, pinned and legacy same-epoch readmission refusals, losing repair
branch and challenged welcome, authority races, repeated repairs and limits,
real cache eviction with a tampered origin receipt, unaccepted removals,
standalone rotation variants and barriers, action denial and mutated
reviews); a native journey with real host restarts, deferred inbound Python
traffic, a denied and then a reviewed fresh-CLI operator retry, an uncertain
repair proven from replay, and both removed peers excluded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…alls

CI 34537110345 (Node 22.23.2, Linux) failed the idle-boundary regression with
69 of 160 POSTs: all 31 non-zero lanes in round 0 hit the 5 s abort although
every one of those messages was stored (replay has seq 1 for all 32 lanes, no
duplicates), and rounds 2 and 4 lost 38 POSTs to "other side closed" or
ECONNRESET on sockets that had carried exactly one earlier exchange. The
artifact records only send times, so it cannot say whether the relay was
slow, the reused socket was stale at the relay's 5 s idle boundary, or the
test process stalled past undici's 4 s native keep-alive timer.

Tag each idle-boundary POST and subscribe to undici's diagnostics channels
for the duration of the test: per POST, the socket id, whether it was
reused, idle time since that socket's previous completed response, and the
times headers were written and response headers/trailers arrived; plus
connection opens/closes, undici transport errors, and event-loop stalls
above 100 ms. Merged into idle-boundary.json rows and written to
idle-boundary-transport.json. No client, relay or assertion change.

Local baseline (macOS, Node 22.23.2): 160/160, round-0 latency 37-119 ms,
76 sockets, reuse only within a round with idle times under 40 ms, no
stalls.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Round two of the native removal journey removed a TypeScript peer whose last
keys dated from epoch 3, so failing to decrypt later traffic only showed
staleness. Before the removal, the host's reviewed refresh now delivers a
signed current-key renewal to the still-admitted peer, which opens it through
the ordinary TypeScript flow (newest openable welcome, coverage from the
signed anchor, replay) and reads a live survivor text. The staged removal pins
that same incarnation. After expiry, the uncertain repair, host restart and the
operator's accepted_cleanup turn, the peer replays the real removal row and
repair rotation into its current session and is removed at the prior epoch,
while every row published after the repair fails with those immediately prior
keys and Python survivors and the host keep replying. Exact-row, no-repost,
deferred-turn and permission assertions are unchanged, as is round one.

Test fixtures: the remaining cache-fill loops in the removal eviction test and
the accepted-rotation fixture use the linear counter from 6c28159 with the
same 8192 bound and real authenticated eviction. The host contact pin is passed
to the Python CLI in hex, since a base64url key starting with '-' is read as
an option by its argument parser.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ence

A saved group_remove that was never verified in replay and can no longer be
retried exactly (expired, source epoch superseded, other branch, absent or
readmitted target, or a legacy journal whose target was admitted at the
current epoch) stayed preserved forever, and its pending journal blocked every
send and membership change. Plain retry still never mints a replacement
removal; instead `qntm group retry CONVERSATION --release-unproven` and MCP
`group_retry(conversation, release_unproven=True)` give up local retry
ownership of exactly that journal. After the full locked replay, under the
receive lock and a canonical journal compare, release refuses when history is
incomplete, when the original removal is verified (plain retry finishes or
repairs it), when the exact bytes still apply to their pinned incarnation, or
when the journal changed. It posts nothing, claims nothing was removed, and
leaves received membership, removal, rotation and recovery state untouched.

The uncertain ciphertext, target pin and prior origin/superseded evidence move
into a flat private archive on the conversation record with the reason and
time, without predicted expected roots. The archive shares the existing
256-entry and 4 MiB budget and is validated before growth; when the entry
does not fit or the archive is malformed, release refuses with the journal
and archive unchanged rather than dropping evidence. The archive survives
receive commits, saves and a challenged welcome replacement. Release of a
removal_rekey repair whose original proof was invalidated by a verified later
replacement is included, since plain retry never republishes an unproven
origin removal. The result reports released, reason, current epoch, members,
removed and needs_rekey; a later `group remove` is a fresh current-epoch
decision with its own pin. prepare_change gains an optional removal_ttl used
only by the relay fixture.

Tests: 23 new cases cover pinned and legacy journals, expired, superseded,
other-branch, absent and readmitted targets, verified and still-exact refusals,
incomplete history, other operation kinds, removed and needsRekey barriers,
journal races, revision, byte and malformed archive limits, restart and
welcome-replacement survival, late arrival of the released ciphertext, and CLI
and MCP surfaces. Full Python suite: 840 passed, 1 skipped, 3 subtests.
Generated CLI reference regenerated and drift-checked. Real relay journeys
through fresh CLI (expired) and MCP (superseded by a peer's later admission)
processes with TypeScript peers show zero posts during release, no false
exclusion, then an explicit new removal that excludes the target with current
keys while other peers keep reading.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…of prepare_change

Root review of 551832b asked for two bounded corrections. The speculative
proof_invalidated escape in the release classifier had no reachable
production scenario: after an accepted removal the target is absent from the
roster, and a same-epoch readmission changes the pin, so a repair origin whose
bytes still exactly apply cannot occur. Remove it and its reason from
RELEASE_REASONS; if the original unproven bytes still exactly apply at this
epoch to their pinned incarnation, release refuses whatever journal kind holds
them, while expired, superseded and other-branch removal_rekey origins remain
releasable as tested. A focused regression calls the classifier with the same
still-applying bytes carried as a repair origin and expects the refusal,
without any fabricated acceptance.

The prepare_change(removal_ttl=...) argument existed only for tests. Revert
the production signature and docstring; the unit helper and the relay fixture
now stage a short-lived removal journal from the same production factories
the remove action uses (create_group_control_message with a lifetime,
receive_group_event, create_rekey and the target pin) and the private
journal builder, installing no expected keys and inventing no receive proof.

Verification: test_group_removal_release.py 23 passed, removal recovery and
control receipt suites unchanged and passing, both real relay release
journeys (fresh CLI expired, fresh MCP superseded) passed with zero posts
during release.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vessenes vessenes changed the title Bring client parity fixes and contact-based group admission together Add contact-based group admission across maintained clients Sep 10, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant