Skip to main content
Runbooks live downstream of agents. Agents send fuzzy strings — dates in three different formats, currency amounts as strings, partial dicts with missing fields. Without a boundary contract, every runbook re-invents the same coercion in activity bodies — try/except cascades around datetime.strptime, Decimal(str(...)), .get() chains. That’s a guaranteed drift surface. ntro.types solves it once. Each type is a Pydantic Annotated over a base Python type plus a validator. Declare a Pydantic model with these types as fields, model_validate the agent payload in your activity, and read typed values directly. These are the stage-2 boundary types of the data lifecycle. Two flavours sit alongside each other:
  • Forgiving — fuzzy input degrades to None on parse failure; HITL review at stage 3 fills the gap. Right when input arrives in many formats and “approximated” makes sense (most dates, most fuzzy strings).
  • Strict — categorical values that can’t degrade gracefully. A typo means “wrong” not “missing”, so coercion failures raise ValidationError. Right for ISO codes, periods, jurisdiction tags, and other categorical concepts.
Each catalogue entry below names its On parse failure behaviour explicitly. “Boundary type” doesn’t imply uniform leniency — pick strict or forgiving per type, on the merits of the domain.

Pattern

Three things follow from this pattern:
  1. No coercion code in activities. datetime.strptime, Decimal(str(...)), currency .upper() — that work has a single home, the boundary type. If it’s in an activity body, that’s a smell.
  2. HITL fills the gaps for forgiving types. If an agent sent garbage to a forgiving field, the row lands with that field None and the reviewer corrects it during stage 3 review. Crashing the activity for forgiving fields is the wrong behaviour at this layer of the ladder.
  3. Strict fields raise — and that’s the contract. A garbage CurrencyCode is “the agent extracted the wrong field”, not “the data was approximate”. Raising surfaces the row for explicit correction; HITL still gets to fix it (the activity catches the ValidationError and routes the row to a needs-attention bucket), but the row never reaches PENDING with a silently-wrong currency code.
  4. The same boundary type works in tests. Unit-test your activities by feeding ExpenseSubmissionFields.model_validate({...}) directly — no need to mock the data plane to exercise the input contract.

Catalogue

Dates

ForgivingDate

Strictness: forgiving — coerces to datetime.date | None. On parse failure: silent None. ISO strings are parsed with date.fromisoformat first so dayfirst=True doesn’t flip YYYY-MM-DD into YYYY-DD-MM. Locale-tolerant strings fall through to dateutil.parser.parse(..., dayfirst=True) — UK default. When a non-UK-domiciled tenant lands the parser will read entity.config.locale and thread it through; today the default is hard-wired.

Currencies

CurrencyCode

Strictness: strict — ISO 4217 three-letter code. On parse failure: raises ValidationError. Lowercase / mixed-case input is normalised to upper. CurrencyCode is strict because currency codes are categorical — a typo means “wrong currency”, not “approximate currency”. Letting a bad code reach storage means downstream renderers and reports silently mis-format the row’s amounts. Better to catch it at row-construction time and route the row to HITL for explicit correction.
Money / MonetaryAmount (composite Decimal + CurrencyCode) is intentionally not shipped yet. Most rows carry one currency code shared across multiple amount fields (amount_gross + vat_amount + net_amount); a composite would just repeat the currency three times per row. The composite type lands when a multi-currency runbook (FX desk, multi-currency settlement) genuinely needs per-cell currencies.

Accounting

Period

Strictness: strict — YYYY-MM accounting period with valid month range. On parse failure: raises ValidationError. Validation layers a ^\d{4}-\d{2}$ regex (canonical shape, zero-padded month) on top of datetime.strptime("%Y-%m") (real-month range). Both are needed: strptime alone accepts "2026-5"; the regex alone accepts "2026-13". Period lives on every subledger row’s period field via the Row base, so every subledger type gets the validation for free. Runbook contexts (ExpenseProcessorContext.period, etc.) carry it too — invalid month gets caught at workflow-input validation, before any activity runs.

Adding a new boundary type

Pick the domain (dates, currencies, accounting, …) and add a module under src/ntro/types/. The two patterns to mirror:
  • Forgiving (like ForgivingDate): Annotated[T | None, BeforeValidator(...)] whose coercer returns None on parse failure, never raises.
  • Strict (like CurrencyCode, Period): Annotated[T, BeforeValidator(...)?, StringConstraints(...) / AfterValidator(...)] that raises on invalid input. Add normalisation (case folding etc.) in a BeforeValidator if it makes sense.
Re-export from ntro/types/__init__.py. Add a unit test covering the accepted-input matrix and either the silent-None-on-failure or the raises-ValidationError contract. Drop an entry in this catalogue with the matrix, the Strictness + On parse failure tag, and a worked example. The first occurrence of a domain in a real runbook drives the design — don’t pre-build empty boundary types in case they’re needed later. Same rule for siblings of an existing type (ForgivingMoney, MonetaryAmount, VATRate): wait for the runbook.