From 54368ffb0fb0e6fdb44444b7d033f48a2277abca Mon Sep 17 00:00:00 2001 From: delchev Date: Sat, 15 Aug 2026 08:24:38 +0300 Subject: [PATCH] spec: history - the shadow change trail `audit: true` records only the last writer and time, in four columns of the row itself. A domain that has to answer what changed, from what to what, by whom and when - for every write - had no construct; the 1.1 "Planned" list named shadow audit-history entities. `history: true` on an entity declares a shadow history table: one entry per property whose value actually changed on a write, carrying the property, its old and new value, who wrote it, when, and whether the write came from a user or from the system. A create is null -> value and a delete value -> null, so the trail alone reconstructs the row at any point in its life. Normative rules: the table is append-only by construction (no create, update or delete path may be emitted for it - append-only enforced by policy is not append-only); every write path the data-access layer offers must append, including the targeted writes the system uses; the entry records the source; representation-only differences (a decimal of another scale, a translated overlay) are not changes; the primary key and the audit columns are not tracked; and a scoped surface that hides sensitive fields is not handed a trail it cannot filter. Reference implementation: eclipse-dirigible/dirigible#6734. --- versions/1.2.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/versions/1.2.md b/versions/1.2.md index f321557..005dcbb 100644 --- a/versions/1.2.md +++ b/versions/1.2.md @@ -265,6 +265,7 @@ Generators map each logical type to a physical column type. `text` is a large-ob | Attribute | Effect | | --- | --- | | `audit: true` | adds the four standard audit columns, populated automatically | +| `history: true` | records every write as field-level deltas in a shadow history table (see [history](#history--the-change-trail)) | | `multilingual: true` | makes string properties translatable (see [multilingual data](#multilingual-data)) | | `label:` | a stored, read-only display name (see [label](#label--a-stored-display-name)) | | `function:` | an explicit presentation role (see [function](#function--the-presentation-role)) | @@ -522,6 +523,52 @@ different lifecycles on the same document. > declaration is indistinguishable from a working one until someone needs it. > It does not apply to a document's own line items, which ARE the document's content. +### history — the change trail + +`audit: true` records only the **last** writer and time, in four columns of the row itself. Where a +domain has to answer *what changed, from what to what, by whom, when* — for every write, for years — +declare a history: + +```yaml +- name: Contract + audit: true + history: true # every write is recorded as field-level deltas + fields: + - { name: id, type: integer, primaryKey: true } + - { name: amount, type: decimal } +``` + +The entity gains a **shadow history table** — a sibling of its own table, like the +[multilingual](#multilingual-data) language table — carrying one entry per property whose value +actually changed on a write: the property, its old value, its new value, who wrote it, when, and +whether the write came from a **user** or from the **system** (a roll-up total, a workflow +write-back, a recomputed document total). A create is recorded as `null -> value` and a delete as +`value -> null`, so the trail alone reconstructs the row at any point in its life. The record's own +form shows it as a read-only **History** panel. + +The source matters as much as the delta. Once a total the application recomputed and an amount a +person typed sit in the same column, nothing downstream can tell them apart — and "who changed this" +is the first question asked of a trail. + +> **Normative.** +> The shadow table is **append-only by construction**: a generator MUST NOT emit any create, update +> or delete path to it — not a service, not an endpoint, not a UI affordance. Append-only enforced by +> policy is not append-only. +> Every write path the generated data-access layer offers MUST append, including the targeted +> single-column and multi-column writes the system uses; a path that writes silently is worse than +> no trail, because the trail then reads as complete. +> An entry MUST record whether the write was a user write or a system write. +> Only properties whose value actually changed are recorded. Values that differ solely in +> representation (a decimal of a different scale, a translated overlay of a stored value) are NOT +> changes, and a generator MUST NOT record them as such. +> The primary key and the audit columns are NOT tracked — the key never changes and the audit columns +> restate what the entry already carries. +> A [scoped surface](#personal-and-partner-surfaces) that hides `sensitive:` fields MUST NOT be given +> a history it cannot filter: either the trail it exposes excludes those properties, or it exposes +> none. Leaking a hidden field's old and new values defeats the scoping exactly. +> Rows written outside the generated data-access layer — [seeds](#seeds), direct database writes — +> have no history, and a conforming tool documents that rather than implying completeness. + ### hierarchy / leafOnly — tree entities ```yaml @@ -1626,6 +1673,7 @@ One line per construct, linking into the chapters above. | [`checks`](#checks--declarative-validations) | cross-field / cross-line validations | | [`immutableWhen` / `immutable`](#immutablewhen--immutable--user-write-immutability) | reject user writes in a status / append-only | | [`locksWithMaster`](#lockswithmaster--a-child-collection-that-outlives-its-masters-lock) | a child collection that stays writable while its master is locked | +| [`history`](#history--the-change-trail) | a shadow, append-only trail of every write: property, old and new value, who, when, user or system | | [`hierarchy` / `leafOnly`](#hierarchy--leafonly--tree-entities) | tree entities, leaf-only references | | [calculated fields](#calculated-fields) | server + UI-evaluated expressions, date helpers, call-outs | | [`relations` / `composition`](#relations) | associations and master-detail compositions | @@ -1671,7 +1719,7 @@ The following are parsed (or reserved) but not yet materialised by a generator; - Reserved `function` values for upcoming presentations (`Board`, `Gantt`, `Timeline`). - **`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). +- Event-driven **document** generation (produce a whole document, rather than the mapped rows of [`posts`](#posts--derived-rows-on-an-event), on an event) and a declarative state machine. - 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