cadrumo.application.ledger.confirmation_gate module

The blocking half of the human review gate: what cannot be confirmed blind.

A draft is a proposal. Turning one into an Invoice is the moment a machine reading becomes a filing-grade fact, and this module is the only thing standing between the two. It answers one question — which of this draft’s problems must a human answer before it may be confirmed at all — and refuses the confirm until each named one carries an explicit, per-finding answer.

There is no bulk resolution and there will not be one. Not a --confirm-all, not a --force, not an “accept remaining” that resolves the set in one keystroke. The entire value of the gate is the per-document attention it forces; a flag that clears every blocker at once re-creates exactly the rubber stamp the gate exists to remove, and it would be asked for the first time someone has a hundred documents waiting. Every resolution names ONE blocker by its id.

Completeness is by construction, not by diligence. BLOCKING_REASON_BY_DISCREPANCY_KIND maps every member of DraftDiscrepancyKind to a ConfirmationBlockReason, and the module refuses to import if a member is missing. A new deterministic check therefore cannot land as a finding that quietly fails to block — the tree stops instead. That is the opposite of a hand-listed set, which passes silently while covering half the axis.

The second blocker source is the grounding pass rather than the discrepancy checks: a counterparty identifier whose reading was AMBIGUOUS produced competing candidates and no decision. That is not an arithmetic failure and raises no DraftDiscrepancyFinding, but confirming it picks one real taxpayer’s identifier over another’s by accident.

See also

InvoiceDraft

The proposal this gate guards.

closure_findings()

Where the arithmetic blockers come from.

InvoiceConfirmationRecord

What the resolutions are persisted into once the confirm succeeds.

BLOCKING_REASON_BY_DISCREPANCY_KIND: Mapping[DraftDiscrepancyKind, ConfirmationBlockReason]

Which review-gate reason each deterministic check’s failure raises.

Total over DraftDiscrepancyKind by construction — the assertion below fails the import when a member is unmapped, so a newly-landed check cannot produce a finding that silently does not block. Every member maps to a reason because every member names a real defect: an unmapped member would be a check whose failure the product decided not to care about, and that decision belongs in the check’s own existence rather than hidden in this table.

A deterministic condition the product decides NOT to block on is therefore not represented here at all. It is reported through the non-blocking advisory channel instead – country_vocabulary_advisory() and party_attribution_advisory() are the two – which the review surface projects onto the envelope’s typed notices. An exemption row in this table would be the worse shape: the reader could no longer tell a blocking axis from an advisory one by looking at the axis.

IDENTITY_FIELDS: frozenset[str]

Draft fields whose ambiguity is an ambiguous COUNTERPARTY, not a soft field.

Named rather than inlined so the exclusion is auditable. An ambiguous supplier name is a nuisance the operator retypes; an ambiguous tax identifier selects which real taxpayer the record names, feeds Modelo 347 per counterparty, and drives deductibility — so it blocks and the name does not.

exception ConfirmationBlockedError(message=None, *, context=None, translated_message=None, precondition_verdict=None)[source]

Bases: LedgerPreconditionErrorMixin, CadrumoError

Raised when a confirm is attempted with an unresolved blocking finding.

Parameters:
Return type:

None

code
class ConfirmationBlocker(**data)[source]

Bases: BaseModel

One problem a human must answer before this draft may be confirmed.

Variables:
  • blocker_id – Clock-free, deterministic address for this blocker, derived from what the blocker IS. Re-reading the same document raises the same id, so an operator can resolve a blocker they saw in an earlier listing without the ids having shifted under them — and a resolution can never be pointed at a blocker by position.

  • reason – Which class of refusal this is.

  • field – The draft field the blocker is about, or None when it is about a relationship between several.

  • detail – Operator-facing explanation naming the figures or readings involved.

  • candidates – The competing readings, when the blocker is an ambiguity the operator resolves by choosing one. Empty otherwise.

Parameters:

data (Any)

