Skip to content

intent: a create-from's items: has a source-row rule - where: + refuse: (#7091) - #7164

Merged
delchev merged 1 commit into
masterfrom
issue-7091-generates-items-where
Sep 8, 2026
Merged

intent: a create-from's items: has a source-row rule - where: + refuse: (#7091)#7164
delchev merged 1 commit into
masterfrom
issue-7091-generates-items-where

Conversation

@delchev

@delchev delchev commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

The mirror items: block of a create-from cloned every row of the source document into a target line, and nothing could say which rows qualified — GeneratesItemsIntent carried from/to/map/defaults and nothing else, and checks: lives on entities so it cannot gate a generate's item selection either.

base-timesheets invoice-from-timesheet therefore billed every EmployeeTimesheet of the project-month:

  • a DRAFT / SUBMITTED / REJECTED member timesheet at the same footing as an APPROVED one — hours nobody has approved on the customer's invoice;
  • an EMPTY one (totalHours null), whose mapped quantity the target refuses, blocked the whole Generate until someone deleted the row by hand — one person who never filed a day stopped the billing of everyone who did.

"Invoice the approved month" is the one flow a billing clerk runs, so the module could either bill unapproved hours or not bill at all. The gap is fleet-wide: proforma → invoice, quotation → order, order → invoice.

where: — the source-row rule

    items:
      from: EmployeeTimesheet
      to: SalesInvoiceItem
      where:
        - { field: Status,     op: eq, value: APPROVED }   # only approved member timesheets
        - { field: totalHours, op: gt, value: 0 }          # an empty one is not a line
      map: { Name: employeeName, Quantity: totalHours, Price: rate }

The same { field, op, value } triples a schedules[].where carries — ScheduleSupport.conditionChain is the shared renderer, extracted out of criteriaExpression — pushed into the very Criteria that already selects the source's rows by their master foreign key, so an unqualified row is never loaded. A moment value (CURRENT_DATE, CURRENT_TIMESTAMP-PT30M) is resolved against the clock of the run that generates.

A condition naming the source item's own function: EntityStatus relation may use the seeded status NAME, resolved on the item's nomenclature and never the document header's — resolving against the header's lifecycle would take an id out of the wrong nomenclature and filter on it silently. Only that one condition is a candidate, exactly as a register lookup's static filter is.

refuse: — the other reading

      refuse: "Member timesheet is not approved"

An unqualified row stops the whole create-from with the authored message plus the keys of the offending rows, instead of being left out. Which of the two a document means is a property of the document, not of the platform — a rejected timesheet quietly dropped from an invoice and a rejected timesheet quietly billed are both wrong, for different months — so skipping is the default, and refuse: without a where: is refused at parse (with no conditions no row is ever unqualified).

A rule that qualifies no row refuses too

Rather than committing a header with no lines at all: that is the harder of the two failures to notice, since the document exists, counts as the period's billing, and is empty.

Scope

Scoped to a where-declaring block — a rule-less items block regenerates byte-identical output, and the descriptor's two new keys default to the empty string in GlueGenerator.bindGenerate, so a .glue written before them renders the unfiltered clone loop it always had. Unlike a schedule's query, whose source may be a cross-model row or an audit: column this model cannot see, the rule reads a LOCAL row being cloned — so field: is checked against the item source's own fields and to-one relations at parse.

Tests

  • GlueGeneratesItemsWhereTest (new, 8 cases): the rendered Criteria chain incl. the resolved status name and a moment value, the carried refusal, the rule-less descriptor unchanged, and each parse refusal.
  • IntentEmissionCoverageIT: the emitted query and both refusals, then end to end — a mixed stay bills exactly its past nights, an all-unqualified one answers 400 Stay night carries no amount (StayNight [10, 11, 12]), and the same source under the skip rule answers 400 for the lineless document. Green locally (185 s).
  • Full -P unit-tests reactor green; formatter:validate green; -P release javadoc clean on both touched modules.

Docs

Fixes #7091

🤖 Generated with Claude Code

…fuse:` (#7091)

The mirror `items:` block cloned EVERY row of the source document into a
target line, and nothing could say which rows qualified: `GeneratesItemsIntent`
carried `from`/`to`/`map`/`defaults` and `checks:` lives on entities, so it
cannot gate a generate's item selection either. base-timesheets therefore
billed every EmployeeTimesheet of the project-month - a DRAFT / SUBMITTED /
REJECTED one at the same footing as an APPROVED one, and an EMPTY one (whose
mapped quantity the target refuses) blocked the whole Generate until someone
deleted the row by hand. "Invoice the approved month" is the one flow a billing
clerk runs, and the module could either bill unapproved hours or not bill at
all; the gap is fleet-wide (proforma -> invoice, quotation -> order, order ->
invoice).

`where:` is the rule - the same field/op/value triples a `schedules[].where`
carries, incl. a moment value resolved against the clock of the run - pushed
into the very Criteria that already selects the source's rows by their master
foreign key, so an unqualified row is never loaded. A condition naming the
source ITEM's own `function: EntityStatus` relation may use the seeded status
name, resolved on the item's nomenclature and never the header's.

`refuse:` declares the other reading: an unqualified row stops the whole
create-from with the authored message plus the keys of the offending rows,
instead of being left out. Dropping a rejected line silently and billing it
silently are both wrong, for different months, so skipping is the default and
`refuse:` without a `where:` is refused at parse. A rule that qualifies NO row
refuses too, rather than committing a header with no lines - the harder failure
to notice, the document existing and counting as the period's billing.

Scoped to a `where`-declaring block, so a rule-less items block regenerates
byte-identical output; the descriptor's two keys default to the empty string in
GlueGenerator.bindGenerate, so a .glue written before them renders the
unfiltered clone loop it always had.

Fixes #7091

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
return;
}
Target itemStatus = statusOf(itemEntity);
for (Object node : asList(items.get("where"))) {
@delchev
delchev merged commit 78ea83e into master Sep 8, 2026
10 checks passed
@delchev
delchev deleted the issue-7091-generates-items-where branch September 8, 2026 08:15
delchev added a commit that referenced this pull request Sep 10, 2026
)

#7164 taught `StatusSymbolResolver` to resolve a seeded status NAME in a
create-from's `items: where:`. The construct that shape was modelled on - a
`schedules[].where` row query, "the same { field, op, value } triples" - had no
rewrite, and nothing refused a non-numeric value against a `function:
EntityStatus` relation there either. So

    schedules:
      - name: dunning
        cron: "0 0 8 * * ?"
        entity: SalesInvoice
        where:
          - { field: Status, op: eq, value: OVERDUE }

parsed, generated `.eq("Status", "OVERDUE")` into the job, and matched nothing
for as long as the schedule kept ticking - the exact silent failure #6645
removed for every other status site, at the site a status guard is written most
often (dunning, staleness sweeps, month-end runs), where replacing a positional
id with a name is most tempting.

`rewriteSchedules` resolves the condition naming the source's own status
relation, on that source's nomenclature. It shares `rewriteConditions` with the
items rule, so the two constructs cannot drift apart again by having one of
them taught and the other not. Same-model source only: a cross-model source
(`model: <uses alias>`) is not in this file's entities, so neither its
nomenclature nor even WHICH of its conditions names its status is knowable
here, and it keeps the numeric seed id exactly as every other cross-model
status site does.

The second half is the invariant at the consuming site, independent of the
resolver's site list: a `where` condition on the queried entity's own
`function: EntityStatus` relation must carry an integer by the time validation
runs (`validateWhereStatusValue`, on both where sites). A value no status can
equal - a stage word, a blank, a moment token - is refused by name instead of
rendering into a query that matches nothing. That check alone catches the drift
this issue is about: with the rewrite disabled, it reports the authored name.

Covered by `StatusSymbolIntentTest` (the schedule query joins the every-site
case; a mistyped name is refused) and `GlueSchedulesTest` (the rendered
criteria - `Criteria.create().eq("Status", 3).lt("DueOn", ...)`; a value that is
no status refused). Documented in the `schedules` section of the assistant
guide and in the site list of "Statuses may be named, not numbered".

Fixes #7251

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
delchev added a commit that referenced this pull request Sep 10, 2026
…fused, not left unresolved (#7225) (#7300)

A create-from's `items: where:` may name the item's status by its seeded
name (#7164). When the source is cross-model (`fromUses:`) the item's
nomenclature is seeded in the owner model, so the parser's resolver could
not rewrite the name - and returned silently, leaving `.eq("Status",
"APPROVED")` in the generated query: a rule that matched nothing on every
click. Worse, a LOCAL entity sharing the item's name lent its own
nomenclature to the rewrite, an id positional in the wrong seed list.

The resolver now leaves a cross-model source's items rule alone entirely,
and GlueIntentGenerator - the one place the owner .model tells WHICH
condition names the status (its DOCUMENT_STATUS property) - refuses a
non-numeric value there with the message every other cross-model status
site gives: a cross-model status must be referenced by its numeric seed
id. The item source is resolved once instead of twice.

The refuse:/guard/check literal escaping half of the issue was already
shipped by #7241 (#7262) and is not touched here.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants