schema_version: 4
prefix: QCTL
style:
timezone: "-03:00"
section_order: [queue, horizon, archive]
active: QCTL-014
queue:
- id: QCTL-014
title: Let a ledger declare whether rows are blank-separated
scope: qctl
outcome: fmt keeps one blank line between rows when the style asks for it, so a lost separator is repaired rather than found by eye.
blocked_by: []
acceptance:
- A style option decides whether rows in a list are separated by exactly one blank line, defaulting to leaving them as written.
- With it on, fmt inserts a missing separator and removes a doubled one; the first row of a list never gets one above it.
- The two separators repaired by hand in QCTL-013 would have been fixed by running fmt.
patch: blank-between-rows
- id: QCTL-037
title: Adopt the ctl-core 0.6 presentation defaults
scope: presentation
outcome: 'qctl output uses the shared default look: borderless records, the id role for row ids, pretty JSON, and an 80-column fallback in piped captures.'
blocked_by: []
acceptance:
- Row ids render through the id role, and paths stay tokens.
- Piped `qctl show` with no COLUMNS keeps every line within 80 columns.
- '`mise run verify` passes.'
- Cargo.toml pins ctl-core =0.6.3.
links:
- https://github.com/victor-software-house/ctl-core/tree/main/openspec/changes/choose-visual-identity
- id: QCTL-015
title: Write a ledger atomically so a crash cannot truncate it
scope: qctl
outcome: A verb that dies mid-write leaves the ledger as it was, not half of it. The file is written beside itself and renamed over the original, so a reader sees one version or the other.
blocked_by: []
acceptance:
- Every write goes through a temp file in the ledger's own directory, then a rename.
- A test proves the original survives when the write fails after the temp file exists.
- No verb calls fs::write on the ledger path.
- id: QCTL-016
title: Name the next row a ledger can actually start
scope: qctl
outcome: 'qctl answers what to work on next, instead of a human reading status and applying blocked_by in their head. One ledger first: the earliest queued row whose blockers are all archived.'
blocked_by: []
acceptance:
- qctl next prints one id, or says why nothing is startable — every queued row blocked, or the queue empty.
- It agrees with active when active is set and startable, and disagrees loudly when active names a row whose blockers are still queued.
- A scenario in tests/fixtures covers a queue whose head is blocked and whose second row is not.
- id: QCTL-017
title: Decide whether a served include pins a tag or a commit
scope: cross-repo
outcome: Every consumer's task include names the same kind of ref, on purpose, and the reason is written down where the include is generated.
blocked_by: []
acceptance:
- 'The decision is recorded in docs/served-files terms: a tag is ours and never moved, or a SHA because a tag can be.'
- If SHAs win, the served-file template renders the tag's commit and every consumer moves in the same slice.
- If tags win, the reasoning is in the repo, so the next review that raises it is answered by a link rather than an argument.
- id: QCTL-018
title: Stop the example catalog declaring a second qctl pin
scope: qctl
outcome: 'examples/mise.toml shows the shape consumers actually use: the include alone, with the served task carrying the version.'
blocked_by: []
acceptance:
- "The mise.toml template drops its [tools] entry, and the rendered comment says the include ref is the only pin — whatever kind of ref QCTL-017 settles on."
- README says the same thing.
- id: QCTL-020
title: Let a row wait on work in another ledger
scope: qctl
outcome: A blocked_by entry may name a foreign prefix, and check treats it as a dependency it deliberately cannot resolve rather than an error. No registry, no sources — the dependency is data, and this is the half that is.
blocked_by: []
acceptance:
- A foreign prefix in blocked_by passes check, and status shows the row as blocked with the foreign id named.
- A same-prefix id that does not exist still fails, exactly as it does today.
- status says which foreign ids a row waits on, without claiming to know whether they are done.
- 'qctl instructions states the rule: a foreign prefix is recorded, never resolved, until QCTL-019 decides how resolution works.'
- "Which prefixes a ledger may name is declared in tasks.yaml, beside the rows — it is data about the work, so this phase adds no second file."
- id: QCTL-021
title: Style says how a scalar is quoted
scope: qctl
outcome: >-
A ledger declares its quoting the way it declares its indent, so one
file writes one fact one way whoever wrote the row.
blocked_by: []
acceptance:
- "`style` carries the option, defaulting to what the serializer does today, so an existing ledger is unchanged until it asks."
- "`fmt` rewrites a scalar that disagrees with the declared style, and `fmt --check` fails it, so a mix cannot survive a hook."
- Every writer honours it, so a row a verb writes and a row fmt rewrites come out identical.
- A test asserts the shape on a written row rather than on a hand-built string.
notes:
- >-
2026-08-19, raised on verctl#46 and corrected here after reading the
code rather than the symptom. The first write-up called this qctl
disagreeing with itself. It is not: every stamp qctl has ever written is
unquoted, because `yaml_serde`'s emitter decides, and CTC's writer row
already stated that intent — "the serializer quotes what needs quoting".
The mix in verctl's ledger came from a foreign writer, the QCTL-011
recovery script, which wrote `completed: "…"` as literal text.
So the gap is a missing option, not a bug, and the code already says so:
`format.rs` names "row key order and quote style" as the obvious next
options, deliberately not decided, and `document.rs` disclaims deciding
"how to quote a value". `style` has five options — timezone,
section_order, indent, archive_order, normalize_on_write — and none is
about scalar style. `check` cannot see quoting at all, because it
validates the parsed value, where both forms are the same String.
Nothing is broken today: every reader we ship takes an unquoted ISO-8601
scalar as a string. The risk is a reader that does not — a YAML 1.1
resolver takes it as a timestamp node while the `$schema` line says
string. Do this with row key order, since format.rs pairs them, and
leave the default at today's behaviour so no ledger moves unasked.
- id: QCTL-023
title: check says which rules it enforces
scope: qctl
outcome: A consumer deleting a hand-rolled validator can prove qctl covers it by reading a published inventory, instead of building defect fixtures for an hour.
blocked_by: []
acceptance:
- check --explain prints every rule, its exact message, and which lists it reads, then exits 0.
- The inventory is machine-readable, so a consumer can diff it against their own rule set.
- It is generated from the checks themselves, so it cannot drift from them.
notes:
- >-
docs/adoption-report.md §1 — they call it the cheapest item on their
whole list and the most valuable for adoption, because it is a document
rather than code. They proved `check` subsumes their 209 lines of Rego
and Bun policy by writing ten defect ledgers by hand, one per rule, and
running each through `qctl check --no-git`. All seven of their Rego deny
rules and both halves of their script matched, and qctl was strictly
wider: their script read queue and archive only, so a horizon row's
`plan:` went unchecked. Their point is that the hour they spent is the
hour every migrating consumer faces, and most will skip it and either
keep both validators forever or delete on faith. `qctl check --help` at
HEAD offers only --file and --no-git.
- id: QCTL-027
title: One problem is reported once
scope: qctl
outcome: A ledger with ten problems prints ten lines, each readable in a terminal.
blocked_by: []
acceptance:
- check prints one line per problem and a bail that counts them, not both.
- The --no-git help text names the scratch-file case that makes it necessary.
notes:
- >-
docs/adoption-report.md §5. src/check.rs:40 prints each problem on
its own line, then line 42 bails with all of them joined — twenty lines
for ten facts, and the joined form is the one that wraps badly. Keep the
per-line output and reduce the bail to the count and the path.
- id: QCTL-028
title: The example catalog shows a whole row of each kind
scope: qctl
outcome: Somebody writing a row by hand copies a populated example instead of learning the schema from error messages.
blocked_by: []
acceptance:
- examples/tasks.yaml carries one maximal QueuedTask, one ArchivedTask and one HorizonTask.
- A test parses it, so it cannot drift from the types.
notes:
- >-
docs/adoption-report.md §6. Writing their first horizon row by hand,
their first attempt used `evidence: Shipped.` on an archive fixture. The
error was good and specific — `/archive/0/evidence: "Shipped." is not of
type "array"` — but they found it by trial. `qctl init` writes an empty
ledger, and examples/ at HEAD holds only mise.toml. They name verctl's
examples/ver.yaml plus tests/examples.rs as the pattern to copy and call
it the best thing in verctl's documentation. Distinct from QCTL-018,
which is about the catalog declaring a second qctl pin.
- id: QCTL-030
title: Let a verb update fields on an existing row
scope: qctl
outcome: Notes, acceptance, title, scope, outcome, plan, and links on a queued or horizon row can be changed without splicing YAML.
blocked_by: []
acceptance:
- qctl edit ID rewrites only the named fields on that row and leaves unrelated rows byte-identical.
- Notes append without replacing existing items; notes can be removed by index or exact text and reordered by one permutation.
- A missing id fails; archive rows accept only note, evidence, and link operations; id and completed are never editable.
- Queue positioning is one parameter and refuses any move that would place a row before its blocker or a dependent before the edited row.
- Help, instructions, the bundled skill, fixtures, and snapshots describe the same edit and park grammar.
notes:
- 'Operator, 2026-08-26: how do we just update metadata on an existing task? add --notes is create-time only. Do not cover this with a second ledger skill or a blessed YAML splice. Document::set already patches a top-level scalar; the verb needs the same surgical rewrite on a row.'
- '2026-09-12 recovery: preserved the inherited dirty implementation without reset, restore, stash, or clean; tracked diff SHA-256 3a353b12517300ba40f217de3d8f8641953050d574e47f8ae6333eff30faae1d.'
- '2026-09-12 operator decisions: both releases are patch bumps; edit collapses position and removal flags; Rust code follows nested feature modules; qctl is reviewed as a PR stack.'
plan: goals/row-edit/plan.md
horizon:
- id: QCTL-019
title: Resolve a foreign blocker instead of only recording it
scope: qctl
kind: evaluation
open: >-
QCTL-020 lets a row say it waits on VER-025 and stops there, on purpose.
Reading that row — is it archived yet — means fetching another repository
at a pinned ref. Is that worth building, and is a stale answer from a pin
better than no answer?
outcome: >-
A decision, written down, on what a source is, where it is declared, and
what reading one costs.
notes:
- >-
2026-08-19, from migrating six owned ledgers by hand in one pass, of
which three are private. What the pass actually taught:
Cross-repo order is real and invisible. QCTL-003 waited on QCTL-009 in the
same ledger and that was easy to see; verctl VER-025 will rewrite the
config this repo just gained, and nothing anywhere records that. A foreign
blocked_by is the first thing worth having.
Prefixes already carry the repository. Every one of the six is unique,
and this ledger already reserves the rule that a foreign prefix in
blocked_by is not waited on.
2026-08-19, read from a package manager's own source rather than its docs.
The donor considered first treats a git source as always a **whole
repository**: it refuses a path that is not exactly user/project, and its
source type carries no subdirectory at all, because selection inside a
package is done by globs in that package's own manifest. Protocol is gated
deliberately — without a scheme prefix the string must already name one —
and the prefix is what buys the host shorthands. A ref is `@ref`.
So it is the wrong donor for the path half. A ledger is a file inside a
repository, and the syntax that says protocol, subpath, and ref at once is
the go-getter form this repo's own task includes already use:
`git::https://github.com/o/r.git//tasks/q?ref=v0.1.2`.
2026-08-19, decided by Victor: a source is structured fields, not a URI.
`repo` is parsed by gix-url — https, ssh, scp-like and file all parse, and
its Scheme carries the protocol distinction the donor spends a prefix on —
`dir` and `ref` beside it. gix-url derives serde, so the config
deserializes without a line of parsing. The shorter-syntax question
dissolves rather than being answered: a URI is long because it packs four
facts into one string, and packing them is also what forces a parser we
would then own. PURL was the runner-up — the only one-string form with a
real spec and a maintained crate (packageurl 0.7.0, `#subpath` first
class) — and it loses on exactly the protocol point, because ssh and
self-hosted are qualifiers there, not types.
Decided with it: resolution reads a pinned ref, never a checkout. A working
tree can be dirty, on a branch, or behind, so reading one would reintroduce
the drift a pin exists to remove. A foreign row's state is its state at the
ref this repo names.
Where it goes: `.ctl/q.yaml`, settled on verctl VER-025 where the name was
being chosen — one `.ctl/` directory, one file per tool, named after the
task an operator runs rather than the crate that reads it, with served-file
templates shared at `.ctl/templates/`.
What anything local does earn, and it is not a registry: caching. A
user-level SQLite store keyed by the exact commit SHA a ref resolved to —
pinned to the SHA, not the ref, so it can never answer for a tag that
moved, and so two repos naming the same commit read it once. That is the
cache side of the line below, and the only local state this design wants.
One red line, so it does not get relitigated: a database may be a derived
cache and never the authority. The record reviews as a diff and orders by
file position, and both are gone the moment rows live in SQLite. An index
rebuilt from the files costs nothing, because deleting it changes no
answer.
archive:
- id: QCTL-036
title: Adopt isolated Surface model feature
scope: qctl
outcome: qctl uses ctl-core surface-model for its short-option test without enabling MiniJinja in any qctl dependency graph.
notes:
- Stacked above QCTL-035 so the cache mitigation and dependency-isolation adoption merge and release together.
completed: 2026-09-12T23:49:18
evidence:
- 'qctl 3fe13db: ctl-core 0.5.2 surface-model replaces surface in tests; MiniJinja is absent from normal and all-target trees; short-option test, full verify, schema stability, ledger check, and two patch fragments pass.'
disposition: completed
- id: QCTL-035
title: Silence rust-cache package cleanup noise
scope: qctl-ci
outcome: The publish job removes only disposable Cargo package staging before rust-cache cleanup, avoiding false ENOENT annotations without changing cache keys, scopes, targets, or save policy.
notes:
- >2-
Swatinem/rust-cache 2.9.2 issue #193: its tests-profile cleanup starts un-awaited scans of absent target and trybuild children, so try/catch cannot suppress their rejected promises.
completed: 2026-09-12T23:30:43
evidence:
- https://github.com/victor-software-house/qctl/pull/66
- 'qctl 7e506a8: workflow YAML, release-equivalent Cargo package staging, cache-preservation sentinel, repository ledger, and CI pass; cache configuration is unchanged.'
disposition: completed
- id: QCTL-034
title: Adopt released ctl-core contracts
scope: qctl
outcome: qctl pins ctl-core 0.5.1, inherits its automatic width policy, and verifies short options through Surface without adding surface-only dependencies to the release binary.
notes:
- Final adoption slice above the reviewed qctl stack, unblocked by the published ctl-core 0.5.1 patch release.
completed: 2026-09-12T22:51:16
evidence:
- https://github.com/victor-software-house/qctl/pull/64
- 'qctl d772827: ctl-core 0.5.1 is pinned with surface test-only; the shared short-option contract, production dependency exclusion, full verification, schema stability, repository ledger, and CI pass.'
disposition: completed
- id: QCTL-032
title: Validate one gapless global task corpus
scope: qctl
outcome: Queue, horizon, and archive partition one globally unique, gapless task-id sequence.
notes:
- 'Operator direction, 2026-09-12: id continuity and uniqueness apply to the entire task corpus, never independently within queue, horizon, or archive.'
- QCTL-007 was restored from commit history as a dropped tombstone because commit e5e48b removed the duplicated daemon proposal after ctl-core CTC-003 became its owner; the id was not reused.
completed: 2026-09-12T18:32:08
evidence:
- https://github.com/victor-software-house/qctl/pull/61
- 'qctl b3389ec: global gap, duplicate, status-partition, bounded-id, instructions, schema, and graph coverage passed mise run verify and PR CI/Kody with zero open findings.'
disposition: completed
- id: QCTL-033
title: Write readable structured note items
scope: qctl
outcome: Every note remains one list item with intentional line and paragraph breaks readable in YAML.
notes:
- 'Operator direction: notes are lists, and each repeated -n is one item.'
- Intentional lines and blank paragraph separators must survive readback; qctl does not insert mechanical sentence wraps.
completed: 2026-09-12T18:02:14
evidence:
- https://github.com/victor-software-house/qctl/pull/62
- 'qctl ba09083: readable ordered notes preserve content and formatting boundaries through add, edit, and fmt; focused tests, mise run verify, and repository-ledger check passed on macbook-portable.'
disposition: completed
- id: QCTL-031
title: Define acceptance as observable closure truth
scope: qctl
outcome: Every qctl surface teaches acceptance as demonstrably true end conditions, while plans and notes carry implementation method.
notes:
- 'Operator follow-up, 2026-09-12: make acceptance semantics unmistakable without changing the existing demonstrably-true schema contract.'
completed: 2026-09-12T16:41:25
evidence:
- https://github.com/victor-software-house/qctl/pull/60
- 'qctl 04509b1: help, instructions, skill, README, and schema state observable closure conditions; focused tests and mise run verify pass on macbook-portable.'
disposition: completed
- id: QCTL-022
title: The bundled skill says which release it describes
scope: qctl
outcome: A reader can tell whether skills/qctl/SKILL.md predates the qctl they hold, without leaving the file.
notes:
- >-
2026-08-20, from the bundle contract written down as the
`ctl-bundle-contract` skill (dotfiles b944acf9). A ctl CLI ships a usage
skill, an installed instruction sheet and docs beside the binary, and the
skill is vendored into the global set at `latest-release` — so the text a
consumer loads is a released snapshot they cannot date from the outside.
verctl did it first in verctl#50 and that is the shape to copy: a
`patterns` entry matching `version: {version}` at arity `once`, plus a
`pins` entry naming `skills/qctl/SKILL.md`. Not a template — the served
files doc reaches for a template only when a file can be generated, and a
skill is prose. Confirm no other `version: ` line exists in the document
first, since the match is literal and the arity would fail the release.
completed: 2026-08-24T00:49:59
evidence:
- https://github.com/victor-software-house/qctl/pull/55
disposition: completed
- id: QCTL-029
title: status and check speak JSON
scope: qctl
outcome: An agent reads the ledger state without parsing a table that is free to change shape.
notes:
- >-
docs/adoption-report.md §7. They read status and check with agents
far more often than with humans. This is qctl-only: verctl already has a
global --format json, measured at HEAD on status, check, ci and assets —
and `git -C ../verctl show v0.1.1:src/cli.rs` carries `FormatArgs` too,
so it was already there in the verctl release they read and they missed
it rather than reading an old tool. Which is itself the argument for QCTL-023: a feature
nobody can find is a feature nobody has. Spell it the way verctl does, so
one convention covers the family.
completed: 2026-08-24T00:49:59
evidence:
- https://github.com/victor-software-house/qctl/pull/55
disposition: completed
- id: QCTL-008
title: Adopt ctl-core in qctl and forkctl
scope: cross-repo
outcome: >-
Qctl and forkctl return typed reports through ctl-core App and Present.
No consumer owns a second help, stream, color, width, table, or JSON path.
notes:
- >-
Forkctl shipped on ctl-core 0.4.1. Qctl's established -f/--file exposed
the missing reusable composition, so ctl-core 0.4.2 added FormatLong
before qctl pinned it and deleted every domain print path.
completed: 2026-08-24T00:49:58
evidence:
- https://github.com/victor-software-house/qctl/pull/55
disposition: completed
- id: QCTL-026
title: A check that cannot run says so instead of passing
scope: qctl
outcome: No qctl gate reports success by having skipped itself.
notes:
- >-
docs/adoption-report.md §4. `trailer_errors` (src/check.rs:50) does
`let Ok(closed) = trailers::closed_ids(root) else { return Vec::new() }`
— an environment where the git call fails drops a whole check class and
the run passes quietly. The discoverability half is the same shape from
the operator's side: validating a ledger that lives outside the current
repository — a fixture, a scratch copy, a migration staging file — runs
the trailer scan against whatever repo surrounds it, and nothing points
at --no-git until the odd complaints start. verctl carries the same law
as VER-036, for `expand` and `prove_default_history`.
completed: 2026-08-23T12:36:32
evidence:
- https://github.com/victor-software-house/qctl/pull/51 squash-merged as d8bcb56
disposition: completed
- id: QCTL-006
title: Archive from commit trailers in a pre-push hook
scope: qctl
outcome: >-
A merged or pushed commit that closes a ledger id archives that
row. An active id cannot sit on main after its close trailer landed.
notes:
- >-
check now fails if a Closes/Completes trailer names a still-queued
id. Auto-archive of tasks.yaml still needs QCTL-002. VER-003 went
stale because check did not look at git and no commit had a trailer.
completed: 2026-08-23T12:36:32
evidence:
- https://github.com/victor-software-house/qctl/pull/48 squash-merged as 4aee207
disposition: completed
- id: QCTL-024
title: Horizon has verbs, so a row is not hand-edited into place
scope: qctl
outcome: Moving a row between horizon and queue is one command that respects the invariants, not four hand edits.
notes:
- >-
docs/adoption-report.md §2. instructions.md is already upfront that
horizon is edited by hand until these verbs exist; they hit it on day
one, and the hand edit is the thing the same document tells agents not to
do. Worth keeping from their write-up: they call the HorizonTask schema
shape *good* and ask us not to change it. Having no acceptance and no
blocked_by mechanically forced them to split one vague request into a
queue row with provable acceptance and a horizon row with a named
blocker, which they say they would not have split as cleanly on
judgement alone. promote is the verb that matters more than park:
horizon to queue is four hand edits with the active invariant to respect.
completed: 2026-08-22T14:18:45
evidence:
- park writes a horizon row from title, scope, outcome, kind and open.
- promote moves a horizon row onto the queue tail, dropping kind and open, and leaves active alone.
- instructions.md no longer says horizon is edited in the yaml.
disposition: completed
- id: QCTL-025
title: add writes the row you meant, where you meant it
scope: qctl
outcome: A row added by the verb needs no hand edit afterwards, and lands where its priority says.
notes:
- >-
docs/adoption-report.md §3, filed together because both halves are
the same verb falling short of the schema. `AddArgs` (src/cli.rs:85) is
title, scope, outcome, acceptance and patch; QueuedTask also has notes,
blocked_by, plan and links. Every row they added needed a hand edit
straight after `add`, always for notes, which is where their conventions
put the evidence — so the verb bought them the id and the placement and
they edited the file anyway. Their reasoning for why blocked_by is safe
to accept — a blocker must be earlier, and `add` appends to the tail, so
any queued id qualifies — holds only while the tail is the only landing
place. Placement is the other half of the ask: queue order is priority,
so anything urgent means a hand edit or an immediate `start`, which
forces it to the head and makes it active, usually wrong. Take both and
the two halves collide: `add --before X --blocked-by X` would write a row
ahead of its own blocker, and `graph_errors` (src/ledger.rs:196-200)
rejects it as "is not earlier" — the verb writing a ledger its own check
refuses. So the blocker is validated against the resolved insertion
index, not against mere membership. Confirmed while filing this very row:
seven rows added, seven notes hand-edited in afterwards.
completed: 2026-08-22T14:08:34
evidence:
- add writes notes, blocked_by, plan, and links.
- before and after place the row; a blocker that would not sit earlier is refused.
- horizon writes a horizon row.
disposition: completed
- id: QCTL-004
title: Point consumers at qctl check instead of copied Ajv tests
scope: cross-repo
outcome: >-
Every owned consumer calls qctl check. Copied
test/tasks-ledger.test.ts files can die once the pin is real.
notes:
- >-
2026-08-19: three of four lines are met. Two private consumers deleted
their vendored tasks.schema.json — one with its Ajv spec, one with the
check-jsonschema tool that only existed to run it — and both now gate on
`q check` from `?ref=v0.1.2`; ctl-core#9 and verctl#41 moved their
$schema URLs to the same release. What is left is a third private ledger,
whose change is open and deliberately not merged by us — that ledger
churns, so the PR carries the recipe to regenerate rather than a rebase.
The fourth line names an upstream engagement and needs a decision rather
than work: QCTL-011 settled that those ledgers are not ours, which
contradicts this line. Either that seed is a different thing than those
ledgers, or the line should go.
completed: 2026-08-19T22:24:32
evidence:
- 'Two private consumers deleted their vendored tasks.schema.json, one with its Ajv spec and one with the check-jsonschema tool that only ran it, the latter keeping Conftest for its own graph policy and plan-path rule. Both now gate on q check from ?ref=v0.1.2. ctl-core#9, verctl#41 and a third private ledger moved their $schema URLs to that release. No owned consumer vendors a schema or an Ajv test. The upstream line was dropped rather than met: that repo is not ours.'
disposition: completed
- id: QCTL-011
title: Migrate the five other owned ledgers to schema 3
scope: workspace
outcome: Every ledger under this operator's own repositories declares schema_version 3, stamps completion with a moment, and declares the zone that moment is in.
notes:
- >-
2026-08-19: four of five migrated — ctl-core#9, verctl#41 and two private
ledgers, each with a style block naming -03:00 and every archived row
carrying an instant recovered from git. The third private one is open and
ours to leave alone; its stamps were placeholders reading 12:00:00, now
real instants in that PR.
Two corrections to this row's own wording, both learned by doing it. The
moment is the commit in which a row **last** entered the archive, not the
first: one ledger had three rows archived, re-queued, and archived again,
and the first commit dates a stay that ended. And the day gate cannot be
absolute — three rows entered the archive at 00:21 on 2026-08-18 while
saying 2026-08-17, the day the work finished. The commit instant is what
`completed` means, so those three were accepted by name rather than by
loosening the rule for everything.
completed: 2026-08-19T22:24:20
evidence:
- 'All six owned ledgers speak schema 3 — three public (ctl-core#9, verctl#41, this one) and three private — each with a style block naming -03:00 and every archived row carrying the instant it last entered the archive, recovered from git. 88 rows across five repos; the day gate held everywhere except three rows that entered the archive at 00:21 and were accepted by name. Two upstream ledgers that are not ours were left alone.'
disposition: completed
- id: QCTL-007
title: Optional per-user daemon sharing one session and view
scope: ctl-core
completed: 2026-08-19T21:13:45
outcome: The duplicate qctl daemon proposal is removed; ctl-core CTC-003 owns the evaluation.
evidence:
- commit e5e48b removed the qctl row after the same evaluation moved to ctl-core CTC-003.
disposition: dropped
notes:
- >-
Restored as a historical tombstone when qctl began enforcing a gapless
global id corpus. This does not revive the proposal or reuse the id.
- id: QCTL-003
title: "Publish native qctl so mise github: can install it"
scope: release
outcome: >-
A tagged release ships darwin-arm64 and linux-x64 binaries. The
tasks/q catalog pin is a real immutable ref.
notes:
- >-
2026-08-17: the lane exists — CI, Version PR, publish, and both
tarballs, all through released verctl, same shape as ctl-core.
0.0.1 was cut by hand from this workstation, which is why it
shipped macos-arm64 only.
2026-08-18: 0.0.2's first Version PR bumped Cargo.toml and left
Cargo.lock at 0.0.1, so Verify's `cargo package --locked` hit a dirty
tree. Fixed by declaring `[prepare]` here and moving to verctl 0.0.3,
which is the release that commits `[prepare].stage` files. The nscloud
note is withdrawn, not deferred: this repo is public, so
macos-latest/ubuntu-latest are the runners it wants, and verctl 0.0.3
makes them a default rather than a hardcoded map.
2026-08-19, corrected: 0.0.2 did ship after that fix. Its release
carries qctl_0.0.2_linux_x64.tar.gz and qctl_0.0.2_macos_arm64.tar.gz
alongside tasks.schema.json, so the first acceptance line is already
met and the earlier "never shipped" note was stale. What is left is
proving a clean consumer can install and check against a real tag.
examples/mise.toml does exist, and said `?ref=v0.0.1` — correcting that
is QCTL-009's subject, which is why this row now waits on it: a tag
whose task file installs 0.0.1 cannot prove the last line.
completed: 2026-08-19T16:12:33
evidence:
- v0.1.1 ships qctl_0.1.1_macos_arm64.tar.gz and qctl_0.1.1_linux_x64.tar.gz, examples/mise.toml at that tag pins 0.1.1, and a scratch consumer holding only that file ran mise install, then mise run q -- init and check ok against qctl 0.1.1 fetched from the tagged include.
disposition: completed
- id: QCTL-009
title: Declare this repo's served files so its tag stops lying
scope: qctl
outcome: >-
Every ref a consumer fetches out of a qctl tag names that tag's
version, without anybody bumping five files by hand.
notes:
- >-
2026-08-18, verified against the published tag: `git show
v0.0.2:tasks/q/q` still says `"github:…/qctl" = "0.0.1"`, and
examples/mise.toml and README in that tree say `?ref=v0.0.1`. So the
current release ships a task file that installs the previous release —
the exact failure verctl VER-017/VER-018 were built to end, live in this
repo's latest tag. Five sites, counted: the [tools] pin and the ?ref= in
examples/mise.toml, the `#MISE tools` header at tasks/q/q line 5, and
the ?ref= plus the install hint in README. They were hand-bumped once as
a bridge and drifted again, which is the argument for declaring them
rather than remembering them. Split out of QCTL-003, which was about
publishing native binaries and got that done — this is the part that
never landed, and it is unblocked now that verctl 0.0.4 exists.
2026-08-19: declared. `.verctl/templates/q.jinja` and
`mise.toml.jinja` render `tasks/q/q` (0755) and `examples/mise.toml`,
README's two spellings are `[patterns.catalog]` and
`[patterns.install]` at arity once, and the release lane runs verctl
0.0.4. Proven locally in a clone: `verctl prepare` renders all three
files byte-identical to the committed ones with only 0.1.0 → 0.1.1
moved. One thing worth knowing: a template git does not track renders
nothing — the dry run listed README alone until the templates were
staged. The last line waits on the 0.1.1 tag.
completed: 2026-08-19T16:12:33
evidence:
- Rendered from .verctl/templates and declared as [patterns]; verctl 0.0.4 rewrote all five sites onto the Version PR commit. git show v0.1.1:tasks/q/q names 0.1.1, mode 100755, and examples/mise.toml and README say v0.1.1 in that tree.
disposition: completed
- id: QCTL-013
title: Stop a cut row taking two separators with it
scope: qctl
outcome: Removing a row from between two others leaves those two separated the way they were.
patch: cut-separator
completed: 2026-08-19T02:03:57
evidence:
- 'qctl PR #18, squashed 5dc2467.'
disposition: completed
- id: QCTL-012
title: Declare a ledger's style, and add qctl fmt to apply it
scope: qctl
outcome: A ledger states its own formatting, qctl fmt applies it, and the verbs write new text in it.
patch: style-and-fmt
completed: 2026-08-19T01:46:55
evidence:
- 'qctl PR #17, squashed 91adda1.'
disposition: completed
- id: QCTL-010
title: Stamp completion with an instant, not a day
scope: qctl
outcome: An archived row says when it left the queue, to the second, in one shape that sorts.
patch: completed-instant
completed: 2026-08-19T01:06:14
evidence:
- 'qctl PR #16, squashed 408f0d0.'
disposition: completed
- id: QCTL-002
title: A mutation rewrites the lines it changes, not the file
scope: qctl
outcome: >-
add, start, and archive change only the bytes they must, so a ledger
edit is reviewable as a diff, two people can edit different rows
without conflicting, and the file still reads the way it was written.
notes:
- >-
2026-08-18, measured on the real thing rather than assumed. One
`qctl start VER-020` against a copy of verctl's 427-line tasks.yaml
changed 641 lines — every line in the file — and stretched the longest
line from 153 to 2158 characters. Six distinct losses, all from the
same cause: mutate.rs parses to serde_yml `Value`, which keeps values
and drops everything else, then serializes from scratch. Gone are
comments (line 1 `# yaml-language-server: $schema=…` included), blank
lines between rows, two-space sequence indentation, folded `>-`
scalars (flattened to single long lines), double quotes (rewritten to
single), and inline flow sequences (expanded to block lists). The
values themselves survive — `check` still passes on the output — so
this is entirely a writer problem, not a schema one.
The shortest demonstration needs two commands and no existing file:
`qctl init` writes the `# yaml-language-server:` header, and the first
`qctl add` deletes it. The tool destroys its own output on the first
verb after creating it.
Why it matters beyond taste: a whole-file diff cannot be reviewed, and
two people editing different rows conflict on every line. It also
blocks verctl VER-021, which installs a `$schema` line in every ctl
repo that qctl would delete on the next verb.
Until this ships, a comment in a ledger does not survive the next verb,
so nothing belongs beside a key that could live in notes instead.
The writer, chosen against the alternatives rather than assumed:
yamlpath + yamlpatch (zizmor's crates, MIT, 460k and 236k downloads,
tree-sitter-yaml spans and comment-preserving patch ops). Rust has no
mature toml_edit equivalent for YAML, so the field was searched and
each candidate tried against the real ledger.
Rejected, with the reason each was rejected. yaml-edit 0.2.3 (rowan
lossless CST, the closest analogue on paper) reported this repo's
seven-row queue as three rows, so remove-then-insert silently did
nothing, and it dropped the `# yaml-language-server:` line — the exact
byte the task exists to protect. yaml-rt, edikt-yaml, yaml-serde-edit
and fyaml all describe the right thing, including a typed-overlay
writer, but were published weeks ago and have double- to
triple-digit downloads; this tool is a dependency of every ledger in
the workspace. rust-yaml has adoption but no span or patch API.
Two gaps in the chosen pair are ours to close, both measured.
One: tree-sitter attributes a comment to the row *above* it, so
`removal_span` on a row swallows the next row's leading comment while
excluding its own. Reordering therefore takes the item's own span from
`query_exact` and applies one attribution rule of our own — a comment
line directly above a row, with no blank line between, belongs to that
row — with the parser still deciding what is a comment. YAML has no
formal comment ownership, so every round-tripping library invents this
rule; ruamel.yaml included.
Two: `Op::Append` refuses a flow sequence, so `add` on a
freshly initialized ledger (`queue: []`) needs its own path.
Verified for the writer, so it does not have to be discovered mid-build:
the queued row type serializes into that value type with its absent
fields skipped, so a generated row carries no `patch: null` and no empty
`links`, and the serializer quotes what needs quoting.
Two defects this verb rewrite also owns. A blocker resolves against the
queue alone, so archiving an id leaves every row that named it failing
`check` with "is not queued" — the verb has to drop the archived id from
the rows that named it, which is also why every blocked row in this
ledger would otherwise break the moment its blocker ships. And `archive`
is the one verb that never loads the ledger before editing it, so it
alone will edit a file whose values do not pass.
Also known and accepted: a generated row's nested sequence items come
out at the serializer's indentation, one level shallower than the
hand-written rows. That applies only to rows with no prior formatting
to preserve.
completed: 2026-08-18T23:00:41
evidence:
- 'qctl PR #15'
- 'one start on verctl''s 479-line ledger: 641 changed lines before, 67 after; longest line 235 both ways'
- 8 before/after fixtures in tests/fixtures/mutations, asserted as whole files
- '3 mutations bite: comment attribution 2, list indentation 6, blank-run cut 1'
- archive now clears the archived id from every blocked_by, in the style it was written in
disposition: completed
- id: QCTL-001
title: Own the ledger contract as Rust types via schemars
scope: qctl
completed: 2026-08-18T22:46:12
disposition: completed
outcome: >-
The work-queue shape is declared once, as Rust types. serde reads and
writes it, garde refuses the values a type cannot, and schemars
generates the committed JSON Schema from the same declarations.
evidence:
- "qctl main f35ccfd (#13), squashed from five commits"
- "src/schema.rs is the only statement of the shape; the hand-written schema stopped being source"
- "qctl schema regenerates schema/tasks.schema.json; a test fails when the committed file is stale"
- "12 suites, 45 cases; 4 mutations proved them: schema ignored fails 19, format-as-annotation fails 3, garde off fails both verb cases, one-pass off fails 1"
- "verctl and ctl-core ledgers still check ok against the generated schema"
- "the pinned versioned $schema URL is deliberately not here; QCTL-004 owns it"
- id: QCTL-005
title: Start the shared CLI crate (short/long, help, negations)
scope: cross-repo
completed: 2026-08-16T23:08:28
disposition: completed
outcome: >-
forkctl, qctl, and verctl share one clap chassis instead of
copying flag style.
evidence:
- "github.com/victor-software-house/ctl-core main 02c7e9f"
- "verctl ver-004-version-pr consumes ctl-core via git rev"
- "prelude, Render, formatdoc, FormatArgs/ColorLong, View"