Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
cf501df
feat: persist proposed reconciliation matches
seonghobae Aug 29, 2026
75ca8ab
feat: add reconciliation match command schema
seonghobae Aug 29, 2026
e38d0d1
test: scope psycopg wheel hash contract
seonghobae Aug 29, 2026
75732b8
fix: validate psycopg Python 3.14 hash
seonghobae Aug 29, 2026
17340f9
fix: validate reconciliation match sources
seonghobae Aug 29, 2026
b73f57d
fix: classify reconciliation match conflicts
seonghobae Aug 29, 2026
06c02cc
fix: distinguish absent reconciliation journals
seonghobae Aug 29, 2026
08e88d9
test: reject cross-paired reconciliation command evidence
seonghobae Aug 29, 2026
3a42b1c
fix: bind match command evidence to candidate chain
seonghobae Aug 29, 2026
a418d45
fix: bind match command to its candidate chain
seonghobae Aug 29, 2026
0f7a9d4
docs: record database-owned match command chain
seonghobae Aug 29, 2026
24e6367
Merge remote candidate-chain hardening
seonghobae Aug 29, 2026
c77cf13
Merge remote match-chain documentation
seonghobae Aug 29, 2026
8602b55
Merge current reconciliation run API base
seonghobae Aug 29, 2026
26e92ef
Merge remote-tracking branch 'origin/feat/reconciliation-run-api' int…
seonghobae Aug 29, 2026
56381ee
fix(reconciliation): enforce run evidence provenance
seonghobae Aug 29, 2026
55dc4d6
Merge remote-tracking branch 'origin/feat/reconciliation-run-command'…
seonghobae Aug 29, 2026
cb309f5
fix(reconciliation): bind matches to source cash evidence
seonghobae Aug 29, 2026
10ead71
fix(reconciliation): close historical evidence gaps
seonghobae Aug 29, 2026
e7ef2e4
fix(reconciliation): close concurrency and upgrade gaps
seonghobae Aug 29, 2026
270f0c7
fix(reconciliation): validate legacy provenance on upgrade
seonghobae Aug 29, 2026
d4581bf
docs: refresh product gap evidence date
seonghobae Aug 29, 2026
20cb6b6
fix(reconciliation): align amount precision domain
seonghobae Aug 29, 2026
b740bcc
fix(reconciliation): rebuild dependent amount trigger
seonghobae Aug 30, 2026
134ecb5
docs: update migration chain guidance
seonghobae Aug 30, 2026
98ba7df
fix(reconciliation): close command evidence races
seonghobae Aug 30, 2026
39d134d
fix(reconciliation): persist command freeze marker
seonghobae Aug 30, 2026
37c7aef
docs(reconciliation): describe durable freeze marker
seonghobae Aug 30, 2026
03dc547
fix(reconciliation): protect command freeze marker
seonghobae Aug 30, 2026
e813c8f
fix(http): advertise reconciliation match lookup
seonghobae Aug 30, 2026
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
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@

## [Unreleased]

