Traceability report · compiled from source
LedgerSMB · 1,522 files · 192 tables · 218 foreign keys · compiled 2026-09-16
“When a payment fails or its status changes, I can’t trace where it comes from. I have to search three places — the Postgres schema, the backend code, and the invoice templates — and I still can’t tell which code updates payment status.”
Payment status is not a column on payment. It lives in workflow.state, and exactly one function in the codebase writes it.
Three of those four hops are declared foreign keys. The fourth (⇢) is a convention, not a constraint — which is why no schema diagram would ever have shown you this path.
The answer to “which code”
| Location | Function | Statement | Effect |
|---|---|---|---|
| Persister.pm:155 | update_workflow | UPDATE workflow SET state = ?, last_update = ? WHERE workflow_id = ? | The only write to payment status anywhere in the system. |
| Persister.pm:85 | create_workflow | INSERT INTO workflow (workflow_id, type, state, last_update) | Sets the opening state when a payment first enters the workflow. |
| Persister.pm:181 | create_history | INSERT INTO workflow_history (workflow_id, action, description, state, workflow_user, history_date) | Writes the audit row — who changed it, to what, and when. |
| Persister.pm:115 | fetch_workflow | SELECT state, last_update FROM workflow WHERE workflow_id = ? | Every read of a payment’s current status. |
| Persister.pm:58 | _persist_context | INSERT INTO workflow_context (workflow_id, context) … ON CONFLICT DO UPDATE | Stores the JSON context a transition was decided from. |
All five in lib/LedgerSMB/Workflow/Persister.pm. Notably, lib/LedgerSMB/Scripts/payment.pm — the file whose name suggests it owns this — contains no status write at all. Searching for the word “payment” is what makes this trace hard.
Requirement 1
The dashed hop is declared in workflows/persisters.xml:12 as extra_table="transactions": the ExtraData persister keys each workflow row to a transaction id. Nothing in the database enforces it, so it is invisible to schema introspection — and it is precisely the link that makes the trace feel impossible from the DB side alone.
Requirement 3 · the three places, reconciled
sql/changes/1.9/
workflow-schema.sql:5
workflow.state varchar(30) — the authoritative current status, plus workflow_history for every past transition.
lib/LedgerSMB/Workflow/
Persister.pm:155
The single UPDATE. Every status change in the product passes through this one statement.
workflows/
payment.workflow.xml:5
The legal states and transitions — declarative XML, not code. No Perl file lists them.
The invoice templates under templates/ are the fourth place people look and the one place status never lives: they render a posted transaction, and hold no state of their own.
The allowed transitions
workflows/payment.workflow.xml lines 5–21. REVERSED is declared as a state but no action in this file transitions into it.Whether approve or batch-approve fires is decided by one condition, is-batch-member, defined at workflows/payment.conditions.xml:2 as the test $context->{'batch-id'} — evaluated against the JSON in workflow_context. That is the whole reason a payment “fails” to approve individually: it belongs to a batch.
Why three places felt like three places
Each of these is described by its own authors, or by its own schema, as holding payment state. Only one is written by the workflow engine.
varchar(30). Written only by update_workflow. History in workflow_history.
Pg-database.sql. A boolean, predating the workflow engine.
bool, with approved_by and approved_at. Overlaps the POSTED state without being the same thing.
A search for “payment status” finds the two decoys first, because they carry the word payment and the word state. The authoritative field carries neither.
Requirement 3 · proof
| Claim | Evidence | Line | Provenance |
|---|---|---|---|
Status column is workflow.state | sql/changes/1.9/workflow-schema.sql | 5 | source |
| Audit trail exists per transition | sql/changes/1.9/workflow-schema.sql | 12–22 | source |
| The only status write | lib/LedgerSMB/Workflow/Persister.pm | 160 | source |
| History insert | lib/LedgerSMB/Workflow/Persister.pm | 181 | source |
payment.gl_id → gl.id | sql/Pg-database.sql | 3408 | compiled FK |
gl.id → transactions.id | sql/Pg-database.sql | 982 | compiled FK |
acc_trans.trans_id → transactions.id | sql/Pg-database.sql | 1124 | compiled FK |
| workflow keyed to transactions | workflows/persisters.xml | 12 | convention |
| States and transitions | workflows/payment.workflow.xml | 5–21 | source |
is-batch-member test | workflows/payment.conditions.xml | 2–4 | source |
payment.closed holds state | sql/Pg-database.sql | 3419 | |
transactions.approved | sql/Pg-database.sql | 301 | compiled |
| Table purpose descriptions | workflow, workflow_history, workflow_context | — | model-written |
The economics
A capable model with grep can reach this answer. The question is what it costs to reach it, whether the same question costs that again tomorrow, and whether the answer arrives with its evidence attached or has to be re-verified by hand.
| Step | Text search | EKOS |
|---|---|---|
| Find the candidate files | grep -ril payment → 202 files, 3.86 MB (~964,000 tokens of candidate text to narrow by reading) |
One query → ~938 tokens of ranked results, each already carrying its object kind and source path |
Traverse payment → gl → transactions |
Not possible. Three foreign keys declared 2,400 lines apart in one 134 KB file; grep matches strings, not edges | A graph query over 218 compiled foreign keys — the hops are edges, not text |
| Know which descriptions are real | No signal. A comment and a guess look identical in a diff | sql_comment vs llm_description are separate properties — 127 of 182 descriptions are the authors’ own |
| Ask the second question | Full re-exploration, every time, at the same cost | The ledger is already built; each further question is another ~1,000 tokens |
| Repeatability | Depends on which patterns the model happens to try | Deterministic passes over content-addressed artifacts — same input, same answer |
The amortisation is the real argument. Compiling LedgerSMB read roughly 2.3 M tokens’ worth of source once and produced 6,807 objects, 3,114 relationships and 7,677 evidence records. Every question after that is answered from the compiled model, not from the source. The first question is roughly break-even with a careful manual search; the tenth is not close.
And one hop is not findable by search at all. The link from transactions to workflow is not a foreign key and not a string either side would match — it is the attribute extra_table="transactions" in a persister configuration file. No amount of grepping payment or status reaches it, because neither word appears. It is reachable only by knowing the workflow engine is involved, which is what the compiled graph tells you first.
Three parts of this report came from reading files directly, and saying so is the point of a traceability document:
extra_table hop
Read from persisters.xml by hand. EKOS does not model persister configuration, so this edge is in the diagram as a convention, not as a compiled fact.
.pm files and harvests declaration symbols, but there is no call graph — the five functions in the evidence table were confirmed by reading Persister.pm.
COMMENT ON was discarding the entire file. A compiler is only as good as its front end, and it failed quietly.
The honest division of labour: the compiled ledger narrows 202 candidate files to six and supplies the graph; direct reading confirms the lines. Neither half is sufficient alone, and a tool that claimed otherwise would be the wrong tool to trust with an audit trail.
Runbook
Six ordered steps. Each stage of the pipeline writes the artifacts the next one consumes, so the order is not a suggestion. Steps 2 and 4 exist because the two failures that matter most in this tool are silent ones.
Rust 2024 edition. The Cargo workspace root is ekos/, not the repository root.
git clone https://github.com/alexeyban/EKOS.git # the Cargo workspace root is the ekos/ subdirectory, not the repo root cd EKOS/ekos cargo build --release # optional — put `ekos` on your PATH cargo install --path crates/cli
ekos.toml in your project rootThree settings decide whether the compile succeeds. All three failed silently in this project before they were set correctly.
# Scope of observation [workspace] root = "." [observe] # Use "." — NOT a list of subdirectories. Observers run once per entry with that # entry as their root, and the git connector only checks <root>/.git. A list like # ["src", "sql"] silently yields ZERO commits. paths = ["."] # Matched against exact path COMPONENTS — not globs. Filenames count too, so # "favicon.ico" works but "*.png" never will. Prune binaries and generated output. ignore-patterns = [ ".ekos", ".git", "node_modules", "target", "images", "css", "img", # binary assets "locale", # translation catalogues "dist", "build", "coverage", # generated output ".venv", "site-packages", # third-party code is NOT your architecture ] # Load-bearing. The wrong dialect fails the whole-file parse and your entire # schema disappears behind one buried SQL001 warning. [recover.sql] default-dialect = "postgres" # or mysql / mssql / snowflake / databricks / clickhouse # Per-folder override, first path-glob match wins: # [[recover.sql.dialect-rules]] # path-glob = "warehouse/**" # dialect = "snowflake"
Two optional blocks. Without either, the pipeline makes no model calls at all and every structural fact in this report is still recovered — the schema, the foreign keys, the lineage and the author-written descriptions are all deterministic.
# Local, free, no key. Slowest, and needs RAM. [llm] provider = "ollama" model = "llama3:latest" # context-window = 8192 # sent as num_ctx; Ollama otherwise truncates silently # ── or ── OpenAI proper [llm] provider = "openai" model = "gpt-4o-mini" api-key-env = "OPENAI_API_KEY" # default; the key is read from the env, never stored here # ── or ── any OpenAI-compatible host: OpenCode Zen, OpenRouter, # DeepSeek, Groq, a self-hosted vLLM or llama.cpp server [llm] provider = "openai" base-url = "https://opencode.ai/zen/v1" model = "deepseek-v4-flash" api-key-env = "OPENCODE_API_KEY" # Only if SQL004 reports a response hitting its ceiling. Unset = a budget # computed per file from its own table and foreign-key count. # max-tokens = 32768
Precedence for each value is [llm] first, then the env var (OPENAI_MODEL, OPENAI_BASE_URL), then the built-in default. A base-url takes no trailing slash; requests go to {base-url}/chat/completions.
--mode vector and --mode hybrid# Local, free. The safe default, and unrelated to which chat model you use. [embeddings] enabled = true provider = "ollama" model = "nomic-embed-text" # provider default cache = true # default — .ekos/embed-cache/ # ── or ── OpenAI embeddings [embeddings] enabled = true provider = "openai" model = "text-embedding-3-small" # provider default api-key-env = "OPENAI_API_KEY"
Embeddings are built by a post-commit pass, so after adding this block re-run ekos commit — the vector index does not exist until you do. commit then prints Vector embeddings: N embedded.
provider
It falls back to [llm] provider, and if that is also unset, to mock — deterministic nonsense vectors. Hybrid search will appear to work and rank meaninglessly. Always name the provider explicitly.
[embeddings] provider = "openai" posts to api.openai.com and ignores [llm] base-url. Running chat through Zen or OpenRouter and leaving embeddings to inherit means the embed pass hits OpenAI with the wrong key. Pair a non-OpenAI chat host with provider = "ollama" here.
This reads no file contents and costs nothing. It is the cheapest place to catch a mis-scoped config.
ekos config validate ekos config preview-scan
validate warns that a filename entry such as favicon.ico “matches nothing”. It does match — the check runs on every path component, filenames included. Verified against the compiled ledger; ignore the warning.Each verb is a compiler stage. On a repository this size expect roughly ten minutes end to end, most of it in commit.
ekos init ekos build # observe files → content-addressed artifacts ekos recover # artifacts → typed KIR (SQL, git, JS/TS, docs) ekos resolve # merge duplicate identities; candidates go to review ekos compile # KIR → Canonical Knowledge Model ekos commit # CKM → append-only ledger + search index
A clean exit code is not success. Both failures this project hit exited zero.
ekos status ekos ekl "FIND Object WHERE kind = 'Table' COUNT" grep -c SQL001 .ekos/diagnostics/recover.log
[recover.sql] and re-run from recover.recover prints this. Zero means paths is not ["."].[llm] max-tokens if it names a ceiling.Search narrows; the graph traverses; EKL filters. Run these from inside the workspace — the ledger is resolved from the working directory.
# 1 — which objects are even involved (lexical works with no extra setup; # --mode hybrid/vector needs [embeddings] enabled and a re-run of commit) ekos query find "payment status" --mode lexical # 2 — what it touches, N hops out (this is the part grep cannot do). # Takes an object id from step 1, not a name. ekos query neighbourhood c600e4f4-f88b-5d21-94ac-7d918d48dcf3 --depth 2 # 3 — structured filters over the compiled model # entity is always Object/Relationship/Event; kind is a WHERE clause ekos ekl "FIND Object WHERE kind = 'Table' AND name CONTAINS 'payment'" ekos ekl "FIND Object WHERE kind = 'Table' COUNT" # 4 — a grounded natural-language answer with citations ekos ask "which code updates payment status?"
For the document itself, either render one deterministically from the ledger — no model, no cost:
ekos docs generate --layout solution-architect # → DependencyRiskReport.md, OnboardingGuide.md, FindingsMemo.md ekos docs generate --layout curated --format html --output doc # → README / Architecture / API / SequenceDiagrams
…or expose the ledger to a coding agent over MCP and let it assemble a bespoke report, which is how this page was built:
ekos mcp serve --workspace /path/to/project # stdio
ekos mcp serve --http 127.0.0.1:7331 --token-file .ekos/token
The agent then has ekos_search, ekos_query, ekos_neighborhood, ekos_dependents, ekos_impact and ekos_ekl as read-only tools — it retrieves evidence instead of reading your repository.
| Symptom | Cause | Fix |
|---|---|---|
| 0 tables, repo has a schema | Wrong or missing SQL dialect — one unparseable statement discards the whole file | Set [recover.sql] default-dialect; re-run from recover |
| 0 git commits | paths lists subdirectories; the git connector only looks for <root>/.git | paths = ["."] plus ignore-patterns |
| Answers cite vendored or generated files | .gitignore does not exclude anything from observation | Add .venv, site-packages, dist, coverage output to ignore-patterns |
| Descriptions look invented | Model-written, because the schema carries no COMMENT ON | Check the sql_comment property — present means the authors wrote it |
| Enrichment empty on a big file | Reasoning models spend hidden tokens from the same budget | Raise [llm] max-tokens; SQL004 names the ceiling when it is hit |
| Re-run changed nothing | Pass cache keys on preprocessed input; unchanged input means the pass is skipped | Move .ekos/ aside and rebuild — keep .ekos/llm-cache/ to avoid re-billing |
The ledger is append-only. Re-running the pipeline adds a new version of every changed object rather than overwriting, so a rebuild is always safe and the previous state stays queryable with ekos diff.