blocker_id: str
reason: ConfirmationBlockReason
field: str | None
detail: str
candidates: tuple[FieldAmbiguityCandidate, ...]
property candidate_values: tuple[str, ...]

Return the candidate values, for checking a choose-candidate resolution.

property candidate_digests: tuple[str, ...]

Return each candidate’s value as the review surface renders it.

The operator-facing surfaces emit every payload through the redaction funnel, so an ambiguous IDENTITY reaches the operator as a digest. That is correct and stays: the value that could not be established is exactly the value that must not cross an output boundary.

It also means the value is not a token the operator can type back. This is the form they actually saw, so it is the form they can name.

names_a_candidate(token)[source]

Return whether token identifies one of this blocker’s readings.

Accepts the value or the digest the surface rendered it as. Both are choices rather than assertions, which is the property the refusal below exists to enforce: a digest matches only a reading the document actually offered, and it cannot be constructed for a value that was never a candidate without already holding that value.

Accepting the digest does not weaken the check – it makes it reachable. A resolution can only ever have been expressed by an operator who had the value in hand, which excluded every operator adjudicating from the review surface, where the value is redacted by design.

Return type:

bool

Parameters:

token (str)

class FindingResolution(**data)[source]

Bases: BaseModel

The operator’s explicit answer to exactly one named blocker.

One resolution, one blocker, always. The id is required and is matched against the blockers the draft actually raises, so a resolution naming nothing is a refusal rather than a silent no-op — the failure mode where an operator believes they answered a finding and the confirm proceeded on a different one entirely.

Variables:
  • blocker_id – The blocker being answered.

  • action – How it was settled.

  • value – The chosen or supplied value, as printed. Required for CHOOSE_CANDIDATE and SUPPLY_VALUE, refused for ATTEST, which changes no figure.

  • note – The operator’s reason, required for ATTEST — an attestation with no stated basis is a waiver wearing a resolution’s shape.

Parameters:

data (Any)

blocker_id: str
action: FindingResolutionAction
value: str | None
note: str
blocker_id(*, reason, field, detail)[source]

Return the clock-free derived address for one blocker.

Folds only what the blocker IS. A timestamp would make a re-read of the same unchanged document raise different ids, so a resolution captured from one listing would refuse against the next — and the operator would learn to re-run and re-copy rather than to answer.

Return type:

str

Parameters:
confirmation_blockers(draft)[source]

Return every problem that must be answered before draft may be confirmed.

Deterministic and total: the same draft always yields the same blockers with the same ids. An empty result means nothing blocks — never that the document is correct, because a check whose terms the document did not state is not run at all.

Parameters:

draft (InvoiceDraft) – The reviewed draft, carrying the discrepancies its deterministic checks raised and the provenance envelopes its grounding pass wrote.

Returns:

the discrepancy findings in the order the checks produced them, then the ambiguous identity fields in draft-field declaration order.

Return type:

Blockers in a stable order

resolved_blockers(*, draft, resolutions=())[source]

Return draft’s blockers, having refused unless every one is answered.

The single gate every confirm path passes through. It returns the blockers rather than a bare None so the caller persists exactly the set that was answered, and cannot record a different one.

Parameters:
Return type:

tuple[ConfirmationBlocker, ...]

Returns:

The blockers the draft raised, all of them resolved.

Raises:

ConfirmationBlockedError – When a blocker carries no resolution, when a resolution names no blocker this draft raises, when two resolutions answer one blocker, or when a CHOOSE_CANDIDATE names neither a recorded candidate’s value nor the digest the surface rendered it as.

A choose-candidate resolution may name its reading EITHER way, and the second form is what makes the ambiguity surface usable at all. An ambiguous identity reaches the operator through the redaction funnel, so both competing readings render as digests; requiring the value asked them to type a string the surface had deliberately withheld, and the review command’s whole purpose for identity fields is that the operator – who holds the document – decides between the readings. They could see which was right and had no way to say so.

The value is never consumed beyond this check: it attests WHICH reading was chosen and never becomes the record’s data, so nothing downstream needed it to have crossed the boundary.