- Aligned reconciliation candidate and allocation amounts with the platform `numeric(38, 6)` monetary domain through migration `0022_reconciliation_amount_precision.sql`; exact trigger aggregates no longer overflow before conservation validation, command evidence is admitted only for still-proposed matches, and allocation-freeze checks read a durable command-evidence marker from the locked parent match. Existing command rows backfill that marker, and the shared parser rejects wider values before persistence.
- Closed additional match-evidence gaps: journal `posted_at` and statement `recorded_at` now respect the run knowledge cutoff, direct command evidence must match candidate amounts as well as allocation totals, command-side allocation checks serialize on the match row, and migration `0021_reconciliation_run_command_provenance_repair.sql` reapplies provenance guards to existing installations while failing closed on pre-existing cross-bank command evidence. The psycopg exception import is deferred until the database command path. Dependency-free public imports remain usable; the match boundary remains review-only.
- Hardened proposed-match source admission: the run row is locked through match persistence; statement booking/value timestamps and journal accounting dates respect the run cutoffs; CRDT/DBIT must agree with the debit/credit side of the assigned cash chart line; and compound journals match that cash line rather than the journal-wide total. Migration `0020_reconciliation_match_command_evidence.sql` now requires exactly one equal statement/journal allocation at command insert and freezes allocations after command evidence. These controls remain review evidence only and cannot approve, close, or post.
- Hardened migration `0020_reconciliation_match_command_evidence.sql` so immutable match-command evidence cannot mix an independently valid candidate with another candidate's match; the candidate-inclusive composite foreign key now proves the persisted chain.
- Extended the hash-locked PostgreSQL quality dependency set with the CPython 3.14 `psycopg-binary` wheel required by the central coverage runner; the repository contract now keeps that interpreter boundary executable.
- Added the tenant-scoped `POST /reconciliation-matches` and `GET /reconciliation-matches?reconciliation_match_id=` command boundary with migration `0020_reconciliation_match_command_evidence.sql`. One exact 1:1 proposed match persists its candidate, statement/journal allocations, idempotency key, canonical command hash, and immutable source-payload provenance atomically; exact retries replay and changed evidence conflicts. Quoted positive equal decimal amounts must match the bound bank-entry and posted-journal source facts, and database source-conservation violations return stable validation errors. The HTTP boundary distinguishes malformed identifiers (400), absent sources (404), state conflicts (409), and source-content validation (422). The evidence cannot approve, close, select chart accounts, or post journals. ADR 0059 records the boundary.
- Added migration `0018_bank_statement_balance_evidence.sql` and exact normalized balance facts to the immutable camt.053 registry. Opening and closing amounts, currency, direction, effective date/time, sequence, locator, and source hash now survive persistence as forced-RLS evidence for a later exact reconciliation bridge; no balance row grants posting, reversal, approval, close, or accounting-policy authority. ADR 0057 records the decision.
- Added migration `0019_reconciliation_run_command_evidence.sql` and the tenant-scoped `POST /reconciliation-runs` / `GET /reconciliation-runs?reconciliation_run_id=` boundary. A run opens only as `evaluating` from one persisted statement and active bank-account assignment; the command binds the raw artifact payload hash (distinct from the normalized statement hash), exact retries replay immutable command/source evidence, and changed key evidence fails closed. Distinct keys may create separately auditable runs for later policy or cutoff evaluation. This slice does not match, approve, close, post, or select chart accounts. ADR 0058 records the decision.
- Added migration `0019_reconciliation_run_command_evidence.sql` and the tenant-scoped `POST /reconciliation-runs` / `GET /reconciliation-runs?reconciliation_run_id=` boundary. A run opens only as `evaluating` from one persisted statement and active bank-account assignment; the command binds the raw artifact payload hash (distinct from the normalized statement hash), exact retries replay immutable command/source evidence, and changed key evidence fails closed. Historical runs exclude statement, artifact, account, assignment, legal-entity, and accounting-book facts recorded after `knowledge_cutoff_at`; a deferred database provenance guard rejects orphan runs and commands whose statement belongs to another bank account. Distinct keys may create separately auditable runs for later policy or cutoff evaluation. This slice does not match, approve, close, post, or select chart accounts. ADR 0058 records the decision.
- Reconciliation-run bank, book, and knowledge cutoffs now require an explicit zero-offset UTC timezone (`Z` or `+00:00`); timezone-naive and non-UTC values fail before persistence or command hashing, so an ambiguous wall clock cannot become authoritative evidence.
- Exact reconciliation-run retries now resolve stored command evidence before live assignment validation, preserving replay after assignment rollover or closure while changed request evidence remains a conflict.
- Added database-owned reconciliation approval snapshots in migration `0016_reconciliation_approval_evidence.sql` and the forward lock-order repair in `0017_reconciliation_approval_lock_order.sql`: command hashes and immutable object-storage references remain caller evidence, while PostgreSQL computes a versioned SHA-256 digest over the candidate and exact allocation rows. Approval, allocation, and terminal match transitions share a tenant/run/match advisory lock in parent-row-first order; late allocations, candidate retargeting, status-only decisions, stale snapshots, unbound legacy terminal rows, and row/advisory deadlocks fail closed, while supersession preserves terminal approval time. The migration upgrade check temporarily grants only its transaction's current migration user visibility over forced-RLS match rows and removes that policy before commit. This control evidence cannot post, reverse, close, or alter accounting policy. ADR 0055 records the decision.
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ What is present:
- a stdlib HTTP surface for proposal acceptance, posting receipts, journals,
reversals, period close/open, trial balances, financial statements, ledgers,
aging, VAT/HomeTax rejection receipts, outbox, audit, catalog reads, and
tenant-scoped evaluating reconciliation-run creation/lookup;
tenant-scoped evaluating reconciliation-run and proposed reconciliation-match
creation/lookup;
- product, architecture, security, and standards documents listed below.

