Skip to main content

Building a tournament

A tournament is built by four writes, in a client's order: addParticipants, addEvent, addEventEntries and addDrawDefinition (a draw generated by generateDrawDefinition, added as it was generated), with seven smaller writes around them (§6: venues and courts, policies and extensions, flights, scale items, publication). This page states what each stores, byte for byte, so that a second implementation can reproduce the record they leave: the inputs, the refusals, every field written, and the stamps. Where the behaviour is a question rather than a decision it is marked OPEN.

First version, 2026-10-29, written from the source; §6 added 2026-10-30. The golden corpus pins it: the mocks source (pnpm corpus:mocks) writes generated tournaments as these calls (generateTournamentRecord({ recordMethods: true })), with the state hash after every call. A record is hashed in canonical form (RFC 8785), so key order never matters; a key holding undefined is not stored.

1. What every call shares​

1.1 The clock and the stamps​

An engine call captures one instant when it starts, and every write in the call reads it: an executionQueue shares one instant across all its directives. Two kinds of stamp are written from it.

updatedAt (ISO string) is stamped on a drawDefinition, its top-level structures and its matchUps, by the notices that announce them, never on participants, events, entries or flights. The rule:

  • t is the captured instant. If the entity's existing updatedAt is at or after t, the new value is the existing value plus 1 ms; otherwise it is t.
  • An entity is stamped at most once per call (the first stamp stands).

So a draw generated in one call and added in the next, under the same instant, is stored one millisecond later than its generation stamp, and a matchUp that had no stamp is stored at t.

