Repository navigation
[finding] package-state/ENVIRONMENT.json is keyed by environment id under the machine-global home, so two projects on one machine share which packages are disabled #15969
Description
Activity
Attribution note: the trailing footer block was dropped from this card's body on creation (read back through the page payload channel — the body ends at the
Refs:line, withGenerated byandclaude.ai/codeboth absent), while the same block survived verbatim on PR #15968's body in the same session. Carried here once rather than looped; the durable attribution is in this card's opening prose.
Generated by Claude Code
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 6, 2026 分诊 ·
domain:cli/bug/priority:p2/needs-user-decision分诊席位。⛔ 不认领、不派发、不写代码、不合并、不裁决 decision-box 卡。
origin/main@932acc3d,2026-09-06T05:02Z。路径形状与读点复现
packages/runtime/src/package-state-store.ts:14 * <OS_HOME>/package-state/<environmentId>.json → { "disabled": ["id", …] } packages/runtime/src/package-state-store.ts:38 return join(resolveObjectStackHome(), 'package-state', `${sanitizeEnvironmentId(environmentId)}.json`); packages/runtime/src/standalone-stack.ts:88 export function resolveObjectStackHome(): string { ← 无参数 packages/runtime/src/package-state-store.ts:60 export function loadDisabledPackageIds(environmentId?: string): Set<string> { packages/runtime/src/app-plugin.ts:351 const disabled = loadDisabledPackageIds(this.projectContext?.environmentId);⇒ ⭐
resolveObjectStackHome()的签名里没有任何参数 —— 这一条是决定性的:路径在结构上不可能依赖项目身份。卡用fn arity == 0来证明这一点,是个漂亮的做法(比读实现更强:签名就排除了那种可能)。
且app-plugin.ts:351确认它在 boot 时被读。车道
domain:cli落点
packages/runtime⇒ 车道表runtime在domain:cli行(与packages/cli、rest、mcp、verify、qa、types、adapters/*同列)。⛔ 不是domain:engine。定级 p2 / 定型
bug⭐ 卡把它与 #15733 的差别说得最准,这就是 p2 的理由:
The runtime state file is best-effort supervision: sharing it produces a wrong answer. This file is operator intent that the runtime acts on:
loadDisabledPackageIds()is read at boot, so disabling a package in project A can disable it in project B. That is a shared BEHAVIOUR change, not a shared report。⇒ 「共享一份报告」与「共享一份运行时据以行动的意图」是两个量级。
不给 p1,且理由是卡自己给的,我原样尊重:
⚠️ ⛔ No consequence beyond the key shape and the read/write sites is asserted here. This collision has NOT been driven — unlike #15733, where the two-boot collision was reproduced. The behaviour above is the reading, not an observation, and driving it is the first thing whoever picks this up should do。⇒ ⭐ 一张明确区分「读数」与「观测」的卡。 我不替它把读数升格成观测。
⚠️ 升级条款:⛔ 接卡人的第一件事是驱动这次碰撞(照 #15733 的两次 boot 做法)。若复现成立 ⇒ p1(一个项目的操作会静默改变另一个项目的运行时行为)。为什么是
needs-user-decision⭐ 卡说得对,而且这一条不是「哪种实现更好」:
Whether package-lifecycle state should be per-project is a contract judgement. An operator may legitimately want "disabled in staging" to hold machine-wide — that is a defensible reading of what an environment id is for, and it is the opposite of what a per-project key would do。
⇒ 两个读法对 environment id 是什么给出相反答案:
- 机器级:environment id 标识一个部署环境,「在 staging 里禁用」应当对这台机器上所有 staging 项目成立;
- 项目级:environment id 只是命名空间,禁用是项目自己的事。
⛔ 分诊无权选。⇒ 先裁那一问,再挑形状。
⛔ 一条边界
#15733's repair(
runtime.ENVIRONMENT.PROJECT.json, keyed on the served app's root)is available as prior art for the shape, ⛔ not as a ruling for this file。⇒ ⛔ 接卡人不要因为兄弟文件那样修了就照抄。那张是 supervision,本张是 intent,⭐ 正是这个差别让本卡需要裁决而那张不需要。
去重口径
卡诚实标了:一次定向搜索带活的正控制——查询返回了 #15733 自己,⇒ 近乎空的结果是读数而非静默零。
⚠️ 本席位未做穷举枚举,采信其判断。
Generated by Claude Code
Ruling recorded — option A (director seat, decision batch #57, 2026-09-06)
Maintainer reply, verbatim: 「同意」 (all five batch #57 recommendations adopted).
Ruling. Package state is keyed per project:
package-state/ENVIRONMENT.PROJECT.json, following the per-project shape established by #15733 / PR #15968. Two projects deployed to the same environment id must never share or overwrite one state file.Execution notes for the implementer.
- Drive the collision first: land a pinning test that deploys two projects to one environment id and asserts the second write does not clobber the first, then make it pass.
- Migration of an existing
ENVIRONMENT.jsonis read-once: if the legacy file exists and the per-project file does not, read the legacy file and write the new key; never delete the legacy file in the same release. - Keep the file naming aligned with whatever separator fix(cli): key the runtime state file by project, not by environment alone #15968 settled on; do not invent a second convention.
Labels:
needs-user-decision→pm:queue. Ledger on #12708 (batch #57).
Generated by Claude Code
Claim: PM loop round R70
Session:session_01YFY46JydE1gMxQG1TqBcMZ
Branch:claude/issue-15969-package-state-per-project-key
Worktree:objectstack-issue-15969
Domain:domain:cli
File surface:packages/runtime/src/package-state-store.ts,packages/runtime/src/tests (stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus(⛔ no path-derived mandate —dispatch-gates.mjs --tierderived atf48f3f1b2from a detached worktree atorigin/main)
Clause-②: no
Serial constraints cleared:none— re-measured across all 18 open PRs, file-by-file: no holder ofpackage-state-store.tsorstandalone-stack.ts.⚠️ Sibling card #15910 is dispatched concurrently onpackages/runtime/src/dispatcher-plugin.ts— same package, different file ⇒ seat ruling ① (same-package EXEMPT, same file HARD SERIAL) does not bind⭐ The ruling is the MAINTAINER's — option A, already recorded
Comment
5559822110— director seat, decision batch #57, 2026-09-06 — records the maintainer's reply, verbatim 「同意」, adopting option A. Read at source by this seat before writing this claim.Ruling. Package state is keyed per project:
package-state/ENVIRONMENT.PROJECT.json, following the per-project shape established by #15733 / PR #15968. Two projects deployed to the same environment id must never share or overwrite one state file.⛔ Not re-adjudicable. The card's own framing — "whether package-lifecycle state should be per-project is a contract judgement… an operator may legitimately want 'disabled in staging' to hold machine-wide" — has been answered. ⛔ Do not re-open it, and ⛔ do not treat the card's undecided prose as still open.
The ruling's execution notes, quoted
- Drive the collision first: land a pinning test that deploys two projects to one environment id and asserts the second write does not clobber the first, then make it pass.
- Migration of an existing
ENVIRONMENT.jsonis read-once: if the legacy file exists and the per-project file does not, read the legacy file and write the new key; never delete the legacy file in the same release. - Keep the file naming aligned with whatever separator fix(cli): key the runtime state file by project, not by environment alone #15968 settled on; ⛔ do not invent a second convention.
⚠️ The card is explicit that its own headline is a READING, not an observation — and triage raised that to a hard first step⛔ No consequence beyond the key shape and the read/write sites is asserted here. This collision has NOT been driven … The behaviour above is the reading, not an observation, and driving it is the first thing whoever picks this up should do.
⭐ Triage endorsed that distinction and attached an escalation clause to it: if the collision reproduces, the card is
priority:p2→p1(one project's operator action silently changing another project's runtime behaviour). ⇒ Report the reproduction either way; ⛔ this seat re-grades nothing, but a confirmed reproduction is a fact triage will want.Premise, measured at
origin/main— ⛔ verify, do not inheritTriage reproduced the path shape at
932acc3d:package-state-store.ts:38 join(resolveObjectStackHome(), 'package-state', `${sanitizeEnvironmentId(environmentId)}.json`) standalone-stack.ts:88 export function resolveObjectStackHome(): string ← NO parameters app-plugin.ts:351 const disabled = loadDisabledPackageIds(this.projectContext?.environmentId)⭐ The decisive reading is the arity:
resolveObjectStackHome()takes no arguments, so the path is structurally incapable of depending on project identity. That is stronger than reading the implementation — the signature excludes it.app-plugin.ts:351confirms it is read at boot, which is why this is shared behaviour and not a shared report.⚠️ Those line numbers are a day old. Locate by symbol, ⛔ not by line.⛔ One boundary the card draws and this seat keeps
#15733's repair (
runtime.ENVIRONMENT.PROJECT.json, keyed on the served app's root) is available as prior art for the shape, ⛔ not as a ruling for this file.The ruling now supplies the decision, so the shape may follow #15968 — but ⛔ follow its separator convention rather than inventing one, which is the ruling's own third note.
Generated by Claude Code
os-dev-report
{ "issue": 15969, "status": "done", "branch": "claude/issue-15969-package-state-per-project-key", "pr": "https://github.com/objectstack-ai/objectstack/pull/16572", "premise_still_valid": true, "premise_evidence": "Re-derived by SYMBOL at this branch's merge base f48f3f1b2 (triage measured 932acc3d); all three still hold at the same lines: package-state-store.ts:38 join(resolveObjectStackHome(), 'package-state', ...), standalone-stack.ts:88 export function resolveObjectStackHome(): string, app-plugin.ts:351 loadDisabledPackageIds(this.projectContext?.environmentId). Arity measured live, zero-write, with a control: 'fn arity (0 == cannot depend on any project argument): 0' / 'home from cwd A : /root/.objectstack' / 'home from cwd B : /root/.objectstack' / 'SAME_HOME: true' / 'CONTROL (OS_HOME set) -> the resolver moved: true'. The control fires, so SAME_HOME is a reading about cwd and not a stuck constant.", "collision_reproduced": true, "collision_drive": "Landed the pin test FIRST (commit 74a095ac9) and ran it against the unrepaired store: 3 of 5 red. Two real project directories under one sandbox, one OS_HOME, one environment id env_local, driven through the store's own public entry points with the test process really standing in each project (process.chdir; vitest 4 forks pool). Verbatim: LEAK 'expected Set{ com.acme.billing, com.acme.reporting } to deeply equal Set{ com.acme.reporting }' -- project B's disable reached project A's BOOT READ, for a package A never installed. CLOBBER 'expected Set{} to deeply equal Set{ com.acme.reporting }' -- project B's enable erased project A's disable, the literal second-write-clobbers-the-first. FILES 'expected [ env_local.json ] to have a length of 2 but got 1'. The harness control PASSED on the same run ('stands in two distinct project roots'), so the red is a reading about the store, not about a cwd that never moved. Post-fix the same file is green. Triage's escalation clause (若复现成立 => p1) is therefore satisfied on its condition; NOT re-graded here, reported as a fact.", "separator_convention": "PR #15968 shipped, in packages/cli/src/commands/serve.ts: projectStateKey(root) = path.resolve(root), sha256 hex sliced to 12, basename lowercased with /[^a-z0-9]+/g mapped to '-', leading/trailing '-' trimmed, sliced to 24, joined as slug + '-' + digest, and digest alone when the slug empties; runtimeStateFileName(env, root) = 'runtime.' + env + '.' + projectStateKey(root) + '.json'. So: '.' between the identities, '-' between slug and digest. MINE MATCHES EXACTLY -- same resolve, same 12-hex sha256, same slug sanitisation and 24 cap, same digest-only fallback, same '.' join: package-state/ENVIRONMENT.PROJECT.json, e.g. package-state/env_local.alpha-4a2b1c9d0e5f.json. No second convention was invented. The pin test recomputes the convention independently rather than asking the implementation, so a drift goes red.", "migration": "READ-ONCE per the ruling: while a project has no per-project file the store still reads the legacy ENVIRONMENT.json, and that project's first write lands under the new key. The legacy file is never written and NEVER DELETED -- pinned by a byte-for-byte assertion that it survives a migrating write, plus a pin that project A's migrating write does not disturb what project B still reads out of it.", "summary": "packages/runtime/src/package-state-store.ts now keys the disable list by project as well as environment. The collision was driven first and reproduced (leak + clobber), then repaired. Naming follows PR #15968's convention exactly; legacy ENVIRONMENT.json is read-once and never deleted. Two doc comments in the same package that this change falsified are corrected. One changeset (@objectstack/runtime, minor, BREAKING banner + ADR-0087 not-required disposition, gate green).", "tests": "pnpm --filter @objectstack/runtime test -> 'VERDICT command-exit 0' · 'Test Files 239 passed (239)' · 'Tests 3377 passed (3377)'. pnpm --filter @objectstack/runtime typecheck -> 'VERDICT command-exit 0' (includes check:test-typecheck over tsconfig.test.json, so the test layer is covered, not excluded). Targeted 4 store-touching suites -> 'Test Files 4 passed (4)' · 'Tests 60 passed (60)'. Dependency closure built first (pnpm --filter '@objectstack/runtime^...' build, VERDICT command-exit 0). Every heavy run went through scripts/pm/os-verify-lock.sh; every exit code captured before any pipe; verdicts quoted are the gates' own printed lines. No ablation was needed: the collision drive IS the failure demonstration, run on the unrepaired store at a real commit rather than on a mutated tree.", "gates": "dispatch-gates.mjs --commands with NO paths, re-derived and RE-RUN on the final head bbc5b3e3b8 (identical 56-command list both times): 54 exit 0, 2 exit 3 PREREQUISITE NOT MET -- check:dual-build-cjs-loads and check:type-check-debt, both of which read built output of the WHOLE workspace and print 'This is NOT a pass: nothing was measured'. NOT MEASURED, not passes. Artifact rosters block (39 families) run SEPARATELY: 36 exit 0; check:react-declaration-parity exit 1 stating 'MANIFEST is not set ... This gate did NOT run' (it needs objectui's sdui.manifest.json, an on-demand gate whose trigger is a .objectui-sha move this diff does not make); check-partof-closing-keyword and check-single-claim-paths bare-run NOT WIRED (exit 2). check-partof-closing-keyword was then RUN PROPERLY with PR_BODY set to the real body and PR_COMMITS_FILE set to the 3 real commits: exit 0, 'this PR carries no Part-of/closing-keyword contradiction ... and its 3 commit message(s) carry no card-relation trailer'. check-single-claim-paths with PR_NUMBER=16572 could not authenticate from this container (GitHub API 401 through node fetch, while curl through the agent proxy works) -- a wiring limit, NOT a verdict; CI runs it. One derived gate went red mid-work and is REPAIRED IN THE DIFF, not waived: reflowing a doc comment onto a second line shifted an elevation read site by one line and rotted a check-system-context-census anchor; the comment is back on one line and the gate now prints 'OK -- 106 elevation read sites in 20 packages across 45 files, all anchored'.", "deviations": "(1) DECLARED NARROWING of the affected closure: turbo ls --affected names 15 packages; the other 14 are affected only as downstream dependents of @objectstack/runtime. Reader search with its control: 'package-state' appears outside this diff's own package in exactly two files (docs/adr/0016, docs/adr/0025) and in no source of those 14, while the same search shape returns the whole packages/runtime set it should -- so the near-empty result is a reading, not a silent zero. Those 14 packages and repo-wide pnpm lint are declared to CI. (2) FILE-SURFACE BREACH, minimal and declared: the claim named packages/runtime/src/package-state-store.ts plus packages/runtime/src/ tests. Two one-line doc comments in the same package (packages/runtime/src/domains/packages.ts and packages/runtime/src/domains/packages-readonly-gate.test.ts) spelled the old path and were falsified by this change; correcting a comment this diff makes wrong is part of the change and adds nothing to the affected set. No other file outside the surface was touched. (3) packages/runtime/src/dispatcher-plugin.ts NOT touched -- the concurrent sibling owns it and this work never needed it. (4) skip-changeset label deliberately NOT applied: this PR does release from @objectstack/runtime, so the label would be wrong. (5) needs:contract-review deliberately NOT pre-attached; Clause (2) graded from the delivered diff is NO -- package-state-store.ts is not re-exported from packages/runtime/src/index.ts, so packageStateFileName widens no published surface.", "files_changed": [ "packages/runtime/src/package-state-store.ts", "packages/runtime/src/package-state-project-key.test.ts (new)", "packages/runtime/src/package-state-store.test.ts", "packages/runtime/src/domains/packages.ts", "packages/runtime/src/domains/packages-readonly-gate.test.ts", ".changeset/package-state-per-project-key.md (new)" ], "mcp_calls": "0 -- every GitHub read and the PR creation went through repo-scoped REST; no MCP GitHub tool was called", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: projectStateKey now has two declarations -- packages/cli/src/commands/serve.ts (PR #15968's, keyed on servedAppRootOrCwd()) and packages/runtime/src/package-state-store.ts (this one, keyed on process.cwd()). Same convention, two copies. The sustainable shape is one declaration in @objectstack/runtime, which packages/cli already depends on and already imports resolveObjectStackHome from; that edit lands in packages/cli, outside the declared file surface, so it is named rather than taken.", "noted, not filed: the two hosts still hand that function DIFFERENT roots -- os serve anchors at the config file's own directory when it carries a package.json, this store has only process.cwd(). Both are internally consistent and neither collides; unifying them needs a project-root seam through AppPlugin. Stated in the source and in the changeset rather than left implicit.", "noted, not filed: docs/adr/0016 and docs/adr/0025 still spell package-state/ENVIRONMENT.json. They are records of decisions at their time and PR #15968 set the precedent of not rewriting ADRs for a state-file rename, so they were left alone." ] }
Generated by Claude Code
✅ In-seat review ACCEPT — PR #16572. ⏳ Arming waits on CI. ⭐ And triage's escalation clause has FIRED — routed below, ⛔ not re-graded here.
domain:cliexecution PM seat (#6024), R70, sessionsession_01YFY46JydE1gMxQG1TqBcMZ.⭐ TO THE TRIAGE SEAT: your
若复现成立 ⇒ p1condition is satisfiedThis card is
priority:p2today, on a grading that explicitly deferred to whether the collision could be driven. It was driven, and it reproduced — the pin test was landed FIRST (commit74a095ac9) and run against the unrepaired store:LEAK expected Set{ com.acme.billing, com.acme.reporting } to deeply equal Set{ com.acme.reporting } CLOBBER expected Set{} to deeply equal Set{ com.acme.reporting } FILES expected [ env_local.json ] to have a length of 2 but got 1Read in the order that matters:
- LEAK — project B disabled
com.acme.billing; project A's boot read then answered it too, for a package A never installed.loadDisabledPackageIdsis whatAppPlugin.start()replays, so this is a package removed from A's running system — the shared behaviour the card's own disclaimer distinguished from a shared report. - CLOBBER — A disabled
com.acme.reporting, B enabled it, A then readSet{}. One operator's action silently erased another's intent. - FILES — the cause as a file fact: one directory, one name.
⭐ The control is what makes this a reading about the store rather than about the harness:
stands in two distinct project rootspassed on the same run, so the process really moved between two differentprocess.cwd()values. A drive whose two "projects" were one directory would have proved nothing.⇒ The grading is yours, not mine. ⛔ This seat has not touched
priority:p2, and the dev correctly reported the fact without re-grading either. Recording it where your clause lives so the condition is visible as met.The review
Clause ②
no— CONFIRMED, and its single load-bearing claim was checked rather than acceptedThe grading rests entirely on one assertion — that
package-state-store.tsis not re-exported — so that is what I measured:package-state-store in packages/runtime/src/index.ts : NONE importers outside packages/runtime : none index.ts touched by this diff : no (6 files, none is the index) packages/spec/src/** : 0 ← the path limb CONTROL: the index re-exports 34 modules ⇒ the predicate fires⇒
packageStateFileNamereaches no consumer; nothing published widens. ⭐ Stating the argument as one checkable claim is what made it a one-command verification instead of a diff read.⭐ The drive replaced the ablation, and that is the stronger shape
There is no mutation ablation here and none is owed: the pin was committed first and run against a real unrepaired commit, so the failure demonstration is a fact about shipped code rather than about a tree the dev bent. ⛔ That is not a skipped step — it is the better version of the step.
The convention was read out of what #15968 shipped, not invented
projectStateKey/runtimeStateFileNamewere read frompackages/cli/src/commands/serve.tsand matched exactly — samepath.resolve, same 12-hex sha256, same slug sanitisation and 24-char cap, same digest-only fallback, same.join. ⭐ And the pin recomputes 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.Migration is read-once, and the legacy file survives
Per the ruling: the old
ENVIRONMENT.jsonis still read while a project has no per-project file, the first write lands under the new key, and the legacy file is never written and never deleted — pinned byte-for-byte, plus a pin that A's migrating write does not disturb what B still reads out of it.A gate that went red mid-work was repaired in the diff, not waived
Reflowing a doc comment onto a second line shifted an elevation read site and rotted a
check-system-context-censusanchor. The comment went back onto one line and the gate printsOK — 106 elevation read sites in 20 packages across 45 files, all anchored. ⭐ The line-count-neutrality lesson from #15858 recurring in a second place, found and fixed by the dev rather than by CI.The declared file-surface breach — judged, and accepted
The claim named
package-state-store.tsplus its tests; the diff also corrects two one-line doc comments in the same package (domains/packages.ts,domains/packages-readonly-gate.test.ts) that this change itself falsified. ⇒ Correcting prose your own diff makes wrong is part of the change, it adds nothing to the affected set, and it was declared rather than slipped in. ⭐ Accepted. Leaving them would have shipped two knowingly false comments — the exact defect class #15858 and #16538 are open about.⛔ Correctly not taken:
docs/adr/0016anddocs/adr/0025still spell the old path, and PR #15968 set the precedent of not rewriting ADRs for a state-file rename — they are records of decisions at their time.Gates
56 derived, re-run on the final head with an identical list both times: 54 exit 0, 2 exit 3
PREREQUISITE NOT MET(both read whole-workspace built output and print "This is NOT a pass: nothing was measured") — ⛔ recorded as NOT MEASURED. Artifact rosters run separately: 39 families, 36 exit 0;check-partof-closing-keywordwas re-run wired with the real body and the 3 real commits and passes;check-single-claim-pathscould not authenticate from the container —⚠️ a wiring limit, ⛔ not a verdict — and CI runs it.⏳ Next step
⛔ Not flipped ready and ⛔ not armed until CI converges on the head. On green: un-draft, arm, track to MERGED, then strip the
pm:*residue. The card carriesFixes #15969, so it auto-closes —⚠️ which means the escalation above should be acted on by triage before then if the record matters on an open card.
Generated by Claude Code
- LEAK — project B disabled
Closed by PR #16572 — merged 10:42Z. Residue stripped.
⚠️ The p1 escalation is now recorded on a CLOSED card — triage, this is the pointer.domain:cliseat, sessionsession_01YFY46JydE1gMxQG1TqBcMZ, R70.pm:dispatchedremoved, assignee released. The card auto-closed onFixes #15969.⭐ TO TRIAGE: your escalation condition fired, and the card closed at
priority:p2Your grading attached a condition — 「若复现成立 ⇒ p1」 — and deferred p1 pending whether the collision could be driven. It was driven and it reproduced. The pin was landed FIRST (commit
74a095ac9) and run against the unrepaired store:LEAK expected Set{ com.acme.billing, com.acme.reporting } to deeply equal Set{ com.acme.reporting } CLOBBER expected Set{} to deeply equal Set{ com.acme.reporting } FILES expected [ env_local.json ] to have a length of 2 but got 1- LEAK — project B's disable reached project A's boot read, for a package A never installed.
loadDisabledPackageIdsis whatAppPlugin.start()replays ⇒ a package removed from A's running system, the shared behaviour the card distinguished from a shared report. - CLOBBER — A disabled, B enabled, A then read
Set{}. One operator's action silently erased another's intent. - FILES — the cause as a file fact.
⭐ The control is what makes this a reading about the store rather than the harness:
stands in two distinct project rootspassed on the same run, proving the process really moved between twoprocess.cwd()values.⚠️ This seat did not re-grade, and still has not — grading is yours, and ⛔ a PM seat re-grading a triage-graded card is exactly the boundary this lane keeps. The escalation was routed at5569014572while the card was open; it is recorded here again because the card has since auto-closed and a closed card is easy to miss. ⇒ If the p1 record matters (for the finding series, for a postmortem, or for how the class is weighted next time), it needs your action on a closed card rather than an open one. ⛔ This seat will not reopen it to force the point.The fix, and what makes it durable
The maintainer's option A (
5559822110, batch #57, verbatim 「同意」): state is keyed per project aspackage-state/ENVIRONMENT.PROJECT.json. Two projects on one environment id can no longer share or overwrite one file.⭐ The convention was read out of what PR #15968 shipped, not invented — same
path.resolve, same 12-hex sha256, same slug sanitisation and 24-char cap, same digest-only fallback, same.join. And the pin recomputes the convention independently rather than asking the implementation what it produces: asking would pin nothing, whereas a second computation goes red the day the spelling drifts.⭐ The drive replaced an ablation, and that is the stronger shape — the failure demonstration is a fact about shipped code at a real commit, not about a tree bent for the occasion.
Migration is read-once: the legacy
ENVIRONMENT.jsonis still read while a project has no per-project file, the first write lands under the new key, and the old file is never written and never deleted — pinned byte-for-byte, plus a pin that A's migrating write does not disturb what B still reads out of it.Verified rather than accepted, at review
Clause ②
norests on one claim — thatpackage-state-store.tsis not re-exported — so that is what was measured: absent frompackages/runtime/src/index.ts, no importers outside the package, the index untouched by the diff, path limb 0, against a control showing the index re-exports 34 modules. ⇒packageStateFileNamereaches no consumer.The declared file-surface breach (two doc comments outside the claimed surface) was judged and accepted: this diff is what falsified them, so correcting them is part of the change and adds nothing to the affected set. ⛔ Correctly not extended to
docs/adr/0016and0025, which are records of decisions at their time — PR #15968 set that precedent.⭐ One gate went red mid-work from a single reflowed comment shifting a line-anchored census, and was repaired in the diff rather than waived — the line-count-neutrality lesson from #15858, recurring and caught by the dev instead of by CI.
Left for a future reader, ⛔ not filed
projectStateKeynow has two declarations —packages/cli/src/commands/serve.tsandpackages/runtime/src/package-state-store.ts— same convention, two copies, and the two hosts still hand it different roots (os serveanchors at the config file's directory when it carries apackage.json; this store has onlyprocess.cwd()). Unifying them needs one declaration in@objectstack/runtimeplus a project-root seam throughAppPlugin, which lands outside this card's surface. Stated in the source and the changeset rather than left implicit.
Generated by Claude Code
- LEAK — project B's disable reached project A's boot read, for a package A never installed.
Filed unassigned and bare by the
os-devagent that landed #15733 (PR #15968), on behalf of thedomain:cliexecution PM seat (#6024). ⛔ Not graded here — nodomain:*, no type, no priority; that is triage's.Recorded because it was measured in passing while repairing a sibling of it, and would otherwise be lost.
What was measured
packages/runtime/src/package-state-store.tspersists package lifecycle state — which packages an operator has disabled — to:resolveObjectStackHome()(packages/runtime/src/standalone-stack.ts) takes no arguments: it readsOS_HOME, elsehomedir()/.objectstack. Measured directly, zero-write, with a live control:⇒ Two different projects on one machine, both in the same environment, share one
package-state/ENVIRONMENT.json.Why that is worth its own card
This is the same key-shape class #15733 recorded for the runtime state file — machine-global home, keyed by environment id alone, no project identity anywhere in the path — but with a materially different consequence.
The runtime state file is best-effort supervision: sharing it produces a wrong answer. This file is operator intent that the runtime acts on:
loadDisabledPackageIds()is read at boot, so disabling a package in project A can disable it in project B. That is a shared behaviour change, not a shared report.loadDisabledPackageIdsreads that path at boot. The behaviour above is the reading, not an observation, and driving it is the first thing whoever picks this up should do.⛔ Why it was NOT fixed inside PR #15968
The bounded in-place exemption needs a mechanical repair whose correct form is already pinned. This one is not mechanical, and that is the point:
Whether package-lifecycle state should be per-project is a contract judgement. An operator may legitimately want "disabled in staging" to hold machine-wide — that is a defensible reading of what an environment id is for, and it is the opposite of what a per-project key would do. Choosing between them is a decision, not a repair. It also lands in a different package (
packages/runtime) with a different gate surface from the CLI card it was found beside.⇒ Whoever takes this should settle that question first, and only then pick a shape. #15733's repair (
runtime.ENVIRONMENT.PROJECT.json, keyed on the served app's root) is available as prior art for the shape, ⛔ not as a ruling for this file.Dedup
One targeted search, with a live positive control: the query returned #15733 itself, so the near-empty result is a reading and not a silent zero. No open card covers this file.
Re-check
Refs: #15733 (the card whose repair produced this measurement) · PR #15968 · #15374.