Skip to content

fix(cli): key the runtime state file by project, not by environment alone - #15968

Merged
os-litant merged 4 commits into
mainfrom
claude/issue-15733-runtime-state-file-key-shape
Sep 5, 2026
Merged

fix(cli): key the runtime state file by project, not by environment alone#15968
os-litant merged 4 commits into
mainfrom
claude/issue-15733-runtime-state-file-key-shape

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #15733

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

The card was careful to say what it had not done:

"No consequence is asserted here beyond that. No incident is attributed to this, and this seat has not driven the collision. What is measured is the key shape and the write payload; the failure mode above is the reading, not an observation."

It has now been driven. Two real os serve boots, two different project roots, one home, both in the ordinary local environment. Verbatim from the run:

state file both will write   : .../runtime.env_local.json

BEFORE-STATE (only project A has booted): {"pid":9301,"port":42693,"url":"http://localhost:42693","environmentId":"env_local",...}
AFTER-STATE  (project B has booted too) : {"pid":9345,"port":46175,"url":"http://localhost:46175","environmentId":"env_local",...}

pid  before -> after : 9301 -> 9345 | OVERWRITTEN: true
port before -> after : 42693 -> 46175 | OVERWRITTEN: true
url  before -> after : http://localhost:42693 -> http://localhost:46175 | OVERWRITTEN: true
A's record survives anywhere in the home: false

Project A asks the supervision file "is my server running, and where?"
  file answers            : pid 9345 on http://localhost:46175
  project A's server is   : pid 9301 on http://localhost:42693
  the answer is about ANOTHER PROJECT: true
  A's own server is still alive : true · still reachable on 42693 : true
  the port the file names belongs to B: true

real `os serve` boots driven : 2
state-file reads performed   : 5
TCP reachability probes      : 2

⇒ The failure mode is an observation now, not a reading. It did not fail to reproduce, and no path component, lock or per-project root saved it.

A SECOND consequence the card did not assert, measured in the same run:

file present before A stops : true · it currently describes pid 9345 (= B)
file present after A stops  : false
B still alive               : true

runtimeBoundPortChannels registers process.on('exit', cleanupRuntimeFile) per file written. While every project shared one file, any project's shutdown deleted the record of whoever was still serving. A wrong answer and then no answer at all, from one cause.

Zero-write proof of the machine-global half, so nothing under the real ~/.objectstack was disturbed (other agents share this box; the driven run pinned OS_HOME to one sandbox dir, which is exactly what the default resolves to for every project):

RESOLVED_FROM: packages/runtime/dist/index.cjs      (the resolution the serve child takes)
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): /tmp/os-15733-control-home -> moved: true

The control matters: it proves the resolver is not simply a stuck constant, so "same path from two cwds" is a reading about cwd.

2 — The two facts re-derived, not quoted

packages/cli/src/commands/serve.ts (runtimeBoundPortChannels.writeRuntimeState, on the base commit) wrote exactly { pid, port, url, environmentId, startedAt }no database path, no project path — to path.join(resolveObjectStackHome(), 'runtime.' + environmentId + '.json'). resolveObjectStackHome (packages/runtime/src/standalone-stack.ts) takes no arguments: OS_HOME, else homedir()/.objectstack. Both halves confirmed.

3 — Who reads this file (measured, with a control that fires)

reader how it addresses the file effect of this change
scripts/publish-smoke.sh smoke_wait_for_own_server globs runtime.*.json under a TMPDIR it pins itself, --fresh none — the new name still matches that glob
packages/cli/test/serve-publishes-bound-port.e2e.test.ts spelled the old name now asks the writer's own naming function
packages/cli/test/serve-bound-port-publish-order.test.ts spelled the old name same
packages/cli/src/commands/serve-bound-port-publication.test.ts spelled the old name same
packages/spec/scripts/publish-smoke-port-collision.test.ts asserts the smoke script's shape none

Nothing else. Zero readers in content/, docs/, skills/; zero in the sibling objectui checkout; zero in any other package.

Control on that search, same invocation, same include set, same root: the pattern OS_HOME returns 120 hits across ts / mdx / md / json / sh / yml, and in objectui a control of 716 .ts files matching objectstack against 0 hits for the reader patterns. The near-empty reader result is a reading, not a silent zero.

⭐ Note what the smoke script already says in its own comment: "a neighbouring run's server answers 200 on the requested port exactly like ours would" — it hand-rolled a per-run isolated home to work around precisely this class. The repo has already paid for this defect once.

4 — The repair shape, and why (i)

The dispatch named three shapes and made no ruling. The reader enumeration decided it.

Taken: (i) key the file by a project identity. runtime.ENVIRONMENT.PROJECT.json, where the project component is a sanitised basename plus a 12-hex digest of the served app's rootservedAppRootOrCwd(), the root serve already resolves for host-anchored package loads. No new concept was introduced; the state file simply was not using the identity this command had already computed.

  • actual demand — measured, and honestly small. Unreported by users. But the pull is not zero and it is in-repo: publish-smoke.sh carries a hand-rolled workaround for this exact class, and the card's own filer says working across two projects at once is that seat's ordinary state. Read as real but unreported, not as hot.
  • long-term soundness (weight ≥50%). An environment id is not a project identity, and a file whose job is "is MY server running" must be addressable by whose. (i) closes the class permanently. (ii) and (iii) leave two projects fighting over one file forever — under (ii) project A's record is still destroyed; under (iii) it is destroyed loudly.
  • guarding against wrong diagnoses. A file that answers about someone else's server is exactly the input that produces a confident wrong diagnosis. Under (i) the wrong answer cannot be produced. Under (ii) every reader must remember to check an identity field; under (iii) only the writer is warned, never the reader.
  • not spreading scope. This is p3 and unreported, so the repair had to earn itself: it is one call site, two small pure functions and no new dependency. The cost the dispatch worried about — "a path change breaks every reader" — was measured to be one glob reader that survives it, plus three in-repo test spellings. (ii) would have been the larger change by the measure that counts: it puts a new key on a published payload.

#15374's ruling is untouched. No database path, and no payload key of any kind — the object literal is byte-identical. The identity of the file a process is serving stays a property of that process.

Honest boundaries, stated rather than papered over:

  • A reader that hard-codes the old name now gets ENOENT instead of a stale or foreign record. That is the intended trade: a missing file is a loud, correct answer where the old name could only give a confident wrong one.
  • Two boots of the same project still share one file. That is the same-project case and out of scope here.
  • The key is the resolved path, not the realpath: two symlinked spellings of one project key differently (each internally consistent).

5 — The pin, and its controls

packages/cli/test/serve-runtime-state-project-key.test.ts — 12 tests. Three real child processes, one shared home, two project roots; each writes through the real runtimeBoundPortChannels. No server is booted: the name is what is under test.

  • ⭐ each project gets its own file, and each file still names its own pid/port/url — the assertion that fails on the old shape;
  • ⭐ one project's clean shutdown no longer deletes another project's record, and still deletes its own;
  • ⛔ nothing is written under the shared name any more;
  • CONTROL (axis: is the key PROJECT identity or PROCESS identity?) — a second boot of project A lands on A's file, not a third one. A per-process key would pass every other assertion here and simultaneously destroy the file's purpose.
  • CONTROL (read half) — that same second boot is a deliberate overwrite, and the harness's reading changes with it. "Both records survived" is worthless from a harness that cannot see a record being replaced. overwritesObserved is asserted === 1, not >= 0.
  • CONTROL (freshness / two readings of one stale build) — both children and the runner name a file for one fixed root and the three answers are compared, and checked against the name the repair replaced.
  • COUNTS (anti-vacuity)childrenSpawned === 3, asserted, not printed.

6 — Ablation: prediction written first, then run

Predicted in writing before the mutation (mutation: revert only the write site to the pre-repair line, leaving projectStateKey / runtimeStateFileName intact, so a red is about the wiring and not about the helper vanishing): 5 RED / 7 GREEN, naming all five reds and giving each green its reason — the five pure-function tests survive because the ablation removes the wiring, not the helper; the two controls survive because they assert on what the children reported and on the untouched helper.

Observed: 5 failed | 7 passed (12) — the same five, by name.

Mutation proved on disk before the run:

on-disk removed-text count (want 0): 0
on-disk injected-marker count (want 1): 1
on-disk pre-repair shape count (want 1): 1
HASH now: c0a55c036a7294ac26fb99d1dbb2c04707d0c94c  (baseline was 4745c41995f8d934f894210b5faa79b274761533)

Source-vs-dist resolution, established positively: packages/cli/dist did not exist in the worktree at ablation time (only the dependency closure had been built), and both the runner and the spawned child reach the writer by a relative import into packages/cli/src. A run resolving through the package name to dist/ would have failed loudly, never passed quietly — and the pre-ablation run was green, which a stale dist built from the base commit could not have produced.

Restore, under an EXIT INT TERM trap, absolute paths, via git hash-object -w + git cat-file blob (⛔ never git checkout HEAD --, which is wrong mid-merge), verified three independent ways rather than by the trap having fired:

git diff HEAD empty? exit=0
blob now: 4745c41995f8d934f894210b5faa79b274761533  HEAD blob: 4745c41995f8d934f894210b5faa79b274761533
marker left on disk? 0

7 — Verification

  • pnpm --filter @objectstack/cli exec vitest run over the four affected files at e18125843dd: 4 files / 43 tests passed.
  • pnpm --filter @objectstack/cli typecheck — green (tsc --noEmit + check:test-typecheck, both script names echoed).
  • Gate union — 58 of 58 families, asserted against the script's own Reconciliation line ("58 famil(ies)"), not against a bullet block or a pnpm check: grep. Re-derived after the change set was final; the command list is byte-identical across both derivations. All 58 green at e18125843dd. Four of them first reported PREREQUISITE NOT MET (check:dual-build-cjs-loads, check:i18n, check:i18n-coverage at exit 3; check:i18n-walk-parity at exit 1 with "NOTHING was measured" in its own verdict) — read as NOT MEASURED, never as pass; @objectstack/cli + @objectstack/spec were built and all four then returned real green verdicts. One real finding was fixed rather than baselined: check:cli-test-child-env's shrink-only ratchet named the new pin's bare { ...process.env } spawn, which now goes through childEnv().
  • Repo-wide pnpm lint is CI's run; delivered here as a proven narrowing, with all three pieces of evidence: (a) the population is read from ESLint's own config — all 6 changed source files come back [LINTED], none [IGNORED BY CONFIG]; (b) the count is ESLint's own, from --format json: 6 files, 0 errors, 0 warnings, exit 0; (c) the invariance claim for untouched files is the config's own, stated in eslint.config.mjs: this repo "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file" — so no edit in this diff can move a verdict on a file it does not touch.
  • Exit codes were captured before any pipe throughout (cmd > log 2>&1; EXIT=$?), and every gate verdict is quoted from the gate's own verdict line.

