Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/20596-trigger-record-change-provenance-anchors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/trigger-record-change': patch
---

Provenance comments in `trigger-record-change` were re-anchored

Comment and docblock lines under `src/` that cited tracker numbers which no
longer resolve on GitHub now cite the commit in this repository's history that
decided the matter, and say in their own words what was decided. Comments
only: no type, schema, export, log or refusal text, or runtime behaviour changes.
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#15356 measured, #14744 closed] PIN — a `record-before-update` flow reaches
* [#15356 measured, commit 4f85e4d11 closed] PIN — a `record-before-update` flow reaches
* NO write shape into the BATCH PAYLOAD of a `multi: true` update.
*
* ## What changed about this file, and what did not
*
* It was written for #15356 as a MEASUREMENT and it answered NOT BOUNDED: one
* shape (`S5`) reached the payload. #14744 then ruled the door closed —
* shape (`S5`) reached the payload. The option-A ruling (commit 4f85e4d11) then closed the door —
* `buildContext` decouples the flow-facing roots from the engine's own objects
* (`decoupleFromEngineState`) — and this file was adopted whole as the pin.
* `S5`, `S5b` and the SQL replica were FLIPPED to their opposites in that same
Expand All @@ -19,19 +19,19 @@
*
* The two controls are why the negatives are readable, and BOTH must keep
* firing: the positive control (a script hook that ASSIGNS the payload — the
* #14744 residue shape — still lands the LAST dispatch's value on every row,
* because #14744's fix is about aliasing and deliberately does not touch that
* residue shape commit 4f85e4d11 pins — still lands the LAST dispatch's value on every row,
* because commit 4f85e4d11 fixes aliasing and deliberately does not touch that
* residue) and the #14099 armed control (divergent key sets are still refused
* whole). If either stops firing, this file has stopped measuring.
*
* ## Why this file exists
*
* #14744's census found the in-repo population of same-key / per-row-VALUE
* The census in commit 03c1b0f6f found the in-repo population of same-key / per-row-VALUE
* `beforeUpdate` payload rewrites is ZERO across 23 production registration
* sites. One door that zero does not bound was named there but never driven:
* `record-change-trigger.ts`'s `start()` binds `beforeUpdate` for the
* `record-before-update` / `record-before-write` trigger types and hands the
* write to USER-AUTHORED FLOW METADATA. The conclusion recorded on that card —
* write to USER-AUTHORED FLOW METADATA. The conclusion recorded in that census —
* `buildContext` materialises a NEW record object by overlay rather than
* handing the flow `ctx.input.data` by reference, so a flow cannot reach the
* batch payload — was labelled by its own author a SOURCE READING, explicitly
Expand All @@ -44,7 +44,7 @@
* write shape that mutated a nested value IN PLACE therefore wrote the batch
* payload without ever assigning a top-level key. See `S5` below.
*
* ⭐ #14744 made the reading's sentence TRUE AS STATED rather than deleting it:
* ⭐ Commit 4f85e4d11 made the reading's sentence TRUE AS STATED rather than deleting it:
* the overlay still materialises a new object, and `decoupleFromEngineState`
* now makes that true of the object's CONTENTS too. The distinction is worth
* keeping in front of the next reader — "a new object" and "reaches nothing"
Expand Down Expand Up @@ -84,15 +84,15 @@
* ## Two controls, because a negative needs them
*
* - `positive control` — a script `beforeUpdate` hook that DOES assign the
* payload (the #14744 pinned residue shape), in this same harness, showing
* payload (the residue shape commit 4f85e4d11 pins), in this same harness, showing
* the last dispatch's value on every row. Without it firing, every "does not
* reach" below would be a claim about this harness, not about flows.
* - `#14099 armed control` — a hook writing DIVERGENT KEY SETS per row must
* be refused whole, so "the refusal did not fire for the nested shape" is a
* measurement rather than an unarmed check.
*
* ⚠️ #15356 was a MEASUREMENT card: no guard, no write-shape change, no ADR.
* #14744 carries the fix, and it is still not a write-shape change: ADR-0058
* Commit 4f85e4d11 carries the fix, and it is still not a write-shape change: ADR-0058
* Addendum II D3 stands untouched — the engine does not split its own write,
* one payload still serves N rows, and every per-row context is still handed
* that one object (asserted in `S5`). What changed is only that the object a
Expand Down Expand Up @@ -203,7 +203,7 @@ function makeDriver(): any {

/**
* `residue` and `tags` are DECLARED fields on purpose: the engine's
* declared-field door (#8738 pre-hook / #13657 post-hook) refuses a payload
* declared-field door (#8738 pre-hook / commit b003cf2e8 post-hook) refuses a payload
* carrying an undeclared key, so a probe writing an undeclared name would be
* measuring that refusal instead of the reach question.
*
Expand Down Expand Up @@ -452,7 +452,7 @@ describe('[#15356] can a record-before-update flow reach a multi:true batch payl

// The residue shape: a per-row-VALUE write of the SAME key on every row.
// The key SET is identical across rows, so #14099's divergence refusal does
// not fire — that is precisely the blind spot #14744 is weighing.
// not fire — that is precisely the blind spot commit 4f85e4d11 left unguarded.
const dispatched: string[] = [];
stack.objectql.registerHook(
'beforeUpdate',
Expand Down Expand Up @@ -639,7 +639,7 @@ describe('[#15356] can a record-before-update flow reach a multi:true batch payl
/**
* ⭐ S5 — THE PIN. This case was written on 2026-09-04 as a CHARACTERISATION
* of the defect (the nested in-place mutation REACHED the payload, and both
* rows carried both dispatches' contributions). #14744 closed the door on the
* rows carried both dispatches' contributions). Commit 4f85e4d11 closed the door on the
* same day by decoupling the flow-facing roots from the engine's own objects
* (`decoupleFromEngineState`, called at the end of `buildContext`), and the
* case was flipped in the same PR — the assertions below are the OPPOSITE of
Expand Down Expand Up @@ -683,7 +683,7 @@ describe('[#15356] can a record-before-update flow reach a multi:true batch payl
expect(observed, 'the script function must have RUN, once per row').toHaveLength(2);
// Row 2 does NOT see row 1's mutation: each dispatch is handed its own copy
// of the nested value, so neither run can observe the other's write. Before
// #14744 the second reading was `["seed","REACHED-alpha"]`.
// commit 4f85e4d11 the second reading was `["seed","REACHED-alpha"]`.
expect(observed[0]?.tagsAsSeen).toBe('["seed"]');
expect(observed[1]?.tagsAsSeen).toBe('["seed"]');
// Reference identity, measured across the same boundary the defect was
Expand All @@ -702,7 +702,7 @@ describe('[#15356] can a record-before-update flow reach a multi:true batch payl

// ⭐ The persisted rows: the SET clause carries what the CALLER wrote, and
// no row carries a value derived from the other row's pre-image. Before
// #14744 both rows read `['seed','REACHED-alpha','REACHED-beta']`.
// commit 4f85e4d11 both rows read `['seed','REACHED-alpha','REACHED-beta']`.
expect(wrote, 'and the write still succeeds — this is not a refusal').toMatchObject({ ok: true });
const rows = await rowsByTitle(stack.data, object);
expect(rows.get('alpha')?.tags).toEqual(['seed']);
Expand Down Expand Up @@ -735,7 +735,7 @@ describe('[#15356] can a record-before-update flow reach a multi:true batch payl
// ⚠️ THE BREAKING HALF, pinned deliberately. The aliasing was never
// multi-specific: on a by-id write the same in-place mutation reached this
// write's own payload and PERSISTED correctly (`['seed','REACHED']` before
// #14744), so it read as a working per-row write path rather than as
// commit 4f85e4d11), so it read as a working per-row write path rather than as
// corruption. It is the same alias, so closing the door closes it here too,
// and a stack author using it loses a write that used to land. That is why
// the changeset carries a BREAKING banner: the alternative is `update_record`
Expand Down Expand Up @@ -802,7 +802,7 @@ describe('[#15356] can a record-before-update flow reach a multi:true batch payl
}, 20000);

/**
* ⭐ [#14744] THE CONTROL ON THE FIX'S SHAPE — why a COPY and not a FREEZE.
* ⭐ [commit 4f85e4d11] THE CONTROL ON THE FIX'S SHAPE — why a COPY and not a FREEZE.
*
* The ruling named deep-copy and freeze as alternatives. They are not
* equivalent, and this case is the measurement that chose between them:
Expand Down Expand Up @@ -856,7 +856,7 @@ describe('[#15356] can a record-before-update flow reach a multi:true batch payl
});

/**
* [#15356 measured it, #14744 closed it] S5 again, on the REAL SQL backend —
* [#15356 measured it, commit 4f85e4d11 closed it] S5 again, on the REAL SQL backend —
* `@objectstack/driver-sql` over better-sqlite3 `:memory:`, built the canonical
* way this package's `record-change-integration.test.ts` boots it.
*
Expand Down Expand Up @@ -921,7 +921,7 @@ describe('[#15356/#14744] S5 on the real SQL driver — the mutation reaches no
console.log('[#15356] SQL rows:', JSON.stringify([...rows.values()].map((r) => ({ title: r.title, tags: r.tags }))));
const alpha = rows.get('alpha')?.tags;
const beta = rows.get('beta')?.tags;
// Before #14744 both read `['seed','REACHED-alpha','REACHED-beta']` — one
// Before commit 4f85e4d11 both read `['seed','REACHED-alpha','REACHED-beta']` — one
// SET clause carrying both dispatches, including the value derived from the
// other row's pre-image.
expect(alpha).toEqual(['seed']);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14744] The flow-facing `record` / `previous` roots share no mutable object
* [commit 4f85e4d11] The flow-facing `record` / `previous` roots share no mutable object
* with the engine's own state.
*
* `before-update-flow-payload-reach.test.ts` is the END-TO-END pin: it boots a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

/**
* Decouple the flow-facing `record` / `previous` roots from the ENGINE-OWNED
* objects they were overlaid from (#14744, measured by #15356).
* objects they were overlaid from (commit 4f85e4d11, measured by #15356).
*
* ## The leak this closes
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { SqlDriver } from '@objectstack/driver-sql';
import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation';
import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts';
import { RecordChangeTriggerPlugin } from './plugin.js';
// [#11081] `@objectstack/runtime`'s shared expected-noise capture. This import
// [commit c28e4cfae] `@objectstack/runtime`'s shared expected-noise capture. This import
// escapes the package on PURPOSE, so it is DECLARED rather than left for CI to
// discover: `CROSS_PACKAGE_TEST_INPUTS` in
// `scripts/check-cross-package-test-inputs.mjs` names the one file, and
Expand Down Expand Up @@ -65,7 +65,7 @@ type TestObjectQLEngine = IObjectQLEngine & {
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

/**
* [#11081] The tables this file deliberately never provisions — and therefore
* [commit c28e4cfae] The tables this file deliberately never provisions — and therefore
* the ONLY read refusals whose log frames may be withheld here.
*
* Every `it` below boots a kernel with no datasource, attaches sqlite late, and
Expand Down Expand Up @@ -196,11 +196,11 @@ const authzResolverObjects = [
},
] as const;

/** [#11081] Shared by every kernel this file boots; asserted once in `afterAll`. */
/** [commit c28e4cfae] Shared by every kernel this file boots; asserted once in `afterAll`. */
const noise = captureExpectedReadRefusals([...EXPECTED_ABSENT_PROBE_TABLES]);

/**
* [#11081] The PIN half. ⛔ Repairing a failure here means re-deriving the list
* [commit c28e4cfae] The PIN half. ⛔ Repairing a failure here means re-deriving the list
* above or finding out why a probe stopped firing — NEVER deleting the channel:
* a runtime read that silently stopped happening is exactly the finding this
* assertion exists to make loud.
Expand Down Expand Up @@ -243,7 +243,7 @@ afterEach(async () => {
*/
async function attachSqlite(objectql: any): Promise<any> {
const driver = makeSqliteDriver();
// [#11081] Before `connect()` — i.e. before the driver runs any statement, the
// [commit c28e4cfae] Before `connect()` — i.e. before the driver runs any statement, the
// discipline `captureExpectedReadRefusals` documents. `logger` is a protected
// field with a `console` default, so the sink also RESTORES a loud channel:
// an unexpected driver fault reaches the real console from here even though
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export interface TriggerLogger {
* `ctx.previous`, observed by every OTHER binding sharing the same
* HookContext — pass a copy in. ⚠️ A SHALLOW copy is enough for THIS function
* (it only ever assigns top-level keys), and it is NOT enough for the object
* that reaches a flow: see {@link decoupleFromEngineState} and #14744.
* that reaches a flow: see {@link decoupleFromEngineState} and commit 4f85e4d11.
*
* Exported (module-scope only — NOT re-exported from `index.ts`, so this
* stays off the package's published API) so
Expand Down Expand Up @@ -337,8 +337,8 @@ export class RecordChangeTrigger implements FlowTrigger {
* declared fields (see the `materializeDeclaredFields` call below).
*
* ⭐ Both roots it returns are a SNAPSHOT and are DECOUPLED from the
* engine's own state (#14744): a flow can mutate them however it likes and
* reach nothing outside its own run. Until #14744 that was true only of the
* engine's own state (commit 4f85e4d11): a flow can mutate them however it likes and
* reach nothing outside its own run. Until commit 4f85e4d11 that was true only of the
* TOP LEVEL — every overlay here is a shallow spread, so each nested value
* was still the engine's own object, and `inputData` is the batch payload
* ADR-0058 Addendum II D3 shares across every row of a `multi: true` write.
Expand Down Expand Up @@ -450,7 +450,7 @@ export class RecordChangeTrigger implements FlowTrigger {
const materializedPrevious =
priorBase && fields ? materializeDeclaredFields({ ...priorBase }, fields) : previous;

// #14744 — DECOUPLE the flow-facing roots from the engine's own objects.
// Commit 4f85e4d11 — DECOUPLE the flow-facing roots from the engine's own objects.
// Every overlay above is a SHALLOW spread, so until this point each
// nested value in `record` is still the engine's: `inputData` is
// `ctx.input.data`, which ADR-0058 Addendum II D3 shares across every
Expand Down Expand Up @@ -493,7 +493,7 @@ export class RecordChangeTrigger implements FlowTrigger {
...(session.organizationId ? { tenantId: session.organizationId } : {}),
// Expose the record as params too, so flows with named `isInput`
// variables matching record fields get them seeded. Deliberately the
// SAME object as `record` (unchanged by #14744 — `params` was never a
// SAME object as `record` (unchanged by commit 4f85e4d11 — `params` was never a
// second snapshot, and making it one here would be an observable
// change on top of the aliasing fix).
params: isolatedRecord,
Expand Down
Loading