When a host account update triggers resegmentation, the resulting segment and queue placement must be reproducible
At 09:14 on a Tuesday, the host publishes an update for account A000004821. A payment cleared overnight, arrears have fallen from three months to one, and the status is still Active. Our collections platform consumes that update and recomputes four things: the account's segment, whether it is eligible for commercial treatment, which queue it belongs in, and where it sits in that queue.
The account moves from the late-stage queue to the early-stage queue, which is almost certainly right. The question I want to deal with is harder. If I replay that same update next month, do I get the same answer? And would a different account with identical figures have landed in exactly the same place?
This post covers an event-driven resegmentation model in which the collections platform is not the financial system of record. The host owns the money. The platform derives treatment from account snapshots the host sends it.
My claim is that reproducible allocation in this setting needs three things defined, not two:
- a versioned contract for what an account update is allowed to contain and mean;
- an explicit rule for when an update is allowed to change the platform's view of the account;
- a decision function that maps every admitted account state to exactly one allocation.
The first and third are the obvious ones. The second is the one I see missing most often, and it is where duplicates, late deliveries and contradictory updates quietly break reproducibility even when the decision table is perfect.
Why the same update can land an account in two different places
The failure I am trying to prevent is specific. An account's allocation cannot be explained or reproduced from its inputs. It shows up in three ways:
- Two services consuming the same update place the account differently.
- A replay of a historical update produces a different result from the one recorded at the time.
- Two accounts with identical allocation-relevant figures sit in different queues.
None of these needs a code defect. Each has a structural cause:
- A field changes meaning without anyone saying so.
- A retry delivers the same update twice.
- An older update arrives after a newer one.
- Two rows of a decision table both match.
- The rules change between the original decision and the replay.
Reproducibility needs defining before I go further. I mean that, given the same prior account record, the same host update, the same schema version and the same rule version, the platform derives the same:
- segment;
- eligibility decision;
- queue;
- priority key.
I am deliberately separating the priority key from queue position. The key belongs to the account. The position depends on which other accounts are in the queue at that moment. I can make position reproducible too, but only by treating the queue population as an input, and I come back to that below.
I also want to separate reproduction from re-evaluation. Replaying an update against the rules that were active at the time is a reproduction, and it must match the original. Replaying it against today's rules is a new evaluation, and a different answer can be perfectly legitimate. An audit trail that cannot tell those two apart will generate false alarms and hide real ones.
The question I would ask of my own platform at this point: for the last account that moved queue, could I name the exact update, schema version and rule version that moved it?
What I can rely on from the host, and what I cannot
These are the constraints I am working inside. Each one shapes the model, so I have said how.
- The host is the system of record. The platform never corrects host data. When an update is unusable, the platform can accept it, set it aside or ask the host again, and nothing else. This is why the model has a Held state rather than a repair step.
- Updates are full snapshots, not deltas. Every update carries the complete set of allocation-relevant fields for one account. The platform never merges an update into previous state, so a missing update between two others does not corrupt anything.
- Each snapshot carries a per-account sequence number,
hostSeq. The host increments it on every state change of that account. There is no ordering across accounts, and the model never needs one. This is the only thing that lets the platform tell a stale update from a current one. - Delivery is at least once, in any order. Duplicates and late arrivals are normal operating conditions, not exceptions, so admission has to classify them.
- Transaction history is not available. Neither is arrears ageing below the whole-month figure the host supplies. The decision function can only use what is in the snapshot.
- The host exposes a per-account snapshot request. I call this a reconciliation pull. It is the platform's only way to recover from bad input, so convergence depends on it.
- Protected statuses (complaint, fraud, dispute) override commercial treatment. This is encoded in the conditions of the decision table rather than in row order.
- Every allocation change must be explainable afterwards. This is why every change writes a decision record carrying its inputs and versions.
The platform produces three outputs:
- one current allocation per account;
- an append-only log of decision records;
- an ingestion log entry for every update received.
Some things are deliberately out of scope:
- the order in which exception cases are worked;
- what treatment happens inside a queue (letters, calls, agent assignment);
- how the host calculates arrears or pursuable amounts;
- account closure and archiving;
- relationships between accounts.
Three pieces, each with one job
I find it helps to split the work into three pieces rather than one large configuration problem.
The Constraint Schema, C(v), says what an update may contain and what each field means.
The Admission Rule decides whether a given update is allowed to change the platform's record of the account. It covers duplicates, stale arrivals, contradictions, rejections and the Held state.
The Decision Model, R(v), maps an admitted account state to exactly one allocation.
The split matters because each piece can fail independently while producing the same symptom. A structurally valid update can still be stale. A correctly admitted state can still produce an unreproducible allocation if the decision table is ambiguous. Keeping the pieces apart lets me point at which one failed.
Starting with what counts as an account update
Schema version 2.0 defines the update as follows. Monetary values are integers in pence. There are no floating-point amounts anywhere in the model, so comparisons and ties are exact.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "collections/account-update/2.0",
"title": "Host account update, schema version 2.0",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion", "accountId", "hostSeq", "observedAt", "currency",
"pursuableMinor", "monthsInArrears", "status", "reconciliationRequestId"
],
"properties": {
"schemaVersion": { "const": "2.0" },
"accountId": { "type": "string", "pattern": "^[A-Z0-9]{10}$" },
"hostSeq": { "type": "integer", "minimum": 1 },
"observedAt": { "type": "string", "format": "date-time" },
"currency": { "const": "GBP" },
"pursuableMinor": { "type": "integer", "minimum": 0, "maximum": 100000000000 },
"monthsInArrears": { "type": "integer", "minimum": 0, "maximum": 600 },
"status": { "enum": ["Active", "Complaint", "Fraud", "Dispute"] },
"reconciliationRequestId": {
"type": ["string", "null"],
"pattern": "^[a-f0-9]{32}$"
}
}
}
Validation runs with format assertion enabled, so a malformed observedAt fails. The schema fixes shape. The meanings below fix interpretation, and they are part of the same versioned contract.
| Field | Meaning under version 2.0 |
|---|---|
accountId |
Host account identifier. Ten characters, uppercase ASCII letters and digits only, so byte order and alphabetical order agree. |
hostSeq |
Per-account sequence number. Strictly increases on every host state change for this account. One hostSeq identifies exactly one payload. |
observedAt |
Host timestamp of the snapshot. Retained for audit only. The model never reads it. |
currency |
Always GBP. Fixed so that monetary comparisons never cross currencies. |
pursuableMinor |
The amount, in pence, that the host classifies as recoverable through collections treatment at the time of the snapshot. It is not the total balance. An account in credit reports 0. |
monthsInArrears |
Whole instalment periods for which the oldest unpaid due amount has remained unpaid, as calculated by the host. 0 means nothing is overdue. |
status |
Closed, case-sensitive enumeration. Complaint, Fraud and Dispute are protected statuses. Active is the only commercial status. |
reconciliationRequestId |
Null when the host pushes an update. Set to the platform's request identifier when the snapshot answers a reconciliation pull. |
Three rules govern versions:
- Any change to a field's meaning, type, bounds or enumeration creates a new
schemaVersion. - Published versions are immutable.
- Each rule version declares the schema versions it accepts, and an update declaring any other version is rejected.
For duplicate and conflict detection, the platform computes a state hash. This is SHA-256 over the RFC 8785 canonical JSON of the four state fields: currency, pursuableMinor, monthsInArrears and status. Identity, sequence and audit fields are excluded, so two deliveries of the same state compare equal even if their timestamps differ.
There is a limit here worth being honest about. The schema can enforce shape, and the versioning rule says what should happen when meaning changes. What no validator can detect is a host that starts populating pursuableMinor with total balance while still declaring version 2.0. Both values are valid integers. That is a contract governance problem, and I would rather name it than pretend validation covers it.
Deciding whether an update is allowed to change anything
The platform keeps one record per account:
r = {
ing, ingestion state: Synchronised or Held (no record = Uninitialised)
S, last applied state fields, or null
last, hostSeq of the last applied snapshot (0 if none)
lastHash, state hash of the last applied snapshot
lastSrc, Ordinary or Reconciliation
holdSeq, hostSeq that caused the current hold (0 when Synchronised)
openRequests, reconciliation request identifiers issued during the current hold
A current allocation
}
Here is what the previous record contributes, since this is where I think a lot of designs go vague. Under a snapshot contract, the previous financial state contributes nothing to the new state. A new admitted snapshot replaces S entirely. What the previous record does contribute is ordering and integrity information:
lastlastHashlastSrcholdSeqopenRequestsing
That is the whole of what S(t-1) means in this model.
Every incoming update is classified into exactly one of six outcomes. The checks run in this order, and the first match wins:
| Order | Condition | Classification |
|---|---|---|
| 1 | accountId or hostSeq cannot be read |
DeadLetter |
| 2 | hostSeq < last |
Stale |
| 3 | Fails C for its declared version, or that version is not accepted by the active rule version | Rejected |
| 4 | hostSeq = last, not a reconciliation response, same state hash |
Duplicate |
| 5 | hostSeq = last, not a reconciliation response, different hash, and lastSrc = Reconciliation |
Stale |
| 6 | hostSeq = last, not a reconciliation response, different hash, and lastSrc = Ordinary |
Conflict |
| 7 | Otherwise: hostSeq > last, or a reconciliation response with hostSeq >= last |
Apply |
An update counts as a reconciliation response only when its reconciliationRequestId is in the record's openRequests. A response that arrives after its request has closed is treated as an ordinary update.
Rule 5 exists because of a subtle loop. Suppose a conflict at sequence 7 is resolved by a reconciliation response, and then a late copy of the losing payload arrives. That copy would otherwise trigger a fresh conflict. Once the host has answered a reconciliation pull for a sequence number, its answer supersedes anything else carrying that number.
The processing step, written so that an engineer can implement it without asking me anything:
process(r, E, R_active):
if identity of E unreadable:
deadLetter(E); return # no record exists or changes
n := E.hostSeq
if r absent:
r := { ing: Uninitialised, S: null, last: 0, lastHash: null,
lastSrc: null, holdSeq: 0, openRequests: {}, A: null }
if n < r.last: return logOnly(r, E, Stale)
if not valid(E, C[E.schemaVersion])
or E.schemaVersion not in R_active.acceptedSchemaVersions:
return hold(r, E, n, Rejected)
recon := E.reconciliationRequestId in r.openRequests
h := stateHash(E)
if n = r.last and not recon:
if h = r.lastHash: return logOnly(r, E, Duplicate)
if r.lastSrc = Reconciliation: return logOnly(r, E, Stale)
return hold(r, E, n, Conflict)
return apply(r, E, n, h, recon, R_active)
apply(r, E, n, h, recon, R):
r.S := stateFields(E); r.last := n; r.lastHash := h
r.lastSrc := Reconciliation if recon else Ordinary
if r.ing = Uninitialised: r.ing := Synchronised
if r.ing = Held and n >= r.holdSeq: r.ing := Synchronised; r.holdSeq := 0; r.openRequests := {}
r.A := HOLD if r.ing = Held else R(r.S)
persistDecision(r, E, Apply, R.version)
hold(r, E, n, outcome):
r.ing := Held; r.holdSeq := max(r.holdSeq, n); r.A := HOLD
if r.openRequests is empty: issueReconciliation(r)
persistDecision(r, E, outcome, R_active.version)
The ingestion states are Uninitialised, Synchronised and Held. An account is in exactly one of them at any time. The complete transition table:
| From | Classification | To |
|---|---|---|
| Uninitialised | Apply | Synchronised |
| Uninitialised | Rejected | Held (S null, holdSeq = n) |
| Synchronised | Apply, Duplicate, Stale | Synchronised |
| Synchronised | Rejected, Conflict | Held |
| Held | Apply with n >= holdSeq |
Synchronised |
| Held | Apply with n < holdSeq |
Held (S updated, still withheld) |
| Held | Rejected, Conflict | Held (holdSeq raised if higher) |
| Held | Duplicate, Stale | Held |
Two combinations cannot occur:
- Duplicate or Conflict from Uninitialised, because
lastis 0 andhostSeqis at least 1. - Stale from Uninitialised, for the same reason.
DeadLetter never touches a record.
The question I would ask here: when my platform receives an update it cannot use, what does the account look like in the meantime, and is that written down anywhere?
Turning admitted state into exactly one allocation
The allocation is a four-part value:
A = { segment, eligible, queue, priorityKey }
A Held account always receives the fixed allocation below. This is set by the model rather than by any rule version, so no rule change can accidentally route a withheld account into commercial treatment.
HOLD = { segment: HOLD, eligible: false, queue: HoldQueue, priorityKey: null }
A Synchronised account is evaluated against the active rule version. Rule version 1.0 has one parameter, L = 3, the months-in-arrears threshold for late-stage treatment.
| Row | status | pursuableMinor | monthsInArrears | segment | queue | eligible | priorityKey |
|---|---|---|---|---|---|---|---|
| D1 | Complaint, Fraud or Dispute | any | any | EXCEPTION | ExceptionQueue | false | null |
| D2 | Active | = 0 | any | NOT_PURSUABLE | none | false | null |
| D3 | Active | > 0 | = 0 | UP_TO_DATE | none | false | null |
| D4 | Active | > 0 | >= 1 and <= L - 1 | EARLY | EarlyQueue | true | key(a) |
| D5 | Active | > 0 | >= L | LATE | LateQueue | true | key(a) |
The table uses unique-match semantics. For every valid state, exactly one row matches. This is checked before a rule version can be activated, not hoped for at runtime.
The exception override is not implemented by putting D1 first. It is implemented by D2 to D5 requiring status = Active. Precedence lives in the conditions, so evaluation order cannot change the outcome. Two engines that evaluate rows in opposite orders still agree. I prefer this to first-match tables, because with first-match the precedence rules exist only in the row order, and row order is exactly the kind of thing that gets reshuffled during maintenance.
Eligible accounts are ordered by one key:
key(a) = ( -pursuableMinor, -monthsInArrears, accountId )
Keys are compared left to right, with accountId compared by byte value. Because accountId is unique, this is a total order: no two accounts ever share a key.
Queue position is then a function of the key and the queue population:
Pos(a, Q) = 1 + count of b in Q where key(b) < key(a)
Position is not part of A, because it is not a property of the account alone. It is still reproducible. The population of any queue at any instant can be rebuilt from the decision records: the accounts whose latest decision at or before that instant assigns them to that queue.
Rules do change, so the model has to say what happens when they do. Exactly one rule version is active at any instant, and activation switches atomically. Every decision after the switch uses the new version. The platform then sweeps all Synchronised records whose latest decision predates the switch, re-evaluates them, and writes decision records with trigger RuleActivation. Held records keep HOLD.
Without the sweep, two accounts with identical figures could sit in different segments simply because one of them happened to receive an update after the change. That is precisely the inconsistency this whole exercise exists to remove.
The question I would ask here: if I evaluated my current decision table in reverse row order, would any account move?
The conditions that have to hold after every step
These are the invariants. Each is written so it can be asserted directly against stored state.
I1 Every record has exactly one A, with A.segment in
{HOLD, EXCEPTION, NOT_PURSUABLE, UP_TO_DATE, EARLY, LATE}, and at most one queue.
I2 r.ing = Synchronised and r.S.status in {Complaint, Fraud, Dispute}
implies A.segment = EXCEPTION.
A.segment = EXCEPTION
implies r.ing = Synchronised and r.S.status in {Complaint, Fraud, Dispute}.
I3 r.ing = Held if and only if A.segment = HOLD.
I4 r.last never decreases across successive decision records for the same account.
I5 A.eligible = true iff A.queue in {EarlyQueue, LateQueue} iff A.priorityKey is not null.
I6 r.S is null or r.S.pursuableMinor >= 0.
I7 r.ing = Held implies r.holdSeq >= 1 and r.holdSeq >= r.last.
r.ing = Synchronised implies r.holdSeq = 0.
I8 Published schema and rule versions are immutable, and every version referenced
by a decision record is retrievable.
I9 Once the activation sweep for rule version v completes, every Synchronised
record has A = R(v)(r.S) and was decided under v.
Reproducibility itself is stated over the full record, not just the account state:
For a host update: (r(t), A(t)) = F(r(t-1), E(t), C, R)
For a rule activation: (r(t), A(t)) = G(r(t-1), R)
If r1(t-1) = r2(t-1), E1(t) = E2(t), and C and R are the same versions,
then r1(t) = r2(t) and A1(t) = A2(t).
C is fixed by the update's declared schemaVersion. R is the active version, recorded on every decision. Both F and G are the pseudocode and table above, and nothing else. Nothing in them reads a clock, a random source or another account.
Before a rule version can be activated, the platform checks these preconditions and refuses activation if any fails:
P1 {Complaint, Fraud, Dispute} and {Active} partition the status enumeration
of every schema version the rule version accepts.
P2 D1 to D5 are mutually exclusive and exhaustive. Checked by evaluating the table
at every cell of: status in {Active, Complaint, Fraud, Dispute}
x pursuableMinor in {0, 1} x monthsInArrears in {0, 1, L-1, L, 600}.
The conditions only compare against 0, 1 and L, so these points cover
every region.
P3 L is an integer with 2 <= L <= 600, so EARLY is never empty.
P4 Every accepted schema version defines pursuableMinor, monthsInArrears and status
with the meanings the rule version was written against.
P5 After activation, exactly one rule version is active.
How an account finds its way back after bad input
The snapshot contract gives the model a useful convergence property. For a host that honours its own contract, where each hostSeq carries exactly one payload, the record depends only on the snapshot with the highest hostSeq among the valid updates received. Arrival order, duplication and gaps make no difference.
The reasoning is short:
- A snapshot is applied only when its sequence number exceeds
last. lastnever decreases (I4).- Each application replaces
Sentirely. - So the final
Sis the payload of the highest sequence number applied. - Duplicates carry identical state, so they change nothing.
- A missing update in between changes nothing, because the next snapshot is complete.
Held status converges the same way. An account is Held after a stream of updates exactly when some rejected update has a higher sequence number than every valid one. That is also independent of arrival order. A rejected 105 followed by a valid 106 ends Synchronised. So does a valid 106 followed by a rejected 105, because by then 105 is stale.
When input is bad, recovery works as follows:
- On entering Held, the platform issues a reconciliation pull.
- The host answers with its current snapshot, which carries its current
hostSeq, so it satisfiesn >= holdSeqwhenever the host has not gone backwards. - The first admissible snapshot with
hostSeq >= holdSeqreturns the record to Synchronised, withSequal to that snapshot. - Its allocation is recomputed from scratch.
Until then, the account holds HOLD. It receives no commercial treatment and its last valid state is retained for audit.
Two operational parameters govern the pull: a retry interval I and a maximum of K attempts. Every unanswered interval, and every response classified Stale or Rejected, counts as an attempt. A response classified Rejected also raises holdSeq if its sequence number is higher. After K attempts, the platform stops asking and raises a contract-breach alert. The account stays Held.
I and K affect only when the platform asks. They never enter F or change an allocation, which is why they are not part of the rule version. The alert is the one place a person gets involved, and what they fix is the host, not the allocation.
Rule changes converge through the activation sweep. After the sweep, I9 holds for every Synchronised record.
Pushing on the edges of the model
These are the cases I would want settled before calling resegmentation reproducible. Each has a single expected outcome, and each is written as a test fixture. Rule version 1.0 (L = 3) applies unless stated.
| # | Record before | Update received | Classification | Record after | Allocation |
|---|---|---|---|---|---|
| F1 | None | n=1, Active, 45000, 2 | Apply | Synchronised, last 1 | EARLY, EarlyQueue |
| F2 | None | n=1, status "complaint" (lower case) | Rejected | Held, S null, holdSeq 1, pull issued | HOLD |
| F3 | Any | accountId missing |
DeadLetter | Unchanged | Unchanged |
| F4 | Sync, last 7, hash h | n=7, same state | Duplicate | Unchanged | Unchanged |
| F5 | Sync, last 7, Ordinary | n=7, different state | Conflict | Held, holdSeq 7, pull issued | HOLD |
| F6 | Held after F5 | Pull response, n=7 | Apply | Synchronised, lastSrc Reconciliation | From R(S) |
| F7 | After F6 | Ordinary n=7, the losing payload | Stale | Unchanged | Unchanged |
| F8 | Sync, last 102 | n=101 | Stale | Unchanged | Unchanged |
| F9 | Sync, last 101 | n=103, then n=102 | Apply, then Stale | last 103 | From n=103 |
| F10 | Sync, EARLY | n+1, Active, 0, 1 | Apply | Synchronised | NOT_PURSUABLE, no queue |
| F11 | Sync, last 20 | n=21, pursuableMinor -2000 | Rejected | Held, holdSeq 21 | HOLD |
| F12 | Sync, LATE | n+1, Complaint, 50000, 3 | Apply | Synchronised | EXCEPTION, ExceptionQueue |
| F13 | None | n=1, Active, 30000, 3 | Apply | Synchronised | LATE (3 >= L) |
| F14 | None | n=1, Active, 30000, 2 | Apply | Synchronised | EARLY |
| F15 | Sync, last 9 | n=10, schemaVersion "3.0", not accepted | Rejected | Held, holdSeq 10 | HOLD |
| F16 | Held, last 104, holdSeq 105 | n=106, valid | Apply | Synchronised | From R(S) |
| F17 | Held, last 104, holdSeq 107 | n=106, valid | Apply | Held, last 106 | HOLD |
| F18 | Held, last 104 | Pull response, n=103 | Stale | Held, attempt counted | HOLD |
| F19 | Sync, Active, 30000, 3, LATE under 1.0 | Rule 1.1 (L=4) activated | RuleActivation | Synchronised | EARLY under 1.1 |
| F20 | Held | Rule 1.1 activated | RuleActivation | Held | HOLD |
| F21 | None | n=1, Active, 12000, 0 | Apply | Synchronised | UP_TO_DATE, no queue |
Six of these are worth a sentence each.
F2 shows why the enumeration is closed and case-sensitive. A lower-case "complaint" is not quietly treated as Active and prioritised. The account is withheld until the host sends something it can stand behind. If that update really was a complaint, that is the outcome you want.
F10 and F11 settle credit balances. The host contract says an account in credit reports zero, which D2 handles. A negative figure is a contract breach, not a credit state, so it is rejected rather than interpreted.
F12 is the case where commercial and protected conditions both appear to apply. D1 is the only row whose conditions match, so the account leaves LateQueue and enters ExceptionQueue in a single decision. There is no intermediate state in which it is in both, or neither.
F14 against F13 pins the threshold comparison as >= L. An implementation using > L fails F13.
F19 is the replay case. Replaying the account's original update against its persisted prior record under rule 1.0 reproduces LATE. The 1.1 evaluation is a separate decision record with its own trigger, and both are true statements about different moments.
Ties. Accounts A000000010 and A000000011, both Active with 40000 and 2 months, share a priority key up to the last component. A000000010 sits above A000000011 in EarlyQueue on every rebuild, because the final comparison is on identifiers by byte value.
The pieces you can lift out and use
The artefact is a state model combined with a deterministic allocation model and a constraint schema. It has six parts:
- The Constraint Schema: the JSON Schema plus the field meanings table, versioned together.
- The Admission Rule: the classification order, the ingestion transition table and the processing pseudocode.
- The Decision Model: rows D1 to D5, the HOLD allocation, the priority key, the position function and the activation sweep.
- The invariants and preconditions: I1 to I9 and P1 to P5, run as runtime assertions and activation checks.
- The fixture set: F1 to F21, run as a conformance suite against any implementation.
- The decision record: one per record change. It carries:
- trigger (HostUpdate or RuleActivation);
- retained raw update;
- classification;
- schema version and rule version;
- matched row;
- the full record after the change;
- the allocation;
- a decision timestamp, kept for audit and never read by the model.
An update that changes nothing writes an ingestion log entry only.
Any implementation of this should be an executor of the model. Every rule in code maps to one of the clauses above, and there are no settings beyond L, I and K. The conformance suite is a validator derived from the invariants and fixtures. It adds no rules of its own.
When to use it
- The host is the financial system of record and publishes full account snapshots.
- Each snapshot carries a per-account sequence number, and the host supports a per-account snapshot request.
- Treatment is rule-based, and protected statuses must override commercial prioritisation.
- Historical replay and audit explanation of queue movements are required.
When not to use it
- The collections platform is itself the system of record. Admission then has nothing to defer to.
- The host sends deltas. Deltas need ordered reconstruction and gap repair, which is a different model. Here, gaps are harmless only because every snapshot is complete.
- The host provides no per-account sequence number. Arrival order would be the only ordering, and the model cannot tell a stale update from a current one. Do not adopt it until the host provides one.
- The host has no snapshot request. Held becomes a state that only ends if the host happens to publish again. That may be acceptable, but it should be a conscious choice.
- Segmentation is discretionary or intentionally probabilistic.
- Segmentation needs history the snapshot does not carry, such as "broken twice in twelve months". That history must first become a host field in a new schema version, or the model's state must be extended.
Constraints the artefact depends on
- Closed, case-sensitive status enumeration, partitioned into protected and commercial sets.
- Integer pence for all monetary values, with zero for credit and rejection of negatives.
- Strict rejection of unexpected properties.
- Immutable, versioned schema and rule definitions, with each rule version declaring the schema versions it accepts.
- Precedence expressed in row conditions, with unique-match evaluation.
- Admission by per-account sequence number, with defined handling for duplicates, stale arrivals, conflicts and rejections.
- A total priority key ending in a unique identifier compared by byte value.
- An activation sweep on every rule change.
Removing any one of these reopens a specific path to an unexplained allocation. The fixture set shows which: drop the closed enumeration and F2 fails, drop the sequence rule and F8 fails, drop the tie-breaker and the tie case fails.
Keeping the read model honest
OpenSearch, or any other read model, builds queues and answers queries. It never computes an allocation, and it is populated only from decision records. Every mapping choice traces to a model clause:
| Read model element | Model clause |
|---|---|
accountId as keyword |
C field; final component of the priority key. Keyword sort is by UTF-8 bytes, which for ASCII identifiers matches byte-value comparison. |
pursuableMinor as long |
C field in integer pence. This replaces scaled_float, so no scaling factor or rounding policy exists to drift. |
monthsInArrears as integer |
C field. |
status, segment, queue as keyword |
C enumeration; R output enumerations. |
hostSeq as long |
Admission rule. |
schemaVersion, ruleVersion as keyword |
Decision record. |
decidedAt as date |
Decision record, audit only. |
dynamic: "strict" |
Mirrors additionalProperties: false, so the read model cannot hold fields the contract does not define. |
Queue query: filter on queue, sort pursuableMinor desc, monthsInArrears desc, accountId asc |
The priority key, exactly. |
The only configuration exposed is L, which belongs to the rule version, and I and K, which belong to the reconciliation policy. Nothing else is tunable. A setting that existed only in the implementation would be a decision the model does not make.
Where this sits in the wider body of work
The model applies P1, State Before Events, quite literally. Updates are evidence, and the record is the truth the platform holds. It is an instance of:
- C1, Snapshot Computation, and M1, State Space Theory;
- P3, Convergence Over Completeness, through the reconciliation pull and the highest-sequence property;
- D1, Collections Systems, where the decisions sit.
In the publication programme, it gives concrete material to:
- Phase 3: Snapshot-Based Computation, and Convergence as an Architectural Property;
- Phase 4: Guard Conditions and Business Rules, since precedence lives in row conditions, and Recoverable State Machines, via the Held state;
- Phase 5: Collections Systems as Decision Systems.
The transferable pattern is independent of collections. Admit snapshots by per-entity sequence, evaluate admitted state with a total decision function, and record every change with its versions. It applies wherever a downstream system derives classification from state it does not own: fraud scoring on card account updates, eligibility on benefit records, or routing on policy snapshots in insurance.
What the model still leaves open
A few conditions remain open. I would rather list them than let the model look more complete than it is.
- Unattributable updates. An update whose identity cannot be read goes to the dead-letter store and never reaches a record. If it carried a complaint, the platform will not see that complaint until the host publishes again or someone works the dead letters. Monitoring the dead letters is outside this boundary, but it matters.
- Hold withholds commercial treatment. That costs collection time. I think it is the right default because it errs towards not contacting someone who may have complained. If a business wanted to keep working accounts on their last valid state, that would be a legitimate choice, but it would need to become a rule with its own row, not something an implementation drifts into.
- Work order inside ExceptionQueue belongs to the complaints and fraud processes, not this model. Because
statusis single-valued, the host decides which protected status to report when several apply. While all protected statuses share one queue that causes no divergence. If routing ever splits by exception type, the model will need a precedence order among protected statuses. - Semantic drift without a version change cannot be detected by validation. It is a governance obligation on the host contract.
- A host that breaks its own contract by sending two different payloads under one sequence number gets a defined outcome, but it depends on arrival order. A valid payload arriving after an invalid one at the same sequence ends Synchronised. The reverse order ends Held. Both are deterministic for a given input sequence. The order-independence property only covers contract-honouring hosts.
- During an activation sweep, some Synchronised records still carry decisions made under the previous rule version. Every decision records its version, so audit remains exact. I9 is stated as holding after the sweep for that reason.
- After K failed reconciliation attempts, an account can stay Held indefinitely. That is a safe state rather than a resolved one, and I am not certain every organisation would accept it without a time-based escalation. If one is added, it should change who is alerted, not the allocation.
What reproducibility gives me, and what it costs
With all three pieces in place, I can take any queue movement and answer four questions without guessing:
- Which update or rule activation caused it?
- Which schema and rule versions applied?
- Which row matched?
- What the account record looked like before and after.
I can replay it and get the same answer. I can re-evaluate it under newer rules and get a clearly labelled different answer. I can rebuild any queue as it stood at any moment.
The cost is real:
- a sequence number and a snapshot endpoint from the host;
- a Held state that occasionally delays treatment;
- activation checks that will refuse a rule change someone was keen to ship;
- a decision log that grows with every change.
Whether that is worth paying depends on how often someone asks why an account is in a particular queue, and how much it matters when nobody can say.
The question I would end on: of the accounts that moved queue in my platform last week, how many could I explain from stored records alone, without reading code or asking the person who configured the table?