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 oracle-driven — it never guesses schema, it asks live reference databases — and builds the migration twice, once as a diagnostic scaffold and once as the real thing, gated by whether Odoo 19 actually boots. Debugging it is run by a thirteen-agent framework (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 SQLite knowledge graph and CLI 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.
The migration that started it
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 (product_packaging is gone by 19), models merge (hr_contract becomes hr_version), field types flip (translatable columns move from varchar to jsonb), 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.
Never guess schema
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 oracles. Want to know what sale_order looks like in v19? Ask the v19 oracle's information_schema. Want the default value Odoo itself would assign a new NOT NULL column? Ask the oracle's ORM through odoo shell. 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 missing_in_oracle bucket and is never migrated — nothing turns red. Closing that gap is still open work.
Building it twice
The least obvious architectural choice is that the pipeline builds the target database twice, on purpose, for different reasons. The scaffold path copies data table-by-table into a freshly created target schema — surgical and fully observable, with a per-table verdict (ok / partial / loss / empty / error) for every single table, but not a real migration; it's a diagnostic that answers "what fits and what doesn't." The native path 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.
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 set -e — a failed gate kills the run. No earlier stage's claim about "success" outranks whether Odoo actually starts.
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."
One more thing has to happen before any of this touches a browser: neutralization. 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 -u base quietly re-activates some of what the first pass turned off.
The bug that undid two "successful" runs
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 every single historical document attachment was a broken link. 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.
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."
Thirteen agents and the problem with all of them
Debugging this pipeline is run by a small roster of specialist agents rather than one long conversation: @triage locates the first failure in a run's logs and stops — no fixing. @investigate builds a SQL-verified root cause with a confidence score, read-only. @repair-plan turns a root cause above 80% confidence into a stepwise plan, one change per step, no code written yet. @implement executes exactly one of those steps. @review adversarially critiques the resulting patch without applying anything. @postmortem writes up what happened into a permanent, grep-friendly knowledge base. A non-executing @chief-engineer 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.
The problem this creates is exactly the one the abstract mentions: every one of those agent invocations is a fresh process. A hypothesis @investigate 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.
Memory as a graph, not a transcript
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: nodes, edges, state, events, tasks, memories), managed by its own dedicated agent, @memory, through eighteen CLI commands.
Nodes carry an id, a type, a title, a summary, a status, an optional confidence score, and a JSON attrs blob for anything type-specific. Edges are (src, rel, dst, weight) 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 tasks table holds structured work packets — objective, constraints, expected output, artifacts — so a subagent gets a contract instead of a paragraph.
The node types map directly onto the shape of a debugging investigation, and the edges give them a grammar:
- issue — the top-level problem (a failing stage, a boot failure)
- run — a pipeline execution, with its failed stage and log path
- report — a triage or investigation writeup, with a confidence score
- hypothesis → root_cause — candidate explanations, and the ones that survived scrutiny (a hypothesis supports a root_cause)
- module — the code area a root cause implicates
- repair_plan → patch → review — the fix, in three stages of commitment
- postmortem → lesson — what happened, and what to carry forward
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.
A dispatcher that doesn't call an LLM
The workflow itself runs as a state machine — onboard, observability, run the pipeline, then triage → investigate → module analysis (optional) → repair-plan → implement (one step) → review → postmortem → retro — and the piece I'm most attached to is the function that decides what's next, recommend_next_subagent, which answers that purely by walking the graph:
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 deterministic backstop over prompt hope 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.
Context you can hand to a stranger
The graph is only half of it. Reading it back out is the other half: build_context 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. export-context writes that to .ai/context/<issue>.md, 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.
That context file is the third of five persistence layers, and it's worth naming all five, because each earns its place: .ai/state.json is a single workflow ledger — current stage, active issue, completed stages, last run — read first in every session. .ai/memory.db is the graph itself. .ai/context/<issue>.md is the exported handoff. .ai/reports/<type>-<issue>.md holds the raw per-stage findings each specialist agent writes. And kb/<issue>.md is a permanent, grep-friendly knowledge base written by @postmortem after every verified fix — currently a handful of entries, each one turning what would otherwise be a re-investigation into a lookup the moment @triage greps it against a new failure.
The lesson learned the hard way
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 .ai/reports/ as their own step, and the follow-up step of actually inserting them into memory.db was getting deferred, or skipped, or batched for later. "Later" kept not arriving. The graph looked authoritative and wasn't.
The fix wasn't a smarter agent. It was a rule: every artifact gets persisted to memory immediately after it's created, never batched or deferred — enforced structurally by routing every report-only stage through @memory before the workflow is allowed to proceed to the next one.
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.
Where it stands now
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.
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:
- The v17→v18 hop still has no native boot gate of its own — 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.
- 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.
- The Docker-dependent core (neutralization, the boot gate, the copy engine's data movement) has no automated test coverage; only the pure-Python utility layers do.
- 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.
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.
What this isn't
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 durable, typed, and queryable, 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.
The stack
- opencode / Claude Code — the two agentic-CLI runtimes the thirteen-agent protocol runs under
- Nemotron 3 Ultra — the model behind the opencode sessions
- SQLite — the entire persistence layer for the graph, events, and task packets
- Python —
argparse-based CLI, plainsqlite3, no ORM, no framework - OpenUpgrade — the Community-edition migration scripts the pipeline builds on top of for both hops
- Docker — the three version-oracle instances plus the scaffold and native migration targets
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 navid72m@gmail.com.