Skip to content

cli/metadata: os migrate plan still creates .objectstack/metadata/ on a fresh project (the residual half of #6743's dry-run write side effect) #7000

Description

@os-project-manager

Found while implementing #6743 (PR #6997). Out of that card's scope — #6743's ruling and its pin are scoped to .objectstack/data/, the database — so this is filed separately and unassigned.

Duplicate search: migrate plan / dry-run / FileSystemRepository / .objectstack/metadata over open issues and open PRs — 0 hits (other than #6743 / #6997 themselves).

Observation

#6743 removed the database half of the write side effect: after PR #6997, os migrate plan on a never-started project leaves no .objectstack/data/ at all. The metadata repository half is untouched and still runs — the same command, on the same fresh project, still brings a directory tree into existence:

$ ls -d .objectstack
ls: cannot access '.objectstack': No such file or directory

$ os migrate plan          # succeeds, prints the plan

$ find .objectstack -maxdepth 3
.objectstack
.objectstack/metadata
.objectstack/metadata/.objectstack
.objectstack/metadata/.objectstack/.log

Source: MetadataPlugin attaches its FileSystemRepository at repoRoot=<projectRoot>/.objectstack/metadata during the boot that bootSchemaStack performs, and the attach creates the root (the boot log line [MetadataPlugin] FileSystemRepository attached names it).

Why it is the same class as #6743, and why it is nonetheless separate

Same class: a command that declares itself a dry run leaves a write side effect behind, and the existence of .objectstack/ stops being a usable signal for "this project has never been started".

Separate: it is a different subsystem (metadata repository, not the sqlite driver's open mode), the fix would live in MetadataPlugin / the standalone boot rather than in @objectstack/driver-sql, and #6743's ruling — including its "same report, byte for byte" constraint — reasons only about the database. Fixing it inside #6743 would have been scope expansion past a graded card.

Severity as observed

Low, and deliberately filed as an observation rather than a defect: what is left behind is an empty repository skeleton plus an empty .log directory. No user-visible failure has been traced to it. Recording it so the "dry run leaves nothing behind" property is either completed deliberately or closed with a reason, rather than left half-done by accident — #6743 exists precisely because the #3917 deferral looked complete and was not.

Note the boot is genuinely read-only in the sense #3917 established (no DDL, no seed rows); the question is only whether attaching a metadata repository should create its root during a command that will never write metadata.

Related

Activity

  1. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    Contributor

    Routing repair only: added domain:metadata. finding grade unchanged, no ownership taken.

    • Landing site: per the card's own analysis, the fix lives in MetadataPlugin / the standalone boot (FileSystemRepository root creation at attach time), not in packages/cli — the os migrate plan command is only the trigger. packages/metadata* ⇒ domain:metadata per the domain table. Cross-checked on origin/main @ 2f3e793: packages/cli/src/commands/migrate/plan.ts itself performs no mkdir, consistent with the attribution.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    Contributor

    Finding-triage ruling: Promote — finding to pm:queue.

    Scoped residual of an already-ruled property (#6743: a dry run leaves nothing behind): os migrate plan still creates the .objectstack/metadata/ tree on a fresh project because MetadataPlugin attaches FileSystemRepository at boot (plugin.ts:386-398) and repository start() mkdirs unconditionally (repository.ts:114-115). The DB half was fixed; leaving the FS half undone is the exact failure mode #6743 exists to prevent.

    Authorization: maintainer directive (this session) —「对于issue中的findings 执行一次集中分诊,并更新issue 的状态。」Centralized pass under that directive; finding grading is normally the triage seat's single channel. Round: objectstack#4949 finding-triage, 2026-08-10.
    Session: 01JaVVMrSxt7Tgi1uwEuDtH7


    Generated by Claude Code

  3. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 9 (domain:metadata seat, sticker #6367)

    Session: session_01W6bLax4KMrSfnE1ydFU8Dw
    Branch: claude/issue-7000-migrate-plan-no-metadata-root
    Worktree: objectstack-issue-7000
    Base: origin/main @ current

    Promoted 2026-08-10T00:12Z under the maintainer's centralized finding-triage directive. No unmet promotion gate on this card — the triage comment names the mechanism at line level (plugin.ts:386-398 attaches FileSystemRepository, repository.ts:114-115 mkdirs unconditionally in start()), and the earlier routing repair independently cross-checked on origin/main @ 2f3e793 that packages/cli/src/commands/migrate/plan.ts performs no mkdir of its own. So the trigger is the CLI command but the landing site is this lane's.

    The property being completed is already ruled by #6743 — a dry run leaves nothing behind. Its database half landed (PR #6997); this is the filesystem half, and leaving it undone is the exact failure mode #6743 exists to prevent (#3917's deferral looked complete and was not).

    Region: packages/metadata — disjoint from #6992, dispatched alongside it into metadata-protocol's cold-boot audit.


    Generated by Claude Code

  4. self-assigned this
    on Aug 10, 2026
  5. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    Contributor

    os-dev report

    Status: done — PR #7152 (DRAFT). Branch claude/issue-7000-migrate-plan-no-metadata-root, base origin/main @ 3e8e669c0. Premise still valid.

    The side effect, reproduced

    Reproduced at the seam rather than through the CLI, and it comes back byte-identical to the card's find output. With the fix reverted, the new residue sweep prints:

    AssertionError: expected [ '.objectstack', ...(3) ] to deeply equal []
    + [
    +   ".objectstack",
    +   ".objectstack/metadata",
    +   ".objectstack/metadata/.objectstack",
    +   ".objectstack/metadata/.objectstack/.log",
    + ]
    

    Mechanism confirmed at the symbol, with the file moved as triage warned: FileSystemRepository.start() mkdir-ed its root and its .objectstack/.log unconditionally, and the file is now packages/metadata-fs/src/repository.ts (packages/metadata was split; plugin.ts:386-398 still attaches at REPO_SUBDIR = '.objectstack/metadata', unchanged). No CLI file was needed — triage's attribution held.

    One correction to the card's framing. The database half is pushed but not merged: PR #6997 is still an open draft, b70ca0b0c is not an ancestor of origin/main, and sqliteAbsentFile does not exist on main. So on main today os migrate plan still creates .objectstack/data/ too. This PR removes the metadata tree only, and its pin asserts the metadata paths specifically rather than the absence of .objectstack/ as a whole — the two halves stay independent and land independently.

    What changed

    The behavioural change is confined to packages/metadata-fs/src/repository.ts; the rest of the diff is tests, the package README and a changeset.

    • start() creates nothing. Every read path already tolerated an absent root (scanHeads swallows the readdir ENOENT, JsonlLog.readAll / highestSeq and get guard on existsSync).
    • New private ensureRoot(), called by put() and delete() right before they touch the disk — create-on-write, exactly the shape assumption 1 predicted. No read path required the directory, so no read-side ensureRoot() was needed.
    • Assumption 1, the one measured amendment: a watch path did require it. chokidar cannot watch a path that does not exist yet — measured on chokidar 5.0.0 with the repository's own usePolling options, a root created after watch() produces no events at all, ever. So start() arms the watcher only when the root exists and ensureRoot() arms it when the root appears. Residual case, documented in the README: a root created by a third party while the process runs, with this repository never writing, is not picked up until the next start().

    Which commands the fix covers (assumption 2)

    The property, not the command — the fix is at the repository, so every boot that attaches it without writing metadata is covered: all nine bootSchemaStack commands (migrate plan|apply|meta|value-shapes|recorded-by|resume|summary-nulls|files-to-references, meta resync) and serve / dev through createStandaloneStack. Write-performing commands create the root when they write, as before. It does not fix one command by accident.

    The boot is not weakened (assumption 4)

    No DDL, no seed rows, nothing written that was not written before — the only removed behaviour is the mkdir. Verified positively rather than by absence: the control cases assert the repository is still attached (plugin.repository defined, the object the [MetadataPlugin] FileSystemRepository attached line names), still readable, and that a write through it still materializes root + body + JSONL log. packages/metadata 591 tests and the metadata-core repository contract suite green and untouched.

    Assumption 3 — the .objectstack/.log nesting

    Not a defect on its own: the repository always keeps its log at .objectstack/.log relative to its own root, so the nesting is just that documented layout meeting a root that lives under the project's .objectstack/. But chasing it surfaced a real one, filed as #7150: the watcher's ignored dotfile regex is applied by chokidar to the watched root path itself, so a root under a dot-directory is ignored wholesale. Measured side by side on chokidar 5.0.0 — root at metadata/: getWatched() populated, add/change events fire; same tree at .objectstack/metadata/: getWatched() empty, zero events. The plugin's root is the second shape, so that watcher (and MetadataManager's repo.watch({}) re-emit loop behind it) has never fired in the production layout. Pre-existing on origin/main, unchanged by this PR in both directions; filed unlabeled for triage to grade, with both the observation-class and defect readings written out.

    Reverse verification — predictions vs results

    Predictions recorded before running. Reverted to my own base 3e8e669c0, never a moving origin/main, via git checkout (no stash).

    Variant 1 — whole fix removed:

    # Case Predicted Measured
    1 start() creates neither root nor .objectstack/.log RED RED
    2 absent root attachable / reads empty RED on the trailing residue assertion only RED exactly there; every read assertion above it passed first
    3 first write materializes root/body/log GREEN both directions (guard) RED — missed prediction
    4 watcher arming GREEN both directions (guard) RED — missed prediction
    5 read-only boot creates nothing RED RED
    6 same boot, watcher enabled RED RED
    7 attached repository still usable GREEN both directions (guard) GREEN

    Missed predictions 3 and 4, and their cause: each of those cases opens with an expect(existsSync(root)).toBe(false) precondition right after start() — a line that pins the new behaviour too, which I did not account for when predicting. They went red there (lines 108 and 150) before reaching the assertions the prediction was about, so their substantive halves were left unmeasured by variant 1, not passed. I then measured them: with those two precondition lines removed and the fix still out, both cases pass. So the prediction was right about the substance, wrong about the file — 3 and 4 are guards, 1/2/5/6 are the evidence.

    Variant 2 — the naive shape of the fix (create-on-write kept, watcher compensation deleted). Predicted: case 4 RED, and fs-behavior.test.ts's existing external-edit case GREEN because its root exists at start(). Measured: exactly that — 1 failed / 27 passed, the failure being case 4 alone. That is what makes case 4 load-bearing despite being a guard in variant 1.

    A false green worth recording: the first attempt at variant 1 reported all 591 packages/metadata tests passing with the fix reverted. Cause — the plugin test imports the built @objectstack/metadata-fs, so reverting the source changed nothing until dist was rebuilt. Re-run after rebuilding: 2 failed as predicted.

    Local results

    packages/metadata-fs  Test Files 3 passed (3)   Tests 28 passed (28)
    packages/metadata     Test Files 29 passed (29) Tests 591 passed (591)
    packages/metadata-fs  typecheck (tsc --noEmit) -> Done
    pnpm lint (eslint . --no-inline-config) -> clean
    check:nul-bytes / check:doc-authoring / check:published-files /
    check:engine-double-contract / check:changeset-gate-self-tests /
    check:release-notes / check:release-body / check:objectui-changeset -> PASS
    

    @objectstack/metadata has no typecheck script (it is a check:type-check-debt ledger entry), so I measured tsc --noEmit over the package instead: 89 errors, 0 of them in the new test file. The first draft did add one (TS2835, from the extensionless ./plugin import the neighbouring plugin.test.ts uses); fixed by importing ./plugin.js. No ledger drift.

    CI, per-job conclusions on PR #7152 (head 38b1118)

    Job Status Conclusion
    ESLint completed success
    TypeScript Type Check completed success
    Check Changeset completed success
    Build Core completed success
    Test Core (1/3, 2/3, 3/3) + rollup completed success
    Dogfood Regression Gate (1/3, 2/3, 3/3) + rollup completed success
    Dogfood Verify CLI completed success
    Temporal Conformance (live PG + MySQL) completed success
    Console Pin Freshness completed success
    Check PR Size / Documentation Links / ADR maintainer approval / Auto Label / issue-claim guard / docs-flag completed success
    Build Docs, Console Pin Gate completed skipped (path filter)

    No skip-changeset label needed or applied — this PR ships a changeset (.changeset/gentle-pears-attach.md) and Check Changeset is green.

    Unverified / not done


    Generated by Claude Code


    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions