policy-nexus/build/adr/custodian-hub-authority/v1/index.html
tegwick c1b60f322e
All checks were successful
Build and publish policy-nexus image / build-and-push (push) Successful in 1m10s
feat: publish Risk Nexus findings and methods
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a058f3-8ba0-7692-a042-9a870fc3d663
2026-09-01 01:56:46 +02:00

253 lines
31 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"><meta charset="utf-8">
<meta name="policy-source-revision" content="f9435cd605cc5b3cb0f2e957ce6287d9f3129aac">
<meta name="policy-source-digest" content="5979da20799118259fc19246f51e8cc8f2c0c1be3d414318bd51d5a27d9ad565">
<title>Hub Authority, Local Cache, and the Two Kinds of Hub Data</title>
<style>
:root{
--paper:#EDEEF0; --surface:#F6F7F8; --surface-2:#E4E6E9;
--ink:#171D24; --ink-2:#4A5561; --ink-3:#737E8A;
--rule:#D3D7DC; --rule-strong:#B6BCC3;
--brass:#8A6A2E; --brass-soft:#EFE5CD; --brass-line:#C9AE74;
--clay:#8A3A2C; --clay-soft:#F2DFDA;
--l0:#DCE0E2; --l1:#B9C4C7; --l2:#8CA1A6; --l3:#567D84; --l4:#23555E;
--chip-fg:#F6F7F8;
--font-display:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;
--font-body:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;
--font-mono:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace;
--measure:66ch;
}
@media (prefers-color-scheme:dark){
:root:not([data-theme="light"]){
--paper:#12161A; --surface:#191E24; --surface-2:#222831;
--ink:#E6E9EC; --ink-2:#A3ADB7; --ink-3:#78838E;
--rule:#2A3138; --rule-strong:#3B444D;
--brass:#C9A45C; --brass-soft:#33290F; --brass-line:#6B5426;
--clay:#D08A76; --clay-soft:#3A211B;
--l0:#262C32; --l1:#35424A; --l2:#4A626B; --l3:#6A939D; --l4:#97C4CD;
--chip-fg:#12161A;
}
}
:root[data-theme="dark"]{
--paper:#12161A; --surface:#191E24; --surface-2:#222831;
--ink:#E6E9EC; --ink-2:#A3ADB7; --ink-3:#78838E;
--rule:#2A3138; --rule-strong:#3B444D;
--brass:#C9A45C; --brass-soft:#33290F; --brass-line:#6B5426;
--clay:#D08A76; --clay-soft:#3A211B;
--l0:#262C32; --l1:#35424A; --l2:#4A626B; --l3:#6A939D; --l4:#97C4CD;
--chip-fg:#12161A;
}
*{box-sizing:border-box}
body{
margin:0; background:var(--paper); color:var(--ink);
font-family:var(--font-body); font-size:17px; line-height:1.62;
-webkit-font-smoothing:antialiased;
}
.wrap{max-width:1180px;margin:0 auto;padding:0 24px 96px}
.layout{display:grid;grid-template-columns:180px minmax(0,1fr);gap:56px;align-items:start}
@media (max-width:960px){.layout{grid-template-columns:1fr;gap:0}.rail{display:none}}
/* ---------- rail ---------- */
.rail{position:sticky;top:28px;padding-top:8px;font-family:var(--font-display);font-size:12px;line-height:1.5}
.rail ol{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:7px}
.rail a{color:var(--ink-3);text-decoration:none;display:flex;gap:9px}
.rail a:hover,.rail a:focus-visible{color:var(--brass)}
.rail .n{font-family:var(--font-mono);font-size:10px;color:var(--rule-strong);min-width:16px;padding-top:1px}
.rail .grp{margin-top:14px;font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--rule-strong)}
/* ---------- header ---------- */
header{padding:64px 0 40px;border-bottom:2px solid var(--ink);margin-bottom:44px}
.eyebrow{font-family:var(--font-mono);font-size:11.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-3);display:flex;flex-wrap:wrap;gap:14px;margin-bottom:22px}
.eyebrow .stat{color:var(--clay)}
h1{font-family:var(--font-display);font-weight:800;letter-spacing:-.035em;line-height:.94;font-size:clamp(46px,9vw,92px);margin:0 0 6px;text-wrap:balance}
.sub{font-family:var(--font-display);font-weight:500;font-size:clamp(16px,2.4vw,21px);letter-spacing:-.01em;color:var(--ink-2);margin:0 0 30px;max-width:34ch;line-height:1.3}
.metagrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:20px 28px;border-top:1px solid var(--rule);padding-top:20px}
.metagrid dt{font-family:var(--font-mono);font-size:10px;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-3);margin-bottom:5px}
.metagrid dd{margin:0;font-family:var(--font-display);font-size:13.5px;line-height:1.45;color:var(--ink)}
/* ---------- typography ---------- */
section{margin-bottom:60px;scroll-margin-top:24px}
h2{font-family:var(--font-display);font-weight:750;letter-spacing:-.022em;font-size:clamp(24px,3.4vw,31px);line-height:1.12;margin:0 0 18px;text-wrap:balance;display:flex;gap:14px;align-items:baseline}
h2 .sn{font-family:var(--font-mono);font-size:12px;font-weight:400;color:var(--brass);letter-spacing:.06em;flex:none;padding-top:2px}
h3{font-family:var(--font-display);font-weight:700;font-size:16px;letter-spacing:-.008em;margin:34px 0 10px;color:var(--ink)}
p{margin:0 0 15px;max-width:var(--measure)}
ul,ol{max-width:var(--measure);margin:0 0 15px;padding-left:20px}
li{margin-bottom:7px}
strong{font-weight:600}
em{font-style:italic}
code{font-family:var(--font-mono);font-size:.855em;background:var(--surface-2);padding:1px 5px;border-radius:2px}
a{color:var(--brass)}
.lede{font-size:19px;line-height:1.55;color:var(--ink-2);max-width:60ch}
/* ---------- devices ---------- */
.callout{border-left:3px solid var(--brass);background:var(--brass-soft);padding:18px 22px;margin:0 0 24px;max-width:var(--measure)}
.callout p:last-child{margin-bottom:0}
.callout .lbl{font-family:var(--font-mono);font-size:10px;letter-spacing:.13em;text-transform:uppercase;color:var(--brass);display:block;margin-bottom:8px}
.rule-quote{border-top:2px solid var(--ink);border-bottom:2px solid var(--ink);padding:26px 0;margin:28px 0;max-width:var(--measure)}
.rule-quote p{font-family:var(--font-display);font-weight:600;font-size:19px;line-height:1.38;letter-spacing:-.014em;margin:0;text-wrap:balance}
.hard{border-left:3px solid var(--clay);background:var(--clay-soft);padding:18px 22px;margin:0 0 24px;max-width:var(--measure)}
.hard .lbl{font-family:var(--font-mono);font-size:10px;letter-spacing:.13em;text-transform:uppercase;color:var(--clay);display:block;margin-bottom:8px}
.hard p:last-child{margin-bottom:0}
.dec{font-family:var(--font-mono);font-size:10.5px;letter-spacing:.08em;color:var(--brass);text-transform:uppercase}
.vec{font-family:var(--font-mono);font-size:.9em;font-weight:600;background:var(--surface-2);padding:2px 7px;border-radius:2px;white-space:nowrap;letter-spacing:.04em}
/* ---------- tables ---------- */
.scroll{overflow-x:auto;margin:0 0 24px;-webkit-overflow-scrolling:touch}
table{border-collapse:collapse;width:100%;min-width:520px;font-family:var(--font-display);font-size:13.5px;line-height:1.45}
th{text-align:left;font-family:var(--font-mono);font-size:9.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-3);font-weight:400;padding:0 16px 8px 0;border-bottom:1px solid var(--rule-strong);vertical-align:bottom}
td{padding:11px 16px 11px 0;border-bottom:1px solid var(--rule);vertical-align:top;color:var(--ink-2)}
td:first-child{color:var(--ink);font-weight:600}
tbody tr:last-child td{border-bottom:none}
.lvl{font-family:var(--font-mono);font-weight:600;font-size:12px;letter-spacing:.04em;color:var(--ink)}
/* ---------- ladders ---------- */
.breakout{margin:34px 0 40px}
.bhead{display:flex;justify-content:space-between;align-items:baseline;gap:20px;border-bottom:1px solid var(--rule-strong);padding-bottom:9px;margin-bottom:22px;flex-wrap:wrap}
.bhead h3{margin:0;font-size:13px;letter-spacing:.1em;text-transform:uppercase;font-family:var(--font-mono);font-weight:400;color:var(--ink-3)}
.bhead .note{font-family:var(--font-display);font-size:12.5px;color:var(--ink-3)}
.ladders{display:grid;gap:26px}
.ladder{display:grid;grid-template-columns:126px minmax(0,1fr);gap:18px;align-items:start}
@media (max-width:700px){.ladder{grid-template-columns:1fr;gap:10px}}
.ladder .pname{font-family:var(--font-display);font-weight:700;font-size:14px;letter-spacing:-.01em;padding-top:2px}
.ladder .pname span{display:block;font-family:var(--font-mono);font-size:10px;font-weight:400;letter-spacing:.1em;text-transform:uppercase;color:var(--ink-3);margin-top:3px}
.rungs{display:grid;gap:3px;grid-template-columns:repeat(5,minmax(0,1fr))}
@media (max-width:700px){.rungs{grid-template-columns:repeat(2,minmax(0,1fr))}}
.rung{padding:9px 10px 11px;background:var(--surface);border-top:4px solid var(--l0);min-width:0}
.rung.r1{border-top-color:var(--l1)} .rung.r2{border-top-color:var(--l2)}
.rung.r3{border-top-color:var(--l3)} .rung.r4{border-top-color:var(--l4)}
.rung .code{font-family:var(--font-mono);font-size:11px;font-weight:600;letter-spacing:.06em;color:var(--ink);display:block;margin-bottom:4px}
.rung .txt{font-family:var(--font-display);font-size:11.5px;line-height:1.34;color:var(--ink-2);display:block}
.rung.na{opacity:.42}
/* ---------- matrix ---------- */
.matrix-shell{display:grid;grid-template-columns:auto minmax(0,1fr);gap:12px;align-items:stretch;margin-bottom:14px}
.ylab{writing-mode:vertical-rl;transform:rotate(180deg);font-family:var(--font-mono);font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-3);text-align:center;padding-bottom:22px}
.mgrid{display:grid;grid-template-columns:34px repeat(5,minmax(0,1fr));gap:3px}
.mcell{background:var(--surface);min-height:60px;padding:6px;display:flex;flex-direction:column;justify-content:flex-end;gap:4px;min-width:0}
.mcell.tint1{background:color-mix(in srgb,var(--l1) 26%,var(--surface))}
.mcell.tint2{background:color-mix(in srgb,var(--l2) 26%,var(--surface))}
.mcell.tint3{background:color-mix(in srgb,var(--l3) 24%,var(--surface))}
.mcell.tint4{background:color-mix(in srgb,var(--l4) 22%,var(--surface))}
.mcell.void{background:repeating-linear-gradient(135deg,transparent,transparent 5px,var(--rule) 5px,var(--rule) 6px);opacity:.55}
.rlab,.clab{font-family:var(--font-mono);font-size:10px;font-weight:600;letter-spacing:.05em;color:var(--ink-3);display:flex;align-items:center;justify-content:center}
.rlab{min-height:60px}
.clab{padding-top:7px;min-height:22px}
.pin{font-family:var(--font-mono);font-size:9.5px;font-weight:600;letter-spacing:.02em;background:var(--ink);color:var(--paper);padding:2px 5px;border-radius:2px;line-height:1.3;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pin.ghost{background:transparent;color:var(--ink-2);border:1px dashed var(--rule-strong)}
.mnote{display:flex;gap:22px;flex-wrap:wrap;font-family:var(--font-display);font-size:12px;color:var(--ink-3);padding-top:6px}
.mnote .k{display:flex;align-items:center;gap:7px}
.sw{width:13px;height:13px;flex:none;background:var(--ink)}
.sw.g{background:transparent;border:1px dashed var(--rule-strong)}
.sw.v{background:repeating-linear-gradient(135deg,transparent,transparent 4px,var(--rule) 4px,var(--rule) 5px);border:1px solid var(--rule)}
@media (max-width:640px){
.mgrid{grid-template-columns:28px repeat(5,minmax(0,1fr))}
.mcell{min-height:52px;padding:4px}
.pin{font-size:8px;padding:1px 3px}
.rlab{min-height:52px}
}
/* ---------- methodology ---------- */
.verbs{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:2px;background:var(--rule);border:1px solid var(--rule)}
.verb{background:var(--surface);padding:18px 18px 20px}
.verb h4{font-family:var(--font-display);font-weight:750;font-size:15px;margin:0 0 7px;letter-spacing:-.01em}
.verb p{font-family:var(--font-display);font-size:12.5px;line-height:1.46;color:var(--ink-2);margin:0;max-width:none}
.verb .step{font-family:var(--font-mono);font-size:9.5px;letter-spacing:.13em;color:var(--brass);display:block;margin-bottom:9px}
/* ---------- questions ---------- */
.qs{display:flex;flex-direction:column;gap:0;border-top:1px solid var(--rule-strong)}
.q{display:grid;grid-template-columns:34px minmax(0,1fr) 170px;gap:18px;padding:16px 0;border-bottom:1px solid var(--rule);align-items:start}
@media (max-width:760px){.q{grid-template-columns:28px minmax(0,1fr);gap:12px}.q .owner{grid-column:2}}
.q .qn{font-family:var(--font-mono);font-size:11px;color:var(--brass);padding-top:3px}
.q .qt{font-family:var(--font-display);font-size:14px;line-height:1.48;color:var(--ink-2)}
.q .qt b{color:var(--ink);font-weight:700;display:block;margin-bottom:2px;font-size:14.5px}
.owner{font-family:var(--font-mono);font-size:10px;letter-spacing:.05em;color:var(--ink-3);padding-top:4px}
.owner .tag{display:inline-block;border:1px solid var(--rule-strong);padding:2px 7px;border-radius:2px}
.owner .tag.need{border-color:var(--clay);color:var(--clay)}
/* ---------- misc ---------- */
.numbers{font-family:var(--font-mono);font-size:12.5px;line-height:1.85;background:var(--surface);border-left:3px solid var(--l3);padding:16px 20px;margin:0 0 22px;overflow-x:auto;max-width:var(--measure)}
.numbers .v{color:var(--ink);font-weight:600}
.numbers .k{color:var(--ink-3)}
pre{font-family:var(--font-mono);font-size:12.5px;line-height:1.68;background:var(--surface);border-left:3px solid var(--rule-strong);padding:16px 20px;overflow-x:auto;margin:0 0 22px;max-width:var(--measure);color:var(--ink-2)}
.alt{border-bottom:1px solid var(--rule);padding:14px 0;max-width:var(--measure)}
.alt:last-of-type{border-bottom:none}
.alt b{font-family:var(--font-display);font-size:14px;display:block;margin-bottom:3px}
.alt p{font-size:14.5px;margin:0;color:var(--ink-2)}
.alt .verdict{font-family:var(--font-mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--clay)}
footer{border-top:2px solid var(--ink);margin-top:20px;padding-top:22px;font-family:var(--font-mono);font-size:11px;letter-spacing:.06em;color:var(--ink-3);display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
.tm td,.tm th{text-align:center}
.tm td:first-child,.tm th:first-child{text-align:left}
.yes{color:var(--l4);font-weight:700}
.no{color:var(--clay);font-weight:700}
.kind{font-family:var(--font-mono);font-size:9px;letter-spacing:.09em;text-transform:uppercase;padding:2px 6px;border-radius:2px;white-space:nowrap;border:1px solid var(--rule-strong);color:var(--ink-3)}
.kind.adv{border-color:var(--clay);color:var(--clay)}
.routes{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:2px;background:var(--rule);border:1px solid var(--rule);margin:0 0 22px}
.route{background:var(--surface);padding:16px 18px}
.route h4{font-family:var(--font-display);font-weight:750;font-size:14px;margin:0 0 6px}
.route p{font-family:var(--font-display);font-size:12.5px;line-height:1.45;color:var(--ink-2);margin:0;max-width:none}
.route .tag{font-family:var(--font-mono);font-size:9px;letter-spacing:.1em;text-transform:uppercase;color:var(--brass);display:block;margin-bottom:8px}
a:focus-visible,.rail a:focus-visible{outline:2px solid var(--brass);outline-offset:3px}
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
</style>
<div class="wrap"><header><div class="eyebrow"><span>CUST-ADR-010</span> <span class="stat">proposed · draft-2</span> <span>the-custodian</span> <span>reviewed 2026-08-31</span><span>generated from canonical source — do not edit</span></div><h1>Hub Authority, Local Cache, and the Two Kinds of Hub Data</h1><p class="sub">Source: <code>the-custodian · canon/architecture/adr-010-hub-authority-and-local-cache-model.md · f9435cd605cc5b3cb0f2e957ce6287d9f3129aac</code></p><p class="sub">Review due: 2027-02-28</p></header><div class="layout"><nav class="rail" aria-label="Sections"><ol><li><a href="#status"><span class="n">·</span>Status</a></li><li><a href="#context"><span class="n">·</span>Context</a></li><li><a href="#decision"><span class="n">·</span>Decision</a></li><li><a href="#orphan-disposition"><span class="n">·</span>Orphan disposition</a></li><li><a href="#consequences"><span class="n">·</span>Consequences</a></li><li><a href="#implementation"><span class="n">·</span>Implementation</a></li><li><a href="#references"><span class="n">·</span>References</a></li><li><a href="#outcome-2026-08-24"><span class="n">·</span>Outcome (2026-08-24)</a></li><li><a href="#outcome-2026-08-28"><span class="n">·</span>Outcome (2026-08-28)</a></li></ol></nav><main><section id="status"><h2>Status</h2>
<p>Proposed, and <strong>partially superseded by <code>ADR-012</code></strong> (accepted 2026-08-25). Decisions 1, 5 and 6 are sharpened or given a mechanism there; see the notes on each below. Everything else in this ADR remains in force.</p>
</section>
<section id="context"><h2>Context</h2>
<p>Investigating a workplan-identifier flip-flop (<code>ADR-007</code>) revealed that two State Hub instances were operating as <strong>peer databases</strong>, not as a primary and a replica:</p>
<div class="scroll"><table><thead><tr><th>Address</th><th>Instance</th><th>Workplans</th></tr></thead><tbody><tr><td><code>127.0.0.1:8000</code> (IPv4)</td><td>local uvicorn on docker postgres</td><td>955</td></tr><tr><td><code>[::1]:8000</code> (IPv6)</td><td>ssh tunnel <code>state-hub-primary</code> → railiance01</td><td>649</td></tr></tbody></table></div>
<p>Both listen on port 8000, separated only by IP family, so every tool defaulting to <code>127.0.0.1</code> reached the local instance while believing it was the primary.</p>
<div class="rule-quote"><p><strong>Mechanism identified 2026-08-24 by <code>CUST-WP-0067</code>.</strong> This ADR recorded the shared port as the hazard but not why it was <em>silent</em>, which is the part that let it persist. An <code>ssh -L</code> forward with no bind address listens on every loopback family, and <code>ExitOnForwardFailure=yes</code> fires only when <strong>every</strong> requested bind fails. The IPv4 bind lost to the local uvicorn, the IPv6 bind succeeded, and the tunnel therefore reported success while serving an address nothing resolved to by default. The collision was declared in <code>~/.config/bridge/tunnels.yaml</code>, and <code>ops-bridge</code> guarded only tunnel against tunnel, so a non-bridge listener was invisible to it. Fixed by pinning local forwards to <code>127.0.0.1</code>, so a contested port fails loudly (<code>ops-bridge</code> commit <code>2213847</code>). A shared port is recoverable; a shared port that reports healthy is not.</p></div>
<p>Measured divergence — 320 records exist locally but not on the primary:</p>
<pre>backed by a file that exists on disk 288 fully rebuildable
no backing file recorded 28 not rebuildable as-is
same filename, different UUID 4 duplicate registration</pre>
<p><strong>No file was missing for any of the 288.</strong> The divergence is therefore almost entirely redundant: it can be discarded and rebuilt from repositories without losing anything.</p>
<p><code>ADR-001</code> already says work originates as repository files and the hub is a read model. The two-peer-database situation contradicts that in practice: a second database accumulated 306 net records of its own.</p>
</section>
<section id="decision"><h2>Decision</h2>
<p><strong>1. One authoritative hub, deriving from repository files.</strong> The central State Hub — running on railiance — is authoritative. It is authoritative <em>as a reading of the repositories</em>, not as a second place data lives. Repository files remain the source of truth (<code>ADR-001</code>).</p>
<div class="rule-quote"><p><strong>Sharpened 2026-08-25 by <code>ADR-012</code> decision 1.</strong> "A reading of the repositories" never said <em>which</em> copy of them, and the honest answer was neither the forge nor any particular working copy: the projection derived from whichever checkout last ran the sync. The hub holds no repository files at all and never reads one. <code>ADR-012</code> names the forge as the projection source.</p></div>
<p><strong>2. A local hub is a cache, never a database.</strong> Local instances hold a rebuildable projection. A cache may be discarded and reconstructed from the repositories at any time, and losing it must never lose work.</p>
<p>This replaces the peer-database arrangement. It is also why the divergence is tractable: a divergent database is a merge problem, a stale cache is a refresh problem.</p>
<div class="rule-quote"><p><strong>Corrected 2026-08-24 by <code>CUST-WP-0067</code>.</strong> Those two shapes are not exhaustive, and repository records took a third one. Measured that day: 122 repositories on the cache, 78 on central, <strong>zero central-only</strong>. A strict subset in the cache's favour is neither a merge problem nor a refresh problem — refreshing the cache would have <em>destroyed</em> the 44 extra records rather than reconciling them, and central held no path to re-derive repositories it had never been told existed. The third shape: <strong>cache-only records whose authoritative source exists and is reachable, but was never introduced to central.</strong> Its remedy is re-derivation from source — not refresh, not merge. Of the 44, 43 had a working copy, all 43 were pushed, and 34 carried a classification file; nothing was unrecoverable, but nothing would have recovered itself either. Onboarding by date showed a clean break: central's repository registrations stopped at 2026-07-08 while the workstation kept accepting them.</p></div>
<p><strong>3. Local work requires no hub at all.</strong> Repository files are self-describing — identifier, status, tasks, all in frontmatter. Working in a repository requires reading files, not querying an index. A cache is an optimization for cross-repo questions, never a prerequisite for doing work.</p>
<p><strong>4. Hub data is classified by origin, and the two kinds have opposite rules.</strong></p>
<div class="scroll"><table><thead><tr><th></th><th>File-derived</th><th>Hub-native</th></tr></thead><tbody><tr><td>Examples</td><td>workplans, tasks, statuses, dependencies</td><td>progress events, decisions, inbox messages, token events</td></tr><tr><td>Source of truth</td><td>the repository file</td><td>the hub</td></tr><tr><td>Offline behaviour</td><td>write the file and commit — the commit <em>is</em> the write</td><td>buffer locally, replay when reachable</td></tr><tr><td>Conflict model</td><td><strong>none</strong>; conflicts are git conflicts, resolved in git</td><td><strong>none</strong>; append-only merges regardless of order</td></tr><tr><td>Central accepts pushes?</td><td>no — it derives</td><td>yes</td></tr></tbody></table></div>
<p>Neither kind needs a hub-side conflict model. That is the point of the split: if central derives file-backed state, it cannot hold a conflicting version of it — it re-derives whatever git settles on. Two people editing one workplan is a git conflict and belongs to git.</p>
<p><strong>5. Central derives file-backed state; it does not accept pushes of it.</strong> "Authoritative" means authoritative <em>reading</em>, so nothing may inject derived state directly. Hub-native records are the exception and keep a real write path.</p>
<div class="rule-quote"><p><strong>Sharpened 2026-08-25 by <code>ADR-012</code> decision 6.</strong> This was policy, not practice: nothing derived, and the workstation pushed everything. <code>ADR-012</code> retires push-based sync as the primary path so that "central derives" becomes true rather than aspirational.</p></div>
<p><strong>6. Preliminary until confirmed.</strong> Locally registered data and uncommitted repository state are preliminary until the central service has seen them. Mitigation is by changing the repository files and the local cache — never by editing central to match a local view.</p>
<div class="rule-quote"><p><strong>Given a mechanism 2026-08-25 by <code>ADR-012</code> decisions 3 and 4.</strong> "Preliminary" was named here but never built, so in practice locally registered data was indistinguishable from derived state once it arrived. It is now a labelled overlay within the same projection — explicitly not a second store — and it retires when the commit carrying it reaches the forge. The prohibition on editing central to match a local view is unchanged.</p></div>
<p>Combined with <code>ADR-007</code> decision 2 (identifiers derived from <code>PREFIX-WP-NNNN</code>), "preliminary" largely stops mattering: a cache computes the same identifier central will, so offline-registered data is already correct on arrival and needs confirmation rather than reconciliation.</p>
<p><strong>7. Every record has exactly one authoritative hub.</strong> The State Hub retirement splits one hub into several. Multiple central hubs are permitted only under this rule: the owning hub is determined by the record's repository and domain. Without it, the same peer-database divergence recurs at larger scale.</p>
<p><strong>8. Cache reads are advisory and must carry their age.</strong> Cross-repo answers from a cache are advisory and should be presented with staleness. For the repository an agent is working in, the file is truth and the cache is never consulted for correctness.</p>
</section>
<section id="orphan-disposition"><h2>Orphan disposition</h2>
<p>The 28 records with no backing file are the only ones a cache rebuild would drop. They fall into three classes, to be separated before any rebuild:</p>
<ol><li><strong>Broken links</strong> — a file exists but <code>backing_filename</code> was never recorded. <code>RMGR-WP-0004</code> is one: the workplan file exists and is committed. These are metadata repairs, not data loss, and are likely the largest class.</li><li><strong>Live hub-first records</strong><code>proposed</code>, <code>ready</code>, or <code>backlog</code> with no file, in <code>activity-core</code>, <code>core-hub</code>, <code>hub-core</code>, <code>issue-core</code>, <code>ops-hub</code>, <code>prj-forgejo-org-refactor</code>, <code>railiance-enablement</code>, <code>railiance-infra</code>, <code>reef-railiance</code>. Each needs a repository file written or an explicit drop. These are <code>ADR-001</code> violations and must not be preserved as hub-only records.</li><li><strong>Closed hub-first records</strong><code>finished</code> or <code>archived</code> with no file. Retain as historical provenance where cheap; do not reconstruct plans that are done.</li></ol>
<p>A cache rebuild enforces <code>ADR-001</code> retroactively: the only casualties are records that broke it.</p>
</section>
<section id="consequences"><h2>Consequences</h2>
<p><strong>Positive.</strong> The divergence becomes discardable rather than mergeable. Offline work is fully supported without a write buffer for file-backed state — the git commit is the write. No hub-side conflict model is needed for either data kind. Authority stops being a policy claim and becomes a structural property.</p>
<p><strong>Negative.</strong> The 28 orphans require case-by-case disposition before a rebuild. Any consumer that treats a local hub as authoritative must be corrected. The one-hub-per-record rule constrains the retirement's hub split.</p>
<p><strong>Rescoping.</strong> <code>STATE-WP-0068</code> (offline write buffer and edge relay) is scoped as a single mechanism. Under decision 4, most of what it buffers does not need buffering — only the append-only hub-native stream does. Its scope should be re-examined before more is built on it; this may reduce work rather than add it.</p>
<p><strong>Correction to <code>ADR-007</code>.</strong> Decision 2 there calls the workstation instance a "development read replica". It was neither a replica nor smaller — it held 306 more workplans than the primary. Superseded by decisions 13 here.</p>
</section>
<section id="implementation"><h2>Implementation</h2>
<p>Owned by <code>repo-manager</code> for file-derived state (decision <code>747011c6</code>; it already owns repository representation, file-backed record indexing, and reconciliation) and by <code>hub-core</code> for hub-native records. Tracked under <code>RMGR-WP-0005</code>.</p>
</section>
<section id="references"><h2>References</h2>
<ul><li>ADR-001 — workplans originate as repo files; hub is a read model</li><li>ADR-007 — identifier uniqueness, derived identifiers, worker topology</li><li>Decision <code>747011c6</code> — repository standards belong to Repo Manager</li><li><code>RMGR-WP-0005</code> — registrar consolidation and deterministic identifiers</li><li><code>STATE-WP-0068</code> — offline write buffer and edge relay (rescope candidate)</li><li>Divergence measurement, 2026-08-17: 955 local / 649 primary / 320 local-only</li></ul>
</section>
<section id="outcome-2026-08-24"><h2>Outcome (2026-08-24)</h2>
<div class="rule-quote"><p>Added by <code>CUST-WP-0067</code>. This ADR read as though its remediation had landed. It had not: the two-instance condition it measured on 2026-08-17 was still live seven weeks later, and had continued to accumulate records the whole time. An ADR that describes a fix in the present tense is easily mistaken for a fix that happened — worth stating plainly for the ones that follow.</p></div>
<p>The peer-database arrangement is now resolved, by deletion rather than by reconciliation:</p>
<ul><li><strong>The local hub instance is retired.</strong> Rather than making two instances coexist safely, the second one was removed. Exactly one process binds <code>127.0.0.1:8000</code> and it is the tunnel to central. Nothing remains that could impersonate the primary, and no call site needed editing — retiring the impersonator made the existing <code>127.0.0.1:8000</code> defaults correct.</li><li><strong>Decision 3 carried the argument.</strong> Because local work requires no hub at all, and Repo Manager already maintains a file-derived index, the local instance was redundant rather than load-bearing.</li><li><strong>Repo Manager gained the write path it was already assigned.</strong> <code>hub-record-authority.yaml</code> gives it <code>managed_repos</code> as <code>file-derived</code>, but it exposed no command for it; the only working path lived in the State Hub repo and defaulted to a local address. <code>rmgr repo-onboard</code> closes that, and refuses to onboard a repository whose backing file is uncommitted, unpushed, or has no upstream — a record whose source is only local cannot be re-derived, which is the failure this ADR exists to prevent.</li><li><strong>33 of the 44 were re-derived onto central</strong>, taking it from 78 to 111 repositories. The remaining 11 carry written dispositions rather than guessed values, per the orphan-disposition principle above.</li></ul>
<p>One cause sat deeper than the topology: the hub resolved its classification allowed-values file from three hardcoded developer-workstation checkouts, so in a container every classification write failed. Repository classification could <em>only</em> be written from a workstation. That is a second instance of this ADR's own theme — authority that depends on where a process happens to run is not authority — and is why "central derives" had never been achievable for this record type.</p>
</section>
<section id="outcome-2026-08-28"><h2>Outcome (2026-08-28)</h2>
<div class="rule-quote"><p>Added by <code>CUST-WP-0068</code>. The 2026-08-24 outcome closed the <em>repository</em> divergence. The work-record divergence this ADR originally measured — 955 local / 649 primary — remained, because the retired instance's database was still load-bearing. That is now closed.</p></div>
<ul><li><strong>Central holds 1167 workplans.</strong> Records that existed only in the cache were re-derived from their files, renamed onto the canonical scheme, or given a written disposition (<code>docs/recovery/cache-only-disposition-2026-08-28.md</code>).</li><li><strong>No open work record exists only in the cache.</strong> Remaining cache-only slugs are aliases of recovered records, clay-borg product files (not workplans), or prefix-migration residue.</li><li><strong>The cache database is discarded.</strong> Final dump <code>~/backups/state-hub-cache-2026-08-28.dump</code>. Container <code>infra-postgres-1</code> and volume <code>infra_pg_data</code> removed. Port 5432 is free.</li><li><strong>The local instance is no longer load-bearing for any record type.</strong> Decision 3 is now true in operation, not only in argument.</li></ul>
</section><footer><span>CUST-ADR-010 · draft-2 · proposed</span><span>the-custodian · canon/architecture/adr-010-hub-authority-and-local-cache-model.md · f9435cd605cc5b3cb0f2e957ce6287d9f3129aac</span></footer></main></div></div></html>