787e354b9b
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
100 lines
26 KiB
HTML
100 lines
26 KiB
HTML
<!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'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'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'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'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'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 'what text is in this cell'">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'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<Box<dyn Effect>></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'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>"any record the batch touches" had to name whose list</strong>, and the answer is the <em>server's</em>: reading it off the client'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 "you lost a race" and "you were never in one" 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'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's own edit was already committed, so this is a notification, not a conflict — but it must exist, or "someone else won" becomes indistinguishable from "my edit never happened."</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 'what text is in this cell'">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 "you find out at commit" — 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 "these were concurrent" you still need a policy for class 2 — no clock says whether B's edit into <code>Region=East</code> should survive A'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'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'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'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 "a client that has folded nothing composes nothing" 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 "a batch carries its basis" 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'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(&App, cmd)</code> is not a bypass — it takes <code>&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>'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<'_></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'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'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>
|