SPEC-162
ID:SPEC-162Status:accepted

Amount and tally primitives, and budget as a composition

claude/value-primitives-and-grid View source
Branches 3
History 1
  1. 11d5e7a
    Created (accepted)by bjornolofandersson

Summary

budget is a core rune with a 477-line transform (packages/runes/src/tags/budget.ts). It reads ## categories of - Label: amount items, marks estimated categories, totals everything, and divides by a duration for a per-day rate. None of that is about travel.

The first draft of this spec moved all of it into one ledger primitive. Taking ledger apart shows that most of it is structure, which a composition can already express. Only two things genuinely need code:

  • reading and formatting one value: an amount;
  • arithmetic across values: a total.

So this spec adds two small core primitives, amount and tally. ledger becomes a composition over them, and budget a composition over ledger. The row, which an earlier discussion proposed as a primitive, is the one part that never needed code: item extraction already reads it.

This also resolves one of the three native runes ADR-042 left open. Budget's duration/currency bar is metadata declared in a native rune's config. As a composition, that metadata belongs to its definition.

Accepted on 2026-10-29, with revisions marked Revised 2026-10-29: amount reads only unambiguous currencies (D1); formatting follows SPEC-166 D6; the prerequisites are updated; budget's metadata bar is a {% group %} and it declares its elevation default; ledger's attributes are kebab-case. The name ledger stays.

What budget does today, and what each part needs

JobTodayNeeds code?Under this spec
Header (eyebrow, title, blurb)first heading and its paragraphsnosection's header (SPEC-160)
Categories from ##convertBudgetChildrennoa sections each
Rows from - Hotel: ¥15000DESCRIPTION_AMOUNT_PATTERNnoitem extraction (itemModel), as palette's rows (SPEC-165 D5)
Rows inside categorieshand-builtnoone level of nested each (SPEC-165 D6)
Estimate marker in a headingparseEstimateno, for the form in useheadingExtract with an optional pattern (D5)
Reading an amount: symbols, separators, rangesparseAmountyesamount (D1)
Formatting an amount by localeformatBudgetAmountyesamount (D1)
Subtotals, total, per-day rateinline sumsyestally (D2)
variant (detailed or summary)a modifiernoa composition attribute
Duration and currency barmetaFields and blocks in baseConfignodefinition-owned fields (SPEC-145 D28)

Two facts make the migration safe:

  • Budget emits no structured data. The SEO baseline records budget with an empty jsonLd (contracts/seo-baseline/baseline.json; WORK-567 dropped its ItemList). The reachability problems that gate recipe, event and pricing (SPEC-146) do not apply.
  • The imperative residue is small and generic. SPEC-161 D3's rule applies: a primitive may own imperative code; a composition may not.

Decisions

D1 — amount is a core value primitive

{% amount value="¥15,000" currency="JPY" /%}
{% amount value="€100–200" /%}
{% amount value="19" currency="USD" /%}

amount is the counterpart of color and font (SPEC-165): it reads one value and emits one value element.

  • It reads a currency symbol or code, grouping separators, and a range (100–200, with an en dash or a hyphen). currency names the currency when the value does not.
  • It never guesses a currency. Revised 2026-10-29. An ISO code (SEK 29, 29 SEK) or an unambiguous symbol (€, £, ₹) sets the currency. An ambiguous symbol ($, ¥, kr) needs currency: kr is the Swedish, Norwegian and Danish krona, and budget's own symbol table maps all three to it (budget.ts, BUDGET_CURRENCY_SYMBOLS). Without currency, the number is still read, no data-currency is set, and amount-currency-ambiguous is reported. When both are given, currency wins if the symbol is consistent with it (kr with SEK); otherwise the mismatch is reported. This is the rule SPEC-166 D4 gives date for ambiguous forms.
  • It emits <data value="15000" data-currency="JPY" data-meta-type="quantity">¥15,000</data> for the first example. A range emits data-low and data-high, and its value is empty, because a range has no single value.
  • It formats through the shared currency kind (SPEC-166 D2, D6), which formatBudgetAmount becomes. Revised 2026-10-29: display text is formatted at stage 4 by the engine, with the locale and formats, so amount emits the raw value and the author's text as the fallback. A total tally computes (D2) is emitted the same way.
  • A value it cannot read reports amount-invalid, naming the text, and is rendered as the author wrote it, with no data-value.
  • It works anywhere: in a row, a heading, a table cell or a sentence ("costs a night").

It also replaces SPEC-161 D4's proposed {% price %} rune: a price is an amount (see D7).

D2 — tally is a core container primitive that totals the amounts inside it

{% tally %}
…any content containing amounts…
{% /tally %}
  • It renders its children, then sums every amount element inside them by reading data-value, data-low and data-high. It sums nothing else, so a number in prose is never counted.
  • It appends a total in the shared vocabulary: a row marked data-role="total", holding an amount element. Inside a deflist, it is a deflist row; elsewhere, a block.
  • A tally inside a tally is a subtotal. The inner tally's total is marked data-role="subtotal", and the outer tally sums the amounts once, not the subtotals as well.
  • Ranges: a range among the amounts makes the total a range, with lows and highs summed separately. ranges="midpoint" totals each range at its midpoint instead (D4).
  • approximate marks the total data-approximate, and an approximate inner tally makes the outer total approximate too.
  • per divides the total and appends a rate row (data-role="rate"). It takes a count and a unit, 5 days, 2 weeks or 1 month, parsed as budget does today (weeks ×7, months ×30, days by default). A unit it does not know renders no rate and reports tally-rate-unit, rather than guessing.
  • show-rate (default true) omits the rate row without dropping per.
  • detail="totals" marks the tally data-detail="totals", and the base stylesheet hides every row that is not a subtotal, total or rate. The rows are still rendered and summed; only their display changes. Hiding them with a template {% if %} would drop them from the sum.
  • Mixed currencies in one tally report tally-currency-mixed, and no total is drawn.
  • Labels ("Total", "per day") are translated through SPEC-145 D29's keys, under core.tally.*. The native core.budget.perDay key is kept as an alias for one release.

tally totals whatever it wraps: a list, a definition list, or a table column. An invoice written as a Markdown table gets a total by wrapping it in {% tally %}.

D3 — ledger is a composition over section, deflist, amount and tally

---
tag: section
attributes:
  currency:  { type: string, description: "Currency code or symbol." }
  per:       { type: string, description: "Divisor for a rate, e.g. \"5 days\"." }
  show-rate: { type: boolean, default: true }
  ranges:   { type: string, matches: [range, midpoint], default: range }
  detail:   { type: string, matches: [items, totals], default: items }
content:
  type: sections
  sectionHeading: heading:2
  emitAttributes: { heading: $heading, estimate: $estimate }
  headingExtract:
    fields:
      - { name: estimate, match: text, pattern: "\\((estimate|est\\.)\\)\\s*$", optional: true }
  sectionModel:
    type: sequence
    fields:
      items:
        match: list
        itemModel:
          fields:
            - { name: label, match: text, pattern: "^(.+)\\s*:\\s*(?=[^:]*\\d)" }
            - { name: value, match: text, pattern: remainder, optional: true }
        emitTag: span
        emitAttributes: { label: $label, value: $value }
---

{% tally per=$attrs.per show-rate=$attrs.show-rate ranges=$attrs.ranges detail=$attrs.detail %}
{% slot name="sections" each %}
## {% $each.heading %}

{% tally approximate=$each.estimate %}
{% deflist %}
{% slot name="items" each %}
- **{% $each.label %}:** {% amount value=$each.value currency=$attrs.currency /%}
{% /slot %}
{% /deflist %}
{% /tally %}
{% /slot %}
{% /tally %}

Its prerequisites, revised 2026-10-29:

  • BUG-038: itemModel patterns are compiled. Fixed in v0.42.0.
  • SPEC-165 D6: one level of nested each. Today a slot inside an each is rejected (slot-inside-each), so ledger waits for SPEC-165 D6 to be accepted and built.
  • The estimate marker removed from the displayed heading (D5).

Attributes are kebab-case, as across refrakt (media-position, rating-total): ledger is new, so its rate switch is show-rate, matching tally's. budget keeps showPerDay, which existing content uses.

ledger is generic: invoices, quotes, cost breakdowns. It stays in core beside its primitives.

D4 — a range is shown as a range; the midpoint is opt-in

Today €100–200 counts as €150. That is a budgeting convention, not a fact about the amount.

  • amount always shows the range as written.
  • tally totals ranges as ranges by default.
  • ranges="midpoint" restores today's arithmetic.

budget passes ranges="midpoint", so its numbers do not change in this migration. Whether budget should switch to showing ranges is a separate, visible change.

D5 — "approximate" is the general form of budget's "estimate"

