Traceability report · compiled from source

Where payment status comes from

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.

payment.gl_idgl.idtransactions.idworkflow.workflow_idworkflow.state

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”

One function, one statement

LocationFunctionStatementEffect
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

Data lineage

POSTGRES SCHEMA payment gl_id, closed FK gl id FK transactions id, approved by convention no FK workflow state FK workflow_history every transition payment_links entry_id acc_trans trans_id PERL BACKEND Workflow::Persister::update_workflow Persister.pm:155 writes state
A payment reaches its status through the general ledger, not directly. The green path is the only write; the dashed hop is the one a schema diagram cannot show, because it is configuration rather than a constraint.

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

What each source actually holds

Postgres

sql/changes/1.9/
workflow-schema.sql:5

workflow.state varchar(30) — the authoritative current status, plus workflow_history for every past transition.

Perl backend

lib/LedgerSMB/Workflow/
Persister.pm:155

The single UPDATE. Every status change in the product passes through this one statement.

Definition

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

Payment state machine

INITIAL post SAVED approve !is-batch-member batch-approve is-batch-member batch-delete is-batch-member POSTED DELETED REVERSED no inbound transition
Every legal payment status and the action that produces it, read from 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

There are three status fields, and two are decoys

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.

workflow.state authoritative varchar(30). Written only by update_workflow. History in workflow_history.
payment.closed author comment “This will store the current state of a payment/receipt order” — the schema’s own words, Pg-database.sql. A boolean, predating the workflow engine.
transactions.approved schema 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

Every claim, and where it came from

ClaimEvidenceLineProvenance
Status column is workflow.statesql/changes/1.9/workflow-schema.sql5source
Audit trail exists per transitionsql/changes/1.9/workflow-schema.sql12–22source
The only status writelib/LedgerSMB/Workflow/Persister.pm160source
History insertlib/LedgerSMB/Workflow/Persister.pm181source
payment.gl_id → gl.idsql/Pg-database.sql3408compiled FK
gl.id → transactions.idsql/Pg-database.sql982compiled FK
acc_trans.trans_id → transactions.idsql/Pg-database.sql1124compiled FK
workflow keyed to transactionsworkflows/persisters.xml12convention
States and transitionsworkflows/payment.workflow.xml5–21source
is-batch-member testworkflows/payment.conditions.xml2–4source
payment.closed holds statesql/Pg-database.sql3419author comment
transactions.approvedsql/Pg-database.sql301compiled
Table purpose descriptionsworkflow, workflow_history, workflow_contextmodel-written
source read directly from the file compiled a fact in the knowledge ledger author comment the schema authors’ own words model-written inferred, not stated anywhere

The economics

Why this needed a compiler, not a search

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.

StepText searchEKOS
Find the candidate files grep -ril payment202 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.

What EKOS did not do here

Three parts of this report came from reading files directly, and saying so is the point of a traceability document:

The 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.
Perl line numbers EKOS has no Perl analyzer. It observes .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.
This schema, before today The same compile returned 8 tables instead of 192 until RFC 0146 landed: one unparseable 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

Reproduce this on your own repository

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.

  1. Install

    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
  2. Write ekos.toml in your project root

    Three 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.

    A · Language model — pick one

    # 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.

    B · Embeddings — what turns on --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.

    Two traps in this block specifically

    Omitting 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.
    Cloud chat ≠ cloud embeddings [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.
  3. Check the scope before you compile anything

    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
    across 1 root(s)git history will be observed. More than one root means it will not.
    file countWildly higher than your source tree? Something generated is being ingested.
    known false positivevalidate 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.
  4. Run the pipeline, in order

    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
  5. Verify it actually worked

    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
    Table countZero for a repo that plainly has a schema means the dialect is wrong — fix [recover.sql] and re-run from recover.
    Git commits analysedrecover prints this. Zero means paths is not ["."].
    SQL001“no tables found”. Expected for migration and view files; suspicious on your main schema file.
    SQL004Enrichment named fewer tables than the file declares. Raise [llm] max-tokens if it names a ceiling.
  6. Ask your question, then produce the document

    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.

When it goes wrong

SymptomCauseFix
0 tables, repo has a schemaWrong or missing SQL dialect — one unparseable statement discards the whole fileSet [recover.sql] default-dialect; re-run from recover
0 git commitspaths lists subdirectories; the git connector only looks for <root>/.gitpaths = ["."] plus ignore-patterns
Answers cite vendored or generated files.gitignore does not exclude anything from observationAdd .venv, site-packages, dist, coverage output to ignore-patterns
Descriptions look inventedModel-written, because the schema carries no COMMENT ONCheck the sql_comment property — present means the authors wrote it
Enrichment empty on a big fileReasoning models spend hidden tokens from the same budgetRaise [llm] max-tokens; SQL004 names the ceiling when it is hit
Re-run changed nothingPass cache keys on preprocessed input; unchanged input means the pass is skippedMove .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.