Skip to content

test(dogfood): each file's temporary cwd is created from a base the scratch-dir scan can read - #21935

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-21924-per-file-cwd-readable-base
Oct 6, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-21924-per-file-cwd-readable-base

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #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 #21919 added that site for #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 #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.
  • dogfood: five files boot the showcase in the package directory and leave its federated fixture database behind, so a later showcase boot's federated state depends on shard order #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


Generated by Claude Code

…cratch-dir scan can read

The dispatch-gates self-test refuses a mkdtempSync whose base it cannot
read, and the per-file cwd took its base from inject(): a path handed over
from the globalSetup. Each file's directory is now made as
mkdtempSync(join(tmpdir(), TAG-file-)), where TAG is the run's reserved
name (only a name crosses inject(), and a separator in it is refused). The
globalSetup sweeps that prefix at run end, then removes the reservation,
so the shared boot's handles still live until the run ends and a
concurrent run's directories are never touched. Isolation, the leftover
guard and the stale-dir clearing are unchanged.

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

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

⚠️ 1 changed file(s) yielded no anchor (packages/qa/dogfood/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files. Nothing else in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)).

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/qa/dogfood/vitest.config.ts) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

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 9dce635337c2cc42a4149aa49289ad77d172363d → packageMentionDocs.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 6, 2026 02:19
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 6, 2026 02:19
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit 9e33ee7 Oct 6, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21924-per-file-cwd-readable-base branch October 6, 2026 02:54
This was referenced Oct 6, 2026
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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… metadata layer holds; layering can only narrow intake (objectstack-ai#21864)

Fixes objectstack-ai#21835

Clause-②: yes (widening)

Fixes a regression introduced after 17.6.0 (with objectstack-ai#21420); it should land
before 17.7.0 is cut.

## What

Per the rulings recorded on objectstack-ai#21835: a public form's withdrawal is a kill
switch, layering can only narrow anonymous intake, a withdrawal closes
the **same form** only, and only an **explicit** withdrawal counts.

- **Anonymous doors (`GET /forms/:slug`, `POST /forms/:slug/submit`).**
Both use one resolver and judge by the name of the view item they serve.
When an organization is resolved, the env-wide view list beneath it is
read as well. A form is served only when the env-wide item of the same
name does not explicitly withdraw a form in the same slot (nested form,
the same `formViews` key, or the flattened config) or with the same
slug. Other views that share the public slug never close each other.
- **What counts as a withdrawal.** A sharing that keeps its `publicLink`
and sets `enabled: false` or `allowAnonymous: false`. Only an explicit
false counts. Not a withdrawal: an absent switch, a sharing with no link
(raw, or schema-parsed), a cleared link, a removed sharing block, or no
body of the view at that layer. The public data collection docs page has
a "Withdraw a public form" section with these rules.
- **Write door (save and publish).** An org-scoped `view` save or draft
promotion in the organization the doors read is refused with `403
NOT_OVERRIDABLE` when it would leave open a form the env-wide definition
explicitly withdraws. It judges by the stored row: the body is compared
with the env-wide body of the row it is keyed by (the active env-wide
row, else the package artifact), matched by slot or by slug. So renamed
`formViews` keys, `form.name`, slot moves and listViews collision
renames are the same form. It is also judged against the env-wide view
list the way the doors read it, with container bodies expanded.
Re-saving an overlay that was open before the withdrawal is refused. The
message names both remedies.
- **Package-shipped forms.** A package artifact is part of the env-wide
definition, not a separate layer. A package artifact parsed by the stack
schema (strict `defineStack`, the default) carries the schema's default
`enabled: false`, so a shipped form that keeps its link without
switching `enabled` on is an explicit withdrawal and fails closed. An
artifact loaded without that parse (`defineStack(..., { strict: false
})` or a hand-built manifest) is judged as written: a switch it omits is
absent, which is not a withdrawal. The env-wide definition is the
administrator's switch, so an env-wide save may open a form the package
ships closed.
- **Known limit: packages and names.** A withdrawal of a view name
closes that name in every package: when two packages ship a view of the
same name, one package's withdrawal also closes the other package's form
of that name. It may over-close, never under-close. Per-package
precision is tracked in objectstack-ai#21934. A publish judges the draft it promotes
under the same package key (the stated one, else the resolved draft
row's own), so with two packages holding a draft of the same view in one
organization, each draft is judged on its own publish.
- **Intentional reversal.** The earlier behaviour in which an
organization overlay re-published a form the package had withdrawn is
reversed. A form with no env-wide word on it stays
organization-publishable (objectstack-ai#21420), and the objectstack-ai#21473 anchors and objectstack-ai#21566
field allowlist are unchanged.
- **Public surface:** `@objectstack/metadata-core` adds one export,
`anonymousFormIntakeWithdrawnIn` (`minor`). `@objectstack/rest` and
`@objectstack/metadata-protocol` are `patch`.
- **Known limit (ruled to stay as is).** The doors match by served item
name, and the write door runs only on an org-scoped save or publish. An
organization overlay stored before the env-wide withdrawal, or restored
by rollback or commit revert, can still be served if it keeps the form
open under a different key or slot than the env-wide definition.
Withdrawing the form in that overlay closes it. Stated in the changeset
and the docs.

## Tests

The first bullet is round 6, the second round 5, the third round 4; the
bullets after them were measured at `e8778acb96` (round 2):

- Round 6 at `7882eef683` (merged origin/main `9dce635337`, merge commit
`46d08189a7`): metadata-core 18 files, 411 passed; metadata-protocol 216
files (3 skipped), 27940 passed, 19 skipped; rest 260 files, 4912
passed, 326 skipped; objectql 375 files, 7469 passed (suites at
`4d5f6c4e61`; the later commits touch docs, the changeset and three
ported dogfood files only). Typecheck green for metadata-core,
metadata-protocol, rest and objectql, test layers included. 97 of 97
derived gates green at `7882eef683`, reconciled with `dispatch-gates
--ran`; `dispatch-gates --self-test` 1976 cases pass. The cross-package
skip of round 5 is removed per the ruling, so a withdrawal of a view
name closes it in every package again. Pins: another package's
withdrawal of the same name closes this package's form too
(metadata-core and the doors); with two packages shipping the same view
name, a row-anchored rename by a package-bound org save is refused
(metadata-protocol), with a withdrawn-save control. Ablation: the two
edited sources set back to their round-5 blobs and rebuilt, markers
proved in `dist/`: 1 red in each of metadata-protocol, metadata-core and
rest; restored to HEAD (`git diff HEAD` empty), rebuilt, markers proved
absent.
- Round 5 at `d8657b5c19`: metadata-core 18 files, 411 passed;
metadata-protocol 216 files (3 skipped), 27938 passed, 19 skipped; rest
260 files, 4911 passed, 326 skipped; objectql 374 files, 7464 passed.
Typecheck green for metadata-core, metadata-protocol, rest and objectql,
test layers included. 97 of 97 derived gates green, reconciled with
`dispatch-gates --ran`. New pins: two packages' drafts of one view in
one organization are each judged on their own publish; one package's
withdrawal of a name closes its own form (metadata-core and both doors;
the cross-package half was inverted in round 6). Ablation: removing the
package key from the publish gate's draft read turned the two-package
pin red, and removing the package comparison turned the cross-package
pin red; both restored to HEAD (`git diff HEAD` empty).
- Round 4 at `79b847042d` (targeted): metadata-core
`anonymous-form-intake.test.ts` 39/39, metadata-protocol
`protocol.org-scoped-write-refused.test.ts` 41/41, rest
`public-form-withdrawal` + `public-form-intake-availability` 43/43.
Typecheck green for metadata-core and metadata-protocol. Docs and
changeset gates green. New pins: a package parsed `false` is a
withdrawal; an env-wide save opens a package-closed form.
- `@objectstack/metadata-core`: 18 files, 394 passed.
- `@objectstack/rest`: 260 files, 4906 passed, 326 skipped.
- `@objectstack/metadata-protocol`: 214 files (3 skipped), 27752 passed,
19 skipped.
- Typecheck green for all three and dogfood.
- Dogfood (real showcase boot): the layered-withdrawal suite 5/5
(including the re-save refusal) and the five sibling public-form suites
20/20.
- Coverage: two views sharing a slug do not close each other (one read,
with and without an organization, and across layers); a cleared link is
not a withdrawal; a parsed link-less sharing is not a withdrawal;
re-saving an already-open overlay is refused; a container-shaped save is
judged after expansion.
- Ablation (source set back to the base blobs, packages rebuilt): 3 / 4
/ 4 tests red across metadata-core / rest / metadata-protocol; restored
to HEAD.
- Gates: 92 of 95 derived run green; `check:skill-examples`,
`check:dual-build-cjs-loads` and `check:type-check-debt` are NOT
MEASURED locally (workspace-wide prerequisites) and left to CI.

## Acceptance notes

- The known limit above (an overlay stored before the withdrawal, or
restored by rollback or revert, with its form under a different key or
slot) is accepted per the ruling on objectstack-ai#21835. No provenance or new
protocol query was added.
- Two installed apps publishing the same slug is a separate concern
(slug collision), out of scope here.
- A package artifact loaded without the stack schema's parse (`strict:
false`, a hand-built manifest) is judged as written; giving every load
path the schema's sharing defaults is left as a possible follow-up (see
the round 5 report on objectstack-ai#21835).
- An objectql test double now answers the publish gate's draft-row read
(`protocol-publish-package-drafts.test.ts`); that is test-only.
- Maintainer ruling, 2026-10-06: 「撤掉跨包那一改,合并」. The cross-package skip is
removed; per-package precision is tracked in objectstack-ai#21934.
- This branch carries three dogfood files ported unchanged from objectstack-ai#21935
(`packages/qa/dogfood/test/per-file-cwd.setup.ts`,
`packages/qa/dogfood/test/per-file-cwd.global-setup.ts`,
`packages/qa/dogfood/vitest.config.ts`) so the dispatch-gates self-test
is green here; they merge away once objectstack-ai#21935 lands.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…asses the explicit system opt-in instead of no principal (objectstack-ai#21940)

Fixes objectstack-ai#21913
Clause-②: yes (widening)

This is a slice of objectstack-ai#21908: the services-lane producers. objectstack-ai#21908 stays
open, because it builds the deny itself, last.

## What changes

Every engine call in the card's named functions now passes the explicit
system opt-in that exists today: `{ isSystem: true }` on the call's
context. These calls used to reach the data engine with no context at
all, so they had no principal and no opt-in. They got past the security
middleware only through its principal-less hand-off (ADR-0096 E1), which
objectstack-ai#21908 retires. This PR adds no new elevation API, changes nothing any
door authorizes, and does not build the deny.

| Row | Package | Function | Engine calls that now carry the opt-in |
| :-- | :-- | :-- | :-- |
| 7 | service-settings | `SettingsService.loadRows` | `find` on
`sys_setting` |
| 8 | service-settings | `SettingsService.upsertRow` | existence-probe
`find` and `insert` on `sys_setting` (its `update` already had the
opt-in) |
| 8 | service-settings | `buildSettingAuditWriter` `write` | `insert` on
`sys_setting_audit` |
| 11 | service-datasource | `loadDatasourceRows`, `loadDatasourceRow` |
`find` / `findOne` on `sys_metadata` |
| 11 | service-datasource | `persistDatasourceRow`,
`deleteDatasourceRow` | `findOne` + `insert` / `update` / `delete` on
`sys_metadata` |
| 11 | service-datasource | secret binder `bind` / `unbind` / `resolve`
| `insert` / `delete` / `find` on `sys_secret` |
| 12 | plugin-webhooks | `AutoEnqueuer.doRefresh` | `find` on
`sys_webhook` |
| 12 | plugin-webhooks | `createWebhookRedeliverGuard` | `findOne` on
`sys_webhook` |
| 13 | service-messaging | `SqlNotificationOutbox.claim` / `claimDigest`
/ `reapExpired` | candidate `find`, claiming `update`, read-back `find`;
the reap `update` |
| 13 | service-messaging | `SqlHttpOutbox.claim` / `reapExpired` |
candidate `find`, claiming `update`, read-back `find`; the reap `update`
|
| 14 | service-messaging | `MessagingService.writeEvent` | `insert` on
`sys_notification` |
| 14 | service-messaging | inbox channel `send` +
`writeDeliveredReceipt` | `insert` on `sys_inbox_message`, the
recipient-locale `findOne` on `sys_user` (a helper only `send` calls),
`insert` on `sys_notification_receipt` |
| 14 | service-messaging | `PreferenceResolver.loadRows` | both `find`s
on `sys_notification_preference` |
| 14 | service-messaging | `RecipientResolver.resolveEmail` | `findOne`
on `sys_user` |

IDataEngine reads pass the opt-in in the trailing options argument,
which is where the contract puts a read's context. Two package-local
surfaces have a single options bag, and the opt-in goes there:
`SettingsEngine`, and the `sys_secret` binder's engine slice.
`SettingsEngine.find` and `.insert` and `SecretStoreEngineLike.delete`
now declare the `context` they receive. No symbol is new on any package
entry. The shared constants (`FAN_OUT_SYSTEM_CONTEXT`,
`DISPATCHER_SYSTEM_CONTEXT`) live in package-internal modules.

Rows 15 and 16 are not in this slice and wait for the maintainer.

## Measurement

**Instrument (H2).** A local, uncommitted instrument sat at the security
middleware. It recorded each principal-less, non-system context that
reached the hand-off, with its stack. It recorded whether any of the six
gates before the hand-off threw on such a call, and which of them
matched the call's object and verb. It also recorded the outcome after
`next()`: the result type, row count, key set, a hash of the
non-volatile values, or the error code. In the AFTER leg it recorded the
same outcome for each `isSystem` call whose stack ran through these four
packages. Both legs covered the whole dogfood suite (206 files, 1590
tests passed, 9 skipped, identical in both legs) and a booted showcase
dev composition. The boot covered seed-admin, a settings read plus two
writes, a runtime datasource create / patch / read / delete, and admin
and anonymous requests, then sat idle for 65 seconds so the dispatchers
and the webhook refresh ticked. The instrument was then reverted, and
the file's blob equals HEAD (`5b4ab28045af`). The plugin-security dist
was rebuilt clean: `ablation-dist-preflight --absent` passes, and the
marker had 3 hits in the instrumented dist.

**Before and after, per function.** Columns: principal-less records
BEFORE, principal-less records AFTER, and `isSystem` records AFTER.

| Function | dogfood before / after / after-system | boot before / after
/ after-system |
| :-- | --: | --: |
| `SettingsService.loadRows` | 2090 / 0 / 2090 | 42 / 0 / 42 |
| `SettingsService.upsertRow` (probe + insert) | 7 / 0 / 7 | 3 / 0 / 3 |
| setting-audit `write` | 4 / 0 / 4 | 2 / 0 / 2 |
| `loadDatasourceRows` | — | 1 / 0 / 1 |
| `persistDatasourceRow` | — | 4 / 0 / 4 |
| `deleteDatasourceRow` | — | 2 / 0 / 2 |
| `AutoEnqueuer.doRefresh` | — | 3 / 0 / 3 |
| `SqlNotificationOutbox.claim` | 8 / 0 / 8 | 56 / 0 / 56 |
| `SqlNotificationOutbox.claimDigest` | 8 / 0 / 8 | 56 / 0 / 56 |
| `SqlNotificationOutbox.reapExpired` | 1 / 0 / 1 | 7 / 0 / 7 |
| `SqlHttpOutbox.claim` | 8 / 0 / 8 | 56 / 0 / 56 |
| `SqlHttpOutbox.reapExpired` | 1 / 0 / 1 | 7 / 0 / 7 |
| `MessagingService.writeEvent` | 8 / 0 / 8 | — |
| inbox `send` (row insert) | 8 / 0 / 8 | — |
| `writeDeliveredReceipt` | 8 / 0 / 8 | — |
| `PreferenceResolver.loadRows` | 16 / 0 / 16 | — |
| `RecipientResolver.resolveEmail` | 1 / 0 / 1 | — |

Principal-less totals moved 35245 → 33077 in dogfood (Δ 2168) and 422 →
183 at boot (Δ 239). Each delta is exactly the sum of the rows above. No
principal-less record attributed to any moved function remains. The
hand-off still sees row 15 and every other lane's producers.

No run reached these, so each is held by its unit pin instead:
`loadDatasourceRow`, the secret binder (this repo wires it into no
composition), the redeliver guard, the inbox recipient-locale read
(template path), and the claim path's `update` and read-back (no pending
rows in any run).

**Gates before the hand-off (Zone 1).** Across 35245 dogfood and 422
boot principal-less records, the "gate threw" record fired 0 times. The
package-managed, system-row, curated-capability and audience-anchor
gates never matched an object or verb these producers touch. Neither did
the delegated-administration gate. The engine-owned guard matched the
bucket on the writes to engine-owned objects. On a context with no user
id, its own `isUserContextWrite` predicate returns before it can refuse.
**No producer is held back.**

**What each call answers is unchanged.** Per function, call counts per
object and verb are equal before and after. So are the outcome shapes
(result type, row count, key set). There were 0 errors in either leg.
Content hashes are equal for 11 of 14 functions in dogfood and 7 of 9 at
boot. The rest differ only on values that change every run: the
receipt's `at` timestamp (all 8), and inbox and notification payloads
that carry a per-run record id or date (2 of 8 and 3 of 8, from the
approval and sweep tests). At boot, the probe's own per-phase file path
sits in the stored datasource record. The plugin-audit rows these writes
produce (`sys_audit_log`, `sys_activity`) are written in equal numbers
before and after.

**H6, `loadRows`.** The call count is the same (2090 + 42), and the
returned settings have equal hashes on every call. The opt-in adds one
frozen context object. The middleware now exits at its system
short-circuit instead of running the six gates and the hand-off. No
wall-clock figure is quoted, because the container is shared.

**H7, reads on another principal's behalf.** What these reads return (a
user id for an address, a locale, preference rows) is consumed inside
the fan-out. `emit()` answers the notification id, counts and
per-delivery outcomes. Its three in-repo callers (approvals, the flow
notify node and comment mentions) relay counts and the id only. The
opt-in changes none of this, because the principal-less read returned
the same rows.

**One engine branch keyed on the flag stops running on these writes.**
It is row 23 of the `isSystem` census page: the dangling-reference check
is skipped for an `isSystem` write. Before the move, it ran 10 times
nested under these producers (`writeEvent` 2, inbox `send` 2,
setting-audit `write` 6), on the `actor_id` lookups, and resolved every
time. After the move it does not run. A local probe (real ObjectQL and
SQLite, deleted after the run) showed what that means for an `actor_id`
that names no user. With no context, today's path refuses with
`VALIDATION_FAILED` ("Actor: no sys_user record has id …"). Under
`isSystem` the row is written. A real user is written both ways. That
`actor_id` comes from `emit()`'s `actorId`, which a flow notify node can
author. So the behaviour on measured traffic is unchanged, and a latent
difference remains for an `actorId` that names no user. The Acceptance
notes carry it.

**H4 pins and ablations.** There is one pin per package. The engine
double sits behind the package's real call path, proves the population
ran, and asserts `isSystem` on every call. Each pin was ablated by
dropping the opt-in through `scripts/ablation-replace.mjs` (the anchor
must hit). Seven legs ran: settings `loadRows`, the fan-out constant,
the dispatcher constant, the datasource `sys_metadata` constant, the
secret-binder constant, and the two webhook constants. Every leg went
red under the mutation, and the failure names the call, for example
"find on sys_setting: expected undefined to deeply equal { isSystem:
true }". Every leg was restored with blob equal to HEAD and an empty
`git diff HEAD`, and went green again. The pins are package-local,
imported from `src` with no dist in the path.

**Census pages (H3).** The `isSystem` census
(`check-system-context-census`) is OK, and `--fix` changed nothing: this
change adds no elevation read site. The tenant-audit census did move,
because the write sites now thread a context. It was regenerated with
`tenant-audit-census.mjs --write`. On its page, the hand-written figures
follow the census: the provable no-context, tenancy-enabled count went 9
→ 2, unreadable 67 → 60, decidably elevated 114 → 121.

**Serial (H5).** `origin/main` was merged twice. It now includes
objectstack-ai#21906's squash, and the merge was clean. A `git merge-tree` against
objectstack-ai#21877's head (`5c405846`, now closed as a draft) is clean. This PR
edits neither PR's region: `datasource-admin-plugin.ts` and
`datasource-secret-binder.ts` only, in `service-datasource`.

## Tests

- At `10e77fef6f`, after merging `origin/main` `faf8dce482`. The next
merge (`9dce635337`, which brings this PR to `62960ffa1a`) touches no
file in these four packages. Typecheck of the four packages: exit 0.
- Unit suites: service-settings 614 passed, service-messaging 510,
service-datasource 743, plugin-webhooks 165. All exit 0, unchanged apart
from the new pins and tests that came in from `main`.
- ESLint, narrowed to the 19 changed TS files with `--no-inline-config
--format json`: 19 files, 0 errors, 0 warnings. Those files are inside
the config's own `packages/**/*.{ts,…}` population, and the config
enables no type-aware linting, so this diff cannot move a verdict on any
untouched file. The full `pnpm lint` run belongs to CI.
- At `62960ffa1a`, the head this PR opens with, every one of the 105
commands `dispatch-gates --commands --repo objectstack-ai/objectstack`
derives exited 0. `dispatch-gates --ran` reports: "105 derived
famil(ies) accounted for — 105 run, 0 NOT-MEASURED". In an earlier pass,
four of these went red on this branch, and they are now fixed.
`check:tenant-audit-census` needed the census regenerated.
`check:engine-double-contract` and `check:objectql-double-limit` needed
the pin doubles routed through the shared dispatch asserts and holding a
find's bound, with the ledger recording the new pinned coverage.
`check:dual-build-cjs-loads` needed eight unrelated packages built
first.

## Acceptance notes

- **Producers in these packages that the card does not name.** A static
read finds that they still reach the engine with no context. No run
exercised them, so the measured table never listed them. Without a
route, objectstack-ai#21908's deny breaks each one, so they are listed for the seat's
closure rather than moved here:
- service-settings: the `sys_secret` store the plugin builds (`insert` /
`get` / `update`), and `SettingsService.readStoredHandle`.
- service-messaging: `SqlNotificationOutbox` and `SqlHttpOutbox`
`enqueue`, `ack` and `list`; the email and SMS channels' recipient
reads; `RecipientResolver.resolveRole` / `resolveTeam` /
`resolveOwnerOf`; the emit dedup lookup; the template renderer's read.
- `resolveOwnerOf` reads a business object, and its posture is not
neutral. Today the sharing middleware answers a principal-less read of a
`private` object with a deny-all filter, so an `owner_of:` recipient on
such an object resolves to nobody. Under the opt-in, that filter would
be bypassed.
- **Request-door producers that act on the caller's own rows, like rows
15 and 16** (report-only, for the maintainer's ruling): the inbox unread
count, and mark-read / mark-all-read (`unreadNotificationIds`,
`upsertReadReceipt`, `notificationOrganization`).
- **The row-23 difference above:** the dangling-reference check stops
running on the `actor_id` of `sys_notification`, `sys_inbox_message` and
`sys_setting_audit`.
- One posture question was noted on a request-door read and is held
off-thread. It was not measured.

## Seat's append: patch round 1 at `57f738dfb1` (written by
`domain:services` seat 1 from the dev's report `6009307655`; the dev
does not edit this body)

**What changed in the patch round** (seat verdict `6008259054`). The
sections above describe `62960ffa1a`; where they differ, this append is
current.
- **`Clause-②: yes (widening)`.** The exported `SettingsEngine` (`find`,
`insert`) and `SecretStoreEngineLike` (`delete`) gain an optional
`context`, so `@objectstack/service-settings` and
`@objectstack/service-datasource` take a `minor`. `service-messaging`
and `plugin-webhooks` stay `patch`. Line 2 above, the changeset and the
claim (`6003840075`) moved together. Nothing accepted or refused at any
door changes.
- **A user reference that names no user is still refused.** The engine
skips its dangling-reference check for an `isSystem` write and has no
option to keep it. So each producer that writes a user reference does
one guarded `sys_user` read by id under the opt-in, then refuses an
unknown id with the engine's own answer: `VALIDATION_FAILED`, one
`reference_not_found` finding, and the same message.
- The checked references are the `actor_id` of `sys_notification`
(`writeEvent`), of `sys_inbox_message` (the inbox send) and of
`sys_setting_audit` (the setting-audit writer), and the `user_id` of a
user-scope `sys_setting` row on `SettingsService.upsertRow`'s insert.
The last is the same difference, which this PR's opt-in introduced on
that insert.
- The refusal is built by `validationFailure` from `@objectstack/types`,
already a runtime dependency of both packages, so neither package stamps
the code itself and `check:error-code-provenance` is green with no spec
row and no waiver. It is shape-identical to the engine's refusal but not
`instanceof` objectql's `ValidationError`; the callers on these paths
read the message or the code, and every door maps the shape to `400
VALIDATION_FAILED`.
- A write that names no user is unchanged. A read that cannot run lets
the write through, as the engine's check does. The cost is one extra
`sys_user` read per write that names a user.
- Differential pins over a real engine hold each producer's answer equal
to the engine's own refusal of a context-less insert. Four ablations
went red and were restored with blob equal to HEAD.
- **objectstack-ai#21935 merged in** (`a3c2209a68`). `check:pm-dispatch-gates` exits
0.
- **Gates at `57f738dfb1`:** 105 derived, 105 run, all exit 0. The 54
roster families: 51 exit 0, and 3 are NOT WIRED locally (they need a
pull-request context; CI runs them).
- **`service-settings/vitest.config.ts`** gains one anchored alias
(`platform-objects/identity` → `src`) for the new pin, which
`check:test-source-alias` asks for.

**Carried, not filed here:**
- Producers in these packages that the card does not name are recorded
on objectstack-ai#21908's census (rows 24 onward). `resolveOwnerOf` is not neutral to
move.
- The inbox unread count and mark-read / mark-all-read join the
maintainer's open ruling on rows 15 and 16.
- One request-door posture question is held off-thread, at class level
only.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…der tmpdir() and removes them itself (objectstack-ai#22416)

Fixes objectstack-ai#22400
Clause-②: no

## What this changes

One test file,
`packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts`,
in the private `@objectstack/dogfood` package:

- `databaseFile()` takes its `mkdtempSync` base from `join(tmpdir(),
'catalog-cold-boot-')`. Before, the base was `join(process.cwd(),
'catalog-cold-boot-')`.
- The per-file cwd sweep no longer covers these directories, so the file
records every root `databaseFile()` creates and removes them in a new
`afterAll`. That hook runs after `afterEach` has stopped the last
kernel. A refused boot leaves no kernel to stop, but its directory is
still removed. This follows the harness's own `BootOptions.databaseFile`
contract: "Callers own the file's lifetime (create it under a temp dir,
delete it after)".
- The header comment now describes the new placement and why the base is
spelled `tmpdir()`. The old text ("the files live in this test file's
own working directory, which the dogfood run removes at its end") is no
longer true.

Why: `scripts/pm/dispatch-gates.mjs`'s scratch-directory scan must be
able to read every `mkdtempSync` base. `process.cwd()` is not a base it
reads, so the site came back UNRESOLVED and the `pm dispatch-gates
self-test` case failed on `main`. This uses the same remedy as PR
objectstack-ai#21935, which fixed the same mechanism on `per-file-cwd.setup.ts`: the
base is made readable at the site. Per triage, `dispatch-gates.mjs` is
not touched (the guard is not loosened, and `process.cwd()` is not
taught to the scan), and `per-file-cwd.setup.ts` is not touched.

## Evidence

### `pnpm check:pm-dispatch-gates` (run detached, followed with `tail
--pid`)

| tree | final line |
|---|---|
| `main` `83e7ae93a` (unfixed, in a separate worktree pinned there) | `✗
dispatch-gates self-test: 1 of 2011 case(s) failed.` (the failing case:
`✗ no mkdtempSync site in this tree takes a base the scan cannot read —
UNRESOLVED:
packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts:108
(a base this scan cannot read: process.cwd())`) |
| this branch `d8eee2191` | `✓ dispatch-gates self-test: 2011 cases
pass.` |

On this branch the case itself reads `✓ no mkdtempSync site in this tree
takes a base the scan cannot read` (log line 1533 in both runs). The
wrapper's own battery reads `✓ check:pm-dispatch-gates --self-test: the
exit contract holds in all three directions.` on both trees. The unfixed
run ends `ELIFECYCLE Command failed with exit code 1.`, and the fixed
run prints no such line. Battery time on this shared box: 781.7s before
and 774.7s after.

The same scan, read directly through `exposedScratchDirs()` on both
trees: `sites=1625 inTree=63 exposed=0`, with `unresolved(mkdtempSync)`
going from 1 to 0 and `unresolved(all)` from 289 to 288. Site and
in-tree counts are unchanged, so the one site moved to "outside", not
out of the scan.

### The dogfood file itself (through `scripts/pm/os-verify-lock.sh`, on
`d8eee2191`)

`pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2
test/security-catalog-cold-boot-environment-holder.dogfood.test.ts`

```
 Test Files  1 passed (1)
      Tests  4 passed (4)
os-verify-lock: VERDICT command-exit 0
```

A `catalog-cold-boot-*` listing under `tmpdir()` (`/tmp` here), taken
before and after the run, with a poller sampling `/tmp` every 100 ms
during it:

```
before (2026-10-09T04:02:10Z): count=0
seen during the run:
  2026-10-09T04:02:32.669Z /tmp/catalog-cold-boot-6eM9D3
  2026-10-09T04:02:36.183Z /tmp/catalog-cold-boot-KGxFei
  2026-10-09T04:02:37.484Z /tmp/catalog-cold-boot-eGpgIr
  2026-10-09T04:02:39.290Z /tmp/catalog-cold-boot-PzYAkQ
  (distinct seen=4, one per case)
after (2026-10-09T04:02:41Z): count=0
```

So the four roots really are created under `tmpdir()` and none survives
the file. Nothing else removes them: the dogfood globalSetup teardown
only sweeps its own `os-dogfood-run-TAG-file-` prefix.

`pnpm --filter @objectstack/dogfood typecheck` gives `VERDICT
command-exit 0`. The package's `tsconfig.json` includes `test/**/*`, and
`tsc --noEmit --listFiles` names this file once.

### Derived gates

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 53 commands from this diff (1 path
vs merge base `83e7ae93a`). Each was run on `d8eee2191` with its exit
code recorded, then reconciled with `--ran`:

```
Run reconciliation — 53 derived, 53 run, 0 NOT-MEASURED, 0 UNRUN.
✓ dispatch-gates --ran: 53 derived famil(ies) accounted for — 53 run, 0 NOT-MEASURED (a DERIVED zero — all 53 recorded an exit code and none of them is 3).
```

On its first pass, `pnpm check:dual-build-cjs-loads` exited 3
(`PREREQUISITE NOT MET`): eight packages outside the dogfood closure had
no `dist/`. They were built through the lock (44 of 44 turbo tasks, all
cache hits), and the re-run exited 0: `✓ check:dual-build-cjs-loads —
107 published require entry point(s) across 66 package(s) load`. The
record above carries that re-run's code. The other 52 exited 0 on the
first pass.

ESLint, narrowed to the one changed file: `eslint --no-inline-config
--format json` gives 1 file, 0 errors, 0 warnings, and the file is not
ignored. The resolved config sets neither `parserOptions.project` nor
`projectService`, so linting is not type-aware and this diff cannot
change the verdict on any file it does not touch. The repo-wide `pnpm
lint` is CI's.

## Acceptance notes

- No changeset: `@objectstack/dogfood` is `private: true` and the diff
is one test file.
- The full dogfood suite is not run locally. CI's `Dogfood Regression
Gate` runs it. Locally this file ran on its own, as above.
- The workflow-valued families the derivation names as NOT MEASURED
(`check-shard-attestation --emit …`, `check-test-completeness
"$RUNNER_TEMP/…"`, `check-issue-citations --census`) take a value that
exists only in a CI run. They are CI's.

---

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

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/s skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

hourly full run: red on main (Lint & Type Check)

2 participants