Files
improvise/site/docs/adr/001-concurrent-edits.html
T
Edward Langley 787e354b9b doc-site: name context/code/pr-review.md as an omission
The `build` job fails on main and on every branch merged from it:

  doc-site: context/code/pr-review.md is neither published nor a named omission

NOT a merge loss -- scripts/build-doc-site.mjs in this branch is
byte-identical to main's, and neither lists the file. PR #96 added
context/code/pr-review.md without a manifest line, which is exactly the
gate improvise-res9 added: a doc added without a line publishes nothing
and says nothing, so the generator refuses.

Omitted rather than published, and the line is cloud-agents.md's rather
than tooling.md's. tooling.md explains checkers a reader of the code will
meet; pr-review.md is about THIS REPOSITORY'S PROCESS -- where a Codex
review appears, how to tell it from our own agents' comments, why absence
is not approval. Nobody visiting a website is about to read a PR thread.

Both homes updated, because an omission is restated beside the manifest
AND in doc-site.md section 2, and the prose there counted them ("Neither
is referenced...") so it went stale at three.

This strays into scripts/, which is Wave N's ground. Flagged in the
hand-back; it is a one-line data entry either way if the orchestrator
would rather publish it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 11:12:00 -07:00

100 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ADR 001 — Concurrent edits — improvise docs</title>
<link rel="stylesheet" href="../style.css" />
</head>
<body>
<div class="shell">
<nav class="side"><a class="brand" href="../index.html">improvise docs</a><input type="checkbox" id="navtoggle" class="navtoggle" /><label class="navsummary" for="navtoggle">Pages</label><div class="navlinks"><ul><li><a href="../../index.html">← Project home</a></li><li><a href="../../app/">Browser demo</a></li><li><a href="https://github.com/fiddlerwoaroof/improvise">Source on GitHub</a></li></ul><h3>Start here</h3><ul><li><a href="../design-notes.html">Design notes</a></li><li><a href="../design-principles.html">Design principles</a></li><li><a href="../repo-map.html">Repository map</a></li></ul><h3>Decision records</h3><ul><li><a href="../adr/001-concurrent-edits.html" class="here">ADR 001 — Concurrent edits</a></li><li><a href="../adr/002-naming-a-cell.html">ADR 002 — Naming a cell</a></li><li><a href="../adr/003-live-backed-models.html">ADR 003 — Live-backed models</a></li></ul><h3>The arguments, by area</h3><ul><li><a href="../code/core.html">improvise-core</a></li><li><a href="../code/formula-and-io.html">improvise-formula and improvise-io</a></li><li><a href="../code/protocol.html">improvise-protocol</a></li><li><a href="../code/command.html">improvise-command</a></li><li><a href="../code/clients.html">The clients</a></li><li><a href="../code/tui.html">The TUI</a></li><li><a href="../code/gp.html">gp-tree and improvise-gp</a></li><li><a href="../code/kb-tool.html">The kb tool</a></li><li><a href="../code/tooling.html">The repository's own checkers</a></li></ul><h3>Test evidence</h3><ul><li><a href="../test-evidence/core.html">improvise-core</a></li><li><a href="../test-evidence/formula-and-io.html">improvise-formula and improvise-io</a></li><li><a href="../test-evidence/protocol.html">improvise-protocol</a></li><li><a href="../test-evidence/command.html">improvise-command</a></li><li><a href="../test-evidence/clients.html">The clients</a></li><li><a href="../test-evidence/tui.html">The TUI</a></li><li><a href="../test-evidence/gp.html">gp-tree and improvise-gp</a></li><li><a href="../test-evidence/tooling.html">The repository's own gates</a></li></ul><h3>About this site</h3><ul><li><a href="../doc-site.html">About this site</a></li></ul><h3>Reference</h3><ul><li><a href="../tickets.html">Ticket index</a></li></ul></div></nav>
<main>
<p class="src">Source: <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/context/adr/001-concurrent-edits.md">context/adr/001-concurrent-edits.md</a></p><details class="toc" open><summary>On this page</summary><ul><li class="l1"><a href="#adr-001-concurrent-edits-between-clients">ADR 001 — Concurrent edits between clients</a></li><li class="l2"><a href="#context">Context</a></li><li class="l3"><a href="#there-is-already-a-total-order">There is already a total order</a></li><li class="l3"><a href="#scope-conflicts-range-over-one-document">Scope: conflicts range over one document</a></li><li class="l3"><a href="#what-actually-conflicts">What actually conflicts</a></li><li class="l3"><a href="#machinery-that-already-exists">Machinery that already exists</a></li><li class="l3"><a href="#machinery-that-did-not-exist-and-the-hole-it-left">Machinery that did not exist, and the hole it left</a></li><li class="l2"><a href="#decision">Decision</a></li><li class="l3"><a href="#rejection-must-be-forceable-and-force-means-re-compose">Rejection must be forceable, and force means re-compose</a></li><li class="l3"><a href="#optional-second-layer-advisory-reservations">Optional second layer: advisory reservations</a></li><li class="l2"><a href="#why-not-a-causal-clock">Why not a causal clock</a></li><li class="l2"><a href="#consequences">Consequences</a></li><li class="l2"><a href="#implementation-order">Implementation order</a></li><li class="l3"><a href="#what-the-check-is-not">What the check is not</a></li><li class="l3"><a href="#two-places-the-check-deliberately-does-not-reach">Two places the check deliberately does not reach</a></li></ul></details><h1 id="adr-001-concurrent-edits-between-clients">ADR 001 — Concurrent edits between clients</h1>
<p><strong>Status:</strong> accepted (2026-09-19); <strong>layer 1 implemented</strong> (<a class="tid" href="../tickets.html#improvise-xwi6" title="Concurrent edits: basis check (ADR 001, layer 1 — the correctness floor)">improvise-xwi6</a>/fkfg, 2026-09-19) · <strong>Supersedes:</strong> nothing · <strong>Decided by:</strong> the user</p>
<h2 id="context"><a class="anchor" href="#context">#</a>Context</h2>
<p><code>improvise-cqq</code> made the client/server split real: one authoritative <code>App</code>, a <code>ClientSession</code> mirror per client, and projections diffed against those mirrors. <code>improvise-kmgs</code> then put the 28 model mutations on the wire. Two clients can now edit the same document, and nothing decides what happens when they edit the same part of it.</p>
<h3 id="there-is-already-a-total-order"><a class="anchor" href="#there-is-already-a-total-order">#</a>There is already a total order</h3>
<p>Every <code>ModelCommand</code> reaches the document through one path: <code>Sessions::apply</code> → <code>apply_command</code> → <code>App::apply_effects</code>, against a single <code>App</code>. So the server already serialises writes. <strong>We do not need to build linear history — it exists.</strong></p>
<p>What we lack is a way to notice that a client composed an edit against a picture of the document that has since moved. This is a <em>stale basis</em> problem, not an ordering problem — the same shape as the stale cursor (gotcha 24) and the stale session mirror after a document swap (gotcha 39), one level up.</p>
<h3 id="scope-conflicts-range-over-one-document"><a class="anchor" href="#scope-conflicts-range-over-one-document">#</a>Scope: conflicts range over one document</h3>
<p>improvise is a <strong>multi-document</strong> app, and the server is expected to host several separate models. Sessions editing <em>independent</em> models are trivially non-conflicting, so everything below scopes to a single document.</p>
<p>That falls out of the existing shape rather than needing to be built: <code>Sessions</code> does not own the <code>App</code> — <code>connect</code>, <code>apply</code> and <code>project_all</code> all take it as a parameter — so a <code>Sessions</code> is already bound to one document&#39;s generation sequence, and multi-document hosting is a registry <em>above</em> it mapping a document to its <code>(App, Sessions)</code> pair. The version tiers this ADR introduces live on the <code>App</code>, so they scope per document for free.</p>
<p>Multi-document hosting <strong>landed</strong> as <code>improvise-ws-server::registry</code> (<a class="tid" href="../tickets.html#improvise-j31x" title="Server hosts several documents: a registry above Sessions">improvise-j31x</a>): a <code>Registry</code> of <code>Host</code>s keyed by <code>DocumentId</code>, addressed by <code>ClientId { document, session }</code> because a <code>SessionId</code> is minted per <code>Sessions</code> and so is unique only within its document. The version tiers scope per document as predicted. Note one lifecycle rule that exists to protect <em>this</em> ADR: a document is never evicted while the server runs, because <code>document_generation</code> and <code>record_versions</code> start at zero in a fresh <code>App</code>, so re-opening would accept a reconnecting client&#39;s stale basis as current.</p>
<h3 id="what-actually-conflicts"><a class="anchor" href="#what-actually-conflicts">#</a>What actually conflicts</h3>
<ol>
<li><strong>Two writers, same record.</strong> Survivable under last-write-wins: someone&#39;s number is lost, the document stays coherent.</li>
<li><strong>Shape change vs. cell edit.</strong> A removes category <code>Region</code> while B commits an edit keyed by <code>Region=East</code>. B&#39;s <code>CellKey</code> names a coordinate that no longer exists. This is <em>corruption</em>, not a lost keystroke, and it is the class a cell-level lock does not cover — holding <code>Region=East × Revenue</code> does not stop anyone deleting <code>Region</code>.</li>
<li><strong>Formula vs. data.</strong> A adds a formula targeting the measure B is typing into; B&#39;s raw value is shadowed by a derived one.</li>
</ol>
<h3 id="machinery-that-already-exists"><a class="anchor" href="#machinery-that-already-exists">#</a>Machinery that already exists</h3>
<ul>
<li><code>App::document_generation</code> — bumped by <code>install_workbook</code>, consumed by <code>ClientSession::project</code> to reseed a stale mirror (gotcha 39).</li>
<li><code>ModelChange::{CellData, LayoutGeometry, WholeWorkbook}</code> — the effect taxonomy, already the single classification for what a mutation reaches (gotcha 35).</li>
<li><code>record_row_identity(wide, coord_group_excluding_measure)</code> — a <em>record</em> is the coordinate tuple excluding <code>_Measure</code> (gotcha 1).</li>
<li>Drill staging — <code>pending_edits</code> applied atomically by <code>ApplyAndClearDrill</code>, and ruled <strong>private to whoever staged them</strong> (<a class="tid" href="../tickets.html#improvise-tom1" title="Drill pending edits are invisible to remote clients, and there are now three answers to &#39;what text is in this cell&#39;">improvise-tom1</a>).</li>
</ul>
<h3 id="machinery-that-did-not-exist-and-the-hole-it-left"><a class="anchor" href="#machinery-that-did-not-exist-and-the-hole-it-left">#</a>Machinery that did <em>not</em> exist, and the hole it left</h3>
<p><strong>Nothing in the projection stream carried any of the three tiers.</strong> This ADR says a batch carries the basis it was composed against and never says how a client comes to <em>know</em> one — so the check as originally specified was satisfiable only by a client <strong>forging</strong> a basis. That is the invariant deleted with extra steps, arriving through the front door rather than as a <code>force</code> flag.</p>
<p>Closed during implementation (<a class="tid" href="../tickets.html#improvise-fkfg" title="ADR-001 b: ModelCommand carries its basis; Applied gains a rejected case">improvise-fkfg</a>) by <code>Projection::Basis</code> + <code>BasisCache</code>: the server publishes the two document tiers plus the write counters for the records <strong>this client&#39;s grid names</strong> — exactly the set it can address a write to, per ADR 002 — diffed against what that client was last told and seeded in the bootstrap. It is emitted <strong>after</strong> the grid updates in a batch, because the counters describe the cells those updates carry: a basis arriving first would let a client claim a version for a value it had not been shown. That is the mirror image of gotcha 33.</p>
<p>Read this as step (a′) of the implementation order below: <strong>publication comes before the check, because the check is unsatisfiable without it.</strong></p>
<h2 id="decision"><a class="anchor" href="#decision">#</a>Decision</h2>
<p><strong>Record-level atomicity with optimistic concurrency per batch.</strong></p>
<ol>
<li><strong>The unit of atomicity is the record</strong>, i.e. the coordinate tuple excluding <code>_Measure</code> — the thing <code>record_row_identity</code> already names. Not the cell: gotcha 1 says editing a category column in records mode moves <em>every</em> measure cell in the row, so a cell-level unit would be violated by a single legal edit. The unit must be at least as coarse as the coarsest legal write.</li>
<li><strong>The unit of commit is the batch</strong> — one <code>Vec&lt;Box&lt;dyn Effect&gt;&gt;</code> applied by one <code>apply_effects</code>. This is already atomic in practice: a <code>Binding::Sequence</code> flattens into one batch, and a failed <code>Save</code> aborts the rest (gotcha 21).</li>
<li><p><strong>A batch carries the basis it was composed against</strong>, and the server rejects it if that basis moved. The basis is <strong>tiered by <code>ModelChange</code></strong>, which is what makes class 2 fall out for free:</p>
<table><thead><tr><th>tier</th><th>bumped by</th><th>a batch is stale if</th></tr></thead><tbody><tr><td><code>document_generation</code></td><td><code>WholeWorkbook</code></td><td>it moved at all</td></tr><tr><td><code>layout_version</code></td><td><code>LayoutGeometry</code></td><td>it moved at all</td></tr><tr><td><code>record_version</code></td><td><code>CellData</code> on that record</td><td>any record the batch touches moved</td></tr></tbody></table>
<p>A <code>CellData</code> batch is stale if <em>layout</em> moved, because the shape its keys hang off changed. That is class 2, detected without a lock.</p></li>
<li><strong>Rejection is a normal outcome, not an error.</strong> The client already receives the new state through projections; it re-composes against the new basis. <code>Applied</code> grows a third case alongside <code>Diff</code> and <code>Rebased</code> — matching is what forces a host to handle it (gotcha 39&#39;s reasoning).</li>
</ol>
<h3 id="rejection-must-be-forceable-and-force-means-re-compose"><a class="anchor" href="#rejection-must-be-forceable-and-force-means-re-compose">#</a>Rejection must be forceable, and force means re-compose</h3>
<p>A rejected edit has to give the user a way through. But <strong>force is a re-composition, not a bypass</strong> — it never applies a batch whose basis is stale. A mechanism whose only purpose is to violate the invariant is not a feature, it is the invariant deleted with extra steps. Force re-composes the same <em>intent</em> against the current state and submits a fresh batch with a current basis.</p>
<p>That distinction is not pedantic, because what force can mean differs by class:</p>
<table><thead><tr><th>class</th><th>can force?</th><th>what force does</th></tr></thead><tbody><tr><td>1 — two writers, same record</td><td><strong>yes, trivially</strong></td><td>re-compose and apply; the user has seen their value and chooses to overwrite it</td></tr><tr><td>2 — shape change vs. cell edit</td><td><strong>no</strong></td><td>the intent no longer type-checks: the key names a coordinate that no longer exists</td></tr><tr><td>3 — formula shadows a value</td><td>yes, with a warning</td><td>the write lands; the displayed value is still derived</td></tr><tr><td>4 — the batch names a record its basis says nothing about</td><td><strong>no — and it is not a race</strong></td><td>nothing to re-compose <em>against</em>; the client was never in the running</td></tr></tbody></table>
<p>Class 4 was not in the original draft and was added by the implementation (<code>Rejection::RecordUnstated</code>). It exists because <strong>&quot;any record the batch touches&quot; had to name whose list</strong>, and the answer is the <em>server&#39;s</em>: reading it off the client&#39;s claim would be a <strong>force by omission</strong> — a client that simply omits a record skips the record tier, with no flag to remove and nothing in the message saying so. <code>reduce.rs::records_named</code> is therefore an exhaustive read of the <code>ModelCommand</code>, with one or-pattern for the variants that name no record, so a new record-writing variant fails the build. A well-formed client cannot produce class 4; it is a case of its own rather than folded into class 1 because &quot;you lost a race&quot; and &quot;you were never in one&quot; are different things to tell a user.</p>
<p>For class 1, force is one keystroke and should be. For class 2 there is nothing coherent to force: applying the batch anyway writes a <code>CellKey</code> naming a deleted category, which is the ghost-coordinate corruption this ADR exists to prevent. So:</p>
<ul>
<li><strong>Force never creates structure.</strong> Re-instating a category someone deleted so an edit can land is a <em>different and larger</em> intent than the user expressed, and it must be an explicit action, not a side effect of forcing a cell edit.</li>
<li>When an intent cannot be re-composed, the UI owes the user <strong>what changed and the concrete alternatives</strong> — re-target the edit, or re-create the structure first — rather than a refusal.</li>
</ul>
<p>Two consequences that pull work forward from layer 2:</p>
<ul>
<li><strong>The rejected batch&#39;s values stay staged in the session</strong>, so rejection never costs the user their typing. This is the drill staging mechanism doing the job it already does, and it is what makes force cheap rather than a retype. It belongs in layer 1 even though <em>publishing</em> the staged keys is layer 2.</li>
<li><strong>A forced overwrite must be visible to the client it overwrote.</strong> Otherwise force is silent data loss with a confirmation step in front of it. The loser&#39;s own edit was already committed, so this is a notification, not a conflict — but it must exist, or &quot;someone else won&quot; becomes indistinguishable from &quot;my edit never happened.&quot;</li>
</ul>
<h3 id="optional-second-layer-advisory-reservations"><a class="anchor" href="#optional-second-layer-advisory-reservations">#</a>Optional second layer: advisory reservations</h3>
<p>The staged-edit mechanism from drill, generalised: a client publishes the <em>keys</em> it has staged so others can grey them out and warn early. The <em>values</em> stay private, per <a class="tid" href="../tickets.html#improvise-tom1" title="Drill pending edits are invisible to remote clients, and there are now three answers to &#39;what text is in this cell&#39;">improvise-tom1</a>.</p>
<p>This layer is <strong>advisory only</strong>. If a reservation is lost, never arrives, or leaks from a crashed client, the system degrades to &quot;you find out at commit&quot; — never to corruption. That is what makes it safe to be sloppy about timeouts and disconnects, and it is why correctness must not be built on it.</p>
<h2 id="why-not-a-causal-clock"><a class="anchor" href="#why-not-a-causal-clock">#</a>Why not a causal clock</h2>
<p>Considered and rejected, for four reasons.</p>
<ol>
<li><strong>The server already serialises.</strong> Causal clocks earn their keep where no single node orders writes — peer-to-peer, or offline-capable clients merging on reconnect. With an authoritative <code>App</code> we need <em>staleness detection</em> (one counter per unit), not <em>concurrency detection</em> (a vector per client).</li>
<li><strong>A Lamport clock is insufficient.</strong> It gives a total order but cannot tell you two edits were concurrent, which is exactly the question.</li>
<li><strong>A vector clock is sufficient but does not answer the question that matters.</strong> It costs O(clients), needs entries reaped for departed clients, and when it reports &quot;these were concurrent&quot; you still need a policy for class 2 — no clock says whether B&#39;s edit into <code>Region=East</code> should survive A&#39;s deletion of <code>Region</code>. The clock tells you there is a decision to make; it does not make it.</li>
<li><strong>CRDTs converge, but not necessarily to something meaningful.</strong> A LWW-register per cell is easy; the hard part is the <em>shape</em> — categories, items, formulas, axis assignments — where convergence can mean an orphaned cell or a resurrected category.</li>
</ol>
<p>If offline editing ever becomes a goal, this decision should be revisited: that is the requirement that makes a vector clock worth its cost, and it is a much larger commitment than multi-client-while-connected.</p>
<h2 id="consequences"><a class="anchor" href="#consequences">#</a>Consequences</h2>
<p><strong>Good.</strong> No lock to acquire, so no timeout, no lease renewal, no disconnect handling, and no liveness risk — a crashed client cannot wedge anyone. Correctness does not depend on the advisory layer. The tiered basis reuses <code>ModelChange</code>, so there is no second classification to drift from gotcha 35&#39;s table. Class 2 is detected rather than prevented, which is cheaper and does not require predicting which shape changes matter.</p>
<p><strong>Bad.</strong> A client can compose an edit and be told no, and the UI owes real work there: keep the typing, show what moved, and offer force where force is coherent. Class 2 is the expensive case — the honest answer is a small dialogue about what changed, not a button. Record-level granularity means two clients editing <em>different measures of the same record</em> serialise against each other even though their writes do not overlap — acceptable at the expected scale, and refinable to <code>(record, measure)</code> if contention shows up. A busy <code>LayoutGeometry</code> editor invalidates every in-flight <code>CellData</code> batch, which is correct but could feel aggressive.</p>
<p><strong>Not solved here.</strong> Class 3 (formula shadows a raw value) is detected — the formula bumps <code>layout_version</code> — but the <em>policy</em> is unstated: the edit is rejected, and whether that is the right answer for the user is a separate question. Nor does this address a client that wants to keep editing while disconnected.</p>
<h2 id="implementation-order"><a class="anchor" href="#implementation-order">#</a>Implementation order</h2>
<p>Deliberately two tickets, so the correctness floor lands without waiting on the UX.</p>
<ol>
<li><strong>Basis check.</strong> Version tiers, <code>ModelCommand</code> carries its basis, <code>Applied</code> grows the rejected case. Includes the decision of where the version counters live — they are shared document state, so they belong beside <code>document_generation</code> on the authoritative <code>App</code>, not on <code>ViewState</code>.</li>
<li><strong>Advisory reservations.</strong> Generalise drill staging; publish key sets, keep values private.</li>
</ol>
<p>The nullary wire variants <code>ApplyAndClearDrill</code> and <code>AcceptWizardModel</code> were to be resolved first or alongside. <strong>Already done</strong>: <a class="tid" href="../tickets.html#improvise-3x3u" title="Resolve the two unaddressed nullary wire variants before concurrency lands">improvise-3x3u</a> removed both from <code>ModelCommand</code> entirely, so they have no wire form to carry a basis or a <code>SessionId</code>, and layer 1 landed honestly without them (<a class="tid" href="../tickets.html#improvise-k2i4" title="The two nullary model commands are unaddressed and act on one client&#39;s staged state">improvise-k2i4</a> closed as stale). What survives is the <em>addressed</em> form multi-client drill will want, which is layer 2 work.</p>
<h3 id="what-the-check-is-not"><a class="anchor" href="#what-the-check-is-not">#</a>What the check is not</h3>
<p>It is an <strong>anti-staleness coordination check, not a security boundary.</strong> <code>Basis</code> derives <code>Deserialize</code> because it must cross the wire, and every field is public, so any value at all can arrive as a claim — versions start at 0 and increment by 1, so guessing one is trivial. <code>Rejection::RecordUnstated</code> closes <strong>omission</strong> — a client that leaves a record out of its claim — which is the bypass that would otherwise sit in the message format with nothing saying so. It does not close <strong>invention</strong>, and nothing here tries to: a hostile client that forges a current basis is indistinguishable from one that is genuinely caught up, and defending against that is authentication&#39;s job.</p>
<p><code>BasisCache::claim</code> being the only constructor is therefore a statement about <strong>clients written against this crate</strong>, not about the wire. It is still worth having: it makes the honest path the easy one, and it is what makes &quot;a client that has folded nothing composes nothing&quot; true by construction.</p>
<h3 id="two-places-the-check-deliberately-does-not-reach"><a class="anchor" href="#two-places-the-check-deliberately-does-not-reach">#</a>Two places the check deliberately does not reach</h3>
<p><strong><code>Command::SwitchView</code> carries no basis.</strong> It is <code>LayoutGeometry</code> but sits outside <code>Command::Model</code>, so <code>apply_switch</code> is unchecked. It cannot corrupt — the workbook refuses a view it does not have, and every mirror takes what was <em>accepted</em> (gotcha 36) — but it is a hole in &quot;a batch carries its basis&quot; and should be decided rather than left as an accident.</p>
<p><strong><code>apply_command</code> does not check at all</strong> (nor did <code>reduce_full</code>, which <a class="tid" href="../tickets.html#improvise-s17o" title="reduce_full and apply_command&#39;s view-slice arm have no production caller">improvise-s17o</a> has since deleted for having no caller), and the <em>principle</em> is correct: the check is a property of the <strong>session door</strong>, not of the mutation path, because a host holding the <code>App</code> is the truth and not a claimant. That is also why <code>ui::reduce::caught_up(&amp;App, cmd)</code> is not a bypass — it takes <code>&amp;App</code>, so nothing reachable from a wire can call it.</p>
<p>As implemented it was a <strong>second door, not an exemption</strong> — both functions took a wire <code>Command</code> whose <code>Command::Model { cmd, .. }</code> arm discarded the basis with <code>..</code>, while <code>ui/reduce.rs</code>&#39;s module doc pointed every future host at them by name. <strong>Closed by <a class="tid" href="../tickets.html#improvise-ondj" title="[bug] reduce_full/apply_command apply a Command::Model without the basis check">improvise-ondj</a></strong> as layer-1 cleanup: both now take a <code>Checked&lt;&#39;_&gt;</code>, whose only constructor runs the check against the authoritative <code>App</code> and returns a <code>Rejection</code>. <code>reduce_full</code> was deleted outright shortly after (<a class="tid" href="../tickets.html#improvise-s17o" title="reduce_full and apply_command&#39;s view-slice arm have no production caller">improvise-s17o</a>): having no caller was the condition that let the defect hide, and a <code>Checked</code> signature on a function nobody calls fixes the type and not that. The exemption stands — a host&#39;s own <code>ModelCommand</code> still carries no basis — but it is no longer reachable as a door, and the unchecked path is unconstructible rather than merely discouraged.</p>
</main>
</div>
</body>
</html>