UW Protocol — v1
Status: Accepted normative errata (RFC 0062; unreleased) — protocol 2.17.1 · Format pairing: authors format 2.0 (UW_FORMAT_SPEC_v2.md) and reads the whole 1.x line (UW_FORMAT_SPEC_v1.md) · License: MIT
This document specifies the contract that any conforming viewer, editor, calc host, or agent host must satisfy in order to interoperate with UW Markdown files.
The format spec defines what bytes are allowed on disk. The protocol spec defines what implementations must do with them.
The reference TypeScript implementation lives in packages/uwmd-core/. Where this document references a code symbol (e.g. BUILTIN_REMEDIATIONS, BANCROFT_LAYERS, WRITE_UW_SECTION_TOOL), the symbol is exported from @uwmd/core and is the single source of truth — this document describes what those exports mean.
0. Front matter
0.1 Conformance language
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
0.2 Abstract
The UW Protocol defines four conformance tiers. Each tier is a strict superset of the previous: a Tier-N implementation MUST satisfy all requirements of tiers 1..N.
| Tier | Name | Capability |
|---|---|---|
| 1 | Reader | Parse + display, read-only |
| 2 | Editor | Round-trip writes, supersede semantics |
| 3 | Calc Host | Evaluate custom_calculations and module calculations |
| 4 | Agent Host | Run AI agent layers and apply structured edits |
0.3 Semantic versioning
Three independent semvers are tracked:
- Format version (
uw_versionin frontmatter, currently2.0for authoring;1.0and1.1are still read — seeSUPPORTED_FORMAT_VERSIONS) — the bytes-on-disk schema. Bumped on any breaking format change. - Protocol version (this document, currently
2.17.1) — the contract for implementations. Bumped on any normative change to required behavior. - Reference library version (
@uwmd/core'spackage.json) — the implementation. Independent semver.
An implementation declares the highest format and protocol versions it supports via its ImplementationManifest (§I.4).
I. Scope and Conformance
I.1 What this document specifies
- The four conformance tiers.
- Display conventions implementers MUST follow when rendering values to humans.
- View models implementers SHOULD use as the default per-section layout.
- Edit semantics — what counts as a replace, what requires supersede, who is allowed to write what.
- The expression language and evaluation contract for
custom_calculations. - The contract for hosting Bancroft-style AI agents.
- The module manifest schema.
- The protocol error taxonomy.
- Representation discovery, fidelity classes, and media negotiation.
- The forward-compatibility rules for unknown frontmatter fields, unknown sections, and unknown extensions.
I.2 What this document does not specify
- The bytes-on-disk format. See
UW_FORMAT_SPEC_v1.md. - UI design beyond display conventions. Implementations MAY render any way they wish provided values resolve to the same display strings.
- Deployment-specific network server behavior. Optional HTTP and MCP companion profiles carry registered representations, but no conformance tier requires a network transport.
- Persistence. How implementations cache, version, or back up the file is out of scope.
I.3 Locale
V1 freezes locale to en-US for all numeric and date formatting. Implementations MUST NOT vary number formatting by user locale in v1. V2 will introduce a locale negotiation mechanism; the type SupportedLocale exists in protocol.ts as a v2 hook.
I.4 Self-declaration
Every conforming implementation SHOULD expose an ImplementationManifest that documents its tier, capabilities, representations, supported asset classes, protocol version, and format version. Hosts that load .uwx.md files from third parties MAY use the manifest to refuse files that exceed their declared format version.
Normative schema: spec/schemas/implementation-manifest.schema.json. The TypeScript interface in @uwmd/core/protocol.ts is a mirror — implementations in non-TS languages SHOULD validate against the JSON Schema.
I.5 Representation discovery and negotiation
Protocol 1.2 adds the optional ImplementationManifest.representations array. Each entry is a RepresentationCapability with:
- stable
id; - one or more
media_typesandfile_extensions; - supported
directions(read,write, or both); fidelity(source,model, orview);- an independently versioned
representation_version; and - for views, a required
viewidentifier.
source fidelity can preserve an authoring byte stream, model fidelity round-trips a semantically equivalent UW Document Envelope, and view fidelity is intentionally lossy. A view MUST NOT advertise model fidelity.
HTTP-style output negotiation uses ordinary Accept media ranges and quality values. Exact types are more specific than type/*, which is more specific than */*; structured suffixes do not create an implicit wildcard. A caller may also require a direction and minimum fidelity. No acceptable representation produces REPRESENTATION_NOT_ACCEPTABLE (HTTP binding status 406). An unsupported input Content-Type produces REPRESENTATION_UNSUPPORTED_MEDIA_TYPE (HTTP binding status 415).
The normative manifest shape is in implementation-manifest.schema.json. The reference functions are negotiateRepresentation and resolveInputRepresentation in @uwmd/core.
II. Capability Tiers
II.1 Tier 1 — Reader
A Tier-1 Reader MUST:
- Parse all required frontmatter fields (
uw_version,deal_id,deal_name,created,last_modified,property_address,city,state,zip,asset_class). - Recognize all 21 standard section IDs from the format spec §4.
- Treat unknown frontmatter keys, unknown sections, and unknown
x_*extension blocks as informative: parse and surface them, but do not error. - Resolve supersede semantics: when multiple blocks share a
section_id, the most recent non-superseded block is canonical (§V.2). - Apply the display conventions in Part III when surfacing any value to a human user.
- If — and only if — it claims the
validatecapability, surface validation issues using the remediation copy fromBUILTIN_REMEDIATIONS, and emit only codes whose family is registered in §III.6a. A Tier-1 Reader that does not claimvalidateowes no validator codes.
A Tier-1 Reader MUST NOT silently mutate any input bytes.
Parsing and validating are separate capabilities (parse, validate), and requirement 6 previously read as owing every family to any reader — including INT-NN, which needs a hash chain, and POL-NN, which needs an edit engine. A read-only reader could not satisfy it.
II.2 Tier 2 — Editor
A Tier-2 Editor MUST satisfy all Tier-1 requirements, and additionally:
- Round-trip preservation: bytes outside the modified region of the file MUST be preserved, modulo line-ending normalization.
- Honor
BUILTIN_EDIT_POLICIES(§V.3) — supersede vs replace. - Update
_metaon every write per §V.4 (newversion, newtimestamp, correctactorandsource). - Update
frontmatter.last_modifiedon every write. - Reject any
EditOperationwhosemeta.sourceis not permitted by policy with aProtocolErrorof categoryedit.
II.3 Tier 3 — Calc Host
A Tier-3 Calc Host MUST satisfy all Tier-2 requirements, and additionally:
- Parse the safe-expression grammar in Part VIII exactly as specified.
- Implement the built-in function set (§VIII.3) with the listed signatures and semantics.
- Be deterministic: same
CalcEvaluationContextMUST produce the sameCalcResult. - Refuse expressions containing constructs outside the grammar (function definitions, assignments, lambdas, control flow keywords, property access on disallowed targets) with a
ProtocolErrorof categorycalc.
II.4 Tier 4 — Agent Host
A Tier-4 Agent Host MUST satisfy all Tier-3 requirements, and additionally:
- Honor the
BANCROFT_LAYERSdependency graph: layer L depends on inputs from earlier layers; the host MUST NOT run L until its dependencies are satisfied (§IX.1). - Validate every agent tool call against
WRITE_UW_SECTION_TOOLorWRITE_MULTIPLE_SECTIONS_TOOLbefore applying. - Strip any
_metaand_notesthe agent included insidesection_dataand substitute the canonical_metaconstructed by the host (§IX.4). - Append a
pipeline_logentry for every successful agent invocation.
II.5 Tier composition
An implementation MAY claim partial conformance — e.g. "Tier 1 + edit on frontmatter only". Such partial claims are expressed via ViewerCapability flags rather than tier number.
Tiers are cumulative: a Tier-3 Calc Host satisfies all of Tier 2, which satisfies all of Tier 1. An implementation with a complete calc engine and no edit engine therefore cannot claim Tier 3, and claiming Tier 1 would discard a true statement about most of what it does. That combination is exactly what capability flags exist to express, and an implementation in that position SHOULD publish capabilities and omit a tier claim rather than round down.
A partial claim is checkable, not merely declarative: conformance cases carry the capabilities they require (§II.6a.5), so a claim can be run rather than believed.
II.6 Self-certification
To self-certify, run every conformance case whose required capabilities (§II.6a.5) are a subset of the capabilities the implementation claims, and verify the output matches. A self-certification claim MUST be published together with the capability list it was run against, and with the number of cases skipped; "passes the corpus" without that list does not identify what was run. Tier 4 uses shape assertions due to LLM nondeterminism (§IX.6).
Directory membership is not a normative signal. A fixture group under conformance/tier-N-*/ is not thereby required of a Tier-N implementation — conformance/tier-3-calc-host/refinement/ requires the refinement capability, which no tier requires (§II.3 lists four requirements and refinement is not among them).
An implementation in any language can be driven by the shared conformance driver instead of writing its own, by exposing the CLI protocol in §II.6a. That is strongly RECOMMENDED: two independent re-implementations of a test runner drift, and when they do, "passes the conformance corpus" stops meaning the same thing for two implementations that both say it.
II.6a Conformance CLI protocol (RFC 0004)
Self-certification (§II.6) is a claim. This section is how an implementation makes that claim checkable by somebody else, in any language, without writing its own test runner.
An implementation that wants to be driven by the shared conformance driver exposes a command-line binary supporting the subcommands below. The driver shells out to it; nothing about the implementation's language, runtime, or packaging is observable across that boundary.
II.6a.1 Subcommands
| Invocation | stdout |
|---|---|
<bin> manifest | The implementation's ImplementationManifest. |
<bin> parse <file> | The parsed file, keyed as ParsedUWFile. |
<bin> validate <file> --json | A ValidationResult. |
<bin> render <file> --format <chat|summary|json|csv> | The rendered body, as text. |
<bin> edit <file> <operation.json> --json | { ok, content?, error? }. |
<bin> calc <file> <calc.json> --json | A CalcResult, or an array of them for an array input. |
edit --json MUST NOT write the edited file. A driver that rewrote fixtures as a side effect of reading them would corrupt the corpus it is testing.
render emits the body rather than a JSON envelope. It is the one subcommand whose natural output is text, and wrapping it would gain nothing a driver can use.
II.6a.2 Stream and exit-code contract
- stdout carries exactly one JSON document (or, for
render, the rendered text), optionally followed by a trailing newline. Nothing else. A log line, a deprecation notice, or a progress bar on stdout makes the response unparseable, and a driver MUST report that as its own distinct failure rather than as a wrong answer. - stderr is free for diagnostics and is ignored by the driver. Warnings belong here.
- Exit
0— the operation succeeded. - Exit
1— the operation failed in a way the protocol describes: a validation error, a refused edit, a calc that could not evaluate. stdout is still a parseable response. - Exit
2— unrecoverable internal error. stdout is not required to be anything.
Implementations SHOULD emit UTF-8 regardless of platform locale.
II.6a.3 What the driver may assume about output
The driver compares a response against a frozen baseline. Baselines are projections, not transcripts: a calc baseline names four fields, and a conforming implementation reporting round_to and display as well is more informative, not wrong. So comparison is a subset test by default — every field the baseline names must be present and equal, and extra fields are permitted.
Arrays are exempt from that leniency and compare length-sensitively. An implementation that omits one validation issue has not been concise; it has disagreed.
Values that differ legitimately between two correct runs — last_modified, _meta.timestamp, the fence ts=, and content_hash, which is computed over a timestamp — are masked before comparison. A baseline that pinned any of them would be asserting the clock. parent_hash is not masked: it is stamped from the prior head's content_hash, which the fixture fixes.
II.6a.4 Scope
This protocol covers the tier fixtures — the conformance surface a tier claim is about. The named suites (receipts, composition, market data, packages, signing, and the rest) exercise behavior that is not a single command with a single output, and remain driven by an implementation's own test suite.
An implementation that passes the tier cases under the shared driver has demonstrated its tier claim against the same corpus, executed by the same logic, as every other implementation. That is the whole proposition: without it, each adopter reimplements the runner, and two reimplementations of a runner drift.
The reference driver ships at conformance/runner/.
II.6a.5 Required capabilities and skips
Each conformance case MAY declare requires_capabilities, an array of ViewerCapability values. A case with no such key is required of every implementation.
An implementation driven by the conformance driver MUST populate capabilities in its manifest output. An absent or empty list MUST be treated as claiming every capability: a missing declaration is not a licence to skip, and forgetting the field has to fail closed against the claimant rather than exempt it from the corpus.
A driver MUST report a case whose required capabilities are not all claimed as skipped, and MUST NOT count it as passed. The skip count and the capabilities that caused it are part of the result: a report stating only a pass count does not distinguish an implementation that ran the corpus from one that declined it.
A driver SHOULD offer a mode that treats any skip as a failure. The reference implementation claims every capability, so it runs that way in CI — otherwise a case tagged with a capability nothing claims stops being executed and nothing says so.
Example:
{
"id": "tier-2/section-supersede-risk-rating",
"tier": "2",
"requires_capabilities": ["edit-supersede"],
"command": "edit",
"args": ["before.uw.md", "operation.json", "--json"]
}II.6a.6 The parse conformance projection
uwmd parse --json output is compared against a baseline as a subset: every key the baseline names MUST be present and equal, and an implementation MAY emit more. The baseline therefore states the minimum a conforming reader surfaces, not the shape of its model.
{
"frontmatter": { /* every frontmatter key, verbatim */ },
"sections": {
"<section_id>": { "meta": { /* _meta */ }, "content": { /* block content */ } }
},
"superseded": { "<section_id>": [ /* same shape, document order */ ] },
"pipeline_log": [], "custom_calculations": [], "custom_scenarios": [],
"extensions": {}, "prose": {}
}A multi-variant section (§2.8) nests one such object per variant key.
_meta carries only the keys the document carried. A parser that fills every optional field with a default MUST NOT project the filled value: absent and explicit-null are distinguishable in the source, and a baseline that demanded "agent_id": null from a document never mentioning agent_id would assert a distinction the source does not make.
Not part of the projection, and MUST NOT be required of any implementation: fence-annotation echoes recoverable from _meta, source line numbers, per-block prose recoverable from the document, and the _notes fence member (a sibling of _meta, not block content — a baseline that kept it inside content on flat fences while excluding it on wrapped ones was requiring the same artifact inconsistently). These are artifacts of a particular reader. prose therefore appears in a baseline as {}: an implementation MAY populate it, and a baseline MUST NOT demand entries.
This section exists because the requirement it replaces lived in a corpus README and made @uwmd/core's in-memory ParsedUWFile a protocol surface by accident. §II.1 requires a reader to surface structured data; it does not prescribe a shape, and a conformance corpus is not the place to add one silently.
III. Display Conventions
III.1 Number formatting
Normative schema for remediation entries:spec/schemas/issue-remediation.schema.json (used by §III.6). Every entry in BUILTIN_REMEDIATIONS and any module remediation table MUST validate against it.
All numeric display strings MUST be produced by rules equivalent to those encoded in DEFAULT_NUMBER_FORMAT (protocol.ts) and implemented in format.ts.
| Kind | Default | Example input | Example output |
|---|---|---|---|
| Currency | $1,234,567 (no fractional digits, en-US separators) | 1234567 | $1,234,567 |
| Percent | 5.51% (decimal × 100, 2 decimals) | 0.0551 | 5.51% |
| Ratio | 1.234x (3 decimals, x suffix) | 1.234567 | 1.235x |
| Count | 50 (no thousands separator by default in CSV; with separator in display) | 50 | 50 |
| Null | n/a | null | n/a |
Implementations MAY override per-call to add fractional digits or suppress suffixes (the second-arg options in format.ts), but the default presentation MUST match the table above.
The table above is the en-US row of the per-locale registry; see §III.1a for the other registered locales.
III.1a Display locales (RFC 0001)
A file MAY declare the display locale it was authored in (frontmatter.locale; absent = en-US), and an implementation declares the locales it renders (ImplementationManifest.supported_locales; absent = ['en-US']). Negotiation is support-or-refuse:
An implementation MUST support
en-USand MAY support additional registered locales. When producing a display render (chat, summary, report) of a file whoselocaleit does not support, it MUST emitLOC-01(error,LOCfamily §III.6a) and MUST NOT fall back to a different locale — two readers disagreeing about what1.234,56means is the failure this rule prevents. Parsing, validation, editing, and calc of such a file are unaffected.
The display-only boundary. locale governs human display renders ONLY. Canonical JSON content (numbers, ISO dates, fraction rates), the CSV renders (interchange, not display), UW Lite's canonical form, digests/signatures/receipts, and calc evaluation are locale-free. CalcEvaluationContext.locale is locale-invariant: evaluation MUST be identical whatever locale a context declares.
The registry, not ICU. Formatting for every non-en-US locale MUST be produced from the rules below (BUILTIN_FORMAT_RULES, format-rules.ts) — never from runtime Intl/ICU, whose output varies across runtimes and versions. The rows are the normative contract; new locales land via additive RFC amendments to this table.
| Locale | Decimal | Grouping | Currency | Percent | Date (display) |
|---|---|---|---|---|---|
en-US | . | , | $ prefix | 5.51% | ISO passthrough |
en-GB | . | , | £ prefix | 5.51% | DD/MM/YYYY |
de-DE | , | . | € suffix, NBSP | 5,51 % (NBSP) | DD.MM.YYYY |
fr-FR | , | NBSP | € suffix, NBSP | 5,51 % (NBSP) | DD/MM/YYYY |
ja-JP | . | , | ¥ prefix | 5.51% | YYYY/MM/DD |
zh-CN | . | , | ¥ prefix | 5.51% | YYYY-MM-DD |
NBSP is U+00A0. Currency symbols are the locale's conventional default symbol when frontmatter.currency_code is absent. When that optional field is present, its three-uppercase-letter identity is rendered as an explicit prefix (USD 1,234.56), while the locale continues to control numeric separators. Identity is never inferred from $, locale, address, or asset location; no FX conversion or currency-specific precision is performed. Date patterns are applied textually to the ISO source — no timezone arithmetic. Example (de-DE): 1234567.5 → 1.234.567,5 €; 0.0551 → 5,51 %; 2026-04-15 → 15.04.2026.
III.1b Document currency identity (RFC 0046)
frontmatter.currency_code is an optional document-level denomination assertion. If present it MUST match ^[A-Z]{3}$; a malformed value emits CUR-01 (error) and display renders MUST refuse it. A syntactically valid code is preserved as authored identity; the reference implementation does not perform a live ISO 4217 allocation lookup.
The field applies to monetary display values and calc result display strings in the document. It does not alter canonical numeric storage, CSV/JSON interchange, Lite canonicalization, hashes, signatures, receipts, or numeric calculation. This first tranche supports one currency per document only; per-value identity, FX, and mixed-currency arithmetic require a future additive money representation.
III.2 Date/time
Dates in the file are ISO-8601 strings. The default display style is the declared locale's date pattern (§III.1a) — for en-US, iso passthrough. Implementations MAY surface short, medium, or long styles via Intl.DateTimeFormat; those styles are convenience, not conformance surfaces.
III.3 Section display hierarchy
Section cards SHOULD be presented in the order defined by BUILTIN_VIEW_MODELS[id].display_order. Sections not present in the file MUST NOT render an empty card.
III.4 Badge system
Each block carries provenance via _meta.source. Implementations SHOULD display a compact badge per block:
_meta.source pattern | Suggested label | Color hint |
|---|---|---|
agent/* | "AI" or layer ID | accent |
manual | "Manual" | neutral |
document/* | "Document" | neutral |
system/* | "System" | muted |
institution/* | "Institution" | muted |
Confidence is rendered from _meta.confidence ∈ {high, medium, low}. Style options (pill/icon/text) are at implementer discretion.
III.5 Supersede UX
When a section has superseded blocks, implementations SHOULD provide a way to reveal them (a toggle, a side panel, etc.). Superseded blocks MUST NOT be confused with the canonical block in any default view.
III.6 Validation issue display
Each issue from validateUWFile() carries a code. Implementations SHOULD render the matching IssueRemediation from BUILTIN_REMEDIATIONS — title and remediation copy verbatim. The goal is uniform UX across implementations, so end-users learning the format on one tool see the same remediation copy on another.
III.6a Validator code taxonomy
Every issue code emitted by a conforming validator MUST carry a registered family prefix. The prefix is part of the contract — adopters filter, group, and route issues by prefix without parsing the message text.
Each family names the capability that owns it. An implementation MUST NOT be required to emit codes from a family whose owning capability it does not claim (§II.5), and conformance cases for those codes are tagged accordingly (§II.6a.5). A family with no owning capability is unconditional: every implementation owes it.
| Prefix | Family | Owning capability | Default severity |
|---|---|---|---|
CC-NN | Cross-section consistency — two or more sections disagree about the same fact. NN is a two-digit integer registered in this spec. | validate | warning or error per check |
FV-NN | Single-section financial validity — a value falls outside a registered plausibility threshold. | validate | typically warning |
DQ-NN | Data quality — a required value is missing, provisional, or below a stage threshold. | validate | warning or error |
MU-NN | Mixed-use composition (§XII). | validate | warning or error |
CS-* | Capital stack (§XIII); RFC 0050 registers CS-02b for malformed split preferred-equity coupons. | validate | warning or error |
LU-NN | Lease-up schedule structure — period grammar, contiguity, presence (format spec §4.25, RFC 0008). | validate | warning or error |
RT-NN | Return-metric declarations — the dcf.returns basis fields (format §4.9, RFC 0038). | validate | error |
PS-NN | Period-series shape and identity (RFC 0041). | validate | PS-01/03 warning; PS-02 error |
ROLE-NN | Signed block role vocabulary (RFC 0040). | validate | error |
LOC-NN | Display locale (§III.1a, RFC 0001). | validate | error |
CUR-NN | Document currency identity (§III.1b, RFC 0046). | validate | error |
TAX-NN | Property-tax reassessment basis and abatement schedule (format §4.5 and §4.9, RFC 0053). | validate | error |
LSE-NN | Commercial lease clauses — escalation steps, break options, co-tenancy, TI/LC balances (format §4.3, RFC 0055). | validate | error |
HDG-NN | Interest-rate hedges — cap strike, notional, term and post-expiration assumption (format §4.7, RFC 0056). rate_swap and rate_collar are reserved and refused by HDG-02. | validate | error |
ESC-NN | Escrow and reserve cash lines, and the rate-cap replacement tie (format §4.8, RFC 0056). | validate | error |
WF-NN | Distribution waterfall structure (format §4.27, RFC 0035/0036/0051) and the RFC 0059 clawback provision (WF-10–WF-13, WF-15). Stated-figure disagreement is reported by the verifier as WF-OUTCOME-DISAGREES, not as a validator code. | validate | WF-15 warning; otherwise error |
REC-NN | Expense recoveries and the CAM true-up (format §4.3, RFC 0058). The capped amount and the pool allocation are stated, not recomputed; REC-07 checks only the direction a cap can move. | validate | REC-10 warning; otherwise error |
CAPX-NN | Renovation draw and expense-targeted capex (format §4.8, RFC 0057). CAPX-07 requires the in_noi_model disclosure; no stated saving is ever applied. | validate | error |
META-* | _meta shape by uw_version — the RFC 0009 one-shape-per-file rule (META-V2-IN-V1, META-V1-IN-V2). | validate | error |
INVALID-ASSET-CLASS-NNN | Asset-class identifier syntax (§X.2). | validate | error |
SRC-NN | Source vocabulary — _meta.source outside the §2.6 actor grammar (RFC 0031), and the retired resolution: "manual" spelling (SRC-03, RFC 0009). | validate | per-file (format v2 §1.3): error in a uw_version: "2.0" file, warning in 1.x |
INT-NN | Integrity — content_hash / parent_hash chain (§IX.2). | integrity | warning or error |
POL-NN | Edit policy (§V.3; POL-03 is the capability-token refusal, §XIV). | edit-replace or edit-supersede | error |
MOD-* | Module runtime (§X). | module-load | info to error |
CALC-* | Calc engine (§VIII). | calc-evaluate | error |
UNSUPPORTED_YAML_FEATURE | Frontmatter YAML subset violation (§2.2). | parse | error |
PROTO-* | Protocol-level refusal — a malformed request. | (none) | error |
RCP-* | Verification receipt (§XI). | (none) | error |
Coverage (RFC 0037). Beside its issues, a conforming validator reports a per-check coverage record for the CC-NN family — evaluated, or skipped with a reason (section_absent, variant_unresolvable, field_absent, not_applicable) — as format §5.3 specifies. A host that displays validation results SHOULD show "not evaluated" distinctly from "clean": the two are different facts about the document, and the difference is the whole reason the record exists. CC-16 (info) is the one skip that also surfaces as an issue, because the producer can act on it by declaring an eligible statement or resolving a consulted role collision.
Role-aware selection (RFC 0040). Implement format §2.8/§5.3 using the closed BLOCK_ROLES and frozen CROSS_CHECK_ROLE_PREFERENCE table. Report optional per-section CrossCheckCoverage.resolutions only for role-bearing selections; role-free results remain unchanged. ROLE-01 is a structural error in every active block with an invalid scalar _role. This annotation is included in the existing content hash; _meta.role is not an alias.
FV_* with an underscore was the v1.0 spelling and was renamed to FV-NN in v1.1. META_* (underscore) is the provenance-completeness family and is live: the reference validator emits exactly three codes — META_MISSING (no _meta object), META_FIELD_MISSING_<FIELD> (a required _meta field absent; <FIELD> ranges over SECTION, VERSION, SOURCE, TIMESTAMP, CONFIDENCE), and META_LOW_CONFIDENCE_NO_REVIEW_FLAG (info: low confidence without human_review_required) — and the tier-1 conformance baselines pin them. A prior revision of this table declared the family retired on the claim that it was "specified and never emitted" and that DQ-NN covered its conditions; both halves of that claim were false (the DQ-NN sequence polices incomplete data, not incomplete provenance), and a conforming implementation was left choosing between the spec's prose and the corpus's baselines. The corpus was right. The hyphenated META-* family (RFC 0009) is a separate concern under the same registry prefix — it polices which _meta shape a file's uw_version admits, not provenance completeness.
CC-NN, FV-NN, DQ-NN, MU-NN, and SRC-NN are closed sequences extended by RFC without renumbering. MOD-* and CS-* are open extension points: modules MAY add their own, and adopters MUST treat an unknown code within a registered family the same way they treat a known one (render the message; consult BUILTIN_REMEDIATIONS if present; fall back to message text otherwise).
This table is not the mechanism that keeps itself current. It said "one of three families" for four minor versions while the reference validator emitted eighteen, because a prose list duplicating a list that lives in code goes stale silently. The machine-readable registry is VALIDATOR_CODE_FAMILIES in packages/uwmd-core/src/protocol.ts, remediation copy is BUILTIN_REMEDIATIONS beside it, and a test asserts every code in the latter resolves to a family in the former. The spec states the rule; the assertion enforces the list.
Implementations that surface validator output to end users SHOULD use the prefix to colorize, group, or filter issues — for example, collapsing MOD-* and INT-NN notices behind a "show provenance issues" toggle while always surfacing CC-NN and FV-NN inline.
IV. View Models
IV.1 SectionViewModel
A SectionViewModel (protocol.ts) describes how to lay out one section. It comprises:
display_name— heading shown to users.display_order— sort key relative to other sections.description— one-line context shown on hover or below the heading.primary_fields— fields surfaced on the section's collapsed card.detail_fields— additional fields revealed on expand.multi_variant— true for sections likestress_teststhat may appear with multiple variants.
Each FieldViewHint declares a path resolved via parser.deepGet, a label, and a kind mapping to a formatter in format.ts.
IV.2 BUILTIN_VIEW_MODELS
protocol.ts exports a registry covering all 21 standard sections. Implementations SHOULD use this registry as the default. Modules MAY override per-section by declaring a view_models[] entry with the matching section_id (§X).
IV.3 Composition
When a module's view model and BUILTIN_VIEW_MODELS both define an entry for the same section_id, the module's entry wins. When two modules conflict, the host MUST refuse to load both unless they declare depends_on relationships that establish a precedence.
V. Edit Semantics
Normative schema: spec/schemas/edit-operation.schema.json defines the wire shape for every EditOperation accepted by a Tier-2 Editor.
Trusted role assignment (RFC 0040). section_replace and section_supersede accept optional role: BlockRole | null outside content. Omission preserves the prior block's _role; a valid scalar assigns it; null removes it. Invalid assignments MUST be refused as PROTO-EDIT-002. The host MUST strip content._role and MUST NOT populate the trusted role field from model output. Agent writing preserves an existing role and strips invented roles on new blocks. Hashing and signature verification cover this annotation through the existing content path, without new exclusions.
V.1 Round-trip preservation
A Tier-2 Editor receiving an .uwx.md and returning an .uwx.md MUST preserve bytes outside the directly-modified region, modulo:
- Line-ending normalization (CRLF → LF is permitted).
- Trailing-whitespace stripping is permitted.
- Reordering of frontmatter keys is NOT permitted.
- Reformatting JSON inside untouched fence blocks is NOT permitted.
V.2 Replace vs supersede
| Operation | Effect |
|---|---|
frontmatter_set | In-place update of one frontmatter path. last_modified MUST be updated. |
section_replace | Overwrite the canonical block for section_id. The previous block is discarded. Permitted only for sources whose policy is supersede_on_edit: false. |
section_supersede | The previous block is marked superseded: true in its _meta; a new block is appended with version = previous.version + 1. |
pipeline_log_append | Append-only. Existing entries are immutable. |
V.3 Source authority
_meta.source is actor-only (RFC 0031): manual, or <namespace>/<id> where the namespace is one of the closed set agent | document | system | institution (runtime registry ACTOR_NAMESPACES) and <id> matches [A-Za-z0-9][A-Za-z0-9._-]*. The grammar is normative in format spec §2.6; resolution methods belong in _meta.resolution (§V.7).
BUILTIN_EDIT_POLICIES (protocol.ts) establishes the default policy for each _meta.source pattern:
agent/*→eitherauthority,supersede_on_edit: true.manual→eitherauthority,supersede_on_edit: false.document/*→eitherauthority,supersede_on_edit: true.system/*,institution/*→system_onlyauthority,supersede_on_edit: false.*(terminal catch-all) →eitherauthority,supersede_on_edit: true.
The catch-all is normative: every source resolves to a policy. An unrecognized source gets the conservative treatment — the prior block is preserved by supersede — never the permissive one. (Before it existed, an unmatched source was read as both authorized and exempt from supersede_on_edit, so such a block could be replaced in place with POL-01 and POL-02 unable to fire.) An implementation given a caller-supplied policy list that covers no matching pattern for a source MUST refuse the write rather than grant it: a list that does not cover a source is an incomplete policy, not an authorization.
Authority classification MUST derive from the parsed actor namespace, not from string prefix tests: manual is human; agent/* is agent; system/* and institution/* are system. A source outside the grammar — a retired colon form, a bare word, a resolution tag — belongs to no authority class: it can write where authority is either and can never satisfy human_only, agent_only, or system_only. (The prefix test's negative space used to classify agent:L0-01 as a human write.) document/* likewise satisfies none of the three restricted classes — a document is evidence, not an authority class.
A module MAY contribute additional patterns; conflicts resolve in favor of the more-specific glob (e.g. agent/L6 beats agent/*), and any module pattern is more specific than the catch-all.
V.4 _meta authorship
On every write, the host MUST set:
_meta.version— incremented from the prior canonical block's version (or 1 for a new section)._meta.superseded—falseon the new block;trueon the prior block when superseding._meta.source— the actor:manual | agent/<id> | document/<id> | system/<id> | institution/<id>, with<id>matching[A-Za-z0-9][A-Za-z0-9._-]*(format spec §2.6; RFC 0031). This is the grammar a producer is bound to — nothing outside it resolves a specific edit policy or an authority class._meta.resolution— OPTIONAL; the canonicalSOURCE_TAGStag naming how the value was resolved (e.g. the §V.7 cascade step), when the host knows it._meta.timestamp— current ISO-8601 UTC timestamp._meta.actor— human or system identifier of the writer._meta.agent_id,_meta.agent_version— populated for agent writes; null otherwise.
V.5 Multi-variant edits
For sections with multi_variant: true, edits MUST specify a variant key. Operations against missing variants create a new variant; operations against existing variants follow the same replace/supersede policy as single-variant sections.
V.6 Frontmatter update rules
uw_version, deal_id, and created MUST NOT be modified by post-init edits. last_modified MUST be updated on every write. pipeline_state MUST be updated by Tier-4 hosts as layers complete.
V.7 Fallback cascade
A producer that needs to assign a value to a field for which no explicit user input or document extraction is available MUST resolve the value by walking the following ordered cascade. The first step that yields a value wins. The producer MUST stamp the resulting _meta.resolution (or, when only a subset of fields was resolved this way, a _meta.field_overrides[].resolution) with the cascade step that produced the value; a leaf-level resolution wins over the block-level one for its path. (Before RFC 0031 split the field, the tag was stamped into _meta.source; readers MUST interpret a canonical tag found there as resolution — format spec §2.6.)
| Step | Source tag | Description |
|---|---|---|
| 1 | user_override | An explicit user-entered correction. |
| 2 | user_input | An explicit user-entered initial value. |
| 3 | inherited_assumption | An assumption declared by an ancestor in the composition DAG. See §V.7.1. |
| 4 | investor_profile | Values declared in the active investor profile (e.g. preferred rate spread). |
| 5 | market_data | A market-data lookup at the time of resolution. |
| 6 | asset_class_default | The published default for the deal's asset class. See §V.8. |
| 7 | global_default | The published global default. |
| 8 | system_default | A hardcoded constant in the reference library or institution config. Producers SHOULD avoid relying on this layer for normative values. |
market_data_accepted (RFC 0022 §4) is not a cascade step. It is an in-file value of record that resolves at step 2 while retaining its own source tag, because a market observation an analyst accepted for lack of better evidence must stay distinguishable from a value someone entered from diligence.
A producer MAY skip steps that are not available (e.g. no investor profile attached). A producer MUST NOT reorder the cascade. The runtime constant CASCADE_ORDER in protocol.ts is the canonical machine-readable representation; conformant resolvers walk this array.
A producer that resolves a value via a step at or below asset_class_default (i.e. not from observed user or document data) SHOULD also stamp the resulting block with _meta.provisional: true unless the value originated from inherited_assumption, investor_profile, or fresher market_data. This signals the refinement engine that the field is a candidate for value-of-information ranking.
This obligation is deliberately a SHOULD, and it is safe to leave as one (stated by RFC 0032): _meta.provisional sits inside canonical block JSON (§V.9), so stamping it moves the block's content_hash and therefore any §V.11 signature — and the protocol accepts that two conforming producers may diverge here. Agreement of hashes or signatures across independently-produced blocks is not a protocol goal and is unachievable regardless of this clause, since canonical content includes producer-specific fields (timestamp, source) in every block. What the protocol does guarantee across implementations is agreement on computed values (§VIII; RFCs 0023 and 0024) and on verification verdicts (RFC 0016 receipts). A signature attests to who wrote what, when — it does not attest that every conforming producer would have written the same bytes.
V.7.1 Inherited assumptions
Added at protocol 1.5.0 by RFC 0021 §5, taking the cascade from seven steps to eight.
A composite record MAY declare assumptions its descendants inherit. Resolution of inherited_assumption is subject to four normative rules:
- Composition-scoped only. Inheritance resolves along the composition DAG. A document not reachable as an ancestor contributes nothing, and there is no ambient or global assumption scope. A standalone record therefore can never resolve at this step, which is why introducing it moves no existing digest.
- Nearest ancestor wins. Where several ancestors assert one field, the one fewest hops away supplies the value.
- Equidistant ancestors are an error (
COMP-AMBIGUOUS-INHERIT), never a silent pick. Diamond inheritance resolves explicitly or not at all: choosing by traversal order would make the answer depend on how the graph was walked. - The value is traceable. An inherited value MUST record the asserting ancestor's
document_idand digest in_meta.inherited_from. An inherited value with no named ancestor is indistinguishable from an ambient default, which rule 1 forbids.
Step 3's position is normative in both directions. It sits belowuser_input, so a value entered on the deal always wins — inheritance supplies defaults, it never overrides. It sits above investor_profile, because an assumption asserted by a named ancestor of this specific deal is more specific than an institution-wide preference set.
When the resolved value carries an associated range (e.g. asset-class defaults publish {low, central, high}), the producer MAY also stamp _meta.field_overrides for that path with the range recorded under the entry's note, so downstream tooling can compute value-of-information without re-resolving the cascade.
V.8 Asset-class default tables
The reference library publishes per-asset-class default tables under @uwmd/core/defaults. Each entry resolves to a {low, central, high} triple plus a unit and source tag. Producers consuming the table at cascade step 5 (asset_class_default) MUST use the published central value as the resolved scalar and MAY surface the range to downstream consumers via field_overrides.
Tables are versioned independently of the protocol; consumers MAY pin a specific table version via institution config. Default-table revisions bump their semver and appear in the CHANGELOG.
The MarketDataLookup interface (cascade step 4) is defined by the reference library but ships no built-in implementation; adopters bring their own (CoStar, Yardi, internal). When no implementation is attached, step 4 is silently skipped and resolution falls through to step 5.
V.9 Canonical block JSON
The _meta.content_hash of a block is the SHA-256 hash of the canonical JSON serialization of the block's content (the JSON object inside the fence, exclusive of the fence annotation line and exclusive of _meta.content_hash and _meta.signature themselves).
The canonical form is RFC 8785 (JCS — JSON Canonicalization Scheme) with one uw-md addition: the keys content_hash and signature inside any nested _meta-shaped object are removed before hashing. A _meta-shaped object is any object that carries version, source, AND either section or section_id — this catches both the top-level _meta (whose on-disk spelling is section_id) and any nested provenance (e.g. inside field_overrides).
Implementations MUST produce byte-identical canonical output across platforms. Specifically:
- Object keys are sorted by code-unit comparison (RFC 8785 §3.2.3).
- Strings are JSON-escaped per RFC 8785 §3.2.2.
- Numbers serialize per ECMAScript ToString (RFC 8785 §3.2.2.3), with
-0rendered as0and non-finite numbers rejected (canonical JSON has no representation forInfinityorNaN). undefinedvalues inside objects are dropped (mirroringJSON.stringify).
The reference library's implementation is in @uwmd/core/integrity-canonical; it is dependency-free and ~120 lines.
Canonicalization is versioned by the file's uw_version (RFC 0009). The rule above is the v1 rule and is frozen for the life of the format: no digest stamped in a uw_version: "1.x" file is ever invalidated by a later canonicalization change. Files declaring uw_version: "2.0" or later use canonicalization v2 — normalize-then-hash: the block content is first reshaped to the canonical nested _meta form (the RFC 0009 shim, including the field_overrides → _overrides lift), then integrity.content_hash, integrity.signature, and a defaulted ('sha256') integrity.algorithm are removed, then RFC 8785 serialization applies with the same byte-identity requirements. Because normalization runs first, the two _meta shapes a dual-shape parser accepts yield identical digests for semantically identical blocks. Migration between the versions re-stamps every hash (uwmd migrate --to-v2); signatures commit to the v1 digest and do not survive it — the migrating tool MUST refuse signed blocks unless explicitly told to re-sign or strip (RFC 0009 § Canonicalization).
V.10 Block integrity (content_hash + parent_hash chains)
Two optional _meta fields make supersede chains tamper-evident:
content_hash— the canonical-JSON SHA-256 of the block (§V.9).parent_hash— thecontent_hashof the block this one supersedes;nullon a chain root.
Opt-in. A block lacking both fields is well-formed and verifies as ok: true, chains_with_hashes: 0. A producer that wants tamper-evidence MUST stamp every block it writes within a hashed chain. Once any block in a supersede sequence carries content_hash, every later block in that sequence MUST carry one (else INT-03 warning).
Verification (verifyChain). For every supersede chain in a parsed file:
- If no block in the chain carries
content_hash, the chain is skipped. - Each non-root block's
parent_hashMUST equal the prior block'scontent_hash(elseINT-01 error). - Each block's stamped
content_hashMUST recompute from its current canonicalized content (elseINT-04 warning).
Editor enforcement (applyEdit). When the section's current head carries content_hash, the caller's EditContext.parentHash MUST equal it; mismatch is rejected as INT-02 (concurrent write detected). The new block is then stamped with parent_hash = prior.content_hash and a freshly computed content_hash. The async stamping is performed by applyEditAsync (Web Crypto's SHA-256 is async); the sync applyEdit performs only the INT-02 mismatch check.
Provenance verification (verifyProvenance). Cross-checks _meta.actor and the operation that produced the block against the section's EditPolicy:
POL-01 error— actor not authorized for the policy authority (agent_only,human_only,system_only,either).POL-02 error—section_replaceused where the policy mandatessection_supersede(heuristic:version > 1with no superseded prior versions visible).
Adversarial-write caveat. A non-conforming producer can trivially defeat integrity by simply not stamping content_hash (verifyChain returns "no chain to verify" silently). Adopters who need adversarial resistance MUST enforce a "must have hashes" policy externally — for example, a CI gate that runs uwmd verify --integrity and rejects files where chains_with_hashes < chains_with_supersedes. §V.11 (block signatures) is the stronger tool: a signature cannot be omitted quietly, because a verifier that requires one reports its absence.
V.11 Block signatures (RFC 0010)
_meta.content_hash is tamper-evident, not tamper-proof: it detects a change only for a reader who already holds a trusted copy of the hash. _meta.signature closes that gap for the deployments that need it — regulated lender data rooms, multi-party deal flow where sponsor, lender, and appraiser each sign their own sections, and agent-host accountability where a signature proves which agent instance wrote a block rather than merely what the actor field claims.
Signed blocks are not the everyday case. Reading, validating, editing, and computing over .uw.md requires no cryptography, and the reference library takes no crypto dependency: the wire format and the signing input are normative here, while signing and verification live in the separate optional @uwmd/signing package.
V.11.1 Wire format
_meta.signature is an object:
| Field | Type | Required | Meaning |
|---|---|---|---|
alg | "ed25519" | "es256" | "es384" | yes | Signature algorithm. Closed set for protocol 1.x. |
kid | string | yes | Opaque key identifier the verifier resolves in its own key store. |
sig | string | yes | Signature bytes, base64url (RFC 4648 §5), unpadded. |
signed_at | string | yes | ISO 8601 instant the signature was produced. MAY differ from _meta.timestamp — a block edited offline is signed when re-uploaded. |
v | string | no | Signing-protocol version. Absent means "1". |
es256 and es384 signatures are raw r || s (IEEE P1363), the JOSE convention and what Web Crypto produces. DER-wrapped ECDSA signatures are not interchangeable and MUST NOT be emitted.
Like content_hash, signature is excluded from canonicalization (§V.9), so stamping one does not change the hash it commits to.
V.11.2 What is signed
The signature input is the RFC 8785 canonical JSON of exactly these six fields, and nothing else:
{
"actor": "<_meta.actor>",
"content_hash": "<_meta.content_hash>",
"kid": "<signature.kid>",
"section": "<the block's section id>",
"signed_at": "<signature.signed_at>",
"timestamp": "<_meta.timestamp>"
}The signer commits to the block's content_hash rather than to the block itself — the hash already covers the content, and re-deriving it inside the signature would buy nothing. The other five fields are what a verifier needs in order to answer who signed what, when.
Four of the six fields (actor, content_hash, signed_at, timestamp — and in practice kid) are specific to the producing party, so two conforming producers signing equivalent underwriting content produce different signing inputs and different signatures by design (stated by RFC 0032). A verifier MUST NOT treat signature disagreement between independently-produced blocks as evidence that either is non-conforming; conformance of values is the province of §VIII determinism and RFC 0016 receipts. This includes divergence introduced by optional _meta stamps such as provisional (§V.7), which is covered by content_hash like any other _meta field outside §V.9's two exclusions.
The signing input names no parent_hash field. RFC 0010 read that as a guarantee that re-rooting a supersede chain leaves prior signatures intact; it is not. content_hash is computed over the block's _meta as well as its content (§V.9), and parent_hash lives in _meta, so a re-rooted block gets a new hash and its signature no longer applies. Re-rooting therefore requires re-signing. See the erratum.
A signature is over one block. A document with several signed blocks carries several independent signatures, which is the property multi-party deal flow needs: "the sponsor signed the rent roll" and "the lender signed the term sheet" must be separately checkable, and must stay checkable after either block is superseded.
V.11.3 Verification
Verification is opt-in and capability-gated. A verifier holding no key store treats a signature as opaque metadata: it MUST report that signatures were present and unchecked, and MUST NOT report the document as verified on their account. In the reference library that is IntegrityResult.signatures_present alongside signatures_verified.
A conforming signing-aware verifier emits:
| Code | Severity | Trigger | Needs a key store |
|---|---|---|---|
INT-05 | error | signature present with no content_hash. The signature commits to nothing. | no |
INT-06 | error | signature.kid names a key the store does not hold. | yes |
INT-07 | error | The signature does not verify; or alg is outside the admitted set; or the stamped content_hash no longer recomputes. | partly |
INT-08 | warning | alg is in the verifying deployment's deprecation list. Empty at protocol 1.7. | no |
Three of these distinctions are load-bearing:
INT-06is notINT-07. "I cannot check this" and "this is forged" call for opposite responses — load a key versus reject the document. A verifier that merges them tells an operator to re-sign when the real fix is to configure their key store.- A drifted hash on a signed block escalates. The same drift is
INT-04 warningon an unsigned block andINT-07 erroron a signed one. On an unsigned block it is a bookkeeping slip; on a signed one it means the content in front of you is not the content anybody signed. - Signatures are checked on every block, not only chain heads. A signature on a superseded block is exactly the evidence per-block signing exists to preserve.
V.11.4 Key distribution
Out of scope, deliberately. A public key that travels inside the document it authenticates proves nothing, so keys are distributed out of band and a verifier decides which kid values it trusts.
Rotation is "issue new blocks under a new kid and keep the old key loaded". A kid MUST be unique within a key store and MUST NOT be reused for a different key; blocks signed under a retired kid stay verifiable for as long as the store retains it.
signed_at is self-asserted by the signer. Audit-grade non-repudiation needs a timestamping authority's countersignature, which protocol 1.x does not define.
V.11.5 Capability declaration
An implementation that verifies signatures adds signing to its ImplementationManifest.capabilities. The conformance/signing/ suite gates that claim; an implementation that does not claim signing skips the suite and remains conformant.
VI. Extensibility
VI.1 x_* extension blocks
Section IDs prefixed with x_ are reserved for extensions. Tier-1 Readers MUST parse and surface them but MAY render them as raw JSON if no view model is registered.
VI.2 Custom calculations and scenarios
custom_calculations and custom_scenarios are first-class blocks, not x_* extensions. A Tier-3 Calc Host MUST evaluate them; lower tiers MUST surface their declared result and unit verbatim from the file (treating the host as a fixed observer of pre-computed values).
VI.3 InstitutionConfig layering
A .uw.institution.json sidecar (format spec Appendix C.6) overrides threshold defaults. Implementations MUST apply the institution config to validation but MUST NOT apply it to display formatting in v1 (locale is frozen).
VII. Module System
VII.1 What a module is
A module is a declarative manifest (see §X) plus optional supporting files. Modules in v1 contain no executable code; all dynamic behavior is expressed via the safe-expression language (§VIII).
VII.2 Lifecycle
- Host loads a
module.uw.yamlormodule.uw.jsonfile. - Host validates against
spec/schemas/module-manifest.schema.json. - Host checks
requires_protocolandrequires_formatagainst its own versions. If incompatible, refuse withProtocolErrorcategorymodule. - Host registers the module's sections, calculations, validations, thresholds, view models, and agent layers.
- Host runs subsequent operations with the module's contributions merged into its registries.
VII.3 Conflict resolution
When two loaded modules declare the same section ID, calculation ID, or view-model section_id:
- If both modules are unrelated, the host MUST refuse to load the second one and report a
ProtocolError. - If one module declares the other in
depends_on, the dependent module's declarations override.
VII.4 Capability negotiation
A module's requires_tier declares the minimum host tier. A Tier-2 host MUST refuse to load a module whose requires_tier is tier-3-calc-host or higher.
VIII. Calc Engine Contract (Tier 3)
Normative schema: spec/schemas/calc-result.schema.json defines the shape of every value a Tier-3 Calc Host returns.
VIII.1 Safe-expression grammar (EBNF)
expr ::= conditional
conditional ::= logicalOr ( "?" expr ":" expr )?
logicalOr ::= logicalAnd ( "||" logicalAnd )*
logicalAnd ::= comparison ( "&&" comparison )*
comparison ::= additive ( ( "==" | "!=" | "<=" | ">=" | "<" | ">" ) additive )?
additive ::= multiplicative ( ( "+" | "-" ) multiplicative )*
multiplicative ::= unary ( ( "*" | "/" | "%" ) unary )*
unary ::= ( "-" | "!" )? primary
primary ::= number | string | bool | null
| identifier
| identifier "(" arglist? ")"
| identifier ( "." identifier | "[" string "]" )+
| identifier member+ "@" period_selector member*
| "(" expr ")"
member ::= "." identifier | "[" string "]"
period_selector ::= "Y" positive_integer | calendar_quarter | calendar_month | calendar_date
positive_integer ::= [1-9][0-9]*
calendar_quarter ::= [0-9]{4} "-Q" [1-4]
calendar_month ::= [0-9]{4} "-" [0-9]{2}
calendar_date ::= [0-9]{4} "-" [0-9]{2} "-" [0-9]{2}
arglist ::= expr ( "," expr )*
identifier ::= [A-Za-z_][A-Za-z0-9_]*
number ::= [0-9]+ ( "." [0-9]+ )?
string ::= "'" [^']* "'"
bool ::= "true" | "false"
null ::= "null"Logical operators && and || short-circuit: the right operand is only evaluated when the left does not determine the result. Both operands MUST be boolean or null; non-boolean operands raise CALC-TYPE-001. A null operand propagates as null (matching the arithmetic null-propagation rule).
Hosts MUST reject any input that does not parse against this grammar. Notably absent: assignment, function definitions, arrow functions, for/while/if keywords, property assignment, and indexed assignment.
VIII.2 Variable resolution
Identifiers and dot-paths resolve against the CalcEvaluationContext via parser.deepGet semantics:
- Top-level identifiers map to:
frontmatter.<id>first- then
sections.<id>(the canonical block'scontent) - then
prior_results.<id>
- Dot-paths drill into nested objects.
- A missing path resolves to
null, not an error. Operators MUST propagate null as null for arithmetic (null + x → null).
VIII.2a Explicit period addressing (RFC 0041)
A period reference selects by stated identity, never row position: [email protected]_operating_income. The safe-expression grammar adds one @selector after the section-rooted path of a registered series; ordinary member access may follow it. Exactly one selector is allowed per reference. Existing expressions and bracket-string literal keys are unchanged. @ inside a quoted key remains a literal character; generic deepGet, metadata pointers and other arbitrary string paths do not acquire selector semantics. The contextual resolvePeriodPath(parsed, path, options?) accepts one reference using this calc grammar. Numeric bracket indices are not added to that grammar; an existing bracket-string index such as ['0'] remains valid after a selector.
Selectors and canonical PeriodKey identities:
| Selector | Key | Constraints |
|---|---|---|
Y<n> | { kind: 'year', index: n } | positive safe integer, no leading zero |
YYYY-Qn | { kind: 'quarter', year, quarter: n } | four-digit year, quarter 1–4 |
YYYY-MM | { kind: 'month', year, month } | four-digit year, month 01–12 |
YYYY-MM-DD | { kind: 'date', date } | valid Gregorian date under §VIII.9.1 |
Different kinds never compare equally. Calendar years are absolute, including when a schedule begins midyear. No relative Qn/Mn, wildcard, year-of-date, calendar conversion, or acquisition-date inference is defined here. canonicalPeriod(entry, value) reads the registered row field or keyed period; periodKeyIdentity produces stable kind-qualified keys. Year_N object keys canonicalize their positive safe integer suffix, so year_01 and year_1 collide.
The frozen standard PERIOD_SERIES registry is normative:
| Path | Shape | Period | Grammar | Cadence |
|---|---|---|---|---|
dcf.annual_cash_flows | rows | year | year_index | — |
noi_model.projections | keyed | ^year_(\d+)$ | year_index | — |
lease_up_schedule.schedule | rows | period | calendar_period | period_granularity |
cash_flow_series.series | rows | date | iso_date | — |
distribution_waterfall.stated_schedule | rows | date | iso_date | — |
Module-defined series are deferred. A rows series is an array of objects; a keyed series is an object of period-keyed objects. A present calendar series must state monthly or quarterly cadence and every row must use that kind. Missing or null section/series/period resolves to null. An empty series has no matching period. Every present row is inspected before selection: malformed shape or periods refuse as CALC-PERIOD-001. For every registered series except cash_flow_series.series, any duplicate canonical identity refuses as CALC-PERIOD-002, even without prevalidation. For cash_flow_series.series, multiple rows MAY share a valid date: an ordinary selector MUST refuse CALC-PERIOD-002 only when its requested canonical date matches multiple rows. A date appearing exactly once MUST resolve normally even when other dates in that series repeat. The resolver MUST NOT choose by first/last row, sum or merge matches, or infer intent from kind, label, amount or position. A well-formed selector of a different kind returns null. Missing leaf values return null; calc results remain scalar, although the public resolver may return the selected row object.
Section context. These references are section-rooted and do not consult frontmatter or prior results for the section head. If the caller supplies CalcEvaluationContext.sectionVariants[section] (also accepted by PeriodResolutionOptions), select exactly that variant, with no fallback. An explicitly selected component is intentional and allowed; invalid roles are not. Otherwise use RFC 0040's generic primary/default/base/sole selection, excluding components and invalid roles, with no check-specific role preference. Ambiguity or a missing explicitly requested variant in a present section is CALC-PERIOD-003. A missing section remains null. Own-property traversal and the blocked segments constructor, prototype, __proto__ remain enforced.
Overrides and consumers. Check a full selector-path override before reading document data; null is an explicit override. First validate the registry path and selector itself so an override cannot legitimize an invalid reference. Canonical keys join identifier-like leaf segments with dots and retain other leaf keys in bracket-string form. Dependencies use this same spelling, preserving period identity and distinguishing a literal dotted key from nested traversal. No document writes or changes to numeric quantization occur. Rows retain identity through existing source-row binding surfaces; this exception adds no numeric-index calc grammar. Whole-column contextual Excel projection (§VIII.2c) still refuses duplicate identities rather than collapsing rows into a unique-key column. Its whole-series validity guard is distinct from ordinary scalar date selection.
Excel emission MUST refuse selector nodes with EXCEL-EMIT-PATH unless explicit contextual bindings under §VIII.2c are supplied. A static named-range mapping alone MUST NOT stand for a row chosen by its current position. The reference refinement engine uses finite stated period inputs as fixed values under §VIII.2b. Its general cascade is unchanged.
Validation. Inspect all active variants of every present registered series: PS-01 warning for malformed shape/row/period; PS-02 error for each duplicate canonical identity except in cash_flow_series.series (RFC 0062). Duplicate valid cash-flow dates MUST NOT produce PS-02: these are legal distinct ledger rows under Format §4.26. They MUST NOT be merged, netted, reordered or collapsed merely because dates are equal. All existing CF rules still apply. Duplicate rules for dcf.annual_cash_flows, noi_model.projections, lease_up_schedule.schedule and distribution_waterfall.stated_schedule are unchanged. PS-03 warns for a statically recognizable kind mismatch in custom calculation or scenario formula/base_formula fields under default section selection. When that selection is ambiguous, do not guess the cadence. Superseded history and arbitrary narrative strings are not reinterpreted. Existing LU/CF/WF structural and financial rules remain unchanged. These new diagnostics may expose missing period identities in older files; do not fabricate years or dates to clear them.
VIII.2b Stated period inputs in refinement (RFC 0042)
Refinement is an optional consumer; no tier requires it. A consumer implementing this extension MUST resolve period AST dependencies with §VIII.2a and preserve the complete selector identity. RankGapsOptions.periodContext MAY supply sectionVariants and full-path overrides under CalcEvaluationContext semantics, for period dependencies only. Validate the registry/selector before an own-key override; null is explicit, and an override bypasses document lookup. Unrelated override keys do not alter scalar cascade resolution.
Finite numeric period inputs MUST remain fixed during ordinary gap perturbation. Period dependencies MUST NOT enter the default cascade or acquire inferred ranges, including from profile, inheritance or market providers. A missing value is not zero. Scalar resolution, range arithmetic and normalization remain unchanged.
For each selected output and distinct unresolved period dependency, return a PeriodRefinementIssue with output_id, canonical field_path, code, and human-readable message. Null/absent/kind-mismatched or blocked values use REFINE-PERIOD-MISSING; strings, booleans, objects, arrays and nonfinite numbers use REFINE-PERIOD-NONNUMERIC. Resolver failures retain CALC-PERIOD-001, CALC-PERIOD-002 or CALC-PERIOD-003. Do not suppress unexpected internal errors. The type, schema, and these codes describe the same contract; message wording is not stable machine data.
Expose these issues as diagnostics.period_inputs, in target order and first reference occurrence order, including when there are no rankable ordinary gaps. Omit the member when selected formulas contain no period references; otherwise include an array, empty on successful resolution. Exclude affected outputs from numeric ranking while preserving unaffected outputs. An empty ranking with issues MUST NOT be represented as proof of complete inputs. The historical diagnostics.resolved count remains the number of distinct dependency entries processed, not the number of finite values.
A period leaf is supported by the numeric interpreter; other unsupported AST operations retain their existing diagnostics and behavior. This extension does not improve the existing perturbation approximation, define period defaults or stochastic VOI, or change evaluateCalc's quantization boundary.
VIII.2c Contextual workbook bindings (RFC 0043)
The optional numeric workbook consumer binds a canonical period reference to a PeriodExcelBinding. A series binding names aligned key/value ranges and a whole-series validity cell; an override binding names an explicit override input. A normal namedRanges entry alone is insufficient. Hosts MUST validate the reference registry and selector before emission, including overrides. Names MUST be workbook-scoped identifiers rather than cell addresses, formulas, or external links. The binding is a trusted host assertion about this workbook; the string emitter cannot inspect or authenticate workbook contents.
Series bindings MUST use canonical identities and exact lookup, independent of row order. Before lookup, the validity cell MUST reject duplicate, blank or changed identities across the complete bound series. This stage exports a closed identity set: editing values and reordering rows is supported; adding/removing periods requires editing the source document and re-exporting. A protected or hidden registry is not a security boundary; deliberate workbook tampering is outside this edit contract. Blank source series are valid empty sets.
resolvePeriodColumn returns a PeriodColumnSnapshot using §VIII.2a role selection and full-series validation, projecting one literal leaf without rounding values. Missing sections yield an empty set. Generic/explicit variants are preserved; malformed/duplicate/unresolvable source series raise the existing CALC-PERIOD errors. Safe traversal and literal key rules remain unchanged.
Missing periods and blank values MUST yield #N/A in Excel. Invalid identity sets and nonnumeric inputs MUST yield #VALUE!, never coerced zero. Finite numeric zero is valid. Explicit own-key overrides bypass source lookup after reference validation, including null-as-missing, under §VIII.2a semantics. Numeric expression results MUST be rounded once at the existing §VIII.5 boundary.
The reference converter exports only explicitly requested custom-calculation IDs through toWorkbook(parsed, { calculations, calculationContext }). The context selects variants for period inputs and supplies exact full-path overrides. It does not alter existing pack sheets. The first custom exporter supports numeric literals, paths, period paths, unary minus and arithmetic +, -, *, /; other AST nodes explicitly refuse. Ordinary input snapshots use calc resolution without default inference or boundary rounding. Shared inputs are written once and referenced by formulas. No general calc-result chaining is implied.
Default toWorkbook output is unchanged. Additional period/custom input edits are export-only in this stage: reverse import MUST refuse marked extended workbooks, rather than silently discarding those edits. Excel 2016-compatible functions (IF, INDEX/MATCH exact, COUNTIF, SUMPRODUCT/EXACT, ISBLANK, ISNUMBER, VALUE, NA, ROUND) implement the reference bindings. Native recalculation tests are required to establish supported-case parity; formula snapshots alone are insufficient.
VIII.3 Built-in functions
| Name | Signature | Notes |
|---|---|---|
sum(...nums) | (number|null)[] → number | Nulls treated as 0. |
avg(...nums) | (number|null)[] → number|null | Null if no non-null inputs. |
min(...nums) | (number|null)[] → number|null | |
max(...nums) | (number|null)[] → number|null | |
coalesce(...args) | (any)[] → any | First non-null. |
if(cond, then, else) | (bool, any, any) → any | |
round(num, dec) | (number, number) → number | Half-away-from-zero. |
abs(num) | (number|null) → number|null | Absolute value; null propagates. |
floor(num) | (number|null) → number|null | Round toward -∞; null propagates. |
ceil(num) | (number|null) → number|null | Round toward +∞; null propagates. |
sqrt(num) | (number|null) → number|null | Negative input raises CALC-TYPE-001. |
pow(base, exp) | (number|null, number|null) → number|null | base ** exp. Non-finite result raises CALC-TYPE-001. |
log(num) | (number|null) → number|null | Natural log. Non-positive input raises CALC-TYPE-001. |
exp(num) | (number|null) → number|null | e^num. Non-finite result raises CALC-TYPE-001. |
pmt(rate, n, pv) | Standard mortgage payment formula. | |
fv(rate, n, pmt[, pv]) | Future value of a series of equal payments + initial pv. | |
pv(rate, n, pmt[, fv]) | Present value of a series of equal payments + future value. | |
nper(rate, pmt, pv[, fv]) | Number of periods to pay down pv with pmt payments. Closed-form; see the note below. | |
npv(rate, ...flows) | Net present value. | |
irr(...flows) | Internal rate of return; null if no real root. See the convergence note below. |
Closed-form functions (normative). pmt, fv, pv, and nper MUST be evaluated in closed form — nper as log((pmt - fv·rate) / (pmt - pv·rate)) / log(1 + rate), and the others by the formulas in §VIII.3's table. nper in particular is solved iteratively in some formulations, and an implementation that does so inherits exactly the reproducibility problem the irr procedure below exists to remove. irr — and xirr (§VIII.9.3), which runs this same procedure over date-derived exponents — are the only builtins permitted to iterate.
irr convergence (normative). irr(...flows) MUST return the root computed by the following procedure, which is defined so that any implementation using IEEE 754 binary64 arithmetic in the stated order produces bit-identical results. Introduced by RFC 0024 in protocol 1.4.0.
Let npv(r) = Σ flows[t] / (1 + r)^t for t = 0 … n-1.
- Domain. The search interval is
lo = -0.999,hi = 10.0(i.e. -99.9% to 1000%). A root outside it is not reported; see step 5. - Bracket. Evaluate
npvatloandhi. If either is non-finite, or ifnpv(lo) * npv(hi) > 0, the procedure fails — see step 5. - Endpoint root. If
npv(lo)is exactly0, returnlo; ifnpv(hi)is exactly0, returnhi. This precedes step 4 because bisection cannot reach it: the retention test in step 4 is a product with the endpoint value, and an endpoint value of zero makes that product zero for every midpoint. - Bisection. Bisect for at most 200 iterations, stopping when
|npv(mid)| < 1e-9or(hi - lo) / 2 < 1e-12, whichever comes first, withmid = (lo + hi) / 2evaluated inbinary64. Retain the half whose endpoints bracket the sign change, comparingnpv(lo) * npv(mid) < 0. The midpoint at which a stopping condition is met is the result. - No polish. The bisection result is the answer. An implementation MUST NOT refine it with Newton's method or any other step: Newton's iterates depend on a derivative evaluation order this document does not pin, and the refinement it buys is below the quantization boundary of §VIII.5.
- Failure. If step 2 fails to bracket, or step 4 exhausts its iterations without meeting a stopping condition, raise
CALC-IRR-DIVERGE. An engine MUST NOT substitute a root found outside[lo, hi].
Two consequences an implementer must not read past:
- A cash flow with an even number of roots in
[lo, hi]raises, rather than returning one of them.npvhas the same sign at both endpoints in that case, so step 2 finds no bracket:irr(-100, 230, -132)raisesCALC-IRR-DIVERGEeven though0.1and0.2are both roots. This is intended. A cash flow with several sign changes has no single internal rate of return, and a host SHOULD surface that as a modeling problem rather than a number. - A root exactly at
lois not a well-definedbinary64quantity.1.0 + (-0.999)is0.001000000000000001, not0.001, sonpv(lo)for a cash flow whose exact root is -99.9% lands a few ULP either side of zero, and its sign decides whether step 2 brackets at all. Step 3 is therefore reachable athi, where1.0 + 10.0is exact, and effectively unreachable atlo. Callers SHOULD NOT build on behavior at the low endpoint.
xirr and day-count conventions remain deferred to v2.
VIII.4 Determinism
Calc hosts MUST be deterministic across runs and platforms. No function in the standard library may consult system time, environment variables, or randomness. Floating-point operations MUST follow IEEE 754 double precision. Determinism of the reported value additionally requires the quantization rule in §VIII.5.
VIII.5 Numeric model
Evaluation and reporting are separate concerns, and this section fixes the boundary between them. Without it, two conforming hosts agree on every arithmetic step and still disagree in the last unit in the last place — which is invisible to a tolerant comparison and fatal to a digest. UW Receipts (UW_RECEIPT_v1.md) hash calc outputs, so an unstated precision is an unstated interoperability contract.
Evaluation. A host MUST evaluate expressions in IEEE 754 binary64 and MUST NOT round intermediate values. Rounding inside an expression happens only where the author asked for it with round(num, dec).
Reporting. The value a host reports for a calculation MUST be quantized to that calculation's effective decimal places, using half away from zero — the same rule round() implements and the same rule spreadsheet ROUND implements, which is what makes the two comparable. -2.5 at 0 places is -3, not -2.
Effective decimal places are the declaration's round_to when it states one; otherwise the default for its unit:
unit | Default round_to | Rationale |
|---|---|---|
$ | 2 | Money is quantized to cents. |
% | 6 | Rates are fractions (0.0551), so six places on the fraction is four on the percentage a reader sees. |
x | 4 | The precision lender term sheets quote ratios such as DSCR at. |
| absent, or any other value | 6 | Residual case; matches the rate default rather than introducing a second convention. |
There is deliberately no "unspecified precision" mode: the table is total, so every declaration has an effective precision whether or not its author thought about one.
round_to MUST be an integer in [0, 12]. Past roughly fifteen significant decimal digits a binary64 carries no fractional information left to quantize, so a larger value would state a precision the representation cannot hold.
Quantization is not display. A host's display string is a presentation concern and MAY apply its own locale, symbol, and precision (§ DEFAULT_NUMBER_FORMAT). Digests, equality checks, and Excel parity are defined over value, never over display.
Digests. Any digest taken over calc outputs — a receipt's results_digest in particular — MUST be taken over quantized values. A host that hashes unquantized results produces digests that do not reproduce across platforms.
Implementation note. Quantizing by multiplying by
10 ** decreintroduces the artifact it is meant to remove:1.005 * 100is100.49999999999999, so that formulation yields1.00where spreadsheetROUNDyields1.01. Shifting through a decimal string (Number("1.005e2")→100.5) is correctly rounded and agrees. Seepackages/uwmd-core/src/calc/quantize.ts.
VIII.6 CalcError taxonomy
| Code | Meaning |
|---|---|
CALC-PARSE-001 | Expression failed to parse against the grammar. |
CALC-RESOLVE-001 | Identifier could not be resolved. |
CALC-TYPE-001 | Operator applied to incompatible types. |
CALC-DIV-ZERO | Division by zero. |
CALC-IRR-DIVERGE | IRR did not converge. |
CALC-LIMIT-001 | Expression exceeded host complexity limits. |
CALC-SENS-001 | Sensitivity axis missing or has no variable (§VIII.7.4). |
CALC-SENS-002 | Sensitivity axis has fewer than two values, or a non-finite one. |
CALC-SENS-003 | Sensitivity grid exceeds the cell or per-axis bound. |
CALC-SENS-004 | Both sensitivity axes vary the same variable. |
CALC-SENS-005 | Sensitivity round_to is out of range. |
CALC-STOCH-001 | Stochastic declaration has no integer seed (§VIII.8.5). |
CALC-STOCH-002 | Stochastic samples outside [2, 100000]. |
CALC-STOCH-003 | A stochastic input path or distribution is malformed, or a variable is drawn twice. |
CALC-STOCH-004 | summarize is empty or names an unknown statistic. |
CALC-STOCH-005 | Stochastic declaration has no random inputs. |
CALC-STOCH-006 | Stochastic round_to is out of range. |
VIII.7 Sensitivity tables (RFC 0007)
A sensitivity table is one base formula evaluated once per (row, column) pair, with the two axis variables overridden. It is the thing every underwriter builds by hand in Excel, and the alternative — N×M custom_calculations blocks, one per cell — bloats the document, hides the axis design from any reader, and forces every consumer to re-derive the grid's structure from calc-id naming conventions.
VIII.7.1 A declaration, not a function
A sensitivity table is declared in JSON, not written as a call inside the §VIII.1 grammar:
{
"id": "exit_value",
"label": "Exit Value",
"base_formula": "noi_model.net_operating_income / dcf.exit_cap_rate",
"row_axis": { "variable": "dcf.exit_cap_rate", "values": [0.05, 0.06, 0.075] },
"col_axis": { "variable": "noi_model.net_operating_income", "values": [600000, 660000] },
"unit": "$",
"round_to": 2
}This is deliberate. A sensitivity_table(expr, {…}, {…}) builtin would need object literals, array literals, and a string argument that is executed as a program — three extensions to a grammar whose narrowness is the reason it can be evaluated on untrusted input at all. The axis data is already JSON one level up, so the grammar buys nothing by reaching it.
base_formula is an ordinary safe expression. The sandbox is unchanged.
VIII.7.2 Overrides
Evaluation uses overrides: values keyed by full dotted path, exactly as an expression writes them (dcf.exit_cap_rate, not exit_cap_rate), consulted ahead of frontmatter, sections, and prior results.
Two properties are normative:
- Overrides shadow, they never write. After a sweep, the document reads exactly as it did before. A sweep that mutated the document would silently change the deal.
- A
nulloverride means "treat this path as absent", and is distinct from having no override. Collapsing the two would make it impossible to ask what a formula does when an input goes missing.
Overrides are general, not sensitivity-specific: scenario sweeps and stress tests need the same mechanism.
VIII.7.3 Results
The result is its own type, and MUST NOT be carried in CalcResult.value, which stays number | string | boolean | null. Receipts pin that union (§VIII.5), the CLI renders it, and the Excel emitter emits from it; a grid smuggled through it would break all three for a feature none of them asked to carry.
grid[row][col] follows the axes' declared order. Each cell is either a value or the error that stopped it, never both.
A failed cell does not fail the table. A grid where one combination divides by zero is still a useful grid, and refusing the whole thing would hide the cells that worked. The result reports failed_cells so a consumer can say how much of the table is real.
VIII.7.4 Refusals
A declaration that cannot produce a meaningful grid is refused before any cell is evaluated:
| Code | Trigger |
|---|---|
CALC-SENS-001 | An axis is missing, or its variable is empty. |
CALC-SENS-002 | An axis has fewer than two values, or a non-finite one. |
CALC-SENS-003 | The grid exceeds 256 cells, or an axis exceeds 64 values. |
CALC-SENS-004 | Both axes vary the same variable. |
CALC-SENS-005 | round_to is outside [0, 12] or not an integer. |
Two of these are worth the words:
- One value is not a sweep (
CALC-SENS-002). A degenerate 1×N grid is something every consumer would then have to special-case. - Two axes on one variable (
CALC-SENS-004) is not a redundancy but a trap: the second override silently wins for every cell, producing a grid whose rows are identical and whose reader has no way to see why.
The 256-cell bound keeps a host's evaluation cost predictable when the declaration comes from a document it did not write. Three-axis (cube) tables are out of scope; a follow-up RFC can add them if the case appears.
VIII.7.5 Host obligations
A Tier-3 host that does not implement sensitivity tables MUST report a typed refusal rather than crash or silently skip. A host that does implement them MUST produce the same grid as any other host for the same document and declaration — the base formula is deterministic, the axes are literals, and quantization is §VIII.5, so there is nothing left to disagree about.
VIII.8 Stochastic calculations (RFC 0005)
Underwriting reasons about ranges: "DSCR is 1.45 base case but 1.20–1.65 across rate paths." Today those numbers are computed elsewhere and pasted in as plain values, losing the model behind them, or approximated as best/base/worst — which renders nicely and cannot answer "what is the probability this underwrites above our minimum DSCR?"
A stochastic calculation declares the inputs that vary, the distribution each is drawn from, and a seed. The result is a distribution summary.
VIII.8.1 A declaration, not built-ins
Sampling is not a function inside the §VIII.1 grammar. RFC 0005 proposed uniform(), normal(), triangular(), and monte_carlo(expr, n); each is disqualifying on its own terms:
- Every builtin is a pure function of its arguments. A sampling builtin carries PRNG state, and "the calc engine is pure" is what makes a formula auditable.
monte_carlo(expr, n)needs a lazy argument, but arguments are evaluated eagerly — so it becomes either a special-cased builtin or a string executed as a program.- A call whose legality depends on the enclosing declaration's
deterministicflag is a context-sensitive grammar, checked by a deliberately context-free parser.
Instead the inputs are declared, and each draw is an ordinary evaluation using the override mechanism of §VIII.7.2. The grammar and the built-ins are untouched.
{
"id": "dscr_distribution",
"label": "DSCR distribution",
"base_formula": "noi_model.net_operating_income / debt_structure.annual_debt_service",
"inputs": [
{ "variable": "noi_model.net_operating_income",
"distribution": { "kind": "uniform", "min": 540000, "max": 660000 } },
{ "variable": "debt_structure.annual_debt_service",
"distribution": { "kind": "normal", "mean": 400000, "stddev": 15000 } }
],
"samples": 2000,
"seed": 42,
"summarize": ["mean", "median", "p10", "p90", "stddev"]
}seed is required. A stochastic calc without one produces a different answer every run, which is the opposite of what this format exists to guarantee (CALC-STOCH-001).
Input order is part of the contract. One PRNG stream feeds the inputs in declared order, so reordering the list changes every sample. The alternative — a stream per input — would make output depend on a hidden per-input keying rule instead, which is worse because it is invisible.
VIII.8.2 The PRNG
Normative: PCG-XSL-RR-128/64 (pcg64), seeded as the reference srandom does — zero the state, step, add the seed, step again. A host that merely assigned the seed to the state would produce a different stream from every correct implementation while still being perfectly deterministic, and so would pass any self-consistency test.
Uniform doubles take the top 53 bits of a draw divided by 2⁵³. Not 64 bits over 2⁶⁴: that rounds, so two distinct draws can map to one double and a third can reach exactly 1.0.
Specifying the algorithm rather than naming a library is deliberate — a library's version would become part of the determinism contract.
VIII.8.3 Distributions and exactness
Every distribution is sampled by inverting its CDF at a uniform draw. This is about determinism, not elegance. IEEE 754 exactly specifies + − × ÷ and sqrt; it does not specify log, exp, sin, or cos, whose last-place results differ between platforms. So Box-Muller (log, cos) and Marsaglia polar (log) are unusable: they produce samples that agree to fifteen digits across hosts and disagree in the sixteenth.
| Kind | Parameters | Cross-host exactness |
|---|---|---|
uniform | min, max | Exact — arithmetic only. |
triangular | min, mode, max | Exact — arithmetic and sqrt. |
normal | mean, stddev | Not exact in the tails. |
normal uses Acklam's rational inverse-CDF approximation (relative error ≈ 1.15 × 10⁻⁹). Its central region — about 95% of draws — is arithmetic only and therefore exact; both tails need log, so two hosts may disagree in the last place there.
That limitation is stated rather than hidden. The alternative was to omit the normal distribution, which is worse: it is the one adopters reach for. Conformance therefore compares uniform and triangular results exactly and normal-derived results at a stated tolerance. Naming which distributions are exact is more useful than a blanket tolerance that conceals the difference.
triangular's parameters are exactly the low/central/high the asset-class default tables already carry, so an adopter can turn existing napkin-mode ranges into distributions without inventing any numbers.
VIII.8.4 Summaries
Percentiles use nearest-rank, never interpolation. For n sorted samples, the k-th percentile is sorted[ceil(k/100 × n) − 1]. A percentile is therefore an observed sample, which makes it exactly reproducible whenever the samples are; interpolating between neighbours adds an arithmetic step two hosts can round differently, for a number nobody reads to that precision.
stddev is the sample standard deviation (n − 1). A single draw has no spread, and reporting 0 would claim a certainty the run does not have.
A draw whose formula does not evaluate to a finite number is counted in failed_samples and excluded from the summary — not folded in as zero, which would drag every statistic toward it. When every draw fails, each requested statistic is present and null; an absent key would be indistinguishable from one the declaration never requested.
Raw samples are withheld unless return_samples is set. 100,000 samples inline would make a document unreadable.
VIII.8.5 Refusals and capability
| Code | Trigger |
|---|---|
CALC-STOCH-001 | seed missing or not an integer. |
CALC-STOCH-002 | samples outside [2, 100000]. |
CALC-STOCH-003 | An input path is empty, drawn twice, or its distribution is malformed. |
CALC-STOCH-004 | summarize is empty or names an unknown statistic. |
CALC-STOCH-005 | No random inputs. |
CALC-STOCH-006 | round_to out of range. |
Drawing one variable twice (CALC-STOCH-003) is refused rather than tolerated: the later draw silently wins for every sample while the earlier one still consumes the stream, so the distribution is wrong in a way no output reveals.
A host that evaluates stochastic declarations declares calc-stochastic in its ImplementationManifest.capabilities. One that does not MUST report a typed refusal rather than crash or silently return a point estimate.
VIII.9 Calendar-anchored cash flows (RFC 0034)
The format's cash_flow_series section (format spec §4.26) states dated, irregular flows. This section fixes the arithmetic two engines must agree on when they read one: how a pair of dates becomes a year fraction, how a rate discounts a dated flow, and how the one searched number (xirr) is found. Everything here is reachable only through a declaration or the verifier (§VIII.9.4) — the §VIII.1 grammar and the §VIII.3 expression-callable table are unchanged.
VIII.9.1 Day-count registry (normative, closed)
Mirrored in @uwmd/core as DAY_COUNT_CONVENTIONS / yearfrac. For calendar dates d1 ≤ d2 (proleptic Gregorian):
actual/365f—yearfrac = actualDays(d1, d2) / 365, whereactualDayscounts calendar days between the dates. Leap days count as days; the denominator is always 365 (the "fixed" in 365F).actual/360—yearfrac = actualDays(d1, d2) / 360.30/360us— exactly the ExcelDAYS360U.S. method, pinned so implementers do not reach for one of the other NASD variants: with date parts(y1, m1, dd1)and(y2, m2, dd2)— ifdd1 = 31setdd1 = 30; then ifdd2 = 31anddd1 = 30setdd2 = 30;days = (y2 − y1)·360 + (m2 − m1)·30 + (dd2 − dd1);yearfrac = days / 360. There is no February special-casing — the NASD end-of-February adjustments are deliberately excluded, and the divergence from "full NASD" is documented here rather than discovered in a receipt mismatch.
yearfrac MUST be evaluated in binary64 with the division performed last (days is an exact integer in every convention). An unknown convention MUST be refused, never defaulted.
VIII.9.2 xnpv (normative, closed-form)
Over a series with anchor date d0 (the first row's date, §4.26 CF-02) and rate r > −1:
xnpv(r) = Σᵢ amountᵢ × (1 + r) ^ (−tᵢ) tᵢ = yearfrac(d0, dᵢ, day_count)Terms MUST be accumulated in series order, left to right (the §VIII.5 posture on association order). r ≤ −1 raises CALC-TYPE-001.
VIII.9.3 xirr (normative)
The root of xnpv(r) = 0, computed by the §VIII.3 irr procedure verbatim with npv replaced by xnpv: bracket over [-0.999, 10.0]; return an exact high-endpoint root; bisect to |xnpv(mid)| < 1e-9 or a half-interval below 1e-12, whichever comes first, capped at 200 iterations; no Newton polish. Failure to bracket or to converge raises CALC-XIRR-DIVERGE. The §VIII.3 note on the low endpoint carries over unchanged: a root at exactly -0.999 is not a well-defined binary64 quantity and is not owed.
irr and xirr are the only builtins permitted to iterate (§VIII.3).
VIII.9.4 Reachability, declarations, and quantization
Neither function enters the §VIII.1 grammar. They are reachable only through:
verifyCashFlowSeries— the §4.26 three-state verifier, which recomputes each stated metric and compares at the quantization boundary (§VIII.5), andCashFlowMetricDecl— a JSON declaration, the §VIII.7/§VIII.8 pattern:
{
"id": "levered_xirr",
"label": "Levered XIRR",
"series_path": "cash_flow_series",
"variant": "base",
"metric": "xirr",
"unit": "%"
}metric is closed: xirr | xnpv | moic | total_net; xnpv requires a rate (a fraction). The result is one quantized number in an ordinary CalcResult — CalcResult.value is not widened and no result carries a date. CalcEvaluationContext.overrides applies: a caller MAY shadow an individual row (cash_flow_series.series[7].amount) without touching the document, the same shadowing contract sensitivity sweeps use. Quantization follows the §VIII.5 unit defaults ($ → 2, % → 6, x → 4).
A declaration naming a missing, malformed, or wrong-variant series raises CALC-CF-SERIES. A host that evaluates cash-flow declarations declares calc-cash-flow in its ImplementationManifest.capabilities; one that does not MUST report a typed refusal rather than crash.
VIII.9.5 Explicit lease-up cash-flow projection (RFC 0044)
projectLeaseUpCashFlows(parsed, plan) asynchronously returns a LeaseUpCashFlowProjection candidate. The plan MUST state source_variant, registered day_count, and non-empty cash_dates: [{ period, date }]; other plan or mapping fields MUST be refused. Selection MUST use the exact variant (including eligible component roles), with no base/default fallback. Flat and Format 2.0 wrapped section payloads use the existing period-selection contract.
The selected schedule MUST satisfy RFC 0008 LU-01/02/03 structural rules and verifyLeaseUpSchedule(schedule, leaseUpContext(parsed)) MUST return verified. Every row MUST state finite numeric net_cash_flow; zero is valid, absent/null is not zero. The adapter MUST copy that exact binary64 value, without rounding or substituting the recomputed total. Verification retains its existing quanta.
Every source period MUST map exactly once to a real YYYY-MM-DD cash date. Missing, extra and duplicate mappings MUST refuse. Mapping array order is irrelevant; output follows source period order, in which dates MUST be non-decreasing. Cash dates are caller assertions and MAY lie outside their accrual periods. Same-day rows MUST remain separate, so ordinary date selectors continue to refuse duplicate-date ambiguity. No synthetic anchor row is added.
Output contains source_envelope_digest from the complete semantic document envelope (including superseded blocks), exact source_variant, series, and bindings: [{ source_path, date, amount }]. The canonical source path is lease_up_schedule.schedule@<period>.net_cash_flow; the outer variant applies to every binding. Digest and values MUST describe the same source snapshot. The series label MUST be Lease-up receipts and TI/LC only; each row uses kind: "other" and its source path as label. stated_metrics MUST be omitted. Net cash flow here covers rent revenue, concessions and TI/LC capex only. The operation MUST NOT infer operating expenses, acquisition, debt, disposition, NOI or complete investment returns. It MUST NOT write documents or mutate _meta.
Refusals use LeaseUpCashFlowProjectionError.proto, a serializable LeaseUpCashFlowProjectionIssue with category calc, code CALC-LU-PROJECTION, message and pointer. Plan pointers begin plan; source pointers identify the selected section and variant. Optional evidence retains the original selection ProtocolError, structure ValidationMessage array or verification LeaseUpVerification. Failed and unverifiable verdicts MUST remain distinct; no nested diagnostic is renamed. The three normative schemas are lease-up-cash-flow-plan, lease-up-cash-flow-projection, and lease-up-cash-flow-projection-issue under spec/schemas/.
VIII.9.6 Explicit property cash-flow assembly (RFC 0045)
1. First scope and economic boundary
This opt-in assembler applies the following requirements when invoked. It is not mandatory at any pipeline stage.
The plan MUST state basis: "unlevered" and tax_basis: "pre_tax". Amounts represent cash paid or received by a hypothetical owner funding the whole asset without debt. Property taxes belong in operating expenses; investor income and capital-gains taxes do not. Debt draws, principal, interest, financing fees, refinancing proceeds and investor allocations MUST NOT enter this stream. Existing debt sections may remain in the document; they are not assembly inputs.
The plan MUST identify one currency using an uppercase three-letter currency_code, and MUST carry an explicit same-currency assertion for each selected source variant. Exact string equality is required. This is an author assertion of denomination, not validation against a new currency registry. No display symbol, locale, property location, exchange rate or decimal count establishes currency identity. No conversion or scaling is performed. A missing or conflicting assertion refuses. Documentary evidence of currency remains a host review responsibility; the assembler cannot infer it from bare numbers.
Amounts MUST already be signed cash amounts: receipts positive, payments negative. The assembler copies values without negation, proration, rescaling, netting or intermediate rounding. An accrual expense or amortization charge is not an acceptable substitute for its cash payment.
2. Source contract
The additive entry point is assemblePropertyCashFlows(parsed, plan): Promise<PropertyCashFlowAssembly>. It accepts one parsed document, one exact lease_up_schedule variant and one distinctly selected supplemental cash_flow_series variant from that document. There is no default variant, fallback role, cross-document input or override map.
The lease-up source MUST pass projectLeaseUpCashFlows under the caller's complete explicit cash-date plan. Preserve its failed/unverifiable distinctions and nested evidence. The assembler MUST invoke the adapter against the supplied document rather than trust a previously serialized projection object.
The supplemental series MUST satisfy the existing §4.26 structural rules, including finite amounts and real, nondecreasing dates. Its explicitly stated day count MUST equal the plan's registered day count. If it states metrics, their existing verifier MUST return verified; absent metrics make no claim about completeness. A successful metric verifier alone is not a structural check.
Every supplemental row MUST be assigned exactly once by zero-based row_index to one coverage cell. Indexes refer to that specific series in the digested source snapshot. This is an assembly-plan binding, not new expression syntax or an RFC 0041 date selector: same-date rows remain individually addressable. Each binding carries its canonical source path, exact variant, date and amount in the result. Unknown fields, invalid indexes, unused rows and repeated source row identities refuse. Advisory kind labels cannot substitute for coverage.
General dcf, NOI and valuation totals are not directly accepted sources in this first adapter. The author must state separately covered cash rows in the supplemental series, with provenance. In particular, never append NOI or a levered/net terminal total to the lease-up bundle as another income component.
3. Coverage ledger
A coverage cell is the exact pair (slot, category). slot is either an absolute source period, acquisition or disposition. Required cells are:
| Slot | Required categories | Ownership |
|---|---|---|
| Every lease-up period | rent, concessions, ti_lc | Automatically owned together by that period's RFC 0044 row. |
| Every lease-up period | other_income, operating_expenses, other_capex, reserve_net | Supplemental rows or explicit zero declaration. |
acquisition | purchase_price, transaction_costs, reserve_net | Supplemental rows or explicit zero declaration, subject to the purchase rule below. |
disposition | gross_sale, transaction_costs, reserve_net | Supplemental rows or explicit zero declaration. |
For each cell not owned by lease-up, the plan MUST provide exactly one of:
rows: a nonempty list of distinct supplemental row indexes; orzero: a nonempty author explanation that no cash flow applies to that cell.
A cell may contain multiple cash rows at different dates. A row cannot cover multiple cells. Duplicate cell declarations, a row and zero declaration for the same cell, unknown categories, missing cells or attempts to bind lease-up-owned cells refuse. A numeric zero source row remains a real row; null or omission is never zero. A zero declaration produces coverage evidence, not a synthetic row.
The purchase cell MUST contain at least one negative payment and MUST NOT be declared zero. All purchase rows MUST be negative. gross_sale and other_income rows MUST be nonnegative; transaction_costs, operating_expenses and other_capex rows MUST be nonpositive. Refunds are stated separately as other_income, with their category explained in provenance. reserve_net may have either sign. The existing lease-up verifier governs the mixed signed bundle; no new component-sign rule is retrofitted onto RFC 0044.
other_income MUST exclude rent and concessions. other_capex MUST exclude TI/LC. gross_sale MUST exclude costs of sale, debt payoffs and separately returned reserves. Transaction costs MUST be separately stated cash payments, not recomputed from a percentage. Vacancy already reflected in stated rent is not a second expense. None of these exclusions is inferred from row labels.
4. Reserve treatment and limits of double-count checks
The reserve boundary is cash available to the hypothetical owner: funding a restricted reserve is an outflow, and releasing cash to that owner is an inflow. reserve_net contains those external transfers. Spending inside the funded reserve is not another owner outflow. If reserve-funded work is also included in lease-up TI/LC or a supplemental expense/capital row, the plan MUST be refused; this first scope cannot split or adjust the lease-up bundle.
The plan MUST explicitly assert that reserve-funded spending is excluded from all other selected cash amounts, and that gross sale excludes separately returned reserves. It MUST also state that no restricted reserve remains after disposition. If the author cannot make those assertions, assembly is unsupported; do not assume a zero balance or estimate a release. Even when reserves do not apply, the corresponding coverage cells need explicit zero explanations.
Structural checks catch duplicate identities and declared coverage overlaps. They cannot prove that differently named rows do not contain the same economic payment, or that an authored zero is true. The result MUST distinguish coverage: "declared_complete" from financial verification. Source digests and author assertions are audit evidence, not proof of economic completeness.
5. Dates, valuation anchor and terminal proceeds
The lease-up source supplies the complete, gap-free monthly or quarterly period set. The plan MUST state an acquisition date inside the first source period and a disposition date inside the last source period, with acquisition strictly before disposition. The first implementation covers that full hold only. Partial periods require explicitly authored cash amounts; no proration occurs.
All cash dates MUST lie within that closed acquisition/disposition interval. Acquisition-slot rows MUST occur on the acquisition date, and disposition-slot rows MUST occur on the disposition date. Period-slot rows retain explicitly stated cash dates, which may differ from the period of accrual. The author MUST assert that the stated amounts cover only the declared hold and include any settlement of receivables/payables at exit. A cash lag beyond disposition refuses; extending the post-sale settlement horizon requires a later contract.
Sort output by date, breaking ties by lease-up source order first, then supplemental row index. Preserve every row separately and copy amounts exactly. The required purchase payment makes acquisition the first actual cash date. No synthetic zero is inserted to move the RFC 0034 anchor.
gross_sale is a stated cash receipt. The assembler MUST NOT derive it from trailing or forward NOI, a cap rate or the existing deriveDCF helper. Net exit cash emerges only from the separately stated rows; no extra net-proceeds row may be appended. A zero exit recovery needs its own explicit zero explanation.
5a. Named sale deductions and net proceeds (RFC 0052)
The plan MAY carry sale_deductions, naming each cash row bound to the (disposition, transaction_costs) cell from a closed vocabulary. The unlevered names are broker_commission, transfer_tax, title_and_escrow, legal_and_closing, seller_credits, survey_and_diligence and other. An other entry MUST carry a nonempty author label; any other name MUST NOT, because the name already states what the amount is.
prepayment_penalty, defeasance and loan_payoff are reserved in the same vocabulary and MUST be refused here: they are levered, below-NOI amounts, and this assembly is unlevered and pre-tax. Reserving rather than omitting them keeps a later levered contract from colliding with adopter usage and turns a basis violation into an explained refusal rather than a silent one.
When sale_deductions is present it MUST name every row covering that cell, exactly once, and MUST NOT name a row the cell does not cover. Partial naming refuses: a rollup over some deductions looks complete and is worse than none. Omitting sale_deductions entirely remains valid. Names carry no sign rule of their own; the existing nonpositive transaction_costs rule governs every row they name, seller_credits included, and naming changes no amount, date or output position. Each deduction remains its own dated §4.26 row.
The plan MAY also carry net_sale_proceeds, a stated figure the assembler verifies and MUST NOT derive. Verification sums the gross_sale and transaction_costs rows at the disposition slot and compares against the stated figure, both sides quantized at the §VIII.9.4 currency quantum per §VIII.5. The check spans the whole transaction_costs cell rather than only the named rows, so it cannot be satisfied by leaving a deduction unnamed. It excludes reserve_net: a returned reserve is not sale proceeds. Stating net_sale_proceeds without a gross_sale row refuses; omitting it leaves the result's status not_stated. A verified figure is evidence about stated amounts, never proof that the deductions are complete or the exit is real.
6. Candidate result and metric interpretation
New public types are PropertyCashFlowPlan, PropertyCashFlowAssembly and PropertyCashFlowAssemblyIssue, exported with the function from core and browser. Result fields MUST include:
- Whole-source semantic envelope digest using the existing digest contract.
- The explicit basis, tax basis, currency, acquisition/disposition dates and day count; a copy of the plan's economic assertions and zero explanations.
- A
CashFlowSeries, labelledUnlevered pre-tax property cash flow, withoutstated_metrics; source bindings in output-row order and each cell's ownership. coverage: "declared_complete"and separate source verification evidence.
The wrapper carries economic meaning; the unchanged §4.26 series alone does not encode currency or basis. Consumers MUST retain the wrapper when displaying or comparing results. This RFC does not define persistence of that wrapper in a standard section, a signature profile, or a way to label it equity cash flow.
Callers may evaluate existing metrics over the candidate using explicit declarations. With acquisition included, xnpv is net present value of the stated investment stream at acquisition, not an appraisal value. A discount rate remains caller-supplied. Existing xirr bracket/convergence refusals and boundary quantization remain unchanged. No IRR, growth, tax, amortization or terminal-value formula is added. Same-day rows retain RFC 0041 selector ambiguity.
The operation MUST NOT write files, alter source blocks or _meta, accept changed source values as overrides, or emit partial successful output on refusal. Implementations MUST take a coherent source snapshot before asynchronous hashing.
7. Refusals
Use PropertyCashFlowAssemblyError extends CalcError with a structured CALC-CF-ASSEMBLY issue. Distinct reasons MUST identify plan shape, source selection, source structure, source verification, coverage, basis/currency, amount/sign or date/horizon failures. Include plan/source pointers and preserve existing nested lease-up and cash-flow diagnostics. The wire contract below pins the reason enum and payload shapes; the implementation must register them with the normative schemas and protocol prose in the same commit.
8. Wire contract
The following types define the JSON contract; the schemas are property-cash-flow-plan, property-cash-flow-assembly and property-cash-flow-assembly-issue under spec/schemas/. All object shapes are closed (additionalProperties: false), including array entries. All members are required unless marked optional; optional members are omitted, never emitted as null. Strings used as variants must be nonempty. Zero explanations must contain at least one non-whitespace character; preserve their authored text without trimming. DayCountConvention, CashFlowSeries, ProtocolError, ValidationMessage, CashFlowVerification and LeaseUpCashFlowProjectionIssue retain existing types.
type PropertyCashFlowCategory =
| 'rent' | 'concessions' | 'ti_lc'
| 'other_income' | 'operating_expenses' | 'other_capex' | 'reserve_net'
| 'purchase_price' | 'transaction_costs' | 'gross_sale';
interface PropertyCashFlowCell {
slot: string; // acquisition, disposition, or exact source period
category: PropertyCashFlowCategory;
}
type PropertyCashFlowCoverage = PropertyCashFlowCell & (
| { rows: number[]; zero?: never }
| { zero: string; rows?: never }
);
interface PropertyCashFlowAssertions {
cash_amounts_only: true;
no_financing_or_investor_tax: true;
no_overlapping_economic_amounts: true;
reserve_spending_excluded: true;
gross_sale_excludes_reserve_release: true;
no_terminal_restricted_reserve: true;
hold_only_and_exit_settled: true;
}
type SaleDeductionName =
| 'broker_commission' | 'transfer_tax' | 'title_and_escrow'
| 'legal_and_closing' | 'seller_credits' | 'survey_and_diligence' | 'other'
// Reserved below-NOI names; refused by this unlevered assembler.
| 'prepayment_penalty' | 'defeasance' | 'loan_payoff';
interface SaleDeduction {
row: number; // a supplemental row covering (disposition, transaction_costs)
name: SaleDeductionName;
label?: string; // required when name is 'other', refused otherwise
}
interface PropertyCashFlowPlan {
basis: 'unlevered';
tax_basis: 'pre_tax';
currency_code: string; // ^[A-Z]{3}$; author-stated identity
day_count: DayCountConvention;
acquisition_date: string;
disposition_date: string;
lease_up: {
source_variant: string;
currency_code: string;
cash_dates: Array<{ period: string; date: string }>;
};
supplemental: {
source_variant: string;
currency_code: string;
};
assertions: PropertyCashFlowAssertions;
coverage: PropertyCashFlowCoverage[];
sale_deductions?: SaleDeduction[];
net_sale_proceeds?: number;
}
interface PropertyCashFlowBinding {
output_row_index: number;
source_section: 'lease_up_schedule' | 'cash_flow_series';
source_variant: string;
source_path: string;
date: string;
amount: number;
cells: PropertyCashFlowCell[];
}
type PropertyCashFlowCellEvidence = PropertyCashFlowCell & (
| { output_rows: number[]; zero?: never }
| { zero: string; output_rows?: never }
);
interface PropertyCashFlowAssembly {
source_envelope_digest: string;
plan: PropertyCashFlowPlan;
coverage: 'declared_complete';
series: CashFlowSeries;
bindings: PropertyCashFlowBinding[];
cells: PropertyCashFlowCellEvidence[];
source_verification: {
lease_up: 'verified';
supplemental_metrics: 'verified' | 'not_stated';
};
sale_deductions?: Array<{
output_row_index: number;
name: SaleDeductionName;
label?: string;
amount: number;
}>;
net_sale_proceeds:
| { status: 'verified'; stated: number; computed: number }
| { status: 'not_stated' };
}
interface PropertyCashFlowAssemblyIssue {
category: 'calc';
code: 'CALC-CF-ASSEMBLY';
reason: 'plan' | 'selection' | 'structure' | 'verification'
| 'coverage' | 'basis_currency' | 'amount_sign' | 'date_horizon'
| 'sale_deduction';
message: string;
pointer: string;
evidence?: {
selection?: ProtocolError;
lease_up?: LeaseUpCashFlowProjectionIssue;
structure?: ValidationMessage[];
verification?: CashFlowVerification;
};
}rows and output_rows contain nonnegative safe integer indexes, are nonempty and have no duplicate entries. The input coverage array is nonempty; its legal slot/category pairs are exactly the non-lease-up cells in VIII.9.6.3. Date strings must be real YYYY-MM-DD dates. The plan's lease-up map is the RFC 0044 map with the outer day count; there is no second nested day-count setting. Neither source's currency assertion is inferred from the other. Required assertions must be literal true; false, missing or unknown assertions refuse. These declarations do not make hidden economic overlap mechanically detectable.
The result's plan is a deep copy, preserving the caller's array order. No output object or array shares mutable references with a source or input plan. Source validation and copying must use one consistent snapshot; mutation of the caller's document or plan during the asynchronous call cannot change the returned amounts, evidence or digest. No plan digest is introduced by this RFC.
Result bindings follow output row order and their output_row_index equals their array index. Lease-up paths retain RFC 0044 canonical period paths; supplemental paths are cash_flow_series.series[N].amount. Source variant and whole-document digest qualify each path. Each lease-up binding owns exactly three cells in rent, concessions, ti_lc order. Each supplemental binding owns exactly one cell. Every binding's date and amount match its output row.
Result cells are ordered acquisition first, source periods in source order, then disposition. Within each slot, use the category order printed in §3's table. output_rows are ascending output indexes. Input coverage entry order and order within an input rows list do not change output rows or cell evidence. Zero explanations are copied verbatim. Every output row is covered; the three lease-up cells intentionally reference their common single bundled row.
The series has exactly label, day_count and series. Each row has exactly date, amount, kind: "other" and label equal to its canonical source path. Use other consistently: coverage cells, not the advisory row-kind taxonomy, define economic ownership. The wrapped plan carries basis and currency. No stated metrics or newly calculated monetary total is emitted.
supplemental_metrics is not_stated when every existing metric declaration is absent or null, including an empty or null stated_metrics object. Otherwise the source must pass the existing metric verifier and the value is verified. Structural validation always runs separately. In particular, compare actual calendar dates for ordering; equal day-count exponents do not permit reversed calendar dates under 30/360us. This tightens no existing generic metric API.
Refuse in this order: plan shape; basis/currency and assertions; exact source selection; supplemental structure preflight; lease-up adapter; supplemental metric verification; hold horizon; coverage ownership; closing-slot timing; category signs. Traverse arrays in input order and required cells in the canonical order above; report the first failure. A nested RFC 0044 refusal uses outer reason verification and preserves the original issue in evidence.lease_up, even when that nested issue concerns its date map or source structure. This labels the failed adapter stage, not a new verdict on the source. Selection failures use selection and preserve their existing issue. Supplemental structure is checked before the lease-up adapter because its whole-envelope hash also visits supplemental values. A noncanonical value elsewhere in the envelope refuses with structure at sections. Explicit source shape checks use structure; failed metric recomputation uses verification. False economic assertions use coverage. All pointers use the existing dotted/bracket style rooted at plan or sections.<section>[<JSON-quoted variant>]. Message prose is explanatory; code, reason, pointer and nested evidence carry the machine-readable contract.
VIII.10 Distribution waterfall allocation (RFC 0035, RFC 0036, RFC 0059)
The format's distribution_waterfall section (format spec §4.27) states a tier ladder over a §4.26 dated series. This section fixes the allocation two engines must agree on. Like §VIII.9 it is a verifier surface (verifyWaterfall), not calc-engine surface: no grammar tokens, no builtins, no CalcResult changes.
The walk (normative). Amounts are evaluated in binary64 and quantized only at the reporting boundary (§VIII.5). Maintain, per party (lp, gp): unreturned capital, accrued_pref, contributions, distributions, receipts by tier kind, and the party's dated flow list. Walk the referenced series in row order:
- Accrue first. Let
Δt = yearfrac(prev_date, date, day_count)(§VIII.9.1; the series' convention; the first row'sprev_dateis itself, soΔt = 0, and same-date rows accrue nothing). For each party, when apreferred_returntier is present:simple—accrued_pref += unreturned × rate × Δt;compound_annual—accrued_pref += (accrued_pref + unreturned) × ((1 + rate)^Δt − 1)— unpaid pref itself compounds. - Contribution rows (
amount < 0): each party'sunreturnedandcontributionsgrow by itsequity_splitshare of|amount|; the share joins the party's dated flows as an outflow at the row's anchor-relativet(§VIII.9.2). - Distribution rows (
amount > 0): the cash fills the ladder in order; each tier pays up to its capacity, and the remainder falls through. Capacities and splits:return_of_capital— capacityΣ unreturned; paid pro-rata byunreturned; reduces it.preferred_return— capacityΣ accrued_pref; paid pro-rata by accrued balance; reduces it.catch_up— withP= total profit distributed so far (distributions minus return-of-capital receipts, both parties, including earlier tiers of this row — pref receipts count as profit, or a catch-up following the pref tier could never fill) andG= GP profit so far: capacityx = (target_promote × P − G) / (gp_share − target_promote), floored at 0.gp_shareof the payment goes to the GP, the rest to the LP.split— paidlp_share/gp_share. A capped tier's capacity is governed by its stated hurdles; the final tier is unbounded. When bothuntil_lp_emanduntil_lp_irrare stated:- if
hurdle_modeis"any", the smaller of the two capacities governs (the tier ends when either hurdle is met; RFC 0051); - if
hurdle_modeis"both"or omitted, the larger of the two capacities governs (both must be met before the tier ends). until_lp_em:max(0, until_lp_em × lp.contributions − lp.distributions) / lp_share(unchanged from 2.3.0).until_lp_irr(=h, RFC 0036): letFbe the LP's dated flows so far — every contribution outflow and distribution inflow appended by earlier rows and by earlier tiers of this row, at their anchor-relativet(§VIII.9.2) — andt_rowthis row'st. The LP's hurdle balance isB = −xnpv(F, h) × (1 + h) ^ t_row(§VIII.9.2's sum, accumulated in flow order; the sametvalues the outcomexirrwill use). Capacity ismax(0, B) / lp_share.B ≤ 0means the LP has already achieved the hurdle and the tier pays nothing.
The hurdle test is the balance identity, not a comparison against a solved
xirr:xnpv(F ∪ {(t_row, B)}, h) = 0exactly, so a tier that fills lands the LP at the hurdle rate by construction, without iteration. The reported outcomexirris still the §VIII.9.3 procedure over the final flows; at a filled boundary the two agree to within that procedure's stopping tolerance, which the §VIII.9.4%quantum (6 dp) absorbs. The identity — not "LP IRR ≥ h" — is the definition: for flows with a capital call after a distributionxnpv(·, r)need not be monotone inr, "IRR ≥ h" can be ambiguous or undefined, and two engines solving for the IRR could legitimately disagree on whether the hurdle was met; the balance is single-valued for every finite flow list. Payments join each party's dated flows as inflows. Cash never remains after the final tier (it is unbounded by grammar). An implementation MUST NOT determine a tier boundary by iterating onxirr; §VIII.9.3 remains the only permitted iteration and it runs only in step 4.- if
- Outcomes. Per party:
moic = distributions ÷ contributions;xirrby the §VIII.9.3 procedure over the party's dated flows.promote_total= GP distributions − GP return-of-capital receipts − GP pref receipts;profit_total= Σ distributions − Σ contributions. - Clawback true-up (RFC 0059), only when
clawbackis stated. After the walk, compute the LP's shortfall against the stated floor:lp_preferred_shortfall—unreturned + accrued_pref;lp_em_floor—floor_multiple × contributions − distributions;lp_irr_floor— the step-3 hurdle balanceB = −xnpv(F, floor_rate) × (1 + floor_rate)^tover the LP's dated flows at the final row'st. Thengross = min(max(0, shortfall), max(0, promote_total))— the GP can never return more promote than it received — andclawback = gross × (1 − net_of_tax_rate)when that rate is stated, otherwisegross. The result is a single terminal figure, never a schedule row and never per-period state: a clawback moves cash backward after the last row, so representing it as a tier would put a negative distribution inby_tier. An implementation MUST NOT determine it by iterating onxirr; every basis above is closed-form. A waterfall with noclawbackreportsnull, which is distinct from a stated provision that computes to0(the LP cleared its floor). - Verification (three-state): stated outcomes at the §VIII.9.4 quanta (
$→2,x→4,%→6); stated schedule cells at the currency quantum with an absent cell reading 0; a statedxirrwhose recomputation raises isfailed; amoicover zero contributions isunverifiable; a missing or structurally invalid referenced series makes every stated figureunverifiable; a statedclawback_amounton a waterfall declaring no provision isunverifiable, because there is no recomputed figure to disagree with. Failure outranks indeterminacy.
IX. AI Host Contract (Tier 4)
IX.1 Layer DAG
BANCROFT_LAYERS (context.ts) is the normative layer dependency graph for v1. Each layer declares its reads (sections it needs as input) and writes (sections it produces). A host MUST satisfy all reads before invoking a layer.
The seven canonical layers are L0 (ingestion), L1 (screening), L2 (underwriting), L4 (structuring), L5 (compliance), L6 (risk), L7 (assembly). Modules MAY contribute additional layers; module layers MUST NOT use IDs that shadow canonical IDs.
IX.2 Context bundling
buildAgentPrompt() defines the canonical context bundle shape: the deal frontmatter, the relevant reads sections (canonical blocks only — superseded blocks excluded), validation issues affecting those sections, and the layer's prompt template.
IX.3 Tool schemas
Two normative tool schemas are exported from packages/uwmd-core/src/agents/schemas.ts:
WRITE_UW_SECTION_TOOL— single-section writes.WRITE_MULTIPLE_SECTIONS_TOOL— batched writes for layers that produce multiple sections (seeMULTI_SECTION_LAYERS).
Tool calls that fail validation against these schemas MUST be rejected; the host MAY surface the failure to the agent for retry.
IX.4 _meta substitution
Agents may include _meta and _notes inside section_data. The host MUST strip both keys from the agent payload and substitute the canonical _meta constructed via buildMeta(). This prevents agents from forging provenance.
IX.5 Pipeline log
Every successful layer invocation MUST append a pipeline_log entry with timestamp, agent_or_actor, event_type, and status. Failures MUST also be logged, with status: "failed" and an error string.
IX.6 Shape assertions for tests
Because LLM outputs are nondeterministic, the Tier-4 conformance fixtures use JSON Schema shape assertions rather than byte-equality. A run is considered conformant if the post-run file shape matches the expected schema and the pipeline_log entry was appended.
IX.7 Context Profiles
A consumer of a .uwx.md file SHOULD declare which context profile it consumes. The profile determines which sections are included, whether superseded blocks appear, whether prose is included, and the approximate token budget the producer targets when constructing the context payload.
The five normative profiles are:
| Profile | Audience | Includes | Notes |
|---|---|---|---|
summary | Chat agents, orchestration heuristics | Frontmatter slim subset, pipeline_state, quick_metrics, gaps head | Soft target ≤ 600 tokens (chars/4 estimate). |
live | Calc-aware editors | All non-superseded sections, full prose | Default for human-facing tools. |
compact | LLMs at scale | All non-superseded sections, JSON only, minified, ordered stable→volatile | Targets ≤ 55% of live token count on the canonical Parkview fixture. _meta stripped by default. |
full | Archival / forensic | Every byte, prose included, no compaction | Round-trips the input file byte-for-byte when no truncation. |
relevant | Custom Bancroft layers | Caller-supplied section list, live-style payload | Used by consumed_profile: 'relevant' layers; the host passes the layer's reads as the section filter. |
A producer that satisfies a request for profile X MUST NOT return a context strictly larger than X allows; it MAY return a smaller context (e.g. truncated to fit maxTokens) and MUST set truncated: true in the result when it does.
The compact profile MUST order sections stable-first so prompt- cache prefixes survive frequent edits to volatile downstream sections. The canonical order is:
frontmatterpropertyownershipborrower_sponsordebt_structuresources_usesvaluationrent_rolloperating_statementnoi_modelmarket_analysiscompliancerisk_assessmentdcfstress_testscustom_calculationsgapsextensions
Sections not on this list trail the canonical prefix in Object.keys order.
Token estimates use the chars/4 approximation; producers MUST document the approximation and SHOULD validate periodically against the true tokenizer cost (typical drift ±5% on .uwx.md content).
IX.8 Layer-declared profile consumption
Each Bancroft layer declares a consumed_profile field in BANCROFT_LAYERS. A host MUST request that profile (and only that profile) when preparing the layer's input context. Conformance Tier-4 verifies the declaration via the consumer-profile-contract fixture.
For canonical v1 layers the declared profiles are:
| Layer | consumed_profile |
|---|---|
| L0 — Document Ingestion | summary |
| L1 — Screening | summary |
| L2 — Underwriting | relevant |
| L4 — Structuring | relevant |
| L5 — Compliance | relevant |
| L6 — Risk Rating | relevant |
| L7 — Assembly | live |
Module-contributed layers MUST declare consumed_profile. A module layer that consumes full or that declines to declare a profile is non-conformant.
X. Module Manifest Specification
The canonical schema is spec/schemas/module-manifest.schema.json. Implementations MAY validate manifests with any compliant JSON Schema 2020-12 validator (ajv, Validator, jsonschema, etc.).
Required fields: manifest_version (always "1" in v1), id, name, version, description, authors, license, requires_protocol, requires_format, requires_tier.
Optional fields are documented in the schema. The TypeScript mirror type ModuleManifest in protocol.ts is kept in lockstep.
A registered module must actually run. Registering a manifest is not the same as honoring it, and a host that loads a module and then ignores its calculations, validations, and sections has adopted nothing. A host that loads modules:
- SHOULD evaluate a module's
calculationsin declaration order, threading each result into the next as a prior result. Order is the author's, and a later calc that reads an earlier one depends on it. - SHOULD evaluate each
validations[].ruleas a §VIII.1 safe expression. A rule asserts what must be TRUE: it reports an issue when it evaluates tofalse, and MUST NOT report one when it evaluates tonull.nullis "the inputs are absent" (§VIII.2), and a document that does not carry a section has not violated a rule about it. - MUST NOT introduce an evaluation path a module can reach that the calc engine cannot. A rule is a safe expression, evaluated by the same sandbox, or it is a second unsandboxed language reachable from a third-party manifest.
- SHOULD report a
sections[].requiredsection that is absent, and SHOULD report a rule or calculation that fails to evaluate rather than silently skipping it. A skipped rule is a rule its author believes is protecting them.
Section schema fragments are normative JSON Schema. A host holding a JSON Schema validator SHOULD apply them; one that does not is still conforming, and the reference library is in the second category by design (§Layering — @uwmd/core takes no validator dependency).
The reference module is @uwmd/module-hospitality, built against the library's published surface and nothing else.
X.1 Module signatures (RFC 0002)
A module manifest is executable surface. Its calculations carry formulas the calc engine evaluates, and its validations can decide whether a deal reads as blocking or advisory. A host that loads one from npm, a URL, or a colleague's directory has no built-in way to ask whether it is the manifest the author published.
ModuleManifest.signature gives it one. The canonical schema is module-signature.schema.json.
Advisory by construction. The protocol does not require anyone to sign, or anyone to check. It fixes what "signature valid" means, so that two conforming hosts reach the same verdict on the same manifest; what to do about an unsigned or invalid module is host policy (§X.1.4).
X.1.1 What is signed
The signature input is the RFC 8785 canonical JSON of the manifest with signature removed — removed rather than set to null, so signing a manifest and verifying it afterwards see byte-identical input.
Every other field is covered, including depends_on. A module's dependencies are part of what its author published, and leaving them out would let an attacker redirect a signed module at a different dependency without breaking the signature.
X.1.2 Wire format
| Field | Type | Required | Meaning |
|---|---|---|---|
scheme | "uwmd-keystore" | yes | Signing scheme. See below. |
alg | "ed25519" | "es256" | "es384" | yes | Same closed set as §V.11. ECDSA is raw r || s. |
kid | string | yes | Key identifier the host resolves in its own key store. |
sig | string | yes | Signature bytes, base64url, unpadded. |
signed_at | string | yes | ISO 8601 instant the signature was produced. |
identity | string | no | Identity the signer claims. Advisory — see §X.1.5. |
uwmd-keystore is the one scheme protocol 1.x implements: a detached signature verified against a key store the host holds, reusing the block-signature machinery of §V.11 rather than inventing a second one.
sigstore is reserved and unimplemented. Keyless signing needs a Fulcio trust root and a Rekor inclusion proof, which means either a vendored root snapshot with a release-cadence obligation or network access at verification time. Neither fits a protocol whose conformance corpus is offline and deterministic. The scheme discriminator exists so that adding it later is additive rather than breaking.
X.1.3 Verification verdicts
A host that verifies MUST distinguish these five outcomes. They are listed with the error codes the reference implementation emits.
| Code | Reason | Meaning |
|---|---|---|
PROTO-MOD-068 | missing | The manifest carries no signature. |
PROTO-MOD-069 | unsupported_scheme | scheme names something this host does not implement. |
PROTO-MOD-070 | malformed | signature is present but not a well-formed ModuleSignature. |
PROTO-MOD-071 | unknown_key | kid names a key the host's store does not hold — including the case where the host has no signature backend at all. |
PROTO-MOD-072 | invalid | The signature did not validate over the canonical manifest. |
Three of these must not be collapsed into one another, because they call for three different responses:
missing— nothing was claimed. The host decides a policy.unknown_key— something was claimed and this host cannot check it. The host loads a key.invalid— something was claimed and it is false. The host rejects the module.
A verifier that reports all three as "signature failed" makes them indistinguishable at exactly the point where the operator has to act.
malformed is separate from invalid for the same reason: telling an author their manifest was tampered with, when the real problem is a missing signed_at, sends them hunting for an attack that never happened. A manifest carrying a malformed signature is refused by structural validation regardless of policy — declining to verify is not a licence to admit nonsense.
X.1.4 Host policy
A host's policy is one of:
ignore— do not look. What every host did before this section existed, and the default.verify-if-present— an unsigned module loads; a bad signature refuses. The pragmatic adoption setting: it never punishes an author who has not signed yet, and never lets a broken claim through.require— an unsigned module refuses too.
Both checking policies MUST refuse on unknown_key. A host that cannot check a signature has not established anything about the module, and treating "I hold no key for this" as success would make the policy decorative.
A host that declares the
module-signature-verificationcapability in itsImplementationManifestMUST produce the verdicts in §X.1.3 and MUST NOT load a module that fails verification under its declared policy.
Dependency verification is a host decision, not a protocol requirement. The protocol supplies the verifier; whether to walk depends_on transitively and demand a signature at each hop is policy.
X.1.5 What a signature does not tell you
identity is a claim inside the signed bytes. A valid signature proves the holder of that key asserted that identity — never that the assertion is true. It is worth exactly as much as the host's decision to bind that kid to that identity in its key store, which is an out-of-band act this protocol does not describe.
A host wanting "only modules from @example.org" enforces it with an allow-list over identity on top of a trusted key store, and gains nothing from the allow-list alone.
X.2 Module-declared asset classes (RFC 0003)
A module may introduce an asset class the standard does not have. The identifier grammar is format spec §2.2a; this section is resolution — what a host does when it meets one.
X.2.1 Declaration
ModuleManifest.declares_asset_classes is a list of:
| Field | Required | Meaning |
|---|---|---|
id | yes | The namespaced identifier (§2.2a). |
display_name | yes | Human-readable name. |
fallback | no | The closest builtin, for readers without this module. |
required_sections | no | Sections this class requires beyond the stage baseline. |
optional_sections | no | Sections this class recognizes but does not require. |
Distinct from asset_classes, which names builtin classes a module enhances. A module may do both — declare com.example.data_center and also contribute calculations to industrial.
A declaration whose id is a builtin is refused at load: that is not an extension, it is a redefinition, and asset_classes is how a module enhances a builtin. A fallback MUST be a builtin — falling back to another custom class moves the problem one hop and can cycle.
X.2.2 Resolution
Given a document's asset_class, a host resolves it to exactly one of three outcomes:
- Resolved. The class is a builtin, or a loaded module declares it. The host reads the document fully.
- Degraded. No loaded module declares it, but the host holds a declaration carrying a
fallback. The host MAY render using the fallback's view models, MUST report the result as degraded, and MUST emitMOD-FALLBACK-001(warning). It MUST NOT present the result as a full read. - Unresolved. Neither. The host emits
MOD-MISSING-001(error). The document'smoduleslist is what lets it say what to load rather than only that something is missing.
Holding a declaration is not holding the module. A host may know a class's display name and fallback from a cached declaration or an operator's configuration; that is enough to degrade gracefully and not enough to resolve. What "loaded" means is the module's calculations and validations, and a host that treated a cached declaration as equivalent would run a document through a class whose rules it does not have.
Determinism is the contract, and it holds in all three outcomes: every host with the module resolves identically, every host without one and with a fallback degrades identically, every host with neither fails identically. There is no arrangement in which two conforming hosts read the same file differently — which is the property that makes opening this extension point safe, and the reason the enum could not simply be opened to arbitrary strings.
X.2.3 Conflicts
Two loaded modules declaring the same identifier is MOD-ASSET-CLASS-CONFLICT-001 (error). Reverse-DNS names one owner, so two declarations mean one module is squatting; a host MUST NOT pick one silently, because that makes resolution depend on load order.
Two declarations sharing a display name is MOD-DISPLAY-CONFLICT-001 (info). Two unrelated verticals both calling something "Data Center" is confusing for a reader and irrelevant to a machine; a host SHOULD show the identifier alongside the name and otherwise carry on.
X.2.4 What custom classes do not get
A custom class has no builtin calc pack, no Excel layout, and no entry in the §XIII size-intensive registry — those tables are keyed to the closed builtin set and stay that way. A module supplies its own calculations for the classes it declares (§X, module runtime); anything the module does not supply, a custom class does not have.
This is a real limit, not an oversight. Wiring a custom class into the size-intensive registry would mean a third party could change what price_per_unit divides by, and the per-unit metrics are exactly where a silent denominator change does the most damage.
XI. Error Taxonomy
Normative schema: spec/schemas/protocol-error.schema.json. The TypeScript ProtocolError interface in @uwmd/core/protocol.ts is a mirror — implementers in any language SHOULD validate emitted errors against the JSON Schema.
All protocol-level errors MUST be expressible as a ProtocolError:
{
category: 'parse' | 'validate' | 'render' | 'edit' | 'calc' | 'agent' | 'module' | 'version',
code: string, // e.g. "PROTO-EDIT-001"
message: string,
pointer?: string, // dot-path into the file
remediation?: string,
cause?: string,
}Error codes follow CATEGORY-PREFIX-NNN. Categories are stable across versions; specific codes may be added but never repurposed.
XII. Versioning and Forward Compatibility
XII.1 Unknown frontmatter keys
MUST be preserved on round-trip and surfaced to consumers without error.
XII.2 Unknown sections
A section ID not in the standard 21 and not in any loaded module MUST be parsed and surfaced. Validators MAY warn but MUST NOT error.
XII.3 Format version skew
If the file's uw_version exceeds the implementation's supported format version by a major bump, the implementation MUST refuse to edit and SHOULD refuse to display sections it does not recognize.
XII.4 Protocol version skew
A module declaring requires_protocol: ">=2" MUST NOT load on a host advertising protocol version 1.x.y.
XIII. Size-Intensive Registry
Every asset class's calc pack divides by a size intensive — the denominator of its per-unit value metrics. This section is the single normative answer to "how big is this deal, and what do I call it?" for consumers that display, export, or index a deal's size. It is mirrored executably by SIZE_INTENSIVES / getSizeIntensive() in @uwmd/core's protocol.ts. (Added at protocol 1.6.0 by RFC 0027; the RFC text refers to this section as §XI — see the errata note there.)
| Asset class | Primary | Label | Unit | Secondary |
|---|---|---|---|---|
multifamily | total_units | Units | units | total_nra_sqft |
office | rentable_square_feet | RSF | sqft | — |
industrial | rentable_square_feet | RSF | sqft | — |
retail | gross_leasable_area | GLA | sqft | — |
self_storage | net_rentable_square_feet | NRSF | sqft | rentable_units |
hospitality | keys | Keys | keys | — |
student_housing | total_beds | Beds | beds | total_units |
senior_housing | total_units | Units | units | total_beds |
land | gross_acres | Gross acres | acres | usable_acres, entitled_units |
mixed_use | (none — per component, Format §4.23) | — | — | — |
All field paths are relative to the property section (Format §4.1).
XIII.1 Primary size field
The primary size field of an asset class is the field its calc pack uses as the denominator of its per-unit value metrics. A conforming implementation that displays, exports, or indexes a deal's size MUST select the field through this table and MUST NOT assume total_units.
XIII.2 Mixed-use has no property-level size
mixed_use has no property-level primary size field. A consumer needing a mixed-use deal's size MUST read the per-component intensives from the components section (Format §4.23) and MUST NOT synthesize a property-level total by summing across uses, because the components are denominated in different units — a bed and a square foot do not add.
The lookup's return type is therefore nullable rather than total: a registry that forced every class to name one field would have forced a wrong answer here.
XIII.3 The table is closed for protocol 1.x
A module declaring a custom asset class (RFC 0003, deferred) will declare its own entry; until that RFC lands, an unrecognized asset class has no primary size field and a consumer MUST degrade to stating no size rather than guessing one.
XIV. Capability Tokens (optional, RFC 0011)
An opt-in second gate on writes, for orchestrator-bound deployments (lending platforms, agent fleets) where authorization must be enforced at the file API layer rather than the network layer. Nothing in this section applies to a host that does not configure a verifier; the everyday flow of the open standard is unchanged.
XIV.1 Tokens narrow, never widen
A capability token is a second condition ANDed with the static §V.3 edit policy, never an escalation past it. The §V.3 authority check runs regardless of any token: a write the policy table refuses stays refused no matter what a token claims, and once a verifier is configured, a write the table permits additionally needs an in-scope token.
This is deliberate. The static table is the floor every implementation can evaluate offline from the file and this document alone; if a token could override it, a document's policy story would depend on key material the reader does not hold. In particular, institution/* keeps system_only — a deployment wanting agent-authored institution overrides models that as its coordinator issuing the write to a system/* writer it controls, an orchestration pattern rather than a policy escalation.
XIV.2 Token shape
A capability token is a JWS Compact–encoded JWT. Header: { "alg": <JOSE name>, "kid": <key id>, "typ": "JWT" }, where the JOSE alg maps onto the §V.11 algorithm shortlist (EdDSA → ed25519, ES256 → es256, ES384 → es384; Ed25519 is the default). The verifier resolves kid through the same key-store contract §V.11 block signatures use — one key-distribution story, not two.
Claims:
| Claim | Meaning |
|---|---|
iss | Coordinator identity (informational). |
sub | The actor being authorized — a valid §2.6 actor source (manual or <namespace>/<id>). Instance identity lives inside the id segment (agent/L2.instance-abc-123); a sub outside the grammar is malformed. |
aud | MUST be uwmd-edit. |
deal | Binds to the file's frontmatter deal_id. |
sections | Section ids the token may write; frontmatter ops use the reserved name _frontmatter, pipeline-log appends pipeline_log. Absent = unconstrained. |
stages | deal_stage values the token is valid at. Absent = unconstrained. |
ops | EditOperation kinds permitted. Absent = unconstrained. |
iat / exp | Validity window, unix seconds. Short lifetimes (default guidance: 1 hour) are the revocation story. |
jti | Unique token id, recorded on the written block (§XIV.4). |
XIV.3 Verification
When an editor is configured with a capability verifier, every write MUST present a token, and ALL of the following MUST hold — each failure maps to a typed reason surfaced with the POL-03 refusal:
- The token parses as JWS Compact, names a known
kid, and its signature verifies (malformed/unknown_kid/bad_signature). iat ≤ now < exp(not_yet_valid/expired).audisuwmd-edit(wrong_audience).dealequals the file'sdeal_id(wrong_deal).- The target section appears in
sections(wrong_section). - The file's declared
deal_stageappears instages(wrong_stage). A file with no declared stage fails a stage-constrained token — the token asserts a scope the file cannot demonstrate — and passes an unconstrained one. - The operation kind appears in
ops(wrong_op). subequals the prospective block's_meta.source(sub_mismatch). Free-form_meta.actoris display metadata and takes no part in authorization.
A write with no token, or a token failing any rule, is refused with POL-03 (error, POL family §III.6a). Verification is asynchronous (Web Crypto); a synchronous edit path with a verifier configured MUST refuse rather than skip the check.
XIV.4 Persistence
The token itself is NOT stored in the file — only its jti, appended to the new block's _meta.notes as capability:<jti> (; -separated when notes already exist). Embedding the token would disclose coordinator infrastructure, and per §V.11.2's posture, provenance metadata is outside any cross-producer hash-agreement guarantee — it would buy disclosure, not verifiability. Hosts wanting full forensic recall MAY keep a sidecar audit log (<file>.audit.jsonl); retention is theirs (90 days recommended, non-normative).
XIV.5 Conformance
Hosts that enforce tokens claim the capability-verify capability (§II.6a); conformance cases for POL-03 are tagged with it, so a host that does not opt in owes nothing. Multi-issuer scenarios (sponsor's and lender's coordinators over one file) are a key-store configuration concern — the verifier holds several public keys — not a protocol mechanism.
XV. Portfolio and Relationship Profiles (optional, RFC 0015)
An optional capability (portfolio-relationships) and a JSON sidecar — <portfolio>.uwportfolio.json — carrying typed entities and provenance-backed relationship edges that span deals: a borrower owns properties, a property secures a loan, a source document supports a deal. The sidecar is the portable carrier for the entity layer of the §V edge registry RFC 0018 established (BUILTIN_EDGE_TYPES), whose entity-layer edges projectPackageLinksToEntityEdges can already produce. It is out-of-band by design: a sidecar is not part of any .uwx.md file, claims no _meta ownership, and changes no record's canonical bytes.
Schema: uw-portfolio-profile.schema.json. Reference surface: validatePortfolioProfile, uninterpretedPortfolioTypes, getPortfolioRelationships, entityEdgesToPortfolioEdges; uwmd portfolio validate|edges.
XV.1 Profile shape
- A profile MUST contain
portfolio_version,entities, andedges;portfolio_idis RECOMMENDED.portfolio_versionis independently versioned from the format and protocol; this section defines1.0. - Each entity and edge
idMUST be non-empty and unique within the profile (PORT-006). IDs are opaque, case-sensitive strings; consumers MUST NOT derive identity from an ID's spelling. - The initial entity types are
property,deal,borrower,loan, anddocument. Extension entity types MUST be preserved and MAY be reported as uninterpreted, never refused. Apropertyordealentity MAY carrydeal_id, which MUST exactly equal the referenced deal's canonical frontmatterdeal_idwhen the deal is available.
XV.2 Edges resolve against the canonical registry
- Every edge MUST have
id,type,from,to, and a non-emptyprovenancearray (PORT-007/PORT-009);fromandtoMUST resolve to entity IDs in the same profile (PORT-008). - Edge types come from the §V registry, not from this section. A known type not valid on the
entitylayer (e.g.abstracts) MUST be refused (PORT-010) — the one-table-two-layers rule, enforced from the sidecar side. An unknown type MUST be preserved and MAY be reported as uninterpreted. Where the registry declaresfrom/toentity-kind constraints for a builtin edge, a violation MUST be refused (PORT-011) — checked only when the endpoint's entity type is itself registered; an extension entity type is outside the constraint's vocabulary, not in violation of it. - Each provenance entry MUST contain a stable
sourceidentifier and MAY carrylocator,note, orretrieved_at.sourcehere is a document/source identifier (the lease-abstractSourceRefposture), NOT the §V.3/RFC 0031_meta.sourceactor grammar.
XV.3 Update and preservation obligations
- A profile update MUST append provenance evidence or replace the affected sidecar as a new version; it MUST NOT silently remove provenance from an existing edge. This is an append-only evidence rule, not a storage or revision-control requirement.
- An editor making a targeted edit MUST preserve bytes outside the edited region, and unknown fields at every level MUST survive round-trips (the §XII posture). The reference implementation is read-only and carries no editor; this obligation binds implementations that write sidecars.
XV.4 What a profile is not
Hosts MAY store, query, index, or enrich profiles however they choose. The profile MUST NOT define SQL tables, graph traversal semantics, aggregate financial calculations, or ownership of host _meta fields. A portfolio that wants to state fund-level numbers uses an RFC 0021 composite with rollup receipts; the sidecar stays descriptive.
XVI. Future work (non-normative)
Every RFC accepted through protocol 2.6.0 is implemented (RFC 0001–0038, excepting 0013). The items below were named and deliberately deferred by an implemented RFC; each records where its design lives so later work does not foreclose it. None is required for conformance at any tier, and each opens as its own RFC under docs/rfcs/ when taken up. The maintainable copy of the forward plan is ROADMAP.md.
- Combined-hurdle "any" mode and GP-side hurdles — RFC 0036 shipped IRR hurdles with "both must be met" semantics (protocol 2.6.0, §VIII.10 step 3); RFC 0051 lifted
hurdle_mode: "any"(the smaller capacity) into the normative contract.until_gp_irrremains left out rather than reserved. - Clawback / crystallization — deferred by RFC 0035 and again by RFC 0036 (an IRR ladder makes it more pressing: a promote paid on an interim hurdle that later un-earns itself); expected to land as a terminal true-up tier, not per-period state.
- Currency-code disambiguation — deferred by RFC 0001; a
currency_codeon monetary values so a deal authored in one locale can carry another currency's amounts. - Lease-up follow-ons — RFC 0008 deferred the Excel emit of
lease_up_schedule, its coupling todcf, and defaults-table entries. - Portfolio and relationship agent layers (L9 / L10) — RFC 0015 shipped the data surface (§XV); reference layers that consume it are follow-up work the interchange format did not gate on.
- Embedding-based corpus retrieval — RFC 0013 stays a draft by decision until an adopter needs semantic (find-similar, risk-pattern) retrieval; analytical retrieval is served by the corpus fact table.
- Investor profile — interface-only in
@uwmd/core; excluded from RFC 0022 §5 as an institution-private preference set nobody has yet asked to exchange.
Appendix A — Self-certification checklist
For each tier you claim:
- [ ] Run every fixture in
conformance/tier-N-*/. Output matches. - [ ] Publish your
ImplementationManifest(id, version, tier, capabilities, representations, supported asset classes). - [ ] Document any non-default configuration users must set to achieve conformance.
- [ ] Add yourself to the README's "Who's building on it" section via PR.
Appendix B — Reference implementations
@uwmd/core— TypeScript reference: parser, validator, renderer, runner, agent host. All four tiers.tools/web-viewer/— Single-file Tier-1 reference viewer. Self-contained HTML, no build step.
Appendix C — Conformance corpus index
See conformance/README.md. Per-tier directories contain fixtures and expected outputs.
Appendix D — Worked example: Tier-1 Reader in <500 LOC
The file tools/web-viewer/index.html is a complete Tier-1 Reader in ~450 lines of HTML/CSS/JS, including an inline parser, the BUILTIN_VIEW_MODELS subset, and the formatter helpers. It is intended to be read end-to-end as a learning artifact.
Appendix E — Worked example: authoring a hospitality module
A minimal hospitality module manifest:
manifest_version: "1"
id: org.uwmd.hospitality
name: Hospitality Underwriting Module
version: 0.1.0
description: Adds RevPAR/ADR/occupancy fields and STR comp validations.
authors: [UW Markdown contributors]
license: MIT
requires_protocol: ">=1.0.0 <2.0.0"
requires_format: ">=1.1 <2.0"
requires_tier: tier-3-calc-host
asset_classes: [hospitality]
calculations:
- id: revpar
label: RevPAR
formula: adr * occupancy
unit: "$"
deterministic: true
validations:
- code: CC-MOD-HOSP-01
severity: warning
message: "RevPAR below market by more than 15%."
rule: "revpar >= market_revpar * 0.85"Appendix F — Glossary
- Block: a fenced JSON region inside a
.uwx.mdfile annotated withuw:section=…. - Canonical block: the most recent, non-superseded block for a given section ID (and variant, if applicable).
- Layer: a Bancroft AI agent stage with declared
readsandwrites. - Module: a declarative manifest extending the standard sections, calculations, validations, view models, and/or agent layers.
- Provenance: the
_metablock carried inside every fenced section, recording authorship, timestamp, version, and confidence. - Round-trip preservation: writing an unchanged region back to disk produces byte-identical output (modulo line endings).
- Supersede: the operation of marking a prior block as
superseded: trueand appending a new block with incremented version. - View model: a per-section layout description used to render the section uniformly across implementations.