Skip to content

feat(engine-intent): lifecycle-aware report aggregates (#6645) - #6647

Merged
ThuF merged 2 commits into
masterfrom
feat/intent-lifecycle-aware-aggregates
Aug 10, 2026
Merged

feat(engine-intent): lifecycle-aware report aggregates (#6645)#6647
ThuF merged 2 commits into
masterfrom
feat/intent-lifecycle-aware-aggregates

Conversation

@delchev

@delchev delchev commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #6645.

An aggregate over an entity carrying a function: EntityStatus was wrong by default: drafts nobody had issued, cancelled and voided documents all landed in the sum unless the author remembered a magic-number status predicate in filter:. Nothing said so — the report generated, the SQL was valid, the tile rendered a number, and the number was wrong.

All four parts of the issue are implemented.

Part 1 — classify status semantics where the nomenclature is seeded

seeds:
  - name: sales-invoice-statuses
    entity: SalesInvoiceStatus
    rows:
      - { id: 1, name: DRAFT, stage: draft }
      - { id: 3, name: ISSUED, stage: live }
      - { id: 7, name: PAID, stage: live }
      - { id: 8, name: CANCELLED, stage: cancelled }
      - { id: 9, name: VOIDED, stage: void }

stage is a closed vocabulary and pure metadata — it never becomes a column, so the generated seed CSV is unchanged (pinned by a test). A nomenclature that declares its own stage property is rejected rather than guessed at.

Part 2 — reports become lifecycle-aware by construction

- { name: RevenueByMonth,   source: SalesInvoice, dimensions: ["month(date)"], measures: ["sum(total)"] }
- { name: InvoicesByStatus, source: SalesInvoice, scope: all, dimensions: [Status], measures: ["count(*)"] }

scope is all or a stage name, emitted as <alias>."<STATUS FK>" IN (<stage ids>) ANDed onto the filter.

Part 3 — the default, and the warning when it cannot apply

The design call the issue asked maintainers to make: the safe default applies only once the nomenclature is stage-classified, so no existing model changes behaviour until it opts in by adding stage:. Within that, an aggregating report defaults to live — unless its dimensions or filter already reference the status, because a breakdown BY status must keep its draft rows and an authored predicate is authoritative.

When the nomenclature is unclassified there is nothing to default to, so generation records the lifecycle-blind aggregate as a warning naming the report, the source, the status relation and the fix. That warning — not the default — is what turns an invisible modelling omission into a visible one, and it would have caught all four reports in the motivating case. The Intent Editor now renders the generate response's warnings in its own amber strip; it used to discard them on success (the Builder shell already showed them).

Part 4 — symbolic status references

Every site that names a status accepts the seeded name: transitions[].from / setStatus, a relation's init:, a setRelationField value:, abortOn.status, a check's status / setStatus, immutableWhen, a posting's event.when, a report's filter.

transitions:
  - { name: VoidSalesInvoice, forEntity: SalesInvoice, from: [ISSUED, SENT], setStatus: VOIDED, when: "Paid == 0" }
reports:
  - { name: OverdueInvoices, source: SalesInvoice, filter: "balance > 0 AND Status != VOIDED" }

Resolution happens on the raw YAML tree before the typed Gson mapping (the rejectRemovedNumberKeys precedent), so every validator, generator and template keeps seeing the plain integers it always saw — no downstream change. An unknown name is a generation error listing the known statuses. Numeric ids keep working; this is additive.

This is the part that would have prevented the ledger bug: an id is positional, so inserting a CONFIRMED status mid-nomenclature shifted every later id and silently retargeted a red-storno posting guarded on Status == 8 away from a Void that now writes 9 — leaving the general ledger with a receivable and revenue for a document that no longer existed, with well-formed Java emitted throughout.

Deliberate boundaries

  • Cross-model nomenclature. The parser holds one file and no repository, so a status entity seeded in another model can neither be stage-scoped nor named. Both cases fail loudly naming the numeric-id fallback instead of quietly emitting a query without its predicate. (Cross-model symbols need the name→id map carried on the generated .model; that is follow-up work.) The Part 3 warning still covers cross-model omissions.
  • No symbolic ordering. Status >= ISSUED is rejected — names have no order, and expressing "live rows" as an id range is exactly the idiom this replaces.
  • Nothing is emitted into the .model for stage — no consumer needs it yet. The obvious future one is the Harmonia status badge, which currently guesses its colour variant from the status text.

Verification

  • LifecycleStageIntentTest (11) — the vocabulary, the id requirement, the property collision, and every scope rejection path.
  • StatusSymbolIntentTest (7) — each site resolves; unknown name, symbolic ordering, cross-model report filter and cross-model posting guard all rejected; numeric ids untouched.
  • ReportScopeTest (9) — the emitted SQL for the default, an explicit stage, scope: all, a status dimension, an authored status filter, a non-aggregating report, scope+filter composition, the warning, and a lifecycle-less source.
  • CsvimIntentGeneratorTeststage is not a CSV column.
  • IntentEngineIT — two new end-to-end cases over HTTP generate: the scoped query plus the resolved init:/from/setStatus in the generated .edm/.glue, and the warning reaching the API. Full class green (42 tests), whole engine-intent suite green (396), formatter:validate and the release-profile javadoc clean.

Docs: the assistant guide (so the editor's AI authors stage:/scope: and prefers names), the module CLAUDE.md, and the root CLAUDE.md. The intentfile.org spec and dirigible.io pages follow per the documentation-sync rule.

🤖 Generated with Claude Code

An aggregate over an entity that carries a `function: EntityStatus` was wrong
by default: drafts nobody had issued, cancelled and voided documents all landed
in the sum unless the author remembered a magic-number status predicate in
`filter:`, and nothing in the DSL, the generator or the editor said so. A voided
invoice kept its amount in "Revenue (this month)" because the report declared
dimensions and measures and no filter, so the emitted query had no WHERE at all.

Four coordinated pieces:

1. A status seed row classifies what the status MEANS to the lifecycle with a
   closed-vocabulary `stage: draft|live|cancelled|void`. It is metadata, never a
   column - the seed CSV is unchanged.
2. A report declares `scope: all` or a stage name, emitted as
   `<alias>."<STATUS FK>" IN (<stage ids>)` ANDed onto its filter.
3. With the nomenclature classified, an aggregating report defaults to `live` -
   but only when its dimensions and `filter` do not already reference the status,
   so a breakdown BY status keeps its draft rows and an authored predicate stays
   authoritative. An existing model is byte-identical until it adopts `stage:`.
   When the nomenclature is unclassified there is nothing to default to, so
   generation reports the lifecycle-blind aggregate as a warning instead - and the
   Intent Editor now shows the generate warnings it used to discard on success.
4. Every site that names a status accepts the seeded NAME: `transitions[].from` /
   `setStatus`, a relation's `init:`, a `setRelationField` `value:`,
   `abortOn.status`, a check's `status` / `setStatus`, `immutableWhen`, a posting's
   `event.when`, a report's `filter`. Resolved on the raw YAML tree before the
   typed mapping (the `rejectRemovedNumberKeys` precedent), so every validator,
   generator and template keeps seeing plain integers. An id is positional:
   inserting a status mid-nomenclature shifted every later id and silently
   retargeted every guard authored against the old numbering - that is how a
   red-storno posting guarded on `Status == 8` stopped matching a Void that writes
   9, leaving the ledger with a receivable for a document that no longer existed.

Deliberate boundaries: the nomenclature must be seeded in this model (the parser
holds one file and no repository), so a cross-model status can neither be
stage-scoped nor named and both fail loudly naming the numeric-id fallback; a
symbolic ordering comparison is rejected because names have no order; and a
nomenclature declaring its own `stage` property is rejected rather than guessed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
String text = String.valueOf(value)
.trim();
return INTEGER.matcher(text)
.matches() ? Integer.valueOf(text) : null;
…ries

The specification and its website split in mid-2026, so a DSL change is a PR
against `IntentFile/intent-specification` (`versions/<v>.md` + Appendix A) FIRST,
mirrored into `intentfile.github.io`'s chapters, plus the branded dirigible.io
page - the rule still described the site repo as the spec.

Also records the two things that cost time doing it for #6645: the site chapters
sit one heading level shallower and use `::: info Normative` containers, and the
same heading yields DIFFERENT anchors in VitePress (one hyphen for an em dash)
and GitHub markdown (two), so the built HTML is what you check.
@ThuF
ThuF merged commit 03c8d56 into master Aug 10, 2026
15 of 16 checks passed
@ThuF
ThuF deleted the feat/intent-lifecycle-aware-aggregates branch August 10, 2026 12:48
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: lifecycle-aware report aggregates - a voided or draft document silently counts in every sum

3 participants