Skip to content

fix(runtime): key package lifecycle state by project, not by environment alone - #16572

Merged
os-sales merged 3 commits into
mainfrom
claude/issue-15969-package-state-per-project-key
Sep 7, 2026
Merged

fix(runtime): key package lifecycle state by project, not by environment alone#16572
os-sales merged 3 commits into
mainfrom
claude/issue-15969-package-state-per-project-key

Conversation

@claude

@claude claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #15969

1 — The card's own disclaimer, settled first: the collision is DRIVEN

The card was careful about what it had not done:

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.

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 (commit 74a095ac9, before the fix):

 ❯ src/package-state-project-key.test.ts (5 tests | 3 failed) 32ms
     × does not leak project B's disable into project A's boot read
     × does not let project B's enable erase project A's disable
     × writes two files, one per project, for one environment id

LEAK      AssertionError: expected Set{ 'com.acme.billing', 'com.acme.reporting' }
                          to deeply equal Set{ 'com.acme.reporting' }
CLOBBER   AssertionError: expected Set{} to deeply equal Set{ 'com.acme.reporting' }
FILES     AssertionError: expected [ 'env_local.json' ] to have a length of 2 but got 1

Read them in order:

  • LEAK — project B disabled 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. loadDisabledPackageIds is what AppPlugin.start() replays, so this is a package taken out of A's running system — the shared behaviour the card distinguished from a shared report.
  • CLOBBER — A disabled com.acme.reporting; B enabled it; A then read Set{}. One project's operator action silently erased another project's operator intent. This is the literal "the second write clobbers the first".
  • FILES — the cause, as a file fact: one directory, one name, 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 roots asserts the process really moved between two different process.cwd() values. A drive whose two projects were the same directory would have proved nothing.

⚠️ Triage attached an escalation clause to this exact question — "若复现成立 ⇒ p1". It reproduced. ⛔ Not re-graded here; the fact is reported and the grading is triage's.

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:

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.

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 base f48f3f1b2, located by symbol rather than by line:

packages/runtime/src/package-state-store.ts:38   join(resolveObjectStackHome(), 'package-state', ...)
packages/runtime/src/standalone-stack.ts:88      export function resolveObjectStackHome(): string
packages/runtime/src/app-plugin.ts:351           loadDisabledPackageIds(this.projectContext?.environmentId)

All three still hold, at the same lines. The decisive half, measured live and zero-write:

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 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:

export function projectStateKey(servedAppRoot: string): string {
  const absolute = path.resolve(servedAppRoot);
  const digest = createHash('sha256').update(absolute).digest('hex').slice(0, 12);
  const slug = path.basename(absolute).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24);
  return slug.length > 0 ? `${slug}-${digest}` : digest;
}
export function runtimeStateFileName(environmentId: string, servedAppRoot: string): string {
  return `runtime.${environmentId}.${projectStateKey(servedAppRoot)}.json`;
}

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:

before   OS_HOME/package-state/env_local.json
after    OS_HOME/package-state/env_local.alpha-4a2b1c9d0e5f.json

package-state-project-key.test.ts pins 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

what result
pnpm --filter @objectstack/runtime typecheck VERDICT command-exit 0 (includes check:test-typecheck over tsconfig.test.json, so the test layer is covered)
the four store-touching suites, targeted 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 head bbc5b3e3b8 54 exit 0; 2 exit 3 PREREQUISITE NOT MET (check:dual-build-cjs-loads, check:type-check-debt — both read built output of the whole workspace)
Artifact rosters block, 39 families, run separately 36 exit 0; check:react-declaration-parity needs objectui's sdui.manifest.json and states "this gate did NOT run"; check-partof-closing-keyword and check-single-claim-paths are NOT WIRED bare and were re-run with real PR context
repo-wide pnpm lint and the full 15-package affected closure declared to CI (see below)

Every 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-census anchor. The comment is back on one line, the census page is untouched, and the gate prints OK — 106 elevation read sites in 20 packages across 45 files, all anchored.

Declared narrowing. turbo ls --affected names 15 packages; 14 of them are affected only as downstream dependents of @objectstack/runtime. The reader search behind that narrowing, with its control: package-state appears 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 whole packages/runtime set 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:

  • projectStateKey now 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 on servedAppRootOrCwd()) and packages/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 — which packages/cli already depends on and already imports resolveObjectStackHome from — with the CLI importing it. That edit lands in packages/cli, outside this card's declared file surface, so it is named here rather than taken.
  • Which root each host hands that function still differs, and this change does not unify it: os serve anchors at the config file's own directory when that directory carries a package.json, while this store has only process.cwd(). Threading a project root through AppPlugin would close it and is a separate change. Stated in the source and in the changeset rather than left implicit.
  • Two doc comments naming the old path (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.ts plus its tests, two doc-comment corrections in the same package, and one changeset. packages/runtime/src/dispatcher-plugin.ts is 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.ts is not re-exported from the package index, so packageStateFileName is internal to the package and reaches no consumer.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ


Generated by Claude Code

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
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 7, 2026
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

7 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/runtime/src/domains/packages.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/runtime/src/domains/packages.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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.

Coarse fallback — 24 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 d1c86a745a42b15e0cfc2e61884a6672dfef3706packageMentionDocs.

Which tree this was computed on

This run read content/docs from dd6d09fcf6ae61f6dbc23bfd0c359b3d0d47684d — the merge of head bbc5b3e3b812033ae6a95d5b513e117e72ea0bab into base d1c86a745a42b15e0cfc2e61884a6672dfef3706, which is what actions/checkout gives a pull_request run. Not the PR head.

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

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

@os-sales
os-sales marked this pull request as ready for review September 7, 2026 10:22
@os-sales
os-sales enabled auto-merge September 7, 2026 10:22
@os-sales
os-sales added this pull request to the merge queue Sep 7, 2026
Merged via the queue into main with commit f2f6684 Sep 7, 2026
35 checks passed
@os-sales
os-sales deleted the claude/issue-15969-package-state-per-project-key branch September 7, 2026 10:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants