One rule, written once. Four real tools, rebuilt live.
The card below is not a mock-up. It is a very small Effortless project running in this page, with its three parts kept apart: the rule (Leases.LateFee), its parameters (the LateFeePolicies row) and its data (three leases). Move a slider and the policy changes, and the policy is part of the rules: a new hash, a new version, and every tool rebuilds while each lease looks the new value up. Change a lease and that is data: the same version, one lease recomputed. Change a formula (export to Excel, edit, drop it back) and that is a new version too.
Drop your rulebook here
The longer version, with the whole loop from a rulebook to a running project. A film made from this widget is coming; until then, this one.
What moves, and what does not
The rule is one formula: =IF({{DaysLate}} > {{GraceDays}}, {{MonthlyRent}} * {{LateFeePercent}}, 0). It never names a number. GraceDays and LateFeePercent live in a policy row, and each lease looks them up through the policy it points at. The data is the leases: rent and days late.
It writes one cell of LateFeePolicies › standard. The policy is part of the rules, so the rulebook is a new version and every tool rebuilds from it. The worker recomputes every table at once, lookups first, so the looked-up columns and the fees move together.
SHA-256 over the rulebook with its data rows cleared, the policy rows kept: names, types, formulas, relationships and the parameters. A slider moves a parameter, so it is a new hash; a stepper moves a lease, so it is not. Shown as its last four characters: 0AF1 is the shipped rules.
v1 is the shipped rulebook. Each new hash becomes the next version, stamped with the time it was first computed. Pick a version from the dropdown and its rules apply to the data on screen; Reset clears the list back to v1.
Hover a line: the rulebook hash it was generated from, the tool and its version, and the package hash of everything the tool produced. Click the TypeScript or Postgres line to see all of it, formatted. The lines move only when a formula does.
Each export is the compiled rulebook: compile-rulebook fills in every looked-up and calculated value, so even YAML and CSV list the fees. The file name carries the hash: late-fee-0AF1.xlsx was built from the shipped rules.
The widget is a rulebook
The React component is a generic renderer of a contract, and the contract lives in the same rulebook as the domain. The Leases table is the data; the Widget* tables say which fields bind to which column, which policy cells the sliders edit, which tools run, and how each is labelled. Adding a tool is a row, not a release. Even the Vibe / Developer / Enterprise picker is a table: WidgetLevels says what each level shows, and each WidgetTools row names the levels it appears at.
The tables, live
LateFeePoliciesThe parameters of the rule, kept apart from the formula and from the leases. Each lease points at a policy and looks its values up; the sliders edit this row.
LateFeePolicyIdraw string · keyNameraw stringGraceDaysraw integerDays after the due date before a fee applies.LateFeePercentraw numberThe share of the monthly rent charged as the fee (0.08 = 8%).| LateFeePolicyId | Name | GraceDays | LateFeePercent |
|---|---|---|---|
| standard | Standard | 5 | 0.08 |
LeasesThe data. Three leases, each pointing at a late-fee policy; the fee is computed from the lease and the policy it looks up.
LeaseIdraw string · keyNameraw stringThe unit, as the tenant knows it.MonthlyRentraw numberRent due each month, in dollars.DaysLateraw integerHow many days past the due date the rent arrived.LateFeePolicyrelationship stringThe policy this lease follows. Holds a LateFeePolicies.LateFeePolicyId.GraceDayslookup integerLooked up from the lease's policy.=INDEX(LateFeePolicies!{{GraceDays}}, MATCH({{LateFeePolicy}}, LateFeePolicies!{{LateFeePolicyId}}, 0))LateFeePercentlookup numberLooked up from the lease's policy.=INDEX(LateFeePolicies!{{LateFeePercent}}, MATCH({{LateFeePolicy}}, LateFeePolicies!{{LateFeePolicyId}}, 0))LateFeecalculated numberThe rule: no fee within the grace period; after it, the policy's share of the rent.=IF({{DaysLate}} > {{GraceDays}}, {{MonthlyRent}} * {{LateFeePercent}}, 0)| LeaseId | Name | MonthlyRent | DaysLate | LateFeePolicy | GraceDays | LateFeePercent | LateFee |
|---|---|---|---|---|---|---|---|
| unit-2b | Unit 2B | 1850 | 5 | standard | — | — | — |
| unit-4a | Unit 4A | 2400 | 9 | standard | — | — | — |
| unit-7c | Unit 7C | 1200 | 13 | standard | — | — | — |
WidgetFieldsWhich domain fields the widget binds to, and how. A row here is a column in the data table.
WidgetFieldIdraw string · keyNameraw stringTableNameraw stringFieldNameraw stringRoleraw stringrow-label · stepper · lookup · result · valueFormatraw stringtext · money · integer · percentRequiredTyperaw stringraw or calculated: what the contract check demands of the field.RequiredDatatyperaw stringMinraw numberMaxraw numberStepraw numberHeaderraw stringThe column header.SortOrderraw integer| WidgetFieldId | Name | TableName | FieldName | Role | Format | RequiredType | RequiredDatatype | Min | Max | Step | Header | SortOrder |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| lease | Lease | Leases | Name | row-label | text | raw | string | — | — | — | Lease | 1 |
| rent | Rent | Leases | MonthlyRent | value | money | raw | number | — | — | — | Rent | 2 |
| days | Days late | Leases | DaysLate | stepper | integer | raw | integer | 0 | 30 | 1 | Days late | 3 |
| grace | Grace (looked up) | Leases | GraceDays | lookup | integer | lookup | integer | — | — | — | Grace ↗ | 4 |
| percent | Fee percent (looked up) | Leases | LateFeePercent | lookup | percent | lookup | number | — | — | — | Fee % ↗ | 5 |
| fee | Late fee | Leases | LateFee | result | money | calculated | number | — | — | — | Late fee | 6 |
WidgetSlidersThe sliders. Each one edits one value in one row; here, the two parameters of the Standard late-fee policy.
WidgetSliderIdraw string · keyNameraw stringTableNameraw stringFieldNameraw stringRowIdraw stringThe primary key of the row the slider edits.Minraw numberMaxraw numberStepraw numberDisplayScaleraw number100 shows 0.08 as 8.Labelraw stringWhat the slider is labelled.Unitraw stringHow a value is shown; {v} is the value times DisplayScale. "one|many" gives a singular form for 1.DefaultValueraw numberSortOrderraw integer| WidgetSliderId | Name | TableName | FieldName | RowId | Min | Max | Step | DisplayScale | Label | Unit | DefaultValue | SortOrder |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| grace | Grace period | LateFeePolicies | GraceDays | standard | 0 | 15 | 1 | 1 | GraceDays | {v} day|{v} days | 5 | 1 |
| rate | Late fee rate | LateFeePolicies | LateFeePercent | standard | 0.01 | 0.15 | 0.01 | 100 | LateFeePercent | {v}% | 0.08 | 2 |
WidgetToolsEvery tool or converter the widget runs. A row here is a line in the widget, an export button, or a drop-box format. No code change needed.
WidgetToolIdraw string · keyNameraw stringrunner/tool-name, unique.ToolNameraw stringRunnerraw stringserver = the effortless CLI through /api/live-rulebook · browser = a converter registered in converters/InverseToolNameraw stringThe tool that turns this format back into a rulebook, if any.Priorityraw integerLower runs first. rulebook-to-typescript is 1: it computes the numbers.Roleraw stringComma list of compile · compute · show · export · provenanceExtractorraw stringts-calc-function · sql-calc-function · xlsx-formula · rulespeak-definition · filesLanguageraw stringHow the shown line is coloured: sql · ts · excel · proseLabelraw stringThe name the tool line shows.ExportLabelraw stringThe name in the Export menu.ExportFilePatternraw stringThe downloaded file name. {name} is the rulebook Name, {hash} the first 8 characters of the rulebook hash (the rule the file was built from).AcceptsExtensionsraw stringParametersraw stringComma list of name=value the tool runs with, each passed as -p. simpleSyntax=true makes the TypeScript and Postgres lines the plain code a person would write; the engine row runs without it.Levelsraw stringComma list of WidgetLevels this tool's line and export appear at. Empty = every level. A compute, compile or provenance role runs whatever the level (provenance only where the level ShowsProvenance).IsEnabledraw booleanSortOrderraw integer| WidgetToolId | Name | ToolName | Runner | InverseToolName | Priority | Role | Extractor | Language | Label | ExportLabel | ExportFilePattern | AcceptsExtensions | Parameters | Levels | IsEnabled | SortOrder |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| compile | server/compile-rulebook | compile-rulebook | server | — | 0 | compile | files | — | — | — | — | — | — | — | true | 0 |
| postgres | server/rulebook-to-postgres | rulebook-to-postgres | server | — | 2 | show | sql-calc-function | sql | Postgres | — | — | — | simpleSyntax=true | developer,enterprise | true | 4 |
| typescript | server/rulebook-to-typescript | rulebook-to-typescript | server | — | 1 | show | ts-calc-function | ts | TypeScript | — | — | — | simpleSyntax=true | developer,enterprise | true | 3 |
| typescript-engine | server/rulebook-to-typescript (engine) | rulebook-to-typescript | server | — | 1 | compute,export | ts-calc-function | ts | — | TypeScript | {name}-{hash}-typescript.zip | — | — | developer,enterprise | true | 3 |
| excel | server/rulebook-to-xlsx | rulebook-to-xlsx | server | xlsx-to-rulebook | 2 | show,export | xlsx-formula | excel | Excel | Excel | {name}-{hash}.xlsx | .xlsx | — | vibe,developer,enterprise | true | 2 |
| english | server/rulebook-to-rulespeak | rulebook-to-rulespeak | server | — | 2 | show | rulespeak-definition | prose | English | — | — | — | — | vibe,developer,enterprise | true | 1 |
| explainer | server/rulebook-to-explainer-dag | rulebook-to-explainer-dag | server | — | 3 | provenance | files | — | — | — | — | — | — | — | true | 5 |
| markdown | server/rulebook-to-markdown | rulebook-to-markdown | server | — | 3 | export | files | — | — | Markdown | {name}-{hash}.md | — | — | developer,enterprise | true | 6 |
| yaml | browser/rulebook-to-yaml | rulebook-to-yaml | browser | yaml-to-rulebook | 3 | export | files | — | — | YAML | {name}-{hash}.rulebook.yaml | .yaml,.yml | — | developer,enterprise | true | 7 |
| xml | browser/rulebook-to-xml | rulebook-to-xml | browser | xml-to-rulebook | 3 | export | files | — | — | XML | {name}-{hash}.rulebook.xml | .xml | — | enterprise | true | 8 |
| toml | browser/rulebook-to-toml | rulebook-to-toml | browser | toml-to-rulebook | 3 | export | files | — | — | TOML | {name}-{hash}.rulebook.toml | .toml | — | enterprise | true | 9 |
| csv | browser/rulebook-to-csv | rulebook-to-csv | browser | — | 3 | export | files | — | — | CSV | {name}-{hash}-csv.zip | — | — | enterprise | true | 10 |
WidgetLevelsHow much of the widget a visitor sees, picked at the bottom right. Vibe: the rule, English and Excel, versions as v1, v2, v3. Developer: adds TypeScript, Postgres and more export formats. Enterprise: everything, with the rulebook hash on every version and the derivation of every fee.
WidgetLevelIdraw string · keyNameraw stringLabelraw stringThe name on the level picker.ShowsHashraw booleanVersions show their rulebook hash and time, and tool lines their receipts on hover. Off: just v1, v2, v3.ShowsProvenanceraw booleanEvery fee is clickable to show how it was derived (runs rulebook-to-explainer-dag).IsDefaultraw booleanThe level a first-time visitor sees.SortOrderraw integer| WidgetLevelId | Name | Label | ShowsHash | ShowsProvenance | IsDefault | SortOrder |
|---|---|---|---|---|---|---|
| vibe | vibe | Vibe | false | false | true | 1 |
| developer | developer | Developer | false | false | false | 2 |
| enterprise | enterprise | Enterprise | true | true | false | 3 |
WidgetSettingsSingle values. The shipped rulebook's hash is not stored here: the hash covers every row, so a row cannot hold it.
WidgetSettingIdraw string · keyNameraw stringPrimaryResultFieldraw stringTable.Field the widget is about.WorkerTimeoutSecondsraw integerHow long generated code may run before the worker is killed.HistoryLimitraw integerHow many rulebooks the hash list keeps (the shipped one is always kept).ParameterTablesraw stringTables whose rows are part of the rules, so they count in the rulebook hash (comma list). Every other table's rows are data and do not.| WidgetSettingId | Name | PrimaryResultField | WorkerTimeoutSeconds | HistoryLimit | ParameterTables |
|---|---|---|---|---|---|
| default | default | Leases.LateFee | 5 | 40 | LateFeePolicies |
7 tables · 68 fields · 28 rows, read from the bundled late-fee.rulebook.json.
Two hashes, two proofs
SHA-256 of the canonical JSON of the rulebook with every data row cleared and the parameter rows (LateFeePolicies) kept: sorted keys, no whitespace. It proves the same rules. The leases are left out on purpose: a hash of the data is a hash of a database, and says nothing about the rule.
SHA-256 over the sorted list of (path, sha256 of contents) for everything a tool produced. It proves the same output, whichever runner made it. A browser converter computes the identical shape over its bytes.
What shipped with this page
| Line | Tool | Version | Package hash | Extractor |
|---|---|---|---|---|
| English | rulebook-to-rulespeak | v2026.10.05.1648 | F82B | rulespeak-definition |
| Excel | rulebook-to-xlsx | v2026.09.17.1646 | A7AD | xlsx-formula |
| TypeScript | rulebook-to-typescript | v2026.10.06.1051 | EE35 | ts-calc-function |
| Postgres | rulebook-to-postgres | v2026.10.06.1054 | 5D95 | sql-calc-function |
Built 2026-10-07 from rulebook 0AF1 by the same extractors the server runs, so the first paint needs no server and Reset is instant.
Two jobs
POST /api/live-rulebook/transpile takes a tool name and a rulebook, runs the effortless CLI in a temp project (anonymously; the server holds no account key), computes the package hash, pulls out the one line the widget shows, and deletes the temp project. POST /api/live-rulebook/import does the same for a dropped workbook. A cold tool takes half a minute to wake; the card says so.
Hashing with crypto.subtle, caching by tool@hash in memory and localStorage, one request in flight per tool with the newest replacing a queued one, latest-wins when results arrive out of order, running generated code in a Web Worker, and the converters: rulebook-to-yaml, rulebook-to-xml, rulebook-to-toml, rulebook-to-csv run here, with no server call. The explainer DAG from rulebook-to-explainer-dag drives the derivation panel.
A dropped rulebook, one stage at a time
- 1Read
JSON, YAML, XML or TOML is parsed in the browser. A parse error names the line and column.
- 2Import
An .xlsx goes to the server once, where xlsx-to-rulebook patches the current rulebook with the workbook's formulas and reports what changed.
- 3Contract
Does this rulebook fit the widget? Every missing table, wrong type, short formula and missing text is listed. You can cancel or use it anyway.
- 4Compile
compile-rulebook recomputes every calculated value as a set. A formula that does not compile is reported by field and formula.
- 5TypeScript build
rulebook-to-typescript runs; the generated files are transformed in a Web Worker. A transform error names the file and line.
- 6Run
The generated code computes every row in the worker, under a timeout. Per-field errors come back on the row; an infinite loop is killed.
Each stage reports its own failure, in the words of the tool that failed. Only after all six does the new rulebook replace the current one and the other lines rebuild from it. Exports today: Excel, TypeScript, Markdown, YAML, XML, TOML, CSV, plus the rulebook itself.
Why a new tool is a row
A WidgetTools row names a tool, a runner, an extractor and a text key. Add one for any rulebook-to-xyz in the catalog and the widget grows a line, an export button, or a drop-box format, with no change to the component. The contract-in-the-rulebook is the point of the whole method, shown on the method’s own home page. Back to the widget →