did-btcr2-js

ADR 039: Cohort Condition Model

Status: Accepted

Date: 2026-06-21

Branch / PR: feat/aggregation-cohort-conditions

References: ADR 008, ADR 020, ADR 027, ADR 038

Context

When an Aggregation Service creates a cohort, it advertises the conditions under which prospective participants may enroll. The did:btcr2 specification (Aggregate Beacons, “Step 1: Create Aggregation Cohort”, source src/beacons/aggregate-beacons.md, last substantively edited 2026-05-26) enumerates the conditions a service can define:

When defining an Aggregation Cohort, the Aggregation Service can define conditions such as:

The phrasing (“can define conditions such as”) makes this an illustrative, optional menu, not a closed normative set: the spec also declares the full coordination protocol out of scope. The implementation currently defines only three of the seven.

What the implementation models today (verified against service.ts, cohort.ts, messages/bodies.ts):

Prior decisions to reconcile:

# Spec condition Modeled today This ADR: how handled
1 Beacon Type (CAS / SMT) yes keep; validate at createCohort
2 Min / max participants floor only (as cohortSize) enforced: rename to minParticipants + optional maxParticipants; gate accept + finalize
3 Min / max DIDs per participant no model now; enforcement staged (couples to the submission model used by non-inclusion signaling, where cohorts persist across signing rounds)
4 Cost of enrollment no model now; advertised-only, no settlement
5 Cost per announcement no model now; advertised-only, no settlement
6 Min / max time between announcements no (TTL is adjacent) model now; enforcement staged (generalizes the ADR 027 TTL; couples to multi-round signing where cohorts persist across rounds)
7 Pending-update-count trigger no (hasAllUpdates only) model now; enforcement staged (generalizes hasAllUpdates; couples to non-inclusion signaling, where cohorts persist across signing rounds)

Decision

Adopt the spec’s seven conditions as an explicit, mostly-optional cohort-condition model carried in CohortConfig and the COHORT_ADVERT body. Enforce the self-contained structural conditions now; model the rest as a stable wire format with enforcement staged to the tracks they couple to; advertise economics without settlement.

  1. Introduce a CohortConditions structure (embedded in CohortConfig and serialized into CohortAdvertBody). beaconType and minParticipants are required; every other condition is optional, and absent means unconstrained - matching the spec’s “can define … such as” menu. Sketch:

    interface CohortConditions {
      beaconType: 'CASBeacon' | 'SMTBeacon';        // 1  required
      minParticipants: number;                       // 2  required (lower bound)
      maxParticipants?: number;                      // 2  optional (upper bound)
      minDidsPerParticipant?: number;                // 3  optional
      maxDidsPerParticipant?: number;                // 3  optional
      costOfEnrollment?: CohortCost;                 // 4  optional, advertised-only
      costPerAnnouncement?: CohortCost;              // 5  optional, advertised-only
      minSecondsBetweenAnnouncements?: number;       // 6  optional
      maxSecondsBetweenAnnouncements?: number;       // 6  optional
      pendingUpdateTrigger?: number;                 // 7  optional
    }
    interface CohortCost { amount: number; unit: string; basis?: 'per-did' | 'per-participant'; }
    

    network stays a separate cohort parameter (it is not one of the seven conditions).

  2. Replace the conflated cohortSize wire field with an explicit minParticipants + optional maxParticipants pair. This is a breaking change to the COHORT_ADVERT body; pre-1.0, a clean rename is preferred over keeping a misleadingly-named field.

  3. Enforce the structural participant bounds now. maxParticipants gates acceptParticipant() (reject once the cohort is full) and is a ceiling at finalizeKeygen; minParticipants remains the finalize floor. This closes the unbounded-growth path.

  4. Validate conditions fail-fast at createCohort() rather than discovering invalidity at finalize: minParticipants >= 1, maxParticipants >= minParticipants when present, min* <= max* for every paired bound, non-negative costs and counts. Remove the || 2 silent default-drift (reject a missing/zero minParticipants instead of coercing it) and the participant’s ?? 0 silent zero-floor (reject a malformed advert). Make the advert/opt-in guards range-check, not presence-check.

  5. Economics are advertised-only. costOfEnrollment and costPerAnnouncement are operator-published metadata a participant uses to decide whether to join. The service performs no payment, settlement, escrow, or enforcement of them - consistent with ADR 008 (“no economic model mandated”) and the spec (which advertises costs but defines no settlement). Any future payment work gets its own ADR.

  6. Model the remaining conditions now, stage their enforcement. Add the wire/config fields for DIDs-per-participant (3), timing/cadence (6), and the pending-update-count trigger (7) so the advert format is stable before running multiple cohorts on a single service builds on it, but land full enforcement with the work they couple to: DIDs-per-participant and the update-count trigger depend on the multi-DID / long-standing-cohort submission model from non-inclusion signaling, where cohorts persist across signing rounds; maxSecondsBetweenAnnouncements generalizes the ADR 027 Cohort TTL, and pendingUpdateTrigger generalizes hasAllUpdates() (announce at a threshold, not only when all participants have submitted). Fields modeled-but-not-yet-enforced are documented as such so the advert never implies enforcement that does not exist.

  7. Conditions are advisory to participants, structural to the service. Per the spec, the advertised conditions let a participant decide whether to enroll; the service enforces only the structural subset (participant bounds now; DIDs-per-participant, timing, and trigger as they land).

Rejected alternatives

Consequences

Positive

Negative

Accepted

References