Skip to content

spec: lifecycle stages, report scope, and status references by name - #6

Open
delchev wants to merge 1 commit into
mainfrom
spec/lifecycle-stages-scope
Open

spec: lifecycle stages, report scope, and status references by name#6
delchev wants to merge 1 commit into
mainfrom
spec/lifecycle-stages-scope

Conversation

@delchev

@delchev delchev commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

The problem

An aggregation over an entity that carries a lifecycle (function: EntityStatus) is wrong by default. Drafts nobody has issued, cancelled documents and voided ones all land in the sum unless the author remembers a status predicate — and nothing in the format says an aggregate over such an entity is incomplete without one. The omission is invisible: the file validates, the query is well-formed, the number renders, and the number is wrong.

Worse, the only way to say "the rows that count" today is a predicate over positional ids:

filter: "balance > 0 AND Status >= 3 AND Status != 8 AND Status != 9"

Inserting a status into the middle of a nomenclature shifts every later id, and every filter and guard authored against the old numbering keeps producing well-formed output that now means a different status. Nothing downstream can tell, because the emitted constant is valid.

The proposed shape

One idea in three parts: a status id is data, but its meaning is not.

1. Classify the nomenclature where it is seeded.

seeds:
  - name: invoice-statuses
    entity: InvoiceStatus
    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 metadata — never a column of the seeded table.

2. Reports state which lifecycle rows they count, in stage terms.

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

3. Every site that names a status accepts the seeded name.

transitions:
  - { name: VoidInvoice, forEntity: Invoice, from: [ISSUED, SENT], setStatus: VOIDED, when: "Paid == 0" }
reports:
  - { name: OverdueInvoices, source: Invoice, filter: "balance > 0 AND Status != VOIDED", measures: ["sum(total)"] }

Expected behaviour

Stated in the chapters; the load-bearing normative rules:

  • stage MUST NOT reach the seeded table as a column, MUST accompany the row's primary key, and a value outside draft | live | cancelled | void is an authoring error. An entity owning its own stage property cannot be classified this way — the collision is reported, not guessed at.
  • scope is all or one stage name, meaningful only over a source declaring a status relation. With no scope, a report counts every row except when it aggregates AND its nomenclature is stage-classified AND neither its dimensions nor its filter reference the status — then it counts the live rows. A generator MUST NOT combine an implicit scope with an authored status predicate or a status dimension.
  • An aggregation over a lifecycle-carrying source with no scope, no status predicate and no stage classification MUST be reported as a diagnostic. Emitting it silently is non-conforming — this is the rule that makes the whole class visible even where nothing is resolvable.
  • Status names resolve before any other validation, so every later rule sees the id. An unresolvable name is an authoring error naming the known statuses; numeric ids stay valid everywhere; an ordering comparison against a name is rejected because a name has no order.

Edge rules and deliberate boundaries

  • The default is opt-in, by construction. It only applies once the nomenclature carries stage: markers, so no existing file changes meaning until its author classifies the statuses.
  • A breakdown BY status keeps every row, and an authored filter on the status stays authoritative — the implicit scope stands down for both.
  • A nomenclature owned by another model is seeded there, so neither its stages nor its names can be resolved from the referencing file. Both are authoring errors naming the numeric-id fallback, and the case is listed under Planned.

Prior art / workarounds

Today: a hand-written string predicate over magic ids, repeated per report, plus the same ids in enforcement guards. In a real deployment that combination retired a document through a VOIDED transition while a reversal posting was still guarded on the pre-insertion id — so the ledger kept a receivable and revenue for a document that no longer existed, with valid generated code throughout.

Implementation

Implemented in the Eclipse Dirigible reference implementation: eclipse-dirigible/dirigible#6647 (issue eclipse-dirigible/dirigible#6645), with unit coverage per rule and end-to-end coverage of the emitted query, the diagnostic, and the resolved names.

An aggregation over an entity that carries a lifecycle is wrong by default:
drafts nobody has issued, cancelled documents and voided ones all land in the
sum unless the author remembers a predicate over positional status ids, and
nothing in the format said so.

Three additions, one idea - a status id is data, but its MEANING is not:

- `stage` on a status seed row classifies it (draft | live | cancelled | void).
  Metadata, never a column; it must accompany the row's primary key; a value
  outside the vocabulary and a colliding `stage` property are authoring errors.
- `scope` on a report states which lifecycle rows it counts, in stage terms.
  With no scope, an aggregating report over a stage-classified nomenclature
  counts the live rows - but only when its dimensions and filter do not already
  reference the status, so a breakdown BY status stays complete and an authored
  predicate stays authoritative. `all` is the explicit opt-out. An aggregation
  that resolves no stage classification must be reported as a diagnostic;
  emitting it silently is non-conforming.
- Every site that names a status accepts the seeded NAME. Resolution happens
  before any other validation, so every later rule sees the id. An unresolvable
  name is an authoring error naming the known statuses; ordering comparisons
  against a name are rejected because a name has no order; a nomenclature owned
  by another model is seeded there and must be referenced numerically (listed
  under Planned).

Appendix A gains rows for `scope`, `stage` and status names.
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