What is not present: an automatically started listener, gRPC or live event
Expand Down Expand Up @@ -121,7 +122,7 @@ factory/runner and provide the tenant-bound host boundary explicitly.
`unittest` discovery also runs `tests/test_postgres_posting.py`, which needs a
reachable PostgreSQL 18 instance and `ACCOUNTING_DATABASE_URL` (CI uses
`postgresql://postgres:postgres@127.0.0.1:5432/accounting_test` and applies
the checked-in migration chain through `database/migrations/0019_reconciliation_run_command_evidence.sql`). Persistence is still
the checked-in migration chain through `database/migrations/0022_reconciliation_amount_precision.sql`). Persistence is still
local to this repository; it is not a Naruon or sibling checkout.

Optional import smoke after the editable install above:
Expand Down Expand Up @@ -174,6 +175,9 @@ in the PostgreSQL posting transaction; nothing in this tree publishes those
events onto a live bus. The same boundary exposes `POST /journals` for
AIS-owned adjustments and `GET /financial-statements`, `GET /trial-balances`,
`GET /account-ledgers`, and the aging/reporting routes for buyer-facing reads.
The reconciliation boundary also exposes `POST /reconciliation-matches` and
`GET /reconciliation-matches?reconciliation_match_id=` for one exact 1:1
proposed match; it records review evidence only and cannot approve or post.

## Standards already cited

Expand Down
57 changes: 57 additions & 0 deletions database/migrations/0019_reconciliation_run_command_evidence.sql
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,63 @@ CREATE TRIGGER reconciliation_run_command_immutable_guard
FOR EACH ROW
EXECUTE FUNCTION accounting_core.reject_reconciliation_run_command_mutation();

CREATE OR REPLACE FUNCTION accounting_core.enforce_reconciliation_run_command_provenance()
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
command_count integer;
BEGIN
SELECT count(*)
INTO command_count
FROM accounting_core.reconciliation_run_command AS command
WHERE command.tenant_account_id = NEW.tenant_account_id
AND command.reconciliation_run_id = NEW.reconciliation_run_id;

IF command_count <> 1 THEN
RAISE EXCEPTION
'reconciliation run must have exactly one command evidence row at commit (reconciliation_run_command_provenance)'
USING ERRCODE = '23514';
END IF;

IF EXISTS (
SELECT 1
FROM accounting_core.reconciliation_run_command AS command
JOIN accounting_integration.bank_statement_record AS statement
ON statement.tenant_account_id = command.tenant_account_id
AND statement.bank_statement_record_id = command.bank_statement_record_id
JOIN accounting_core.reconciliation_run AS run
ON run.tenant_account_id = command.tenant_account_id
AND run.reconciliation_run_id = command.reconciliation_run_id
JOIN accounting_core.bank_account_assignment AS assignment
ON assignment.tenant_account_id = run.tenant_account_id
AND assignment.legal_entity_id = run.legal_entity_id
AND assignment.accounting_book_id = run.accounting_book_id
AND assignment.bank_account_assignment_id = run.bank_account_assignment_id
WHERE command.tenant_account_id = NEW.tenant_account_id
AND command.reconciliation_run_id = NEW.reconciliation_run_id
AND statement.bank_account_record_id IS DISTINCT FROM assignment.bank_account_record_id
) THEN
RAISE EXCEPTION
'reconciliation run command bank account provenance does not match the run assignment (reconciliation_run_command_provenance)'
USING ERRCODE = '23514';
END IF;

RETURN NULL;
END;
$$;

