Files
agenticCode/x-docs/roadmap.md
Ingo Schnabel 2c56eea161 Remove MCP
2026-08-04 12:57:08 +02:00

76 KiB
Raw Blame History

AgenticCode Roadmap — Open Tasks

This is a living task list of the remaining work. Completed features have been moved to x-docs/features.md (with full implementation notes); this file tracks only items that are still open.

The Web UI initiative (items 48–51) is largely delivered — backend prereqs 48–50 and frontend milestones M0–M5 are done (see x-docs/features.md); only M6 (scale & polish) remains, and it is postponed as of 2026-07-15. Bug #58 (stale nodes survive refresh), #59 (shared field tokenizer) and #43 (hash-based auto-invalidation + deleted-file sweep) were completed 2026-07-15; #61 (comments parsed as CALLNAT targets), #62 (data literals as false MODULE call targets), #63 (CALLNAT inside a string literal) and #64 (ingest summary contradicted the graph; dispatch guards lost their VALUE alternatives) 2026-07-16 (see x-docs/features.md). #24 (the stale-file sweep scanned the whole AstNode label once per file — a full upms refresh went from ~90 min, incomplete, to 179 s) and #25 (batch persist, already implemented) were completed 2026-07-16. A manual WGEAGB0S endpoint audit against the Natural source (2026-07-16) closed #65 (?depth= now counts module hops — it had silently reported "no DB access" for a module two calls from its tables) and #66 (the API dropped the copycode line/file provenance the graph carries, so a .cpy line was served as a host line). #69 (edges on the same line number from different files collapsed — data loss, found by #66's own fixture) and #70 (placeholder field resolution dropped the copycode provenance, so item 66 only worked for bare field references) were completed 2026-07-16, and #68 (field-flow bounded on raw CALLS hops — it reported "nothing consumes this field" for a consumer called from inside a subroutine, and introduced the derived CALLS_MODULE edge) and #67 (call-tree reported a direct dependency as 3 hops deep, and now flags truncated when its traversal budget cuts) 2026-07-17. The whole raw-hop depth family (65/67/68) and the copycode-provenance family (66/69/70) are closed. (Item 67's follow-up #71 was retracted 2026-07-17: it rested on a measurement error of mine — the correct count is 0, and the parser cannot produce the edge it assumed. See "Not a bug".) A VMULTMN4 audit (2026-07-17) found #72 (a nested DECIDE dropped the outer guard, so the dispatch table stated an incomplete condition as complete — 178 upms modules); #72 is fixed 2026-07-17 (see x-docs/features.md) and left #73 (a NONE branch is a negation no guard chain can express) and #74 (a resolved and a placeholder edge for one statement coexist — exposed, not caused, by #72). Item 26 (MCP session reliability) remains postponed as of 2026-07-13. All remaining open items are postponed; revisit when prioritised.

Web UI — code understanding & navigation

A React/TypeScript web UI (ac-ui/) for navigating the AgenticCode graph to migrate legacy Natural to Java. Full vision/architecture: x-docs/ui-proposal.md. Backend prereqs 48–50 and frontend milestones M0–M5 (+ the M6 explorer regex filter) are DONE — see x-docs/features.md. Only M6 scale/polish remains:

  • 51 (M6). Scale & polish (POSTPONED 2026-07-15) — virtualisation/large-graph performance, multi-project, auth, theming, export (SVG/PNG/report). (The rest of item 51 is complete; see x-docs/features.md for M0–M5 detail.) Key open risks carried from the earlier milestones: auto-ingest latency on large projects (deep-ingest concurrency cap defaults to 2); the STALE_SOURCE warm side-effect (a warming query re-ingests + re-hashes a module and thereby clears its staleness — treat ingest status as "true at query time"); node-id instability after re-ingest (the UI keys on name + sourceFile); auth/multi-user and the deploy model still open.

Lazy / deferred ingest (three-tier model)

Reworks ingest from eager whole-project parsing into a lazy, on-demand model. Three tiers: Tier 1 = cheap eager reference index (per file: nodes, identifiers, coarse call/DB references — no deep bodies); Tier 2 = lazy deep ingest (control flow, statement-level dataflow, precise reads/writes) triggered on demand; Tier 3 = source served from the filesystem, no longer stored on nodes. Reverse queries (callers, search_identifier, flow_backward) stay answerable because Tier 1 pre-indexes coarse references globally.

Items 36–43 are done (Tier-1 reference index + tri-state status, Tier-2 lazy deep-ingest, depth/node caps, unresolved-reference nodes, Tier-3 source-from-disk with stale check, the refresh surface, and hash-based auto-invalidation) — see x-docs/features.md. No open items remain in this track.

Ingest performance

