openspec/work/simplify-context-and-workspace-model/slices/declared-store-fallback/plan.md
Spec locked 2026-06-11 after two adversarial rounds (the store-selected predicate adopted by all seven source-keyed consumers; init's pointer guard; malformed-pointer errors; one-hop rule; warning-silent resolver reads; the recorded doctor-wording amendment). Plan drafted 2026-06-11. Implementation not started.
The main move:
One predicate ("a store-selected root has storeId"), one pointer branch
in the resolver, one init guard — and externalized planning needs no
flags.
Start from spec.md (this folder). Keep nearby: ../../roadmap.md
(Phase 3 precedence lock + the recorded amendment),
../store-references/spec.md (3.1 config patterns),
../store-lifecycle-proof/spec.md (hint-continuity contracts).
resolveOpenSpecRoot (src/core/root-selection.ts:258-314);
the nearest-root arm at 277-280 (findRepoPlanningRootSync returns
the project root whose openspec/ exists and terminates at the
nearest ancestor — planning-home.ts:52-77); the stores-hint error
at 293-302; implicit at 305-313. resolveStoreRoot (134-218, module
private, same file) is the pipeline the pointer branch calls.emitStoreRootBanner (root-selection.ts:339), withStoreFlag
(root-selection.ts:349), new-change path display
(src/commands/workflow/new-change.ts:77), status storeId
threading (src/commands/workflow/status.ts:106 → buildNextSteps
appends --store), validate noun-suggestion suppression
(src/commands/validate.ts:136), show noun-suggestion suppression in
BOTH branches (src/commands/show.ts:138 and
printNonInteractiveHint at show.ts:160), archive absolute display
paths (src/core/archive.ts:446). Spec amendment recorded in the
changelog: eight checks, not seven.ProjectConfigSchema/readProjectConfig
(src/core/project-config.ts); the resolver does NOT reuse
readProjectConfig (it would re-emit field warnings) — it does a
targeted read.InitCommand.execute → createDirectoryStructure
(src/core/init.ts:144, 455-487) unconditionally scaffolds under an
existing openspec/; the guard goes before that.test/core/root-selection.test.ts (resolver unit),
test/commands/store-root-selection.test.ts (CLI),
test/core/init.test.ts, test/cli-e2e/ harness,
test/helpers/openspec-fixtures.ts (shared fixtures from 3.1).src/core/project-config.ts: add store: z.string().optional() to
the schema; resilient parse keeps a string, drops non-strings with
a warning (the parser's behavior is unchanged in spirit — the
RESOLVER, not the parser, owns the malformed-pointer error, and it
reads the file itself).src/core/root-selection.ts:
OpenSpecRootSource gains 'declared'.isStoreSelectedRoot(root) predicate (storeId !== undefined);
emitStoreRootBanner and withStoreFlag switch to it.openspec/specs and
openspec/changes as directories. Planning shape → today's path,
plus the both-shapes check: a targeted, warning-silent read of
openspec/config.{yaml,yml} (small local helper: read file, YAML
parse in try/catch, pluck store) and one stderr warning when a
store key exists ("openspec/config.yaml declares store 'x', but
this directory is a real OpenSpec root; the declaration is
ignored.").store key → today's
nearest behavior; unparseable config or non-string store →
invalid_store_pointer RootSelectionError naming the actual file
read; a string → call resolveStoreRoot(id, globalDataDir, 'declared') inside a try/catch that rewraps any thrown
RootSelectionError/store error with the message prefix
"Declared in <abs path>: " while preserving code, target, and
an UNPREFIXED fix — one wrapper covers all ~7 throw paths
including the fromStoreError pass-throughs
(root-selection.ts:138,146), no per-template surgery.resolveStoreRoot gains only a source parameter (default
'store'; makeRoot already takes source as its second arg).readProjectConfig in project-config.ts, reusing its
.yaml/.yml preference): read file, YAML parse in try/catch,
pluck store — returning {value?, malformed?, filePath}. The
both-shapes warning fires only for STRING values (a non-string in
a real root is not a pointer; the resilient parser's later
drop-warning covers it).source === 'store' to the shared predicate (import
from root-selection).declared, store_id set, banner, hints
carry --store, absolute paths in new-change/archive output, and
the show nothing-to-show hint suppresses noun-form suggestions
(the eighth consumer).--store beats the pointer, asserting source === 'store'.status --json, list --json; normalize or avoid
durationMs-bearing outputs like validate's) — plus exactly one
stderr warning per invocation in human AND JSON modes, JSON stdout
clean.source === 'nearest', path is the
config-only dir, zero stderr warnings, registry never consulted.invalid_store_pointer with origin AND a no-write assertion (the
pointer dir is untouched); invalid grammar → invalid_store_id
with the declared prefix; ALL five taxonomy codes prefixed
(unknown_store, no_registered_stores, unhealthy_store_root,
store_identity_mismatch, invalid_store_id), each asserting
the prefixed diagnostic.message and an UNPREFIXED
diagnostic.fix.store: → resolves to
the first store..yml origin naming.src/core/init.ts: the guard goes immediately after validate()
returns extendMode (init.ts:111) — before legacy cleanup
(:114, which mutates project files), migration (:121, which
writes global config), and the interactive prompts — so the refusal
truly creates and changes nothing. Detection: extendMode and the
shared targeted-read helper reports a string store: in a
config-only openspec/. Test asserts: refusal with the conversion
guidance; NO filesystem changes (project tree snapshot identical;
global data dir untouched); after removing the line, a rerun
scaffolds openspec/specs/ and openspec/changes/ normally.test/cli-e2e/ or
test/commands/, runCLI): rootless app repo with pointer →
new change, status, instructions (+ references composition:
the store's own references: appear per 3.1 symmetry), artifact
writes, validate, list, show, archive — no --store
anywhere; work lands in the store; pointer dir never gains
specs//changes/ (snapshot); banner + JSON root block assert
declared.docs/cli.md: "Declaring a default store" subsection next to the
references one (the pointer, precedence, the init conversion note).--store behavior: the
predicate is true for both sources; every switched site already
behaved this way for explicit stores — the suite's existing
store-root expectations are the net.readProjectConfig reuse (its warnings would double-fire — the
3.1-recorded behavior).invalid_store_pointer is a new code: document it in the slice
artifacts; additive to the resolver taxonomy (the capstone
agent-contract inventory picks it up).findRepoPlanningRootSync semantics
stay; only resolveOpenSpecRoot classifies the found dir. The
legacy planning-home workspace branch is unaffected.