CREATE CONSTRAINT TRIGGER reconciliation_run_command_provenance_guard
AFTER INSERT ON accounting_core.reconciliation_run
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION accounting_core.enforce_reconciliation_run_command_provenance();
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

CREATE TRIGGER reconciliation_run_command_provenance_insert_guard
AFTER INSERT ON accounting_core.reconciliation_run_command
FOR EACH ROW
EXECUTE FUNCTION accounting_core.enforce_reconciliation_run_command_provenance();

ALTER TABLE accounting_core.reconciliation_run_command ENABLE ROW LEVEL SECURITY;
ALTER TABLE accounting_core.reconciliation_run_command FORCE ROW LEVEL SECURITY;
CREATE POLICY reconciliation_run_command_isolation
Expand Down
183 changes: 183 additions & 0 deletions database/migrations/0020_reconciliation_match_command_evidence.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
BEGIN;

-- Immutable application command identity for a proposed reconciliation match.
-- This records reviewable candidate evidence only; it cannot approve, close, or
-- post a journal.

-- Command provenance must name the candidate actually referenced by the match.
-- The existing match primary key proves row identity, while this tenant/run
-- composite key gives downstream evidence a database-owned same-chain target.
ALTER TABLE accounting_core.reconciliation_match
ADD CONSTRAINT reconciliation_match_candidate_chain_unique
UNIQUE (
tenant_account_id,
reconciliation_run_id,
reconciliation_match_id,
reconciliation_candidate_id
);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

CREATE TABLE accounting_core.reconciliation_match_command (
reconciliation_match_command_id uuid PRIMARY KEY DEFAULT uuidv7(),
tenant_account_id uuid NOT NULL,
reconciliation_run_id uuid NOT NULL,
reconciliation_candidate_id uuid NOT NULL,
reconciliation_match_id uuid NOT NULL,
candidate_idempotency_key text NOT NULL
CHECK (btrim(candidate_idempotency_key) <> ''),
candidate_command_hash text NOT NULL
CHECK (candidate_command_hash ~ '^sha256:[0-9a-f]{64}$'),
source_payload_hash text NOT NULL
CHECK (source_payload_hash ~ '^sha256:[0-9a-f]{64}$'),
source_payload_reference text NOT NULL
CHECK (btrim(source_payload_reference) <> ''),
recorded_at timestamptz NOT NULL DEFAULT clock_timestamp(),
FOREIGN KEY (
tenant_account_id,
reconciliation_run_id,
reconciliation_candidate_id
) REFERENCES accounting_core.reconciliation_candidate (
tenant_account_id,
reconciliation_run_id,
reconciliation_candidate_id
),
FOREIGN KEY (
tenant_account_id,
reconciliation_run_id,
reconciliation_match_id,
reconciliation_candidate_id
) REFERENCES accounting_core.reconciliation_match (
tenant_account_id,
reconciliation_run_id,
reconciliation_match_id,
reconciliation_candidate_id
),
UNIQUE (tenant_account_id, candidate_idempotency_key),
UNIQUE (tenant_account_id, reconciliation_run_id, reconciliation_match_id)
);

CREATE INDEX reconciliation_match_command_run_index
ON accounting_core.reconciliation_match_command (
tenant_account_id,
reconciliation_run_id,
recorded_at,
reconciliation_match_command_id
);

ALTER TABLE accounting_core.reconciliation_match_command ENABLE ROW LEVEL SECURITY;
ALTER TABLE accounting_core.reconciliation_match_command FORCE ROW LEVEL SECURITY;

CREATE POLICY reconciliation_match_command_isolation
ON accounting_core.reconciliation_match_command
USING (tenant_account_id = accounting_core.current_tenant_account_id())
WITH CHECK (tenant_account_id = accounting_core.current_tenant_account_id());

REVOKE ALL ON accounting_core.reconciliation_match_command FROM PUBLIC;

CREATE OR REPLACE FUNCTION accounting_core.reject_reconciliation_match_command_mutation()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION
'recorded reconciliation match command evidence is immutable; create a new proposed match instead (reconciliation_match_command_immutable)'
USING ERRCODE = '23514';
END;
$$;

CREATE TRIGGER reconciliation_match_command_immutability_guard
BEFORE UPDATE OR DELETE
ON accounting_core.reconciliation_match_command
FOR EACH ROW EXECUTE FUNCTION accounting_core.reject_reconciliation_match_command_mutation();

CREATE OR REPLACE FUNCTION accounting_core.enforce_reconciliation_match_command_allocations()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
statement_allocation_count bigint;
journal_allocation_count bigint;
statement_allocation_total numeric(30, 6);
journal_allocation_total numeric(30, 6);
candidate_statement_amount numeric(30, 6);
candidate_journal_amount numeric(30, 6);
BEGIN
-- Share the parent match lock with the allocation conservation trigger so
-- command insertion and a concurrent allocation cannot both commit.
PERFORM 1
FROM accounting_core.reconciliation_match AS match
WHERE match.tenant_account_id = NEW.tenant_account_id
AND match.reconciliation_run_id = NEW.reconciliation_run_id
AND match.reconciliation_match_id = NEW.reconciliation_match_id
FOR UPDATE;
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

SELECT candidate.statement_amount, candidate.journal_amount
INTO candidate_statement_amount, candidate_journal_amount
FROM accounting_core.reconciliation_candidate AS candidate
WHERE candidate.tenant_account_id = NEW.tenant_account_id
AND candidate.reconciliation_run_id = NEW.reconciliation_run_id
AND candidate.reconciliation_candidate_id = NEW.reconciliation_candidate_id;

SELECT COUNT(*), COALESCE(SUM(allocation.allocated_amount), 0)
INTO statement_allocation_count, statement_allocation_total
FROM accounting_core.statement_match_allocation AS allocation
WHERE allocation.tenant_account_id = NEW.tenant_account_id
AND allocation.reconciliation_run_id = NEW.reconciliation_run_id
AND allocation.reconciliation_match_id = NEW.reconciliation_match_id;

SELECT COUNT(*), COALESCE(SUM(allocation.allocated_amount), 0)
INTO journal_allocation_count, journal_allocation_total
FROM accounting_core.journal_match_allocation AS allocation
WHERE allocation.tenant_account_id = NEW.tenant_account_id
AND allocation.reconciliation_run_id = NEW.reconciliation_run_id
AND allocation.reconciliation_match_id = NEW.reconciliation_match_id;

IF statement_allocation_count <> 1
OR journal_allocation_count <> 1
OR statement_allocation_total <> journal_allocation_total
OR statement_allocation_total <> candidate_statement_amount
OR journal_allocation_total <> candidate_journal_amount THEN
RAISE EXCEPTION
'reconciliation match command requires exactly one statement and one journal allocation matching candidate amounts (reconciliation_match_command_allocation)'
USING ERRCODE = '23514';
END IF;

RETURN NEW;
END;
$$;

CREATE TRIGGER z_reconciliation_match_command_allocation_guard
AFTER INSERT
ON accounting_core.reconciliation_match_command
FOR EACH ROW EXECUTE FUNCTION accounting_core.enforce_reconciliation_match_command_allocations();
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

CREATE OR REPLACE FUNCTION accounting_core.reject_reconciliation_match_command_allocation()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF EXISTS (
SELECT 1
FROM accounting_core.reconciliation_match_command AS command
WHERE command.tenant_account_id = NEW.tenant_account_id
AND command.reconciliation_run_id = NEW.reconciliation_run_id
AND command.reconciliation_match_id = NEW.reconciliation_match_id
) THEN
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
RAISE EXCEPTION
'reconciliation match command evidence freezes its allocation population; create a new proposed match instead (reconciliation_match_command_allocation_frozen)'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;

CREATE TRIGGER z_reconciliation_match_command_allocation_frozen_guard
BEFORE INSERT
ON accounting_core.statement_match_allocation
FOR EACH ROW EXECUTE FUNCTION accounting_core.reject_reconciliation_match_command_allocation();

CREATE TRIGGER z_reconciliation_match_command_allocation_frozen_guard
BEFORE INSERT
ON accounting_core.journal_match_allocation
FOR EACH ROW EXECUTE FUNCTION accounting_core.reject_reconciliation_match_command_allocation();

COMMIT;
Loading