asx-rs 0.12.0

AS2 and AS4 B2B messaging library for Rust — signing, encryption, MDN, and ebMS3/AS4 profile support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
# Interoperability

## Overview

The `interop` module manages protocol policy behavior through a four-layer profile stack. Each layer narrows or specializes the effective policy for a session without modifying the layers below it.

```
base profile → extension profile → global override → partner overlay
```

---

## Interop Modes

```rust
pub enum InteropMode {
    Strict,   // Default. RFC/spec behavior enforced at every point.
  Relaxed,  // Scoped tolerances for known non-compliant partner edge cases.
}
```

Selected in profile policy:
```rust
let session = SessionContext::new("sess-1", "partner-a", "profile-a")?;
```

`interop-strict` is in the default Cargo feature set. `interop-relaxed` is optional and must be explicitly enabled:
```toml
asx-rs = { version = "0.12", features = ["as2", "interop-relaxed"] }
```

### Interop mode does not govern message security

Neither `InteropMode::Strict` nor the `interop-strict` feature implies a strict
*security* policy. The two axes are independent:

| Axis | Controlled by | Governs |
|---|---|---|
| Interop | `InteropMode` / `interop-strict` | Header parsing, ambiguity tolerance, scoped exception guardrails |
| Security | `SecurityPolicy` / `BaseProfile::security_floor` | Whether signature and encryption are required |

