Buying together · circles · technical · July 10, 2026

Three to six households split one pack.

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 shape: a star

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.

asks in ▸ shares out Dan ───ask──▶ ┌───────────────┐ ◀──ask─── Priya (member) ◀─share│ ORGANIZER │share─▶ (member) │ = SHOPPER (Rosa)│ Miguel ──ask─▶└───────────────┘ (declines — never learns the roster)   every hop is member ↔ organizer · no member↔member edge ever exists auth: HMAC-SHA256 under a per-pair secret, constant-time compare pairwise secrets suffice precisely because no member verifies another's

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.

Organizer == shopper, in v0

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.

What actually changed in the code

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.

Run lifecycle (verbs reused, one gated branch)

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.

BeforeVerb · directionGuard (run branch)After
(none)PROPOSE · organizer→each member (fan-out, per-pair HMAC)body schema …run/v0.1; validate_run passes; leak-scan cleanproposed — kind="run", members invited
proposedACCEPT{wanted_units} · member→organizersender ∈ participants, ≠ organizer, 0 < wanted ≤ per_shareproposed — member accepted
proposedDECLINE · member→organizersender ∈ participants, ≠ organizerproposed — member declined
proposedCONFIRM{agreement} · organizer→each acceptermin_fill ≤ Σsplit ≤ total; split ⊆ accepters, each ≤ wanted; ack==[organizer]confirm_pending — shopper=organizer
confirm_pendingCONFIRM{echo ack} · member→organizersender ∈ split; core byte-identical; ack==[organizer, self]confirm_pending → agreed iff every split party confirmed
proposed / confirm_pending / agreedCANCEL · organizer→eachsender==organizer; status not already terminal (reach widened to agreed — see defects)cancelled — tombstone, history kept
agreedRECEIPT-SHARE · organizer(shopper)→eachsender==shopper==organizer; every line commodity ∈ agreement; Σunits==Σsplitpurchased
purchasedSETTLE · organizer→eachrecompute compute_settlement byte-equal; through_kisop==falsesettled
settledDONE — local only, per sideper-party outcomes mapsettled (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.

The worked settlement, to the cent

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:

RUN: 4 households · case of 24 units · goods 5000¢ · tax 412¢ · min_fill 18 O(organizer=shopper) 10 · M1 8 · M2 6 · M3 declines Σsplit = 24 = total_units ≥ min_fill(18) → CONFIRM issues   GOODS 5000¢ prorated over 10/8/6 (largest-remainder, integer cents): O : 5000*10//24 = 2083 rem (5000*10)%24 = 8 M1: 5000* 8//24 = 1666 rem = 16 ◀ largest M2: 5000* 6//24 = 1250 rem = 0 Σbases = 4999 · leftover 1 → largest remainder M1 → goods: O 2083 · M1 1667 · M2 1250 (Σ = 5000 ✓)   TAX 412¢ prorated over 10/8/6: O : 412*10//24 = 171 rem = 16 ◀ largest M1: 412* 8//24 = 137 rem = 8 M2: 412* 6//24 = 103 rem = 0 Σbases = 411 · leftover 1 → largest remainder O → tax: O 172 · M1 137 · M2 103 (Σ = 412 ✓)   SUBTOTALS (goods + tax): O 2255 · M1 1804 · M2 1353 (Σ = 5412 = 5000+412 ✓)   OWES (shopper O nets out its own 2255¢): M1 → O 1804¢ ($18.04) M2 → O 1353¢ ($13.53) O fronted 5412¢, recovers 3157¢, nets its own 2255¢ ($22.55) money_movement.through_kisop == false # money moves person-to-person

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.

Fill, trim, and all-ack-or-cancel

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.

Blind invite, and what a confirmed run discloses

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.

Enforced versus recorded

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.

ClaimStandingWhere / note
Two-party path unchanged — its 65 tests pass unmodified; every N-party path is a branch gated on kind=="run"ENFORCEDregression bar; full-state-equality tests still green
N-way settlement reconciles exactly for N = 3, 4, 6, including remainder cases; zero cents created or lostENFORCEDdrives compute_settlement unchanged; worked case + randomized splits
Min-fill: CONFIRM refuses below floor and CANCEL is the terminal; a run meeting min_fill confirmsENFORCEDraises ExchangeRefused
Overfill trimmed first-accept-wins by envelope order; issued split never exceeds total_unitsENFORCEDtrimmed member may decline
All-ack-or-cancel: agreed only when every split party echoes; no partial-agreement state existsENFORCEDmissing ack ⇒ organizer CANCEL
No-show: run SETTLEs and completes for the rest; recorded as per-party C1 vouch; state.json never rewrittenENFORCEDDONE 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 eventENFORCEDlog-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 rosterENFORCEDdemo: ['m1-synth','o-synth']
Star routing only: every stored wire envelope is member↔organizer; a forged cross-member envelope is rejectedENFORCEDsender ∈ participants guard
Replay purity + guard + stdlib: delete every state.json → rebuild byte-identical from exchange/ alone; ~/PNA guard holds on all N-party pathsENFORCEDnon-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, settlementENFORCEDdeny set reused verbatim from the intent path
Run payloads carry consent class C3 and cross under a circle grantRECORDEDclass 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 trustRECORDEDlocal C1 ledger accrues; no cross-archive semantics yet
Stranger discovery (ring 2): blinded rendezvous, k-anonymity, PSIFUTUREspecified in the co-purchasing spec
End-to-end sealing rung, asymmetric identity, live transports (Signal / shared repo), decoupled shopper, cross-circle runsFUTUREv0 = HMAC pair secrets over shared-folder transport

A defect found while building — recorded, because that's the culture

The addendum this build follows had a hole in its own CANCEL table, and the build found it.

The privileged observer — the load-bearing risk

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:

Rotation distributes the observer. The observer is never abolished. Whoever hosts a given run sees everything for that run, and a circle with a natural host never really rotates. We name it as residual risk and refuse to let "it's a trusted circle" pretend it's solved.

Run it yourself

# from KISOP-SYSTEM/pna/cli — stdlib only, Python 3.9+, no installs python3 test_pna_copurchase_circles.py # 51 tests — star, fill, N-way settlement, blind invite, no-show python3 demo_copurchase_circle.py # 4 synthetic archives, full run, who-sees-what narrated   # the whole buying lane: 194 green = 78 miner + 65 two-party + 51 circles # the full CLI suite: 594 green · ~/PNA untouched

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.

Not built — named here

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.