Skip to content

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.

TierNameCapability
1ReaderParse + display, read-only
2EditorRound-trip writes, supersede semantics
3Calc HostEvaluate custom_calculations and module calculations
4Agent HostRun AI agent layers and apply structured edits

0.3 Semantic versioning ​

Three independent semvers are tracked:

  • Format version (uw_version in frontmatter, currently 2.0 for authoring; 1.0 and 1.1 are still read — see SUPPORTED_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's package.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_types and file_extensions;
  • supported directions (read, write, or both);
  • fidelity (source, model, or view);
  • an independently versioned representation_version; and
  • for views, a required view identifier.

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:

  1. Parse all required frontmatter fields (uw_version, deal_id, deal_name, created, last_modified, property_address, city, state, zip, asset_class).
  2. Recognize all 21 standard section IDs from the format spec §4.
  3. Treat unknown frontmatter keys, unknown sections, and unknown x_* extension blocks as informative: parse and surface them, but do not error.
  4. Resolve supersede semantics: when multiple blocks share a section_id, the most recent non-superseded block is canonical (§V.2).
  5. Apply the display conventions in Part III when surfacing any value to a human user.
  6. If — and only if — it claims the validate capability, surface validation issues using the remediation copy from BUILTIN_REMEDIATIONS, and emit only codes whose family is registered in §III.6a. A Tier-1 Reader that does not claim validate owes 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:

  1. Round-trip preservation: bytes outside the modified region of the file MUST be preserved, modulo line-ending normalization.
  2. Honor BUILTIN_EDIT_POLICIES (§V.3) — supersede vs replace.
  3. Update _meta on every write per §V.4 (new version, new timestamp, correct actor and source).
  4. Update frontmatter.last_modified on every write.
  5. Reject any EditOperation whose meta.source is not permitted by policy with a ProtocolError of category edit.

II.3 Tier 3 — Calc Host ​

A Tier-3 Calc Host MUST satisfy all Tier-2 requirements, and additionally:

  1. Parse the safe-expression grammar in Part VIII exactly as specified.
  2. Implement the built-in function set (§VIII.3) with the listed signatures and semantics.
  3. Be deterministic: same CalcEvaluationContext MUST produce the same CalcResult.
  4. Refuse expressions containing constructs outside the grammar (function definitions, assignments, lambdas, control flow keywords, property access on disallowed targets) with a ProtocolError of category calc.

II.4 Tier 4 — Agent Host ​

A Tier-4 Agent Host MUST satisfy all Tier-3 requirements, and additionally:

  1. Honor the BANCROFT_LAYERS dependency graph: layer L depends on inputs from earlier layers; the host MUST NOT run L until its dependencies are satisfied (§IX.1).
  2. Validate every agent tool call against WRITE_UW_SECTION_TOOL or WRITE_MULTIPLE_SECTIONS_TOOL before applying.
  3. Strip any _meta and _notes the agent included inside section_data and substitute the canonical _meta constructed by the host (§IX.4).
  4. Append a pipeline_log entry 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 ​

Invocationstdout
<bin> manifestThe implementation's ImplementationManifest.
<bin> parse <file>The parsed file, keyed as ParsedUWFile.
<bin> validate <file> --jsonA 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> --jsonA 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:

jsonc
{
  "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.

jsonc
{
  "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.

KindDefaultExample inputExample output
Currency$1,234,567 (no fractional digits, en-US separators)1234567$1,234,567
Percent5.51% (decimal × 100, 2 decimals)0.05515.51%
Ratio1.234x (3 decimals, x suffix)1.2345671.235x
Count50 (no thousands separator by default in CSV; with separator in display)5050
Nulln/anulln/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-US and MAY support additional registered locales. When producing a display render (chat, summary, report) of a file whose locale it does not support, it MUST emit LOC-01 (error, LOC family §III.6a) and MUST NOT fall back to a different locale — two readers disagreeing about what 1.234,56 means 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.

LocaleDecimalGroupingCurrencyPercentDate (display)
en-US.,$ prefix5.51%ISO passthrough
en-GB.,£ prefix5.51%DD/MM/YYYY
de-DE,.€ suffix, NBSP5,51 % (NBSP)DD.MM.YYYY
fr-FR,NBSP€ suffix, NBSP5,51 % (NBSP)DD/MM/YYYY
ja-JP.,¥ prefix5.51%YYYY/MM/DD
zh-CN.,¥ prefix5.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 patternSuggested labelColor hint
agent/*"AI" or layer IDaccent
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.

PrefixFamilyOwning capabilityDefault severity
CC-NNCross-section consistency — two or more sections disagree about the same fact. NN is a two-digit integer registered in this spec.validatewarning or error per check
FV-NNSingle-section financial validity — a value falls outside a registered plausibility threshold.validatetypically warning
DQ-NNData quality — a required value is missing, provisional, or below a stage threshold.validatewarning or error
MU-NNMixed-use composition (§XII).validatewarning or error
CS-*Capital stack (§XIII); RFC 0050 registers CS-02b for malformed split preferred-equity coupons.validatewarning or error
LU-NNLease-up schedule structure — period grammar, contiguity, presence (format spec §4.25, RFC 0008).validatewarning or error
RT-NNReturn-metric declarations — the dcf.returns basis fields (format §4.9, RFC 0038).validateerror
PS-NNPeriod-series shape and identity (RFC 0041).validatePS-01/03 warning; PS-02 error
ROLE-NNSigned block role vocabulary (RFC 0040).validateerror
LOC-NNDisplay locale (§III.1a, RFC 0001).validateerror
CUR-NNDocument currency identity (§III.1b, RFC 0046).validateerror
TAX-NNProperty-tax reassessment basis and abatement schedule (format §4.5 and §4.9, RFC 0053).validateerror
LSE-NNCommercial lease clauses — escalation steps, break options, co-tenancy, TI/LC balances (format §4.3, RFC 0055).validateerror
HDG-NNInterest-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.validateerror
ESC-NNEscrow and reserve cash lines, and the rate-cap replacement tie (format §4.8, RFC 0056).validateerror
WF-NNDistribution 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.validateWF-15 warning; otherwise error
REC-NNExpense 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.validateREC-10 warning; otherwise error
CAPX-NNRenovation draw and expense-targeted capex (format §4.8, RFC 0057). CAPX-07 requires the in_noi_model disclosure; no stated saving is ever applied.validateerror
META-*_meta shape by uw_version — the RFC 0009 one-shape-per-file rule (META-V2-IN-V1, META-V1-IN-V2).validateerror
INVALID-ASSET-CLASS-NNNAsset-class identifier syntax (§X.2).validateerror
SRC-NNSource vocabulary — _meta.source outside the §2.6 actor grammar (RFC 0031), and the retired resolution: "manual" spelling (SRC-03, RFC 0009).validateper-file (format v2 §1.3): error in a uw_version: "2.0" file, warning in 1.x
INT-NNIntegrity — content_hash / parent_hash chain (§IX.2).integritywarning or error
POL-NNEdit policy (§V.3; POL-03 is the capability-token refusal, §XIV).edit-replace or edit-supersedeerror
MOD-*Module runtime (§X).module-loadinfo to error
CALC-*Calc engine (§VIII).calc-evaluateerror
UNSUPPORTED_YAML_FEATUREFrontmatter YAML subset violation (§2.2).parseerror
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 like stress_tests that 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 ​

OperationEffect
frontmatter_setIn-place update of one frontmatter path. last_modified MUST be updated.
section_replaceOverwrite the canonical block for section_id. The previous block is discarded. Permitted only for sources whose policy is supersede_on_edit: false.
section_supersedeThe previous block is marked superseded: true in its _meta; a new block is appended with version = previous.version + 1.
pipeline_log_appendAppend-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/* → either authority, supersede_on_edit: true.
  • manual → either authority, supersede_on_edit: false.
  • document/* → either authority, supersede_on_edit: true.
  • system/*, institution/* → system_only authority, supersede_on_edit: false.
  • * (terminal catch-all) → either authority, 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 — false on the new block; true on 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 canonical SOURCE_TAGS tag 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.)

StepSource tagDescription
1user_overrideAn explicit user-entered correction.
2user_inputAn explicit user-entered initial value.
3inherited_assumptionAn assumption declared by an ancestor in the composition DAG. See §V.7.1.
4investor_profileValues declared in the active investor profile (e.g. preferred rate spread).
5market_dataA market-data lookup at the time of resolution.
6asset_class_defaultThe published default for the deal's asset class. See §V.8.
7global_defaultThe published global default.
8system_defaultA 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:

  1. 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.
  2. Nearest ancestor wins. Where several ancestors assert one field, the one fewest hops away supplies the value.
  3. 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.
  4. The value is traceable. An inherited value MUST record the asserting ancestor's document_id and 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 -0 rendered as 0 and non-finite numbers rejected (canonical JSON has no representation for Infinity or NaN).
  • undefined values inside objects are dropped (mirroring JSON.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 — the content_hash of the block this one supersedes; null on 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:

  1. If no block in the chain carries content_hash, the chain is skipped.
  2. Each non-root block's parent_hash MUST equal the prior block's content_hash (else INT-01 error).
  3. Each block's stamped content_hash MUST recompute from its current canonicalized content (else INT-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_replace used where the policy mandates section_supersede (heuristic: version > 1 with 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:

FieldTypeRequiredMeaning
alg"ed25519" | "es256" | "es384"yesSignature algorithm. Closed set for protocol 1.x.
kidstringyesOpaque key identifier the verifier resolves in its own key store.
sigstringyesSignature bytes, base64url (RFC 4648 §5), unpadded.
signed_atstringyesISO 8601 instant the signature was produced. MAY differ from _meta.timestamp — a block edited offline is signed when re-uploaded.
vstringnoSigning-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:

json
{
  "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:

CodeSeverityTriggerNeeds a key store
INT-05errorsignature present with no content_hash. The signature commits to nothing.no
INT-06errorsignature.kid names a key the store does not hold.yes
INT-07errorThe signature does not verify; or alg is outside the admitted set; or the stamped content_hash no longer recomputes.partly
INT-08warningalg is in the verifying deployment's deprecation list. Empty at protocol 1.7.no

Three of these distinctions are load-bearing:

  • INT-06 is not INT-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 warning on an unsigned block and INT-07 error on 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 ​

  1. Host loads a module.uw.yaml or module.uw.json file.
  2. Host validates against spec/schemas/module-manifest.schema.json.
  3. Host checks requires_protocol and requires_format against its own versions. If incompatible, refuse with ProtocolError category module.
  4. Host registers the module's sections, calculations, validations, thresholds, view models, and agent layers.
  5. 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's content)
    • 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:

SelectorKeyConstraints
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:

PathShapePeriodGrammarCadence
dcf.annual_cash_flowsrowsyearyear_index—
noi_model.projectionskeyed^year_(\d+)$year_index—
lease_up_schedule.schedulerowsperiodcalendar_periodperiod_granularity
cash_flow_series.seriesrowsdateiso_date—
distribution_waterfall.stated_schedulerowsdateiso_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 ​

NameSignatureNotes
sum(...nums)(number|null)[] → numberNulls treated as 0.
avg(...nums)(number|null)[] → number|nullNull if no non-null inputs.
min(...nums)(number|null)[] → number|null
max(...nums)(number|null)[] → number|null
coalesce(...args)(any)[] → anyFirst non-null.
if(cond, then, else)(bool, any, any) → any
round(num, dec)(number, number) → numberHalf-away-from-zero.
abs(num)(number|null) → number|nullAbsolute value; null propagates.
floor(num)(number|null) → number|nullRound toward -∞; null propagates.
ceil(num)(number|null) → number|nullRound toward +∞; null propagates.
sqrt(num)(number|null) → number|nullNegative input raises CALC-TYPE-001.
pow(base, exp)(number|null, number|null) → number|nullbase ** exp. Non-finite result raises CALC-TYPE-001.
log(num)(number|null) → number|nullNatural log. Non-positive input raises CALC-TYPE-001.
exp(num)(number|null) → number|nulle^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.

  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.
  2. Bracket. Evaluate npv at lo and hi. If either is non-finite, or if npv(lo) * npv(hi) > 0, the procedure fails — see step 5.
  3. Endpoint root. If npv(lo) is exactly 0, return lo; if npv(hi) is exactly 0, return hi. 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.
  4. Bisection. Bisect for at most 200 iterations, stopping when |npv(mid)| < 1e-9 or (hi - lo) / 2 < 1e-12, whichever comes first, with mid = (lo + hi) / 2 evaluated in binary64. Retain the half whose endpoints bracket the sign change, comparing npv(lo) * npv(mid) < 0. The midpoint at which a stopping condition is met is the result.
  5. 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.
  6. 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. npv has the same sign at both endpoints in that case, so step 2 finds no bracket: irr(-100, 230, -132) raises CALC-IRR-DIVERGE even though 0.1 and 0.2 are 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 lo is not a well-defined binary64 quantity. 1.0 + (-0.999) is 0.001000000000000001, not 0.001, so npv(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 at hi, where 1.0 + 10.0 is exact, and effectively unreachable at lo. 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:

unitDefault round_toRationale
$2Money is quantized to cents.
%6Rates are fractions (0.0551), so six places on the fraction is four on the percentage a reader sees.
x4The precision lender term sheets quote ratios such as DSCR at.
absent, or any other value6Residual 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 ** dec reintroduces the artifact it is meant to remove: 1.005 * 100 is 100.49999999999999, so that formulation yields 1.00 where spreadsheet ROUND yields 1.01. Shifting through a decimal string (Number("1.005e2") → 100.5) is correctly rounded and agrees. See packages/uwmd-core/src/calc/quantize.ts.

VIII.6 CalcError taxonomy ​

CodeMeaning
CALC-PARSE-001Expression failed to parse against the grammar.
CALC-RESOLVE-001Identifier could not be resolved.
CALC-TYPE-001Operator applied to incompatible types.
CALC-DIV-ZERODivision by zero.
CALC-IRR-DIVERGEIRR did not converge.
CALC-LIMIT-001Expression exceeded host complexity limits.
CALC-SENS-001Sensitivity axis missing or has no variable (§VIII.7.4).
CALC-SENS-002Sensitivity axis has fewer than two values, or a non-finite one.
CALC-SENS-003Sensitivity grid exceeds the cell or per-axis bound.
CALC-SENS-004Both sensitivity axes vary the same variable.
CALC-SENS-005Sensitivity round_to is out of range.
CALC-STOCH-001Stochastic declaration has no integer seed (§VIII.8.5).
CALC-STOCH-002Stochastic samples outside [2, 100000].
CALC-STOCH-003A stochastic input path or distribution is malformed, or a variable is drawn twice.
CALC-STOCH-004summarize is empty or names an unknown statistic.
CALC-STOCH-005Stochastic declaration has no random inputs.
CALC-STOCH-006Stochastic 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:

jsonc
{
  "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 null override 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:

CodeTrigger
CALC-SENS-001An axis is missing, or its variable is empty.
CALC-SENS-002An axis has fewer than two values, or a non-finite one.
CALC-SENS-003The grid exceeds 256 cells, or an axis exceeds 64 values.
CALC-SENS-004Both axes vary the same variable.
CALC-SENS-005round_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 deterministic flag 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.

jsonc
{
  "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.

KindParametersCross-host exactness
uniformmin, maxExact — arithmetic only.
triangularmin, mode, maxExact — arithmetic and sqrt.
normalmean, stddevNot 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 ​

CodeTrigger
CALC-STOCH-001seed missing or not an integer.
CALC-STOCH-002samples outside [2, 100000].
CALC-STOCH-003An input path is empty, drawn twice, or its distribution is malformed.
CALC-STOCH-004summarize is empty or names an unknown statistic.
CALC-STOCH-005No random inputs.
CALC-STOCH-006round_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, where actualDays counts 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 Excel DAYS360 U.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) — if dd1 = 31 set dd1 = 30; then if dd2 = 31 and dd1 = 30 set dd2 = 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:

  1. verifyCashFlowSeries — the §4.26 three-state verifier, which recomputes each stated metric and compares at the quantization boundary (§VIII.5), and
  2. CashFlowMetricDecl — a JSON declaration, the §VIII.7/§VIII.8 pattern:
json
{
  "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:

SlotRequired categoriesOwnership
Every lease-up periodrent, concessions, ti_lcAutomatically owned together by that period's RFC 0044 row.
Every lease-up periodother_income, operating_expenses, other_capex, reserve_netSupplemental rows or explicit zero declaration.
acquisitionpurchase_price, transaction_costs, reserve_netSupplemental rows or explicit zero declaration, subject to the purchase rule below.
dispositiongross_sale, transaction_costs, reserve_netSupplemental 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; or
  • zero: 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, labelled Unlevered pre-tax property cash flow, without stated_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.

ts
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:

  1. Accrue first. Let Δt = yearfrac(prev_date, date, day_count) (§VIII.9.1; the series' convention; the first row's prev_date is itself, so Δt = 0, and same-date rows accrue nothing). For each party, when a preferred_return tier is present: simple — accrued_pref += unreturned × rate × Δt; compound_annual — accrued_pref += (accrued_pref + unreturned) × ((1 + rate)^Δt − 1) — unpaid pref itself compounds.
  2. Contribution rows (amount < 0): each party's unreturned and contributions grow by its equity_split share of |amount|; the share joins the party's dated flows as an outflow at the row's anchor-relative t (§VIII.9.2).
  3. 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 by unreturned; reduces it.

    • preferred_return — capacity Σ accrued_pref; paid pro-rata by accrued balance; reduces it.

    • catch_up — with P = 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) and G = GP profit so far: capacity x = (target_promote × P − G) / (gp_share − target_promote), floored at 0. gp_share of the payment goes to the GP, the rest to the LP.

    • split — paid lp_share / gp_share. A capped tier's capacity is governed by its stated hurdles; the final tier is unbounded. When both until_lp_em and until_lp_irr are stated:

      • if hurdle_mode is "any", the smaller of the two capacities governs (the tier ends when either hurdle is met; RFC 0051);
      • if hurdle_mode is "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): let F be 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-relative t (§VIII.9.2) — and t_row this row's t. The LP's hurdle balance is B = −xnpv(F, h) × (1 + h) ^ t_row (§VIII.9.2's sum, accumulated in flow order; the same t values the outcome xirr will use). Capacity is max(0, B) / lp_share. B ≤ 0 means 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) = 0 exactly, so a tier that fills lands the LP at the hurdle rate by construction, without iteration. The reported outcome xirr is 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 distribution xnpv(·, r) need not be monotone in r, "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 on xirr; §VIII.9.3 remains the only permitted iteration and it runs only in step 4.

  4. Outcomes. Per party: moic = distributions ÷ contributions; xirr by 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.
  5. Clawback true-up (RFC 0059), only when clawback is 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 balance B = −xnpv(F, floor_rate) × (1 + floor_rate)^t over the LP's dated flows at the final row's t. Then gross = min(max(0, shortfall), max(0, promote_total)) — the GP can never return more promote than it received — and clawback = gross × (1 − net_of_tax_rate) when that rate is stated, otherwise gross. 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 in by_tier. An implementation MUST NOT determine it by iterating on xirr; every basis above is closed-form. A waterfall with no clawback reports null, which is distinct from a stated provision that computes to 0 (the LP cleared its floor).
  6. 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 stated xirr whose recomputation raises is failed; a moic over zero contributions is unverifiable; a missing or structurally invalid referenced series makes every stated figure unverifiable; a stated clawback_amount on a waterfall declaring no provision is unverifiable, 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 (see MULTI_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:

ProfileAudienceIncludesNotes
summaryChat agents, orchestration heuristicsFrontmatter slim subset, pipeline_state, quick_metrics, gaps headSoft target ≤ 600 tokens (chars/4 estimate).
liveCalc-aware editorsAll non-superseded sections, full proseDefault for human-facing tools.
compactLLMs at scaleAll non-superseded sections, JSON only, minified, ordered stable→volatileTargets ≤ 55% of live token count on the canonical Parkview fixture. _meta stripped by default.
fullArchival / forensicEvery byte, prose included, no compactionRound-trips the input file byte-for-byte when no truncation.
relevantCustom Bancroft layersCaller-supplied section list, live-style payloadUsed 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:

  1. frontmatter
  2. property
  3. ownership
  4. borrower_sponsor
  5. debt_structure
  6. sources_uses
  7. valuation
  8. rent_roll
  9. operating_statement
  10. noi_model
  11. market_analysis
  12. compliance
  13. risk_assessment
  14. dcf
  15. stress_tests
  16. custom_calculations
  17. gaps
  18. extensions

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:

Layerconsumed_profile
L0 — Document Ingestionsummary
L1 — Screeningsummary
L2 — Underwritingrelevant
L4 — Structuringrelevant
L5 — Compliancerelevant
L6 — Risk Ratingrelevant
L7 — Assemblylive

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 calculations in 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[].rule as a §VIII.1 safe expression. A rule asserts what must be TRUE: it reports an issue when it evaluates to false, and MUST NOT report one when it evaluates to null. null is "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[].required section 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 ​

FieldTypeRequiredMeaning
scheme"uwmd-keystore"yesSigning scheme. See below.
alg"ed25519" | "es256" | "es384"yesSame closed set as §V.11. ECDSA is raw r || s.
kidstringyesKey identifier the host resolves in its own key store.
sigstringyesSignature bytes, base64url, unpadded.
signed_atstringyesISO 8601 instant the signature was produced.
identitystringnoIdentity 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.

CodeReasonMeaning
PROTO-MOD-068missingThe manifest carries no signature.
PROTO-MOD-069unsupported_schemescheme names something this host does not implement.
PROTO-MOD-070malformedsignature is present but not a well-formed ModuleSignature.
PROTO-MOD-071unknown_keykid names a key the host's store does not hold — including the case where the host has no signature backend at all.
PROTO-MOD-072invalidThe 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-verification capability in its ImplementationManifest MUST 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:

FieldRequiredMeaning
idyesThe namespaced identifier (§2.2a).
display_nameyesHuman-readable name.
fallbacknoThe closest builtin, for readers without this module.
required_sectionsnoSections this class requires beyond the stage baseline.
optional_sectionsnoSections 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:

  1. Resolved. The class is a builtin, or a loaded module declares it. The host reads the document fully.
  2. 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 emit MOD-FALLBACK-001 (warning). It MUST NOT present the result as a full read.
  3. Unresolved. Neither. The host emits MOD-MISSING-001 (error). The document's modules list 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:

ts
{
  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 classPrimaryLabelUnitSecondary
multifamilytotal_unitsUnitsunitstotal_nra_sqft
officerentable_square_feetRSFsqft—
industrialrentable_square_feetRSFsqft—
retailgross_leasable_areaGLAsqft—
self_storagenet_rentable_square_feetNRSFsqftrentable_units
hospitalitykeysKeyskeys—
student_housingtotal_bedsBedsbedstotal_units
senior_housingtotal_unitsUnitsunitstotal_beds
landgross_acresGross acresacresusable_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:

ClaimMeaning
issCoordinator identity (informational).
subThe 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.
audMUST be uwmd-edit.
dealBinds to the file's frontmatter deal_id.
sectionsSection ids the token may write; frontmatter ops use the reserved name _frontmatter, pipeline-log appends pipeline_log. Absent = unconstrained.
stagesdeal_stage values the token is valid at. Absent = unconstrained.
opsEditOperation kinds permitted. Absent = unconstrained.
iat / expValidity window, unix seconds. Short lifetimes (default guidance: 1 hour) are the revocation story.
jtiUnique 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:

  1. The token parses as JWS Compact, names a known kid, and its signature verifies (malformed / unknown_kid / bad_signature).
  2. iat ≤ now < exp (not_yet_valid / expired).
  3. aud is uwmd-edit (wrong_audience).
  4. deal equals the file's deal_id (wrong_deal).
  5. The target section appears in sections (wrong_section).
  6. The file's declared deal_stage appears in stages (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.
  7. The operation kind appears in ops (wrong_op).
  8. sub equals the prospective block's _meta.source (sub_mismatch). Free-form _meta.actor is 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, and edges; portfolio_id is RECOMMENDED. portfolio_version is independently versioned from the format and protocol; this section defines 1.0.
  • Each entity and edge id MUST 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, and document. Extension entity types MUST be preserved and MAY be reported as uninterpreted, never refused. A property or deal entity MAY carry deal_id, which MUST exactly equal the referenced deal's canonical frontmatter deal_id when the deal is available.

XV.2 Edges resolve against the canonical registry ​

  • Every edge MUST have id, type, from, to, and a non-empty provenance array (PORT-007/PORT-009); from and to MUST 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 entity layer (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 declares from/to entity-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 source identifier and MAY carry locator, note, or retrieved_at. source here is a document/source identifier (the lease-abstract SourceRef posture), NOT the §V.3/RFC 0031 _meta.source actor 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_irr remains 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_code on 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 to dcf, 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:

yaml
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.md file annotated with uw: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 reads and writes.
  • Module: a declarative manifest extending the standard sections, calculations, validations, view models, and/or agent layers.
  • Provenance: the _meta block 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: true and appending a new block with incremented version.
  • View model: a per-section layout description used to render the section uniformly across implementations.

Released under the MIT License.