You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This is one proposal with several separable design decisions. They're numbered I–X below so any of them can be argued with, accepted, or rejected on its own — several are ones I'd expect to lose, and I've said which.
Problem
We wanted CI to enforce that changes actually get archived, because without enforcement specs/ silently drifts from shipped reality — someone merges a change, forgets the archive step, and the living specs quietly stop describing the system. But the property we wanted to assert is violated by design for the entire life of an open PR: the change is sitting in changes/, unarchived, precisely because it isn't finished yet.
So a CI check for "nothing is left unarchived" is red as its resting state — red on every PR, from the first commit to the last, as the normal condition of doing work. This is noise during the PR workflow.
That leaves only bad options:
Ship a permanently-red pipeline that everyone immediately learns to ignore — worse than no gate, since it also masks real failures.
Approximate it with a blocking manual job that never gets played until the end. Same permanent-red problem wearing a different hat: the pipeline is incomplete by design for the whole review. There is also nothing to trigger it on — GitLab has no approval event (gitlab-org/gitlab#375908; CI_MERGE_REQUEST_APPROVED is evaluated before the pipeline exists), and reset_approvals_on_push invalidates the obvious workaround.
Use a bot that maintains a review comment as a blocking condition, so you get a check without a red pipeline (on GitLab, via the all-discussions-resolved merge condition). This is what we do now. It works, but the enforcement lives outside CI in a bot that has to model the lifecycle itself.
Archive early inside the PR to make the check pass — at which point review feedback invalidates the fold, and there is no unarchive to back it out (#1332's class: re-archiving isn't a no-op). Even with one, it's a weird workflow.
None of these is a CI problem, which is why none of them has a CI solution. The cause is that archive does two unrelated jobs in one command: a state transition (declaring a change shipped) and a text merge (folding deltas into specs/). Encoding the transition as a directory move welds the merge to a single moment in the PR lifecycle — and on a team with review, that moment doesn't exist. The team-workflow docs offer both conventions and say "pick one and be consistent", which is a choice of costs rather than an answer.
The fix was to make the check conditional on the change's own claim: not "is everything archived?" but "does anything claiming to be shipped still have unfolded deltas?"
A proposed change passes for free, so green is the resting state and red means a real mistake. That only works if a change can declare its state as data instead of encoding it in a directory position — which is what the rest of this proposal does.
#1647 is the same diagnosis arriving from the other direction: a first-time user finding that "sync at archive time" creates rather than reconciles, and that the archive prompt makes a non-choice look like a choice. One command doing two jobs shows up as CI friction for a team and as confusion for a newcomer.
The shape of the proposal
# openspec/config.yamllifecycle: archive # default — unchangedlifecycle: status # experimental — state is data, nothing moves
One line. Merges trivially, needs no special moment, corrected by editing rather than by an inverse operation that doesn't exist.
II. The state set is closed at two, and each state has a machine consequence
shipped — "these deltas belong in specs/". What sync folds and what the gate checks.
proposed — "this change holds a live claim on the requirements it touches". What overlap and drift tooling can reason over without inferring liveness from a path.
A state with no attached consequence is a comment. An applied state was prototyped and dropped: "implementation done" is already recorded by tasks.md checkboxes, and a duplicate record drifts from the original. Further states are possible — abandoned would release the live claim — but each should earn its place with a consequence.
Deliberately not this axis: capability maturity (experimental, beta, deprecated, …) describes requirements in specs/, not changes, and belongs there as user-defined semantic labels edited by ordinary changes. Different axis, different proposal.
III. The merge becomes a standalone, idempotent sync
Run any time, safely late, twice, or not yet. The seam half-exists already — sync is "merge without archiving" — but archive's merge is welded to the move and neither half is independently reachable (#1279: the merge is "reachable today only insidearchive or via the agent-driven /opsx:sync").
IV. Folded-ness is decided by regeneration, not bookkeeping
A change is in sync when re-applying its delta to the current spec produces byte-identical output. No lockfile, no hash sidecar, no timestamp — nothing that can rot.
The important consequence: --check and the fold run the same code, differing only in whether the rebuilt bytes get written. A checker that reimplements the doer eventually disagrees with it, which is precisely #1112 — validate accepting deltas archive then refuses.
Cost, measured rather than asserted. The gate re-verifies every shipped change, so it scales with history rather than with the current diff. On this machine (node 22, warm cache, steady state where everything is already folded):
shipped changes
each on its own capability
all on one capability
50
47 ms
49 ms
200
140 ms
288 ms
400
~280 ms
853 ms
500
342 ms
—
The first column is linear, ~0.7 ms per change — a rounding error next to CLI startup. The second column is the honest one: when many changes pile onto a single capability, each rebuild re-parses and re-serializes a main spec that grows with history, so the work is quadratic. At 400 changes on one capability the spec is 69 KB and the gate takes 0.85 s.
I looked at fixing the asymptote and deliberately didn't, because the two available routes both cost more than they buy right now:
Verify per capability instead of per change — apply all of a capability's shipped deltas once and compare once. That's O(n), but "all of them, in what order?" is exactly the parallel-merge ordering question this proposal declares out of scope.
A cheap containment pre-filter with exact fallback — fast in the common all-folded case, but a positive pre-filter result has to imply byte-identical folding or the gate silently weakens. That needs its own correctness argument, which doesn't belong in the same change as the mode.
What I'd argue against is the obvious-looking fix: scoping by git diff. That would make the predicate depend on VCS history and on trusting a base commit, which is precisely the property decision V exists to avoid. It's defensible for the advisory pre-commit tier and wrong for the authoritative gate. Caching main-spec reads was also measured and rejected — 400 reads of a 69 KB file is ~20 ms of that 853 ms, so it buys about 2%.
So: fine today, with a known shape and a known ceiling. Worth revisiting when someone has a capability with hundreds of shipped changes — at which point the 69 KB spec file is arguably the bigger problem.
V. The gate is a tree predicate, so one command works at every tier
shipped ⇒ folded
This is the Problem section's conditional check, stated formally. It's a pure function of files on disk — no model, no network, no VCS history — so unlike "did archive run at the right moment?", it can be evaluated by anyone, on any tree, at any point in the review:
openspec sync --check # pre-commit · pre-push · CI — same command, same verdict
Hooks are advisory (--no-verify exists), so CI stays the authority for the tree-level property — with one inversion, in VII.
VI. ship restores atomicity as a convenience, not a mandate
openspec ship <change> sets the field and folds in one working-tree diff, so the commit that declares a change shipped is the same commit whose tree satisfies the gate. It's sugar over editing the field and running sync; never the only path.
VII. Atomicity across commits is enforced by hooks, not CI
Flipping in one commit and folding in a later one leaves intermediate commits failing the predicate. ship makes the atomic path the default; beyond that, pre-commit rejects authoring an incoherent commit (weakly — --no-verify bypasses it, and rebase/cherry-pick sequencer commits skip hooks), while pre-push can sweep the pushed range per commit.
This is the one property where hooks are primary rather than an advisory copy of CI: pipelines see only the head tree and are structurally blind to intermediate incoherence, which surfaces later in git bisect.
VIII. Layout: shard by creation date — and this is the decision I expect to lose
If nothing moves, changes/ accumulates. The prototype shards by a date assigned at birth: changes/2026/08/17-add-oauth/. Creation date specifically, because it can never change — sharding by shipped date would smuggle the move back in. Discovery reads both layouts by rule: YYYY/MM are shards to walk into, anything else is a change.
But #1367 answers this better. Its walkForLeaves decides change-vs-container by a leaf marker (.openspec.yaml or proposal.md present) rather than a naming convention parsed out of regexes, and domains carry meaning a calendar cannot. If both landed, a domain literally named 2026 would be ambiguous with a year shard — reason enough for only one to exist. My recommendation: if #1367 lands, drop the sharding in #1684 and replace this discovery with that walk. Decisions I–VII don't depend on which layout wins, only on nothing moving.
Worth flagging for #1367's author and reviewers, because it cuts the other way too: a large share of that PR is the archive move interacting with domains — buildArchivePath mirroring the domain tree into a second tree, findAllArchivedChangeIds enumerating it, assertProspectivePathContained walking a not-yet-existing destination for symlink escapes (the hardest code in the PR, needed only because archiving creates directories somewhere new), archive reserved as a domain name because two trees share a namespace, plus collision handling and two archive workflow templates. Under this mode none of that has anything to do. That's an observation offered, not a precondition attached.
#1648 (why is archive/ under changes/?) dissolves rather than getting a new answer.
IX. Migration is bidirectional, because an experiment must be leaveable
openspec migrate converts a legacy project in; openspec migrate --to archive converts it back out. Neither direction touches spec text — archive-mode specs/ is folded shipped reality, which is exactly what status mode maintains — so reversal is a pure relayout, covered by a round-trip test. The reverse direction refuses while any shipped change has unfolded deltas, reusing the gate's own verdict rather than reimplementing it.
An experiment users can leave is an experiment that can actually be removed. One thing the round trip can't preserve: a change shipped under status mode carries its creation date into an archive folder name where convention reads an archival date, because archive mode never recorded the other one.
Note this decision is entangled with VIII — most of the migration's bulk is layout mapping. If sharding goes, the migration shrinks to stamping fields.
X. Opt-in experimental flag, with an exit
After N releases, lifecycle: status either graduates (with a deprecation path for archive) or is removed. No permanent third workflow. Adoption costs a config line; existing changes/archive/ history keeps its folds in specs/, so the gate is green on day one.
What changes for existing projects
Nothing, unless they opt in. No lifecycle key — or an unreadable or unrecognized value — resolves to archive. Under archive mode sync reports there's nothing to gate and exits 0, ship refuses and points at openspec archive, list renders no lifecycle column, archive is untouched, and discovery returns exactly what it returned before for a flat tree.
One cosmetic change does reach archive mode: the generated spec skeleton's Purpose line no longer says the spec was "created by archiving", since a fold can now happen without one. That collides textually with #1671 / #1670, which add placeholder detection keyed on that same string — whichever lands second should reconcile it.
Commit topology becomes a choice
lifecycle: archive
lifecycle: status
Where bookkeeping lands
end of PR or post-merge main — fixed repo-wide
any commit or PR — per change
Declaration diff
folder move + specs/ rewrite
one line
Rides with the implementation commit
practically no — review feedback invalidates it
yes
Review iteration
un-archive → edit → re-archive
edit, re-run idempotent sync
Commits to protected main after merge
required (recommended convention)
never
Enforceable in CI
no — it's a timing condition
yes — it's a tree predicate
Fold diff reviewability
hand-checked
regenerable — reviews like a lockfile
Non-goals
Concurrent modification of the same requirement by two open changes — #1669, #1387, the parallel-merge plan. This changes when the merge may run, not how it merges.
Re-merging an edited delta after an early fold (#1332's residual class). Making fold-anytime first-class means that pre-existing window sees more traffic. The gate detects the state and fails closed rather than corrupting specs/; real repair needs base snapshots, for which sync is a natural recording point.
Git-derived shipped-ness. Git proves a change folder landed on a branch, not that the change was implemented. Status stays an explicit declaration; git facts can cross-check it, not replace it.
A shipped timestamp. The commit that flips the field records when, first-hand; a written date is a second, drift-prone source of truth. Teams wanting timestamps without git access want #1245's metadata, which this composes with.
ls as the answer to "what's active." It becomes openspec list --status proposed. Once state is data, the filesystem stops being the UI for state. This is the honest cost of the whole design, and why the mode is opt-in.
Prior art and related work
Directly complementary
#1245 — lifecycle timestamps as first-class metadata. status is the same kind of fact about the same object; independently adoptable, and the natural home for a shipped timestamp if one is ever wanted.
#1669 — overlap between open changes is invisible until one archives (parallel-merge Phase 1). The strongest argument for decision II: that issue needs to know which changes hold live claims, and today liveness is inferred from directory position. An explicit proposed is exactly that signal, arriving without any of the merge machinery.
#1387 (concord) — same shape: drift/overlap detection re-derives liveness from paths and must survive archive's move. .openspec.yaml is also a natural home for its base fingerprints.
#1456 — where phase configuration lives. lifecycle is in config.yaml on that issue's stated rule (project-specific rather than reusable-across-projects). If the outcome is that phase-shaped config nests under operations:, this should follow.
Answered or made moot
#1279 / #1288 (deterministic merge engine, closed unmerged) — the smallest slice of that idea: no engine, no baselines, just decoupling state from the move so a deterministic check has something to check. Decision V's framing is inherited from there.
#709 (git-mv archive) · #1192 / #1169 (archive ordering) · #409 (deterministic ordering for archived specs) — all concern properties of a move that no longer happens.
#682 / #704 (archive hooks) — hooks key on a state transition (on: proposed → shipped) rather than filesystem events.
Adjacent, worth reconciling before this lands
#1647 — if the outcome there is a rename (promote, graduate), this should adopt it rather than entrench sync. The naming isn't load-bearing; the decoupling is.
#1112 / #1113 — not fixed here, but decision IV is a direct response to that failure mode.
#1671 — the Purpose-placeholder text collision noted above.
Outside OpenSpec
fiberplane/drift — AST-anchored code↔doc binding. It needs stable paths (archive's move dangles anchors at exactly the moment a change ships) and a merge that round-trips anchors (a deterministic sync does; an LLM rewrite doesn't). A --verify-anchors corroboration of declared status against anchor evidence would be the deterministic slice of #880.
The general point: the interface is data (a field) plus a pure predicate (an exit code). That leaves both doors open — absorb these feature sets natively later, or let companions compose over the same field with the same contract. Today there's no landing spot for either: a companion must hook the archive flow, re-derive liveness from paths, and survive moves.
Questions
Is the specs/ = shipped-reality invariant (#1279's design notes) about the directory separation, or about the content of specs/ — which V would enforce more precisely?
Is decision V (sync --check as a deterministic, model-free gate) interesting on its own, independent of the rest?
Proposal:
lifecycle: status— record change state as data, not directory position (experimental)Implementation: #1684 · Working demo with real git history, hooks and CI: https://github.com/ixxie/openspec-status-demo
This is one proposal with several separable design decisions. They're numbered I–X below so any of them can be argued with, accepted, or rejected on its own — several are ones I'd expect to lose, and I've said which.
Problem
We wanted CI to enforce that changes actually get archived, because without enforcement
specs/silently drifts from shipped reality — someone merges a change, forgets the archive step, and the living specs quietly stop describing the system. But the property we wanted to assert is violated by design for the entire life of an open PR: the change is sitting inchanges/, unarchived, precisely because it isn't finished yet.So a CI check for "nothing is left unarchived" is red as its resting state — red on every PR, from the first commit to the last, as the normal condition of doing work. This is noise during the PR workflow.
That leaves only bad options:
CI_MERGE_REQUEST_APPROVEDis evaluated before the pipeline exists), andreset_approvals_on_pushinvalidates the obvious workaround.unarchiveto back it out (#1332's class: re-archiving isn't a no-op). Even with one, it's a weird workflow.None of these is a CI problem, which is why none of them has a CI solution. The cause is that
archivedoes two unrelated jobs in one command: a state transition (declaring a change shipped) and a text merge (folding deltas intospecs/). Encoding the transition as a directory move welds the merge to a single moment in the PR lifecycle — and on a team with review, that moment doesn't exist. The team-workflow docs offer both conventions and say "pick one and be consistent", which is a choice of costs rather than an answer.The fix was to make the check conditional on the change's own claim: not "is everything archived?" but "does anything claiming to be shipped still have unfolded deltas?"
A proposed change passes for free, so green is the resting state and red means a real mistake. That only works if a change can declare its state as data instead of encoding it in a directory position — which is what the rest of this proposal does.
#1647 is the same diagnosis arriving from the other direction: a first-time user finding that "sync at archive time" creates rather than reconciles, and that the archive prompt makes a non-choice look like a choice. One command doing two jobs shows up as CI friction for a team and as confusion for a newcomer.
The shape of the proposal
I. State is a field, not a location
One line. Merges trivially, needs no special moment, corrected by editing rather than by an inverse operation that doesn't exist.
II. The state set is closed at two, and each state has a machine consequence
shipped— "these deltas belong inspecs/". Whatsyncfolds and what the gate checks.proposed— "this change holds a live claim on the requirements it touches". What overlap and drift tooling can reason over without inferring liveness from a path.A state with no attached consequence is a comment. An
appliedstate was prototyped and dropped: "implementation done" is already recorded bytasks.mdcheckboxes, and a duplicate record drifts from the original. Further states are possible —abandonedwould release the live claim — but each should earn its place with a consequence.Deliberately not this axis: capability maturity (
experimental,beta,deprecated, …) describes requirements inspecs/, not changes, and belongs there as user-defined semantic labels edited by ordinary changes. Different axis, different proposal.III. The merge becomes a standalone, idempotent
syncRun any time, safely late, twice, or not yet. The seam half-exists already —
syncis "merge without archiving" — but archive's merge is welded to the move and neither half is independently reachable (#1279: the merge is "reachable today only insidearchiveor via the agent-driven/opsx:sync").IV. Folded-ness is decided by regeneration, not bookkeeping
A change is in sync when re-applying its delta to the current spec produces byte-identical output. No lockfile, no hash sidecar, no timestamp — nothing that can rot.
The important consequence:
--checkand the fold run the same code, differing only in whether the rebuilt bytes get written. A checker that reimplements the doer eventually disagrees with it, which is precisely #1112 —validateaccepting deltasarchivethen refuses.Cost, measured rather than asserted. The gate re-verifies every shipped change, so it scales with history rather than with the current diff. On this machine (
node 22, warm cache, steady state where everything is already folded):The first column is linear, ~0.7 ms per change — a rounding error next to CLI startup. The second column is the honest one: when many changes pile onto a single capability, each rebuild re-parses and re-serializes a main spec that grows with history, so the work is quadratic. At 400 changes on one capability the spec is 69 KB and the gate takes 0.85 s.
I looked at fixing the asymptote and deliberately didn't, because the two available routes both cost more than they buy right now:
What I'd argue against is the obvious-looking fix: scoping by
git diff. That would make the predicate depend on VCS history and on trusting a base commit, which is precisely the property decision V exists to avoid. It's defensible for the advisory pre-commit tier and wrong for the authoritative gate. Caching main-spec reads was also measured and rejected — 400 reads of a 69 KB file is ~20 ms of that 853 ms, so it buys about 2%.So: fine today, with a known shape and a known ceiling. Worth revisiting when someone has a capability with hundreds of shipped changes — at which point the 69 KB spec file is arguably the bigger problem.
V. The gate is a tree predicate, so one command works at every tier
This is the Problem section's conditional check, stated formally. It's a pure function of files on disk — no model, no network, no VCS history — so unlike "did archive run at the right moment?", it can be evaluated by anyone, on any tree, at any point in the review:
openspec sync --check # pre-commit · pre-push · CI — same command, same verdictHooks are advisory (
--no-verifyexists), so CI stays the authority for the tree-level property — with one inversion, in VII.VI.
shiprestores atomicity as a convenience, not a mandateopenspec ship <change>sets the field and folds in one working-tree diff, so the commit that declares a change shipped is the same commit whose tree satisfies the gate. It's sugar over editing the field and runningsync; never the only path.VII. Atomicity across commits is enforced by hooks, not CI
Flipping in one commit and folding in a later one leaves intermediate commits failing the predicate.
shipmakes the atomic path the default; beyond that, pre-commit rejects authoring an incoherent commit (weakly —--no-verifybypasses it, and rebase/cherry-pick sequencer commits skip hooks), while pre-push can sweep the pushed range per commit.This is the one property where hooks are primary rather than an advisory copy of CI: pipelines see only the head tree and are structurally blind to intermediate incoherence, which surfaces later in
git bisect.VIII. Layout: shard by creation date — and this is the decision I expect to lose
If nothing moves,
changes/accumulates. The prototype shards by a date assigned at birth:changes/2026/08/17-add-oauth/. Creation date specifically, because it can never change — sharding by shipped date would smuggle the move back in. Discovery reads both layouts by rule:YYYY/MMare shards to walk into, anything else is a change.But #1367 answers this better. Its
walkForLeavesdecides change-vs-container by a leaf marker (.openspec.yamlorproposal.mdpresent) rather than a naming convention parsed out of regexes, and domains carry meaning a calendar cannot. If both landed, a domain literally named2026would be ambiguous with a year shard — reason enough for only one to exist. My recommendation: if #1367 lands, drop the sharding in #1684 and replace this discovery with that walk. Decisions I–VII don't depend on which layout wins, only on nothing moving.Worth flagging for #1367's author and reviewers, because it cuts the other way too: a large share of that PR is the archive move interacting with domains —
buildArchivePathmirroring the domain tree into a second tree,findAllArchivedChangeIdsenumerating it,assertProspectivePathContainedwalking a not-yet-existing destination for symlink escapes (the hardest code in the PR, needed only because archiving creates directories somewhere new),archivereserved as a domain name because two trees share a namespace, plus collision handling and two archive workflow templates. Under this mode none of that has anything to do. That's an observation offered, not a precondition attached.#1648 (why is
archive/underchanges/?) dissolves rather than getting a new answer.IX. Migration is bidirectional, because an experiment must be leaveable
openspec migrateconverts a legacy project in;openspec migrate --to archiveconverts it back out. Neither direction touches spec text — archive-modespecs/is folded shipped reality, which is exactly what status mode maintains — so reversal is a pure relayout, covered by a round-trip test. The reverse direction refuses while any shipped change has unfolded deltas, reusing the gate's own verdict rather than reimplementing it.An experiment users can leave is an experiment that can actually be removed. One thing the round trip can't preserve: a change shipped under status mode carries its creation date into an archive folder name where convention reads an archival date, because archive mode never recorded the other one.
Note this decision is entangled with VIII — most of the migration's bulk is layout mapping. If sharding goes, the migration shrinks to stamping fields.
X. Opt-in experimental flag, with an exit
After N releases,
lifecycle: statuseither graduates (with a deprecation path forarchive) or is removed. No permanent third workflow. Adoption costs a config line; existingchanges/archive/history keeps its folds inspecs/, so the gate is green on day one.What changes for existing projects
Nothing, unless they opt in. No
lifecyclekey — or an unreadable or unrecognized value — resolves toarchive. Under archive modesyncreports there's nothing to gate and exits 0,shiprefuses and points atopenspec archive,listrenders no lifecycle column,archiveis untouched, and discovery returns exactly what it returned before for a flat tree.One cosmetic change does reach archive mode: the generated spec skeleton's Purpose line no longer says the spec was "created by archiving", since a fold can now happen without one. That collides textually with #1671 / #1670, which add placeholder detection keyed on that same string — whichever lands second should reconcile it.
Commit topology becomes a choice
lifecycle: archivelifecycle: statusspecs/rewritesyncNon-goals
specs/; real repair needs base snapshots, for whichsyncis a natural recording point.shippedtimestamp. The commit that flips the field records when, first-hand; a written date is a second, drift-prone source of truth. Teams wanting timestamps without git access want #1245's metadata, which this composes with.lsas the answer to "what's active." It becomesopenspec list --status proposed. Once state is data, the filesystem stops being the UI for state. This is the honest cost of the whole design, and why the mode is opt-in.Prior art and related work
Directly complementary
statusis the same kind of fact about the same object; independently adoptable, and the natural home for ashippedtimestamp if one is ever wanted.proposedis exactly that signal, arriving without any of the merge machinery..openspec.yamlis also a natural home for its base fingerprints.lifecycleis inconfig.yamlon that issue's stated rule (project-specific rather than reusable-across-projects). If the outcome is that phase-shaped config nests underoperations:, this should follow.Answered or made moot
on: proposed → shipped) rather than filesystem events.Adjacent, worth reconciling before this lands
promote,graduate), this should adopt it rather than entrenchsync. The naming isn't load-bearing; the decoupling is.Outside OpenSpec
syncdoes; an LLM rewrite doesn't). A--verify-anchorscorroboration of declared status against anchor evidence would be the deterministic slice of #880.The general point: the interface is data (a field) plus a pure predicate (an exit code). That leaves both doors open — absorb these feature sets natively later, or let companions compose over the same field with the same contract. Today there's no landing spot for either: a companion must hook the archive flow, re-derive liveness from paths, and survive moves.
Questions
specs/= shipped-reality invariant (#1279's design notes) about the directory separation, or about the content ofspecs/— which V would enforce more precisely?sync --checkas a deterministic, model-free gate) interesting on its own, independent of the rest?lifecycle: statusmode — state as data, deterministic gate, bidirectional migration #1684 drop its sharding now and wait on feat: support multi-level change domains and sibling archives #1367, or keep it as a self-contained prototype and reconcile later?lifecycleas a top-level config key, or nested underoperations:pending Proposal: decide where workflow phases (apply/archive/sync) are configured — schema.yaml, config.yaml, or both #1456?