Budget marks a category as an estimate in four ways (parseEstimate): (estimate) or (est.) at the end of the heading, ~Label~, or ~~Label~~. Only (estimate) is used on the site (site/content/runes/budget.md:91).

  • ledger keeps the parenthesised forms through headingExtract. The marker is removed from the displayed heading, and the group's tally is marked approximate.
  • The tilde forms are dropped. ~~Label~~ is detected today by finding a strikethrough node in the heading, which a text pattern cannot see. A codemod rewrites any tilde form to (estimate), and content-unmatched reports any that are missed. D8 records the change.

Today's budget does not show that the grand total is approximate when a category is. Under D2 it does, which D8 also records as a visible change.

D6 — budget becomes a composition over ledger

---
tag: section
description: A trip or project budget, with categories, totals and a per-day rate.
attributes:
  currency:   { type: string, default: USD, description: "Currency code or symbol." }
  duration:   { type: string, description: "Budget duration for the per-day rate, e.g. \"5 days\"." }
  showPerDay: { type: boolean, default: true }
  variant:    { type: string, matches: [detailed, summary], default: detailed }
  detail:     { type: string, matches: [items, totals], default: items }
content:
  type: sequence
  fields:
    eyebrow:  { match: paragraph, optional: true }
    headline: { match: heading, optional: true }
    blurb:    { match: paragraph, optional: true }
    body:     { match: any, optional: true, greedy: true }
metaFields:
  duration: { metaType: category, condition: duration }
  currency: { metaType: category, condition: currency }
defaults:
  elevation: flat
---

{% section %}
{% group name="meta" layout="bar" %}
{% field name="duration" /%}
{% group name="currency" align="end" %}{% field name="currency" /%}{% /group %}
{% /group %}
{% slot name="eyebrow" /%}
{% slot name="headline" /%}
{% slot name="blurb" /%}

{% ledger currency=$attrs.currency per=$attrs.duration show-rate=$attrs.showPerDay ranges="midpoint" detail=$attrs.detail %}
{% slot name="body" /%}
{% /ledger %}
{% /section %}
  • The metadata bar is placed with {% field %} (SPEC-145 D28), so the bar's empty items drop out when there is no duration. D28 is not built yet; it is SPEC-166's first prerequisite too.
  • Revised 2026-10-29: the bar is a {% group layout="bar" %} (SPEC-156 D2), not a placed {% bar %}, so the currency can be pushed to the end with align="end", as the native budget does. A placed bar rune cannot align one field.
  • defaults: { elevation: flat } keeps the native defaultElevation: 'flat' (SPEC-145 D34). density needs nothing: section already resolves to full.
  • showPerDay passes through as ledger's show-rate.
  • variant="summary" today is a modifier that CSS reads to hide the line items. It becomes detail="totals", a generic tally setting the base stylesheet understands, so no theme needs to know budget. variant stays accepted for one release and maps summary to detail="totals", with a deprecation report.
  • It stays a core rune, defined as a string in a TypeScript module, which SPEC-160 D3 measured to work. Whether it moves to places beside itinerary and event is a separate question; moving it would make a core rune disappear for sites that do not load places.
  • Its metadata becomes definition-owned under ADR-042, resolving one of that decision's three open native runes.

D7 — amount replaces SPEC-161 D4's {% price %}

