Hit policies: FIRST, UNIQUE, COLLECT and why they matter
Motto — The hit policy is the table’s contract about overlap. UNIQUE makes overlaps a caught bug, and FIRST makes them silent policy. Choose like it matters, because it does.
Part of Phase 05 — DMN: decisions as tables.
The Problem
Lesson 01 ended on a cliff: score 780, amount ₹4 lakh, two rows match. Multiply that
by a real table — forty rows, five input columns, edited quarterly by three
different analysts — and overlaps stop being an edge case. They become a certainty.
The question is never “will rows overlap” but “what happens when they do”: error,
first wins, or all of them? The DMN answer is a single declared setting on the table
— the hit policy. Tables copied from examples with an unconsidered FIRST are
how banks end up pricing the same customer two different ways depending on row
order.
The Concept
<style> .dgm-hp{background:var(–color-surface);border:1px solid var(–color-border);border-top:3px solid var(–color-border-danger-outline); border-radius:16px;padding:28px 26px 22px;box-shadow:0 1px 3px rgb(var(–shadow-color) / 0.04),0 8px 24px rgb(var(–shadow-color) / 0.04); margin:26px 0;max-width:100%;font-family:inherit;text-align:left;position:relative} .dgm-hp-chip{position:absolute;top:-11px;left:24px;background:var(–color-background-danger-solid);color:var(–white); font-size:10.5px;font-weight:700;letter-spacing:1px;text-transform:uppercase; border-radius:20px;padding:3px 12px;box-shadow:0 2px 6px color-mix(in oklab, var(–color-background-danger-solid) 28%, transparent)} .dgm-hp h4{margin:0 0 4px;font-size:11px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:var(–color-text-secondary)} .dgm-hp-sub{font-size:12px;color:var(–color-text-secondary);margin:0 0 16px} .dgm-hp-q{background:var(–color-surface-tertiary);border:1px dashed var(–color-border);border-radius:10px;padding:9px 14px;text-align:center; font-size:11px;font-weight:700;color:var(–color-text);margin-bottom:10px} .dgm-hp-branches{display:grid;grid-template-columns:repeat(4,1fr);gap:10px} .dgm-hp-b{border-radius:11px;padding:11px 13px;border:1.5px solid} .dgm-hp-b h6{margin:0 0 4px;font-size:10.5px;font-weight:700} .dgm-hp-b p{margin:0;font-size:10px;color:var(–color-text-secondary);line-height:1.35} .dgm-hp-b.accent{background:var(–color-background-danger-surface);border-color:var(–color-border-danger-surface)} .dgm-hp-b.accent h6{color:var(–color-text-danger)} .dgm-hp-b.blue{background:var(–color-background-info-surface);border-color:var(–color-border-info-surface)} .dgm-hp-b.blue h6{color:var(–color-text-info)} .dgm-hp-b.green{background:var(–color-background-success-surface);border-color:var(–color-border-success-surface)} .dgm-hp-b.green h6{color:var(–color-text-success)} .dgm-hp-b.red{background:var(–color-background-danger-surface);border-color:var(–color-border-danger-surface)} .dgm-hp-b.red h6{color:var(–color-text-danger)} .dgm-hp-b.neutral{background:var(–color-surface-tertiary);border-color:var(–color-border)} .dgm-hp-b.neutral h6{color:var(–color-text)} @media (max-width:640px){.dgm-hp{padding:20px 16px 18px}.dgm-hp-branches{grid-template-columns:1fr}} </style> <div class=“dgm-hp”> <span class=“dgm-hp-chip”>Four hit policies</span> <h4>Which rows match the input decides what ‘hit policy’ even means</h4> <p class=“dgm-hp-sub”>The same overlapping table is a bug under UNIQUE and perfectly fine under COLLECT.</p> <div class=“dgm-hp-q”>Rules matching the input</div> <div class=“dgm-hp-branches”> <div class=“dgm-hp-b accent”> <h6>UNIQUE</h6> <p>0 or 1 may match — 2+ is an ERROR (table bug)</p> </div> <div class=“dgm-hp-b blue”> <h6>FIRST</h6> <p>Row order decides — reordering rows is a policy change</p> </div> <div class=“dgm-hp-b green”> <h6>ANY</h6> <p>Overlaps are fine if all agree — disagreement is an error</p> </div> <div class=“dgm-hp-b neutral”> <h6>COLLECT</h6> <p>Return all matches, optionally SUM / MIN / MAX / COUNT</p> </div> </div> </div>
| Policy | Overlap is… | Result | Reach for it when |
|---|---|---|---|
| UNIQUE | a bug, caught at evaluation | the one match (or none) | classification: risk bands, eligibility — rows should partition the space |
| FIRST | intentional; order is the tie-break | first matching row | exception-then-default tables (“specific offers first, catch-all last”) |
| ANY | tolerable redundancy | the agreed value | denormalised tables where several rows restate one truth |
| COLLECT | the whole point | all matches, or an aggregate | accumulation: fee components, applicable checks, SUM/COUNT pricing |
Two consequences people learn the hard way:
- UNIQUE is the strictest, and therefore the safest default. It turns analyst
mistakes (a
<=where<was meant) into loud evaluation errors instead of quiet misclassification. Use FIRST only when “specific rules shadow general ones” is genuinely the mental model. Then treat row reordering as a policy change, requiring the same review as an edit. - No-match is part of the contract too. UNIQUE and FIRST returning nothing means the table has a hole. COLLECT returning an empty list may be perfectly normal, like no surcharges applied. Decide which your table means, and make the process route the empty case explicitly.
Build It
code/hit_policies.py extends lesson 01’s engine — the
whole family is one method:
if self.hit_policy == "UNIQUE":
if len(hits) > 1:
raise ValueError(
f"{self.key}: UNIQUE violated — {len(hits)} rules match {context}")
return hits[0] if hits else None
The demo plants a real analyst bug — a risk-band table where one row says >= 750
and the next says 700 <= s <= 750 — and evaluates score 750 under both policies:
$ python3 hit_policies.py
clean input : {'band': 'prime'}
overlap bug : riskBand: UNIQUE violated — 2 rules match {'score': 750}
fees online : 500
fees branch : 1750
same table, FIRST: {'band': 'prime'} (bug hidden)
Same rows, same input. UNIQUE catches the boundary overlap, while FIRST silently ships whichever band happens to sit higher in the file. The COLLECT+SUM fee table shows the accumulation case — base fee, branch surcharge, and big-ticket diligence sum to 1750, three rows contributing to one number.
Use It
In DMN XML the policy is one attribute (lesson 03 writes the full file):
<decisionTable id="riskBandTable" hitPolicy="UNIQUE">
<decisionTable id="feeTable" hitPolicy="COLLECT" aggregation="SUM">
Flowable evaluates exactly these semantics, with one operational note. A UNIQUE violation or a no-match surfaces as an evaluation failure in the calling process — a technical error on the decision task, landing in the Phase 4 pipeline (retry, dead-letter). Retrying won’t fix a table bug, which is exactly why UNIQUE violations should page the table’s owner, not ops (lesson 04).
Ship It
This lesson ships code/hit_policies.py: the complete
toy DMN engine, with tables and UNIQUE/FIRST/ANY/COLLECT plus aggregation. It’s
small enough to use as an oracle when a production table misbehaves.
Check Yourself
Q1. A risk-band table should assign every score exactly one band. Best policy?
- A) FIRST — simplest
- B) UNIQUE — partitioning is the intent, so overlaps must be errors
- C) COLLECT — return all candidate bands
- D) ANY
<details><summary>Answer</summary>B — when rows are meant to partition the input space, UNIQUE turns any accidental overlap into a caught bug instead of an order-dependent answer.</details>
Q2. Under FIRST, an analyst drags a row up “for readability”. What just happened?
- A) nothing — order is cosmetic
- B) a policy change: inputs matched by both rows now get the moved row’s outputs
- C) the table becomes invalid
- D) the engine re-sorts rows anyway
<details><summary>Answer</summary>B — under FIRST, order is semantics. That review burden is the hidden cost that makes UNIQUE the better default.</details>
Q3. Which is a natural COLLECT+SUM use?
- A) choosing an applicant’s risk band
- B) totalling every fee component whose condition applies
- C) picking an interest rate
- D) validating a PAN
<details><summary>Answer</summary>B — accumulation across all applicable rows is what COLLECT exists for. Classification and pricing pick one answer.</details>
Challenge. Write a static overlap checker for UNIQUE tables. For each pair of
rows, decide whether some input could satisfy both — for interval predicates, this
is just interval intersection. Run it on RISK_BAND: it should flag rows 1–2
without evaluating anything. You’ve built the validation Flowable’s model editor
runs on save.
Related
- Next: DMN XML & the decision task
- Previous: A decision engine in 80 lines
