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
Noneon 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.
Pattern
- 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. - HITL fills the gaps for forgiving types. If an agent sent garbage to a forgiving field, the row lands with that field
Noneand the reviewer corrects it during stage 3 review. Crashing the activity for forgiving fields is the wrong behaviour at this layer of the ladder. - Strict fields raise — and that’s the contract. A garbage
CurrencyCodeis “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 theValidationErrorand routes the row to a needs-attention bucket), but the row never reaches PENDING with a silently-wrong currency code. - 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
datetime.date | None. On parse failure: silent None.
| Input | Result |
|---|---|
"2026-04-12" | date(2026, 4, 12) |
"12/04/2026" (UK DD/MM) | date(2026, 4, 12) |
"04/12/2026" (ambiguous, dayfirst=True) | date(2026, 12, 4) |
"April 12, 2026" | date(2026, 4, 12) |
"12 Apr 26" | date(2026, 4, 12) |
datetime.date(2026, 4, 12) | date(2026, 4, 12) (passthrough) |
datetime.datetime(2026, 4, 12, 9, 30) | date(2026, 4, 12) (time stripped) |
None | None |
"" / " " | None |
"garbage", "13/13/2026", "2026-13-99" | None |
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
ValidationError. Lowercase / mixed-case input is normalised to upper.
| Input | Result |
|---|---|
"GBP" / "USD" / "AED" | "GBP" / "USD" / "AED" |
"gbp" (lowercase) | "GBP" (normalised) |
"Gbp" (mixed case) | "GBP" |
"" / "GB" / "GBPP" | raises |
"123" / "G1P" | raises |
123 (int) / None / non-string | raises |
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
YYYY-MM accounting period with valid month range. On parse failure: raises ValidationError.
| Input | Result |
|---|---|
"2026-05" | "2026-05" |
"2026-01" / "2026-12" | passthrough |
"2026-5" (single-digit month) | raises |
"2026-13" / "2026-99" (out-of-range month) | raises |
"05-2026" / "2026/05" (wrong shape) | raises |
"2026-05-01" (full date) | raises |
"" / None | raises |
^\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 undersrc/ntro/types/. The two patterns to mirror:
- Forgiving (like
ForgivingDate):Annotated[T | None, BeforeValidator(...)]whose coercer returnsNoneon parse failure, never raises. - Strict (like
CurrencyCode,Period):Annotated[T, BeforeValidator(...)?, StringConstraints(...) / AfterValidator(...)]that raises on invalid input. Add normalisation (case folding etc.) in aBeforeValidatorif it makes sense.
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.