@uwmd/core 2.8.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/browser.d.ts +9 -2
  2. package/dist/browser.d.ts.map +1 -1
  3. package/dist/browser.js +9 -2
  4. package/dist/browser.js.map +1 -1
  5. package/dist/calc/errors.d.ts +1 -1
  6. package/dist/calc/errors.d.ts.map +1 -1
  7. package/dist/calc/errors.js.map +1 -1
  8. package/dist/calc/index.js +3 -3
  9. package/dist/calc/index.js.map +1 -1
  10. package/dist/capital-stack.d.ts +6 -2
  11. package/dist/capital-stack.d.ts.map +1 -1
  12. package/dist/capital-stack.js +5 -0
  13. package/dist/capital-stack.js.map +1 -1
  14. package/dist/cash-flow-series.d.ts +7 -0
  15. package/dist/cash-flow-series.d.ts.map +1 -1
  16. package/dist/cash-flow-series.js +10 -3
  17. package/dist/cash-flow-series.js.map +1 -1
  18. package/dist/cli-cash-flows.d.ts +16 -0
  19. package/dist/cli-cash-flows.d.ts.map +1 -0
  20. package/dist/cli-cash-flows.js +96 -0
  21. package/dist/cli-cash-flows.js.map +1 -0
  22. package/dist/cli.js +90 -0
  23. package/dist/cli.js.map +1 -1
  24. package/dist/format.d.ts +1 -0
  25. package/dist/format.d.ts.map +1 -1
  26. package/dist/format.js +9 -0
  27. package/dist/format.js.map +1 -1
  28. package/dist/index.d.ts +9 -2
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +9 -2
  31. package/dist/index.js.map +1 -1
  32. package/dist/property-cash-flow-inputs.d.ts +34 -0
  33. package/dist/property-cash-flow-inputs.d.ts.map +1 -0
  34. package/dist/property-cash-flow-inputs.js +95 -0
  35. package/dist/property-cash-flow-inputs.js.map +1 -0
  36. package/dist/property-cash-flows.d.ts +13 -0
  37. package/dist/property-cash-flows.d.ts.map +1 -0
  38. package/dist/property-cash-flows.js +369 -0
  39. package/dist/property-cash-flows.js.map +1 -0
  40. package/dist/protocol.d.ts +117 -2
  41. package/dist/protocol.d.ts.map +1 -1
  42. package/dist/protocol.js +32 -2
  43. package/dist/protocol.js.map +1 -1
  44. package/dist/renderer.d.ts +5 -0
  45. package/dist/renderer.d.ts.map +1 -1
  46. package/dist/renderer.js +22 -3
  47. package/dist/renderer.js.map +1 -1
  48. package/dist/report.d.ts.map +1 -1
  49. package/dist/report.js +19 -3
  50. package/dist/report.js.map +1 -1
  51. package/dist/types.d.ts +2 -0
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/types.js.map +1 -1
  54. package/dist/validator.d.ts +3 -0
  55. package/dist/validator.d.ts.map +1 -1
  56. package/dist/validator.js +288 -2
  57. package/dist/validator.js.map +1 -1
  58. package/dist/version.d.ts +1 -1
  59. package/dist/version.d.ts.map +1 -1
  60. package/dist/version.js +1 -1
  61. package/dist/version.js.map +1 -1
  62. package/dist/waterfall.d.ts +6 -0
  63. package/dist/waterfall.d.ts.map +1 -1
  64. package/dist/waterfall.js +17 -7
  65. package/dist/waterfall.js.map +1 -1
  66. package/package.json +2 -2
package/dist/validator.js CHANGED
@@ -2,13 +2,13 @@
2
2
  // Spec: UW_FORMAT_SPEC_v1.md Part V
3
3
  import { DEFAULT_THRESHOLDS, SOURCE_TAGS } from './types.js';
4
4
  import { getSection, getSectionVariant, deepGet } from './parser.js';
5
- import { BUILTIN_REMEDIATIONS, BUILTIN_INCOMPLETE_DATA_POLICIES, lookupIncompleteDataPolicy, getSizeIntensive, DEAL_UNDERWRITING_PROFILE, parseActorSource, isSupportedLocale, STAGE_REQUIREMENTS, requiredSectionsFor, CROSS_CHECK_RULE_IDS, RETURN_TAX_BASES, DEFAULT_RETURN_TAX_BASIS, } from './protocol.js';
5
+ import { BUILTIN_REMEDIATIONS, BUILTIN_INCOMPLETE_DATA_POLICIES, lookupIncompleteDataPolicy, getSizeIntensive, DEAL_UNDERWRITING_PROFILE, parseActorSource, isSupportedLocale, isCurrencyCode, STAGE_REQUIREMENTS, requiredSectionsFor, CROSS_CHECK_RULE_IDS, RETURN_TAX_BASES, DEFAULT_RETURN_TAX_BASIS, } from './protocol.js';
6
6
  import { EXTERNAL_ANNOTATION_KEY } from './composition.js';
7
7
  import { UW_LITE_SOURCE_EXTENSION } from './lite-bridge.js';
8
8
  import { readGapsContent } from './gaps.js';
9
9
  import { LEASE_UP_STABILIZED_TOLERANCE } from './lease-up.js';
10
10
  import { checkLeaseUpContent } from './lease-up-structure.js';
11
- import { CASH_FLOW_KINDS } from './cash-flow-series.js';
11
+ import { CASH_FLOW_KINDS, CASH_FLOW_VERIFY_DECIMALS, quantizeAtDecimals } from './cash-flow-series.js';
12
12
  import { isDayCountConvention, parseISODate } from './calc/day-count.js';
13
13
  import { parseAssetClass, declaredModuleDependencies } from './asset-class.js';
14
14
  import { isV2File } from './meta-shape.js';
@@ -105,7 +105,9 @@ export function validateUWFile(parsed, thresholdOverrides) {
105
105
  checkSizeIntensive(parsed, issues, ledger);
106
106
  checkSectionReadiness(parsed, issues, ledger);
107
107
  checkReturnsTaxBasis(parsed, issues);
108
+ checkTaxBasis(parsed, issues);
108
109
  checkLocale(parsed, issues);
110
+ checkCurrencyIdentity(parsed, issues);
109
111
  checkAssetClassIdentifier(parsed, issues);
110
112
  checkMetaIntegrity(parsed, issues);
111
113
  checkMetaShape(parsed, issues);
@@ -636,6 +638,245 @@ function checkReturnsTaxBasis(parsed, issues) {
636
638
  value: v,
637
639
  });
638
640
  }
641
+ // ─── §4.5 / §4.9 Tax abatements and reassessment basis (RFC 0053) ────────────
642
+ //
643
+ // Every rule here checks a figure the author stated. Nothing is derived, no
644
+ // jurisdiction rules are inferred, and the exit-value/terminal-tax circularity
645
+ // is left to the author — the calc engine has no iteration and this adds none.
646
+ // Quantize with the §VIII.5 helper the cash-flow verifier exports rather than
647
+ // keeping a second copy of the rounding rule.
648
+ /** What caused the reassessment; `none` means none applies. */
649
+ export const REASSESSMENT_TRIGGERS = Object.freeze([
650
+ 'sale', 'construction_completion', 'statutory_cycle', 'none',
651
+ ]);
652
+ export const TAX_ABATEMENT_KINDS = Object.freeze([
653
+ 'exemption', 'freeze', 'pilot', 'phase_in', 'credit',
654
+ ]);
655
+ const TAX_PATH = 'expenses.real_estate_taxes';
656
+ const CURRENCY_DP = CASH_FLOW_VERIFY_DECIMALS.currency;
657
+ function taxIssue(issues, code, section, field, message, value) {
658
+ issues.push({ code, severity: 'error', section, field, message, ...(value !== undefined ? { value } : {}) });
659
+ }
660
+ const finiteNum = (v) => typeof v === 'number' && Number.isFinite(v);
661
+ const sameMoney = (a, b) => quantizeAtDecimals(a, CURRENCY_DP) === quantizeAtDecimals(b, CURRENCY_DP);
662
+ /**
663
+ * The shape shared by the `noi_model` reassessment and the `dcf` terminal tax.
664
+ * Returns the quantum the stated expense must be rounded at, or null when the
665
+ * object is too malformed to check any further.
666
+ */
667
+ function checkReassessment(r, issues, section, base) {
668
+ const trigger = r['trigger'];
669
+ if (typeof trigger !== 'string' || !REASSESSMENT_TRIGGERS.includes(trigger)) {
670
+ taxIssue(issues, 'TAX-01', section, `${base}.trigger`, `TAX-01: reassessment.trigger must be one of ${REASSESSMENT_TRIGGERS.join(', ')}`, trigger);
671
+ return null;
672
+ }
673
+ const assessed = r['assessed_value'];
674
+ const millage = r['millage_rate'];
675
+ const indicated = r['indicated_tax'];
676
+ let ok = true;
677
+ for (const [key, v] of [['assessed_value', assessed], ['millage_rate', millage], ['indicated_tax', indicated]]) {
678
+ if (!finiteNum(v)) {
679
+ taxIssue(issues, 'TAX-01', section, `${base}.${key}`, `TAX-01: reassessment.${key} must be a finite number`, v);
680
+ ok = false;
681
+ }
682
+ }
683
+ if (!ok)
684
+ return null;
685
+ // TAX-01: the assessed value follows from the basis and ratio when both are stated.
686
+ const basis = r['value_basis'];
687
+ const ratio = r['assessment_ratio'];
688
+ if (basis !== undefined || ratio !== undefined) {
689
+ if (!finiteNum(basis) || !finiteNum(ratio)) {
690
+ taxIssue(issues, 'TAX-01', section, `${base}.assessment_ratio`, 'TAX-01: value_basis and assessment_ratio are stated together or not at all');
691
+ }
692
+ else if (!sameMoney(basis * ratio, assessed)) {
693
+ taxIssue(issues, 'TAX-01', section, `${base}.assessed_value`, `TAX-01: assessed_value ${assessed} must equal value_basis x assessment_ratio (${quantizeAtDecimals(basis * ratio, CURRENCY_DP)})`, assessed);
694
+ }
695
+ }
696
+ // TAX-02: the indicated tax follows from the assessed value and the millage.
697
+ if (!sameMoney(indicated, assessed * millage)) {
698
+ taxIssue(issues, 'TAX-02', section, `${base}.indicated_tax`, `TAX-02: indicated_tax ${indicated} must equal assessed_value x millage_rate (${quantizeAtDecimals(assessed * millage, CURRENCY_DP)})`, indicated);
699
+ }
700
+ const round = r['round_to_decimals'];
701
+ if (round !== undefined && !Number.isSafeInteger(round)) {
702
+ taxIssue(issues, 'TAX-03', section, `${base}.round_to_decimals`, 'TAX-03: round_to_decimals must be an integer; it may be negative to round to a magnitude', round);
703
+ return null;
704
+ }
705
+ return round === undefined ? CURRENCY_DP : round;
706
+ }
707
+ function checkAbatement(a, issues, section, base, stated) {
708
+ const kind = a['kind'];
709
+ if (typeof kind !== 'string' || !TAX_ABATEMENT_KINDS.includes(kind)) {
710
+ taxIssue(issues, 'TAX-06', section, `${base}.kind`, `TAX-06: abatement.kind must be one of ${TAX_ABATEMENT_KINDS.join(', ')}`, kind);
711
+ return;
712
+ }
713
+ const schedule = a['schedule'];
714
+ if (!Array.isArray(schedule) || schedule.length === 0) {
715
+ taxIssue(issues, 'TAX-05', section, `${base}.schedule`, 'TAX-05: abatement.schedule must be a nonempty array');
716
+ return;
717
+ }
718
+ const periods = [];
719
+ let shapeOk = true;
720
+ for (const [i, row] of schedule.entries()) {
721
+ const at = `${base}.schedule[${i}]`;
722
+ if (row === null || typeof row !== 'object' || Array.isArray(row)) {
723
+ taxIssue(issues, 'TAX-05', section, at, 'TAX-05: each schedule entry must be an object');
724
+ shapeOk = false;
725
+ continue;
726
+ }
727
+ const e = row;
728
+ const period = e['period'];
729
+ if (typeof period !== 'string' || taxPeriodKind(period) === null) {
730
+ taxIssue(issues, 'TAX-05', section, `${at}.period`, 'TAX-05: period must be an RFC 0041 selector (Y1+, YYYY-Qn, YYYY-MM, or YYYY-MM-DD)', period);
731
+ shapeOk = false;
732
+ continue;
733
+ }
734
+ periods.push(period);
735
+ const full = e['full_tax'];
736
+ const abated = e['abated_tax'];
737
+ if (!finiteNum(full) || !finiteNum(abated)) {
738
+ taxIssue(issues, 'TAX-06', section, at, 'TAX-06: full_tax and abated_tax must both be finite numbers');
739
+ shapeOk = false;
740
+ continue;
741
+ }
742
+ if (abated < 0 || full < 0) {
743
+ taxIssue(issues, 'TAX-06', section, at, 'TAX-06: full_tax and abated_tax must be nonnegative', abated);
744
+ }
745
+ else if (abated > full) {
746
+ taxIssue(issues, 'TAX-06', section, `${at}.abated_tax`, `TAX-06: abated_tax ${abated} cannot exceed full_tax ${full}`, abated);
747
+ }
748
+ }
749
+ if (!shapeOk)
750
+ return;
751
+ // TAX-05: one granularity, strictly increasing, no duplicates.
752
+ const kinds = new Set(periods.map(p => taxPeriodKind(p)));
753
+ if (kinds.size > 1) {
754
+ taxIssue(issues, 'TAX-05', section, `${base}.schedule`, `TAX-05: an abatement schedule uses one period granularity (found ${[...kinds].sort().join(', ')})`);
755
+ return;
756
+ }
757
+ for (let i = 1; i < periods.length; i++) {
758
+ if (periods[i - 1].localeCompare(periods[i], 'en', { numeric: true }) >= 0) {
759
+ taxIssue(issues, 'TAX-05', section, `${base}.schedule[${i}].period`, `TAX-05: schedule periods must strictly increase (${periods[i - 1]} then ${periods[i]})`, periods[i]);
760
+ return;
761
+ }
762
+ }
763
+ // TAX-06: a freeze holds the unabated tax nondecreasing across the schedule.
764
+ if (kind === 'freeze') {
765
+ for (let i = 1; i < schedule.length; i++) {
766
+ const prev = schedule[i - 1]['full_tax'];
767
+ const cur = schedule[i]['full_tax'];
768
+ if (cur < prev) {
769
+ taxIssue(issues, 'TAX-06', section, `${base}.schedule[${i}].full_tax`, `TAX-06: a freeze holds full_tax nondecreasing (${prev} then ${cur})`, cur);
770
+ return;
771
+ }
772
+ }
773
+ }
774
+ // TAX-07: the stabilized snapshot ties to one named period of the schedule.
775
+ const stabilized = a['stabilized_period'];
776
+ if (stabilized === undefined)
777
+ return;
778
+ if (typeof stabilized !== 'string') {
779
+ taxIssue(issues, 'TAX-07', section, `${base}.stabilized_period`, 'TAX-07: stabilized_period must be a period selector naming a schedule entry', stabilized);
780
+ return;
781
+ }
782
+ const at = periods.indexOf(stabilized);
783
+ if (at === -1) {
784
+ taxIssue(issues, 'TAX-07', section, `${base}.stabilized_period`, `TAX-07: stabilized_period ${stabilized} does not name a period in the schedule`, stabilized);
785
+ return;
786
+ }
787
+ if (!finiteNum(stated))
788
+ return;
789
+ const abated = schedule[at]['abated_tax'];
790
+ if (!sameMoney(stated, abated)) {
791
+ taxIssue(issues, 'TAX-07', section, `${TAX_PATH}.value`, `TAX-07: the stated tax ${stated} must equal the abated tax ${abated} for stabilized_period ${stabilized}`, stated);
792
+ }
793
+ }
794
+ /** The RFC 0041 selector families, or null when the string is not one. */
795
+ function taxPeriodKind(p) {
796
+ if (/^Y[1-9]\d*$/.test(p))
797
+ return 'holding_year';
798
+ if (/^\d{4}-Q[1-4]$/.test(p))
799
+ return 'quarter';
800
+ if (/^\d{4}-(0[1-9]|1[0-2])$/.test(p))
801
+ return 'month';
802
+ if (/^\d{4}-\d{2}-\d{2}$/.test(p) && parseISODate(p))
803
+ return 'date';
804
+ return null;
805
+ }
806
+ function checkTaxBasis(parsed, issues) {
807
+ const noi = resolveCrossCheckSection(parsed, 'noi_model').block;
808
+ if (noi) {
809
+ const tax = deepGet(noi.content, TAX_PATH);
810
+ if (tax !== null && typeof tax === 'object' && !Array.isArray(tax)) {
811
+ const t = tax;
812
+ const stated = t['value'];
813
+ const r = t['reassessment'];
814
+ if (r !== undefined) {
815
+ if (r === null || typeof r !== 'object' || Array.isArray(r)) {
816
+ taxIssue(issues, 'TAX-01', 'noi_model', `${TAX_PATH}.reassessment`, 'TAX-01: reassessment must be an object when stated', r);
817
+ }
818
+ else {
819
+ const rr = r;
820
+ const dp = checkReassessment(rr, issues, 'noi_model', `${TAX_PATH}.reassessment`);
821
+ // TAX-03: the stated expense is the indicated tax at the declared quantum.
822
+ if (dp !== null && finiteNum(stated) && finiteNum(rr['indicated_tax'])) {
823
+ const rounded = quantizeAtDecimals(rr['indicated_tax'], dp);
824
+ if (!sameMoney(stated, rounded)) {
825
+ taxIssue(issues, 'TAX-03', 'noi_model', `${TAX_PATH}.value`, `TAX-03: the stated tax ${stated} must equal indicated_tax rounded at ${dp} decimals (${rounded})`, stated);
826
+ }
827
+ }
828
+ // TAX-04: the legacy boolean has to agree with the typed trigger.
829
+ const flag = t['sale_triggers_reassessment'];
830
+ if (typeof flag === 'boolean' && flag !== (rr['trigger'] === 'sale')) {
831
+ taxIssue(issues, 'TAX-04', 'noi_model', `${TAX_PATH}.sale_triggers_reassessment`, `TAX-04: sale_triggers_reassessment ${flag} disagrees with reassessment.trigger ${JSON.stringify(rr['trigger'])}`, flag);
832
+ }
833
+ }
834
+ }
835
+ const a = t['abatement'];
836
+ if (a !== undefined) {
837
+ if (a === null || typeof a !== 'object' || Array.isArray(a)) {
838
+ taxIssue(issues, 'TAX-06', 'noi_model', `${TAX_PATH}.abatement`, 'TAX-06: abatement must be an object when stated', a);
839
+ }
840
+ else {
841
+ checkAbatement(a, issues, 'noi_model', `${TAX_PATH}.abatement`, stated);
842
+ }
843
+ }
844
+ }
845
+ }
846
+ // The terminal tax: the next buyer's, after this sale reassesses at the exit
847
+ // price. No arithmetic is asserted over exit_noi — deriveDCF leaves
848
+ // exit_value_gross an input because it capitalizes an unstored forward NOI.
849
+ const dcf = resolveCrossCheckSection(parsed, 'dcf').block;
850
+ if (!dcf)
851
+ return;
852
+ const tt = deepGet(dcf.content, 'exit_analysis.terminal_tax');
853
+ if (tt === undefined)
854
+ return;
855
+ const base = 'exit_analysis.terminal_tax';
856
+ if (tt === null || typeof tt !== 'object' || Array.isArray(tt)) {
857
+ taxIssue(issues, 'TAX-01', 'dcf', base, 'TAX-01: terminal_tax must be an object when stated', tt);
858
+ return;
859
+ }
860
+ const t = tt;
861
+ checkReassessment(t, issues, 'dcf', base);
862
+ if (typeof t['in_exit_noi'] !== 'boolean') {
863
+ taxIssue(issues, 'TAX-08', 'dcf', `${base}.in_exit_noi`, 'TAX-08: terminal_tax.in_exit_noi must state whether this tax is already inside exit_noi', t['in_exit_noi']);
864
+ }
865
+ // TAX-08: the next buyer is reassessed at what they pay, so the basis is the
866
+ // exit value — unless the author says in words why it is not.
867
+ if (t['trigger'] !== 'sale')
868
+ return;
869
+ const why = t['value_basis_differs_because'];
870
+ if (typeof why === 'string' && why.trim().length > 0)
871
+ return;
872
+ const gross = deepGet(dcf.content, 'exit_analysis.exit_value_gross');
873
+ const basis = t['value_basis'];
874
+ if (!finiteNum(gross) || !finiteNum(basis))
875
+ return;
876
+ if (!sameMoney(basis, gross)) {
877
+ taxIssue(issues, 'TAX-08', 'dcf', `${base}.value_basis`, `TAX-08: terminal_tax.value_basis ${basis} must equal exit_value_gross ${gross} for a sale trigger, or state value_basis_differs_because`, basis);
878
+ }
879
+ }
639
880
  // ─── §4.23 Mixed-use components (RFC 0019) ───────────────────────────────────
