Most software systems are comfortable returning incomplete answers.
An API returns null.
A model returns a low-confidence score.
A workflow enters an undefined status.
A human operator is left to infer what the system failed to decide.
That may be acceptable in a search interface or recommendation engine. It is not acceptable when software controls whether an operational process may advance, must stop, or requires intervention.
In those systems, uncertainty cannot remain an implementation accident. It must become a first-class decision state.
The architectural objective is therefore not merely to produce accurate positive matches. It is to construct a total decision function: a function that maps every valid input into exactly one documented terminal outcome.
Formally:
D(record, evidence, policy) → decision
Where:
decision ∈ {
PROCEED,
REVIEW,
BLOCK,
NO_RESULT
}
The defining property is completeness:
∀ valid record r:
D(r) returns exactly one terminal decision
This changes the entire system design. Data retrieval is no longer the product. The controlled commitment made from that data is the product.
Partial Functions Are Hidden Operational Risk
Many data systems are effectively partial functions.
They work when:
- the input is clean;
- the upstream provider responds;
- one candidate dominates;
- no fields contradict each other;
- and the expected entity appears in the expected schema.
The difficult records fall outside that happy path. They contain stale attributes, competing identities, incomplete timelines, duplicated upstream data, ambiguous relationships, or explicit restrictions.
A partial implementation often handles these cases with one of three shortcuts:
return null
return bestCandidate
return confidenceScore
All three defer the real decision.
null does not explain what failed.
A best candidate may conceal unresolved contradictions.
A confidence score does not specify what action is permitted.
A decision system must instead answer a more precise question:
Given the evidence currently available, what is the most defensible operational state for this record?
That answer may be “do not advance.” It may be “human review required.” It may be “no usable evidence exists.” Those are still successful resolutions of the decision problem.
Separate Observations From Decisions
The first architectural rule is strict separation between raw observations and final decisions.
External systems return incompatible structures:
{
"telephone": "..."
}
{
"phones": [
{
"number": "...",
"status": "current"
}
]
}
{
"subjects": [
{
"contactPoints": []
}
]
}
Allowing downstream decision logic to consume these payloads directly creates several problems:
- provider-specific conditionals spread throughout the codebase;
- provenance becomes difficult to preserve;
- schema changes affect policy behavior;
- duplicate upstream data may be counted as independent evidence;
- raw private data can leak into logs or interfaces;
- and decision rules become inseparable from integration code.
The safer pattern is:
Raw Provider Response
↓
Provider Adapter
↓
Normalized Evidence Units
↓
Evidence Ledger
↓
Context and Policy Evaluation
↓
Terminal Decision
A normalized evidence unit can be represented generically:
type EvidenceUnit = {
id: string;
sourceFamily: string;
evidenceType: string;
subject: string;
value: unknown;
supports: string[];
contradicts: string[];
confidenceBand: "high" | "medium" | "low" | "unknown";
recencyBand: "current" | "recent" | "historical" | "unknown";
restrictionFlag: boolean;
rawReference?: string;
publicSummary: string;
};
The important feature is not the exact schema. It is the boundary.
The decision engine receives evidence with canonical semantics. It does not receive arbitrary provider JSON.
Use an Append-Only Evidence Ledger
Normalized evidence should enter an append-only ledger.
Once an observation has been recorded, downstream modules may query it or derive new findings from it, but they should not silently rewrite the original unit.
function addEvidence(
ledger: EvidenceLedger,
observation: EvidenceUnit
): Readonly<EvidenceUnit> {
const immutableUnit = Object.freeze({
...observation,
id: nextEvidenceId(ledger),
createdAt: new Date().toISOString()
});
ledger.units.push(immutableUnit);
return immutableUnit;
}
This produces several useful properties.
Provenance
Every conclusion can be traced back to the observations that supported or contradicted it.
Reproducibility
A decision can be rerun against the same ledger and policy version.
Safe projection
Operator-facing output can expose a redacted evidence view without returning raw source material.
Conflict preservation
Contradictory observations remain visible. A later module cannot erase inconvenient evidence simply because another candidate appears stronger.
Testability
Tests can assert not only the final decision but also the exact evidence path that produced it.
The evidence ledger becomes the stable interface between retrieval and judgment.
Provider Count Is Not Evidence Independence
A common failure in multi-provider systems is treating the number of returned records as the number of independent confirmations.
Suppose three vendors return the same telephone number. That may look like three corroborating observations.
But all three vendors may derive the number from the same upstream dataset.
Counting them independently produces false confidence.
The correct unit of corroboration is not the vendor. It is the source family or underlying evidence structure.
function independentSupport(
ledger: EvidenceLedger,
claim: string
): number {
return new Set(
ledger.units
.filter(unit => unit.supports.includes(claim))
.map(unit => unit.sourceFamily)
).size;
}
This distinction matters because confidence must represent evidentiary diversity, not response volume.
Ten repetitions of the same observation are not equivalent to two structurally independent anchors.
The system should preserve both facts:
provider_return_count = 10
independent_source_family_count = 1
Only the second should influence corroboration logic.
Context Must Be Computed, Not Assumed
An attribute has little meaning without context.
An address can be:
- current;
- historical;
- institutional;
- associated with a relative;
- associated with an employer;
- temporarily occupied;
- or shared by multiple people with similar names.
A telephone can be:
- directly held;
- household-held;
- business-owned;
- recycled;
- masked;
- or associated with a third party.
A name difference can represent:
- a collision;
- a prior surname;
- a life event;
- an alias;
- or a simple mismatch.
A decision engine should therefore avoid treating evidence as a flat collection of fields.
Instead, it should construct intermediate models:
Evidence Ledger
├── Identity Clusters
├── Contact Reversals
├── Name-Collision Analysis
├── Address Timeline
├── Household Relationships
├── Life-Event Continuity
├── Restrictions
└── Available Operational Paths
These modules do not make the final decision. They transform observations into structured findings.
For example:
type AddressTimelineResult = {
entries: AddressTimelineEntry[];
timelineExplained: boolean;
unresolvedTransitions: string[];
};
type CollisionResult = {
candidateClusters: IdentityCluster[];
targetCluster?: IdentityCluster;
collisionRisk: "low" | "medium" | "high";
unresolvedReason?: string;
};
The final arbiter consumes these findings together. No single module is allowed to promote a record independently.
Safety Constraints Should Be Monotonic
In a controlled decision system, restrictions and safety sentinels should be monotonic.
A restriction may make a result more conservative. It must never make the result less conservative.
Conceptually:
PROCEED → REVIEW → BLOCK
A newly discovered contradiction may downgrade PROCEED to REVIEW.
A confirmed restriction may downgrade PROCEED or REVIEW to BLOCK.
But a suppressor should never increase confidence or promote a record.
This can be expressed as an invariant:
assert(
applyRestriction(existingDecision, restriction)
<= existingDecision
);
Where <= represents the permitted ordering from less restrictive to more restrictive.
The precise state ordering may vary by application, but the principle is stable:
Safety evidence can constrain action. It cannot manufacture authorization.
This is especially important when probabilistic components are present. A highly confident model output must not outrank an explicit restriction.
Keep Probabilistic Planning Outside the Final Arbiter
Machine learning and language models can be useful for:
- suggesting additional investigations;
- organizing unresolved questions;
- drafting operator notes;
- identifying potentially useful evidence gaps;
- and prioritizing expensive API calls.
They should not be the sole source of the final operational state.
The safe separation is:
Evidence State ──→ Advisory Planner
│
└─────────→ Deterministic Policy ──→ Final Decision
The advisory planner may recommend:
{
"suggestedActions": [
"investigate competing address cluster",
"request an independent contact-path source"
]
}
But the final decision policy must not read a model-generated instruction as evidence.
function decide(
state: InvestigationState,
policy: DecisionPolicy
): Decision {
// Deliberately does not consume state.aiPlan.
}
This creates a strong architectural boundary:
- probabilistic systems help determine what to investigate;
- deterministic systems determine what may happen next.
Build the Final Decision as an Ordered Policy
The final arbiter should be the only module authorized to emit a terminal state.
An ordered policy is easier to audit than a collection of distributed booleans.
Generic pseudocode might look like this:
function decide(
state: InvestigationState,
policy: DecisionPolicy
): Decision {
const inputs = deriveDecisionInputs(state);
if (inputs.hasExplicitRestriction) {
return block({
reasonCode: "EXPLICIT_RESTRICTION",
nextAllowedStep: "STOP"
});
}
if (inputs.hasRestrictedAlternatePath) {
return block({
reasonCode: "DIRECT_PATH_RESTRICTED",
nextAllowedStep: "USE_AUTHORIZED_ALTERNATE_PATH"
});
}
if (inputs.hasMaterialCollision) {
return review({
reasonCode: "UNRESOLVED_IDENTITY_COLLISION",
nextAllowedStep: "HUMAN_REVIEW"
});
}
if (
inputs.identitySupported &&
inputs.contactPathSupported &&
inputs.timelineExplained &&
policy.allowAction
) {
return proceed({
reasonCode: "ALL_REQUIRED_CHECKS_SUPPORTED",
nextAllowedStep: "ENTER_APPROVED_WORKFLOW"
});
}
if (inputs.hasUsefulEvidence) {
return review({
reasonCode: "EVIDENCE_REQUIRES_INTERPRETATION",
nextAllowedStep: "HUMAN_REVIEW"
});
}
return noResult({
reasonCode: "NO_USABLE_EVIDENCE",
nextAllowedStep: "RETURN_TO_SOURCE_WORKFLOW"
});
}
The final fallback is essential.
Without it, the function remains partial.
“No Result” Is a Successful Decision
Engineers often treat “no result” as failure.
That is correct for a lookup function:
lookup(identifier) → entity | null
It is not correct for a decision function:
decide(record, evidence, policy) → terminal state
When the available evidence cannot justify action, NO_RESULT is the correct result.
It establishes that:
- the system completed its investigation;
- no supported path passed the policy gates;
- no unresolved exception was hidden;
- the record must not be promoted;
- and the next step is explicitly defined.
This is operationally different from a system error.
type RunStatus =
| "COMPLETED"
| "FAILED";
type DecisionState =
| "PROCEED"
| "REVIEW"
| "BLOCK"
| "NO_RESULT";
A completed run can return any of the four decision states.
A failed run means the system could not execute the decision function because of an infrastructure, validation, or integrity failure.
Conflating these concepts creates bad metrics and dangerous retry behavior.
A Decision Packet Is More Useful Than a Score
A score gives an operator a number.
A decision packet gives the organization an accountable state transition.
type DecisionPacket = {
recordId: string;
decision: DecisionState;
reasonCode: string;
operatorReason: string;
nextAllowedStep: string;
supportingEvidence: PublicEvidenceUnit[];
contradictingEvidence: PublicEvidenceUnit[];
unresolvedQuestions: string[];
sourceFamiliesUsed: string[];
policyVersion: string;
createdAt: string;
};
The reason code serves machines.
The operator reason serves humans.
The next allowed step connects the decision to workflow.
The evidence projection explains why the state exists.
The policy version makes historical decisions reproducible after rules evolve.
This is the difference between returning information and controlling a process.
Test Invariants, Not Only Examples
Fixture tests are necessary, but decision systems also need invariant-based tests.
Completeness
Every valid record produces one terminal decision.
assert(terminalStates.includes(result.decision));
Exclusivity
A record cannot be both PROCEED and BLOCK.
assert.equal(result.terminalStateCount, 1);
Determinism
The same normalized evidence and policy version produce the same decision.
assert.deepEqual(
decide(state, policy),
decide(state, policy)
);
Provenance
Every supporting conclusion references existing evidence.
for (const reference of result.evidenceReferences) {
assert(ledger.has(reference));
}
Monotonic restrictions
Adding a restriction cannot upgrade a decision.
assert(
restrictiveness(withRestriction) >=
restrictiveness(withoutRestriction)
);
Source independence
Repeated returns from the same family do not inflate independent support.
assert.equal(
independentSupport(duplicatedLedger, claim),
independentSupport(originalLedger, claim)
);
Public-output safety
Raw payload references and credentials never appear in operator output.
assert.equal(publicPacket.rawPayload, undefined);
assert.equal(publicPacket.credentials, undefined);
These invariants protect the architecture even as providers, schemas, and policy configurations change.
The Hard Problem Is Controlled Commitment
It is relatively easy to retrieve more data.
It is harder to determine:
- which observations are independent;
- which observations contradict each other;
- what context changes their meaning;
- what restrictions dominate;
- what uncertainty remains;
- and what action is justified.
That is why the core abstraction should not be the provider call, the confidence score, or the candidate record.
The core abstraction should be the transition from evidence to controlled commitment.
A mature decision system does not claim certainty where none exists. It does something more useful:
- it preserves evidence;
- separates observation from interpretation;
- models conflict explicitly;
- applies restrictions conservatively;
- isolates probabilistic advice;
- produces one exhaustive terminal state;
- and explains the next permitted action.
The quality of such a system is not measured only by how often it says “yes.”
It is measured by whether every “yes,” “no,” “review,” and “not enough evidence” is deliberate, reproducible, and defensible.
That is the point at which decisioning stops being a feature layered on top of data.
The decision becomes the product.
Suggested tags: #architecture #systemdesign #backend #decisioning #dataengineering #softwareengineering