Skip to content
Merged
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
47 changes: 34 additions & 13 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ pub struct ConstraintViolation {
```
src/
├── lib.rs # Re-exports: de, ser, envelope, error
├── de.rs # from_str<T>, from_reader<T>
├── de.rs # document_xml, from_str<T>, from_reader<T>
├── ser.rs # to_string<T>, to_string_with_declaration<T>
├── envelope.rs # detect_message_type, parse_namespace, MessageId
└── error.rs # ParseError enum
Expand All @@ -340,6 +340,7 @@ src/

| Function | Signature | Description |
|---|---|---|
| `de::document_xml` | `(xml: &str) → Result<&str, ParseError>` | Return a zero-copy payload `Document`, bare or below any transport wrapper |
| `de::from_str` | `<T: DeserializeOwned>(xml: &str) → Result<T, ParseError>` | Deserialize XML to typed struct |
| `de::from_reader` | `<R: BufRead, T: DeserializeOwned>(r: R) → Result<T, ParseError>` | Streaming deserialization |
| `ser::to_string` | `<T: Serialize>(v: &T) → Result<String, ParseError>` | Serialize to XML |
Expand Down Expand Up @@ -400,7 +401,7 @@ src/
├── fednow.rs # FedNow rules (USD, $500K limit, UETR, etc.)
├── sepa.rs # SEPA SCT rules (EUR, IBAN required, charset)
├── cbpr.rs # CBPR+ rules (BAH required, 4 BICs mandatory)
└── xml_scan.rs # Lightweight XML string extraction helpers
└── raw.rs # Private envelope, size, and control checks
```

**Validation architecture**:
Expand All @@ -419,6 +420,8 @@ Layer 2 — Business Rules (field-level)

Layer 3 — Scheme Rules (message-level)
├── SchemeValidator trait with validate() and validate_typed()
├── validate(): unwrap Document, route namespace, deserialize, delegate
├── validate_typed(): sole home for supported field rules
├── FedNow: 15+ rules (currency, settlement, amounts, UETR, size limits)
├── SEPA: 12+ rules (EUR, IBAN both sides, charset, amount bounds)
├── CBPR+: 12+ rules (BAH, 4 BICs, UETR, charge bearer, UTF-8)
Expand All @@ -433,16 +436,23 @@ pub trait Rule: Send + Sync {
}
```

**Scheme validator trait** (dual-path):
**Scheme validator trait** (parse-and-delegate plus typed entry point):
```rust
pub trait SchemeValidator: Send + Sync {
fn name(&self) -> &'static str;
fn supported_messages(&self) -> &[&str];
fn validate(&self, xml: &str, message_type: &str) -> ValidationResult; // XML string path
fn validate_typed(&self, msg: &dyn Any, message_type: &str) -> Option<ValidationResult>; // Typed path
fn validate(&self, xml: &str, message_type: &str) -> ValidationResult; // Parse and delegate
fn validate_typed(&self, msg: &dyn Any, message_type: &str) -> Option<ValidationResult>;
}
```

For supported `pacs.008`, the `Document` namespace is authoritative. Version
`pacs.008.001.13` delegates to typed rules; other versions receive raw findings,
the same version-neutral field rules, and `SCHEME_UNTYPED_VERSION`. Parse
failures produce one `SCHEME_PARSE` finding at `/Document`, alongside any
independent raw findings for the caller's supported short message type. The
former public `schemes::xml_scan` API was removed in 0.4.0.

**Validation output**:
```rust
pub struct ValidationResult { pub errors: Vec<ValidationError> }
Expand Down Expand Up @@ -692,7 +702,7 @@ use mx20022::validate::schemes::{fednow::FedNowValidator, SchemeValidator};
let registry = RuleRegistry::with_defaults();
let errors = registry.validate_field("GB82WEST12345698765432", "/iban", &["IBAN_CHECK"]);

// Scheme validation (XML path)
// Scheme validation (parse-and-delegate path)
let fednow = FedNowValidator::new();
let result = fednow.validate(&xml, "pacs.008.001.13");

Expand Down Expand Up @@ -743,6 +753,12 @@ let raw = ActiveCurrencyCode(arbitrary_string); // No validation — use with c

## 6. Design Patterns

### Design Decisions

| Area | Decision | Rationale |
|---|---|---|
| `pacs.008` scheme validation | Keep `pacs008::Facts` as the shared version-neutral extraction boundary for typed `pacs.008.001.13` and older-version paths. Keep FedNow, SEPA, and CBPR+ rule evaluation explicit; do not replace it with an optional-policy configuration engine. | Scheme policies differ in requiredness, accepted values, bounds, aggregation, diagnostics, and finding order. Extract helpers only when their mechanics are genuinely identical. |

### Code Generation over Hand-Writing

The single most important architectural decision. ISO 20022 defines 800+ message types. Hand-writing Rust types would be unmaintainable. Instead, a three-stage pipeline (XSD → IR → Rust) auto-generates all types from the official schemas. Adding a new message type means pointing the generator at an XSD file.
Expand Down Expand Up @@ -775,13 +791,19 @@ This enables:

`xs:choice` elements (mutually exclusive alternatives) map naturally to Rust enums. However, quick-xml's serde integration requires a `$value`-renamed wrapper for enum fields in structs. `ChoiceWrapper<T>` provides this transparently with `Deref`/`DerefMut` for ergonomic access.

### Dual Validation Paths
### Parse-and-Delegate Scheme Validation

Scheme validators implement both:
- `validate(&str, &str) → ValidationResult` — operates on raw XML via string scanning
- `validate_typed(&dyn Any, &str) → Option<ValidationResult>` — operates on deserialized types
- `validate(&str, &str) → ValidationResult` — unwraps the payload `Document`
below any transport wrapper, routes by its namespace, and applies typed or
version-neutral field rules
- `validate_typed(&dyn Any, &str) → Option<ValidationResult>` — applies the same
field rules directly to an already-deserialized document

The typed path is preferred (compile-time field access, no fragile string scanning). The XML path exists for backward compatibility and for validating messages that haven't been deserialized.
Raw XML pre-passes are deliberately narrow: byte-size limits, envelope-header
presence, and disallowed controls. They do not implement field rules. This
keeps both public entry points behaviorally aligned without invoking generated
`Validatable` constraints as part of scheme validation.

### Error Composition

Expand Down Expand Up @@ -918,7 +940,7 @@ testdata/
| Item | Description |
|---|---|
| **Ergonomic API layer** | Prelude module with type aliases, convenience `parse<T>()`/`to_xml<T>()` at top level |
| **Typed validation pipeline** | Migrate scheme validators from XML string scanning to `Validatable` trait path |
| **Additional typed scheme versions** | Add generated-document routing beyond `pacs.008.001.13` |
| **WASM bindings** | Separate `mx20022-wasm` crate via wasm-bindgen, target < 2MB bundle |
| **Python bindings** | Separate `mx20022-python` crate via PyO3/maturin, type stubs for mypy |

Expand All @@ -933,8 +955,7 @@ testdata/

| Gap | Status | Resolution |
|---|---|---|
| Scheme validators use XML string scanning (fragile) | Typed path added, XML path retained for compat | v0.5: migrate to typed-only |
| Generated typed scheme routing covers only `pacs.008.001.13` | Other `pacs.008` versions receive raw findings, version-neutral field checks, and `SCHEME_UNTYPED_VERSION` | Add typed routes as models stabilize |
| Builder API is flat, not nested-closure style | Acceptable trade-off for codegen complexity | Possible `with_defaults()` convenience |
| Expert module paths required (no prelude) | DX friction | v0.5: prelude with type aliases |
| SEPA validator uses f64 for amount comparison | Precision risk | v0.5: integer-cent arithmetic |
| No Display for ValidationResult | Usability gap | v0.5: Display impls |
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Added `mx20022_parse::de::document_xml`, which returns the complete payload
`Document` as a zero-copy XML slice whether it is bare or nested below an
arbitrary transport wrapper. Descendant `Document` elements inside that
payload are not treated as additional candidates.

### Changed

- Bumped all seven workspace crates and their internal dependency requirements
to 0.4.0. This source change is not a published release.
- Scheme validation now unwraps `Document`, routes `pacs.008` by its
authoritative namespace, deserializes `pacs.008.001.13`, and delegates all
field rules to `validate_typed`. Other `pacs.008` versions retain raw checks,
apply the same version-neutral field rules, and report
`SCHEME_UNTYPED_VERSION`.
- FedNow and SEPA EndToEndId length checks now operate on typed values and count
Unicode scalar values. Parse or required-field failures report one
`SCHEME_PARSE` finding at `/Document`; independent raw findings are preserved
alongside extraction, namespace-routing, and deserialization failures.
- CLI generic IBAN, BIC, LEI, currency, and date checks use command-private XML
extraction helpers; scheme validation invokes `SchemeValidator::validate`
once.

### Fixed

- `mx20022-cli validate` now reads the complete XML stream before reporting a
valid message type, so malformed or truncated XML without `--scheme` prints
a parse diagnostic and exits non-zero instead of reporting success.
- `FEDNOW_SINGLE_TX` now checks both the declared `NbOfTxs` and the actual
number of `CdtTrfTxInf` elements. A message declaring one transaction while
carrying multiple transactions is rejected on typed and older-version
validation paths.
- Nested `CdtTrfTxInf` elements inside `SplmtryData` no longer terminate the
enclosing transaction during older-version pacs.008 fact extraction, so
subsequent scheme value rules cannot be bypassed.
- `SEPA_SINGLE_TX` now checks both declared and actual transaction counts, and
SEPA restricted-Latin checks again cover all `Nm`, `StrtNm`, and `TwnNm`
fields in addition to `Ustrd` on typed and older-version validation paths.

### Removed

- Removed the public `mx20022_validate::schemes::xml_scan` module and the
scanner-based scheme field-rule path. This is a deliberate breaking change
for 0.4.0.
- On typed `pacs.008.001.13` parse failures, required or invalid generated
fields now consolidate into `SCHEME_PARSE` rather than the scanner-era
`CBPR_CHRGBR_REQUIRED`, `CBPR_CHRGBR_VALUE`, `CBPR_E2E_REQUIRED`, or
`SEPA_CHRGBR_REQUIRED` IDs. The version-neutral path for older `pacs.008`
versions still emits these scheme IDs when applicable.

## [0.3.2] - 2026-08-12

### Changed
Expand Down
24 changes: 13 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
## Context Database — Your Memory
## Agent Project Context

Location: `docs/agent_context.db` (SQLite, schema v1). **Query before reasoning. Write as you learn.**
When `.sddv4/repository.json` exists or the operator invokes SDDv4, use the
installed `sddv4` skill and service-backed project as the sole context
authority. Do not query or create `docs/agent_context.db` except for the
skill's explicit read-only legacy migration.

```sql
-- Bootstrap: run this first, then follow the protocol rows it returns.
SELECT title, content FROM knowledge WHERE category = 'protocol' ORDER BY title;
```
For an unbound repository without an SDDv4 request, do not invent a project or
context store. A status request remains read-only; setup or concrete work may
initialize through the skill.

## Project: mx20022

Expand Down Expand Up @@ -60,9 +62,9 @@ mx20022/
## Key Commands

```bash
# Database queries (use sqlite3)
sqlite3 docs/agent_context.db "SELECT id, title, status FROM items WHERE status = 'in_progress';"

# Find next available work
sqlite3 docs/agent_context.db "SELECT i.id, i.title, i.priority, it.name FROM items i JOIN iterations it ON i.iteration_id = it.id WHERE i.status = 'not_started' AND NOT EXISTS (SELECT 1 FROM item_dependencies d JOIN items dep ON d.depends_on = dep.id WHERE d.item_id = i.id AND dep.status != 'complete') ORDER BY it.id, i.priority, i.id LIMIT 10;"
sddv4 context
cargo fmt --all -- --check
cargo check --workspace --all-features
cargo clippy --workspace --all-features -- -D warnings
cargo test --workspace --all-features
```
15 changes: 8 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 7 additions & 7 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,13 @@ pretty_assertions = "1"
insta = { version = "1", features = ["yaml"] }
criterion = { version = "0.5", features = ["html_reports"] }
# Internal crates
mx20022 = { path = "crates/mx20022", version = "0.3.2" }
mx20022-model = { path = "crates/mx20022-model", version = "0.3.2" }
mx20022-parse = { path = "crates/mx20022-parse", version = "0.3.2" }
mx20022-validate = { path = "crates/mx20022-validate", version = "0.3.2" }
mx20022-translate = { path = "crates/mx20022-translate", version = "0.3.2" }
mx20022-codegen = { path = "crates/mx20022-codegen", version = "0.3.2" }
mx20022-cli = { path = "crates/mx20022-cli", version = "0.3.2" }
mx20022 = { path = "crates/mx20022", version = "0.4.0" }
mx20022-model = { path = "crates/mx20022-model", version = "0.4.0" }
mx20022-parse = { path = "crates/mx20022-parse", version = "0.4.0" }
mx20022-validate = { path = "crates/mx20022-validate", version = "0.4.0" }
mx20022-translate = { path = "crates/mx20022-translate", version = "0.4.0" }
mx20022-codegen = { path = "crates/mx20022-codegen", version = "0.4.0" }
mx20022-cli = { path = "crates/mx20022-cli", version = "0.4.0" }

[workspace.lints.rust]
unsafe_code = "forbid"
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,14 @@ use mx20022::parse::{de, envelope};

let xml = std::fs::read_to_string("message.xml")?;

// Detect the message type from the XML namespace
// Detect on the complete stream so wrapper-inherited namespaces remain in
// scope. The Document namespace still wins over a preceding AppHdr namespace.
let msg_id = envelope::detect_message_type(&xml)?;
println!("Message type: {}", msg_id.dotted()); // e.g. "pacs.008.001.13"

// Deserialize to a strongly-typed struct
let doc: Document = de::from_str(&xml)?;
// Then unwrap the payload Document without copying and deserialize it.
let document_xml = de::document_xml(&xml)?;
let doc: Document = de::from_str(document_xml)?;
let hdr = &doc.fi_to_fi_cstmr_cdt_trf.grp_hdr;
println!("Message ID: {}", hdr.msg_id.0);
println!("Transactions: {}", hdr.nb_of_txs.0);
Expand All @@ -100,6 +102,9 @@ println!("Transactions: {}", hdr.nb_of_txs.0);
use mx20022::validate::schemes::{fednow::FedNowValidator, SchemeValidator};

let validator = FedNowValidator::new(); // $500K default limit
// validate() accepts a bare or transport-wrapped payload Document. For
// pacs.008.001.13 it parses the Document and delegates to typed field rules;
// older pacs.008 versions use the same version-neutral field rules.
let result = validator.validate(&xml, "pacs.008.001.13");

if result.is_valid() {
Expand Down
2 changes: 1 addition & 1 deletion crates/mx20022-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "mx20022-cli"
version = "0.3.2"
version = "0.4.0"
description = "CLI tool for inspecting, validating, and translating ISO 20022 and SWIFT MT financial messages"
edition.workspace = true
rust-version.workspace = true
Expand Down
1 change: 1 addition & 0 deletions crates/mx20022-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub mod codegen;
pub mod inspect;
pub mod translate;
pub mod validate;
mod xml_extract;

/// Maximum file size accepted by any command (10 MB).
pub(crate) const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
Loading
Loading