<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Teramot – Teramot Docs &amp; Engineering</title><link>https://docs.teramot.com/</link><description>Recent content in Teramot Docs &amp; Engineering on Teramot</description><generator>Hugo -- gohugo.io</generator><language>en</language><atom:link href="https://docs.teramot.com/index.xml" rel="self" type="application/rss+xml"/><item><title>Eight Rows, Not Twelve Million: The Cache Behind Teramot Beacon</title><link>https://docs.teramot.com/blog/eight-rows-not-twelve-million/</link><pubDate>Wed, 16 Sep 2026 00:00:00 +0000</pubDate><guid>https://docs.teramot.com/blog/eight-rows-not-twelve-million/</guid><description>
&lt;div class="tm-intro"&gt;
Teramot Beacon answers business questions against live data by pulling only the slice a question needs into an embedded DuckDB, inside the customer&amp;rsquo;s own network, on any cloud or a laptop. The interesting engineering is not the pull. It is what happens on the second question: a slice registry with a strict containment law, column widening instead of re-pulls, and a cache that is invalidated by the version of the source, never by a timer.
&lt;/div&gt;
&lt;p&gt;Some of our customers cannot let their data leave the building. Some run on AWS, some on GCP, some on Azure, one on Oracle Cloud, and at least one on a server with a sticker on it. All of them wanted to ask a question about their business in plain Spanish and get a correct number back. That need (&lt;strong&gt;on-premise and multicloud&lt;/strong&gt;) is where Teramot Beacon comes from. This is the story of the part I find most fun: the cache.&lt;/p&gt;
&lt;div class="tm-stats tm-stats-4"&gt;
&lt;div class="tm-stat"&gt;
&lt;p class="tm-stat-value"&gt;Dozens&lt;/p&gt;
&lt;p class="tm-stat-label"&gt;Rows moved&lt;/p&gt;&lt;div class="tm-stat-note"&gt;To answer aggregate questions over fact tables of millions of rows, on real customer data, exact to the cent.&lt;/div&gt;
&lt;/div&gt;
&lt;div class="tm-stat"&gt;
&lt;p class="tm-stat-value"&gt;1&lt;/p&gt;
&lt;p class="tm-stat-label"&gt;Container&lt;/p&gt;&lt;div class="tm-stat-note"&gt;The whole node ships as one image with DuckDB inside. State is a directory. Any cloud, or a laptop.&lt;/div&gt;
&lt;/div&gt;
&lt;div class="tm-stat"&gt;
&lt;p class="tm-stat-value"&gt;25&lt;/p&gt;
&lt;p class="tm-stat-label"&gt;Source connectors&lt;/p&gt;&lt;div class="tm-stat-note"&gt;From Postgres and SAP HANA to Salesforce, Google Sheets, Iceberg on S3 and a CSV somebody emailed.&lt;/div&gt;
&lt;/div&gt;
&lt;div class="tm-stat"&gt;
&lt;p class="tm-stat-value"&gt;53&lt;/p&gt;
&lt;p class="tm-stat-label"&gt;Days to 1.0&lt;/p&gt;&lt;div class="tm-stat-note"&gt;First commit 5 July 2026. Version 1.0.0 tagged 27 August 2026. Three engineers.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2 data-hextra-search-id="1-the-two-ways-of-teramot"&gt;1. The two ways of Teramot&lt;span class="hx:absolute hx:-mt-20" id="1-the-two-ways-of-teramot"&gt;&lt;/span&gt;
&lt;a href="#1-the-two-ways-of-teramot" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Teramot&amp;rsquo;s promise has always been one sentence: a correct number about your business, without months of data engineering first. There are two shapes to deliver it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Teramot way&lt;/strong&gt; builds the company&amp;rsquo;s curated data infrastructure and keeps it alive: sources are ingested, cleaned, modelled and served, and every question, dashboard and agent works on top of that map. It is the shape you want when you want to build your warehouse.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Teramot Beacon way&lt;/strong&gt; is for the customers whose data cannot leave their perimeter, who run on whatever cloud they already have. A single node runs inside their network, reads the source&amp;rsquo;s &lt;em&gt;metadata&lt;/em&gt; to learn what is there, lets an agent decide which tables, columns and filters the question needs, pulls exactly that into an embedded DuckDB, and writes SQL against the local slice. The answer travels back with its SQL, its row counts per table and a trace. The source only ever sees &lt;code&gt;SELECT&lt;/code&gt; statements with bind parameters, and the rows that leave it are the ones the answer is made of.&lt;/p&gt;
&lt;h2 data-hextra-search-id="2-the-galaxy-brain-ladder"&gt;2. The galaxy-brain ladder&lt;span class="hx:absolute hx:-mt-20" id="2-the-galaxy-brain-ladder"&gt;&lt;/span&gt;
&lt;a href="#2-the-galaxy-brain-ladder" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Every engineer who hears &amp;ldquo;pull only what you need&amp;rdquo; climbs the same four rungs. We climbed them in about nine days.&lt;/p&gt;
&lt;figure class="tm-figure"&gt;
&lt;div class="tm-figure-body"&gt;&lt;svg id="fig-ladder" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 400" width="100%" role="img" aria-label="Four escalating ideas: copy the whole database; copy the tables you need; copy the columns you need; let the source do the GROUP BY and move eight rows." style="font-family:inherit;font-size:14px;color:inherit"&gt;
&lt;defs&gt;
&lt;style&gt;#fig-ladder .row{fill:var(--tm-surface);stroke:var(--tm-rule-strong);stroke-width:1.5}
#fig-ladder .t{fill:currentColor;font-weight:700;font-size:15px}
#fig-ladder .m{fill:var(--tm-muted);font-size:13px}
#fig-ladder .brain{fill:none;stroke:var(--tm-blue);stroke-width:2.5}
&lt;/style&gt;
&lt;radialGradient id="glow"&gt;&lt;stop offset="0" stop-color="var(--tm-blue)" stop-opacity=".55"/&gt;&lt;stop offset="1" stop-color="var(--tm-blue)" stop-opacity="0"/&gt;&lt;/radialGradient&gt;
&lt;/defs&gt;
&lt;g transform="translate(0,4)"&gt;
&lt;rect class="row" width="720" height="86" rx="12"/&gt;
&lt;circle class="brain" cx="44" cy="43" r="9"/&gt;
&lt;text class="t" x="90" y="36"&gt;Replicate the sources into a warehouse, then ask.&lt;/text&gt;
&lt;text class="m" x="90" y="60"&gt;Solid and governed when you can host it. Not an option when the data cannot leave.&lt;/text&gt;
&lt;/g&gt;
&lt;g transform="translate(0,100)"&gt;
&lt;rect class="row" width="720" height="86" rx="12"/&gt;
&lt;circle class="brain" cx="44" cy="43" r="15"/&gt;
&lt;text class="t" x="90" y="36"&gt;Copy only the tables the question needs.&lt;/text&gt;
&lt;text class="m" x="90" y="60"&gt;Better. But a "small" fact table is still millions of rows and dozens of columns wide.&lt;/text&gt;
&lt;/g&gt;
&lt;g transform="translate(0,196)"&gt;
&lt;rect class="row" width="720" height="86" rx="12"/&gt;
&lt;circle cx="44" cy="43" r="32" fill="url(#glow)"/&gt;&lt;circle class="brain" cx="44" cy="43" r="21"/&gt;
&lt;text class="t" x="90" y="36"&gt;Copy only the columns, and only the rows that pass the filter.&lt;/text&gt;
&lt;text class="m" x="90" y="60"&gt;Projection plus a pushed-down WHERE. Median slice width in production today: 4 columns.&lt;/text&gt;
&lt;/g&gt;
&lt;g transform="translate(0,292)"&gt;
&lt;rect class="row" width="720" height="104" rx="12" stroke="var(--tm-blue)" stroke-width="2.5"/&gt;
&lt;circle cx="44" cy="52" r="46" fill="url(#glow)"/&gt;&lt;circle class="brain" cx="44" cy="52" r="27"/&gt;
&lt;text class="t" x="90" y="34"&gt;Let the source run the GROUP BY. Move the eight rows of the answer.&lt;/text&gt;
&lt;text class="t" x="90" y="56"&gt;Then cache them by source version.&lt;/text&gt;
&lt;text class="m" x="90" y="82"&gt;Aggregation pushdown plus a registry that reuses what it already pulled. This is the article.&lt;/text&gt;
&lt;/g&gt;
&lt;/svg&gt;
&lt;/div&gt;&lt;figcaption&gt;The four rungs. The node lives on the bottom one, and this article is mostly about keeping it there on the second question.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;The last rung matters more than it looks. For &amp;ldquo;revenue by region for 2025&amp;rdquo; the node does not pull the 2025 rows and group them locally. It asks the source to run the &lt;code&gt;GROUP BY&lt;/code&gt; and ships back the groups. In our first three-customer test on production data, aggregate questions moved &lt;strong&gt;a few dozen rows at most&lt;/strong&gt; against fact tables of &lt;strong&gt;millions&lt;/strong&gt;. One of those sources was a multi-gigabyte CSV, and the answer matched the customer&amp;rsquo;s own number to the cent.&lt;/p&gt;
&lt;div class="tm-chart"&gt;
&lt;canvas id="chart-e9f3b029694bf453fe5380fbc9d7d64c"&gt;&lt;/canvas&gt;
&lt;script&gt;
window.addEventListener("DOMContentLoaded", function () {
new Chart(document.getElementById("chart-e9f3b029694bf453fe5380fbc9d7d64c"), {
type: 'bar',
data: {
labels: ['Sales fact, CSV files', 'Sales fact, Iceberg tables', 'Demand table, ERP', 'Cohort question, public benchmark'],
datasets: [
{ label: 'Rows in the source table (order of magnitude)', data: [10000000, 10000000, 10000000, 10000], backgroundColor: 'rgba(148, 163, 184, 0.7)', borderWidth: 0, borderRadius: 4 },
{ label: 'Rows that crossed the wire (order of magnitude)', data: [10, 50, 70, 400], backgroundColor: 'rgba(37, 61, 229, 0.9)', borderWidth: 0, borderRadius: 4 }
]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom' },
title: { display: true, text: 'Rows in the source vs. rows moved to the node (log scale, rounded)' },
tooltip: { callbacks: { label: (c) =&gt; c.dataset.label + ': ' + c.parsed.y.toLocaleString('en-US') } }
},
scales: { y: { type: 'logarithmic', title: { display: true, text: 'rows (log)' } } }
}
});
});
&lt;/script&gt;
&lt;/div&gt;
&lt;p&gt;The funnel that gets there has eight stages. Two carry this story: &lt;strong&gt;decide&lt;/strong&gt;, where the cache lives, and &lt;strong&gt;estimate&lt;/strong&gt;, which counts the rows a filter would return &lt;em&gt;before&lt;/em&gt; pulling them, so the node can refuse, tighten, or push the aggregation to the source.&lt;/p&gt;
&lt;figure class="tm-figure"&gt;
&lt;div class="tm-figure-body"&gt;&lt;svg id="fig-funnel" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 420" width="100%" role="img" aria-label="The eight-stage funnel: discover, narrow, select, decide, estimate, pull, model, execute" style="font-family:inherit;font-size:14px;color:inherit"&gt;
&lt;defs&gt;
&lt;style&gt;#fig-funnel .stage{fill:var(--tm-surface);stroke:var(--tm-rule-strong);stroke-width:1.5}
#fig-funnel .hot{fill:var(--tm-blue);stroke:var(--tm-blue)}
#fig-funnel .lbl{fill:currentColor;font-weight:700;font-size:15px}
#fig-funnel .sub{fill:var(--tm-muted);font-size:12.5px}
#fig-funnel .rows{fill:var(--tm-muted);font-size:13px;font-style:italic;text-anchor:end}
#fig-funnel .w{fill:#fff}.ws{fill:#dbe3ff}
#fig-funnel .arrow{stroke:var(--tm-muted);stroke-width:1.5;fill:none;marker-end:url(#ah)}
&lt;/style&gt;
&lt;marker id="ah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"&gt;&lt;path d="M0 0L10 5L0 10z" fill="var(--tm-muted)"/&gt;&lt;/marker&gt;
&lt;/defs&gt;
&lt;!-- two rows of four --&gt;
&lt;g transform="translate(0,10)"&gt;
&lt;g transform="translate(0,0)"&gt;&lt;rect class="stage" width="165" height="64" rx="12"/&gt;&lt;text class="lbl" x="14" y="27"&gt;1 · discover&lt;/text&gt;&lt;text class="sub" x="14" y="48"&gt;metadata only, no rows&lt;/text&gt;&lt;/g&gt;
&lt;g transform="translate(185,0)"&gt;&lt;rect class="stage" width="165" height="64" rx="12"/&gt;&lt;text class="lbl" x="14" y="27"&gt;2 · narrow&lt;/text&gt;&lt;text class="sub" x="14" y="48"&gt;embeddings, top-K tables&lt;/text&gt;&lt;/g&gt;
&lt;g transform="translate(370,0)"&gt;&lt;rect class="stage" width="165" height="64" rx="12"/&gt;&lt;text class="lbl" x="14" y="27"&gt;3 · select&lt;/text&gt;&lt;text class="sub" x="14" y="48"&gt;agent picks tables + filters&lt;/text&gt;&lt;/g&gt;
&lt;g transform="translate(555,0)"&gt;&lt;rect class="stage hot" width="165" height="64" rx="12"/&gt;&lt;text class="lbl w" x="14" y="27"&gt;4 · decide&lt;/text&gt;&lt;text class="sub ws" x="14" y="48"&gt;cache: hit, widen or miss&lt;/text&gt;&lt;/g&gt;
&lt;path class="arrow" d="M167 32H183"/&gt;&lt;path class="arrow" d="M352 32H368"/&gt;&lt;path class="arrow" d="M537 32H553"/&gt;
&lt;/g&gt;
&lt;!-- connector down --&gt;
&lt;path class="arrow" d="M637 76 V 96 H 82 V 118"/&gt;
&lt;g transform="translate(0,120)"&gt;
&lt;g transform="translate(0,0)"&gt;&lt;rect class="stage" width="165" height="64" rx="12"/&gt;&lt;text class="lbl" x="14" y="27"&gt;5 · estimate&lt;/text&gt;&lt;text class="sub" x="14" y="48"&gt;COUNT(*) before pulling&lt;/text&gt;&lt;/g&gt;
&lt;g transform="translate(185,0)"&gt;&lt;rect class="stage hot" width="165" height="64" rx="12"/&gt;&lt;text class="lbl w" x="14" y="27"&gt;6 · pull&lt;/text&gt;&lt;text class="sub ws" x="14" y="48"&gt;the slice crosses the wire&lt;/text&gt;&lt;/g&gt;
&lt;g transform="translate(370,0)"&gt;&lt;rect class="stage" width="165" height="64" rx="12"/&gt;&lt;text class="lbl" x="14" y="27"&gt;7 · model&lt;/text&gt;&lt;text class="sub" x="14" y="48"&gt;agent writes SQL, locally&lt;/text&gt;&lt;/g&gt;
&lt;g transform="translate(555,0)"&gt;&lt;rect class="stage" width="165" height="64" rx="12"/&gt;&lt;text class="lbl" x="14" y="27"&gt;8 · execute&lt;/text&gt;&lt;text class="sub" x="14" y="48"&gt;answer, SQL, rows per table&lt;/text&gt;&lt;/g&gt;
&lt;path class="arrow" d="M167 32H183"/&gt;&lt;path class="arrow" d="M352 32H368"/&gt;&lt;path class="arrow" d="M537 32H553"/&gt;
&lt;/g&gt;
&lt;!-- what shrinks --&gt;
&lt;g transform="translate(0,222)"&gt;
&lt;text class="lbl" x="0" y="14" style="font-size:13px;fill:var(--tm-muted);font-weight:700;letter-spacing:.04em"&gt;WHAT SHRINKS AT EACH STEP&lt;/text&gt;
&lt;g transform="translate(0,30)" style="font-size:13.5px"&gt;
&lt;text class="rows" x="190" y="16"&gt;tens of thousands of tables&lt;/text&gt;&lt;text fill="currentColor" x="205" y="16"&gt;→ 50 candidates by name and description&lt;/text&gt;
&lt;text class="rows" x="190" y="42"&gt;50 candidates&lt;/text&gt;&lt;text fill="currentColor" x="205" y="42"&gt;→ at most 20 tables, a handful of columns each&lt;/text&gt;
&lt;text class="rows" x="190" y="68"&gt;20 tables&lt;/text&gt;&lt;text fill="currentColor" x="205" y="68"&gt;→ whatever the cache already holds is not pulled again&lt;/text&gt;
&lt;text class="rows" x="190" y="94"&gt;millions of rows&lt;/text&gt;&lt;text fill="currentColor" x="205" y="94"&gt;→ over budget? push the GROUP BY to the source&lt;/text&gt;
&lt;text class="rows" x="190" y="120" style="fill:var(--tm-blue);font-weight:700;font-style:normal"&gt;8 rows&lt;/text&gt;&lt;text fill="currentColor" x="205" y="120" font-weight="700"&gt;→ cross the wire. Everything else stayed home.&lt;/text&gt;
&lt;/g&gt;
&lt;/g&gt;
&lt;/svg&gt;
&lt;/div&gt;&lt;figcaption&gt;The eight stages of one question. Everything before pull is metadata and cache. Customer rows move in exactly one box.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 data-hextra-search-id="3-then-we-measured-and-felt-silly"&gt;3. Then we measured, and felt silly&lt;span class="hx:absolute hx:-mt-20" id="3-then-we-measured-and-felt-silly"&gt;&lt;/span&gt;
&lt;a href="#3-then-we-measured-and-felt-silly" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;The obvious optimisation target was the pull. Pulling is I/O, I/O is slow. Then we instrumented nine real runs of the same question against a customer&amp;rsquo;s data.&lt;/p&gt;
&lt;div class="tm-chart"&gt;
&lt;canvas id="chart-1446d81e4fc72a1b5e46dee400c4b808"&gt;&lt;/canvas&gt;
&lt;script&gt;
window.addEventListener("DOMContentLoaded", function () {
new Chart(document.getElementById("chart-1446d81e4fc72a1b5e46dee400c4b808"), {
type: 'bar',
data: {
labels: ['discover (catalog)', 'select (agent picks tables + columns)', 'pull (rows move)', 'model (agent writes + checks SQL)'],
datasets: [{
label: 'median seconds',
data: [30, 41, 30, 146],
backgroundColor: ['rgba(148, 163, 184, 0.7)', 'rgba(148, 163, 184, 0.7)', 'rgba(37, 61, 229, 0.9)', 'rgba(235, 104, 52, 0.9)'],
borderWidth: 0,
borderRadius: 4
}]
},
options: {
indexAxis: 'y',
responsive: true,
plugins: { legend: { display: false }, title: { display: true, text: 'Where a cold question spends its time (median of nine runs, seconds)' } },
scales: { x: { title: { display: true, text: 'seconds' } } }
}
});
});
&lt;/script&gt;
&lt;/div&gt;
&lt;p&gt;The pull was &lt;strong&gt;8 percent&lt;/strong&gt; of the clock. &lt;strong&gt;78 percent&lt;/strong&gt; was a language model writing tokens at a steady 123 per second, and most of those tokens were the model thinking, not the final SQL. Making the pull twice as fast would have saved fifteen seconds out of four minutes. The only way to make a repeated question dramatically faster is to &lt;strong&gt;not call the model at all&lt;/strong&gt;. So the cache had to remember more than rows. It had to remember the plan.&lt;/p&gt;
&lt;h2 data-hextra-search-id="4-a-cache-with-a-legal-department"&gt;4. A cache with a legal department&lt;span class="hx:absolute hx:-mt-20" id="4-a-cache-with-a-legal-department"&gt;&lt;/span&gt;
&lt;a href="#4-a-cache-with-a-legal-department" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;The cache is a &lt;strong&gt;slice registry&lt;/strong&gt;. Every pull is described by what it asked for: which tables, which columns, which filter, which aggregation. A new question is not hashed and looked up. It is checked for &lt;strong&gt;containment&lt;/strong&gt;: does something we already hold cover what this question needs? And the law is strict.&lt;/p&gt;
&lt;figure class="tm-figure"&gt;
&lt;div class="tm-figure-body"&gt;&lt;svg id="fig-containment" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 560" width="100%" role="img" aria-label="Containment law: a cached slice serves a request only if the requested columns are a subset and the predicate is identical or the slice is unfiltered; a missing column widens; a different filter misses." style="font-family:inherit;font-size:14px;color:inherit"&gt;
&lt;defs&gt;
&lt;style&gt;#fig-containment .card{fill:var(--tm-surface);stroke:var(--tm-rule-strong);stroke-width:1.5}
#fig-containment .t{fill:currentColor}
#fig-containment .m{fill:var(--tm-muted);font-size:13px}
#fig-containment .col{fill:var(--tm-paper);stroke:var(--tm-rule-strong);stroke-width:1}
#fig-containment .colHot{fill:var(--tm-blue);stroke:var(--tm-blue)}
#fig-containment .colNew{fill:none;stroke:var(--tm-blue);stroke-width:1.5;stroke-dasharray:4 3}
#fig-containment .chip{font-size:12.5px;text-anchor:middle}
#fig-containment .verdict{font-weight:800;font-size:16px}
&lt;/style&gt;
&lt;/defs&gt;
&lt;!-- cached slice --&gt;
&lt;g transform="translate(0,0)"&gt;
&lt;rect class="card" width="720" height="104" rx="12"/&gt;
&lt;text class="t" x="18" y="30" font-weight="800" font-size="15"&gt;What the registry holds&lt;/text&gt;
&lt;text class="m" x="18" y="54"&gt;table &lt;tspan font-weight="700" fill="currentColor"&gt;orders&lt;/tspan&gt; · filter &lt;tspan font-weight="700" fill="currentColor"&gt;year = 2025&lt;/tspan&gt; · columns:&lt;/text&gt;
&lt;g transform="translate(18,66)"&gt;
&lt;rect class="col" width="110" height="28" rx="6"/&gt;&lt;text class="t chip" x="55" y="19"&gt;customer_id&lt;/text&gt;
&lt;rect class="col" x="120" width="90" height="28" rx="6"/&gt;&lt;text class="t chip" x="165" y="19"&gt;amount&lt;/text&gt;
&lt;rect class="col" x="220" width="90" height="28" rx="6"/&gt;&lt;text class="t chip" x="265" y="19"&gt;region&lt;/text&gt;
&lt;rect class="col" x="320" width="110" height="28" rx="6"/&gt;&lt;text class="t chip" x="375" y="19"&gt;order_date&lt;/text&gt;
&lt;/g&gt;
&lt;/g&gt;
&lt;!-- A --&gt;
&lt;g transform="translate(0,120)"&gt;
&lt;rect class="card" width="720" height="130" rx="12"/&gt;
&lt;text class="t" x="18" y="30" font-weight="700" font-size="15"&gt;Request A · year = 2025 · customer_id, amount&lt;/text&gt;
&lt;g transform="translate(18,44)"&gt;
&lt;rect class="colHot" width="110" height="28" rx="6"/&gt;&lt;text class="chip" x="55" y="19" fill="#fff"&gt;customer_id&lt;/text&gt;
&lt;rect class="colHot" x="120" width="90" height="28" rx="6"/&gt;&lt;text class="chip" x="165" y="19" fill="#fff"&gt;amount&lt;/text&gt;
&lt;/g&gt;
&lt;text class="verdict" x="18" y="108" fill="var(--tm-blue)"&gt;HIT · 0 rows moved&lt;/text&gt;
&lt;text class="m" x="250" y="88"&gt;Everything the question needs is already here.&lt;/text&gt;
&lt;text class="m" x="250" y="108"&gt;Nothing moves. The source never hears about it.&lt;/text&gt;
&lt;/g&gt;
&lt;!-- B --&gt;
&lt;g transform="translate(0,262)"&gt;
&lt;rect class="card" width="720" height="130" rx="12"/&gt;
&lt;text class="t" x="18" y="30" font-weight="700" font-size="15"&gt;Request B · year = 2025 · customer_id, discount&lt;/text&gt;
&lt;g transform="translate(18,44)"&gt;
&lt;rect class="colHot" width="110" height="28" rx="6"/&gt;&lt;text class="chip" x="55" y="19" fill="#fff"&gt;customer_id&lt;/text&gt;
&lt;rect class="colNew" x="120" width="90" height="28" rx="6"/&gt;&lt;text class="t chip" x="165" y="19"&gt;discount&lt;/text&gt;
&lt;/g&gt;
&lt;text class="verdict" x="18" y="108" fill="var(--tm-blue)"&gt;WIDEN · pull the gap&lt;/text&gt;
&lt;text class="m" x="250" y="88"&gt;One column is missing. Fetch only the gap;&lt;/text&gt;
&lt;text class="m" x="250" y="108"&gt;the rest of the slice stays exactly as it was.&lt;/text&gt;
&lt;/g&gt;
&lt;!-- C --&gt;
&lt;g transform="translate(0,404)"&gt;
&lt;rect class="card" width="720" height="130" rx="12"/&gt;
&lt;text class="t" x="18" y="30" font-weight="700" font-size="15"&gt;Request C · year = 2024 · customer_id, amount&lt;/text&gt;
&lt;g transform="translate(18,44)"&gt;
&lt;rect class="col" width="110" height="28" rx="6"/&gt;&lt;text class="t chip" x="55" y="19"&gt;customer_id&lt;/text&gt;
&lt;rect class="col" x="120" width="90" height="28" rx="6"/&gt;&lt;text class="t chip" x="165" y="19"&gt;amount&lt;/text&gt;
&lt;/g&gt;
&lt;text class="verdict" x="18" y="108" fill="var(--tm-muted)"&gt;MISS · fresh pull&lt;/text&gt;
&lt;text class="m" x="250" y="88"&gt;Different filter, different rows. A slice never serves&lt;/text&gt;
&lt;text class="m" x="250" y="108"&gt;fewer rows than the question asked for. Ever.&lt;/text&gt;
&lt;/g&gt;
&lt;/svg&gt;
&lt;/div&gt;&lt;figcaption&gt;The containment law in three requests: a hit, a widening, and a miss. A cached slice may never serve fewer rows than the question asked for.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;Three outcomes fall out of that law. A &lt;strong&gt;hit&lt;/strong&gt; means the slice already holds everything the question needs and nothing moves. A near-miss on columns is not a miss: it triggers &lt;strong&gt;widening&lt;/strong&gt;, which fetches only the gap and leaves the rest of the slice untouched. In the replay that proved it, widening moved 830 rows where a fresh pull moved 2,985; the commit said &lt;em&gt;&amp;ldquo;a missing column no longer re-pulls the world&amp;rdquo;&lt;/em&gt;. And anything that would make the slice serve fewer rows than the question asked for, a different filter, a different grouping, is a &lt;strong&gt;miss&lt;/strong&gt;, no matter how tempting the shortcut. We learned the cost of a tempting shortcut from a benchmark question that answered 116 where the truth was 51.&lt;/p&gt;
&lt;p&gt;Under the registry sits a &lt;strong&gt;plan cache&lt;/strong&gt; that remembers how a question was answered, not just which rows it needed. When a question comes back and nothing underneath has changed, the answer is recomputed without calling the model at all. A follow-up inside a conversation works on the slice it already has.&lt;/p&gt;
&lt;div class="tm-chart"&gt;
&lt;canvas id="chart-b661d7f8705d32a10f4e6d759aaa2ff0"&gt;&lt;/canvas&gt;
&lt;script&gt;
window.addEventListener("DOMContentLoaded", function () {
new Chart(document.getElementById("chart-b661d7f8705d32a10f4e6d759aaa2ff0"), {
type: 'bar',
data: {
labels: ['Cold question on a multi-GB CSV', 'Next question, same fact table', 'Follow-up on a cached slice'],
datasets: [{
label: 'seconds to answer (approximate)',
data: [557, 22, 20],
backgroundColor: ['rgba(148, 163, 184, 0.7)', 'rgba(37, 61, 229, 0.9)', 'rgba(37, 61, 229, 0.9)'],
borderWidth: 0,
borderRadius: 4
}]
},
options: {
responsive: true,
plugins: { legend: { display: false }, title: { display: true, text: 'First question vs. the ones after it (real customer files, approximate seconds)' } },
scales: { y: { title: { display: true, text: 'seconds' } } }
}
});
});
&lt;/script&gt;
&lt;/div&gt;
&lt;p&gt;The cold number is honest: streaming a multi-gigabyte CSV from object storage takes minutes, and the node says so while it works. The next question on the same data takes about twenty seconds, and a follow-up on a warm slice about the same, with &lt;strong&gt;zero reads from object storage&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 data-hextra-search-id="5-validate-dont-expire"&gt;5. Validate, don&amp;rsquo;t expire&lt;span class="hx:absolute hx:-mt-20" id="5-validate-dont-expire"&gt;&lt;/span&gt;
&lt;a href="#5-validate-dont-expire" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;This is the part I am proudest of, because it is the part where we deleted code.&lt;/p&gt;
&lt;p&gt;Our first cache had a TTL of 900 seconds. Fifteen minutes is a perfectly reasonable number to type. It is also completely wrong for how people ask questions about their business.&lt;/p&gt;
&lt;figure class="tm-figure"&gt;
&lt;div class="tm-figure-body"&gt;&lt;svg id="fig-ttl-comic" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 318" width="100%" role="img" aria-label="Three-panel comic: at 10:00 a question fills the cache; at 10:15 the TTL expires it; at 14:00 the same question re-pulls everything." style="font-family:inherit;font-size:14px;color:inherit"&gt;
&lt;defs&gt;
&lt;style&gt;#fig-ttl-comic .panel{fill:var(--tm-surface);stroke:var(--tm-rule-strong);stroke-width:1.5}
#fig-ttl-comic .t{fill:currentColor}
#fig-ttl-comic .m{fill:var(--tm-muted);font-size:12.5px}
#fig-ttl-comic .clock{fill:none;stroke:currentColor;stroke-width:2}
#fig-ttl-comic .bubble{fill:var(--tm-paper);stroke:var(--tm-rule-strong);stroke-width:1.2}
#fig-ttl-comic .slice{fill:var(--tm-blue)}
#fig-ttl-comic .tomb{fill:none;stroke:var(--tm-muted);stroke-width:2}
#fig-ttl-comic .fig{fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round}
&lt;/style&gt;
&lt;/defs&gt;
&lt;!-- panel 1 --&gt;
&lt;g transform="translate(0,0)"&gt;
&lt;rect class="panel" width="230" height="250" rx="10"/&gt;
&lt;text class="t" x="14" y="28" font-weight="800" font-size="16"&gt;10:00&lt;/text&gt;
&lt;text class="m" x="14" y="48"&gt;"Policies sold in August?"&lt;/text&gt;
&lt;circle class="fig" cx="44" cy="112" r="12"/&gt;&lt;path class="fig" d="M44 124v40M44 134l-16 14M44 134l16 14M44 164l-12 26M44 164l12 26"/&gt;
&lt;rect class="slice" x="96" y="112" width="122" height="60" rx="8"/&gt;
&lt;text x="157" y="138" text-anchor="middle" fill="#fff" font-weight="700" font-size="13"&gt;slice&lt;/text&gt;
&lt;text x="157" y="156" text-anchor="middle" fill="#dbe3ff" font-size="11"&gt;8 rows, pulled once&lt;/text&gt;
&lt;text class="m" x="14" y="214"&gt;The cache is warm.&lt;/text&gt;
&lt;text class="m" x="14" y="232"&gt;Life is good.&lt;/text&gt;
&lt;/g&gt;
&lt;!-- panel 2 --&gt;
&lt;g transform="translate(245,0)"&gt;
&lt;rect class="panel" width="230" height="250" rx="10"/&gt;
&lt;text class="t" x="14" y="28" font-weight="800" font-size="16"&gt;10:15&lt;/text&gt;
&lt;text class="m" x="14" y="48"&gt;Nobody asked. The clock did.&lt;/text&gt;
&lt;circle class="clock" cx="64" cy="136" r="36"/&gt;&lt;path class="clock" d="M64 136V108M64 136l20 12"/&gt;
&lt;text class="m" x="64" y="192" text-anchor="middle"&gt;TTL = 900 s&lt;/text&gt;
&lt;path class="tomb" d="M130 182v-46a38 38 0 0 1 76 0v46z"/&gt;&lt;text class="m" x="168" y="154" text-anchor="middle"&gt;RIP&lt;/text&gt;&lt;text class="m" x="168" y="170" text-anchor="middle" font-size="10.5"&gt;slice&lt;/text&gt;
&lt;text class="m" x="14" y="214"&gt;Expired. The data didn't change.&lt;/text&gt;
&lt;text class="m" x="14" y="232"&gt;Fifteen minutes did.&lt;/text&gt;
&lt;/g&gt;
&lt;!-- panel 3 --&gt;
&lt;g transform="translate(490,0)"&gt;
&lt;rect class="panel" width="230" height="250" rx="10"/&gt;
&lt;text class="t" x="14" y="28" font-weight="800" font-size="16"&gt;14:00&lt;/text&gt;
&lt;text class="m" x="14" y="48"&gt;Same person, question, data.&lt;/text&gt;
&lt;circle class="fig" cx="44" cy="112" r="12"/&gt;&lt;path class="fig" d="M44 124v40M44 134l-16 14M44 134l16 14M44 164l-12 26M44 164l12 26"/&gt;
&lt;path class="bubble" d="M74 88h140a8 8 0 0 1 8 8v62a8 8 0 0 1-8 8H94l-14 12v-12h-6a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8z"/&gt;
&lt;text class="t" x="144" y="110" text-anchor="middle" font-size="11.5"&gt;discover · narrow · select&lt;/text&gt;
&lt;text class="t" x="144" y="130" text-anchor="middle" font-size="11.5"&gt;estimate · pull · model&lt;/text&gt;&lt;text class="t" x="144" y="152" text-anchor="middle" font-size="11.5" font-weight="700"&gt;…all over again.&lt;/text&gt;
&lt;text class="m" x="14" y="214"&gt;Nine real runs, hours apart:&lt;/text&gt;
&lt;text class="m" x="14" y="232"&gt;the TTL never hit once.&lt;/text&gt;
&lt;/g&gt;
&lt;text class="t" x="360" y="282" text-anchor="middle" font-weight="700" font-size="14"&gt;The fix was not a longer TTL. It was no TTL:&lt;/text&gt;&lt;text class="t" x="360" y="304" text-anchor="middle" font-weight="700" font-size="14"&gt;a slice is valid until the source's version moves.&lt;/text&gt;
&lt;/svg&gt;
&lt;/div&gt;&lt;figcaption&gt;Nine real runs of the same question, hours apart. The 900-second TTL never hit once. The cache worked perfectly and served nobody.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Nobody:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Absolutely nobody:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The 900-second TTL:&lt;/strong&gt; &lt;em&gt;expires quietly at 10:15, three hours and forty-five minutes before anyone needs the data again.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The temptation is a longer TTL. That is the wrong fix, because age was never what made a slice invalid. &lt;strong&gt;A slice is invalid when the source changed.&lt;/strong&gt; So the clock was replaced by a &lt;strong&gt;source version&lt;/strong&gt; with two parts, on purpose, because a slice and a plan break for different reasons:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;What it fingerprints&lt;/th&gt;
&lt;th&gt;What it invalidates&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shape&lt;/td&gt;
&lt;td&gt;A fingerprint of the catalog: which tables and columns exist, and their types.&lt;/td&gt;
&lt;td&gt;Cached &lt;strong&gt;plans&lt;/strong&gt;. A stored answer against a shape that moved is wrong.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content&lt;/td&gt;
&lt;td&gt;A per-table marker that advances when a refresh actually lands new data. It describes &lt;em&gt;that&lt;/em&gt; something changed, never &lt;em&gt;what&lt;/em&gt;, so it can never leak a value.&lt;/td&gt;
&lt;td&gt;Cached &lt;strong&gt;slices&lt;/strong&gt; of that table only. A hot table does not evict the other nineteen.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The rule that now governs the node, in one sentence from the commit that shipped it: &lt;em&gt;&amp;ldquo;the clock stops invalidating and starts triggering validation.&amp;rdquo;&lt;/em&gt; A slice serves for as long as it is &lt;strong&gt;valid&lt;/strong&gt;, and age is not part of validity. The cache stays on until somebody refreshes, by hand or on a schedule; a question that cannot tolerate that asks in &lt;code&gt;live&lt;/code&gt; mode. Every answer carries the version it was served from. The correct model &lt;strong&gt;deleted more lines than it added&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 data-hextra-search-id="6-etl-at-question-time"&gt;6. ETL at question time&lt;span class="hx:absolute hx:-mt-20" id="6-etl-at-question-time"&gt;&lt;/span&gt;
&lt;a href="#6-etl-at-question-time" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;&amp;ldquo;Pull only what you need&amp;rdquo; also changed &lt;em&gt;what gets cleaned&lt;/em&gt;. An up-front pipeline has to transform every table it ingests, because it cannot know which questions will arrive. Beacon runs its ETL on the slice, and only on the slice: the columns a question selected, the rows that passed its filter, the groups the source returned. If the answer is eight rows, eight rows get typed, normalised and checked. The other twelve million never enter the pipeline, because they never entered the node.&lt;/p&gt;
&lt;p&gt;That has two consequences we did not fully appreciate at first. The obvious one is cost: cleaning the fraction of a source that a question touches is cheap enough to do on every question, so nothing has to be pre-computed or kept in sync. The less obvious one is context. When the transformation runs at question time, it runs with the question in hand and the real values in front of it, and whatever it decides is recorded in that answer&amp;rsquo;s trace. A decision that turns out to be wrong affects one answer, where it can be seen and corrected, instead of being baked into a table that everybody reads for a year.&lt;/p&gt;
&lt;h2 data-hextra-search-id="7-what-grew-around-the-cache"&gt;7. What grew around the cache&lt;span class="hx:absolute hx:-mt-20" id="7-what-grew-around-the-cache"&gt;&lt;/span&gt;
&lt;a href="#7-what-grew-around-the-cache" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;A fast, honest answer is the seed. Around it, in about eight weeks, the node grew what a company needs to actually run on those answers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Organizations and permissions.&lt;/strong&gt; Members, roles and workspaces, signed in with the company&amp;rsquo;s identity provider. A results table has an owner and can be shared.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Row-and-column policies&lt;/strong&gt;, enforced inside the funnel, not in the UI. A slice pulled under one policy is never served under another, so the cache cannot be used to look around a permission.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dashboards.&lt;/strong&gt; A saved answer gets a place to live, controls the reader can move, a click that filters the rest, a link to share. As one release note put it, &lt;em&gt;a dashboard stopped being a photo.&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Alerts.&lt;/strong&gt; A monitor watches a results table and speaks first. The organization writes the message, the node owns the words, and no model runs at trigger time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bring your own model.&lt;/strong&gt; The reasoning model is chosen per node, and an organization can bring its own key.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And every answer still carries its SQL, its rows per table and a trace id. Sources are read-only, and budgets refuse &lt;em&gt;before&lt;/em&gt; rows are in memory.&lt;/p&gt;
&lt;h2 data-hextra-search-id="8-the-milestones"&gt;8. The milestones&lt;span class="hx:absolute hx:-mt-20" id="8-the-milestones"&gt;&lt;/span&gt;
&lt;a href="#8-the-milestones" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;ol class="tm-timeline"&gt;
&lt;li class="tm-timeline-item"&gt;
&lt;span class="tm-timeline-icon"&gt;&lt;svg height=16 xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"&gt;&lt;path stroke-linecap="round" stroke-linejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/&gt;&lt;/svg&gt;&lt;/span&gt;
&lt;div class="tm-timeline-card"&gt;
&lt;div class="tm-timeline-head"&gt;&lt;p class="tm-timeline-title"&gt;The thin loop&lt;/p&gt;&lt;span class="tm-badge"&gt;5–6 July 2026&lt;/span&gt;&lt;/div&gt;&lt;div class="tm-timeline-body"&gt;
Four repos in one day. The first real commit is the architecture that never changed: &lt;em&gt;harvest → narrow → select → pull → model&lt;/em&gt;. Next morning the eval goes from 20% to &lt;strong&gt;25/25&lt;/strong&gt;, the protocol freezes 1.0, and the first slice cache replays a repeated question &lt;strong&gt;1,274× faster&lt;/strong&gt;.
&lt;/div&gt;
&lt;/div&gt;
&lt;/li&gt;
&lt;li class="tm-timeline-item"&gt;
&lt;span class="tm-timeline-icon"&gt;&lt;svg height=16 xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"&gt;&lt;path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/&gt;&lt;/svg&gt;&lt;/span&gt;
&lt;div class="tm-timeline-card"&gt;
&lt;div class="tm-timeline-head"&gt;&lt;p class="tm-timeline-title"&gt;Files are sources, and three real customers&lt;/p&gt;&lt;span class="tm-badge"&gt;14 July&lt;/span&gt;&lt;/div&gt;&lt;div class="tm-timeline-body"&gt;
CSV, Excel, JSON, Parquet, buckets and Apache Iceberg in one day, read in situ. The same day, three customers' production data answered exact to the cent with &lt;strong&gt;a few dozen rows moved&lt;/strong&gt;.
&lt;/div&gt;
&lt;/div&gt;
&lt;/li&gt;
&lt;li class="tm-timeline-item"&gt;
&lt;span class="tm-timeline-icon"&gt;&lt;svg height=16 xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"&gt;&lt;path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/&gt;&lt;/svg&gt;&lt;/span&gt;
&lt;div class="tm-timeline-card"&gt;
&lt;div class="tm-timeline-head"&gt;&lt;p class="tm-timeline-title"&gt;Organizations, policies and dashboards&lt;/p&gt;&lt;span class="tm-badge"&gt;4–13 August&lt;/span&gt;&lt;/div&gt;&lt;div class="tm-timeline-body"&gt;
Organizations managed from the product. Row-and-column policies enforced in the funnel. Dashboards land, then bring-your-own-model.
&lt;/div&gt;
&lt;/div&gt;
&lt;/li&gt;
&lt;li class="tm-timeline-item"&gt;
&lt;span class="tm-timeline-icon"&gt;&lt;svg height=16 xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"&gt;&lt;path stroke-linecap="round" stroke-linejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/&gt;&lt;/svg&gt;&lt;/span&gt;
&lt;div class="tm-timeline-card"&gt;
&lt;div class="tm-timeline-head"&gt;&lt;p class="tm-timeline-title"&gt;The source-version cache&lt;/p&gt;&lt;span class="tm-badge"&gt;20 August&lt;/span&gt;&lt;/div&gt;&lt;div class="tm-timeline-body"&gt;
Five PRs in a day. The clock leaves the serving path, and every answer says how current it is.
&lt;/div&gt;
&lt;/div&gt;
&lt;/li&gt;
&lt;li class="tm-timeline-item"&gt;
&lt;span class="tm-timeline-icon"&gt;&lt;svg height=16 xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"&gt;&lt;path stroke-linecap="round" stroke-linejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/&gt;&lt;/svg&gt;&lt;/span&gt;
&lt;div class="tm-timeline-card"&gt;
&lt;div class="tm-timeline-head"&gt;&lt;p class="tm-timeline-title"&gt;1.0&lt;/p&gt;&lt;span class="tm-badge"&gt;27 August&lt;/span&gt;&lt;/div&gt;&lt;div class="tm-timeline-body"&gt;
&lt;strong&gt;v1.0.0&lt;/strong&gt;: the node has its own assistant and knows what it spends. Fifty-three days after the first commit.
&lt;/div&gt;
&lt;/div&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-hextra-search-id="9-what-a-cache-taught-me"&gt;9. What a cache taught me&lt;span class="hx:absolute hx:-mt-20" id="9-what-a-cache-taught-me"&gt;&lt;/span&gt;
&lt;a href="#9-what-a-cache-taught-me" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;A cache is a theory about what will be asked next. Ours started as a theory about time, and time turned out to be the wrong axis: nobody asks a business question every fifteen minutes, and nothing about a number becomes false because a quarter of an hour passed. What makes a number false is that the world it described has moved. Once we stopped asking &lt;em&gt;how old is this&lt;/em&gt; and started asking &lt;em&gt;is this still true&lt;/em&gt;, the code got shorter and the answers got more honest, and I suspect that trade shows up everywhere we build systems that remember things on behalf of people. The other lesson is quieter. We set out to move as little data as possible, and ended up with a node that holds exactly the pieces of a company&amp;rsquo;s data that somebody, at some point, genuinely needed: small, correct, versioned against their source, and shaped like questions. That is a strange and rather beautiful kind of memory. It knows nothing the business never cared about, and everything it does know, it knows because someone asked. What happens when you let that memory grow on purpose, one question at a time, is the next thing we are building. More on that soon.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Sol Soletti&lt;/em&gt;&lt;/p&gt;</description></item><item><title>How to Get SOC 2 Certified Without Dying in the Attempt</title><link>https://docs.teramot.com/blog/soc-2-without-dying/</link><pubDate>Mon, 14 Sep 2026 00:00:00 +0000</pubDate><guid>https://docs.teramot.com/blog/soc-2-without-dying/</guid><description>
&lt;p&gt;Somewhere in your sales pipeline there is a deal parked behind a single sentence: &lt;em&gt;&amp;ldquo;we&amp;rsquo;ll need your SOC 2 report before we can sign.&amp;rdquo;&lt;/em&gt; Nobody on the engineering side asked for this. Everybody on the engineering side is now doing it.&lt;/p&gt;
&lt;p&gt;The good news is that SOC 2 is survivable. The bad news is that most teams lose the run in the first ten minutes, during character creation, before a single control has been tested.&lt;/p&gt;
&lt;div class="tm-stats tm-stats-3"&gt;
&lt;div class="tm-stat"&gt;
&lt;p class="tm-stat-value"&gt;3–12&lt;/p&gt;
&lt;p class="tm-stat-label"&gt;Months of observation&lt;/p&gt;&lt;div class="tm-stat-note"&gt;The window a Type II report covers. The part you cannot redo.&lt;/div&gt;
&lt;/div&gt;
&lt;div class="tm-stat"&gt;
&lt;p class="tm-stat-value"&gt;1 of 5&lt;/p&gt;
&lt;p class="tm-stat-label"&gt;Criteria that are mandatory&lt;/p&gt;&lt;div class="tm-stat-note"&gt;Security. The other four are opt-in — usually by accident.&lt;/div&gt;
&lt;/div&gt;
&lt;div class="tm-stat"&gt;
&lt;p class="tm-stat-value"&gt;0&lt;/p&gt;
&lt;p class="tm-stat-label"&gt;Do-overs&lt;/p&gt;&lt;div class="tm-stat-note"&gt;March is already in the report. It always was.&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2 data-hextra-search-id="1-type-i-is-the-tutorial-type-ii-is-the-boss-run"&gt;1. Type I is the tutorial. Type II is the boss run.&lt;span class="hx:absolute hx:-mt-20" id="1-type-i-is-the-tutorial-type-ii-is-the-boss-run"&gt;&lt;/span&gt;
&lt;a href="#1-type-i-is-the-tutorial-type-ii-is-the-boss-run" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;These are not two difficulty levels of the same report. They measure different things.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Type I&lt;/strong&gt; asks: &lt;em&gt;on this one day, were your controls designed sensibly?&lt;/em&gt; It is a photograph of your dojo. You can tidy the dojo the night before. Plenty of teams do.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Type II&lt;/strong&gt; asks: &lt;em&gt;over the next three to twelve months, did those controls actually operate?&lt;/em&gt; It is the whole fight, on tape, including the part where you fumbled.&lt;/p&gt;
&lt;div class="tm-chart"&gt;
&lt;canvas id="chart-cf1c663ac2265ac6d08cab65b4c0c597"&gt;&lt;/canvas&gt;
&lt;script&gt;
window.addEventListener("DOMContentLoaded", function () {
new Chart(document.getElementById("chart-cf1c663ac2265ac6d08cab65b4c0c597"), {
type: 'bar',
data: {
labels: ['Elapsed time', 'Evidence required', 'Can be tidied up the night before', 'What enterprise buyers accept'],
datasets: [
{
label: 'Type I',
data: [8, 12, 90, 25],
backgroundColor: 'rgba(148, 163, 184, 0.85)',
borderWidth: 0
},
{
label: 'Type II',
data: [100, 100, 5, 100],
backgroundColor: 'rgba(56, 189, 248, 0.85)',
borderWidth: 0
}
]
},
options: {
indexAxis: 'y',
responsive: true,
plugins: {
legend: { position: 'bottom' },
title: { display: true, text: 'Type I vs. Type II, relative' }
},
scales: {
x: { max: 100, title: { display: true, text: 'Relative' } }
}
}
});
});
&lt;/script&gt;
&lt;/div&gt;
&lt;p&gt;This is the detail that catches people: &lt;strong&gt;the observation window is a roguelike run.&lt;/strong&gt; There is no save scumming. When the auditor pulls a sample from March and you offboarded someone in March without revoking their access, you cannot go back and fix March. March is done. March is in the report.&lt;/p&gt;
&lt;h2 data-hextra-search-id="2-your-controls-are-binding-vows--so-stop-making-them-harsher"&gt;2. Your controls are binding vows — so stop making them harsher&lt;span class="hx:absolute hx:-mt-20" id="2-your-controls-are-binding-vows--so-stop-making-them-harsher"&gt;&lt;/span&gt;
&lt;a href="#2-your-controls-are-binding-vows--so-stop-making-them-harsher" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;In &lt;em&gt;Jujutsu Kaisen&lt;/em&gt;, a binding vow grants you power in exchange for a restriction you impose on yourself. The vow is only as strong as it is specific, and once you make it, you are held to it. Whether you meant it that way is not relevant.&lt;/p&gt;
&lt;p&gt;SOC 2 controls are binding vows. &lt;strong&gt;You write them.&lt;/strong&gt; Then an auditor spends a year checking whether you honoured your own words.&lt;/p&gt;
&lt;p&gt;Which is why the single most expensive mistake in SOC 2 is not laziness. It is &lt;em&gt;enthusiasm&lt;/em&gt;. A nervous team writes the most impressive-sounding policy it can imagine, and only later discovers it has signed a contract with its own future self.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The vow you were tempted to make&lt;/th&gt;
&lt;th&gt;The vow that survives the run&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&amp;ldquo;Access reviews are performed monthly.&amp;rdquo;&lt;/td&gt;
&lt;td&gt;&amp;ldquo;Access reviews are performed quarterly.&amp;rdquo;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;ldquo;All findings are remediated within 24 hours.&amp;rdquo;&lt;/td&gt;
&lt;td&gt;&amp;ldquo;Findings are triaged within 5 business days, with severity-based SLAs.&amp;rdquo;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;ldquo;Every production change is reviewed by two engineers.&amp;rdquo;&lt;/td&gt;
&lt;td&gt;&amp;ldquo;Every production change is reviewed by at least one engineer who is not the author.&amp;rdquo;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;ldquo;We log everything.&amp;rdquo;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(delete this, it is not a control, it is a mood)&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The monthly access review does not get you a better report than the quarterly one. It gets you &lt;strong&gt;twelve chances to fail instead of four.&lt;/strong&gt; Same power, triple the restriction. That is a bad vow.&lt;/p&gt;
&lt;p&gt;Write the weakest vow you can defend, honour it perfectly, and tighten it next year.&lt;/p&gt;
&lt;h2 data-hextra-search-id="3-character-creation-do-not-pick-all-five-criteria"&gt;3. Character creation: do not pick all five criteria&lt;span class="hx:absolute hx:-mt-20" id="3-character-creation-do-not-pick-all-five-criteria"&gt;&lt;/span&gt;
&lt;a href="#3-character-creation-do-not-pick-all-five-criteria" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;SOC 2 has five Trust Services Criteria. Exactly one of them is mandatory:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Security&lt;/strong&gt; — the Common Criteria. Required. This is the run.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Availability&lt;/strong&gt; — optional.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Confidentiality&lt;/strong&gt; — optional.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Processing Integrity&lt;/strong&gt; — optional.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Privacy&lt;/strong&gt; — optional, and the most expensive of the four by a wide margin.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Selecting all five on your first audit is choosing Ultra Hard on a fresh save file with no New Game+, no gear, and no idea where the checkpoints are. Your customer almost certainly asked for &lt;em&gt;&amp;ldquo;a SOC 2 report.&amp;rdquo;&lt;/em&gt; They did not ask for Processing Integrity. Ask them before you volunteer for it.&lt;/p&gt;
&lt;div class="tm-chart"&gt;
&lt;canvas id="chart-7aede014c51e6f1c689fe55b1824b5f7"&gt;&lt;/canvas&gt;
&lt;script&gt;
window.addEventListener("DOMContentLoaded", function () {
new Chart(document.getElementById("chart-7aede014c51e6f1c689fe55b1824b5f7"), {
type: 'bar',
data: {
labels: ['Security', 'Availability', 'Confidentiality', 'Processing Integrity', 'Privacy'],
datasets: [{
label: 'Relative effort added to the run',
data: [100, 25, 20, 45, 90],
backgroundColor: [
'rgba(56, 189, 248, 0.85)',
'rgba(148, 163, 184, 0.75)',
'rgba(148, 163, 184, 0.75)',
'rgba(249, 168, 37, 0.85)',
'rgba(239, 68, 68, 0.9)'
],
borderWidth: 0
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
title: { display: true, text: 'What each criterion costs you (Security = the baseline run)' }
},
scales: {
y: { title: { display: true, text: 'Relative effort' } }
}
}
});
});
&lt;/script&gt;
&lt;/div&gt;
&lt;p&gt;Start with Security. Add criteria in year two, when evidence collection is boring instead of terrifying.&lt;/p&gt;
&lt;h2 data-hextra-search-id="4-the-chart-every-team-recognises"&gt;4. The chart every team recognises&lt;span class="hx:absolute hx:-mt-20" id="4-the-chart-every-team-recognises"&gt;&lt;/span&gt;
&lt;a href="#4-the-chart-every-team-recognises" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Here is the strategy teams instinctively reach for, plotted against what a Type II audit actually samples:&lt;/p&gt;
&lt;div class="tm-chart"&gt;
&lt;canvas id="chart-934b671e5dd03f99a5e3ef16eec950b2"&gt;&lt;/canvas&gt;
&lt;script&gt;
window.addEventListener("DOMContentLoaded", function () {
new Chart(document.getElementById("chart-934b671e5dd03f99a5e3ef16eec950b2"), {
type: 'line',
data: {
labels: ['M1','M2','M3','M4','M5','M6','M7','M8','M9','M10','M11','M12'],
datasets: [
{
label: 'Evidence you produced (the panic strategy)',
data: [3, 1, 0, 2, 0, 1, 1, 0, 2, 4, 38, 95],
borderColor: 'rgb(239, 68, 68)',
backgroundColor: 'rgba(239, 68, 68, 0.15)',
tension: 0.3,
fill: true
},
{
label: 'Evidence the auditor samples',
data: [8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8],
borderColor: 'rgb(56, 189, 248)',
backgroundColor: 'rgba(56, 189, 248, 0.15)',
borderDash: [6, 4],
tension: 0,
fill: true
}
]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom' },
title: { display: true, text: 'Observation window: effort vs. sampling' }
},
scales: {
y: { title: { display: true, text: 'Relative volume' } }
}
}
});
});
&lt;/script&gt;
&lt;/div&gt;
&lt;p&gt;The auditor samples &lt;strong&gt;uniformly&lt;/strong&gt;. That heroic spike in month eleven is worth nothing for months one through ten, because months one through ten are where the sample came from. The dashed line is the only line that matters, and it is flat, which means the work has to be flat too.&lt;/p&gt;
&lt;h2 data-hextra-search-id="5-domain-expansion-the-audit-itself"&gt;5. Domain Expansion: the audit itself&lt;span class="hx:absolute hx:-mt-20" id="5-domain-expansion-the-audit-itself"&gt;&lt;/span&gt;
&lt;a href="#5-domain-expansion-the-audit-itself" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Megumi&amp;rsquo;s Ten Shadows can only summon what has already been trained. You do not get a new shikigami during the fight. You get the ones you did the work for.&lt;/p&gt;
&lt;p&gt;An audit is a domain expansion in the most annoying sense: &lt;strong&gt;inside it, attacks land automatically.&lt;/strong&gt; You cannot dodge a question about March by being excellent in September. You cannot out-argue a missing offboarding ticket. Being genuinely good at security and having no record of being good at security produce the same report.&lt;/p&gt;
&lt;p&gt;There is also the manoeuvre every engineer tries exactly once:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;&amp;ldquo;But our cloud provider is SOC 2 certified.&amp;rdquo;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That is their Infinity, not yours. A vendor&amp;rsquo;s report covers the vendor&amp;rsquo;s controls. Yours still has to explain who at your company can reach production, and when you last checked.&lt;/p&gt;
&lt;h2 data-hextra-search-id="6-where-the-year-actually-goes"&gt;6. Where the year actually goes&lt;span class="hx:absolute hx:-mt-20" id="6-where-the-year-actually-goes"&gt;&lt;/span&gt;
&lt;a href="#6-where-the-year-actually-goes" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;div class="tm-chart"&gt;
&lt;canvas id="chart-939052fe242defe2c29232510f5e5e88"&gt;&lt;/canvas&gt;
&lt;script&gt;
window.addEventListener("DOMContentLoaded", function () {
new Chart(document.getElementById("chart-939052fe242defe2c29232510f5e5e88"), {
type: 'doughnut',
data: {
labels: [
'Chasing screenshots from 9 people on Slack',
'Writing policies nobody will read',
'Actual security engineering',
'The audit itself',
'Reading the criteria'
],
datasets: [{
data: [45, 20, 18, 10, 7],
backgroundColor: [
'rgb(239, 68, 68)',
'rgb(249, 168, 37)',
'rgb(56, 189, 248)',
'rgb(139, 92, 246)',
'rgb(148, 163, 184)'
],
borderWidth: 0
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom' },
title: { display: true, text: 'How SOC 2 time is really spent' }
}
}
});
});
&lt;/script&gt;
&lt;/div&gt;
&lt;p&gt;Every screenshot you take by hand is a control that will fail the moment the person who takes it goes on holiday. &lt;strong&gt;Evidence should be a side effect of the system, not a chore appended to it.&lt;/strong&gt; Access reviews come out of your identity provider. Change management comes out of your pull requests. Vulnerability management comes out of your scanner. If a human is pasting an image into Slack once a month, that control is not automated — it is &lt;em&gt;scheduled&lt;/em&gt;, and schedules break.&lt;/p&gt;
&lt;h2 data-hextra-search-id="7-the-actual-survival-kit"&gt;7. The actual survival kit&lt;span class="hx:absolute hx:-mt-20" id="7-the-actual-survival-kit"&gt;&lt;/span&gt;
&lt;a href="#7-the-actual-survival-kit" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Security only, year one.&lt;/strong&gt; Add criteria later, from a position of boredom.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Weakest defensible vow.&lt;/strong&gt; Quarterly beats monthly. Always.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Say &amp;ldquo;not applicable&amp;rdquo; out loud.&lt;/strong&gt; No physical datacenter means no physical access control. Write that down instead of inventing a policy about a building you do not have.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One owner with real calendar time.&lt;/strong&gt; SOC 2 distributed evenly across a team is SOC 2 owned by nobody, discovered in month eleven.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Automate at the source.&lt;/strong&gt; If evidence cannot be exported from a system, the control is already broken.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Start the window when you are ready&lt;/strong&gt;, not when the deal is signed. The window is the only part of the schedule you control.&lt;/li&gt;
&lt;/ol&gt;</description></item><item><title>Building Self-Service IPsec Connectivity for External Databases</title><link>https://docs.teramot.com/blog/self-service-ipsec/</link><pubDate>Fri, 11 Sep 2026 16:50:39 -0300</pubDate><guid>https://docs.teramot.com/blog/self-service-ipsec/</guid><description>
&lt;p&gt;Teramot extracts data from sources operated by its customers. Some databases live inside private networks and are reachable only through a site-to-site VPN. Connecting them used to require an operator to configure our gateway, create a TCP forward for the database, and give its local port to the extraction worker.&lt;/p&gt;
&lt;p&gt;We replaced that runbook with a control plane that stores the requested connection and a gateway controller that applies it. The controller creates the isolated Linux networking resources, configures strongSwan, and reports the resulting state. If the gateway is replaced, its configuration can be reconstructed from the same stored request instead of being rebuilt by hand.&lt;/p&gt;
&lt;p&gt;The important change was not automating a sequence of commands. It was making a private connection a state the system maintains. This article explains how we provision and maintain that connection, then how extraction workers use it without gaining unrestricted access to the customer&amp;rsquo;s network.&lt;/p&gt;
&lt;h2 data-hextra-search-id="1-from-a-manual-runbook-to-a-managed-connection"&gt;1. From a manual runbook to a managed connection&lt;span class="hx:absolute hx:-mt-20" id="1-from-a-manual-runbook-to-a-managed-connection"&gt;&lt;/span&gt;
&lt;a href="#1-from-a-manual-runbook-to-a-managed-connection" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;In the original process, an operator exchanged a pre-shared key, configured the Teramot gateway, and set up forwarding for each database endpoint. Operators had to track which network configuration and local ports belonged to each connection. The gateway&amp;rsquo;s local disk held its configuration, so replacing the machine also meant reconstructing that setup.&lt;/p&gt;
&lt;p&gt;A script could automate those setup steps, but running them once would not answer the ongoing questions: does the gateway still match the requested configuration? Which update is current? What should a replacement machine recreate?&lt;/p&gt;
&lt;p&gt;We split those responsibilities between a &lt;strong&gt;control plane&lt;/strong&gt;, which stores what should exist, and a &lt;strong&gt;gateway controller&lt;/strong&gt;, which manages the corresponding network resources:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Manual responsibility&lt;/th&gt;
&lt;th&gt;Managed replacement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Edit the gateway configuration&lt;/td&gt;
&lt;td&gt;Validate and store the requested connection outside the gateway.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prepare the network environment&lt;/td&gt;
&lt;td&gt;Have the controller manage namespaces, interfaces, and routes.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Configure the IPsec connection&lt;/td&gt;
&lt;td&gt;Load or update strongSwan through its control API.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inspect the machine to understand its state&lt;/td&gt;
&lt;td&gt;Report the applied configuration and tunnel health.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rebuild configuration after a gateway failure&lt;/td&gt;
&lt;td&gt;Reconstruct the runtime from the stored request.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Self-service describes the Teramot side of this process. The customer-side gateway still needs compatible configuration; storing a request in Teramot does not configure the remote peer. The connection parameters describe the agreement between those two sides.&lt;/p&gt;
&lt;p&gt;Using the tunnel is a separate responsibility. The &lt;strong&gt;data plane&lt;/strong&gt; carries database traffic through the provisioned connection. Keeping it separate from provisioning lets a worker use an approved destination without giving it authority to configure the gateway or choose arbitrary destinations inside the private network.&lt;/p&gt;
&lt;h2 data-hextra-search-id="2-how-the-controller-provisions-and-maintains-a-tunnel"&gt;2. How the controller provisions and maintains a tunnel&lt;span class="hx:absolute hx:-mt-20" id="2-how-the-controller-provisions-and-maintains-a-tunnel"&gt;&lt;/span&gt;
&lt;a href="#2-how-the-controller-provisions-and-maintains-a-tunnel" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Consider a customer connecting a private network that contains a database at &lt;code&gt;10.12.4.20&lt;/code&gt;. Before an extraction can reach that database, Teramot needs a tunnel to the customer&amp;rsquo;s gateway and a routing context for its private network.&lt;/p&gt;
&lt;p&gt;The user supplies the peer address, Internet Key Exchange (IKE) identities, approved network ranges, optional private DNS servers, and cryptographic settings. The control plane validates and stores that configuration as &lt;strong&gt;desired state&lt;/strong&gt;: what the gateway should maintain, not a copy of files from the gateway&amp;rsquo;s disk.&lt;/p&gt;
&lt;h3 data-hextra-search-id="turning-the-request-into-network-state"&gt;Turning the request into network state&lt;span class="hx:absolute hx:-mt-20" id="turning-the-request-into-network-state"&gt;&lt;/span&gt;
&lt;a href="#turning-the-request-into-network-state" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;The controller reads the desired state and performs the work previously done on the gateway by an operator:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Ensure the connection&amp;rsquo;s isolated network namespace exists.&lt;/li&gt;
&lt;li&gt;Ensure its Linux IPsec interface (XFRM) and routes match the requested network ranges.&lt;/li&gt;
&lt;li&gt;Retrieve the connection&amp;rsquo;s pre-shared key and load or update its configuration in strongSwan through &lt;a href="https://docs.strongswan.org/docs/latest/plugins/vici.html" target="_blank" rel="noopener"&gt;VICI&lt;/a&gt;, strongSwan&amp;rsquo;s local control API.&lt;/li&gt;
&lt;li&gt;Inspect the resulting Linux and IPsec state and report what was applied.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The controller manages the configuration around the tunnel; &lt;a href="https://docs.strongswan.org/docs/latest/index.html" target="_blank" rel="noopener"&gt;strongSwan&lt;/a&gt; handles negotiation with the remote gateway. Its &lt;a href="https://www.rfc-editor.org/rfc/rfc7296.html" target="_blank" rel="noopener"&gt;IKEv2&lt;/a&gt; implementation uses the pre-shared key to authenticate the peers and establish an IKE security association. CHILD security associations then define protection for the approved traffic. The Linux kernel encrypts that traffic using Encapsulating Security Payload (ESP) and its &lt;a href="https://docs.kernel.org/networking/xfrm/index.html" target="_blank" rel="noopener"&gt;XFRM&lt;/a&gt; framework.&lt;/p&gt;
&lt;p&gt;We did not build a new IPsec implementation. We built the system that supplies its configuration, manages the surrounding Linux network environment, and connects its runtime state back to the product.&lt;/p&gt;
&lt;h3 data-hextra-search-id="maintaining-state-instead-of-running-setup-once"&gt;Maintaining state instead of running setup once&lt;span class="hx:absolute hx:-mt-20" id="maintaining-state-instead-of-running-setup-once"&gt;&lt;/span&gt;
&lt;a href="#maintaining-state-instead-of-running-setup-once" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;The controller repeats this process, fetching the latest desired state and reporting &lt;strong&gt;observed state&lt;/strong&gt;: what actually exists on the gateway. This reconciliation loop also supports recovery. After a restart or instance replacement, the controller reconstructs the runtime from the stored configuration instead of requiring an operator to restore local files.&lt;/p&gt;
&lt;p&gt;Updates can overlap in time. If a user changes a connection while an earlier update is still running, the older result must not become authoritative. Each update therefore carries an increasing generation number, which the controller checks before applying or reporting a result.&lt;/p&gt;
&lt;p&gt;The controller is also the only component that changes this managed network state. The proxy does not write routes, and operators do not need to edit connection files for ordinary updates. A connection can be updated without restarting the entire IPsec service or interrupting unrelated tunnels.&lt;/p&gt;
&lt;p&gt;Credentials follow a different lifecycle from configuration. The controller retrieves a pre-shared key only while configuring its tunnel. Desired-state documents, server images, logs, command lines, and Terraform state do not contain these keys. Certificate files are written only when the runtime starts.&lt;/p&gt;
&lt;h3 data-hextra-search-id="knowing-what-ready-means"&gt;Knowing what “ready” means&lt;span class="hx:absolute hx:-mt-20" id="knowing-what-ready-means"&gt;&lt;/span&gt;
&lt;a href="#knowing-what-ready-means" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;Applying configuration does not prove that the remote peer accepted it, and an established tunnel does not prove that a database accepts connections. We report these separately:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Question it answers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Provisioning&lt;/td&gt;
&lt;td&gt;Did the gateway apply the requested configuration?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tunnel health&lt;/td&gt;
&lt;td&gt;Is IPsec active?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source health&lt;/td&gt;
&lt;td&gt;Does the database accept connections?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;For our example, the gateway may have the correct routes while the remote peer is unavailable. Or the tunnel may be active while the database at &lt;code&gt;10.12.4.20&lt;/code&gt; is unreachable. A single “healthy” flag would hide the distinction an operator needs to diagnose the failure.&lt;/p&gt;
&lt;h2 data-hextra-search-id="3-keeping-overlapping-private-networks-separate"&gt;3. Keeping overlapping private networks separate&lt;span class="hx:absolute hx:-mt-20" id="3-keeping-overlapping-private-networks-separate"&gt;&lt;/span&gt;
&lt;a href="#3-keeping-overlapping-private-networks-separate" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;The controller needs an isolated routing context because private addresses are not globally unique. A second customer may also have a database at &lt;code&gt;10.12.4.20&lt;/code&gt;. In one shared routing table, that destination alone would not identify which customer network to use.&lt;/p&gt;
&lt;p&gt;Each private connection therefore gets a &lt;a href="https://man7.org/linux/man-pages/man7/network_namespaces.7.html" target="_blank" rel="noopener"&gt;network namespace&lt;/a&gt; with its own interfaces, routes, DNS view, and XFRM interface:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;customer A namespace: 10.12.4.20 -&amp;gt; customer A tunnel
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;customer B namespace: 10.12.4.20 -&amp;gt; customer B tunnel&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Copy code"
aria-label="Copy code"
data-copied-label="Copied!"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;The destination must identify both a database and its network context. When a worker requests a connection, the gateway selects that context from a server-side authorization record rather than from a namespace or address chosen by the worker.&lt;/p&gt;
&lt;p&gt;We separate privileges along the same boundary:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;controller&lt;/strong&gt; has the capabilities needed to manage namespaces, routes, XFRM, and the strongSwan control socket.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;connector proxy&lt;/strong&gt; authenticates and dispatches requests without Linux network capabilities.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;per-namespace dialer&lt;/strong&gt; opens TCP connections inside its assigned namespace without permission to reconfigure the host or another namespace.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The network-facing proxy therefore does not hold the controller&amp;rsquo;s authority to change routing or IPsec configuration. Isolation is part of provisioning, not something an extraction worker sets up for itself.&lt;/p&gt;
&lt;h2 data-hextra-search-id="4-connecting-existing-drivers-to-one-approved-database"&gt;4. Connecting existing drivers to one approved database&lt;span class="hx:absolute hx:-mt-20" id="4-connecting-existing-drivers-to-one-approved-database"&gt;&lt;/span&gt;
&lt;a href="#4-connecting-existing-drivers-to-one-approved-database" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Once the tunnel exists, an extraction still needs a way to use it. Our original proxy accepted a destination host and port from the worker. That gave the worker too much control: a compromised worker could request other destinations inside the customer&amp;rsquo;s network.&lt;/p&gt;
&lt;p&gt;The replacement separates three questions: who is connecting, which database they may use, and which isolated network contains it.&lt;/p&gt;
&lt;h3 data-hextra-search-id="authorizing-the-destination-before-opening-a-socket"&gt;Authorizing the destination before opening a socket&lt;span class="hx:absolute hx:-mt-20" id="authorizing-the-destination-before-opening-a-socket"&gt;&lt;/span&gt;
&lt;a href="#authorizing-the-destination-before-opening-a-socket" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;When a user associates a data source with a private connection, the control plane creates a &lt;strong&gt;source binding&lt;/strong&gt;. This server-side record stores the network, namespace, database host, and port. The worker receives an opaque reference to the approved source, not authority to supply a different destination.&lt;/p&gt;
&lt;p&gt;For every request, the gateway:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Authenticates the worker through mutual TLS (mTLS) and checks that its workload certificate is authorized to use the binding.&lt;/li&gt;
&lt;li&gt;Rejects a binding that has expired or been replaced by a newer version.&lt;/li&gt;
&lt;li&gt;Resolves the destination and namespace from the stored binding, never from a worker-supplied host and port.&lt;/li&gt;
&lt;li&gt;Rejects loopback, cloud metadata, management networks, and addresses outside the approved private network ranges.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The tunnel supplies network connectivity; the binding restricts its use to the approved database. After a restart, the proxy rejects connections until the controller supplies current bindings and trusted certificate authorities. Listening on a port is not enough to start accepting database requests.&lt;/p&gt;
&lt;h3 data-hextra-search-id="adapting-the-connection-without-changing-database-drivers"&gt;Adapting the connection without changing database drivers&lt;span class="hx:absolute hx:-mt-20" id="adapting-the-connection-without-changing-database-drivers"&gt;&lt;/span&gt;
&lt;a href="#adapting-the-connection-without-changing-database-drivers" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;Database drivers speak their own database protocol. They cannot first perform Teramot&amp;rsquo;s gateway authorization exchange and then switch protocols on the same socket. We put that exchange in a local forwarder and give the driver a temporary loopback port.&lt;/p&gt;
&lt;p&gt;For each driver socket, the forwarder authenticates to the gateway with a workload certificate and sends the source reference. The proxy validates the request, then asks the dialer in the selected namespace to open the stored database host and port. Once the connection is established, the forwarder relays database traffic.&lt;/p&gt;
&lt;img src="https://docs.teramot.com/diagrams/self-service-ipsec-sequence.png" alt="Sequence diagram showing a database connection through the local forwarder, private gateway, and IPsec tunnel" data-zoomable loading="lazy" /&gt;&lt;p&gt;&lt;em&gt;This is the extraction path after provisioning. The driver sees a local socket; the namespace dialer reaches the approved private database through IPsec.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Existing drivers therefore need no knowledge of namespaces or Teramot&amp;rsquo;s gateway protocol. Forwarding introduces a TLS naming detail: the local socket is at &lt;code&gt;127.0.0.1&lt;/code&gt;, while a database certificate identifies the real database host. When hostname verification is used, it must verify that logical hostname rather than the loopback address; forwarding is not a reason to disable verification.&lt;/p&gt;
&lt;h2 data-hextra-search-id="5-recovering-the-gateway-without-hiding-connection-failures"&gt;5. Recovering the gateway without hiding connection failures&lt;span class="hx:absolute hx:-mt-20" id="5-recovering-the-gateway-without-hiding-connection-failures"&gt;&lt;/span&gt;
&lt;a href="#5-recovering-the-gateway-without-hiding-connection-failures" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Storing desired state outside the gateway solves only part of recovery. A replacement host also needs the correct software, services, and permissions before the controller can recreate the connections.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://developer.hashicorp.com/packer/docs" target="_blank" rel="noopener"&gt;Packer&lt;/a&gt; and &lt;a href="https://docs.ansible.com/projects/ansible-core/" target="_blank" rel="noopener"&gt;Ansible Core&lt;/a&gt; build a versioned Ubuntu image containing strongSwan, systemd services, permissions, and safe defaults. It contains no peer configuration, credentials, or environment endpoints. &lt;a href="https://developer.hashicorp.com/terraform/docs" target="_blank" rel="noopener"&gt;Terraform&lt;/a&gt; deploys the exact image ID and adds environment-specific network and access configuration.&lt;/p&gt;
&lt;p&gt;The image restores the host software; reconciliation restores the connection configuration. We promote the same image between environments, and the previous image ID remains an explicit rollback target.&lt;/p&gt;
&lt;p&gt;This is recoverability, not uninterrupted availability. The first version has one active gateway, so connections remain unavailable while automated replacement restores it. A load balancer with one target does not provide high availability.&lt;/p&gt;
&lt;h3 data-hextra-search-id="a-restored-tunnel-cannot-restore-a-database-session"&gt;A restored tunnel cannot restore a database session&lt;span class="hx:absolute hx:-mt-20" id="a-restored-tunnel-cannot-restore-a-database-session"&gt;&lt;/span&gt;
&lt;a href="#a-restored-tunnel-cannot-restore-a-database-session" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h3&gt;&lt;p&gt;Even when connectivity returns, the forwarder cannot safely reconstruct an interrupted database session. The database may have an open transaction or may already have returned partial results. Transparently reconnecting and repeating work could duplicate an operation or produce an incorrect result.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure point&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Before the stream is established&lt;/td&gt;
&lt;td&gt;Retry only when the failure is known to be temporary.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authorization, protocol, or certificate rejection&lt;/td&gt;
&lt;td&gt;Fail immediately; retrying the same identity cannot help.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After database bytes have crossed the relay&lt;/td&gt;
&lt;td&gt;Fail the operation; never reconnect transparently.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;An interrupted stream can look like a normal end-of-file (EOF) signal to the driver. The transport layer preserves its first specific failure so operators can see the network cause together with the database symptom. Recovery must restore future connectivity without pretending that an interrupted operation succeeded.&lt;/p&gt;
&lt;h2 data-hextra-search-id="6-what-staging-taught-us"&gt;6. What staging taught us&lt;span class="hx:absolute hx:-mt-20" id="6-what-staging-taught-us"&gt;&lt;/span&gt;
&lt;a href="#6-what-staging-taught-us" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;Integration tests start the real proxy and namespace dialer, create Linux network namespaces, and connect through PostgreSQL, MySQL, and SQL Server. They cover different TLS modes, rejected identities, revoked certificates, overlapping private networks, and interrupted streams. These tests exercise the components, but not every interaction in the deployed system.&lt;/p&gt;
&lt;p&gt;In staging, we test the complete path from the control plane to an extraction worker. That exposed two failures the isolated tests had missed:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Healthy processes, broken handoff.&lt;/strong&gt; Both the proxy and namespace dialer were healthy, but Linux permissions prevented the proxy from opening the dialer&amp;rsquo;s Unix socket. Process health did not prove that the components could communicate.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unchanged configuration, rejected refresh.&lt;/strong&gt; The control plane refreshed timestamps without changing the configuration generation. The gateway treated each refresh as a conflicting update and eventually rejected new connections. The test fixtures had not reproduced that control-plane behavior.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These failures are why complete staging extractions are part of release validation before promotion. Component tests establish important properties; an end-to-end extraction checks that the deployed permissions, state exchanges, and data path work together.&lt;/p&gt;
&lt;h2 data-hextra-search-id="conclusion"&gt;Conclusion&lt;span class="hx:absolute hx:-mt-20" id="conclusion"&gt;&lt;/span&gt;
&lt;a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;The original runbook produced a working tunnel, but left its configuration and recovery dependent on an operator. The new system stores the intended connection outside the gateway, gives one controller responsibility for maintaining it, and reconstructs the runtime when the machine is replaced.&lt;/p&gt;
&lt;p&gt;That managed tunnel is only the foundation. Isolated routing contexts distinguish overlapping customer networks, server-side bindings restrict workers to approved databases, and the forwarder keeps existing drivers compatible. Explicit failure behavior and staging validation make the operational limits visible rather than hiding them behind a successful VPN handshake.&lt;/p&gt;
&lt;p&gt;IPsec provides the encrypted path. The system around it turns that path into a repeatable capability for Teramot customers.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Facundo Vivas&lt;/em&gt;&lt;/p&gt;
&lt;h2 data-hextra-search-id="references-and-further-reading"&gt;References and further reading&lt;span class="hx:absolute hx:-mt-20" id="references-and-further-reading"&gt;&lt;/span&gt;
&lt;a href="#references-and-further-reading" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.strongswan.org/docs/latest/index.html" target="_blank" rel="noopener"&gt;strongSwan documentation&lt;/a&gt;, including &lt;a href="https://docs.strongswan.org/docs/latest/plugins/vici.html" target="_blank" rel="noopener"&gt;VICI&lt;/a&gt;, &lt;a href="https://docs.strongswan.org/docs/latest/swanctl/swanctl.html" target="_blank" rel="noopener"&gt;swanctl&lt;/a&gt;, and the &lt;a href="https://docs.strongswan.org/docs/latest/daemons/charon.html" target="_blank" rel="noopener"&gt;&lt;code&gt;charon&lt;/code&gt; daemon&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc7296.html" target="_blank" rel="noopener"&gt;RFC 7296: Internet Key Exchange Protocol Version 2 (IKEv2)&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://man7.org/linux/man-pages/man7/network_namespaces.7.html" target="_blank" rel="noopener"&gt;Linux network namespaces&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kernel.org/networking/xfrm/index.html" target="_blank" rel="noopener"&gt;Linux kernel XFRM framework&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.hashicorp.com/packer/docs" target="_blank" rel="noopener"&gt;Packer documentation&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.ansible.com/projects/ansible-core/" target="_blank" rel="noopener"&gt;Ansible Core documentation&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.hashicorp.com/terraform/docs" target="_blank" rel="noopener"&gt;Terraform documentation&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;</description></item><item><title>Welcome to Teramot Engineering</title><link>https://docs.teramot.com/blog/hello-world/</link><pubDate>Fri, 11 Sep 2026 14:42:16 -0300</pubDate><guid>https://docs.teramot.com/blog/hello-world/</guid><description>
&lt;h1 data-hextra-search-id="welcome"&gt;Welcome&lt;/h1&gt;&lt;p&gt;Welcome to the Teramot engineering blog.&lt;/p&gt;
&lt;p&gt;We build a data platform that turns raw, messy sources into governed,
queryable tables. Along the way, we run into problems worth writing about:
data pipeline design, orchestration, infrastructure, and the occasional
production incident that teaches us something.&lt;/p&gt;
&lt;h2 data-hextra-search-id="what-to-expect"&gt;What to expect&lt;span class="hx:absolute hx:-mt-20" id="what-to-expect"&gt;&lt;/span&gt;
&lt;a href="#what-to-expect" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;This is where we will share architecture write-ups, postmortems, tooling
deep-dives, and the reasoning behind decisions we made so future readers —
often future us — do not have to rediscover it from scratch.&lt;/p&gt;
&lt;p&gt;More soon.&lt;/p&gt;</description></item><item><title>Welcome to Product Updates</title><link>https://docs.teramot.com/updates/welcome/</link><pubDate>Thu, 24 Jul 2025 00:00:00 +0000</pubDate><guid>https://docs.teramot.com/updates/welcome/</guid><description>
&lt;p&gt;Welcome to the official &lt;strong&gt;Teramot Product Updates&lt;/strong&gt; section. 🚀&lt;/p&gt;
&lt;p&gt;Here you’ll find:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;✅ The latest updates from our product roadmap&lt;/li&gt;
&lt;li&gt;🧩 Announcements of new features and internal tools&lt;/li&gt;
&lt;li&gt;🛠️ Guidance for integrating with our APIs and services&lt;/li&gt;
&lt;li&gt;🔒 Insights into security and compliance improvements&lt;/li&gt;
&lt;li&gt;🧪 Previews of upcoming capabilities for our users&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Stay tuned! This space will evolve as we continue opening up the Teramot platform to our clients and developer community.&lt;/p&gt;
&lt;hr&gt;
&lt;blockquote&gt;
&lt;p&gt;🧭 &lt;em&gt;This update feed is part of our commitment to transparency, innovation, and user-centric development.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;img src="https://docs.teramot.com/updates/welcome/teramotLogo.png" alt="Teramot Logo" data-zoomable loading="lazy" /&gt;</description></item></channel></rss>