SPEC-161 D4 proposed a small {% price %} rune to format a tier's authored price. A price is an amount, so tier uses {% amount value=$attrs.price currency=$attrs.currency /%}.

  • The published Offer price still comes from the attributes, through the field bag (SPEC-145 D28's "the bag is the record"), never from the rendered element.
  • tier publishes its authored currency attribute, so a tier priced in kronor publishes SEK (or NOK, DKK) because its author says so. Revised 2026-10-29: an earlier draft said amount would read SEK from 29 kr. It cannot, because kr is three currencies (D1). What fixes SPEC-161 D4's defect is that nothing guesses: the plugin's inferCurrency defaults kr to USD today, and amount reports it instead.
  • amount could display the ## Pro — $19 heading shorthand, but it cannot publish it, because the value would come from the tree, not the bag. SPEC-161 D4's conclusion about the shorthand stands.

D8 — the output uses the vocabulary, and the migration is compared, not assumed

Budget hand-builds its classes today: rf-budget-category__header, __label, __subtotal, rf-budget__total, rf-budget__per-day. Under this spec its output is:

  • rows as deflist rows (data-zone-layout="definition-list");
  • each value an amount element (data-meta-type="quantity", data-value);
  • totals, subtotals and the rate as rows marked data-role="total", "subtotal" and "rate";
  • data-approximate and data-low/data-high on values a theme may want to soften.

So packages/lumina/styles/runes/budget.css (134 lines) is replaced by vocabulary rules for data-role and data-approximate, and the rf-budget* classes go. As with hero and cta (SPEC-160 D7), that is a migration note for third-party themes.

The composed budget is compared against today's on:

  • its rune fixture (packages/runes/fixtures/budget.md);
  • the SEO baseline fixture (contracts/seo-baseline/fixtures/budget.md);
  • packages/runes/test/budget.test.ts;
  • the examples on site/content/runes/budget.md.

The comparison checks that:

  • every amount, subtotal, total and per-day figure is identical (D4's ranges="midpoint");
  • (estimate) categories are approximate groups with the same labels;
  • the JSON-LD stays empty, and the SEO baseline does not move.

Expected visible changes, recorded rather than avoided:

  • the grand total is marked approximate when any category is (D5);
  • the tilde estimate markers are gone (D5);
  • the structure contract changes (this decision).

The native rune is removed only after the comparison is recorded, as SPEC-147 D2 did for storytelling.

Not in scope

  • Changing budget's numbers, for example showing ranges by default (D4).
  • Moving budget out of core (D6).
  • pricing's tiers as ledgers. A tier lists features but does not total them; SPEC-161 D4 stands, with amount for the price (D7).

Open questions

  • Units other than money. A timesheet totals hours, and nutrition totals grams. The obvious shape is amount with unit (currency, number, duration), formatted by the matching formatter, and tally refusing to mix units as it refuses to mix currencies. It is not needed for budget, so it waits for a second consumer.
  • Table input. tally already totals a table column of amounts. Whether ledger should also accept a two-column table as an alternative to the list is left open.
  • The name ledger. It reads as accounting; breakdown is the alternative.

Acceptance Criteria

  • {% amount %} exists as a core rune: reads symbols, codes, separators and ranges; emits a value element with data-value (or data-low/data-high) and data-currency; formats through the shared currency kind; reports amount-invalid for text it cannot read
  • amount sets a currency only from an ISO code, an unambiguous symbol, or currency; an ambiguous symbol without currency reports amount-currency-ambiguous and sets no data-currency, and a currency that contradicts the symbol is reported (D1)
  • {% tally %} exists as a core rune: sums only amount elements inside it; appends a total, subtotal and rate in the vocabulary; nests as subtotals without double counting; handles ranges and ranges="midpoint"; propagates approximate; supports show-rate and detail="totals"; reports tally-rate-unit and tally-currency-mixed
  • With detail="totals", line rows are still summed and only hidden by the base stylesheet, asserted against a fixture whose total includes them
  • tally totals a Markdown table column of amounts, asserted in a test
  • ledger is a core composition over section, deflist, amount and tally, with kebab-case attributes; SPEC-165 D6 (nested each) is built first
  • budget is a core composition over ledger, with its duration/currency bar placed by {% field %} in a {% group layout="bar" %}, the currency at the end, and defaults: { elevation: flat }; variant="summary" maps to detail="totals" for one release, with a deprecation report
  • Every figure in budget's fixtures and site examples is unchanged; the SEO baseline does not move; the visible changes in D8 are recorded
  • A codemod rewrites tilde estimate markers to (estimate), and content-unmatched reports any missed
  • tier (SPEC-161 D4) uses amount for its price and publishes its authored currency; kr with no currency is reported, not guessed
  • Lumina's budget.css is replaced by vocabulary rules, and the theme migration note lists the removed rf-budget* classes
  • ADR-042's follow-up records budget as resolved

References

  • ADR-042: metadata belongs to a rune's definition; budget was one of its three open cases.
  • ADR-043: formats, which amount will read.
  • SPEC-160: section, and D3's measurement that a core rune can ship as a composition.
  • SPEC-161: D3, a primitive may own imperative code; D4, the price amount replaces.
  • SPEC-165: color and font, the other value primitives; D5's item extraction and D6's nested each.
  • SPEC-145: D28's {% field %} and "the bag is the record"; D29's translated labels.
  • BUG-038: itemModel patterns, fixed in v0.42.0.
  • SPEC-166: the shared value kinds, formats (D6), and D28 as the shared prerequisite.
  • SPEC-156: D2's {% group %}, which places budget's metadata bar.
  • ADR-036: why aggregation belongs in a named primitive, not an expression language.