Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.ntropii.com/llms.txt

Use this file to discover all available pages before exploring further.

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

from decimal import Decimal
from pydantic import BaseModel
from ntro.types.currencies import CurrencyCode
from ntro.types.dates import ForgivingDate

class ExpenseSubmissionFields(BaseModel):
    """Boundary type over the agent-supplied receipt fields."""
    model_config = {"extra": "ignore"}

    vendor: str = ""
    amount: Decimal = Decimal("0")
    currency: CurrencyCode = "GBP"   # strict — "gbp" → "GBP"; "USDD" raises
    date: ForgivingDate = None       # forgiving — bad shape → None

# In your activity:
submission = ExpenseSubmissionFields.model_validate(payload.data)
# submission.date is date | None; submission.currency is guaranteed-valid ISO
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

from ntro.types.dates import ForgivingDate
Strictness: forgiving — coerces to datetime.date | None. On parse failure: silent None.
InputResult
"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)
NoneNone
"" / " "None
"garbage", "13/13/2026", "2026-13-99"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.
from pydantic import BaseModel
from ntro.types.dates import ForgivingDate

class ReceiptFields(BaseModel):
    expense_date: ForgivingDate = None

# All of the following land with expense_date typed as date | None:
ReceiptFields.model_validate({"expense_date": "2026-04-12"})         # ISO
ReceiptFields.model_validate({"expense_date": "12/04/2026"})         # UK locale
ReceiptFields.model_validate({"expense_date": "April 12, 2026"})     # natural language
ReceiptFields.model_validate({"expense_date": "sometime last week"}) # → None
ReceiptFields.model_validate({})                                      # → None

Currencies

CurrencyCode

from ntro.types.currencies import CurrencyCode
Strictness: strict — ISO 4217 three-letter code. On parse failure: raises ValidationError. Lowercase / mixed-case input is normalised to upper.
InputResult
"GBP" / "USD" / "AED""GBP" / "USD" / "AED"
"gbp" (lowercase)"GBP" (normalised)
"Gbp" (mixed case)"GBP"
"" / "GB" / "GBPP"raises
"123" / "G1P"raises
123 (int) / None / non-stringraises
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.
from decimal import Decimal
from pydantic import BaseModel, ValidationError
from ntro.types.currencies import CurrencyCode

class ReceiptFields(BaseModel):
    currency: CurrencyCode = "GBP"
    amount: Decimal = Decimal("0")

ReceiptFields.model_validate({"currency": "gbp", "amount": "12.50"}).currency
# → "GBP"  (normalised to upper)

try:
    ReceiptFields.model_validate({"currency": "USDD", "amount": "12.50"})
except ValidationError as exc:
    # Activity catches this, routes the row to HITL with a needs-attention flag.
    ...
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

from ntro.types.accounting import Period
Strictness: strict — YYYY-MM accounting period with valid month range. On parse failure: raises ValidationError.
InputResult
"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
"" / Noneraises
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.
from pydantic import BaseModel, ValidationError
from ntro.types.accounting import Period

class ExpenseProcessorContext(BaseModel):
    period: Period

ExpenseProcessorContext.model_validate({"period": "2026-05"}).period
# → "2026-05"

try:
    ExpenseProcessorContext.model_validate({"period": "2026-13"})
except ValidationError:
    # Workflow input rejected — agent / scheduler bug surfaces immediately.
    ...

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.