fix(workflows): the recursive BOM templates run through the entity core - #69
Merged
Conversation
The workflow skill taught the opposite of the policy it is meant to support. Its source-preference ladder read "No matching business entity? → use a `xentral-api` node against the raw Xentral endpoint" — step two, before the author has any reason to doubt it. `graph-and-nodes.md` documented `xentral-api` as a first-class node and named `xentral_op` / `xentral_request` as in-scope code helpers. An agent that follows the ladder now writes a graph that is rejected at save. Prose that argues with the gate is worse than no prose: it costs a build cycle and teaches the author to distrust the tool. So the ladder is business-entity → integration-action → http-request (never at Xentral), `xentral-api` is documented as unavailable for authoring, and the code helper list keeps only `business_entity_op`. The replacement text also says what to do at the wall, because a ban without a route out is just a wall: state the gap and stop. It is reported automatically so the core can gain the capability — the only fix that helps the next tenant too, rather than one customer's workaround.
These two were the last shipped content still reaching Xentral's HTTP API
directly, and that is what kept `WORKFLOW_FORBID_RAW_ERP` disarmed: arming it
would have made `init_from_template` fail on templates we publish ourselves.
More than a node-type swap. The old graph read the components
(`/products/{id}/parts`) and the product itself (`/products/{id}`) as two calls
on two branches. The core carries the bill of materials ON the product record —
`bom.items`, filled on a single read — so ONE `Product.read` now serves both
branches and `n_parts` + `n_artikel` collapse into `n_prod`. The leaf branch
loses its extra product read too: `PurchasePrice` rows carry
`isStandardSupplier` themselves, so nothing has to look up the product's
standard supplier to compare ids.
Every reference downstream moved with it: `bauteile.data` → `produktDetail.bom.items`,
`Bauteil.amount` → `Bauteil.quantity`, `artikel.data.calculatedPurchasePrice.price.amount`
→ `produktDetail.prices.purchase.amount`, and the driver loop over
`produkte.data` → `produkte` (ADR-0002: a `list` node's output IS the array).
**The read node is `produktDetail`, not `produkt`, on purpose.** The driver loop
already binds `Produkt`, and rendering the first attempt showed the renderer
silently resolving the collision by renaming the read to `produkt2` — while the
condition still read `produkt` and the component loop bound to the DRIVER's
items instead of the bill of materials. Both branches would have run green on
the wrong data. Caught by reading the generated Python, which is why that is the
verification step and not the JSON diff.
**`source: "calculated"` is load-bearing.** It is what makes the core emit
`hasCalculatedPurchasePrice: true`; without it the same number lands as a MANUAL
price. Measured, not read off the schema — the schema marks `prices.purchase.source`
read-only, but `map_write` accepts it and only that value sets the flag. Reported
separately; the templates depend on the measured behaviour.
Also fixed, because the code and the prose disagreed: on a tie at the smallest
tier quantity the leaf now takes the CHEAPER row, which is what the description
has always promised. Both the old and the first migrated version took whichever
row the API returned first.
Verified by running both rendered workflows against entity fixtures — a
two-level bill (2 × prd_2 + 3 × prd_3):
* supplier-priced: each leaf resolves to 5.00 — the standard supplier's base
tier, NOT the cheaper 1.00 row of an alternative supplier and not the 10-tier
4.00 — so the parent writes `{"prices": {"purchase": {"amount": "25.0",
"currency": "EUR", "source": "calculated"}}}`
* calculated-EK: leaves resolve to their stored 7.00 → `"35.0"`
* no write is traced for either leaf, so "purchased articles are only read,
never modified" still holds
The prose is reconciled with what the templates now do, including the runtime
budget: one core read fetches stock, bill of materials, sale prices and
properties together. They are parallel, but it is several calls against the rate
limit instead of one, so a run should be budgeted at ~50–100 articles rather
than ~100–200.
This was referenced Aug 7, 2026
sauterbe
added a commit
that referenced
this pull request
Aug 7, 2026
…cles (#71) Regression I shipped in #69. Migrating the template off the raw API replaced a path-scoped call — `/products/{id}/purchasePrices`, correct by construction — with `PurchasePrice.list` filtered by `product`. In the flat shape `query: {product: <id>}`, which is what the skill documentation teaches, that filter does nothing: a core reads a filter only as `filter[i][key|op|value]`, and Xentral answers 200 with the UNFILTERED collection for a query param it does not know. So the leaf branch took its "standard supplier base tier" from a pool of arbitrary articles and wrote that as the component's purchase price. No error, no empty result — a confidently wrong number. Measured on mvp, same call, both shapes: flat product=prd_8 -> 50 rows across 11 different products (exactly 1 of them actually prd_8) bracketed product=prd_8 -> 1 row, prd_8 bracketed product=prd_62004 -> 0 rows (that article has no purchase price) My fixture tests did not catch it because they hand the node an already-filtered list — they pin the arithmetic, not whether the filter reaches the wire. That is the same gap I have been flagging in other people's work. The bracketed shape works today and stays correct after agent-os `fix/flat-list-filters-never-filtered`, which makes the flat shape filter too; this does not depend on that landing.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
These two were the last shipped content still reaching Xentral's HTTP API directly — and that is what kept
WORKFLOW_FORBID_RAW_ERPdisarmed in agent-os #100. Arming it would have madeinit_from_templatefail on templates we publish ourselves.With this merged and vendored, the armed gate rejects 0 of the shipped templates.
Not just a node-type swap
The old graph read the components (
/products/{id}/parts) and the product itself (/products/{id}) as two calls on two branches. The core carries the bill of materials on the product record —bom.items, filled on a single read — so oneProduct.readserves both branches andn_parts+n_artikelcollapse inton_prod.The leaf branch loses its extra read too:
PurchasePricerows carryisStandardSupplierthemselves, so nothing needs to fetch the product just to compare supplier ids.bauteile.dataproduktDetail.bom.itemsBauteil.amountBauteil.quantityartikel.data.calculatedPurchasePrice.price.amountproduktDetail.prices.purchase.amountprodukte.dataprodukte(ADR-0002: alistnode's output IS the array)The read node is
produktDetail, notproduktThe driver loop already binds
Produkt. Rendering the first attempt showed the renderer silently resolving the collision by renaming the read toprodukt2— while the condition still readprodukt, and the component loop bound to the driver's items instead of the bill of materials. Both branches would have run green on the wrong data.Caught by reading the generated Python. That is the verification step here, not the JSON diff — the JSON looked perfect.
source: "calculated"is load-bearingIt is what makes the core emit
hasCalculatedPurchasePrice: true. Without it the same number lands as a manual price — same figure, different meaning to the ERP.Measured, not read off the schema: the schema marks
prices.purchase.sourceread-only, butmap_writeaccepts it and only that value sets the flag. Filed separately as a core finding; these templates depend on the measured behaviour.One behaviour fix
On a tie at the smallest tier quantity the leaf now takes the cheaper row — which is what the description has always promised ("der günstigste Basis-Staffelpreis"). Both the old version and my first migrated one took whichever row the API returned first, so the result depended on upstream ordering.
Verification
Both rendered workflows executed against entity fixtures — a two-level bill, 2 ×
prd_2+ 3 ×prd_3:5.00— the standard supplier's base tier, not the cheaper1.00row of an alternative supplier and not the 10-tier4.00. Parent writes{"prices": {"purchase": {"amount": "25.0", "currency": "EUR", "source": "calculated"}}}.7.00→"35.0".Plus:
validate_graphclean (no errors, no warnings) andcheck_workflowclean in both locales for both templates.Prose reconciled
Including the runtime budget, which genuinely changed: one core read fetches stock, bill of materials, sale prices and properties together. They run in parallel, but it is several calls against the rate limit instead of one — so a run is now budgeted at ~50–100 articles rather than ~100–200. Stated rather than quietly inherited.
Follow-up, not in here
bom_cost_rollup.json(the non-recursive sibling) is fiction against this core: it lists aBillOfMaterialsentity that does not exist, filtersProductonisBomwhich is not filterable, and readspurchasePrice/ writescalculatedPurchasePriceas flat fields the model does not have. It passes the raw-ERP gate because it is alreadybusiness-entityshaped — it is simply wrong. Worth its own PR.