Repository navigation
Changes cannot be nested under a namespace folder, while specs can (specs/<area>/<capability> works, changes/<area>/<name> does not) #1846
Description
Summary
Specs support nested/namespaced paths (specs/<area>/<capability>/spec.md, implemented in #1353), but changes cannot be nested the same way: openspec new change <area>/<name> is rejected, and a manually created nested change directory is not recognized by the CLI.
This asymmetry makes it impossible to mirror the spec namespace in changes/, which is the natural layout for a shared spec store that serves several repos.
Verified on 1.12.0 and on the current 1.13.0.
Context
We keep a single shared OpenSpec store (itimind-specs) that receives specs and changes from two products: a Kotlin Multiplatform mobile app and a web/backend platform. Capabilities are namespaced by product:
openspec/specs/mobile/tutorial-videos/spec.md
openspec/specs/platform/api-gateway/spec.md
That part works. We would like the changes that produce them to live under the matching namespace:
openspec/changes/mobile/refresh-token/
openspec/changes/platform/api-endpoint/
Reproduction
1. Nested capability paths in specs/ work (as expected, per #1353)
A change carrying specs/mobile/tutorial-videos/spec.md archives correctly:
$ openspec archive test-nested --yes
Specs to update:
mobile/tutorial-videos: create
Applying changes to openspec/specs/mobile/tutorial-videos/spec.md:
+ 1 added
Totals: + 1, ~ 0, - 0, → 0
Specs updated successfully.
Change 'test-nested' archived as '2026-09-11-test-nested'.
Resulting tree — the nested target path is honoured:
openspec/changes/archive/2026-09-11-test-nested/specs/mobile/tutorial-videos/spec.md
openspec/specs/mobile/tutorial-videos/spec.md
2. Nested change names are rejected
$ openspec new change mobile/refresh-token
✖ Error: Change name can only contain lowercase letters, numbers, and hyphens
Lookup paths reject them too (dist/commands/workflow/shared.js):
Change name cannot contain path separators
3. A manually created nested change is mis-listed
Creating openspec/changes/mobile/refresh-token/ by hand and running openspec list:
Changes:
mobile No tasks just now
The CLI reports the namespace folder as the change, not the change inside it. There is no error, so this fails silently and misleadingly rather than being rejected outright.
Relevant code
dist/core/id.js — the shared kebab grammar rejects separators:
export const KEBAB_ID_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
export function folderStyleNameProblem(value, label) {
...
if (/[\/]/u.test(value)) {
return `${label} must not contain path separators`;
}
return null;
}dist/commands/change.js — a change must sit directly under changes/:
/**
* A change is a directory directly under changes/. Rejecting anything else up
* front keeps a traversing name (`../..`) from reading a proposal outside the
* changes directory, and keeps the missing-proposal message honest.
*/
function isChangeDirectoryName(changesPath, changeDir) {
return path.dirname(path.resolve(changeDir)) === path.resolve(changesPath);
}We understand the intent here is path-traversal safety, which is the right goal — the ask is to allow a bounded namespace segment rather than arbitrary paths.
Current workaround
Flatten the namespace into the change name:
openspec new change mobile-refresh-token
It works, but it diverges from the specs/ layout, and the prefixes grow long and unsearchable as the store accumulates changes from both products.
Requests (any one would solve it)
A. Native nested change ids. Accept a namespace segment while keeping traversal rejected:
openspec new change mobile/refresh-token
→ openspec/changes/mobile/refresh-token/
with list, show, validate, apply and archive addressing them by the same mobile/refresh-token id — exactly the treatment specs/ already gets after #1353. . / .. segments, absolute paths and separators beyond the allowed depth stay rejected.
B. A directory flag, if the id grammar should stay flat:
openspec new change refresh-token --directory mobile/
C. Fail loudly instead. If nesting is intentionally out of scope, please at least make a nested change directory an explicit error in list/show rather than silently listing the namespace folder as a change (reproduction 3 above), and note the flat-only constraint in the docs.
Related
- Support nested/namespaced capability paths consistently across validate, show, apply, and archive #1353 — nested capability paths in
specs/(closed as completed; this issue is thechanges/counterpart) - [Feature Request] Multi-repository / microservice spec management #725 — multi-repository / microservice spec management
- [Proposal] Add hierarchical spec structure support #662, Nested Specs #594 — hierarchical spec structure
Environment
@fission-ai/openspec1.12.0 (installed) and 1.13.0 (latest, verified from the published tarball)- Node.js on Windows 11; same behaviour reported on the shared store used by our Linux CI
Activity
clay-good commentedon Sep 11, 2026
Thanks for the exceptionally clear report — the reproduction, the code pointers and the three-option framing all made this quick to act on.
PR #1849 implements option C, plus one case the issue didn't cover.
Options A and B change the change-id grammar and how every command addresses a change; that's a product-design decision for the maintainers rather than something to land inside a bug fix, so I've left them for a separate discussion.
While reproducing, one more consequence turned up:
openspec archive mobile --yessucceeded. It reportedNo tasks, moved the whole namespace — including the unfinished nested change — intochanges/archive/<date>-mobile/refresh-token/, and applied none of its deltas. So the silent mis-listing wasn't only misleading; it had a path to losing work. Archive now refuses the folder outright.What the PR does:
openspec listmobile No tasksmobile not a changeplus the explanation and a flat alternativeopenspec list --jsonmobileas an ordinary changewarningsentry (nested_change_directory), documented in the agent contractopenspec show/status --changeopenspec validateopenspec archiveAnd the flat-only constraint is now documented in
docs/concepts.mdwith a troubleshooting entry carrying themv/rmdirfix.Your workaround (flattening to
mobile-refresh-token) stays the supported layout for now — this change just makes sure nothing silently pretends otherwise.racase commentedon Sep 14, 2026
Thanks — and the
archivefinding is a good catch. Burying an unfinished nested change with none of its deltas applied is worse than the mis-listing I reported, and I'd rather have it refused outright. C was on my list, so no complaint about the choice.One thing I want to check before this is considered settled, because I don't think the PR touches it and I'd rather ask than assume.
The nesting already exists at both ends of the flow. Only the creation step is flat.
The
spec-drivenschema generates the specs artifact with a recursive glob:So nested capability paths inside a change are native, not incidental — which is why reproduction 1 above archives cleanly into
openspec/specs/mobile/tutorial-videos/spec.md.What stays flat is the change directory itself:
And
openspec new changeexposes only--schema,--goal,--description,--storeand--json— no directory option — and scaffolds nospecs/tree at all, just the change directory and.openspec.yaml.The question: the schema already emits the nested layout and
archivealready honours it, but the step in between — creating the change — can't start from that same structure. Is that gap deliberate (changes are meant to be flat, and only the specs they produce end up namespaced), or is it just the part that hasn't been covered?I'm not asking to reopen A or B here — I understand those are a separate design call. I'm only trying to know whether the creation/archive asymmetry is the intended end state, because that determines whether flattening to
mobile-refresh-tokenis a workaround I should keep maintaining or the permanent answer.fix(changes): report a change nested in a namespace folder (Fission-A…