intent: a create-from's items: has a source-row rule - where: + refuse: (#7091) - #7164
Merged
Conversation
…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>
This was referenced Sep 8, 2026
| return; | ||
| } | ||
| Target itemStatus = statusOf(itemEntity); | ||
| for (Object node : asList(items.get("where"))) { |
This was referenced Sep 9, 2026
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>
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.
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 —GeneratesItemsIntentcarriedfrom/to/map/defaultsand nothing else, andchecks:lives on entities so it cannot gate a generate's item selection either.base-timesheets
invoice-from-timesheettherefore billed everyEmployeeTimesheetof the project-month:totalHoursnull), 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 ruleThe same
{ field, op, value }triples aschedules[].wherecarries —ScheduleSupport.conditionChainis the shared renderer, extracted out ofcriteriaExpression— pushed into the veryCriteriathat 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: EntityStatusrelation 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 readingAn 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 awhere: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 inGlueGenerator.bindGenerate, so a.gluewritten before them renders the unfiltered clone loop it always had. Unlike a schedule's query, whose source may be a cross-model row or anaudit:column this model cannot see, the rule reads a LOCAL row being cloned — sofield: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 400Stay 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).-P unit-testsreactor green;formatter:validategreen;-P releasejavadoc clean on both touched modules.Docs
engine-intent/CLAUDE.md,.claude/docs/intent-layer.md,intent-assistant-guide.mdFixes #7091
🤖 Generated with Claude Code