Same verbs, one new payload, the two-party path untouched. A circle run extends the two-party co-purchase engine to N = 3–6 households over an organizer-star topology. The organizer is the serialization point and the shopper; members pair with the organizer only; every wire verb is reused and gated behind a state.kind=="run" branch, so the two-party path stays byte-identical. Built overnight July 10, 2026; 51 new tests green, 194 in the exchange lane, 594 across the CLI suite at that build (current counts live on the build log); synthetic data only; ~/PNA untouched. A plain-words version tells the same story for everyone else, linked below.
The organizer is the star center and the serialization point. Members pair with the organizer and no one else: every wire hop is member ↔ organizer; members never exchange an envelope with each other. This is the dinner-party-host trust model made literal: guests trust the host's assertion of the roster, and they need not hold each other's keys.
Choosing the star over a mesh was a deliberate decision. The reasoning follows. Pairwise secrets are sufficient because every hop is member↔organizer: no member ever verifies another member's signature. Full mesh would cost O(N²) pairings between people who never send each other a byte, and it would open the exact member-to-member side channel the design excludes. The named cost: a member cannot check another member's ack cryptographically, so she trusts the organizer's word that everyone confirmed. The mitigation is real: every member independently recomputes the full N-way settlement and refuses a mismatch, so the organizer cannot fabricate a share without that member's own recompute catching it.
The household that proposes the run also makes the warehouse run, holds the receipt, and computes the settlement. This is deliberate: it means no receipt ever relays through a third hand. The organizer holds the one receipt, so no private document travels member→organizer→member. Decoupling shopper from organizer is future work, and it buys little privacy: the organizer-as-hub already sees the receipt regardless.
The seven wire verbs are the built set — PROPOSE · ACCEPT · DECLINE · CONFIRM · RECEIPT-SHARE · SETTLE · CANCEL — reused with N-party semantics. The only new artifact is one payload schema, kisop.copurchase.run/v0.1, a broadcast proto-agreement carried by the organizer's PROPOSE. The verb set did not grow. The run-PROPOSE stamps state["kind"]="run"; every later verb routes to a run branch when kind=="run", and two-party threads (no kind key) fall through the existing code unchanged. That is the regression bar: no two-party state dict or transition changes byte-for-byte, and its tests still pass.
The run branch is the two-party state machine with N-party completion tests. apply_envelope stays a pure, clock-free fold: it reads env["sent_utc"]. The wall clock is never read. Deadline logic lives only in send-side orchestration, so replay stays deterministic.
| Before | Verb · direction | Guard (run branch) | After |
|---|---|---|---|
| (none) | PROPOSE · organizer→each member (fan-out, per-pair HMAC) | body schema …run/v0.1; validate_run passes; leak-scan clean | proposed — kind="run", members invited |
| proposed | ACCEPT{wanted_units} · member→organizer | sender ∈ participants, ≠ organizer, 0 < wanted ≤ per_share | proposed — member accepted |
| proposed | DECLINE · member→organizer | sender ∈ participants, ≠ organizer | proposed — member declined |
| proposed | CONFIRM{agreement} · organizer→each accepter | min_fill ≤ Σsplit ≤ total; split ⊆ accepters, each ≤ wanted; ack==[organizer] | confirm_pending — shopper=organizer |
| confirm_pending | CONFIRM{echo ack} · member→organizer | sender ∈ split; core byte-identical; ack==[organizer, self] | confirm_pending → agreed iff every split party confirmed |
| proposed / confirm_pending / agreed | CANCEL · organizer→each | sender==organizer; status not already terminal (reach widened to agreed — see defects) | cancelled — tombstone, history kept |
| agreed | RECEIPT-SHARE · organizer(shopper)→each | sender==shopper==organizer; every line commodity ∈ agreement; Σunits==Σsplit | purchased |
| purchased | SETTLE · organizer→each | recompute compute_settlement byte-equal; through_kisop==false | settled |
| settled | DONE — local only, per side | per-party outcomes map | settled (unchanged) — vouch + savings to local ledgers; state.json untouched |
Fan-out is one stored original per thread (a canonical to = sorted(recipients) envelope, so replay stays pure) plus one per-member wire copy signed with that pair secret, one courier-log line per delivery, log-then-commit each: an unwritable log fails that delivery closed, per recipient.
compute_settlement already iterates a split of arbitrary length and prorates per line with largest-remainder, integer cents, a deterministic (-remainder, party-index) tie-break. It is N-agnostic and changed zero lines. The exact demo run:
The leftover cent lands on different parties per line (goods to M1, tax to O) because each line is prorated independently. Fully deterministic, reconciles exactly for any N, zero cents created or lost. Reconciliation is enforced for N = 3, 4, 6 including randomized remainder cases.
There is no free-form COUNTER in a run: an N-way renegotiation loop breaks the serialization model. The only negotiation is ACCEPT{wanted_units} with wanted ≤ per_share; a member who dislikes the terms declines.
Disclosure is stated as design. A member's PROPOSE names only the run terms. The co-invitees never appear in it, which falls out of the star for free, because each member's wire envelope carries to:[member_i] only. Post-PROPOSE, a member's participants is exactly {organizer, self}, and a test proves it: in the demo, M1's state reads exactly ['m1-synth','o-synth']. The proposal attributes no demand either. The organizer may aggregate members' asks in private, and even then the run body names no demander. Who wanted it first never crosses.
A confirmed run includes a participant list. The agreement issued at CONFIRM carries parties[] and split[], listing every accepter and their unit share, and reaches only accepters. Participants see one another's names, shares, and settlement subtotals. A decliner never learns the roster: M3's agreement remains None.
Same discipline as the two-party page: a claim is ENFORCED only when a test refuses the violation today. RECORDED means written down and honored by convention; FUTURE means designed. The build has not started. The 51 new tests carry the enforced rows on N-party paths.
| Claim | Standing | Where / note |
|---|---|---|
| Two-party path unchanged — its 65 tests pass unmodified; every N-party path is a branch gated on kind=="run" | ENFORCED | regression bar; full-state-equality tests still green |
| N-way settlement reconciles exactly for N = 3, 4, 6, including remainder cases; zero cents created or lost | ENFORCED | drives compute_settlement unchanged; worked case + randomized splits |
| Min-fill: CONFIRM refuses below floor and CANCEL is the terminal; a run meeting min_fill confirms | ENFORCED | raises ExchangeRefused |
| Overfill trimmed first-accept-wins by envelope order; issued split never exceeds total_units | ENFORCED | trimmed member may decline |
| All-ack-or-cancel: agreed only when every split party echoes; no partial-agreement state exists | ENFORCED | missing ack ⇒ organizer CANCEL |
| No-show: run SETTLEs and completes for the rest; recorded as per-party C1 vouch; state.json never rewritten | ENFORCED | DONE local-only; state byte-identical before/after |
| Rail invariants on N-party paths: unknown sender quarantined unopened; one-bit tamper refused (per-pair HMAC, constant-time); replay is an idempotent no-op; unwritable courier-log fails a fan-out delivery closed; one log line per event | ENFORCED | log-then-commit per recipient |
| Blind invite + disclosure: post-PROPOSE state names only {organizer, self}; guest list only in the agreement, only to accepters; a decliner's state never names the roster | ENFORCED | demo: ['m1-synth','o-synth'] |
| Star routing only: every stored wire envelope is member↔organizer; a forged cross-member envelope is rejected | ENFORCED | sender ∈ participants guard |
| Replay purity + guard + stdlib: delete every state.json → rebuild byte-identical from exchange/ alone; ~/PNA guard holds on all N-party paths | ENFORCED | non-vacuous guard meta-test; Python 3.9 stdlib only |
| Run minimization: validate_run fails closed on any of the same 19-key deny set or any non-allowlisted field, at any depth; leak-scan clean across run, agreement, receipt-share, settlement | ENFORCED | deny set reused verbatim from the intent path |
| Run payloads carry consent class C3 and cross under a circle grant | RECORDED | class is stamped; the consent gate is not wired into this loop yet, so enforcement happens at the library level today |
| Vouch / reliability record informs circle trust | RECORDED | local C1 ledger accrues; no cross-archive semantics yet |
| Stranger discovery (ring 2): blinded rendezvous, k-anonymity, PSI | FUTURE | specified in the co-purchasing spec |
| End-to-end sealing rung, asymmetric identity, live transports (Signal / shared repo), decoupled shopper, cross-circle runs | FUTURE | v0 = HMAC pair secrets over shared-folder transport |
The addendum this build follows had a hole in its own CANCEL table, and the build found it.
The star has a cost, and the cost is who sees what. The organizer is the hub, and the hub sees the traffic: every member's ACCEPT and wanted_units, who accepted versus declined, the full roster, everyone's split, the receipt, and the full settlement. Over repeated runs, an organizer can assemble a per-neighbor consumption profile (who buys oil, how much, how often), strictly more than any single two-party counterpart ever learned. This is the central new surface. The organizer really does hold the receipt, so no key can keep it from her.
The mitigations are social, and stated without overselling:
The demo builds four synthetic archives, runs the real machine end to end, and prints the transport logs and each archive's view: the 24-unit case above splits 10/8/6 to $22.55 / $18.04 / $13.53, M3 declines and its state never names the roster, then a no-show path is walked: the run stays settled, a local vouch is written, and every one of the four state.json files rebuilds byte-identical from exchange/ alone.
Decoupled shopper (organizer ≠ shopper), partial-agreement (proceed with the subset who acked), and proportional-trim (first-accept-wins today) are all deferred, each argued in the addendum's pushback.
Background deadline enforcement is not built yet. There is no daemon, so the organizer triggers min-fill and all-ack cancellation by hand at the deadline. A scheduled delivery check is the fix and belongs on the near list.
Inherited futures: the consent gate is not yet wired into this loop; stranger discovery (ring 2), the end-to-end sealing rung, live transport bindings, multi-run bundling, and cross-circle runs remain designed. The build has not started on any of them.
Working documents, in the repository: product/CO-PURCHASING-CIRCLES-ADDENDUM-v0.1.md · product/CO-PURCHASING-SPEC-v0.1.md · pna/SHARING-SPEC-v0.1.md · pna/PNA-COPURCHASE-BUILD-REPORT.md.