fix(runtime): key package lifecycle state by project, not by environment alone - #16572
Conversation
Pins the isolation the store does not yet have: two real project roots, one OS_HOME, one environment id. Red on this commit by design -- it records the leak (B's disable reaches A's boot read) and the clobber (B's enable erases A's disable) before the repair. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
…ent alone The disable list lived at `<OS_HOME>/package-state/<environmentId>.json`, and both halves of that path are machine-global -- `resolveObjectStackHome()` takes no arguments, and an environment id is not a project identity -- so two projects on one machine shared one file. Driven, not reasoned: project B's disable reached project A's boot read, and project B's enable erased project A's disable. The name now carries the project as well, using the same convention the runtime state file settled on: a sanitised basename plus a 12-hex digest of the resolved root, joined to the environment id with a dot. A pre-existing `<environmentId>.json` is still read while a project has no file of its own and is never deleted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
The reflow shifted an elevation read site by one line and rotted a system-context census anchor; keeping the comment on one line leaves the census page untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
📓 Docs Drift Check7 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin dd6d09fcf6ae61f6dbc23bfd0c359b3d0d47684d && git checkout dd6d09fcf6ae61f6dbc23bfd0c359b3d0d47684d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d1c86a745a42b15e0cfc2e61884a6672dfef3706 bbc5b3e3b812033ae6a95d5b513e117e72ea0bab && git checkout -B drift-repro d1c86a745a42b15e0cfc2e61884a6672dfef3706 && git merge --no-ff bbc5b3e3b812033ae6a95d5b513e117e72ea0bab
node scripts/docs-audit/affected-docs.mjs --json d1c86a745a42b15e0cfc2e61884a6672dfef3706 |
Fixes #15969
1 — The card's own disclaimer, settled first: the collision is DRIVEN
The card was careful about what it had not done:
It has now been driven, and it REPRODUCED. Two real project directories, one
OS_HOME, one environment id (env_local), the store's own public entry points, and the test process really standing in each project in turn. Verbatim, from the pin test run against the unrepaired store (commit74a095ac9, before the fix):Read them in order:
com.acme.billing; project A's boot read then answered{ com.acme.billing, com.acme.reporting }. A had never installed, seen or disabled that package.loadDisabledPackageIdsis whatAppPlugin.start()replays, so this is a package taken out of A's running system — the shared behaviour the card distinguished from a shared report.com.acme.reporting; B enabled it; A then readSet{}. One project's operator action silently erased another project's operator intent. This is the literal "the second write clobbers the first".env_local.json.The control that makes this a reading about the store rather than about the harness passed on the same run, in the same file:
stands in two distinct project rootsasserts the process really moved between two differentprocess.cwd()values. A drive whose two projects were the same directory would have proved nothing.2 — The ruling, and that it is the maintainer's
Comment
5559822110(director seat, decision batch #57, 2026-09-06) records the maintainer's reply, verbatim 「同意」, adopting option A:The card's undecided prose — whether an operator may want "disabled in staging" to hold machine-wide — was answered there and is not re-opened here.
3 — The premise, re-derived on today's tree by SYMBOL, with a live control
Triage measured this at
932acc3d; re-derived at this branch's merge basef48f3f1b2, located by symbol rather than by line:All three still hold, at the same lines. The decisive half, measured live and zero-write:
The arity is the argument:
resolveObjectStackHome()takes no parameters, so the path was structurally incapable of depending on project identity — the signature excludes it. The control matters for the same reason it did on the card: it proves the resolver is not a stuck constant, so "same home from two cwds" is a reading about cwd.4 — The naming convention: PR #15968's, not a second one
The ruling's third note is to keep the naming aligned with whatever separator PR #15968 settled on. Read out of what it shipped rather than assumed —
packages/cli/src/commands/serve.ts:So the convention is: a
.between the identities, and the project component is a sanitised basename, a-, and 12 hex of the sha256 of the resolved root. This change uses that convention exactly — same resolve, same digest length, same slug sanitisation, same slug cap, same digest-only fallback, same.join:package-state-project-key.test.tspins that by recomputing the convention independently rather than asking the implementation what it produces — asking would pin nothing; a second computation goes red the day the spelling drifts.5 — Migration is READ-ONCE, and the legacy file is not deleted
Per the ruling's second note. While a project has no per-project file, the store still reads the old name; that project's first write lands under the new key; the old file is never written and never removed. Pinned in five tests, including a byte-for-byte assertion that the legacy file survives a migrating write, and one that a migrating write in project A does not disturb what project B still reads out of the legacy record.
6 — Verification
pnpm --filter @objectstack/runtime typecheckVERDICT command-exit 0(includescheck:test-typecheckovertsconfig.test.json, so the test layer is covered)Test Files 4 passed (4)·Tests 60 passed (60)pnpm --filter @objectstack/runtime test(the whole affected package)VERDICT command-exit 0·Test Files 239 passed (239)·Tests 3377 passed (3377)dispatch-gates.mjs --commands, no paths, 56 derived, re-run on the final headbbc5b3e3b8PREREQUISITE NOT MET(check:dual-build-cjs-loads,check:type-check-debt— both read built output of the whole workspace)check:react-declaration-parityneeds objectui'ssdui.manifest.jsonand states "this gate did NOT run";check-partof-closing-keywordandcheck-single-claim-pathsareNOT WIREDbare and were re-run with real PR contextpnpm lintand the full 15-package affected closureEvery exit code was captured before any pipe, and the verdicts quoted are the gates' own printed lines. Every heavy run went through
scripts/pm/os-verify-lock.sh.One derived gate went red mid-work and is repaired in this diff rather than waived: reflowing a doc comment onto a second line shifted an elevation read site by one line and rotted a
check-system-context-censusanchor. The comment is back on one line, the census page is untouched, and the gate printsOK — 106 elevation read sites in 20 packages across 45 files, all anchored.Declared narrowing.
turbo ls --affectednames 15 packages; 14 of them are affected only as downstream dependents of@objectstack/runtime. The reader search behind that narrowing, with its control:package-stateappears in exactly two source files outside this diff's own package — neither is one of those 14 — while the same search shape over the same root returns the wholepackages/runtimeset it is meant to, so the near-empty result is a reading and not a silent zero. The affected closure and the repo-wide lint run are CI's.验收备注
Observations from the surrounding code, noted here rather than filed — none is a reproducible defect, a declared-contract violation, or a metadata trap:
projectStateKeynow exists in two packages:packages/cli/src/commands/serve.ts(PR fix(cli): key the runtime state file by project, not by environment alone #15968's, keyed onservedAppRootOrCwd()) andpackages/runtime/src/package-state-store.ts(this one, keyed on the working directory). Same convention, two declarations. The sustainable shape is one declaration in@objectstack/runtime— whichpackages/clialready depends on and already importsresolveObjectStackHomefrom — with the CLI importing it. That edit lands inpackages/cli, outside this card's declared file surface, so it is named here rather than taken.os serveanchors at the config file's own directory when that directory carries apackage.json, while this store has onlyprocess.cwd(). Threading a project root throughAppPluginwould close it and is a separate change. Stated in the source and in the changeset rather than left implicit.packages/runtime/src/domains/packages.ts,packages/runtime/src/domains/packages-readonly-gate.test.ts) are corrected in this diff — this change is what falsified them. ADR-0016 and ADR-0025 also carry the old spelling; they are records of decisions at their time and PR fix(cli): key the runtime state file by project, not by environment alone #15968 set the precedent of not rewriting them for a rename, so they are left alone.Scope
packages/runtime/src/package-state-store.tsplus its tests, two doc-comment corrections in the same package, and one changeset.packages/runtime/src/dispatcher-plugin.tsis untouched — the concurrently dispatched sibling card owns that file, and this work never needed it.Clause ② graded from the delivered diff: no. Nothing is added to
packages/runtime's published surface —package-state-store.tsis not re-exported from the package index, sopackageStateFileNameis internal to the package and reaches no consumer.🤖 Generated with Claude Code
https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
Generated by Claude Code