(Items 24 — the stale-file sweep's missing (project, sourceFile) index, the actual persist bottleneck — and 25 — batch persist, found already implemented — completed 2026-07-16 and moved to x-docs/features.md. A full upms call-graph refresh (6311 files) now takes 179 s end-to-end (~63 s parse, 104.5 s persist across 32 batches, ~12 s finalize), against ~2.5–2.8 min per batch before. The parked "parallel parse phase" idea was implemented 2026-07-18 (item 24) — see x-docs/features.md. No open items remain in this track.)

Known bugs

  • 107. Every module endpoint answers 200 with an empty shell for a module that does not exist — indistinguishable from a real but empty module (found 2026-08-02, upms webservice-layer audit; contradicts the documented 404 MODULE_NOT_FOUND)

    Symptom. Measured against the live server, upms:

    GET /modules/WXSPOD0S/digest      → 200 {"name":"WXSPOD0S","description":null,"functionCount":0,
                                              "callers":{},"callees":{},"dbTables":[],"dataStructures":[]}
    GET /modules/NOSUCHMOD123/digest  → 200 {"name":"NOSUCHMOD123", … identical shell … }
    

    Byte-identical answers apart from the echoed name — for a module that exists nowhere in the graph and for a name typed at random. callees, call-tree and context behave the same (all 200). search/identifier?name=WXSPOD0S&type=MODULE correctly returns [], so the graph knows; only the module endpoints invent the row. agent-api-system-prompt.md promises 404 NODE_NOT_FOUND / MODULE_NOT_FOUND — unknown id / module name.

    Why it matters — this produced a wrong analytical result, not just an ugly response. The question was "can any W* webservice module reach the commission calculation?". Five dispatchers (WPOLIX0S, WGARCX0S, WACOMX0S, WCLAIX0S, WOBJPX0S) dispatch dynamically to 13 targets (WXSPOD0S, WXSDAD0S, WXSCMD0S, WCARLD0S, WXSCLD0S, WXSCLD2S, WXSFUD0S, WXSGAD0S, WACOMD0S, WCLAID0S, WOBJPD0S, WCATEX0S, WCATED0R). call-tree?depth=4 on each returned 200 with 0 modules and 0 provenance hits, which reads as "analysed, nothing found". The truth is "not analysable" — all 13 source files are absent from the checkout (find finds none). An agent that trusts the 200 concludes "these paths trigger no commission processing"; the honest answer is "unknown". Same failure mode as item 103: an incomplete answer that looks complete.

    Fix. Return 404 MODULE_NOT_FOUND when no MODULE node exists for (project, name) — the check search/identifier already performs. If a node exists but its source is not ingested (placeholder, sourceFile = ""), that is a different state and deserves an explicit marker in the payload (placeholder: true / sourceFile: null) rather than an all-zeros body. Worth checking which other /modules/{name}/… endpoints share the shell (functions, db-accesses, data-structures, dispatch-table all plausibly do — dispatch-table returning [] for a non-existent module is currently indistinguishable from item 108's real gap).

  • 75. CONTAINS is not acyclic — 22 self-loops and 162 two-cycles in upms (found 2026-07-17 while root-causing item 74; cause NOT established — do not treat the notes below as settled). The containment hierarchy that dozens of queries traverse with CONTAINS* contains cycles:

    (IF  @ JX0031N0.nat:781-966) -[:CONTAINS]-> (FOR @ YFRAMBC0.cpy:65-15)
    (FOR @ YFRAMBC0.cpy:65-15)   -[:CONTAINS]-> (IF  @ JX0031N0.nat:781-966)
    

    Neo4j's variable-length patterns use trail semantics (no relationship repeats in a path), so queries terminate rather than hang — but the blow-up is real: three probes using an unbounded CONTAINS* over this region were killed at a 2-minute timeout during the item-74 investigation. Candidate, unconfirmed: copycode CONTROL_FLOW nodes are shared by every including module (one node per .cpy line), and a .cpy that opens a block it does not close (YFRAMBC0.cpy opens FOR at line 65; the END-FOR lives in the includer) gets containment edges from every includer's nesting context accumulated onto that one shared node. Related: 625 nodes have endLine < startLine (531 DB_ACCESS, 94 CONTROL_FLOW) — the FOR above is 65 -> 15. But this explains only 26 of 162 cycles and 4 of 22 self-loops, so it is not the main cause. Left open on purpose rather than guessed at. The cycles are not confined to the statement tree: the DATA_STRUCTURE node named "," in BSUPLFN0.nat (itself a parser artifact worth its own look) carries self-loops, so the INCLUDES -> field walk the bare-field resolvers do runs through cyclic ground too. That is why item 77's INCLUDE_FIELD_DEPTH bound is a correctness requirement, not a tuning knob — an unbounded CONTAINS* there is what killed the probes above. (Item 77's redirect does not traverse this region: src for a placeholder edge is only ever FUNCTION (157,616 edges) or MODULE (35,572) — never CONTROL_FLOW — and all 16,231 such functions are direct CONTAINS children of a module, so its *0..1 bound avoids the cycles entirely.)

    2026-07-17 — concrete impact established and fixed for the dynamic-CALLNAT family (pending corpus re-verify). The blow-up is not merely theoretical: a whole-root deep refresh of upms wedged finalize step 17 (resolve-dynamic-callnat-intra-indirect) for ~2 h without completing, which blocks every later step — including item 77's bare-field resolution, so item 77 could not be corpus-verified. Measured cause: that step joins three unbounded (caller:MODULE)-[:CONTAINS*0..]-> anchors, and the resulting per-caller path enumeration over the cyclic copycode region is cubic — a read-only probe of the exact query for a single caller (DAGNTFN0) did not finish in 60 s. Fix: the six dynamic-CALLNAT resolvers (RESOLVE_DYNAMIC_CALLNAT_INTRA / …_INDIRECT / …_CROSS

    • their …_SCOPED variants in CypherQueries) no longer descend CONTAINS to find a module's own statements. A MODULE is 1:1 with its sourceFile (verified: 3587 files, max one module each), and a module's CALLNAT sites and WRITES statements all carry that same sourceFile, so the anchors become sourceFile-equality hash-joins that cannot cycle. The dispatch variable in the cross-module resolver can live in an included PDA, so it is scoped to the caller's own file or a data structure the caller INCLUDES (matching by name alone would pull in 20958 unrelated same-named vars; the scope filter keeps the 307 in-scope ones). Proven equivalent on upms: resolve-dynamic-callnat-intra yields the identical 31 resolved (caller, target, lineNo) triples project-wide, and the rewritten indirect step completes project-wide in ~6 s. Existing dynamic-dispatch ITs (intra / indirect / cross / scoped / unresolved- survival) stay green. Still to do: deep-recreate upms and confirm finalize reaches 36/36; then finish item-77 corpus verification. This does not remove the underlying CONTAINS cycles — other CONTAINS* traversals remain exposed if a future step joins several of them; the cycles themselves (parser line-range/shared-copycode artifacts) are still open above.

    2026-07-18 — same blow-up confirmed on the READ path (frontend-facing, NOT yet fixed). Only the finalize write-queries were rewritten above; the runtime read-queries the UI (ac-ui) renders still use unbounded (m:MODULE)-[:CONTAINS*0..]->(src). Measured live against server v71 on a heavy module (ACCNPE01): callees >2 min / hangs, digest >10 s timeout, context >10 s timeout, callers ~3.6 s; light modules (BMTABBP0) and db-accesses/call-tree/graph/functions stay <1.2 s. So the UI's Callees / module-overview / context panels spin on large modules. Same cure as the dynamic- CALLNAT fix (src.sourceFile = m.sourceFile hash-join, INCLUDES-scoped for variables). Affected read constants: CALLEES, CALLERS, DIGEST/CONTEXT query, FUNCTION_CALLERS, DB_ACCESSES, variable READS/WRITES, *_FOR_MODULES. 2026-07-19 — fixed. Rather than the sourceFile hash-join, the read queries use a tighter, provably equivalent bound: every {@code CALLS}/{@code READS}/{@code WRITES}/{@code DB_ACCESS}-parent edge source is a {@code MODULE} (depth 0) or a {@code FUNCTION} that is a direct {@code CONTAINS} child of the module (depth 1) — verified corpus-wide (0 sources deeper, 0 non-direct-child edge-source functions, and {@code DB_ACCESS} parents are only {@code FUNCTION}/{@code MODULE}, never {@code CONTROL_FLOW}). So (m)-[:CONTAINS*0..]->(src) becomes (m)-[:CONTAINS*0..1]->(src), which returns the identical set but cannot walk the cyclic copycode region. 20 read-side traversals updated (callees, MODULE_HOP_OUT(+ wiring), DISPATCH_TABLE, EGO_NEIGHBORS_*, VARIABLE_ACCESSES, DB_ACCESSES(+FOR_MODULES), SQL_STATEMENTS(+FOR_MODULES), FUNCTION_CALLERS, SEARCH_BY_VALUE(+_CONTAINS), fieldFlow, BUILD_CALLS_MODULE, FLOW_FRONTIER_SOURCE_FILES). Finalize/resolve queries left as-is (they completed). Guarded by ReadPathBoundedTraversalIT (EXPLAIN plan asserts no unbounded CONTAINS expand in callees); full IT suite green (193/0/0). No recreate needed — a query-only change against the existing graph. Verified live (v76): ACCNPE01 callees >2 min → 0.16 s, digest >10 s → 3.3 s, context **>10 s → 3.1 s; WGEAGB0S calleesunchanged (7). The underlyingCONTAINS` cycles (parser artefacts) still exist, but both the finalize and the read consumers are now bounded — item 75 no longer has a practical impact.

  • 73. A NONE/ANY branch is reported under its enclosing guard alone — the condition is a negation no guard chain can express (found 2026-07-17 while fixing item 72; item 72 does not fix this). NaturalParser's VALUE_RESET clears a DECIDE's active value on NONE/ANY, so an assignment inside such a branch is attributed to the enclosing guard chain only. Under VALUE 'TABL' → DECIDE ON #FIELD-NAME → NONE → MOVE ..., item 72 now reports guards = [#SHORT-VIEW='TABL'], which reads as "happens for all of TABL". The truth is "#SHORT-VIEW = 'TABL' AND NOT (#FIELD-NAME = any of the branch's VALUEs)". This is the same complaint item 72 makes — a condition served as complete when it is not — so item 72's guards is not a total answer, only a strictly better one. A guard chain is a conjunction of equalities by construction; expressing this needs a negated link (e.g. a DispatchGuard with negated=true carrying the sibling branches' values), which is a model change, not a parser tweak. (Unmeasured: how many NONE branches actually contain assignments — many are IGNORE. Worth counting before investing.)

