Files
doormilxpress_astryx/DOORMILE_AUTOMATION_PLAN.html

655 lines
54 KiB
HTML
Raw Permalink 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.
<title>Doormile Automation Plan</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700&family=Source+Sans+3:ital,wght@0,400;0,600;1,400&family=JetBrains+Mono:wght@400;600&display=swap">
<style>
:root { color-scheme: light dark; }
html, body { margin: 0; }
img { max-width: 100%; }
[hidden] { display: none !important; }
</style>
<style>
:root {
--paper:#F6F4F5; --surface:#FFFFFF; --surface-2:#EFEBED;
--ink:#17141A; --ink-2:#453E48; --muted:#6E6670;
--line:#DCD5D8; --line-2:#C6BCC0;
--accent:#C01227; --accent-ink:#C01227; --accent-soft:#F6E2E5;
--alt:#10646F; --alt-soft:#DDEEF0;
--gold:#8A6206; --gold-soft:#F7ECD4;
--shadow: 0 1px 2px rgba(23,20,26,.06), 0 8px 24px -16px rgba(23,20,26,.35);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--paper:#121014; --surface:#1A171C; --surface-2:#221E24;
--ink:#EFEAEC; --ink-2:#C4BBC0; --muted:#968D94;
--line:#322C34; --line-2:#443C46;
--accent:#FF6076; --accent-ink:#FF8090; --accent-soft:#3A1B22;
--alt:#4FC7D4; --alt-soft:#10333A;
--gold:#E8B84B; --gold-soft:#3A2E10;
--shadow: 0 1px 2px rgba(0,0,0,.5), 0 8px 24px -16px rgba(0,0,0,.9);
}
}
:root[data-theme="dark"] {
--paper:#121014; --surface:#1A171C; --surface-2:#221E24;
--ink:#EFEAEC; --ink-2:#C4BBC0; --muted:#968D94;
--line:#322C34; --line-2:#443C46;
--accent:#FF6076; --accent-ink:#FF8090; --accent-soft:#3A1B22;
--alt:#4FC7D4; --alt-soft:#10333A;
--gold:#E8B84B; --gold-soft:#3A2E10;
--shadow: 0 1px 2px rgba(0,0,0,.5), 0 8px 24px -16px rgba(0,0,0,.9);
}
* { box-sizing: border-box; }
body {
background: var(--paper); color: var(--ink);
font-family: "Source Sans 3", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 16.5px; line-height: 1.62; margin: 0; padding: 0 24px 96px;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 1180px; margin: 0 auto; }
.measure { max-width: 68ch; }
header.masthead { padding: 64px 0 40px; border-bottom: 2px solid var(--ink); margin-bottom: 40px; }
.eyebrow {
font-family: "JetBrains Mono", ui-monospace, Menlo, monospace;
font-size: 11.5px; letter-spacing: .14em; text-transform: uppercase;
color: var(--accent-ink); margin: 0 0 18px;
}
h1 {
font-family: Archivo, "Helvetica Neue", Arial, sans-serif; font-weight: 700;
font-size: clamp(2.3rem, 5.4vw, 3.6rem); line-height: 1.03;
letter-spacing: -.03em; text-wrap: balance; margin: 0 0 20px;
}
.standfirst { font-size: 1.16rem; color: var(--ink-2); max-width: 62ch; margin: 0; }
.standfirst strong { color: var(--ink); font-weight: 600; }
nav.toc { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 52px; }
nav.toc a {
font-family: "JetBrains Mono", monospace; font-size: 11.5px; letter-spacing: .05em;
text-decoration: none; color: var(--ink-2); background: var(--surface);
border: 1px solid var(--line); border-radius: 3px; padding: 6px 11px;
}
nav.toc a:hover { border-color: var(--accent); color: var(--accent-ink); }
nav.toc a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
section { margin: 0 0 76px; scroll-margin-top: 20px; }
h2 {
font-family: Archivo, "Helvetica Neue", Arial, sans-serif; font-weight: 600;
font-size: 1.62rem; letter-spacing: -.017em; line-height: 1.2;
text-wrap: balance; margin: 0 0 6px;
}
h3 {
font-family: Archivo, "Helvetica Neue", Arial, sans-serif; font-weight: 600;
font-size: 1.1rem; letter-spacing: -.01em; margin: 34px 0 8px;
}
.sec-head {
display: flex; align-items: baseline; gap: 16px;
border-top: 1px solid var(--line); padding-top: 16px; margin-bottom: 18px;
}
.sec-num {
font-family: "JetBrains Mono", monospace; font-size: 12px; font-weight: 600;
color: var(--accent-ink); padding-top: 6px; flex: none;
}
p { margin: 0 0 15px; }
code, .mono { font-family: "JetBrains Mono", ui-monospace, Menlo, monospace; font-size: .86em; }
p code, li code, td code {
background: var(--surface-2); border: 1px solid var(--line);
border-radius: 3px; padding: .07em .34em; color: var(--ink);
}
figure {
margin: 26px 0 32px; background: var(--surface); border: 1px solid var(--line);
border-radius: 4px; box-shadow: var(--shadow); overflow: hidden;
}
.fig-scroll { overflow-x: auto; padding: 22px 22px 6px; }
figure svg { display: block; max-width: 100%; height: auto; min-width: 700px; margin: 0 auto; }
figcaption {
border-top: 1px solid var(--line); background: var(--surface-2);
padding: 12px 22px 14px; font-size: 14px; color: var(--ink-2); line-height: 1.5;
}
figcaption b {
font-family: "JetBrains Mono", monospace; font-size: 11px; letter-spacing: .1em;
text-transform: uppercase; color: var(--accent-ink); display: block;
margin-bottom: 4px; font-weight: 600;
}
.table-scroll { overflow-x: auto; margin: 24px 0; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); }
table { border-collapse: collapse; width: 100%; font-size: 14.4px; min-width: 720px; }
th, td { text-align: left; padding: 11px 15px; border-bottom: 1px solid var(--line); vertical-align: top; }
thead th {
font-family: "JetBrains Mono", monospace; font-size: 11px; letter-spacing: .1em;
text-transform: uppercase; color: var(--muted); font-weight: 600; background: var(--surface-2);
}
tbody tr:last-child td { border-bottom: 0; }
td.num { font-family: "JetBrains Mono", monospace; font-variant-numeric: tabular-nums; white-space: nowrap; }
.pill {
font-family: "JetBrains Mono", monospace; font-size: 10.5px; padding: 2px 7px;
border-radius: 3px; white-space: nowrap; font-weight: 600;
display: inline-block;
}
.p-auto { background: var(--alt-soft); color: var(--alt); }
.p-part { background: var(--gold-soft); color: var(--gold); }
.p-man { background: var(--accent-soft); color: var(--accent-ink); }
.p-low { background: var(--surface-2); color: var(--muted); }
.callout {
border-left: 3px solid var(--accent); background: var(--accent-soft);
padding: 16px 20px; border-radius: 0 4px 4px 0; margin: 24px 0; font-size: 15.4px;
}
.callout.teal { border-left-color: var(--alt); background: var(--alt-soft); }
.callout.teal .label { color: var(--alt); }
.callout.gold { border-left-color: var(--gold); background: var(--gold-soft); }
.callout.gold .label { color: var(--gold); }
.callout p:last-child { margin-bottom: 0; }
.callout .label {
font-family: "JetBrains Mono", monospace; font-size: 11px; letter-spacing: .1em;
text-transform: uppercase; font-weight: 600; color: var(--accent-ink);
display: block; margin-bottom: 5px;
}
ul.plain { padding-left: 1.15em; margin: 12px 0 18px; max-width: 68ch; }
ul.plain li { margin-bottom: 8px; }
pre {
background: var(--surface-2); border: 1px solid var(--line); border-radius: 4px;
padding: 14px 16px; overflow-x: auto; font-size: 13px; line-height: 1.55;
font-family: "JetBrains Mono", ui-monospace, Menlo, monospace; color: var(--ink);
margin: 18px 0;
}
pre .cm { color: var(--muted); }
.ladder { display: grid; gap: 10px; margin: 24px 0; }
.rung {
display: grid; grid-template-columns: 62px 1fr; gap: 16px; align-items: start;
background: var(--surface); border: 1px solid var(--line);
border-left: 3px solid var(--line-2); border-radius: 0 4px 4px 0; padding: 13px 18px;
}
.rung .lvl {
font-family: "JetBrains Mono", monospace; font-weight: 600; font-size: 13px;
color: var(--accent-ink);
}
.rung .rn { font-family: Archivo, sans-serif; font-weight: 600; font-size: 1rem; display: block; margin-bottom: 2px; }
.rung p { margin: 0; font-size: 14.8px; color: var(--ink-2); }
.rung.r4 { border-left-color: var(--alt); }
.rung.r3 { border-left-color: var(--alt); }
.rung.r2 { border-left-color: var(--gold); }
.rung.r1 { border-left-color: var(--gold); }
.rung.r0 { border-left-color: var(--accent); }
footer { border-top: 1px solid var(--line); padding-top: 20px; color: var(--muted); font-size: 13.6px; }
@media (max-width: 640px) {
body { padding: 0 16px 64px; font-size: 16px; }
header.masthead { padding: 44px 0 30px; }
.rung { grid-template-columns: 1fr; gap: 4px; }
}
</style>
<div class="wrap">
<header class="masthead">
<p class="eyebrow">Implementation plan · fourth in the Doormile series</p>
<h1>Doormile Automation Plan</h1>
<p class="standfirst">What it would take to run the parcel pipeline <strong>without a dispatcher in it</strong> — decision by decision, from the one that is already automated to the leg that has no code in it at all. Grounded in <code>doormile_backend</code> as it stands today.</p>
</header>
<nav class="toc">
<a href="#s0">00 · What full means</a>
<a href="#s1">01 · The nine decisions</a>
<a href="#s2">02 · The blocker</a>
<a href="#s3">03 · First mile</a>
<a href="#s4">04 · Mid mile</a>
<a href="#s5">05 · Last mile</a>
<a href="#s6">06 · Exception agent</a>
<a href="#s7">07 · Control plane</a>
<a href="#s8">08 · Build order</a>
<a href="#s9">09 · What stays human</a>
</nav>
<section id="s0">
<div class="sec-head"><span class="sec-num">00</span><h2>What "fully automated" has to mean</h2></div>
<p class="measure">Full automation is not one switch, and a plan that treats it as one will fail in a specific way: it will automate the decisions that are easy to automate, leave the hard ones to a dispatcher, and then discover that the dispatcher is still working every day because the hard ones are the ones that happen.</p>
<p class="measure">So the target has to be stated as a property of <em>decisions</em>, not of the system. There are nine decisions between a booking existing and a parcel being delivered. Fully automated means <strong>every one of them is made by code, every one is scored against its outcome, and a human is involved only where the system explicitly escalates.</strong></p>
<p class="measure">That last clause is the honest part. The ceiling is not zero humans; it is humans on the exception queue only. §09 says exactly what stays there and why that is the correct design rather than a shortfall.</p>
<h3>The autonomy ladder</h3>
<p class="measure">Each of the nine decisions climbs these rungs independently, and each rung above shadow has a numeric gate. Nothing is promoted on confidence.</p>
<div class="ladder">
<div class="rung r0"><span class="lvl">L0</span><div><span class="rn">Manual</span><p>A person decides at a screen. No record exists of what they considered, so the decision cannot be replayed or improved.</p></div></div>
<div class="rung r1"><span class="lvl">L1</span><div><span class="rn">Shadow</span><p>Code decides in parallel and writes what it would have done. Nothing acts on it. Free, and where the surprises actually surface.</p></div></div>
<div class="rung r2"><span class="lvl">L2</span><div><span class="rn">Advisory</span><p>The proposal is shown to the operator as the default. They accept or override, and every override is a labelled training example.</p></div></div>
<div class="rung r3"><span class="lvl">L3</span><div><span class="rn">Auto with veto</span><p>The system acts on a timer. The operator sees it happen and has a window to reverse it. Most decisions should end their life here.</p></div></div>
<div class="rung r4"><span class="lvl">L4</span><div><span class="rn">Autonomous</span><p>Acts and notifies nobody unless it escalates. Reserved for decisions with a measured outcome history and a cheap failure mode.</p></div></div>
</div>
<div class="callout gold">
<span class="label">The gate, in one rule</span>
<p>A decision moves up a rung when its replayed outcomes beat the incumbent on the corpus, and its escalation rate is stable for two weeks. A decision moves <em>down</em> a rung automatically when either regresses. The ladder is a controller, not a roadmap — it should be demoting things without anyone filing a ticket.</p>
</div>
</section>
<section id="s1">
<div class="sec-head"><span class="sec-num">01</span><h2>The nine decisions</h2></div>
<p class="measure">This is the spine of the plan. Every row is a real decision the business makes hundreds of times a day, with what makes it today and where that sits on the ladder. Read the middle column as the honest current state, not the intended one.</p>
<figure>
<div class="fig-scroll">
<svg viewBox="0 0 960 430" role="img" aria-label="Nine decisions across first, mid and last mile, coloured by how automated each is today">
<defs>
<marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M0,0 L10,5 L0,10 z" fill="var(--line-2)"/>
</marker>
</defs>
<text x="20" y="20" font-family="JetBrains Mono, monospace" font-size="11" font-weight="600" letter-spacing="1.4" fill="var(--muted)">FIRST MILE</text>
<text x="340" y="20" font-family="JetBrains Mono, monospace" font-size="11" font-weight="600" letter-spacing="1.4" fill="var(--muted)">MID MILE</text>
<text x="660" y="20" font-family="JetBrains Mono, monospace" font-size="11" font-weight="600" letter-spacing="1.4" fill="var(--muted)">LAST MILE</text>
<line x1="20" y1="30" x2="300" y2="30" stroke="var(--line)" stroke-width="1"/>
<line x1="340" y1="30" x2="620" y2="30" stroke="var(--line)" stroke-width="1"/>
<line x1="660" y1="30" x2="940" y2="30" stroke="var(--line)" stroke-width="1"/>
<!-- FIRST MILE -->
<rect x="20" y="46" width="280" height="62" rx="4" fill="var(--gold-soft)" stroke="var(--gold)" stroke-width="1.2"/>
<text x="34" y="66" font-family="Archivo, sans-serif" font-size="13.5" font-weight="600" fill="var(--ink)">D1 · Which rider collects</text>
<text x="34" y="84" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">GEOSEARCH 10 km → LLM picks one → 5 s</text>
<text x="34" y="99" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">fallback to greedy nearest</text>
<text x="272" y="66" font-family="JetBrains Mono, monospace" font-size="11" font-weight="600" fill="var(--gold)">L2</text>
<rect x="20" y="118" width="280" height="62" rx="4" fill="var(--gold-soft)" stroke="var(--gold)" stroke-width="1.2"/>
<text x="34" y="138" font-family="Archivo, sans-serif" font-size="13.5" font-weight="600" fill="var(--ink)">D2 · Order of a rider's stops</text>
<text x="34" y="156" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">Valhalla sequencing — but only after</text>
<text x="34" y="171" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">assignment is already committed</text>
<text x="272" y="138" font-family="JetBrains Mono, monospace" font-size="11" font-weight="600" fill="var(--gold)">L2</text>
<rect x="20" y="190" width="280" height="62" rx="4" fill="var(--accent-soft)" stroke="var(--accent)" stroke-width="1.2"/>
<text x="34" y="210" font-family="Archivo, sans-serif" font-size="13.5" font-weight="600" fill="var(--ink)">D3 · How wide to search</text>
<text x="34" y="228" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">Hard-coded 10 km, max 3 active jobs.</text>
<text x="34" y="243" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">Same at noon and at 6 AM.</text>
<text x="272" y="210" font-family="JetBrains Mono, monospace" font-size="11" font-weight="600" fill="var(--accent-ink)">L0</text>
<!-- MID MILE -->
<rect x="340" y="46" width="280" height="62" rx="4" fill="var(--accent-soft)" stroke="var(--accent)" stroke-width="1.2"/>
<text x="354" y="66" font-family="Archivo, sans-serif" font-size="13.5" font-weight="600" fill="var(--ink)">D4 · Which parcels board</text>
<text x="354" y="84" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">A person builds the tripsheet by hand,</text>
<text x="354" y="99" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">scanning parcels into a vehicle.</text>
<text x="592" y="66" font-family="JetBrains Mono, monospace" font-size="11" font-weight="600" fill="var(--accent-ink)">L0</text>
<rect x="340" y="118" width="280" height="62" rx="4" fill="var(--accent-soft)" stroke="var(--accent)" stroke-width="1.2"/>
<text x="354" y="138" font-family="Archivo, sans-serif" font-size="13.5" font-weight="600" fill="var(--ink)">D5 · When the truck leaves</text>
<text x="354" y="156" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">Dispatcher instinct. Cost of a half-empty</text>
<text x="354" y="171" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">run vs SLA risk is never priced.</text>
<text x="592" y="138" font-family="JetBrains Mono, monospace" font-size="11" font-weight="600" fill="var(--accent-ink)">L0</text>
<rect x="340" y="190" width="280" height="62" rx="4" fill="var(--accent-soft)" stroke="var(--accent)" stroke-width="1.2"/>
<text x="354" y="210" font-family="Archivo, sans-serif" font-size="13.5" font-weight="600" fill="var(--ink)">D6 · Whether a lane exists</text>
<text x="354" y="228" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">Standing arrangement. Thin lanes run</text>
<text x="354" y="243" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">direct rather than consolidating.</text>
<text x="592" y="210" font-family="JetBrains Mono, monospace" font-size="11" font-weight="600" fill="var(--accent-ink)">L0</text>
<!-- LAST MILE -->
<rect x="660" y="46" width="280" height="62" rx="4" fill="var(--accent-soft)" stroke="var(--accent)" stroke-width="1.2"/>
<text x="674" y="66" font-family="Archivo, sans-serif" font-size="13.5" font-weight="600" fill="var(--ink)">D7 · Which rider delivers</text>
<text x="674" y="84" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">Hub staff assign from the console after</text>
<text x="674" y="99" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">offload. Batch-assign is greedy nearest.</text>
<text x="912" y="66" font-family="JetBrains Mono, monospace" font-size="11" font-weight="600" fill="var(--accent-ink)">L0</text>
<rect x="660" y="118" width="280" height="62" rx="4" fill="var(--accent-soft)" stroke="var(--accent)" stroke-width="1.2"/>
<text x="674" y="138" font-family="Archivo, sans-serif" font-size="13.5" font-weight="600" fill="var(--ink)">D8 · Will this attempt land</text>
<text x="674" y="156" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">Not decided at all. Failure is discovered</text>
<text x="674" y="171" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">at the door, then retried in full.</text>
<text x="912" y="138" font-family="JetBrains Mono, monospace" font-size="11" font-weight="600" fill="var(--accent-ink)">L0</text>
<rect x="660" y="190" width="280" height="62" rx="4" fill="var(--gold-soft)" stroke="var(--gold)" stroke-width="1.2"/>
<text x="674" y="210" font-family="Archivo, sans-serif" font-size="13.5" font-weight="600" fill="var(--ink)">D9 · Where the address is</text>
<text x="674" y="228" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">General geocoder, corrected by hand</text>
<text x="674" y="243" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">when a rider cannot find the door.</text>
<text x="912" y="210" font-family="JetBrains Mono, monospace" font-size="11" font-weight="600" fill="var(--gold)">L1</text>
<!-- cross-cutting -->
<line x1="20" y1="278" x2="940" y2="278" stroke="var(--line)" stroke-width="1"/>
<text x="20" y="298" font-family="JetBrains Mono, monospace" font-size="11" font-weight="600" letter-spacing="1.4" fill="var(--muted)">ACROSS ALL THREE LEGS</text>
<rect x="20" y="312" width="450" height="58" rx="4" fill="var(--accent-soft)" stroke="var(--accent)" stroke-width="1.2"/>
<text x="34" y="332" font-family="Archivo, sans-serif" font-size="13.5" font-weight="600" fill="var(--ink)">How long anything will take</text>
<text x="34" y="350" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">calculateETA is a flat 20 km/h against straight-line distance,</text>
<text x="34" y="365" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">plus a fixed ten-minute buffer. Sets every promise made.</text>
<text x="442" y="332" font-family="JetBrains Mono, monospace" font-size="11" font-weight="600" fill="var(--accent-ink)">L0</text>
<rect x="490" y="312" width="450" height="58" rx="4" fill="var(--accent-soft)" stroke="var(--accent)" stroke-width="1.2"/>
<text x="504" y="332" font-family="Archivo, sans-serif" font-size="13.5" font-weight="600" fill="var(--ink)">What to do when it breaks</text>
<text x="504" y="350" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">Rider absent, vehicle down, forty parcels stranded — every</text>
<text x="504" y="365" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">recovery is improvised by a person, and none is recorded.</text>
<text x="912" y="332" font-family="JetBrains Mono, monospace" font-size="11" font-weight="600" fill="var(--accent-ink)">L0</text>
<!-- legend -->
<rect x="20" y="396" width="11" height="11" rx="2" fill="var(--accent-soft)" stroke="var(--accent)"/>
<text x="38" y="406" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--muted)">L0 — a person decides, or nothing does</text>
<rect x="290" y="396" width="11" height="11" rx="2" fill="var(--gold-soft)" stroke="var(--gold)"/>
<text x="308" y="406" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--muted)">L1–L2 — code decides, unmeasured</text>
<rect x="560" y="396" width="11" height="11" rx="2" fill="var(--alt-soft)" stroke="var(--alt)"/>
<text x="578" y="406" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--muted)">L3–L4 — acts on its own (nothing is here yet)</text>
</svg>
</div>
<figcaption><b>Figure 1 — where the automation actually is</b>Two of nine decisions have code making them, and neither is scored. The middle column being entirely red is the finding: mid mile has no automation at all, which also makes it the cheapest leg to improve, because there is no incumbent to beat.</figcaption>
</figure>
<p class="measure">Two things fall out of this picture immediately. Nothing sits at L3 or L4, so <em>nothing in Doormile currently acts without a person</em> — the "AI dispatch" is L2 at best because no one can tell whether it beats the greedy fallback. And the cross-cutting pair at the bottom, ETA and exceptions, touch every leg, which makes them worth more than their row count suggests.</p>
</section>
<section id="s2">
<div class="sec-head"><span class="sec-num">02</span><h2>The blocker: nothing is measured</h2></div>
<p class="measure">Before any of the nine can climb a rung, one thing has to be true that is not true today: outcomes have to be recorded. Everything in this plan is gated on a change that takes about a day.</p>
<p class="measure">The audit trail is built. <code>AgentDecision</code> carries <code>Context</code>, <code>Decision</code>, <code>Reasoning</code>, <code>Outcome</code> and <code>OutcomeRecordedAt</code>. <code>UpdateDecisionOutcome</code> exists in <code>controllers/agentDecisionController.go</code>. The route is live at <code>PATCH /internal/agent-decisions/:id/outcome</code>. There is even a <code>context_embedding</code> pgvector column that <code>CreateAgentDecision</code> populates and <code>FindSimilarDecisions</code> queries.</p>
<p class="measure">Nothing in the Go codebase calls it. Not one handler. Every dispatch decision Doormile has ever made is stored with a null outcome.</p>
<div class="callout">
<span class="label">Week one, and it unblocks everything else</span>
<p>Call the outcome update from the three terminal handlers — <code>MilerDeliverConsignment</code>, <code>MilerSkipDelivery</code> and <code>MilerSkipPickup</code> — with a verdict of <code>delivered_first_attempt</code>, <code>delivered_late</code>, <code>failed</code> or <code>reassigned</code>. From that day every decision is scored, and every claim about the dispatcher becomes checkable rather than arguable.</p>
</div>
<p class="measure">The second half of the same change is the replay harness: a command that takes a date range of <code>AgentDecision</code> rows, re-runs a candidate policy against the stored <code>Context</code>, and scores its choices against the recorded outcomes. That harness is what every gate in §00 reads from. Without it, promotion is a vote.</p>
<pre><span class="cm">// the loop, closed — internal/assignment/outcome.go</span>
func RecordOutcome(bookingID uint64, verdict string) {
<span class="cm">// best-effort, exactly like AI_LAYER_FALLBACK: never block a delivery</span>
<span class="cm">// on the audit write. A missing outcome is a gap in the corpus,</span>
<span class="cm">// not a failed handover.</span>
}
</pre>
<p class="measure">Write it as best-effort and off the request path, matching the pattern <code>selectMilerWithAI</code> already uses. A rider standing at a door must never wait on an analytics write.</p>
</section>
<section id="s3">
<div class="sec-head"><span class="sec-num">03</span><h2>First mile: stop deciding one parcel at a time</h2></div>
<p class="measure">D1 and D2 are both automated and both compromised by the same thing — they run in series. <code>SequenceMilerStopsAsync</code> is called from five places, and every one of them fires <em>after</em> the assignment has been committed. The solver is therefore always sequencing stops that someone else already chose.</p>
<figure>
<div class="fig-scroll">
<svg viewBox="0 0 960 330" role="img" aria-label="Today's serial assign-then-sequence pipeline compared with a batched joint solve">
<defs>
<marker id="ar2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
<path d="M0,0 L10,5 L0,10 z" fill="var(--line-2)"/>
</marker>
<marker id="ar3" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
<path d="M0,0 L10,5 L0,10 z" fill="var(--alt)"/>
</marker>
</defs>
<text x="20" y="22" font-family="JetBrains Mono, monospace" font-size="11" font-weight="600" letter-spacing="1.4" fill="var(--accent-ink)">TODAY — ONE PARCEL AT A TIME</text>
<rect x="20" y="40" width="128" height="52" rx="4" fill="var(--surface-2)" stroke="var(--line-2)"/>
<text x="84" y="63" text-anchor="middle" font-family="Source Sans 3, sans-serif" font-size="12.5" fill="var(--ink)">one booking</text>
<text x="84" y="79" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="10.5" fill="var(--muted)">NATS</text>
<line x1="152" y1="66" x2="180" y2="66" stroke="var(--line-2)" stroke-width="1.4" marker-end="url(#ar2)"/>
<rect x="184" y="40" width="140" height="52" rx="4" fill="var(--surface-2)" stroke="var(--line-2)"/>
<text x="254" y="63" text-anchor="middle" font-family="Source Sans 3, sans-serif" font-size="12.5" fill="var(--ink)">GEOSEARCH 10 km</text>
<text x="254" y="79" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="10.5" fill="var(--muted)">fixed radius</text>
<line x1="328" y1="66" x2="356" y2="66" stroke="var(--line-2)" stroke-width="1.4" marker-end="url(#ar2)"/>
<rect x="360" y="40" width="140" height="52" rx="4" fill="var(--accent-soft)" stroke="var(--accent)"/>
<text x="430" y="63" text-anchor="middle" font-family="Source Sans 3, sans-serif" font-size="12.5" fill="var(--ink)">LLM picks 1 rider</text>
<text x="430" y="79" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="10.5" fill="var(--accent-ink)">5 s cap, on path</text>
<line x1="504" y1="66" x2="532" y2="66" stroke="var(--line-2)" stroke-width="1.4" marker-end="url(#ar2)"/>
<rect x="536" y="40" width="140" height="52" rx="4" fill="var(--surface-2)" stroke="var(--line-2)"/>
<text x="606" y="63" text-anchor="middle" font-family="Source Sans 3, sans-serif" font-size="12.5" fill="var(--ink)">commit assignment</text>
<text x="606" y="79" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="10.5" fill="var(--muted)">irreversible</text>
<line x1="680" y1="66" x2="708" y2="66" stroke="var(--line-2)" stroke-width="1.4" marker-end="url(#ar2)"/>
<rect x="712" y="40" width="146" height="52" rx="4" fill="var(--gold-soft)" stroke="var(--gold)"/>
<text x="785" y="63" text-anchor="middle" font-family="Source Sans 3, sans-serif" font-size="12.5" fill="var(--ink)">then sequence</text>
<text x="785" y="79" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="10.5" fill="var(--gold)">Valhalla, too late</text>
<text x="20" y="120" font-family="Source Sans 3, sans-serif" font-size="12.5" font-style="italic" fill="var(--muted)">Each parcel is committed to a rider before the next parcel is even looked at. The solver inherits a set it had no say in.</text>
<line x1="20" y1="146" x2="940" y2="146" stroke="var(--line)" stroke-width="1"/>
<text x="20" y="176" font-family="JetBrains Mono, monospace" font-size="11" font-weight="600" letter-spacing="1.4" fill="var(--alt)">TARGET — SOLVE THE WINDOW</text>
<rect x="20" y="194" width="150" height="64" rx="4" fill="var(--alt-soft)" stroke="var(--alt)"/>
<text x="95" y="217" text-anchor="middle" font-family="Source Sans 3, sans-serif" font-size="12.5" fill="var(--ink)">accumulate 10–15 min</text>
<text x="95" y="234" text-anchor="middle" font-family="Source Sans 3, sans-serif" font-size="12.5" fill="var(--ink)">of open pickups</text>
<text x="95" y="250" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="10.5" fill="var(--alt)">batch window</text>
<line x1="174" y1="226" x2="202" y2="226" stroke="var(--alt)" stroke-width="1.4" marker-end="url(#ar3)"/>
<rect x="206" y="194" width="150" height="64" rx="4" fill="var(--surface-2)" stroke="var(--line-2)"/>
<text x="281" y="217" text-anchor="middle" font-family="Source Sans 3, sans-serif" font-size="12.5" fill="var(--ink)">adaptive radius</text>
<text x="281" y="234" text-anchor="middle" font-family="Source Sans 3, sans-serif" font-size="12.5" fill="var(--ink)">widen until k found</text>
<text x="281" y="250" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="10.5" fill="var(--muted)">replaces fixed 10 km</text>
<line x1="360" y1="226" x2="388" y2="226" stroke="var(--alt)" stroke-width="1.4" marker-end="url(#ar3)"/>
<rect x="392" y="188" width="196" height="76" rx="4" fill="var(--alt-soft)" stroke="var(--alt)" stroke-width="1.8"/>
<text x="490" y="212" text-anchor="middle" font-family="Archivo, sans-serif" font-size="13.5" font-weight="600" fill="var(--ink)">joint solve</text>
<text x="490" y="230" text-anchor="middle" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">who carries it AND</text>
<text x="490" y="245" text-anchor="middle" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">in what order — one problem</text>
<text x="490" y="259" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="10.5" fill="var(--alt)">Valhalla cost matrix</text>
<line x1="592" y1="226" x2="620" y2="226" stroke="var(--alt)" stroke-width="1.4" marker-end="url(#ar3)"/>
<rect x="624" y="194" width="150" height="64" rx="4" fill="var(--surface-2)" stroke="var(--line-2)"/>
<text x="699" y="217" text-anchor="middle" font-family="Source Sans 3, sans-serif" font-size="12.5" fill="var(--ink)">validate + commit</text>
<text x="699" y="234" text-anchor="middle" font-family="Source Sans 3, sans-serif" font-size="12.5" fill="var(--ink)">whole plan</text>
<text x="699" y="250" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="10.5" fill="var(--muted)">one transaction</text>
<line x1="778" y1="226" x2="806" y2="226" stroke="var(--alt)" stroke-width="1.4" marker-end="url(#ar3)"/>
<rect x="810" y="194" width="130" height="64" rx="4" fill="var(--gold-soft)" stroke="var(--gold)"/>
<text x="875" y="217" text-anchor="middle" font-family="Source Sans 3, sans-serif" font-size="12.5" fill="var(--ink)">veto window</text>
<text x="875" y="234" text-anchor="middle" font-family="Source Sans 3, sans-serif" font-size="12.5" fill="var(--ink)">then it stands</text>
<text x="875" y="250" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="10.5" fill="var(--gold)">L3</text>
<text x="20" y="292" font-family="Source Sans 3, sans-serif" font-size="12.5" font-style="italic" fill="var(--muted)">The model is not on this path at all. It compiles constraints into the solver beforehand and explains the output afterwards — §06 and §07.</text>
<text x="20" y="314" font-family="Source Sans 3, sans-serif" font-size="12.5" fill="var(--muted)">Superfast bookings bypass the window entirely and take today's immediate path. A batch window is a latency budget, and not every parcel has one.</text>
</svg>
</div>
<figcaption><b>Figure 2 — the ordering change</b>The Valhalla client already exists and already speaks Doormile's vocabulary. The work is moving it in front of the commit and feeding it the whole open set rather than one rider's inherited stops. Note what leaves the critical path: the five-second model call.</figcaption>
</figure>
<h3>What changes in code</h3>
<ul class="plain">
<li><strong>A batch accumulator on the existing queue.</strong> <code>booking.assignment_requested</code> already exists as a NATS subject with a worker behind it. Buffer into a window keyed by hub and service type rather than handling each message on arrival.</li>
<li><strong>Adaptive candidate search replaces <code>geoRadiusKm = 10.0</code>.</strong> Widen until <em>k</em> genuine candidates are found. The fixed radius returns ten near-identical riders at noon and nothing at 6 AM, and both are failures of the same constant.</li>
<li><strong>Learned dwell replaces the ten-minute buffer.</strong> Every <code>arrivedat</code> → <code>pickup-complete</code> pair in the database is a labelled example of how long a pickup point actually takes. Per-location dwell plus Valhalla road time plus a time-of-day factor replaces <code>calculateETA</code> outright.</li>
<li><strong>Keep <code>maxActive = 3</code> as a solver constraint,</strong> not a filter applied before the solver sees the candidate. It is a capacity limit, and capacity limits belong in the model.</li>
</ul>
<div class="callout teal">
<span class="label">Already done — do not rebuild it</span>
<p><code>ai_layer.go</code> already validates that the model's <code>chosen_miler_id</code> is inside the eligibility set and falls back to legacy scoring when it is not. That is the closed-set guardrail, and it is correctly implemented. The joint solver needs the same check on every rider in its returned plan.</p>
</div>
</section>
<section id="s4">
<div class="sec-head"><span class="sec-num">04</span><h2>Mid mile: three decisions, no code</h2></div>
<p class="measure">This is the largest single gap and the easiest to close, because there is no incumbent behaviour to beat and no model quality argument to have. All three decisions are classical operations research with objectives you can write down, and the constraints are already columns in the database.</p>
<div class="table-scroll">
<table>
<thead><tr><th>Decision</th><th>Technique</th><th>Constraints already stored</th><th>Objective</th></tr></thead>
<tbody>
<tr>
<td><strong>D4</strong> — which parcels board which truck</td>
<td>Bin packing</td>
<td><code>Vehicle.Maxweight</code>, <code>Vehicle.Maxvolume</code>, consignment dimensions, destination hub</td>
<td>Fill vehicles by destination mix without breaching capacity or cut-off</td>
</tr>
<tr>
<td><strong>D5</strong> — when it leaves</td>
<td>Priced threshold</td>
<td><code>Sladueat</code> on every consignment and booking</td>
<td>Marginal cost of an extra run vs expected SLA breach cost of holding</td>
</tr>
<tr>
<td><strong>D6</strong> — whether the lane runs direct</td>
<td>Network consolidation</td>
<td><code>Tripsheet.Batchkind</code> — the <code>local</code> / <code>transfer</code> split already exists</td>
<td>Route thin lanes through a transfer hub instead of running half-empty</td>
</tr>
</tbody>
</table>
</div>
<p class="measure">D5 is the one worth doing first, and it is worth doing even before D4. It is a scalar comparison, not a search: for the parcels currently at the hub, the cost of dispatching now is known, and the cost of holding is the sum of breach probabilities against <code>Sladueat</code>. Today that trade is a dispatcher's instinct and is never written down. Turning it into a number, shown on the hub console next to the tripsheet, is an advisory-rung feature that needs no solver at all.</p>
<p class="measure">D4 then automates the tripsheet build: propose a manifest, let hub staff scan against it, and treat every manual deviation as a labelled correction. The scan step stays exactly as it is — <code>TripsheetItem.Scanstatus</code> already carries <code>Pending</code> / <code>Loaded</code> / <code>Unloaded</code> / <code>Discrepancy</code>, which is precisely the reconciliation surface an auto-built manifest needs.</p>
<div class="callout gold">
<span class="label">The one thing to add to the schema</span>
<p>Tripsheets have no cut-off time and no planned capacity fields. Both D4 and D5 need them. That is the only schema change mid-mile automation requires — everything else it needs is already a column.</p>
</div>
</section>
<section id="s5">
<div class="sec-head"><span class="sec-num">05</span><h2>Last mile: predict instead of discover</h2></div>
<p class="measure">Every failed delivery is a second full run for one parcel, and first-attempt success is the dominant cost line in Indian last mile. Doormile enters that retry loop on discovery — the rider reaches the door and finds nobody there. The information needed to predict it is already being written on every failure.</p>
<ul class="plain">
<li><strong>D8 — first-attempt success prediction.</strong> <code>Attemptcount</code> and the skip reason are recorded on every failed attempt. That is a labelled training set sitting unused. Predict per-stop success, then sequence on it and shift low-probability stops to a window where they are likely to land, rather than burning an attempt to learn what the data already says.</li>
<li><strong>D9 — address resolution.</strong> pgvector is already running and already in use for decision similarity. Extend it: embed delivery address text, cluster duplicates, and snap each cluster to the coordinates confirmed by past <code>DeliveryProof</code> records. Your delivered history becomes a private geocoder that beats any general one inside your own zones, because it is built from the doors your riders actually found.</li>
<li><strong>D7 — delivery rider selection.</strong> Same joint solve as §03, run at the destination hub on offload. It is the same problem shape with a different origin, and it should share the solver rather than get its own greedy path.</li>
</ul>
<p class="measure">D9 is the highest-leverage item in the plan and among the lowest risk, because it can run in shadow indefinitely: compute the corrected coordinate, log the delta against what the geocoder said, and change nothing until the deltas are demonstrably better. It also compounds — every leg's routing improves when the destination coordinates are right.</p>
</section>
<section id="s6">
<div class="sec-head"><span class="sec-num">06</span><h2>The exception agent: the last human out of the loop</h2></div>
<p class="measure">Automating the nine decisions gets you a pipeline that runs itself on a good day. What keeps a dispatcher employed is the bad day — a rider goes dark mid-round, a vehicle breaks down with forty parcels aboard, a client calls at 4 PM to move a pickup. These are exactly the decisions a solver is bad at and a language model is good at, because they are unstructured, contextual and rare.</p>
<p class="measure">This is the one place in the plan where generative AI does something irreplaceable, and the design rule from the agent-layer document holds: <strong>the model proposes, the solver decides, the system validates.</strong> The exception agent's job is to turn a mess into a structured recovery request that the existing solver can price, not to choose the recovery itself.</p>
<div class="callout teal">
<span class="label">The boundary that makes it safe</span>
<p>The agent never writes to the database. It calls the same internal endpoints a human operator would, so tenant scoping, idempotency, validation and audit apply identically whether the caller is a person or a model. If an agent can reach a table your dispatcher cannot, you have built two systems and will only secure one.</p>
</div>
<p class="measure">Give it a bounded tool set — reassign a stop, rebuild a manifest, extend a window, notify a consignee, page a human — and reward the last one. An agent with no honourable way to stop will invent an action instead. The escalation rate is also the metric that tells you whether the rest of the automation is working: it should fall as the other eight decisions climb, and a rising escalation rate is the earliest signal that something below it has regressed.</p>
</section>
<section id="s7">
<div class="sec-head"><span class="sec-num">07</span><h2>The control plane</h2></div>
<p class="measure">Running unattended is a different engineering problem from deciding well. This is the machinery that makes it safe to let the nine decisions act, and most of it already exists in the estate.</p>
<figure>
<div class="fig-scroll">
<svg viewBox="0 0 900 250" role="img" aria-label="The control loop: decide, act, record outcome, replay, gate, promote or demote">
<defs>
<marker id="ar4" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
<path d="M0,0 L10,5 L0,10 z" fill="var(--alt)"/>
</marker>
</defs>
<rect x="20" y="60" width="130" height="56" rx="4" fill="var(--surface-2)" stroke="var(--line-2)"/>
<text x="85" y="84" text-anchor="middle" font-family="Archivo, sans-serif" font-size="13" font-weight="600" fill="var(--ink)">decide</text>
<text x="85" y="102" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="10.5" fill="var(--muted)">AgentDecision row</text>
<line x1="154" y1="88" x2="182" y2="88" stroke="var(--alt)" stroke-width="1.4" marker-end="url(#ar4)"/>
<rect x="186" y="60" width="130" height="56" rx="4" fill="var(--surface-2)" stroke="var(--line-2)"/>
<text x="251" y="84" text-anchor="middle" font-family="Archivo, sans-serif" font-size="13" font-weight="600" fill="var(--ink)">act</text>
<text x="251" y="102" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="10.5" fill="var(--muted)">idempotent write</text>
<line x1="320" y1="88" x2="348" y2="88" stroke="var(--alt)" stroke-width="1.4" marker-end="url(#ar4)"/>
<rect x="352" y="60" width="130" height="56" rx="4" fill="var(--accent-soft)" stroke="var(--accent)" stroke-width="1.6"/>
<text x="417" y="84" text-anchor="middle" font-family="Archivo, sans-serif" font-size="13" font-weight="600" fill="var(--ink)">record outcome</text>
<text x="417" y="102" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="10.5" fill="var(--accent-ink)">missing today</text>
<line x1="486" y1="88" x2="514" y2="88" stroke="var(--alt)" stroke-width="1.4" marker-end="url(#ar4)"/>
<rect x="518" y="60" width="130" height="56" rx="4" fill="var(--surface-2)" stroke="var(--line-2)"/>
<text x="583" y="84" text-anchor="middle" font-family="Archivo, sans-serif" font-size="13" font-weight="600" fill="var(--ink)">replay</text>
<text x="583" y="102" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="10.5" fill="var(--muted)">policy vs corpus</text>
<line x1="652" y1="88" x2="680" y2="88" stroke="var(--alt)" stroke-width="1.4" marker-end="url(#ar4)"/>
<rect x="684" y="60" width="130" height="56" rx="4" fill="var(--alt-soft)" stroke="var(--alt)" stroke-width="1.6"/>
<text x="749" y="84" text-anchor="middle" font-family="Archivo, sans-serif" font-size="13" font-weight="600" fill="var(--ink)">gate</text>
<text x="749" y="102" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="10.5" fill="var(--alt)">promote / demote</text>
<path d="M749,120 L749,160 L85,160 L85,122" fill="none" stroke="var(--alt)" stroke-width="1.4" stroke-dasharray="4 3" marker-end="url(#ar4)"/>
<rect x="330" y="146" width="210" height="26" rx="3" fill="var(--surface)" stroke="var(--line)"/>
<text x="435" y="164" text-anchor="middle" font-family="Source Sans 3, sans-serif" font-size="12" fill="var(--ink-2)">the rung changes what "decide" may do</text>
<text x="20" y="212" font-family="Source Sans 3, sans-serif" font-size="12.5" fill="var(--muted)">Break the loop at "record outcome" and the two boxes after it cannot run at all — which is the state the system is in today.</text>
<text x="20" y="232" font-family="Source Sans 3, sans-serif" font-size="12.5" fill="var(--muted)">Every capability rides the same loop. There is one control plane, not one per feature.</text>
</svg>
</div>
<figcaption><b>Figure 3 — one loop, every decision</b>The ladder in §00 is the feedback path of this loop rather than a project schedule. When it is closed, autonomy levels move on their own evidence; when it is open, as now, every promotion is someone's opinion.</figcaption>
</figure>
<h3>Invariants that must hold before anything reaches L3</h3>
<ul class="plain">
<li><strong>Closed-set validation on every agent output.</strong> Already correct in <code>ai_layer.go</code>; apply the same pattern to solver plans and agent tool calls. Models hallucinate identifiers, and a validator is three lines.</li>
<li><strong>Idempotency on every agent-initiated write.</strong> The Redis-backed <code>Idempotency-Key</code> middleware is already on <code>/consignments/:id/deliver</code>. Agents retry more than humans do, and a duplicated reassignment or double COD entry is far worse than a slow one.</li>
<li><strong>Tenant isolation as a hard validator failure.</strong> <code>HubStaffAccount.Tenantid</code> already isolates partner freight. Cross-tenant leakage must fail the plan, not cost it points. This is the one mistake that is a breach rather than a bad route.</li>
<li><strong>Structured payloads only.</strong> The current request to the decision engine carries no customer free text — that is a security property worth defending. The moment raw notes or address strings enter a prompt that also drives actions, anyone who can type into a booking form can try to instruct your dispatcher. Extraction from free text happens in a separate call whose output is validated before it reaches an action.</li>
<li><strong>A kill switch per decision, not one global flag.</strong> Each of the nine falls back to its previous rung independently. <code>routing.BaseURL</code> already models this correctly: empty disables sequencing, and stops simply stay unsequenced rather than assignment failing.</li>
<li><strong>A per-booking and per-day compute budget with a hard cutoff.</strong> Cost overruns in agentic systems come from retry storms, not steady state.</li>
</ul>
</section>
<section id="s8">
<div class="sec-head"><span class="sec-num">08</span><h2>Build order</h2></div>
<p class="measure">Sequenced by ratio of effect to effort and by what unblocks what — not by ambition. The first three need no new infrastructure and no model.</p>
<div class="table-scroll">
<table>
<thead><tr><th>#</th><th>Ship</th><th>Decisions</th><th>Depends on</th><th>Target rung</th></tr></thead>
<tbody>
<tr><td class="num">1</td><td><strong>Close the outcome loop.</strong> Call the outcome update from the deliver, skip-delivery and skip-pickup handlers; build the replay harness over <code>AgentDecision</code>.</td><td class="num">all</td><td>nothing</td><td><span class="pill p-low">enables all</span></td></tr>
<tr><td class="num">2</td><td><strong>Real ETA.</strong> Valhalla road time × time-of-day factor + learned dwell, replacing <code>calculateETA</code>.</td><td class="num">ETA</td><td>#1 to measure it</td><td><span class="pill p-auto">L4 direct</span></td></tr>
<tr><td class="num">3</td><td><strong>Adaptive candidate set.</strong> Widen the radius until <em>k</em> real candidates exist; move <code>maxActive</code> into the solver as a constraint.</td><td class="num">D3</td><td>nothing</td><td><span class="pill p-auto">L4</span></td></tr>
<tr><td class="num">4</td><td><strong>Hold-vs-go, priced.</strong> Show the dispatch-now vs hold number on the hub console against <code>Sladueat</code>.</td><td class="num">D5</td><td>#1</td><td><span class="pill p-part">L2 advisory</span></td></tr>
<tr><td class="num">5</td><td><strong>Address resolution on pgvector.</strong> Embed delivered addresses, cluster, snap to <code>DeliveryProof</code> coordinates.</td><td class="num">D9</td><td>delivered history</td><td><span class="pill p-part">L1 → L3</span></td></tr>
<tr><td class="num">6</td><td><strong>Batched joint assign + sequence</strong> behind a flag, running in shadow beside today's path.</td><td class="num">D1, D2, D7</td><td>#2, #3, #5</td><td><span class="pill p-part">L1 → L3</span></td></tr>
<tr><td class="num">7</td><td><strong>Auto-built tripsheets.</strong> Bin-pack the manifest; reconcile against the existing scan flow.</td><td class="num">D4</td><td>#4, cut-off fields</td><td><span class="pill p-part">L2 → L3</span></td></tr>
<tr><td class="num">8</td><td><strong>First-attempt success model,</strong> sequencing stops on predicted success.</td><td class="num">D8</td><td>#5, attempt history</td><td><span class="pill p-part">L1 → L3</span></td></tr>
<tr><td class="num">9</td><td><strong>Exception agent,</strong> bounded tool set, proposals only at first.</td><td class="num">exceptions</td><td>#1, #6</td><td><span class="pill p-part">L2 → L3</span></td></tr>
<tr><td class="num">10</td><td><strong>Lane consolidation.</strong> Route thin lanes via transfer hubs using <code>Batchkind</code>.</td><td class="num">D6</td><td>#7, volume forecast</td><td><span class="pill p-part">L2</span></td></tr>
</tbody>
</table>
</div>
<p class="measure">Items 1 through 3 are the ones to commit to now. None of them involves a model, all three are measurable within a fortnight, and #1 is the difference between a plan and a hope: until outcomes are recorded, no gate in this document can be evaluated and every later item is unfalsifiable.</p>
<div class="callout">
<span class="label">The dependency worth respecting</span>
<p>Item 6 is the largest routing gain available and it is deliberately sixth. It depends on a real cost matrix (#2), a sane candidate set (#3) and trustworthy coordinates (#5). Built before those, it is a better solver fed worse data, and it will underperform the greedy path it replaces — which is the failure mode most likely to kill the whole programme politically.</p>
</div>
</section>
<section id="s9">
<div class="sec-head"><span class="sec-num">09</span><h2>What stays human, permanently</h2></div>
<p class="measure">A plan for full automation is only credible if it says where automation stops. These are not gaps to close later; they are the designed boundary.</p>
<ul class="plain">
<li><strong>The escalation queue.</strong> Novel exceptions — a road closed by a festival, a client dispute, a rider in an accident. The agent's job is to recognise it cannot handle these and hand over cleanly with context attached.</li>
<li><strong>Constraint approval.</strong> When a dispatcher's rule is compiled into a solver constraint, a human approves it before it binds. The compiler makes the encoding fast; it does not get to decide policy.</li>
<li><strong>Anything that touches money or identity.</strong> COD discrepancies, refunds, rate-card changes. These need an accountable person, and the audit trail should say who.</li>
<li><strong>Promotion past L3 for any decision with an expensive failure mode.</strong> Most decisions should live at auto-with-veto permanently. L4 is for decisions that are cheap to get wrong, and the ladder is allowed to keep things below it forever.</li>
</ul>
<p class="measure">Measured against that boundary, the target is reachable and specific: nine decisions made by code, one loop scoring all of them, and a dispatcher whose day is the exception queue rather than the queue. Doormile is closer to it than the current state suggests — the audit table, the vector store, the road-network client, the idempotency middleware and the async worker are all already built. The thing standing between the estate and full automation is not infrastructure. It is that nobody is writing down what happened.</p>
</section>
<footer>
Written against <code>doormile_backend</code> in the nearle-de workspace — <code>internal/assignment/{ai_layer,crm_assignment,customer_assignment,queue}.go</code>, <code>internal/routing/optimizer.go</code>, <code>models/{agentdecision,audit,transit,users,booking}.go</code>, <code>controllers/agentDecisionController.go</code> and <code>routes/routes.go</code>. Constants, field names, formulas and endpoints are quoted as they appear there. Follows <em>Doormile Parcel Flow</em>, <em>Doormile Route Intelligence</em> and <em>Doormile Agent Layer</em>.
</footer>
</div>