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.

The platform ships two subledger types — expenses and journal_proposals — that any runbook can reuse. Most runbooks need more: a rent-roll line, a capital-call schedule, a fixed-asset depreciation entry. These are runbook-owned types. A runbook-owned subledger:
  • Lives inside the runbook bundle (runbooks/<name>/subledgers/<type>.py).
  • Ships its own migration (runbooks/<name>/migrations/NNN_subledger_<type>.sql).
  • Registers with @register_type("<name>", owner_runbook="<runbook-slug>") so the platform registry can disambiguate types across hot-reloads and across runbooks that use the same logical name.

Platform vs runbook-owned

AspectPlatform type (expenses, journal_proposals)Runbook-owned type (rental_statement, custom)
Code locationntro.subledger.types.<name>runbooks/<runbook>/subledgers/<name>.py
Migrationntro-infra/tenant-postgres/migrations/runbooks/<runbook>/migrations/
Registration@register_type("<name>")@register_type("<name>", owner_runbook="<slug>")
Reuse across runbooksDesigned for cross-runbook reuseLocal to the runbook that owns it
Schema evolutionPlatform release cycleRunbook release cycle
owner_runbook= is the boundary marker — it tells the registry “this type belongs to that runbook” so the same logical name can carry different shapes in different bundles without conflicting, and so hot-reloading the runbook re-registers cleanly.

Worked example — RentalStatementRow

From runbooks/nav-monthly-journals/subledgers/rental_statement.py:
from datetime import date
from decimal import Decimal
from uuid import UUID

from pydantic import Field, model_validator

from ntro.subledger.registry import register_type
from ntro.subledger.row import Row
from ntro.subledger.status import SubledgerStatus


@register_type("rental_statement", owner_runbook="nav-monthly-journals")
class RentalStatementRow(Row):
    unit: str
    tenant_name: str
    monthly_rent: Decimal = Field(gt=0)
    rent_received: Decimal = Field(default=Decimal("0"))
    arrears_30d: Decimal = Field(default=Decimal("0"))
    arrears_60d: Decimal = Field(default=Decimal("0"))
    arrears_90d_plus: Decimal = Field(default=Decimal("0"))
    deductions_total: Decimal = Field(default=Decimal("0"))
    management_fee_gross: Decimal = Field(default=Decimal("0"))
    repairs_gross: Decimal = Field(default=Decimal("0"))
    net_cash_received: Decimal = Field(default=Decimal("0"))
    lease_start: date | None = None
    lease_end: date | None = None
    vacant: bool = False
    confidence: float | None = Field(default=None, ge=0.0, le=1.0)
    # GL-handoff fields — opt-in. Mirror the platform types so
    # mark_posted-style updates work if this type later posts directly.
    posted_to_gl: bool = False
    posted_journal_ref: str | None = None

    @model_validator(mode="after")
    def _non_negative_money_fields(self) -> "RentalStatementRow":
        for key in (
            "rent_received", "arrears_30d", "arrears_60d", "arrears_90d_plus",
            "deductions_total", "management_fee_gross", "repairs_gross",
            "net_cash_received",
        ):
            if getattr(self, key) < 0:
                raise ValueError(f"{key} cannot be negative")
        return self
Things to notice:
  • Subclasses Row — gets the standard column block for free (id, entity_id, period, task_id, status, source_ref, validation_errors, raw_payload, timestamps).
  • Domain validators live on the row class — same Pydantic model_validator pattern as ExpenseRow / JournalProposalRow.
  • No propose_for_gl — this type stages period-scoped statement facts; it’s read by nav-monthly-journals’s journal proposer and never posts to the GL directly. GL handoff is opt-in per type.

Migration

The matching migration ships in the runbook bundle, not the platform schema. It applies on tenant Postgres alongside the platform migrations and creates the ledgers.<type> table:
-- runbooks/nav-monthly-journals/migrations/001_subledger_rental_statement.sql

CREATE TABLE IF NOT EXISTS ledgers.rental_statement (
    id              UUID PRIMARY KEY,
    entity_id       UUID NOT NULL REFERENCES system.entities(id),
    period          TEXT NOT NULL,
    task_id         UUID NOT NULL,
    status          TEXT NOT NULL,
    source_ref      TEXT,
    validation_errors  JSONB,
    raw_payload        JSONB,
    -- Type-specific columns:
    unit            TEXT NOT NULL,
    tenant_name     TEXT NOT NULL,
    monthly_rent    NUMERIC NOT NULL,
    rent_received   NUMERIC DEFAULT 0,
    -- … one column per Row field …
    posted_to_gl    BOOLEAN DEFAULT FALSE,
    posted_journal_ref TEXT,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW()
);
The runbook deploy step picks up runbooks/<slug>/migrations/*.sql and runs them against the tenant database in filename order. Keep the standard column names exact — the Row base relies on them for SubledgerHandle.insert / query / status mutations.

Using the handle

Once registered + migrated, the type is reachable via the same subledger.open(...) flow as platform types:
from uuid import UUID
from ntro.subledger import open as subledger_open

# RentalStatementRow has been imported (registration runs at import).
handle = subledger_open(
    name="rental_statement",
    entity_id=ctx.entity_id,
    task_id=ctx.task_id,
    tenant_slug=ctx.tenant_slug,
)

await handle.insert(RentalStatementRow(
    entity_id=ctx.entity_id,
    period=ctx.period,
    task_id=ctx.task_id,
    unit="Flat 4A",
    tenant_name="Tenant Co Ltd",
    monthly_rent=Decimal("2400.00"),
    rent_received=Decimal("2400.00"),
))

rows = await handle.query(period=ctx.period)
The handle reads the type from the import-time registry, so the runbook’s subledgers/__init__.py must import rental_statement (or be discovered by the runbook loader) for subledger_open(name="rental_statement", …) to work.

GL handoff (opt-in)

Add a propose_for_gl classmethod when your type does post to the external GL — same shape as ExpenseRow.propose_for_gl or JournalProposalRow.propose_for_gl. Most pure staging types (rent rolls, raw statements) skip it — they’re read by a downstream proposer that produces the actual journal.

When to use the status enum override

The default SubledgerStatus lifecycle (NEEDS_ATTENTION → PENDING → APPROVED → POSTED/REJECTED/EXCLUDED) covers most types. Override only when your type has fundamentally different states — e.g. a capital_calls type with AWAITING_BANK / PAID / DEFAULTED. Override via Pydantic field annotation on your Row subclass; the column name stays status, only the enum type changes.

Subledgers overview

Row base, standard column block, SubledgerStatus lifecycle.

expenses

Platform reference type — full propose_for_gl example.

journal_proposals

Platform reference type — multi-row aggregation pattern.

UI and Temporal signals

HITL row mutations against custom types via workflow signals.