> ## 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.

# Boundary types

> Pydantic Annotated types that constrain fuzzy agent input into typed Ntropii domain values at the runbook boundary.

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`](https://docs.pydantic.dev/latest/concepts/types/#annotated-types) 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](/get-started/tenant-architecture#data-lifecycle-from-agent-input-to-gl). 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

```python theme={null}
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`

```python theme={null}
from ntro.types.dates import ForgivingDate
```

**Strictness:** forgiving — coerces to `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`                              |

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.

```python theme={null}
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`

```python theme={null}
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.

| 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.

```python theme={null}
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`

```python theme={null}
from ntro.types.accounting import Period
```

**Strictness:** strict — `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      |

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.

```python theme={null}
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.
