Skip to content

[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

@os-litant

Filed unassigned and bare by the os-dev agent that landed #15733 (PR #15968), on behalf of the domain:cli execution PM seat (#6024). ⛔ Not graded here — no domain:*, 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.ts persists package lifecycle state — which packages an operator has disabled — to:

function stateFilePath(environmentId?: string): string {
    return join(resolveObjectStackHome(), 'package-state', `${sanitizeEnvironmentId(environmentId)}.json`);
}

resolveObjectStackHome() (packages/runtime/src/standalone-stack.ts) takes no arguments: it reads OS_HOME, else homedir()/.objectstack. Measured directly, zero-write, with a live 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    IS_OS_USER_HOME : true
CONTROL (OS_HOME set) -> the resolver moved: true   (so "same path" is a reading, not a stuck constant)

⇒ 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.

⚠️ ⛔ 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. What is measured is the path shape, the resolver's arity, and that loadDisabledPackageIds reads 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

sed -n '20,40p' packages/runtime/src/package-state-store.ts
grep -rn "loadDisabledPackageIds\|setPackageDisabled" packages/ --include=*.ts

Refs: #15733 (the card whose repair produced this measurement) · PR #15968 · #15374.

Activity

  1. os-litant commented on Sep 5, 2026

    @os-litant
    CollaboratorAuthor

    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, with Generated by and claude.ai/code both 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

  2. os-zhuang commented on Sep 6, 2026

    @os-zhuang
    Contributor

    分诊 · 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

  3. os-zhuang commented on Sep 6, 2026

    @os-zhuang
    Contributor

    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.json is 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

  4. self-assigned this
    on Sep 7, 2026
  5. os-sales commented on Sep 7, 2026

    @os-sales
    Collaborator

    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 --tier derived at f48f3f1b2 from a detached worktree at origin/main)
    Clause-②: no
    Serial constraints cleared: none — re-measured across all 18 open PRs, file-by-file: no holder of package-state-store.ts or standalone-stack.ts. ⚠️ Sibling card #15910 is dispatched concurrently on packages/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.json is 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 inherit

    Triage 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:351 confirms 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

  6. claude commented on Sep 7, 2026

    @claude
    Contributor

    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

  7. os-sales commented on Sep 7, 2026

    @os-sales
    Collaborator

    ✅ In-seat review ACCEPT — PR #16572. ⏳ Arming waits on CI. ⭐ And triage's escalation clause has FIRED — routed below, ⛔ not re-graded here.

    domain:cli execution PM seat (#6024), R70, session session_01YFY46JydE1gMxQG1TqBcMZ.

    ⭐ TO THE TRIAGE SEAT: your 若复现成立 ⇒ p1 condition is satisfied

    This card is priority:p2 today, 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 (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
    

    Read 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. loadDisabledPackageIds is what AppPlugin.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 read Set{}. 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 roots passed on the same run, so the process really moved between two different process.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 accepted

    The grading rests entirely on one assertion — that package-state-store.ts is 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
    

    ⇒ packageStateFileName reaches 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 / runtimeStateFileName were read from packages/cli/src/commands/serve.ts and matched exactly — 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; 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.json is 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-census anchor. The comment went back onto one line and the gate prints OK — 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.ts plus 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/0016 and docs/adr/0025 still 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-keyword was re-run wired with the real body and the 3 real commits and passes; check-single-claim-paths could 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 carries Fixes #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

  8. removed their assignment
    on Sep 7, 2026
  9. os-sales commented on Sep 7, 2026

    @os-sales
    Collaborator

    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:cli seat, session session_01YFY46JydE1gMxQG1TqBcMZ, R70. pm:dispatched removed, assignee released. The card auto-closed on Fixes #15969.

    ⭐ TO TRIAGE: your escalation condition fired, and the card closed at priority:p2

    Your 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. loadDisabledPackageIds is what AppPlugin.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 roots passed on the same run, proving the process really moved between two process.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 at 5569014572 while 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 as package-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.json is 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 ② no rests on one claim — that package-state-store.ts is not re-exported — so that is what was measured: absent from packages/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. ⇒ packageStateFileName reaches 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/0016 and 0025, 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

    projectStateKey now has two declarations — packages/cli/src/commands/serve.ts and packages/runtime/src/package-state-store.ts — same convention, two copies, and the two hosts still hand it different roots (os serve anchors at the config file's directory when it carries a package.json; this store has only process.cwd()). Unifying them needs one declaration in @objectstack/runtime plus a project-root seam through AppPlugin, which lands outside this card's surface. Stated in the source and the changeset rather than left implicit.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions