Skip to content

test(dogfood): every test file runs in its own temporary working directory - #21919

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-21914-dogfood-package-dir-state
Oct 6, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-21914-dogfood-package-dir-state

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21914
Clause-②: no

What changes

Every dogfood test file now runs in its own temporary working directory. The suite fails when any file leaves .objectstack/data in packages/qa/dogfood.

  • test/per-file-cwd.setup.ts (new) is a setupFiles entry, wired explicitly in BOTH projects of vitest.config.ts, because inline projects inherit nothing from the root block. shared-showcase keeps isolate: false; the module still runs once per file there.
    • At module top level, before the test file's own imports, it creates a directory under the run's temporary root and chdirs into it.
    • In afterAll it restores the previous cwd. That afterAll is also the guard: it THROWS when packages/qa/dogfood/.objectstack/data exists. The message names the directory, its entries and the remedy. It also says the named file may be a concurrent one on another worker rather than the writer, and whether the directory was already present when the file started.
  • test/per-file-cwd.global-setup.ts (new) is a root-level globalSetup. It runs once per run, covering both projects and each OS_TEST_SHARD slice (measured).
    • At the START it clears a stale packages/qa/dogfood/.objectstack, so a developer's earlier run never reds the suite.
    • It creates one temporary root for the run and hands it to the workers with provide / inject.
    • At the END it removes that root, which is where the per-file directories are removed. The removal is run-level, not per file, because the memoized shared-showcase boot keeps its SQLite handles open in the directory of the file that booted it.
    • The teardown judges nothing (see Evidence: a throwing teardown is a false green).
  • vitest.config.ts wires the two modules. A header section explains why there are two halves and why the guard is not in the teardown.
  • test/enterprise-organizations.ts: the module-level probeOrganizations() now passes this package's root as hostRoot, resolved from the module's location (new URL('..', import.meta.url)), not the cwd. This was measured to be needed; see Evidence.

No per-file edits. The five files the card names, and the other 87 measured writers, are covered by the module with no change of their own. Test isolation only: @objectstack/dogfood is private: true, so no published package moves and there is no changeset (skip-changeset).

The invariant for every dogfood author

  • Each test file runs in its own temporary cwd. Anything it writes relative to the cwd is its own, no other file sees it, and it is removed at the end of the run. A file needs no mkdtemp / chdir of its own.
  • A package-relative read must resolve from the module's location (new URL('..', import.meta.url), import.meta.dirname), never from process.cwd(). The cwd is a temporary directory.
  • A file that writes into packages/qa/dogfood/.objectstack/data fails the run. That happens through an absolute path built from the package root, or through a process.chdir() back to the package directory before a boot. The fix is to write relative to the file's own cwd.
  • Files that already chdir into a temp dir of their own still work, because they restore to the per-file directory. Their own chdir is now redundant and harmless.

Why (measured)

A per-file probe over the whole suite measured 92 test files leaving .objectstack/data/showcase_external.db in the package directory, not the five the card names:

  • 7 leave the populated federated fixture (24576 B, 2 tables): the card's five, plus showcase-demo-personas-loginable and showcase-demo-personas-membership, which pass onEnable in the bundle.
  • 85 leave an empty SQLite file (4096 B, 0 tables). The showcase's declared external datasource has a cwd-relative filename, and its auto-connect creates the file on every showcase boot, onEnable or not.

A later boot on the same runner found or missed the federated tables depending on which files ran before it, and that ordering is how PR #21905 went red only on dogfood shard 3/3. The seat chose this route (one module) and this guard (comment 6004950414 on #21914), on the dev's measurement (comment 6004909676).

Evidence

All runs are at head 967ce88a, under the shared verify lock, from a clean package directory.

  • Whole suite: pnpm --filter @objectstack/dogfood test gave Test Files 205 passed | 1 skipped (206) and Tests 1591 passed | 9 skipped (1600). Afterwards packages/qa/dogfood/.objectstack does not exist, and no os-dogfood-run-* root is left in the temp dir.

  • CI's three-shard split: CI's dogfood leg exports OS_TEST_SHARD=k/3 and vitest.config.ts turns it into vitest's shard. Here each shard ran as OS_TEST_SHARD=k/3 pnpm --filter @objectstack/dogfood test: the same vitest selection, without turbo, so no cached replay. Each exited 0 and left no .objectstack:

    shard Test Files Tests
    1/3 69 passed (69) 507 passed (507)
    2/3 69 passed (69) 461 passed, 1 skipped (462)
    3/3 67 passed, 1 skipped (68) 623 passed, 8 skipped (631)

    The three add up to the whole run: 206 files, 1600 tests.

  • Ablation (H4) through scripts/ablation-replace.mjs, wrap mode. The central process.chdir(...) was replaced by the bare mkdtempSync(...): anchor count 1 to 0, blob 0991eb9c to ee5a65be.

    • With the chdir dropped, showcase-external-autoconnect and showcase-search ran: Test Files 2 failed (2), Tests 8 passed (8), exit 1. Each failed in the guard: .../packages/qa/dogfood/.objectstack/data exists after this test file ran. Entries: showcase_external.db (plus -shm / -wal for the shared-showcase file).
    • Restore was proven by the tool: blob after restore equals HEAD (0991eb9c), and git diff HEAD is empty.
    • The same two files then gave 2 passed, exit 0, and left nothing.
    • No build step is involved: vitest loads the mutated module from source.
  • Stale directory: .objectstack/data/x.db was planted, then 9 files were run. Result: Test Files 9 passed (9), exit 0, nothing left (the globalSetup cleared it).

  • Census: those 9 files are the 7 populated-fixture writers plus showcase-search and showcase-permission-zoo, both shared-showcase files.

  • hostRoot line, measured both ways, running rls-multitenant, org-create-default-team and enterprise-organizations.test:

    • Without the line (commit 4d07dc29), the skip text read not resolvable from /tmp/os-dogfood-run-.../file-... and told the reader to declare the package in that temp directory's package.json.
    • With it (967ce88a), the text names packages/qa/dogfood/.
    • The verdict is the same both ways (skipped), because no framework package declares @objectstack/organizations.
  • Guard placement: a throwing globalSetup teardown was measured on vitest 4.1.11 to print error during close and still exit 0, a false green. So the guard is the per-file afterAll. (A teardown that sets process.exitCode = 1 does exit 1, but the summary still reads all-passed.)

  • Typecheck and lint: pnpm --filter @objectstack/dogfood typecheck is green, and tsc --listFiles includes both new modules and enterprise-organizations.ts. pnpm lint exits 0.

  • Gates: 130 commands at 967ce88a, the dispatch list plus pnpm check:dispatcher-error-vocabulary from dispatch-gates --commands. dispatch-gates --ran: 48 derived famil(ies) accounted for — 48 run, 0 NOT-MEASURED.

    • check:dual-build-cjs-loads and check:published-readme-exports first exited 3 (dist prerequisite: 7 packages unbuilt). After building those 7, both exit 0.
    • The three PR-context scripts (check-closing-target-claim, check-partof-closing-keyword, check-single-claim-paths) are re-run with this PR's context; the results are in the report on the card.

Open PRs that add dogfood files

PR new file boots the showcase own chdir under this PR
#21864 showcase-public-form-withdrawal-layers.dogfood.test.ts yes no Covered with no author action. Without this PR it would leave .objectstack/data in the package directory.
#21917 organization-delete-federated-fixture.dogfood.test.ts yes, with onEnable yes Unaffected; its own chdir is redundant.
#21906 external-import-code-datasource-namespace.dogfood.test.ts (also edits three external-* files) yes, with onEnable yes Unaffected. None of its files is edited here.
#21877 datasource-contractless-credentials.dogfood.test.ts yes yes Unaffected.
#21897 flow-node-config-values-at-registration.dogfood.test.ts no (fixture stack) no Runs in its own temp cwd; it reads nothing relative to the cwd.

None of these files reads a package-relative path through process.cwd(). Only their own prevCwd captures do.

Acceptance notes

  • Observation, not filed. The showcase's external datasource is declared read-only (schemaMode: 'external', allowWrites: false). Its auto-connect CREATES a missing .objectstack/data/showcase_external.db, plus -wal / -shm (measured on 85 harness boots).
    • The declaration's own comment in showcase-external.datasource.ts says that if the fixture file cannot be opened, "the boot stops with that as the reason rather than serving a showcase whose federation pages are quietly dead".
    • It was measured only through the verify harness's bootStack, never at a public door (os start / os dev), so it stays here.
  • Latent, unreachable today. bootStack(..., { multiTenant: true }) also defaults its hostRoot to the cwd: rls-multitenant.dogfood.test.ts:79, and attachments-permission-matrix.dogfood.test.ts:766 through bootFixture. Both are gated on organizationsAvailable, which is false in this repository because no framework package may declare @objectstack/organizations (ADR-0132). A run that declares it in this package would need those boots to pass the package root too. Carrier: whoever declares it.
  • The own chdir in external-validate-sees-runtime-save, external-import-destructive-remedy, PR fix(runtime,service-datasource): an import over a code-defined datasource is held to its package's ADR-0028 namespace #21906's file and PR fix(objectql): the cascade skips a federated object's injected tenant anchor #21917's file is now redundant. It is left untouched and can be removed once fix(runtime,service-datasource): an import over a code-defined datasource is held to its package's ADR-0028 namespace #21906 lands. Carrier: the domain:cli seat.
  • Attribution limit: under parallel workers, the guard can name a file that ran at the same time as the writer. The message says so, and says whether the directory was already present when the named file started.

