<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: László Hadházy</title>
    <description>The latest articles on DEV Community by László Hadházy (@lhadhazy).</description>
    <link>https://dev.to/lhadhazy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4093667%2F712ee963-9d68-4802-9deb-20c892319d30.jpg</url>
      <title>DEV Community: László Hadházy</title>
      <link>https://dev.to/lhadhazy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lhadhazy"/>
    <language>en</language>
    <item>
      <title>The descriptor survived, const did not — full-stack Rust</title>
      <dc:creator>László Hadházy</dc:creator>
      <pubDate>Mon, 07 Sep 2026 12:45:35 +0000</pubDate>
      <link>https://dev.to/lhadhazy/the-descriptor-survived-const-did-not-full-stack-rust-2bbc</link>
      <guid>https://dev.to/lhadhazy/the-descriptor-survived-const-did-not-full-stack-rust-2bbc</guid>
      <description>&lt;p&gt;&lt;a href="https://cordata.tech/en/blog/one-skeleton-many-screens" rel="noopener noreferrer"&gt;&lt;em&gt;One skeleton, many screens&lt;/em&gt;&lt;/a&gt; argued that admin screens should be declared as typed data rather than coded, and it ended by claiming the idea was independent of the stack:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;draw the boundary as a &lt;strong&gt;one-way dependency&lt;/strong&gt; — domains depend inward on a framework that knows nothing about them — and validate it with a &lt;strong&gt;zero-diff refactor&lt;/strong&gt; of a screen you already trust.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That was React and TypeScript. This is the same claim re-run in Rust, where a descriptor can be a compile-time constant and a template is a macro. Because the first result is already published, the second stack is a replication with a control rather than a fresh opinion — which is rare enough to be worth doing properly.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Companion to &lt;a href="https://cordata.tech/en/blog/topcoat-full-stack-rust" rel="noopener noreferrer"&gt;&lt;em&gt;Topcoat and the shrinking cost of full-stack Rust&lt;/em&gt;&lt;/a&gt;.&lt;/strong&gt; That post was written from the announcement and promised a follow-up reporting where the rough edges actually show. This is it, from the pilot that followed: a small admin panel built on Topcoat 0.6.2 and Toasty 0.10.0, and the four questions that post committed to answering. The pilot is open source — a clean clone runs both screens and the test that decides the argument.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That phrase, &lt;em&gt;a compile-time constant&lt;/em&gt;, is where the title comes from, so it is worth saying now what it buys and why I wanted it. A TypeScript descriptor is an array of objects assembled when the module loads. A Rust one can be more than that: &lt;code&gt;&amp;amp;'static&lt;/code&gt;, &lt;code&gt;Copy&lt;/code&gt;, allocated never, fully checked before the program starts. Going in, that looked to me like the same idea in a stricter form — if declaring a screen as data is good, then declaring it as data the compiler can see through and verify must be better still. I treated that property as the thing worth protecting, and the pilot was partly a test of whether it could be.&lt;/p&gt;

&lt;p&gt;The stack is deliberately a young one. Topcoat is six weeks old: Tokio's team announced it on 22 July 2026, the pilot pins 0.6.2, and the project still expects breaking changes. It is not the only full-stack Rust framework — Leptos and Dioxus have been at this for years — but it is the only one of them that answers the question without WebAssembly, server-rendering by default and keeping the reactive layer out of a Wasm bundle. That is the part nobody has years of experience with, and the part worth testing.&lt;/p&gt;

&lt;p&gt;It came out differently, and not in the direction I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The only data in this post is metadata
&lt;/h2&gt;

&lt;p&gt;There is no warehouse here and no pipeline. There is a table of invoices with a dozen invented rows, and those rows are not the data that matters. The data that matters is the &lt;strong&gt;descriptor&lt;/strong&gt; — the small typed declaration saying that an invoice screen has these columns, read from these fields, rendered these ways. That is metadata in the plainest sense: it describes something else, it is versioned separately from the thing it describes, and it is reviewed on its own terms.&lt;/p&gt;

&lt;p&gt;Which is the shape of the rest of Cordata's work. &lt;a href="https://cordata.tech/en/blog/pipelines-as-descriptors" rel="noopener noreferrer"&gt;&lt;em&gt;A pipeline is a descriptor, not a program&lt;/em&gt;&lt;/a&gt; makes the identical argument one domain over — put source, steps, target, contract and policy into one versioned declaration, and onboarding a new source becomes a configuration change rather than a release. A lineage event, a data contract, a retention rule: all the same move, taking something smeared across code and turning it into a document you can diff, review, and refuse to merge.&lt;/p&gt;

&lt;p&gt;Metadata is worth the effort for a reason that is not documentation. &lt;strong&gt;It is constraint.&lt;/strong&gt; A descriptor is worth having for what it stops a domain from doing — the pipeline that cannot write without declaring a contract, the screen that cannot render a column it never declared. In this pilot that constraint is a type, which makes it something other than advisory: the descriptor is the entire vocabulary a screen is allowed, and a domain that needs something the vocabulary does not have cannot quietly work around it. It gets a compile error, and somebody has to decide what the vocabulary should say instead. Everything reported below came from pushing on that boundary until it answered.&lt;/p&gt;

&lt;p&gt;Every metadata layer meets the same test eventually, whatever it describes. The declaration covers the ordinary cases comfortably. Then a case turns up that it has no vocabulary for, and there are two ways out.&lt;/p&gt;

&lt;p&gt;The first is an escape hatch: one field that accepts arbitrary code or text — a &lt;code&gt;raw_sql&lt;/code&gt; string on a pipeline step, a &lt;code&gt;render&lt;/code&gt; callback on a column, a free-text &lt;code&gt;notes&lt;/code&gt; blob on a contract. It is quick, and it solves not only today's case but every future one, which is exactly the problem. Nothing outside the domain can read what ends up in that field, so from the day it exists the declaration stops describing the system and starts describing only the parts nobody needed an exception for. The layer still looks like a constraint in the type signature, but it has stopped acting as one.&lt;/p&gt;

&lt;p&gt;The second way out is slower: name the new case and add it to the vocabulary. A declared &lt;code&gt;Link&lt;/code&gt; cell type rather than a render callback. A declared join step rather than a SQL string. The cost is real — the framework has to change every time a domain needs something genuinely new, and that change is reviewed by whoever owns the framework rather than whoever wanted the feature. What it buys is that the declaration still says what the system does, and still refuses what the system should not do.&lt;/p&gt;

&lt;p&gt;Which of the two a layer reaches for is the only question about it that really matters, and it is normally answered years in, on a system far too large to watch.&lt;/p&gt;

&lt;p&gt;Two admin screens are small enough to watch. So this is a post about a Rust web framework on the surface and a post about metadata underneath — and if you never write a line of Rust, the second half is the one that still applies to you, because the same question is waiting in whatever declaration layer you do own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The experiment
&lt;/h2&gt;

&lt;p&gt;The pilot has two screens, picked to sit on either side of the boundary. Each is its own module — &lt;code&gt;users.rs&lt;/code&gt; and &lt;code&gt;invoices.rs&lt;/code&gt; — and those two modules are what this post means by the &lt;strong&gt;domains&lt;/strong&gt;: the code that knows what a user is, or what an invoice is. Facing them is the &lt;strong&gt;framework&lt;/strong&gt;: &lt;code&gt;descriptor.rs&lt;/code&gt;, which defines what a description may say, and &lt;code&gt;table.rs&lt;/code&gt;, which renders one. The framework knows about neither users nor invoices, and the entire experiment is about what can travel from a domain to it.&lt;/p&gt;

&lt;p&gt;The first screen, &lt;code&gt;users&lt;/code&gt;, lists name, email, seats, joined and status — all plain values, every one of them covered by the cell types the framework already ships. The second, &lt;code&gt;invoices&lt;/code&gt;, was written &lt;strong&gt;by hand first&lt;/strong&gt; and made deliberately harder, carrying three things the descriptor could not express:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;ambient context&lt;/strong&gt; — a money amount formatted for a locale that arrives from the &lt;strong&gt;request&lt;/strong&gt;, not from the row: &lt;code&gt;4.850,00 €&lt;/code&gt; in German, &lt;code&gt;$4,850.00&lt;/code&gt; in English&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;a derived value&lt;/strong&gt; — a column computed from two fields rather than read from one&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;structure&lt;/strong&gt; — a cell that renders as a link rather than as text&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those three names are used for the rest of the post, and the third is the one worth pinning down now, because the result turns on it. A descriptor of this shape answers one question per column: &lt;em&gt;given a row, what string does this cell show?&lt;/em&gt; A derived value is still an answer to that question — compute whatever you like, hand back a string. A link is not an answer to it at all, because a link is a different &lt;strong&gt;shape of output&lt;/strong&gt;: an &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; element wrapping the text, carrying a second value, the destination, which has nowhere to live in a description that only knows how to say "string". So structure here means not what a cell contains, but what kind of thing the cell is.&lt;/p&gt;

&lt;p&gt;Screen one exists to be trusted so that screen two can be refactored against it — and that word, refactored, is carrying the whole method, so it is worth being exact about what it means here.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;invoices&lt;/code&gt; was written the ordinary way first: its own markup, its own loop over the rows, its own formatting, answering to nothing but itself. Refactoring it means deleting all of that and rebuilding the screen as a descriptor handed to the same shared table component &lt;code&gt;users&lt;/code&gt; already uses — the same generic component, with no invoice-shaped special case added inside it. The screen's own code changes completely, while what the browser receives must not change at all.&lt;/p&gt;

&lt;p&gt;The order matters more than it looks. If both screens are written through the abstraction from the start, the abstraction gets to decide what correct looks like, and the test can only agree with itself. Writing &lt;code&gt;invoices&lt;/code&gt; by hand first produces a control: a screen built by somebody solving the real problem, free to do whatever that problem needed — including the three things above, which the descriptor had no vocabulary for. Refactoring it afterwards asks a question that can genuinely come back no. Can the abstraction reproduce, byte for byte, a screen that was never designed to fit it?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fixgujurgr4qkfnyrtmgy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fixgujurgr4qkfnyrtmgy.png" alt="Where the descriptor boundary held and where it broke" width="800" height="238"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The diagram, in words: both domains hand descriptions inward to one table component that does not name any domain type, and nothing points back out. Of the three things tested against that boundary, values crossed unchanged, structure required a change on the framework side, and ambient context could not cross at all while the accessor remained a plain function pointer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Because Topcoat renders on the server, the zero-diff claim stops being a visual judgement and becomes a text comparison. The test script captures the HTML for every route before a refactor and diffs it after, which turns the whole question into a pass or a fail: either &lt;code&gt;diff&lt;/code&gt; returns nothing, or the abstraction has changed what the user sees and is therefore wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The test script gave one false positive before I trusted it.&lt;/strong&gt; Perturbing a column header produced the failure it should. Reverting that change with &lt;code&gt;mv&lt;/code&gt; produced the failure &lt;em&gt;again&lt;/em&gt; — because &lt;code&gt;mv&lt;/code&gt; restores the original modification time, leaving the source older than the compiled binary, so cargo skipped the rebuild and the server kept serving the previous build. Nothing anywhere reported a skipped step. It now forces the sources current on every run, which costs 1.4 seconds and removes the category.&lt;/p&gt;

&lt;p&gt;That is the same shape as &lt;a href="https://cordata.tech/en/blog/trace-id-was-the-easy-part" rel="noopener noreferrer"&gt;the five traps in the trace_id post&lt;/a&gt;: a check that is confidently wrong because something upstream silently did not happen. Prove your test discriminates before you believe it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result one: the boundary held for values and broke at structure
&lt;/h2&gt;

&lt;p&gt;First refactor, changing nothing in the framework:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ZERO DIFF  users
CHANGED    invoices   — one column
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five of the six columns reproduced byte for byte, including both I expected to break it. The computed column survived outright. The locale-formatted money survived too, though the next section explains why that was an accident of the fixture rather than a result.&lt;/p&gt;

&lt;p&gt;Exactly one column failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- &amp;lt;td class="cell cell-text"&amp;gt;
  &amp;lt;a href="/invoices/2026-0041"&amp;gt;2026-0041&amp;lt;/a&amp;gt;&amp;lt;/td&amp;gt;
+ &amp;lt;td class="cell cell-text"&amp;gt;
  &amp;amp;lt;a href="/invoices/2026-0041"&amp;amp;gt;2026-0041&amp;amp;lt;/a&amp;amp;gt;&amp;lt;/td&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(wrapped to fit this surface — each cell is one line in the real output)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The accessor's type was &lt;code&gt;fn(&amp;amp;T) -&amp;gt; String&lt;/code&gt;, and &lt;code&gt;view!&lt;/code&gt; escapes an interpolated string. That is correct behaviour — it is the cross-site scripting defence — not a bug to route around.&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;an accessor returning a string can express any cell that is a value, and no cell that is structure.&lt;/strong&gt; It can compute anything at all from the row, and it can nest nothing at all inside the cell it hands back.&lt;/p&gt;

&lt;p&gt;Every column in a descriptor carries two things: an accessor saying &lt;em&gt;what to show&lt;/em&gt;, and a &lt;strong&gt;cell kind&lt;/strong&gt; saying &lt;em&gt;how to render it&lt;/em&gt;. The cell kind is an enum, and that enum is the framework's entire vocabulary of cell shapes — a domain may choose from it and cannot extend it, which is the constraint from earlier in this post expressed as a type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;CellKind&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;'static&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Badge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Link&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Accessor&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// added by this result&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So fixing the link column meant adding that &lt;code&gt;Link&lt;/code&gt; variant, plus one &lt;code&gt;if let&lt;/code&gt; inside the table component's template — the single place that decides whether a cell's text goes into the &lt;code&gt;&amp;lt;td&amp;gt;&lt;/code&gt; bare or wrapped in an anchor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nn"&gt;CellKind&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Link&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;href&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="py"&gt;.kind&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;href&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;href&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="py"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="py"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both changes are framework-side, and the variant carries something worth noticing: a second accessor for the destination, which is exactly the value that had nowhere to live when a column could only produce one string. The domains did not change, and the table component still does not name any domain type — it learned a &lt;strong&gt;shape&lt;/strong&gt;, not a subject.&lt;/p&gt;

&lt;p&gt;That inverts the React result, and this is the first thing worth stealing. There, a column definition held a render function, so any domain could return arbitrary markup, which is more flexible and &lt;strong&gt;no boundary at all&lt;/strong&gt;. The Rust version has the stricter boundary, and the price is that a genuinely new presentation costs a deliberate, single, framework-wide decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result two: the locale broke the thing I thought was the point
&lt;/h2&gt;

&lt;p&gt;The money column survived the first refactor only because the currency formatter had the locale hardcoded, which let the closure capture nothing and coerce to a plain function pointer. The moment the locale became a real one, the accessor needed to &lt;em&gt;capture&lt;/em&gt; it, and a function pointer cannot.&lt;/p&gt;

&lt;p&gt;Where the locale is &lt;em&gt;stored&lt;/em&gt; turns out not to matter, which is worth saying because the obvious objection is that locale is data like anything else. On this site it is a route segment, &lt;code&gt;/en/…&lt;/code&gt; and &lt;code&gt;/de/…&lt;/code&gt;. It could as easily be a cookie, an &lt;code&gt;Accept-Language&lt;/code&gt; header, or a column in a user-preferences table read at login — and frequently is. All of those reach the render the same way: resolved once per request, then threaded down to whatever formats a number. None of them puts the locale on the invoice row that the accessor is handed, and that is what makes it ambient rather than data.&lt;/p&gt;

&lt;p&gt;The distinction is worth keeping sharp, because part of this genuinely &lt;em&gt;is&lt;/em&gt; row data. An invoice may well carry its own currency code, and a plain function pointer reads that perfectly happily — a React codebase I worked on last year threads the locale as a parameter and takes the currency from the record, which is exactly the right split. What cannot live on the row is which human is looking at it.&lt;/p&gt;

&lt;p&gt;The smallest thing that compiled was a global: set the locale per request, read it per cell. It kept the descriptor a compile-time constant, and it is worse than the problem it solves. Two descriptors differing only by locale cannot exist at once, and what a cell renders depends on &lt;em&gt;when&lt;/em&gt; it is read. I committed it with a note saying exactly that, so the cost sat in the code rather than in prose.&lt;/p&gt;

&lt;p&gt;All three routes then reported zero diff — the two screens, with &lt;code&gt;invoices&lt;/code&gt; counted twice because a German route had been added alongside the English one for exactly this test. The output was identical and the program was worse, which is precisely the limit of what this test can tell you: &lt;strong&gt;it compares what the reader sees, never what it cost to produce.&lt;/strong&gt; A green run here was not evidence that the global was acceptable. It was evidence that the test script cannot see a global at all.&lt;/p&gt;

&lt;p&gt;The fix is to let the accessor capture after all, which means boxing it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Accessor&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt; &lt;span class="nf"&gt;Fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Send&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Sync&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the moment the descriptor stops being a compile-time constant. It is no longer &lt;code&gt;Copy&lt;/code&gt;, no longer &lt;code&gt;static&lt;/code&gt;, and it allocates once per column per request. On day one of the pilot I wrote that down as the thing most likely to break the thesis.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that actually cost, measured
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;lines of Rust&lt;/th&gt;
&lt;th&gt;function pointers + global&lt;/th&gt;
&lt;th&gt;boxed closures&lt;/th&gt;
&lt;th&gt;change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;descriptor.rs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;66&lt;/td&gt;
&lt;td&gt;88&lt;/td&gt;
&lt;td&gt;+22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;table.rs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;users.rs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;72&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;td&gt;−18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;invoices.rs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;119&lt;/td&gt;
&lt;td&gt;88&lt;/td&gt;
&lt;td&gt;−31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;315&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;288&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−27&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;wc -l&lt;/code&gt;, before and after that one refactor. Later work grew these files, so the numbers come from the change itself rather than from the repository as it stands today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The expensive version is smaller.&lt;/strong&gt; The framework grew twenty-two lines — a documented type alias and a constructor function — and the domains shrank forty-nine, because keeping the descriptor constant had required a macro generating two static column arrays per locale, plus the global to smuggle the locale in. All of that went away.&lt;/p&gt;

&lt;p&gt;What was lost is real: &lt;code&gt;Copy&lt;/code&gt;, &lt;code&gt;const&lt;/code&gt;, &lt;code&gt;&amp;amp;'static&lt;/code&gt;, zero allocation. What was gained is also real: the locale is captured rather than smuggled, two locales coexist, and a cell's value no longer depends on when it is read.&lt;/p&gt;

&lt;p&gt;And the call site still reads as data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;de&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;"Betrag"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;"Amount"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nn"&gt;CellKind&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="py"&gt;.amount_cents&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is not meaningfully further from a struct literal than the original was, which is the finding in one line: &lt;strong&gt;the declarative shape survived the change, and &lt;code&gt;const&lt;/code&gt; did not.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The four questions the first post promised
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Developer experience.&lt;/strong&gt; Mostly unremarkable, which is the compliment. Two things cost real time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A page function cannot share a name with a module in scope&lt;/strong&gt;, and the five-error cascade that follows describes the macro expansion rather than the cause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A generic component needs &lt;code&gt;Send + Sync&lt;/code&gt; bounds&lt;/strong&gt; the React version never had — though the compiler suggested exactly the right fix, which is more than can be said for some.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Error messages.&lt;/strong&gt; The spans are consistently good and the vocabulary is not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every probe pointed at the source line&lt;/strong&gt;, never into expanded code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Malformed markup gets a purpose-written diagnostic&lt;/strong&gt; — &lt;em&gt;closing tag &lt;code&gt;div&lt;/code&gt; does not match opening tag &lt;code&gt;p&lt;/code&gt;&lt;/em&gt;, with the exact column and no trait bounds anywhere in it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The vocabulary is where it thins out.&lt;/strong&gt; Feeding &lt;code&gt;$()&lt;/code&gt; something that cannot cross to JavaScript produces an unsatisfied bound on a trait called &lt;code&gt;Surrogated&lt;/code&gt;, a word that appears nowhere in the documentation, followed by a list of implementors ending "and 25 others".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the boundary is policed correctly and explained poorly, which means a junior would land in the right file every time and need telling once what those names mean.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rebuild loop.&lt;/strong&gt; It holds up, with a warm incremental rebuild at 0.47 seconds. To find out whether macro expansion comes to dominate as an app grows, I generated components carrying template blocks and measured the slope:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;
&lt;code&gt;view!&lt;/code&gt; blocks&lt;/th&gt;
&lt;th&gt;warm rebuild&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;0.47s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;td&gt;0.52s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;81&lt;/td&gt;
&lt;td&gt;0.57s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;156&lt;/td&gt;
&lt;td&gt;0.71s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Roughly linear at about 1.6 milliseconds per block, so five hundred components would still land near 1.3 seconds, which means expansion is not the thing you will notice. The browser-reload half of the loop remains untested.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where the trade-offs land.&lt;/strong&gt; Swapping the fixtures for a real Toasty model over SQLite changed the field types from string slices to owned strings, widened an integer, and made the row query asynchronous — and &lt;strong&gt;not one accessor changed.&lt;/strong&gt; The query completes before rendering starts, so the descriptor never sees the asynchrony. That held because every column is a scalar already on the row; a lazily-loaded relation would need the accessor to reach the database, and its signature is synchronous. That case is untested and I am not going to pretend otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The take-home
&lt;/h2&gt;

&lt;p&gt;I went in believing the descriptor should be &lt;em&gt;data&lt;/em&gt;, and that Rust's function pointers were a stricter, purer version of the same idea. They are stricter, but past the first cell that needs context from outside the row they are not purer, and they are not cheaper either — keeping them cost a macro, two duplicated arrays and a global, which is more machinery than the boxed closure it was avoiding.&lt;/p&gt;

&lt;p&gt;So the finding is that &lt;strong&gt;&lt;code&gt;const&lt;/code&gt;-ness was never the property that mattered.&lt;/strong&gt; What made the original abstraction work is the one-way dependency: domains hand the framework descriptions, the framework knows nothing about domains. That held unchanged across all three attempts — through a new cell shape, through a new source of ambient context, and through replacing the entire data layer. What broke was narrower than it felt at the time: the accessor's type, a plain &lt;code&gt;fn&lt;/code&gt; pointer, and with it the descriptor's standing as a compile-time constant. Both of those are decisions about how a description is &lt;em&gt;represented&lt;/em&gt;, not about what it is allowed to say. The React post took the descriptor's being plain data for the idea, and I took its being a constant for a stricter version of the same idea, when in both cases the idea was the dependency direction underneath.&lt;/p&gt;

&lt;p&gt;Which generalises past admin screens, and back to the metadata argument this started from. A declaration is worth having because of what it refuses to let a domain do, not because of where it is stored or when it is evaluated. Ask of any descriptor — pipeline, screen, policy — &lt;strong&gt;which of its properties are the boundary and which are just how it happens to be built today.&lt;/strong&gt; They are easy to confuse, and only one of them is worth defending.&lt;/p&gt;

&lt;p&gt;The pilot is at &lt;a href="https://github.com/cordata-tech/topcoat-descriptor-pilot" rel="noopener noreferrer"&gt;github.com/cordata-tech/topcoat-descriptor-pilot&lt;/a&gt;. A clean clone builds and serves both screens, and &lt;code&gt;scripts/zero-diff.sh check&lt;/code&gt; runs the test that decides the argument. &lt;code&gt;NOTES.md&lt;/code&gt; carries the findings as they were recorded, including the ones that went the other way and one probe I designed wrong.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with an AI pair, which is worth stating on a post about developer experience: Topcoat's first release was April 2026, so there is little training data and the README is most of the corpus. The probing of error messages was mine; the scaffold was not. If you have piloted Topcoat and found the rough edges somewhere else — or think a descriptor of closures gives up more than I am admitting — I would like to hear it. The door is open at &lt;a href="https://cordata.tech/en/contact" rel="noopener noreferrer"&gt;cordata.tech/contact&lt;/a&gt;. Related reading: &lt;a href="https://cordata.tech/en/blog/one-skeleton-many-screens" rel="noopener noreferrer"&gt;One skeleton, many screens&lt;/a&gt; is the React original this replicates, and &lt;a href="https://cordata.tech/en/blog/pipelines-as-descriptors" rel="noopener noreferrer"&gt;A pipeline is a descriptor, not a program&lt;/a&gt; makes the same argument about metadata one domain over.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>webdev</category>
      <category>architecture</category>
      <category>typescript</category>
    </item>
    <item>
      <title>The trace_id was the easy part — Rust, OTel, Loki</title>
      <dc:creator>László Hadházy</dc:creator>
      <pubDate>Sun, 30 Aug 2026 10:30:55 +0000</pubDate>
      <link>https://dev.to/lhadhazy/the-traceid-was-the-easy-part-rust-otel-loki-44hc</link>
      <guid>https://dev.to/lhadhazy/the-traceid-was-the-easy-part-rust-otel-loki-44hc</guid>
      <description>&lt;p&gt;An &lt;a href="https://cordata.tech/en/blog/why-cordata" rel="noopener noreferrer"&gt;earlier post&lt;/a&gt; listed what the difficult middle of a data platform actually consists of, and one item on that list was &lt;em&gt;"observability that points at the right wire when something breaks"&lt;/em&gt;. This post is what that one item cost.&lt;/p&gt;

&lt;p&gt;The system is a multi-chain ingestion pipeline: Rust services subscribing to Ethereum, Solana and Polkadot, publishing to Kafka, running on Kubernetes with Prometheus, Loki and Tempo behind it. An ordinary enough shape. What makes the observability requirement unusual is not the architecture but what the data does to it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Companion to &lt;a href="https://cordata.tech/en/blog/one-port-three-client-crates" rel="noopener noreferrer"&gt;&lt;em&gt;One port, three client crates&lt;/em&gt;&lt;/a&gt;.&lt;/strong&gt; That post covered how one Rust service reads three blockchains through a single trait, and stopped at the boundary where the design was done. This one picks up after it: the same service, an issue estimated at an afternoon, and the four conditions that had to hold before the result meant anything — plus a fifth that arrived with the follow-up. Reading the first is not required; the traps below are about OpenTelemetry, &lt;code&gt;tracing&lt;/code&gt; and Loki rather than about chains.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Blockchain sources are ephemeral in a way most sources are not.&lt;/strong&gt; A database you are replicating from still has the row tomorrow. A chain's recent blocks are served by an RPC provider for a while and then they are not, and provider retention is a commercial decision rather than a promise to you. If the pipeline silently drops an event, the evidence it existed may be gone before anyone notices — and nothing upstream will complain, because nothing upstream knows you were listening.&lt;/p&gt;

&lt;p&gt;So &lt;em&gt;"did we see everything, and did we handle it correctly"&lt;/em&gt; is not a comfort question on this platform — it accounts for most of what the product is, which is why an issue that reads like a formatting nicety was not a cosmetic one.&lt;/p&gt;

&lt;p&gt;The issue was one sentence: log lines should carry the active OpenTelemetry &lt;code&gt;trace_id&lt;/code&gt; so a line in &lt;a href="https://grafana.com/docs/loki/latest/" rel="noopener noreferrer"&gt;Loki&lt;/a&gt; links to its trace in &lt;a href="https://grafana.com/docs/tempo/latest/" rel="noopener noreferrer"&gt;Tempo&lt;/a&gt;. It was estimated at a field, a formatter and an afternoon.&lt;/p&gt;

&lt;p&gt;The formatter did take about an afternoon. Making the field mean anything took the rest of the work, and three of the four conditions that had to hold failed &lt;em&gt;silently&lt;/em&gt; — none of them raised an error, panicked or turned a test red, and each produced output that looked entirely correct.&lt;/p&gt;

&lt;p&gt;That silence is the part worth generalising, because observability code has a property that makes it unusually prone to it: it is the instrument you use to check whether everything else works, so nothing else is watching it. A broken pipeline raises an error someone sees. A broken metric reads zero, and zero is a plausible number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four things, not one
&lt;/h2&gt;

&lt;p&gt;Before a &lt;code&gt;trace_id&lt;/code&gt; in a log line is useful, all of these have to hold:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A span exists around the work.&lt;/li&gt;
&lt;li&gt;That span &lt;strong&gt;closes&lt;/strong&gt;, so it gets exported.&lt;/li&gt;
&lt;li&gt;The log event is emitted &lt;strong&gt;inside&lt;/strong&gt; it, and inside the &lt;em&gt;right&lt;/em&gt; one.&lt;/li&gt;
&lt;li&gt;The formatter can &lt;strong&gt;see&lt;/strong&gt; the span's OTel context and writes it out.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The issue named only the fourth, and the service had the first and nothing else.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foktr33owy3z9dyz0r5xa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foktr33owy3z9dyz0r5xa.png" alt="The two paths a trace_id depends on" width="800" height="1383"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The diagram, in words: two paths leave the opened span. The first closes it and exports it, producing the trace. The second carries the log event through the formatter into the JSON line, then through the log pipeline to the clickable link. Both have to finish before that link opens anything; the issue described only the last step of the second.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The formatter is the only step of that chain visible from a log line, which is why an afternoon was a fair estimate for it and a bad one for the feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1 — a span that never closes is never exported
&lt;/h2&gt;

&lt;p&gt;The service's ingestion loop carried &lt;code&gt;#[instrument]&lt;/code&gt; on its top-level &lt;code&gt;run&lt;/code&gt; function, so a span did exist — exactly one, opened at startup and closed at shutdown.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/tokio-rs/tracing-opentelemetry" rel="noopener noreferrer"&gt;&lt;code&gt;tracing-opentelemetry&lt;/code&gt;&lt;/a&gt; ends and exports an OTel span in the subscriber's &lt;code&gt;on_close&lt;/code&gt; callback. A span that never closes is never sent. The process could run for a week with tracing fully configured, the OTLP exporter connected and healthy, and Tempo would receive nothing at all.&lt;/p&gt;

&lt;p&gt;Nothing in the system reports this, because from every component's point of view everything is working: the exporter is up, the sampler says &lt;code&gt;AlwaysOn&lt;/code&gt;, and the configuration is correct. There is simply never anything for it to send.&lt;/p&gt;