The factory stamp on the tournament record, factory: { ...existing, version, timeStamp }, is written after a call on each record the call modified, and only on those: a record is modified by a notice that names its tournamentId (whether or not anyone subscribes); a notice that names none modifies every loaded record; a notice that only reports (dataIssue, audit) modifies nothing. version is the package version; timeStamp is the captured instant in epoch milliseconds (a number). A write made with suppressNotifications is stamped as an announced one is: its draws, structures and matchUps take updatedAt, and its record the factory stamp; only the notices are dropped. A call that writes nothing, or writes only to a working copy (the engine's internal disableNotifications), does not stamp. A refused call without rollback keeps its earlier writes and is stamped if any of them modified a record; with rollbackOnError nothing is kept or stamped. (A state provider without the optional cycleModifiedTournamentIds hook stamps every loaded record, and one without markModified leaves a suppressed write unstamped.)

1.2 Ids and randomness​

An id a write mints (a participant's, an event's, a TEAM collection's) is a v4-style UUID drawn from the call's random source: a seeded call (nonRandom) draws from a stream derived from its seed, otherwise from the engine's configured source. A client that supplies its ids makes no draws. Every call in the corpus supplies them.

The mint. An id takes four draws r0..r3 from the source (setRandomSource(seed) installs mulberry32 seeded with seed). Each is scaled and truncated to a 32-bit word, di = trunc(ri × 0xffffffff), and the id is the words' bytes, least significant first, as two lowercase hex digits each, in the 8-4-4-4-12 form:

d0[0] d0[1] d0[2] d0[3] - d1[0] d1[1] - (d1[2] & 0x0f | 0x40) d1[3] - (d2[0] & 0x3f | 0x80) d2[1] - d2[2] d2[3] d3[0] d3[1] d3[2] d3[3]

where dn[k] is byte k of dn ((dn >> 8k) & 0xff): the version nibble is 4 and the variant bits are 10. With a prefix, the id is `${prefix}_${id without hyphens}`. A source that returns a value outside [0, 1), or that mints an id it minted among its recent ids, throws (DEGENERATE_RANDOM_SOURCE).

1.3 Params​

The engine copies the params object shallowly, then resolves the record, the event and the draw the call works on. An engine call works only on the records the engine holds: whether the call names them by id or passes them as objects, it is given the ones in state, never the caller's own. This is done for every call, before the method runs:

  1. An entity given both as an object and by id must be one entity: a tournamentRecord whose tournamentId, a drawDefinition whose drawId or an event whose eventId is not the id given beside it refuses the call (INVALID_VALUES).
  2. A tournamentRecord passed is resolved by its tournamentId. A tournamentId (given, or the passed record's) that names no loaded record refuses the call: MISSING_TOURNAMENT_RECORD.
  3. A drawDefinition passed is resolved by its drawId. A drawId (or a matchUp's drawId) found in a loaded record gives the call that draw, its event and its record; a flight can name its event after its draw is gone. A passed draw found nowhere refuses the call (DRAW_DEFINITION_NOT_FOUND); a drawId found nowhere, given alone, resolves nothing and the method decides.
  4. Otherwise an event passed is resolved by its eventId, and an eventId gives the call the stored event and its record. An event with no eventId, or an eventId found in no loaded record, refuses the call: EVENT_NOT_FOUND.
  5. The record the call writes is the one resolved, else the one tournamentId names, else the active record. An eventId or drawId held by another loaded record moves the call to that record; the active record is unchanged.

A method's creation payload is not resolved: addEvent's event and addDrawDefinition's drawDefinition are the entities it stores. To work on a record, draw or event the engine does not hold (a record being built before it is loaded, a generated draw before it is added), call the governor function directly, which works on the object given. Called directly, a function given both an entity and its id is held to rule 1.

Nested objects are not copied: an object passed in (a participant, a drawDefinition) is the object stored, and a write to it after the call changes the record.

2. addParticipants​

Inputs. participants (default []), allowDuplicateParticipantIdPairs, returnParticipants.

Refusals, in order, the first refusal ending the call. A participant is checked against those appended before it in the same call, so a refusal can come part way through; the participants appended before it are then removed, and the call writes nothing:

  1. No tournament: MISSING_TOURNAMENT_RECORD.
  2. Then per participant, in the add order below:
    • its participantId is already in the record, including one appended earlier in the same call: PARTICIPANT_ID_EXISTS;
    • participantType not one of INDIVIDUAL, PAIR, TEAM, GROUP: INVALID_PARTICIPANT_TYPE;
    • no participantRole: MISSING_PARTICIPANT_ROLE;
    • a person on anything but an INDIVIDUAL, or individualParticipantIds that is not an array: INVALID_VALUES;
    • PAIR: no individualParticipantIds (MISSING_PARTICIPANT_IDS), not exactly two, or one not an INDIVIDUAL in the record (INVALID_PARTICIPANT_IDS);
    • INDIVIDUAL: a person.sex that is not FEMALE, MALE, OTHER, F, M or O (INVALID_VALUES); no name, meaning neither both standard names nor participantOtherName nor participantName (MISSING_PERSON_DETAILS);
    • TEAM or GROUP: a member id that is not a string (INVALID_VALUES) or not an INDIVIDUAL in the record (PARTICIPANT_NOT_FOUND).

What is written.

  • participants is created on the record if absent (an empty array when the call adds no one); a refused call creates nothing.
  • A participant without a participantId is given one, before anything else, in input order.
  • Participants whose id was in the record when the call began are skipped (reported in notAdded). The rest are appended in this order: every INDIVIDUAL in input order, then every other participant in input order. Each is the object passed in.
  • participantName, when given, has its whitespace collapsed (runs of whitespace become one space, ends trimmed).
  • INDIVIDUAL: person.sex '' or null is removed; F, M, O and the long forms are stored as FEMALE, MALE, OTHER. person.standardGivenName and standardFamilyName have their whitespace collapsed. A missing participantName becomes "<standardGivenName> <standardFamilyName>" when both are present, otherwise participantOtherName ?? ''.
  • PAIR: if a PAIR of the same two individuals exists (the first in record order), nothing is written for it, unless allowDuplicateParticipantIdPairs. A missing participantName is built from its individuals in record order: each one's standardFamilyName, else participantOtherName, else participantName; nameless ones dropped; a FEMALE and MALE pair puts the female first, otherwise names sort (Intl.Collator('en')); joined with /; one name alone becomes "<name>/Unknown".
  • TEAM, GROUP: a gender is normalised (F, M, X, A to FEMALE, MALE, MIXED, ANY); individualParticipantIds defaults to [].
  • No timestamps. One notice for the call, if anything was added.

Returns { success, addedCount }, with participants (copies) when returnParticipants, and notAdded with info: EXISTING_PARTICIPANT when some were skipped. When nothing new is given: { success, addedCount: 0, info }.

3. addEvent​

Inputs. event (it may omit eventId), suppressNotifications, uuids (a pool for TEAM collection ids). internalUse, which lets an event arrive with entries or draws, is not a client param.

Refusals, in order:

  1. No tournament or no event: MISSING_TOURNAMENT_RECORD, MISSING_EVENT (INVALID_VALUES when not an object).
  2. The event has entries or draws: INVALID_VALUES, info: 'entries/drawDefinitions cannot exist' (empty arrays pass).
  3. TEAM with a tieFormat that does not validate: INVALID_TIE_FORMAT. TEAM with a tieFormatName that is not a named format (fixtures.tieFormats, which lists every tieFormatConstants name): INVALID_VALUES. STANDARD, the builder's fallback for a name it does not know, is not a named format.
  4. The eventId (given or minted) is already an event's: EVENT_EXISTS.

What is written. A new object, appended to events (created on the record if absent):

{ eventType: 'SINGLES', drawDefinitions: [], entries: [], startDate: record.startDate, endDate: record.endDate, ...event }
  • The caller's fields override the defaults, an explicit undefined included. Nested objects are stored as given.
  • gender, when truthy: F, M, X, A (and the long forms) to FEMALE, MALE, MIXED, ANY, exactly and case-sensitively; anything else is stored as given.
  • discipline, when a non-empty string: trimmed, upper-cased, runs of whitespace or - replaced by _.
  • TEAM with a tieFormat: a copy is stored, each collection without a collectionId given one (from uuids, taken from the end, else minted), in order. TEAM with only a tieFormatName: the named format is stored as tieFormat and tieFormatName stays; each collection id is taken from uuids (from the end), else minted, or "<eventId>-COL-<n>" (the event's stored id) when the tournament or event is a mock.
  • eventId, when absent, is minted, before any collection ids.
  • No timestamps on the event. The event's draws and their matchUps (none, for an event added before its draws) are stamped (§1.1), and, unless suppressNotifications, announced, whether or not anything subscribes.

Returns { success, event } (the stored event).

4. addEventEntries​

Inputs. eventId (or a drawId, which also names the event), participantIds, entryStatus (DIRECT_ACCEPTANCE), entryStage (MAIN), entryStageSequence, autoEntryPositions (true), roundTarget, extension / extensions, enforceGender (true), enforceCategory (false), and for a draw suppressDuplicateEntries (true) and ignoreStageSpace.

Refusals. Nothing is written before any of them; the call is all or nothing for the event.

  1. participantIds not an array: INVALID_PARTICIPANT_IDS. No event: MISSING_EVENT. No ids after removing duplicates: MISSING_PARTICIPANT_IDS. An event without eventId: EVENT_NOT_FOUND. A malformed extension: INVALID_VALUES.

  2. Eligibility. A participant must be a competitor (no participantRole, or COMPETITOR) of the event's kind:

    • SINGLES: an INDIVIDUAL, entered with a status that is not UNGROUPED or UNPAIRED;
    • DOUBLES: a PAIR, or an INDIVIDUAL entered UNGROUPED or UNPAIRED;
    • TEAM: a TEAM, or an INDIVIDUAL entered UNGROUPED or UNPAIRED;
    • HYBRID: an INDIVIDUAL or a PAIR.

    Where the event has a gender other than MIXED or ANY and gender is enforced, an individual's person.sex must match it. With enforceCategory, every individual must fit the event's category. Any participant failing these: INVALID_PARTICIPANT_IDS, with context naming mismatchedGender and categoryRejections.

What is written.

  • entries is created on the event if absent. Each id not already entered gets, in input order: { participantId, entryStatus, entryStage }, plus extensions (copies), an extension (with createdAt set to the call's instant if it has none), roundTarget and entryStageSequence when given. Existing entries are unchanged.
  • With autoEntryPositions, the event's entries are then rewritten: grouped by stage and status (groups in order of first appearance, which can reorder the array), ordered within a group by their existing entryPosition (absent last, stable), and numbered entryPosition 1..n per group.
  • DOUBLES and TEAM: individuals entered UNGROUPED or UNPAIRED who now belong to an entered PAIR or TEAM are removed from the event's entries (unless one holds a draw position).
  • With a drawId, the same participants are entered in that draw (and its flight), ungrouped individuals included; a refusal there is returned as info and the event's entries stand. A participant already in the draw's entries is not entered again (reported in the draw's duplicateEntries, and refused with DUPLICATE_ENTRY when suppressDuplicateEntries is false). The draw and its structures are stamped (§1.1).
  • No stamps on the event or its entries.

Returns { success, addedEntriesCount, removedEntriesCount }, with info when the draw refused.

5. addDrawDefinition​

Inputs. eventId, drawDefinition (complete: structures, matchUps, entries, positions), suppressNotifications, existingDrawCount, allowReplacement, force, modifyEventEntries, checkEntryStatus, flight.

Refusals, in order; nothing is written before any of them:

  1. No draw: MISSING_DRAW_DEFINITION. No event: MISSING_EVENT.
  2. existingDrawCount given and not the event's draw count: INVALID_VALUES.
  3. The event's flight for this draw names a source draw that is not in the event: MISSING_DRAW_DEFINITION.
  4. The selected flight (flight.flightNumber) is for another draw, or a draw entry is not among its entries: INVALID_DRAW_DEFINITION. With checkEntryStatus, a draw entry without a matching event entry: INVALID_DRAW_DEFINITION.
  5. The event already has this drawId: DRAW_ID_EXISTS, unless allowReplacement; replacing a draw with scores needs force or the scoring policy's permission (SCORES_PRESENT).

What is written, in this order:

  1. drawDefinitions is created on the event if absent.
  2. With modifyEventEntries, each event entry whose participant is selected in the draw takes the draw entry's status.
  3. drawOrder = one more than the largest of the event's draws' drawOrders and its flights' flightNumbers (0 when there are none).
  4. The flight. If the event's flightProfile has a flight for this drawId, its drawName is set from the draw, and its flightNumber (a number, or a numeric string read as its number) becomes the draw's drawOrder when it is not already used; the flight then takes drawOrder as its flightNumber. Otherwise a flight is appended (the profile created if absent): { manuallyAdded: true, flightNumber: drawOrder, drawEntries, drawName, drawId }, its drawEntries a copy of the draw's entries.
  5. drawOrder is set on the draw.
  6. A draw without its stored winnerMatchUpId / loserMatchUpId edges has them written (a generated draw has them).
  7. A new draw is appended to the event's drawDefinitions (the object passed in). Every matchUp in it is stamped, then the draw and its top-level structures (§1.1), and, unless suppressNotifications, announced. A replacement takes the old draw's place, stamped the same way.

Returns { success, modifiedEventEntryStatusCount }.

6. The smaller writes​

Seven calls that a generated tournament makes around the four above: venues and courts, policies and extensions on the record, flights, seeding (scale items) and publication. None of them stamps updatedAt except attachPolicies on a draw; each raises the notice named, whether or not anything subscribes, and so takes the factory stamp (§1.1).

6.1 addVenue​

Inputs. venue, context, disableNotice. The venue goes to every loaded record (one, unless tournamentId names it).

  1. venue not an object: INVALID_VALUES. venueId is minted if absent, onto a copy (the caller's object is not written).
  2. A venue with this venueId exists: VENUE_EXISTS. defaultStartTime without defaultEndTime (or the reverse), or an end not after the start: INVALID_VALUES.

What is written. A non-empty dateAvailability is canonicalized (times to HH:MM, a truthy date to YYYY-MM-DD), as are defaultStartTime and defaultEndTime. context becomes an extension { name: 'context', value, createdAt: now }. A venue added with isPrimary clears isPrimary on the record's other venues. Each record stores its own copy of the venue (courts as given), venues created if absent; a refusal writes nothing. Notice ADD_VENUE unless disableNotice.

Returns { success, venue }.

6.2 addCourts​

Inputs. venueId, courtsCount, courtNames, courtIds, dateAvailability, courtTimings, dates, startTime, endTime, courtNameRoot (default Court), venueAbbreviationRoot, idPrefix.

Refusals. venueId not a non-empty string: MISSING_VENUE_ID. No loaded record holds the venue: VENUE_NOT_FOUND. courtsCount not numeric and no courtNames: MISSING_COURTS_INFO. A court that cannot be added (an id the venue or the call already holds, an invalid availability): INVALID_VALUES (not all courts could be generated). Every court is built before any is stored, so a refusal writes nothing.

What is written, for court i of courtsCount (a number or a numeric string, else courtNames.length), in every record holding the venue:

  1. Availability: dateAvailability (default []) shared by every court, unless courtTimings[i] is given; then dates.map((date) => ({ date, startTime, endTime, ...courtTimings[i] })), with { startTime, endTime } appended when both are given.
  2. Name: courtNames[i], else `${venue.venueAbbreviation} ${i + 1}` when venueAbbreviationRoot is given and the venue has an abbreviation, else `${courtNameRoot} ${i + 1}`.
  3. Id: the next of courtIds (taken from the front), else `${idPrefix}-${i + 1}`, else minted.
  4. courts is created on the venue if absent, and the court is appended: { courtId, courtName, dateAvailability, onlineResources: [], venueId }. Each availability entry is copied, its date (when truthy) and times converted to their stored forms, and its bookings rebuilt as { startTime, endTime, bookingType }.

Notice MODIFY_VENUE. Returns { success, courtIds }.

6.3 attachPolicies​

Inputs. policyDefinitions (an object keyed by policy type), allowReplacement, and a draw (drawId), an event (eventId) or a tournamentId. The element is the draw, else the event, else the named record; with none of them, every loaded record. A drawId that resolves to no draw refuses (DRAW_DEFINITION_NOT_FOUND), as an unresolved eventId does (EVENT_NOT_FOUND). A round robin tally policy is validated first.

What is written, on each element:

  1. extensions is created if absent.
  2. The applied policies are a copy of the element's appliedPolicies extension value. For each type given: a falsy policy is skipped; a policy that is not an object, holds nothing but its policyName (an attribute whose value is undefined does not count: it is not stored), or has a policyName that is not a string refuses (INVALID_VALUES); a type already held is skipped unless allowReplacement; otherwise the policy replaces that type whole.
  3. If any type was taken, the appliedPolicies extension's value is replaced, or the extension is appended: { name: 'appliedPolicies', value, createdAt: now }.

A draw is announced as modified (stamping it and its structures, §1.1); an event or a record is announced as modified. Across every loaded record, a record that takes nothing is passed over. Nothing taken anywhere: EXISTING_POLICY_TYPE. Returns { success, applied } (the types taken).

6.4 addTournamentExtension​

Inputs. extension ({ name, value }), creationTime. A name that is not a string, or an extension without a name or value key, refuses.

extensions is created on the record if absent. The stored extension is a copy carrying createdAt (the caller's, else now; none with creationTime: false). An extension of the same name has its value replaced; otherwise the copy is appended, only if its value is truthy. Notice MODIFY_TOURNAMENT_DETAIL if the extensions changed. Returns { success }.

6.5 addFlight​

Inputs. eventId, drawId, drawName, drawEntries, stage, qualifyingPositions.

Refusals, in order, before any write: no event, MISSING_EVENT; no drawName, MISSING_VALUE; a draw entry whose participant is not among the event's entries, INVALID_VALUES; the profile has a flight with this drawId, EXISTING_FLIGHT.

What is written. flightNumber = one more than the largest numeric flightNumber in the event's flight profile (0 when none). The flight is { drawId, flightNumber, drawEntries, drawName } (drawId minted if absent, drawEntries default [], stored as passed), with stage and qualifyingPositions only when truthy. The event's flightProfile becomes { ...profile, flights: [...flights, flight] } (a profile held as an extension is moved to the attribute). Notice MODIFY_EVENT. Returns { success }.

6.6 setParticipantScaleItem​

Inputs. participantId, scaleItem ({ scaleType, eventType, scaleName, scaleValue, scaleDate, scaleId, createdAt }), removePriorValues.

Refusals. scaleItem without the keys scaleType, eventType and scaleName: INVALID_SCALE_ITEM. No such participant: PARTICIPANT_NOT_FOUND. A TEAM participant with an eventType other than TEAM, or any of the three keys falsy: INVALID_SCALE_ITEM. A changed value with a scaleDate that is not a date: INVALID_DATE, after timeItems is created.

What is written. itemType is `SCALE.${scaleType}.${eventType}.${scaleName}`; timeItems is created on the participant if absent. The value is unchanged when it equals the participant's current item of that type (compared as JSON; undefined, null and '' are equal to each other): nothing more is written. Otherwise the time item { itemValue: scaleValue, itemDate: scaleDate, createdAt: scaleItem.createdAt ?? now, itemType } (undefined keys dropped), with itemSubTypes: [scaleId] when scaleId is truthy, is appended; removePriorValues first removes the items of that type. Notice MODIFY_PARTICIPANTS when the value changed.

Returns { success, newValue }, or { success, info: VALUE_UNCHANGED, existingValue }.

6.7 publishEvent​

Inputs. eventId, drawIds, drawDetails, eventDataParams, embargo, status (default PUBLIC), removePriorValues (default true).

Refusals. No event: MISSING_EVENT. A drawId not in the event: DRAW_DEFINITION_NOT_FOUND. An embargo that is not a date: INVALID_EMBARGO. A structure not in its draw: STRUCTURE_NOT_FOUND.

What is written. A time item on the event: { itemType: 'PUBLISH.STATUS', createdAt: now, itemValue: { ...otherStatuses, [status]: { ...prior, drawDetails } } }. A plain publication gives every draw of the event, in order, { [drawId]: { publishingDetail: { published: true } } } (an event without draws gives {}). timeItems is created if absent; with removePriorValues the event's earlier PUBLISH.STATUS items are removed; the item is appended. Notice PUBLISH_EVENT (its payload carries the event data only for a subscriber).

Returns { success, eventData }.

7. Guarantees​

  • A refusal of addParticipants, addEvent, addEventEntries or addDrawDefinition writes nothing.
  • Given the same record, params, clock and random source, each call stores the same bytes.

8. Open questions​

Resolved 2026-10-30: a replacement with suppressNotifications now stores the new draw (it sat inside the notification guard); addEvent stamps an event's matchUps whether or not anything subscribes (it stamped them only for a subscriber, so the stored bytes depended on who was listening).

Resolved 2026-10-30, the smaller writes (§6): publishEvent, setParticipantScaleItem and eleven other participant writes announce whether or not anything subscribes, so the factory stamp no longer depends on who is listening; addCourts counts a numeric string as its number and refuses before storing any court; attachPolicies refuses a drawId that resolves to no draw (it attached to every loaded record); addVenue and addTournamentExtension store copies and no longer write the caller's objects, and addVenue creates venues only when it stores one.

Resolved 2026-10-30, the four writes: addParticipants is all or nothing (it kept the participants appended before a refusal, unannounced); addParticipants and addEvent create no empty participants or events on a refused call; addEvent mints the eventId before a named tieFormat's collection ids, which take uuids and, for a mock, the stored id (they were undefined-COL-n); a new flight's drawEntries is a copy of the draw's entries (it was the same array); a numeric string flightNumber is read as its number (it became a string drawOrder). addEventEntries tested isUngrouped(entryStage), a stage against statuses, which never held: the dead test is removed and the behaviour it never changed is stated (§4: every entry made with a drawId reaches the draw, ungrouped individuals included, as an AD_HOC doubles field relies on).

Resolved 2026-10-30 (debate-ruled, two opposed measurements each, pending review): a draw entry made under the default suppressDuplicateEntries for a participant already in the draw was reported as a duplicate and written anyway (a probe draw then refused automated positioning); it is now skipped, as addDrawEntry and the flight path skip it. INTENNSE_2026 had a constant, a template and a JSON but no fixtures.tieFormats entry, so addEvent alone refused it; it is now published, and STANDARD (the fallback, not a named format) is still refused.

Resolved 2026-10-30 (CA): the factory stamp goes only to the records a call modified (it went to every loaded record, so a record merely loaded beside a written one claimed to have been written); a notice that only reports (dataIssue, audit) modifies nothing, and addDrawEntries announces only when the entries changed. A write made with suppressNotifications is stamped as an announced one is (it left its draws and matchUps unstamped and the factory stamp unmoved); only the engine's internal disableNotifications, around working copies, stamps nothing.

Decided (CA 2026-10-30): an engine call works only on the records it holds (§1.3). A caller's own event object was the event the method wrote to: addEventEntries given a copy of the stored event (or an event in no loaded record) reported success, wrote the entries into the caller's object and left the record unchanged, even beside a correct eventId. A tournamentRecord and a drawDefinition passed through the engine behaved the same way. Now every object passed is resolved to the one in state, one not held refuses the call, and an object and an id that name different entities refuse it, with the engine or without it. A write never silently goes nowhere. (Recorded 2026-10-29 as a refusal at 8.0.0; CA brought it forward.)

Resolved (CA 2026-10-29): a policy attribute whose value is undefined does not count toward a policy (§6.3). It was counted, so { policyName, roundsToSeparate: undefined } was accepted and stored as its name alone, which the same check refuses.

No question is open.