Skip to content

fix(workflows): the recursive BOM templates run through the entity core - #69

Merged
sauterbe merged 2 commits into
mainfrom
fix/bom-templates-through-the-core
Aug 7, 2026
Merged

fix(workflows): the recursive BOM templates run through the entity core#69
sauterbe merged 2 commits into
mainfrom
fix/bom-templates-through-the-core

Conversation

@sauterbe

@sauterbe sauterbe commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

These two were the last shipped content still reaching Xentral's HTTP API directly — and that is what kept WORKFLOW_FORBID_RAW_ERP disarmed in agent-os #100. Arming it would have made init_from_template fail 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 one Product.read serves both branches and n_parts + n_artikel collapse into n_prod.

The leaf branch loses its extra read too: PurchasePrice rows carry isStandardSupplier themselves, so nothing needs to fetch the product just to compare supplier ids.

before after
bauteile.data produktDetail.bom.items
Bauteil.amount Bauteil.quantity
artikel.data.calculatedPurchasePrice.price.amount produktDetail.prices.purchase.amount
produkte.data produkte (ADR-0002: a list node's output IS the array)

The read node is produktDetail, not produkt

The driver loop already binds Produkt. 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. That is the verification step here, not the JSON diff — the JSON looked perfect.

source: "calculated" is load-bearing

It 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.source read-only, but map_write accepts 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:

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

Plus: validate_graph clean (no errors, no warnings) and check_workflow clean 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 a BillOfMaterials entity that does not exist, filters Product on isBom which is not filterable, and reads purchasePrice / writes calculatedPurchasePrice as flat fields the model does not have. It passes the raw-ERP gate because it is already business-entity shaped — it is simply wrong. Worth its own PR.

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.
@sauterbe
sauterbe merged commit c6046a7 into main Aug 7, 2026
1 check passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant