Skip to content

feat(intent): a notify block can attach a parameterized report render (#6931) - #6934

Merged
delchev merged 1 commit into
masterfrom
feat/notify-attach-report-6931
Aug 24, 2026
Merged

feat(intent): a notify block can attach a parameterized report render (#6931)#6934
delchev merged 1 commit into
masterfrom
feat/notify-attach-report-6931

Conversation

@delchev

@delchev delchev commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Closes #6931.

attach: print is deliberately document-only, so the standard AR customer statement - a period's rows per customer, mailed monthly or on demand - had no way out of the system, while its little brother the per-invoice dunning reminder already had one. With report parameters: generalized to any report (#6911) the statement is expressible as a report definition; only the delivery was missing.

reports:
  - name: CustomerStatement
    source: SalesInvoice
    dimensions: [issuedOn]
    measures: ["sum(total)"]
    parameters:
      - { name: fromDate, target: issuedOn, op: ge }
      - { name: toDate, target: issuedOn, op: le }
      - { name: customer, target: Customer.name, op: eq, initial: "-" }

schedules:
  - name: monthly-statements
    cron: "0 0 7 1 * ?"
    entity: Customer
    where: [{ field: openBalance, op: gt, value: 0 }]
    notify:
      to: email
      subject: "Your statement"
      body: "Please find attached your account statement."
      attach:
        report: CustomerStatement
        bind: { customer: name, fromDate: periodStart, toDate: periodEnd }

bind: maps a report parameter to a field or a one-hop relation.field of the record the message is about (the ROW inside a forEach), resolved through the same resolver a {placeholder} uses - so a relation the bindings read shares the local the message text already declares. Available at all four notify call sites; language: / languageFrom: / fileName: apply as they do to a document, and absent a pattern the name is <Report> <record>.pdf.

Four decisions, each of them a way the mail is quietly WRONG rather than broken

  • attach is now polymorphic, so NotificationIntent.attach is typed Object (a String field makes plain Gson throw on the map) and getAttach() reports only the KIND. Every existing reader compares kinds, so nothing else moved. The map's vocabulary is closed through UnknownKeyValidator.MAP_KEYS, with bind: staying opaque - its keys are the named report's parameters, which the notify validator checks.

  • A parameter that declares an initial must be bound. A parameter is bound on every call (feat(intent): user-set report parameters on any report (#6357) #6911), so an unbound one rides its initial: one FIXED slice, identical for every recipient - the "whole ledger to one customer" shape, where the mail goes out, the attachment IS a report, and nothing about it says whose. A parameter without an initial is precisely one whose comparison has a neutral any-value default (a date window bound, a like search), so omitting it legitimately means the whole range. That is why requiredness is tested on initial and not on the op: no re-resolution of the target, and no issue reported twice. A balance report's own fromDate/toDate are bindable and optional.

  • An unresolvable report attachment drops the mail rather than degrading to plain text: the bindings ARE the scoping, so sending without them would carry the wrong rows, which is worse than not sending.

  • The render reuses the whole print path. Print.render(<name>, ...) resolves a CMS template BY NAME, so a report name works exactly as an entity name does. ReportPrintTemplate seeds doc/Templates/<Report>/Print/en/standard.print - written from the columns ReportIntentGenerator just resolved rather than re-derived, because a column alias is what the query actually SELECTs and a second derivation would drift into placeholders that render empty. Written once and developer-owned afterwards (a mailed statement is a formatted artifact), and only for reports something actually mails. The bound values double as the rendered header, since a table of rows never states which slice it is.

One run-time detail: each bound value passes through a generated reportValue(Object) that stringifies it, because the report repository JSON-encodes its parameters and the named-parameter binder accepts only a JSON primitive - a raw LocalDate would arrive as an object and be rejected. Null stays null, which is what makes a nullable source column fall back to the parameter's initial instead of failing the send.

Scope notes

  • Two follow-ups are deliberately out of scope, both of them affordances rather than mechanism: binding from a fan-out's anchor (record. scope) is rejected with a message of its own, and a report attachment has no recordPrint-style anchor variant - a report is always scoped by the record the message is about, because that is what makes the rows the recipient's.
  • Attaching a report that declares no parameters is a validation error: it would render the same PDF for every recipient.

Tests

  • NotifyAttachReportTest - the glue at two call sites, the merged relation load, and every parse rule (unbound selector, unknown report, unknown parameter with a case suggestion, an unresolvable bind source, the record. scope, a malformed shape, a stray key, a parameterless report).
  • ReportPrintTemplateTest - the scaffold binds the report's own resolved aliases, a balance report's window is part of the header, and the seeded path is the one Print.render resolves by name.
  • ModelGenerationIT - the orders.glue fixture gained a report-attaching schedule, so the new template branch is actually rendered and shape-checked.
  • IntentEmissionCoverageIT - ClaimsByUnit is attached to the BillFlow sending step, so the client-Java compile of that project proves the generated code (repository package included) compiles, and the seeded report print template is asserted.

Docs: the engine-intent guide gained a mail a REPORT section; CLAUDE.md and the module CLAUDE.md record the decisions. A docs-site PR follows.

🤖 Generated with Claude Code

…#6931)

`attach: print` is deliberately document-only, so the standard AR
**customer statement** - a period's rows per customer, mailed monthly or on
demand - had no way out of the system, while its little brother the
per-invoice dunning reminder already had one. With report `parameters:`
generalized to any report (#6911) the statement is expressible as a report
definition; only the delivery was missing.

    reports:
      - name: CustomerStatement
        source: SalesInvoice
        dimensions: [issuedOn]
        measures: ["sum(total)"]
        parameters:
          - { name: fromDate, target: issuedOn, op: ge }
          - { name: toDate, target: issuedOn, op: le }
          - { name: customer, target: Customer.name, op: eq, initial: "-" }

    schedules:
      - name: monthly-statements
        cron: "0 0 7 1 * ?"
        entity: Customer
        where: [{ field: openBalance, op: gt, value: 0 }]
        notify:
          to: email
          subject: "Your statement"
          body: "Please find attached your account statement."
          attach:
            report: CustomerStatement
            bind: { customer: name, fromDate: periodStart, toDate: periodEnd }

`bind:` maps a report parameter to a field or a one-hop `relation.field` of
the record the message is about (the ROW inside a `forEach`), resolved
through the same resolver a `{placeholder}` uses - so a relation the
bindings read shares the local the message text already declares. Available
at all four notify call sites; `language:` / `languageFrom:` / `fileName:`
apply as they do to a document, and absent a pattern the name is
`<Report> <record>.pdf`.

Four decisions, each of them a way the mail is quietly WRONG rather than
broken:

- **`attach` is now polymorphic**, so `NotificationIntent.attach` is typed
  `Object` (a `String` field makes plain Gson throw on the map) and
  `getAttach()` reports only the KIND. Every existing reader compares kinds,
  so nothing else moved. The map's vocabulary is closed through
  `UnknownKeyValidator.MAP_KEYS`, with `bind:` staying opaque - its keys are
  the named report's parameters, which the notify validator checks.

- **A parameter that declares an `initial` must be bound.** A parameter is
  bound on every call (#6911), so an unbound one rides its `initial`: one
  FIXED slice, identical for every recipient - the "whole ledger to one
  customer" shape, where the mail goes out, the attachment IS a report, and
  nothing about it says whose. A parameter WITHOUT an `initial` is precisely
  one whose comparison has a neutral any-value default (a date window bound,
  a `like` search), so omitting it legitimately means the whole range. That
  is why requiredness is tested on `initial` and not on the op: no
  re-resolution of the target, and no issue reported twice. A balance
  report's own `fromDate`/`toDate` are bindable and optional.

- **An unresolvable report attachment drops the mail** rather than degrading
  to plain text: the bindings ARE the scoping, so sending without them would
  carry the wrong rows, which is worse than not sending.

- **The render reuses the whole print path.** `Print.render(<name>, ...)`
  resolves a CMS template BY NAME, so a report name works exactly as an
  entity name does. `ReportPrintTemplate` seeds
  `doc/Templates/<Report>/Print/en/standard.print` - written from the columns
  `ReportIntentGenerator` JUST resolved rather than re-derived, because a
  column alias is what the query actually SELECTs and a second derivation
  would drift into placeholders that render empty. Written once and
  developer-owned afterwards (a mailed statement is a formatted artifact),
  and only for reports something actually mails. The bound values double as
  the rendered header, since a table of rows never states which slice it is.

One run-time detail: each bound value passes through a generated
`reportValue(Object)` that stringifies it, because the report repository
JSON-encodes its parameters and the named-parameter binder accepts only a
JSON primitive - a raw `LocalDate` would arrive as an object and be
rejected. Null stays null, which is what makes a nullable source column fall
back to the parameter's `initial` instead of failing the send.

Tests: `NotifyAttachReportTest` covers the glue at two call sites, the
merged relation load and every parse rule; `ReportPrintTemplateTest` pins
the scaffold to the report's own aliases; the `orders.glue` fixture renders
the branch in `ModelGenerationIT`; and `IntentEmissionCoverageIT` attaches
`ClaimsByUnit` to the `BillFlow` sending step, so the client-Java compile of
that project proves the generated code - repository package included -
actually compiles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@delchev
delchev merged commit 0d5b5c3 into master Aug 24, 2026
10 checks passed
@delchev
delchev deleted the feat/notify-attach-report-6931 branch August 24, 2026 19:01
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.

intent: notify can attach a parameterized REPORT render (attach: { report, bind }) - the customer-statement mail

1 participant