A profile can be `InteropMode::Strict` and still resolve to
`require_encryption: false`. Enforce the security axis with a
[security floor](#security-floor), not by selecting a strict interop mode.

---

## Profile Stack

A profile stack is built by composing layers:

```rust
use asx_rs::core::InteropMode;
use asx_rs::interop::{
  BaseProfile, CanonicalizationPolicy, PartnerProfileOverlay, ProfileExtension,
  ProfilePolicyOverrides, ProfileStack, ProfileOverride, SecurityPolicy, ValidationPolicy,
};

let stack = ProfileStack {
  // `BaseProfile::new(name, version)` fills the rest from `BaseProfile::default()`:
  // strict mode, sign-and-encrypt required, and a matching sign-and-encrypt floor.
  base: BaseProfile::new("strict-edelivery", "2.0"),
  extensions: vec![ProfileExtension {
    name: "peppol-bis".into(),
    overrides: ProfilePolicyOverrides {
      mode: Some(InteropMode::Relaxed),
      ..Default::default()
    },
  }],
  overrides: vec![ProfileOverride {
    name: "deployment-global".into(),
    overrides: ProfilePolicyOverrides {
      mode: Some(InteropMode::Strict),
      ..Default::default()
    },
  }],
  partner_overrides: vec![PartnerProfileOverlay {
    name: "partner-acme".into(),
    partner_id: "partner-acme".into(),
    overrides: ProfilePolicyOverrides::default(),
  }],
};
```

Every field of `BaseProfile` is spelled out below; construct it directly when you
need to depart from the fail-closed defaults:

```rust
let base = BaseProfile {
  name: "strict-edelivery".into(),
  version: "2.0".into(),
  mode: InteropMode::Strict,
  canonicalization: CanonicalizationPolicy::default(),
  security: SecurityPolicy::default(),        // require_signature + require_encryption
  security_floor: SecurityPolicy::default(),  // no layer may go below this
  validation: ValidationPolicy::default(),
  as2_validation: Default::default(),
};
```

Resolution is deterministic: the last applicable partner overlay wins for any given field. `ProfileStack::validate()` checks the resolved policy for every partner before any message processing, and returns structured errors and lint findings.

### Resolution precedence (highest to lowest)

1. Partner overlay (per-partner configuration)
2. Global override (deployment-wide configuration)
3. Extension profile (protocol or regional extension)
4. Base profile (protocol defaults)

`security_floor` is deliberately **not** in this list: it lives on the base
profile only, because it is the invariant the other layers are checked against.

---

## Effective Policy Snapshot

After profile resolution, the effective policy for a session is captured in an `EffectivePolicySnapshot`. This snapshot is the authoritative record of what policy was applied for a given message exchange.

```rust
let resolved = stack.resolve_for_session(&session)?;
let json = resolved.effective_profile.snapshot.to_json_pretty()?;
```

`resolve_for_session` returns a `ResolvedSessionProfile` carrying both the
`effective_profile` and a `session` clone with the snapshot JSON attached.

### Snapshot schema

```json
{
  "session_id": "sess-1",
  "partner_id": "partner-acme",
  "profile_name": "strict-edelivery+peppol-bis",
  "resolved_mode": "Strict",
  "canonicalization": {
    "wssec": { "kind": "Exclusive", "include_comments": false, "strip_blank_text": false },
    "normalize_mime_headers": true
  },
  "security": {
    "require_signature": true,
    "require_encryption": true
  },
  "security_floor": {
    "require_signature": true,
    "require_encryption": true
  },
  "validation": {
    "reject_ambiguous_headers": true,
    "enforce_payload_limits": true
  },
  "as2_validation": {
    "require_mic": true
  },
  "resolution_trace": ["base", "extension:peppol-bis", "partner:partner-acme"]
}
```

Snapshots are round-trippable: `EffectivePolicySnapshot::from_json(&json)?`.

### Protocol-neutral vs AS2-only validation

`ValidationPolicy` carries only knobs that apply to both protocols
(`reject_ambiguous_headers`, `enforce_payload_limits`). AS2-only settings live in
`As2ValidationPolicy`, so an AS4 profile never has to carry — or explicitly
disable — a setting that cannot apply to it:

| Type | Field | Applies to |
|---|---|---|
| `ValidationPolicy` | `reject_ambiguous_headers` | AS2 + AS4 |
| `ValidationPolicy` | `enforce_payload_limits` | AS2 + AS4 |
| `As2ValidationPolicy` | `require_mic` | AS2 only (RFC 4130 §7.3 `Received-Content-MIC`) |

AS4 integrity is carried by the WS-Security XML Signature, not by an AS2 MIC, so
AS4-only profiles can leave `as2_validation` at its default and ignore it.

`as2_validation` is `#[serde(default)]` on both `ProfilePolicyOverrides` and
`EffectivePolicySnapshot`, so JSON written before the split still deserializes; a
stale `require_as2_mic` key inside `validation` is ignored. Override it like any
other layer:

```json
{
  "pack_id": "de-as2",
  "version": "1.0.0",
  "applies_to_base_profile": "base",
  "overrides": { "as2_validation": { "require_mic": false } }
}
```

---

## Profile Diff and Impact Analysis

Compare two profile snapshots to detect security-relevant changes before a release:

```rust
use asx_rs::interop::diff_effective_policy_snapshots;

let report = diff_effective_policy_snapshots(&before_snapshot, &after_snapshot);
println!("{}", report.to_json_pretty()?);
```

`ProfileImpactReport` fields:
- `changes[]` — list of changed fields with `previous_value`, `new_value`, `risk` (`Low`/`Medium`/`High`), and `rationale`.
- `highest_risk` — highest risk across all changes.
- `release_blocked``true` if any change is `High` risk.

**High risk** (blocks release):
- Removing a signature or encryption requirement
- **Lowering `security_floor`** — flagged even when the resolved policy is unchanged, because it is precisely the change that lets a later overlay relax security without validation objecting
- Disabling payload limit enforcement

**Medium risk** (reported, does not block):
- Interop mode changes
- Canonicalization changes
- Raising `security_floor`
- Strengthening the security policy
- AS2 `require_mic` changes

Run via CI gate:
```bash
cargo run -p xtask -- profile-diff-gate before_snapshot.json after_snapshot.json
```
Exits non-zero for high-risk diffs.

---

## Security Floor

`ProfileStack.overrides` and `ProfileStack.partner_overrides` are public `Vec`
fields, so any overlay can rewrite `SecurityPolicy`. `BaseProfile::security_floor`
is the invariant that survives that: **no layer may resolve below it.**

It defaults to `SecurityPolicy::SIGN_AND_ENCRYPT`, which is what PEPPOL, CEF
eDelivery and BDEW AS4-Profil v1.2 §2.2.6.2.2 all mandate. A partner overlay that
keeps signing but turns encryption off is therefore rejected:

```rust
stack.partner_overrides.push(PartnerProfileOverlay {
  name: "legacy-partner".into(),
  partner_id: "9900000000001".into(),
  overrides: ProfilePolicyOverrides {
    security: Some(SecurityPolicy::SIGN_ONLY),
    ..Default::default()
  },
});

let failure = stack.validate().unwrap_err();
assert!(failure.has_code(ProfileValidationCode::SecurityFloorViolation));
assert_eq!(failure.affected_partners(), vec!["9900000000001"]);
```

`SecurityPolicy` is a two-element lattice with named constants and ordering
helpers:

| Item | Meaning |
|---|---|
| `SecurityPolicy::SIGN_AND_ENCRYPT` | Both required — the default, and the default floor |
| `SecurityPolicy::SIGN_ONLY` / `ENCRYPT_ONLY` | One required |
| `SecurityPolicy::UNCONSTRAINED` | Neither required; valid as a *floor*, rejected as an effective policy |
| `a.satisfies(floor)` | `a` is at least as strong as `floor` |
| `floor.unmet_by(a)` | Which requirements `a` is missing, as `Vec<SecurityRequirement>` |
| `a.strengthen(b)` / `a.weaken(b)` | Lattice join / meet |

### Enforcing a floor the profile does not declare

A host with its own mandate should not have to trust the profile's floor.
`validate_with_floor` combines the two by taking the stronger of each
requirement, so it can only tighten validation:

```rust
// Fails if any partner resolves below sign-and-encrypt, regardless of
// what BaseProfile::security_floor says.
stack.validate_with_floor(SecurityPolicy::SIGN_AND_ENCRYPT)?;
```

For full control, use `validate_with`:

```rust
use asx_rs::interop::ProfileValidationOptions;

stack.validate_with(
  &ProfileValidationOptions::default()
    .with_security_floor(SecurityPolicy::SIGN_AND_ENCRYPT)
    // Any overlay that weakens security fails, even above the floor.
    .forbidding_security_relaxation(),
)?;
```

---

## Profile Validation and Linting

`ProfileStack::validate()` runs before any message processing. It resolves the
stack **once per scope** — the deployment baseline, then a fork of it for every
partner in `partner_overrides` — so one partner's overlay never contaminates
another's findings, and every finding names the scope it came from.

```rust
match stack.validate() {
    Ok(report) => {
        for lint in &report.lints {
            eprintln!("{lint}");   // [info/dead_override] override:x: … — hint: …
        }
    }
    Err(failure) => {
        // Multi-line operator rendering, every error and lint.
        eprintln!("{}", failure.report());
    }
}
```

### Errors (block validation)

| Code | Raised when |
|---|---|
| `SecurityFloorViolation` | A scope's **resolved** policy is below the effective floor |
| `NoCriticalSecurityInvariant` | A scope resolves with neither signature nor encryption (only reachable when the floor is permissive) |
| `SecurityRelaxation` | A layer weakened security while `forbid_security_relaxation` was set |

Errors are raised against the *resolved* policy per scope, not per layer: a dip
that a later overlay restores has no runtime effect and is not an error.

### Lints (never block; carry a severity)

| Code | Severity | Raised when |
|---|---|---|
| `SecurityRelaxation` | `Critical` | Any layer drops a requirement a lower layer had enabled — including a dip that is later restored |
| `DeadOverride` | `Info` | A layer sets a field to the value already in effect |

```rust
let report = stack.validate()?;
if report.highest_lint_severity() >= Some(ProfileLintSeverity::Critical) {
    for lint in report.lints_at_least(ProfileLintSeverity::Critical) {
        eprintln!("blocking: {lint}");
    }
}
```

### Error handling

`ProfileValidationFailure` implements `Display` and `std::error::Error`, so it
composes with `thiserror`, `anyhow`, and `Box<dyn Error>`:

```rust
#[derive(Debug, thiserror::Error)]
pub enum StartupError {
    #[error(transparent)]
    Profile(#[from] asx_rs::interop::ProfileValidationFailure),
}
```

`?` also works directly in any function returning the crate's `Result`, via
`From<ProfileValidationFailure> for AsxError` (`ErrorCode::PolicyViolation`).

| Rendering | Shape | Use for |
|---|---|---|
| `Display` / `to_string()` | One line, first 3 errors, then `(+N more)` | Error chains, log fields |
| `report()` | Multi-line, every error and lint with hints | Startup failure logs |
| `Debug` | Struct dump | Test assertions only |

Programmatic access stays available: `errors`, `lints`, `first_error()`,
`has_code(code)`, and `affected_partners()`.

Validation is enforced as a hard release gate (`scripts/check_profile_coverage.sh`).

---

## Partner Profile Overlays

Partner overlays apply per-partner policy specializations on top of the global profile without code changes:

```rust
use asx_rs::interop::PartnerProfileOverlay;

let overlay = PartnerProfileOverlay {
  name: "partner-acme-overlay".into(),
  partner_id: "partner-acme".into(),
  overrides: ProfilePolicyOverrides {
    mode: Some(InteropMode::Relaxed),
    ..Default::default()
  },
};
```

Overlays are composable: multiple overlays for the same partner are merged in declaration order, with later entries winning.

### Auditing overlays without a session

`ProfileStack::resolve(&SessionContext)` needs a live session, which startup
validation does not have. The session-free counterparts answer "what will
partner X actually get?" before the first message:

| Method | Returns |
|---|---|
| `resolve_baseline()` | `ResolvedPolicyView` for base + extensions + global overrides |
| `resolve_partner(id)` | `ResolvedPolicyView` for one partner |
| `resolve_all_partners()` | Baseline first, then one view per distinct partner |
| `partner_ids()` | Distinct partner ids carrying an overlay, in declaration order |

All of them go through the same resolution path as `resolve()`, so a
session-free view can never drift from what a live session receives.

```rust
let weak: Vec<_> = stack
    .resolve_all_partners()
    .into_iter()
    .filter(|view| !view.satisfies(SecurityPolicy::SIGN_AND_ENCRYPT))
    .map(|view| view.scope_label().to_string())  // "<baseline>" or the partner id
    .collect();
```

Prefer `validate_with_floor` when the assertion is a plain security floor; use
`resolve_all_partners()` when it is over other policy fields.

---

## Regional Profile Packs

Regional profile packs provide data-driven policy overlays loadable from JSON without code changes. They are designed for EU eDelivery network variants (Peppol, CEF, ENTSOG, BDEW) and other regional specifications.

```rust
use asx_rs::interop::RegionalProfilePack;

let pack = RegionalProfilePack::from_json(json_str)?;
let stack = base_stack.apply_regional_pack(&pack)?;   // or apply_regional_packs(&[…])
```

A pack is applied as an additional extension layer, so it sits above the base
profile and below global and partner overrides. A pack whose
`applies_to_base_profile` does not match the active base is rejected with
`ErrorCode::PolicyViolation`.

A regional pack can define override values for `mode`, `canonicalization`,
`security`, `validation`, and `as2_validation`. It cannot raise or lower
`security_floor` — the floor is base-only, so a data-driven pack can never
loosen the invariant a pack-applying deployment relies on.

Packs are validated on load: the JSON is capped at
`RegionalProfilePack::MAX_PACK_JSON_BYTES` (512 KiB), `pack_id` and
`applies_to_base_profile` must be non-empty, and `version` must be semver-like
`x.y.z`.

---

## Interop Exception Policies

Exception policies define a bounded allow-list for known non-compliant partner behaviors:

```rust
use asx_rs::interop::{InteropExceptionCode, InteropExceptionPolicy};

let exceptions = InteropExceptionPolicy::scoped(
  "profile-a",
  vec![InteropExceptionCode::As2AllowMissingMdnBoundary],
);
```

Each allowed exception emits an `AuditEvent` with a reason code so that exception usage is traceable. Exceptions are partner-scoped and do not affect sessions with other partners.

---

## WS-Security Strict Behavior

WS-Security verification is strict-only in runtime APIs.

- Canonicalization and reference verification use strict defaults only.
- `InteropMode::Relaxed` controls scoped interop exception guardrails only.
- Relaxed mode does not enable WS-Security canonicalization/profile fallback behavior.

---

## Bounded Streaming and Wire Limits

`wire::StreamLimits` is the central configuration point for all I/O bounds:

```rust
pub struct StreamLimits {
  pub max_body_bytes: usize, // Default: 256 MiB
  pub chunk_bytes: usize,    // Default: 64 KiB
}
```

`read_bounded_stream_into_memory_async`, `read_bounded_stream_into_handle_async`, and `copy_bounded_stream_async` all enforce `max_body_bytes` and return `Err` if the limit is exceeded.