&lt;p&gt;The fix is a span whose lifetime matches a unit of work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;info_span!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"ingest_event"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="nf"&gt;.event_id&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="nf"&gt;.in_scope&lt;/span&gt;&lt;span class="p"&gt;(||&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ... one event's worth of work&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="c1"&gt;// the span drops here, and only here does it export&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;An &lt;code&gt;#[instrument]&lt;/code&gt; on a long-running function is a logging construct, not a tracing one.&lt;/strong&gt; It scopes your log lines correctly and it will never produce a trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2 — &lt;code&gt;builder.trace_id&lt;/code&gt; is a root-span-only answer
&lt;/h2&gt;

&lt;p&gt;To write the field, the formatter needs the trace id of the span it is inside. &lt;code&gt;tracing-opentelemetry&lt;/code&gt; stores its per-span state in the registry's extensions, and the obvious implementation is three lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;extensions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="nf"&gt;.extensions&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;otel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;extensions&lt;/span&gt;&lt;span class="py"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OtelData&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;otel&lt;/span&gt;&lt;span class="py"&gt;.builder.trace_id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;OtelData&lt;/code&gt; is public, the field is right there, and this works. On root spans.&lt;/p&gt;

&lt;p&gt;From &lt;code&gt;tracing-opentelemetry-0.28.0/src/layer.rs:887&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Record new trace id if there is no active parent span&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;parent_cx&lt;/span&gt;&lt;span class="nf"&gt;.has_active_span&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="py"&gt;.trace_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.tracer&lt;/span&gt;&lt;span class="nf"&gt;.new_trace_id&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a child span it is &lt;code&gt;None&lt;/code&gt;. The trace id lives in the parent context instead, reachable via &lt;code&gt;parent_cx.span().span_context().trace_id()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is a nasty shape of bug, because the naive version &lt;em&gt;passes the naive test&lt;/em&gt;. Open one span, log inside it, assert the field is present — green. Every real log line in the service is inside a child span, so the field would have been silently absent in production and reliably present in CI.&lt;/p&gt;

&lt;p&gt;The test that catches it logs from inside a &lt;strong&gt;child&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;tracing&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;info_span!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"root"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;_root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="nf"&gt;.enter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;tracing&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;info_span!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"child"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;_child&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="nf"&gt;.enter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nn"&gt;tracing&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;info!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"block ingested"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is worth proving that the test discriminates rather than assuming it. Replacing the parent-context fallback with &lt;code&gt;return None&lt;/code&gt; fails that test and only that test — the other three stay green.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The obvious fixture is not a simpler version of the real case.&lt;/strong&gt; It is a different case — and when the library branches on the difference, a green test is pinning the branch you do not ship.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Trap 3 — &lt;code&gt;.instrument()&lt;/code&gt; on the wrong future compiles and does nothing
&lt;/h2&gt;

&lt;p&gt;Each chain adapter runs its subscribe-and-reconnect loop in a spawned task. &lt;code&gt;tokio::spawn&lt;/code&gt; does not inherit the caller's span, so every reconnect warning was emitted outside any span at all — invisible to the whole scheme.&lt;/p&gt;

&lt;p&gt;The obvious fix, applied three times:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;run_subscription_loop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="nf"&gt;.instrument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads correctly, and it is completely inert.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.rs/tokio/latest/tokio/task/struct.JoinHandle.html" rel="noopener noreferrer"&gt;&lt;code&gt;tokio::spawn&lt;/code&gt;&lt;/a&gt; returns a &lt;code&gt;JoinHandle&lt;/code&gt;, and &lt;code&gt;JoinHandle&lt;/code&gt; is itself a &lt;code&gt;Future&lt;/code&gt;. So &lt;code&gt;.instrument()&lt;/code&gt; accepts it and produces an instrumented &lt;em&gt;handle&lt;/em&gt;, which is dropped on the next line without ever being polled. The task — already handed to the executor — still has no span.&lt;/p&gt;

&lt;p&gt;The type system has no objection, because nothing here is type-incorrect. The only signal is a lint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;warning: unused `tracing::instrument::Instrumented` that must be used
    = note: futures do nothing unless you `.await` or poll them
help: use `let _ = ...` to ignore the resulting value
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that the suggested fix is precisely wrong — &lt;code&gt;let _ = ...&lt;/code&gt; silences the one piece of evidence that the change did nothing. The message body is the actual information: &lt;em&gt;you built a future and threw it away&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;run_subscription_loop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nf"&gt;.instrument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.instrument()&lt;/code&gt; decorates a future you still own. Once a future is with the executor there is nothing left to decorate — same for &lt;code&gt;.timeout()&lt;/code&gt;, &lt;code&gt;.boxed()&lt;/code&gt;, and every other combinator. If you are calling one on a &lt;code&gt;JoinHandle&lt;/code&gt;, ask what you actually meant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 4 — every span closed, and they all landed in one trace
&lt;/h2&gt;

&lt;p&gt;This one survived implementation, review of the implementation, and a written retrospective. It was caught on a second review pass, by asking a question none of the tests asked: not &lt;em&gt;does the span close&lt;/em&gt;, but &lt;em&gt;what is it a child of&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ingest_event&lt;/code&gt; was created contextually, which makes it a child of &lt;code&gt;run&lt;/code&gt; — the process-lifetime span from Trap 1. &lt;code&gt;run&lt;/code&gt; is the root, so &lt;code&gt;run&lt;/code&gt; generates the only trace id that ever exists, and every child inherits it.&lt;/p&gt;

&lt;p&gt;Two sibling event spans under one root, printed from an actual test run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;trace_ids: [
    "f3da095a59a8db160b9f4605b879b2e0",
    "f3da095a59a8db160b9f4605b879b2e0",
]  same = true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So: spans close, spans export, logs carry a well-formed 32-hex-character trace id, every test passes — and clicking a log line opens a "trace" containing every event the process has handled inside the backend's retention window. On a blockchain ingester pulling three chains, that is several hundred thousand spans a day in one trace. That is not a trace in any useful sense; it is a haystack with a link pointing at it.&lt;/p&gt;

&lt;p&gt;The fix is one keyword — make each unit of work its own root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;info_span!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"ingest_event"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="nf"&gt;.event_id&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same probe after the change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;trace_ids: [
    "508d439ce5a118553871703aea27d140",
    "29ec1a9e882c4970d778e47143d50833",
]  same = false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parenting to &lt;code&gt;run&lt;/code&gt; bought nothing anyway — &lt;code&gt;run&lt;/code&gt; never closes, so it never exports. The child was inheriting an id from a span that does not exist downstream.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The general shape: "the span closed" and "the span is in a sensible trace" are separate claims, and only the first one is easy to test.&lt;/strong&gt; A test that asserts a trace id is &lt;em&gt;present&lt;/em&gt; passes identically whether you have one trace per event or one trace per process.&lt;/p&gt;

&lt;h2&gt;
  
  
  The formatter, and the advice I gave myself that was wrong
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tracing-subscriber&lt;/code&gt;'s &lt;code&gt;fmt&lt;/code&gt; layer and the OTel layer are independent — the JSON formatter serialises fields and span &lt;em&gt;names&lt;/em&gt;, and never looks at the OTel &lt;code&gt;SpanContext&lt;/code&gt;. A &lt;code&gt;Layer&lt;/code&gt; cannot add fields to an already-constructed &lt;code&gt;Event&lt;/code&gt; either; the visitor is read-only. So the injection has to happen during serialisation, which means implementing &lt;code&gt;FormatEvent&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There are two ways to do that, and I picked wrong first. My own note-to-self said: own the serialisation with &lt;code&gt;serde_json&lt;/code&gt;, because splicing a field into another formatter's output is fragile string surgery.&lt;/p&gt;

&lt;p&gt;Reading &lt;code&gt;tracing-subscriber&lt;/code&gt;'s &lt;a href="https://github.com/tokio-rs/tracing/blob/master/tracing-subscriber/src/fmt/format/json.rs" rel="noopener noreferrer"&gt;&lt;code&gt;fmt/format/json.rs&lt;/code&gt;&lt;/a&gt; changed my mind. The stock implementation uses &lt;code&gt;WriteAdaptor&lt;/code&gt; and &lt;code&gt;SerializableSpan&lt;/code&gt;, neither of which is public. Reimplementing means writing both by hand — including the part that pulls formatted fields out of span extensions and re-parses them as JSON, which carries a maintainer comment in that file calling it an ugly fix. Roughly 120 lines of replicated private internals, drifting from upstream as the crate evolves, to add one key.&lt;/p&gt;

&lt;p&gt;The fragility argument was also only half right. &lt;em&gt;Suffix&lt;/em&gt; surgery is fragile — inserting before the closing brace means reasoning about whether the object is empty. &lt;em&gt;Prefix&lt;/em&gt; surgery has no such case: the record always opens with &lt;code&gt;{&lt;/code&gt;, and inserting immediately after it preserves the stock key order exactly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;String&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.inner&lt;/span&gt;
    &lt;span class="nf"&gt;.format_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;Writer&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="nf"&gt;current_trace_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trace_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="nf"&gt;write_with_trace_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nb"&gt;None&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="nf"&gt;.write_str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole edit is one &lt;code&gt;strip_prefix&lt;/code&gt;, one guard, and a fallback that emits the record untouched if the assumption ever breaks. A test pins that the delegated JSON shape survives.&lt;/p&gt;

&lt;p&gt;"Don't do string surgery" is a good prior, not a rule. It was written for the case where you own the format — and here the entire point was &lt;em&gt;not&lt;/em&gt; owning it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Absent, not zero
&lt;/h2&gt;

&lt;p&gt;An invalid span context yields the all-zero trace id. Writing &lt;code&gt;"trace_id":"00000000000000000000000000000000"&lt;/code&gt; would satisfy a careless test — the key is present, it is 32 hex characters — and would then match the dashboard's derived-field regex, producing a clickable link to a trace that cannot exist.&lt;/p&gt;

&lt;p&gt;The formatter omits the key entirely instead. Two of its four tests exist only to pin that: no OTel layer installed (a supported deployment — the exporter is optional), and no span entered.&lt;/p&gt;

&lt;p&gt;For a correlation id, absent and wrong are not adjacent failure modes — they are opposites. An absent field tells the reader there is nothing to follow, whereas a wrong one sends someone down a dead end and costs them the time it takes to prove it is one.&lt;/p&gt;

&lt;h2&gt;
  
  
  And then the pipeline was wrong too
&lt;/h2&gt;

&lt;p&gt;Verifying end to end found the part that no amount of Rust would have fixed: two breaks between the service and the dashboard link, neither in the application.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The derived-field matcher was written in logfmt.&lt;/strong&gt; The Grafana datasource config had been carrying this since the stack was built:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;derivedFields&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;datasourceUid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tempo&lt;/span&gt;
    &lt;span class="na"&gt;matcherRegex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trace_id=(&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;w+)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service emits JSON, so &lt;code&gt;trace_id=&lt;/code&gt; never appears in a line that reads &lt;code&gt;"trace_id":"..."&lt;/code&gt;. It had matched nothing for weeks, and there was no way to notice, because nothing emitted a trace id either. Two halves of a feature, both missing, each hiding the other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The log aggregator only parsed the outer envelope.&lt;/strong&gt; &lt;a href="https://vector.dev/docs/" rel="noopener noreferrer"&gt;Vector&lt;/a&gt;'s aggregator ran one transform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;. = parse_json!(.message)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;but its Kafka source had no &lt;code&gt;decoding&lt;/code&gt; block, so the default byte codec made &lt;code&gt;.message&lt;/code&gt; the &lt;em&gt;agent's&lt;/em&gt; envelope rather than the service's log line. That single parse lifts the envelope and leaves the application's JSON as an escaped string still sitting inside &lt;code&gt;.message&lt;/code&gt;. Querying the log store directly, the stored top-level keys were:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;['file', 'kubernetes', 'message', 'source_type', 'stream']
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of the service's own fields made it into that list: &lt;code&gt;level&lt;/code&gt;, &lt;code&gt;target&lt;/code&gt;, &lt;code&gt;span&lt;/code&gt; and the event id were all still inside the escaped string in &lt;code&gt;.message&lt;/code&gt;. Every structured field the service works to emit was one &lt;code&gt;parse_json&lt;/code&gt; short of being queryable — which also explained something I would otherwise have blamed on the log store, namely that the field list for those streams only ever showed labels.&lt;/p&gt;

&lt;p&gt;There is a trap in fixing that too. The existing &lt;code&gt;!&lt;/code&gt; means &lt;em&gt;abort on failure&lt;/em&gt;, and Vector drops the event and increments an error counter. Defensible for the envelope, which the agent always produces. Not defensible for the inner payload: every other container in the cluster logs plain text, and a &lt;code&gt;!&lt;/code&gt; there silently discards all of it.&lt;/p&gt;

&lt;p&gt;With both ends repaired, the two halves finally describe the same string. The matcher, in JSON rather than logfmt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;derivedFields&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;datasourceUid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tempo&lt;/span&gt;
    &lt;span class="na"&gt;matcherRegex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;"trace_id":\s*"(\w+)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a line off the ingest path, captured from a test that renders records through the same formatter production uses — emitted as one line, wrapped here so the keys after &lt;code&gt;trace_id&lt;/code&gt; stay readable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"trace_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"90a79d9c4db18e37d9f45c22338fa3e1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-25T14:37:59.805729Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"level"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"INFO"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"chain event ingested"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"event_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"01a0395b-4c7a-7112-b4ee-2c154b83f8c0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"event_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"block_ingested"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lag_us"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3259&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"block_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"chain_ingestor::application::ingest_blocks"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"span"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"chain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ethereum"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"event_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"01a0395b-4c7a-7112-b4ee-2c154b83f8c0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ingest_event"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"spans"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"chain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ethereum"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"event_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"01a0395b-4c7a-7112-b4ee-2c154b83f8c0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ingest_event"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;trace_id&lt;/code&gt; is the first key because the formatter splices it after the opening brace rather than appending it, which is what keeps the matcher a plain match instead of something that has to reason about position. Everything after it is queryable now that the inner payload is parsed, so &lt;code&gt;level&lt;/code&gt;, &lt;code&gt;event_type&lt;/code&gt; and &lt;code&gt;lag_us&lt;/code&gt; are filters rather than text a human reads.&lt;/p&gt;

&lt;p&gt;That is the entire feature: a line in the log store, a pattern that matches it, and a link that resolves to a trace holding that one event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 5 — a sampler that decides per span, not per trace
&lt;/h2&gt;

&lt;p&gt;Trap 4's fix is one keyword, which is what it costs to read. What it costs to run is arithmetic.&lt;/p&gt;

&lt;p&gt;A root span per event pins span count to event count, and the Kafka publisher already carries its own &lt;code&gt;#[instrument]&lt;/code&gt;, so each event produces two spans rather than one. Three chains at current block rates works out to roughly 222,000 events a day — call it 444,000 spans. The next issue adds transaction streaming to the Ethereum adapter, which takes Ethereum alone from 7,200 events a day to about 1.3 million. Two and a half million spans, from one chain, against a Tempo ingester with a 384Mi memory limit.&lt;/p&gt;

&lt;p&gt;Storage is not where this breaks — retention is 24 hours and the volume stays in single-digit gigabytes. What does not scale is the ingester's memory and the OTLP exporter's batch queue, which drops spans when it fills and reports nothing when it does. Those drops cluster in exactly the busy periods worth looking at, so an accidental sample at an unknown rate is strictly worse than a deliberate one at a known rate.&lt;/p&gt;

&lt;p&gt;That makes &lt;code&gt;AlwaysOn&lt;/code&gt; — the sampler in place until now, which exports every trace and decides nothing — the wrong default at this volume. Replacing it is the obvious move, and the replacement carries a trap of precisely the same shape as the four above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// re-decides at every span&lt;/span&gt;
&lt;span class="nn"&gt;Sampler&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;TraceIdRatioBased&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// decides once, at the root, and every descendant inherits it&lt;/span&gt;
&lt;span class="nn"&gt;Sampler&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;ParentBased&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nn"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Sampler&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;TraceIdRatioBased&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;should_sample&lt;/code&gt; is called per span. A bare ratio sampler therefore evaluates each span independently, so a parent can be sampled in while its child is sampled out. The trace arrives in Tempo with spans missing, and nothing in it indicates that anything is missing. That is worse than not sampling at all, for the same reason an all-zero &lt;code&gt;trace_id&lt;/code&gt; is worse than an absent one: it is a dead end that costs someone the time it takes to prove it is one.&lt;/p&gt;

&lt;p&gt;Writing the test for that was the interesting part. The obvious fixture — pick a ratio, check what the children do — passes against both samplers, because for most ratios they agree. They differ in exactly one case, and the test has to sit on it: a span whose parent arrived &lt;strong&gt;already sampled out&lt;/strong&gt;, at ratio &lt;strong&gt;1.0&lt;/strong&gt;. At 1.0 the bare sampler keeps every trace id unconditionally, so it clears every other assertion in the module and fails only here, where the right answer is to drop a span the ratio would have kept. Get that fixture wrong and the suite passes against the broken sampler.&lt;/p&gt;

&lt;p&gt;The fix then arrived carrying a version of its own trap. The ratio is a fraction, and &lt;code&gt;100&lt;/code&gt; is what someone thinking in percentages writes — a valid &lt;code&gt;f64&lt;/code&gt;, and &lt;code&gt;TraceIdRatioBased&lt;/code&gt; reads anything &lt;code&gt;&amp;gt;= 1.0&lt;/code&gt; as always-on. A config edit meant to cut export by ninety-five percent silently produces the maximum instead. It is refused at load now; clamping it would have been one more thing that looks like it worked.&lt;/p&gt;

&lt;p&gt;The real span rate is still unmeasured, so the deployed ratio is 1.0 and the number will follow the measurement. The wrapper was not the part to defer, though. A rate that is too high shows up on a dashboard; a half-sampled trace looks exactly like a whole one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The take-home
&lt;/h2&gt;

&lt;p&gt;Five traps, one property in common: each produced output that looked right.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A span that never closes exports nothing, and nothing reports it.&lt;/li&gt;
&lt;li&gt;A trace id read from the wrong field is absent in production and present in CI.&lt;/li&gt;
&lt;li&gt;A combinator applied to the wrong future compiles, runs, and does nothing.&lt;/li&gt;
&lt;li&gt;Spans that close correctly can still all land in one trace, and every test still passes.&lt;/li&gt;
&lt;li&gt;A sampler that decides per span rather than per trace yields traces that are half there and look whole — and only one fixture in the space tells the broken one from the correct one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thread through them is that &lt;strong&gt;observability code has no independent observer&lt;/strong&gt;. Everywhere else in a system, being wrong eventually surfaces as an error someone sees. Here, being wrong surfaces as telemetry that looks fine — and you find out during the incident it existed to help with.&lt;/p&gt;

&lt;p&gt;Two habits would have caught all of them. Write the chain from &lt;em&gt;work happens&lt;/em&gt; to &lt;em&gt;human sees it&lt;/em&gt; before estimating any link in it. And for each link, ask what it would look like if it were broken — if the answer is "the same", that link needs a test that would fail.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you have hit a different flavour of silently-fine telemetry — or think per-event root spans are the wrong call and &lt;code&gt;run&lt;/code&gt; should have been the trace — I would genuinely like to hear which one bit you. The door is open at &lt;a href="https://cordata.tech/en/contact" rel="noopener noreferrer"&gt;cordata.tech/contact&lt;/a&gt;. Related reading: &lt;a href="https://cordata.tech/en/blog/one-port-three-client-crates" rel="noopener noreferrer"&gt;One port, three client crates&lt;/a&gt; for the architecture of the service this post instruments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>observability</category>
      <category>opentelemetry</category>
      <category>devops</category>
    </item>
    <item>
      <title>One port, three client crates — a hexagonal Rust ingester</title>
      <dc:creator>László Hadházy</dc:creator>
      <pubDate>Fri, 28 Aug 2026 15:39:51 +0000</pubDate>
      <link>https://dev.to/lhadhazy/one-port-three-client-crates-a-hexagonal-rust-ingester-30ce</link>
      <guid>https://dev.to/lhadhazy/one-port-three-client-crates-a-hexagonal-rust-ingester-30ce</guid>
      <description>&lt;p&gt;Most of what makes a data platform hard is not the modelling. It is the boundaries: the seams where someone else's SDK meets code you have to maintain, and where a vendor's idea of a "block" meets yours. Those seams are where change arrives, and drawing them in the wrong place is expensive in a way that stays invisible until the second integration.&lt;/p&gt;

&lt;p&gt;This is a worked example of drawing one — real trait, real adapter code, and the reasoning that produced them. The system ingests on-chain data from three chains through three client crates written by teams with no reason to agree on anything. If you work on ingestion of any other kind — CDC connectors, partner APIs, IoT feeds, market data — the chains are incidental and the problem is identical: heterogeneous sources, a domain that should not know about them, and the question of how much to abstract before it costs more than it saves.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Notes from the workbench.&lt;/strong&gt; A design walkthrough rather than a tutorial: how one Rust service reads three blockchains through a single trait, why that trait has the signature it does, and — the part most ports-and-adapters write-ups skip — the three places we deliberately stopped abstracting.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every crypto observability project starts with one chain. Someone writes an Ethereum indexer that subscribes to &lt;code&gt;newHeads&lt;/code&gt;, decodes blocks and publishes to Kafka, and it does the job. Two months later a second chain arrives — Solana, Polkadot, an L2 — and the pressure question is whether it lives in the same service or gets its own.&lt;/p&gt;

&lt;p&gt;The same service is usually the honest answer. The operational cost of running two ingester binaries — two deployments, two config surfaces, two dashboards, two on-call runbooks — is high, and none of it is fundamental. What &lt;em&gt;is&lt;/em&gt; fundamental is that each chain is served by a different Rust crate with a completely different shape. The Ethereum crate (&lt;a href="https://alloy.rs/" rel="noopener noreferrer"&gt;alloy&lt;/a&gt;) hands you a WebSocket subscription that yields &lt;code&gt;Header&lt;/code&gt; values. The Solana crate (&lt;a href="https://docs.rs/solana-client/" rel="noopener noreferrer"&gt;solana-client&lt;/a&gt;) makes you run &lt;em&gt;two&lt;/em&gt; clients side by side — a &lt;code&gt;PubsubClient&lt;/code&gt; for slot notifications and an &lt;code&gt;RpcClient&lt;/code&gt; for actually fetching blocks. The Polkadot crate (&lt;a href="https://github.com/paritytech/subxt" rel="noopener noreferrer"&gt;subxt&lt;/a&gt;) yields decoded &lt;code&gt;Block&amp;lt;PolkadotConfig&amp;gt;&lt;/code&gt; values, but reaching their extrinsics costs two further awaits.&lt;/p&gt;

&lt;p&gt;Those are three genuinely different mental models, and the reflex fix — a big &lt;code&gt;Chain&lt;/code&gt; enum branching to per-chain code paths everywhere the ingester runs — grows a new arm each time a chain is added, and eventually leaks each crate's idioms into every layer above it. The better move is a &lt;a href="https://alistair.cockburn.us/hexagonal-architecture/" rel="noopener noreferrer"&gt;&lt;strong&gt;port&lt;/strong&gt;&lt;/a&gt;: one Rust trait that all chain sources implement, with just enough shape to compose the layer above without knowing which chain is downstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually makes it hexagonal
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://alistair.cockburn.us/hexagonal-architecture/" rel="noopener noreferrer"&gt;Ports and adapters&lt;/a&gt; gets described as &lt;em&gt;"use interfaces at the boundaries"&lt;/em&gt;, which is true and useless — every codebase has interfaces at boundaries. The actual rule is narrower, and it is about which direction the dependencies point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The domain defines the trait. The adapter implements it. The domain never imports the adapter.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That inverts the direction dependencies naturally want to flow. Without inversion you get layering: the application imports the Ethereum client, wraps it in something, and the wrapper's shape is decided by whatever alloy happens to expose. With inversion the trait is written to suit the &lt;em&gt;use case&lt;/em&gt;, and each vendor SDK has to contort itself to fit — which is the point, because the contortion is then confined to one file per vendor.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5aqtm740je1qydnwwgc9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5aqtm740je1qydnwwgc9.png" alt="Ports and adapters layout of the chain ingester" width="800" height="971"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The diagram, in words: chains drive the system from the top, through adapters that wrap each vendor SDK and implement the &lt;code&gt;ChainSource&lt;/code&gt; port. The core reacts, then calls outward through &lt;code&gt;EventSink&lt;/code&gt; and &lt;code&gt;MetricsSink&lt;/code&gt; to Kafka and Prometheus below. The arrows show data; the dependencies all run the other way, because every one of those five adapters is written against a trait the core defines, and the core names none of them. &lt;code&gt;main.rs&lt;/code&gt; sits outside the picture and wires the concrete adapters in at construction time.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In Rust this is enforced rather than encouraged. &lt;code&gt;alloy&lt;/code&gt; appears in exactly one file; if the application module tried to import it, the build would fail on an unresolved dependency, because the crate graph does not permit it. That is a stronger guarantee than a convention nobody checks at review time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a driving port hands back a channel
&lt;/h2&gt;

&lt;p&gt;Hexagonal classifies ports by &lt;strong&gt;who invokes whom&lt;/strong&gt;. That is the whole test, and it is worth stating as a question about the actor on the other side rather than about the direction data happens to move:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Driving&lt;/strong&gt; (primary) ports are &lt;strong&gt;invoked by the outside world&lt;/strong&gt;. Something out there acts on its own — a request arrives, a block is produced — and our application is what gets kicked into behaviour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Driven&lt;/strong&gt; (secondary) ports are &lt;strong&gt;invoked by the application&lt;/strong&gt;. The actor on the other side does nothing until our use case reaches out and kicks &lt;em&gt;it&lt;/em&gt; into behaviour. A database does not write itself; Kafka does not decide to accept a message.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The diagram above stacks them: driving at the top, driven at the bottom. Cockburn's original hexagon and most renderings you will meet elsewhere put driving on the left and driven on the right — the same distinction, turned ninety degrees.&lt;/p&gt;

&lt;p&gt;Data-flow direction is the wrong test and gives the wrong answer here. &lt;code&gt;EventSink&lt;/code&gt; moves data outward and &lt;code&gt;ChainSource&lt;/code&gt; moves it inward, but that is not what separates them. What separates them is &lt;strong&gt;whose activity decides that work happens at all&lt;/strong&gt;. The sink does nothing until our use case tells it to. The chain does not wait for us — blocks are produced whether we are listening or not, and our job is to keep up with an actor we do not control.&lt;/p&gt;

&lt;p&gt;That is deliberately not a claim about who makes the function call at the transport layer, because the answer there varies and does not matter. Our Solana adapter &lt;em&gt;polls&lt;/em&gt;: it subscribes to slot notifications and then fetches each block over RPC, one call per slot. A backfill adapter would poll a whole block range with no subscription at all. Neither is a driven port.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hiding that difference is exactly what the adapter is for.&lt;/strong&gt; Whether the external world pushes at us or has to be pulled from is a property of someone else's SDK, and letting it change the port's classification would mean the application's structure shifts every time a vendor changes their delivery model. The adapter polls on behalf of an actor whose schedule it does not set — the port stays driving either way, and the use case above it never learns which it was.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;ChainSource&lt;/code&gt; is a &lt;strong&gt;driving&lt;/strong&gt; port, and the chain adapters sit on the driving side where an HTTP controller would in a request-driven service.&lt;/p&gt;

&lt;p&gt;The interesting part is the &lt;em&gt;shape&lt;/em&gt; it takes, because the usual driving adapter calls &lt;strong&gt;into&lt;/strong&gt; the application. An HTTP handler receives a request and invokes a use case. A message listener receives a message and invokes a handler. Push, in both cases, with the adapter owning the call.&lt;/p&gt;

&lt;p&gt;Ours inverts that, handing back a receiver and letting the application pull from it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;IngestorResult&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nn"&gt;mpsc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Receiver&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChainEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IngestorError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SourceHandle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;That inversion is deliberate, and cancellation is the reason.&lt;/strong&gt; A push-based driving adapter owns the control loop — it decides when the application runs. This service cannot allow that, because it has to wait on incoming events and a shutdown signal &lt;em&gt;at the same time&lt;/em&gt; — &lt;a href="https://docs.rs/tokio/latest/tokio/macro.select.html" rel="noopener noreferrer"&gt;&lt;code&gt;tokio::select!&lt;/code&gt;&lt;/a&gt;, which races several futures and proceeds with whichever finishes first. A handler being called from inside an adapter has nowhere to put that choice. Ownership of the loop has to stay with the application.&lt;/p&gt;

&lt;p&gt;So the port keeps the classification (the chain drives) and inverts the delivery (the application pulls). Written out, the trade is: give up the adapter's ability to call in, gain the application's ability to compose event handling with cancellation, backpressure and anything else it needs to &lt;code&gt;select!&lt;/code&gt; over.&lt;/p&gt;

&lt;p&gt;The cost lands on the adapter, which now has to run something of its own to fill that channel — a task, a client it owns, and a reconnect loop. Which is the next section's problem, and where the three crates stop resembling each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which pattern is doing which job
&lt;/h2&gt;

&lt;p&gt;Three patterns are stacked here, and their names get used interchangeably often enough to be worth pinning down:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Where it lives here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hexagonal / Ports &amp;amp; Adapters&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;Architectural.&lt;/em&gt; Dependency inversion at the system boundary&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ChainSource&lt;/code&gt;, &lt;code&gt;EventSink&lt;/code&gt;, &lt;code&gt;MetricsSink&lt;/code&gt; and the module layout that enforces them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Adapter (GoF, object adapter)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;Object-level.&lt;/em&gt; Wraps one vendor API behind an interface we own&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;EthereumAdapter&lt;/code&gt; wrapping &lt;code&gt;alloy::Provider&lt;/code&gt;; &lt;code&gt;SolanaAdapter&lt;/code&gt; wrapping &lt;code&gt;PubsubClient&lt;/code&gt; + &lt;code&gt;RpcClient&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Bridge (GoF)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;Object-level.&lt;/em&gt; Stands between us and a &lt;strong&gt;family&lt;/strong&gt; of interchangeable backends&lt;/td&gt;
&lt;td&gt;Not the chain adapters — &lt;code&gt;PrometheusMetricsSink&lt;/code&gt; instruments via the &lt;code&gt;metrics&lt;/code&gt; crate's &lt;code&gt;Recorder&lt;/code&gt; trait, so the backend swaps without touching call sites (see &lt;a href="https://cordata.tech/en/blog/one-port-three-client-crates#the-driven-side-briefly" rel="noopener noreferrer"&gt;the driven side&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Hexagonal is the architecture; the GoF Adapter is one common way to fill its adapter slot. Adapter wraps &lt;em&gt;one&lt;/em&gt; vendor API, Bridge abstracts over a &lt;em&gt;family&lt;/em&gt; of them — and the two compose, because an adapter at the port boundary can use Bridge internally, which is exactly what the metrics sink does.&lt;/p&gt;

&lt;p&gt;A fourth pattern shows up at the layer above: &lt;strong&gt;fan-out (task-per-source)&lt;/strong&gt;. The application runs one &lt;code&gt;tokio::spawn&lt;/code&gt; per adapter and composes cancellation with a &lt;code&gt;select!&lt;/code&gt;. That is only expressible because every adapter returns the same channel-and-handle pair — the pattern above the port is enabled by the shape chosen at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The port in full
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[async_trait]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;trait&lt;/span&gt; &lt;span class="n"&gt;ChainSource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Send&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Sync&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;'static&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Chain&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;IngestorResult&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nn"&gt;mpsc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Receiver&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChainEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IngestorError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;SourceHandle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;is_healthy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;chain()&lt;/code&gt; is a tag and &lt;code&gt;is_healthy()&lt;/code&gt; backs the readiness probe. Everything above concerned &lt;code&gt;subscribe()&lt;/code&gt;, and there is one alternative worth addressing, because it is the shape most Rust reviewers reach for first.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The port's return type — a channel and a cancel handle rather than a stream — is what lets three genuinely different client crates plug in without the application layer knowing which one it has.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Why not &lt;code&gt;impl Stream&lt;/code&gt;?&lt;/strong&gt; It is the idiomatic Rust answer, it composes with the whole &lt;code&gt;futures&lt;/code&gt; ecosystem, and it would have been wrong here. A stream carries values; it carries no way to stop producing them. So the caller would have to compose cancellation itself &lt;em&gt;and&lt;/em&gt; every adapter would need a cancellation token threaded through its internals to produce a stream that honours it. That token-plumbing spreads through the trait hierarchy, each crate ends up implementing shutdown slightly differently, and the uniformity the port existed to provide is gone — three chains that look alike at the type level and behave differently on SIGTERM.&lt;/p&gt;

&lt;p&gt;By returning &lt;code&gt;(mpsc::Receiver&amp;lt;_&amp;gt;, SourceHandle)&lt;/code&gt;, the port pushes the cancellation problem &lt;em&gt;inside&lt;/em&gt; the adapter. Each adapter spawns its own task, owns its own client, and hands the caller two things: a channel to read events from, and an opaque handle whose &lt;code&gt;Drop&lt;/code&gt; implementation shuts the task down. The caller never touches a cancellation token; the adapter's spawn/select internals never leak upward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three adapters
&lt;/h2&gt;

&lt;p&gt;All three adapter files start the same way — domain imports, client-crate imports, a struct and an impl block — and then diverge almost completely.&lt;/p&gt;

&lt;p&gt;Each snippet opens with that file's vendor imports, because those lines &lt;em&gt;are&lt;/em&gt; the boundary — every crate-specific name in the whole service appears in one of these three blocks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ethereum&lt;/strong&gt; (&lt;code&gt;ethereum.rs&lt;/code&gt;) uses alloy's WebSocket provider. &lt;code&gt;Provider::subscribe_blocks()&lt;/code&gt; returns a &lt;code&gt;Subscription&amp;lt;Header&amp;gt;&lt;/code&gt;; the adapter drains that stream, converts each header into a &lt;code&gt;ChainEvent::BlockIngested&lt;/code&gt;, and pushes it to the mpsc.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;alloy&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;
    &lt;span class="nn"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;Provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ProviderBuilder&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nn"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;types&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nn"&gt;transports&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="nn"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;WsConnect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TransportError&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;ws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;WsConnect&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ws_url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;ProviderBuilder&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.connect_ws&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="nf"&gt;.subscribe_blocks&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="nf"&gt;.into_stream&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="nf"&gt;.next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalise_block_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;event_tx&lt;/span&gt;&lt;span class="nf"&gt;.send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Solana&lt;/strong&gt; (&lt;code&gt;solana.rs&lt;/code&gt;) needs two clients running side by side. There is no combined "subscribe to full blocks" primitive — &lt;code&gt;PubsubClient::slot_subscribe()&lt;/code&gt; gives you slot notifications, and for each slot you have to call &lt;code&gt;RpcClient::get_block_with_config()&lt;/code&gt; separately to fetch the actual block body.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;solana_client&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;
    &lt;span class="nn"&gt;nonblocking&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="nn"&gt;pubsub_client&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;PubsubClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;rpc_client&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;RpcClient&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nn"&gt;rpc_config&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;CommitmentConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RpcBlockConfig&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nn"&gt;rpc_response&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;SlotInfo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;solana_transaction_status_client_types&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;
    &lt;span class="n"&gt;TransactionDetails&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UiConfirmedBlock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UiTransactionEncoding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// slot_subscribe hands back two things: the stream and an&lt;/span&gt;
&lt;span class="c1"&gt;// unsubscribe callback. The callback is dropped — a subscription here&lt;/span&gt;
&lt;span class="c1"&gt;// ends by dropping the client along with it.&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;slot_notifications&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_unsubscribe&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;pubsub_client&lt;/span&gt;&lt;span class="nf"&gt;.slot_subscribe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slot_info&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;slot_notifications&lt;/span&gt;&lt;span class="nf"&gt;.next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// second client, second round trip — the slot notification carries&lt;/span&gt;
    &lt;span class="c1"&gt;// no block body&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rpc_client&lt;/span&gt;
        &lt;span class="nf"&gt;.get_block_with_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slot_info&lt;/span&gt;&lt;span class="py"&gt;.slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rpc_block_config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalise_slot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slot_info&lt;/span&gt;&lt;span class="py"&gt;.slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;Utc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;event_tx&lt;/span&gt;&lt;span class="nf"&gt;.send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Polkadot&lt;/strong&gt; (&lt;code&gt;polkadot.rs&lt;/code&gt;) uses subxt 0.50, whose block API changed shape in the 0.50 release — &lt;code&gt;client.stream_blocks()&lt;/code&gt; yields finalized blocks by default, but the block header doesn't carry the timestamp. That has to be pulled from &lt;code&gt;Timestamp.Now&lt;/code&gt; storage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;subxt&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="nn"&gt;client&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Block&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OnlineClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PolkadotConfig&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;subxt&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;rpcs&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="nn"&gt;client&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;RpcClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;methods&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;LegacyRpcMethods&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;OnlineClient&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PolkadotConfig&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ws_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;block_stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="nf"&gt;.stream_blocks&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;block_stream&lt;/span&gt;&lt;span class="nf"&gt;.next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;at_block&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="nf"&gt;.at&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;timestamp_addr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;subxt&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;dynamic&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Timestamp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Now"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;at_block&lt;/span&gt;&lt;span class="nf"&gt;.storage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timestamp_addr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="nf"&gt;.decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// …build ChainEvent::BlockIngested with a real block_time&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three crates, three streaming models, three timestamp-extraction strategies. What they share is the shape at the boundary: each returns an &lt;code&gt;IngestorResult&amp;lt;(mpsc::Receiver&amp;lt;_&amp;gt;, SourceHandle)&amp;gt;&lt;/code&gt; from &lt;code&gt;subscribe()&lt;/code&gt;, and each spawns its own task that owns the client and pushes events into the channel.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flu1y1rlhjudyo58z2sjb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flu1y1rlhjudyo58z2sjb.png" alt="The ChainSource port with three chain adapters behind it" width="800" height="552"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The diagram, in words: the application layer talks to &lt;code&gt;ChainSource&lt;/code&gt; as a trait object; three concrete adapters — each wrapping a different client crate — implement it. The application never imports a chain-specific crate; every crate's types stay inside its own adapter file.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The dependency arrow only points one way. The application module doesn't import &lt;code&gt;alloy&lt;/code&gt;, &lt;code&gt;solana-client&lt;/code&gt;, or &lt;code&gt;subxt&lt;/code&gt;. It couldn't compile if it tried — those imports live in exactly one place per chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The driven side, briefly
&lt;/h2&gt;

&lt;p&gt;Everything so far has been the driving port, because that is where the three crates disagree. The driven ports are worth a look for contrast — they are shaped differently, and for reasons that follow from the classification rather than from taste.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[async_trait]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;trait&lt;/span&gt; &lt;span class="n"&gt;EventSink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Send&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Sync&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;'static&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;publish_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ChainEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;IngestorResult&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;IngestorResult&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cd"&gt;/// Outbound port: observability counters.&lt;/span&gt;
&lt;span class="cd"&gt;///&lt;/span&gt;
&lt;span class="cd"&gt;/// Infallible by design — recording a metric must never fail an ingestion.&lt;/span&gt;
&lt;span class="cd"&gt;///&lt;/span&gt;
&lt;span class="cd"&gt;/// # Cardinality contract&lt;/span&gt;
&lt;span class="cd"&gt;///&lt;/span&gt;
&lt;span class="cd"&gt;/// The label set used by implementations of this trait is closed: the only&lt;/span&gt;
&lt;span class="cd"&gt;/// permitted label dimension is `chain`. Implementations must not introduce&lt;/span&gt;
&lt;span class="cd"&gt;/// additional dimensions, and new methods must not take parameters carrying&lt;/span&gt;
&lt;span class="cd"&gt;/// unbounded values — block numbers, transaction hashes, wallet addresses,&lt;/span&gt;
&lt;span class="cd"&gt;/// log indices.&lt;/span&gt;
&lt;span class="cd"&gt;///&lt;/span&gt;
&lt;span class="cd"&gt;/// The rule lives at the port, not at the adapter, because future backends&lt;/span&gt;
&lt;span class="cd"&gt;/// (OTLP-metrics, vendor SaaS) inherit it via this trait. Enforcement is&lt;/span&gt;
&lt;span class="cd"&gt;/// code review at the call site: there is no compile-time guard, and a&lt;/span&gt;
&lt;span class="cd"&gt;/// panicking sink would turn a slow cardinality bug into a fast process kill.&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;trait&lt;/span&gt; &lt;span class="n"&gt;MetricsSink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Send&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Sync&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;'static&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;record_block_ingested&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Chain&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;record_event_published&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Chain&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;record_chain_head_lag_ms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Chain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lag_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;i64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;EventSink&lt;/code&gt; needs neither a channel nor a cancel handle, because it is request-and-response: the application calls, the adapter answers, and the call ends. That is the ordinary shape for a driven port, and it is the shape &lt;code&gt;ChainSource&lt;/code&gt; could not use — a subscription has no point at which the call is finished.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;MetricsSink&lt;/code&gt; is different again — synchronous, returning nothing at all. The port removes the failure case rather than documenting a convention about it: there is no &lt;code&gt;Result&lt;/code&gt; for a caller to mishandle, and no way for an observability problem to become an ingestion problem.&lt;/p&gt;

&lt;p&gt;Its cardinality contract is worth the space it takes. Prometheus stores one time series per unique label combination, so a single unbounded label — a block number, a transaction hash — turns a per-block counter into an OOM kill on the scrape target, weeks later, with nothing failing in a test. That is invisible at review time unless someone has written down what "closed" means.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A port is a good place to put an invariant that outlives any particular adapter.&lt;/strong&gt; That is a use for the boundary beyond swapping implementations, and the one most likely to be missed when ports and adapters get described as "just interfaces".&lt;/p&gt;

&lt;h2&gt;
  
  
  What the port bought us
&lt;/h2&gt;

&lt;p&gt;Once every adapter returns a channel receiver, everything above the port becomes uniform. The fan-out layer that runs all three adapters concurrently is one &lt;code&gt;tokio::spawn&lt;/code&gt; and one &lt;code&gt;select!&lt;/code&gt; per adapter to compose cancellation with event reception, with no chain-specific branching anywhere in it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chain_sources&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_handle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="nf"&gt;.subscribe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="nf"&gt;.chain&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;loop&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;select!&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;biased&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;cancel_rx&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="nf"&gt;.recv&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;publish_to_kafka&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;log_and_reconnect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="nb"&gt;None&lt;/span&gt;         &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reconnect and backoff end up uniform without being shared. Because each adapter owns its own subscribe loop, the reconnect logic — exponential backoff, max-attempts, "send &lt;code&gt;SourceDisconnected&lt;/code&gt; on the channel when retries exhaust" — lives inside the adapter, not in the application. All three use the same 2s → 4s → 8s → 64s cadence, not because the port enforces it — there is no shared code between the subscribe loops — but because fixing what "the boundary" meant made the pattern trivial to copy.&lt;/p&gt;

&lt;p&gt;Observability follows the same shape. Each adapter calls a shared &lt;code&gt;observe_head_lag(...)&lt;/code&gt; helper, which clamps the value and then calls &lt;code&gt;MetricsSink::record_chain_head_lag_ms(chain, lag)&lt;/code&gt; — the trait method above. One metric, &lt;code&gt;chain_head_lag_ms&lt;/code&gt;, distinguished by its &lt;code&gt;chain&lt;/code&gt; label rather than by three per-chain metric names, which is the cardinality contract being honoured rather than merely stated.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we deliberately did NOT abstract
&lt;/h2&gt;

&lt;p&gt;Every abstraction has a failure mode where it doesn't know when to stop. "Ports and adapters" easily becomes "shared crate that every adapter must import", and then the shared crate becomes a place where you paper over the client crates' differences with common helpers, and then the helpers become the &lt;em&gt;actual&lt;/em&gt; interface — the port is decorative. Each of the following is a place we stopped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One adapter file per chain, no shared adapter helpers.&lt;/strong&gt; &lt;code&gt;ethereum.rs&lt;/code&gt;, &lt;code&gt;solana.rs&lt;/code&gt;, &lt;code&gt;polkadot.rs&lt;/code&gt;. The rate-limited-warn atomic that keeps a clock-skew warning from firing every block now exists in all three, copied rather than extracted. That is roughly seven duplicated lines per chain, and it buys the certainty that changing one chain's warn cadence cannot silently change another's. At three copies this is still the right trade; it is also the point at which a fourth chain would make us re-examine it, which is worth saying out loud rather than discovering later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No shared &lt;code&gt;run_subscription_loop&lt;/code&gt; generic.&lt;/strong&gt; Tempting — the shape is nearly identical across the three — but "nearly identical" hides subxt's two-await-hop for extrinsics, Solana's separate &lt;code&gt;RpcClient::get_block_with_config&lt;/code&gt; call per slot, and Ethereum's alloy subscription-id reconnect quirk that the outer reconnect loop is written specifically to route around. A generic loop over &lt;code&gt;impl Stream&lt;/code&gt; would either need a config trait wider than the port itself, or it would push those idiosyncrasies down into the adapter as flags — worse than duplication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The domain conversion is per-adapter.&lt;/strong&gt; &lt;code&gt;normalise_block_header&lt;/code&gt;, &lt;code&gt;normalise_slot&lt;/code&gt;, &lt;code&gt;normalise_block&lt;/code&gt;. Each takes crate-specific types (alloy's &lt;code&gt;Header&lt;/code&gt;, solana's &lt;code&gt;UiConfirmedBlock&lt;/code&gt;, subxt's &lt;code&gt;Block&amp;lt;PolkadotConfig&amp;gt;&lt;/code&gt;) and produces the domain-owned &lt;code&gt;ChainEvent&lt;/code&gt;. This function is the &lt;em&gt;actual&lt;/em&gt; adapter boundary — the one place per chain where crate-specific types are named. Making it generic would defeat the entire point of the pattern.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Recording what you did &lt;em&gt;not&lt;/em&gt; abstract is what keeps the abstraction boundary where you put it. Each of those is a point where further extraction is defensible in isolation, and each would have added a layer justified by symmetry rather than by a requirement — which is how a port stops being a boundary and becomes a shared-code dependency with a trait on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;The one-spawn-per-chain shape holds below O(dozens) of sources. Each adapter owns a Tokio task, an mpsc buffer and a connection, which stays cheap at forty and stops being cheap at four hundred — a per-rollup adapter for every L2, say. At that point you want a shared event loop with per-source filters, paid for in coarser cancellation.&lt;/p&gt;

&lt;p&gt;The port also assumes every source is a live subscription. Historical backfill needs a second port on the same side — a range source rather than a stream — because stretching &lt;code&gt;ChainSource&lt;/code&gt; to cover both would put a mode flag in the one place the design works hardest to keep clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The take-home
&lt;/h2&gt;

&lt;p&gt;The pattern underneath all of this is ports and adapters, applied narrowly: three traits owned by the application core, five adapters implementing them, and a module layout that makes the dependency direction a compile error rather than a convention. Nothing exotic — the value came from where the boundaries were drawn and from being deliberate about where they stopped.&lt;/p&gt;

&lt;p&gt;Two things worth stealing, independent of the stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Give a driving port over an unbounded source a channel and a cancel handle, not a stream.&lt;/strong&gt; That is the shape that lets the application compose cancellation and backpressure without a token threaded through every adapter. Driven ports do not need it — &lt;code&gt;EventSink&lt;/code&gt; is a plain async call, because a request ends.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the crate-specific types inside one file per source.&lt;/strong&gt; The domain conversion function is the real boundary: everything above it is a &lt;code&gt;ChainEvent&lt;/code&gt;, everything below it is &lt;code&gt;alloy::Header&lt;/code&gt; or &lt;code&gt;subxt::Block&amp;lt;PolkadotConfig&amp;gt;&lt;/code&gt; or whatever the client crate hands you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You pay one spawn and one mpsc per source. In exchange, adding the next one is a new adapter file, a new arm in a config enum, and no change anywhere above the port — a trade worth taking on any ingester whose sources are heterogeneous.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you have drawn this boundary somewhere different — or think returning a stream would have been the right call and can say why — I would genuinely like to hear where it held or bent. The door is open at &lt;a href="https://cordata.tech/en/contact" rel="noopener noreferrer"&gt;cordata.tech/contact&lt;/a&gt;. The companion post on the observability side of this same service is now up: &lt;a href="https://cordata.tech/en/blog/trace-id-was-the-easy-part" rel="noopener noreferrer"&gt;&lt;em&gt;The trace_id was the easy part&lt;/em&gt;&lt;/a&gt; — what it took to make a log line point at the right trace, and the four things that had to be true before it meant anything.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>architecture</category>
      <category>blockchain</category>
      <category>programming</category>
    </item>
    <item>
      <title>The catalog is the API — a governed mesh over MCP</title>
      <dc:creator>László Hadházy</dc:creator>
      <pubDate>Thu, 27 Aug 2026 08:14:00 +0000</pubDate>
      <link>https://dev.to/lhadhazy/the-catalog-is-the-api-a-governed-mesh-over-mcp-1o18</link>
      <guid>https://dev.to/lhadhazy/the-catalog-is-the-api-a-governed-mesh-over-mcp-1o18</guid>
      <description>&lt;p&gt;A domain engineer should be able to ask an assistant &lt;em&gt;"which sensitivity level applies to a column that stores an EU IBAN?"&lt;/em&gt; and get the answer from the live LakeFormation policy rather than from a wiki page somebody last edited in 2023.&lt;/p&gt;

&lt;p&gt;That sentence is easy to write and considerably harder to earn. The interesting part is not that a model can answer the question — it is &lt;em&gt;where the answer comes from&lt;/em&gt;, and what stops the model from answering when it should not.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Companion to &lt;a href="https://cordata.tech/en/blog/fabric-mesh-on-aws" rel="noopener noreferrer"&gt;Fabric + Mesh on AWS&lt;/a&gt;.&lt;/strong&gt; I wrote that sentence at the end of July; that post's &lt;a href="https://cordata.tech/en/blog/fabric-mesh-on-aws#s3" rel="noopener noreferrer"&gt;§ 3&lt;/a&gt; ended with a forward-pointer and no landing page: &lt;em&gt;"the next affordance this ontology deserves is an MCP-exposed catalog tool… It is a natural next step; it is not in the reference implementation yet."&lt;/em&gt; This post is the design behind that sentence. The tool is still unbuilt; what follows marks which parts are protocol, which are pattern, and which are neither yet.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; moved a long way while those posts were being written. Everything below is checked against revision &lt;strong&gt;&lt;code&gt;2026-07-28&lt;/code&gt;&lt;/strong&gt;, and the revision is named on purpose: a post about a protocol that dates itself is more useful than one that pretends to be timeless.&lt;/p&gt;

&lt;h2&gt;
  
  
  § 1 — Why the catalog, and not the warehouse
&lt;/h2&gt;

&lt;p&gt;The reflex when someone says &lt;em&gt;"let the agent query our data"&lt;/em&gt; is text-to-SQL against the lake. &lt;a href="https://cordata.tech/en/blog/pipeline-half-openlineage-gx#s7" rel="noopener noreferrer"&gt;Part 2 § 7&lt;/a&gt; argued why that is the wrong surface: raw SQL against a governed lake is unsafe for an agentic consumer, and a typed semantic API above it is what makes a question answerable without hallucination.&lt;/p&gt;

&lt;p&gt;That names one tier — the semantic layer, where &lt;code&gt;monthly_recurring_revenue&lt;/code&gt; means one thing to every consumer. This post is about the other, and the two are easy to swap. The semantic layer answers questions &lt;strong&gt;about numbers&lt;/strong&gt;: &lt;em&gt;what was fraud recall at 30 days last quarter?&lt;/em&gt; The catalog answers questions &lt;strong&gt;about the data itself&lt;/strong&gt;: &lt;em&gt;which sensitivity level applies to a column that stores an EU IBAN? Who owns the claims domain? May this role read it?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;None of it is queryable from the lake. It is metadata &lt;em&gt;about&lt;/em&gt; the datasets rather than data in them — LakeFormation's &lt;a href="https://cordata.tech/en/blog/fabric-mesh-on-aws#s3" rel="noopener noreferrer"&gt;LF-tags&lt;/a&gt;, ownership records, grant expressions, descriptors — four stores behind four APIs in the governance account, reached through the console or Terraform.&lt;/p&gt;

&lt;p&gt;A determined engineer can assemble the answer by hand, and nothing here is impossible without an assistant. But that is the wrong counterfactual. Nobody writes a four-API join at 16:40 while adding a column to a table — they copy the tag from the column next to it, or ask in Slack and get somebody's memory of a decision made in March. The tool competes against guessing, not against scripting. And &lt;em&gt;"stores an EU IBAN"&lt;/em&gt; is not a lookup key in any case: no record is filed under that phrase, so answering means matching an informal description against the columns already classified — which is § 7's problem, not SQL's.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A catalog question is not a query with tighter permissions on it.&lt;/strong&gt; It is a different question against a different store — and the answer counts only if the model retrieved it rather than knew it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That constraint is sharper than it looks. A domain engineer tagging a new column asks which sensitivity level applies to an EU IBAN, and the model answers &lt;code&gt;high&lt;/code&gt; — fluently, and correctly: that is exactly how &lt;code&gt;policy_curated.customer.iban&lt;/code&gt; is tagged in the reference.&lt;/p&gt;

&lt;p&gt;Now the same engineer tags an IBAN column in an analytics extract that is replicated across regions. The sensitivity is still &lt;code&gt;high&lt;/code&gt;, so the answer still looks right. But &lt;code&gt;residency&lt;/code&gt; records where the &lt;em&gt;data&lt;/em&gt; sits, not where the account holder banks — the source table is &lt;code&gt;residency: eu&lt;/code&gt; and this copy is &lt;code&gt;residency: global&lt;/code&gt;, which changes which grants reach it while leaving the sensitivity untouched. A model reasoning from world knowledge will offer &lt;code&gt;eu&lt;/code&gt; anyway, because that is what &lt;em&gt;EU&lt;/em&gt; IBAN says, and validation accepts it — &lt;code&gt;eu&lt;/code&gt; is a permitted value. The tag set is legal, it is wrong, and nothing downstream will say so.&lt;/p&gt;

&lt;p&gt;That is the failure mode — right about the obvious key, quietly wrong about the one carrying the policy. The platform team owns this ontology, and they are not standing behind the domain engineer while the column gets tagged. Retrieving how comparable columns are &lt;em&gt;already&lt;/em&gt; classified is what puts them there in effect; asking a model what it knows about IBANs cannot. So the assistant's job here is retrieval and routing; the judgement stays in the policy store.&lt;/p&gt;

&lt;h2&gt;
  
  
  § 2 — Tools or Resources — the decision everything else hinges on
&lt;/h2&gt;

&lt;p&gt;MCP exposes two server-side primitives that look interchangeable in a diagram and are not. The specification separates them by &lt;em&gt;who decides&lt;/em&gt;. Tools are &lt;strong&gt;model-controlled&lt;/strong&gt; — the language model discovers and invokes them from its own reading of the conversation. Resources are &lt;strong&gt;application-driven&lt;/strong&gt; — the host application decides what to put in context, typically through a picker the user drives.&lt;/p&gt;

&lt;p&gt;A catalog is both, and splitting it correctly is the most consequential decision in the design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The LF-tag ontology is a Resource.&lt;/strong&gt; From &lt;a href="https://cordata.tech/en/blog/fabric-mesh-on-aws#s3" rel="noopener noreferrer"&gt;§ 3&lt;/a&gt; of the reference: five keys, twenty-one values, small enough to fit on a whiteboard. That is a &lt;em&gt;document&lt;/em&gt;, and a host should be able to pin it into context wholesale — the same way an IDE pins an open file — so the model reasons against the full vocabulary instead of discovering it one lookup at a time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A grant check is a Tool.&lt;/strong&gt; &lt;em&gt;"May this role read this table?"&lt;/em&gt; is a question with arguments, asked mid-reasoning, whose answer the model cannot predict. It has to be invoked.&lt;/p&gt;

&lt;p&gt;The costs of getting this backwards are asymmetric. Everything-as-Tools burns turns re-fetching a twenty-one-value vocabulary the model could have held from the start. Everything-as-Resources is worse — the model cannot look anything up on its own, and a host forced to anticipate every dataset a conversation might touch will anticipate wrong.&lt;/p&gt;

&lt;p&gt;The split shows up in the wire format, and it is worth seeing the two side by side. The ontology is one fixed document, so it is a &lt;code&gt;Resource&lt;/code&gt; with a &lt;code&gt;uri&lt;/code&gt;. Datasets are parameterised, so they are &lt;code&gt;ResourceTemplate&lt;/code&gt;s with a &lt;code&gt;uriTemplate&lt;/code&gt; — the protocol uses &lt;a href="https://datatracker.ietf.org/doc/html/rfc6570" rel="noopener noreferrer"&gt;RFC 6570 URI templates&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ← resources/list — one fixed document, addressed directly&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resultType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"complete"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ttlMs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cacheScope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"private"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"uri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cordata://ontology/lf-tags"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lf-tag-ontology"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"LF-tag ontology"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The governance-owned tag vocabulary. Keys, permitted values, and what each value implies."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"mimeType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"application/json"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c1"&gt;// ← resources/templates/list — a shape, expanded per domain and table&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resultType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"complete"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ttlMs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cacheScope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"private"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resourceTemplates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"uriTemplate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cordata://domain/{domain}/dataset/{table}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dataset"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Dataset descriptor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Schema, ownership, LF-tags and contract for one published dataset."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"mimeType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"application/json"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A URI with no variables in it is not a template — it is a resource that has been filed in the wrong list. The distinction is mechanical, and it is the same one the section has been arguing all along.&lt;/p&gt;

&lt;p&gt;Template arguments can be auto-completed through the protocol's completion API, which means a host can offer a domain picker without the server writing a picker.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe0cg8jm944ntvhqv7uy9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe0cg8jm944ntvhqv7uy9.png" alt="The catalog server's reach across accounts, and where the metadata plane stops" width="800" height="1830"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diagram read-out.&lt;/strong&gt; Three tiers, and the boundary that matters is not the one between accounts. &lt;strong&gt;Outside the governed accounts&lt;/strong&gt; sit the host application, which pins the ontology as a Resource, and the language model, which invokes lookups as Tools — the host may well run inside a consumer account, but the model inference usually does not, which is the whole reason § 6 exists. Both paths enter the &lt;strong&gt;central governance account&lt;/strong&gt; — the same one that holds the LakeFormation admin plane in the &lt;a href="https://cordata.tech/en/blog/fabric-mesh-on-aws#s1" rel="noopener noreferrer"&gt;reference topology&lt;/a&gt; — where the caller's token is validated as this server's own audience before anything is read. That validation is the account boundary doing its job: the server holds its own LakeFormation grants rather than borrowing the caller's, which is what § 5 means by refusing passthrough. Where scope falls short the answer is a challenge, not an empty list — an empty one would tell the agent the dataset does not exist, which is a different sentence and a false one (§ 5).&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;producer accounts&lt;/strong&gt; are where the distinction has to be exact. They hold two different things, and the catalog reaches one of them. Column schema lives in the domain's Glue catalog, and &lt;code&gt;get_dataset&lt;/code&gt; reads it — that is metadata, and the dotted edge is real traffic. The S3 objects beside it hold the rows, and they carry no inbound edge at all. So the line the design defends is the &lt;strong&gt;metadata plane against the data plane&lt;/strong&gt;, not one account against another: a catalog server that reads a producer's Glue catalog is behaving correctly, and the same server reading a producer's S3 object is the failure § 4 is built to prevent.&lt;/p&gt;

&lt;p&gt;Which leaves the decision the section opened with, now with the consequences attached. Resource or Tool is not a tidy way to organise an API surface — it decides &lt;strong&gt;who may pull a thing into context&lt;/strong&gt;: the host, deliberately and in advance, or the model, mid-reasoning and on its own initiative. That single choice then fixes what varies by the caller's scopes (§ 5), what a cache may hold and for how long (§ 7), and which primitive the safety envelope has to be enforced on rather than annotated (§ 4). Get it backwards and none of those three has a stable answer, because the thing being governed keeps changing hands. Everything downstream is a consequence of this split, which is why it comes first.&lt;/p&gt;

&lt;h2&gt;
  
  
  § 3 — The tool set, and where its schema comes from
&lt;/h2&gt;

&lt;p&gt;The catalog server exposes four tools. § 1 put three questions to the catalog — &lt;em&gt;who owns the claims domain? which sensitivity level applies to a column that stores an EU IBAN? may this role read it?&lt;/em&gt; — and each already has its answer sitting in a store the &lt;a href="https://cordata.tech/en/blog/fabric-mesh-on-aws#s1" rel="noopener noreferrer"&gt;reference topology&lt;/a&gt; holds: ownership in DataZone, schema in the domain's Glue catalog, the permitted vocabulary in the LF-tag ontology, the reach of a role in the LakeFormation grants. The sensitivity question takes two tools rather than one, because retrieving precedent and validating a proposal are different operations — the exchange below shows why.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Question it answers&lt;/th&gt;
&lt;th&gt;Determinism&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list_domains&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;What domains exist, and who owns them?&lt;/td&gt;
&lt;td&gt;Exact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_dataset&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;What is this dataset's schema, owner, and tag set?&lt;/td&gt;
&lt;td&gt;Exact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;resolve_lf_tag&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Is this tag set valid against the ontology?&lt;/td&gt;
&lt;td&gt;Exact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;check_grant_expression&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Would this role's grant expression reach this dataset?&lt;/td&gt;
&lt;td&gt;Exact&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one is a lookup or an evaluation. None asks a model to decide anything — which is what makes the tool set auditable, and which is the inclusion rule in full: &lt;strong&gt;a question earns a tool when its answer already exists as a record in the governance plane.&lt;/strong&gt; That rule is what generates the exclusions in § 4 as well — rows are data rather than metadata, a per-user grant listing is an access-review artefact with its own approval path, cross-domain composition is a subscription workflow. Those are not gaps in the tool set; they are the same rule, applied.&lt;/p&gt;

&lt;p&gt;Most of the schema for these tools does not need inventing. &lt;a href="https://cordata.tech/en/blog/pipelines-as-descriptors#s2" rel="noopener noreferrer"&gt;Part 1 § 2&lt;/a&gt; ended on the rule that one Pydantic model is imported by both CI and the executor, because two implementations of the same rules drift. The catalog server becomes its third importer: ownership and the policy contract are the descriptor's own fields, so &lt;code&gt;get_dataset&lt;/code&gt; reuses them rather than restating them. Only the column list is declared locally, because the descriptor does not carry one — columns come from the domain's Glue catalog, which is the same plane boundary the § 2 diagram draws.&lt;/p&gt;

&lt;p&gt;What follows is a fragment of the catalog server's own source: a module-level declaration of one tool. It is evaluated once, when the server process starts, and the resulting object is what every &lt;code&gt;tools/list&lt;/code&gt; response hands back. Nothing here runs per request — the two &lt;code&gt;model_json_schema()&lt;/code&gt; calls happen at import, which is why a change to the descriptor model reaches the advertised schema without anyone editing the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pipeline_runtime.descriptor&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Contract&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Metadata&lt;/span&gt;

&lt;span class="n"&gt;GET_DATASET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get_dataset&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Dataset lookup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Schema, ownership and policy tags for one published dataset.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inputSchema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;properties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;domain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Owning domain, e.g. fraud or claims&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-mcp-header&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Domain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;domain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;additionalProperties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;# The governance-owned halves are not redefined here. One model, three
&lt;/span&gt;    &lt;span class="c1"&gt;# consumers: CI validation, the executor, and now the catalog server.
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;outputSchema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;properties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metadata&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_json_schema&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;   &lt;span class="c1"&gt;# name, domain, owner
&lt;/span&gt;            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contract&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_json_schema&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;   &lt;span class="c1"&gt;# freshness_sla, lf_tags
&lt;/span&gt;            &lt;span class="c1"&gt;# Columns are the one part the descriptor does not carry: they are
&lt;/span&gt;            &lt;span class="c1"&gt;# read from the domain's Glue catalog, so their shape is declared here.
&lt;/span&gt;            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;columns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;array&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;properties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lf_tags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;additionalProperties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
                    &lt;span class="p"&gt;},&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lf_tags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;additionalProperties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metadata&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contract&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;columns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;additionalProperties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One declaration, three consumers. The model that fails a pull request when a descriptor is malformed is the same model that tells an agent what shape to expect. When a field is added, all three move together or none do.&lt;/p&gt;

