You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
security(automation): a paused flow run's stored state keeps record-change values that #21830's mask removes elsewhere — detail withheld pending maintainer #21867
Found by the independent security review of PR #21866 (the fix for #21830). #21830 masks credential-class and internal fields in record-change payloads and their downstream stores; one more store of the same class remains.
Class: authorization (disclosure across the write boundary to holders of a read grant on run state). Under RUNNER rule 2 the detail is withheld pending maintainer, held in the PM session (Claude Code session session_018zT8d8NpiQ1ExhuNd5TxY6).
Path: approvals and automation — flows run: errors, pauses and schedules | 缺项 (no item reads a paused run's stored state as a non-privileged holder) | P2
Triage: first grade — bug · security · priority:p2 · domain:services · area:access · needs-user-decision. Masking run state would narrow ruling #7900's "no per-field filtering of the variables map", so the maintainer rules
Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-05T11:52Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes and positions only. The withheld detail stays withheld (RUNNER rule 2); this seat does not hold it.
Triage: lands where a paused run's state is persisted (service-automation, by class) ⇒ domain:services; rationale: it is the last store of #21830's class. The card names its direction, and the one obstacle is an earlier ruling.
Ruling: batch #278 item 1 · letter A · maintainer 「21867 同意」 2026-10-05T13:24Z
Director seat, summon #33, session_011SekRJwTRqXSmsP6xTSci4 (GitHub os-tesla; written as objectstack-fleet[bot] via the relay). Batch #278 was presented in chat with options A, A′ and B; the maintainer answered 「21867 同意」, which takes the recommendation, A. Freshness gate: the body (last edited 2026-10-05T11:53Z) and the one comment (triage 5993882228) were re-read before this record; nothing newer exists. ⛔ Classes, positions and functions only; the withheld detail (RUNNER rule 2) stays in the holding session and is not on this record.
The ruling
A — mask at the source: the record-change trigger builds the flow's trigger record already masked.RecordChangeTrigger.buildContext (packages/triggers/trigger-record-change/src/record-change-trigger.ts, the one place the flow's record and previous are built from the engine hook's ctx.result / ctx.previous) projects both through the one helper PR #21816 landed and PR #21866 reused — omitInternalFieldsFromWriteResponse (@objectstack/core, packages/core/src/utils/internal-write-response.ts:169-204), with the object definition of the trigger's object — so credential-class fields carry SECRET_MASK (or null when unset) and internal: true fields are omitted. Everything downstream inherits it without a second copy: the variables map (record, $record, previous, service-automation/src/engine.ts:12463-12469), SuspendedRun.context (record / previous, packages/spec/src/contracts/automation-service.ts:26-33), the persisted variables_json / context_json (suspended-run-store.ts:988-990), the /automation/:name/runs/:runId answer and the /data/sys_automation_run columns, and the run a flow resumes with, in-process and after a restart alike.
Clause-②: no. No Zod or ADR-0100 table change. A flow author's visible value changes (a credential-class field read off the trigger record is now the mask), so the changeset carries the ! banner, FROM → TO and the one-line handling, and an ADR-0087 semantic entry, sibling of 18.by-id-write-unreadable-row-not-found.ts, carries the changed answer to the upgrade guide: a flow that needs a credential uses a privileged binder, never the trigger record (ADR-0100 A, "privileged read").
Execution parameters. This card continues (no new card): needs-user-decision → pm:queue; domain:services, priority:p2, security, area:access stay. At-tier contract review before landing (security boundary). The PR text stays at classes and positions. The claiming seat obtains the withheld consumers and mechanism from the holding session session_018zT8d8NpiQ1ExhuNd5TxY6 on this word; ⛔ the detail goes on neither the card nor the PR.
Pins. On a flow paused after a record-change trigger on an object with a secret and a password field: the paused row's variables_json and context_json carry SECRET_MASK for both and omit internal: true fields; GET /automation/:name/runs/:runId shows the same; a node after the pause reading record.<credential field> sees the mask, in-process and after a restart; a node reading an ordinary field sees its value; a privileged resolveSecret path is unchanged; the QA path "approvals and automation — flows run: errors, pauses and schedules" gains the item triage named as missing.
Premises the dev verifies before writing (third-tier ruling: a failed premise is reported as fork on this card — do not force A, do not fall back to A′ or B silently)
The object definition is reachable in buildContext. The trigger already materialises the record over the object's declared fields (materializeDeclaredFields, since #4649 的「记录对已声明字段全量」只落在两个接缝上 —— 另外三处求值仍是稀疏绑定 #4953), so the definition is in hand or one registry read away. Re-check: git grep -n "materializeDeclaredFields\|hydrateComputedFields" origin/main -- packages/triggers/trigger-record-change/src/record-change-trigger.ts.
The projection is the last overlay.buildContext layers ctx.result over the prior row and re-reads computed fields; the mask must be applied after the last overlay so no later layer re-introduces a clear value. Re-check: read buildContext from record-change-trigger.ts:333 to its return.
No shipped flow reads a credential-class field off its trigger record. Re-check: git grep -n -E "record\.(.*)(password|secret|api_key|token)" origin/main -- 'examples/**/*flow*' 'examples/**/flows/**'; a hit is a fork, not a reason to leave that field clear.
Only the trigger's own record enters here. Records a flow reads later through get_record / CRUD nodes come through the engine's generic read path and are masked by ADR-0100 already; nothing in this ruling touches those nodes. Re-check: packages/services/service-automation/src/builtin/crud-nodes.ts:438-497.
Readings behind it (each taken this summon at origin/main9f9510f25e, or marked)
ADR-0100 A/B/C (docs/adr/0100-credential-field-channels.md:47-115): masked on the generic read path, plaintext only through privileged dereference; the rule declared once in masked-field-types.ts.
The source: record-change-trigger.ts:333-345 — "The new record comes from ctx.result (after-hooks) … the old record from ctx.previous".
Default grants: default-permission-sets.ts names sys_automation_run nowhere (0 hits); who holds its read grant is deployment-specific — unverified this summon.
Not verified this summon: premises 1–4 (the dev's first act); the withheld detail (not read by this seat; the positions above are read by class); the objectui console does not render a run's variables at origin/main (grep found none), so the read surfaces are the two doors named.
Ruled, recorded by the PM seat from the maintainer's answer in Claude Code session session_018zT8d8NpiQ1ExhuNd5TxY6, 2026-10-05: when a paused run's state is persisted, mask credential-class fields in the trigger record and its previous values with the same helper PR #21816 introduced. Do not filter the variables map per field, which keeps this consistent with #7900. Chosen option 「持久化时遮盖凭据字段」. The withheld detail stays withheld (RUNNER rule 2); the dispatching session holds it.
Alignment note from the PM seat (session_018zT8d8NpiQ1ExhuNd5TxY6). My ruling note above (6005739672) and the director's ruling (5995381726) record the same answer from the maintainer: mask credential-class fields, with no per-field filtering of the variables map. Where the two differ on where the mask is applied, the director's ruling governs. The mask is applied at the source: RecordChangeTrigger.buildContext projects record and previous through omitInternalFieldsFromWriteResponse. Persisted run state inherits the mask, so a separate mask at persistence time (option A′) is not added. This card is dispatched on that ruling, including its premises and pins.
Claim: PM loop round 1 (17.7 pre-release follow-up, dispatched on the maintainer's direct order)
Session: session_018zT8d8NpiQ1ExhuNd5TxY6
Account: hotlong (the seat's linked user as GET /user answers it; always the card's assignee)
Branch: claude/issue-21867-run-state-trigger-record-mask
Worktree: objectstack-issue-21867
Domain: domain:services (card label, as triage set it)
File surface: packages/triggers/trigger-record-change/src, packages/spec/src/migrations (ADR-0087 semantic entry), docs/qa/platform-checklist/ (the missing item), .changeset/
Container & model: M, mode:subagent, model: opus (default tier; no path-derived mandate)
Clause-②: no
Thread-read: 6005739672
Serial constraints cleared: none named
Provenance: the maintainer, in Claude Code session session_018zT8d8NpiQ1ExhuNd5TxY6, 2026-10-05, verbatim: 「持久化时遮盖凭据字段 (推荐)」, which matches the director's ruling A (5995381726, 「21867 同意」); dispatch per 「只有不能公开的才需要你派发处理」. This card withholds detail; this session holds it, and the dispatch carries it privately.
{
"issue": 21867,
"status": "done",
"branch": "claude/issue-21867-run-state-trigger-record-mask",
"pr": "https://github.com/objectstack-ai/objectstack/pull/21928",
"session": "session_018zT8d8NpiQ1ExhuNd5TxY6",
"premise_still_valid": true,
"summary": "Ruling A, implemented at the source. RecordChangeTrigger.buildContext projects record and previous (params is the same object as record) through omitInternalFieldsFromWriteResponse from @objectstack/core, using the trigger object's definition. The projection runs last: after hydration, materialisation and the decoupling copy, in place on the decoupled copies only. The engine's own hook objects stay whole. A credential-class field (secret, and password outside the exempt managedBy buckets) reads SECRET_MASK, or null when unset, and an internal: true field is omitted. There is no second mask in service-automation or in the store, and no other variable is filtered (#7900 stands). All four premises held at dcb11c2ec9. P1: getObject is already read in buildContext. P2: the last layers are materialise then decouple, and the projection goes after them. P3: the card's grep returns zero hits (exit 1); control: record.FIELD reads exist in 4 flow files; the only credential-holding example object is showcase_field_zoo, whose single flow is draft and reads neither credential field. P4: get_record and the other CRUD nodes read through data.find and data.findOne. Shipped with: a changeset (Clause-② no, ! banner, FROM to TO, one-line handling); the ADR-0087 semantic entry 18.flow-trigger-record-credential-masked, registered via gen:migration-registry; and the QA item automation.paused-run-trigger-record-masked. The PR body and this comment stay at classes and positions; no repro or attack detail.",
"tests": "All gate readings at head 67ce8a46a2. Unit pin src/trigger-record-credential-mask.test.ts: 7 cases. Dogfood pin test/flow-trigger-record-credential-mask.dogfood.test.ts: real bootStack with automation, a file DB, the crypto provider and RecordChangeTriggerPlugin. Its cases: paused-row variables_json and context_json masked, internal field omitted; the data door over the row; GET /automation/:name/runs/:runId; a post-pause node stores the mask while the ordinary field stores its value; resolveSecretField still returns the plaintext; a cold-boot suite resumes in a second kernel and the node again sees the mask. Dogfood 7/7 green at 48e0b1cc35 (trigger source unchanged since). trigger-record-change pnpm test: 11 files, 108 tests, green at 67ce8a46a2. core pnpm test: 77 files, 2177 tests, green. service-automation vitest: 173 files, 2112 tests, green. spec src/migrations: 3 files, 179 tests, green. Typecheck green for trigger-record-change (src and tsconfig.test.json; --listFiles counts the new test once), dogfood (covers the new file) and spec (src, scripts, test layer). spec check:generated: 15/15 up to date. check:adr-0087-registration: green ([BREAKING+bang] registered flow-trigger-record-credential-masked). check:platform-checklist: green. Lint narrowed to the 6 changed .ts files: eslint --no-inline-config --format json reads 6 files, 0 errors, 0 warnings; the .md and .json files answer 'File ignored because no matching configuration was supplied'; --print-config shows parserOptions without project, so type-aware linting is off and no untouched file's verdict can move. dispatch-gates --ran: 90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN. Two gates went red mid-pass when plugin-approvals and plugin-auth dist lost their .d.ts (neither package is in this diff); after a rebuild, check:dts-closure and check:dual-build-cjs-loads read green. Ablation: scripts/ablation-replace.mjs in wrap mode with an EXIT/INT/TERM restore replaced the two projection calls. On-disk proof: anchor 1 to 0, marker 0 to 1, blob d0702684cb19 to 27a41720a0ab. No dist hop: the dogfood isolated project aliases @objectstack/trigger-record-change to source, and the plugin comes from that import. Unit pin: 3 red, 4 green; the 4 that stay green hold without a mask too (ordinary value, params identity, unset reads null, hook objects whole). Dogfood: 5 red, 2 green (armed scene, privileged path). Restore: blob == HEAD d0702684cb19, git diff HEAD empty. packages/cli is not touched, so there is no integration tier owed.",
"mcp_calls": "0",
"api_writes": "4 — git push of claude/issue-21867-run-state-trigger-record-mask (not REST); fleet-write relay pr_create (POST /repos/objectstack-ai/objectstack/pulls, draft, PR #21928); label-write relay assign (POST /repos/objectstack-ai/objectstack/issues/21928/assignees hotlong); post-stamped os-dev-report comment (POST /repos/objectstack-ai/objectstack/issues/21867/comments). Zero label writes: the dispatch named no labels, and skip-changeset does not apply because the PR ships a changeset.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: 承接者:无 · noted, not filed — during the second full gate pass, packages/plugins/plugin-approvals/dist and packages/plugins/plugin-auth/dist were found rewritten without .d.ts. Which step wrote them was not established; a rebuild restored them. Neither package is in this diff. Recorded in the PR's Acceptance notes only.",
"deviation (not a finding): the claim's declared file surface is packages/triggers/trigger-record-change/src, packages/spec/src/migrations, docs/qa/platform-checklist and .changeset. The PR also touches packages/triggers/trigger-record-change/vitest.config.ts (anchored array alias plus @objectstack/spec/data to source, required by check:test-source-alias for the new value import) and adds packages/qa/dogfood/test/flow-trigger-record-credential-mask.dogfood.test.ts (the dogfood pin the dispatch asked for)."
]
}
{
"issue": 21867,
"status": "done",
"branch": "claude/issue-21867-run-state-trigger-record-mask",
"pr": "https://github.com/objectstack-ai/objectstack/pull/21928",
"session": "session_018zT8d8NpiQ1ExhuNd5TxY6",
"premise_still_valid": true,
"summary": "Round 2 on PR #21928, head 4f287e072f. Merged origin/main as a merge commit (baa4b2fe6c; the branch was 9 behind). (1) The changeset gains the record-vs-previous line in the handling paragraph (two equal masks whenever the credential field is set on both sides; use a privileged binder to detect a change) and a 'Runs stored before this release' paragraph: paused runs, and terminal runs that keep a restorable snapshot, created before the upgrade still hold clear values in variables_json / context_json / steps_json, so operators resume, cancel or purge them after upgrading. There is no migration and no scrub. (2) In record-change-trigger.ts, the comment that said an unknown object 'was refused upstream' is corrected: the bind-time probe only warns and still binds. readObjectDefinition now logs once per object per trigger, at error through the plugin logger (falling back to warn when the logger has no error, the same pattern as the file's execution-failure log), whenever the definition cannot be resolved (getObject absent, returns nothing, or throws). The log names the object, the flow, the consequence and the fix. Dispatch is unchanged. (3) trigger-record-credential-mask.test.ts gains afterDelete and beforeUpdate mask cases, a pin for each of the three unresolved shapes (one error naming the object, the flow still runs on both writes) and a no-error control. The package's vitest config now aliases @objectstack/core to source, and the KNOWN_UNALIASED_TEST_IMPORTS entry for this package in scripts/check-test-source-alias.mjs drops @objectstack/core (the gate reported the entry stale).",
"tests": "All at head 4f287e072f. Closure build: pnpm --workspace-concurrency=2 --filter '@objectstack/trigger-record-change...' build, VERDICT command-exit 0. Package: pnpm --filter @objectstack/trigger-record-change test, 'Test Files 11 passed (11) / Tests 114 passed (114)'. Then typecheck, which ran 'tsc --noEmit && tsc --noEmit -p tsconfig.test.json'; VERDICT command-exit 0 for both. The mask file has 13 cases. Ablation 1, proving the core alias resolves to source: through scripts/ablation-replace.mjs, an early return was planted in packages/core/src/utils/internal-write-response.ts omitInternalFieldsFromWriteResponse, with core dist NOT rebuilt (the marker has 0 hits in packages/core/dist). Landed: anchor 1 -> 0, blob 2a6a48c04fdb -> d51283c8f5e6. Result: 5 failed / 8 passed (the masking cases, the new afterDelete and beforeUpdate included). Restored: blob == HEAD 2a6a48c04fdb, git diff HEAD empty. The first attempt was a no-op that the tool refused (the replacement contained the anchor, so the anchor count did not drop); it measured nothing and was redone with a non-overlapping replacement. Ablation 2, proving the log pin can fail: the error branch's condition was replaced with false. Landed: anchor 1 -> 0. Result: 3 failed (the absent, nothing and throws cases), 10 passed. Restored 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. Spec check:generated after the main merge (spec moved on main): 'All 15 generated artifacts are up to date', against the spec dist built post-merge. NOT MEASURED: check:console-injection, reason: it skipped, since there is no packages/console/dist in this worktree. Lint, narrowed: eslint --no-inline-config --format json over 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) gave 4 files, 0 errors, 0 warnings. Population: all 4 are in eslint's own config, per --print-config. Invariance: --print-config shows parserOptions.project and projectService undefined, so type-aware linting is off and this diff cannot move any untouched file's verdict. 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.",
"mcp_calls": "0",
"api_writes": "1 - this os-dev-report comment on #21867, via scripts/pm/post-stamped.mjs (fleet relay). git push x2 is not a REST write. Zero PR-body, label or assignee writes.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: none (承接者:无) - noted in Acceptance notes 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/delete with no prior row. This behaviour predates the PR and was left untouched, as the dispatch said dispatch behaviour must not change. No public entry point is shown to throw from getObject."
],
"pr_body_edits_needed": [
"What this changes, bullet 'The definition is read regardless of ground truth': replace 'tolerates a missing accessor or a throw. With no definition there is nothing to mask.' with: '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.'",
"Pins, unit bullet: '7 cases' becomes '13 cases'. Add: 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 logs one error naming the object while the flow runs on both writes; a resolved definition logs no error.",
"Upgrade text: add that the changeset now names the record-vs-previous credential comparison (two equal masks when the field is set on both sides; use a privileged binder) and carries a 'Runs stored before this release' paragraph (resume, cancel or purge pre-upgrade paused runs and snapshot-keeping terminal runs; no migration or scrub). Replace the vitest.config bullet's tail with: 'and gains @objectstack/spec/data and @objectstack/core to source; the check-test-source-alias registry entry for this package drops @objectstack/core'.",
"Verification: add a round-2 block at head 4f287e072f with the test/typecheck/gate readings and the two ablations from this report's tests field. Acceptance notes: add the carrier-none note from out_of_scope_findings, and the touched-file extension (scripts/check-test-source-alias.mjs, registry narrowing only)."
]
}
Activity
objectstack-fleet commentedon Oct 5, 2026
Path: approvals and automation — flows run: errors, pauses and schedules | 缺项 (no item reads a paused run's stored state as a non-privileged holder) | P2
Triage: first grade —
bug·security·priority:p2·domain:services·area:access·needs-user-decision. Masking run state would narrow ruling #7900's "no per-field filtering of the variables map", so the maintainer rulesTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-05T11:52Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes and positions only. The withheld detail stays withheld (RUNNER rule 2); this seat does not hold it.Triage: lands where a paused run's state is persisted (
service-automation, by class) ⇒domain:services; rationale: it is the last store of #21830's class. The card names its direction, and the one obstacle is an earlier ruling.维护者速读
一个流程暂停时会把运行状态存下来,其中包含触发它的那条记录。这条记录里的密钥类字段,在别处(写操作返回结果、记录变更事件)已经按 #21787 和 #21830 遮掩了,唯独这里没有遮掩,持有"运行状态读权限"的人能看到原值。你早先在 #7900 裁过"运行变量表不做逐字段过滤"。
推荐 A(只看①选 A;②③④ 是否翻转:否)。置信缺口:我看不到暂扣的细节,不知道"运行状态读权限"实际给了哪些人。
四棱(os-decision-facets)
Prior rulings read: #7900 (variables map not filtered per field) → 1; #21787 / #21830 (the mask's class) → 2; thread: the security review of PR #21866
Generated by Claude Code
objectstack-fleet commentedon Oct 5, 2026
Ruling: batch #278 item 1 · letter A · maintainer 「21867 同意」 2026-10-05T13:24Z
Director seat, summon #33,
session_011SekRJwTRqXSmsP6xTSci4(GitHubos-tesla; written asobjectstack-fleet[bot]via the relay). Batch #278 was presented in chat with options A, A′ and B; the maintainer answered 「21867 同意」, which takes the recommendation, A. Freshness gate: the body (last edited 2026-10-05T11:53Z) and the one comment (triage 5993882228) were re-read before this record; nothing newer exists. ⛔ Classes, positions and functions only; the withheld detail (RUNNER rule 2) stays in the holding session and is not on this record.The ruling
A — mask at the source: the record-change trigger builds the flow's trigger record already masked.
RecordChangeTrigger.buildContext(packages/triggers/trigger-record-change/src/record-change-trigger.ts, the one place the flow'srecordandpreviousare built from the engine hook'sctx.result/ctx.previous) projects both through the one helper PR #21816 landed and PR #21866 reused —omitInternalFieldsFromWriteResponse(@objectstack/core,packages/core/src/utils/internal-write-response.ts:169-204), with the object definition of the trigger's object — so credential-class fields carrySECRET_MASK(ornullwhen unset) andinternal: truefields are omitted. Everything downstream inherits it without a second copy: the variables map (record,$record,previous,service-automation/src/engine.ts:12463-12469),SuspendedRun.context(record/previous,packages/spec/src/contracts/automation-service.ts:26-33), the persistedvariables_json/context_json(suspended-run-store.ts:988-990), the/automation/:name/runs/:runIdanswer and the/data/sys_automation_runcolumns, and the run a flow resumes with, in-process and after a restart alike.persistSuspendedRun): the in-memorySuspendedRunand the rehydrated one would differ, and the live flow would still hold clear values before the pause. B (declare the run-state read grant covers the values): keeps the last store of security(data): credential-class field values reach record-change consumers (event payloads and the records downstream features keep from them) unmasked — detail withheld pending maintainer #21830's class in clear for every holder ofsys_automation_runread, andvariables_jsonis a plain readable column.isMaskedOnReadFieldType,packages/spec/src/data/masked-field-types.ts:116) to the two record-shaped entries of a known object, and leaves every other variable as it is.service-automationor in the store; the trigger is the record-change consumer that masks, as the engine's publish, the approval snapshot, the webhook body and the knowledge document do after PR fix(data): record-change payloads apply the write-response credential mask and internal-field omission #21866 (merged 2026-10-05T12:47Z).Settled in the same stroke:
!banner, FROM → TO and the one-line handling, and an ADR-0087 semantic entry, sibling of18.by-id-write-unreadable-row-not-found.ts, carries the changed answer to the upgrade guide: a flow that needs a credential uses a privileged binder, never the trigger record (ADR-0100 A, "privileged read").needs-user-decision→pm:queue;domain:services,priority:p2,security,area:accessstay. At-tier contract review before landing (security boundary). The PR text stays at classes and positions. The claiming seat obtains the withheld consumers and mechanism from the holding sessionsession_018zT8d8NpiQ1ExhuNd5TxY6on this word; ⛔ the detail goes on neither the card nor the PR.secretand apasswordfield: the paused row'svariables_jsonandcontext_jsoncarrySECRET_MASKfor both and omitinternal: truefields;GET /automation/:name/runs/:runIdshows the same; a node after the pause readingrecord.<credential field>sees the mask, in-process and after a restart; a node reading an ordinary field sees its value; a privilegedresolveSecretpath is unchanged; the QA path "approvals and automation — flows run: errors, pauses and schedules" gains the item triage named as missing.Premises the dev verifies before writing (third-tier ruling: a failed premise is reported as
forkon this card — do not force A, do not fall back to A′ or B silently)buildContext. The trigger already materialises the record over the object's declared fields (materializeDeclaredFields, since #4649 的「记录对已声明字段全量」只落在两个接缝上 —— 另外三处求值仍是稀疏绑定 #4953), so the definition is in hand or one registry read away. Re-check:git grep -n "materializeDeclaredFields\|hydrateComputedFields" origin/main -- packages/triggers/trigger-record-change/src/record-change-trigger.ts.buildContextlayersctx.resultover the prior row and re-reads computed fields; the mask must be applied after the last overlay so no later layer re-introduces a clear value. Re-check: readbuildContextfromrecord-change-trigger.ts:333to its return.git grep -n -E "record\.(.*)(password|secret|api_key|token)" origin/main -- 'examples/**/*flow*' 'examples/**/flows/**'; a hit is afork, not a reason to leave that field clear.get_record/ CRUD nodes come through the engine's generic read path and are masked by ADR-0100 already; nothing in this ruling touches those nodes. Re-check:packages/services/service-automation/src/builtin/crud-nodes.ts:438-497.Readings behind it (each taken this summon at
origin/main9f9510f25e, or marked)docs/adr/0100-credential-field-channels.md:47-115): masked on the generic read path, plaintext only through privileged dereference; the rule declared once inmasked-field-types.ts.a0176efe85, write responses, the helper); security(data): credential-class field values reach record-change consumers (event payloads and the records downstream features keep from them) unmasked — detail withheld pending maintainer #21830 → PR fix(data): record-change payloads apply the write-response credential mask and internal-field omission #21866 (merged, record-change payloads and the stored-snapshot, webhook and knowledge producers; "the engine's own write result is untouched, so privileged in-process callers are unchanged" — which is why the trigger must mask).packages/runtime/src/domains/automation.ts:249-300(run-state reads require thesys_automation_runread grant; "⛔ deliberately NOT a per-field filter of the run'svariablesmap").sys-automation-run.object.ts:314-333(variables_json,context_json: plaintextareacolumns);suspended-run-store.ts:988-990(verbatimJSON.stringify);engine.ts:1090-1130(ExecutionLogEntry.variables, published for paused runs since finding: a suspended flow run's variables are unreadable on run-detail — the pausedrecordLogomits them while the continuation keeps them #7639) and:1462-1490(SuspendedRun).record-change-trigger.ts:333-345— "The new record comes fromctx.result(after-hooks) … the old record fromctx.previous".default-permission-sets.tsnamessys_automation_runnowhere (0 hits); who holds its read grant is deployment-specific — unverified this summon.variables map, run state, credential, mask, secret field, paused run, automation run, internal field→ 13 hits; ADR-0100 §A/B/C (the rule itself), ADR-0106 D1 (FLS masking, not this), ADR-0128 D1 (unrelated); finding: the whole /automation read domain is gated only by "authenticated" — run-detail returns the triggering record's fields without that record's own FLS #7900 (FLS per-field filtering rejected, not touched); thread: none before this record.variablesatorigin/main(grep found none), so the read surfaces are the two doors named.四棱(本裁决新记录)
record.<密钥字段>静默送进 HTTP 节点。Generated by Claude Code
objectstack-fleet commentedon Oct 5, 2026
Ruled, recorded by the PM seat from the maintainer's answer in Claude Code session
session_018zT8d8NpiQ1ExhuNd5TxY6, 2026-10-05: when a paused run's state is persisted, mask credential-class fields in the trigger record and its previous values with the same helper PR #21816 introduced. Do not filter the variables map per field, which keeps this consistent with #7900. Chosen option 「持久化时遮盖凭据字段」. The withheld detail stays withheld (RUNNER rule 2); the dispatching session holds it.Generated by Claude Code
objectstack-fleet commentedon Oct 5, 2026
Alignment note from the PM seat (
session_018zT8d8NpiQ1ExhuNd5TxY6). My ruling note above (6005739672) and the director's ruling (5995381726) record the same answer from the maintainer: mask credential-class fields, with no per-field filtering of the variables map. Where the two differ on where the mask is applied, the director's ruling governs. The mask is applied at the source:RecordChangeTrigger.buildContextprojectsrecordandpreviousthroughomitInternalFieldsFromWriteResponse. Persisted run state inherits the mask, so a separate mask at persistence time (option A′) is not added. This card is dispatched on that ruling, including its premises and pins.Generated by Claude Code
objectstack-fleet commentedon Oct 5, 2026
Claim: PM loop round 1 (17.7 pre-release follow-up, dispatched on the maintainer's direct order)
Session:
session_018zT8d8NpiQ1ExhuNd5TxY6Account:
hotlong(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-21867-run-state-trigger-record-maskWorktree:
objectstack-issue-21867Domain:
domain:services(card label, as triage set it)File surface:
packages/triggers/trigger-record-change/src,packages/spec/src/migrations(ADR-0087 semantic entry),docs/qa/platform-checklist/(the missing item),.changeset/Container & model:
M,mode:subagent,model: opus(default tier; no path-derived mandate)Clause-②: no
Thread-read: 6005739672
Serial constraints cleared: none named
Provenance: the maintainer, in Claude Code session
session_018zT8d8NpiQ1ExhuNd5TxY6, 2026-10-05, verbatim: 「持久化时遮盖凭据字段 (推荐)」, which matches the director's ruling A (5995381726, 「21867 同意」); dispatch per 「只有不能公开的才需要你派发处理」. This card withholds detail; this session holds it, and the dispatch carries it privately.Generated by Claude Code
objectstack-fleet commentedon Oct 6, 2026
os-dev-report
{ "issue": 21867, "status": "done", "branch": "claude/issue-21867-run-state-trigger-record-mask", "pr": "https://github.com/objectstack-ai/objectstack/pull/21928", "session": "session_018zT8d8NpiQ1ExhuNd5TxY6", "premise_still_valid": true, "summary": "Ruling A, implemented at the source. RecordChangeTrigger.buildContext projects record and previous (params is the same object as record) through omitInternalFieldsFromWriteResponse from @objectstack/core, using the trigger object's definition. The projection runs last: after hydration, materialisation and the decoupling copy, in place on the decoupled copies only. The engine's own hook objects stay whole. A credential-class field (secret, and password outside the exempt managedBy buckets) reads SECRET_MASK, or null when unset, and an internal: true field is omitted. There is no second mask in service-automation or in the store, and no other variable is filtered (#7900 stands). All four premises held at dcb11c2ec9. P1: getObject is already read in buildContext. P2: the last layers are materialise then decouple, and the projection goes after them. P3: the card's grep returns zero hits (exit 1); control: record.FIELD reads exist in 4 flow files; the only credential-holding example object is showcase_field_zoo, whose single flow is draft and reads neither credential field. P4: get_record and the other CRUD nodes read through data.find and data.findOne. Shipped with: a changeset (Clause-② no, ! banner, FROM to TO, one-line handling); the ADR-0087 semantic entry 18.flow-trigger-record-credential-masked, registered via gen:migration-registry; and the QA item automation.paused-run-trigger-record-masked. The PR body and this comment stay at classes and positions; no repro or attack detail.", "tests": "All gate readings at head 67ce8a46a2. Unit pin src/trigger-record-credential-mask.test.ts: 7 cases. Dogfood pin test/flow-trigger-record-credential-mask.dogfood.test.ts: real bootStack with automation, a file DB, the crypto provider and RecordChangeTriggerPlugin. Its cases: paused-row variables_json and context_json masked, internal field omitted; the data door over the row; GET /automation/:name/runs/:runId; a post-pause node stores the mask while the ordinary field stores its value; resolveSecretField still returns the plaintext; a cold-boot suite resumes in a second kernel and the node again sees the mask. Dogfood 7/7 green at 48e0b1cc35 (trigger source unchanged since). trigger-record-change pnpm test: 11 files, 108 tests, green at 67ce8a46a2. core pnpm test: 77 files, 2177 tests, green. service-automation vitest: 173 files, 2112 tests, green. spec src/migrations: 3 files, 179 tests, green. Typecheck green for trigger-record-change (src and tsconfig.test.json; --listFiles counts the new test once), dogfood (covers the new file) and spec (src, scripts, test layer). spec check:generated: 15/15 up to date. check:adr-0087-registration: green ([BREAKING+bang] registered flow-trigger-record-credential-masked). check:platform-checklist: green. Lint narrowed to the 6 changed .ts files: eslint --no-inline-config --format json reads 6 files, 0 errors, 0 warnings; the .md and .json files answer 'File ignored because no matching configuration was supplied'; --print-config shows parserOptions without project, so type-aware linting is off and no untouched file's verdict can move. dispatch-gates --ran: 90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN. Two gates went red mid-pass when plugin-approvals and plugin-auth dist lost their .d.ts (neither package is in this diff); after a rebuild, check:dts-closure and check:dual-build-cjs-loads read green. Ablation: scripts/ablation-replace.mjs in wrap mode with an EXIT/INT/TERM restore replaced the two projection calls. On-disk proof: anchor 1 to 0, marker 0 to 1, blob d0702684cb19 to 27a41720a0ab. No dist hop: the dogfood isolated project aliases @objectstack/trigger-record-change to source, and the plugin comes from that import. Unit pin: 3 red, 4 green; the 4 that stay green hold without a mask too (ordinary value, params identity, unset reads null, hook objects whole). Dogfood: 5 red, 2 green (armed scene, privileged path). Restore: blob == HEAD d0702684cb19, git diff HEAD empty. packages/cli is not touched, so there is no integration tier owed.", "mcp_calls": "0", "api_writes": "4 — git push of claude/issue-21867-run-state-trigger-record-mask (not REST); fleet-write relay pr_create (POST /repos/objectstack-ai/objectstack/pulls, draft, PR #21928); label-write relay assign (POST /repos/objectstack-ai/objectstack/issues/21928/assignees hotlong); post-stamped os-dev-report comment (POST /repos/objectstack-ai/objectstack/issues/21867/comments). Zero label writes: the dispatch named no labels, and skip-changeset does not apply because the PR ships a changeset.", "open_questions": [], "out_of_scope_findings": [ "carrier: 承接者:无 · noted, not filed — during the second full gate pass, packages/plugins/plugin-approvals/dist and packages/plugins/plugin-auth/dist were found rewritten without .d.ts. Which step wrote them was not established; a rebuild restored them. Neither package is in this diff. Recorded in the PR's Acceptance notes only.", "deviation (not a finding): the claim's declared file surface is packages/triggers/trigger-record-change/src, packages/spec/src/migrations, docs/qa/platform-checklist and .changeset. The PR also touches packages/triggers/trigger-record-change/vitest.config.ts (anchored array alias plus @objectstack/spec/data to source, required by check:test-source-alias for the new value import) and adds packages/qa/dogfood/test/flow-trigger-record-credential-mask.dogfood.test.ts (the dogfood pin the dispatch asked for)." ] }Generated by Claude Code
objectstack-fleet commentedon Oct 6, 2026
os-dev-report
{ "issue": 21867, "status": "done", "branch": "claude/issue-21867-run-state-trigger-record-mask", "pr": "https://github.com/objectstack-ai/objectstack/pull/21928", "session": "session_018zT8d8NpiQ1ExhuNd5TxY6", "premise_still_valid": true, "summary": "Round 2 on PR #21928, head 4f287e072f. Merged origin/main as a merge commit (baa4b2fe6c; the branch was 9 behind). (1) The changeset gains the record-vs-previous line in the handling paragraph (two equal masks whenever the credential field is set on both sides; use a privileged binder to detect a change) and a 'Runs stored before this release' paragraph: paused runs, and terminal runs that keep a restorable snapshot, created before the upgrade still hold clear values in variables_json / context_json / steps_json, so operators resume, cancel or purge them after upgrading. There is no migration and no scrub. (2) In record-change-trigger.ts, the comment that said an unknown object 'was refused upstream' is corrected: the bind-time probe only warns and still binds. readObjectDefinition now logs once per object per trigger, at error through the plugin logger (falling back to warn when the logger has no error, the same pattern as the file's execution-failure log), whenever the definition cannot be resolved (getObject absent, returns nothing, or throws). The log names the object, the flow, the consequence and the fix. Dispatch is unchanged. (3) trigger-record-credential-mask.test.ts gains afterDelete and beforeUpdate mask cases, a pin for each of the three unresolved shapes (one error naming the object, the flow still runs on both writes) and a no-error control. The package's vitest config now aliases @objectstack/core to source, and the KNOWN_UNALIASED_TEST_IMPORTS entry for this package in scripts/check-test-source-alias.mjs drops @objectstack/core (the gate reported the entry stale).", "tests": "All at head 4f287e072f. Closure build: pnpm --workspace-concurrency=2 --filter '@objectstack/trigger-record-change...' build, VERDICT command-exit 0. Package: pnpm --filter @objectstack/trigger-record-change test, 'Test Files 11 passed (11) / Tests 114 passed (114)'. Then typecheck, which ran 'tsc --noEmit && tsc --noEmit -p tsconfig.test.json'; VERDICT command-exit 0 for both. The mask file has 13 cases. Ablation 1, proving the core alias resolves to source: through scripts/ablation-replace.mjs, an early return was planted in packages/core/src/utils/internal-write-response.ts omitInternalFieldsFromWriteResponse, with core dist NOT rebuilt (the marker has 0 hits in packages/core/dist). Landed: anchor 1 -> 0, blob 2a6a48c04fdb -> d51283c8f5e6. Result: 5 failed / 8 passed (the masking cases, the new afterDelete and beforeUpdate included). Restored: blob == HEAD 2a6a48c04fdb, git diff HEAD empty. The first attempt was a no-op that the tool refused (the replacement contained the anchor, so the anchor count did not drop); it measured nothing and was redone with a non-overlapping replacement. Ablation 2, proving the log pin can fail: the error branch's condition was replaced with false. Landed: anchor 1 -> 0. Result: 3 failed (the absent, nothing and throws cases), 10 passed. Restored 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. Spec check:generated after the main merge (spec moved on main): 'All 15 generated artifacts are up to date', against the spec dist built post-merge. NOT MEASURED: check:console-injection, reason: it skipped, since there is no packages/console/dist in this worktree. Lint, narrowed: eslint --no-inline-config --format json over 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) gave 4 files, 0 errors, 0 warnings. Population: all 4 are in eslint's own config, per --print-config. Invariance: --print-config shows parserOptions.project and projectService undefined, so type-aware linting is off and this diff cannot move any untouched file's verdict. 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.", "mcp_calls": "0", "api_writes": "1 - this os-dev-report comment on #21867, via scripts/pm/post-stamped.mjs (fleet relay). git push x2 is not a REST write. Zero PR-body, label or assignee writes.", "open_questions": [], "out_of_scope_findings": [ "carrier: none (承接者:无) - noted in Acceptance notes 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/delete with no prior row. This behaviour predates the PR and was left untouched, as the dispatch said dispatch behaviour must not change. No public entry point is shown to throw from getObject." ], "pr_body_edits_needed": [ "What this changes, bullet 'The definition is read regardless of ground truth': replace 'tolerates a missing accessor or a throw. With no definition there is nothing to mask.' with: '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.'", "Pins, unit bullet: '7 cases' becomes '13 cases'. Add: 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 logs one error naming the object while the flow runs on both writes; a resolved definition logs no error.", "Upgrade text: add that the changeset now names the record-vs-previous credential comparison (two equal masks when the field is set on both sides; use a privileged binder) and carries a 'Runs stored before this release' paragraph (resume, cancel or purge pre-upgrade paused runs and snapshot-keeping terminal runs; no migration or scrub). Replace the vitest.config bullet's tail with: 'and gains @objectstack/spec/data and @objectstack/core to source; the check-test-source-alias registry entry for this package drops @objectstack/core'.", "Verification: add a round-2 block at head 4f287e072f with the test/typecheck/gate readings and the two ablations from this report's tests field. Acceptance notes: add the carrier-none note from out_of_scope_findings, and the touched-file extension (scripts/check-test-source-alias.mjs, registry narrowing only)." ] }Generated by Claude Code
fix(trigger-record-change)!: a record-change flow's trigger record ca…
docs(qa): checklist item for public-form withdrawal layering (17.7 se…