Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# ADR 0062: Reconciliation exception resolution uses named immutable maker-checker commands

- Status: Proposed
- Date: 2026-09-02
- Bounded context: Bank Reconciliation / Evidence and Audit
- Depends on: ADR 0058 reconciliation-run command evidence and the stacked reconciliation lifecycle transition
- Follow-ups: #34/#9 for authenticated HTTP command authorization; #44 for least-privilege database capability
Comment on lines +1 to +7

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Required release records are missing

The new public command changes reconciliation authority, but CHANGELOG.md and STANDARD_TRACEABILITY.md omit it. Repository rules require both updates for authority changes.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


## Context

A reconciliation exception is an accounting control fact. The existing `reconciliation_exception.resolution_status_code` records `open`, `resolved`, or `superseded`, but a mutable status value by itself does not prove who reviewed the exception, why the reviewer accepted the outcome, which retained evidence supported it, when the decision became effective, or whether an idempotent command and its integration event committed together.

The reconciliation-run lifecycle therefore correctly treated every exception as a blocker. The next bounded buyer-visible gap is to make terminal exception state authoritative without weakening that fail-closed boundary. This decision does not create or post journals, alter ledger balances, close periods, or change accounting policy.

IFRS Accounting Standards do not prescribe an application-level bank-reconciliation command protocol. IAS 7 remains relevant to cash and cash-equivalent reporting, and the IASB is actively researching Statement of Cash Flows improvements in 2026; this ADR does not characterize the implementation as IFRS compliance. The control design instead treats maker-checker separation, retained evidence, transaction atomicity, tenant isolation, and replay safety as system control requirements.

## Decision

Introduce `accounting_core.reconciliation_exception_resolution_command` as immutable command evidence. A command is tenant/run/exception scoped and contains a shared reconciliation idempotency key, terminal target (`resolved` or `superseded`), retained evidence reference plus exact SHA-256 digest, distinct reviewer actor, purpose, effective time, database recording time, and a database-derived command hash.

The command uses the existing tenant-wide `reconciliation_command_identity` namespace with the new `exception_resolution` family. Opening, run-finalization, and exception-resolution commands therefore cannot silently reuse the same idempotency key for different meanings.

Exception resolution follows the reconciliation-run lifecycle serialization lock. The database verifies that the run is still `evaluating` or `review_required`, the exception is still `open`, the reviewer is not the exception owner, and the decision does not predate the exception. One exception may have only one terminal resolution command. Exact retries replay the retained command receipt; changed retries require a new idempotency key.

A raw `UPDATE reconciliation_exception SET resolution_status_code = ...` is not authority. The database permits the `open` to terminal transition only when exactly one matching resolution command already exists in the same transaction. A deferred constraint requires the command and terminal status to commit as a pair, and terminal exception evidence becomes immutable afterward. Tenant row-level security is forced and `PUBLIC` has no table privilege.

The application command writes the resolution command, terminal exception state, and transactional outbox event in one PostgreSQL transaction. `reconciliation_exception_resolved` and `reconciliation_exception_superseded` carry the exception aggregate reference and immutable command hash. The eventual restricted database capability in #44 must expose the named command boundary rather than grant raw insert/update/outbox privileges.

Run finalization is changed from the interim “any exception blocks” rule to: every exception must be terminal and must have exactly one durable resolution command whose target agrees with the terminal status. The application additionally includes the stable ordered resolution-command state—exception id, target, retained evidence reference/hash, and command hash—in the reconciliation transition snapshot. Changing resolution evidence therefore changes the run-finalization digest.

No statement line, imported bank evidence, probabilistic suggestion, or LLM output can invoke this command automatically. It is a reviewed accounting-control decision. A resolved exception also does not post a journal; any correcting entry remains a separate authorized accounting command with its own evidence and idempotency boundary.

## Consequences

The run lifecycle can now progress after exceptions are explicitly reviewed without treating mutable status as evidence. Auditors can trace a terminal exception to a named reviewer decision, retained evidence digest, exact command hash, and emitted event. Concurrent finalization and exception resolution serialize on the same aggregate lock, while PostgreSQL `REPEATABLE READ` keeps the lifecycle evidence reads on one transaction snapshot.

The forward migration is unreleased and stacked. It must pass real PostgreSQL installation, rollback/restore rehearsal, direct-SQL bypass tests, concurrency tests, exact replay/conflict tests, forced-RLS tests, 100% owned production statement/branch coverage, security scans, package/SBOM/provenance gates, and independent review before integration. Databases containing pre-migration terminal exception rows require explicit review because those rows predate the named-command authority; they must not be silently grandfathered as valid resolution evidence.

## Alternatives rejected

**Keep `resolution_status_code` as the only fact.** Rejected because a privileged or accidental update could manufacture terminal control state without durable reviewer provenance.

**Store free-form resolution notes on the exception row.** Rejected because notes do not provide immutable command identity, exact replay, maker-checker enforcement, or an atomic integration event.

**Let reconciliation finalization infer that every non-`open` status is approved.** Rejected because it promotes historical mutable state into authority and makes the finalization snapshot blind to the evidence that justified the exception decision.