&lt;p&gt;The declaration says what the answer will look like. The handler is what fills it in, and it runs once per &lt;code&gt;tools/call&lt;/code&gt; — two reads against the domain's account, joined on the column name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Identity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# § 5 — before either read, not after
&lt;/span&gt;
    &lt;span class="c1"&gt;# The server runs in the governance account; the catalog it needs lives in
&lt;/span&gt;    &lt;span class="c1"&gt;# the domain's. One assumed role per call, session-named for the caller, so
&lt;/span&gt;    &lt;span class="c1"&gt;# the producer account's CloudTrail attributes the read to a person.
&lt;/span&gt;    &lt;span class="n"&gt;producer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="nf"&gt;assume_reader_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;glue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;producer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;glue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;lf&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;producer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lakeformation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# `&amp;lt;domain&amp;gt;_curated` is the per-domain Glue database the blueprint creates
&lt;/span&gt;    &lt;span class="c1"&gt;# (reference § 2: name = "${var.domain_name}_curated"), so the tool takes a
&lt;/span&gt;    &lt;span class="c1"&gt;# domain and a table and never asks the caller for a database name.
&lt;/span&gt;    &lt;span class="n"&gt;cols&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;glue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DatabaseName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_curated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cols&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;StorageDescriptor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Columns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;tagged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_resource_lf_tags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Resource&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TableWithColumns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DatabaseName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_curated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ColumnNames&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;]}},&lt;/span&gt;
        &lt;span class="n"&gt;ShowAssignedLFTags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;by_column&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TagKey&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TagValues&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LFTags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tagged&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LFTagsOnColumns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                            &lt;span class="c1"&gt;# validated against outputSchema on the way out
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metadata&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;metadata_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contract&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;contract_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;columns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
             &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lf_tags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;by_column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{})}&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cols&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in there is clever, which is the point: two AWS calls and a dictionary join. The role assumption is the one line worth pausing on — it is the cross-account hop from &lt;a href="https://cordata.tech/en/blog/fabric-mesh-on-aws#s1" rel="noopener noreferrer"&gt;§ 1&lt;/a&gt; of the reference, and naming the session after the caller is what keeps the producer account's CloudTrail useful. A read attributed to &lt;code&gt;mcp-catalog-server&lt;/code&gt; tells an auditor nothing; one attributed to the engineer who asked is the same behaviour record &lt;a href="https://cordata.tech/en/blog/behaviour-first-governance-in-practice#s4" rel="noopener noreferrer"&gt;Behaviour-first governance&lt;/a&gt; already projects from. Neither call can return a row — &lt;code&gt;get_table&lt;/code&gt; returns column definitions and &lt;code&gt;get_resource_lf_tags&lt;/code&gt; returns tags, and there is no third call to Athena. The read-only property § 4 insists on is a fact about which APIs the handler is able to reach, visible in the source, rather than a claim made in an annotation.&lt;/p&gt;

&lt;p&gt;The protocol is strict about the contract this creates: where an output schema is declared, servers &lt;strong&gt;MUST&lt;/strong&gt; return structured results conforming to it, and clients &lt;strong&gt;SHOULD&lt;/strong&gt; validate against it.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;x-mcp-header&lt;/code&gt; annotation on &lt;code&gt;domain&lt;/code&gt; is worth a note. A parameter marked this way is mirrored into an &lt;code&gt;Mcp-Param-Domain&lt;/code&gt; HTTP header, so a load balancer or WAF can route on it without parsing the request body — which maps directly onto the account-per-domain topology from &lt;a href="https://cordata.tech/en/blog/fabric-mesh-on-aws#s1" rel="noopener noreferrer"&gt;§ 1&lt;/a&gt; of the reference. It carries its own warning, and it is a real one: header values are visible to network intermediaries, so sensitive parameters must never be marked this way. A domain name is fine. A subject identifier is not.&lt;/p&gt;

&lt;h3&gt;
  
  
  The IBAN question, answered
&lt;/h3&gt;

&lt;p&gt;Back to the sentence that started this. A domain engineer is tagging a new column and asks which sensitivity level applies to one holding an EU IBAN.&lt;/p&gt;

&lt;p&gt;The answer does not come from the model's opinion about IBANs. It comes from &lt;strong&gt;precedent already in the catalog&lt;/strong&gt;. &lt;a href="https://cordata.tech/en/blog/fabric-mesh-on-aws#s3" rel="noopener noreferrer"&gt;§ 3&lt;/a&gt; of the reference tags exactly this column, in Terraform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lakeformation_resource_lf_tags"&lt;/span&gt; &lt;span class="s2"&gt;"iban_column_high"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;database&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"policy_curated"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;table&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"customer"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;column&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"iban"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;lf_tag&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sensitivity"&lt;/span&gt;
    &lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the exchange is a retrieval, then a validation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// → tools/call — find how comparable columns are already classified&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"get_dataset"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"domain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"policy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"table"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"customer"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c1"&gt;// ← result&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"resultType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"complete"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"structuredContent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"columns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iban"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"lf_tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sensitivity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"residency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eu"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                     &lt;/span&gt;&lt;span class="nl"&gt;"subject_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"customer"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"policy-domain"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c1"&gt;// → tools/call — validate the proposed tag set before it is written&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"resolve_lf_tag"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sensitivity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"residency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eu"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                 &lt;/span&gt;&lt;span class="nl"&gt;"subject_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"customer"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second call is not a formality. It runs the same &lt;code&gt;policy.resolve()&lt;/code&gt; the executor runs, against the same governance-owned vocabulary, and it fails the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;declared&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;known&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ontology&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# governance-owned vocabulary
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;declared&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;known&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;UnknownTagKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;known&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;known&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;UnknownTagValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;known&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;declared&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An assistant proposing &lt;code&gt;sensitivity: hgh&lt;/code&gt; gets &lt;code&gt;UnknownTagValue&lt;/code&gt; with the permitted set attached, at the moment of tagging — rather than a table written with a tag nothing grants against. That is the defence-in-depth the original callout claimed, and it is worth being clear about which half does the work: the model reduces search time and the ontology refuses invalid answers, and neither half is sufficient alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  § 4 — The safety envelope is enforced, not annotated
&lt;/h2&gt;

