Skip to content

fix(workflows): rebuild the library templates against what the core actually has - #72

Merged
sauterbe merged 5 commits into
mainfrom
fix/library-templates-against-the-core
Aug 7, 2026
Merged

fix(workflows): rebuild the library templates against what the core actually has#72
sauterbe merged 5 commits into
mainfrom
fix/library-templates-against-the-core

Conversation

@sauterbe

@sauterbe sauterbe commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Four templates rebuilt. They were not "broken filters" — they were written against an API that does not exist.

All 12 workflow templates were created on 2026-06-27 in one commit ("Populate library catalogue (246 items)"), without ever running against a core. init_from_template copies a graph verbatim, so a fictional template becomes a fictional workflow on a customer instance.

The conceptual defect under most of it

They model state transitions as field writesbody: {status: "cancelled"}, {status: "released"}. status is readOnly on every document. Cancelling, releasing and invoicing are actions. Almost everything else follows from that.

What changed

auto_release_paid_ordersstatus: "paid_awaiting_release" is not one of the options, and the graph wrote status twice. Now: status=draft server-side, "paid" read off trafficLights (payment.status is null on a list), release through the release step. Plus a guard that did not exist: an order still carrying a hold is not released — the old version checked a stockAvailable field that does not exist.

prepayment_order_cleanup — the dangerous one. Four flat keys, none of them filters, then a loop that cancelled everything whose status was not already cancelled. Its only guard could not protect against a row that was never supposed to be in the list. Now: status=draft + dates.issued lessThan today(-7) server-side, payment method resolved through the PaymentMethod catalogue (payment.method comes back as {id, href} with no name), unpaid read off trafficLights, cancelling through the cancel step.

daily_opos_digestpaymentStatus: "open" was wrong twice: the key is payment.status, the options are unpaid | partiallyPaid | paid.

return_to_credit_noteSalesReturn answers 404; the entity is Return. creditNoteCreated is not a field — resolution.creditNote decides it on the record. The credit note is not assembled from customerId/positions (neither exists): Return carries createCreditNote. Filters on progress, not status.

invoice_after_shipmentinvoiced is not a filter; documents.salesInvoices is. The invoice comes from DeliveryNote's createSalesInvoice.

Measured on mvp

before after
prepayment cleanup selects the entire order book 100 → 3
auto_release releasable 2 of 50 (paid and unheld)
Return progress=checked 32, all settled 7, all checked
DeliveryNote status=shipped 1, and it read delivered 50, all sent
SalesInvoice payment.status=unpaid unfiltered 50, all unpaid

validate_graph, the render and check_workflow are clean in both locales for all four.

Requires

A CORES_VERSION carrying agent-cores #89return_to_credit_note and invoice_after_shipment filter on states the core only resolves correctly after it. That is exactly why those two waited.

Not in here

opos_dunning_escalation is not buildable. dunning.level is readOnly and the remind action carries the wish "Dunning has no public API". The template promises something the platform cannot do — it should be withdrawn, not repaired.

low_stock_reorder_proposal needs a decision: stock is detail-only, so it is always null on a product list. Either one StockLevel call per article, or withdraw it.

…tered

Every predicate and the write itself were fiction against the core:

- `paymentMethod`, `paymentStatus` and `maxAgeDays` are not filters on
  SalesOrder, and `status: "open"` is not one of its options (draft / confirmed /
  fulfilled / closed / cancelled). In the flat shape none of them filtered
  anything, so the list came back as the WHOLE order book.
- `status` is `access: readOnly`. The old `body: {status: "cancelled"}` could
  never have cancelled anything — cancelling is the `cancel` process step.
- the only guard asked whether the order was ALREADY cancelled, which does not
  protect against a row that was never supposed to be in the list.

Rebuilt against what the core has, all measured on mvp:

- `status` and `dates.issued` filter server-side in the bracketed shape. The
  operator is `lessThan`, not `lt` — upstream answers 400 and names the eight it
  accepts. A live run caught that; the static check could not.
- `payment.method` comes back as `{id, href}` with no name, so the method is
  resolved through the PaymentMethod catalogue (one call, `Vorkasse` -> paym_21).
- `payment.status` is null on a list, so "still unpaid" is read off
  `trafficLights`, where it is present.
- cancelling goes through the `cancel` process step.

Measured effect on mvp: the server-side filters return 100 orders, none with the
wrong status and none newer than the cutoff, and the guard narrows those to 3 —
prepayment and unpaid. The old version would have looped the entire order book.
Same family of defect as prepayment_order_cleanup: invented filter keys and
values, and — the conceptual one — a state transition modelled as a field write.
`status` is readOnly on every document; releasing is an ACTION.

auto_release_paid_orders
  `status: "paid_awaiting_release"` is not one of the options (draft /
  confirmed / fulfilled / closed / cancelled), and the old graph WROTE `status`
  twice, which could never have worked. "Paid" is not filterable on SalesOrder
  and `payment.status` is null on a list, so it is read off `trafficLights`,
  where it is present. Releasing now goes through the `release` step, and a
  second guard refuses to release an order that still carries a hold — the old
  version checked a `stockAvailable` field that does not exist.

daily_opos_digest
  `paymentStatus: "open"` was wrong twice: the key is `payment.status` and its
  options are unpaid / partiallyPaid / paid.

Both verified against mvp: `SalesOrder status=draft` returns 50 rows, all draft;
`SalesInvoice payment.status=unpaid` returns 50, all unpaid; and of the 50
orders, 2 are paid and unheld — the ones the workflow would release.

Two further rebuilds are finished but NOT shipped here, because the core cannot
serve them yet (see the PR): filtering DeliveryNote by `shipped` and Return by
`checked` returns records in a different state.
Both were rebuilt earlier and held back because the core returned records in a
different state than the filter asked for. agent-cores #89 fixed that, so they
ship now.

return_to_credit_note
  `SalesReturn` does not exist — a read of it answers 404. The entity is
  `Return`. It filtered `creditNoteCreated`, which is not a field; the readable
  `resolution.creditNote` decides that on the record instead. And the credit note
  does not have to be assembled from `customerId`/`positions` (neither exists on
  the model): `Return` carries a `createCreditNote` action.
  The lifecycle filter is `progress`, not `status` — `status` merges in
  `cancelled` and cannot answer `checked` (agent-cores #89).

invoice_after_shipment
  `invoiced` is not a filter; `documents.salesInvoices` is readable, so "not yet
  invoiced" is decided on the record. The invoice is not assembled by hand
  either — DeliveryNote carries `createSalesInvoice`.

Both now do the same thing the whole set was missing: they ask the ERP to
perform the transition instead of writing a status field that is readOnly.

Server-side filters verified against mvp: `Return progress=checked` returns 7
records, all checked (it used to return 32 that were all settled), and
`DeliveryNote status=shipped` now resolves to upstream `sent` and returns 50, all
sent (it used to return 1, and that one read as delivered).

Requires a CORES_VERSION carrying agent-cores #89. Without it the two filters
return the wrong state — which is exactly why these two waited.
`belowMinStock: true` is not a field on Product. In the flat shape it filtered
nothing, so the workflow proposed purchase orders for the whole catalogue.

The real constraint is that `stock` is a detail-only section: a product LIST
always returns it null, so "below minimum" cannot be decided from a list at all.
What IS on the list is `logistics.minimumStockQuantity` and `suppliers` — so the
loop spends a `StockLevel` call only on articles that carry a minimum.

Measured on mvp: 100 active products in one call, 10 of them carry a minimum, so
the per-article call is paid 10 times rather than 100. All 10 are below minimum
and NONE has a default supplier, so the run ends with zero purchase orders and
ten records on the "below minimum, no supplier" branch.

That branch is the point. A purchase order needs a supplier; without one the old
shape would have produced an order with no recipient, or dropped the article
silently. Now it is visible.

Also fixed while building: the loop referenced `{{ artikel_liste }}`, and the
renderer derives `artikelListe` — camelCase, not snake_case. The list resolved to
nothing, the loop skipped, and its whole body (including the StockLevel read)
never rendered. The renderer says so itself in the skip message; the static
validator reports it as `loop_items_unresolved`.
@sauterbe

sauterbe commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

low_stock_reorder_proposal nachgezogen — jetzt sind fünf Vorlagen in diesem PR.

belowMinStock ist kein Feld auf Product. Die eigentliche Einschränkung ist aber eine andere: stock ist ein detail-only-Abschnitt, auf einer Produktliste also immer null. „Unter Mindestbestand" lässt sich aus einer Liste gar nicht entscheiden.

Was auf der Liste steht, ist logistics.minimumStockQuantity und suppliers. Deshalb zahlt die Schleife den StockLevel-Aufruf nur für Artikel, die überhaupt einen Mindestbestand haben.

Live auf mvp gemessen:

100 aktive Produkte          -> 1 Aufruf
 10 mit Mindestbestand       -> 10 StockLevel-Aufrufe (nicht 100)
 10 unter Mindestbestand
 10 davon ohne Lieferant     -> eigener Zweig, 0 Bestellungen

Der letzte Zweig ist der Punkt: eine Bestellung braucht einen Lieferanten. Ohne ihn hätte die alte Form eine Bestellung ohne Empfänger erzeugt oder den Artikel still fallengelassen. Jetzt ist er sichtbar.

Beim Bauen noch eine Namensfalle gefunden: die Schleife referenzierte {{ artikel_liste }}, der Renderer leitet aber artikelListe ab — camelCase, nicht snake_case. Die Liste löste zu nichts auf, die Schleife wurde übersprungen, und ihr ganzer Körper inklusive des StockLevel-Lesens rendete nie. Der Renderer sagt es selbst in der skip-Meldung, der Validator meldet loop_items_unresolved.

I had recommended withdrawing this template, on the grounds that dunning has no
public API. That was wrong — `dunningSettings` sits in `UpdateInvoiceData` and
the level is writable (agent-cores #90). The old version was still fiction: it
filtered `paymentStatus` and `minOverdueDays`, neither of which is a filter, and
wrote `body: {dunningLevel: 3}`, which is not a field.

Rebuilt against what the core has:

- `payment.status = unpaid` plus `dates.issued lessThan today(-14)` server-side.
- Due date is computed from the document date and the invoice's own payment
  terms, because `payment.dueDate` comes back EMPTY on a list — measured, null on
  all 100 unpaid invoices on mvp. `dates.issued` and `payment.terms.dueDays` are
  both there.
- The level walks the upstream ladder (paymentReminder1 -> reminder1..3 ->
  debtCollection -> lossOfReceivables) and stops at the top instead of writing
  past it.
- A dunning block is honoured, and every skip says why in the run log.

The write is `body: {dunning: <ref>}` — one whole object bound at the TOP level,
because a `{mode: ref}` nested deeper inside a body is not resolved and would
travel to the ERP verbatim.

The date maths uses the runtime's `days_since` helper: a plain
`from datetime import ...` in a code box is refused by the AST allowlist
(`unsafe_code`), which the static validator caught before this ever ran.

Measured on mvp: 100 unpaid invoices issued before the cutoff, all on
`reminder3`, all overdue — every one would escalate to `debtCollection`, none is
blocked or already at the top.

Both descriptions now say what the workflow does NOT do: it records the level, it
does not SEND a reminder. No endpoint does.

Needs a CORES_VERSION carrying agent-cores #90.
@sauterbe
sauterbe merged commit b26fa3b into main Aug 7, 2026
1 check passed
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