From c83aa099d059393298e89f3b55b29bf4e4f0bdf6 Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 14 Aug 2026 19:37:01 +0300 Subject: [PATCH 1/2] spec: resolves - fill a relation from a register valid on a date A register says "X applied to Y from A to B" - a vehicle assignment, a price list, a contract in force, an org assignment - and a record carries the match key(s) and a date. Setting the to-one from the row whose period covers that date had no declarative form: dependsOn is an authoring-time copy matched by equality, a decision condition is a single comparison, and a setField step writes a constant, so every application hand-writes the same query-and-classify code. The section specifies the shape and, normatively, the two properties that make it worth having as a construct rather than a query helper: - all three outcomes are distinguished - exactly one covering row fills the relation, while none and more-than-one both leave it unset, and a conforming generator MUST NOT choose between candidate rows; - the attempt is observable - `outcome:` stamps found/notFound/ambiguous into a string field, so the unresolved records form a worklist a person can finish and a decision can branch on. Plus the constraints that keep it unambiguous: the register must carry exactly one to-one to the target, an already-filled relation is skipped, and the write is targeted to the resolved column, the outcome and the status. --- README.md | 2 +- versions/1.2.md | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index da4cc5b..4f03bac 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ manifesto — is published at **[intentfile.org](https://intentfile.org)** (sour | --- | --- | | [1.0](versions/1.0.md) | superseded | | [1.1](versions/1.1.md) | superseded - added `aggregates`, `checks: kind: guard`, `posts`, the notify block (`attach: print`, `forEach`), `pattern` and header-mediated `dependsOn` ([proposal](proposals/0001-keyed-aggregates-guard-checks-and-row-posting.md)) | -| [1.2](versions/1.2.md) | current - adds lifecycle stages + report `scope` + status names, `locksWithMaster`, `generates` prompted input and computed lines, `defaultValue` row seeding, print-template row filtering ([proposal](proposals/0002-print-template-row-filtering.md)), the scope boundary and the authoring assistant's honesty rule ([proposal](proposals/0003-the-scope-boundary.md)), and aligns document numbering with deployed practice | +| [1.2](versions/1.2.md) | current - adds lifecycle stages + report `scope` + status names, `locksWithMaster`, `generates` prompted input and computed lines, `resolves` (effective-dated register lookup), `defaultValue` row seeding, print-template row filtering ([proposal](proposals/0002-print-template-row-filtering.md)), the scope boundary and the authoring assistant's honesty rule ([proposal](proposals/0003-the-scope-boundary.md)), and aligns document numbering with deployed practice | ## Structure diff --git a/versions/1.2.md b/versions/1.2.md index f321557..4a1e039 100644 --- a/versions/1.2.md +++ b/versions/1.2.md @@ -7,6 +7,7 @@ This version adds to [1.1](1.1.md): - **Lifecycle-aware aggregation** — [`stage`](#stage--what-a-status-means-to-the-lifecycle) classifies what a status seed *means* (draft / live / cancelled / void), a report's [`scope`](#lifecycle-scope) states which lifecycle rows it counts (an aggregating report over a stage-classified nomenclature defaults to the live rows), and [every site that names a status accepts the seeded name](#status-references--name-not-number) instead of its positional id. - **[`locksWithMaster`](#lockswithmaster--a-child-collection-that-outlives-its-masters-lock)** — a composition child collection that stays writable while its master is locked (settlement against an issued invoice). - **[`generates` prompted input](#prompted-input--prompt)** — a create-from action may collect the values the source cannot derive before the create; and [`generates.items`](#generates--create-from) gains a **computed list form** building synthetic lines from expressions over the source. +- **[`resolves`](#resolves--fill-a-relation-from-a-register-valid-on-a-date)** — fill a to-one from the register row valid on a date the record carries, with `found` / `notFound` / `ambiguous` as three first-class, observable outcomes. - **[`defaultValue` semantics completed](#defaultvalue--field-defaults)** — a default is the column default, the reason a `required` field is not demanded from the caller, and the value a new row is seeded with in the UI. - **[Print-template row filtering](#row-filtering--filter--match)** — `filter` / `match` render one items collection into several purpose-grouped tables (earnings beside deductions, debit beside credit). - **[The scope boundary](#the-scope-boundary)** — what a conforming file deliberately never models (protocol, algorithm, statutory form), each category's designated hand-off, and the honesty a conforming authoring assistant owes when a requirement falls outside the format. @@ -1458,6 +1459,36 @@ posts: `posts` differs from [`generates`](#generates--create-from) in intent: `generates` creates ONE document from a user action, while `posts` emits N mapped rows automatically and idempotently on an event. +### resolves — fill a relation from a register valid on a date + +Set a to-one from the row of a **register** whose validity period covers a date the record carries. The register says "X applied to Y from A to B" — a vehicle assignment, a price list, a contract in force, an org assignment — and the record carries the match key(s) and the date: + +```yaml +resolves: + - name: identifyDriver + event: { onCreate: Fine } # onCreate or onUpdate, optional `when` guard + set: driver # the to-one of Fine this fills + from: VehicleAssignment # the register + match: { vehicle: vehicle } # register property <- record property (one or more) + between: { start: validFrom, end: validTo, value: violationAt } + outcome: resolution # optional string field: found / notFound / ambiguous + found: { setStatus: IDENTIFIED } + notFound: { setStatus: UNRESOLVED } + ambiguous: { setStatus: UNRESOLVED } +``` + +Nothing else in the format reaches this shape: [`dependsOn`](#relations) is an authoring-time copy matched by equality, a [`decision`](#processes) condition is a single comparison, and a `setField` step writes a constant. Without it every application hand-writes the same query-and-classify code. + +**Normative.** A lookup MUST declare exactly one of `onCreate` / `onUpdate` naming a declared entity; `onDelete` MUST be rejected, since there is no record left to fill. `set` MUST name a to-one relation of that entity, `from` a declared register entity, and `match` at least one pair whose left side is a property of the register and whose right side a property of the record. `between.value` MUST name a date field of the record; `between.start` and `between.end` name date fields of the register and MAY each be omitted, in which case that side of the period is open. The end of a period is **inclusive**, and a bound expressed as a date (rather than an instant) covers its whole day. + +**Normative.** The register MUST carry exactly one to-one relation to the entity `set` points at; that relation is the value the lookup copies. Zero or more than one MUST be rejected — a register offering a choice of columns to copy is a modelling ambiguity, and guessing one would defeat the construct's purpose. + +**Normative.** All three outcomes are first-class and MUST be distinguished. Exactly one covering row fills the relation. No covering row (`notFound`) and more than one covering row (`ambiguous`) MUST both leave the relation unset: a conforming generator MUST NOT choose between candidate rows. Each outcome MAY carry a `setStatus` routing the record, which requires the record to declare a `function: EntityStatus` relation and accepts a [status name](#status-references--name-not-number) as well as an id. + +**Normative.** The attempt MUST be observable. When `outcome` names a `string` field of the record, that field MUST be stamped with `found`, `notFound` or `ambiguous`, so unresolved records form a filterable worklist a person can finish and a process [`decision`](#processes) can branch on the result. A conforming generator SHOULD additionally log the keys and the date it checked. + +**Normative.** A record that already carries the relation MUST be skipped, so a manual correction is never overwritten and a re-delivered event is a no-op. The resolved relation, the outcome and the status MUST be written as one targeted update of those columns only, leaving every other column of the record — and any concurrent write to it — untouched. + ### Guardrails - **Curated vocabulary, not a general DSL.** Real logic is a `script` step or a hand-written hook — the escape hatch is non-negotiable. @@ -1656,6 +1687,7 @@ One line per construct, linking into the chapters above. | [`postings`](#postings--source-document-to-ledger) | declarative source-document to balanced-document posting | | [`aggregates`](#aggregates--keyed-cross-entity-totals) | keyed cross-entity totals materialised into their own entity | | [`checks: kind: guard`](#kind-guard--a-precondition-over-an-aggregate) | a precondition over an aggregate: block, mark for a task, or reject | +| [`resolves`](#resolves--fill-a-relation-from-a-register-valid-on-a-date) | fill a to-one from the register row valid on the record's date | | [`posts`](#posts--derived-rows-on-an-event) | derived ledger rows emitted idempotently on an event | | [`personal` / `partner`](#personal-and-partner-surfaces) | per-user and per-partner row-scoped surfaces | | [`seeds`](#seeds) | initial data, CSV-backed sets, translations | From 51077a0f04a9e57a1feef9dffa2341d173b8d402 Mon Sep 17 00:00:00 2001 From: delchev Date: Mon, 17 Aug 2026 17:44:24 +0300 Subject: [PATCH 2/2] proposal: `resolves` - fill a relation from a register valid on a date Moves this change out of the current version document and into proposals/0006-register-lookup-resolves.md, per the proposal-first process: a version is written at a release, so a pending semantic change waits in proposals/ carrying the exact text and anchors the release folds in. No specification text was rewritten - it is the same prose, relocated. --- README.md | 2 +- proposals/0006-register-lookup-resolves.md | 93 ++++++++++++++++++++++ versions/1.2.md | 32 -------- 3 files changed, 94 insertions(+), 33 deletions(-) create mode 100644 proposals/0006-register-lookup-resolves.md diff --git a/README.md b/README.md index 4f03bac..da4cc5b 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ manifesto — is published at **[intentfile.org](https://intentfile.org)** (sour | --- | --- | | [1.0](versions/1.0.md) | superseded | | [1.1](versions/1.1.md) | superseded - added `aggregates`, `checks: kind: guard`, `posts`, the notify block (`attach: print`, `forEach`), `pattern` and header-mediated `dependsOn` ([proposal](proposals/0001-keyed-aggregates-guard-checks-and-row-posting.md)) | -| [1.2](versions/1.2.md) | current - adds lifecycle stages + report `scope` + status names, `locksWithMaster`, `generates` prompted input and computed lines, `resolves` (effective-dated register lookup), `defaultValue` row seeding, print-template row filtering ([proposal](proposals/0002-print-template-row-filtering.md)), the scope boundary and the authoring assistant's honesty rule ([proposal](proposals/0003-the-scope-boundary.md)), and aligns document numbering with deployed practice | +| [1.2](versions/1.2.md) | current - adds lifecycle stages + report `scope` + status names, `locksWithMaster`, `generates` prompted input and computed lines, `defaultValue` row seeding, print-template row filtering ([proposal](proposals/0002-print-template-row-filtering.md)), the scope boundary and the authoring assistant's honesty rule ([proposal](proposals/0003-the-scope-boundary.md)), and aligns document numbering with deployed practice | ## Structure diff --git a/proposals/0006-register-lookup-resolves.md b/proposals/0006-register-lookup-resolves.md new file mode 100644 index 0000000..6d3ebf4 --- /dev/null +++ b/proposals/0006-register-lookup-resolves.md @@ -0,0 +1,93 @@ +# `resolves` - fill a relation from a register valid on a date + + +- **Status:** draft +- **Issue:** https://github.com/eclipse-dirigible/dirigible/issues/6712 +- **Discussion:** https://github.com/IntentFile/intent-specification/pull/15 + +## The gap + +A very common enterprise shape has no declarative form in the format today: **resolve a relation by consulting a register with a validity period**. The register rows say "X applied to Y from A to B"; an incoming record carries the match key(s) and a date, and a to-one must be set from the row whose period covers that date. + +The same pattern under many names: + +- a fine carries a vehicle and a violation date; the **driver** comes from a vehicle-assignment register; +- a price comes from the **price list** valid on the order date; +- a rate from the **contract** in force on the booking date; +- an approver from an **org assignment** on the request date. + +Nothing reaches it: `dependsOn` is an authoring-time copy matched by equality, a `decision` condition is a single comparison, and a `setField` step writes a constant. Every application hand-writes the same code — query the register, apply the between-dates predicate, classify the result. + +## What this adds + +A `resolves` section in the declarative-glue chapter of 1.2: + +```yaml +resolves: + - name: identifyDriver + event: { onCreate: Fine } + set: driver + from: VehicleAssignment + match: { vehicle: vehicle } + between: { start: validFrom, end: validTo, value: violationAt } + outcome: resolution + found: { setStatus: IDENTIFIED } + notFound: { setStatus: UNRESOLVED } + ambiguous: { setStatus: UNRESOLVED } +``` + +The normative content is deliberately about the two properties that make this a construct rather than a query helper: + +1. **All three outcomes are distinguished.** Exactly one covering row fills the relation; no covering row and more than one covering row both leave it unset, and a conforming generator MUST NOT choose between candidate rows. An automation that silently picks one of two candidates is worse than none. +2. **The attempt is observable.** `outcome:` stamps `found` / `notFound` / `ambiguous` into a string field, so the unresolved records form a worklist a person can finish and a process `decision` can branch on the result. + +Plus the constraints that keep it unambiguous: the register MUST carry exactly one to-one relation to the entity `set` points at (that relation is the copied value — zero or two is a modelling ambiguity, not something to guess); a record that already carries the relation MUST be skipped, so a manual correction is never overwritten; and the relation, outcome and status MUST be written as one targeted update leaving every other column alone. Period bounds may be open on either side, the end is inclusive, and a date-only bound covers its whole day. + +Also updates the 1.2 change list, the construct index and the README version row. + +Implemented in Eclipse Dirigible (eclipse-dirigible/dirigible#6712, PR eclipse-dirigible/dirigible#6732) — the implementation and this text were written together. + +## Specification text + +The prose below is what a release folds into the next version document, at the anchors +given. It was written against `versions/1.2.md` and is carried here unchanged. + +**Anchor:** The Intent File Specification > Version 1.2 + +- **[`resolves`](#resolves--fill-a-relation-from-a-register-valid-on-a-date)** — fill a to-one from the register row valid on a date the record carries, with `found` / `notFound` / `ambiguous` as three first-class, observable outcomes. + +**Anchor:** Declarative glue > posts — derived rows on an event + +### resolves — fill a relation from a register valid on a date + +Set a to-one from the row of a **register** whose validity period covers a date the record carries. The register says "X applied to Y from A to B" — a vehicle assignment, a price list, a contract in force, an org assignment — and the record carries the match key(s) and the date: + +```yaml +resolves: + - name: identifyDriver + event: { onCreate: Fine } # onCreate or onUpdate, optional `when` guard + set: driver # the to-one of Fine this fills + from: VehicleAssignment # the register + match: { vehicle: vehicle } # register property <- record property (one or more) + between: { start: validFrom, end: validTo, value: violationAt } + outcome: resolution # optional string field: found / notFound / ambiguous + found: { setStatus: IDENTIFIED } + notFound: { setStatus: UNRESOLVED } + ambiguous: { setStatus: UNRESOLVED } +``` + +Nothing else in the format reaches this shape: [`dependsOn`](#relations) is an authoring-time copy matched by equality, a [`decision`](#processes) condition is a single comparison, and a `setField` step writes a constant. Without it every application hand-writes the same query-and-classify code. + +**Normative.** A lookup MUST declare exactly one of `onCreate` / `onUpdate` naming a declared entity; `onDelete` MUST be rejected, since there is no record left to fill. `set` MUST name a to-one relation of that entity, `from` a declared register entity, and `match` at least one pair whose left side is a property of the register and whose right side a property of the record. `between.value` MUST name a date field of the record; `between.start` and `between.end` name date fields of the register and MAY each be omitted, in which case that side of the period is open. The end of a period is **inclusive**, and a bound expressed as a date (rather than an instant) covers its whole day. + +**Normative.** The register MUST carry exactly one to-one relation to the entity `set` points at; that relation is the value the lookup copies. Zero or more than one MUST be rejected — a register offering a choice of columns to copy is a modelling ambiguity, and guessing one would defeat the construct's purpose. + +**Normative.** All three outcomes are first-class and MUST be distinguished. Exactly one covering row fills the relation. No covering row (`notFound`) and more than one covering row (`ambiguous`) MUST both leave the relation unset: a conforming generator MUST NOT choose between candidate rows. Each outcome MAY carry a `setStatus` routing the record, which requires the record to declare a `function: EntityStatus` relation and accepts a [status name](#status-references--name-not-number) as well as an id. + +**Normative.** The attempt MUST be observable. When `outcome` names a `string` field of the record, that field MUST be stamped with `found`, `notFound` or `ambiguous`, so unresolved records form a filterable worklist a person can finish and a process [`decision`](#processes) can branch on the result. A conforming generator SHOULD additionally log the keys and the date it checked. + +**Normative.** A record that already carries the relation MUST be skipped, so a manual correction is never overwritten and a re-delivered event is a no-op. The resolved relation, the outcome and the status MUST be written as one targeted update of those columns only, leaving every other column of the record — and any concurrent write to it — untouched. + +**Anchor:** Appendix A: DSL index + +| [`resolves`](#resolves--fill-a-relation-from-a-register-valid-on-a-date) | fill a to-one from the register row valid on the record's date | diff --git a/versions/1.2.md b/versions/1.2.md index 4a1e039..f321557 100644 --- a/versions/1.2.md +++ b/versions/1.2.md @@ -7,7 +7,6 @@ This version adds to [1.1](1.1.md): - **Lifecycle-aware aggregation** — [`stage`](#stage--what-a-status-means-to-the-lifecycle) classifies what a status seed *means* (draft / live / cancelled / void), a report's [`scope`](#lifecycle-scope) states which lifecycle rows it counts (an aggregating report over a stage-classified nomenclature defaults to the live rows), and [every site that names a status accepts the seeded name](#status-references--name-not-number) instead of its positional id. - **[`locksWithMaster`](#lockswithmaster--a-child-collection-that-outlives-its-masters-lock)** — a composition child collection that stays writable while its master is locked (settlement against an issued invoice). - **[`generates` prompted input](#prompted-input--prompt)** — a create-from action may collect the values the source cannot derive before the create; and [`generates.items`](#generates--create-from) gains a **computed list form** building synthetic lines from expressions over the source. -- **[`resolves`](#resolves--fill-a-relation-from-a-register-valid-on-a-date)** — fill a to-one from the register row valid on a date the record carries, with `found` / `notFound` / `ambiguous` as three first-class, observable outcomes. - **[`defaultValue` semantics completed](#defaultvalue--field-defaults)** — a default is the column default, the reason a `required` field is not demanded from the caller, and the value a new row is seeded with in the UI. - **[Print-template row filtering](#row-filtering--filter--match)** — `filter` / `match` render one items collection into several purpose-grouped tables (earnings beside deductions, debit beside credit). - **[The scope boundary](#the-scope-boundary)** — what a conforming file deliberately never models (protocol, algorithm, statutory form), each category's designated hand-off, and the honesty a conforming authoring assistant owes when a requirement falls outside the format. @@ -1459,36 +1458,6 @@ posts: `posts` differs from [`generates`](#generates--create-from) in intent: `generates` creates ONE document from a user action, while `posts` emits N mapped rows automatically and idempotently on an event. -### resolves — fill a relation from a register valid on a date - -Set a to-one from the row of a **register** whose validity period covers a date the record carries. The register says "X applied to Y from A to B" — a vehicle assignment, a price list, a contract in force, an org assignment — and the record carries the match key(s) and the date: - -```yaml -resolves: - - name: identifyDriver - event: { onCreate: Fine } # onCreate or onUpdate, optional `when` guard - set: driver # the to-one of Fine this fills - from: VehicleAssignment # the register - match: { vehicle: vehicle } # register property <- record property (one or more) - between: { start: validFrom, end: validTo, value: violationAt } - outcome: resolution # optional string field: found / notFound / ambiguous - found: { setStatus: IDENTIFIED } - notFound: { setStatus: UNRESOLVED } - ambiguous: { setStatus: UNRESOLVED } -``` - -Nothing else in the format reaches this shape: [`dependsOn`](#relations) is an authoring-time copy matched by equality, a [`decision`](#processes) condition is a single comparison, and a `setField` step writes a constant. Without it every application hand-writes the same query-and-classify code. - -**Normative.** A lookup MUST declare exactly one of `onCreate` / `onUpdate` naming a declared entity; `onDelete` MUST be rejected, since there is no record left to fill. `set` MUST name a to-one relation of that entity, `from` a declared register entity, and `match` at least one pair whose left side is a property of the register and whose right side a property of the record. `between.value` MUST name a date field of the record; `between.start` and `between.end` name date fields of the register and MAY each be omitted, in which case that side of the period is open. The end of a period is **inclusive**, and a bound expressed as a date (rather than an instant) covers its whole day. - -**Normative.** The register MUST carry exactly one to-one relation to the entity `set` points at; that relation is the value the lookup copies. Zero or more than one MUST be rejected — a register offering a choice of columns to copy is a modelling ambiguity, and guessing one would defeat the construct's purpose. - -**Normative.** All three outcomes are first-class and MUST be distinguished. Exactly one covering row fills the relation. No covering row (`notFound`) and more than one covering row (`ambiguous`) MUST both leave the relation unset: a conforming generator MUST NOT choose between candidate rows. Each outcome MAY carry a `setStatus` routing the record, which requires the record to declare a `function: EntityStatus` relation and accepts a [status name](#status-references--name-not-number) as well as an id. - -**Normative.** The attempt MUST be observable. When `outcome` names a `string` field of the record, that field MUST be stamped with `found`, `notFound` or `ambiguous`, so unresolved records form a filterable worklist a person can finish and a process [`decision`](#processes) can branch on the result. A conforming generator SHOULD additionally log the keys and the date it checked. - -**Normative.** A record that already carries the relation MUST be skipped, so a manual correction is never overwritten and a re-delivered event is a no-op. The resolved relation, the outcome and the status MUST be written as one targeted update of those columns only, leaving every other column of the record — and any concurrent write to it — untouched. - ### Guardrails - **Curated vocabulary, not a general DSL.** Real logic is a `script` step or a hand-written hook — the escape hatch is non-negotiable. @@ -1687,7 +1656,6 @@ One line per construct, linking into the chapters above. | [`postings`](#postings--source-document-to-ledger) | declarative source-document to balanced-document posting | | [`aggregates`](#aggregates--keyed-cross-entity-totals) | keyed cross-entity totals materialised into their own entity | | [`checks: kind: guard`](#kind-guard--a-precondition-over-an-aggregate) | a precondition over an aggregate: block, mark for a task, or reject | -| [`resolves`](#resolves--fill-a-relation-from-a-register-valid-on-a-date) | fill a to-one from the register row valid on the record's date | | [`posts`](#posts--derived-rows-on-an-event) | derived ledger rows emitted idempotently on an event | | [`personal` / `partner`](#personal-and-partner-surfaces) | per-user and per-partner row-scoped surfaces | | [`seeds`](#seeds) | initial data, CSV-backed sets, translations |