Companion to Topcoat and the shrinking cost of full-stack Rust. That post was written from the announcement and promised a follow-up reporting where the rough edges actually show. This is it, four weeks later: 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.
One skeleton, many screens 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:
draw the boundary as a one-way dependency — domains depend inward on a framework that knows nothing about them — and validate it with a zero-diff refactor of a screen you already trust.
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.
That phrase, a compile-time constant, 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: &'static, Copy, 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.
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.
It came out differently, and not in the direction I expected.
The only data in this post is metadata
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 descriptor — 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.
Which is the shape of the rest of Cordata's work. A pipeline is a descriptor, not a program 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.
Metadata is worth the effort for a reason that is not documentation. It is constraint. 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.
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.
The first is an escape hatch: one field that accepts arbitrary code or text — a raw_sql string on a pipeline step, a render callback on a column, a free-text notes 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.
The second way out is slower: name the new case and add it to the vocabulary. A declared Link 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.
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.
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.
The experiment
The pilot has two screens, picked to sit on either side of the boundary. Each is its own module — users.rs and invoices.rs — and those two modules are what this post means by the domains: the code that knows what a user is, or what an invoice is. Facing them is the framework: descriptor.rs, which defines what a description may say, and table.rs, 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.
The first screen, users, 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, invoices, was written by hand first and made deliberately harder, carrying three things the descriptor could not express:
- ambient context — a money amount formatted for a locale that arrives from the request, not from the row:
4.850,00 €in German,$4,850.00in English - a derived value — a column computed from two fields rather than read from one
- structure — a cell that renders as a link rather than as text
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: given a row, what string does this cell show? 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 shape of output: an <a> 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.
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.
invoices 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 users 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.
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 invoices 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?
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.
Because Topcoat renders on the server, the zero-diff claim stops being a visual judgement and becomes a text comparison. The harness 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 diff returns nothing, or the abstraction has changed what the user sees and is therefore wrong.
The harness gave one false positive before I trusted it. Perturbing a column header produced the failure it should. Reverting that change with mv produced the failure again — because mv 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.
That is the same shape as the five traps in the trace_id post: a check that is confidently wrong because something upstream silently did not happen. Prove your test discriminates before you believe it.
Result one: the boundary held for values and broke at structure
First refactor, changing nothing in the framework:
ZERO DIFF users
CHANGED invoices — one columnFive 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.
Exactly one column failed:
- <td class="cell cell-text"><a href="/invoices/2026-0041">2026-0041</a></td>
+ <td class="cell cell-text"><a href="/invoices/2026-0041">2026-0041</a></td>The accessor's type was fn(&T) -> String, and view! escapes an interpolated string. That is correct behaviour — it is the cross-site scripting defence — not a bug to route around.
So: an accessor returning a string can express any cell that is a value, and no cell that is structure. It can compute anything at all from the row, and it can nest nothing at all inside the cell it hands back.
Every column in a descriptor carries two things: an accessor saying what to show, and a cell kind saying how to render it. 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:
pub enum CellKind<T: 'static> {
Text,
Number,
Date,
Badge,
Link { href: Accessor<T> }, // added by this result
}So fixing the link column meant adding that Link variant, plus one if let inside the table component's template — the single place that decides whether a cell's text goes into the <td> bare or wrapped in an anchor:
if let CellKind::Link { href } = &col.kind {
<a href=(href(row))>((col.get)(row))</a>
} else {
((col.get)(row))
}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 shape, not a subject.
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 no boundary at all. The Rust version has the stricter boundary, and the price is that a genuinely new presentation costs a deliberate, single, framework-wide decision.
Result two: the locale broke the thing I thought was the point
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 capture it, and a function pointer cannot.
Where the locale is stored 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, /en/… and /de/…. It could as easily be a cookie, an Accept-Language 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.
The distinction is worth keeping sharp, because part of this genuinely is 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.
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 when it is read. I committed it with a note saying exactly that, so the cost sat in the code rather than in prose.
All three routes then reported zero diff — the two screens, with invoices 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: it compares what the reader sees, never what it cost to produce. A green run here was not evidence that the global was acceptable. It was evidence that the harness cannot see a global at all.
The fix is to let the accessor capture after all, which means boxing it:
pub type Accessor<T> =
Box<dyn Fn(&T) -> String + Send + Sync>;That is the moment the descriptor stops being a compile-time constant. It is no longer Copy, no longer static, 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.
What that actually cost, measured
| lines of Rust | function pointers + global | boxed closures | change |
|---|---|---|---|
descriptor.rs |
66 | 88 | +22 |
table.rs |
58 | 58 | — |
users.rs |
72 | 54 | −18 |
invoices.rs |
119 | 88 | −31 |
| total | 315 | 288 | −27 |
wc -l, 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.
The expensive version is smaller. 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.
What was lost is real: Copy, const, &'static, 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.
And the call site still reads as data:
col(
if de { "Betrag" } else { "Amount" },
CellKind::Number,
move |i: &Invoice| money(l, i.amount_cents),
),That is not meaningfully further from a struct literal than the original was, which is the finding in one line: the declarative shape survived the change, and const did not.
The four questions the first post promised
Developer experience. Mostly unremarkable, which is the compliment. Two things cost real time:
- A page function cannot share a name with a module in scope, and the five-error cascade that follows describes the macro expansion rather than the cause.
- A generic component needs
Send + Syncbounds the React version never had — though the compiler suggested exactly the right fix, which is more than can be said for some.
Error messages. The spans are consistently good and the vocabulary is not:
- Every probe pointed at the source line, never into expanded code.
- Malformed markup gets a purpose-written diagnostic — closing tag
divdoes not match opening tagp, with the exact column and no trait bounds anywhere in it. - The vocabulary is where it thins out. Feeding
$()something that cannot cross to JavaScript produces an unsatisfied bound on a trait calledSurrogated, a word that appears nowhere in the documentation, followed by a list of implementors ending "and 25 others".
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.
The rebuild loop. 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:
view! blocks |
warm rebuild |
|---|---|
| 6 | 0.47s |
| 31 | 0.52s |
| 81 | 0.57s |
| 156 | 0.71s |
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.
Where the trade-offs land. 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 not one accessor changed. 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.
The take-home
I went in believing the descriptor should be data, 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.
So the finding is that const-ness was never the property that mattered. 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 fn pointer, and with it the descriptor's standing as a compile-time constant. Both of those are decisions about how a description is represented, 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.
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 — which of its properties are the boundary and which are just how it happens to be built today. They are easy to confuse, and only one of them is worth defending.
The pilot is at github.com/cordata-tech/topcoat-descriptor-pilot. A clean clone builds and serves both screens, and scripts/zero-diff.sh check runs the test that decides the argument. NOTES.md carries the findings as they were recorded, including the ones that went the other way and one probe I designed wrong.
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 cordata.tech/contact. Related reading: One skeleton, many screens is the React original this replicates, and A pipeline is a descriptor, not a program makes the same argument about metadata one domain over.