Skip to content

Commit 1f04696

Browse files
fix(trigger-record-change)!: a record-change flow's trigger record carries the credential mask and omits internal fields (#21928)
Fixes #21867 Clause-②: no ## What this changes Ruling A on #21867 (director's record 5995381726, alignment note 6005796816): mask at the source. `RecordChangeTrigger.buildContext` (`packages/triggers/trigger-record-change/src/record-change-trigger.ts`) now projects both roots it hands a flow, `record` and `previous`, through the one helper `omitInternalFieldsFromWriteResponse` (`@objectstack/core`, `packages/core/src/utils/internal-write-response.ts`), with the trigger object's definition. A credential-class field (every `secret` field, and every `password` field outside the exempt `managedBy` buckets, per ADR-0100 and `isMaskedOnReadFieldType`) carries `SECRET_MASK`, or `null` when unset. A field declared `internal: true` is omitted. `params` is the same object as `record`, so it inherits the projection. - **Applied last.** The projection runs after hydration, after declared-field materialisation and after the decoupling copy, so no later layer brings a clear value back. It runs in place on the decoupled copies only, so the engine's `ctx.result` / `ctx.previous` / `ctx.input`, which are shared with every other binding and hook on the write, are never touched. - **The definition is read regardless of ground truth.** Materialisation is gated on persisted state; the mask is not. A new private `readObjectDefinition` reads the engine's optional `getObject` accessor. When the definition cannot be resolved (accessor absent, no answer, or a throw), the flow still dispatches unmasked, and `readObjectDefinition` logs that once per object at error through the plugin logger, naming the object. The bind-time existence probe only warns and still binds; nothing upstream refuses an unknown object. - **Downstream inherits it, with no second copy.** The variables map (`record`, `$record`, `previous`), `SuspendedRun.context`, the persisted `variables_json` / `context_json`, the run read doors, and the run a resume rehydrates, in-process and after a restart. ⛔ No mask in `service-automation` or in the suspended-run store. ⛔ No other variable is filtered (#7900 stands). ## Premises verified before writing (at `origin/main` `dcb11c2ec9`) 1. **The definition is reachable in `buildContext`.** `this.engine.getObject` is already read there for materialisation. Re-check grep: 9 hits in `record-change-trigger.ts`. 2. **The projection is the last overlay.** The last layers are materialisation, then `decoupleFromEngineState` on both roots, then the return. The projection sits between the decoupling and the return. 3. **No shipped flow reads a credential-class field off its trigger record.** The card's grep over `examples/**/*flow*` and `examples/**/flows/**` returns zero hits (`git grep` exit 1). Control: the same paths carry `record.FIELD` reads in 4 files, so the zero is not a dead pattern. The only example object with `password` / `secret` fields is `showcase_field_zoo`. Its one record-change flow (`showcase_approver_bindings`, `status: 'draft'`) reads neither field. 4. **Only the trigger's own record enters here.** `get_record` and the other CRUD nodes (`service-automation/src/builtin/crud-nodes.ts`) read through `data.find` / `data.findOne`, the engine's generic read path, which ADR-0100 already masks. Nothing here touches those nodes. ## Pins - `packages/triggers/trigger-record-change/src/trigger-record-credential-mask.test.ts` (unit, fake engine, 13 cases): - `password` and `secret` carry the mask on `record` and on `previous`, and the `internal` field is omitted. - An ordinary field keeps its value, and `params` is the same object as `record`. - An unset credential reads `null`. - The engine's hook objects stay whole. - Insert events are masked too. - A `better-auth`-managed `password` keeps the read path's exemption. - `afterDelete` (record from the prior row) and `beforeUpdate` (payload over the prior row) are masked on both roots. - Each of the three unresolved-definition shapes (accessor absent, no answer, a throw) logs one error naming the object, while the flow runs on both writes. - A resolved definition logs no error. - `packages/qa/dogfood/test/flow-trigger-record-credential-mask.dogfood.test.ts`. A real boot: `bootStack` with automation, a file-backed database, the real crypto provider and the record-change trigger. It uses one object with an ordinary field, a `password` field, a `secret` field and an `internal` field, and one `record-after-update` flow that pauses at a `screen` node. The cases: - The scene is armed: the engine write result holds the stored values. - The paused row's `variables_json` and `context_json` carry the mask for both credential fields and omit the internal field (`record`, `$record`, `previous`), with no stored credential spelling anywhere in either column. - The data door over that row serves the same. - `GET /automation/:name/runs/:runId` shows the same. - After the resume, a node reading `record.CREDENTIAL_FIELD` / `previous.CREDENTIAL_FIELD` stores the mask, while the ordinary field stores its value. - The privileged `resolveSecretField` path still returns the plaintext. - A second suite pauses, stops the kernel, cold-boots a second kernel over the same file and resumes there. The post-pause node again stores the mask. - QA checklist: `automation.paused-run-trigger-record-masked` in `docs/qa/platform-checklist/areas/automation.json`. This is the item triage named as missing on the path "approvals and automation — flows run: errors, pauses and schedules". It covers reading a paused run's stored state as a non-privileged holder. `automated.ref` names the dogfood pin, and a `knownGaps` line says the pin reads as the admin. ## Upgrade text - Changeset `.changeset/21867-flow-trigger-record-credential-mask.md`: `@objectstack/trigger-record-change` minor, `@objectstack/spec` patch. It carries the `!` banner, FROM → TO and the one-line handling: a flow that needs a credential uses a privileged binder, never the trigger record. - It names the record-vs-previous credential comparison: a condition comparing the two sees two equal masks whenever the field is set on both sides, so a credential change is detected through a privileged binder. - It carries a "Runs stored before this release" paragraph: paused runs, and terminal runs that keep a restorable snapshot, created before the upgrade are resumed, cancelled or purged after upgrading. There is no migration and no scrub. - ADR-0087 semantic entry `packages/spec/src/migrations/entries/semantic/18.flow-trigger-record-credential-masked.ts`, a sibling of `18.by-id-write-unreadable-row-not-found`. It is registered through `gen:migration-registry` (`registry.ts`) and declared in the changeset as `registered flow-trigger-record-credential-masked`. - `packages/triggers/trigger-record-change/vitest.config.ts`: the alias moves to the anchored array form and gains `@objectstack/spec/data` and `@objectstack/core` to source; the `check-test-source-alias` registry entry for this package drops `@objectstack/core`. ## Verification Round 1 readings are at head `67ce8a46a2` unless marked. Round 2 readings are in their own block below, at head `4f287e072f`. - **Ablation.** The two projection calls were replaced via `scripts/ablation-replace.mjs`, wrap mode, with an EXIT/INT/TERM restore. On-disk proof: anchor 1 → 0, marker 0 → 1, blob `d0702684cb19` → `27a41720a0ab`. The dogfood project aliases `@objectstack/trigger-record-change` to source, and the plugin is passed in `extraPlugins` from that import, so no dist hop applies. - Unit pin: 3 red, 4 green. The four that stay green: ordinary value, `params` identity, unset reads null, hook objects whole. All four hold without a mask too. - Dogfood pin: 5 red, 2 green. The two that stay green: armed scene, privileged path. - Restore: blob == HEAD `d0702684cb19`, and `git diff HEAD` is empty. - **Tests.** - `@objectstack/trigger-record-change` `pnpm test`: 11 files, 108 tests, green at `67ce8a46a2`. - `@objectstack/core` `pnpm test`: 77 files, 2177 tests, green. - `@objectstack/service-automation` vitest: 173 files, 2112 tests, green. - Dogfood pin: 7/7 green, at `48e0b1cc35` (trigger source unchanged since). - `@objectstack/spec` `src/migrations`: 3 files, 179 tests, green. - **Typecheck.** - `@objectstack/trigger-record-change` `typecheck`, including `tsconfig.test.json`: green. `--listFiles` counts the new test file once. - `@objectstack/dogfood` `typecheck`: green, and it covers the new file. - `@objectstack/spec` `typecheck` (src, scripts, test layer): green. - **Gates.** - `@objectstack/spec` `check:generated`: all 15 artifacts up to date. - `check:adr-0087-registration`: green. It reads the changeset as `[BREAKING+bang] registered flow-trigger-record-credential-masked`. - `check:platform-checklist`: green. - `dispatch-gates.mjs --ran`: 90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN. - **Lint.** The run was narrowed to the 6 changed `.ts` files, under `eslint --no-inline-config --format json`: 6 files, 0 errors, 0 warnings. - The population comes from eslint's own config: the two non-code files (`.changeset/*.md` and `automation.json`) answer "File ignored because no matching configuration was supplied". - `--print-config` shows `parserOptions` without `project`, so type-aware linting is off. This diff cannot move any untouched file's verdict. ### Round 2, at head `4f287e072f` `origin/main` was merged in as a merge commit (`baa4b2fe6c`; the branch was 9 behind). - **Build and tests.** - Closure build `pnpm --workspace-concurrency=2 --filter '@objectstack/trigger-record-change...' build`: exit 0. - `@objectstack/trigger-record-change` `pnpm test`: 11 files, 114 tests, green. The mask file has 13 cases. - `@objectstack/trigger-record-change` `typecheck` (`tsc --noEmit && tsc --noEmit -p tsconfig.test.json`): exit 0 for both. - **Ablation 1, the core alias resolves to source.** Via `scripts/ablation-replace.mjs`, an early return was planted in `omitInternalFieldsFromWriteResponse` (`packages/core/src/utils/internal-write-response.ts`), with core `dist` not rebuilt (marker: 0 hits in `packages/core/dist`). Landed: anchor 1 → 0, blob `2a6a48c04fdb` → `d51283c8f5e6`. Result: 5 red, 8 green; the red ones are the masking cases, the new `afterDelete` and `beforeUpdate` included. Restore: blob == HEAD `2a6a48c04fdb`, `git diff HEAD` empty. A first attempt was refused by the tool as a no-op (the replacement contained the anchor); it measured nothing and was redone with a non-overlapping replacement. - **Ablation 2, the log pin can fail.** The error branch's condition was replaced with `false`. Landed: anchor 1 → 0. Result: 3 red (the absent, no-answer and throw cases), 10 green. Restore: blob == HEAD `04e3ca86825f`, `git diff HEAD` empty. - **Gates, each exit 0.** `check:adr-0087-registration` (reads `[BREAKING+bang] registered flow-trigger-record-credential-masked`; `--self-test` 441 assertions), `check-adr-0087-registration --base origin/main`, `check-changeset-no-major --base origin/main`, `check-empty-changeset --base origin/main`, `check:changeset-gate-self-tests`, `check:test-source-alias` (73 packages with tests scanned, 60 registered), `check:nul-bytes`, `check-scripts-symbol-anchors`, `check-published-list-mirrors`, `check:cross-package-test-inputs`, `check:doc-authoring`, `check:issue-citations`, `check:logger-receiver-detach`, `check-changeset-fixed`, `check:published-files`. - `@objectstack/spec` `check:generated` after the main merge: all 15 generated artifacts up to date, against the spec `dist` built post-merge. - NOT MEASURED: `check:console-injection`. It skipped, because there is no `packages/console/dist` in this worktree. - The rest of the `dispatch-gates` derivation (109 commands over the whole PR diff, mostly round-1 spec and dogfood families) was not re-run this round; CI owns it. - **Lint.** Narrowed to the 4 files changed this round (`record-change-trigger.ts`, `trigger-record-credential-mask.test.ts`, `vitest.config.ts`, `scripts/check-test-source-alias.mjs`), under `eslint --no-inline-config --format json`: 4 files, 0 errors, 0 warnings. All 4 are in eslint's own config, per `--print-config`, which also shows `parserOptions.project` and `projectService` undefined, so type-aware linting is off and this diff cannot move any untouched file's verdict. ## Acceptance notes - The claim's file surface names `packages/triggers/trigger-record-change/src`. This PR also touches that package's `vitest.config.ts` (the alias above) and adds one dogfood test file under `packages/qa/dogfood/test/`, as the dispatch asked. Round 2 also touches `scripts/check-test-source-alias.mjs`, a registry narrowing only (this package's entry drops `@objectstack/core`). - During the second full gate pass, `packages/plugins/plugin-approvals/dist` and `packages/plugins/plugin-auth/dist` were found without `.d.ts` (written mid-pass). `check:dts-closure` and `check:dual-build-cjs-loads` went red as a result. A rebuild of those two packages restored them, and both gates read green. Neither package is in this diff. Which step wrote them was not established. - Carrier: none; noted here only, not filed. In `buildContext`, the materialisation read of `getObject` (gated on ground truth) is not wrapped in try/catch. A `getObject` that throws therefore fails the dispatch before the mask runs, and the handler logs "execution failed". So `readObjectDefinition`'s throw branch is reachable only on an update or delete with no prior row. This behaviour predates the PR and was left untouched, because the dispatch said dispatch behaviour must not change. No public entry point is shown to throw from `getObject`. - Of the three operator actions for runs stored before this release, purging is the only one that leaves no clear value behind; resuming an old paused run can still write its clear values into the run's step log. A follow-up edit to the changeset should list purge first. - `48c162ed06` ports the three dogfood test-infra files of open PR #21935 (`packages/qa/dogfood/test/per-file-cwd.setup.ts`, `per-file-cwd.global-setup.ts`, `packages/qa/dogfood/vitest.config.ts`), byte-identical, to clear the `PM dispatch-gates self-test` red that `main` has carried since #21919. It is a no-op once #21935 lands. --- _Generated by [Claude Code](https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 412d7dd commit 1f04696

9 files changed

Lines changed: 855 additions & 8 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/trigger-record-change': minor
3+
'@objectstack/spec': patch
4+
---
5+
6+
fix(trigger-record-change)!: a record-change flow's trigger record carries the credential mask and omits internal fields
7+
8+
Clause-②: no
9+
10+
<!-- adr-0087: registered flow-trigger-record-credential-masked -->
11+
12+
**BREAKING**: the `record` and `previous` a record-change flow receives are now served on the generic read path's terms (ADR-0100). A credential-class field — every `secret` field, and every `password` field outside the exempt `managedBy` buckets — reads as the mask `SECRET_MASK` when set and `null` when unset, and a field declared `internal: true` is absent. It ships as `minor` under the launch-window convention for a changed answer. No export, schema key or error code is added or removed.
13+
14+
**What changed.** The trigger built both roots from the engine's own write result, which keeps the stored row whole for privileged in-process callers. A credential's stored value and an internal field's value therefore reached the flow, and from there its variables map, a paused run's persisted state and the read doors over that state. The trigger now projects both roots through `omitInternalFieldsFromWriteResponse` from `@objectstack/core`, the helper every external write response already uses, with the trigger object's definition. Everything downstream inherits the projection: the variables map, a paused run's persisted state and its read doors, and the run a resume rehydrates, in the same process and after a restart.
15+
16+
**FROM → TO.**
17+
- `{record.<password or secret field>}` and `{previous.<password or secret field>}` in a record-change flow: FROM the stored value (the plaintext password, or the secret's stored handle) → TO `SECRET_MASK` when set, `null` when unset.
18+
- `{record.<internal field>}` and `{previous.<internal field>}`: FROM the stored value → TO absent.
19+
20+
**If you are affected.** A flow that needs a credential reads it through a privileged binder (the flow credential channel, or a privileged server-side read such as the engine's `resolveSecretField`), never off the trigger record. A start or edge condition that compared such a field with a literal tests whether it is set (`!= null`) instead. A condition that compares `record.<credential field>` with `previous.<credential field>` now sees two equal masks whenever the field is set on both sides, so it can no longer detect a change; use a privileged binder to detect a credential change.
21+
22+
**Runs stored before this release.** The mask applies to trigger records built after the upgrade. Paused runs, and terminal runs that keep a restorable snapshot, created before it still hold the clear values in `variables_json`, `context_json` and `steps_json`. After upgrading, resume, cancel or purge those runs.
23+
24+
**Unchanged.**
25+
- Every ordinary field of the trigger record keeps its value, and every other flow variable is untouched.
26+
- The engine's own write result, the stored row and the privileged read paths (`resolveSecret`, `resolveSecretField`) are unchanged.
27+
- Records a flow reads later through its data nodes already came through the generic read path, which masks them.

‎docs/qa/platform-checklist/areas/automation.json‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -944,6 +944,98 @@
944944
}
945945
]
946946
},
947+
{
948+
"id": "automation.paused-run-trigger-record-masked",
949+
"title": "A paused record-change run's stored state serves its trigger record masked — credential-class fields as the mask, internal fields absent — to a run-state reader, hot and after a cold boot",
950+
"since": "v17",
951+
"status": "active",
952+
"revision": 1,
953+
"priority": "P2",
954+
"surface": "api",
955+
"personas": [
956+
"admin",
957+
"a member granted read on sys_automation_run and nothing elevated"
958+
],
959+
"fixtures": {
960+
"app": "showcase",
961+
"requires": [
962+
"a FILE-backed database (the cold-boot clause is structurally unreachable on the in-memory store — record the db path in the run env)",
963+
"an object declaring one ordinary field, one password field, one secret field and one internal: true field, and an ACTIVE record-change flow on it (record-after-update) that pauses at a screen node and, after the resume, copies {record.<each field>} and {previous.<password field>} into a second object",
964+
"a member persona whose permission set grants read on sys_automation_run (and on the echo object) but is not a platform admin"
965+
],
966+
"knownGaps": [
967+
"Stock showcase has the credential-class object (showcase_field_zoo carries f_password and f_secret) but no ACTIVE record-change flow on it that pauses: showcase_approver_bindings is draft on purpose. Author the fixture flow at runtime (and the member's permission set) or score the item from its pin, recording which the verdict rests on.",
968+
"The pin reads every surface as the seeded admin. The mask is applied where the trigger record is built, so it does not depend on the reader, but the member-persona reads in steps 3 and 4 are this item's own clause: score them by hand, never from the pin."
969+
]
970+
},
971+
"steps": [
972+
"boot showcase isolated against a file DB (dogfood §0); sign in as the dev admin",
973+
"create a row of the fixture object with all three non-ordinary fields set (a password, a secret, an internal value), then PATCH its ordinary field and its password so the record-change flow fires and pauses",
974+
"as the member persona, read the paused sys_automation_run row by id over GET /data/sys_automation_run/:id and parse variables_json and context_json",
975+
"as the member persona, read GET /automation/:name/runs/:runId",
976+
"resume the run (POST /automation/:name/runs/:runId/resume with the screen's required input) and read the echo row the post-pause node wrote",
977+
"repeat the pause on a second row, stop the server process entirely, cold-boot a second server over the SAME database file, resume there, and read the echo row"
978+
],
979+
"acceptance": [
980+
{
981+
"clause": "the paused row's variables_json and context_json serve record, $record and previous with the password and secret fields as the mask and the internal field absent, while the ordinary field reads its value",
982+
"oracle": "api",
983+
"verify": "parse both columns: record/previous password and secret = the SECRET_MASK constant (null where unset), the internal key absent, name = the written value; and no stored credential spelling (the plaintext password, a secret: handle, the internal value) appears anywhere in either column",
984+
"evidence": "row read + parsed columns"
985+
},
986+
{
987+
"clause": "GET /automation/:name/runs/:runId serves the same masked roots",
988+
"oracle": "api",
989+
"verify": "the run's variables.record and variables.previous carry the mask for both credential fields, omit the internal field, and keep the ordinary field's value; no stored credential spelling in the body",
990+
"evidence": "response body"
991+
},
992+
{
993+
"clause": "a node after the pause reads the mask off record and previous, and an ordinary field reads its value",
994+
"oracle": "api",
995+
"verify": "the echo row: seen_name = the written name; seen_password, seen_token and seen_previous_password = the mask",
996+
"evidence": "echo row read"
997+
},
998+
{
999+
"clause": "after a cold boot the rehydrated run resumes with the same masked record",
1000+
"oracle": "api",
1001+
"verify": "the echo row written by the SECOND process carries the mask for both credential fields and the ordinary field's value",
1002+
"evidence": "pre-restart run id + post-restart echo row read"
1003+
},
1004+
{
1005+
"clause": "the privileged read path is unchanged: the stored row still holds the credential and resolveSecretField still returns the secret's plaintext",
1006+
"oracle": "test",
1007+
"verify": "run the pin; its armed case reads the engine's write result (plaintext password, secret: handle, internal value) and its last case resolves the secret field to its plaintext",
1008+
"evidence": "pin output naming the revision"
1009+
}
1010+
],
1011+
"negative": [
1012+
"a stored credential spelling anywhere in variables_json, context_json or the run read door — whichever persona reads it — is a FAIL: the run's trigger record is served on the generic read path's terms, so no run-state reader holds more than a read of the record would give it",
1013+
"a mask on an ORDINARY field, or on a variable that is not the trigger record, is a FAIL in the other direction: only the trigger record's credential-class and internal fields change"
1014+
],
1015+
"traps": [
1016+
"wrong-persona",
1017+
"stale-dist",
1018+
"seed-data-thin"
1019+
],
1020+
"automated": {
1021+
"kind": "e2e",
1022+
"ref": "packages/qa/dogfood/test/flow-trigger-record-credential-mask.dogfood.test.ts"
1023+
},
1024+
"source": [
1025+
"packages/qa/dogfood/test/flow-trigger-record-credential-mask.dogfood.test.ts (the pin, hot and cold boot)",
1026+
"packages/triggers/trigger-record-change/src/record-change-trigger.ts#RecordChangeTrigger (buildContext projects record and previous through the write-response helper)",
1027+
"packages/core/src/utils/internal-write-response.ts#omitInternalFieldsFromWriteResponse (the one mask-and-omit helper)",
1028+
"docs/adr/0100-credential-field-channels.md (masked on every generic channel; plaintext only through a privileged dereference)"
1029+
],
1030+
"history": [
1031+
{
1032+
"revision": 1,
1033+
"date": "2026-10-06",
1034+
"change": "initial — the run-state path had no item reading a paused run's stored state as a non-privileged holder; adds it with the hot, cold-boot and privileged-path clauses",
1035+
"ref": "#21867"
1036+
}
1037+
]
1038+
},
9471039
{
9481040
"id": "automation.connector-dispatch-matrix",
9491041
"title": "connector_action dispatches through every registered connector kind, and the registry feeds the designer pickers",

0 commit comments

Comments
 (0)