From 4060dbfa7583ae39e8bd3aec4e33f87ce3c11e55 Mon Sep 17 00:00:00 2001 From: delchev Date: Sat, 15 Aug 2026 11:10:03 +0300 Subject: [PATCH] spec: resolver-path user-task assignment A user task's `assignee` accepts a relation walk off the trigger record beside a role name and the `personal` literal: assignee: { path: employee.manager, fallback: manager } Every segment is a to-one relation and the walk ends at an entity declaring `identity`; a cross-model relation may only be the last segment, since a projection carries the target's own properties but not its relations. A conforming generator validates every hop when the file is read. `fallback` is required, and is what makes the unresolvable case total: the walk is resolved when the task is reached, and when it resolves to nobody the task is created unassigned so the candidate group can still claim it. A resolver path can therefore never mint a task nobody can see. Removes the matching entry from the Planned list and adds the DSL index row. Co-Authored-By: Claude Opus 5 --- versions/1.2.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/versions/1.2.md b/versions/1.2.md index f321557..ebd7b86 100644 --- a/versions/1.2.md +++ b/versions/1.2.md @@ -779,7 +779,19 @@ trigger: { onCreate: Order, businessKey: orderNo, businessKeyStrategy: timestamp #### Task assignment -A user task's `assignee` is a role / candidate-group name, or the literal **`assignee: personal`** to route the task to the **record owner's** inbox (requires the trigger entity to declare a `personal:` relation — see [scoped surfaces](#scoped-surfaces--roles)). +A user task's `assignee` is a role / candidate-group name, or the literal **`assignee: personal`** to route the task to the **record owner's** inbox (requires the trigger entity to declare a `personal:` relation — see [scoped surfaces](#scoped-surfaces--roles)), or a **relation walk** off the trigger record: + +```yaml +- name: approve + kind: userTask + args: + assignee: { path: employee.manager, fallback: manager } + form: ApproveRequest +``` + +Every segment of `path` is a **to-one relation** — the first of the trigger entity, each further one of the previous target — and the walk ends at an entity that declares `identity`, which is what maps a record to a login. A **cross-model** relation may only be the **last** segment: a projection carries the target's own properties but not its relations, so there is nothing to walk on from there. A conforming generator validates every hop when the file is read, so a dangling segment is reported then rather than when the process runs. + +`fallback` is **required** and names the candidate group. The walk is resolved when the task is reached, not when the process starts — so a relation an earlier step of the same process set is visible — and when it resolves to nobody (a null hop, a missing record, a blank identity) the task is created **unassigned** and the fallback group can still claim it. That is what makes the unresolvable case total: a resolver path can never mint a task nobody can see. ### forms @@ -1493,7 +1505,7 @@ entities: > **Normative.** > A scope's safety is by **construction, not by a filter**: the scoped controller only ever queries the caller's own rows, and a sensitive field is on an allow-list the scoped serialiser never includes. A field hidden only in the UI is cosmetic; `sensitive` is a server-side guarantee. -A user task can also be routed to the record owner's inbox with the literal `assignee: personal`, which resolves the owner through the `personal:` relation (see [processes](#task-assignment)). +A user task can also be routed to the record owner's inbox with the literal `assignee: personal`, which resolves the owner through the `personal:` relation, or to whoever a relation walk off the record names — `assignee: { path, fallback }`, whose walk likewise ends at an `identity`-declaring entity (see [processes](#task-assignment)). ### permissions @@ -1631,6 +1643,7 @@ One line per construct, linking into the chapters above. | [`relations` / `composition`](#relations) | associations and master-detail compositions | | [`uses`](#multi-model-applications) | reuse entities owned by another intent model | | [`processes`](#processes) | workflows: user tasks, decisions, waits, boundary timers | +| [task assignment](#task-assignment) | route a user task to a role, the record owner, or a relation walk | | [`abortOn`](#aborton--cancel-the-instance-on-a-terminal-status) | cancel the running instance when the document reaches a terminal status | | [`function: Attachment` / `Snapshot`](#attachments-and-snapshots) | a Files panel / immutable versioned printed copies | | [`forms`](#forms) | task data-entry pages | @@ -1672,7 +1685,6 @@ The following are parsed (or reserved) but not yet materialised by a generator; - **`manyToMany`** — parsed but never materialised; the supported shape is the [explicit intermediate entity](#many-to-many). - **Cross-model status names and stage scopes** — a nomenclature owned by another model is seeded there, so its stages and names cannot be resolved from the referencing file; such references are rejected with the numeric-id fallback named. - Event-driven **document** generation (produce a whole document, rather than the mapped rows of [`posts`](#posts--derived-rows-on-an-event), on an event), a declarative state machine, and shadow audit-history entities (audit *columns* via `audit: true` ship today). -- Arbitrary resolver-path task assignment beyond `assignee: personal`. - **A fan-out that attaches the RECORD's document rather than the row's.** `forEach` + `attach: print` attaches each row's own document, which is what a per-row document (a payslip) needs. The mirror shape - one document sent to many recipients, where the rows ARE the recipients and the attachment