&lt;p&gt;The tempting shortcut is declaring the tool set read-only. MCP has a field that looks exactly like it should do the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"annotations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"readOnlyHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destructiveHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The schema's own documentation removes any doubt about what that is worth — this is the doc comment on &lt;code&gt;ToolAnnotations&lt;/code&gt; in &lt;a href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2026-07-28/schema.ts" rel="noopener noreferrer"&gt;&lt;code&gt;schema/2026-07-28/schema.ts&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;NOTE: all properties in &lt;code&gt;ToolAnnotations&lt;/code&gt; are &lt;strong&gt;hints&lt;/strong&gt;. They are not guaranteed to provide a faithful description of tool behavior (including descriptive properties like &lt;code&gt;title&lt;/code&gt;). Clients should never make tool use decisions based on &lt;code&gt;ToolAnnotations&lt;/code&gt; received from untrusted servers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So &lt;code&gt;readOnlyHint&lt;/code&gt; is display metadata. It tells a host how to render a confirmation dialog. It enforces nothing, and a safety envelope that depends on it is not an envelope. &lt;strong&gt;Read-only has to hold by construction.&lt;/strong&gt; The handler has no write path, the database role has no &lt;code&gt;INSERT&lt;/code&gt;, and the grants stop at &lt;code&gt;SELECT&lt;/code&gt; and &lt;code&gt;DESCRIBE&lt;/code&gt;. A server built that way stays read-only no matter what its annotations say.&lt;/p&gt;

&lt;p&gt;What the tool set deliberately does not expose matters as much as what it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No raw rows.&lt;/strong&gt; The catalog answers questions about datasets, never from them. An agent that can read &lt;code&gt;customer.iban&lt;/code&gt; values has defeated the entire LF-tag model in one call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No per-user grant listings.&lt;/strong&gt; &lt;em&gt;"Would this expression reach this dataset"&lt;/em&gt; is answerable. &lt;em&gt;"Show me everyone who can read the claims domain"&lt;/em&gt; is an access-review artefact with its own approval path, not a tool call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No join hints across domains.&lt;/strong&gt; Cross-domain composition is a subscription workflow in DataZone, and routing around it through a helpful assistant is exactly the failure the workflow exists to prevent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every call gets logged into the governance-plane event stream that &lt;a href="https://cordata.tech/en/blog/behaviour-first-governance-in-practice#s4" rel="noopener noreferrer"&gt;Behaviour-first governance § 4&lt;/a&gt; already builds its audit projection from. The tool set is not an exception to the governance model — it is another source of behaviour, and it should show up in the same query a supervisor's ninety-day access question runs against.&lt;/p&gt;

&lt;p&gt;One further trap, new enough in the specification to be easy to miss. MCP has no protocol-level session, so servers needing state across calls hand back a handle — and the specification's guidance here — non-normative, but unusually direct — is that for authenticated servers &lt;em&gt;"a handle is a name, not a capability"&lt;/em&gt;, to be re-validated against the caller's authorization on every call. A &lt;code&gt;dataset_id&lt;/code&gt; returned by &lt;code&gt;list_domains&lt;/code&gt; is not a ticket. If the second call skips that re-check, the first call has quietly become an access-granting operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  § 5 — Authorization is the governance plane
&lt;/h2&gt;

&lt;p&gt;This is the part that changed most, and it lands directly on the mesh argument.&lt;/p&gt;

&lt;p&gt;Honesty about scope first, because the specification's framing is easy to misread. &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization" rel="noopener noreferrer"&gt;Protocol Requirements&lt;/a&gt; opens by declaring authorization &lt;strong&gt;OPTIONAL&lt;/strong&gt; for MCP implementations, and then says why in the next three lines: HTTP-based transports &lt;strong&gt;SHOULD&lt;/strong&gt; conform, STDIO transports &lt;strong&gt;SHOULD NOT&lt;/strong&gt; — they &lt;em&gt;"retrieve credentials from the environment"&lt;/em&gt; instead — and alternative transports &lt;strong&gt;MUST&lt;/strong&gt; follow the best practices of their own protocol.&lt;/p&gt;

&lt;p&gt;So OPTIONAL is about which transports the specification covers, not about how much security a server owes its callers. A stdio server is a local subprocess started by the host: there is no browser to redirect, no audience to bind a token to, and the operating system already decided who may run it. An OAuth flow would protect nothing the process boundary does not already protect. Nothing about that concession applies to a catalog server, which is remote, HTTP, and multi-tenant by construction — the &lt;strong&gt;SHOULD&lt;/strong&gt; lands squarely on it.&lt;/p&gt;

&lt;p&gt;And once a server is inside that scope, the requirements stop being soft:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;MCP servers &lt;strong&gt;MUST&lt;/strong&gt; implement OAuth 2.0 Protected Resource Metadata (&lt;a href="https://datatracker.ietf.org/doc/html/rfc9728" rel="noopener noreferrer"&gt;RFC 9728&lt;/a&gt;); clients &lt;strong&gt;MUST&lt;/strong&gt; use it for authorization-server discovery.&lt;/li&gt;
&lt;li&gt;Clients &lt;strong&gt;MUST&lt;/strong&gt; implement Resource Indicators (&lt;a href="https://www.rfc-editor.org/rfc/rfc8707.html" rel="noopener noreferrer"&gt;RFC 8707&lt;/a&gt;) and send &lt;code&gt;resource&lt;/code&gt; on &lt;strong&gt;both&lt;/strong&gt; authorization and token requests.&lt;/li&gt;
&lt;li&gt;Servers &lt;strong&gt;MUST&lt;/strong&gt; validate that access tokens were issued specifically for them as the intended audience, and &lt;strong&gt;MUST NOT&lt;/strong&gt; accept or transit any other tokens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last requirement is the confused-deputy protection stated normatively: token passthrough is prohibited. A catalog server cannot take the token it was handed and replay it against LakeFormation on the caller's behalf. It has to be its own audience, with its own grants.&lt;/p&gt;

&lt;p&gt;On AWS that prohibition costs nothing, because there is nothing to pass through to. &lt;strong&gt;LakeFormation has never heard of a bearer token.&lt;/strong&gt; Its grants are written against IAM principals, so an OAuth access token is not a weaker credential there — it is not a credential at all. The identity has to be translated, and that translation is where this section stops being about MCP and starts being about your accounts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_SAFE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^\w+=,.@-]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# STS: alphanumerics and +=,.@- only
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;assume_reader_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Identity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# 1. Scope check, against the token this server already validated as its
&lt;/span&gt;    &lt;span class="c1"&gt;#    own audience. The token does not travel any further than this line.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;catalog:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:read&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;InsufficientScope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;catalog:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:read&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. The server assumes the domain's reader role under its OWN identity.
&lt;/span&gt;    &lt;span class="c1"&gt;#    The caller is carried as a name and a session tag, never as a
&lt;/span&gt;    &lt;span class="c1"&gt;#    credential — an OIDC `sub` may contain characters STS rejects.
&lt;/span&gt;    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;assume_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;RoleArn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arn:aws:iam::&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ACCOUNTS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:role/cordata-catalog-reader&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;RoleSessionName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mcp-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;_SAFE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;Tags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cordata:caller&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;]}],&lt;/span&gt;
        &lt;span class="n"&gt;DurationSeconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Credentials&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aws_access_key_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AccessKeyId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aws_secret_access_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SecretAccessKey&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aws_session_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SessionToken&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;assume_role&lt;/code&gt; rather than &lt;code&gt;assume_role_with_web_identity&lt;/code&gt; is deliberate. Federating the caller's own JWT into AWS would mean forwarding a token this server accepted as its own audience on to a third party — the passthrough the &lt;strong&gt;MUST NOT&lt;/strong&gt; above prohibits. The token stops at the scope check.&lt;/p&gt;

&lt;p&gt;So the governance chain has three links, no two of them enforced by the same system, and only one of them living in the MCP server at all:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Link&lt;/th&gt;
&lt;th&gt;Enforced by&lt;/th&gt;
&lt;th&gt;Owned by&lt;/th&gt;
&lt;th&gt;Decides&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Role trust policy&lt;/td&gt;
&lt;td&gt;IAM, in the domain's account&lt;/td&gt;
&lt;td&gt;Platform team, via the account blueprint&lt;/td&gt;
&lt;td&gt;Whether this server may assume the role at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LF-tag grant expression&lt;/td&gt;
&lt;td&gt;LakeFormation, governance account&lt;/td&gt;
&lt;td&gt;Governance&lt;/td&gt;
&lt;td&gt;Which columns come back&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scope check&lt;/td&gt;
&lt;td&gt;The MCP server's own code&lt;/td&gt;
&lt;td&gt;The server&lt;/td&gt;
&lt;td&gt;Whether the call is attempted in the first place&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The server holds none of that policy — a scope check and an &lt;code&gt;AssumeRole&lt;/code&gt; call is the whole of it, which is the entire reason a compromised catalog server is a contained problem rather than a catastrophic one.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;role's trust policy&lt;/strong&gt; decides who may assume it at all, and it is platform-owned — part of the same domain-account blueprint as everything else in &lt;a href="https://cordata.tech/en/blog/fabric-mesh-on-aws#s2" rel="noopener noreferrer"&gt;§ 2&lt;/a&gt; of the reference, not something a domain writes by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_policy_document"&lt;/span&gt; &lt;span class="s2"&gt;"catalog_reader_trust"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;statement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;effect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;

    &lt;span class="c1"&gt;# sts:TagSession is not optional here — without it the Tags= argument&lt;/span&gt;
    &lt;span class="c1"&gt;# in the handler fails, and the caller's identity never reaches CloudTrail.&lt;/span&gt;
    &lt;span class="nx"&gt;actions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"sts:AssumeRole"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"sts:TagSession"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="nx"&gt;principals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AWS"&lt;/span&gt;
      &lt;span class="nx"&gt;identifiers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;catalog_server_role_arn&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# governance account, one role&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;condition&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;test&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"StringEquals"&lt;/span&gt;
      &lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sts:ExternalId"&lt;/span&gt;
      &lt;span class="nx"&gt;values&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;catalog_external_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One named principal, in one account. Nothing else, in any account, can assume this role. A domain that wants to stop answering catalog questions removes the trust statement, and no coordination with the server is required.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;LF-tag grant&lt;/strong&gt; on that role decides which columns come back, and it is governance-owned — written once against a tag expression, never against a table name.&lt;/p&gt;

&lt;p&gt;And the &lt;strong&gt;scope check stays in the MCP server&lt;/strong&gt;, necessarily — AWS never sees the caller's token, so no IAM policy can condition on what is in it. That check is ordinary application code and can therefore have bugs, which is why the role it guards is scoped to a single domain: a bug there costs one domain's metadata, not every domain's.&lt;/p&gt;

&lt;p&gt;The sentence that matters most for a federated mesh sits in the &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/server/tools" rel="noopener noreferrer"&gt;tools chapter&lt;/a&gt;, and repeats verbatim for &lt;code&gt;resources/list&lt;/code&gt;. The tool set &lt;strong&gt;MUST NOT&lt;/strong&gt; vary per-connection, but it &lt;strong&gt;MAY&lt;/strong&gt; vary &lt;em&gt;"by the authorization presented on the request — for example, returning only the tools the caller's granted scopes permit — since credentials are per-request input, not connection state."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That is account-per-domain expressed in a protocol: a fraud engineer's token surfaces fraud datasets, and the identical server shows a different catalog to the governance function. Not a filtered view rendered by a client — a different response, decided server-side, per request.&lt;/p&gt;

&lt;p&gt;And when the caller asks for something outside their scope, the correct answer is not an empty list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;403&lt;/span&gt; &lt;span class="ne"&gt;Forbidden&lt;/span&gt;
&lt;span class="na"&gt;WWW-Authenticate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bearer error="insufficient_scope",&lt;/span&gt;
&lt;span class="s"&gt;                         scope="catalog:claims:read",&lt;/span&gt;
&lt;span class="s"&gt;                         resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",&lt;/span&gt;
&lt;span class="s"&gt;                         error_description="Read access to the claims domain is required"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty result tells an agent &lt;em&gt;"no such dataset"&lt;/em&gt;, and a competent agent will believe it and tell the user the data does not exist. A scope challenge tells it &lt;em&gt;what to ask for&lt;/em&gt;, and the client can run a step-up authorization flow. The specification also asks servers to emit all required scopes in a single challenge rather than one at a time, because incremental challenges force repeated round-trips for one operation.&lt;/p&gt;

&lt;p&gt;This maps onto grant expressions better than anything designed for the purpose would have. &lt;em&gt;"You cannot see this"&lt;/em&gt; and &lt;em&gt;"this does not exist"&lt;/em&gt; are different sentences, and only one of them is true.&lt;/p&gt;

&lt;h2&gt;
  
  
  § 6 — DSGVO (the GDPR) — what the design keeps out of scope
&lt;/h2&gt;

&lt;p&gt;Two legal statements, both narrow enough that anyone can check them against the text. &lt;a href="https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32016R0679" rel="noopener noreferrer"&gt;Art. 28(3)(a) DSGVO&lt;/a&gt; requires a processor to act only on the controller's documented instructions. &lt;a href="https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32016R0679" rel="noopener noreferrer"&gt;Art. 5(2)&lt;/a&gt; requires the controller to be able to demonstrate compliance. Everything else in this section is architecture rather than law, and I will keep the two apart.&lt;/p&gt;

&lt;p&gt;The architectural claim is about scope. § 4's rule — questions about datasets, never from them — means a &lt;code&gt;sensitivity=high&lt;/code&gt; tag and a column name cross the boundary and an IBAN never does. That is worth stating plainly, because the reflex assumption about an assistant near a governed lake is the opposite one. An assistant that reaches rows raises the whole processor question; this design's contribution is to keep one surface on the other side of that line rather than to answer it.&lt;/p&gt;

&lt;p&gt;What the protocol contributes is evidence. Audience-bound tokens make a call attributable to a named client acting for a named principal rather than to an ambient credential, and prohibited token passthrough stops the chain widening silently to a fourth party. Whether the resulting record is legally required is not mine to say. Where it goes is checkable, and it is worth being precise rather than waving at the earlier post.&lt;/p&gt;

&lt;p&gt;Every call in this design leaves an AWS trail before it leaves an MCP one: &lt;code&gt;sts:AssumeRole&lt;/code&gt; into the producer account, then &lt;code&gt;glue:GetTable&lt;/code&gt; and &lt;code&gt;lakeformation:GetResourceLFTags&lt;/code&gt; inside it. Those are CloudTrail events, and the cross-account stitching in &lt;a href="https://cordata.tech/en/blog/behaviour-first-governance-in-practice#s4" rel="noopener noreferrer"&gt;Behaviour-first governance § 4&lt;/a&gt; already reads exactly this shape — its own diagram has the &lt;code&gt;AssumeRole into producer&lt;/code&gt; hop in it. The session name from the handler is what makes the trail resolve to a person rather than a service. One caveat that post is explicit about and this one should repeat: CloudTrail data events are &lt;strong&gt;not on by default&lt;/strong&gt;, so "no new reporting path" holds only where that Terraform is already applied.&lt;/p&gt;

&lt;p&gt;The RoPA projection is a different matter, and the honest answer is that a catalog read does not appear in it. &lt;a href="https://cordata.tech/en/blog/behaviour-first-governance-in-practice#s2" rel="noopener noreferrer"&gt;§ 2&lt;/a&gt; derives RoPA from OpenLineage &lt;code&gt;RunEvent&lt;/code&gt;s — jobs moving data — and a tool call is not a job run. That is a gap only if you expected the catalog to be a processing activity. Under the scoping above it is not one, so the absence is consistent rather than missing. The reference runtime's ontology carries &lt;code&gt;subject_type&lt;/code&gt; and &lt;code&gt;retention&lt;/code&gt; because the pipelines that &lt;em&gt;do&lt;/em&gt; move data need them for that projection, not because the catalog is doing anything to a data subject.&lt;/p&gt;

&lt;p&gt;Two cautions that are not legal claims at all:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Organisational, before architectural.&lt;/strong&gt; A works council will hear &lt;em&gt;"an AI can see the data catalog"&lt;/em&gt; as a monitoring question long before an architecture one, so bring the tool inventory and the audit projection to that meeting rather than the protocol diagram.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aggregation, not any single answer.&lt;/strong&gt; One dataset name is unremarkable; the complete list of them is a map of how the business is organised — which domains exist, what they are called, where the regulated ones sit. An agent that can enumerate everything holds that map, and no individual call is the moment it was handed over. That is the argument for scoping &lt;code&gt;list_domains&lt;/code&gt; by authorization exactly as § 5 permits: domain names are not harmless in bulk merely because each one is harmless alone.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  § 7 — Retrieval over a closed set
&lt;/h2&gt;

&lt;p&gt;§ 3's IBAN exchange skipped a step. The handler turns a domain and a table into &lt;code&gt;policy_curated.customer&lt;/code&gt; on its own — but how did the assistant arrive at &lt;code&gt;domain: policy, table: customer&lt;/code&gt; to begin with?&lt;/p&gt;

&lt;p&gt;The naive answer is to let the model guess a table name. The better one exploits a property of this problem — the vocabulary is &lt;em&gt;closed and tiny&lt;/em&gt;. Five keys, twenty-one values, and dataset and column names that are enumerable.&lt;/p&gt;

&lt;p&gt;For a closed set, embedding the vocabulary and retrieving the nearest entries beats asking a model to produce a value from memory, for a reason that has nothing to do with model quality: retrieval &lt;strong&gt;cannot return a value that is not in the set&lt;/strong&gt;, and generation always can. A model asked to invent a sensitivity level will invent &lt;code&gt;pii&lt;/code&gt; or &lt;code&gt;restricted&lt;/code&gt; — plausible, absent from the ontology, and rejected by &lt;code&gt;policy.resolve()&lt;/code&gt; one step later. Retrieval over the twenty-one permitted values cannot produce that failure at all.&lt;/p&gt;

&lt;p&gt;That disposes of invalid values. It does not touch the harder case from § 1, where the model proposes &lt;code&gt;residency: eu&lt;/code&gt; for a globally replicated copy — a permitted value, accepted by &lt;code&gt;resolve()&lt;/code&gt;, and wrong anyway. No vocabulary check catches that one, because the vocabulary was never violated. What catches it is a second retrieval against a different corpus: the classifications already applied to comparable columns. Asking &lt;code&gt;get_dataset&lt;/code&gt; what the other columns in that same extract carry answers from precedent, and the reply stops depending on what the phrase &lt;em&gt;EU IBAN&lt;/em&gt; suggests.&lt;/p&gt;

&lt;p&gt;So the shape is: embed the ontology values and the catalog's column descriptions once, retrieve candidates for &lt;em&gt;"stores an EU IBAN"&lt;/em&gt;, and let the tools do the rest. Generation belongs where the answer is genuinely open — a description, a summary of why a dataset exists — and nowhere near a policy value.&lt;/p&gt;

&lt;p&gt;That word &lt;em&gt;once&lt;/em&gt; is where the cost sits. An embedding index is a snapshot; the catalog it describes is not. Datasets get published, columns get reclassified, and a retrieval that was correct on Monday starts answering from a world that has moved — silently, because a stale index returns confident nearest matches rather than errors.&lt;/p&gt;

&lt;p&gt;The protocol has an answer for the part of this it can see. List and read results alike carry &lt;code&gt;ttlMs&lt;/code&gt; and &lt;code&gt;cacheScope&lt;/code&gt;, so a client knows how long the answer it just received may be trusted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resultType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"complete"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;/* … */&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ttlMs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cacheScope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"private"&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;// never "public": this list varies by scope&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing about this is stored in the catalog. &lt;code&gt;ttlMs&lt;/code&gt; is a property of the response, not of the asset — the server picks a number as it answers, from what it knows about how quickly that particular answer goes stale. The LF-tag ontology changes by governance pull request, so it can afford minutes. A dataset list changes whenever any domain publishes, so it can afford less. A single &lt;code&gt;resources/read&lt;/code&gt; of one descriptor sits somewhere between. None of that is catalog metadata; it is a policy the server holds, and the only thing the client is told is the number.&lt;/p&gt;

&lt;p&gt;The server's own embedding index is outside all of it. No client-side TTL refreshes something the server built for itself — that refresh is triggered by the same events described below, not by anything in a response.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;private&lt;/code&gt; is not a preference here. It follows from the choice made in § 5 to let the lists vary by the caller's granted scopes, because the specification defines &lt;code&gt;public&lt;/code&gt; as a response any client or intermediary may cache and serve &lt;em&gt;across authorization contexts&lt;/em&gt;. Combine the two and a shared cache in front of the catalog becomes a way to hand one caller's view to another — the fraud engineer's dataset list served from a proxy to somebody with no fraud scope at all. A server that returned an identical list to everyone could say &lt;code&gt;public&lt;/code&gt; honestly. This one cannot, and the field is where that consequence becomes visible to the client.&lt;/p&gt;

&lt;p&gt;Five minutes is too long whenever a tag write lands inside the TTL window. A governance PR retags &lt;code&gt;policy_curated.customer.iban&lt;/code&gt; from &lt;code&gt;sensitivity: confidential&lt;/code&gt; to &lt;code&gt;high&lt;/code&gt; at 14:03; a client that listed at 14:01 keeps serving the old classification until 14:06, and every retrieval in between answers against a grant expression that no longer holds. The protocol's answer is subscriptions: the client opens a long-lived &lt;code&gt;subscriptions/listen&lt;/code&gt; stream naming the URIs it cares about in &lt;code&gt;notifications.resourceSubscriptions&lt;/code&gt;, the server replies with &lt;code&gt;notifications/subscriptions/acknowledged&lt;/code&gt;, and thereafter pushes &lt;code&gt;notifications/resources/updated&lt;/code&gt; whenever a watched resource changes. A server declares &lt;code&gt;resources: { subscribe: true }&lt;/code&gt; to offer it at all.&lt;/p&gt;

&lt;p&gt;Writing that sentence is considerably easier than deploying it. What it actually costs, on AWS:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A compute target that stays up.&lt;/strong&gt; Not Lambda. ECS, Fargate or App Runner, with the scaling, health checks and monitoring that a long-running service needs and a function does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;idle_timeout.timeout_seconds&lt;/code&gt; raised on the ALB.&lt;/strong&gt; The default is &lt;strong&gt;60 seconds&lt;/strong&gt;; a quiet subscription is closed by your own load balancer before it reports anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A heartbeat&lt;/strong&gt;, if raising that timeout is not enough or not permitted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Egress proxies you do not control.&lt;/strong&gt; Any enterprise network may buffer or terminate long-lived responses whatever you configure at either end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client-side reconnect, backoff and re-subscribe&lt;/strong&gt; — plus reconciling what changed while disconnected, which is the part that gets discovered in production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So subscriptions are an optimisation, and for a catalog it is worth asking whether it is ever the right one. The ontology looks like the obvious candidate because every answer depends on it — but &lt;em&gt;small&lt;/em&gt; and &lt;em&gt;rarely changed&lt;/em&gt; are exactly the properties that make re-reading it cheap. One document, a short &lt;code&gt;ttlMs&lt;/code&gt;, and the exposure is bounded by the window. Dataset descriptors have the opposite shape: they genuinely do change, but watching them costs a URI per table and a held stream per connected host. Neither case is a comfortable fit.&lt;/p&gt;

&lt;p&gt;Which leaves the unglamorous default doing most of the work, including through a proxy you cannot change: a short &lt;code&gt;ttlMs&lt;/code&gt;, plus a &lt;code&gt;resources/read&lt;/code&gt; of the one descriptor immediately before acting on its policy value — the retrieve-then-validate shape from § 3, with the read doing what the stale index cannot. Subscriptions pay off where a resource changes often and is watched by few. A catalog is usually the other way round. Reclassification propagating without touching pipelines was already a property of the ontology in &lt;a href="https://cordata.tech/en/blog/pipelines-as-descriptors#s6" rel="noopener noreferrer"&gt;Part 1 § 6&lt;/a&gt;; this extends the same property to the agent's view of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  § 8 — What this design does not solve
&lt;/h2&gt;