(Fixed bugs 55, 57, 58, the dossier field-ordering fix, and the raw-hop depth family — 65, 67 (call-tree), 68 (field-flow) — plus 69/70 (edge identity + copycode provenance) moved to x-docs/features.md. #71 is a known imprecision left behind by item 67 rather than a wrong answer; #72 is a real wrong answer, found by the 2026-07-17 VMULTMN4 audit.)

  • Not a bug — retracted 2026-07-17 (was #71): "external subroutine calls do not count as a module hop" rested on a bad measurement of mine, not on the code. I counted 4,808 "genuine external subroutine calls" in upms with (ma)-[:CONTAINS*0..]->(src)-[:CALLS]->(f), applying the single-owner filter to the target but not to the source: for a copycode-shared source function (L4N-ENTER is CONTAINSed by 137 modules) that enumerates all 137 as ma, each differing from the target's owner. The correct test — source and target single-owner — returns 0. And it must: resolvePlaceholderTargets filters ph.type IN ['MODULE', 'DATA_STRUCTURE'], so a FUNCTION placeholder is never resolved across modules; a PERFORM to another module's subroutine stays an unresolved placeholder (6 in upms) and never becomes a cross-module CALLS edge. A path therefore cannot enter a module at a FUNCTION node, which was #71's entire premise. (The real, tiny gap: those 6 unresolved placeholders. Natural external subroutines are a language feature this parser does not resolve — worth its own item if the corpus ever needs it.)

  • 99. DATA_AREA_FIELD mis-split data-area lines that carry a marker column — the field name was lost and the level invented (found and fixed 2026-07-27 while implementing item 98)

    Symptom. Natural data-area exports (.lda/.pda/.gda) contain lines with a marker character between the type/length columns and the level. For those lines the parser emits an invented level and uses the marker (or type) letter as the field name; the real field name never reaches the graph at all.

    Cause. DATA_AREA_FIELD is ^\s*(.*?)(\d)([#A-Za-z][#\w-]*)\s*(.*)$ — the prefix is non-greedy, so the regex takes the first digit followed by an identifier as the level. Normally that is right, because the prefix ends in <TYPE><spaces><LENGTH> and <LEVEL><NAME> follows directly:

    A        60  2##COMMAND      ->  prefix 'A        60', level 2, name '##COMMAND'   (correct)
    

    But when a marker is glued to the length, the regex stops too early — inside the length:

    A         4C 2#C-PADRE_START-PATTERN
        parsed  : level 4, name 'C'          <- '4' is the length, 'C' the constant marker
        correct : level 2, name '#C-PADRE_START-PATTERN'
    
    S0001A        50M 2CRITERIA
        parsed  : level 1, name 'A'          <- 'S0001' is the marker column, 'A' the type
        correct : level 2, name 'CRITERIA'
    

    Measured impact (whole upms corpus, 2726 data-area files):

    • 60 files affected, 931 lines mis-split.
    • Of those, 333 lines in 31 files produce a phantom group at level 1.
    • The invented names are nearly always marker/type letters: C (739×, the constant marker), A (117×), I (44×), N (20×), M (9×), B, P.
    • The invented levels come from the length digits and range from 0 to 6.

    Consequences.

    1. The real field name does not exist. search_identifier cannot find #C-PADRE_START-PATTERN; data_structure_fields shows a field called C instead.
    2. Group nesting collapses. The level is arbitrary, and at level 0 the groupStack is emptied completely (while peek().level() >= level) including the root — every following field in the file loses its parent.
    3. The wrapper root disappears. parseDataArea only creates the file-named root when topLevelNames reports exactly one top-level group. A phantom level-1 group makes it two, and a module's USING <area> then references a node that does not exist — exactly what happens at YLORDVL1.lda:30.
    4. Identifier-index pollution. 739 nodes named C across the corpus, which can collapse together at the sourceFile="" placeholder level.

    Relation to item 98. Item 98 was not blocked by this: its enricher deliberately joins on area.sourceFile instead of walking CONTAINS from the root, precisely because that root was not guaranteed to exist. That join stays — it is the more robust one regardless.

    Done. The export is column-oriented — [<occ>] <TYPE> <LENGTH>[<marker>] <LEVEL><NAME> — where <occ> is an occurrence/superdescriptor column (S0001, 0013) glued to the type and <marker> (C constant, M multiple-value, *) is glued to the length. New anchored DATA_AREA_FIELD_EXPORT is tried first, with the permissive DATA_AREA_FIELD kept as a fallback, so anything the anchored form does not recognise keeps its previous behaviour exactly — regression is impossible by construction. topLevelNames uses the same split, otherwise a phantom level-1 group would still suppress the wrapper root. Deliberately a targeted grammar, not a complete one: the zero-padded two-digit level in A 8*03COD-GENAGREE is left to the fallback, which already resolves it correctly.

    Measured over all 2726 data areas: 59 282 lines parse identically, 931 are corrected (in 60 files), 1 338 fall back to the previous behaviour verbatim. Tests: NaturalParserTest#dataAreaMarkerColumnsDoNotStealTheLevelAndName (one case per marker shape) and #dataAreaLinesWithoutAMarkerColumnAreUnchanged (regression guard — the source form, the export view marker and the plain field all depend on regex backtracking past the occurrence column, so they are pinned explicitly rather than assumed).

    Knock-on: YLORDVL1.lda regains a single top-level group, so its wrapper root reappears and its USING reference resolves — item 98's enricher now reaches VDB2-VERSIS_LISTORDER too.

    Open. What the * marker means is still unknown (the adjacent comment on A 1002* 2V25C6961-RECORD reads /* #01 - alte Länge, hinting at a superseded field). Both the old and the new grammar treat it as a live field, so including it changes no outcome — but if it marks a removed field, those declarations are wrong in the graph either way, which would be its own item.

  • 98. View aliases declared in a USING data area were reported as tables (2026-07-27, follow-up to item 95). Item 95's alias pre-scan is per-module over the copycode-expanded lines, but a LOCAL USING data area is a separate module, so a view declared there stayed unresolved: YGEAGBNH.nat:2617 does FIND (1) VDB2-VERSIS_GENAGREE with the view declared in YGEAGVL1.lda, so db-accesses reported the alias instead of VERSVW_GENAGREE. Root cause was deeper than cross-file scoping: parseDataArea did not recognise the data-area export view marker at all. In an export a view is V 1VDB2-VERSIS_GENAGREE VERSVW_GENAGREE DA:00,00… — there is no VIEW OF text, and the V prefix was read as a data type, so the view became a VARIABLE with no USES_TYPE to its DDM and no group push, letting its columns escape to the file root. 45 data areas use this form; 32 modules do DML on an alias only declared there. Done: (a) parseDataArea treats prefix V as a view — DATA_STRUCTURE + USES_TYPE to the DDM (first token of the rest), fields now nesting under it; (b) new enrichment steps resolve-view-alias-tables READS|WRITES + resolve-view-alias-access-nodes redirect the module's READS/WRITES and its DB_ACCESS node onto the real table, scoped by the module's own USING set — a name-based redirect would be arbitrary, since NEXT-VIEW alone is declared over 100 different tables corpus-wide. size(reals) = 1 leaves a contradictory USING set unresolved rather than guessed; the join is on area.sourceFile, not CONTAINS from the area root, because that root only exists when the file has one top-level group (see item 99). Tests: NaturalParserTest#dataAreaExportViewMarkerLinksToTheTableAndNestsItsFields, IT NaturalCrossFileViewAliasIT (incl. two modules resolving the same alias name to different tables); the enricher was verified load-bearing by disabling it and watching the IT go red.

  • 97. call-tree leaked the dynamic-call placeholder a manual override only hides (2026-07-27, third WGEAGB0S deep API audit). call-tree for WGEAGB0S listed #GETSHORT-MODUL — a variable (YGEAGGNH.nat:443, CALLNAT #GETSHORT-MODUL) — as a MODULE in the closure, while callees for the same module correctly reported only the resolved target YGEAGGN0. Cause: a manual override does not delete the marker edge to the variable-named placeholder, it sets manualHidden = true and relies on the read queries to suppress it (DELETE_DYNAMIC_CALLNAT_PLACEHOLDER_EDGES). callees/callers filter it; the BFS behind call-tree did not — MODULE_HOP_OUT/MODULE_HOP_OUT_WIRING did not even bind the relationship. Everything driven by that BFS inherited the pollution (graph, db-accesses?depth=N, sql-statements?depth=N). Done: both hop queries bind r and apply coalesce(r.manualHidden, false) = false, matching callees/callers. Characterization IT DynamicCallOverrideIT#callTreeHonoursTheOverrideLikeCallees (placeholder present → override → absent → reset → present again); verified red against the pre-fix query.

  • 96. Natural UPDATE(ref.) / DELETE(ref.) were dropped, hiding every access layer's write path (2026-07-27, third WGEAGB0S deep API audit). 34 statement sites across 13 of the 65 modules in the WGEAGB0S closure — every Y****MN0 CRUD module — produced no WRITES edge, so db-accesses showed them as read-only plus a single STORE. Cause: DB_WRITE's (?!\() guard (added by item 90 to stop a phantom (OLD.) table) suppressed the phantom but never recovered the real table, and DELETE was only handled in its SQL DELETE FROM form. Done: new DB_WRITE_BY_REF plus a pre-scan mapping each FIND/READ statement label to its (alias-resolved) table; an unresolvable reference still records nothing, so item 90's no-phantom guarantee holds — its two tests stay green unchanged and now serve as the negative cases. Shared with NaturalCoarseScanner so tier-1 and deep agree. Tests: NaturalParserTest#updateAndDeleteByReferenceResolveToTheEnclosingLoopTable, #byReferenceWriteWithoutAResolvableLoopNamesNoTable, NaturalCoarseScannerTest, IT NaturalViewAliasDbAccessIT. A label may also introduce a SQL SELECT loop rather than a FIND (YELEMMN0, YMULTMN0 hold their record that way) — those resolve through the FROM clause; #byReferenceWriteResolvesThroughALabelledSelectLoop. Verified on live upms: all 34 by-reference sites in the WGEAGB0S closure now recorded, 0 missing.

  • 95. Natural view aliases were reported as DB tables (2026-07-27, third WGEAGB0S deep API audit). db-accesses named the Natural view variable of a DML statement, not the DDM it is declared over: 58 rows across 13 of the 65 modules in the WGEAGB0S closure, 32 alias names standing in for 20 real tables. Worst effects — the generator's boilerplate alias NEXT-VIEW became one DB_TABLE node shared by 11 modules meaning 11 different tables (and reporting no columns), and 11 VDB2-*-VLOG aliases hid every write to VERSVW_LOGFILE, so "who writes the audit log?" answered nothing. Cause: VIEW OF was only recognised in parseDataArea (.pda files); parseModule — which parses every .nat — never built an alias map, and the DML branches passed the operand verbatim to dbTable(...). Done: VIEW_DECL pre-scan over the copycode-expanded lines feeds resolveViewAlias into the DB_WRITE/DB_READ branches; dbTable() now upper-cases (Natural is case-insensitive and DB_TABLE merges on the name). Shared with NaturalCoarseScanner so a shallow and a FULL module cannot report different names for the same statement. Tests: NaturalParserTest#viewAliasResolvesToTheUnderlyingTable, NaturalCoarseScannerTest, IT NaturalViewAliasDbAccessIT (incl. the same alias in two modules resolving to two tables). Verified on live upms: alias rows in the WGEAGB0S closure 58 → 1, the phantom NEXT-VIEW node gone, VERSVW_LOGFILE reachable for the first time. The remaining row is the cross-file case, item 98.

  • 94. call-tree no longer enumerates paths; followWiring usable again (2026-07-20, JX0034N0 ↔ MultiTableImportJob functional comparison). call-tree?followWiring=true timed out on pur at depth ≥ 2 (>120s; depth 1 already took 5.3s), which made the Java wiring closure unobtainable. Measured cause — a single quantified path pattern over CALLS|INJECTS|REFERENCES, bounded by maxDepth × (1 + internalBudget) (= 42 at depth 2), recovering each target's depth as min(#MODULE nodes on path) - 1, i.e. by enumerating every path. With the CHA-materialized wiring edges (items 31/92) that is combinatorial:

    rawBound Java, followWiring, depth 2 Natural JX0034N0, depth 5
    3 1.6s, 71 targets —
    4 1.7s, 71 targets 2.6s, truncated (42)
    6 18.9s, 71 targets 3.2s, truncated (79)
    8 / 12 >120s 2.2s / 2.4s, truncated (120/172)
    21 >120s 3.9s, converged (182)
    42 (production) >120s 3.2s, 182

    So the budget is necessary for Natural (whose result converges only near 21) and useless for Java (converged at 3) — lowering it globally would silently truncate Natural, the exact failure its javadoc warns about. The blow-up comes from the wiring edges, which JavaParser.addWiringEdges and the CHA steps only ever emit class-to-class, so they can never reach a FUNCTION. Fix: split the query. MODULE rows now come straight from the moduleDepths BFS (its hop index is the module-hop depth — verified equal to the old query's module set: 71/71 for Java, 46/46 for Natural), and only FUNCTION rows still traverse, CALLS-only and bounded within one module. Behaviour change: the budget can no longer hide a module whose call site sits behind a long internal PERFORM chain — DEPTHLEAF is now reported at depth 1, which also removes a standing contradiction with db-accesses/sql-statements, whose module set always came from the same BFS. truncated accordingly now means "some module's internal subroutine chain may be cut off". Covered by CallTreeTruncationIT (both tests).

    Measured live after deploy — call-tree?followWiring=true on MultiTableImportJob: depth 2 1.9s (was >120s) with the same 71 modules, depth 6 3.0s, depth 10 2.3s converging at 852 modules. On upms/JX0034N0 at depth 5 the module set grew 46 → 53 with nothing lost; the seven that had been hidden are NDBERR, NDBNOERR, USIX009N, USIX052N, USIX053N, YELEMGN0, YLITEMN0. They are real: USIX052N is CALLNATed by ISI173N0 (line 474), itself a direct callee of JX0034N0, so it sits at module depth 2; and YLITEMN0 was already reported by db-accesses?depth=5 as a via, which is the contradiction this item removes.

  • 93. Transitive db-accesses lost the DECLARES rows (2026-07-20, JX0034N0 ↔ MultiTableImportJob functional comparison). DB_ACCESSES resolves a table from three sources — READS/WRITES, an entity's own MAPS_TO, and a repository's repositoryEntity (item 32) — but its transitive counterpart DB_ACCESSES_FOR_MODULES (item 65) only ever had the first. The transitive view was therefore not a superset of the direct one: asking the same module with depth silently dropped its table. Minimal repro: db-accesses on MultiTableEntryEntity returns multi_table_entry/DECLARES, db-accesses?depth=1 on that same module returns []. Consequence: a Java caller's transitive db-accesses came back empty even though the entity it persists through maps to a real table, which made the Java side of a Natural↔Java DB comparison impossible to obtain from the API. Fix: DB_ACCESSES_FOR_MODULES now carries the same three UNION branches, with via naming the module that declares the table. SQL_STATEMENTS has no such branches, so SQL_STATEMENTS_FOR_MODULES needed no change (verified). Covered by JavaRepositoryOwnTableIT.entityTableAlsoResolvesInTheTransitiveView / repositoryTableAlsoResolvesInTheTransitiveView (both red before the fix).

  • 92. Java inheritance/CHA wiring: three defect classes fixed (2026-07-19, MultiTableImportJob deep API audit — manual Java source pass, project pur). Three systemic errors in the callees/wiring materialization, all found by comparing callees against source:

    • A — inherited INJECTS/REFERENCES lost their origin file (635× in the MTIJ closure, 255 with a lineNo past the caller file's end). LINK_REFERENCES_TO_SUBCLASSES/LINK_INJECTS_TO_SUBCLASSES (item 31) copied the base class's lineNo onto the subclass edge but never set originFile, so the callees sites (coalesce(r.originFile, source.sourceFile)) fell back to the subclass file. Worked example: MultiTableImportJob reports PurBatchJobListener REFERENCES lineNo=133, but that file has 93 lines — line 133 is in AbstractPurBatchJob.java. Same as the Natural copycode bug (items 66/91), for Java inheritance. Fix: materialized edges now carry originFile = coalesce(r.originFile, base.sourceFile) + inheritedFrom = base.name.
    • B — CHA fanned constructor calls out to subtypes (48× phantom CONSTRUCTOR callees). LINK_CALLS_TO_IMPLEMENTATIONS applied class-hierarchy analysis to callKind='CONSTRUCTOR' CALLS, so new ArrayList<>() produced a phantom → InputConstraintHolder [CONSTRUCTOR] (it extends ArrayList), and new BaseException() fanned out to every exception subtype. A constructor is statically bound. Fix: AND coalesce(r.callKind,'') <> 'CONSTRUCTOR'.
    • C — a qualified same-name supertype resolved to self (3× self-EXTENDS). DateUtils extends org.apache.commons.lang3.time.DateUtils (and NumberUtils/StringUtils) were resolved by simple name to the project's own same-named class → a DateUtils EXTENDS DateUtils self-loop that also poisoned the inheritance materialization. Fix: JavaParser.supertypeName keeps the FQN when a qualified supertype's simple name equals the declaring class's own name; the materializers additionally guard sub <> base. The parser fix stops new self-edges, but a non-wiping refresh leaves the old self-EXTENDS behind (both endpoints are the surviving class node, so node reconciliation never sweeps it — the edge gap item 86 closed for Natural), so a delete-self-inheritance-edges enrichment step reaps any self-EXTENDS/IMPLEMENTS edge project-wide before the inheritance graph is traversed.
    • Infra: a delete-synthetic-inheritance-edges enrichment step reaps all resolvedVia:'INHERITANCE' edges before the three materializers rebuild them, so a non-wiping refresh picks up the new properties/gates (otherwise MERGE ... ON CREATE never updates a pre-existing edge). Query-only fixes for A/B + reap; parser fix for C. IT JavaInheritanceWiringIT (3 tests). No response-shape change — originFile flows through the existing callees sites.callSiteFile.
  • 91. db-accesses/workfile-accesses/sql-statements carry copycode provenance (2026-07-19, third WGEAGB0S deep API audit — manual source pass). A DB or work-file access whose statement lives in an INCLUDEd copycode was reported with a copycode-local lineNo and no file context, so the number read as a line of the host module. Concretely: the DB2 sequence read SELECT … FROM SYSIBM-SYSDUMMY1 lives in USIX043C.cpy at lines 31/39/45/51/57; db-accesses for the 9 including modules (YAPRFMN0, YCUACMN0, YLITEMN0, YMODAMN0, YMTABMN0, YMULTMN0, YPRODMN0, YRAMOMN0, YUGRPMN0) reported those as bare lineNos that land on each host's own comment/DEFINE DATA lines. Root cause: the provenance was already on the READS/WRITES edge (item 66 stamps originFile/viaCopycode/includedAt on every edge in CopycodePreprocessor.remap, persisted via SET r += e.properties), but the DB_ACCESSES/ WORKFILE_ACCESSES/SQL_STATEMENTS queries never returned it — exactly the gap item 85 closed for functions and item 66 for callees/variables. Fix (pure query + DTO, no re-parse of data): db-accesses and workfile-accesses now return sites: [{lineNo, sourceFile, viaCopycode, includedAt}] (new AccessSite record) alongside the kept lineNos; sql-statements gains sourceFile + viaCopycode from the DB_ACCESS node's (remapped) file. includedAt is stored as a string, so the site queries wrap it in toInteger(...). Direct and transitive (?depth>0, *_FOR_MODULES) variants. REST auto-serializes the records; MCP returns the same DTOs; the CLI is a JSON passthrough — all in sync. IT WorkfileAndCopycodeFunctionIT#dbAccessSiteNamesTheCopycodeFileForCopycodeSourcedAccess (host FIND vs copycode FIND → sites[0].sourceFile/viaCopycode distinguish the two).

  • 91. search/identifier?priorityModule= pins the caller's module into the page; deterministic order (2026-07-26, UI click-to-identify test). Click-to-identify sent search/identifier?name=&limit=25, but the query had no ORDER BY and paginated in incidental index order, so for a name declared in >25 modules (e.g. #I-LINE-LEV, 192 declarations) the open module's own declaration was truncated away and the popover falsely reported "0 in this module". Fix: SEARCH_IDENTIFIER gains $priorityModule — it does not filter (unlike module=) but computes a pinRank (0 for that module's file, else 1) and ORDER BY pinRank, sourceFile, startLine, so the local match survives the limit while the global list is preserved; ordering is now deterministic (it was undefined before). Delivered across REST (priorityModule), MCP search_identifier, CLI --priority-module, and the UI hook. IT IdentifierPriorityModuleIT (four modules sharing one LOCAL field, PRIO_ZZZ_TARGET sorts last: excluded at limit=2 without the pin, first in the page with it, and the full set still returned at a large limit — i.e. no filtering).

  • 90. DELETE no longer mis-parsed as a table write (2026-07-19, second WGEAGB0S deep API audit, Finding 5). Natural DML DELETE [(label)] deletes the current record of the enclosing READ/FIND loop and names no view, and the EXAMINE … DELETE [FIRST] clause is not a DELETE statement at all — but DB_WRITE captured the token after DELETE as a table, producing phantom FROM (108×, from SQL DELETE FROM <table>), (OLD.)/(*)/label refs (19×), and FIRST (7×) — and, for SQL, lost the real table (it sat after FROM). Fix: DELETE removed from DB_WRITE (STORE/UPDATE keep their view operand); a new DB_DELETE_FROM captures the SQL DELETE FROM <table> real table (mode DELETE); DELETE (label)/DELETE FIRST name no table. The same label-reference shape also affects UPDATE (label) (UPDATE (OLD.)/(HOLD-PRIME.)) and a (*) read operand — a view never starts with (, so DB_WRITE/DB_READ now carry a (?!\\() guard that rejects a parenthesized label reference (no phantom (OLD.)/(*) table) while UPDATE <view> still records the real view. Both parsers. Tests in NaturalParserTest (DELETE FROM, DELETE (OLD.), EXAMINE … DELETE FIRST, UPDATE (OLD.)). Follow-up (2026-07-19, final verification): one last (*) phantom survived, from the SQL SELECT parser, not DB_READ. A SELECT column list can contain a hyphenated Natural field whose last segment is literally FROM (YCOMIROW.DAT-CALC-FROM (*)); FROM_VIEW = \bFROM\s+(\S+) treated the hyphen as a word boundary, captured the trailing (*) as a phantom table, and — since the FROM view binds on the first match only — swallowed the real FROM VERSVW_COMISION clause below. Fix: FROM_VIEW now uses a negative lookbehind (?<![-\\w.])FROM (FROM must be a standalone SQL keyword, not an identifier tail) plus the (?!\\() operand guard. Both parsers. Test NaturalParserTest#sqlSelectColumnEndingInFromDoesNotShadowTheRealFromClause.

  • 89. READ WORK <n> (FILE keyword omitted) recognized as work-file I/O (2026-07-19, second WGEAGB0S deep API audit, Finding 4). The item-84 guard only matched READ WORK FILE; the corpus also writes READ WORK 1 ONCE RECORD … (289× project-wide) without FILE, which still fell through to DB_READ and produced a phantom DB_TABLE 'WORK' (154 accesses). Fix: the WORK [FILE] n guard and the WORKFILE_ACCESS/WORKFILE_DEFINE patterns now treat FILE as optional (guarded on a following digit, so a view whose name merely starts with WORK is unaffected). Both parsers; NaturalParserTest (READ WORK 1 ONCE RECORD).

  • 88. Finalize sweep deletes edgeless DB_TABLE/WORKFILE placeholder nodes (2026-07-19, second WGEAGB0S deep API audit). Companion to item 86: reaping a stale access edge left the placeholder node (sourceFile="", never node-swept) behind with degree 0 — invisible to db-accesses (edge-driven) but still surfacing in search_identifier?type=DB_TABLE and the DB-table inventory (observed: orphaned NUMBER/WORK/FIRST after items 84/87). New finalize step delete-orphaned-placeholder-tables (DELETE_ORPHANED_PLACEHOLDER_TABLES) removes any DB_TABLE/ WORKFILE with no relationships; degree-0 only, so a table any file still accesses (or a Java @Entity's MAPS_TO target) is kept. Covered by StaleTableEdgeReapIT.orphanedPlaceholderTableNodeIsDeleted.

  • 87. FIND NUMBER <view> no longer mis-parsed as a phantom DB_TABLE 'NUMBER' (2026-07-19, second WGEAGB0S deep API audit). FIND NUMBER <view> is a count-only FIND (natural-grammar.md §7.1); NUMBER is a statement keyword, not the accessed view — but the DB_READ regex (in both NaturalParser and NaturalCoarseScanner) captured it as the table name, so db-accesses reported a bogus NUMBER table and lost the real view (e.g. CON-DB2-AUTHPROF-USED-IN-AUTHSPC, NEXT-VIEW). Seen on 11 modules of the WGEAGB0S call tree (YAPRFMN0, YCUACMN0, YENTIMN0, YGARAMN0, YLITEMN0, YMODAMN0, YMTABMN0, YPRODMN0, YRAMOMN0, YTABLMN0, YUGRPMN0; 13 accesses; 182 FIND NUMBER occurrences project-wide). Same class as item 84's READ WORK FILE. Fix: DB_READ now skips the FIND options ALL/FIRST/NUMBER/UNIQUE and the RECORDS/IN/FILE noise words before the view (and allows a variable record-limit (operand), not just a literal). Covered by NaturalParserTest (FIND NUMBER MY_VIEW / FIND NUMBER IN FILE OTHER_VIEW). Item 86 reaps the existing NUMBER edges on the next refresh.

  • 86. Re-ingest reaps stale Natural DB_TABLE/WORKFILE access edges (self-healing) (2026-07-19, follow-up to items 84/85). A statement whose access target changed between parses orphaned its old READS/WRITES edge forever: the target is a placeholder (sourceFile="", never node-swept) and the source node survives, so neither the item-58 node sweep nor the target-keyed edge MERGE reaped it. Seen as the WORK db-access that lingered on USIX052N after the item-84 parser fix (a pre-fix READ WORK FILE→DB_TABLE 'WORK' edge), and it applies to any edited view name too. Fix: before re-merging a re-parsed Natural file's edges, DELETE_STALE_NATURAL_TABLE_ACCESS_EDGES drops its READS/WRITES edges to DB_TABLE/WORKFILE placeholders; the fresh parse (which always re-emits them) re-creates the current ones, unchanged ones round-trip identically. Scoped to language:'natural' source nodes — Java DB edges are resolver-built (RESOLVE_JAVA_DB_ACCESS) and untouched. Covered by StaleTableEdgeReapIT (edit READ VERSVW_OLD → READ VERSVW_NEW + READ WORK FILE, assert old view + WORK gone). (A clean re-ingest of upms already cleared the existing stale WORK; item 86 prevents recurrence on incremental refreshes.)

  • 84. Natural work-file access tracking (workfile-accesses) + READ WORK FILE no longer a phantom DB table (2026-07-19, WGEAGB0S deep API audit, Finding 2). READ WORK FILE n <buf> (sequential flat-file I/O) was matched by the (READ|FIND) <view> DB pattern in both NaturalParser and NaturalCoarseScanner, creating a bogus DB_TABLE 'WORK' READS access (seen on USIX052N in the WGEAGB0S call tree). Both DB_READ patterns now negative-lookahead WORK FILE, and READ/WRITE WORK FILE are modelled as first-class WORKFILE + WORKFILE_ACCESS nodes (analogue of DB_TABLE/DB_ACCESS), keyed by work-file number, with the record buffer on the READS/WRITES edge and the DEFINE WORK FILE n '<name>' physical name on the node. New GET /modules/{name}/workfile-accesses → [{workFile, physicalName, mode, recordBuffers, lineNos}], MCP workfile_accesses, CLI ac workfile-accesses. Covered by NaturalParserTest (READ + WRITE) and full-stack WorkfileAndCopycodeFunctionIT; mcp-api-usage/system-prompt docs updated.

  • 85. /functions items carry sourceFile + viaCopycode (copycode-provided subroutine provenance) (2026-07-19, WGEAGB0S deep API audit, Finding 1). A subroutine pulled into a module via INCLUDE was listed with declaredIn=the including module and the copycode's startLine/endLine but no file, so the lines pointed outside the module's own (shorter) file — e.g. ISIN0019 (59-line file) reported GET-FORMAT at 90–120, which actually live in ISIC0010.cpy. The FUNCTION node already stored the right sourceFile; the MODULE_FUNCTIONS/MODULE_FUNCTIONS_OWN/_INHERITED projections just dropped it. Now InheritedFunction/FunctionInfo expose sourceFile (+ derived viaCopycode = f.sourceFile <> m.sourceFile). Covered by WorkfileAndCopycodeFunctionIT; docs updated.

  • Module callers default is external-only; no MODULE self-loop (2026-07-19, WGEAGB0S deep API audit). Two coupled defects in CypherQueries.callers(scope): (1) the top-level main body's PERFORMs originate at the MODULE node, so scope=internal/default reported the module as its own caller (WGEAGB0S → WGEAGB0S), a self-loop callees never mirrors — fixed with AND caller <> m on the internal scope; (2) the default (scope=null) merged external callers with intra-module PERFORM wiring, so a module's own subroutines showed up as its "callers" — the default now maps to external (genuine incoming CALLNAT/inheritance only). context/digest (both call callers(…, null)) inherit the clean view; scope=internal still exposes function→function PERFORM wiring; callees unchanged. Covered by ModuleCallersSelfLoopIT (fails 2/3 before the fix). MCP callers tool description + REST endpoint doc + mcp-api-usage-ac-implementation.md updated. (Supersedes the earlier "Not a bug (verified): context.callers includes internal PERFORM callers — noisy but accurate" note.) Ego-graph direction=in for WGEAGB0S now returns its dynamic callers W-LST-N0/W-MNT-N0 (item 75). Payload direction is always REQUEST for PDA-derived contracts (a single interface PDA doesn't encode direction) — a documented limitation, not a bug.

    • Follow-up (2026-07-19, WGEAGB0S call-tree closure re-audit): the "external-only" default was only half-fixed. The default view still returned the calling FUNCTION node (a subroutine/method) whenever the call originated inside a subroutine rather than the main body — ModuleCallersSelfLoopIT missed it because its fixture caller CALLNATs from the main body, where the edge already starts at the MODULE. Measured on upms: 46/56 modules in the WGEAGB0S closure had FUNCTION-typed rows in the default callers, 29/56 had duplicate rows, and hot utilities were unusable (CDRANGE default callers = 500 rows / 3 distinct FUNCTION names / 0 module callers). Fixed by making the external branch of CypherQueries.callers(scope) roll every caller up to its owning MODULE via (callerModule:MODULE)-[:CONTAINS*0..1]->(source)-[r]->(m) and collect(DISTINCT …) — symmetric with how callees anchors its source side. Covered by new CallersRollupIT (caller invokes from inside a subroutine, twice → one rolled-up MODULE row with two aggregated sites, no FUNCTION leak); the old ModuleCallersSelfLoopIT, JavaWiringIT, JavaModulesExtendsFilterIT stay green.
  • 100. DEFINE DATA ... USING <member> binds by level-1 record name, not by member (file) name (found and fixed 2026-07-28, WGEAGB0S deep API audit tier 2 — 19 of 379 USING sites (5.0%) in the WGEAGB0S call-tree closure are wrong or unresolved)

    Symptom, two shapes.

    1. Wrong file. GET /api/projects/upms/modules/WGEAGB0S/data-structures reports
      W-WIF-A2  USING  PDA  fieldCount=10  src/manual/parameter_data_area/old/W-WIF-A7.pda
      
      Ground truth: WGEAGB0S.nat:47 says PARAMETER USING W-WIF-A2, i.e. member W-WIF-A2 = new/W-WIF-A2.pda (5 fields: P-LINE-TYPE/LEVEL/KEY/VALUE) — exactly the fields the module uses at 386, 732–735 and 1286. old/W-WIF-A7.pda is a different member whose level-1 record was copy-pasted as 1W-WIF-A2; it holds P-REST-*, which WGEAGB0S reads from W-WIF-A1 (verified: W-WIF-A1 carries P-REST-FLAG, P-REST-LEVEL-IND, P-REST-POINT-KEY, P-LINE-START, P-LINE-END). So both sourceFile and fieldCount are wrong. Same shape: BGEAGFN0/USIX052N USING YFRAMBL0 → old/ZFRAMBL0.lda instead of new/YFRAMBL0.lda, and USIX052N USING YFRAMBL1 → new/ZFRAMBL1.lda instead of old/YFRAMBL1.lda. Not cosmetic: YFRAMBL0/ZFRAMBL0 and YFRAMBL1/ZFRAMBL1 differ in the browse-array bound V (CONST<13> vs CONST<1000>), so an agent reading the wrong twin gets the wrong page size.
    2. Never resolved at all. USING VLAYERLA, USING USIX020L, USING USIX036L report sourceFile: null, area: UNKNOWN, fieldCount: 0 — although all three .lda files exist inside the project root and are ingested. 15 of the 19 affected sites are this shape (10× VLAYERLA in ISI173N0/VMULTDN1/VMULTGN1/VMULTMN1..4/VMULTON1/VMULTSN2/ZINELEM1, 2× USIX020L in ISI173N0/USIX021N, 3× USIX036L in YGARAMN0/YMODAMN0/YPRODMN0). search/identifier shows only the sourceFile: "" placeholder for each.

    Cause, two cooperating places.

    • NaturalParser.parseDataArea (ac-parser-natural/.../NaturalParser.java, ~line 920) emits the member-named wrapper root only for the single-top-level case:
      if (topNames.size() == 1 && !topNames.get(0).equalsIgnoreCase(areaName)) { … }
      
      A data area with several level-1 records therefore gets no node named after its member, so a USING of it can never resolve. VLAYERLA.lda (constants), USIX020L.lda (1#C-HM-FUNC, …) and USIX036L.lda (1#V-ID-TRAN_TAB_FWD, …) are exactly that case. The existing comment claims "multi-top-group areas keep their existing shape (no wrapper, no name collision)" — that decision is what produces shape 2.
    • CypherQueries.resolvePlaceholderTargets (ac-neo4j-store/.../CypherQueries.java, ~line 2650) matches a placeholder purely on (type, name, project). It already excludes module-owned groups (item 74) but has no preference for the node that is the member root of a data-area file, and no tie-break when two files declare the same level-1 name — so USING W-WIF-A2 matches the level-1 node inside W-WIF-A7.pda just as well as the root of W-WIF-A2.pda. In upms 42 level-1 names are declared in more than one data-area file, so this is not a one-off.

    Fix. (a) In parseDataArea, emit the member-named root whenever no level-1 record already carries the member name (!topNames.contains(areaName)), parenting every level-1 record under it — so each data-area file contributes exactly one node named after its member. (b) In resolvePlaceholderTargets, for DATA_STRUCTURE placeholders prefer a real node that is a data-area member root (real.sourceFile ends .lda/.pda/.gda and its basename equals real.name), falling back to the current global name match only when no such candidate exists — so coverage never regresses for USINGs that have no matching file. Characterization tests: NaturalParserTest case for a multi-top-level .lda (must yield a member-named root containing all top-level records), plus a Testcontainers IT with two fixture PDAs declaring the same level-1 name (the USING must bind to the file whose member name matches).

    Done. Both halves landed as described. NaturalParserTest.multiTopLevelDataAreaStillGetsAMemberNamedRoot

    • …dataAreaWhoseTopLevelAlreadyMatchesTheMemberKeepsItsShape cover the parser; DataAreaMemberResolutionIT covers the graph end to end (DAOTHER.pda carries a copy-pasted 1DAMEMBER record, DAMULTI.lda has several level-1 records and none named after the member). parsesLdaWithMultipleTopLevelStructures was updated: its "top-level structures have no CONTAINS parent" assertion encoded exactly the behaviour this item changes.
  • 101. /data-structures/{name}/fields silently unions homonymous definitions from different files (found and fixed 2026-07-28, WGEAGB0S deep API audit)

    Symptom. GET /api/projects/upms/data-structures/W-WIF-A2/fields returns 15 fields — the union of new/W-WIF-A2.pda (5) and old/W-WIF-A7.pda (10) — with no sourceFile on any row and no parameter to disambiguate. An agent cannot tell that it is looking at two unrelated record layouts merged into one, and will happily "verify" a field that the module it is analysing cannot see.

    Cause. CypherQueries.DATA_STRUCTURE_FIELDS collects every same-named definition into canon and UNWINDs it:

    MATCH (s0:AstNode {type: 'DATA_STRUCTURE', name: $name, project: $project})
    WITH collect(s0) AS defs
    WITH [d IN defs WHERE d.sourceFile <> ''] AS withFile, defs
    WITH CASE WHEN size(withFile) > 0 THEN withFile ELSE defs END AS canon
    UNWIND canon AS s
    

    The Javadoc and agent-api-system-prompt.md both claim the opposite ("scoped to the structure's own definition"), so the contract is documented as something the query does not do.

    Fix. Return sourceFile on every row, accept an optional ?sourceFile= filter, and when the parameter is absent and more than one definition exists prefer the member-root definition (item 100) rather than the union. Covered by an IT with two fixture areas declaring the same level-1 name.

    Done. DataStructureField gained sourceFile (so db-tables/{name}/columns, which shares the record, returns it too); REST ?sourceFile=, MCP data_structure_fields(sourceFile) and CLI ac data-structure-fields --source-file delivered together. Covered by DataAreaMemberResolutionIT.dataStructureFieldsAreNotUnionedAcrossHomonymousDefinitions (fails before the fix: the decoy record's fields are merged in) and …CanBePinnedToOneSourceFile.

  • 102. module_data_structures collapses homonyms into one row with an arbitrary file and a blended fieldCount (found and fixed 2026-07-28, WGEAGB0S deep API audit)

    Symptom. The W-WIF-A2 row on WGEAGB0S reads sourceFile = old/W-WIF-A7.pda, fieldCount = 10 — a combination that is wrong even if you accept either candidate as the intended one, because the file and the count can come from different nodes.

    Cause. CypherQueries.MODULE_DATA_STRUCTURES groups by d.name only and then reports

    WITH d.name AS name, rel, max(fc) AS fieldCount,
         head([sf IN collect(d.sourceFile) WHERE sf <> '']) AS sourceFile
    

    head(collect(…)) is order-dependent (nondeterministic across ingests) and max(fc) is taken over all homonyms, so the reported sourceFile and fieldCount need not describe the same definition.

    Fix. Group by (name, sourceFile) and return one row per resolved definition. With item 100 in place this normally collapses back to a single row; where it does not, the agent sees the ambiguity instead of a fabricated blend. Covered by the same IT as item 101.

    Done. A still-unresolved placeholder is reported only when no resolved definition exists for that (name, relationship), so the sourceFile: null / area: UNKNOWN row keeps its meaning. Covered by DataAreaMemberResolutionIT.moduleDataStructuresReportsOneRowPerUsedDefinition (before the fix: [DAMEMBER.pda, DAOTHER.pda] collapsed to one arbitrary row).

  • 103. db-accesses silently truncates at the default limit=50 — a bare array with no truncated signal (found and fixed 2026-07-28, WGEAGB0S deep API audit)

    Symptom. Measured on upms:

    GET /modules/WGEAGB0S/db-accesses?depth=10             → 50 rows
    GET /modules/WGEAGB0S/db-accesses?depth=10&limit=500   → 64 rows
    

    The 14 dropped rows hide 7 tables entirely — VERSVW_MULTILIN, VERSVW_MULTTABL, VERSVW_PRODUCTO, VERSVW_RAMO, VERSVW_TABLAS, VERSVW_USERGRP, VERSVW_USUARIO_NEW. The response is a bare JSON array: no envelope, no total, no truncated flag, so the caller cannot detect the cut. An agent following the documented Natural playbook (db-accesses?depth=3, no limit) therefore gets a silently incomplete DB footprint — the single most damaging failure mode for a reengineering or impact analysis, because it looks like a complete answer.

    Cause. AnalysisResource.dbAccesses applies effectiveLimit(limit), which defaults to 50. sql-statements on the same closure has no such cap (389 rows returned uncapped), so the two endpoints disagree about the same data. workfile-accesses shares the defaulted limit.

    Fix. Remove the default cap on db-accesses/workfile-accesses so they match sql-statements.

    Done — narrower than first proposed. Only the default cap was removed (AnalysisResource.uncappedLimit

    • the same helper in McpQueryTools); the {items, total, truncated} envelope was not added. Rationale: the defect is silent truncation, i.e. a cut the caller never asked for and cannot detect. An explicit limit is neither — the caller chose it — so wrapping the response would change the array contract for every REST/MCP/CLI/UI consumer to signal something already known. If a truncated flag is wanted anyway, it should be a separate item covering all paginated endpoints, not just these two. Covered by IncludeProvenanceAndAccessLimitIT.dbAccessesAreNotSilentlyTruncatedAtFifty (60-table fixture; returns 50 before the fix) and …explicitLimitIsStillHonoured.
  • 104. includedAt is ambiguous for nested copycode includes — it can point into an intermediate file that the response never names (found and fixed 2026-07-28, WGEAGB0S deep API audit)

    Symptom. GET /modules/ISI173N0/callees reports the YFRAMN04 callee as viaCopycode: 'YFRAMC01', includedAt: 27. Line 27 of ISI173N0.nat is a comment. The real chain is

    ISI173N0.nat:232  INCLUDE USIX050C 'YFRAMMC1' …
    USIX050C.cpy:58     INCLUDE &1&                 (parameterised)
    YFRAMMC1.cpy:27       INCLUDE YFRAMC01
    YFRAMC01.cpy:12         CALLNAT 'YFRAMN04'
    

    includedAt: 27 is a line in YFRAMMC1.cpy — an intermediate file that appears nowhere in the response. In the single-level case (WGEAGB0S → ADLML02, includedAt: 673 = the INCLUDE ISIYESNO statement) the same field is a line in the module's own file, so the field silently means two different things and the caller cannot tell which. (The resolution itself is correct — the parameterised 3-level expansion is followed properly; only the provenance reporting loses the chain.)

    Fix. Report the chain rather than one line: make includedAt always the line in the module's own file (232 here) and add a full includePath: [{sourceFile, lineNo}, …] on the sites entries of callees/callers/db-accesses/workfile-accesses. Covered by an IT over a 2-level include fixture.

    Done. CopycodePreprocessor.LineOrigin now carries the host include line unchanged through every nesting level plus the chain as a compact file:line>file:line edge property (Neo4j properties cannot hold a list of maps); CallSite/AccessSite expose it as List<IncludeStep>. sites is a nested response object, so MCP and the CLI pick the new field up without a signature change. Covered by IncludeProvenanceAndAccessLimitIT.nestedIncludeReportsHostLineAndTheWholeChain (before the fix: includedAt = 2, the line inside the intermediate .cpy — the ISI173N0 shape exactly) and …directCallHasNoIncludeChain.

  • 106. A module's resolved USING edges are never reaped, so an old binding survives every refresh (found and fixed 2026-07-28 while re-verifying item 100 against the live upms graph)

    Symptom. After the item-100 fix had landed and upms had been rebuilt and deep-refreshed, WGEAGB0S USING W-WIF-A2 reported two rows — the correct new/W-WIF-A2.pda (5 fields) and the pre-fix old/W-WIF-A7.pda (10 fields). The fix had not failed: the correct edge was created, the wrong one simply was never removed. Measured across the WGEAGB0S closure: the 19 wrong/unresolved USING sites dropped to 5, and all 5 residuals were this shape — a stale edge sitting next to the right one (BGEAGFN0/USIX052N → YFRAMBL0, USIX052N → YFRAMBL1 ×2, WGEAGB0S → W-WIF-A2).

    Cause. Item 86 reaps a re-parsed Natural file's stale access edges, but only those pointing at a placeholder (sourceFile = ""). A USING edge is resolved onto a real DATA_STRUCTURE node by resolvePlaceholderTargets, and from then on nothing deletes it: MERGE only ever adds. So any binding an older ingest made is permanent. This is not specific to item 100 — plain editing of a module's DEFINE DATA ... USING list leaves the dropped data area attached forever, which is the more common everyday case.

    Fix. DELETE_STALE_NATURAL_USING_EDGES, the INCLUDES counterpart of item 86, run in the same spot (right before the fresh edges are merged). Deleting all of a re-parsed file's USING edges is safe because the fresh parse always re-emits every one of them as a placeholder and the same finalize re-resolves them; unchanged ones round-trip identically. Covered by DataAreaMemberResolutionIT.anEditedUsingDropsTheOldBindingOnRefresh (edit USING DAMEMBER → USING DAOTHER, refresh, assert the old binding is gone — fails before the fix). Ordered last in that class because it mutates the fixture.

    Note: the fix prevents recurrence; it does not retro-clean a graph that already carries such edges — those disappear on the next refresh of each affected file, since the reap runs per re-parsed file. upms still carried the 5 residuals at the time of writing and needs one more refresh.

  • 105. A fan-out query that surfaces a data-area file re-ingests it on every call — search/identifier took ~60-75 s per lookup (found 2026-07-28 WGEAGB0S deep API audit, root-caused and fixed the same day)

    Symptom. GET /search/identifier?name=X on upms: ZFRAMBL0 (1 hit) 56.8 s / 62.2 s / 74.7 s across runs, YFRAMBL1 (9 hits) 58.2 s. Both the system prompt and the Natural playbook recommend this endpoint for orientation and for confirming a candidate is a real module; at that latency it cannot be used in a loop, and a batch of lookups exceeds a 2-minute client timeout.

    Cause — not the query. The first suspicion recorded here (a missing/unusable (project, name) index) was wrong, and the measurement that settled it is worth keeping:

    call result
    ?name=NOSUCHNAME12345 (0 hits) 0.90 s
    ?name=ZFRAMBL0 (1 hit, a .lda) 74.7 s

    Identical scan work, 80× the latency — so the cost is not the scan. (The scan is a full label scan: PROFILE shows 2,000,905 DbHits, because the name predicate is wrapped in a CASE that strips a leading Natural sigil and so cannot use ast_node_project_name. But that is ~1 s, and it is the same ~1 s in both rows above. Worth its own item if 1 s ever matters; it is not this bug.)

    The real cost is in withFanoutWarm → DeepIngestCoordinator.ensureDeepMany, which deep-ingests the source files a result set surfaced. FULLY_INGESTED_SOURCE_FILES asks for a MODULE node with ingestDepth = 'FULL' — but a Natural data area (.lda/.pda/.gda) produces only DATA_STRUCTURE nodes, never a MODULE. So a data area can never be reported as fully ingested, is treated as pending on every call, and is re-warmed forever. Server log for one lookup:

    07:11:29,595  Ingesting DATA_STRUCTURE ZFRAMBL0, YFRAMBL0 ... [old/ZFRAMBL0.lda]   <- 40 ms
    07:11:29,635  Finalizing project 'upms' (1 files persisted, scoped deep to 0 modules)
    07:11:29,635  Finalize upms (scoped-deep): 45 steps
    07:12:31,175  <next request>                                   <- ~60 s in finalize
    

    The warm itself is trivial; each one drags a whole-project 45-step finalize behind it and then reports "changed", so the caller re-runs its query on top. A repeat lookup re-ingested the same file again — it never converges.

    Fix. DeepIngestCoordinator.ensureDeepMany filters .lda/.pda/.gda out of the warm candidate set (isWarmable). Nothing is lost: a data area has no deep tier — both ingest tiers run the same parseDataArea — so warming one can never add anything to the graph. Applies to every withFanoutWarm caller (search/identifier, callers, callees, call-tree), not just this endpoint.

    Covered by DataAreaMemberResolutionIT.surfacingADataAreaDoesNotReIngestItOnEveryCall, which asserts the node id is stable across two lookups — ids are regenerated on every re-ingest, so a changed id is the re-ingest. It fails before the fix with two different UUIDs. Timing is deliberately not asserted: on a small fixture the finalize is fast, so a wall-clock bound would not reproduce the bug.

    Note: two further latency questions were surfaced by this and left open on purpose, not folded in: the 2M-DbHit label scan above, and why a scoped 1-file finalize runs all 45 project-wide steps (scoped deep to 0 modules). The second is the larger prize and affects every incremental ingest.

Natural parser robustness

(Item 59 — shared field-declaration tokenizer — completed 2026-07-15; items 61 — comments parsed as CALLNAT targets — 62 — data literals as false MODULE call targets — and 63 — CALLNAT matched inside a string literal — completed 2026-07-16. All moved to x-docs/features.md. No open items remain in this track: the unanchored-CALLNAT family (#61 comments / #62 data literals / #63 string literals) is closed, and the shared lexical helpers now live in NaturalLines + NaturalFieldTokenizer so a fix lands in both ingest tiers at once.)

Agent API / MCP tooling gaps

(Done items 52, 53, 54, 56 moved to x-docs/features.md.)

  • 108. dispatch-table only understands the DECIDE dispatcher, not the dispatch-table idiom — the one the endpoint is named after (found 2026-08-02, upms webservice-layer audit)

    Symptom. Two dispatcher idioms are common in upms, and the endpoint covers one of them.

    GET /modules/WSUBPX0S/dispatch-table  → 25 rows   (DECIDE ON VALUE 'supl_fin' → #W-ACT-PROG := 'WNSUPD0S')
    GET /modules/WPOLIX0S/dispatch-table  → []
    GET /modules/WGARCX0S/dispatch-table  → []        (likewise WACOMX0S, WCLAIX0S, WOBJPX0S)
    

    The five empty ones dispatch through an array built from literals:

    DEFINE SUBROUTINE INIT-OBJECT-TABLE
      ASSIGN #WT-OBJ-PROG (1) = 'WXSPOD0S'
      ASSIGN #WT-OBJ-PROG (2) = 'WXSDAD0S'
      …
    …
    #W-ACT-PROG := #WT-OBJ-PROG (#I-OBJ)
    CALLNAT #W-ACT-PROG …
    

    Every target is a literal in the source; nothing is runtime-dependent. These are 10 of the 116 entries in dynamic-calls/unresolved, all with variable: #W-ACT-PROG.

    Relation to item 83. Item 83 constant-folds MOVE '<lit>' / MOVE '<lit>' TO SUBSTR(var,pos,len) chains. This is the same class — literal assignment feeding a CALLNAT var — but through an indexed array element rather than a scalar, so the fold does not apply. The set of possible targets is exactly the set of literals assigned to any element of that array; which index is live at runtime is not statically known, so the honest model is n candidate edges (as item 82 already allows for a manual override with multiple targets), not one.

    Fix (proposal). Extend the fold to indexed writes: collect ASSIGN <array>(<n>) = '<literal>' for the array feeding CALLNAT, and emit one CALLS edge per literal that names a real ingested module, tagged folded=true + something like viaTable=true. Independently, dispatch-table should report them, keyed by index instead of by guard value — the response already has guardValue/assignedValue, so guardValue: "(3)" or a dedicated index field would fit. Caveat: in upms most of these targets are not ingested (see item 107), so the edges would resolve to nothing there — the value is in no longer silently reporting [].

  • 109. variables/{name}/writes gives the location but not the written value, so resolving a dispatch needs the source anyway (found 2026-08-02, upms webservice-layer audit)

    Symptom. Working around item 108:

    GET /variables/%23WT-OBJ-PROG/writes?module=WPOLIX0S
    → [{"function":"INIT-OBJECT-TABLE","sourceFile":"…/WPOLIX0S.nat","lineNo":772, …}, … 6 rows]
    

    Six correct write sites, and not one of the six assigned values. Answering "what does this dispatcher dispatch to" therefore requires opening the file and reading lines 772/775/777/780/783/785 — the API narrows the search to the right lines and then stops one step short. dispatch-table already returns assignedValue for the DECIDE idiom, so the concept and the field name exist.

    Fix. Add assignedValue (and, where the write is indexed, assignedIndex) to the writes rows, populated when the right-hand side is a literal, null otherwise. Cheap next to item 108 and useful far beyond it: "which constants does this module put into field X" is a routine question in a reengineering pass.

  • 110. No reachability query — "can A reach B?" has to be hand-rolled as ~100 callers calls (found 2026-08-02, upms webservice-layer audit)

    Symptom. The question was "does any W* module reach the commission calculation (ISINCOMI / VCOMIN00 / VVERAN50 / VCOMIN55 / VCOMIN57 / VCOMIN50)?" — a yes/no with a witness path. There is no endpoint for it. call-tree goes downward from one root and returns a flat closure without paths, so it answers "what does A reach", never "who reaches B", and never "how". The workaround was a client-side breadth-first search upward over /callers, six seeds, depth 6: ~100 HTTP round-trips, 101 modules visited, and the path reconstruction written by hand.

    Fix (proposal). GET /modules/{name}/reaches?target=<name>&direction=up|down&depth=N returning {reachable: bool, paths: [[module, …], …], truncated: bool} — or, more useful for this shape of question, a filtered variant of callers/call-tree that accepts a set of targets and returns only the witnesses. In Cypher this is one bounded shortestPath/variable-length match; done client-side it is 100 requests and an easy place to introduce a bug. Note the traversal must be bounded — see item 75 on the CONTAINS cycles.

    Why it matters. "Who can trigger X" is the recurring question in legacy reengineering: which entry points reach a calculation, a table write, an external interface. It is the natural counterpart to call-tree and currently the biggest hole in the query surface for that work.

  • 82. Manual override for unresolvable dynamic CALLNAT targets (human/agent-settable) (proposed + implemented 2026-07-19, from the WGEAGB0S deep-API audit; REST + MCP + ac CLI + Testcontainers ITs green). The dynamic-CALLNAT resolvers cannot follow every name-assembly pattern — e.g. YGEAGGNH.nat:443 CALLNAT #GETSHORT-MODUL where the name is built via MOVE 'YGEAGKEY' TO #GETSHORT-MODUL + MOVE 'GN0' TO SUBSTR(#GETSHORT-MODUL,6,3) → YGEAGGN0 (a real, ingested module). Such a call site leaves an unresolved placeholder (type=MODULE, sourceFile="", name = the variable). Add a REST + MCP + ac-CLI capability to list unresolved dynamic call sites and manually resolve a call site to one or more target modules (multiple targets = deliberate branches, each materialised as a real CALLS callKind=CALLNAT_DYNAMIC edge, provenance manual). Decisions (2026-07-19): callsite key = originFile + lineNo; overrides are persistent (own node type the refresh never deletes) and auto re-applied by an enrichment step after every refresh/deep-refresh; a reset endpoint clears manual overrides (one call site, or all). Related: this is the actionable counterpart to the Bug B consistency gap — callees/digest should also surface the unresolved flag that graph already exposes.

  • 83. Auto-resolver for string-assembled dynamic CALLNAT targets (SUBSTR/MOVE constant-folding) (proposed 2026-07-19, from the WGEAGB0S deep-API audit follow-up; implemented 2026-07-27). Many unresolved dynamic call sites are in fact statically foldable: the target name is built from literals only, e.g. the Y…GNH "GetShort" family (~44 modules) — MOVE 'YxxxxKEY' TO #GETSHORT-MODUL + MOVE 'GN0' TO SUBSTR(#GETSHORT-MODUL,6,3) → YxxxxGN0 — plus similar families (PXFRAA01.ST-PGM across the P…MP0 programs, the YFRAMBCK.cpy:27 group). Add an enricher that constant-folds a chain of MOVE <literal> and MOVE <literal> TO SUBSTR(var,pos,len) assignments feeding a CALLNAT var into the effective target, and resolves the edge automatically when that target is a real ingested module — so item 82's manual override is only needed for genuinely runtime-dependent names, not the deterministic string idioms. Must respect precedence: a manual override (item 82) still wins over an auto-fold. Deliver with characterization ITs (a minimal fixture per idiom) + the usual REST/MCP/CLI-visible effect (fewer unresolved sites; resolved callees/callers). Done: NaturalParser records MOVE '<lit>' TO SUBSTR(var,pos,len) as a WRITES on the base var carrying substrPos/substrLen; new RESOLVE_DYNAMIC_CALLNAT_FOLD (+_SCOPED) folds base literal + ordered overlays (reduce/left/substring) → resolved CALLS edge tagged folded=true; the direct-literal/indirect/cross resolvers now skip partial-slice writes (substrPos IS NULL). Precedence honoured by guarding the fold against :DynamicCallOverride sites plus a delete-folded-overridden-dynamic-callnat step before apply-manual. Characterization ITs in DynamicCallnatFoldIT (fold resolves YABALKEY+GN0@6/3 → YABALGN0; manual override wins); full dynamic-callnat regression 89/89 green. Docs in mcp-api-usage-ac-implementation.md.

  • 26. MCP session reliability — RESOLVED BY REMOVAL (2026-08-04) (investigated 2026-07-07, reproduced 2026-08-02, never fixed). mcp__agenticcode__* calls intermittently — and in the 2026-08-02 session, from the very first call — failed with "the first message from the client must be initialize: tools/call", forcing every playbook step to be re-expressed as curl. The evidence pointed at the MCP client's reconnect handling rather than a server-side bug this codebase's config could fix, and the REST endpoints answered normally throughout. Decision 2026-08-04: the MCP server surface was removed entirely rather than debugged — see "MCP surface removed" in x-docs/features.md. REST + ac CLI are now the only access paths; all 40 former tools had a REST twin, so no capability was lost.