640
881
  // The eight income classes admissible as a mixed-use component. `land` is
641
882
  // excluded (its NOI model nets negative) and `mixed_use` cannot nest in itself.
@@ -858,6 +1099,30 @@ function checkStackContent(content, section, prefix, issues) {
858
1099
  issues.push({ code: 'CS-02', severity: 'error', section, field: `${prefix}${label}.rate`, message: `CS-02: common_equity tranche ${label} must not state a rate` });
859
1100
  }
860
1101
  }
1102
+ // CS-02b: a split coupon is one preferred-equity tranche with two
1103
+ // explicitly typed rate components. Keep the field-presence rule here,
1104
+ // beside CS-02, so schema-valid and hand-built ParsedUWFile inputs receive
1105
+ // the same refusal.
1106
+ const hasCashRate = Object.prototype.hasOwnProperty.call(t, 'cash_rate');
1107
+ const hasAccruedRate = Object.prototype.hasOwnProperty.call(t, 'accrued_rate');
1108
+ if (t['accrual'] === 'split') {
1109
+ if (cls !== 'preferred_equity') {
1110
+ issues.push({ code: 'CS-02b', severity: 'error', section, field: `${prefix}${label}.accrual`, message: `CS-02b: split accrual is supported only for preferred_equity tranche ${label}` });
1111
+ }
1112
+ if (!hasCashRate || !Number.isFinite(t['cash_rate'])) {
1113
+ issues.push({ code: 'CS-02b', severity: 'error', section, field: `${prefix}${label}.cash_rate`, message: `CS-02b: split tranche ${label} must state a numeric cash_rate` });
1114
+ }
1115
+ if (!hasAccruedRate || !Number.isFinite(t['accrued_rate'])) {
1116
+ issues.push({ code: 'CS-02b', severity: 'error', section, field: `${prefix}${label}.accrued_rate`, message: `CS-02b: split tranche ${label} must state a numeric accrued_rate` });
1117
+ }
1118
+ if (hasRate && Number.isFinite(t['cash_rate']) && Number.isFinite(t['accrued_rate'])
1119
+ && t['rate'] !== t['cash_rate'] + t['accrued_rate']) {
1120
+ issues.push({ code: 'CS-02b', severity: 'error', section, field: `${prefix}${label}.rate`, message: `CS-02b: split tranche ${label}.rate must equal cash_rate + accrued_rate` });
1121
+ }
1122
+ }
1123
+ else if (hasCashRate || hasAccruedRate) {
1124
+ issues.push({ code: 'CS-02b', severity: 'error', section, field: `${prefix}${label}.accrual`, message: `CS-02b: cash_rate and accrued_rate are permitted only when tranche ${label} uses accrual: "split"` });
1125
+ }
861
1126
  // A waterfall smuggled in at the tranche level (promote/hurdle/catch-up).