&lt;p&gt;Three gaps, stated rather than buried.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A catalog cannot describe what nobody registered.&lt;/strong&gt; Asked about a dataset a domain built last week and never published, an agent correctly reports that it does not exist — indistinguishable, to the person asking, from the tool being broken. Coverage is a governance property, not a protocol one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Annotations remain untrusted in aggregate.&lt;/strong&gt; § 4 covers a single server. A host aggregating several inherits all their trust assumptions, and cross-server tool-name collisions are explicitly the client's problem to disambiguate. A catalog tool that is safe alone is not automatically safe beside an arbitrary third-party server in the same context window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Half of this does not port.&lt;/strong&gt; Everything the protocol contributes is cloud-agnostic — Tools versus Resources, output schemas, scope challenges and cache scoping hold wherever you run them — but the identity chain in § 5 is built out of &lt;code&gt;AssumeRole&lt;/code&gt;, LF-tag grant expressions and account-per-domain, none of which exist elsewhere. On another cloud the question &lt;em&gt;how does a validated caller become a credential the catalog can be read with?&lt;/em&gt; has to be answered again from first principles, and while the shape of that answer transfers, none of the code does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What gets built next
&lt;/h2&gt;

&lt;p&gt;Every protocol claim above is checked against the specification; none of it has served a request yet. That is worth stating plainly, and worth not leaving there.&lt;/p&gt;

&lt;p&gt;The next piece of work is a running server in its own repository, built on the descriptor model the &lt;a href="https://github.com/cordata-tech/pipeline-runtime" rel="noopener noreferrer"&gt;pipeline runtime&lt;/a&gt; already publishes — so that § 3's argument about one model and three consumers becomes something you can check out and run rather than something I have asserted. Until then this post is a design, and it is not meant to stay one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://cordata.tech/en/blog/pillar/data-governance" rel="noopener noreferrer"&gt;Governance pillar&lt;/a&gt; claimed governance artefacts should be byproducts of behaviour; &lt;a href="https://cordata.tech/en/blog/fabric-mesh-on-aws" rel="noopener noreferrer"&gt;Fabric + Mesh on AWS&lt;/a&gt; built the account topology and the tag ontology; &lt;a href="https://cordata.tech/en/blog/behaviour-first-governance-in-practice" rel="noopener noreferrer"&gt;Behaviour-first governance&lt;/a&gt; turned the emitted events into the projections an auditor asks for; the &lt;a href="https://cordata.tech/en/blog/pipelines-as-descriptors" rel="noopener noreferrer"&gt;pipeline pair&lt;/a&gt; made a pipeline a declaration that emits its own evidence.&lt;/p&gt;

&lt;p&gt;This post adds the read path, and the arc closes somewhere I did not expect when the first one was written. The ontology those posts spent so long keeping small, explicit and machine-checkable is what makes agentic access tractable — not because the agents are clever, but because a twenty-one-value vocabulary with a validator behind it is a search problem rather than a judgement problem. Invert it and the claim falls over: three hundred values and no validator, and no amount of protocol rescues any of this.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you have put an agent in front of a governed catalog — or think doing so is a worse idea than I have made it sound, and can say where it breaks — I would genuinely like to hear the counter-argument. The door is open at &lt;a href="https://cordata.tech/en/contact" rel="noopener noreferrer"&gt;cordata.tech/contact&lt;/a&gt;. Related reading: the &lt;a href="https://cordata.tech/en/blog/fabric-mesh-on-aws" rel="noopener noreferrer"&gt;Fabric + Mesh on AWS reference&lt;/a&gt; for the LF-tag ontology every tool here reads from; &lt;a href="https://cordata.tech/en/blog/pipelines-as-descriptors" rel="noopener noreferrer"&gt;part 1 — a pipeline is a descriptor, not a program&lt;/a&gt; for the descriptor model the output schemas derive from, and &lt;a href="https://cordata.tech/en/blog/pipeline-half-openlineage-gx" rel="noopener noreferrer"&gt;part 2&lt;/a&gt; for the semantic-layer tier this one sits beside; and &lt;a href="https://cordata.tech/en/blog/behaviour-first-governance-in-practice" rel="noopener noreferrer"&gt;Behaviour-first governance in practice&lt;/a&gt; for the audit projection every tool call lands in.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://cordata.tech/en/blog/governed-mesh-over-mcp" rel="noopener noreferrer"&gt;cordata.tech&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>aws</category>
      <category>datagovernance</category>
    </item>
    <item>
      <title>One skeleton, many screens — React + TypeScript</title>
      <dc:creator>László Hadházy</dc:creator>
      <pubDate>Tue, 25 Aug 2026 08:21:56 +0000</pubDate>
      <link>https://dev.to/lhadhazy/one-skeleton-many-screens-react-typescript-4698</link>
      <guid>https://dev.to/lhadhazy/one-skeleton-many-screens-react-typescript-4698</guid>
      <description>&lt;p&gt;Every internal admin tool starts clean. One screen lists articles: a table, a few count cards across the top, a status pill. Someone writes it in an afternoon and it's good.&lt;/p&gt;

&lt;p&gt;Then comes the pages screen. It's &lt;em&gt;almost&lt;/em&gt; the same — a table, count cards, no status pill this time. So it gets copy-pasted and trimmed. Then a users screen, a media screen, an audit-log screen. Six months later the tool is a graveyard of near-identical table components, each with its own subtly different pagination handling, its own loading spinner, its own idea of what an empty state looks like. Fixing a bug means fixing it five times, and you'll miss one.&lt;/p&gt;

&lt;p&gt;The reflex fix is to reach for a single mega-component — a &lt;code&gt;&amp;lt;SuperTable&amp;gt;&lt;/code&gt; with forty props and a &lt;code&gt;variant&lt;/code&gt; enum — and wire every screen through it. That trades a copy-paste problem for a God-object problem. The component grows a new prop every time a screen needs something slightly different, and eventually no one can change it without breaking a caller three domains away.&lt;/p&gt;

&lt;p&gt;The better move is to change &lt;em&gt;what a screen is&lt;/em&gt;. Not a component you configure, but &lt;strong&gt;data you declare&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A screen is a descriptor
&lt;/h2&gt;

&lt;p&gt;The unit I settled on is a plain description of the screen — its columns, and the numbers it summarises — with no rendering logic in it at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ColumnDef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Row&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;minWidth&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;sortable&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;date&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="cm"&gt;/** Derive the sortable/display value from the row. */&lt;/span&gt;
  &lt;span class="nl"&gt;value&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="cm"&gt;/** Custom cell content, e.g. a status pill. */&lt;/span&gt;
  &lt;span class="nl"&gt;render&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ReactNode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;StatCard&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Row&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;hint&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole contract a screen author touches. Everything about &lt;em&gt;how&lt;/em&gt; a table paginates, sorts, shows a loading bar, or renders an error lives elsewhere — in a single generic component the domains never edit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;TablePage&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Row&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;GridValidRowModel&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;getRowId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;statCards&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;pageSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;TablePageProps&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Row&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// maps ColumnDef -&amp;gt; the grid's native column shape, once&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gridColumns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useMemo&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;GridColDef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Row&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headerName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;minWidth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;sortable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sortable&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;valueGetter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;renderCell&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;render&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ...renders the stat cards + the data grid&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two small translations do the real work here. A column's &lt;code&gt;value&lt;/code&gt; becomes the grid's &lt;code&gt;valueGetter&lt;/code&gt; — the function that turns a row into a sortable, displayable cell value. A column's &lt;code&gt;render&lt;/code&gt; becomes &lt;code&gt;renderCell&lt;/code&gt; — arbitrary JSX for the cell, which is how a status pill or a link gets in without the framework knowing anything about statuses or links. The domain describes intent; the framework owns the mechanics of the grid library underneath. Swap that library out one day and no descriptor changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is a DDD boundary, not just a helper
&lt;/h2&gt;

&lt;p&gt;The split isn't only about avoiding duplication. It's a &lt;a href="https://martinfowler.com/bliki/DomainDrivenDesign.html" rel="noopener noreferrer"&gt;domain-driven design&lt;/a&gt; boundary drawn in the file tree — DDD in the one sense that earns its keep here: each part of the product (articles, pages, users) is a &lt;a href="https://martinfowler.com/bliki/BoundedContext.html" rel="noopener noreferrer"&gt;&lt;em&gt;bounded context&lt;/em&gt;&lt;/a&gt; that lives in its own module, owns its own rules, and is not allowed to reach into another. The shared machinery lives somewhere those modules can depend on, but which depends on none of them.&lt;/p&gt;

&lt;p&gt;On disk that's two top-level folders:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/
  framework/                 # reusable, domain-agnostic — knows no domain
    bodies/
      TablePage.tsx          # the generic, descriptor-driven list screen
  features/                  # bounded contexts (migrating to domains/)
    articles/
      ArticlesDataGrid.tsx   # a descriptor, nothing more
      StatusChip.tsx         # article-specific cell content
    pages/
      PagesTable.tsx         # a descriptor, nothing more
  utils/api/                 # typed client + query hooks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The folder is still called &lt;code&gt;features/&lt;/code&gt; — the conventional Next.js name — and is on its way to being renamed &lt;code&gt;domains/&lt;/code&gt;, because &lt;em&gt;bounded context&lt;/em&gt; is the honest label for what lives there. The name matters less than the rule it encodes: nothing in &lt;code&gt;articles/&lt;/code&gt; may import from &lt;code&gt;pages/&lt;/code&gt;, and neither may be imported by &lt;code&gt;framework/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feur69a08e73as47u698f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feur69a08e73as47u698f.png" alt="articles/ and pages/ declare columns against framework's TablePage and depend on its ColumnDef and StatCard types; framework depends on neither" width="800" height="343"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The diagram, in words: &lt;code&gt;articles/&lt;/code&gt; and &lt;code&gt;pages/&lt;/code&gt; each declare their columns against &lt;code&gt;framework/&lt;/code&gt;'s &lt;code&gt;TablePage&lt;/code&gt; and depend on its &lt;code&gt;ColumnDef&lt;/code&gt; / &lt;code&gt;StatCard&lt;/code&gt; types; &lt;code&gt;framework/&lt;/code&gt; depends on neither of them.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The dependency arrow only points one way. &lt;code&gt;framework/&lt;/code&gt; knows nothing about articles or pages — it can't, and that's enforced by it having no imports from the domains. Each domain owns its own descriptor and its data-fetching, and depends &lt;em&gt;inward&lt;/em&gt; on the framework's types. A new domain can't accidentally couple itself to another domain through the shared table, because the shared table has no domain in it to couple to.&lt;/p&gt;

&lt;p&gt;This is what makes "data-driven" more than a slogan. The domain layer declares &lt;em&gt;what&lt;/em&gt; a screen is; the framework layer decides &lt;em&gt;how&lt;/em&gt; it renders. The boundary between them is a set of serialisable-ish descriptors, not a call graph.&lt;/p&gt;

&lt;p&gt;Here's what a domain screen actually looks like once the skeleton exists — the entire articles list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ColumnDef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Article&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Title&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;minWidth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;240&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;category&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Category&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;minWidth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;author&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Author&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;author&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;—&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;updatedAt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Updated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;130&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;date&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updatedAt&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;140&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;sortable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;render&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;StatusChip&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;statCards&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;StatCard&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Article&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Total&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Published&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PUBLISHED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No JSX for the grid. No pagination. No loading state. The screen is a list of columns and two reducers over the rows. Everything else is inherited.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test: a zero-diff refactor
&lt;/h2&gt;

&lt;p&gt;Here's the part that tells you whether the boundary is in the right place.&lt;/p&gt;

&lt;p&gt;I didn't build the skeleton first and then screens on top of it. I had a working, hand-written articles table already built and rendering live data. To validate the abstraction, I &lt;em&gt;rewrote the existing screen to render through it&lt;/em&gt; — and watched for changes.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The proof you drew the boundary correctly is that refactoring an existing&lt;br&gt;
screen through the abstraction produces zero visual diff.&lt;/strong&gt; If the screen&lt;br&gt;
changes, the abstraction is imposing opinions the screen didn't ask for.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The articles table looked and behaved identically before and after — same columns, same counts, same status pills, same sort behaviour. That's not an anticlimax; it's the whole result. A refactor that changes nothing the user can see, while collapsing a hand-written component into a ten-line descriptor, is the signal that the skeleton captured exactly the screen's structure and none of its incidental detail.&lt;/p&gt;

&lt;p&gt;Then the second screen — pages — cost a descriptor and nothing else. Here's the whole thing — the same &lt;code&gt;TablePage&lt;/code&gt;, fed a different descriptor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ColumnDef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Page&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Title&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;minWidth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;240&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;slug&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Route&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;minWidth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;160&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;updatedAt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Updated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;130&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;date&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updatedAt&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;statCards&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;StatCard&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Page&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Total&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put it next to the articles descriptor above and the entire difference between the two screens &lt;em&gt;is&lt;/em&gt; the difference in their data: pages drop the status column — they have no lifecycle status — and keep one stat card instead of two. No new component, no second grid, no fifth reimplementation of pagination. The skeleton was already written.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the data typo-proof
&lt;/h2&gt;

&lt;p&gt;There's an obvious objection to declaring screens as data: a descriptor is just an object literal, and an object literal is where typos go to hide. I hit exactly that. A pages column read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;routeSlug&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Route&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// the real key is `slug`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It compiled, it ran, and the column was blank for every row — the grid looked up &lt;code&gt;row["routeSlug"]&lt;/code&gt;, got &lt;code&gt;undefined&lt;/code&gt;, and rendered nothing. No error, no crash. A silent bug that looks like a backend problem.&lt;/p&gt;

&lt;p&gt;The fix is to make the descriptor's keys answer to the row type. Instead of &lt;code&gt;field: string&lt;/code&gt;, bind it to the row's own property names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the one line that changes in ColumnDef&amp;lt;Row&amp;gt;:&lt;/span&gt;
&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Extract&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;keyof&lt;/span&gt; &lt;span class="nx"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// was: field: string&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;"routeSlug"&lt;/code&gt; is not a valid &lt;code&gt;field&lt;/code&gt; — the compiler rejects it and lists the keys that &lt;em&gt;are&lt;/em&gt; valid. The typo becomes a build error, not a runtime mystery. Columns that genuinely have no backing property — an actions button, a row number — opt out through a separate variant, so "no field" is a decision you can see, never an accident.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Data-driven only pays off if the data is typed as tightly as code would be.&lt;/strong&gt;&lt;br&gt;
A descriptor you can typo freely is just a config file waiting to rot.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That &lt;code&gt;keyof&lt;/code&gt; binding is small, but it's what earns "screens as data" the right to be trusted — and it's the same generated row types (straight from the API's OpenAPI schema) doing double duty: rename a field on the backend and every stale descriptor turns into a compile error on the next codegen, instead of a blank column at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  The discipline: name what you did &lt;em&gt;not&lt;/em&gt; abstract
&lt;/h2&gt;

&lt;p&gt;The failure mode of every framework is that it doesn't know when to stop. "Data-driven" slides into a configuration language that reinvents the host framework badly, and now you maintain a worse React inside React.&lt;/p&gt;

&lt;p&gt;So the useful half of this work is the list of things I deliberately left concrete:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Column headers are plain strings&lt;/strong&gt; — even though the localisation primitive already exists in the codebase (&lt;code&gt;LocalizedText&lt;/code&gt;, a value that's either a literal string or a reference to a translation resolved per-locale). Wiring headers through it is a one-line type change — &lt;code&gt;header: string&lt;/code&gt; becomes &lt;code&gt;header: LocalizedText&lt;/code&gt; — one I'd make the day a second language becomes real, not a speculative layer baked in on day one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stat cards recompute on every render&lt;/strong&gt;, over the currently-loaded page of rows. For an internal admin tool with modest datasets, a &lt;code&gt;filter&lt;/code&gt; over a few hundred rows per render is free, and the simplicity is worth more than a memoised aggregate I'd have to invalidate correctly. That's an explicit &lt;em&gt;data-driven over performance&lt;/em&gt; call, made with eyes open — and the kind of thing to revisit the day the numbers say to, not before.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The grid library shows through in the descriptor&lt;/strong&gt; — &lt;code&gt;flex&lt;/code&gt;, &lt;code&gt;minWidth&lt;/code&gt;. I chose not to invent a layout abstraction over the top of it, because a leaky one costs more than the honesty of naming the underlying grid's own knobs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those is a place a purist would abstract further. Each would have added a layer I can't yet justify with a real requirement. The boundary is only in the right place if you can say, out loud, where it ends.&lt;/p&gt;

&lt;h2&gt;
  
  
  The take-home
&lt;/h2&gt;

&lt;p&gt;A screen declared as data is not automatically better than a screen written as code. It's better when you have &lt;em&gt;many screens that share a shape&lt;/em&gt; and a team that will keep adding to them — which is exactly the situation an internal admin tool is always in, whether or not anyone admitted it at the start.&lt;/p&gt;

&lt;p&gt;The two things worth stealing, independent of the stack: draw the boundary as a &lt;strong&gt;one-way dependency&lt;/strong&gt; — domains depend inward on a framework that knows nothing about them — and validate it with a &lt;strong&gt;zero-diff refactor&lt;/strong&gt; of a screen you already trust. If the screen changes, you've abstracted the wrong thing. If it doesn't, you've earned the next screen for the price of a descriptor.&lt;/p&gt;

&lt;p&gt;If you've drawn this boundary somewhere different — or think the whole descriptor move is a trap — I'd genuinely like to hear where it broke for you. The door's open at &lt;a href="https://cordata.tech/en/contact" rel="noopener noreferrer"&gt;cordata.tech/contact&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://cordata.tech/en/blog/one-skeleton-many-screens" rel="noopener noreferrer"&gt;cordata.tech&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>typescript</category>
      <category>nextjs</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