**Post a correcting journal as part of exception resolution.** Rejected because bank reconciliation review and General Ledger posting are distinct bounded-context commands. Resolution may identify that a correcting journal is needed, but posting must remain a separate authorized double-entry operation.

## Verification and traceability

Acceptance evidence must demonstrate at least these cases on real PostgreSQL: raw terminal status update fails; owner-as-reviewer fails; decision before exception time fails; valid command changes status and emits the matching outbox event atomically; exact replay returns the same receipt; changed replay conflicts; terminal exception evidence cannot be rewritten; a terminal status without matching command still blocks run reconciliation; a terminal status with matching command can proceed when all other bridge/review controls pass; tenant RLS and shared command idempotency remain enforced.
Comment on lines +49 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the changelog and standards traceability

This commit changes reconciliation authority from “every exception blocks finalization” to maker-checker terminal commands, but none of CHANGELOG.md or the repository's standards traceability records are updated. The ADR alone does not update those required project-wide records, so release and control documentation will continue to describe the pre-0020 authority boundary. Record this behavior change in both locations.

AGENTS.md reference: AGENTS.md:L30-L30

Useful? React with 👍 / 👎.


Primary-source alignment used for this decision:

- International Accounting Standards Board. (2026). *Statement of Cash Flows and Related Matters*. IFRS Foundation. https://www.ifrs.org/projects/work-plan/statement-of-cash-flows-and-related-matters/
- International Organization for Standardization. (2022). *ISO/IEC 27001:2022 Information security, cybersecurity and privacy protection—Information security management systems—Requirements*. ISO. https://www.iso.org/standard/27001
- National Institute of Standards and Technology. (2020, updated 2025). *Security and Privacy Controls for Information Systems and Organizations (NIST SP 800-53 Rev. 5), AC-5 Separation of Duties*. U.S. Department of Commerce. https://csrc.nist.gov/pubs/sp/800/53/r5/upd1/final
- PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: SET TRANSACTION*. https://www.postgresql.org/docs/18/sql-set-transaction.html
- PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Row Security Policies*. https://www.postgresql.org/docs/18/ddl-rowsecurity.html

These references support reporting context, segregation-of-duty intent, transactional snapshot semantics, and tenant-access enforcement. They are design inputs, not certification or compliance claims.
4 changes: 3 additions & 1 deletion src/accounting_information_platform/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
render_reconciliation_close_package_json,
verify_reconciliation_close_package,
)
from .reconciliation_exception_resolution import resolve_reconciliation_exception
from .reconciliation_lifecycle import reconcile_reconciliation_run
from .reconciliation_run import accept_reconciliation_run, lookup_reconciliation_run
from .reconciliation_read_model import (
Expand Down Expand Up @@ -175,9 +176,10 @@
"pull_journal_proposal",
"pull_validated_journal_proposals",
"reconcile_reconciliation_run",
"resolve_reconciliation_exception",
"render_reconciliation_close_package_json",
"render_reconciliation_close_review_csv",
"render_reconciliation_close_review_json",
"run_journal_proposal_server",
"verify_reconciliation_close_package",
]
]
27 changes: 26 additions & 1 deletion src/accounting_information_platform/migration_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,37 @@

from pathlib import Path

from .persistence import apply_foundation_migration as _apply_foundation_migration
from .core import AccountingValidationError
from .persistence import (
_import_psycopg,
apply_foundation_migration as _apply_foundation_migration,
)


def apply_foundation_migration(database_url: str, migration_path: Path) -> None:
"""Apply the complete checked-in foundation chain through the canonical loader."""
resolution_migration_path = (
migration_path.parent / "0020_reconciliation_exception_resolution_command.sql"
)
if not resolution_migration_path.is_file():
raise AccountingValidationError(
"Reconciliation exception-resolution command migration is missing at "
f"{resolution_migration_path}. Restore "
"database/migrations/0020_reconciliation_exception_resolution_command.sql, "
"then retry."
)
_apply_foundation_migration(database_url, migration_path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Install migration 0020 through the canonical persistence loader

Callers that use the existing accounting_information_platform.persistence.apply_foundation_migration entry point—including several repository PostgreSQL fixtures—still install only through migration 0019 because migration 0020 is known only to this new wrapper. A database initialized through that established entry point therefore lacks reconciliation_exception_resolution_command, and the newly exported resolution command fails with an undefined-relation error. Add 0020 to the canonical persistence migration chain rather than applying it solely in the wrapper.

Useful? React with 👍 / 👎.

psycopg = _import_psycopg()
try:
with psycopg.connect(
database_url, autocommit=True, cursor_factory=psycopg.ClientCursor
) as connection:
connection.execute(resolution_migration_path.read_text(encoding="utf-8"))
Comment on lines 14 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Migration loaders now disagree

The public wrapper installs migration 0020, while persistence.apply_foundation_migration still stops at 0019. Callers now receive different schemas from the same-named loader.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

except Exception as error:
raise AccountingValidationError(
"Reconciliation exception-resolution migration failed. Inspect the PostgreSQL "
"error, restore a clean database, then retry the complete foundation migration."
) from error


__all__ = ["apply_foundation_migration"]
Loading
Loading