openspec/work/simplify-context-and-workspace-model/slices/declared-store-fallback/spec.md
A repo whose planning is fully externalized — no local OpenSpec root —
declares its store once, and every normal command works there without
--store on every invocation. The declaration is a fallback, never an
override: with any local root present, behavior is byte-identical to
today, declaration or not. The fixed precedence is finally complete:
explicit --store → nearest local root → declared store (only when no
local root exists) → today's error with the stores hint.
openspec/config.yaml — the fallback
store: pointer shares one home with references:. The fallback
case is a config-only openspec/ directory (no specs/, no
changes/): root detection keeps today's stat-only walk, and two
extra stats distinguish a real root from a pointer. A top-level
marker file was rejected (.openspec.yaml is taken; dot-only
filename collisions are an agent hazard).openspec/ wins and terminates the walk. On that one
directory, two stats (openspec/specs, openspec/changes, each
required to be a directory) classify it: either present → a
real root, today's nearest path, byte-identical. Both absent
(config-only) → a warning-silent targeted read of the config
(parse for the store: key only — never re-emitting the resilient
parser's field warnings during resolution); a store: key makes it
a pointer and resolution proceeds through the shared store pipeline;
no store: key → today's behavior is preserved (the config-only
directory is still a root — freshly initialized minimal roots keep
working). The walk never continues past the nearest openspec/
directory; nesting a pointer under a real root is pathological and
out of scope.store: value
(non-string, invalid id grammar) or an unparseable config file fails
resolution with an origin-naming error (invalid_store_pointer for
the malformed/unparseable cases; the grammar case flows into the
pipeline's invalid_store_id) — it must not degrade into scaffolding
work next to the pointer. (This deliberately differs from 3.1's
drop-with-warning references parsing: a dropped reference degrades
an index; a dropped pointer would silently flip the write target.)--store root except for
its source — enforced by one predicate. "Store-selected" means
root.storeId is set; every consumer currently keyed on
source === 'store' switches to that predicate: the banner and
withStoreFlag (root-selection.ts:339,349), new-change's absolute
path display (new-change.ts:77), status's storeId threading
(status.ts:106), validate/show noun-form suggestion suppression —
both show branches, including printNonInteractiveHint
(validate.ts:136, show.ts:138, show.ts:160 — the eighth check,
found in plan review), and archive's absolute cross-root display
paths (archive.ts:446).
Resolution runs the same resolveStoreRoot pipeline via an optional
declaredOrigin parameter; errors keep their codes and gain a true
prefix: "Declared in <abs path to the actual config file read>: " +
the existing message. The JSON root block carries
source: "declared" (additive enum value) plus store_id; hint
continuity appends --store <id> exactly as for explicit selection
(pasted hints work from any cwd). Explicit --store always wins and
never consults the pointer.store:
key is never consulted — no chaining, no recursion (a pointer chain
target that is itself config-only simply fails health as
unhealthy_store_root).store: pointer, commands emit
exactly one stderr warning per invocation — "Warning: <absolute
config path> declares store 'x', but this directory is a real
OpenSpec root; the declaration is ignored." (implementation
amendment: the absolute path replaces the spec draft's relative
openspec/config.yaml, per the absolute-paths quality bar) — in
both human and JSON modes (stderr keeps stdout payloads clean).
references: in the same config keeps working; only the store:
pointer is ignored.openspec init may convert it, deliberately. No lifecycle command
creates specs/ or changes/ inside a config-only pointer
directory; work lands in the declared store's root. openspec init
run in a pointer repo refuses with an actionable error ("this
repo's planning is externalized to store 'x' (openspec/config.yaml);
remove the store: line first to convert it to a local root") instead
of silently scaffolding a both-shapes directory.A team keeps all planning in team-context. Their app repo carries
only a pointer:
# app-repo/openspec/config.yaml
store: team-context
Every normal command just works there, no flag:
$ openspec new change billing-rework
Using OpenSpec root: team-context (/Users/dev/src/team-context)
Created change 'billing-rework' at /Users/dev/src/team-context/openspec/changes/billing-rework/
...
$ openspec status --change billing-rework --json
{ ..., "root": { "path": "/Users/dev/src/team-context",
"source": "declared", "store_id": "team-context" } }
(Note the absolute path: a declared root is cross-root, exactly like
--store, so every displayed path is absolute.)
The pointer never hijacks a real root: in a repo that has its own
openspec/specs/, the same store: line changes nothing except one
stderr warning that it is being ignored. And a teammate without the
store registered gets the full store-error treatment, told exactly
where the requirement came from:
Error: Declared in /Users/dev/src/app-repo/openspec/config.yaml: Unknown store
'team-context'. No stores are registered. Run openspec store setup team-context
or openspec store register <path> first.
In scope:
store: (optional string) in ProjectConfigSchema and
the resilient parser (src/core/project-config.ts).resolveOpenSpecRoot
(src/core/root-selection.ts:275-313), after
findRepoPlanningRootSync returns a directory: the two
directory-shape stats; the pointer branch (warning-silent targeted
config read, malformed-pointer errors, resolve via the existing
resolveStoreRoot with the declaredOrigin prefix); source: 'declared' added to OpenSpecRootSource and RootOutput; the
store-selected predicate (storeId set) adopted by all seven
source-keyed consumers (decision 3's list); the both-shapes stderr
warning.openspec init refuses to scaffold a config-only
pointer directory (decision 6), with its own test.docs/cli.md "Referencing stores from a
project" area with a sibling "Declaring a default store" subsection;
add the store: bullet to the config keys covered there.--store precedence; pointer ignored with planning shape +
warning; config-only without pointer unchanged; pointer to
unknown/unhealthy store errors with origin prefix; invalid pointer id
grammar); byte-identity pin (real root with and without store: —
identical stdout); an e2e externalized-planning journey (rootless app
repo with pointer → new change, status, instructions, artifact
writes, validate, archive, all without --store; work lands in
the store; the pointer dir gains no specs//changes/; banner and
JSON root block report declared).Out of scope:
references: work exactly as for any resolved
root.--store behavior, the stores-hint error, or
the implicit-root scaffold for directories without openspec/.openspec/ contains only config.yaml with
store: team-context, and team-context registered and healthynew change, status, instructions, validate, list,
show, and archive run there without --storeUsing OpenSpec root: team-context (…){path: <store root>, source: "declared", store_id: "team-context"}--store team-contextspecs/ or changes/team-context--store other-contextother-context with source: "store", the
pointer never consultedopenspec/specs/ or
openspec/changes/ present)store: line in its
confignearest)openspec/ directory whose config has no
store: keyunknown_store,
no_registered_stores, unhealthy_store_root,
store_identity_mismatch, invalid_store_id) with the message
prefixed "Declared in <absolute path to the config file actually
read>: "store: value or an unparseable config in a
config-only directory fails with invalid_store_pointer naming the
origin — never a silent fall-through to local-root behavior, never a
write next to the pointerstore:
resolves to that target (one hop, no chaining)openspec initstore: line first) and creates nothingopenspec/ anywhere up the walkreferences:instructions runs in the pointer repo