8 — Clause ② declaration, from the delivered diff — YES

Graded conservatively, per the doctrine that an unclear call is graded yes. Both limbs, separately:

  • Mechanical limb: YES (conservative). Zero files under packages/spec/src/**. The published payload gains no key — the object literal is byte-identical. What triggers the floor is that the diff adds two new export functions (projectStateKey, runtimeStateFileName) to a shipped module. Mitigating fact, recorded rather than used to argue the grade down: packages/cli's exports map declares only ., ./console and ./hook-body, so commands/serve is not a declared public subpath.
  • Conformance limb: YES (substantive). A shipped face moves: the path a supervisor opens changes from runtime.ENVIRONMENT.json to runtime.ENVIRONMENT.PROJECT.json, so for one unchanged input (a boot in some project) the published verdict changes from "a record exists here" to ENOENT. That is the same line A boot whose SQLite database file has been unlinked keeps serving it silently, so every filesystem inspection describes a different file #15374 declined to cross, approached from the path side rather than the payload side, and it is the reviewer's call — not something a p3 finding gets to assume away.

needs:contract-review is applied to this PR and to the card together.

9 — Out-of-scope finding, handed back with its measurement

packages/runtime/src/package-state-store.ts has the same defect class in a different file: stateFilePath() is join(resolveObjectStackHome(), 'package-state', sanitizeEnvironmentId(environmentId) + '.json') — the same machine-global home, keyed by environment id alone. Two projects on one machine therefore share which packages an operator has disabled, and that one changes behaviour rather than only reporting it.

⛔ Deliberately not repaired here. It fails the bounded-in-place test on two counts: it is a different package with a different gate surface, and — more importantly — whether package-lifecycle state should be per-project is a contract judgement, not a mechanical repair. An operator may legitimately want "disabled in staging" to be machine-wide. Dedup was run before filing, with a live positive control (the query returned this card, #15733, so the result is a reading and not a silent zero); no existing card covers it.


Generated by Claude Code

…lone

`os serve` publishes `{ pid, port, url, environmentId, startedAt }` under the
ObjectStack home so a supervisor can answer "is my server running, and where?".
The file was `runtime.<environment>.json`, and both halves of where it lived are
machine-global: `resolveObjectStackHome()` takes no arguments, and an environment
id is not a project identity. Two projects on one machine, both in the ordinary
`local` environment, wrote one file.

Driven with two real boots, two project roots and one home — two failures, one
cause:

  * project B's boot replaced project A's record, so a reader asking about A was
    answered with B's pid/port/url while A's server was still alive elsewhere;
  * project A's shutdown then deleted the file that by then described B, leaving
    a running server with no supervision record at all.

The file is now `runtime.<environment>.<project>.json`, keyed on the served
app's root — the root `serve` already resolves for host-anchored loads. The
payload is unchanged: no new key, and no database path (issue 15374 ruled that
one out deliberately, because it would turn a best-effort supervision file into
an identity contract).

The three in-repo readers that spelled the old name out now ask the writer's own
naming function instead, so a second copy of the rule cannot drift from it.
`scripts/publish-smoke.sh` globs `runtime.*.json` in a home it pins itself and is
unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
… a bare spread

`check:cli-test-child-env` is the gate that keeps `packages/cli/test`'s spawners
off `{ ...process.env }`: vitest sets `TEST`, `VITEST` and the `VITEST_*` family
on its worker, and a child inheriting them boots with a different auth and
crypto posture than the one under test. The new collision pin spawned its
children with a bare spread and the gate's shrink-only ratchet named it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 5 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 6615a024c33a9204a7a88273aa40b9ee107f78e3.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 22 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 6615a024c33a9204a7a88273aa40b9ee107f78e3packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8438cdc024aeec79a1f74aadbb373aedca92fe53 — the merge of head db54f46a98e826d08acab8be3014c9ab875c6e1f into base 6615a024c33a9204a7a88273aa40b9ee107f78e3, 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 8438cdc024aeec79a1f74aadbb373aedca92fe53 && git checkout 8438cdc024aeec79a1f74aadbb373aedca92fe53
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6615a024c33a9204a7a88273aa40b9ee107f78e3 db54f46a98e826d08acab8be3014c9ab875c6e1f && git checkout -B drift-repro 6615a024c33a9204a7a88273aa40b9ee107f78e3 && git merge --no-ff db54f46a98e826d08acab8be3014c9ab875c6e1f

node scripts/docs-audit/affected-docs.mjs --json 6615a024c33a9204a7a88273aa40b9ee107f78e3

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6615a024c33a9204a7a88273aa40b9ee107f78e3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-litant os-litant left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Contract review — clause ② verdict on the delivered diff at e18125843dd

Submitted as a COMMENT review rather than APPROVE / REQUEST_CHANGES because GitHub refuses both on a same-account PR; the verdict line below is the verdict.

What was reviewed: the diff d4c2cb196e8..e18125843dd (7 files, +532/−11), measured on a local merge of that head onto main at f50c394da55 (a merge commit built in a private worktree, never pushed; git diff --stat of the merge against main is exactly the PR's 7 files, +532/−11, so nothing in main's 16 newer commits overlapped). Judged from the delivered diff, not from the PR body.

Clause ② — the two limbs, judged separately

Mechanical floor: YES. The published payload gains no key — the JSON.stringify object literal is untouched (no added or removed line in the diff names pid, port, url, environmentId or startedAt) — and no path under packages/spec/src/** is touched, so neither of those two triggers fires; what fires is the floor's own wording in references/contract-review.md (any new exported symbol), because the diff adds export function projectStateKey and export function runtimeStateFileName to packages/cli/src/commands/serve.ts. Recorded so the PM seat can see which trigger decided it: those two exports are reachable only inside the package — @objectstack/cli's exports map declares ., ./console, ./hook-body and ./package.json, and src/index.ts re-exports only the ServeCommand default — so the floor triggers on the wording, not on a widening of the npm face. Graded YES under the doctrine that an unclear call is a yes.

Non-mechanizable conformance limb: YES. A shipped face moves: the on-disk name a supervisor opens under the ObjectStack home changes from runtime.ENVIRONMENT.json to runtime.ENVIRONMENT.PROJECT.json, so for one unchanged input class — a boot of some project in some environment — the published verdict of a reader of the old name is re-selected from "record present" to ENOENT, and the record's address is re-selected by a new identity component (the served app's root). That face was published: packages/cli/CHANGELOG.md for 17.3.0 (#13193, #13062) tells a supervisor to open runtime.env_local.json when the banner says ready, and scripts/publish-smoke.sh consumes it. It is the line #15374 declined to cross, approached from the path side rather than the payload side; the author's own declaration (PR §8) reads the same.

Verdict

CHANGES REQUESTED — one blocking item, on the changeset only; the code and the pin are sound.

Blocking

  1. The changeset does not carry the breaking-ness the diff performs. During the launch window the bump level is not the carrier (scripts/check-changeset-no-major.mjs header; .github/workflows/pr-automation.yml, "WHICH LEVEL"): a breaking change ships as minor, and its only signals are the **BREAKING** banner in the changeset body plus an adr-0087: disposition comment marker, which scripts/check-adr-0087-registration.mjs then re-verifies. This diff retires a shipped observable — the old file name — and the changeset's own paragraph says so ("a reader that hard-codes runtime.ENVIRONMENT.json now gets ENOENT"), yet it carries neither signal, so the release notes would list the rename of a supervisor-facing file as an ordinary minor with no BREAKING marker.
    Reproduction on the PR head: grep -c BREAKING .changeset/runtime-state-file-project-key.md prints 0; grep -c adr-0087 .changeset/runtime-state-file-project-key.md prints 0; node scripts/check-adr-0087-registration.mjs --base origin/main is green only because nothing is declared.
    Fix: add the banner and the disposition marker. The category is the author's call — no-migration-prescription reads closest (no metadata migrates; the only prescription is "delete the stale file once") — and the gate re-verifies whichever category is claimed, so run it after the edit. minor is the right level under the window rule either way. If the position is that a best-effort, never-documented supervision file is not a published contract, that reasoning belongs in the changeset body as the stated reason no banner is carried; silence is the one shape the rule forbids.

Non-blocking — worth taking in the same round

  • packages/cli/src/commands/serve.ts now states two names for the file: the new docblocks say runtime.ENVIRONMENT.PROJECT.json, while the BoundPortChannels.writeRuntimeState interface doc (line 463, "Writes runtime.ENVIRONMENT.json") and the prose at lines 395, 479, 486, 495 and 4744 still name the old shape (runtime.env_local.json); packages/cli/test/helpers/serve-process.ts lines 560 and 571 (error text) likewise. A reader of the interface is told the wrong name.
  • The project component is servedAppRootOrCwd(), which anchorServedApp sets to the config's directory only when that directory has a package.json, else the CWD. So the same app served from two working directories without a manifest keys two files, and an out-of-tree supervisor has to replicate that anchoring rule to find the record. The docblock and changeset record the symlink boundary; this one belongs beside it.

Measured — every exit code captured before any pipe

  • Gate union: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands over the 7 paths — 58 commands, asserted against the script's own Reconciliation — 58 famil(ies) line (46 by path + 7 by change kind + 7 whole-tree, 2 reached both ways; the "Local gates for this card" bullet block is 46 of those 58 and was not used as the population). All 58 GREEN on the merged tree after turbo run build --filter='./packages/*' --filter='./packages/*/*', so check:type-check-debt, check:dual-build-cjs-loads and check:i18n* ran against real dist rather than exiting 3. --ran reconciliation: 58 derived, 58 run, 0 NOT-MEASURED, 0 UNRUN.
  • Artifact rosters block (outside that total by the tool's design): 37 commands run separately — 34 GREEN; the 3 that are not are named under NOT MEASURED below, none of them red about this diff.
  • pnpm check:merge-driver on the merged tree, which includes #16002: exit 0.
  • Tests: vitest run over the four affected files on the merged tree — 4 files / 43 tests passed, exit 0. pnpm --filter @objectstack/cli typecheck — exit 0, tsc --noEmit and check:test-typecheck both echoed.
  • Discriminating control on the pin (prediction written before the run: 5 RED / 7 GREEN, the reds named): reverting only the write site to the pre-repair line with both helpers intact — blob 4745c41…bd08f51…, pre-repair shape count 1, repaired-call count 0 — gave 5 failed | 7 passed (12), exit 1, the five by name (own file, shared name, project-not-process control, shutdown, COUNTS). Restored by state, not by trap: porcelain empty, blob back to 4745c41…, marker count 0. The pin fails on the axis that can actually fail.
  • Reader enumeration re-derived with a control: a repo-wide grep for the name-constructing spellings finds exactly the writer, the four in-repo tests, scripts/publish-smoke.sh (globs runtime.*.json, unaffected) and the spec smoke-shape fixture; content/, docs/ and skills/ name the file nowhere (grep exit 1) while the control pattern OS_HOME hits 7 files in the same roots with the same include set. Payload literal byte-identical, confirmed on the diff.
  • CI at head e18125843dd: every check run completed as success or skipped-by-design; the six required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL)) are success.

NOT MEASURED — by name, each with its own verdict line

  • pnpm --filter @objectstack/spec run check:react-declaration-parity — exit 1 with its own text "This gate did NOT run" (MANIFEST unset; needs the objectui console build plus a browser). PREREQUISITE NOT MET; an on-demand gate by decision, not a CI job; not attributed to this diff.
  • node scripts/check-partof-closing-keyword.mjs — exit 2, NOT WIRED (no PR_BODY/PR_NUMBER). CI's "Part-of PR must not also close its card" is success on this head.
  • node scripts/check-single-claim-paths.mjs — exit 2, NOT WIRED (no PR_NUMBER). CI's "No other open PR may claim the same single-writer path" is success on this head.
  • The 6 value-bearing families (check-cross-package-test-inputs --union-into …, check-shard-attestation ×3, check-test-completeness ×2) — no local invocation exists by design; their CI jobs (Test Core 1–6/6, Dogfood Regression Gate 1–3/3, Dogfood Verify CLI) are success on this head.
  • node scripts/pm/check-clause2-carriers.mjs --pair 15968 — exit 3, PREREQUISITE NOT MET (HTTP 403 from this container). Read by hand instead: needs:contract-review sits on both carriers. Note for the PM seat: the card's claim comment carries no machine-spelled Clause-②: yes line — the declaration lives in PR §8 and the dev report as prose, which that checker does not read.
  • Sibling checkouts objectui and cloud are not present in this container; the PR's "zero readers there" claim is unverified here.
  • Repo-wide pnpm lint was not re-run locally; CI's Lint & Repo Gates is success on this head.

Generated by Claude Code

…geset

The launch window refuses `major`, so the bump level is not the carrier: a
breaking change ships as `minor` and its only signals are the BREAKING banner
in the changeset body and the ADR-0087 disposition marker that
`check-adr-0087-registration.mjs` re-verifies. This diff retires a shipped
observable -- the on-disk name a supervisor opens -- and the changeset said so
in its own prose while carrying neither signal.

Adds both. The disposition is `not-required (no-migration-prescription)`,
derived from the gate's own `CATEGORIES` const rather than its prose header:
nothing authorable moves, `packages/spec` is untouched, and what is retired is
a path on disk rather than a metadata surface the ledger can project into
`spec-changes.json` or the upgrade guide. `unpublished` is false (the package
publishes), `already-registered` names no entry that covers this, and neither
`runtime-interface-only` nor `type-surface-only` has a `path#Symbol` to name.

Also ends the two names `serve.ts` was stating at once. The
`BoundPortChannels.writeRuntimeState` interface doc and the four other
current-tense sites named the retired file; they now name the shipped one,
while the measured ENOENT quote and the pre-repair collision diagram keep the
name they observed and say that is what it was. The anchoring boundary an
out-of-tree reader has to replicate -- the app root is the config's own
directory only when that config exists and that directory carries a
`package.json`, else the CWD -- is stated beside the symlink boundary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
Clears staleness ahead of the contract-review patch round and picks up #16002.
Measured before the merge: `pnpm check:merge-driver` was already exit 0 on the
pre-merge head, so the branch was not red on that gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N

Copy link
Copy Markdown
Collaborator Author

