From 560c3768a8949059152f37882f8b5a139c34fec3 Mon Sep 17 00:00:00 2001 From: delchev Date: Tue, 18 Aug 2026 11:28:14 +0300 Subject: [PATCH] docs: catch the site up to specification 1.5 Three released constructs never had site pages - resolves (1.3), visibleTo (1.4) and entity-level unique (1.5); their sections land in glue.md and entities.md, carried from the released specification text with links and normative blocks in the site's own style, plus their reference rows. Also repairs what the merge wave left behind: duplicated reference-table rows and a mangled Planned list (now the 1.5 list), a duplicated inbound heading in glue.md, a duplicated description key in glue.md's frontmatter that failed the build, and blockquote-style normative blocks normalised to the ::: info containers the site uses. --- docs/reference.md | 21 +++-------- docs/spec/entities.md | 88 ++++++++++++++++++++++++++++++++++++++++++- docs/spec/glue.md | 34 +++++++++++++++-- docs/spec/index.md | 13 ++++--- 4 files changed, 130 insertions(+), 26 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 81095f1..913c4fb 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -11,6 +11,8 @@ The quick lookup surface: one line and a minimal snippet per construct. For rule | --- | --- | | [`entities`](/spec/entities) | tables + CRUD UI + a generated data layer & API | | [field / relation attributes](/spec/entities#fields) | uniqueness, layout, read-only, dropdown filtering, cascades | +| [`entities.unique`](/spec/entities#unique-a-business-key-over-more-than-one-field) | a business key spanning more than one field or to-one relation | +| [`visibleTo`](/spec/entities#role-scoped-field-visibility-visibleto) | an allow-list of roles that may read one field, enforced on the wire | | [`pattern`](/spec/entities#fields) | an input-format regular expression enforced in the UI and server-side | | [`defaultValue`](/spec/entities#defaultvalue-field-defaults) | a field default: column default, satisfies `required`, and seeds a new row in the UI | | [`dependsOn`](/spec/relations) | link a dropdown to another, copy a value from the referenced record, or default a line from the open document header | @@ -39,29 +41,23 @@ The quick lookup surface: one line and a minimal snippet per construct. For rule | [`reports`](/spec/presentation#reports) | aggregations, charts, dashboard KPI tiles, balance reports | | [`scope`](/spec/presentation#lifecycle-scope) | which lifecycle rows an aggregating report counts | | [`widgets`](/spec/presentation#widgets-custom-dashboard-tiles) | custom KPI / embedded-page dashboard tiles | -| [`notifications`](/spec/glue#notifications) | email on create / update / delete | | [`notify.forEach`](/spec/glue#one-message-per-related-row-foreach) | fan the block out over a related collection: one message per row, every bare path resolved against the row | | [`attach: recordPrint`](/spec/glue#one-document-many-recipients-attach-recordprint) | in a fan-out: attach the ANCHOR record's document, rendered once, to every recipient (`{record.}` addresses that record) | -| [the notify block / `attach: print`](/spec/glue#the-notify-block-and-attach-print) | send a message about a record - with the record's own document attached - from a process step, a transition or a schedule | -| [`schedules`](/spec/glue#schedules) | cron: notify or generate records per matching row | -| [`integrations`](/spec/glue#integrations-outbound-http) | outbound HTTP on a data change | -| [`integrations.payload`](/spec/glue#payload-the-declared-envelope) | the declared envelope a message carries, instead of the record as stored | -| [`inbound`](/spec/glue#inbound-webhooks) | a webhook that creates records | +| [`payload`](/spec/glue#payload-the-declared-envelope) | the declared envelope an outward-facing message carries (integrations and outbound alike), instead of the record as stored | | [the event axis](/spec/glue#the-event-axis-lifecycle-and-process-step-events) | what a reacting glue entry binds to: an entity lifecycle event, or a process step reached / completed | | [`notifications`](/spec/glue#notifications) | email on an event of the axis | | [notify link placeholders](/spec/glue#links-back-to-the-application-recordurl-inboxurl-appurl) | `{recordUrl}` / `{inboxUrl}` / `{appUrl}` - a message that carries the way back into the application | -| [`notify.forEach`](/spec/glue#one-message-per-related-row-foreach) | fan the block out over a related collection: one message per row | | [the notify block / `attach: print`](/spec/glue#the-notify-block-and-attach-print) | send a message about a record - with the record's own document attached - from a process step, a transition or a schedule | -| [`schedules`](/spec/glue#schedules) | cron: notify or generate records per matching row | +| [`schedules`](/spec/glue#schedules) | cron: notify or generate records per matching row; `where` values may be moments relative to the firing | | [`integrations`](/spec/glue#integrations-outbound-http) | outbound HTTP on a data change | | [`inbound`](/spec/glue#inbound-arrivals-from-outside) | records arriving from outside: a webhook, a queue/topic message, a dropped file | -| [`inbound`](/spec/glue#inbound-webhooks) | a webhook that creates records | | [`outbound`](/spec/glue#outbound-departures-on-a-queue-or-a-topic) | a record emitted on a queue or a topic when an event fires | | [`rollups`](/spec/glue#rollups-denormalised-parent-totals) | counts, sums, balance + status maintenance | | [`settlements`](/spec/glue#settlements-payment-allocation) | auto-allocation of payments across open invoices | | [`expansions`](/spec/glue#expansions-child-rows-from-a-date-span) | generated child rows per day / week / month | | [`generates`](/spec/glue#generates-create-from) | one-click document-from-document cloning | | [`generates.event`](/spec/glue#event-driven-creation-event) | mint the document on a source event instead of a click, at most once | +| [`resolves`](/spec/glue#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 | | [`transitions`](/spec/glue#transitions-guarded-status-flips) | guarded on-demand status flips (void / cancel / reopen) | | [`postings`](/spec/glue#postings-source-document-to-ledger) | declarative source-document to balanced-document posting - on a status transition, or on create for a lifecycle-less source | | [`aggregates`](/spec/glue#aggregates-keyed-cross-entity-totals) | keyed cross-entity totals materialised into their own entity | @@ -184,10 +180,5 @@ seeds: The following are parsed (or reserved) but not yet materialised by a generator; a conforming tool rejects or ignores them with a clear message rather than failing obscurely: - Reserved `function` values for upcoming presentations (`Board`, `Gantt`, `Timeline`). -- **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. - **`manyToMany`** — parsed but never materialised; the supported shape is the [explicit intermediate entity](/spec/relations#many-to-many). -- A declarative state machine, and shadow audit-history entities (audit *columns* via `audit: true` ship today). -- Event-driven document generation (produce a document on an event), and shadow audit-history entities (audit *columns* via `audit: true` ship today). -- Event-driven document generation (produce a document on an event) and a declarative state machine. -- Arbitrary resolver-path task assignment beyond `assignee: personal`. -- Event-driven document generation (produce a document on an event), a declarative state machine, and shadow audit-history entities (audit *columns* via `audit: true` ship today). +- **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. diff --git a/docs/spec/entities.md b/docs/spec/entities.md index 31cb901..8e8b231 100644 --- a/docs/spec/entities.md +++ b/docs/spec/entities.md @@ -39,6 +39,7 @@ fields: | `pattern` | an input-format regular expression the value must match (string / text fields only) | | `defaultValue` | the field's default: 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 (see [Field defaults](#defaultvalue-field-defaults)) | | `unique` | a UNIQUE constraint (e.g. a code or business key) | +| `visibleTo` | an allow-list of roles that may read the field, enforced where the data leaves the server (see [Role-scoped field visibility](#role-scoped-field-visibility-visibleto)) | | `precision` / `scale` | override the decimal default (16, 2) | | `readOnly` | rendered read-only in the UI (e.g. a calculated total) | | `major: false` | keep the column off the compact list table (still on the detail page) | @@ -86,6 +87,36 @@ By default the generated UI controls follow declaration order - all fields first Names match field / relation names (case-insensitive). A partial order is fine - any property not listed keeps its default position and is appended after the listed ones. +## unique — a business key over more than one field + +`unique: true` on a field constrains one column. When what makes a row unique spans several, +declare it on the entity instead: + +```yaml +entities: + - name: TenantApplication + unique: + - { fields: [tenant, application], message: "This application is already provisioned for the tenant" } +``` + +`fields` names fields or to-one relations of the same entity; a to-one relation contributes its +foreign-key column. The key is the combination of those columns - the declared order is how it +reads, and a conforming implementation is not required to give it any physical meaning. `message` is what a caller is told when a +write collides; omitted, an implementation derives one from the names. + +::: info Normative +The key MUST be enforced by the data store, so that it holds for every writer — a form, an import, +an arriving message, a scheduled creation — and not only for the ones that route through the +application. +A colliding write MUST be reported as a conflict that a caller can distinguish from a generic +failure, carrying the authored `message` when one was given. +Every name MUST resolve to a field or a to-one relation of the same entity; a to-many MUST be +rejected, having no column on this side to constrain. +A key naming a single field MUST be rejected, naming the field-level `unique` it duplicates. +A name repeated within one key, and a key declared twice on one entity, MUST be rejected. +An implementation is NOT required to add the constraint to a table that already exists. +::: + ## defaultValue — field defaults `defaultValue` states what a field holds when nobody supplies a value: @@ -296,6 +327,55 @@ The total is recomputed from the guarded entity's own rows for the incoming reco `outcome: task` stamps a flag; it does not create or route to a task. A workflow [decision](/spec/processes#decision-steps) reads the marker and routes the record - the two constructs compose, and the guard is the part that computes. +## Role-scoped field visibility — `visibleTo` + +A field is normally as visible as its entity. `visibleTo` narrows one field to the callers holding +**any one** of the listed roles — the salary on an employee, the cost price on an order line, the +credit limit on a customer — without splitting the record into a satellite entity: + +```yaml +permissions: + - { role: Payroll } + - { role: Administrator } + +entities: + - name: Employee + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true } + - { name: dailyRate, type: decimal, visibleTo: [Payroll, Administrator] } +``` + +Absent (the default), nothing changes: the field is visible to every caller who may read the +entity. The inverse spelling (`hiddenFor:`) is deliberately not part of the format — a deny-list +fails open (a role added later, or misspelled, would see the value); an allow-list fails closed. + +::: info Normative +A conforming generator MUST enforce the list where the data leaves the server, never only in the +presentation layer. On a **read**, the property is absent (or null) in every response of every +generated surface — the main one and the scoped ones — unless the caller holds one of the roles; +owning the record, or being the partner it belongs to, grants nothing. On a **write**, a create +ignores the submitted value and an update keeps the stored one — the rest of the write is +legitimate and MUST NOT be refused for carrying a field that is not the caller's to set. Where +the format records a field-level [history](/spec/entities#history-the-change-trail), a restricted property's +entries are withheld from a caller who may not read it. A derived value fed by a restricted field +(a [roll-up](/spec/glue#rollups-denormalised-parent-totals), an aggregated master total, a +[keyed aggregate](/spec/glue#aggregates-keyed-cross-entity-totals)) inherits the same allow-list unless it +declares its own — a sum of hidden figures is that figure one entity out. The generated UI SHOULD +omit the column or input for a caller who cannot read the field, and MUST derive that from what +the server actually withheld, not from a role list evaluated in the client. +::: + +Edge rules: every listed role must be granted by the file's [`permissions`](/spec/surfaces#permissions) (a role +nothing grants hides the field from everybody, which is a typo far more often than an intention); +an empty list is rejected rather than read as "no restriction"; the primary key, the entity's +`identity` field and a document-title field cannot be restricted (hiding them breaks the page, not +the figure); a restricted field cannot be a [`label`](/spec/entities#label-a-stored-display-name) token. A +**report** over a restricted field is a warning, not a rejection — a report carries no field-level +scoping, so the author is told which report re-serves which figure and scopes the report's own +roles accordingly. `sensitive` and `visibleTo` are independent and compose: the first is about a +surface, the second about a role. A relation (a foreign key) cannot be restricted this way. + ## immutableWhen / immutable — user-write immutability ```yaml @@ -330,9 +410,13 @@ Everything else about statuses is stated one edge at a time: `init:` says where - The graph is always over the entity's `function: EntityStatus` relation, so it names no column; the nomenclature must be seeded in the same file (a status entity owned by another model is seeded there, and so is its lifecycle). - A status not listed as any `from` is **terminal**; a status listed nowhere is simply unreachable through this entity. -> **Normative.** A conforming generator MUST validate every status write against the graph — user, workflow, glue, transition button alike — and reject a move no edge declares, with a message naming both statuses. Enforcement therefore belongs to the layer every writer passes through (the generated persistence layer), never to the transition endpoints alone, which would leave every other writer unguarded. Where the status relation declares `init:`, a record MUST also be *created* in that status: entering the lifecycle anywhere else skips the graph rather than travelling it. +::: info Normative +A conforming generator MUST validate every status write against the graph — user, workflow, glue, transition button alike — and reject a move no edge declares, with a message naming both statuses. Enforcement therefore belongs to the layer every writer passes through (the generated persistence layer), never to the transition endpoints alone, which would leave every other writer unguarded. Where the status relation declares `init:`, a record MUST also be *created* in that status: entering the lifecycle anywhere else skips the graph rather than travelling it. +::: -> **Normative.** With a lifecycle declared, `transitions` become **presentation over its edges**: each `from` status of a transition MUST reach its `setStatus` along a declared edge, and a status written by a workflow step or forced by a check's rejection MUST be one that some edge reaches. A conforming generator reports the disagreement when the file is read, not when the button is pressed — a reject path transiting through an approved status is exactly the mistake the graph exists to catch. +::: info Normative +With a lifecycle declared, `transitions` become **presentation over its edges**: each `from` status of a transition MUST reach its `setStatus` along a declared edge, and a status written by a workflow step or forced by a check's rejection MUST be one that some edge reaches. A conforming generator reports the disagreement when the file is read, not when the button is pressed — a reject path transiting through an approved status is exactly the mistake the graph exists to catch. +::: It composes with the [`stage:` classification](/spec/data#stage-what-a-status-means-to-the-lifecycle): a stage says what a status *means* (draft, live, cancelled, void) and scopes reports by it; the lifecycle says how a record may *move* between statuses. diff --git a/docs/spec/glue.md b/docs/spec/glue.md index 8b366f5..a2290ec 100644 --- a/docs/spec/glue.md +++ b/docs/spec/glue.md @@ -1,7 +1,6 @@ --- title: Declarative glue -description: notifications, schedules, integrations, inbound arrivals (webhook, message, file), roll-ups, keyed aggregates, settlements, expansions, generates, transitions, postings and event-driven row posting - declared in the intent, generated as integration code, never hand-written. -description: notifications, schedules, integrations, inbound webhooks, outbound departures, roll-ups, keyed aggregates, settlements, expansions, generates, transitions, postings and event-driven row posting - declared in the intent, generated as integration code, never hand-written. +description: notifications, schedules, integrations, inbound arrivals (webhook, message, file), outbound departures, roll-ups, keyed aggregates, settlements, expansions, resolves, generates, transitions, postings and event-driven row posting - declared in the intent, generated as integration code, never hand-written. --- # Declarative glue @@ -362,7 +361,6 @@ A bare word that names no field and no to-one relation of the record is a **lite Three value forms and four tokens is the cap, and the cap is the point: it expresses a frozen contract without the construct becoming a transformation language. A payload that needs more than this is an algorithm, and belongs in a hand-written handler — the honest hand-off. -## inbound — webhooks ## inbound — arrivals from outside Another system tells us — a JSON record shaped like the entity, ingested into it. What differs between the three forms is only **where the record arrives**; the action is the same `create`. @@ -557,6 +555,36 @@ Declaring an `event` drops the button unless `button: true` is declared as well; Prefer this over [`posts`](#posts-derived-rows-on-an-event) when the result is a document with line items — `posts` emits flat mapped rows and cannot reference the freshly created header. Prefer it over a button plus a [`wait`](/spec/processes#wait-park-the-process-on-a-data-event) step when the step is really waiting for a person to remember to click: an unclicked record parks its process instance indefinitely. +## 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`](/spec/relations) is an authoring-time copy matched by equality, a [`decision`](/spec/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](/spec/data#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`](/spec/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. + ## transitions — guarded status flips A per-record button that flips an entity's `function: EntityStatus` relation on demand — void, cancel, close, reopen — guarded by allowed source statuses and an optional condition. A flip from any other status (or a failing guard) is rejected; a successful flip publishes a `-transitioned` event that `postings` and integrations can observe. diff --git a/docs/spec/index.md b/docs/spec/index.md index 45567d9..f6391e2 100644 --- a/docs/spec/index.md +++ b/docs/spec/index.md @@ -35,12 +35,13 @@ The boundary is a feature, not a shortfall. Everything inside it is deterministi The altitude table names two authors: a human, and an AI assistant proposing patches. The assistant is held to the same honesty this specification demands of generators, which must report what they cannot resolve rather than ignore it: -> **Normative.** -> An authoring assistant that cannot express a requirement in this format MUST say so rather than -> silently substituting weaker semantics — a manual step proposed where automation was requested is -> a changed contract, not a smaller change. It MUST NOT drop a stated requirement from a proposal -> without reporting it. It SHOULD name the category of the gap and the designated hand-off point, -> and it MUST NOT imply that hand-off code will be generated when it is the developer's to write. +::: info Normative +An authoring assistant that cannot express a requirement in this format MUST say so rather than +silently substituting weaker semantics — a manual step proposed where automation was requested is +a changed contract, not a smaller change. It MUST NOT drop a stated requirement from a proposal +without reporting it. It SHOULD name the category of the gap and the designated hand-off point, +and it MUST NOT imply that hand-off code will be generated when it is the developer's to write. +::: ## Editor-first, not a runtime artefact