PageSourceSearch

https://www.navidmirnouri.com/blog/agent-memory-graph-odoo-migration.html

html navidmirnouri.com collected 2026-10-09 22:08:26 UTC 24,703 bytes, 223 lines download raw bytes

1<!DOCTYPE html>
2<html lang="en">
3<head>
4    <meta charset="UTF-8">
5    <meta name="viewport" content="width=device-width, initial-scale=1.0">
6    <title>Giving Agents a Memory: A Knowledge Graph for an ERP Migration — Navid Mirnouri</title>
7    <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
8    <link rel="preconnect" href="https://fonts.googleapis.com">
9    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10    <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
11    <link rel="stylesheet" href="/assets/blog.css">
12
13    <meta name="description" content="Inside a two-hop Odoo 17→19 migration pipeline and the multi-agent debugging framework, SQLite knowledge graph, and CLI built to keep AI coding agents coherent across weeks of sessions. By Navid Mirnouri.">
14    <meta name="author" content="Navid Mirnouri">
15    <meta property="og:title" content="Giving Agents a Memory: A Knowledge Graph for an ERP Migration">
16    <meta property="og:description" content="A graph-structured memory and CLI that turned stateless coding agents into a coordinated debugging team.">
17    <meta property="og:type" content="article">
18    <meta property="og:url" content="https://navid72m.github.io/blog/agent-memory-graph-odoo-migration.html">
19    <link rel="canonical" href="https://navid72m.github.io/blog/agent-memory-graph-odoo-migration.html">
20    <link rel="alternate" type="application/rss+xml" title="Navid Mirnouri — Writing" href="/feed.xml">
vendor: 69 bytes, lines 20-22
20
21
22    <script async src="https://www.googletagmanager.com/gtag/js?id=
22G-NJ4XLD7F9K
vendor: 16 bytes, lines 22-23
22"></script>
23    
23<script data-goatcounter="https://navid72m.goatcounter.com/count"
24        async src="//gc.zgo.at/count.js"></script>
24
25    
25<script>
26      
vendor: 145 bytes, lines 26-29
26window.dataLayer = window.dataLayer || [];
27      function gtag(){dataLayer.push(arguments);}
28      gtag('js', new Date());
29      gtag('config', '
29G-NJ4XLD7F9K
vendor: 8 bytes, lines 29-30
29');
30    
30</script>
30
31</head>
32<body>
33
34    <div class="trace-rail" aria-hidden="true">
35        <div class="trace-fill" id="trace-fill"></div>
36    </div>
37
38    <nav id="navbar">
39        <div class="nav-inner">
40            <a href="/" class="nav-logo"><span class="prompt">navid@systems</span>:~$</a>
41            <ul class="nav-links">
42                <li><a href="/#home">home</a></li>
43                <li><a href="/#stack">stack</a></li>
44                <li><a href="/#projects">projects</a></li>
45                <li><a href="/#research">research</a></li>
46                <li><a href="/#blog">writing</a></li>
47                <li><a href="/#contact">contact</a></li>
48            </ul>
49        </div>
50    </nav>
51
52    <header class="post-head">
53        <p class="doc-path"><a href="/#blog">~/writing</a>/agent-memory-graph-odoo-migration</p>
54        <span class="post-category">Agentic Engineering</span>
55        <h1>Giving Agents a Memory: A Knowledge Graph for an ERP Migration</h1>
56        <p class="post-tagline">Stateless coding agents can debug hard problems for an hour. Keeping thirteen of them coherent for six weeks needed something more than a longer prompt.</p>
57        <div class="post-meta">
58            <span><i class="far fa-calendar-alt"></i> July 2026</span>
59            <span><i class="far fa-clock"></i> 15 min read</span>
60            <span><i class="fas fa-user"></i> Navid Mirnouri</span>
61        </div>
62        <div class="like-widget" data-slug="agent-memory-graph-odoo-migration">
63            <button class="like-btn" id="like-btn" aria-pressed="false">
64                <i class="far fa-heart"></i>
65                <span class="like-count">–</span>
66                <span>likes</span>
67            </button>
68        </div>
69    </header>
70
71    <article class="post-body">
72        <div class="abstract">
73            <div class="abstract-label">Abstract</div>
74            <p>I'm migrating an organization's ERP system from Odoo 17 Enterprise to Odoo 19 — a two-hop schema migration (17→18→19) through a version whose Community-edition upgrade scripts are open source and whose Enterprise scripts aren't, self-hosted, in Docker, from scratch. The pipeline itself is <strong>oracle-driven</strong> — it never guesses schema, it asks live reference databases — and builds the migration <strong>twice</strong>, once as a diagnostic scaffold and once as the real thing, gated by whether Odoo 19 actually boots. Debugging it is run by a <strong>thirteen-agent framework</strong> (triage, investigate, repair-plan, implement, review, postmortem, and more) operating across both Claude Code and an agentic CLI called opencode. The problem that framework kept hitting: every agent invocation starts from zero. So alongside the pipeline I built a small <strong>SQLite knowledge graph and CLI</strong> that gives the debugging process itself a durable, queryable memory — and, after one embarrassing lesson about what "durable" actually requires, a workflow rule that every finding gets persisted before anything moves forward.</p>
75        </div>
76
77        <h2>The migration that started it</h2>
78
79        <p>The concrete problem is an Odoo 17 Enterprise → 19 migration, run self-hosted. Odoo forbids skipping major versions, so the path is two hops — 17→18→19 — not one. Between those versions almost everything changes, not just column names: tables disappear (<code>product_packaging</code> is gone by 19), models merge (<code>hr_contract</code> becomes <code>hr_version</code>), field types flip (translatable columns move from <code>varchar</code> to <code>jsonb</code>), and view templates restructure underneath customizations that assume the old shape. Odoo's own upgrade service is SaaS-only for Enterprise customers, and the open-source alternative, OpenUpgrade, assumes an in-place upgrade of a live server — not a dump you're reconstructing offline, in Docker, with evidence at every step. So that reconstruction is what the pipeline does.</p>
80
81        <h3>Never guess schema</h3>
82
83        <p>The design principle underneath everything is that the pipeline never derives target schema from source code, documentation, or a hardcoded mapping table. Instead, three live Odoo instances — one per version, with the relevant modules installed — act as <strong>oracles</strong>. Want to know what <code>sale_order</code> looks like in v19? Ask the v19 oracle's <code>information_schema</code>. Want the default value Odoo itself would assign a new <code>NOT NULL</code> column? Ask the oracle's ORM through <code>odoo shell</code>. The cost of this design is real: an oracle only knows the modules installed in it, so a customer table that uses a module the oracle lacks silently falls into a <code>missing_in_oracle</code> bucket and is never migrated — nothing turns red. Closing that gap is still open work.</p>
84
85        <h3>Building it twice</h3>
86
87        <p>The least obvious architectural choice is that the pipeline builds the target database <strong>twice</strong>, on purpose, for different reasons. The <strong>scaffold path</strong> copies data table-by-table into a freshly created target schema — surgical and fully observable, with a per-table verdict (<code>ok</code> / <code>partial</code> / <code>loss</code> / <code>empty</code> / <code>error</code>) for every single table, but not a real migration; it's a diagnostic that answers "what fits and what doesn't." The <strong>native path</strong> takes that scaffold, restores it onto the target version's Odoo stack, and lets real Odoo and the full OpenUpgrade tree perform the migration exactly as documented upstream. The scaffold gives you evidence and control the native pass can't. The native pass gives you correctness — only Odoo actually knows all of its own migration semantics — that the scaffold can't. Together they produce the deliverable.</p>
88
89        <div class="callout">
90            <div class="callout-title">The acceptance test the pipeline never had</div>
91            <p>Everything upstream of the boot gate is diagnosis. Two things have to exit 0: a migration boot (which cascades the OpenUpgrade path through all 265 installed modules) and a plain smoke boot with no upgrade crutches. The whole pipeline runs under <code>set -e</code> — a failed gate kills the run. No earlier stage's claim about "success" outranks whether Odoo actually starts.</p>
92        </div>
93
94        <p>Two more guards keep results honest rather than accidental. Every environment run is checked against pinned container digests and vendored OpenUpgrade commits — a drifted environment refuses to run at all. And every dump gets its sha256 written into the scratch database itself at restore time, so a later stage can refuse to reuse a scaffold that was actually built from a different dump than the one you think you passed. That guard exists because, once, it didn't, and a run silently read the wrong dump's data — a bug the team still refers to as "the paramond confound."</p>
95
96        <p>One more thing has to happen before any of this touches a browser: <strong>neutralization</strong>. A restored production database is a loaded gun — crons that email real customers, live payment providers, IAP tokens that bill real accounts, a queued outbound mail queue. Every boot, and the deliverable dump itself, runs through a step that disables crons, mail servers, and payment providers, wipes IAP tokens, and cancels the outbox. It's idempotent by design and runs three times per pipeline run, because <code>-u base</code> quietly re-activates some of what the first pass turned off.</p>
97
98        <h2>The bug that undid two "successful" runs</h2>
99
100        <p>The boot gate answers "does Odoo start." It does not answer "is the data actually there." That gap produced the single most useful failure in the project's history. A filestore check — the first thing written for a new validation framework — found that two runs that had passed every existing check, including a working web UI a person could click through, had migrated a database where <strong>every single historical document attachment was a broken link</strong>. The filestore — the actual files behind every invoice PDF and uploaded document — had never been migrated. Nothing in the pipeline had been wrong about what it checked; the pipeline just hadn't been checking that.</p>
101
102        <p>That discovery is what turned "the boot passed" into a six-layer validation suite: structural integrity (row counts, FK orphans, dangling xmlids, sequence safety, filestore integrity, translation survival), full accounting reconciliation (debits and credits, tax totals, reconciliations, trial balance), business-object state distributions across ten models, a functional read-sweep on a disposable clone, an HTTP crawl of the booted instance's public routes, and a final check that the deliverable is actually safe to hand over — crons off, payment providers disabled, mail queue empty. The gate criterion is zero failures across the structural, accounting, and safety layers. It exists specifically because "it booted" turned out not to imply "it's correct."</p>
103
104        <h2>
104Thirteen agents and the problem with all of them</h2>
105
106        <p>Debugging this pipeline is run by a small roster of specialist agents rather than one long conversation: <code>@triage</code> locates the first failure in a run's logs and stops — no fixing. <code>@investigate</code> builds a SQL-verified root cause with a confidence score, read-only. <code>@repair-plan</code> turns a root cause above 80% confidence into a stepwise plan, one change per step, no code written yet. <code>@implement</code> executes exactly one of those steps. <code>@review</code> adversarially critiques the resulting patch without applying anything. <code>@postmortem</code> writes up what happened into a permanent, grep-friendly knowledge base. A non-executing <code>@chief-engineer</code> sits above all of them, routing work by validating prerequisites and handing out structured task packets rather than doing anything itself. Thirteen roles in total, mirrored across two runtimes — Claude Code and opencode running Nemotron 3 Ultra — so the same protocol works whichever CLI a given session happens to be in.</p>
107
108        <p>The problem this creates is exactly the one the abstract mentions: every one of those agent invocations is a fresh process. A hypothesis <code>@investigate</code> ruled out on Monday has no reason not to get re-proposed by a different session on Wednesday, because nothing about "we already tried that" survives between processes by default. Multiply that by weeks of sessions against a genuinely hard boot-failure bug, and re-deriving "already ruled out" starts costing more than the original investigation did.</p>
109
110        <h2>Memory as a graph, not a transcript</h2>
111
112        <p>The obvious fix — paste more history into the prompt — breaks down fast. Transcripts don't compress, and an agent re-reading a wall of prior chat still has to re-derive the relationship between a hypothesis and the root cause it led to and the patch that root cause eventually produced. What I wanted was closer to how a human debugging team keeps state: a shared ticket with typed, linked artifacts, not a shared chat log. So the memory is a small typed graph, backed by SQLite (six tables: <code>nodes</code>, <code>edges</code>, <code>state</code>, <code>events</code>, <code>tasks</code>, <code>memories</code>), managed by its own dedicated agent, <code>@memory</code>, through eighteen CLI commands.</p>
113
114        <p><strong>Nodes</strong> carry an id, a type, a title, a summary, a status, an optional confidence score, and a JSON attrs blob for anything type-specific. <strong>Edges</strong> are <code>(src, rel, dst, weight)</code> triples, upserted idempotently so re-running a command never duplicates a link. An events table logs every state change as an immutable timeline, and a separate <code>tasks</code> table holds structured work packets — objective, constraints, expected output, artifacts — so a subagent gets a contract instead of a paragraph.</p>
115
116        <p>The node types map directly onto the shape of a debugging investigation, and the edges give them a grammar:</p>
117
118        <ul>
119            <li><strong>issue</strong> — the top-level problem (a failing stage, a boot failure)</li>
120            <li><strong>run</strong> — a pipeline execution, with its failed stage and log path</li>
121            <li><strong>report</strong> — a triage or investigation writeup, with a confidence score</li>
122            <li><strong>hypothesis → root_cause</strong> — candidate explanations, and the ones that survived scrutiny (a hypothesis <em>supports</em> a root_cause)</li>
123            <li><strong>module</strong> — the code area a root cause implicates</li>
124            <li><strong>repair_plan → patch → review</strong> — the fix, in three stages of commitment</li>
125            <li><strong>postmortem → lesson</strong> — what happened, and what to carry forward</li>
126        </ul>
127
128        <p>None of this is novel graph theory — it's closer to a lightweight, purpose-built issue tracker schema than to anything from the knowledge-graph literature. That's deliberate. The goal was never a general-purpose reasoning substrate; it was a place for an agent's conclusions to survive past the process that produced them.</p>
129
130        <h2>A dispatcher that doesn't call an LLM</h2>
131
132        <p>The workflow itself runs as a state machine — onboard, observability, run the pipeline, then <code>triage → investigate → module analysis (optional) → repair-plan → implement (one step) → review → postmortem → retro</code> — and the piece I'm most attached to is the function that decides what's next, <code>recommend_next_subagent</code>, which answers that purely by walking the graph:</p>
133
134        <div class="formula-block">if failed_run and not has_triage:      → @triage
135if has_triage and not has_investigation: → @investigate
136if has_investigation and no high-confidence
137   root_cause and not has_repair_plan:   → @investigate
138if high_confidence_root_cause (≥ 80)
139   and not has_repair_plan:              → @repair-plan
140if has_repair_plan and not has_patch:    → @implement
141if has_patch and not has_review:         → @review
142if has_review and not has_postmortem:    → @postmortem
143if has_postmortem:                       → @retro</div>
144
145        <p>No model call, no prompt — just graph state read off an issue's related-node neighborhood. It's a deliberate echo of a principle I keep coming back to across projects: prefer a <strong>deterministic backstop over prompt hope</strong> wherever a decision can be made from facts you already have. An LLM deciding "what's the next step" from a paragraph of context can drift or hallucinate a stage that was already done; a function that only knows how to check "does a repair_plan node exist yet" cannot.</p>
146
147        <h2>Context you can hand to a stranger</h2>
148
149        <p>The graph is only half of it. Reading it back out is the other half: <code>build_context</code> walks an issue two hops out, pulls the current workflow state and recent events, and renders the whole thing as markdown — runs, reports, hypotheses, root causes, modules, repair plans, patches, reviews, postmortems, lessons, each with its status and confidence. <code>export-context</code> writes that to <code>.ai/context/&lt;issue&gt;.md</code>, and that file — not "here's what we talked about" but a self-contained brief — is what the chief-engineer agent actually reads before routing work, and what a completely fresh session, human or agent, can pick up cold.</p>
150
151        <p>That context file is the third of five persistence layers, and it's worth naming all five, because each earns its place: <code>.ai/state.json</code> is a single workflow ledger — current stage, active issue, completed stages, last run — read first in every session. <code>.ai/memory.db</code> is the graph itself. <code>.ai/context/&lt;issue&gt;.md</code> is the exported handoff. <code>.ai/reports/&lt;type&gt;-&lt;issue&gt;.md</code> holds the raw per-stage findings each specialist agent writes. And <code>kb/&lt;issue&gt;.md</code> is a permanent, grep-friendly knowledge base written by <code>@postmortem</code> after every verified fix — currently a handful of entries, each one turning what would otherwise be a re-investigation into a lookup the moment <code>@triage</code> greps it against a new failure.</p>
152
153        <h2>The lesson learned the hard way</h2>
154
155        <p>The system doesn't get to claim this worked cleanly from the start. At one point the memory database fell more than twenty nodes out of sync with what was actually on disk, because review and investigation reports were being written to <code>.ai/reports/</code> as their own step, and the follow-up step of actually inserting them into <code>memory.db</code> was getting deferred, or skipped, or batched for later. "Later" kept not arriving. The graph looked authoritative and wasn't.</p>
156
157        <blockquote>The fix wasn't a smarter agent. It was a rule: <strong>every artifact gets persisted to memory immediately after it's created</strong>, never batched or deferred — enforced structurally by routing every report-only stage through <code>@memory</code> before the workflow is allowed to proceed to the next one.</blockquote>
158
159        <p>It's a small, almost boring lesson, and I think that's exactly why it matters. The graph doesn't do anything clever to stay in sync with reality — it just refuses to let the workflow move forward while it's out of sync.</p>
160
161        <h2>Where it stands now</h2>
162
163        <p>As of the most recent run, the system is idle: the last pipeline execution passed every stage, produced a bootable v19 dump, and the memory graph is fully synchronized with disk. The milestone since the previous check-in was finishing and wiring in the six-layer validation framework described above — the gap that the filestore discovery exposed is now a permanent, automatable check rather than a one-off finding.</p>
164
165        <p>It would be dishonest to stop there, though. The project keeps an explicit gaps list, and some of the entries are structurally important, not cosmetic:</p>
166
167        <div class="callout gaps">
168            <div class="callout-title">Known gaps</div>
169            <ul>
170                <li>The v17→v18 hop still has <strong>no native boot gate of its own</strong> — a stage banner announces one, but it invokes nothing, so any bug introduced in that first hop only surfaces four times harder, at the v19 boot.</li>
171                <li>Oracle coverage — whether a customer's installed modules are fully represented in the reference databases — isn't gated in the run itself yet, so a missing module's tables can still vanish silently.</li>
172                <li>The Docker-dependent core (neutralization, the boot gate, the copy engine's data movement) has no automated test c
172overage; only the pure-Python utility layers do.</li>
173                <li>Post-boot FK restoration is explicitly best-effort — the "with foreign keys" deliverable is rebuilt from pre-boot state, not extracted from the booted database itself, so its lineage doesn't fully match what actually shipped.</li>
174            </ul>
175        </div>
176
177        <p>None of these are secret from the system itself — they're the same graph, tracked as open issues with the same node types as everything else, waiting for the next run to reopen them.</p>
178
179        <h2>What this isn't</h2>
180
181        <p>I want to be honest about the scope of the memory layer specifically. It is not a reasoning framework in the sense of an inference engine or a planner — it does no reasoning of its own. The reasoning still happens inside the LLM, inside opencode or Claude Code, inside a human reading a stack trace. What the graph and CLI do is narrower and less glamorous: they make an agent's conclusions <em>durable</em>, <em>typed</em>, and <em>queryable</em>, so that reasoning someone already did doesn't have to be redone. It's closer to source control for a debugging investigation than to an intelligent system — and the same instinct that made a heuristic dispatcher preferable to an LLM call argues against dressing it up as more than that.</p>
182
183        <div class="reading-list">
184            <h3>The stack</h3>
185            <ul>
186                <li><strong>opencode / Claude Code</strong> — the two agentic-CLI runtimes the thirteen-agent protocol runs under</li>
187                <li><strong>Nemotron 3 Ultra</strong> — the model behind the opencode sessions</li>
188                <li><strong>SQLite</strong> — the entire persistence layer for the graph, events, and task packets</li>
189                <li><strong>Python</strong> — <code>argparse</code>-based CLI, plain <code>sqlite3</code>, no ORM, no framework</li>
190                <li><strong>OpenUpgrade</strong> — the Community-edition migration scripts the pipeline builds on top of for both hops</li>
191                <li><strong>Docker</strong> — the three version-oracle instances plus the scaffold and native migration targets</li>
192            </ul>
193        </div>
194
195        <p class="disclaimer">Both projects are ongoing. If you're wrestling with a similar Odoo migration, or you've built something in this same "memory for agents" space, I'd like to hear about it — reach me at <a href="mailto:[email protected]">[email protected]</a>.</p>
196
197        <div class="post-nav">
198            <a href="/#blog"><i class="fas fa-arrow-left"></i> Back to Blog</a>
199            <a href="/feed.xml"><i class="fas fa-rss"></i> RSS feed</a>
200            <a href="mailto:[email protected]"><i class="fas fa-envelope"></i> Discuss via email</a>
201        </div>
202    </article>
203
204    <footer>
205        <p id="footer-year">&copy; 2026 <a href="/">Navid Mirnouri</a>.</p>
206    </footer>
207
208    
208<script src="/assets/likes.js"></script>
208
209    
209<script>
210        const navbar = document.getElementById('navbar');
211        window.addEventListener('scroll', () => {
212            navbar.classList.toggle('scrolled', window.scrollY > 40);
213
214            const traceFill = document.getElementById('trace-fill');
215            const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
216            const scrolled = scrollHeight > 0 ? (window.scrollY / scrollHeight) * 100 : 0;
217            traceFill.style.height = scrolled + '%';
218        });
219
220        document.getElementById('footer-year').textContent = `© ${new Date().getFullYear()} Navid Mirnouri.`;
221    </script>
221
222</body>
223</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.