<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://curling.io/blog</id>
    <title>Curling IO Dev Blog</title>
    <updated>2026-09-01T00:00:00.000Z</updated>
    <generator>https://github.com/jpmonette/feed</generator>
    <link rel="alternate" href="https://curling.io/blog"/>
    <subtitle>Developer insights on building and enhancing Curling IO</subtitle>
    <icon>https://curling.io/img/curling-io-mark.svg</icon>
    <rights>Copyright © 2026 Curling IO</rights>
    <entry>
        <title type="html"><![CDATA[Why We Built Our Own Error Tracking]]></title>
        <id>https://curling.io/blog/why-we-built-our-own-error-tracking</id>
        <link href="https://curling.io/blog/why-we-built-our-own-error-tracking"/>
        <updated>2026-09-01T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Why Curling IO keeps error tracking in its own infrastructure, and how it handles capture, deduplication, notifications, AI analysis, source maps, and reports.]]></summary>
        <content type="html"><![CDATA[<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>About this post</div><div class="admonitionContent_BuS1"><p>This is a technical implementation note about error tracking in Curling IO v3.
It is written for software engineers and operators, and goes deeper into Rust,
SQLite, durable jobs, source maps, diagnostic safety, and LLM requests than our
usual product posts.</p></div></div>
<p>A request returning HTTP 500 or a background job failing gives us an error
message, but investigating it usually requires more: the producing commit, a
source location, the failure chain, the operations that ran before the failure,
and enough occurrences to see whether the inputs vary.</p>
<p>Repeated failures need separate handling. One defect inside a loop can produce
thousands of reports. We need to retain the occurrence count without sending an
alert or making an LLM request for each report.</p>
<p>We could have sent these errors to a hosted error-tracking service. We built a
narrower system ourselves for three reasons: data sovereignty, direct
integration with our application and operations pipelines, and control over
which internal data leaves our infrastructure.</p>
<p>The implementation is split between Curling IO and our separate Operations
application. Curling captures a bounded diagnostic envelope without waiting for
another service. Operations imports and deduplicates it, sends the alert, asks
a fast, lightweight, low-cost LLM for a structured analysis through OpenRouter
when that integration is enabled, and stores the result with the issue. The
redacted issue record is available through a command and a static report.</p>
<!-- -->
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="data-sovereignty-integration-and-security">Data Sovereignty, Integration, and Security<a href="https://curling.io/blog/why-we-built-our-own-error-tracking#data-sovereignty-integration-and-security" class="hash-link" aria-label="Direct link to Data Sovereignty, Integration, and Security" title="Direct link to Data Sovereignty, Integration, and Security">​</a></h2>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="data-sovereignty">Data sovereignty<a href="https://curling.io/blog/why-we-built-our-own-error-tracking#data-sovereignty" class="hash-link" aria-label="Direct link to Data sovereignty" title="Direct link to Data sovereignty">​</a></h3>
<p>Error reports can contain stack traces, internal file paths, deployment
identifiers, integration failures, and selected application context. We keep
the primary error record in the Operations SQLite database on our server in
Canada. Its replication, backups, retention, and access controls use the same
infrastructure boundaries as the rest of Operations data.</p>
<p>A hosted error-tracking service would maintain another copy of that diagnostic
archive. Keeping it in Operations means we know where the archive lives, which
accounts can read it, how long it is retained, and which recovery process
restores it.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="direct-integration">Direct integration<a href="https://curling.io/blog/why-we-built-our-own-error-tracking#direct-integration" class="hash-link" aria-label="Direct link to Direct integration" title="Direct link to Direct integration">​</a></h3>
<p>First-party ownership lets us shape reports around Curling IO workflows instead
of adapting them to a vendor event format. A request failure, background-job
panic, handled Mailchimp failure, Stripe reconciliation problem, and browser
exception can each include the bounded context appropriate to that boundary.</p>
<p>The stored issue is also easy to connect to other pipelines. The opening email
is one durable job. An optional second job builds a redacted prompt, makes one
OpenRouter request to a fast, lightweight, low-cost LLM, validates the
structured result, and stores it beside the issue.</p>
<p>That LLM request is not an agent run. The model has no coding harness, repository
access, tools, write access, or investigation loop. It cannot change code,
resolve the issue, or mutate Operations data. It gets one bounded diagnostic
record and returns one structured first-pass analysis.</p>
<p>A developer may later choose to give the same issue to a frontier coding agent
through a read-only command. That optional second pass is a separate consumer,
not part of the automatic alert pipeline. It typically uses a more capable
model in a proper coding harness, can inspect the repository and use development
tools, and is intended to do the deeper diagnosis when the answer is not already
obvious. The issue is also rendered into the static report. None of these paths
needs a vendor webhook or a second export format.</p>
<p>Both machine consumers are optional. Without them, Operations still captures
and deduplicates errors, sends notifications, and generates the report. We are
testing whether the first-pass LLM analysis and the coding-agent bundle reduce
the time required to understand and fix an issue.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="security">Security<a href="https://curling.io/blog/why-we-built-our-own-error-tracking#security" class="hash-link" aria-label="Direct link to Security" title="Direct link to Security">​</a></h3>
<p>We do not send the full diagnostic archive, private source maps, request bodies,
provider responses, or general service logs to an observability vendor. The
capture code accepts specific safe fields, and Operations applies another
redaction pass when reading them.</p>
<p>When enabled, AI analysis is an intentional exception to the local boundary.
OpenRouter receives one bounded diagnostic prompt for an eligible issue, not
access to the Operations database or error archive. The request disables
storage and asks for zero-data-retention routing. The model returns a fixed
structured analysis, and that result is stored in Operations. This is not the
same as keeping every byte inside our infrastructure, but it lets us choose
exactly what crosses the boundary for each analysis call.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="scope">Scope<a href="https://curling.io/blog/why-we-built-our-own-error-tracking#scope" class="hash-link" aria-label="Direct link to Scope" title="Direct link to Scope">​</a></h2>
<p>This covers application error tracking. It does not include performance
monitoring, session replay, general log search, or distributed tracing.</p>
<p>The stored data is selected for debugging. It includes the full producing Git
commit rather than only a display release, a bounded sequence of application
operations, and separate generated and original JavaScript locations. It does
not include data needed only for performance charts or replay.</p>
<p>A frontier coding agent is an optional downstream consumer of the data,
separate from the first-pass LLM request. When a problem is not already obvious,
a developer can choose to give the issue bundle to an agent that uses a more
capable model and a proper coding harness. It can inspect the producing
revision, form a hypothesis, change the relevant workflow, and write a
regression test. The bundle includes the deployment and failure context that we
would otherwise have to assemble manually.</p>
<p>The issue record is organized around that purpose:</p>
<table><thead><tr><th>Evidence</th><th>Purpose</th></tr></thead><tbody><tr><td>Full producing commit</td><td>Opens the exact code that emitted the error</td></tr><tr><td>Original and generated source locations</td><td>Separates reliable evidence from source-map interpretation</td></tr><tr><td>Error kind, cause chain, and trace</td><td>Identifies the failing boundary and nested cause</td></tr><tr><td>Request or job identity</td><td>Locates the workflow without copying customer input</td></tr><tr><td>Ordered breadcrumbs</td><td>Reconstructs what the application did before it failed</td></tr><tr><td>Representative occurrences</td><td>Shows meaningful variation without flooding context</td></tr><tr><td>Stored first-pass LLM analysis</td><td>Gives the investigation an initial hypothesis, not an answer</td></tr><tr><td>Explicit missing context</td><td>Stops unavailable logs or maps from looking like empty evidence</td></tr></tbody></table>
<p>When present, the first-pass analysis is stored beside these fields rather than
replacing them. The captured error message, cause chain, stack trace, context,
and occurrences remain available whether the analysis succeeds or not. A
developer or coding agent can check its hypothesis against that underlying
evidence.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="applications-write-to-a-local-handoff">Applications Write to a Local Handoff<a href="https://curling.io/blog/why-we-built-our-own-error-tracking#applications-write-to-a-local-handoff" class="hash-link" aria-label="Direct link to Applications Write to a Local Handoff" title="Direct link to Applications Write to a Local Handoff">​</a></h2>
<p>Curling does not call Operations, Postmark, or an LLM while handling the
failing request or job.</p>
<p>The application writes a bounded diagnostic envelope to a local handoff. A
separate Operations worker imports those envelopes once a minute. If
Operations is unavailable, the original request still returns its original
result and a background job still follows its own retry or failure policy.</p>
<p>This is also used for handled failures. A Mailchimp member sync may
fail for one account while later accounts can still be processed. A Stripe
recovery job may confirm that a payment succeeded remotely but fail while
finalizing the local order. The business workflow owns whether it can continue,
retry, reconcile, or stop. Error reporting records the evidence. It does not
make the business decision.</p>
<p>Reporter failures are contained. A full handoff directory, malformed envelope,
or unavailable Operations database does not recursively create another wave of
reports.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="captured-context-and-redaction">Captured Context and Redaction<a href="https://curling.io/blog/why-we-built-our-own-error-tracking#captured-context-and-redaction" class="hash-link" aria-label="Direct link to Captured Context and Redaction" title="Direct link to Captured Context and Redaction">​</a></h2>
<p>Serializing the request, job payload, local variables, and provider response
would copy credentials, personal information, payment details, and arbitrary
customer input into a second database and a model request.</p>
<p>Each producing boundary defines its safe context.</p>
<p>Request reports retain the matched route, method, status, correlation ID, and
safe source evidence. They do not retain raw paths, query strings, form values,
or request bodies. Integration reports can include a provider request ID,
status code, and retry stage, but not credentials, account email addresses, or
provider response bodies.</p>
<p>Each request and job also carries a small ordered breadcrumb trail. A
breadcrumb names an application operation, its outcome, elapsed time, and a
few whitelisted scalar fields. The producer keeps the newest 16 events within
4 KiB. Unknown keys and nested values are dropped.</p>
<p>A failed checkout might retain this sequence:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">+0 ms     http.request    started</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">+5,000 ms database.write failed    status=busy</span><br></span></code></pre></div></div>
<p>This records which operation failed and how long it ran without storing the
order form or customer record.</p>
<p>Operations redacts the evidence again when it builds the diagnostic bundle.
This protects readers from mistakes in current or historical producers.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="deduplication-and-llm-request-limits">Deduplication and LLM Request Limits<a href="https://curling.io/blog/why-we-built-our-own-error-tracking#deduplication-and-llm-request-limits" class="hash-link" aria-label="Direct link to Deduplication and LLM Request Limits" title="Direct link to Deduplication and LLM Request Limits">​</a></h2>
<p>Each request is cheap. Thousands of them are not. A simple error inside a tight
loop is the main cost risk.</p>
<p>Operations groups occurrences into an issue using the application,
environment, full producing commit, and a normalized error fingerprint. The
build is part of the identity because an investigation must start from the code
that actually ran. A new deployment creates a new issue instead of quietly
mixing evidence from two revisions.</p>
<p>Volatile values such as numeric identifiers are normalized before
fingerprinting. One reconciliation loop that fails for item 1001 and then item
1002 should normally be one issue with two occurrences, not two issues.</p>
<p>The database enforces one analysis job for each issue and prompt version. A
thousand repeated reports therefore produce:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">1,000 occurrences</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">1 deduplicated issue</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">1 opening notification</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">1 LLM analysis job</span><br></span></code></pre></div></div>
<p>Deduplication is not enough protection. A broken fingerprint or a broad outage
could create many distinct issues at once, so the worker starts analysis for at
most 10 new deduplicated issues in any rolling hour. Excess analyses are marked
as skipped. They do not form an expensive backlog that wakes up later and
drains the account after the outage is already understood.</p>
<p>Ten is intentionally unsophisticated. If more than ten genuinely different
errors appear in an hour, we probably have a larger incident and do not need a
model to explain every symptom. If bad normalization created the extra issues,
we need to fix the normalization, not pay for the noise.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="notification-and-analysis-jobs">Notification and Analysis Jobs<a href="https://curling.io/blog/why-we-built-our-own-error-tracking#notification-and-analysis-jobs" class="hash-link" aria-label="Direct link to Notification and Analysis Jobs" title="Direct link to Notification and Analysis Jobs">​</a></h2>
<p>Notification and analysis are separate durable jobs.</p>
<p>The worker processes the opening email first. That email says AI analysis is
pending and goes out even if the model credential is unavailable, the provider
is down, or the hourly analysis budget is exhausted. The analysis runs
afterward with extra-high reasoning, and its structured result is attached to
the same issue in SQLite.</p>
<p>This uses a fast, lightweight, low-cost LLM, not an agent. It receives one
bounded diagnostic record and returns a summary, likely cause, suggested fix,
confidence, supporting evidence, and missing context. If the evidence does not
support a reasonable diagnosis, the confidence and missing-context fields
should say so. A suggested fix is only a suggestion. The model has no write
access and cannot edit code, resolve the issue, deploy a fix, or make a business
decision. We use extra-high reasoning for this request without moving it to a
heavier model.</p>
<p>The optional second pass comes later, if a developer decides the issue needs
more work. A frontier coding agent can use its more capable model and coding
harness to verify the first-pass analysis against the source, trace,
breadcrumbs, and producing commit. If the analysis is wrong, disabled, or
unavailable, the issue and alert still contain the captured evidence.</p>
<p>Provider failures use bounded retries. A worker restart can recover a stale
claim, and a permanently failed analysis is not reopened every time another
occurrence arrives. One claimed attempt makes at most one LLM request.</p>
<p>The processing order is:</p>
<ol>
<li>Preserve the failure.</li>
<li>Send the alert.</li>
<li>Analyze it asynchronously when eligible.</li>
<li>Make the evidence and analysis available for investigation.</li>
</ol>
<p>The email path does not depend on analysis completion.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="browser-errors-and-source-maps">Browser Errors and Source Maps<a href="https://curling.io/blog/why-we-built-our-own-error-tracking#browser-errors-and-source-maps" class="hash-link" aria-label="Direct link to Browser Errors and Source Maps" title="Direct link to Browser Errors and Source Maps">​</a></h2>
<p>Uncaught JavaScript exceptions and unhandled promise rejections use the same
issue model, with one additional problem: the browser reports a location in a
generated asset.</p>
<p>Curling sends bounded browser reports to a same-origin endpoint. The server
adds the trusted build identity before writing the handoff envelope. Production
source maps are private release artifacts with <code>sourcesContent</code> removed, and
Operations uses a map only when its manifest matches the occurrence's full Git
commit.</p>
<p>When mapping succeeds, the issue keeps both locations:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">generated: /assets/islands/checkout.js:42:7</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">original:  apps/curling/platform/src/islands/checkout.ts:118:14</span><br></span></code></pre></div></div>
<p>When mapping fails, the generated evidence remains usable and the issue records
an explicit status such as <code>release_missing</code>, <code>map_missing</code>, or <code>unmapped</code>.
An unavailable mapping remains unavailable rather than being presented as an
original TypeScript location.</p>
<p>Browser capture has its own per-page and server-hour limits. Those limits
contain reload loops before Operations deduplication and the hourly LLM analysis
budget even come into play.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="a-command-and-a-static-report">A Command and a Static Report<a href="https://curling.io/blog/why-we-built-our-own-error-tracking#a-command-and-a-static-report" class="hash-link" aria-label="Direct link to A Command and a Static Report" title="Direct link to A Command and a Static Report">​</a></h2>
<p>One issue can be retrieved through a bounded JSON command. It opens the
Operations database read-only and returns the exact producing commit, source
locations, first and latest occurrence, up to five meaningfully different
occurrences, breadcrumbs, safe context, and stored analysis. It is used for
command-line investigation. A developer's coding agent can call the command to
receive that bounded bundle without receiving general access to the Operations
database.</p>
<p>The static report presents the same record as an index and one detail page per
issue.</p>
<figure><img src="https://curling.io/img/blog/error-intelligence-report.png" alt="Operations error intelligence report showing a one-thousand-occurrence reconciliation loop deduplicated into one analyzed issue and a second pending issue"><figcaption><em>The report generated from our no-network diagnostic fixture. The first issue represents 1,000 occurrences but only one notification and one LLM analysis.</em></figcaption></figure>
<p>The report is rendered in Rust using the same Basecoat and Tailwind setup as
the Curling site. It contains no client JavaScript and has no write actions.
The generator stages the complete output before replacing the previous report,
and fingerprints the issue data and stylesheet. If nothing changed, it leaves
the existing files alone.</p>
<p>The report has no write endpoint. Marking an issue resolved and authorizing a
repair belong to explicit Operations commands with narrower permissions.
Serving and authentication are separate deployment concerns.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-diagnostic-drill">The Diagnostic Drill<a href="https://curling.io/blog/why-we-built-our-own-error-tracking#the-diagnostic-drill" class="hash-link" aria-label="Direct link to The Diagnostic Drill" title="Direct link to The Diagnostic Drill">​</a></h2>
<p>The implementation has a no-network diagnostic drill that creates plausible
failures and runs the whole workflow against a temporary Operations database.
It includes:</p>
<ul>
<li>an HTTP request that times out on a database write;</li>
<li>an unhandled background-job panic;</li>
<li>a handled Mailchimp synchronization failure;</li>
<li>a Stripe payment that succeeds remotely but fails during local finalization;</li>
<li>a browser exception with generated source evidence;</li>
<li>a 1,000-occurrence reconciliation loop; and</li>
<li>enough distinct issues to cross the 10-per-hour LLM analysis limit.</li>
</ul>
<p>The drill sends no email and makes no LLM request. Fake Postmark and analysis
boundaries record what would have happened, including the structured analysis.
It asserts the stored issues, job counts, notification order, redaction,
source-map status, throttle behavior, and final diagnostic bundle.</p>
<p>The report screenshot above comes from that fixture: 17 issues and 1,016
occurrences, with 16 fake analyses attached and one skipped by the hourly
limit. These counts verify the loop and throttle behavior rather than only the
single-error path.</p>]]></content>
        <author>
            <name>Dave Rapin</name>
        </author>
        <category label="v3" term="v3"/>
        <category label="rust" term="rust"/>
        <category label="operations" term="operations"/>
        <category label="architecture" term="architecture"/>
        <category label="ai" term="ai"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Human in the Loop with Contracts]]></title>
        <id>https://curling.io/blog/human-in-the-loop-with-contracts</id>
        <link href="https://curling.io/blog/human-in-the-loop-with-contracts"/>
        <updated>2026-08-25T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Why Curling IO freezes agent proposals into typed application contracts, asks a human to approve their exact effects, and executes them without handing control back to the model.]]></summary>
        <content type="html"><![CDATA[<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>About this post</div><div class="admonitionContent_BuS1"><p>This is a technical implementation note about the AI assistant architecture in
Curling IO v3. It is written for software engineers and others designing agent
systems, and goes deeper into Rust, persistence, authorization, and failure
handling than our usual product posts.</p></div></div>
<p>The usual human-in-the-loop AI agent pattern goes something like this: the model
requests a tool call, the agent runtime pauses, a human approves the call, and
the runtime resumes so the tool can execute.</p>
<p>That is a reasonable general-purpose design. It is also stricter than simply
letting an agent call every tool it can see. For Curling IO, we wanted to expose
the smallest possible surface to the model and put an application-owned
guardrail around every path to a write. That led us to a stricter question:</p>
<p>If the application already has the exact call details, why hand control back to
the agent at all?</p>
<p>By the time we ask a club manager to approve an operation, Curling IO has parsed
the model's request, resolved every default, checked the current application
state, produced a fixed preview, and stored the exact arguments. The model has
nothing useful left to contribute to execution, so we do not let it execute
the operation or resume it merely to carry out the approval.</p>
<p>The agent proposes. The application turns that proposal into a contract. The
human approves the contract. Rust executes it.</p>
<!-- -->
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-common-approval-pattern">The common approval pattern<a href="https://curling.io/blog/human-in-the-loop-with-contracts#the-common-approval-pattern" class="hash-link" aria-label="Direct link to The common approval pattern" title="Direct link to The common approval pattern">​</a></h2>
<p>The <a href="https://openai.github.io/openai-agents-python/human_in_the_loop/" target="_blank" rel="noopener noreferrer">OpenAI Agents SDK human-in-the-loop
flow</a> can pause
a run when a tool requires approval. The application serializes the <code>RunState</code>,
records approvals or rejections for pending tool calls, and resumes the original
run. The approved tool executes as the run continues.</p>
<p><a href="https://langchain-ai.github.io/langgraph/how-tos/human_in_the_loop/review-tool-calls/" target="_blank" rel="noopener noreferrer">LangGraph interrupts</a>
support a similar shape. A graph can stop before a tool node, let a person
approve, edit, or reject the call, then resume toward the tool or back toward
the model.</p>
<p>Those frameworks solve a broad problem. An agent may have many tools, nested
agents, long-running work, and several points where a human needs to intervene.
Keeping the pending tool call inside durable agent state is useful in that
world.</p>
<p>Curling IO has a narrower problem. We own the application, the database, the
authorization rules, the interface, and every operation an assistant may
propose. We do not need a generic agent runtime to remain authoritative after a
proposal has crossed into application state.</p>
<p>This is the difference:</p>
<p><img decoding="async" loading="lazy" alt="A comparison of common tool-call approval, where the agent runtime resumes
after human approval, and Curling IO operation contracts, where Rust owns the
validated contract and executes it without resuming the
model." src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjAwIiBoZWlnaHQ9IjcyMCIgdmlld0JveD0iMCAwIDEyMDAgNzIwIiByb2xlPSJpbWciIGFyaWEtbGFiZWxsZWRieT0idGl0bGUgZGVzY3JpcHRpb24iPgogIDx0aXRsZSBpZD0idGl0bGUiPkNvbXBhcmlzb24gb2YgdG9vbC1jYWxsIGFwcHJvdmFsIGFuZCBDdXJsaW5nIElPIG9wZXJhdGlvbiBjb250cmFjdHM8L3RpdGxlPgogIDxkZXNjIGlkPSJkZXNjcmlwdGlvbiI+SW4gYSBjb21tb24gdG9vbC1jYWxsIGFwcHJvdmFsIGZsb3csIHRoZSBtb2RlbCByZXF1ZXN0cyBhIHRvb2wsIHRoZSBhZ2VudCBydW50aW1lIHBhdXNlcywgYSBodW1hbiBhcHByb3ZlcywgdGhlIHJ1bnRpbWUgcmVzdW1lcywgYW5kIHRoZSB0b29sIGV4ZWN1dGVzLiBJbiBDdXJsaW5nIElPLCB0aGUgbW9kZWwgcHJvcG9zZXMgYW4gb3BlcmF0aW9uLCBSdXN0IHZhbGlkYXRlcyBhbmQgZnJlZXplcyBhIGNvbnRyYWN0LCBhIGh1bWFuIGFwcHJvdmVzIGl0cyBleGFjdCBlZmZlY3RzLCB0aGVuIFJ1c3QgcmVsb2FkcywgcmV2YWxpZGF0ZXMsIGV4ZWN1dGVzLCBhbmQgc3RvcmVzIHRoZSBvdXRjb21lIHdpdGhvdXQgcmVzdW1pbmcgdGhlIG1vZGVsLjwvZGVzYz4KICA8ZGVmcz4KICAgIDxmaWx0ZXIgaWQ9InNoYWRvdyIgeD0iLTIwJSIgeT0iLTIwJSIgd2lkdGg9IjE0MCUiIGhlaWdodD0iMTQwJSI+CiAgICAgIDxmZURyb3BTaGFkb3cgZHg9IjAiIGR5PSIyIiBzdGREZXZpYXRpb249IjMiIGZsb29kLWNvbG9yPSIjMGYxNzJhIiBmbG9vZC1vcGFjaXR5PSIuMTIiLz4KICAgIDwvZmlsdGVyPgogICAgPG1hcmtlciBpZD0iYXJyb3ctbXV0ZWQiIHZpZXdCb3g9IjAgMCAxMCAxMCIgcmVmWD0iOCIgcmVmWT0iNSIgbWFya2VyV2lkdGg9IjciIG1hcmtlckhlaWdodD0iNyIgb3JpZW50PSJhdXRvLXN0YXJ0LXJldmVyc2UiPgogICAgICA8cGF0aCBkPSJNIDAgMCBMIDEwIDUgTCAwIDEwIHoiIGZpbGw9IiM2NDc0OGIiLz4KICAgIDwvbWFya2VyPgogICAgPG1hcmtlciBpZD0iYXJyb3ctYmx1ZSIgdmlld0JveD0iMCAwIDEwIDEwIiByZWZYPSI4IiByZWZZPSI1IiBtYXJrZXJXaWR0aD0iNyIgbWFya2VySGVpZ2h0PSI3IiBvcmllbnQ9ImF1dG8tc3RhcnQtcmV2ZXJzZSI+CiAgICAgIDxwYXRoIGQ9Ik0gMCAwIEwgMTAgNSBMIDAgMTAgeiIgZmlsbD0iIzI1NjNlYiIvPgogICAgPC9tYXJrZXI+CiAgPC9kZWZzPgoKICA8cmVjdCB3aWR0aD0iMTIwMCIgaGVpZ2h0PSI3MjAiIHJ4PSIyMCIgZmlsbD0iI2Y4ZmFmYyIvPgogIDx0ZXh0IHg9IjQ4IiB5PSI1NCIgZmlsbD0iIzBmMTcyYSIgZm9udC1mYW1pbHk9IkludGVyLCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMzAiIGZvbnQtd2VpZ2h0PSI3MDAiPldobyBvd25zIHRoZSBvcGVyYXRpb24gYWZ0ZXIgYXBwcm92YWw/PC90ZXh0PgoKICA8ZyBmb250LWZhbWlseT0iSW50ZXIsIHVpLXNhbnMtc2VyaWYsIHN5c3RlbS11aSwgc2Fucy1zZXJpZiI+CiAgICA8cmVjdCB4PSIzNiIgeT0iODgiIHdpZHRoPSIxMTI4IiBoZWlnaHQ9IjI1MCIgcng9IjE2IiBmaWxsPSIjZmZmIiBzdHJva2U9IiNjYmQ1ZTEiIHN0cm9rZS13aWR0aD0iMiIvPgogICAgPHRleHQgeD0iNjQiIHk9IjEyNiIgZmlsbD0iIzBmMTcyYSIgZm9udC1zaXplPSIyMSIgZm9udC13ZWlnaHQ9IjcwMCI+Q29tbW9uIHRvb2wtY2FsbCBhcHByb3ZhbDwvdGV4dD4KICAgIDx0ZXh0IHg9IjY0IiB5PSIxNTMiIGZpbGw9IiM0NzU1NjkiIGZvbnQtc2l6ZT0iMTYiPlRoZSBwYXVzZWQgYWdlbnQgcnVuIHJlbWFpbnMgdGhlIGNvb3JkaW5hdG9yLjwvdGV4dD4KCiAgICA8ZyBmaWx0ZXI9InVybCgjc2hhZG93KSI+CiAgICAgIDxyZWN0IHg9IjY0IiB5PSIxODgiIHdpZHRoPSIxODAiIGhlaWdodD0iMTA0IiByeD0iMTIiIGZpbGw9IiNmOGZhZmMiIHN0cm9rZT0iI2NiZDVlMSIgc3Ryb2tlLXdpZHRoPSIyIi8+CiAgICAgIDxyZWN0IHg9IjI4NiIgeT0iMTg4IiB3aWR0aD0iMTgwIiBoZWlnaHQ9IjEwNCIgcng9IjEyIiBmaWxsPSIjZjhmYWZjIiBzdHJva2U9IiNjYmQ1ZTEiIHN0cm9rZS13aWR0aD0iMiIvPgogICAgICA8cmVjdCB4PSI1MDgiIHk9IjE4OCIgd2lkdGg9IjE4MCIgaGVpZ2h0PSIxMDQiIHJ4PSIxMiIgZmlsbD0iI2ZmZjdlZCIgc3Ryb2tlPSIjZmI5MjNjIiBzdHJva2Utd2lkdGg9IjIiLz4KICAgICAgPHJlY3QgeD0iNzMwIiB5PSIxODgiIHdpZHRoPSIxODAiIGhlaWdodD0iMTA0IiByeD0iMTIiIGZpbGw9IiNmOGZhZmMiIHN0cm9rZT0iI2NiZDVlMSIgc3Ryb2tlLXdpZHRoPSIyIi8+CiAgICAgIDxyZWN0IHg9Ijk1MiIgeT0iMTg4IiB3aWR0aD0iMTgwIiBoZWlnaHQ9IjEwNCIgcng9IjEyIiBmaWxsPSIjZjhmYWZjIiBzdHJva2U9IiNjYmQ1ZTEiIHN0cm9rZS13aWR0aD0iMiIvPgogICAgPC9nPgoKICAgIDxnIGZpbGw9IiMwZjE3MmEiIGZvbnQtc2l6ZT0iMTYiIGZvbnQtd2VpZ2h0PSI3MDAiIHRleHQtYW5jaG9yPSJtaWRkbGUiPgogICAgICA8dGV4dCB4PSIxNTQiIHk9IjIyNCI+PHRzcGFuIHg9IjE1NCI+TW9kZWwgcmVxdWVzdHM8L3RzcGFuPjx0c3BhbiB4PSIxNTQiIGR5PSIyMyI+YSB0b29sIGNhbGw8L3RzcGFuPjwvdGV4dD4KICAgICAgPHRleHQgeD0iMzc2IiB5PSIyMjQiPjx0c3BhbiB4PSIzNzYiPkFnZW50IHJ1bnRpbWU8L3RzcGFuPjx0c3BhbiB4PSIzNzYiIGR5PSIyMyI+cGF1c2VzPC90c3Bhbj48L3RleHQ+CiAgICAgIDx0ZXh0IHg9IjU5OCIgeT0iMjI0Ij48dHNwYW4geD0iNTk4Ij5IdW1hbiByZXZpZXdzPC90c3Bhbj48dHNwYW4geD0iNTk4IiBkeT0iMjMiPmFuZCBhcHByb3ZlczwvdHNwYW4+PC90ZXh0PgogICAgICA8dGV4dCB4PSI4MjAiIHk9IjIyNCI+PHRzcGFuIHg9IjgyMCI+QWdlbnQgcnVudGltZTwvdHNwYW4+PHRzcGFuIHg9IjgyMCIgZHk9IjIzIj5yZXN1bWVzPC90c3Bhbj48L3RleHQ+CiAgICAgIDx0ZXh0IHg9IjEwNDIiIHk9IjIyNCI+PHRzcGFuIHg9IjEwNDIiPkFwcHJvdmVkIHRvb2w8L3RzcGFuPjx0c3BhbiB4PSIxMDQyIiBkeT0iMjMiPmV4ZWN1dGVzPC90c3Bhbj48L3RleHQ+CiAgICA8L2c+CgogICAgPGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjQ3NDhiIiBzdHJva2Utd2lkdGg9IjMiIG1hcmtlci1lbmQ9InVybCgjYXJyb3ctbXV0ZWQpIj4KICAgICAgPHBhdGggZD0iTTI0NCAyNDBoMzEiLz4KICAgICAgPHBhdGggZD0iTTQ2NiAyNDBoMzEiLz4KICAgICAgPHBhdGggZD0iTTY4OCAyNDBoMzEiLz4KICAgICAgPHBhdGggZD0iTTkxMCAyNDBoMzEiLz4KICAgIDwvZz4KCiAgICA8cmVjdCB4PSIzNiIgeT0iMzcwIiB3aWR0aD0iMTEyOCIgaGVpZ2h0PSIzMDQiIHJ4PSIxNiIgZmlsbD0iI2VmZjZmZiIgc3Ryb2tlPSIjOTNjNWZkIiBzdHJva2Utd2lkdGg9IjIiLz4KICAgIDx0ZXh0IHg9IjY0IiB5PSI0MDgiIGZpbGw9IiMwZjE3MmEiIGZvbnQtc2l6ZT0iMjEiIGZvbnQtd2VpZ2h0PSI3MDAiPkN1cmxpbmcgSU8gb3BlcmF0aW9uIGNvbnRyYWN0PC90ZXh0PgogICAgPHRleHQgeD0iNjQiIHk9IjQzNSIgZmlsbD0iIzQ3NTU2OSIgZm9udC1zaXplPSIxNiI+VGhlIHZhbGlkYXRlZCBwcm9wb3NhbCBiZWNvbWVzIGR1cmFibGUgYXBwbGljYXRpb24gc3RhdGUuIFRoZSBtb2RlbCBydW4gaXMgZmluaXNoZWQuPC90ZXh0PgoKICAgIDxwYXRoIGQ9Ik0yNjMgNDUxdjE4MiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMjU2M2ViIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1kYXNoYXJyYXk9IjcgNyIvPgogICAgPHJlY3QgeD0iMjc4IiB5PSI0NDgiIHdpZHRoPSIyMjYiIGhlaWdodD0iMzAiIHJ4PSIxNSIgZmlsbD0iI2RiZWFmZSIvPgogICAgPHRleHQgeD0iMzkxIiB5PSI0NjkiIGZpbGw9IiMxZDRlZDgiIGZvbnQtc2l6ZT0iMTQiIGZvbnQtd2VpZ2h0PSI3MDAiIHRleHQtYW5jaG9yPSJtaWRkbGUiPkFQUExJQ0FUSU9OLU9XTkVEIEZST00gSEVSRTwvdGV4dD4KCiAgICA8ZyBmaWx0ZXI9InVybCgjc2hhZG93KSI+CiAgICAgIDxyZWN0IHg9IjY0IiB5PSI1MDQiIHdpZHRoPSIxODAiIGhlaWdodD0iMTEyIiByeD0iMTIiIGZpbGw9IiNmZmYiIHN0cm9rZT0iI2NiZDVlMSIgc3Ryb2tlLXdpZHRoPSIyIi8+CiAgICAgIDxyZWN0IHg9IjI4NiIgeT0iNTA0IiB3aWR0aD0iMTgwIiBoZWlnaHQ9IjExMiIgcng9IjEyIiBmaWxsPSIjZGJlYWZlIiBzdHJva2U9IiM2MGE1ZmEiIHN0cm9rZS13aWR0aD0iMiIvPgogICAgICA8cmVjdCB4PSI1MDgiIHk9IjUwNCIgd2lkdGg9IjE4MCIgaGVpZ2h0PSIxMTIiIHJ4PSIxMiIgZmlsbD0iI2ZmZjdlZCIgc3Ryb2tlPSIjZmI5MjNjIiBzdHJva2Utd2lkdGg9IjIiLz4KICAgICAgPHJlY3QgeD0iNzMwIiB5PSI1MDQiIHdpZHRoPSIxODAiIGhlaWdodD0iMTEyIiByeD0iMTIiIGZpbGw9IiNkYmVhZmUiIHN0cm9rZT0iIzYwYTVmYSIgc3Ryb2tlLXdpZHRoPSIyIi8+CiAgICAgIDxyZWN0IHg9Ijk1MiIgeT0iNTA0IiB3aWR0aD0iMTgwIiBoZWlnaHQ9IjExMiIgcng9IjEyIiBmaWxsPSIjZGJlYWZlIiBzdHJva2U9IiMyNTYzZWIiIHN0cm9rZS13aWR0aD0iMiIvPgogICAgPC9nPgoKICAgIDxnIGZpbGw9IiMwZjE3MmEiIGZvbnQtc2l6ZT0iMTYiIGZvbnQtd2VpZ2h0PSI3MDAiIHRleHQtYW5jaG9yPSJtaWRkbGUiPgogICAgICA8dGV4dCB4PSIxNTQiIHk9IjU0MCI+PHRzcGFuIHg9IjE1NCI+TW9kZWwgcHJvcG9zZXM8L3RzcGFuPjx0c3BhbiB4PSIxNTQiIGR5PSIyMyI+YW4gb3BlcmF0aW9uPC90c3Bhbj48L3RleHQ+CiAgICAgIDx0ZXh0IHg9IjM3NiIgeT0iNTMwIj48dHNwYW4geD0iMzc2Ij5SdXN0IHZhbGlkYXRlczwvdHNwYW4+PHRzcGFuIHg9IjM3NiIgZHk9IjIzIj5hbmQgZnJlZXplczwvdHNwYW4+PHRzcGFuIHg9IjM3NiIgZHk9IjIzIj50aGUgY29udHJhY3Q8L3RzcGFuPjwvdGV4dD4KICAgICAgPHRleHQgeD0iNTk4IiB5PSI1MzAiPjx0c3BhbiB4PSI1OTgiPkh1bWFuIGFwcHJvdmVzPC90c3Bhbj48dHNwYW4geD0iNTk4IiBkeT0iMjMiPnRoZSBleGFjdDwvdHNwYW4+PHRzcGFuIHg9IjU5OCIgZHk9IjIzIj5lZmZlY3RzPC90c3Bhbj48L3RleHQ+CiAgICAgIDx0ZXh0IHg9IjgyMCIgeT0iNTMwIj48dHNwYW4geD0iODIwIj5SdXN0IHJlbG9hZHM8L3RzcGFuPjx0c3BhbiB4PSI4MjAiIGR5PSIyMyI+YW5kIHJldmFsaWRhdGVzPC90c3Bhbj48dHNwYW4geD0iODIwIiBkeT0iMjMiPmN1cnJlbnQgc3RhdGU8L3RzcGFuPjwvdGV4dD4KICAgICAgPHRleHQgeD0iMTA0MiIgeT0iNTMwIj48dHNwYW4geD0iMTA0MiI+UnVzdCBleGVjdXRlczwvdHNwYW4+PHRzcGFuIHg9IjEwNDIiIGR5PSIyMyI+YW5kIHN0b3JlcyB0aGU8L3RzcGFuPjx0c3BhbiB4PSIxMDQyIiBkeT0iMjMiPnR5cGVkIG91dGNvbWU8L3RzcGFuPjwvdGV4dD4KICAgIDwvZz4KCiAgICA8ZyBmaWxsPSJub25lIiBzdHJva2U9IiMyNTYzZWIiIHN0cm9rZS13aWR0aD0iMyIgbWFya2VyLWVuZD0idXJsKCNhcnJvdy1ibHVlKSI+CiAgICAgIDxwYXRoIGQ9Ik0yNDQgNTYwaDMxIi8+CiAgICAgIDxwYXRoIGQ9Ik00NjYgNTYwaDMxIi8+CiAgICAgIDxwYXRoIGQ9Ik02ODggNTYwaDMxIi8+CiAgICAgIDxwYXRoIGQ9Ik05MTAgNTYwaDMxIi8+CiAgICA8L2c+CgogICAgPHRleHQgeD0iMTA0MiIgeT0iNjQ3IiBmaWxsPSIjMWQ0ZWQ4IiBmb250LXNpemU9IjE0IiBmb250LXdlaWdodD0iNzAwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIj5OTyBNT0RFTCBSRVNVTUU8L3RleHQ+CiAgPC9nPgo8L3N2Zz4K" width="1200" height="720" class="img_ev3q"></p>
<p>Not resuming the model after approval has a narrow meaning. While a proposal is
being prepared, safe validation errors can go back into the active model loop
so it can correct its request. If a manager declines a proposal, or execution
finds a recoverable state change, Curling IO records app-authored revision
context for the manager's next message. That starts a new model turn with the
safe reason included. A terminal internal failure is recorded and reported as
non-retryable instead. The model can adapt where that is useful, but it never
owns execution of the approved contract.</p>
<p>The first pattern can be implemented safely. It's not that resuming an agent
automatically changes an approved call. A good runtime should preserve the
exact call and its identity. For a first-party application, the paused model
run is unnecessary operational state once the application has accepted a
complete proposal.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="human-approval-is-not-enough">Human approval is not enough<a href="https://curling.io/blog/human-in-the-loop-with-contracts#human-approval-is-not-enough" class="hash-link" aria-label="Direct link to Human approval is not enough" title="Direct link to Human approval is not enough">​</a></h2>
<p>An approval button is only meaningful if the application can say exactly what
was approved.</p>
<p>Suppose a model asks to refund an order. A weak approval could show:</p>
<blockquote>
<p>Refund this customer?</p>
</blockquote>
<p>That leaves almost every material decision hidden. Which payment? How much?
Which line item? Where will the money go? Is the model using a value it
calculated itself? Could it select a different destination when execution
resumes?</p>
<p>Our refund review shows the participant, product, discount, payment, amount,
destination, and resulting order total. The manager is not approving the
model's general intention to fix an order. They are approving one concrete
operation with one set of effects.</p>
<p>That requires more than a tool schema. It requires an application-owned
contract that defines all of these together:</p>
<ul>
<li>the fields the model must supply;</li>
<li>the defaults Rust is allowed to resolve;</li>
<li>valid and invalid combinations;</li>
<li>the typed representation stored for approval;</li>
<li>the fixed human review presentation;</li>
<li>revalidation against current state;</li>
<li>the executor;</li>
<li>the typed result and safe handback; and</li>
<li>the audit events for the whole lifecycle.</li>
</ul>
<p>If those pieces are split between a prompt, a generic JSON schema, a hand-built
review page, and an unrelated executor, they will drift. A new field can affect
execution without appearing in the review. A prompt can describe a default
differently from the application. A model can produce a value the ordinary
interface would never allow.</p>
<p>In Curling IO, those are all parts of one capability.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-smallest-useful-surface">The smallest useful surface<a href="https://curling.io/blog/human-in-the-loop-with-contracts#the-smallest-useful-surface" class="hash-link" aria-label="Direct link to The smallest useful surface" title="Direct link to The smallest useful surface">​</a></h2>
<p>Our first preference is not to guard a broad agent surface. It is to avoid
presenting that surface in the first place.</p>
<p>Each assistant is confined to one section of Curling IO and receives only the
context needed for the current task. The order assistant does not receive an
organization-wide database view. The email assistant does not inherit the
order assistant's tools. Tenant identity, permissions, internal field names,
provider payloads, and unrelated customer records stay on the application side
of the boundary.</p>
<p>The same rule applies to operations. An assistant sees a small catalogue of
things it may propose, not a generic write API. Its model-facing fields contain
only the choices that genuinely require interpretation. Rust supplies resource
scope, resolves defaults, calculates derived values, rejects unsupported
combinations, and builds the human preview.</p>
<p>Then we add a guardrail at every remaining vector from model output to durable
state:</p>
<ul>
<li>bounded, application-written context before the model call;</li>
<li>task-specific read tools with server-owned tenant scope;</li>
<li>strict parsing and validation of the model's operation request;</li>
<li>typed arguments and previews built by Rust;</li>
<li>human approval of every material effect;</li>
<li>authorization, expiry, and state revalidation at execution time;</li>
<li>idempotency at the proposal and domain-record boundaries; and</li>
<li>typed, redacted outcomes after execution.</li>
</ul>
<p>No one check carries the whole safety argument. Human approval does not replace
authorization. A type does not prove that current state still permits the
operation. Revalidation does not prevent a duplicate provider call after a
lost response. The surface stays small, and every boundary still has its own
job.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-model-request-is-not-the-stored-proposal">The model request is not the stored proposal<a href="https://curling.io/blog/human-in-the-loop-with-contracts#the-model-request-is-not-the-stored-proposal" class="hash-link" aria-label="Direct link to The model request is not the stored proposal" title="Direct link to The model request is not the stored proposal">​</a></h2>
<p>Our order assistant has a model-facing operation called
<code>propose_payment_refund</code>. Its input is intentionally small. The model identifies
the relevant payment, line item, discount, and requested destination from the
evidence Curling IO gave it.</p>
<p>Rust does not store that request directly. It validates the request against the
server-owned order investigation, calculates the refund using application
rules, resolves a concrete destination, and creates typed arguments:</p>
<div class="language-rust codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-rust codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token attribute attr-name" style="color:rgb(255, 203, 107)">#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token keyword" style="font-style:italic">struct</span><span class="token plain"> </span><span class="token type-definition class-name" style="color:rgb(255, 203, 107)">OrderRefundArguments</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    order_id</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token keyword" style="font-style:italic">i64</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    payment_id</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token keyword" style="font-style:italic">i64</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    line_item_id</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token keyword" style="font-style:italic">i64</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    discount_id</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token keyword" style="font-style:italic">i64</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    amount_cents</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token keyword" style="font-style:italic">i64</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    refund_destination</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">RefundDestination</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><br></span></code></pre></div></div>
<p>The model does not choose <code>amount_cents</code>. It cannot say "use the original
payment method" and leave that decision until later. Rust resolves that phrase
to a concrete <code>RefundDestination</code> before the manager sees anything.</p>
<p>The review is typed separately from the executable arguments:</p>
<div class="language-rust codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-rust codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token attribute attr-name" style="color:rgb(255, 203, 107)">#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token keyword" style="font-style:italic">struct</span><span class="token plain"> </span><span class="token type-definition class-name" style="color:rgb(255, 203, 107)">OrderRefundPreview</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    participant_name</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">String</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    product_name</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">String</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    discount_name</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">String</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    payment_method</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">PaymentMethod</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    amount_cents</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token keyword" style="font-style:italic">i64</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    current_order_total_cents</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token keyword" style="font-style:italic">i64</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    resulting_order_total_cents</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token keyword" style="font-style:italic">i64</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    currency</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">String</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    refund_destination</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">RefundDestination</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    product_configuration_unchanged</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token keyword" style="font-style:italic">bool</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><br></span></code></pre></div></div>
<p>The arguments contain what execution needs. The preview contains what a human
needs to understand the consequences. Both come from the same validated domain
facts, and both are frozen together.</p>
<p>JSON appears at the database boundary, but it is not the programming model. An
operation is parsed back into its Rust type before it can be revalidated or
executed. An operation kind cannot be dispatched into another operation's
argument type.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="a-proposal-is-durable-application-state">A proposal is durable application state<a href="https://curling.io/blog/human-in-the-loop-with-contracts#a-proposal-is-durable-application-state" class="hash-link" aria-label="Direct link to A proposal is durable application state" title="Direct link to A proposal is durable application state">​</a></h2>
<p>Each proposal gets an opaque public identifier and a row containing its tenant,
requesting administrator, section, resource, operation kind, exact arguments,
preview, status, and expiry. It also records approval, execution, result, and
failure information.</p>
<p>A simplified view of the Rust side looks like this:</p>
<div class="language-rust codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-rust codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token keyword" style="font-style:italic">struct</span><span class="token plain"> </span><span class="token type-definition class-name" style="color:rgb(255, 203, 107)">OperationProposal</span><span class="token operator" style="color:rgb(137, 221, 255)">&lt;</span><span class="token class-name" style="color:rgb(255, 203, 107)">Arguments</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">Preview</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">Result</span><span class="token operator" style="color:rgb(137, 221, 255)">&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    public_id</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">ProposalId</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    interaction_id</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">InteractionId</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    scope</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">OperationScope</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    requested_by</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">UserId</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    operation_kind</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">OperationKind</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    arguments</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">Arguments</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    preview</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">Preview</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    status</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">ProposalStatus</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    expires_at</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">DateTime</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    approval</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">Option</span><span class="token operator" style="color:rgb(137, 221, 255)">&lt;</span><span class="token class-name" style="color:rgb(255, 203, 107)">Approval</span><span class="token operator" style="color:rgb(137, 221, 255)">&gt;</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    outcome</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">Option</span><span class="token operator" style="color:rgb(137, 221, 255)">&lt;</span><span class="token class-name" style="color:rgb(255, 203, 107)">OperationOutcome</span><span class="token operator" style="color:rgb(137, 221, 255)">&lt;</span><span class="token class-name" style="color:rgb(255, 203, 107)">Result</span><span class="token operator" style="color:rgb(137, 221, 255)">&gt;&gt;</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token keyword" style="font-style:italic">struct</span><span class="token plain"> </span><span class="token type-definition class-name" style="color:rgb(255, 203, 107)">OperationScope</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    organization_id</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">OrganizationId</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    section</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">AssistantSection</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    resource_id</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">ResourceId</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><br></span></code></pre></div></div>
<p><code>Arguments</code>, <code>Preview</code>, and <code>Result</code> are the types owned by one operation
contract. SQLite stores their serialized form and the proposal's audit events,
but application code cannot execute those values without decoding them through
the matching contract.</p>
<p>The conversation is not the source of truth for this operation. Neither is the
model provider's stored run state. A pending proposal survives a browser
disconnect, a model change, or a deployment because Curling IO can reconstruct
the approval from its own records.</p>
<p>This also gives the approval request a very small input. The browser submits the
opaque proposal identifier. It does not submit the refund amount, destination,
message body, or any other approved value a second time.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="approval-creates-one-execution-contract">Approval creates one execution contract<a href="https://curling.io/blog/human-in-the-loop-with-contracts#approval-creates-one-execution-contract" class="hash-link" aria-label="Direct link to Approval creates one execution contract" title="Direct link to Approval creates one execution contract">​</a></h2>
<p>When the administrator selects <strong>Approve refund</strong>, Curling IO verifies all of
the ordinary request boundaries again:</p>
<ul>
<li>the signed-in user still has access to the organization;</li>
<li>the proposal belongs to that organization, section, and order;</li>
<li>the proposal is still pending and has not expired;</li>
<li>its stored operation kind and arguments can still be parsed; and</li>
<li>the current user is still allowed to perform the underlying operation.</li>
</ul>
<p>Only then does the proposal move from <code>pending</code> to <code>executing</code>. The update is
atomic and produces a typed execution contract:</p>
<div class="language-rust codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-rust codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token keyword" style="font-style:italic">struct</span><span class="token plain"> </span><span class="token type-definition class-name" style="color:rgb(255, 203, 107)">ExecutionContract</span><span class="token operator" style="color:rgb(137, 221, 255)">&lt;</span><span class="token class-name" style="color:rgb(255, 203, 107)">Arguments</span><span class="token operator" style="color:rgb(137, 221, 255)">&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    proposal_id</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">ProposalId</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    operation_kind</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">OperationKind</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    scope</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">OperationScope</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    arguments</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">Arguments</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    approved_by</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">UserId</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    approved_at</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">DateTime</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token keyword" style="font-style:italic">match</span><span class="token plain"> proposals</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token function" style="color:rgb(130, 170, 255)">approve_for_execution</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain">proposal_id</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> administrator</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> now</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token operator" style="color:rgb(137, 221, 255)">?</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token class-name" style="color:rgb(255, 203, 107)">Approval</span><span class="token punctuation" style="color:rgb(199, 146, 234)">::</span><span class="token class-name" style="color:rgb(255, 203, 107)">Claimed</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain">contract</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(137, 221, 255)">=&gt;</span><span class="token plain"> </span><span class="token function" style="color:rgb(130, 170, 255)">execute</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain">contract</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token class-name" style="color:rgb(255, 203, 107)">Approval</span><span class="token punctuation" style="color:rgb(199, 146, 234)">::</span><span class="token class-name" style="color:rgb(255, 203, 107)">AlreadyCompleted</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain">outcome</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(137, 221, 255)">=&gt;</span><span class="token plain"> </span><span class="token function" style="color:rgb(130, 170, 255)">present</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain">outcome</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token class-name" style="color:rgb(255, 203, 107)">Approval</span><span class="token punctuation" style="color:rgb(199, 146, 234)">::</span><span class="token class-name" style="color:rgb(255, 203, 107)">NotApprovable</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain">reason</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(137, 221, 255)">=&gt;</span><span class="token plain"> </span><span class="token function" style="color:rgb(130, 170, 255)">reject</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain">reason</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><br></span></code></pre></div></div>
<p>The repository issues <code>Approval::Claimed</code> only when it atomically moves the
matching, unexpired proposal from <code>pending</code> to <code>executing</code>. If another request
already approved, declined, or completed it, the handler does not receive an
execution contract and therefore does not get a second authorization to
execute.</p>
<p>The model is not involved in any of this. Approval is an authenticated request
from the administrator to Curling IO, not another message in the conversation.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="revalidation-is-part-of-execution">Revalidation is part of execution<a href="https://curling.io/blog/human-in-the-loop-with-contracts#revalidation-is-part-of-execution" class="hash-link" aria-label="Direct link to Revalidation is part of execution" title="Direct link to Revalidation is part of execution">​</a></h2>
<p>Freezing a proposal prevents its arguments from changing. It does not freeze
the rest of the world.</p>
<p>A payment may have been refunded in another tab. Someone may have changed a
broadcast's audience. The administrator may have lost access. A proposal may
have sat open long enough to expire.</p>
<p>The refund executor therefore reloads the order and proves the original
evidence still holds. It checks that:</p>
<ul>
<li>the same line item and discount evidence still exist;</li>
<li>the calculated amount has not changed;</li>
<li>the selected payment still exists and has enough refundable value;</li>
<li>no unresolved refund attempt makes another call unsafe; and</li>
<li>the concrete destination is still compatible with the payment and account.</li>
</ul>
<p>If a material fact changed, the old proposal fails closed. The application
records a revision-required outcome, explains the changed condition in safe
terms, and asks the manager to prepare a new proposal. It does not silently
update the amount under an approval that showed something else.</p>
<p>This is optimistic concurrency in human terms. The preview is a claim about a
particular state. Revalidation proves that claim is still true when approval
arrives.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="idempotency-has-to-reach-the-domain-record">Idempotency has to reach the domain record<a href="https://curling.io/blog/human-in-the-loop-with-contracts#idempotency-has-to-reach-the-domain-record" class="hash-link" aria-label="Direct link to Idempotency has to reach the domain record" title="Direct link to Idempotency has to reach the domain record">​</a></h2>
<p>Conditional approval prevents the same pending proposal from starting twice,
but that alone is not enough for operations with external effects.</p>
<p>Consider an online card refund. Curling IO can send the provider request and
lose the HTTP response. At that point, retrying may create a second refund. The
correct outcome is not a generic failure. It is
<code>OperationOutcome::NeedsReconciliation { status_path }</code>.</p>
<p>The refund workflow retains stable proposal and provider identities, records
the unresolved attempt, and refuses to improvise another call. Recovery checks
the original attempt and eventually records the authoritative result.</p>
<p>For local database operations, the domain mutation and successful proposal
outcome are committed in the same transaction. For external operations, the
proposal stays in an executing or reconciliation state until recovery closes
the uncertainty.</p>
<p>Repeated approval of a completed proposal returns its original stored outcome.
It does not manufacture a fresh success message, and it does not execute the
operation again. Replay is a delivery fact, not a new business result.</p>
<p>That distinction matters because HTTP responses are not durable. The operation
record is.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="handing-back-the-result-without-resuming-the-agent">Handing back the result without resuming the agent<a href="https://curling.io/blog/human-in-the-loop-with-contracts#handing-back-the-result-without-resuming-the-agent" class="hash-link" aria-label="Direct link to Handing back the result without resuming the agent" title="Direct link to Handing back the result without resuming the agent">​</a></h2>
<p>The application already knows what happened, so it should not spend another
model call asking for a paraphrase.</p>
<p>We use a small typed outcome vocabulary around each operation's own result:</p>
<div class="language-rust codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-rust codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token keyword" style="font-style:italic">enum</span><span class="token plain"> </span><span class="token type-definition class-name" style="color:rgb(255, 203, 107)">OperationOutcome</span><span class="token operator" style="color:rgb(137, 221, 255)">&lt;</span><span class="token class-name" style="color:rgb(255, 203, 107)">S</span><span class="token operator" style="color:rgb(137, 221, 255)">&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token class-name" style="color:rgb(255, 203, 107)">Succeeded</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token class-name" style="color:rgb(255, 203, 107)">S</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token class-name" style="color:rgb(255, 203, 107)">NeedsReconciliation</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">        status_path</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">String</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token class-name" style="color:rgb(255, 203, 107)">RevisionRequired</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">        reason</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">RevisionReason</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token class-name" style="color:rgb(255, 203, 107)">TerminalFailure</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">        correlation_id</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">String</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><br></span></code></pre></div></div>
<p>The operation-specific <code>S</code> might identify the refund and resulting order
balance, or state that an email draft was copied into the editable form. The
shared enum says what the user and system can safely do next.</p>
<p>One stored outcome drives several projections:</p>
<ul>
<li>a localized browser response;</li>
<li>an app-authored follow-up in the assistant conversation;</li>
<li>bounded context supplied if the manager sends another message; and</li>
<li>eventually, the same semantic result through a machine-facing agent API.</li>
</ul>
<p>The model does not classify the failure or decide whether retrying is safe.
Rust does. On the next user turn, a recoverable outcome gives the model enough
safe context to investigate again or prepare a revised proposal.</p>
<p>For an internal invariant failure or corrupt stored contract, the application
records the operational error itself. The model and browser receive a terminal
message with a correlation identifier, not a stack trace and not an invitation
to keep trying. Asking the agent to submit a support ticket would add another
failure-prone step while throwing away context the application already has.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="two-assistants-were-enough-to-expose-the-boundary">Two assistants were enough to expose the boundary<a href="https://curling.io/blog/human-in-the-loop-with-contracts#two-assistants-were-enough-to-expose-the-boundary" class="hash-link" aria-label="Direct link to Two assistants were enough to expose the boundary" title="Direct link to Two assistants were enough to expose the boundary">​</a></h2>
<p>We currently have two operation contracts, and they are deliberately
different.</p>
<p>The order assistant can propose <code>order.refund_missing_discount</code>. Approval may
lead to a financial operation with an external provider, uncertain responses,
and reconciliation work.</p>
<p>The email broadcast assistant can propose <code>email_broadcast.apply_draft</code>.
Approval copies frozen filters, subject, and message into an editable form. It
does not create or send the broadcast. This is a local operation, but it still
recounts the audience before applying the draft. If the audience changed, the
manager needs a new proposal.</p>
<p>That difference stopped us from building a refund framework and calling it an
agent framework. The shared part is small: lifecycle, approval identity,
outcome semantics, audit, and handback. Argument types, previews,
revalidation, and execution remain with the operation that understands them.</p>
<p>We expect to add assistants to a couple dozen sections. Each new operation will
need a typed request, frozen arguments, a human preview, a revalidator, an
executor, a typed result, and focused tests for expiry, stale state, replay,
and failure classification.</p>
<p>That is more work than adding another function tool to a prompt. It is supposed
to be.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-this-pattern-is-and-what-it-is-not">What this pattern is, and what it is not<a href="https://curling.io/blog/human-in-the-loop-with-contracts#what-this-pattern-is-and-what-it-is-not" class="hash-link" aria-label="Direct link to What this pattern is, and what it is not" title="Direct link to What this pattern is, and what it is not">​</a></h2>
<p>We did not invent human approval, durable commands, optimistic concurrency,
capability security, or idempotency keys. The design borrows from all of them.
Agent frameworks already support pausing tool calls for review.</p>
<p>The useful shift is treating the agent's requested mutation as input to an
application command, not as the command itself. The application materializes a
new durable object with stricter semantics than the model call that inspired
it. Once that object exists, the model run is disposable.</p>
<p>This pattern is not necessary for every assistant response. Read-only answers
do not need frozen proposals. Draft text that has no application effect can
remain draft text. But if an operation changes customer data, sends something,
moves money, or alters access, we want a stronger statement than "the model
called a tool and a human clicked approve."</p>
<p>We want to know exactly what the application promised to do, exactly what the
human approved, exactly which current facts were rechecked, and exactly what
happened afterward.</p>
<p>That is the contract.</p>]]></content>
        <author>
            <name>Dave Rapin</name>
        </author>
        <category label="v3" term="v3"/>
        <category label="agents" term="agents"/>
        <category label="rust" term="rust"/>
        <category label="architecture" term="architecture"/>
        <category label="security" term="security"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[In-app Assistance in Curling IO]]></title>
        <id>https://curling.io/blog/in-app-assistance-in-curling-io</id>
        <link href="https://curling.io/blog/in-app-assistance-in-curling-io"/>
        <updated>2026-08-24T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[How optional, task-specific assistants in Curling IO can investigate a problem, prepare an operation, and carry it out after approval.]]></summary>
        <content type="html"><![CDATA[<p><em>This post is part of our Curling IO v3
<a href="https://curling.io/blog/tags/sneak-peek">sneak peek series</a>, where we explore some of the new
features available in the upcoming version.</em></p>
<p>Curling IO v3 includes optional in-app assistance for most tasks a club manager
does. It appears within the section where the work is happening and uses the
context needed for that work.</p>
<p>An assistant can investigate a problem, explain what it finds, and prepare an
operation for review. It performs that operation only after an administrator
explicitly approves it.</p>
<!-- -->
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="a-change-from-our-earlier-plan">A change from our earlier plan<a href="https://curling.io/blog/in-app-assistance-in-curling-io#a-change-from-our-earlier-plan" class="hash-link" aria-label="Direct link to A change from our earlier plan" title="Direct link to A change from our earlier plan">​</a></h2>
<p>Earlier this year, we <a href="https://curling.io/blog/automate-club-management-with-ai">wrote about letting clubs connect their own AI
agent</a> to Curling IO. That approach put
the choice and configuration of the model in the club's hands. We've since
changed direction. The main experience will be assistants built into Curling
IO, with the external model provider selected and managed by us.</p>
<p>Managing the provider ourselves means we can:</p>
<ul>
<li>control exactly what information and operations are available for each task;</li>
<li>require <strong>Zero Data Retention</strong> from the model provider; and</li>
<li>avoid asking every club to configure and pay for its own model account.</li>
</ul>
<p>It should also cost less to operate because each assistant receives the context
it needs instead of having to discover how the entire system works. When an
assistant needs better context, we can improve its supporting documentation and
task design for every club.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="investigating-an-order">Investigating an order<a href="https://curling.io/blog/in-app-assistance-in-curling-io#investigating-an-order" class="hash-link" aria-label="Direct link to Investigating an order" title="Direct link to Investigating an order">​</a></h2>
<p><em>We set up this premise in a demo club, then worked through it using the
in-app assistant. The responses shown below were not scripted.</em></p>
<p>Suppose a club offers a Youth discount of 50% to curlers aged 17 and under.
The discount is assigned to most of the club's leagues, but an administrator
misses one while setting up the season.</p>
<p>Madison registers for that league and the order is paid at full price. Later, her
parent calls the club. The club manager opens the order and asks:</p>
<blockquote>
<p>Madison's parent called. They paid full price, but thought Madison was
supposed to get the Youth discount. Can you look into it?</p>
</blockquote>
<p>The assistant can inspect the information relevant to that order. It checks
Madison's age at the start of the season, the requirements of the Youth
discount, the league she purchased, and the discounts assigned to it.</p>
<p><img decoding="async" loading="lazy" alt="The Order assistant investigating why Madison&amp;#39;s paid registration did not
receive the Youth discount." src="https://curling.io/assets/images/order-investigation-d649a255d955ec91bf8910439bd2c7eb.png" width="2400" height="1440" class="img_ev3q"></p>
<p>In this case, Madison meets the age requirement. The problem is that the Youth
discount was not assigned to the league, so it was not considered when the
order was priced.</p>
<p>That distinction matters. Checkout followed the club's configuration. The
assistant has found a likely setup mistake, but it does not assume that the
club intended the discount to apply. The club manager confirms that before
anything is changed.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="preparing-the-correction">Preparing the correction<a href="https://curling.io/blog/in-app-assistance-in-curling-io#preparing-the-correction" class="hash-link" aria-label="Direct link to Preparing the correction" title="Direct link to Preparing the correction">​</a></h2>
<p>After the manager confirms that the league should offer the Youth discount,
the assistant calculates the adjustment using the same pricing rules as
checkout. It prepares a reduction to the order and the corresponding refund
against the original payment. For Madison's $275 registration, the proposed
refund is $137.50.</p>
<p><img decoding="async" loading="lazy" alt="A refund proposal for Madison&amp;#39;s order, showing the participant, league, Youth
discount, payment being refunded, refund destination, refund amount, resulting order total, and Approve Refund
button." src="https://curling.io/assets/images/order-refund-proposal-31c4e31120e9a46170ba752c385b9306.png" width="2400" height="1440" class="img_ev3q"></p>
<p>The manager can review the calculation, the payment being refunded, where the
refund will go, and the resulting order balance. Nothing changes until the
manager approves the operation. If the refund should go to account credit
instead, the manager can decline the proposal and ask for that change.</p>
<p>The approval corrects this order. It does not change the league's
configuration. The assistant explains that the manager still needs to open
the product and assign the Youth discount so future registrations receive it.</p>
<p>After approval, the order shows the $137.50 reduction and the refund against
the original payment.</p>
<p><img decoding="async" loading="lazy" alt="Madison&amp;#39;s order after approval, with the $137.50 reduction and refund recorded
against the original payment." src="https://curling.io/assets/images/order-refund-completed-c6f7feeea356a584748b29c457c1a182.png" width="2496" height="1440" class="img_ev3q"></p>
<p>That is an intentional boundary. The order assistant can investigate and
correct that order. It cannot cross into editing a product just because the
investigation found a product configuration problem.</p>
<p>We may eventually allow an explicit handoff from the order assistant to the
assistant responsible for products. For now, each assistant stays within a
tight, task-specific sandbox.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="preparing-an-email-broadcast">Preparing an email broadcast<a href="https://curling.io/blog/in-app-assistance-in-curling-io#preparing-an-email-broadcast" class="hash-link" aria-label="Direct link to Preparing an email broadcast" title="Direct link to Preparing an email broadcast">​</a></h2>
<p><em>We set up this premise in a demo club, then worked through it using the
in-app assistant. The responses shown below were not scripted.</em></p>
<p>A club manager can also ask the assistant to prepare an email for a particular
audience. The assistant prepares the audience filters and message, then presents
the broadcast for review.</p>
<p>For example, a manager can ask:</p>
<blockquote>
<p>Please prepare a short reminder for everyone registered in Tuesday Mixed
Doubles this season. Let them know the first draw is Tuesday, October 7 at
7:00 PM and ask them to arrive 15 minutes early.</p>
</blockquote>
<p>The assistant tests the requested filters separately from the broadcast form,
finds four matching recipients, and prepares the subject and message. It then
presents the complete draft for review. The manager's form has not changed yet.</p>
<p><img decoding="async" loading="lazy" alt="An Email Broadcast proposal showing the audience, four current recipients,
subject, message, and Decline and Apply to broadcast buttons while the editable
form remains unchanged." src="https://curling.io/assets/images/email-broadcast-prepared-7790ce09541cda25f8cbb5697c2d89b0.png" width="2400" height="1440" class="img_ev3q"></p>
<p>If the manager chooses <strong>Apply to broadcast</strong>, Curling IO copies those exact
filters and message into the editable form. The manager can change them further,
inspect the resulting recipients, and use the normal send confirmation when
ready. This step does not create or send the broadcast.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="limited-to-the-current-task">Limited to the current task<a href="https://curling.io/blog/in-app-assistance-in-curling-io#limited-to-the-current-task" class="hash-link" aria-label="Direct link to Limited to the current task" title="Direct link to Limited to the current task">​</a></h2>
<p>Each assistant is optional and inactive until someone uses it. When a manager
asks for help, Curling IO gathers only the information relevant to completing
that task and provides it to the model in a limited, structured form.</p>
<p>The order example does not require unrestricted access to the club. It needs
the order, the participant and purchase information relevant to its pricing,
and the nearby discount configuration needed to answer the question. Unrelated
orders and unrelated member information are left out.</p>
<p>Curling IO also decides which task-specific tools are available in each
section. The order assistant may inspect pricing and propose an order
correction. Editing a product is not available. The email broadcast assistant
receives a different set of read-only tools for preparing and previewing an
audience and message.</p>
<p>The model cannot call arbitrary application code or add tools to its own task.
Most importantly, it is not given a tool that performs an approved operation.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="data-processing">Data processing<a href="https://curling.io/blog/in-app-assistance-in-curling-io#data-processing" class="hash-link" aria-label="Direct link to Data processing" title="Direct link to Data processing">​</a></h2>
<p>Curling IO requires the external model provider handling each assistant request
to operate under a Zero Data Retention policy. This means the provider can
process the information supplied for the request and return a response,
but cannot retain the prompt or response afterward or use it to train models.
Provider prompt logging is not enabled.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="approval-is-part-of-the-operation">Approval is part of the operation<a href="https://curling.io/blog/in-app-assistance-in-curling-io#approval-is-part-of-the-operation" class="hash-link" aria-label="Direct link to Approval is part of the operation" title="Direct link to Approval is part of the operation">​</a></h2>
<p>Preparing an operation and performing it are separate steps. The assistant
can present a proposal, but it cannot approve or perform it.</p>
<p>Each operation has a contract that defines its required information, valid
values, defaults, review presentation, and execution rules. If the assistant
cannot fill every required field from the information available, and there is
no valid default, it has to gather more information or ask the manager.</p>
<p>The proposal contains the exact action and every parameter that can affect the
result. Curling IO stores that snapshot and uses the contract to render the
review shown to the manager. The assistant does not decide which fields to
show. For the refund, the review includes the participant, product, discount,
payment being refunded, refund amount, refund destination, and resulting order
total. For the broadcast, it includes the audience filters, current recipient
count, subject, and full message.</p>
<p>Approval turns that frozen proposal into an <strong>execution contract</strong>. Approval
is an authenticated request made by the administrator to Curling IO. It is not
a prompt sent to the model. The model is not resumed after approval and never
receives a function that can carry out the contract.</p>
<p>Curling IO loads the stored contract and executes it through application code.
No model is running at this point, so its parameters cannot change between
review and execution. The contract is single-use. A batch, another action, or
any material change requires a new proposal and another approval.</p>
<p>Approval does not bypass the regular application. Curling IO checks the
administrator's permissions, validates the current state, and applies the same
business rules used by the ordinary interface. If the operation is no longer
valid when it is approved, it is rejected instead of being performed from
stale information.</p>
<p>If the manager chooses <strong>Decline</strong>, Curling IO records that decision and asks
what they would like changed. Their next message continues the same
conversation with the decline included as context. The declined proposal
cannot later be approved.</p>
<p>Proposal creation, approval, decline, execution, and the resulting record are
audited. Operations that can be retried also retain the proposal identity so an
interrupted request cannot perform the same contract twice.</p>
<p>As we add assistants to more sections before Curling IO v3 launches, each will
have its own task-specific information and contracts. The approval boundary
will remain the same.</p>]]></content>
        <author>
            <name>Dave Rapin</name>
        </author>
        <category label="v3" term="v3"/>
        <category label="agents" term="agents"/>
        <category label="club-management" term="club-management"/>
        <category label="sneak-peek" term="sneak-peek"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Send Curling IO Purchases to Zapier or n8n]]></title>
        <id>https://curling.io/blog/product-webhooks-for-zapier-and-n8n</id>
        <link href="https://curling.io/blog/product-webhooks-for-zapier-and-n8n"/>
        <updated>2026-08-06T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Curling IO v3 webhooks can send selected product purchases, including rentals, to Zapier, n8n, or another HTTPS endpoint.]]></summary>
        <content type="html"><![CDATA[<p><em>This post is part of our Curling IO v3
<a href="https://curling.io/blog/tags/sneak-peek">sneak peek series</a>, where we explore some of the new
features available in the upcoming version.</em></p>
<p>Clubs often need to send purchase information to systems outside Curling IO.
That might mean updating a spreadsheet, notifying staff, or starting a workflow
in another service.</p>
<p>Rentals are one example. Someone may need to prepare the lounge, reserve tables,
arrange catering, or add the booking to a staff spreadsheet.</p>
<p>Curling IO v3 webhooks can send that product purchase to
<a href="https://zapier.com/" target="_blank" rel="noopener noreferrer">Zapier</a>, <a href="https://n8n.io/" target="_blank" rel="noopener noreferrer">n8n</a>, or another HTTPS endpoint
when it is submitted, paid, cancelled, or rescheduled.</p>
<!-- -->
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="choose-the-products-to-watch">Choose the products to watch<a href="https://curling.io/blog/product-webhooks-for-zapier-and-n8n#choose-the-products-to-watch" class="hash-link" aria-label="Direct link to Choose the products to watch" title="Direct link to Choose the products to watch">​</a></h2>
<p>One webhook sends one kind of notification to one destination. A club can send:</p>
<ul>
<li>every product;</li>
<li>products matching selected product types, such as rentals;</li>
<li>individual products, such as four specific corporate rental options.</li>
</ul>
<p>The product filter has three scopes. If two receivers need different products,
the administrator creates a separate webhook for each receiver and scope.</p>
<p>Rentals and their add-ons are products. The same webhook configuration also
works for memberships, leagues, programs, and other products.</p>
<p><img decoding="async" loading="lazy" alt="A Curling IO webhook configured to send paid rental purchases to Zapier." src="https://curling.io/assets/images/configure-rental-webhook-c241239ea9d1e772b327f79c05e222bd.png" width="2880" height="1800" class="img_ev3q"></p>
<p><em>A webhook can send every product, selected product types, or individual
products. This one sends paid rental purchases to Zapier.</em></p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="choose-when-to-send">Choose when to send<a href="https://curling.io/blog/product-webhooks-for-zapier-and-n8n#choose-when-to-send" class="hash-link" aria-label="Direct link to Choose when to send" title="Direct link to Choose when to send">​</a></h2>
<p>A webhook sends for one trigger:</p>
<ul>
<li><strong>A product is submitted</strong> after checkout finishes.</li>
<li><strong>A product is paid in full</strong> the first time its line item is fully paid.</li>
<li><strong>A product is cancelled</strong> when a refund allocation cancels the line item.
Reducing its quantity or value does not send this notification.</li>
<li><strong>A product is rescheduled</strong> when an administrator changes the purchased
product's start time in the calendar.</li>
</ul>
<p>A workflow that needs more than one trigger can use separate webhooks at the
same destination.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="choose-what-the-receiver-gets">Choose what the receiver gets<a href="https://curling.io/blog/product-webhooks-for-zapier-and-n8n#choose-what-the-receiver-gets" class="hash-link" aria-label="Direct link to Choose what the receiver gets" title="Direct link to Choose what the receiver gets">​</a></h2>
<p>The club chooses which purchase fields each webhook sends. New webhooks start
with 16 recommended fields covering the order, registration, product, pricing,
and schedule. Purchaser and participant information is not selected by default.</p>
<p>Fields are grouped and searchable. An administrator can select a whole group,
clear it, or return to the recommended selection. The payload preview updates
immediately, so the club can see the JSON keys and representative values before
saving or sending a test.</p>
<p><img decoding="async" loading="lazy" alt="A webhook payload preview with product name and sensitive participant fields selected." src="https://curling.io/assets/images/configure-webhook-payload-4e28a8f8a9b96035e375aba8fe98fbce.png" width="2880" height="1200" class="img_ev3q"></p>
<p><em>This example uses a small field selection so the complete payload is visible.
Delivery details remain in every payload, while the other keys follow the saved
selection.</em></p>
<p>Purchaser and participant fields are marked <strong>Sensitive</strong>. Custom participant
and registration questions are marked <strong>May be sensitive</strong> because the answer
depends on the question. Curling IO warns the administrator but lets the club
decide whether the destination should receive that information. Passwords,
authentication tokens, payment card data, and payment-provider secrets cannot
be selected.</p>
<p>Curling IO accepts only public HTTPS destination URLs, which encrypts the
request in transit. Curling IO has no control over how Zapier, n8n, or another
receiving service handles the data after delivery and is not responsible for
that third-party handling. The club is responsible for choosing the service
and deciding which fields to send to it.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="test-the-receiver-before-enabling-the-webhook">Test the receiver before enabling the webhook<a href="https://curling.io/blog/product-webhooks-for-zapier-and-n8n#test-the-receiver-before-enabling-the-webhook" class="hash-link" aria-label="Direct link to Test the receiver before enabling the webhook" title="Direct link to Test the receiver before enabling the webhook">​</a></h2>
<p>The administrator can leave a webhook disabled and select <strong>Send test</strong>. The
test uses the saved field selection and the real delivery path. It contains a
representative rental, so Zapier or n8n can discover the selected JSON fields
before the webhook is enabled.</p>
<p>In Zapier, use a <strong>Webhooks by Zapier &gt; Catch Hook</strong> trigger. In n8n, use a
<strong>Webhook</strong> node. Copy the receiver's HTTPS URL into Curling IO, send the test,
and map the fields before enabling the automation.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="delivery-reliability">Delivery reliability<a href="https://curling.io/blog/product-webhooks-for-zapier-and-n8n#delivery-reliability" class="hash-link" aria-label="Direct link to Delivery reliability" title="Direct link to Delivery reliability">​</a></h2>
<p>Webhook calls run in the background. Curling IO retries network failures,
timeouts, rate limits, and server errors up to five times. The receiver can use
the stable webhook publication ID to discard a duplicate if a request succeeds
but its response is lost.</p>
<p>Each request is signed with HMAC-SHA256 for receivers that can verify it. A
Zapier Catch Hook normally relies on its hard-to-guess URL instead, so that URL
needs to be treated like a password.</p>
<p>Administrators can inspect <strong>Recent Deliveries</strong>, see attempts and HTTP
statuses, retry a failed retained delivery, rotate the signing secret, or
disable the webhook. Detailed payload and delivery data are removed after 30
days. Each delivery stores its exact request body, and every retry sends that
same body even if the field selection or purchase data changes later.</p>
<p><img decoding="async" loading="lazy" alt="Recent Curling IO webhook deliveries, including successful and failed attempts." src="https://curling.io/assets/images/recent-deliveries-cf7cd107e59077cd0ce4273f6ae46058.png" width="2880" height="1640" class="img_ev3q"></p>
<p><em>Recent deliveries show successful requests, failed attempts, HTTP statuses,
and when a failed delivery can be retried.</em></p>
<p>See the <a href="https://curling.dev/docs/integrations/webhooks" target="_blank" rel="noopener noreferrer">webhook setup guide</a>
for the payload, Zapier and n8n steps, retry rules, and signature verification.</p>]]></content>
        <author>
            <name>Dave Rapin</name>
        </author>
        <category label="v3" term="v3"/>
        <category label="integrations" term="integrations"/>
        <category label="webhooks" term="webhooks"/>
        <category label="rentals" term="rentals"/>
        <category label="sneak-peek" term="sneak-peek"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Optimizing Curling Draw Schedules]]></title>
        <id>https://curling.io/blog/optimizing-curling-draw-schedules</id>
        <link href="https://curling.io/blog/optimizing-curling-draw-schedules"/>
        <updated>2026-08-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[*This post is part of our Curling IO v3]]></summary>
        <content type="html"><![CDATA[<p><em>This post is part of our Curling IO v3
<a href="https://curling.io/blog/tags/sneak-peek">sneak peek series</a>, where we explore some of the new
features available in the upcoming version.</em></p>
<p>Curling IO v3 includes a new draw scheduling screen for event games. The
schedule is a grid of draws and club resources, with unassigned games kept in a
queue beside it. You can drag and drop games, lock specific placements, and use
<strong>Allocate</strong> and <strong>Optimize</strong> around those locks.</p>
<p>Unlike a separate schedule template generator, this editor works with the
event's actual teams, stages, games, resources, and draw times. Saving the
schedule updates the event directly.</p>
<p>You can try most of the scheduling interface now at
<a href="https://curlingschedules.com/" target="_blank" rel="noopener noreferrer">CurlingSchedules.com</a>. It uses generic teams and
browser-local saves instead of an event's actual games, but the grid,
drag-and-drop editing, locks, catalog schedules, fairness inspection, and
optimization are available today.</p>
<!-- -->
<p><img decoding="async" loading="lazy" alt="A league draw schedule in the Curling IO v3 event editor" src="https://curling.io/assets/images/canonical-8x4-6e54d901aa66b015304394203545e0eb.png" width="1280" height="720" class="img_ev3q"></p>
<p><em>The Curling IO v3 schedule editor keeps the game queue, event controls, draw
times, resources, and scheduled games in one view.</em></p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-draw-scheduling-tool">The Draw Scheduling Tool<a href="https://curling.io/blog/optimizing-curling-draw-schedules#the-draw-scheduling-tool" class="hash-link" aria-label="Direct link to The Draw Scheduling Tool" title="Direct link to The Draw Scheduling Tool">​</a></h2>
<p>Version 2 generates draw schedule templates and uses dropdowns to place games.
It does not have this editor, the full canonical catalog, game locks, or local
optimization around manual changes.</p>
<p>The v3 editor supports:</p>
<ul>
<li>drag and drop games between the schedule and game queue</li>
<li>edit draw times, add draws as games are placed, or delete a draw and return
its games to the queue</li>
<li>lock a game so <strong>Allocate</strong> and <strong>Optimize</strong> leave it where you put it</li>
<li>allocate round-robin, ad hoc, and bracket games under their different
ordering rules, including queued games when requested</li>
<li>fill unused sheets with games from the next logical round when compact draws
matter more than keeping every round in a fresh row</li>
<li>inspect exactly which games produce the Max, Total, and Back-to-back fairness
numbers</li>
<li>add an ad hoc game to a specific round robin, so it remains part of that
stage's standings and scoring</li>
<li>import CSV assignments for existing games without recreating the event's
competition structure</li>
<li>save the current arrangement as a club draw schedule template, or deliberately
apply a matching template</li>
<li>start from a validated canonical schedule, then optimize event-specific
changes without moving locked games</li>
</ul>
<p>These are curling-specific scheduling rules. The allocator distinguishes
round-robin, ad hoc, and bracket games; understands logical rounds that may span
several draws; accounts for selected resources and fixed placements; and scores
how often teams return to the same sheet, including consecutive draws. It puts
ad hoc games after the affected teams' round-robin games, and unlocked bracket
games after round-robin and ad hoc play. A locked game is exempt from those
placement rules.</p>
<p>When an event contains one complete round robin and no conflicting locks,
<strong>Allocate</strong> checks our schedule catalog before doing any browser-side search.
Multiple iterations repeat the matching catalog layout as a starting point. A
matching club draw schedule template is presented separately and is applied
only when the drawmaster chooses it. Once locks, ad hoc games, multiple stages,
or manual placements change the problem, the browser optimizer works from the
actual event schedule.</p>
<p><img decoding="async" loading="lazy" alt="A locked game and an ad hoc game in the Curling IO v3 event editor" src="https://curling.io/assets/images/locked-and-extra-games-1480948d28ae5b65d9dac7a7347ed3b5.png" width="1280" height="720" class="img_ev3q"></p>
<p><em>The blue lock marks a placement that automatic allocation must preserve. The
queue contains an ad hoc second meeting between A and B, ready to be placed.</em></p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="see-the-draw-scheduling-workflow">See the Draw Scheduling Workflow<a href="https://curling.io/blog/optimizing-curling-draw-schedules#see-the-draw-scheduling-workflow" class="hash-link" aria-label="Direct link to See the Draw Scheduling Workflow" title="Direct link to See the Draw Scheduling Workflow">​</a></h2>
<p>This tutorial shows how to configure, allocate, edit, inspect, optimize, save,
and reuse an event draw schedule.</p>
<div class="text--center videoWrapper"><iframe width="100%" src="https://www.youtube.com/embed/wigR-bzj004?si=8tE3gk-KWvb7ZpKT" title="Build and Manage a Curling Draw Schedule in Curling IO" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen=""></iframe></div>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="for-drawmasters-how-we-built-the-schedule-catalog">For Drawmasters: How We Built the Schedule Catalog<a href="https://curling.io/blog/optimizing-curling-draw-schedules#for-drawmasters-how-we-built-the-schedule-catalog" class="hash-link" aria-label="Direct link to For Drawmasters: How We Built the Schedule Catalog" title="Direct link to For Drawmasters: How We Built the Schedule Catalog">​</a></h2>
<p><em>The rest of this article gets technical. It covers how we scored schedules,
tested algorithms, used exact solvers, and distinguished best-found schedules
from proven optima. You've been warned!</em></p>
<p>The v3 scheduling system has two computational parts. An offline Rust
application searches for, validates, and in some cases proves optimal base
schedules. A JavaScript optimizer handles event-specific changes in the
browser, including locked games, ad hoc games, and competition stages sharing
the same resources.</p>
<p>The offline work produced a versioned catalog covering all 736 combinations
from 2 through 24 teams and 1 through 16 sheets, under both supported
draw-packing modes. Curling IO v3 loads this catalog directly when allocating
an event schedule.</p>
<p>The catalog currently contains 449 layouts. Of those, 419 are proven optimal
and 30 are the best result found so far. That includes 448 distinct
requirements in the practical catalog envelope and one historical outlier
retained for compatibility.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="scheduling-objective">Scheduling Objective<a href="https://curling.io/blog/optimizing-curling-draw-schedules#scheduling-objective" class="hash-link" aria-label="Direct link to Scheduling Objective" title="Direct link to Scheduling Objective">​</a></h3>
<p>We score sheet fairness as an ordered tuple:</p>
<ol>
<li><strong>Maximum repeat visits:</strong> the largest number of extra visits one team makes
to one sheet.</li>
<li><strong>Total repeat visits:</strong> every visit by every team after its first visit to a
sheet.</li>
<li><strong>Back-to-back repeat visits:</strong> cases where a team plays on the same sheet in
adjacent draws.</li>
</ol>
<p>Lower is better. We compare the first number first, then the second, then the
third. Runtime never compensates for a worse schedule.</p>
<p>An eight-team, four-sheet result of <code>(1, 24, 0)</code> means no team visits one sheet
more than twice, there are 24 repeat visits in total, and nobody returns to the
same sheet in consecutive draws.</p>
<p>Those 24 repeats are unavoidable. Eight teams make 56 team-sheet visits across
seven draws, but there are only 32 unique team-and-sheet combinations. Even a
perfectly balanced schedule has at least <code>56 - 32 = 24</code> repeat visits. Reaching
that lower bound with a maximum of one extra visit and no consecutive repeats
proves that there is nothing left to improve under our scoring rules.</p>
<p><img decoding="async" loading="lazy" alt="The fairness inspector showing where a team repeats on one sheet" src="https://curling.io/assets/images/repeat-highlights-31d5b6c8e316e492979c7d54f8b5f7cb.png" width="1280" height="720" class="img_ev3q"></p>
<p><em>The stats are inspectable in the schedule editor. Selecting one highlights the
games and teams that produced it.</em></p>
<p>We chose an ordered tuple instead of a weighted score because weights hide
tradeoffs. Is reducing one team's sixth visit to Sheet A worth creating three
new consecutive repeats elsewhere? A scalar score can answer that only after
someone invents a conversion rate. The tuple makes the priority explicit.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="how-good-is-ai-for-draw-schedule-generation">How Good Is AI for Draw Schedule Generation?<a href="https://curling.io/blog/optimizing-curling-draw-schedules#how-good-is-ai-for-draw-schedule-generation" class="hash-link" aria-label="Direct link to How Good Is AI for Draw Schedule Generation?" title="Direct link to How Good Is AI for Draw Schedule Generation?">​</a></h3>
<p>We tested whether current general-purpose AI models could produce these
schedules from a normal drawmaster request. This was a direct, one-shot model
test, not an agent with access to our validator or a repair loop.</p>
<p>The prompt took some work. Early versions that sounded completely natural
often returned missing or duplicate matchups, especially with an odd number of
teams. We refined it to approximate the clarifications a drawmaster might give
in a short conversation. The final version stated the expected number of
games, asked for one Markdown table, and asked the model to check its work. It
did not provide the matchup inventory, minimum draw count, fairness tuple,
cached schedule, circle method, or any other scheduling algorithm.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>Read the exact 8-team, 4-sheet prompt</summary><div><div class="collapsibleContent_i85q"><p>Create a curling draw schedule for a single round robin with 8 teams and 4
sheets. Label the teams 1 through 8 and the sheets A through D.</p><p>The schedule must contain exactly 28 games, one for each unique team pairing. A
team cannot play more than once in the same draw.</p><p>Try to spread each team's games across the sheets, especially avoiding the same
sheet in consecutive draws. Correct pairings matter more than perfect sheet
balance.</p><p>Before answering, count the games and check for missing or duplicate matchups.
Return one completed Markdown table, not a separate table for each draw. Use one
row per draw and columns named Draw, Sheet A through Sheet D. Write each game as
<code>1 vs 2</code>, leave unused sheet cells blank, and include a Bye column when needed.</p></div></div></details>
<p>Only the team count, sheet count, labels, and expected game count changed for
the other fixtures.</p>
<p>We sent that frozen prompt to OpenAI and Anthropic through their first-party
APIs. We added Gemini in a separate pass through OpenRouter, pinned to Google
AI Studio with provider fallback disabled. Each model received the same five
fixtures once: 8 teams on 4 sheets, 8 on 2, 9 on 4, 11 on 5, and 14 on 7.
Every request started with a fresh one-message context, used the provider's
default reasoning behaviour, and had a hard five-minute timeout. We kept every
response, including timeouts.</p>
<p>The familiar 8-team, 4-sheet layout has been published for years and may appear
in model training data. We kept it as a calibration case, not proof that a
model derived the answer. We also kept informal consumer-chat experiments out
of this table because those products may add hidden instructions, model
routing, and reasoning settings.</p>
<p>The results were parsed and checked independently. Harmless formatting could
be normalized, but the evaluator could not add or remove a game, change a
pairing, or improve a sheet assignment. A schedule had to contain every
matchup exactly once, keep each team to one game per draw, and pass the same
fairness calculation used by Drawmaster. Invalid schedules did not receive a
fairness score.</p>
<table><thead><tr><th>API and model</th><th style="text-align:right">Valid results</th><th style="text-align:right">Median response time</th><th style="text-align:right">Output tokens</th><th style="text-align:right">Calculated cost</th></tr></thead><tbody><tr><td>OpenAI <code>gpt-5.6-sol</code></td><td style="text-align:right">5/5</td><td style="text-align:right">50.7 seconds</td><td style="text-align:right">21,079</td><td style="text-align:right">$0.64</td></tr><tr><td>Anthropic <code>claude-opus-5</code></td><td style="text-align:right">5/5</td><td style="text-align:right">108.3 seconds</td><td style="text-align:right">59,070</td><td style="text-align:right">$1.48</td></tr><tr><td>Gemini <code>google/gemini-3-flash-preview</code></td><td style="text-align:right">2/5</td><td style="text-align:right">3.7 seconds</td><td style="text-align:right">3,338</td><td style="text-align:right">$0.01</td></tr></tbody></table>
<p>Reasoning tokens are included in the output totals, not added a second time.
One pass over five fixtures is useful baseline evidence, but it is not a
statistically strong ranking of the models.</p>
<p>OpenAI and Claude returned a valid, minimum-draw schedule for every fixture.
OpenAI matched or beat Claude on all five. Sheet fairness was a different
result:</p>
<p>Gemini finished every request in 5.1 seconds or less, but three of its five
schedules were invalid. The 8×4 and 14×7 answers repeated matchups despite
claiming in their own prose that every pairing had been verified. The 9×4
answer repeated two matchups and scheduled one team twice in its final draw.
Its two valid schedules used the minimum number of draws, but neither matched
the catalog's fairness score.</p>
<table><thead><tr><th>Fixture</th><th>Current catalog</th><th>Best AI result</th><th>Comparison</th></tr></thead><tbody><tr><td>8 teams, 4 sheets</td><td><code>(1, 24, 0)</code></td><td><code>(2, 24, 0)</code></td><td>Curling IO is better</td></tr><tr><td>8 teams, 2 sheets</td><td><code>(3, 40, 0)</code></td><td><code>(3, 40, 0)</code></td><td>Equal, proven optimal</td></tr><tr><td>9 teams, 4 sheets</td><td><code>(1, 36, 0)</code></td><td><code>(2, 36, 0)</code></td><td>Curling IO is better</td></tr><tr><td>11 teams, 5 sheets</td><td><code>(1, 55, 0)</code></td><td><code>(2, 55, 0)</code></td><td>Curling IO is better</td></tr><tr><td>14 teams, 7 sheets</td><td><code>(1, 84, 0)</code></td><td><code>(2, 84, 0)</code></td><td>Curling IO is better</td></tr></tbody></table>
<p>Our simple neutral-start Rust search matched OpenAI's best result on all five
fixtures after a few seconds of combined local work per fixture. The much
heavier offline process then produced the current catalog, which is better on
four of the five fixtures and equal on the fifth.</p>
<p>AI is still useful here as another offline candidate generator. It can find a
schedule our existing methods missed. We just treat its answer the same as any
other untrusted candidate: parse it, validate every matchup and conflict,
calculate the score ourselves, and prove optimality when we can. For custom
event schedules, the browser optimizer works around locks and extra games under
a 100 ms deadline instead of asking a general model to rebuild the schedule
from prose.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="search-envelope">Search Envelope<a href="https://curling.io/blog/optimizing-curling-draw-schedules#search-envelope" class="hash-link" aria-label="Direct link to Search Envelope" title="Direct link to Search Envelope">​</a></h3>
<p>Our practical envelope contains:</p>
<ul>
<li>2 through 24 teams</li>
<li>1 through 16 selected sheets</li>
<li>one complete round robin</li>
<li>one iteration</li>
<li>no locked, ad hoc, or bracket games</li>
<li>two behaviours when one logical round spans multiple draws: start each round
in a fresh draw, or fill blank sheets with games from the next round</li>
</ul>
<p>That is <code>23 × 16 × 2 = 736</code> lookup keys.</p>
<p>The two packing settings cannot always produce different layouts. If all games
in a round fit in one draw, for example, there are no blank sheets for the next
round to fill. Aliasing those equivalent cases reduces the envelope to 448
distinct layout requirements. The catalog stores 449 layouts because it also
retains one historically used shape outside the envelope, 26 teams on 7
sheets.</p>
<p>This boundary was not arbitrary. We analyzed Curling IO data. Among thousands of generated, single-iteration schedules where sheets were scarce, 98% had at most 20 teams and 8 sheets. The five most common shapes were 8×4, 12×6, 6×3, 10×5, and 9×4. Together they accounted for 63% of that cohort.</p>
<p>We extended sheet coverage to 16 because surplus-sheet layouts are cheap to
store and useful to support. We later expanded the team boundary to 24. The
42-team outliers in the historical data still did not justify expanding the
interactive target that far.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="research-harness">Research Harness<a href="https://curling.io/blog/optimizing-curling-draw-schedules#research-harness" class="hash-link" aria-label="Direct link to Research Harness" title="Direct link to Research Harness">​</a></h3>
<p>The original CurlingSchedules implementation already had a circular
round-robin generator, a greedy sheet allocator, local cleanup moves, and a
small exact-match cache. We had also done a lot of experimentation, including a
simulated annealing branch.</p>
<p>The first issue was measurement. Some historical runs used different weights,
some used an older definition of maximum repeats, and some tests did not assert
what their names claimed. Results using different objective functions could
not be compared directly.</p>
<p>We built an application in Rust as a research harness with:</p>
<ul>
<li>one reference schedule model and independent validator</li>
<li>deterministic fixtures and seeds</li>
<li>swappable search strategies</li>
<li>paired comparisons under equal budgets</li>
<li>text, JSON, and CSV reports</li>
<li>production-frequency weighting from Curling IO data</li>
<li>resumable, atomic checkpoints for long-running searches</li>
<li>a versioned catalog with the method, seed, effort, runtime, lower bound, and
proof status attached to every layout</li>
</ul>
<p><img decoding="async" loading="lazy" alt="The search and validation workflow used to add a schedule to the canonical catalog" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjAwIiBoZWlnaHQ9IjU2MCIgdmlld0JveD0iMCAwIDEyMDAgNTYwIiByb2xlPSJpbWciIGFyaWEtbGFiZWxsZWRieT0idGl0bGUgZGVzY3JpcHRpb24iPgogIDx0aXRsZSBpZD0idGl0bGUiPkhvdyBhIGN1cmxpbmcgc2NoZWR1bGUgZW50ZXJzIHRoZSBjYW5vbmljYWwgY2F0YWxvZzwvdGl0bGU+CiAgPGRlc2MgaWQ9ImRlc2NyaXB0aW9uIj5HZW5lcmF0ZWQgc2NoZWR1bGVzIHBhc3MgdGhyb3VnaCBoZXVyaXN0aWMgc2VhcmNoIGFuZCBleGFjdCBtZXRob2RzLCB0aGVuIGluZGVwZW5kZW50IHZhbGlkYXRpb24sIGJlZm9yZSB0aGV5IGVudGVyIHRoZSB2ZXJzaW9uZWQgY2F0YWxvZyB1c2VkIGJ5IEN1cmxpbmcgSU8uPC9kZXNjPgogIDxkZWZzPgogICAgPGZpbHRlciBpZD0ic2hhZG93IiB4PSItMjAlIiB5PSItMjAlIiB3aWR0aD0iMTQwJSIgaGVpZ2h0PSIxNDAlIj4KICAgICAgPGZlRHJvcFNoYWRvdyBkeD0iMCIgZHk9IjIiIHN0ZERldmlhdGlvbj0iMyIgZmxvb2QtY29sb3I9IiMwZjE3MmEiIGZsb29kLW9wYWNpdHk9Ii4xMiIvPgogICAgPC9maWx0ZXI+CiAgPC9kZWZzPgoKICA8cmVjdCB3aWR0aD0iMTIwMCIgaGVpZ2h0PSI1NjAiIHJ4PSIyMCIgZmlsbD0iI2Y4ZmFmYyIvPgogIDx0ZXh0IHg9IjUyIiB5PSI2MiIgZmlsbD0iIzBmMTcyYSIgZm9udC1mYW1pbHk9IkludGVyLCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMzAiIGZvbnQtd2VpZ2h0PSI3MDAiPkhvdyBhIHNjaGVkdWxlIGVudGVycyB0aGUgY2F0YWxvZzwvdGV4dD4KICA8dGV4dCB4PSI1MiIgeT0iOTQiIGZpbGw9IiM0NzU1NjkiIGZvbnQtZmFtaWx5PSJJbnRlciwgdWktc2Fucy1zZXJpZiwgc3lzdGVtLXVpLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjE3Ij5TZWFyY2ggYW5kIHByb29mIHVzZSB0aGUgc2FtZSBzY2hlZHVsZSBtb2RlbCwgc2NvcmluZyB0dXBsZSwgYW5kIHZhbGlkYXRvci48L3RleHQ+CgogIDxnIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzY0NzQ4YiIgc3Ryb2tlLXdpZHRoPSIzIj4KICAgIDxwYXRoIGQ9Ik0yNzQgMjM0aDI0Ii8+CiAgICA8cGF0aCBkPSJNNTQ2IDIzNGgyNCIvPgogICAgPHBhdGggZD0iTTgxOCAyMzRoMjQiLz4KICAgIDxwYXRoIGQ9Ik05NzQgMzE4djE4Ii8+CiAgPC9nPgogIDxnIGZpbGw9IiM2NDc0OGIiPgogICAgPHBhdGggZD0iTTI5OCAyMjhsMTAgNi0xMCA2eiIvPgogICAgPHBhdGggZD0iTTU3MCAyMjhsMTAgNi0xMCA2eiIvPgogICAgPHBhdGggZD0iTTg0MiAyMjhsMTAgNi0xMCA2eiIvPgogICAgPHBhdGggZD0iTTk2OCAzMzZoMTJsLTYgMTB6Ii8+CiAgPC9nPgoKICA8ZyBmaWx0ZXI9InVybCgjc2hhZG93KSIgZm9udC1mYW1pbHk9IkludGVyLCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIHNhbnMtc2VyaWYiPgogICAgPGc+CiAgICAgIDxyZWN0IHg9IjQyIiB5PSIxNTAiIHdpZHRoPSIyMzIiIGhlaWdodD0iMTY4IiByeD0iMTQiIGZpbGw9IiNmZmYiIHN0cm9rZT0iI2NiZDVlMSIgc3Ryb2tlLXdpZHRoPSIyIi8+CiAgICAgIDxjaXJjbGUgY3g9IjczIiBjeT0iMTgxIiByPSIxNCIgZmlsbD0iI2RiZWFmZSIvPgogICAgICA8dGV4dCB4PSI3MyIgeT0iMTg3IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjMWQ0ZWQ4IiBmb250LXNpemU9IjE2IiBmb250LXdlaWdodD0iNzAwIj4xPC90ZXh0PgogICAgICA8dGV4dCB4PSI5NiIgeT0iMTg4IiBmaWxsPSIjMGYxNzJhIiBmb250LXNpemU9IjIwIiBmb250LXdlaWdodD0iNzAwIj5Jbml0aWFsPC90ZXh0PgogICAgICA8dGV4dCB4PSI2OCIgeT0iMjI5IiBmaWxsPSIjNDc1NTY5IiBmb250LXNpemU9IjE2Ij5DaXJjbGUgcGFpcmluZ3M8L3RleHQ+CiAgICAgIDx0ZXh0IHg9IjY4IiB5PSIyNTMiIGZpbGw9IiM0NzU1NjkiIGZvbnQtc2l6ZT0iMTYiPkRldGVybWluaXN0aWMgc2VlZHM8L3RleHQ+CiAgICAgIDx0ZXh0IHg9IjY4IiB5PSIyNzciIGZpbGw9IiM0NzU1NjkiIGZvbnQtc2l6ZT0iMTYiPk5vIGNhY2hlZCBpbnB1dDwvdGV4dD4KICAgIDwvZz4KCiAgICA8Zz4KICAgICAgPHJlY3QgeD0iMzE0IiB5PSIxNTAiIHdpZHRoPSIyMzIiIGhlaWdodD0iMTY4IiByeD0iMTQiIGZpbGw9IiNmZmYiIHN0cm9rZT0iIzkzYzVmZCIgc3Ryb2tlLXdpZHRoPSIyIi8+CiAgICAgIDxjaXJjbGUgY3g9IjM0NSIgY3k9IjE4MSIgcj0iMTQiIGZpbGw9IiNkYmVhZmUiLz4KICAgICAgPHRleHQgeD0iMzQ1IiB5PSIxODciIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiMxZDRlZDgiIGZvbnQtc2l6ZT0iMTYiIGZvbnQtd2VpZ2h0PSI3MDAiPjI8L3RleHQ+CiAgICAgIDx0ZXh0IHg9IjM2OCIgeT0iMTg4IiBmaWxsPSIjMGYxNzJhIiBmb250LXNpemU9IjIwIiBmb250LXdlaWdodD0iNzAwIj5IZXVyaXN0aWM8L3RleHQ+CiAgICAgIDx0ZXh0IHg9IjM0MCIgeT0iMjI5IiBmaWxsPSIjNDc1NTY5IiBmb250LXNpemU9IjE2Ij5UYXJnZXRlZCBsb2NhbCBzZWFyY2g8L3RleHQ+CiAgICAgIDx0ZXh0IHg9IjM0MCIgeT0iMjUzIiBmaWxsPSIjNDc1NTY5IiBmb250LXNpemU9IjE2Ij5SZWNvbnN0cnVjdGlvbjwvdGV4dD4KICAgICAgPHRleHQgeD0iMzQwIiB5PSIyNzciIGZpbGw9IiM0NzU1NjkiIGZvbnQtc2l6ZT0iMTYiPkluZGVwZW5kZW50IHBvcHVsYXRpb25zPC90ZXh0PgogICAgPC9nPgoKICAgIDxnPgogICAgICA8cmVjdCB4PSI1ODYiIHk9IjE1MCIgd2lkdGg9IjIzMiIgaGVpZ2h0PSIxNjgiIHJ4PSIxNCIgZmlsbD0iI2ZmZiIgc3Ryb2tlPSIjZmJiZjI0IiBzdHJva2Utd2lkdGg9IjIiLz4KICAgICAgPGNpcmNsZSBjeD0iNjE3IiBjeT0iMTgxIiByPSIxNCIgZmlsbD0iI2ZlZjNjNyIvPgogICAgICA8dGV4dCB4PSI2MTciIHk9IjE4NyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZmlsbD0iI2I0NTMwOSIgZm9udC1zaXplPSIxNiIgZm9udC13ZWlnaHQ9IjcwMCI+MzwvdGV4dD4KICAgICAgPHRleHQgeD0iNjQwIiB5PSIxODgiIGZpbGw9IiMwZjE3MmEiIGZvbnQtc2l6ZT0iMjAiIGZvbnQtd2VpZ2h0PSI3MDAiPkV4YWN0PC90ZXh0PgogICAgICA8dGV4dCB4PSI2MTIiIHk9IjIyOSIgZmlsbD0iIzQ3NTU2OSIgZm9udC1zaXplPSIxNiI+Q29uc3RyYWludCBzb2x2aW5nPC90ZXh0PgogICAgICA8dGV4dCB4PSI2MTIiIHk9IjI1MyIgZmlsbD0iIzQ3NTU2OSIgZm9udC1zaXplPSIxNiI+QnJhbmNoLWFuZC1ib3VuZDwvdGV4dD4KICAgICAgPHRleHQgeD0iNjEyIiB5PSIyNzciIGZpbGw9IiM0NzU1NjkiIGZvbnQtc2l6ZT0iMTYiPkNvbWJpbmF0b3JpYWwgZGVzaWduczwvdGV4dD4KICAgIDwvZz4KCiAgICA8Zz4KICAgICAgPHJlY3QgeD0iODU4IiB5PSIxNTAiIHdpZHRoPSIyMzIiIGhlaWdodD0iMTY4IiByeD0iMTQiIGZpbGw9IiNmZmYiIHN0cm9rZT0iI2NiZDVlMSIgc3Ryb2tlLXdpZHRoPSIyIi8+CiAgICAgIDxjaXJjbGUgY3g9Ijg4OSIgY3k9IjE4MSIgcj0iMTQiIGZpbGw9IiNlMmU4ZjAiLz4KICAgICAgPHRleHQgeD0iODg5IiB5PSIxODciIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiMzMzQxNTUiIGZvbnQtc2l6ZT0iMTYiIGZvbnQtd2VpZ2h0PSI3MDAiPjQ8L3RleHQ+CiAgICAgIDx0ZXh0IHg9IjkxMiIgeT0iMTg4IiBmaWxsPSIjMGYxNzJhIiBmb250LXNpemU9IjIwIiBmb250LXdlaWdodD0iNzAwIj5WYWxpZGF0aW9uPC90ZXh0PgogICAgICA8dGV4dCB4PSI4ODQiIHk9IjIyOSIgZmlsbD0iIzQ3NTU2OSIgZm9udC1zaXplPSIxNSI+R2FtZXMsIGRyYXdzLCBhbmQgc2hlZXRzPC90ZXh0PgogICAgICA8dGV4dCB4PSI4ODQiIHk9IjI1MyIgZmlsbD0iIzQ3NTU2OSIgZm9udC1zaXplPSIxNSI+RmFpcm5lc3MgYW5kIHByb29mIHN0YXR1czwvdGV4dD4KICAgIDwvZz4KCiAgICA8Zz4KICAgICAgPHJlY3QgeD0iNzIwIiB5PSIzNTIiIHdpZHRoPSIzNzAiIGhlaWdodD0iMTM0IiByeD0iMTQiIGZpbGw9IiNlZmY2ZmYiIHN0cm9rZT0iIzI1NjNlYiIgc3Ryb2tlLXdpZHRoPSIyIi8+CiAgICAgIDxjaXJjbGUgY3g9Ijc1MSIgY3k9IjM4MyIgcj0iMTQiIGZpbGw9IiMyNTYzZWIiLz4KICAgICAgPHRleHQgeD0iNzUxIiB5PSIzODkiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiNmZmYiIGZvbnQtc2l6ZT0iMTYiIGZvbnQtd2VpZ2h0PSI3MDAiPjU8L3RleHQ+CiAgICAgIDx0ZXh0IHg9Ijc3NCIgeT0iMzkwIiBmaWxsPSIjMGYxNzJhIiBmb250LXNpemU9IjIwIiBmb250LXdlaWdodD0iNzAwIj5DYXRhbG9nPC90ZXh0PgogICAgICA8dGV4dCB4PSI3NDYiIHk9IjQyOSIgZmlsbD0iIzQ3NTU2OSIgZm9udC1zaXplPSIxNiI+VmFsaWRhdGVkIGxheW91dHM8L3RleHQ+CiAgICAgIDx0ZXh0IHg9Ijc0NiIgeT0iNDUzIiBmaWxsPSIjNDc1NTY5IiBmb250LXNpemU9IjE2Ij5CdW5kbGVkIGludG8gQ3VybGluZyBJTyB2MzwvdGV4dD4KICAgIDwvZz4KICA8L2c+Cgo8L3N2Zz4K" width="1200" height="560" class="img_ev3q"></p>
<p><em>Generated schedules pass through heuristic search, exact methods, and an
independent validator before entering the versioned catalog.</em></p>
<p>Cached layouts were comparison targets, never search inputs. Each algorithm had
to produce its result from the same generated starting conditions.</p>
<p>Rust was a good fit for this work for many of the same reasons we chose it for
the Curling IO v3 backend. Native execution let us evaluate large numbers of
candidates, and the type system made it difficult to mix an invalid partial
schedule into a supposedly valid result. The same harness could run bounded
comparisons or resume long exact searches. This was a small research project we
knew we would change constantly, but correctness still matters.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="evaluated-algorithms">Evaluated Algorithms<a href="https://curling.io/blog/optimizing-curling-draw-schedules#evaluated-algorithms" class="hash-link" aria-label="Direct link to Evaluated Algorithms" title="Direct link to Evaluated Algorithms">​</a></h3>
<p>No single algorithm won across every fixture. The final offline process uses
several methods with different costs and strengths.</p>
<table><thead><tr><th>Approach</th><th>What it was good at</th><th>What we learned</th></tr></thead><tbody><tr><td>Greedy generation</td><td>Producing a valid deterministic baseline quickly</td><td>Local sheet choices are myopic, and more random rebuilds do not guarantee a better basin</td></tr><tr><td>Single, double, triple, and quadruple swaps</td><td>Cheap local improvements</td><td>Two and three moves can escape some local optima, but deeper is not automatically better</td></tr><tr><td>Targeted moves</td><td>Spending work on teams and sheets blocking the next fairness reduction</td><td>Reusing scoring ledgers improved both quality and speed</td></tr><tr><td>Tabu and plateau walking</td><td>Moving through equal-scoring states</td><td>Strict improvement-only search gets stuck too early</td></tr><tr><td>Greedy reconstruction</td><td>Entering a different part of the search space</td><td>Independent restarts were more useful than repeatedly polishing one schedule</td></tr><tr><td>Elite pools and populations</td><td>Preserving several strong, different candidates</td><td>More diversity does not automatically become better schedules</td></tr><tr><td>Simulated annealing</td><td>Occasionally improving an unresolved fixture</td><td>It was not a competitive general strategy under our measured budgets</td></tr><tr><td>Ruin-and-recreate, crossover, rotations, and sheet-column moves</td><td>Adding specific neighbourhoods to a portfolio</td><td>Most were fixture-sensitive, and several were useful only as negative controls</td></tr><tr><td>Constraint solving and branch-and-bound</td><td>Proving a target reachable or impossible</td><td>Exact methods work best after the problem is decomposed correctly</td></tr><tr><td>Combinatorial constructions</td><td>Solving whole families directly</td><td>Sometimes the right answer is a design, not more search</td></tr></tbody></table>
<h4 class="anchor anchorWithStickyNavbar_LWe7" id="local-search">Local Search<a href="https://curling.io/blog/optimizing-curling-draw-schedules#local-search" class="hash-link" aria-label="Direct link to Local Search" title="Direct link to Local Search">​</a></h4>
<p>The simplest optimizer tries one legal game swap and keeps it only if the tuple
score improves. Our depth-two version tries a second swap even when the first
move is awful, then keeps both only if their combined result beats the untouched
schedule. Across 20 neutral eight-team starts, depth two beat single-swap search
16 times and reached the known optimum twice.</p>
<p>Depth three improved the odds again, but it did not dominate depth two. Across
1,000 paired starts it won 394, tied 310, and lost 296. Depth four then lost
more often than it won against depth three. Bigger neighbourhoods created new
paths, but they also spent more of a fixed budget wandering through unhelpful
ones.</p>
<p>Targeting helped more consistently. We aimed most source-game choices at the
team-sheet assignments blocking the next maximum-repeat reduction. Then we
added tabu memory so the search could walk across previously unseen
equal-fairness states instead of bouncing between the same arrangements.</p>
<p>Still, the classic 8×4 case exposed the limit of local moves. One exact check
proved that a particular matchup grouping could do no better than <code>(1,24,5)</code>.
The global optimum was <code>(1,24,0)</code>. No amount of sheet shuffling inside that
grouping could close the gap because the pairings had to be regrouped across
draws.</p>
<p>This is a limit of local search. Some improvements require changing which games
share a draw, not only changing their sheet assignments.</p>
<h4 class="anchor anchorWithStickyNavbar_LWe7" id="simulated-annealing">Simulated Annealing<a href="https://curling.io/blog/optimizing-curling-draw-schedules#simulated-annealing" class="hash-link" aria-label="Direct link to Simulated Annealing" title="Direct link to Simulated Annealing">​</a></h4>
<p>We tried simulated annealing twice, first in the old Gleam implementation and
again as a deliberately smaller Rust experiment. The larger historical version
had temperature schedules, reheating, restarts, adaptive move weights, sheet
cycles, draw rotations, targeted moves, and several phases. It retained the
best candidate, so returning a hot degraded state did not explain its
performance.</p>
<p>Considering the effort we put into understanding and implementing simulated
annealing, the results were disappointing. The smaller Rust version made the
comparison easier to trust. It remained competitive on 8×4 but lost across the
broader fixture suite. Annealing added some portfolio diversity on one shape,
but the evidence did not support using it as a general strategy.</p>
<p>We retained the negative result in the research record. Further temperature
tuning was lower priority than approaches that improved more fixtures under
the same evaluation budget.</p>
<h4 class="anchor anchorWithStickyNavbar_LWe7" id="reconstruction-and-portfolios">Reconstruction and Portfolios<a href="https://curling.io/blog/optimizing-curling-draw-schedules#reconstruction-and-portfolios" class="hash-link" aria-label="Direct link to Reconstruction and Portfolios" title="Direct link to Reconstruction and Portfolios">​</a></h4>
<p>Greedy reconstruction keeps the draws and competition rules but rebuilds sheet
assignments from a new seeded ordering. Five independent reconstructed starts
beat five ordinary starts under the same total evaluation budget. It did not
solve every fixture, but it entered basins that swap-only search never saw.</p>
<p>Our offline portfolio eventually kept independent reconstruction and population
workers, plus a separate lane that exploited the best validated candidate found
so far. We tried forcing every worker back to the global best, but that reduced
diversity and did not help. Sharing became useful only when it was additional
work rather than a replacement for each worker's private search.</p>
<p>This is why we added checkpoints to the offline runner for each worker's
private state as well as the global best. A long search can stop and resume
with both the global best and the independent state of each worker intact.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="exact-methods-and-constructions">Exact Methods and Constructions<a href="https://curling.io/blog/optimizing-curling-draw-schedules#exact-methods-and-constructions" class="hash-link" aria-label="Direct link to Exact Methods and Constructions" title="Direct link to Exact Methods and Constructions">​</a></h3>
<p>Heuristic search gave us strong candidates, but proving optimality required
exact methods.</p>
<p>We added exact methods in layers instead of handing the entire problem to one
solver:</p>
<ol>
<li><strong>Fixed-draw sheet assignment</strong> keeps matchups and draw membership fixed,
then solves only which sheet hosts each game.</li>
<li><strong>Logical-round models</strong> can repartition games across the draws belonging to
one round while preserving round boundaries.</li>
<li><strong>Packed factorization search</strong> chooses both the matchup grouping and sheet
assignment for smaller full-draw schedules.</li>
<li><strong>Bounded repair queries</strong> ask whether a known target can be reached within
a radius of a strong incumbent.</li>
<li><strong>Combinatorial constructions</strong> directly generate known design families such
as partitioned balanced tournament designs.</li>
</ol>
<p>The fixed-draw constraint solver brought several common layouts to their global
lower bounds. A later exact pass promoted 64 of 74 previously unresolved
high-sheet layouts to proven optimal, about 86%. Packed branch-and-bound also
proved the 10×5 lower bound from generated input without reading the cached
schedule, and handled small odd-team schedules using near-perfect matchings.</p>
<p>The exact results also identified limits in the search formulation. A 16×8
round robin is a partitioned balanced tournament design of side eight. A
published starter-adder construction produced the global <code>(1,112,0)</code> lower
bound directly. Local search had stalled because a strong heuristic schedule
needed at least 41 game-sheet placements changed under its existing draw
composition.</p>
<p>For 19 teams on 9 sheets, bounded repair could not reach <code>(1,171,17)</code> within 10
changed cells. We removed redundant variables and encoded the lower-bound
condition directly: every team uses every sheet exactly twice. Z3 found
<code>(1,171,0)</code> on the existing cyclic matchup structure. The solution differed
from the starting sheet labels in 151 of 171 game cells, about 88%. The local
repair radius had excluded the relevant solutions.</p>
<p>We then ran an exact round-order finishing pass. It treats each complete logical
round as one block and finds the best path through those blocks without changing
matchups, sheets, or the games grouped into each draw. That improved 28 catalog
entries and proved 23 of them optimal. The pass deliberately rejects packed
schedules whose draws cross round boundaries rather than guessing which games
belong together.</p>
<p>A short constraint sweep produced another 51 proofs. Forty-nine finished in
under one second on the development machine. The largest remaining
back-to-back gap was 17 teams on 8 sheets, which improved from <code>(1,136,16)</code> to
the global <code>(1,136,0)</code> lower bound after about 25 seconds. Both 13-team,
4-sheet packing modes remained unresolved after a one-minute attempt.</p>
<p>Finally, we generalized the full-round enumerator to allow surplus sheets. It
exhausted six small search spaces, proved all six optima, and improved the
6-team, 5-sheet catalog entry from <code>(1,3,0)</code> to <code>(1,2,0)</code>. These proofs matter
because the cheap generic lower bound is not attainable for every shape.</p>
<p>Seventeen layouts were still marked best-found at that point. Graph-degree
parity and a single-sheet transition bound proved 12 of them without changing
their schedules. Cyclic edge-difference constructions then closed the 9-team
and 13-team two-sheet layouts. Complete edge-colouring, draw-pairing, and draw
ordering searches closed the 6-team and 7-team two-sheet layouts.</p>
<p>The last case was 9 teams on 8 sheets. Searching complete schedules carried too
much symmetry, so we split it into smaller exact problems. A pseudo-Boolean
model assigned the 36 games to sheets first. An exact factorization then formed
nine legal draws, and a Hamiltonian path search ordered them without
back-to-back repeats. The result improved from <code>(1,5,0)</code> to the global lower
bound of <code>(1,4,0)</code>.</p>
<p>We later expanded the catalog through 24 teams. Published partitioned balanced
tournament designs gave exact 22-team, 11-sheet and 24-team, 12-sheet
schedules. Splitting the two balanced halves of those designs also proved nine
surplus-sheet layouts. Exact draw ordering and the existing odd-team split
construction proved another 14 layouts. The remaining new shapes still receive
validated cached schedules, but keep <strong>Optimize</strong> available while we work
through them offline.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="coverage-and-proof-status">Coverage and Proof Status<a href="https://curling.io/blog/optimizing-curling-draw-schedules#coverage-and-proof-status" class="hash-link" aria-label="Direct link to Coverage and Proof Status" title="Direct link to Coverage and Proof Status">​</a></h3>
<p>Catalog revision 23 resolves every team-and-sheet shape in the practical
2-through-24-team and 1-through-16-sheet envelope. Of the 449 stored layouts,
419 are proven optimal and 30 retain validated best-found results.</p>
<p>Of the 419 proven layouts, 392 reach the generic global lower bound. The other
27 need a tighter proof: 16 use a graph-degree parity bound, one uses a
single-sheet transition bound, and ten use exhaustive search. The odd
five-team, two-sheet layout is one example. Exact enumeration proved that four
back-to-back repeats cannot be removed. Small surplus-sheet layouts provide
others because games sharing a draw still need different sheets.</p>
<p>Every lookup in the defined envelope has a validated cached result. The 30
best-found layouts remain in the improvement queue. Proven layouts stay in the
regression suite, but receive no more offline search budget unless the catalog
envelope or the scoring rules change.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="offline-rust-and-browser-javascript">Offline Rust and Browser JavaScript<a href="https://curling.io/blog/optimizing-curling-draw-schedules#offline-rust-and-browser-javascript" class="hash-link" aria-label="Direct link to Offline Rust and Browser JavaScript" title="Direct link to Offline Rust and Browser JavaScript">​</a></h3>
<p>Precomputing ordinary layouts changes the role of the browser optimizer. It is
used for schedules that no longer match the canonical inputs.</p>
<p>A real event may have:</p>
<ul>
<li>multiple round-robin pools sharing sheets</li>
<li>multiple iterations, where combining canonical layouts creates new repeat
patterns</li>
<li>locked games that Allocate and Optimize must not move</li>
<li>ad hoc filler games for unbalanced round robins</li>
<li>bracket games that have to follow their predecessors</li>
</ul>
<p>Those combinations are too event-specific to cache globally. They still need
immediate and reversible optimization in the editor.</p>
<p>The in-browser optimizer runs in a cancellable Web Worker with a hard 100 ms
deadline today. It always retains the input and the best valid result found, so
running longer can fail to improve a schedule but cannot return a worse one.</p>
<p>Its current dispatcher uses three broad policies:</p>
<ul>
<li><strong>ordinary:</strong> an unlocked schedule that needs general local improvement</li>
<li><strong>constrained repair:</strong> locks or fixed placements make stability important</li>
<li><strong>bracket-safe:</strong> game ordering and fixed draw boundaries matter more than a
wider reconstruction</li>
</ul>
<p>It spends 70% of the deadline on depth-two search, then gives the remainder to
the selected repair lane. The bracket-safe path stays with depth two for the
whole deadline. Across 700 paired benchmark cases, this three-policy dispatcher
beat the older five-route selector 22 times, tied 666, and lost 12.</p>
<p>The paired result supported using the simpler dispatcher. It nearly always
returns the same or a better answer, runs off the main thread, and selects a
policy from visible schedule features.</p>
<p>The browser and Rust implementations share the schedule vocabulary, validator
expectations, fairness tuple, fixtures, and evidence. The offline Rust budget is
effectively unconstrained, so it can use populations, exact solvers, parallel
workers, and long-running checkpoints. The browser budget is tightly constrained
because longer optimization would noticeably affect the editor's responsiveness.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="catalog-distribution-and-runtime-selection">Catalog Distribution and Runtime Selection<a href="https://curling.io/blog/optimizing-curling-draw-schedules#catalog-distribution-and-runtime-selection" class="hash-link" aria-label="Direct link to Catalog Distribution and Runtime Selection" title="Direct link to Catalog Distribution and Runtime Selection">​</a></h3>
<p>Drawmaster writes a versioned JSON catalog. Each entry contains indexed teams,
draws, and sheets plus its fairness tuple, lower bound, producing method, proof
status, command, and independent validation evidence.</p>
<p>The catalog is owned by our shared Curling scheduling package and bundled into
the Curling IO v3 event schedule builder. The free
<a href="https://curlingschedules.com/" target="_blank" rel="noopener noreferrer">CurlingSchedules.com</a> editor also consumes the
same artifact, but its interface and local-storage persistence remain separate
from Curling IO. An 8×4 lookup resolves to the same canonical schedule in both
places.</p>
<p>The runtime order is:</p>
<ol>
<li>Look for an exact canonical layout.</li>
<li>Apply it immediately when it maps safely to the teams and selected sheets.</li>
<li>Repeat or compose catalog layouts for iterations and multiple pools.</li>
<li>Repair around locks, extra games, bracket games, and manual placements.</li>
<li>Generate directly only when no catalog starting point can be used.</li>
</ol>
<p>An exact, single-iteration layout marked proven optimal disables <strong>Optimize</strong>.
There is nothing useful for the button to do. The catalog format can still hold
a best-found layout if the envelope expands before its optimum is proved. In
that case, Optimize remains available. Repeated, composed, or repaired
schedules also remain optimizable because the original one-iteration proof no
longer covers the combined result.</p>
<p>Club draw schedule templates are separate. A drawmaster can deliberately apply
a recurring local arrangement instead of the global default, and v3 warns
before that template moves games already placed in the schedule. Templates
express a club's preference. The canonical catalog expresses the best general
result we can validate for a numerical shape.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="findings">Findings<a href="https://curling.io/blog/optimizing-curling-draw-schedules#findings" class="hash-link" aria-label="Direct link to Findings" title="Direct link to Findings">​</a></h3>
<p>The research produced the following working conclusions:</p>
<ul>
<li>Complete validated coverage took priority over spending more search time on
one already well-understood layout</li>
<li>No heuristic dominated every team-and-sheet shape. The amount of variance
between shapes was larger than we expected</li>
<li>More search depth did not guarantee better results under a fixed budget</li>
<li>Equal-score plateau movement mattered, but it could not repair a bad matchup
factorization</li>
<li>Reconstruction and independent portfolios found different basins more
reliably than one long local walk</li>
<li>Simulated annealing did not perform well enough to become a general strategy</li>
<li>Exact solvers became practical after we split the problem into the right
layers</li>
<li>Published combinatorial designs supplied direct solutions for applicable
schedule families</li>
<li>Proof status belongs beside every cached answer</li>
<li>The best production optimizer is often a cache lookup followed by a small,
domain-specific repair</li>
</ul>
<p>The current catalog covers the v3 target envelope, and every stored layout is
proven optimal. The remaining research is event-specific repair around locks,
ad hoc games, bracket games, and shared resources, plus any future expansion of
the catalog envelope.</p>
<p>Curling IO v3 maps each abstract catalog layout onto real event teams,
resources, draws, and games. From there, the drawmaster can apply club
templates, import assignments, add ad hoc games, and make manual changes while
preserving any placements they choose to lock.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="glossary">Glossary<a href="https://curling.io/blog/optimizing-curling-draw-schedules#glossary" class="hash-link" aria-label="Direct link to Glossary" title="Direct link to Glossary">​</a></h2>
<ul>
<li><strong>Fairness tuple:</strong> The ordered <code>(Max, Total, Back-to-back)</code> score used to
compare schedules. Lower is better, and the numbers are compared from left
to right.</li>
<li><strong>Heuristic:</strong> A method designed to find a strong schedule quickly without
proving that it is the best possible schedule.</li>
<li><strong>Local search:</strong> Improving a schedule by making nearby changes to its
current arrangement.</li>
<li><strong>Swap depth:</strong> The number of moves evaluated together before deciding
whether to keep them. A depth-two search can keep two moves whose combined
result improves the original schedule.</li>
<li><strong>Plateau:</strong> A group of different schedules with the same score.</li>
<li><strong>Plateau walking:</strong> Moving through equal-scoring schedules in search of a
position from which an improvement becomes possible.</li>
<li><strong>Tabu search:</strong> Keeping a short memory of recent moves or schedules so the
search does not repeatedly cycle through them.</li>
<li><strong>Simulated annealing:</strong> A search that sometimes accepts a worse move to
escape a local optimum, with that willingness usually decreasing over time.</li>
<li><strong>Basin:</strong> A region of the search space whose nearby moves tend to lead to
the same local optimum.</li>
<li><strong>Portfolio:</strong> Several search strategies or workers run independently, with
the best validated result retained.</li>
<li><strong>Exact method or solver:</strong> A method that can establish whether a target is
reachable or prove that no better valid schedule exists.</li>
<li><strong>Branch-and-bound:</strong> An exact method that stops exploring a branch when its
mathematical bound proves it cannot beat the best result already found.</li>
<li><strong>Lower bound:</strong> A score that no valid schedule can beat. Reaching an
applicable lower bound proves optimality.</li>
<li><strong>Proven optimal:</strong> A validated schedule for which no better fairness tuple
can exist.</li>
<li><strong>Best-found:</strong> The strongest validated schedule found so far, without proof
that a better one does not exist.</li>
<li><strong>Checkpoint:</strong> Saved search state that lets a long-running process resume
without starting over.</li>
</ul>]]></content>
        <author>
            <name>Dave Rapin</name>
        </author>
        <category label="v3" term="v3"/>
        <category label="competition-management" term="competition-management"/>
        <category label="architecture" term="architecture"/>
        <category label="rust" term="rust"/>
        <category label="javascript" term="javascript"/>
        <category label="sneak-peek" term="sneak-peek"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Renting Your Ice with Curling IO]]></title>
        <id>https://curling.io/blog/rentals-built-into-the-calendar</id>
        <link href="https://curling.io/blog/rentals-built-into-the-calendar"/>
        <updated>2026-07-29T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Curling IO v3 connects rental schedules, public availability, club website embeds, checkout, capacity, waitlists, and grouped resources in one calendar workflow.]]></summary>
        <content type="html"><![CDATA[<p><em>This post is part of our Curling IO v3
<a href="https://curling.io/blog/tags/sneak-peek">sneak peek series</a>, where we explore some of the new
features available in the upcoming version.</em></p>
<p>Curling clubs use rentals for practice ice, lounges, meeting rooms, and other
bookable resources they define. A rental needs a product and price, but it also
needs a schedule, one or more resources, public availability, and a booking
record after checkout.</p>
<p>In Curling IO v3, an administrator defines the rental schedule and resources
in the admin area. Available time slots appear on the public calendar, where
curlers can add one to the cart and complete checkout. This post explains the
rental options being added and how they fit into the calendar.</p>
<!-- -->
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-changes-from-v2">What Changes From v2<a href="https://curling.io/blog/rentals-built-into-the-calendar#what-changes-from-v2" class="hash-link" aria-label="Direct link to What Changes From v2" title="Direct link to What Changes From v2">​</a></h2>
<p>Curling IO v2 already lets clubs rent venues. Curling IO v3 vastly improves on
this workflow and changes how rentals are configured and managed:</p>
<ul>
<li><strong>Rental administration:</strong> Create and edit rentals from <strong>Products &gt;
Rentals</strong>, with the schedule, resources, pricing, and booking options
together in the admin area.</li>
<li><strong>Resource management:</strong> Resources replace what v2 calls venues. A resource
can be a sheet, lounge, meeting room, or anything else the organization
defines. A dedicated settings area controls the resources used by rental
schedules, reservations, closures, and the calendar.</li>
<li><strong>Capacity and waitlists:</strong> A rental can accept one exclusive booking or
several bookings for each time slot. Capacity and waitlists belong to the
exact resource, date, and time being booked.</li>
<li><strong>Grouped resources:</strong> Several resources can form one time slot. One booking
can reserve two sheets or the complete rink together.</li>
</ul>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="see-the-rental-workflow">See the Rental Workflow<a href="https://curling.io/blog/rentals-built-into-the-calendar#see-the-rental-workflow" class="hash-link" aria-label="Direct link to See the Rental Workflow" title="Direct link to See the Rental Workflow">​</a></h2>
<p>This tutorial creates and books an exclusive rental, configures a rental with
capacity, and finishes with a grouped rental that reserves two sheets together.</p>
<div class="text--center videoWrapper"><iframe width="100%" src="https://www.youtube.com/embed/IzBPT6nmIOo" title="Manage Rentals in Curling IO" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen=""></iframe></div>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="how-the-rental-schedule-works">How the Rental Schedule Works<a href="https://curling.io/blog/rentals-built-into-the-calendar#how-the-rental-schedule-works" class="hash-link" aria-label="Direct link to How the Rental Schedule Works" title="Direct link to How the Rental Schedule Works">​</a></h2>
<p>A rental starts with one or more resources, a date, at least one start time,
and a duration. It can happen once or repeat daily, weekly, monthly, or yearly.
An administrator can set an end date and exclude individual dates without
creating a separate product for every time slot.</p>
<p>For example, a club can offer two-hour practice ice on sheets A and B at 4:00
PM every day for one week. Each sheet and date becomes its own bookable time
slot, while the rental keeps one shared price and set of booking rules.</p>
<p><img decoding="async" loading="lazy" alt="Team Practice Ice configured as a daily rental on Sheet A and Sheet B." src="https://curling.io/assets/images/team-practice-availability-en-e001589791aa7c07d434e1e37d86ed5b.png" width="1520" height="1308" class="img_ev3q"></p>
<p>The rental defines the complete offer. A time slot is one resource, date, and
start time that a curler can book. If the sheets need different prices or
rules, the club creates separate rentals.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="how-rentals-appear-on-the-calendar">How Rentals Appear on the Calendar<a href="https://curling.io/blog/rentals-built-into-the-calendar#how-rentals-appear-on-the-calendar" class="hash-link" aria-label="Direct link to How Rentals Appear on the Calendar" title="Direct link to How Rentals Appear on the Calendar">​</a></h2>
<p>Available rentals appear alongside games and other activity on the public
calendar. Curlers can filter the calendar to rentals, and a club can link
directly to that filtered view when promoting practice ice or room bookings.</p>
<p>The desktop calendar provides day, week, month, and list views. On a phone it
becomes a simpler day list, with date controls and each event shown as a card.
When viewing the current date, the phone view leaves out events that have
already finished.</p>
<p>Selecting an available rental opens its date, time, duration, price, and
resource choice. When several equivalent sheets are offered separately, the
curler can choose an available sheet before adding the time slot to the cart.</p>
<p><img decoding="async" loading="lazy" alt="Team Practice Ice booking details with Sheet A selected." src="https://curling.io/assets/images/team-practice-booking-en-d3b27e29809b6f325aafa15aa1ecca1d.png" width="1104" height="628" class="img_ev3q"></p>
<p>Adding a time slot to the cart does not reserve it. Availability is checked
again at checkout, and the resource is allocated only when checkout completes.
Abandoned carts do not affect public availability.</p>
<p>After checkout, the person who made the booking sees <strong>Booked</strong>. Other curlers
see <strong>Reserved</strong>, without receiving the rental name or order details. Sheet B
can remain available at the same time when only sheet A was booked.</p>
<p><img decoding="async" loading="lazy" alt="A completed booking on Sheet A while the same rental remains available on Sheet B." src="https://curling.io/assets/images/team-practice-calendar-booked-en-473a0be0ee8b6f4d7efdfa4acb671b9b.png" width="2048" height="1536" class="img_ev3q"></p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="embed-rentals-on-your-club-website">Embed Rentals on Your Club Website<a href="https://curling.io/blog/rentals-built-into-the-calendar#embed-rentals-on-your-club-website" class="hash-link" aria-label="Direct link to Embed Rentals on Your Club Website" title="Direct link to Embed Rentals on Your Club Website">​</a></h2>
<p>A club does not have to send visitors to a separate calendar before they can
find available ice. The rental widget puts a focused seven-day list of
bookable times directly on the club's existing website. Visitors can compare
dates, times, resources, and prices without leaving the page.</p>
<p><img decoding="async" loading="lazy" alt="The Curling IO rental widget embedded on a curling club&amp;#39;s practice ice page." src="https://curling.io/assets/images/embedded-rental-widget-desktop-e7090e871e9128265cbbfece61316e9f.png" width="2880" height="2000" class="img_ev3q"></p>
<p>The widget can show every public rental or be limited to selected rental
products and resources. A club could use one page for practice ice, another
for lounge rentals, or show only the sheets relevant to a particular program.
The list stays connected to the same rental schedule and resource conflicts as
the full calendar, so the club does not maintain availability in two places.</p>
<p>Selecting <strong>Book</strong> takes the visitor into the club's normal Curling IO cart and
checkout. That is where Curling IO confirms availability again and handles
login, participants, questions, waivers, add-ons, discounts, payment, and the
final booking. The embedded list is for finding a time, not a stripped-down
checkout that skips the club's booking rules.</p>
<p>The layout adapts to the space available on the host page, including narrow
website columns and phones. It also carries the club's Curling IO colours and
protects its controls from conflicting website styles.</p>
<p><img decoding="async" loading="lazy" alt="The embedded rental widget adapted to a club website on a phone." src="https://curling.io/assets/images/embedded-rental-widget-mobile-c469f29c24e55195eb527e59c5f29ec4.png" width="860" height="1840" class="img_ev3q"></p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="exclusive-rentals-and-rentals-with-capacity">Exclusive Rentals and Rentals With Capacity<a href="https://curling.io/blog/rentals-built-into-the-calendar#exclusive-rentals-and-rentals-with-capacity" class="hash-link" aria-label="Direct link to Exclusive Rentals and Rentals With Capacity" title="Direct link to Exclusive Rentals and Rentals With Capacity">​</a></h2>
<p>Some rentals are exclusive. One booking takes the complete sheet or room for
that time. This is the natural setup for practice ice.</p>
<p>Other rentals can accept several bookings for the same resource and time. A
drop-in practice might use sheet C at 7:00 PM with capacity for four bookings.
After one checkout, the time slot remains available with capacity for three
more.</p>
<p><img decoding="async" loading="lazy" alt="Drop-in Practice showing capacity for four bookings." src="https://curling.io/assets/images/drop-in-capacity-en-ea92aa8043bb5c0781194ffd7f2fc335.png" width="1520" height="436" class="img_ev3q"></p>
<p>The club can also limit an account or participant to one purchase per time
slot. If the time slot sells out, an optional waitlist collects interest for
that exact resource, date, and time.</p>
<p>Approving a waitlist entry gives that account one private capacity exception.
This lets the approved account book without increasing the capacity available
to everyone else.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="group-several-sheets-into-one-rental">Group Several Sheets Into One Rental<a href="https://curling.io/blog/rentals-built-into-the-calendar#group-several-sheets-into-one-rental" class="hash-link" aria-label="Direct link to Group Several Sheets Into One Rental" title="Direct link to Group Several Sheets Into One Rental">​</a></h2>
<p>Sometimes the thing being rented is not one sheet. An instructor may want two
adjacent sheets, or a rental league may want the full rink for a block of time.</p>
<p>An administrator can group the selected resources so one booking reserves all
of them together. The day calendar shows adjacent grouped resources as one
block spanning their columns. Selecting the block shows the complete resource
set, and checkout creates one booking for the group.</p>
<p><img decoding="async" loading="lazy" alt="Full Rink Practice shown as one grouped rental across Sheet A and Sheet B." src="https://curling.io/assets/images/full-rink-calendar-grouped-en-efc44ea4d4e7e284ae96ed6457555042.png" width="2048" height="794" class="img_ev3q"></p>
<p>A conflict on any resource makes the complete grouped time slot unavailable.
If a scheduled game, closure, reservation, or existing booking uses sheet A,
the calendar does not offer a partial version of the rental on sheet B.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="calendar-actions-for-administrators">Calendar Actions for Administrators<a href="https://curling.io/blog/rentals-built-into-the-calendar#calendar-actions-for-administrators" class="hash-link" aria-label="Direct link to Calendar Actions for Administrators" title="Direct link to Calendar Actions for Administrators">​</a></h2>
<p>The calendar is not only a public rental list. Administrators can add manual
reservations and closures, edit a rental schedule, and reschedule an existing
booking from the event itself.</p>
<p>Those records have different jobs:</p>
<ul>
<li>A rental is a public product with a price, schedule, cart, order, and payment.</li>
<li>A reservation holds a resource manually.</li>
<li>A closure marks a resource as unavailable.</li>
<li>A booking is the purchased resource and time saved on an order.</li>
</ul>
<p>The calendar resolves those records together. Removing a closure or
reservation exposes the underlying rental time slot again without requiring
the administrator to recreate its schedule.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="other-product-features-available-to-rentals">Other Product Features Available to Rentals<a href="https://curling.io/blog/rentals-built-into-the-calendar#other-product-features-available-to-rentals" class="hash-link" aria-label="Direct link to Other Product Features Available to Rentals" title="Direct link to Other Product Features Available to Rentals">​</a></h2>
<p>Rentals use the same basic commercial tools as other Curling IO products. A
club can require a participant, add registration questions, offer add-ons,
apply discounts, assign taxes and accounting, and use the normal payment and
refund workflow.</p>
<p>For example, a room booking can ask for setup requirements, a practice booking
can require a participant, and a full-rink rental can include a related add-on.</p>
<p>Rentals will be a Premium feature in Curling IO v3. A rental schedule will be
the shared source used to calculate calendar availability, validate checkout,
and create the booking saved with the completed order.</p>]]></content>
        <author>
            <name>Dave Rapin</name>
        </author>
        <category label="v3" term="v3"/>
        <category label="rentals" term="rentals"/>
        <category label="calendar" term="calendar"/>
        <category label="club-management" term="club-management"/>
        <category label="sneak-peek" term="sneak-peek"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Managed Waiver Templates for Membership Associations]]></title>
        <id>https://curling.io/blog/v3-waiver-templates-for-membership-associations</id>
        <link href="https://curling.io/blog/v3-waiver-templates-for-membership-associations"/>
        <updated>2026-07-24T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Curling IO v3 will help membership associations roll out locked waiver documents, track versions, and report on which member clubs are collecting responses.]]></summary>
        <content type="html"><![CDATA[<p><em>This post is part of our Curling IO v3
<a href="https://curling.io/blog/tags/sneak-peek">sneak peek series</a>, where we explore some of the new
features available in the upcoming version.</em></p>
<p>Membership associations sometimes need more than a recommended waiver. A group
insurance policy may require every member club to present the same approved
language to its Participants.</p>
<p>Emailing a Word document to each club distributes the wording, but it does not
keep that wording under control. A club can edit its copy, miss the next update,
or keep collecting responses against an old version. Later, the association may
have no reliable way to tell which document a Participant actually saw.</p>
<p>Curling IO v3 will connect those pieces. A membership association can publish
one locked waiver, make it available to its member clubs, update it in
one place, and report on which clubs have Participants responding to it.</p>
<!-- -->
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="see-the-managed-waiver-workflow">See the Managed Waiver Workflow<a href="https://curling.io/blog/v3-waiver-templates-for-membership-associations#see-the-managed-waiver-workflow" class="hash-link" aria-label="Direct link to See the Managed Waiver Workflow" title="Direct link to See the Managed Waiver Workflow">​</a></h2>
<p>This tutorial shows the complete workflow: creating managed and customizable
waiver templates, publishing a new version, enabling association waivers at a
member club, and reviewing version and response records.</p>
<div class="text--center videoWrapper"><iframe width="100%" src="https://www.youtube.com/embed/kkbquPdg9oM" title="Managed Waiver Templates for Membership Associations in Curling IO v3" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen=""></iframe></div>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="from-group-insurance-policy-to-participant-response">From Group Insurance Policy to Participant Response<a href="https://curling.io/blog/v3-waiver-templates-for-membership-associations#from-group-insurance-policy-to-participant-response" class="hash-link" aria-label="Direct link to From Group Insurance Policy to Participant Response" title="Direct link to From Group Insurance Policy to Participant Response">​</a></h2>
<p>Suppose Demo Curling Association’s group insurance policy requires every adult
Participant to accept an approved General Liability Waiver. Here is how that
would work in Curling IO v3.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="1-the-association-publishes-a-managed-waiver-template">1. The association publishes a managed waiver template<a href="https://curling.io/blog/v3-waiver-templates-for-membership-associations#1-the-association-publishes-a-managed-waiver-template" class="hash-link" aria-label="Direct link to 1. The association publishes a managed waiver template" title="Direct link to 1. The association publishes a managed waiver template">​</a></h3>
<p>A managed waiver template is an association-controlled waiver that member clubs
can use but cannot edit. The association creates it once and controls the
waiver name, body, acceptance text, and inclusive age range. Setting a minimum
age of 18 means the waiver includes Participants who are 18 and older.</p>
<p>The waiver can contain variables for the member club’s name, province,
and membership association. Curling IO fills those values in for each club, so
the association does not need to maintain separate copies of otherwise
identical documents.</p>
<p><img decoding="async" loading="lazy" alt="Managed and customizable waiver templates created by a membership association in Curling IO v3." src="https://curling.io/assets/images/membership-association-templates-en-e285b7657b0feeb0d763008cad226225.png" width="1992" height="560" class="img_ev3q"></p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="2-member-clubs-enable-the-association-waiver">2. Member clubs enable the association waiver<a href="https://curling.io/blog/v3-waiver-templates-for-membership-associations#2-member-clubs-enable-the-association-waiver" class="hash-link" aria-label="Direct link to 2. Member clubs enable the association waiver" title="Direct link to 2. Member clubs enable the association waiver">​</a></h3>
<p>New member clubs begin with available managed waivers enabled. When an
association adds a managed waiver later, existing clubs see it in their
<strong>Association waivers</strong> list as disabled. They can review it and enable it when
they are ready to begin collecting responses.</p>
<p>Once enabled, the club cannot change the association-controlled document or age
range. It can disable the waiver, but it cannot quietly edit the wording while
continuing to present it as the association’s waiver.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="3-participants-respond-during-registration">3. Participants respond during registration<a href="https://curling.io/blog/v3-waiver-templates-for-membership-associations#3-participants-respond-during-registration" class="hash-link" aria-label="Direct link to 3. Participants respond during registration" title="Direct link to 3. Participants respond during registration">​</a></h3>
<p>When the waiver applies to a Participant, Curling IO presents the current
association-controlled document during registration. The account holder
accepts it for themselves or, when required, responds as the Participant’s
legal guardian.</p>
<p>The saved response records the managed template, its version, and the exact
resolved document shown at the time. It also records the Participant’s age,
who responded, and the guardian relationship when one applies.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="4-the-association-can-see-which-clubs-are-using-it">4. The association can see which clubs are using it<a href="https://curling.io/blog/v3-waiver-templates-for-membership-associations#4-the-association-can-see-which-clubs-are-using-it" class="hash-link" aria-label="Direct link to 4. The association can see which clubs are using it" title="Direct link to 4. The association can see which clubs are using it">​</a></h3>
<p>Membership association reporting will show which member clubs have Participant
responses tied to each managed waiver. This is more useful than checking
whether a waiver is enabled. A club can have a waiver enabled without
registering anyone, while a saved response proves that the managed document was
presented and answered.</p>
<p>The report provides evidence that clubs are collecting responses against the
association-managed waiver. It does not make an automatic legal ruling that a
club has satisfied every condition of an insurance policy. Those policies may
include requirements Curling IO cannot infer.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="update-every-club-without-replacing-history">Update Every Club Without Replacing History<a href="https://curling.io/blog/v3-waiver-templates-for-membership-associations#update-every-club-without-replacing-history" class="hash-link" aria-label="Direct link to Update Every Club Without Replacing History" title="Direct link to Update Every Club Without Replacing History">​</a></h2>
<p>When approved wording or age requirements change, the association publishes a
new managed template version. The new version immediately becomes the document
shown by every member club using that waiver.</p>
<p>People who have not responded will see the latest version. Participants who
already responded during the season will not be asked again just because the
association published an update.</p>
<p>The earlier version and its responses are not overwritten. Member clubs can
review the version history and compare what changed, but they cannot roll a
managed waiver back or apply their own version. Each historical response still
shows the exact waiver document presented to that Participant.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="use-a-customizable-template-when-locking-is-not-required">Use a Customizable Template When Locking Is Not Required<a href="https://curling.io/blog/v3-waiver-templates-for-membership-associations#use-a-customizable-template-when-locking-is-not-required" class="hash-link" aria-label="Direct link to Use a Customizable Template When Locking Is Not Required" title="Direct link to Use a Customizable Template When Locking Is Not Required">​</a></h2>
<p>Central control is not always necessary. An association may have useful base
language that clubs can adapt to local programs, facilities, or legal advice.
In that case, it can publish a customizable waiver template instead. The
practical differences are:</p>
<table><thead><tr><th></th><th>Managed template</th><th>Customizable template</th></tr></thead><tbody><tr><td>Wording controlled by</td><td>Membership association</td><td>Member club</td></tr><tr><td>Later association updates</td><td>Roll out to every club using it</td><td>Do not change saved club waivers</td></tr><tr><td>Responses tied to the association template</td><td>Yes</td><td>No</td></tr><tr><td>Best fit</td><td>Group insurance or association policy requiring consistent wording</td><td>Optional base language clubs may adapt</td></tr></tbody></table>
<p>Selecting a customizable template prefills a new waiver form. The club can
change the content before saving, and the resulting waiver belongs to the club.
This is convenient, but convenience and compliance are not the same thing. If
an insurer requires every club to use unchanged wording, the managed template
is the option designed to preserve that control.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="more-than-a-waiver-upload-and-checkbox">More Than a Waiver Upload and Checkbox<a href="https://curling.io/blog/v3-waiver-templates-for-membership-associations#more-than-a-waiver-upload-and-checkbox" class="hash-link" aria-label="Direct link to More Than a Waiver Upload and Checkbox" title="Direct link to More Than a Waiver Upload and Checkbox">​</a></h2>
<p>A basic waiver field can display text and collect an agreement. It does not
give a membership association control over that document across independent
clubs, preserve every version, or connect each response back to its managed
source.</p>
<p>That connection is the important part of the v3 design. The association can
publish the requirement. Clubs can put it into use without copying it.
Participants respond to the current document. The resulting records show which
version was presented and where it was used.</p>
<p>This is the latest post in our series about product features coming to Curling
IO v3. We’ll keep showing the real workflows as they take shape, including what
changes for membership associations, member clubs, and the people
registering.</p>]]></content>
        <author>
            <name>Dave Rapin</name>
        </author>
        <category label="v3" term="v3"/>
        <category label="membership-associations" term="membership-associations"/>
        <category label="waivers" term="waivers"/>
        <category label="group-insurance" term="group-insurance"/>
        <category label="sneak-peek" term="sneak-peek"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[What We Found Useful About NixOS]]></title>
        <id>https://curling.io/blog/improving-recovery-with-nixos</id>
        <link href="https://curling.io/blog/improving-recovery-with-nixos"/>
        <updated>2026-07-19T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[This is a technical implementation note about the infrastructure and recovery]]></summary>
        <content type="html"><![CDATA[<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>About this post</div><div class="admonitionContent_BuS1"><p>This is a technical implementation note about the infrastructure and recovery
architecture for Curling IO v3. It is written for software engineers and
operators, and goes deeper into NixOS, bare-metal provisioning, secrets,
deployment, backups, and recovery validation than our usual product posts.</p></div></div>
<p>Curling IO used to run on Debian configured with Ansible. That setup worked. It
installed packages, configured Caddy and the firewall, created systemd
services, and prepared our blue-green deployment slots.</p>
<p>The problem was not that Ansible couldn't describe the server. The problem was
that a working, mutable server let us get away with an incomplete description.
Old files remained under <code>/etc</code>. Build tools had been installed through a
different path. Provider choices about disks and RAID lived outside the
playbook. A provisioning run could succeed because an earlier run had already
left the right thing behind.</p>
<p>NixOS is much less tolerant of these gaps. This strictness is occasionally
annoying, but it is also the main benefit. It forces us to decide who owns a
file, a service, a secret, a package, or a recovery input.</p>
<p>Moving from Ansible to NixOS taught us how much validation and cleanup the old
approach had been missing. It exposed the difference between a server that
continues to work and one we can recreate from declared inputs.</p>
<p>This is what we found useful while rebuilding an OVH bare-metal server from
empty disks, restoring its state, and making the process safe enough to repeat.</p>
<!-- -->
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-boundary-we-wanted">The Boundary We Wanted<a href="https://curling.io/blog/improving-recovery-with-nixos#the-boundary-we-wanted" class="hash-link" aria-label="Direct link to The Boundary We Wanted" title="Direct link to The Boundary We Wanted">​</a></h2>
<p>We wanted to answer a simple question: if the server disappeared, could we
rebuild Curling IO in a timely manner, without depending on undocumented state
from the old host, and with basically no customer data loss?</p>
<p>A successful recovery had to:</p>
<ul>
<li>Recreate the mirrored disk and EFI boot layout.</li>
<li>Activate the exact host configuration.</li>
<li>Restore the host's ability to decrypt its secrets.</li>
<li>Restore valid Caddy certificate state.</li>
<li>Build and deploy the platform, public site, and Operations application from
one frozen Git revision.</li>
<li>Restore and validate SQLite from Litestream.</li>
<li>Verify public and private upload storage.</li>
<li>Create and verify an encrypted database snapshot in a second region.</li>
<li>Pass local, public, storage, monitoring, and provider checks.</li>
</ul>
<p>This is broader than operating-system provisioning. NixOS owns the host, but
application releases and customer state have their own workflows.</p>
<table><thead><tr><th>Concern</th><th>Owner</th></tr></thead><tbody><tr><td>Disk layout and initial installation</td><td>OVH BYOLinux and our recovery-image boot hook</td></tr><tr><td>Operating-system packages and services</td><td>NixOS configuration</td></tr><tr><td>Generated host configuration</td><td>Versioned Nix store paths</td></tr><tr><td>Host secret activation</td><td>SOPS recovery workflow</td></tr><tr><td>Platform, public-site, and Operations builds</td><td>Independent deployment workflows</td></tr><tr><td>Release installation and activation</td><td>Independent server-side deployment controls</td></tr><tr><td>Database migrations</td><td>Platform deployment workflow</td></tr><tr><td>Host and application monitoring</td><td>Independently deployed Operations application</td></tr><tr><td>Customer database and uploads</td><td>Litestream and Object Storage workflows</td></tr></tbody></table>
<p>That separation has held up well. We can change the host without deploying an
application. We can deploy the platform without deploying the public site. We
can restore a database without reinstalling the server.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="application-releases-stay-separate">Application Releases Stay Separate<a href="https://curling.io/blog/improving-recovery-with-nixos#application-releases-stay-separate" class="hash-link" aria-label="Direct link to Application Releases Stay Separate" title="Direct link to Application Releases Stay Separate">​</a></h2>
<p>NixOS owns the host, not the application build. We build the platform, public
site, and Operations application before deployment, package them as verified
releases, and upload them over SSH. Production does not need source code,
repository credentials, compiler toolchains, or build caches.</p>
<p>That leaves a smaller production software footprint: fewer packages and
executable tools, no source checkout or build credentials, and less mutable
build state. It reduces the attack surface and patching burden, though it does
not replace service sandboxing or timely updates.</p>
<p>This keeps host changes and application releases independent. It also gives
recovery a simple boundary: build from one frozen Git revision, verify each
package before and after upload, then activate it using the same deployment
workflow we use during normal operation.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="nix-has-two-different-host-deployment-paths">Nix Has Two Different Host Deployment Paths<a href="https://curling.io/blog/improving-recovery-with-nixos#nix-has-two-different-host-deployment-paths" class="hash-link" aria-label="Direct link to Nix Has Two Different Host Deployment Paths" title="Direct link to Nix Has Two Different Host Deployment Paths">​</a></h2>
<p>Routine provisioning and replacement-host recovery deliberately work
differently.</p>
<p>For a normal configuration change, our <code>bin/provision production</code> script sends
the exact committed infrastructure source to the running server. The server
uses Nix to build a new immutable system generation, validates it, switches to
it, and keeps the previous generation available for rollback.</p>
<p>Clean-host recovery cannot rely on that path. A supposedly complete recovery
image should not boot and then download or build most of the real system. Our
BYOLinux image therefore contains:</p>
<ul>
<li>A minimal, secret-free bootstrap system that can boot and accept SSH.</li>
<li>The complete production NixOS closure as inactive store content.</li>
</ul>
<p>After first boot, the recovery command activates the already embedded
production system and its required secrets. If the image does not contain the
required closure, recovery stops. It does not hide the incomplete image by
falling back to a remote build.</p>
<p>This makes the recovery image a real artifact rather than a thin installer.
Application-only commits can still reuse it because the image derivation
depends only on infrastructure source. Application code, documentation, and
task files do not force another multi-gigabyte image build.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="bare-metal-keeps-the-provider-in-the-design">Bare Metal Keeps the Provider in the Design<a href="https://curling.io/blog/improving-recovery-with-nixos#bare-metal-keeps-the-provider-in-the-design" class="hash-link" aria-label="Direct link to Bare Metal Keeps the Provider in the Design" title="Direct link to Bare Metal Keeps the Provider in the Design">​</a></h2>
<p>NixOS does not remove OVH from the recovery path.</p>
<p>Our first installer created valid software RAID metadata with a newer Linux
kernel. The installed NixOS kernel and OVH's rescue kernel both refused to
assemble it. Upgrading only NixOS would have made the installed system work,
but it would still have left the provider's rescue environment unable to read
the disks.</p>
<p>We changed the ownership boundary. OVH BYOLinux now creates the mirrored ext4
filesystems using the provider's supported layout. Our image supplies the
NixOS filesystem and a boot hook that labels the filesystems and installs
removable-path EFI GRUB on both drives.</p>
<p>The provider's BYOLinux hook runs inside a restricted chroot. During our first
attempts, the chroot did not have the shell or <code>PATH</code> we expected. A working
image build told us nothing about whether OVH could execute the hook. We had to:</p>
<ul>
<li>Use an absolute Nix store path for Bash.</li>
<li>Inline the hook instead of calling a second store path that was absent from
the image.</li>
<li>Use shell builtins until the embedded system <code>PATH</code> was available.</li>
<li>Test every referenced store path against the image itself.</li>
<li>Replay the hook inside a provider-like chroot.</li>
</ul>
<p>This was disappointing. QCOW2 gives us a standard image format, but not a
standard contract for turning that image into a bootable bare-metal
installation. We expected the provider boundary to be more predictable.
Instead, OVH ran a provider-specific hook inside a sparse chroot whose shell,
<code>PATH</code>, and available store paths had to be discovered through testing. The
image format was portable; the installation contract was not.</p>
<p>Provider monitoring also mattered. During an early rehearsal, OVH detected the
planned outage and opened a technician intervention. That intervention blocked
an API request to change the server's boot mode. Recovery preflight now
disables and verifies automatic intervention before allowing a reinstall, then
restores the selected policy after the recovered host passes its checks.</p>
<p>Getting this provider boundary right consumed the lion's share of the
engineering time we spent on recovery provisioning. The successful reinstall
itself took under eight minutes, but reaching that point required repeated image
builds, failed hooks, rescue boots, and destructive rehearsals.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="generated-configuration-needs-one-owner">Generated Configuration Needs One Owner<a href="https://curling.io/blog/improving-recovery-with-nixos#generated-configuration-needs-one-owner" class="hash-link" aria-label="Direct link to Generated Configuration Needs One Owner" title="Direct link to Generated Configuration Needs One Owner">​</a></h2>
<p>Caddy exposed the problem with mutable configuration quickly.</p>
<p>The old server had generated fragments copied into mutable directories. When
we changed the layout, stale files remained and were still loaded. The new
configuration was correct in the repository but wrong on the server because
the server contained both old and new interpretations.</p>
<p>The NixOS configuration now owns generated Caddy files as links to exact Nix
store paths. Obsolete paths are removed explicitly, and the assembled
configuration is validated before activation.</p>
<p>This lesson is not specific to NixOS. An Ansible deployment can enforce the
same rule, but it has to do so deliberately. Copying the latest desired file is
not enough when an old file can still affect runtime behaviour.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="recovery-state-must-exist-before-the-wipe">Recovery State Must Exist Before the Wipe<a href="https://curling.io/blog/improving-recovery-with-nixos#recovery-state-must-exist-before-the-wipe" class="hash-link" aria-label="Direct link to Recovery State Must Exist Before the Wipe" title="Direct link to Recovery State Must Exist Before the Wipe">​</a></h2>
<p>The failed server is not a recovery source.</p>
<p>Before erasing it, preflight verifies:</p>
<ul>
<li>The selected infrastructure revision and recovery image.</li>
<li>The required secret-recovery inputs.</li>
<li>Current RAID health.</li>
<li>A decryptable Caddy-state backup with valid certificates.</li>
<li>The offsite Litestream replica and encrypted snapshot in a second offsite
location.</li>
<li>Public and private upload storage boundaries.</li>
<li>Public endpoints and OVH state.</li>
</ul>
<p>Caddy state became an explicit input after we realized that certificate
issuance should not sit on the critical recovery path. NixOS checks for changed
Caddy state every five minutes and stores encrypted copies offsite. Weekly
retention slots provide a backstop.</p>
<p>During recovery, Caddy stays stopped while the platform, site, database,
uploads, and encrypted snapshot are prepared. It starts only when the server
can present a complete system using the restored certificate state. Normal
background renewal resumes afterward.</p>
<p>SQLite follows a separate path. Litestream continuously replicates offsite. A
daily job restores and validates that replica before creating a client-side
encrypted full snapshot in a second offsite location. The restore process
prevents an older disaster snapshot from replicating back to the primary
replica while it is being tested.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="recovery-runs-from-a-frozen-revision">Recovery Runs from a Frozen Revision<a href="https://curling.io/blog/improving-recovery-with-nixos#recovery-runs-from-a-frozen-revision" class="hash-link" aria-label="Direct link to Recovery Runs from a Frozen Revision" title="Direct link to Recovery Runs from a Frozen Revision">​</a></h2>
<p>A long recovery cannot safely follow a moving branch.</p>
<p>The complete recovery command creates a temporary checkout at the selected
commit. Image publication, provider installation, host activation, all three
application builds, state restoration, and final acceptance run from that
frozen tree. New commits in the normal checkout cannot alter a recovery already
in progress.</p>
<p>The workflow also has durable resume points:</p>
<ol>
<li>Verify the image and recovery inputs.</li>
<li>Ask OVH to install the image.</li>
<li>Activate the embedded NixOS closure.</li>
<li>Build verified application packages locally.</li>
<li>Deploy the platform and public site.</li>
<li>Restore and validate SQLite, then verify upload storage.</li>
<li>Start Caddy.</li>
<li>Deploy Operations.</li>
<li>Run final acceptance checks.</li>
</ol>
<p>If application deployment fails after the host boots, we resume at application
deployment. We do not erase the disks again. Public DNS cutover is another
separate, confirmed operation after direct checks against the replacement host.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="updates-use-the-same-health-check">Updates Use the Same Health Check<a href="https://curling.io/blog/improving-recovery-with-nixos#updates-use-the-same-health-check" class="hash-link" aria-label="Direct link to Updates Use the Same Health Check" title="Direct link to Updates Use the Same Health Check">​</a></h2>
<p>NixOS checks for stable updates each night and keeps the previous generation
available for rollback. A new generation is accepted only after it passes the
same read-only health checks used by Operations monitoring.</p>
<p>Provisioning, automatic updates, recovery, and monitoring therefore share one
definition of a healthy server. If those checks fail, the host returns to the
previous generation.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="lessons-beyond-nixos">Lessons Beyond NixOS<a href="https://curling.io/blog/improving-recovery-with-nixos#lessons-beyond-nixos" class="hash-link" aria-label="Direct link to Lessons Beyond NixOS" title="Direct link to Lessons Beyond NixOS">​</a></h2>
<p>You can get pretty far by applying the same strictness to Debian and Ansible:</p>
<ul>
<li>provision from an exact revision;</li>
<li>define explicit ownership and cleanup;</li>
<li>pin the tools used during recovery;</li>
<li>guard destructive commands;</li>
<li>test the complete replacement-host workflow automatically.</li>
</ul>
<p>None of this requires NixOS. The difference is that Ansible lets you skip these
steps and keep going. NixOS makes disagreements with the declared system much
harder to ignore. And we like having these technical guardrails at Curling IO.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="decision">Decision<a href="https://curling.io/blog/improving-recovery-with-nixos#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision">​</a></h2>
<p>We are keeping NixOS.</p>
<p>It does not build our applications, restore customer data, eliminate the
provider, or replace deployment scripts. It gives those systems a host with a
clearer definition and a safer activation boundary.</p>
<p>The most valuable part is not reproducibility as an abstract property. It is
the pressure to make hidden assumptions explicit:</p>
<ul>
<li>Which system owns this file?</li>
<li>Which key can recover this secret?</li>
<li>Which artifact identifies this host?</li>
<li>What survives loss of the server?</li>
<li>What proves a new generation is healthy?</li>
<li>Where can recovery resume after a failure?</li>
</ul>
<p>These questions have improved the whole deployment system, including the parts
that do not use Nix.</p>
<hr>
<p><em>This is Part 9 of the Curling IO Foundation series. Previous: <a href="https://curling.io/blog/live-admin-without-javascript">A Live Admin Panel Without Writing JavaScript</a>.</em></p>]]></content>
        <author>
            <name>Dave Rapin</name>
        </author>
        <category label="foundation" term="foundation"/>
        <category label="nixos" term="nixos"/>
        <category label="deployment" term="deployment"/>
        <category label="operations" term="operations"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Your Club, Your Product Types]]></title>
        <id>https://curling.io/blog/your-club-your-product-types</id>
        <link href="https://curling.io/blog/your-club-your-product-types"/>
        <updated>2026-07-14T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Curling IO v3 lets organizations create product types that match how they run camps, clinics, rentals, fundraising, and more.]]></summary>
        <content type="html"><![CDATA[<p><em>This post is part of our Curling IO v3
<a href="https://curling.io/blog/tags/sneak-peek">sneak peek series</a>, where we explore some of the new
features available in the upcoming version.</em></p>
<p>Curling clubs sell much more than leagues and bonspiels. They run clinics, rent ice, assign lockers, host junior camps, sell banquet tables, and collect donations.</p>
<p>Today, each of those offerings has to fit one of Curling IO's predefined types. That works, but sometimes only because a club picks the closest available bucket. One active junior summer camp is stored as a Product, with its August dates written into the name. A full-sheet ice rental is stored as a Program because it needs a date, capacity, and a waitlist. A donation is stored as a Fee.</p>
<p>In Curling IO v3, the organization decides which product types it needs. A club can create Camps, Clinics, Ice Rentals, Locker Rentals, Fundraising, or any other category that fits its operation, then choose what each type can do.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="start-with-what-your-club-calls-it">Start with what your club calls it<a href="https://curling.io/blog/your-club-your-product-types#start-with-what-your-club-calls-it" class="hash-link" aria-label="Direct link to Start with what your club calls it" title="Direct link to Start with what your club calls it">​</a></h2>
<p>Suppose the club tells us:</p>
<blockquote>
<p>We run learn-to-curl clinics and junior camps. We also rent practice ice, sell lockers, and hold a fundraising dinner. Those shouldn't all be mixed into Programs or Products.</p>
</blockquote>
<p>In v3, they don't have to be. The club can create a product type for each part of the operation, put them in the order staff expects, and turn off types it doesn't use.</p>
<p><img decoding="async" loading="lazy" alt="Product type settings in Curling IO v3" src="https://curling.io/assets/images/product-types-0d7123539c738a0f2dfaa2f2935a0fe2.png" width="2266" height="1586" class="img_ev3q"></p>
<p>New organizations still get familiar starting types such as Leagues, Bonspiels, Programs, Products, and Fees. They aren't a permanent menu chosen by Curling IO. An administrator can rename them, reorder them, disable them, or create another type.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="these-categories-already-exist-in-the-real-world">These categories already exist in the real world<a href="https://curling.io/blog/your-club-your-product-types#these-categories-already-exist-in-the-real-world" class="hash-link" aria-label="Direct link to These categories already exist in the real world" title="Direct link to These categories already exist in the real world">​</a></h2>
<p>We looked at active offerings in Curling IO rather than inventing examples for this post. Here are a few of the things clubs and associations are selling now:</p>
<table><thead><tr><th>Current offering</th><th>Where it fits in v2</th><th>A natural v3 product type</th></tr></thead><tbody><tr><td>Junior summer camp in August</td><td>Product, with the dates in its name</td><td>Camps</td></tr><tr><td>Two-hour full-sheet ice rental</td><td>Program, with four available spots and a waitlist</td><td>Ice Rentals</td></tr><tr><td>Evening tune-up clinic</td><td>Program, with a date, 20 spots, and a waitlist</td><td>Clinics</td></tr><tr><td>Half-size locker for the season</td><td>Product assigned to a participant</td><td>Locker Rentals</td></tr><tr><td>Table for eight at a fundraising dinner</td><td>Product</td><td>Fundraising</td></tr><tr><td>Additional $100 donation</td><td>Fee</td><td>Donations</td></tr></tbody></table>
<p>The current choices aren't mistakes. They show the limitation of a fixed set of types. Clubs are already using Curling IO for this work, but the product structure doesn't always speak their language.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="a-type-controls-more-than-its-label">A type controls more than its label<a href="https://curling.io/blog/your-club-your-product-types#a-type-controls-more-than-its-label" class="hash-link" aria-label="Direct link to A type controls more than its label" title="Direct link to A type controls more than its label">​</a></h2>
<p>A product type decides which tools are available for products of that type.</p>
<p>If a club creates a Clinics type, it might enable purchasing, participant registration, dates, capacity, waitlists, and product managers. Each clinic can then have a price, registration form, opening and closing dates, participant limit, and assigned staff.</p>
<p>An Ice Rentals type might need purchasing, dates, and inventory, but no participant registration. A Donations type might need purchasing and accounting without dates, teams, or a waitlist.</p>
<p>The result also carries through the rest of Curling IO. Enabled product types appear in the Products section in the order the organization chooses. Each type can have its own bilingual name and summary, accounting code, and public listing address.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>What can a product type do?</summary><div><div class="collapsibleContent_i85q"><p>Curling IO v3 currently provides six capabilities:</p><ul>
<li><strong>Purchasable:</strong> pricing, taxes, discounts, add-ons, late fees, and accounting.</li>
<li><strong>Registerable:</strong> participant registration, custom questions, eligibility rules, and registration records.</li>
<li><strong>Dates:</strong> start and end dates, plus registration opening and closing dates.</li>
<li><strong>Inventory:</strong> capacity or stock limits and optional waitlists.</li>
<li><strong>Event:</strong> teams, schedules, stages, and results.</li>
<li><strong>Managers:</strong> staff access scoped to individual products.</li>
</ul><p>Capabilities are supported behaviour built into Curling IO. Creating a product type doesn't create arbitrary new software. It lets an organization combine the behaviours Curling IO supports under a category that makes sense to its staff and customers.</p></div></div></details>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="familiar-defaults-without-the-fixed-boxes">Familiar defaults, without the fixed boxes<a href="https://curling.io/blog/your-club-your-product-types#familiar-defaults-without-the-fixed-boxes" class="hash-link" aria-label="Direct link to Familiar defaults, without the fixed boxes" title="Direct link to Familiar defaults, without the fixed boxes">​</a></h2>
<p>Most clubs can keep using Leagues, Bonspiels, Programs, Products, and Fees. Nothing requires an administrator to design a taxonomy before opening registration.</p>
<p>But the club running camps can create Camps. The association running coaching courses can create Courses. The facility selling practice ice can create Ice Rentals. Their admin navigation, public listings, and product setup can match the operation people already understand.</p>
<p>Custom product types are coming in Curling IO v3.</p>]]></content>
        <author>
            <name>Dave Rapin</name>
        </author>
        <category label="v3" term="v3"/>
        <category label="club-management" term="club-management"/>
        <category label="products" term="products"/>
        <category label="sneak-peek" term="sneak-peek"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Why Your Curling Club Shouldn't Use a CMS]]></title>
        <id>https://curling.io/blog/why-your-curling-club-shouldnt-use-a-cms</id>
        <link href="https://curling.io/blog/why-your-curling-club-shouldnt-use-a-cms"/>
        <updated>2026-04-14T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[General-purpose CMS platforms like WordPress and Joomla introduce serious security risks and maintenance burden for curling clubs. Here is what you need to know.]]></summary>
        <content type="html"><![CDATA[<p>We know that many of our clubs use WordPress or Joomla for their curling websites. These are popular platforms, and for good reason: they're flexible and there's no shortage of tutorials and plugins. But that popularity comes with a serious downside. General-purpose CMS platforms are big targets, and volunteer-run clubs often don't have anyone watching the security queue. Here's what you need to know.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-security-problem-is-real">The security problem is real<a href="https://curling.io/blog/why-your-curling-club-shouldnt-use-a-cms#the-security-problem-is-real" class="hash-link" aria-label="Direct link to The security problem is real" title="Direct link to The security problem is real">​</a></h2>
<p>WordPress powers roughly 43% of all websites on the internet. That kind of market share makes it a huge target for malicious actors. Sucuri's <a href="https://sucuri.net/reports/2023-hacked-website-report/" target="_blank" rel="noopener noreferrer">2023 hacked website report</a> found WordPress accounted for over 95% of hacked CMS platforms they remediated. Joomla and Drupal made up most of the rest.</p>
<p>Patchstack's <a href="https://patchstack.com/whitepaper/state-of-wordpress-security-in-2024/" target="_blank" rel="noopener noreferrer">2024 State of WordPress Security report</a> found 5,948 new WordPress vulnerabilities disclosed in a single year. The vast majority, over 97%, were in plugins and themes rather than WordPress core. The exact number changes every year, but the pattern is stable: plugins and themes are where most of the risk lives.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="supply-chain-attacks">Supply chain attacks<a href="https://curling.io/blog/why-your-curling-club-shouldnt-use-a-cms#supply-chain-attacks" class="hash-link" aria-label="Direct link to Supply chain attacks" title="Direct link to Supply chain attacks">​</a></h2>
<p>It's not just about unpatched bugs. In June 2024, WordPress.org disclosed that <a href="https://www.wordfence.com/blog/2024/06/supply-chain-attack-on-wordpress-org-plugins-leads-to-5-maliciously-compromised-wordpress-plugins/" target="_blank" rel="noopener noreferrer">several plugins in the official repository had been compromised</a>. Attackers gained access to developer accounts and pushed malicious updates to trusted plugins with tens of thousands of active installs. The malicious code created unauthorized admin accounts and exfiltrated data.</p>
<p>Even more concerning is a growing pattern where malicious actors <strong>purchase legitimate, established plugins</strong> from their original developers and then inject malicious code in subsequent updates. Users who have auto-updates enabled, as is generally recommended, receive the compromised version automatically without any indication that the plugin has changed hands.</p>
<p>The <a href="https://blog.sucuri.net/2023/04/balada-injector-synopsis-of-a-massive-ongoing-wordpress-malware-campaign.html" target="_blank" rel="noopener noreferrer">Balada Injector campaign</a>, documented by Sucuri, has been exploiting known plugin vulnerabilities since 2017. By their estimates, it has compromised over one million WordPress sites, injecting malicious JavaScript that redirects visitors to scam sites.</p>
<p>In August 2024, a <a href="https://patchstack.com/articles/critical-privilege-escalation-in-litespeed-cache-plugin-affecting-5-million-sites/" target="_blank" rel="noopener noreferrer">critical vulnerability in the LiteSpeed Cache plugin</a> (used by over 5 million sites) allowed attackers to create admin accounts on any site running the vulnerable version. It was actively exploited in the wild.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="joomla-and-drupal-arent-immune">Joomla and Drupal aren't immune<a href="https://curling.io/blog/why-your-curling-club-shouldnt-use-a-cms#joomla-and-drupal-arent-immune" class="hash-link" aria-label="Direct link to Joomla and Drupal aren't immune" title="Direct link to Joomla and Drupal aren't immune">​</a></h2>
<p>Joomla has had its share of critical vulnerabilities. In February 2024, a <a href="https://www.sonarsource.com/blog/joomla-multiple-xss-vulnerabilities/" target="_blank" rel="noopener noreferrer">high severity XSS vulnerability in Joomla's core filter component</a> (CVE-2024-21726) could lead to remote code execution. Proof-of-concept exploit code was published shortly after disclosure. An <a href="https://developer.joomla.org/security-centre/894-20230201-core-improper-access-check-in-webservice-endpoints.html" target="_blank" rel="noopener noreferrer">earlier vulnerability</a> (CVE-2023-23752) that leaked database credentials was still being mass-exploited well into 2024 because so many sites remained unpatched.</p>
<p>Drupal has a better security track record thanks to a more curated extension ecosystem and dedicated security team, but its complexity means fewer organizations keep it fully patched. The infamous Drupalgeddon vulnerabilities from 2014 and 2018 continued to be used against unpatched sites years later.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="automation-is-making-it-worse">Automation is making it worse<a href="https://curling.io/blog/why-your-curling-club-shouldnt-use-a-cms#automation-is-making-it-worse" class="hash-link" aria-label="Direct link to Automation is making it worse" title="Direct link to Automation is making it worse">​</a></h2>
<p>Automated bot traffic already accounts for <a href="https://www.imperva.com/resources/resource-library/reports/2024-bad-bot-report/" target="_blank" rel="noopener noreferrer">roughly half of all internet traffic</a>, with malicious bots making up about a third. AI tools add another layer: attackers can use them to research vulnerabilities, generate exploit code, and move faster once a flaw is public.</p>
<p>A <a href="https://arxiv.org/abs/2404.08144" target="_blank" rel="noopener noreferrer">University of Illinois study</a> demonstrated that an AI agent could successfully exploit 87% of known vulnerabilities when given their CVE descriptions. <a href="https://www.microsoft.com/en-us/security/blog/2024/02/14/staying-ahead-of-threat-actors-in-the-age-of-ai/" target="_blank" rel="noopener noreferrer">Microsoft and OpenAI confirmed</a> that state-affiliated threat actors are already using LLMs for reconnaissance and scripting attacks. The window between a vulnerability being disclosed and being actively exploited has compressed from days to hours, partly because AI tools help attackers weaponize published CVE information almost instantly.</p>
<p>For a CMS ecosystem like WordPress, with thousands of plugin vulnerabilities disclosed in recent years, this means every unpatched plugin becomes a race.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="why-this-matters-for-curling-clubs">Why this matters for curling clubs<a href="https://curling.io/blog/why-your-curling-club-shouldnt-use-a-cms#why-this-matters-for-curling-clubs" class="hash-link" aria-label="Direct link to Why this matters for curling clubs" title="Direct link to Why this matters for curling clubs">​</a></h2>
<p>Most curling clubs have a small group of volunteers managing their online presence. They don't have a dedicated IT team. They don't have a security budget. When a WordPress plugin introduces a vulnerability, they may not even know about it until their site is defaced, redirecting members to phishing pages, or quietly harvesting payment information.</p>
<p>The maintenance burden is significant even without a security incident:</p>
<ul>
<li><strong>Constant updates.</strong> WordPress core, themes, and plugins all need regular updates. Falling behind is how sites get hacked.</li>
<li><strong>Plugin sprawl.</strong> Need a contact form? Plugin. Need event registration? Plugin. Need a photo gallery? Plugin. Each one is a potential attack vector.</li>
<li><strong>Hosting management.</strong> You're responsible for the full stack: operating system, web server, PHP version, database, CMS core, and every plugin.</li>
<li><strong>Backups and recovery.</strong> If something goes wrong, you need a recent backup and someone who knows how to restore it.</li>
</ul>
<p>A general-purpose CMS is designed to build any kind of website. That flexibility comes with complexity that most clubs don't need for what is often a fairly simple informational site.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="if-you-insist-on-using-a-cms">If you insist on using a CMS<a href="https://curling.io/blog/why-your-curling-club-shouldnt-use-a-cms#if-you-insist-on-using-a-cms" class="hash-link" aria-label="Direct link to If you insist on using a CMS" title="Direct link to If you insist on using a CMS">​</a></h2>
<p>Some clubs may have reasons to stick with WordPress or another CMS. If that's the case, treat security as a non-negotiable priority.</p>
<p>If someone else manages your site for you, whether that's a volunteer, a local web developer, or an agency, you should be asking them about each of the following points. If their response is dismissive or vague, that's a red flag. Your club's reputation and your members' data are on the line.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="keep-everything-updated">Keep everything updated<a href="https://curling.io/blog/why-your-curling-club-shouldnt-use-a-cms#keep-everything-updated" class="hash-link" aria-label="Direct link to Keep everything updated" title="Direct link to Keep everything updated">​</a></h3>
<p>Enable automatic core updates and update plugins within 24-48 hours of security releases. Subscribe to security advisories from <a href="https://www.wordfence.com/blog/" target="_blank" rel="noopener noreferrer">Wordfence</a> or <a href="https://patchstack.com/whitepaper/state-of-wordpress-security-in-2024/" target="_blank" rel="noopener noreferrer">Patchstack</a> (for WordPress), the <a href="https://developer.joomla.org/security-centre.html" target="_blank" rel="noopener noreferrer">Joomla Security Strike Team</a>, or the <a href="https://www.drupal.org/security" target="_blank" rel="noopener noreferrer">Drupal Security Team</a>. An unpatched site is a compromised site waiting to happen.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="minimize-your-attack-surface">Minimize your attack surface<a href="https://curling.io/blog/why-your-curling-club-shouldnt-use-a-cms#minimize-your-attack-surface" class="hash-link" aria-label="Direct link to Minimize your attack surface" title="Direct link to Minimize your attack surface">​</a></h3>
<p>Use the fewest plugins possible. Every plugin you install is code you're trusting with your site and your members' data. Before installing anything, check when it was last updated, how many installs it has, and whether it has known vulnerabilities. Remove (don't just deactivate) anything you're not using.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="use-a-web-application-firewall">Use a web application firewall<a href="https://curling.io/blog/why-your-curling-club-shouldnt-use-a-cms#use-a-web-application-firewall" class="hash-link" aria-label="Direct link to Use a web application firewall" title="Direct link to Use a web application firewall">​</a></h3>
<p>Deploy a cloud-based WAF like <a href="https://www.cloudflare.com/" target="_blank" rel="noopener noreferrer">Cloudflare</a> in front of your site. A WAF can block known exploit patterns even before you've applied an update, providing protection against zero-day attacks.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="harden-authentication">Harden authentication<a href="https://curling.io/blog/why-your-curling-club-shouldnt-use-a-cms#harden-authentication" class="hash-link" aria-label="Direct link to Harden authentication" title="Direct link to Harden authentication">​</a></h3>
<p>Enforce strong passwords and enable two-factor authentication for all admin accounts. Limit login attempts to prevent brute force attacks. Change the default admin username. These are basic steps that block a surprising number of attacks.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="use-managed-hosting">Use managed hosting<a href="https://curling.io/blog/why-your-curling-club-shouldnt-use-a-cms#use-managed-hosting" class="hash-link" aria-label="Direct link to Use managed hosting" title="Direct link to Use managed hosting">​</a></h3>
<p>If possible, use a managed hosting provider that specializes in your CMS (WP Engine or Kinsta for WordPress, Pantheon for Drupal). These providers handle server-level security, automatic backups, and often include malware scanning. It costs more than bargain shared hosting, but the security and peace of mind are worth it for a volunteer-run organization.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="monitor-your-site">Monitor your site<a href="https://curling.io/blog/why-your-curling-club-shouldnt-use-a-cms#monitor-your-site" class="hash-link" aria-label="Direct link to Monitor your site" title="Direct link to Monitor your site">​</a></h3>
<p>Set up alerts for unauthorized file changes and unexpected admin account creation. Run regular security scans. Have a plan for what to do if your site is compromised, including who to contact and where your backups are stored.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="consider-a-simpler-alternative">Consider a simpler alternative<a href="https://curling.io/blog/why-your-curling-club-shouldnt-use-a-cms#consider-a-simpler-alternative" class="hash-link" aria-label="Direct link to Consider a simpler alternative" title="Direct link to Consider a simpler alternative">​</a></h2>
<p>If your curling club website is primarily informational, showing hours, ice schedules, contact info, and news, you may not need a CMS at all. There are two good options:</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="a-static-site">A static site<a href="https://curling.io/blog/why-your-curling-club-shouldnt-use-a-cms#a-static-site" class="hash-link" aria-label="Direct link to A static site" title="Direct link to A static site">​</a></h3>
<p>Tools like <a href="https://gohugo.io/" target="_blank" rel="noopener noreferrer">Hugo</a>, Eleventy, or even plain HTML hosted on <a href="https://www.netlify.com/" target="_blank" rel="noopener noreferrer">Netlify</a> or <a href="https://pages.github.com/" target="_blank" rel="noopener noreferrer">GitHub Pages</a> have virtually no attack surface. There's no database to breach and no server-side code to exploit. The performance is also fantastic since there's no server-side processing or database queries slowing things down.</p>
<p>AI agents like <a href="https://claude.ai/" target="_blank" rel="noopener noreferrer">Claude</a>, <a href="https://chatgpt.com/" target="_blank" rel="noopener noreferrer">ChatGPT</a>, and <a href="https://gemini.google.com/" target="_blank" rel="noopener noreferrer">Gemini</a> have made this easier for non-developers. If you're already paying for one of these tools, you can ask it to create a static site for your curling club. It can walk you through the process, show you mockups, help you pick a hosting provider, and help with deployment. It can also handle the integration of our Curling IO <a href="https://curling.io/docs/club-management/registration-widget">widgets</a> and <a href="https://curling.io/docs/advanced/api">APIs</a>.</p>
<p>Make sure you also ask the agent to document what it's built, how it's hosted, and how deployments are done, so the next volunteer can pick up where you left off. Ask it to commit the source code to somewhere like GitHub as well. This might sound like a lot, but the agents will step you through all of it. <a href="https://claude.ai/code" target="_blank" rel="noopener noreferrer">Claude Code</a> is especially useful for this if you don't mind paying $20 / month (and it's great for other things too).</p>
<p>If you need something bespoke for your club's website that Curling IO doesn't offer, we highly recommend this approach.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="curling-ios-built-in-website-hosting">Curling IO's built-in website hosting<a href="https://curling.io/blog/why-your-curling-club-shouldnt-use-a-cms#curling-ios-built-in-website-hosting" class="hash-link" aria-label="Direct link to Curling IO's built-in website hosting" title="Direct link to Curling IO's built-in website hosting">​</a></h3>
<p>Every club on Curling IO can enable a <a href="https://curling.io/docs/club-management/website-hosting">hosted website</a> at no extra cost. It's minimalist by design, so it's best suited for clubs that don't need a bunch of bells and whistles.</p>
<p>It includes a customizable landing page, pages, news articles, sponsor sections, and member-only content, all tightly integrated with your registrations, leagues, and bonspiels. There's no plugin ecosystem to exploit, no PHP stack to maintain, and no CMS updates to fall behind on. You can even redirect your existing domain to it.</p>
<p>Either approach eliminates the entire class of vulnerabilities described in this post. Your volunteers' time is better spent running the club than patching software.</p>]]></content>
        <author>
            <name>Dave Rapin</name>
        </author>
        <category label="club-management" term="club-management"/>
        <category label="security" term="security"/>
        <category label="website" term="website"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Automate Club Management With AI]]></title>
        <id>https://curling.io/blog/automate-club-management-with-ai</id>
        <link href="https://curling.io/blog/automate-club-management-with-ai"/>
        <updated>2026-04-12T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Imagine you're a club manager setting up next season. You open your AI agent and type (or just say):]]></summary>
        <content type="html"><![CDATA[<p>Imagine you're a club manager setting up next season. You open your AI agent and type (or just say):</p>
<blockquote>
<p>Set up early bird pricing for the Tuesday Night League. 15% off if they register before September 1st.</p>
</blockquote>
<p>Five seconds later, it's done. No browser tabs, no forms, no clicking through menus. With Curling IO v3, this is something you'll be able to do.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-agent-in-action">The Agent in Action<a href="https://curling.io/blog/automate-club-management-with-ai#the-agent-in-action" class="hash-link" aria-label="Direct link to The Agent in Action" title="Direct link to The Agent in Action">​</a></h2>
<p>The following conversation occurs after you've already logged in to Curling IO and authorized the <code>curling</code> CLI (more on that below):</p>
<blockquote>
<p><strong>You:</strong> Help me get the new season ready. Set up early bird pricing for the Tuesday Night League, 15% off if they register before September 1st.</p>
</blockquote>
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Agent</div><div class="admonitionContent_BuS1"><details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>Ran 2 commands</summary><div><div class="collapsibleContent_i85q"><div class="language-sh codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-sh codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">$ curling call admin.items.list --org vancouver</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">$ curling commands admin.discounts.create</span><br></span></code></pre></div></div></div></div></details><p>Found the Tuesday Night League (2026-27). Creating the early bird discount now.</p><details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>Ran 1 command</summary><div><div class="collapsibleContent_i85q"><div class="language-sh codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-sh codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">$ curling call admin.discounts.create --org vancouver '{"params": {...}}'</span><br></span></code></pre></div></div></div></div></details><p>Done. 15% early bird discount on the Tuesday Night League, valid until September 1st. I also see a Monday Mixed and Friday Doubles league for this season. Want early bird pricing on those too?</p></div></div>
<blockquote>
<p><strong>You:</strong> Same deal for Monday Mixed. Skip Friday, that one fills up on its own.</p>
</blockquote>
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Agent</div><div class="admonitionContent_BuS1"><details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>Ran 1 command</summary><div><div class="collapsibleContent_i85q"><div class="language-sh codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-sh codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">$ curling call admin.discounts.create --org vancouver '{"params": {...}}'</span><br></span></code></pre></div></div></div></div></details><p>Done. Monday Mixed has the same 15% early bird discount. By the way, last season's Tuesday Night League still has 3 people on the waitlist. Want me to send them an email letting them know registration is open for the new season?</p></div></div>
<blockquote>
<p><strong>You:</strong> Yes, do that.</p>
</blockquote>
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Agent</div><div class="admonitionContent_BuS1"><details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>Ran 2 commands</summary><div><div class="collapsibleContent_i85q"><div class="language-sh codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-sh codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">$ curling call admin.waitlists.list --org vancouver '{"item_id": 31}'</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">$ curling call admin.emails.send --org vancouver '{"params": {...}}'</span><br></span></code></pre></div></div></div></div></details><p>Sent. Three emails went out to the waitlisted curlers from last season's Tuesday Night League, letting them know the 2026-27 season is open with early bird pricing until September 1st.</p></div></div>
<blockquote>
<p><strong>You:</strong> What does our registration look like so far this season?</p>
</blockquote>
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Agent</div><div class="admonitionContent_BuS1"><details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>Ran 1 command</summary><div><div class="collapsibleContent_i85q"><div class="language-sh codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-sh codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">$ curling call admin.items.list --org vancouver '{"season": "2026-27"}'</span><br></span></code></pre></div></div></div></div></details><p>Here's where things stand for 2026-27:</p><table><thead><tr><th>League</th><th>Registered</th><th>Capacity</th><th>Status</th></tr></thead><tbody><tr><td>Monday Mixed</td><td>14</td><td>72</td><td>Open</td></tr><tr><td>Tuesday Night League</td><td>23</td><td>96</td><td>Open</td></tr><tr><td>Friday Doubles</td><td>52</td><td>64</td><td>Open</td></tr></tbody></table><p>Friday Doubles is already at 81% capacity with no discount. Tuesday and Monday are still early. Want me to keep an eye on these and let you know when any league crosses 80%?</p></div></div>
<blockquote>
<p><strong>You:</strong> Yeah, check in with me weekly on that.</p>
</blockquote>
<p>Season setup, targeted outreach, and reporting in a five-minute conversation.</p>
<p>The same pattern works for setting up a full season's events, bulk-adjusting fees across leagues, generating financial reports, or processing waitlists. Anything an admin can do through the web interface, an agent can do through the CLI.</p>
<p>This isn't theoretical. We're building this right now.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="how-it-works">How It Works<a href="https://curling.io/blog/automate-club-management-with-ai#how-it-works" class="hash-link" aria-label="Direct link to How It Works" title="Direct link to How It Works">​</a></h2>
<p>Behind the scenes, the agent is calling a command-line tool called <code>curling</code>. Any AI agent with shell access can call it: OpenClaw, NemoClaw, Claude Code, Codex.</p>
<p>We considered MCP (Model Context Protocol), a standard for connecting AI models to external tools. We built a working prototype. But a CLI is simpler, more portable, and works with every agent framework, not just MCP-compatible ones. MCP can come later as a thin layer on top.</p>
<p>The CLI connects to the same backend as the Curling IO admin web interface. Every admin operation available in the browser is also available through the CLI. When we add a new feature to the admin, it becomes available to agents automatically with no client update.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="authentication">Authentication<a href="https://curling.io/blog/automate-club-management-with-ai#authentication" class="hash-link" aria-label="Direct link to Authentication" title="Direct link to Authentication">​</a></h2>
<p>Before an agent can manage your club, you grant it access. One time, takes about 30 seconds.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">curling login</span><br></span></code></pre></div></div>
<p>The CLI prints a URL. You open it, log into Curling IO, and approve access. That's it. The CLI stores a token and handles refreshes silently from that point on.</p>
<p>This uses the OAuth 2.1 device flow (RFC 8628), the same standard behind the GitHub CLI, Google Cloud CLI, and AWS SSO. It's well understood by security teams and works in any environment.</p>
<p>After login, the agent discovers which clubs you manage:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">curling orgs</span><br></span></code></pre></div></div>
<p>And scopes its commands accordingly:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">curling call admin.events.list --org vancouver</span><br></span></code></pre></div></div>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="who-its-for">Who It's For<a href="https://curling.io/blog/automate-club-management-with-ai#who-its-for" class="hash-link" aria-label="Direct link to Who It's For" title="Direct link to Who It's For">​</a></h2>
<p>The real target is an AI agent acting on behalf of a club manager. The CLI's natural users are administrators who are early adopters of AI tools, the ones already using agents to help manage their inbox, draft communications, and automate repetitive work. For them, giving an agent access to <code>curling</code> is the next logical step.</p>
<p>We build it as if a human expert might use it directly. No wizard prompts, no hand-holding. Terse, precise output. Comprehensive <code>--help</code> that rewards reading. Agents are evolving toward human-like behaviour, so building a proper CLI serves both audiences.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-bigger-picture">The Bigger Picture<a href="https://curling.io/blog/automate-club-management-with-ai#the-bigger-picture" class="hash-link" aria-label="Direct link to The Bigger Picture" title="Direct link to The Bigger Picture">​</a></h2>
<p>Club management software has always been built for humans navigating web interfaces. That's the right design for most users most of the time. But administrators have repetitive, structured work: setting up a new season, bulk-updating pricing, generating reports, approving registration queues. Work that an agent can handle.</p>
<p>Most clubs aren't thinking about this yet, but we believe they will be within the next few years as AI tools and agents become more prominent. In the near term it'll probably be just a handful of technically oriented club managers who give it a try, and will probably never go back.</p>]]></content>
        <author>
            <name>Dave Rapin</name>
        </author>
        <category label="ai" term="ai"/>
        <category label="v3" term="v3"/>
        <category label="ui" term="ui"/>
        <category label="club-management" term="club-management"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Custom Registration Forms Are Coming to Curling IO]]></title>
        <id>https://curling.io/blog/drag-and-drop-registration-forms</id>
        <link href="https://curling.io/blog/drag-and-drop-registration-forms"/>
        <updated>2026-03-28T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A first look at the drag-and-drop form builder in Curling IO v3. Club admins design registration forms by arranging questions on a visual canvas.]]></summary>
        <content type="html"><![CDATA[<p><em>This post is part of our Curling IO v3
<a href="https://curling.io/blog/tags/sneak-peek">sneak peek series</a>, where we explore some of the new
features available in the upcoming version.</em></p>
<p>Registration forms in Curling IO have always collected the basics: team name, lineup, skill level, contact info. But every club runs things a little differently. Some need emergency contacts. Others want dietary restrictions for banquet planning. A bonspiel might ask for team contact information while a league doesn't.</p>
<p>In v2, admins can already choose which questions appear and create custom ones. What's new in v3 is control over the layout: where each question sits, how wide it is, and how the form is organized into sections.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="how-it-works">How it works<a href="https://curling.io/blog/drag-and-drop-registration-forms#how-it-works" class="hash-link" aria-label="Direct link to How it works" title="Direct link to How it works">​</a></h2>
<p>The form builder is a drag-and-drop tool inside the product admin. You start with a panel of available questions on the left and an empty canvas on the right. Drag a question onto the canvas and it becomes part of the registration form. Drag it back to remove it. Reorder by dragging within the canvas.</p>
<p><img decoding="async" loading="lazy" alt="Form builder admin interface" src="https://curling.io/assets/images/form-builder-admin-f73eab20ba1adf72a20fd039b7c2cfab.png" width="2921" height="1645" class="img_ev3q"></p>
<p>Questions have a defined column width (1, 2, or 3 columns) that controls how they sit in the grid. A one-column question like "Shoe Size" takes up a third of the row. A two-column question like "Street Address" spans two thirds. A three-column question like "Notes" takes the full width. The layout uses a masonry grid, so questions pack together without leaving gaps.</p>
<p>Separators let you break the form into visual sections. Questions above a separator stay above it. Below it, a new section starts fresh.</p>
<p>Each question can be toggled between required and optional. Required questions show an asterisk on the public form and are enforced on submission.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-curlers-see">What curlers see<a href="https://curling.io/blog/drag-and-drop-registration-forms#what-curlers-see" class="hash-link" aria-label="Direct link to What curlers see" title="Direct link to What curlers see">​</a></h2>
<p>The public registration form renders the layout the admin designed. Questions float their labels above the input when you start typing, keeping the form compact. Hints appear as info icons that expand on hover.</p>
<p><img decoding="async" loading="lazy" alt="Public registration form" src="https://curling.io/assets/images/form-builder-public-601b03d3dd5f0920af4c1443ad9b4705.png" width="2582" height="1538" class="img_ev3q"></p>
<p>The form is responsive. On a phone, everything stacks into a single column. On a tablet, two columns. On a desktop, the full three-column layout shows.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="available-questions">Available questions<a href="https://curling.io/blog/drag-and-drop-registration-forms#available-questions" class="hash-link" aria-label="Direct link to Available questions" title="Direct link to Available questions">​</a></h2>
<p>The form builder ships with a catalog of predefined questions covering contact info, emergency contacts, medical details, sport-specific questions, apparel sizes, dietary restrictions, and more. Clubs can also create their own custom questions to collect whatever else they need. The list is searchable, and any combination of predefined and custom questions can be used on any product.</p>
<p>Some questions are tied to event settings. If an admin configures a team name label in the event settings, the team name question appears on the form builder canvas automatically and is locked in place. Same for the lineup question when a lineup option is selected. Turn those settings off and the locked questions disappear from the builder.</p>
<p>Gaps and separators are layout tools, not data questions. Add a gap to leave an empty cell in the grid. Add a separator to visually divide the form into sections.</p>
<p>The form builder is part of Curling IO v3. We'll share more v3 features as we get closer to launch.</p>]]></content>
        <author>
            <name>Dave Rapin</name>
        </author>
        <category label="v3" term="v3"/>
        <category label="registration" term="registration"/>
        <category label="form-builder" term="form-builder"/>
        <category label="sneak-peek" term="sneak-peek"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Live Admin With Gleam and Lustre]]></title>
        <id>https://curling.io/blog/live-admin-without-javascript</id>
        <link href="https://curling.io/blog/live-admin-without-javascript"/>
        <updated>2026-03-26T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[This is a technical architecture post from the development of Curling IO v3.]]></summary>
        <content type="html"><![CDATA[<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>About this post</div><div class="admonitionContent_BuS1"><p>This is a technical architecture post from the development of Curling IO v3.
It is written for software engineers and goes deeper into Gleam, Lustre, the
BEAM, WebSockets, and server-rendered interfaces than our usual product posts.</p></div></div>
<p>Curling IO's admin panel should feel instant when a club manager is working through a season setup. Toggle a setting, save a discount, move between product sections: the page should respond without a full reload.</p>
<p>Version 2 works, but every form submission reloads the page. Version 3's admin is a single Lustre server component running on the BEAM. One WebSocket connection, one long-lived Erlang process per session. Every interaction goes over that WebSocket and comes back as a DOM patch. The page never reloads, and there's no client-side JavaScript framework.</p>
<figure><img src="https://curling.io/img/blog/product-registration.png" alt="Curling IO admin panel showing the product registration page"><figcaption><em>The product registration page: sidebar, breadcrumbs, toggle switches, and form fields, all rendered server-side over a single WebSocket connection. Every toggle, input, and save is a live state update.</em></figcaption></figure>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="whats-a-server-component">What's a Server Component?<a href="https://curling.io/blog/live-admin-without-javascript#whats-a-server-component" class="hash-link" aria-label="Direct link to What's a Server Component?" title="Direct link to What's a Server Component?">​</a></h2>
<p>Phoenix LiveView popularized this idea: render HTML on the server, send patches to the client over a WebSocket, handle events the same way. The browser becomes a thin rendering layer. Lustre, Gleam's UI framework, has the same concept built in as "server components."</p>
<p>A Lustre server component is an Elm-architecture application (Model → Update → View) that runs as a BEAM process. When the model changes, Lustre diffs the old and new virtual DOM and sends a JSON patch over the WebSocket. The client applies it. Events from the browser (clicks, form submissions, input changes) travel back as JSON. The whole loop takes single-digit milliseconds on a local network.</p>
<p>The client side is a custom element called <code>&lt;lustre-server-component&gt;</code>. You point it at a WebSocket route and it handles everything: creating a shadow DOM, adopting your stylesheets, applying patches, and forwarding events. Lustre ships the client runtime as a single JavaScript file you include in the page.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-architecture">The Architecture<a href="https://curling.io/blog/live-admin-without-javascript#the-architecture" class="hash-link" aria-label="Direct link to The Architecture" title="Direct link to The Architecture">​</a></h2>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>Server component wiring</summary><div><div class="collapsibleContent_i85q"><p>The admin loads a minimal HTML shell:</p><div class="language-html codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-html codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&lt;</span><span class="token tag" style="color:rgb(255, 85, 114)">html</span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&gt;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&lt;</span><span class="token tag" style="color:rgb(255, 85, 114)">head</span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&gt;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&lt;</span><span class="token tag" style="color:rgb(255, 85, 114)">link</span><span class="token tag" style="color:rgb(255, 85, 114)"> </span><span class="token tag attr-name" style="color:rgb(255, 203, 107)">rel</span><span class="token tag attr-value punctuation attr-equals" style="color:rgb(199, 146, 234)">=</span><span class="token tag attr-value punctuation" style="color:rgb(199, 146, 234)">"</span><span class="token tag attr-value" style="color:rgb(255, 85, 114)">stylesheet</span><span class="token tag attr-value punctuation" style="color:rgb(199, 146, 234)">"</span><span class="token tag" style="color:rgb(255, 85, 114)"> </span><span class="token tag attr-name" style="color:rgb(255, 203, 107)">href</span><span class="token tag attr-value punctuation attr-equals" style="color:rgb(199, 146, 234)">=</span><span class="token tag attr-value punctuation" style="color:rgb(199, 146, 234)">"</span><span class="token tag attr-value" style="color:rgb(255, 85, 114)">/static/css/app.css</span><span class="token tag attr-value punctuation" style="color:rgb(199, 146, 234)">"</span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&gt;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&lt;/</span><span class="token tag" style="color:rgb(255, 85, 114)">head</span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&gt;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&lt;</span><span class="token tag" style="color:rgb(255, 85, 114)">body</span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&gt;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&lt;</span><span class="token tag" style="color:rgb(255, 85, 114)">lustre-server-component</span><span class="token tag" style="color:rgb(255, 85, 114)"> </span><span class="token tag attr-name" style="color:rgb(255, 203, 107)">route</span><span class="token tag attr-value punctuation attr-equals" style="color:rgb(199, 146, 234)">=</span><span class="token tag attr-value punctuation" style="color:rgb(199, 146, 234)">"</span><span class="token tag attr-value" style="color:rgb(255, 85, 114)">/ws/admin?url=/en/admin/products</span><span class="token tag attr-value punctuation" style="color:rgb(199, 146, 234)">"</span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&gt;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&lt;/</span><span class="token tag" style="color:rgb(255, 85, 114)">lustre-server-component</span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&gt;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&lt;</span><span class="token tag" style="color:rgb(255, 85, 114)">script</span><span class="token tag" style="color:rgb(255, 85, 114)"> </span><span class="token tag attr-name" style="color:rgb(255, 203, 107)">src</span><span class="token tag attr-value punctuation attr-equals" style="color:rgb(199, 146, 234)">=</span><span class="token tag attr-value punctuation" style="color:rgb(199, 146, 234)">"</span><span class="token tag attr-value" style="color:rgb(255, 85, 114)">/static/lustre-server-component.mjs</span><span class="token tag attr-value punctuation" style="color:rgb(199, 146, 234)">"</span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&gt;</span><span class="token script"></span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&lt;/</span><span class="token tag" style="color:rgb(255, 85, 114)">script</span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&gt;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&lt;</span><span class="token tag" style="color:rgb(255, 85, 114)">script</span><span class="token tag" style="color:rgb(255, 85, 114)"> </span><span class="token tag attr-name" style="color:rgb(255, 203, 107)">src</span><span class="token tag attr-value punctuation attr-equals" style="color:rgb(199, 146, 234)">=</span><span class="token tag attr-value punctuation" style="color:rgb(199, 146, 234)">"</span><span class="token tag attr-value" style="color:rgb(255, 85, 114)">/static/js/admin-live.js</span><span class="token tag attr-value punctuation" style="color:rgb(199, 146, 234)">"</span><span class="token tag" style="color:rgb(255, 85, 114)"> </span><span class="token tag attr-name" style="color:rgb(255, 203, 107)">defer</span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&gt;</span><span class="token script"></span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&lt;/</span><span class="token tag" style="color:rgb(255, 85, 114)">script</span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&gt;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&lt;/</span><span class="token tag" style="color:rgb(255, 85, 114)">body</span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&gt;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&lt;/</span><span class="token tag" style="color:rgb(255, 85, 114)">html</span><span class="token tag punctuation" style="color:rgb(199, 146, 234)">&gt;</span><br></span></code></pre></div></div><p>That's the entire HTML the server sends. Everything else renders through the WebSocket.</p><p>When the browser opens that page, Lustre's custom element connects to <code>/ws/admin</code>, which upgrades to a WebSocket. On the server side, Mist (the HTTP server) hands the connection to our WebSocket handler, which starts a Lustre runtime:</p><div class="language-gleam codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-gleam codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">fn(_connection) {</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  let assert Ok(runtime) =</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    lustre.start_server_component(admin.app(), flags)</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  let self = process.new_subject()</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  let selector =</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    process.new_selector()</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    |&gt; process.select(for: self)</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  server_component.register_subject(self)</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  |&gt; lustre.send(to: runtime)</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  #(SocketState(runtime:, self:), Some(selector))</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">}</span><br></span></code></pre></div></div><p><code>lustre.start_server_component</code> spawns an actor that holds the application state, runs the update function on every message, diffs the view, and emits patches. The <code>register_subject</code> call tells the runtime "send your patches to this WebSocket connection." From here, everything is bidirectional:</p><ul>
<li><strong>Browser → Server</strong>: User clicks a link. Client sends a JSON event. Mist decodes it and forwards it to the Lustre actor.</li>
<li><strong>Server → Browser</strong>: The actor updates the model, diffs the view, and sends a JSON patch back. The client applies it to the shadow DOM.</li>
</ul><p>Each admin session is its own BEAM process. They share nothing: no session store, no pub/sub, no state to coordinate. If one session crashes, the others don't notice. That's the BEAM's process isolation at work.</p></div></div></details>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="navigation-without-page-loads">Navigation Without Page Loads<a href="https://curling.io/blog/live-admin-without-javascript#navigation-without-page-loads" class="hash-link" aria-label="Direct link to Navigation Without Page Loads" title="Direct link to Navigation Without Page Loads">​</a></h2>
<p>Server-rendered pages usually reload on every navigation. That kills the experience for an admin panel where you're moving between product sections constantly. We needed client-side navigation semantics (pushState, back button support) without a client-side router.</p>
<p>The solution is about 90 lines of vanilla JavaScript in <code>admin-live.js</code> that bridges the browser's history API with the server component. It handles three flows:</p>
<p><strong>Link clicks.</strong> A click listener on the server component intercepts <code>&lt;a&gt;</code> tags, prevents the page load, calls <code>history.pushState</code>, and tells the server about the new URL. Modifier keys (cmd+click), external links, and <code>target="_blank"</code> pass through to the browser normally. The listener uses <code>composedPath()</code> to pierce the shadow DOM boundary and find the actual <code>&lt;a&gt;</code> element.</p>
<p><strong>Back/forward buttons.</strong> A <code>popstate</code> listener detects when the user navigates with browser buttons and sends the new URL to the server without pushing to the history stack (since the browser already updated it).</p>
<p><strong>Server-initiated navigation.</strong> When a save operation redirects to a different page (like navigating from the edit form to the overview after saving), the server emits a custom event with the new URL. The client intercepts it and calls <code>pushState</code>.</p>
<p>All three flows use the same trick to communicate with the server component: hidden <code>&lt;input&gt;</code> elements inside the shadow DOM. JavaScript sets the input's value to the new URL and dispatches a <code>change</code> event. Lustre's event handler on the server side picks it up. Regular DOM events don't cross the shadow DOM boundary reliably, but events on form elements bubble through the component's internal wiring. We tried custom events first and spent a while debugging before landing on this.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="shadow-dom-and-stylesheets">Shadow DOM and Stylesheets<a href="https://curling.io/blog/live-admin-without-javascript#shadow-dom-and-stylesheets" class="hash-link" aria-label="Direct link to Shadow DOM and Stylesheets" title="Direct link to Shadow DOM and Stylesheets">​</a></h2>
<p>Lustre renders inside a shadow DOM. This gives you proper encapsulation. Styles don't leak in or out. The admin's CSS won't affect the rest of the page, and vice versa. But it means your stylesheets need to get inside the shadow root somehow.</p>
<p>Lustre handles this automatically through <code>adoptedStyleSheets</code>, a browser API that lets shadow roots share stylesheets with the parent document. When the component mounts, it iterates over every stylesheet in the document, and pushes each one into the shadow root's <code>adoptedStyleSheets</code> array. If a stylesheet can't be directly adopted (cross-origin restrictions), it copies the CSS rules into a new stylesheet. If that fails too, it clones the <code>&lt;link&gt;</code> or <code>&lt;style&gt;</code> element into the shadow root. Three strategies, in order of preference.</p>
<p>This worked out of the box for Tailwind and our Basecoat component library. One place it tripped us up: CSS custom properties. We had alert color variants that referenced <code>--success</code>, <code>--warning</code>, and <code>--info</code> CSS variables, but those variables were never defined. The styles adopted fine, but <code>color: var(--success)</code> resolved to nothing because the variable didn't exist. The fix was just adding the variable definitions to <code>:root</code>. CSS custom properties inherit through shadow boundaries (unlike regular CSS rules), so once defined on <code>:root</code> they're available everywhere.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-runs-on-the-server-what-runs-on-the-client">What Runs on the Server, What Runs on the Client<a href="https://curling.io/blog/live-admin-without-javascript#what-runs-on-the-server-what-runs-on-the-client" class="hash-link" aria-label="Direct link to What Runs on the Server, What Runs on the Client" title="Direct link to What Runs on the Server, What Runs on the Client">​</a></h2>
<p>The split is straightforward. The server handles everything that doesn't need sub-frame visual feedback:</p>
<ul>
<li>Navigation and routing</li>
<li>Form input and validation</li>
<li>Feature toggle switches</li>
<li>Data loading (synchronous, from SQLite)</li>
<li>Flash alerts</li>
<li>Sidebar expand/collapse</li>
</ul>
<p>The client handles the navigation glue (90 lines of JS) and stylesheet adoption (built into Lustre). That's it. The entire admin UI, including the sidebar, breadcrumbs, form fields, tables, and alert components, is written in Gleam.</p>
<figure><img src="https://curling.io/img/blog/bracket-builder.png" alt="The Curling IO bracket builder"><figcaption><em>The Curling IO bracket builder. This kind of drag-and-drop interactivity needs client-side rendering.</em></figcaption></figure>
<p>Things we explicitly don't do in the server component: timers, drag-and-drop, animations, resize observers. Those require client-side feedback loops that would overwhelm the WebSocket. When we build the bracket builder and team drag-and-drop, those will be Lustre "islands": small client-side Gleam applications compiled to JavaScript, embedded within specific pages. The server component renders the page; the island handles the interactive widget.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-we-got-wrong-along-the-way">What We Got Wrong Along the Way<a href="https://curling.io/blog/live-admin-without-javascript#what-we-got-wrong-along-the-way" class="hash-link" aria-label="Direct link to What We Got Wrong Along the Way" title="Direct link to What We Got Wrong Along the Way">​</a></h2>
<p><strong>Back button infinite loop.</strong> Our first navigation implementation used a single <code>NavigateTo</code> message for everything. Server tells client to push URL, client pushes URL. User hits back, browser fires popstate, client sends the old URL to server, server processes it and emits another pushState. Loop. The fix was separating <code>NavigateTo</code> (which pushes to history) from <code>UrlChanged</code> (which doesn't).</p>
<p><strong>Sidebar flicker.</strong> When navigating between product pages, we were clearing the product context during the loading state. The sidebar would collapse to its default and then re-expand when the page loaded. We fixed this by persisting the product context on the admin model across navigations within the same product.</p>
<p><strong>Flash messages disappearing.</strong> Save operations on discounts and affiliate fees navigate to the list page and show a flash. But we also added "clear flash on navigation." The save set the flash, then called <code>NavigateTo</code>, which cleared it. The fix was to set the flash <em>after</em> the navigation resolved.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-we-got-for-free">What We Got for Free<a href="https://curling.io/blog/live-admin-without-javascript#what-we-got-for-free" class="hash-link" aria-label="Direct link to What We Got for Free" title="Direct link to What We Got for Free">​</a></h2>
<p>BEAM's hot code loading applies to server components. When the dev watcher recompiles a module, the running process picks up the new code on the next message. The WebSocket stays connected, the session state is preserved, and the next click or navigation renders the updated view. We didn't build this. We didn't configure it. We noticed it working one day during development and realized the BEAM had been doing it the whole time.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="why-not-ssr-or-a-spa">Why Not SSR or a SPA?<a href="https://curling.io/blog/live-admin-without-javascript#why-not-ssr-or-a-spa" class="hash-link" aria-label="Direct link to Why Not SSR or a SPA?" title="Direct link to Why Not SSR or a SPA?">​</a></h2>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="over-ssr">Over SSR<a href="https://curling.io/blog/live-admin-without-javascript#over-ssr" class="hash-link" aria-label="Direct link to Over SSR" title="Direct link to Over SSR">​</a></h3>
<p>The Version 2 admin is server-rendered with full page reloads. It works. But every interaction has visible latency. Toggle a switch, wait for the page to reload to see the result. Navigate between product sections, lose your scroll position.</p>
<p>With the server component, toggling a switch updates the UI immediately (the BEAM process is right there, no network hop to a database and back). Form saves can navigate to a different page with a flash message, and the transition is instant. The sidebar persists across pages because it's part of the same running application.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="over-a-spa">Over a SPA<a href="https://curling.io/blog/live-admin-without-javascript#over-a-spa" class="hash-link" aria-label="Direct link to Over a SPA" title="Direct link to Over a SPA">​</a></h3>
<p>From the user's perspective, the server component feels identical to a single-page application. Navigation doesn't reload the page, state persists, and updates are instant. The difference is where the work happens.</p>
<p>A SPA downloads a JavaScript bundle before the user sees anything. A modest React admin with a component library, router, and state management easily runs 200-500KB of JavaScript. Our admin sends ~15KB of initial HTML over the WebSocket and zero application JavaScript (the Lustre client runtime is a generic 10KB script, not application code).</p>
<p>A SPA also needs a REST or GraphQL API to talk to the server. That means designing endpoints, serializing data to JSON, deserializing it on the client, handling loading states, caching, and keeping client and server types in sync. With the server component, the update function has direct access to the full domain and business logic. There's no API ceremony in between. When a user saves a form, the update function writes to the database and returns the new model. The view diffs automatically. The whole admin is one Gleam codebase with state in one place.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="developer-experience">Developer experience<a href="https://curling.io/blog/live-admin-without-javascript#developer-experience" class="hash-link" aria-label="Direct link to Developer experience" title="Direct link to Developer experience">​</a></h3>
<p>Every admin page is a Gleam module with <code>Model</code>, <code>Msg</code>, <code>init</code>, <code>update</code>, and <code>view</code>. The types enforce that pages handle all their messages. Adding a new page means adding a route variant (the compiler tells you everywhere that needs to handle it), a page module, and wiring it into the admin's update function. You don't need a template language or context objects passed through middleware. It's functions all the way down.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-numbers">The Numbers<a href="https://curling.io/blog/live-admin-without-javascript#the-numbers" class="hash-link" aria-label="Direct link to The Numbers" title="Direct link to The Numbers">​</a></h2>
<p>For context on what we're working with:</p>
<ul>
<li>The admin shell HTML is 6 lines of content (meta tags, one stylesheet, the custom element, two scripts)</li>
<li><code>admin-live.js</code> is 89 lines of vanilla JavaScript</li>
<li>The initial mount sends the full virtual DOM (~15-30KB depending on the page)</li>
<li>Subsequent patches are typically under 1KB (just the diff)</li>
<li>Each admin session is a single BEAM process consuming a few hundred KB of memory</li>
<li>SQLite queries run in-process, so data loading for page transitions is synchronous and measured in microseconds</li>
</ul>
<p>There's also a payload advantage over traditional SSR. Every HTTP request carries headers: cookies, content-type, CSRF tokens, accept headers, cache directives. That's typically 1-2KB of overhead on every round trip, in both directions. A WebSocket frame is just the payload, a few bytes of framing around the actual data. An admin session might make hundreds of interactions (toggling switches, navigating between sections, saving forms). Over HTTP, each one pays the header tax. Over the WebSocket, the connection is already established and authenticated. A toggle switch that changes one boolean sends maybe 80 bytes of JSON and gets back a 200-byte patch. The equivalent HTTP POST would be 2-3KB counting headers, redirect, and full page re-render.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="whats-next">What's Next<a href="https://curling.io/blog/live-admin-without-javascript#whats-next" class="hash-link" aria-label="Direct link to What's Next" title="Direct link to What's Next">​</a></h2>
<p>The server component covers the 95% of admin pages that are forms, tables, and configuration. The remaining 5% (our bracket builder, drag-and-drop team management, live scoreboards) will be client-side Lustre islands that communicate back to the server through the same WebSocket or separate API calls.</p>
<hr>
<p><em>This is Part 8 of the Curling IO Foundation series. Previous: <a href="https://curling.io/blog/parallel-tests-for-free">Parallel Tests for Free</a>.</em></p>]]></content>
        <author>
            <name>Dave Rapin</name>
        </author>
        <category label="foundation" term="foundation"/>
        <category label="gleam" term="gleam"/>
        <category label="beam" term="beam"/>
        <category label="lustre" term="lustre"/>
        <category label="architecture" term="architecture"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[AI Agents Love Gleam]]></title>
        <id>https://curling.io/blog/21-reasons-ai-agents-love-gleam</id>
        <link href="https://curling.io/blog/21-reasons-ai-agents-love-gleam"/>
        <updated>2026-03-12T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Fair warning: this post contains some opinions that are going to be controversial and may not age well. Here be dragons.]]></summary>
        <content type="html"><![CDATA[<p><em>Fair warning: this post contains some opinions that are going to be controversial and may not age well. Here be dragons.</em></p>
<p>AI coding agents like Claude Code, OpenAI Codex, and Google Gemini can write code, run it, read the errors, and try again. That loop is the whole game. The faster and more informative that loop is, the more useful the agent becomes. After building Curling IO Version 3 in Gleam alongside AI coding agents, I'm convinced Gleam is the best language for this workflow. Agents don't write better Gleam - there's less training data. But Gleam's compiler lets agents self-correct without waiting for a human.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-feedback-loop-that-matters">The Feedback Loop That Matters<a href="https://curling.io/blog/21-reasons-ai-agents-love-gleam#the-feedback-loop-that-matters" class="hash-link" aria-label="Direct link to The Feedback Loop That Matters" title="Direct link to The Feedback Loop That Matters">​</a></h2>
<p>Every AI coding agent works the same way: write code, check if it works, fix what's broken, repeat. The quality of that "check if it works" step determines everything.</p>
<p>In a dynamically typed language, "check if it works" means running the test suite. Tests take time, they might not cover the thing that's actually broken, and they're code too - every test you add increases the complexity of your project. And many bugs don't surface until runtime, sometimes much later, in production. The agent writes code that looks correct, you review it, it looks correct to you too, and then a user hits a nil error at 2am.</p>
<p>In Gleam, "check if it works" means compiling. That takes a few seconds. When compilation fails, the error messages are specific: here's the file, here's the line, here's what you wrote, here's what was expected. The agent reads that, fixes it, and compiles again. A few rounds of this and the code is structurally sound.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-the-compiler-catches">What the Compiler Catches<a href="https://curling.io/blog/21-reasons-ai-agents-love-gleam#what-the-compiler-catches" class="hash-link" aria-label="Direct link to What the Compiler Catches" title="Direct link to What the Compiler Catches">​</a></h2>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="no-nulls">No Nulls<a href="https://curling.io/blog/21-reasons-ai-agents-love-gleam#no-nulls" class="hash-link" aria-label="Direct link to No Nulls" title="Direct link to No Nulls">​</a></h3>
<p>Gleam has no null. Optional values are represented as <code>Option(T)</code>, which is either <code>Some(value)</code> or <code>None</code>. You can't accidentally dereference a nil. The compiler forces you to handle both cases. Null-related errors are <a href="https://www.harness.io/blog/10-exception-types-in-production-java-applications" target="_blank" rel="noopener noreferrer">the most common exception in production</a>, appearing in 70% of production environments in a study of over 1 billion events.</p>
<p>When an agent writes code in JavaScript, it has to remember to check for null everywhere. It doesn't always remember. Gleam removes the possibility entirely.</p>
<p>Null references are famously the <a href="https://www.infoq.com/presentations/Null-References-The-Billion-Dollar-Mistake-Tony-Hoare/" target="_blank" rel="noopener noreferrer">billion dollar mistake</a>, and they're not just a theoretical concern. In June 2025, <a href="https://dev.to/pantoai/how-a-null-pointer-exception-brought-down-mighty-google-7-hours-of-downtime-explained-p2g" target="_blank" rel="noopener noreferrer">a single null value in a database field</a> cascaded through Google Cloud's Service Control system and took down multiple GCP and Workspace products worldwide for hours. The null hit a code path with no error handling, replicated globally in seconds, and the resulting outage took nearly three hours to fully resolve. Gleam's <code>Option</code> type would have forced the developer or the agent to handle the missing case before the code compiled. It doesn't prevent every failure in a chain like that, but it removes the specific class of bug that triggered it.</p>
<p>There's a security angle too. Unhandled nulls can leave an application in unexpected states - authentication checks skipped because a nil slipped through, or data exposed through an error page that should never have been reached. Every error class the compiler eliminates is attack surface the agent can't accidentally introduce.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="structural-changes">Structural Changes<a href="https://curling.io/blog/21-reasons-ai-agents-love-gleam#structural-changes" class="hash-link" aria-label="Direct link to Structural Changes" title="Direct link to Structural Changes">​</a></h3>
<p>Here's a real example from our codebase. Say the agent adds a new field to a type:</p>
<div class="language-gleam codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-gleam codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">pub type Listing {</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  Listing(</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    id: Int,</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    name: String,</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    capacity: Int,</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    waitlist_enabled: Bool,  // new field</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  )</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">}</span><br></span></code></pre></div></div>
<p>In JavaScript, adding a property to an object changes nothing until runtime. If some template or API endpoint doesn't include the new field, you won't know until that code path executes. Your test suite might catch it. Or it might not, if coverage is incomplete.</p>
<p>In Gleam, every function that constructs or destructures a <code>Listing</code> now fails to compile. The compiler lists every location that needs updating. The agent works through the list, updates each one, and compiles clean.</p>
<p>This extends to every structural change: renaming a field, changing a type from <code>String</code> to <code>Int</code>, adding a variant to a union type. Agents handle this well. Humans forget things in lists.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="exhaustive-pattern-matching">Exhaustive Pattern Matching<a href="https://curling.io/blog/21-reasons-ai-agents-love-gleam#exhaustive-pattern-matching" class="hash-link" aria-label="Direct link to Exhaustive Pattern Matching" title="Direct link to Exhaustive Pattern Matching">​</a></h3>
<p>Gleam's compiler requires that pattern matches cover every possible case. If you match on a <code>Result</code> type, you handle both <code>Ok</code> and <code>Error</code>. If you match on a custom union type with four variants, you handle all four. Miss one and the compiler tells you.</p>
<p>Say you have a payment status type:</p>
<div class="language-gleam codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-gleam codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">pub type PaymentStatus {</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  Pending</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  Completed</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  Refunded</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  Failed</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">}</span><br></span></code></pre></div></div>
<p>If someone adds a <code>Disputed</code> variant, every <code>case</code> expression matching on <code>PaymentStatus</code> across the entire codebase will fail to compile until it handles <code>Disputed</code>. In a dynamically typed language, the agent writes a <code>switch</code> or <code>if</code> chain that handles the common cases and forgets the edge case. That's a runtime error waiting to happen. In Gleam, the compiler catches it before the code ever runs.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="simple-syntax-fewer-ways-to-go-wrong">Simple Syntax, Fewer Ways to Go Wrong<a href="https://curling.io/blog/21-reasons-ai-agents-love-gleam#simple-syntax-fewer-ways-to-go-wrong" class="hash-link" aria-label="Direct link to Simple Syntax, Fewer Ways to Go Wrong" title="Direct link to Simple Syntax, Fewer Ways to Go Wrong">​</a></h2>
<p>Gleam is a small language. There's one way to define a function, one way to handle errors and optional values (<code>Result</code> and <code>Option</code>). No exceptions, no implicit conversions, no macros.</p>
<p>This matters for agents because smaller decision space means fewer wrong decisions. When there are six ways to do something, the agent has to pick one, and it might not pick the idiomatic one. In Gleam, there's usually one way. The agent doesn't need to know the community's style preferences or the codebase's conventions for error handling. The language already decided.</p>
<p>Formatting is the same story. <code>gleam format</code> is canonical. No configuration, no style debates. The agent's output looks identical to hand-written code after formatting. You can't tell the difference, and you don't need to.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="whitespace-doesnt-matter">Whitespace Doesn't Matter<a href="https://curling.io/blog/21-reasons-ai-agents-love-gleam#whitespace-doesnt-matter" class="hash-link" aria-label="Direct link to Whitespace Doesn't Matter" title="Direct link to Whitespace Doesn't Matter">​</a></h2>
<p>I actually prefer whitespace-significant languages. Elm and Haskell are two of my favorites. Clean indentation instead of curly braces everywhere looks better to me. But agents aren't good at it yet. In my experience, agents constantly trip over Slim templates: indentation errors are common with LLM-generated code. The model might mix tabs and spaces, or get the nesting level wrong by one indent. These errors are silent and semantic (they change what the code does).</p>
<p>Gleam uses curly braces. Whitespace is irrelevant to the compiler. <code>gleam format</code> normalizes it. One fewer class of errors for agents to make.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="tight-feedback-loops">Tight Feedback Loops<a href="https://curling.io/blog/21-reasons-ai-agents-love-gleam#tight-feedback-loops" class="hash-link" aria-label="Direct link to Tight Feedback Loops" title="Direct link to Tight Feedback Loops">​</a></h2>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="fast-compilation">Fast Compilation<a href="https://curling.io/blog/21-reasons-ai-agents-love-gleam#fast-compilation" class="hash-link" aria-label="Direct link to Fast Compilation" title="Direct link to Fast Compilation">​</a></h3>
<p>Gleam's compiler runs in a few seconds for our full project. A typical test suite in a dynamically typed language takes 30 seconds to a few minutes. When the agent is iterating, the difference between a few-second compile and a multi-minute test run adds up fast.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="fast-tests">Fast Tests<a href="https://curling.io/blog/21-reasons-ai-agents-love-gleam#fast-tests" class="hash-link" aria-label="Direct link to Fast Tests" title="Direct link to Fast Tests">​</a></h3>
<p>Tests are fast too. Because each test gets its own in-memory SQLite database with no shared state, we <a href="https://curling.io/blog/parallel-tests-for-free">run them all in parallel</a>. Around 800 tests finish in under a second. That speed accumulates over a session where the agent is compiling and testing dozens of times.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="less-training-data-is-a-real-cost">Less Training Data Is a Real Cost<a href="https://curling.io/blog/21-reasons-ai-agents-love-gleam#less-training-data-is-a-real-cost" class="hash-link" aria-label="Direct link to Less Training Data Is a Real Cost" title="Direct link to Less Training Data Is a Real Cost">​</a></h2>
<p>Gleam has less training data than JavaScript, TypeScript, or Python. Agents write worse Gleam on their first attempt. They reach for patterns that don't exist and invent functions that aren't in the standard library.</p>
<p>This is a real cost. The agent takes longer to write initial code in Gleam than it would in JavaScript.</p>
<p>But here's what I've found: the total time from "start writing" to "code is correct and deployed" is shorter in Gleam. The agent writes slower but the compiler catches errors instantly. In JavaScript, the agent writes faster but errors surface later, in tests or in production.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-workflow-in-practice">The Workflow in Practice<a href="https://curling.io/blog/21-reasons-ai-agents-love-gleam#the-workflow-in-practice" class="hash-link" aria-label="Direct link to The Workflow in Practice" title="Direct link to The Workflow in Practice">​</a></h2>
<p>Here's how a typical feature goes:</p>
<ol>
<li>We write a spec describing what we want. This is iterative - we go back and forth with the agent refining the spec before any code is written. A good spec is the difference between useful output and garbage.</li>
<li>The agent writes the implementation: types, database queries, request handlers, views</li>
<li>It runs <code>gleam build</code>. Compilation fails with 5-10 errors.</li>
<li>The agent reads each error, fixes the code, rebuilds. A few rounds.</li>
<li>Compilation succeeds. The agent runs the tests. They pass, or they fail on business logic (not on null errors or type mismatches).</li>
<li>We review the diff. We're looking at logic, intent, and redundancy.</li>
</ol>
<p>Agents tend to duplicate code rather than reuse existing functions, probably because of context window limits. Catching those opportunities to extract shared logic is the most common feedback we give.</p>
<p>We still write a fair amount of code by hand. Agents aren't always right, and they sometimes produce ugly or redundant code that needs to be caught and rewritten. We step in when the agent is spinning out on something, going in circles trying to fix the same error. In Gleam that happens a lot less than what we've seen in dynamic languages.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="other-languages">Other Languages<a href="https://curling.io/blog/21-reasons-ai-agents-love-gleam#other-languages" class="hash-link" aria-label="Direct link to Other Languages" title="Direct link to Other Languages">​</a></h2>
<p>Other statically typed languages with good compilers share some of these properties. Rust, OCaml, Haskell, and Elm all have strong type systems that catch errors at compile time.</p>
<p>But Gleam has a specific combination that makes it well-suited for agents:</p>
<ul>
<li>Simple syntax that's easy to generate</li>
<li>Fast compilation for tight feedback loops</li>
<li>Canonical formatting so agent output looks like hand-written code</li>
<li>No nulls, eliminating one of the most common error classes</li>
<li>Exhaustive pattern matching so no cases are forgotten</li>
<li>Whitespace-insensitive so formatting errors can't change behavior</li>
</ul>
<p>It also runs on the BEAM, which gives you fault tolerance and concurrency, but that's a <a href="https://curling.io/blog/background-jobs-without-the-baggage">separate conversation</a>.</p>
<p>The trade-off is ecosystem maturity and training data. Gleam is young. Libraries are fewer. Agent-generated code needs more correction on the first pass. That gap is closing as training data grows.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-bet">The Bet<a href="https://curling.io/blog/21-reasons-ai-agents-love-gleam#the-bet" class="hash-link" aria-label="Direct link to The Bet" title="Direct link to The Bet">​</a></h2>
<p>I believe AI agents are already writing most of the code in a growing number of projects. Language choice is being evaluated differently. "How fast can a human write this?" matters less. "How much of the developer's review time does this require?" matters most.</p>
<p>The developer is the bottleneck. We're slow compared to computers. A language where the compiler has already verified structural correctness before the diff reaches your screen means the reviewer can focus on logic and intent instead of chasing down missing nil checks.</p>
<p>We picked Gleam for other reasons, but how well it works with AI coding agents has been huge.</p>
<hr>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="addendum-the-state-of-ai-assisted-coding">Addendum: The State of AI-Assisted Coding<a href="https://curling.io/blog/21-reasons-ai-agents-love-gleam#addendum-the-state-of-ai-assisted-coding" class="hash-link" aria-label="Direct link to Addendum: The State of AI-Assisted Coding" title="Direct link to Addendum: The State of AI-Assisted Coding">​</a></h2>
<p>Stepping back from the technical argument. AI-assisted coding is here to stay. Even if the technology doesn't meaningfully improve from where it is today, it's already useful enough that developers are adopting it en masse.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="the-bar-is-higher-not-lower">The Bar Is Higher, Not Lower<a href="https://curling.io/blog/21-reasons-ai-agents-love-gleam#the-bar-is-higher-not-lower" class="hash-link" aria-label="Direct link to The Bar Is Higher, Not Lower" title="Direct link to The Bar Is Higher, Not Lower">​</a></h3>
<p>Agents are good enough for a lot of business application work right now, but "good enough" comes with a big asterisk. Every diff needs scrutiny. The developer needs to be a domain expert, a security expert, and a programming expert. The speed at which code is produced has raised the expertise required from the developer, not lowered it.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="expect-more-security-vulnerabilities">Expect More Security Vulnerabilities<a href="https://curling.io/blog/21-reasons-ai-agents-love-gleam#expect-more-security-vulnerabilities" class="hash-link" aria-label="Direct link to Expect More Security Vulnerabilities" title="Direct link to Expect More Security Vulnerabilities">​</a></h3>
<p>Agents generate code that works, but "works" and "secure" are very different bars. They'll probably catch the obvious stuff like SQL injection. The less obvious stuff is where it gets dangerous - like not realizing that a decision you made three prompts ago means all your customers' PII is now accessible on a public URL as a side effect. That requires understanding the full picture, and agents don't have that yet. I think this will become a visible reality over the next few years as more agent-written code hits production without adequate review.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="the-industry-is-evolving">The Industry Is Evolving<a href="https://curling.io/blog/21-reasons-ai-agents-love-gleam#the-industry-is-evolving" class="hash-link" aria-label="Direct link to The Industry Is Evolving" title="Direct link to The Industry Is Evolving">​</a></h3>
<p>The industry is actively trying to figure out what the right abstractions look like for AI-assisted development. Two notable attempts:</p>
<p><a href="https://moglang.org/" target="_blank" rel="noopener noreferrer">Mog</a> is a language designed to be written and read by AIs rather than humans. I'm skeptical. If the developer reviewing the diff is the bottleneck, optimizing for machine readability at the expense of human readability makes things worse.</p>
<p><a href="https://codespeak.dev/" target="_blank" rel="noopener noreferrer">Codespeak</a> takes a different approach: specs and code as interchangeable representations, where you can move between them in both directions. I think both specs and code are important, and right now neither is enough on its own. A spec is great for big-picture thinking, but it lacks the detail that matters when things go wrong. Code has all the detail, but it's hard to step back and reason about the whole system by reading it. They operate at different levels of granularity, and collapsing them into one thing loses what makes each useful.</p>
<p>Statically typed languages have an advantage here: types are self-documenting. A well-defined type in Gleam already communicates a lot of what a spec would say about the shape of data and the boundaries of a function, without needing a separate document to describe it.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="what-comes-next">What Comes Next<a href="https://curling.io/blog/21-reasons-ai-agents-love-gleam#what-comes-next" class="hash-link" aria-label="Direct link to What Comes Next" title="Direct link to What Comes Next">​</a></h3>
<p>The developer's role is shifting toward a supervisory one. That might mean a lot more software gets written, or a lot fewer developers get employed, or both. And the supervisory role itself might not last if agents get good enough to close the loop on their own. That's a real possibility and it's worth being honest about.</p>
<p>After a year of building production software with AI coding agents, it works if you put in the effort to verify everything that comes out the other end. Gleam wasn't designed for AI agents, but good language design turns out to matter more than any tool built specifically for them.</p>]]></content>
        <author>
            <name>Dave Rapin</name>
        </author>
        <category label="gleam" term="gleam"/>
        <category label="ai" term="ai"/>
        <category label="architecture" term="architecture"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Parallel Tests for Free]]></title>
        <id>https://curling.io/blog/parallel-tests-for-free</id>
        <link href="https://curling.io/blog/parallel-tests-for-free"/>
        <updated>2026-03-08T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[This is a technical implementation note from the development of Curling IO v3.]]></summary>
        <content type="html"><![CDATA[<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>About this post</div><div class="admonitionContent_BuS1"><p>This is a technical implementation note from the development of Curling IO v3.
It is written for software engineers and goes deeper into Gleam, Erlang,
EUnit, test isolation, and parallel execution than our usual product posts.</p></div></div>
<p>While writing the <a href="https://curling.io/blog/sqlite-test-isolation">previous post</a> about our per-test SQLite databases, I was describing how each test gets its own in-memory database, no shared connections, no shared state. And I thought: wait, if nothing is shared, can we just run them all at the same time?</p>
<p>Turns out we could, and our server test suite went from ~4 seconds to ~0.85 seconds for around 800 tests. Zero code changes to the tests themselves. One 25-line Erlang module.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="why-sequential-was-the-default">Why Sequential Was the Default<a href="https://curling.io/blog/parallel-tests-for-free#why-sequential-was-the-default" class="hash-link" aria-label="Direct link to Why Sequential Was the Default" title="Direct link to Why Sequential Was the Default">​</a></h2>
<p>Gleam's test runner, gleeunit, delegates to Erlang's EUnit framework. By default, EUnit runs test modules one at a time. This is the safe choice because most test suites have shared mutable state somewhere: a database connection, a named process, a file on disk. Running those tests concurrently produces the kind of failures that pass locally and fail in CI, or pass on Tuesday and fail on Wednesday.</p>
<p>Our tests don't have shared mutable state. Every test clones its own database. No two tests touch the same connection. The previous post explains the full setup, but the short version is: <code>test_db.setup()</code> clones a cached template database via SQLite's backup API and returns a fresh, independent connection. When the test ends, the connection is garbage collected and the database disappears.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="whats-actually-isolated">What's Actually Isolated<a href="https://curling.io/blog/parallel-tests-for-free#whats-actually-isolated" class="hash-link" aria-label="Direct link to What's Actually Isolated" title="Direct link to What's Actually Isolated">​</a></h2>
<p>Before flipping the switch, we checked every category of shared state:</p>
<table><thead><tr><th>Resource</th><th>Isolation</th></tr></thead><tbody><tr><td>SQLite databases</td><td>Each test clones its own in-memory DB</td></tr><tr><td><code>persistent_term</code> cache</td><td>Idempotent first-writer-wins (template DB, timezone data, logger config)</td></tr><tr><td>ETS tables (rate limiter)</td><td>Unnamed, each test creates its own via <code>ets:new</code></td></tr><tr><td>Named processes</td><td>None started in tests</td></tr><tr><td>File system</td><td>No writes in tests</td></tr></tbody></table>
<p>The <code>persistent_term</code> entries are write-once caches. Multiple tests might try to initialize the template database at the same time, but the first one wins and subsequent calls just read the cached value. That's safe.</p>
<p>ETS tables used in tests (for the rate limiter) are created without the <code>named_table</code> option, so each call to <code>ets:new</code> returns a unique table reference. No conflicts.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-implementation">The Implementation<a href="https://curling.io/blog/parallel-tests-for-free#the-implementation" class="hash-link" aria-label="Direct link to The Implementation" title="Direct link to The Implementation">​</a></h2>
<p>EUnit supports a <code>{inparallel, Tests}</code> wrapper that distributes test functions across BEAM schedulers. It's been there for years, but gleeunit doesn't expose it. We submitted a PR to add a <code>main_parallel()</code> function, but it was pointed out that this fits better as a project-local solution than a change to gleeunit's core. That makes sense. We closed the PR and wrote a project-local Erlang module that replicates the test discovery logic and calls EUnit directly.</p>
<div class="language-erlang codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-erlang codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">-module(parallel_test_runner).</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">-export([main/0]).</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">main() -&gt;</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    Options = [verbose, no_tty,</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">               {report, {gleeunit_progress, [{colored, true}]}},</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">               {scale_timeouts, 10}],</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    Files = filelib:wildcard("**/*.{erl,gleam}", "test"),</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    Modules = [to_module(list_to_binary(F)) || F &lt;- Files],</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    case eunit:test({inparallel, Modules}, Options) of</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">        ok -&gt; erlang:halt(0);</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">        _  -&gt; erlang:halt(1)</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    end.</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">to_module(Path) -&gt;</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    case filename:extension(Path) of</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">        &lt;&lt;".gleam"&gt;&gt; -&gt;</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">            binary_to_atom(</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">              binary:replace(</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">                binary:replace(Path, &lt;&lt;".gleam"&gt;&gt;, &lt;&lt;""&gt;&gt;),</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">                &lt;&lt;"/"&gt;&gt;, &lt;&lt;"@"&gt;&gt;, [global]),</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">              utf8);</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">        &lt;&lt;".erl"&gt;&gt; -&gt;</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">            binary_to_atom(</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">              binary:replace(lists:last(binary:split(Path, &lt;&lt;"/"&gt;&gt;, [global])),</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">                &lt;&lt;".erl"&gt;&gt;, &lt;&lt;""&gt;&gt;),</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">              utf8)</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    end.</span><br></span></code></pre></div></div>
<p>This does the same thing gleeunit does (glob for test files, convert filenames to module atoms) but wraps the module list in <code>{inparallel, ...}</code> instead of passing it flat. It reuses <code>gleeunit_progress</code> from the hex dependency for the dot-per-test output with color, so the test output looks the same as before.</p>
<p>The test entry point is one line:</p>
<div class="language-gleam codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-gleam codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">@external(erlang, "parallel_test_runner", "main")</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">pub fn main() -&gt; Nil</span><br></span></code></pre></div></div>
<p>No fork. No submodule. Stock gleeunit stays in <code>gleam.toml</code> as a dependency (we still use it for the progress reporter). Only the server package uses the parallel runner. The shared and client packages use gleeunit's standard sequential <code>main()</code> because they have far fewer tests and don't need it.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-results">The Results<a href="https://curling.io/blog/parallel-tests-for-free#the-results" class="hash-link" aria-label="Direct link to The Results" title="Direct link to The Results">​</a></h2>
<table><thead><tr><th>Package</th><th>Tests</th><th>Before</th><th>After</th></tr></thead><tbody><tr><td>server</td><td>~800</td><td>~4.0s</td><td>~0.85s</td></tr><tr><td>shared</td><td>~100</td><td>~0.5s</td><td>~0.5s</td></tr><tr><td>client</td><td>1</td><td>~0.2s</td><td>~0.2s</td></tr><tr><td><strong>Total</strong></td><td><strong>~900</strong></td><td><strong>~4.7s</strong></td><td><strong>~1.6s</strong></td></tr></tbody></table>
<p>The shared and client packages didn't change because they already ran in under a second.</p>
<p>EUnit serializes its listener callbacks through a single process, so the dot-per-test progress output still arrives in order. Per-test stdout is delivered after each test completes, so you don't get interleaved output from concurrent tests. The test output looks identical to before, just faster.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="keeping-it-working">Keeping It Working<a href="https://curling.io/blog/parallel-tests-for-free#keeping-it-working" class="hash-link" aria-label="Direct link to Keeping It Working" title="Direct link to Keeping It Working">​</a></h2>
<p>This works because the test architecture is clean. If someone adds a test that registers a named process with a fixed atom, or writes to a shared file path, two copies of that test will collide and produce flaky failures. The rules are simple:</p>
<ul>
<li>Don't share database connections between tests. Use <code>test_db.setup()</code>.</li>
<li>Don't register processes with fixed names. If you need a named process, include the test module name.</li>
<li>Don't use named ETS tables. Pass the table reference instead.</li>
<li>Don't write to the file system in tests.</li>
</ul>
<p>If a test can't follow these rules, EUnit lets you mix <code>{inparallel, ...}</code> and <code>{inorder, ...}</code> in the same test run. We could maintain a list of sequential modules in the runner and group them separately:</p>
<div class="language-erlang codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-erlang codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">Sequential = [some_integration_test],</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">Parallel = Modules -- Sequential,</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">eunit:test([{inparallel, Parallel}, {inorder, Sequential}], Options)</span><br></span></code></pre></div></div>
<p>The sequential modules would run one at a time while everything else stays parallel. We haven't needed this yet, but it's a few lines if we do.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-accidental-win">The Accidental Win<a href="https://curling.io/blog/parallel-tests-for-free#the-accidental-win" class="hash-link" aria-label="Direct link to The Accidental Win" title="Direct link to The Accidental Win">​</a></h2>
<p>None of this was planned. We chose per-test SQLite databases because they eliminated cleanup code, ordering dependencies, and flaky tests from leaked state. We're still a bit surprised that a permanent 4.7x speedup, one we'll benefit from for the lifetime of this project, took an afternoon and 25 lines of Erlang.</p>
<p>37 lines total (counting the 12-line FFI module from the previous post) for per-test database isolation and parallel execution across all available CPU cores. No test framework plugins. No configuration. The whole thing compiles with <code>gleam build</code> and runs with <code>gleam test</code>.</p>
<hr>
<p><em>This is Part 7 of the Curling IO Foundation series. Next up: <a href="https://curling.io/blog/live-admin-without-javascript">A Live Admin Panel Without Writing JavaScript</a>.</em></p>]]></content>
        <author>
            <name>Dave Rapin</name>
        </author>
        <category label="foundation" term="foundation"/>
        <category label="gleam" term="gleam"/>
        <category label="beam" term="beam"/>
        <category label="testing" term="testing"/>
        <category label="architecture" term="architecture"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Every Provincial and Territorial Curling Association in Canada Uses Curling IO]]></title>
        <id>https://curling.io/blog/curling-io-powers-provincial-associations</id>
        <link href="https://curling.io/blog/curling-io-powers-provincial-associations"/>
        <updated>2026-03-06T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[All 14 provincial and territorial curling associations use Curling IO. Here is what that means for clubs and curlers.]]></summary>
        <content type="html"><![CDATA[<p>Every provincial and territorial curling association in Canada uses Curling IO. That's 14 membership associations on the same platform, along with their affiliated clubs. Curling IO also supports registration and competition management at the national level.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-full-list">The Full List<a href="https://curling.io/blog/curling-io-powers-provincial-associations#the-full-list" class="hash-link" aria-label="Direct link to The Full List" title="Direct link to The Full List">​</a></h2>
<ul>
<li>
<a href="https://ab.curling.io/" target="_blank">Curling Alberta</a>
</li>
<li>
<a href="https://bc.curling.io/" target="_blank">Curl BC</a>
</li>
<li>
<a href="https://mb.curling.io/" target="_blank">CurlManitoba</a>
</li>
<li>
<a href="https://sk.curling.io/" target="_blank">CURLSASK</a>
</li>
<li>
<a href="https://on.curling.io/" target="_blank">Curling Ontario</a>
</li>
<li>
<a href="https://noca.curling.io/" target="_blank">Northern Ontario Curling Association</a>
</li>
<li>
<a href="https://qc.curling.io/" target="_blank">Curling Québec</a>
</li>
<li>
<a href="https://nb.curling.io/" target="_blank">New Brunswick Curling Association</a>
</li>
<li>
<a href="https://ns.curling.io/" target="_blank">Nova Scotia Curling</a>
</li>
<li>
<a href="https://nl.curling.io/" target="_blank">Newfoundland &amp; Labrador Curling Association</a>
</li>
<li>
<a href="https://pe.curling.io/" target="_blank">Curl PEI</a>
</li>
<li>
<a href="https://nt.curling.io/" target="_blank">Curling NT</a>
</li>
<li>
<a href="https://nu.curling.io/" target="_blank">Nunavut Curling Association</a>
</li>
<li>
<a href="https://yt.curling.io/" target="_blank">Yukon Curling Association</a>
</li>
</ul>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-associations-use-it-for">What Associations Use It For<a href="https://curling.io/blog/curling-io-powers-provincial-associations#what-associations-use-it-for" class="hash-link" aria-label="Direct link to What Associations Use It For" title="Direct link to What Associations Use It For">​</a></h2>
<p>Provincial associations use Curling IO to run their competitions: provincial championships, playdowns, regional qualifiers. They manage team registrations, schedule draws, score games live, and publish results. Fans and participants see brackets, standings, and scoreboards update in real time.</p>
<p>Associations also collect affiliate fees through the platform. When a curler registers at their local club, the club's registration fees and the association's affiliate fees can be collected together in one transaction. The association fee and the club's own fees appear on a single checkout. No separate invoicing, no manual reconciliation.</p>
<p>The affiliate fee tie-in is optional. Clubs can use Curling IO independently without inheriting association fees or reporting member data. But clubs that do opt in get the fee collection and reporting handled automatically.</p>
<p>The platform is fully bilingual (English and French), which matters for associations like Curling Québec and for clubs across the country with francophone members.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="why-it-matters-for-clubs">Why It Matters for Clubs<a href="https://curling.io/blog/curling-io-powers-provincial-associations#why-it-matters-for-clubs" class="hash-link" aria-label="Direct link to Why It Matters for Clubs" title="Direct link to Why It Matters for Clubs">​</a></h2>
<p>When your provincial association is on Curling IO, a few things work in your favour.</p>
<p><strong>Your curlers already have profiles.</strong> A curler who registered for a provincial bonspiel or played in a championship already exists in the system. When they register at your club, they find their existing profile. No re-entering names, addresses, or emergency contacts.</p>
<p><strong>Competitions connect.</strong> A curler's results from club leagues, provincial playdowns, and national championships all live in the same system. Their profile carries from club leagues to provincial to national competitions.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="why-it-matters-for-curlers">Why It Matters for Curlers<a href="https://curling.io/blog/curling-io-powers-provincial-associations#why-it-matters-for-curlers" class="hash-link" aria-label="Direct link to Why It Matters for Curlers" title="Direct link to Why It Matters for Curlers">​</a></h2>
<p>From a curler's perspective, Curling IO is one account. They register for their Tuesday night league at their local club, sign up for a weekend bonspiel at another club, and qualify for a provincial championship. Same login, same profile, same payment flow.</p>
<p>There are currently around 150,000 curler profiles in the system across hundreds of active clubs and all 14 provincial and territorial membership associations. That number grows every season as more clubs come online.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="getting-started">Getting Started<a href="https://curling.io/blog/curling-io-powers-provincial-associations#getting-started" class="hash-link" aria-label="Direct link to Getting Started" title="Direct link to Getting Started">​</a></h2>
<p>If your club is under a Canadian provincial association, your association is already on Curling IO. Getting started means your affiliate fees and curler database are connected from day one. There's no setup fee, no monthly fee, and no contract.</p>
<p>Clubs outside of Canada can use Curling IO too, just without the association tie-in.</p>
<p>Check out our <a href="https://curling.io/docs/getting-started/curling-club-managers">getting started guide</a> or visit your provincial association's Curling IO page to see it in action.</p>]]></content>
        <author>
            <name>Chris</name>
        </author>
        <category label="provincial-associations" term="provincial-associations"/>
        <category label="curler-database" term="curler-database"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Test Isolation for Free with SQLite]]></title>
        <id>https://curling.io/blog/sqlite-test-isolation</id>
        <link href="https://curling.io/blog/sqlite-test-isolation"/>
        <updated>2026-03-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[This is a technical implementation note about the testing architecture for]]></summary>
        <content type="html"><![CDATA[<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>About this post</div><div class="admonitionContent_BuS1"><p>This is a technical implementation note about the testing architecture for
Curling IO v3. It is written for software engineers and goes deeper into
SQLite, in-memory databases, the backup API, and test isolation than our usual
product posts.</p></div></div>
<p>Curling IO's tests don't need a shared test database, cleanup hooks, or transaction tricks. Each test gets its own database, so a test can pass alone or in the full suite for the same reason: nothing else can touch its data.</p>
<p>That falls out of one Version 3 choice: SQLite runs in-process. Each test gets a completely independent in-memory SQLite database, cloned from a template in microseconds using SQLite's backup API.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-shared-database-problem">The Shared Database Problem<a href="https://curling.io/blog/sqlite-test-isolation#the-shared-database-problem" class="hash-link" aria-label="Direct link to The Shared Database Problem" title="Direct link to The Shared Database Problem">​</a></h2>
<p>In a traditional Postgres or MySQL test setup, every test talks to the same database server. You need a strategy to keep tests from contaminating each other:</p>
<p><strong>Transaction rollback.</strong> Wrap each test in a transaction, roll it back at the end. Works until your code uses transactions internally, or spawns processes that need to see the test data (the classic Ecto sandbox problem).</p>
<p><strong>Truncation.</strong> Delete all rows from every table between tests. Slow, and you need to get the table ordering right to avoid foreign key violations.</p>
<p><strong>Database cleaner.</strong> A gem/library that combines both strategies with configuration for which tables to clean, which strategy to use, and when. It works, but it's ceremony that exists purely because of the shared database.</p>
<p>All of these are workarounds for the same architectural constraint: one database server, many tests.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="in-memory-sqlite-one-database-per-test">In-Memory SQLite: One Database Per Test<a href="https://curling.io/blog/sqlite-test-isolation#in-memory-sqlite-one-database-per-test" class="hash-link" aria-label="Direct link to In-Memory SQLite: One Database Per Test" title="Direct link to In-Memory SQLite: One Database Per Test">​</a></h2>
<p>SQLite opens a database by passing a file path. Pass <code>:memory:</code> instead and you get an in-memory database that exists only for the lifetime of that connection. It's fast (no disk I/O) and completely isolated. Nothing else can see it, and closing the connection frees everything.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>Basic setup example</summary><div><div class="collapsibleContent_i85q"><p>Our test setup function:</p><div class="language-gleam codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-gleam codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">pub fn setup() -&gt; sqlight.Connection {</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  let assert Ok(conn) = sqlight.open(":memory:")</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  let assert Ok(_) = sqlight.exec("PRAGMA foreign_keys=ON;", conn)</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  let assert Ok(_) = sqlight.exec(schema_sql, conn)</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  conn</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">}</span><br></span></code></pre></div></div><p>The <code>schema_sql</code> variable contains the contents of <code>db/schema.sql</code>, which is regenerated from migrations by <code>bin/reset</code>. This means tests always use the current schema without anyone remembering to update them.</p><p>Every test calls <code>setup()</code>, gets a fresh database with the full schema, inserts whatever test data it needs, and runs its assertions. When the test ends, the connection is garbage collected and the database disappears. There's nothing to clean up.</p><div class="language-gleam codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-gleam codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">pub fn should_reject_duplicate_registration_test() {</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  let conn = test_db.setup()</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  // Insert test-specific data</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  insert_org(conn, test_org())</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  insert_listing(conn, test_listing())</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  // ... test logic, assertions</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  // conn goes out of scope, database vanishes</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">}</span><br></span></code></pre></div></div></div></div></details>
<p>There's no possible cross-contamination because there's nothing shared. Test A and test B literally operate on different databases. You can run them in parallel on BEAM processes without any coordination.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-schema-execution-cost">The Schema Execution Cost<a href="https://curling.io/blog/sqlite-test-isolation#the-schema-execution-cost" class="hash-link" aria-label="Direct link to The Schema Execution Cost" title="Direct link to The Schema Execution Cost">​</a></h2>
<p>This approach has an obvious cost: every test executes the full schema. For us that's 77 <code>CREATE TABLE</code>, <code>CREATE INDEX</code>, and <code>CREATE VIEW</code> statements. We profiled it:</p>
<ul>
<li>Opening an in-memory SQLite database: <strong>15 microseconds</strong></li>
<li>Executing 77 schema statements: <strong>1,100 microseconds</strong> (1.1 ms)</li>
</ul>
<p>At 1.1 ms per test across ~500 tests that need a database, that's about 550 ms of schema execution. Not terrible, but not free either. And it scales linearly with both the number of tests and the size of your schema.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="sqlites-backup-api-page-level-cloning">SQLite's Backup API: Page-Level Cloning<a href="https://curling.io/blog/sqlite-test-isolation#sqlites-backup-api-page-level-cloning" class="hash-link" aria-label="Direct link to SQLite's Backup API: Page-Level Cloning" title="Direct link to SQLite's Backup API: Page-Level Cloning">​</a></h2>
<p>SQLite has a <a href="https://www.sqlite.org/backup.html" target="_blank" rel="noopener noreferrer">backup API</a> designed for copying databases between connections. It operates at the page level: it doesn't re-parse or re-execute SQL, it copies raw database pages from one connection to another.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>Template cloning implementation</summary><div><div class="collapsibleContent_i85q"><p>The idea: build the schema once in a template database, then clone it per test.</p><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">Once at startup:  open(":memory:") → execute full schema → template</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">Per test:         open(":memory:") → backup_copy(template) → ready</span><br></span></code></pre></div></div><p>The backup API is three calls: <code>backup_init</code> (set up the copy), <code>backup_step</code> with <code>-1</code> (copy all pages in one shot), and <code>backup_finish</code> (release resources).</p><p>Our Gleam SQLite driver, <a href="https://hexdocs.pm/sqlight/" target="_blank" rel="noopener noreferrer">sqlight</a>, doesn't expose the backup API. But it's built on <a href="https://github.com/mmzeeman/esqlite" target="_blank" rel="noopener noreferrer">esqlite</a>, an Erlang NIF wrapper around SQLite's C library, and esqlite exposes the full backup API. Since Gleam compiles to Erlang and runs on the BEAM, we can call Erlang libraries directly through Gleam's foreign function interface (FFI). We wrote a 12-line Erlang module that takes two sqlight connections (which are just esqlite records under the hood) and performs the clone:</p><div class="language-erlang codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-erlang codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">-module(test_db_ffi).</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">-export([clone_db/1]).</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">clone_db(Template) -&gt;</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    {ok, Dest} = esqlite3:open(":memory:"),</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    {ok, Backup} = esqlite3:backup_init(Dest, "main", Template, "main"),</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    '$done' = esqlite3:backup_step(Backup, -1),</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    ok = esqlite3:backup_finish(Backup),</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    {ok, Dest}.</span><br></span></code></pre></div></div><p>On the Gleam side, we declare the FFI binding and call it like any other function:</p><div class="language-gleam codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-gleam codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">@external(erlang, "test_db_ffi", "clone_db")</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">fn clone_db(</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  template: sqlight.Connection,</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">) -&gt; Result(sqlight.Connection, Nil)</span><br></span></code></pre></div></div><p>This is one of the nice things about the BEAM ecosystem. When your high-level driver doesn't expose what you need, the lower-level library almost always does, and the FFI boundary is trivial to cross.</p><p>The template is created once per test run and cached in BEAM's <code>persistent_term</code> (a global immutable store optimized for read-heavy access):</p><div class="language-gleam codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-gleam codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">fn template_db() -&gt; sqlight.Connection {</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  case get_cached("test_template_db") {</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    Ok(conn) -&gt; conn</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    Error(Nil) -&gt; {</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">      let assert Ok(conn) = sqlight.open(":memory:")</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">      let assert Ok(_) = sqlight.exec("PRAGMA foreign_keys=ON;", conn)</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">      let assert Ok(_) = sqlight.exec(schema_sql, conn)</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">      cache("test_template_db", conn)</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">      conn</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    }</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  }</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">}</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">pub fn setup() -&gt; sqlight.Connection {</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  let assert Ok(conn) = clone_db(template_db())</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  let assert Ok(_) = sqlight.exec("PRAGMA foreign_keys=ON;", conn)</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  conn</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">}</span><br></span></code></pre></div></div><p>The <code>PRAGMA foreign_keys=ON</code> runs after the clone because SQLite pragma settings are per-connection, not stored in the database file. It's a single statement, negligible cost.</p></div></div></details>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-numbers">The Numbers<a href="https://curling.io/blog/sqlite-test-isolation#the-numbers" class="hash-link" aria-label="Direct link to The Numbers" title="Direct link to The Numbers">​</a></h2>
<p>At the time we switched to the clone method, we had 594 tests and 77 schema objects. We're still early in the rewrite and already approaching 1,000 tests. By launch we expect closer to twice the schema objects and somewhere around 5,000 tests.</p>
<p>Here's what we measured at 594 tests:</p>
<table><thead><tr><th>Approach</th><th>Server test time</th></tr></thead><tbody><tr><td>Execute full schema per test</td><td>3.2 s</td></tr><tr><td>Clone template via backup API</td><td>2.6 s</td></tr></tbody></table>
<p>The schema execution cost dropped from ~550 ms to near zero. The remaining 2.6 seconds is actual test logic (inserting data, running business logic, assertions) plus BEAM VM startup overhead.</p>
<p>The savings become more meaningful at scale. In a traditional Postgres setup, you'd use truncation between tests, issuing <code>TRUNCATE TABLE</code> on every table, in foreign-key-safe order, over a socket to the database server. With around 100 tables and 5,000 tests (a conservative estimate for our full rewrite, realistically 2-3x the tests), that's 500,000 truncation statements hitting a database server. Even at sub-millisecond per truncate, it adds up.</p>
<p>With the backup approach, the clone cost is ~25 microseconds per test regardless of schema size. At 5,000 tests that's 0.125 seconds total. No truncation ordering, no socket round-trips.</p>
<p>For context, this is a real application test suite covering cart operations, payment processing, registration validation, affiliate fees, waivers, round robin generation, scheduling, scoring, and more. Not trivial tests.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="why-this-works-so-well">Why This Works So Well<a href="https://curling.io/blog/sqlite-test-isolation#why-this-works-so-well" class="hash-link" aria-label="Direct link to Why This Works So Well" title="Direct link to Why This Works So Well">​</a></h2>
<p>The backup API is a nice optimization, but the real win is that in-process SQLite eliminates the shared database problem entirely.</p>
<p>With Postgres, your test process talks to a database server over a socket. Every test that writes data is writing to the same place. You need isolation strategies because the architecture demands them.</p>
<p>With in-process SQLite, the database lives in your process's memory. Creating a new one is a memory allocation, not a network connection. There's no server to coordinate with and no connection pool to manage. The isolation falls out of the architecture for free.</p>
<p>This also means:</p>
<ul>
<li>No test ordering dependencies. Each test is fully self-contained.</li>
<li>Safe parallelism. BEAM processes each get their own database, zero coordination needed.</li>
<li>No cleanup code. No <code>teardown</code>, no <code>after_each</code>, no <code>database_cleaner</code>.</li>
<li>No flaky tests from shared state leaking between runs.</li>
</ul>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="trade-offs">Trade-offs<a href="https://curling.io/blog/sqlite-test-isolation#trade-offs" class="hash-link" aria-label="Direct link to Trade-offs" title="Direct link to Trade-offs">​</a></h2>
<p>This isn't free of trade-offs. You're not testing against the same database engine you run in production... unless you also run SQLite in production, which we do. If you're using SQLite for tests but Postgres in production, you'd miss Postgres-specific behavior (custom types, advisory locks, jsonb operators, etc.).</p>
<p>The other trade-off is that in our setup, each test builds up its own data from scratch. In practice this is a feature (every test explicitly declares its dependencies) but it does mean more setup code per test compared to a shared fixtures approach. That said, this is a choice, not a limitation of the technique. You could just as easily insert seed data into the template database before caching it, and every clone would start with that data pre-loaded. If your test suite benefits from a well-defined set of standard users, organizations, or other reference data, seeding the template is a straightforward way to reduce per-test setup while keeping full isolation.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="this-isnt-beam-specific">This Isn't BEAM-Specific<a href="https://curling.io/blog/sqlite-test-isolation#this-isnt-beam-specific" class="hash-link" aria-label="Direct link to This Isn't BEAM-Specific" title="Direct link to This Isn't BEAM-Specific">​</a></h2>
<p>Our examples are in Gleam and Erlang, but the backup API is a C-level SQLite feature. Most languages expose it:</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>Examples in Python, Node.js, and Rust</summary><div><div class="collapsibleContent_i85q"><div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token comment" style="color:rgb(105, 112, 152);font-style:italic"># Python 3.7+ has it built in</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">source </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> sqlite3</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token plain">connect</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token string" style="color:rgb(195, 232, 141)">":memory:"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">source</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token plain">executescript</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain">schema_sql</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">dest </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> sqlite3</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token plain">connect</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token string" style="color:rgb(195, 232, 141)">":memory:"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">source</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token plain">backup</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain">dest</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><br></span></code></pre></div></div><div class="language-javascript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-javascript codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token comment" style="color:rgb(105, 112, 152);font-style:italic">// Node.js better-sqlite3</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token keyword" style="font-style:italic">const</span><span class="token plain"> template </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> </span><span class="token keyword" style="font-style:italic">new</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">Database</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token string" style="color:rgb(195, 232, 141)">":memory:"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token punctuation" style="color:rgb(199, 146, 234)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">template</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token method function property-access" style="color:rgb(130, 170, 255)">exec</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain">schemaSQL</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token punctuation" style="color:rgb(199, 146, 234)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token keyword" style="font-style:italic">const</span><span class="token plain"> dest </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> template</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token method function property-access" style="color:rgb(130, 170, 255)">backup</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token string" style="color:rgb(195, 232, 141)">":memory:"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token punctuation" style="color:rgb(199, 146, 234)">;</span><br></span></code></pre></div></div><div class="language-rust codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-rust codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token comment" style="color:rgb(105, 112, 152);font-style:italic">// Rust rusqlite</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token keyword" style="font-style:italic">let</span><span class="token plain"> template </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">Connection</span><span class="token punctuation" style="color:rgb(199, 146, 234)">::</span><span class="token function" style="color:rgb(130, 170, 255)">open_in_memory</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token operator" style="color:rgb(137, 221, 255)">?</span><span class="token punctuation" style="color:rgb(199, 146, 234)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">template</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token function" style="color:rgb(130, 170, 255)">execute_batch</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain">schema_sql</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token operator" style="color:rgb(137, 221, 255)">?</span><span class="token punctuation" style="color:rgb(199, 146, 234)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token keyword" style="font-style:italic">let</span><span class="token plain"> </span><span class="token keyword" style="font-style:italic">mut</span><span class="token plain"> dest </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">Connection</span><span class="token punctuation" style="color:rgb(199, 146, 234)">::</span><span class="token function" style="color:rgb(130, 170, 255)">open_in_memory</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token operator" style="color:rgb(137, 221, 255)">?</span><span class="token punctuation" style="color:rgb(199, 146, 234)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token keyword" style="font-style:italic">let</span><span class="token plain"> backup </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> </span><span class="token namespace" style="color:rgb(178, 204, 214)">backup</span><span class="token namespace punctuation" style="color:rgb(199, 146, 234)">::</span><span class="token class-name" style="color:rgb(255, 203, 107)">Backup</span><span class="token punctuation" style="color:rgb(199, 146, 234)">::</span><span class="token function" style="color:rgb(130, 170, 255)">new</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token operator" style="color:rgb(137, 221, 255)">&amp;</span><span class="token plain">template</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> </span><span class="token operator" style="color:rgb(137, 221, 255)">&amp;</span><span class="token keyword" style="font-style:italic">mut</span><span class="token plain"> dest</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token operator" style="color:rgb(137, 221, 255)">?</span><span class="token punctuation" style="color:rgb(199, 146, 234)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">backup</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token function" style="color:rgb(130, 170, 255)">run_to_completion</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token number" style="color:rgb(247, 140, 108)">5</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">Duration</span><span class="token punctuation" style="color:rgb(199, 146, 234)">::</span><span class="token constant" style="color:rgb(130, 170, 255)">ZERO</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(255, 203, 107)">None</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token operator" style="color:rgb(137, 221, 255)">?</span><span class="token punctuation" style="color:rgb(199, 146, 234)">;</span><br></span></code></pre></div></div></div></div></details>
<p>The pattern is the same in every language: build a template once, clone it per test via the backup API. The FFI detour we took through esqlite is only necessary because our particular Gleam driver doesn't expose it yet.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-full-stack">The Full Stack<a href="https://curling.io/blog/sqlite-test-isolation#the-full-stack" class="hash-link" aria-label="Direct link to The Full Stack" title="Direct link to The Full Stack">​</a></h2>
<p>Here's what our test infrastructure looks like in its entirety:</p>
<ul>
<li><code>test_db.setup()</code> clones a cached template database via SQLite backup API</li>
<li><code>test_db.tz_db()</code> returns a cached timezone database (loaded once, not per test)</li>
<li><code>persistent_term</code> is BEAM's global cache for the template DB and timezone data</li>
<li>12 lines of Erlang FFI wraps the SQLite backup API</li>
</ul>
<p>That's the entire test infrastructure. No test framework plugins, no database cleaner gems, no truncation strategies. SQLite and 12 lines of Erlang.</p>
<hr>
<p><em>This is Part 6 of the Curling IO Foundation series. Next up: <a href="https://curling.io/blog/parallel-tests-for-free">Parallel Tests for Free</a>.</em></p>]]></content>
        <author>
            <name>Dave Rapin</name>
        </author>
        <category label="foundation" term="foundation"/>
        <category label="sqlite" term="sqlite"/>
        <category label="testing" term="testing"/>
        <category label="architecture" term="architecture"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[What to Look for in Curling Club Management Software]]></title>
        <id>https://curling.io/blog/what-to-look-for-in-curling-club-management-software</id>
        <link href="https://curling.io/blog/what-to-look-for-in-curling-club-management-software"/>
        <updated>2026-03-02T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A practical guide to evaluating club management software for curling clubs: what matters, what to watch for, and the questions worth asking before you commit.]]></summary>
        <content type="html"><![CDATA[<p>If your curling club is shopping for management software, or wondering whether it's time to replace what you've got, the wrong choice can mean years of workarounds. Here's what to look for.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="purpose-built-for-curling">Purpose-Built for Curling<a href="https://curling.io/blog/what-to-look-for-in-curling-club-management-software#purpose-built-for-curling" class="hash-link" aria-label="Direct link to Purpose-Built for Curling" title="Direct link to Purpose-Built for Curling">​</a></h2>
<p>Generic sports platforms and website builders can handle basic registration, but curling has concepts that most software doesn't account for: ends, hammer, draw schedules, round robins with pools, page playoffs, spare management, rental ice, bonspiels, waitlists, and online waivers.</p>
<p>If the software you're evaluating doesn't understand these things natively, you'll spend your time working around it instead of working with it. Ask whether you can schedule a draw across four sheets, score a game end-by-end, or run a triple knockout bracket without manual intervention.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="management-first-not-a-cms">Management First, Not a CMS<a href="https://curling.io/blog/what-to-look-for-in-curling-club-management-software#management-first-not-a-cms" class="hash-link" aria-label="Direct link to Management First, Not a CMS" title="Direct link to Management First, Not a CMS">​</a></h2>
<p>Some club management software is really just an extension of a content management system (CMS), focused on building websites like it's still 1999. Building a nice website is already a solved problem. Squarespace, Wix, WordPress, and hundreds of others already have that covered. Look for software that has the curling-specific depth you need, not a website builder with registration bolted on. Most clubs are better off with a simple website any volunteer can update than a giant CMS no one remembers how to administer.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="modern-and-fast">Modern and Fast<a href="https://curling.io/blog/what-to-look-for-in-curling-club-management-software#modern-and-fast" class="hash-link" aria-label="Direct link to Modern and Fast" title="Direct link to Modern and Fast">​</a></h2>
<p>Performance matters more than people think. If the platform feels slow when a curler is trying to register and pay, they notice. Pages need to feel snappy, not just functional. If the platform is also used for provincial or national competitions, it's already proven it can handle real traffic, not a few dozen people checking scores at the same time.</p>
<p>More than half of web traffic comes from phones now. Volunteers entering scores, curlers checking draw times, spectators following results: most of that happens on a phone. The platform needs to work well on smaller screens, desktops included but not prioritized.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="pricing-transparency">Pricing Transparency<a href="https://curling.io/blog/what-to-look-for-in-curling-club-management-software#pricing-transparency" class="hash-link" aria-label="Direct link to Pricing Transparency" title="Direct link to Pricing Transparency">​</a></h2>
<p>Pricing models vary widely. Some platforms charge setup fees, monthly fees, per-member fees, or some combination. Others take a percentage of transactions. Before you sign up, make sure you understand:</p>
<ul>
<li>Is there a cost just to get started?</li>
<li>Are there ongoing fees regardless of usage?</li>
<li>What does payment processing actually cost, all-in?</li>
<li>Are there per-member or per-registration charges on top?</li>
</ul>
<p>Clubs are often volunteer-run with tight budgets. A platform that costs hundreds of dollars a month before a single curler registers doesn't make sense for a 4-sheet club with 120 members.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="online-registration-and-payments">Online Registration and Payments<a href="https://curling.io/blog/what-to-look-for-in-curling-club-management-software#online-registration-and-payments" class="hash-link" aria-label="Direct link to Online Registration and Payments" title="Direct link to Online Registration and Payments">​</a></h2>
<p>If your members can't register and pay online, you're creating work for yourself. Curlers should be able to browse events, register, and pay in one flow. No filling out PDFs, emailing forms, or bringing cheques to the club.</p>
<p>The payment processing should be built in, not bolted on through a third-party plugin. And it should handle what curling clubs deal with: membership fees, family registrations, multiple events in one cart, early bird pricing, discounts, and partial refunds.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="league-and-competition-management">League and Competition Management<a href="https://curling.io/blog/what-to-look-for-in-curling-club-management-software#league-and-competition-management" class="hash-link" aria-label="Direct link to League and Competition Management" title="Direct link to League and Competition Management">​</a></h2>
<p>You should be able to:</p>
<ul>
<li>Generate round robin draw schedules across sheets and time slots</li>
<li>Score games live with optional end scores</li>
<li>Automatically calculate standings with head-to-head tiebreakers</li>
<li>Run playoff brackets that advance teams based on results</li>
<li>Share live scoreboards that spectators and curlers can follow from home</li>
</ul>
<p>If the platform can't handle a standard 8-team round robin on 4 sheets without manual scheduling, it's not built for curling.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="data-portability-and-accounting-integration">Data Portability and Accounting Integration<a href="https://curling.io/blog/what-to-look-for-in-curling-club-management-software#data-portability-and-accounting-integration" class="hash-link" aria-label="Direct link to Data Portability and Accounting Integration" title="Direct link to Data Portability and Accounting Integration">​</a></h2>
<p>Your data should be yours. Look for the ability to export registration data, financial records, and member information. If you ever want to switch platforms, you shouldn't be locked in.</p>
<p>For clubs that track finances seriously, look for accrual, double-entry accounting and integration with software like QuickBooks, Xero, or Sage. Ask whether the platform can export transactions in a format your bookkeeper can actually use.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="track-record-development-and-support">Track Record, Development, and Support<a href="https://curling.io/blog/what-to-look-for-in-curling-club-management-software#track-record-development-and-support" class="hash-link" aria-label="Direct link to Track Record, Development, and Support" title="Direct link to Track Record, Development, and Support">​</a></h2>
<p>Who else is using the platform? A tool used by a handful of clubs is a different proposition than one trusted by national organizations and hundreds of clubs. Is the software still being actively improved? How often do updates ship? Some platforms haven't changed in years, and it shows.</p>
<p>Support turnaround matters too. When something breaks during league night or a bonspiel weekend, you need a response in hours, not weeks. Ask other clubs what their experience has been. Community adoption also means your curlers are more likely to already have an account, which makes registration smoother for everyone.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="who-owns-it">Who Owns It<a href="https://curling.io/blog/what-to-look-for-in-curling-club-management-software#who-owns-it" class="hash-link" aria-label="Direct link to Who Owns It" title="Direct link to Who Owns It">​</a></h2>
<p>It's worth looking past the product and asking who actually owns the company behind it. A platform built and run by people in the curling community is a different thing than one owned by an investment firm in another country. When a private equity group acquires a software company, the priorities tend to shift: prices go up, support gets outsourced, and development slows down or focuses on whatever makes the numbers look good for the next quarter. The curling world is small and mostly volunteer-driven. You want the people making decisions about your software to understand that.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="ai-ready">AI Ready<a href="https://curling.io/blog/what-to-look-for-in-curling-club-management-software#ai-ready" class="hash-link" aria-label="Direct link to AI Ready" title="Direct link to AI Ready">​</a></h2>
<p>Are you already using chatbots like Claude.ai to help with day-to-day tasks? A lot of club managers are. As these tools get more capable, they'll be able to do more than just answer questions. Look for a platform that's ready for this. Setting up a draw, checking registration numbers, pulling a financial report: these are things your AI assistant should eventually be able to do for you, if the software supports it. Your members could benefit too, using their own chatbots to register for events or set up reminders for upcoming matches.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="ask-the-right-questions">Ask the Right Questions<a href="https://curling.io/blog/what-to-look-for-in-curling-club-management-software#ask-the-right-questions" class="hash-link" aria-label="Direct link to Ask the Right Questions" title="Direct link to Ask the Right Questions">​</a></h2>
<p>Full disclosure: we built <a href="https://curling.io/" target="_blank" rel="noopener noreferrer">Curling IO</a> to check all of these boxes. But regardless of what you choose, these are the questions worth asking.</p>]]></content>
        <author>
            <name>Chris</name>
        </author>
        <category label="club-management" term="club-management"/>
        <category label="buyers-guide" term="buyers-guide"/>
        <category label="choosing-software" term="choosing-software"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Why We Chose SQLite]]></title>
        <id>https://curling.io/blog/why-we-chose-sqlite</id>
        <link href="https://curling.io/blog/why-we-chose-sqlite"/>
        <updated>2026-02-27T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[This is a technical architecture note about the database decision for Curling]]></summary>
        <content type="html"><![CDATA[<div class="theme-admonition theme-admonition-note admonition_xJq3 alert alert--secondary"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>About this post</div><div class="admonitionContent_BuS1"><p>This is a technical architecture note about the database decision for Curling
IO v3. It is written for software engineers and operators, and goes deeper into
SQLite, Litestream, database isolation, hosting, and recovery tradeoffs than
our usual product posts.</p></div></div>
<p>Version 3 should be cheaper to operate, easier to restore, and still fast during peak registration and live scoring. That pushed us toward a choice we didn't expect: SQLite.</p>
<p>We assumed PostgreSQL at first. After a decade running Postgres in production, we knew the tooling, the failure modes, and the operational playbook. Then we compared self-hosting Postgres with Litestream-backed SQLite and changed our minds.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-version-2-runs-on">What Version 2 Runs On<a href="https://curling.io/blog/why-we-chose-sqlite#what-version-2-runs-on" class="hash-link" aria-label="Direct link to What Version 2 Runs On" title="Direct link to What Version 2 Runs On">​</a></h2>
<p>Curling IO Version 2 runs on Rails backed by PostgreSQL on <a href="https://www.crunchydata.com/products/crunchy-bridge" target="_blank" rel="noopener noreferrer">Crunchy Bridge</a>, a managed Postgres service that has worked well for us. Crunchy Data handles backups, failover, and tuning. We don't think about <code>shared_buffers</code> or <code>autovacuum</code> settings. We don't run pgBackRest or schedule base backups. (Crunchy Data was <a href="https://www.crunchydata.com/blog/crunchy-data-joins-snowflake" target="_blank" rel="noopener noreferrer">acquired by Snowflake</a> in 2025, which adds another reason to reduce our dependency on third-party managed services.)</p>
<p>The trade-off is cost, lock-in, and jurisdiction. Crunchy Bridge is AWS-only, so the app servers have to live on AWS too. That's the full stack dependent on a single US cloud provider. We're a 100% Canadian company and we'd rather keep our infrastructure closer to home, especially given the current political uncertainty south of the border.</p>
<p>For Version 3, we're moving to OVH, a French-owned provider with data centers in Quebec and Ontario. All data stays in Canada. Crunchy Bridge doesn't run on OVH, so we can't bring it along. Self-hosting Postgres on OVH would mean taking on everything Crunchy handles today: connection pooling, server tuning (<code>shared_buffers</code>, <code>effective_cache_size</code>, <code>work_mem</code>, <code>max_connections</code>), autovacuum monitoring, and the full backup story (pgBackRest, WAL archiving, scheduled base backups, tested restore procedures, monitoring backup freshness). That's a lot to take on for a small organization.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-architecture-database-per-sport">The Architecture: Database Per Sport<a href="https://curling.io/blog/why-we-chose-sqlite#the-architecture-database-per-sport" class="hash-link" aria-label="Direct link to The Architecture: Database Per Sport" title="Direct link to The Architecture: Database Per Sport">​</a></h2>
<p>Version 3 uses a database-per-sport architecture. Each sport gets its own SQLite file with an identical schema:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">db/</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">├── curling.db          # All curling club data</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">├── pickleball.db       # All pickleball club data</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">└── shared.db           # Cross-sport reference data, jobs, email suppressions</span><br></span></code></pre></div></div>
<p>Each sport database is multi-tenant. Every table with tenant data includes an <code>org_id</code> column, and every query filters by it. Adding a new sport means creating a new database file with the same schema. No cross-sport queries, no shared-database contention.</p>
<p><code>shared.db</code> holds things that span sports: Canadian tax jurisdictions (17 rows covering GST, HST, PST, and QST rates by province), the <a href="https://curling.io/blog/background-jobs-without-the-baggage">background job queue</a>, and email suppression lists from Postmark webhooks.</p>
<p>At startup, the server opens one connection per database and holds it for the lifetime of the app:</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>Connection setup</summary><div><div class="collapsibleContent_i85q"><div class="language-gleam codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-gleam codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">pub fn open(path: String) -&gt; Result(sqlight.Connection, sqlight.Error) {</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  use conn &lt;- result.try(sqlight.open(path))</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  use _ &lt;- result.try(sqlight.exec("PRAGMA journal_mode=WAL;", conn))</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  use _ &lt;- result.try(sqlight.exec("PRAGMA busy_timeout=5000;", conn))</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  use _ &lt;- result.try(sqlight.exec("PRAGMA foreign_keys=ON;", conn))</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  Ok(conn)</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">}</span><br></span></code></pre></div></div></div></div></details>
<p>Three PRAGMAs on every connection: WAL mode for concurrent reads, a 5-second busy timeout so writes queue instead of failing immediately, and foreign key enforcement (which SQLite disables by default).</p>
<p>One connection per database. No connection pool. No pool configuration. Each incoming HTTP request gets the sport-specific connection (resolved from the hostname) plus the shared connection.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="why-in-process-sqlite">Why In-Process SQLite<a href="https://curling.io/blog/why-we-chose-sqlite#why-in-process-sqlite" class="hash-link" aria-label="Direct link to Why In-Process SQLite" title="Direct link to Why In-Process SQLite">​</a></h2>
<p>SQLite runs inside the application process. There's no socket, no TCP round-trip, no serialization between the app and the database. A query is a function call.</p>
<p>Our workload is primarily simple indexed lookups: single-row fetches by primary key, filtered lists by <code>org_id</code>, a handful of line items joined to an order. SQLite handles tens of thousands of write transactions per second, far beyond what 1,000+ curling clubs will generate. Even during peak registration or when a provincial championship is posting live scores, the load profile is overwhelmingly reads with occasional write bursts.</p>
<p>The cost follows from the architecture. No separate database server means no database hosting bill. The entire v3 stack runs on a single server for less than what managed Postgres alone costs on AWS.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="wal-mode-and-concurrency">WAL Mode and Concurrency<a href="https://curling.io/blog/why-we-chose-sqlite#wal-mode-and-concurrency" class="hash-link" aria-label="Direct link to WAL Mode and Concurrency" title="Direct link to WAL Mode and Concurrency">​</a></h2>
<p>WAL (Write-Ahead Logging) is what makes SQLite viable for a concurrent web application. Without it, any write locks the entire database for both readers and writers. With WAL, readers proceed concurrently with writes. On the BEAM, where hundreds of lightweight processes might query simultaneously during a traffic spike, this is essential.</p>
<p>Writes are still serialized. One writer at a time, coordinated by a mutex in the SQLite library. If the write lock can't be acquired within the 5-second busy timeout, SQLite returns <code>SQLITE_BUSY</code>. With our read-heavy workload, write contention hasn't been a practical concern. This is why we use a <a href="https://curling.io/blog/background-jobs-without-the-baggage">separate database for background jobs</a>. Job processing writes to <code>shared.db</code>, not the sport databases, so it never contends with registration queries. With SQLite's single-writer model, separating write-heavy workloads into different files is a real architectural consideration, not just organization.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="litestream-the-tipping-point">Litestream: The Tipping Point<a href="https://curling.io/blog/why-we-chose-sqlite#litestream-the-tipping-point" class="hash-link" aria-label="Direct link to Litestream: The Tipping Point" title="Direct link to Litestream: The Tipping Point">​</a></h2>
<p>This is what tipped the scales.</p>
<p>SQLite is a file. <a href="https://litestream.io/" target="_blank" rel="noopener noreferrer">Litestream</a> watches that file and continuously replicates its WAL frames to S3-compatible object storage. For us, that's OVH Object Storage in two different geographic locations in Canada. Changes sync every 10 seconds to both.</p>
<p>The configuration is one YAML file:</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>Litestream restore details</summary><div><div class="collapsibleContent_i85q"><div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token key atrule">dbs</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(199, 146, 234)">-</span><span class="token plain"> </span><span class="token key atrule">path</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> /var/lib/curling</span><span class="token punctuation" style="color:rgb(199, 146, 234)">-</span><span class="token plain">io/databases/curling.db</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token key atrule">replicas</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">      </span><span class="token punctuation" style="color:rgb(199, 146, 234)">-</span><span class="token plain"> </span><span class="token key atrule">type</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> s3</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">        </span><span class="token key atrule">bucket</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> xxxxx</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">        </span><span class="token key atrule">endpoint</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> xxxxx</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">        </span><span class="token key atrule">sync-interval</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> 10s</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">      </span><span class="token punctuation" style="color:rgb(199, 146, 234)">-</span><span class="token plain"> </span><span class="token key atrule">type</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> s3</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">        </span><span class="token key atrule">bucket</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> xxxxx</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">        </span><span class="token key atrule">endpoint</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> xxxxx</span><br></span><span class="token-line" style="color:#bfc7d5"><span class="token plain">        </span><span class="token key atrule">sync-interval</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> 10s</span><br></span></code></pre></div></div><p>Recovery is one command:</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#bfc7d5"><span class="token plain">litestream restore -o /path/to/curling.db s3://xxxxx/curling.db</span><br></span></code></pre></div></div></div></div></details>
<p>That produces a ready-to-use database file. Copy it to the right path, start the app.</p>
<p>Now compare that to self-hosting PostgreSQL backup and recovery:</p>
<ol>
<li>Install pgBackRest</li>
<li>Configure a stanza, set <code>archive_mode</code> and <code>archive_command</code> in <code>postgresql.conf</code></li>
<li>Schedule base backups via cron</li>
<li>Monitor backup success, WAL retention, and disk usage</li>
<li>Periodically test restores (because an untested backup isn't a backup)</li>
<li>When disaster strikes: stop PostgreSQL, run <code>pgbackrest restore --type=time --target="..."</code>, wait for WAL replay, verify the recovery point, promote to primary</li>
</ol>
<p>PostgreSQL gives you something Litestream doesn't: exact transaction-level point-in-time recovery. You can roll back to any specific transaction. Litestream gives you "latest snapshot plus WAL frames," which means a worst-case recovery gap of about 10 seconds. For our use case that's more than sufficient.</p>
<p>The operational gap is where it gets decisive. Litestream is one binary, one config file, near-zero ongoing maintenance. PostgreSQL backup is a practice, something you rehearse, monitor, and maintain over the life of the project. Even with perfect tooling, you can't reduce "restore a running server's state" to "download a file and start the app." That gap is architectural. It won't close with better Postgres tooling because the complexity is inherent to backing up a running server process.</p>
<p>The simplicity ceiling for SQLite backup is fundamentally higher than what's possible for any client-server database.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-sqlite-tax">The SQLite Tax<a href="https://curling.io/blog/why-we-chose-sqlite#the-sqlite-tax" class="hash-link" aria-label="Direct link to The SQLite Tax" title="Direct link to The SQLite Tax">​</a></h2>
<p>Here's what we pay for that simplicity. Every item below is something PostgreSQL handles natively.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="no-real-types">No Real Types<a href="https://curling.io/blog/why-we-chose-sqlite#no-real-types" class="hash-link" aria-label="Direct link to No Real Types" title="Direct link to No Real Types">​</a></h3>
<p>SQLite doesn't have booleans, dates, or enums. Booleans are stored as INTEGER 0/1. Dates are Unix epoch integers. Enums are TEXT strings.</p>
<p>Every boolean parameter needs a <code>db.bool_to_int(value)</code> call. Every boolean decoder needs a <code>!= 0</code> check. All date formatting, timezone conversion, and comparison lives in application code. There's no <code>DATE_TRUNC</code>, no <code>INTERVAL</code>, no <code>AT TIME ZONE</code>.</p>
<p>We use <a href="https://www.sqlite.org/stricttables.html" target="_blank" rel="noopener noreferrer">STRICT tables</a> on everything, which enforces that values match declared column types (INTEGER, TEXT, REAL, BLOB). It's a free safety net for anything that bypasses the application layer, but it doesn't help with booleans, dates, or enums. Those stay as application-level concerns, but since Gleam and <a href="https://hexdocs.pm/parrot/" target="_blank" rel="noopener noreferrer">Parrot</a> are the only source of writes and types are enforced there, it's manageable.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="limited-alter-table">Limited ALTER TABLE<a href="https://curling.io/blog/why-we-chose-sqlite#limited-alter-table" class="hash-link" aria-label="Direct link to Limited ALTER TABLE" title="Direct link to Limited ALTER TABLE">​</a></h3>
<p>SQLite stores its schema as the original <code>CREATE TABLE</code> text, not as structured system catalogs like PostgreSQL. This means <code>ALTER TABLE</code> is minimal:</p>
<ul>
<li><code>ADD COLUMN</code>, <code>RENAME COLUMN</code>, <code>DROP COLUMN</code> all work</li>
<li>Changing a column's type, default, or <code>NOT NULL</code> does not</li>
<li>Adding or modifying <code>CHECK</code> or <code>FOREIGN KEY</code> constraints does not</li>
<li>Adding <code>UNIQUE</code> to an existing column does not</li>
</ul>
<p>Changing a column post-launch requires a four-step dance: add the new column, copy the data, drop the old column, rename. PostgreSQL handles this with a single <code>ALTER COLUMN</code>.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="no-check-constraints">No CHECK Constraints<a href="https://curling.io/blog/why-we-chose-sqlite#no-check-constraints" class="hash-link" aria-label="Direct link to No CHECK Constraints" title="Direct link to No CHECK Constraints">​</a></h3>
<p>We don't use CHECK constraints anywhere. This is a policy decision, and not one we're thrilled about.</p>
<p>CHECK constraints are embedded in the <code>CREATE TABLE</code> DDL string. Modifying one means a full table rebuild. Adding a new enum value shouldn't require that. So all validation lives in Gleam: union types for enums (with exhaustive pattern matching at compile time), validation functions for business logic, and <code>let assert</code> in decoders to crash fast if bad data gets in.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="write-serialization">Write Serialization<a href="https://curling.io/blog/why-we-chose-sqlite#write-serialization" class="hash-link" aria-label="Direct link to Write Serialization" title="Direct link to Write Serialization">​</a></h3>
<p>As covered above, writes are serialized. In practice this barely matters because there's no network round-trip and no protocol overhead. A write is a function call that completes in microseconds. The lock is held for such a short window that writers rarely wait for each other at all. But it does rule out horizontal scaling to multiple app servers writing to the same database.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="single-node-only">Single Node Only<a href="https://curling.io/blog/why-we-chose-sqlite#single-node-only" class="hash-link" aria-label="Direct link to Single Node Only" title="Direct link to Single Node Only">​</a></h3>
<p>SQLite is a file on local disk. Multiple app servers can't share it (NFS breaks SQLite's file locking and leads to corruption). We're locked into a single-server architecture. On a BEAM runtime that handles high concurrency on one node, this is fine for well over 100,000 clubs. It's a hard ceiling if we ever need more, and if we do, that's a really good problem to have.</p>
<p>There are projects that add distributed replication to SQLite (LiteFS, rqlite, dqlite), but they add significant complexity: FUSE filesystems, consensus protocols, additional infrastructure. At that point you're fighting to make SQLite behave like Postgres, and you should probably just use Postgres.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="nif-risk">NIF Risk<a href="https://curling.io/blog/why-we-chose-sqlite#nif-risk" class="hash-link" aria-label="Direct link to NIF Risk" title="Direct link to NIF Risk">​</a></h3>
<p>Our SQLite driver (<a href="https://github.com/mmzeeman/esqlite" target="_blank" rel="noopener noreferrer">esqlite</a>) is an Erlang NIF - compiled C code that runs outside the BEAM scheduler's control. A long-running query blocks a scheduler thread. A segfault takes down the entire VM.</p>
<p>In practice, SQLite's C library is near-bulletproof, and our queries return in microseconds. We mitigate the risk by keeping all queries simple (indexed lookups, no full table scans) and pushing anything heavy to background jobs. But the risk surface exists.</p>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="sql-dialect-gaps">SQL Dialect Gaps<a href="https://curling.io/blog/why-we-chose-sqlite#sql-dialect-gaps" class="hash-link" aria-label="Direct link to SQL Dialect Gaps" title="Direct link to SQL Dialect Gaps">​</a></h3>
<p>Small things that add friction:</p>
<ul>
<li><code>DELETE FROM t WHERE ... LIMIT 1</code> doesn't work. You need a subquery: <code>DELETE FROM t WHERE id = (SELECT id FROM t WHERE ... LIMIT 1)</code>.</li>
<li>No <code>LISTEN/NOTIFY</code> for real-time push from the database. Change notification goes through application-level pub/sub.</li>
<li><code>NULL</code> comparisons need <code>IS ?</code> instead of <code>= ?</code> when the parameter can be NULL.</li>
</ul>
<h3 class="anchor anchorWithStickyNavbar_LWe7" id="why-we-accept-it">Why We Accept It<a href="https://curling.io/blog/why-we-chose-sqlite#why-we-accept-it" class="hash-link" aria-label="Direct link to Why We Accept It" title="Direct link to Why We Accept It">​</a></h3>
<p>Every tax above is an application-level workaround. Type conversions, date formatting, schema evolution dances, validation in Gleam instead of CHECK constraints. None of them change the architecture. They're annoyances, not obstacles.</p>
<p>The things that would actually force a migration (write contention, multi-node scaling, database size) aren't happening at 1,000 clubs with a read-heavy workload on the BEAM.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="when-wed-switch-to-postgresql">When We'd Switch to PostgreSQL<a href="https://curling.io/blog/why-we-chose-sqlite#when-wed-switch-to-postgresql" class="hash-link" aria-label="Direct link to When We'd Switch to PostgreSQL" title="Direct link to When We'd Switch to PostgreSQL">​</a></h2>
<p>Honestly, we don't see it happening anytime soon. Most of the limits people worry about with SQLite (database size, write throughput, busy errors) can be pushed further by upgrading the server. Vertical scaling is cheap and simple, and the BEAM makes excellent use of whatever hardware you give it.</p>
<p>The only real trigger would be needing multiple application servers, and that would mean we've seriously outgrown our predictions or miscalculated. If that happens, the migration path is mechanical, not architectural. Swap <code>sqlight</code> for <code>gleam_pgo</code>. Adjust SQL dialect across all query files: <code>?</code> becomes <code>$1, $2, ...</code>, <code>INTEGER PRIMARY KEY</code> becomes <code>GENERATED ALWAYS AS IDENTITY</code>, booleans become real booleans, remove the <code>DELETE</code> subquery workarounds. Separate <code>.db</code> files map to PostgreSQL schemas within a single database. Business logic and request handlers stay the same.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="whats-next">What's Next<a href="https://curling.io/blog/why-we-chose-sqlite#whats-next" class="hash-link" aria-label="Direct link to What's Next" title="Direct link to What's Next">​</a></h2>
<p>If SQLite hits a wall, we switch to Postgres. The exit strategy is defined and the migration is a dialect swap, not a rewrite.</p>
<p>But so far, Litestream plus in-process SQLite has been one of the best decisions in the v3 rewrite. And there's one more benefit worth its own post: because SQLite runs in-process, every test gets its own in-memory database. No shared state, no cleanup, no flaky tests. That's next.</p>
<hr>
<p><em>This is Part 5 of the Curling IO Foundation series. Next up: <a href="https://curling.io/blog/sqlite-test-isolation">Test Isolation for Free with SQLite</a>.</em></p>]]></content>
        <author>
            <name>Dave Rapin</name>
        </author>
        <category label="foundation" term="foundation"/>
        <category label="sqlite" term="sqlite"/>
        <category label="architecture" term="architecture"/>
        <category label="gleam" term="gleam"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Built for High-Traffic Curling Competitions]]></title>
        <id>https://curling.io/blog/built-for-high-traffic-curling-competitions</id>
        <link href="https://curling.io/blog/built-for-high-traffic-curling-competitions"/>
        <updated>2026-02-26T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Curling IO handles registration, scheduling, live scoring, and results for major curling competitions. The same platform is available to any curling club.]]></summary>
        <content type="html"><![CDATA[<p>Major curling competitions put very different demands on software than a typical league night. Registration can involve qualification rules and team assembly, draw schedules need to cover multiple pools and playoff formats, and live scoring traffic can spike as thousands of fans follow the same games.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="one-workflow-from-registration-to-results">One Workflow From Registration to Results<a href="https://curling.io/blog/built-for-high-traffic-curling-competitions#one-workflow-from-registration-to-results" class="hash-link" aria-label="Direct link to One Workflow From Registration to Results" title="Direct link to One Workflow From Registration to Results">​</a></h2>
<p>Curling IO handles the full competition workflow:</p>
<ul>
<li>Online registration and team assembly</li>
<li>Draw scheduling across pools, sheets, and time slots</li>
<li>Round robins, page playoffs, brackets, and mixed doubles formats</li>
<li>Live end-by-end and shot-by-shot scoring</li>
<li>Automatic standings and bracket advancement</li>
<li>Embeddable results and scoreboards</li>
</ul>
<p>Organizers manage the event in one place instead of moving registrations, schedules, scores, and results between separate systems.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="built-for-traffic-spikes">Built for Traffic Spikes<a href="https://curling.io/blog/built-for-high-traffic-curling-competitions#built-for-traffic-spikes" class="hash-link" aria-label="Direct link to Built for Traffic Spikes" title="Direct link to Built for Traffic Spikes">​</a></h2>
<p>Competition traffic is bursty. A draw can finish on several sheets within minutes, while fans refresh scores, standings, and brackets at the same time. Curling IO caches and distributes that live data so event administrators can keep scoring without the public scoreboard slowing them down.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-same-platform-your-club-can-use">The Same Platform Your Club Can Use<a href="https://curling.io/blog/built-for-high-traffic-curling-competitions#the-same-platform-your-club-can-use" class="hash-link" aria-label="Direct link to The Same Platform Your Club Can Use" title="Direct link to The Same Platform Your Club Can Use">​</a></h2>
<p>The draw scheduling, scoring, and bracket tools used for major competitions are the same tools available for a Tuesday night league or weekend bonspiel. A four-sheet club gets the same competition engine used at the national level.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="built-for-curling-not-adapted-for-it">Built for Curling, Not Adapted for It<a href="https://curling.io/blog/built-for-high-traffic-curling-competitions#built-for-curling-not-adapted-for-it" class="hash-link" aria-label="Direct link to Built for Curling, Not Adapted for It" title="Direct link to Built for Curling, Not Adapted for It">​</a></h2>
<p>Curling has concepts that generic sports software doesn't account for: ends, hammer, last stone advantage, round robins with multiple pools, page playoffs, mixed doubles scoring. Even draw scheduling has curling-specific nuances, like minimizing how often a team plays on the same sheet. We even published a free draw schedule tool at <a href="https://curlingschedules.com/" target="_blank" rel="noopener noreferrer">curlingschedules.com</a> that anyone can use.</p>
<p>Curling IO was built around these concepts. Every provincial and territorial curling association in Canada uses it for registration, competition management, or both.</p>
<h2 class="anchor anchorWithStickyNavbar_LWe7" id="see-it-in-action">See It in Action<a href="https://curling.io/blog/built-for-high-traffic-curling-competitions#see-it-in-action" class="hash-link" aria-label="Direct link to See It in Action" title="Direct link to See It in Action">​</a></h2>
<p>If your club wants the same tools that power Canadian curling at the national level, <a href="https://curling.io/docs/getting-started/curling-club-managers">get started here</a>. There are no setup fees or monthly fees. Check out our <a href="https://curling.io/docs/getting-started/pricing">pricing page</a> for a full feature breakdown and cost calculator.</p>]]></content>
        <author>
            <name>Chris</name>
        </author>
        <category label="competition-management" term="competition-management"/>
        <category label="live-scoring" term="live-scoring"/>
    </entry>
</feed>