Generated by Claude Code

claude added 2 commits October 5, 2026 23:04
…ctory

Showcase boots write .objectstack/data/showcase_external.db relative to the
cwd: the declared external datasource auto-connects (SQLite creates the
file) and onEnable provisions its federated tables there. From the package
directory 92 files left it behind, 7 of them populated, so a later boot's
federated state depended on shard composition and file order.

A setupFiles module, wired in both projects, chdirs each file into its own
directory under a run-level temporary root and restores the cwd in
afterAll, where it throws when packages/qa/dogfood/.objectstack/data
exists. A root globalSetup clears a stale .objectstack at the start and
removes the run root at the end; its teardown judges nothing.

Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Co-authored-by: Claude <noreply@anthropic.com>
… root

Under the per-file temporary cwd, probeOrganizations() defaulted its host
root to that temp directory, so its skip text told the reader to declare
@objectstack/organizations in a /tmp directory's package.json (measured).
The module-level probe now passes this package's root, resolved from the
module's own location.

Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Oct 5, 2026
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Oct 5, 2026
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see

Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e6dc7a240617eaeef9a64e788bf6e5561c107f1b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 76c0afd216a96d371d1e40d30d8a3b31dd5ddc40 — the merge of head 967ce88af337772d0a40b7d6dfaebfac1b901207 into base e6dc7a240617eaeef9a64e788bf6e5561c107f1b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 76c0afd216a96d371d1e40d30d8a3b31dd5ddc40 && git checkout 76c0afd216a96d371d1e40d30d8a3b31dd5ddc40
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e6dc7a240617eaeef9a64e788bf6e5561c107f1b 967ce88af337772d0a40b7d6dfaebfac1b901207 && git checkout -B drift-repro e6dc7a240617eaeef9a64e788bf6e5561c107f1b && git merge --no-ff 967ce88af337772d0a40b7d6dfaebfac1b901207

node scripts/docs-audit/affected-docs.mjs --json e6dc7a240617eaeef9a64e788bf6e5561c107f1b

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actions github-actions Bot added the tests label Oct 5, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 5, 2026 23:55
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 5, 2026 23:55
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit be97cf3 Oct 6, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21914-dogfood-package-dir-state branch October 6, 2026 00:23
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…cratch-dir scan can read (objectstack-ai#21935)

Fixes objectstack-ai#21924
Clause-②: no

## What changes

`main`'s hourly `Lint & Type Check` run is red on one `dispatch-gates`
self-test case (run `37394652870` at `be97cf3c93`, `Lint & Repo Gates`,
step `PM dispatch-gates self-test`):

> ✗ no mkdtempSync site in this tree takes a base the scan cannot read —
UNRESOLVED: packages/qa/dogfood/test/per-file-cwd.setup.ts:60 (a base
this scan cannot read: inject('dogfoodCwdRoot'))

PR objectstack-ai#21919 added that site for objectstack-ai#21914. The guard is right: a
`mkdtempSync` base handed over through `inject()` is an expression the
tree's scratch-directory scan cannot read. So the fix is at the site,
and nothing in the guard or the scan changes.

- **`packages/qa/dogfood/test/per-file-cwd.setup.ts`**: each file's
directory is now `mkdtempSync(join(tmpdir(),
perFileDirPrefix(runTag)))`. The base is `tmpdir()`, outside the tree by
construction, and the scan reads it as such.
- Only the run's TAG crosses `inject()` (key renamed `dogfoodCwdRoot` to
`dogfoodRunTag`), and only as a name component.
  - A tag carrying a path separator or `..` is refused.
- **`packages/qa/dogfood/test/per-file-cwd.global-setup.ts`**:
- At start it clears a stale `.objectstack` (unchanged). It reserves the
run's tag as `mkdtempSync(join(tmpdir(), 'os-dogfood-run-'))` (unchanged
expression) and provides its basename.
- At END it removes every system-temp entry named with this run's prefix
(`perFileDirPrefix`, exported here and imported by the setup module, so
the prefix has one spelling), then the reservation.
- The removal stays run-level, so the memoized `shared-showcase` boot
keeps its handles until the run ends. A concurrent run carries another
tag and is never touched.
  - The teardown still judges nothing.
- **`packages/qa/dogfood/vitest.config.ts`**: one comment line ("removes
the run's per-file directories at the end").

Unchanged from objectstack-ai#21914: every file still runs in its own temporary cwd;
the cwd is restored in `afterAll`; and the throwing `afterAll` guard on
`packages/qa/dogfood/.objectstack/data` is untouched.

Out of scope here: the rule, the case and
`scripts/pm/dispatch-gates.mjs` are untouched. Nothing is skipped or
recorded as an exception.

## Evidence

- **Reproduced first**, on `origin/main` `faf8dce4` with no change:
`pnpm check:pm-dispatch-gates` gave `✗ dispatch-gates self-test: 1 of
1976 case(s) failed.`. The one red case is the line quoted above, at
`per-file-cwd.setup.ts:60`. The battery took 821.9s.
- **At head `2edc5d59`**: `pnpm check:pm-dispatch-gates` gave `✓ no
mkdtempSync site in this tree takes a base the scan cannot read` and `✓
dispatch-gates self-test: 1976 cases pass.` (780.2s).
- **The case still fails on a planted unreadable base.** A second
worktree was checked out at `2edc5d59`, and
`scripts/ablation-replace.mjs` (wrap mode) replaced the site with
`process.chdir(mkdtempSync(join(inject('dogfoodRunTag'), 'file-')));`.
The anchor went from 1 to 0, and the blob from `dc1d3de3` to `51346b9f`.
- Result: `✗ dispatch-gates self-test: 1 of 1976 case(s) failed.`, and
the red case was exactly `... UNRESOLVED:
packages/qa/dogfood/test/per-file-cwd.setup.ts:73 (a base this scan
cannot read: inject('dogfoodRunTag'))`.
- Restore was proven: blob after restore equals HEAD (`dc1d3de3`), `git
diff HEAD` is empty, and `git status --porcelain` is empty. The second
worktree was then removed.
- **objectstack-ai#21914's behaviour, re-proven at `2edc5d59`**:
- Whole dogfood suite, `pnpm --filter @objectstack/dogfood test`: `Test
Files 208 passed | 1 skipped (209)`, `Tests 1606 passed | 9 skipped
(1615)`. Afterwards `packages/qa/dogfood/.objectstack` does not exist.
Zero `/tmp/os-dogfood-run-*` entries before the run and zero after it.
- H4 ablation through `scripts/ablation-replace.mjs`: the central
`process.chdir(...)` was dropped (blob `dc1d3de3` to `ad6684f2`) and two
files were run, `showcase-external-autoconnect` and `showcase-search`.
Result: `Test Files 2 failed (2)`. Both failed in the guard:
`.../packages/qa/dogfood/.objectstack/data exists after this test file
ran. Entries: showcase_external.db` (plus `-shm` / `-wal` on the
shared-showcase file).
- The restore was proven (blob equals HEAD, `git diff HEAD` empty). The
restored leg gave `2 passed`, exit 0, and left nothing in the package
dir. Zero temp entries were left after each leg, the red one included.
- **Gates**: `dispatch-gates --commands` over the 3 changed paths
derived 47 families. All 47 ran at `2edc5d59`, and `dispatch-gates
--ran` reported `47 derived famil(ies) accounted for — 47 run, 0
NOT-MEASURED`.
- `check:dual-build-cjs-loads` first exited 3 (prerequisite:
`packages/apps/studio/dist` missing), then exited 0 after building it.
- `pnpm lint` exits 0, and `pnpm --filter @objectstack/dogfood
typecheck` is green.
- `check:pm-dispatch-gates` is path-scoped, so this PR's CI may not
schedule it, which is how the red reached `main`. It was run here in
full at the head, as above.

## Acceptance notes

- Open PR objectstack-ai#21930 also edits `packages/qa/dogfood/vitest.config.ts`, in
the `isolated` project's alias block (about line 295). This PR changes
one comment line near line 146. The hunks do not overlap.
- How this red reached `main`: objectstack-ai#21919's own CI did not schedule
`check:pm-dispatch-gates`, because that battery is path-scoped and the
battery's live-tree half reads every tracked source. This is noted, not
filed: the scoping is the merge-group ruling that `lint.yml` documents,
and the hourly full run caught the red as designed.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…rries the credential mask and omits internal fields (objectstack-ai#21928)

Fixes objectstack-ai#21867
Clause-②: no

## What this changes

Ruling A on objectstack-ai#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 (objectstack-ai#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
objectstack-ai#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 objectstack-ai#21919. It is a no-op once objectstack-ai#21935 lands.

---

_Generated by [Claude
Code](https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

2 participants