862
1127
  for (const key of Object.keys(t)) {
863
1128
  if (WATERFALL_MARKERS.includes(key)) {
@@ -1291,6 +1556,14 @@ function checkWaterfallContent(content, variant, parsed, issues) {
1291
1556
  irrOk = true;
1292
1557
  }
1293
1558
  }
1559
+ if (t.hurdle_mode != null) {
1560
+ if (t.hurdle_mode !== 'both' && t.hurdle_mode !== 'any') {
1561
+ wf01(`tiers[${i}].hurdle_mode`, 'hurdle_mode must be "both" or "any"');
1562
+ }
1563
+ else if (t.until_lp_em == null || t.until_lp_irr == null) {
1564
+ wf01(`tiers[${i}].hurdle_mode`, 'hurdle_mode is only permitted when both until_lp_em and until_lp_irr are stated');
1565
+ }
1566
+ }
1294
1567
  if (t.until_lp_em != null || t.until_lp_irr != null) {
1295
1568
  if ((emOk || irrOk) && !(typeof t.lp_share === 'number' && t.lp_share > 0)) {
1296
1569
  wf01(`tiers[${i}].lp_share`, 'a capped split must pay the LP (lp_share > 0) or the cap can never bind');
@@ -1383,6 +1656,19 @@ function checkLocale(parsed, issues) {
1383
1656
  value: String(declared),
1384
1657
  });
1385
1658
  }
1659
+ // ─── Document currency identity (RFC 0046) ──────────────────────────────────
1660
+ function checkCurrencyIdentity(parsed, issues) {
1661
+ const declared = parsed.frontmatter.currency_code;
1662
+ if (declared == null)
1663
+ return; // absent preserves legacy symbol behavior
1664
+ if (isCurrencyCode(declared))
1665
+ return;
1666
+ issues.push({
1667
+ code: 'CUR-01', severity: 'error', field: 'currency_code',
1668
+ message: `CUR-01: currency_code ${JSON.stringify(declared)} is not three uppercase ASCII letters; currency identity is never inferred from locale or a display symbol`,
1669
+ value: declared,
1670
+ });
1671
+ }
1386
1672
  // ─── Asset-class identifier (RFC 0003) ───────────────────────────────────────
1387
1673
  /**
1388
1674
  * The identifier itself, independent of whether any module is loaded.