os-dev-report
{
"issue": 15733,
"status": "done",
"round": "patch round answering contract review 5122785090 (CHANGES REQUESTED, one blocking item)",
"branch": "claude/issue-15733-runtime-state-file-key-shape",
"pr": "#15968",
"premise_still_valid": true,
"head_sha_after_push": "db54f46a98e826d08acab8be3014c9ab875c6e1f",
"pushed": "e18125843dd..db54f46, fast-forward, no force-push, no rebase, no amend",
"mergeable_state": "GitHub reports 'blocked' with draft:true. That is NOT a conflict reading: the zero-quota local probe git merge-tree --write-tree origin/main HEAD exited 0 and printed only tree 4642044c1ca with no conflict lines, against an origin/main that had already moved on to 6615a02 after my merge. So the head is clean against current main; 'blocked' is about required checks/review on a draft, not dirtiness.",
"summary": "The one blocking item is closed: .changeset/runtime-state-file-project-key.md now carries BOTH breaking-ness carriers - the BREAKING banner and an adr-0087 disposition marker - while staying minor, which is what the launch window requires. Disposition derived from the gate rather than accepted from the review: not-required (no-migration-prescription). Also took the review's non-blocking serve.ts items. packages/cli/test/helpers/serve-process.ts was DROPPED per the coordinator's mid-task correction; measured that the branch's pre-round diff never touched it (7 files), so leaving it out removes nothing and manufactures no same-file collision with #15902. Merged origin/main (f50c394) in as a merge commit to clear staleness. Code and pin untouched, per Zone 1.",
"blocking_item_closed": {
"what_was_added": "the BREAKING banner in the changeset body, plus one adr-0087 disposition marker",
"level": "unchanged at minor - check-changeset-no-major.mjs refuses major during the launch window, so the level is not the carrier and these two are",
"disposition_chosen": "not-required (no-migration-prescription)",
"how_it_was_derived": "From the gate, not from the review's guess, and from the CATEGORIES const rather than the prose header. CATEGORIES = [unpublished, already-registered, no-migration-prescription, runtime-interface-only, type-surface-only], plus the registered id-list form. Eliminations, each measured or read from the gate's own refusal logic: registered would require writing an entry into packages/spec/src/migrations/registry.ts for a change that migrates no metadata - the ADR's own #12104 table calls that false data in the one ledger this mechanism keeps true - and it would widen the PR into packages/spec, which Zone 1 forbids; unpublished is FALSE, measured - packages/cli/package.json has no private field, so the package publishes; already-registered names no pre-existing entry that covers a CLI file rename; runtime-interface-only and type-surface-only both require a path#Symbol to name, and what is retired here is a filename on disk, not an exported symbol. That leaves exactly one honest category. Its one mechanical precondition was measured directly before running the gate: the gate's own exported findMigrationPrescription(body) returns null on the final body, and the justification is 680 chars against a 40-char floor.",
"marker_rendering_note": "In the file the marker is a real HTML comment on one line. It is rendered in this report with COMMENT-OPEN / COMMENT-CLOSE placeholders, and the angle-bracket name placeholders as ENVIRONMENT / PROJECT, because the GitHub body sanitizer eats angle-bracket fragments - including inside backticks."
},
"gate_output_that_accepted_it": {
"command": "node scripts/check-adr-0087-registration.mjs --base origin/main",
"exit_code": 0,
"verdict_line_quoted_from_the_gate": "check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. / .changeset/runtime-state-file-project-key.md [BREAKING] not-required (no-migration-prescription)",
"why_this_green_means_something_now": "This is the point the review flagged - it was green BEFORE only because nothing was declared. Discriminating control, run at the ORIGINAL head: node scripts/check-adr-0087-registration.mjs --base d4c2cb196e84 --head e18125843dd also exits 0, but its verdict line reads 'this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)'. Same exit code, different subject: before, the gate measured that there was nothing to check; now it measures a declaration and re-verifies the category. The gate's own self-test also passes: 325 assertions over real temp git repos, exit 0."
},
"final_changeset_text": "---\n"@objectstack/cli": minor\n---\n\nos serve's runtime state file is keyed by the PROJECT, not by the environment id alone — so two projects on one machine stop overwriting each other's supervision record.\n\nCOMMENT-OPEN adr-0087: not-required (no-migration-prescription) Nothing authorable moves: no spec key, export, config field or stored metadata changes spelling or shape, packages/spec is untouched, and objectstack migrate meta has nothing to rewrite. What is retired is the NAME of a best-effort supervision file that os serve writes under the ObjectStack home — a path on disk, not a metadata surface the ledger can project into spec-changes.json or the generated upgrade guide. The affected party is an out-of-tree supervisor that opens that path, and the one action it takes is deleting a single stale file; there is no authored artifact for a metadata upgrader to rewrite, and no ledger entry could reach the party that is affected. COMMENT-CLOSE\n\nBREAKING for anything that opens the runtime state file by its old name. Shipped as minor under the launch-window convention: while the whole workspace versions in lockstep the bump level carries no breaking-ness, so this banner and the ADR-0087 disposition above are the carriers. The file os serve writes under the ObjectStack home was named runtime.ENVIRONMENT.json and is now named runtime.ENVIRONMENT.PROJECT.json.\n\nos serve publishes { pid, port, url, environmentId, startedAt } to a file under the ObjectStack home, so a supervisor can answer "is my server running, and where?". That file was named runtime.ENVIRONMENT.json, and both halves of where it lived were machine-global: resolveObjectStackHome() takes no arguments (it reads OS_HOME, else ~/.objectstack), and an environment id is not a project identity. Two different projects on one machine, both in the ordinary local environment, therefore wrote one file.\n\nDriven with two real boots, two project roots and one home, that produced two failures with one cause:\n\n- project B's boot replaced project A's record, so a reader asking about A's server was answered pid/port/url belonging to B — confidently, while A's own server was still alive and still listening elsewhere;\n- project A's shutdown then deleted the file that by that point described B, leaving a running server with no supervision record at all.\n\nThe file is now runtime.ENVIRONMENT.PROJECT.json, where the project component is a sanitised basename plus a short digest of the served app's root — the same root serve already resolves for host-anchored package loads. The payload is unchanged: no new key, and in particular no database path (which #15374 ruled out deliberately, because it would turn a best-effort supervision file into an identity contract).\n\nIf you read this file: a reader that hard-codes runtime.ENVIRONMENT.json now gets ENOENT rather than a stale or foreign record — a loud, correct answer to "is my server running", where the old name could only give a confident wrong one. Readers that glob runtime.*.json inside a home they pinned themselves (as scripts/publish-smoke.sh does) are unaffected. A runtime.ENVIRONMENT.json left over from an earlier version is no longer written or cleaned up by os serve; delete it once.\n\nWhich root the project component is taken from, for a supervisor that has to reconstruct the name out of tree: it is the app root serve anchors at, which is the config file's own directory when that file exists and that directory carries a package.json, and the process's working directory otherwise. Two boundaries follow, stated rather than fixed: the same app served from two working directories without a manifest keys two files, and the key is the resolved path rather than the realpath, so two symlinked spellings of one project key differently — each spelling gets its own file, and each is internally consistent.\n\nTwo boots of the same project from the same anchor still share one file, which is the same-project case and unchanged here.\n",
"gate_union": {
"derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands - no hand-made path list; the tool derived the change set itself from git (7 paths vs merge base f50c394 of origin/main and HEAD, three-dot semantics, committed 7 / working tree 0 / untracked 0). Its stderr line 1 confirms the answer is about THIS repo at commit db54f46, and the --repo assertion was checked against the checkout's origin remote and holds.",
"reconciliation_line_quoted": "Reconciliation - 58 famil(ies): this card's WHOLE runnable answer, and the number to assert a harvest against. (46 named by PATH + 7 by change KIND + 7 DECLARED whole-tree, 2 of them the same family reached both ways => 58 distinct.)",
"result": "58 of 58 GREEN on the final head db54f46.",
"tool_run_reconciliation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran (harvest) => exit 0, 'Run reconciliation - 58 derived, 58 run, 0 NOT-MEASURED, 0 UNRUN.'",
"exit_code_discipline": "Every gate ran as: eval cmd > logfile 2>&1 ; EXIT=$? - redirect FIRST, capture BEFORE any pipe. No gate verdict in this report was read from a piped $?. Grading: exit 2 = NOT WIRED and exit 3 = PREREQUISITE NOT MET are both recorded as NOT MEASURED, never as a pass. The union produced zero of either.",
"build_prerequisite": "Ran BEFORE the union so the dist-dependent families measured real output rather than exiting 3: turbo run build over ./packages/* and ./packages//, through the shared verify lock (VERDICT command-exit 0, held 22s, waited 0s), 71/71 tasks successful. check:dts-closure, check:dual-build-cjs-loads, check:type-check-debt and the three check:i18n* families all returned real green verdicts rather than a prerequisite refusal."
},
"artifact_rosters_block_run_separately": {
"why_separate": "The tool states it: these 37 families are NOT in the runnable total above and are NOT counted among the derived families - their silent verdict is a fact about a LIST, not about the card's paths. They were harvested from the tool's own 'Artifact rosters' heading and run as their own pass.",
"result": "37 run - 34 GREEN, 3 NOT MEASURED. None red about this diff.",
"not_measured_by_name": [
"node scripts/check-partof-closing-keyword.mjs - exit 2, its own text: NOT WIRED, neither PR_BODY nor PR_NUMBER is set, so this run was handed no pull request and judged nothing. NOT MEASURED.",
"node scripts/check-single-claim-paths.mjs - exit 2, its own text: NOT WIRED, PR_NUMBER is not set. NOT MEASURED.",
"pnpm --filter @objectstack/spec run check:react-declaration-parity - exit 1, but read its verdict line and not its code: 'MANIFEST is not set - there is no registry side to compare against. This gate did NOT run. That is a failure, not a skip.' PREREQUISITE NOT MET; it needs the objectui console build plus a browser. Not attributable to this diff."
],
"honesty_note_on_two_greens_in_this_block": "pnpm check:partof-closing-keyword and pnpm check:single-claim-paths are GREEN in this block, but the echoed script line shows both resolve to --self-test only. Their green is about the CHECKER's own fixtures, not about this PR - the PR-judging invocation is the bare node form, which is the NOT WIRED pair above. Recorded so those two greens are not read as PR clearance.",
"four_marked_rosters": "The tool marks 4 of the 37 as keeping their roster in a directory one of my paths is in, where silence is evidence in neither direction. check-changeset-fixed.mjs is the one that sits under .changeset; it was run and is GREEN."
},
"tests": "On the final head db54f46, after the merge: pnpm --filter @objectstack/cli exec vitest run over the four affected files (no bare -- passthrough) - Test Files 4 passed (4), Tests 43 passed (43), wrapper VERDICT command-exit 0 through the shared verify lock. pnpm --filter @objectstack/cli typecheck - VERDICT command-exit 0, both script names echoed (tsc --noEmit then check:test-typecheck), and check:test-typecheck printed its own OK line. Both ran through scripts/pm/os-verify-lock.sh, never a hand-rolled flock.",
"zone2_measurements": {
"assumption_1_six_dispositions_header_lists_five": "CONFIRMED. CATEGORIES holds the five not-required categories including type-surface-only; the file's prose header docblock enumerates only four not-required forms plus registered, omitting type-surface-only. I read CATEGORIES, not the header, exactly as instructed. No action taken - already filed as #15915.",
"assumption_1b_reviewer_guess": "The reviewer's read that no-migration-prescription 'reads closest' turned out CORRECT, but it is recorded here as independently derived from the gate's vocabulary and its per-category preconditions, not adopted.",
"assumption_2_registered_takes_id_list_only": "NOT EXERCISED - registered was eliminated before it mattered, so no rationale was ever placed on a marker line. Read and respected all the same.",
"assumption_3_merge_driver_red_at_head": "FALSIFIED, in the direction of the head being fine. pnpm check:merge-driver on the PRE-merge head e181258 exited 0 ('check-regen-pending self-test passed'), so the branch was NOT red on that gate; the review's own CI reading at that head also recorded Lint & Repo Gates as success. I merged origin/main (f50c394, which carries #16002) in anyway - right for staleness and Zone 1's sanctioned way to clear it - but the merge was NOT needed to turn that gate green, because it was never red here. The merge itself was clean, zero conflicts."
},
"out_of_scope_findings": [
"HANDED BACK, not acted on - packages/cli/test/helpers/serve-process.ts stale docblocks at lines 560 and 571 still name the retired file. The measurement you asked for: the branch's pre-round diff is 7 files and serve-process.ts is NOT among them, so there is ZERO overlap with #15902 to size - the file is untouched by this PR both before and after this round. Dropping it cost nothing.",
"HANDED BACK - the two roster gates that judge a PR (check-partof-closing-keyword, check-single-claim-paths) have no local invocation that measures anything: the bare node form exits 2 NOT WIRED, and the pnpm script name resolves to --self-test. A seat harvesting the roster block sees two greens that look like PR clearance and are not. Not filed: it may be by design, and filing is your call.",
"ALREADY YOURS, restated with its measurement - packages/runtime/src/package-state-store.ts carries the same defect class (stateFilePath keyed by environment id alone under the same machine-global home). Declared in PR section 9 of the existing body; unchanged by this round and still deliberately unrepaired, because whether package-lifecycle state should be per-project is a contract judgement rather than a mechanical repair."
],
"scope_discipline": "Two files changed this round: .changeset/runtime-state-file-project-key.md and packages/cli/src/commands/serve.ts (comments only - no logic, no pin assertion, no export moved), plus the merge commit. No force-push, no rebase, no amend, no stash, no hook bypass, no test skipped, no ratchet raised. Worktree /home/user/objectstack-issue-15733; the shared primary checkout was never edited.",
"serve_ts_docblock_treatment": "The review named six sites. Four are present-tense statements of a live channel and now name the shipped file: the BoundPortChannels.writeRuntimeState interface doc (the one a reader of the interface is misled by), the three-channel narrative at ~395, the three-channel list at ~479, and the boundPort comment at ~4744. TWO were treated differently ON PURPOSE, and it is the one judgement call worth your eye: the ENOENT string at ~495 is a VERBATIM QUOTE of a measured incident (14 PRs ejected, 10 independent hits, #13158), so rewriting it to the new name would falsify a measured record - it keeps the name it actually observed and now says so in-line ('the name that file carried then'), while the diagram at ~486, which illustrates the race a supervisor still runs, carries the current shape. Three further occurrences were left untouched because they are already explicitly past-tense ('used to be', 'the old', and the pre-repair collision diagram). The anchoring boundary is now stated beside the symlink boundary in projectStateKey's docblock, naming servedAppRootOrCwd and anchorServedApp: the app root is the config's own directory only when that config exists AND that directory carries a package.json, else the CWD - so one app served from two working directories with no manifest keys two files.",
"mcp_calls": "3 - two pull_request_read (get_reviews, get) and one add_issue_comment for this report. Everything else went through git and local tooling; the REST channel was probed once at session start and returned 403 on this seat.",
"open_questions": []
}


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Clause ② carriers cleared — contract review satisfied

Contract review #pullrequestreview-5122785090 returned CHANGES REQUESTED with one blocking item, and that item is closed at head db54f46a98e. Per the maintainer ruling of 2026-08-31, a satisfied review is discharged by the ruling seat stripping needs:contract-review from both carriers in one act:

Both label sets were read before the write and read back after; the read's positive control is that it had to see needs:contract-review itself, which it did on both carriers.

No second tier round, and why

The seat rules this closed without sending it back to the review tier. The change is confined to the changeset's two breaking-ness carriers — the **BREAKING** banner and the ADR-0087 disposition marker — which is precisely what the review itself prescribed. Neither clause ② limb moved: the mechanical floor stays yes (new exported symbols projectStateKey / runtimeStateFileName, noted as package-internal), and the conformance limb stays yes (the on-disk name a supervisor opens moves from runtime.ENVIRONMENT.json to runtime.ENVIRONMENT.PROJECT.json, so a reader of the old name is re-selected from "record present" to ENOENT). Re-running a tier review over a fix the tier itself specified is waste. This mirrors the seat's ruling on #15849 earlier today.

What was verified before clearing

  • check-adr-0087-registration --base origin/main → exit 0, verdict line 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition // .changeset/runtime-state-file-project-key.md [BREAKING] not-required (no-migration-prescription).
  • The control discriminates on the right axis. At the original head the same gate also exits 0 — but its verdict line reads this PR adds no declared-breaking changeset. Same exit code, different subject: the old green measured that there was nothing to check; the new green measures a declaration and re-verifies its category. A control that could not have failed would have been worthless here.
  • The disposition was derived from the CATEGORIES const rather than adopted from the review's suggestion, with the one mechanical precondition measured first via the gate's own exported findMigrationPrescription. unpublished was eliminated by measurement (packages/cli/package.json carries no private field), not by assumption.
  • The level stays minor: check-changeset-no-major refuses major during the launch window, so the banner and the marker are the carriers, not the level. The ADR-0087 addendum of 2026-08-30 ([finding] check-adr-0087-registration has no honest disposition for a published TYPE-surface narrowing — every category is refused, so the class is pushed to drop the **BREAKING** token instead #13080) closed "drop the token" as an exit; the token was added.
  • CI at db54f46a98e: 31 checks, 28 success, 3 skipped, 0 failureLint & Repo Gates, Check Changeset and Type Check · workspace among the successes.

Still open, handed back rather than folded in


Generated by Claude Code

@os-litant
os-litant marked this pull request as ready for review September 5, 2026 20:53
@os-litant
os-litant enabled auto-merge September 5, 2026 20:53
@os-litant
os-litant added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit acab609 Sep 5, 2026
38 checks passed
@os-litant
os-litant deleted the claude/issue-15733-runtime-state-file-key-shape branch September 5, 2026 21:50
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/l tests tooling

Projects

None yet

2 participants