Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Changes and tasks as ledger items, and next over them

The previous chapter put the spec and the ledger in one store. This one connects them: every open task of a change in flight becomes a ledger item about the requirement it implements, next ranks them beside everything else in the ledger, checking a box closes its item, and archiving the change closes the rest. The tree stays the source of truth for what; the ledger is where who, when and what next live; and neither copies the other’s job.

Which tasks, and which items

The whole decision is two SPARQL queries over the union of the spec graphs and the ledger’s own graph — the join gonk’s single store exists to make cheap:

/// Every open task of an unarchived change, with the requirement each names (if it names one the
/// change adds or modifies). Whether a task already HAS an item is not asked here: the ledger
/// answers that itself, when the task's IRI is the append's key.
pub const OPEN_TASKS: &str = r#"
PREFIX os: <http://example.org/openspec#>
SELECT DISTINCT ?task ?id ?number ?title ?requirement WHERE {
  ?task a os:Task ; os:change ?change ; os:number ?number ; os:title ?title ; os:done false .
  ?change os:id ?id .
  FILTER NOT EXISTS { ?change os:archivedOn ?archived }
  OPTIONAL {
    ?task os:cites ?name .
    ?change os:adds|os:modifies ?requirement .
    ?requirement os:name ?name .
  }
}
ORDER BY ?id ?number
"#;

/// Open ledger items about a task that is done, or whose change has been archived.
pub const FINISHED: &str = r#"
PREFIX os: <http://example.org/openspec#>
PREFIX ledger: <https://ikigai-rs.dev/ns/ledger#>
SELECT DISTINCT ?number ?id ?task ?why WHERE {
  ?item ledger:about ?task ; ledger:number ?number ; ledger:status ledger:open .
  ?task a os:Task ; os:change ?change .
  ?change os:id ?id .
  OPTIONAL { ?task os:done ?done }
  OPTIONAL { ?change os:archivedOn ?archived }
  FILTER(?done = true || BOUND(?archived))
  BIND(IF(BOUND(?archived), CONCAT("archived with ", ?id, " on ", STR(?archived)),
                            "checked off in tasks.md") AS ?why)
}
ORDER BY ?number
"#;

OPEN_TASKS is every open task of an unarchived change, with the requirement its parenthesis names if the change adds or modifies one by that name — so a typo in a task files an item about the task alone rather than about the wrong requirement. It does not ask whether a task already has an item; the ledger answers that. FINISHED is an open item about a task that is checked off, or whose change has been archived. Acting on the rows is one ledger request each:

/// Bring the ledger in line with the tree: file an item for every open task, keyed by the task's
/// IRI and about the task and the requirement it names; close every item whose task is checked
/// off or whose change is archived. Idempotent, and safe to run twice at once: the key lets the
/// ledger file at most one item per task, in the same store update that checks for one.
/// Returns one line per write, in the ledger's own words.
pub fn sync_ledger(kernel: &Kernel) -> Result<Vec<String>> {
    let mut done = Vec::new();
    for row in rows(&select(kernel, OPEN_TASKS)?) {
        let [task, id, number, title, requirement] = &row[..] else {
            return Err(Error::Endpoint(format!(
                "an OPEN_TASKS row of {} columns",
                row.len()
            )));
        };
        let about = format!("{requirement} {task}");
        let answer = ask(
            kernel,
            Verb::Sink,
            "urn:iki:ledger:append",
            &[
                ("content", &format!("{id} {number}: {title}")),
                ("about", about.trim()),
                ("labels", &format!("openspec,{id}")),
                ("key", task),
            ],
        )?;
        // `#4 <iri>` when it filed; `#4 <iri> existing open` when an item already carries the
        // key, and nothing was written. Only a filing is a write.
        match answer.split_whitespace().nth(2) {
            None => done.push(format!("{} — {id} {number}", answer.trim())),
            Some("existing") => {}
            Some(other) => {
                return Err(Error::Endpoint(format!(
                    "an append answered `{}`: neither filed nor existing ({other})",
                    answer.trim()
                )))
            }
        }
    }
    for row in rows(&select(kernel, FINISHED)?) {
        let [number, _id, _task, why] = &row[..] else {
            return Err(Error::Endpoint(format!(
                "a FINISHED row of {} columns",
                row.len()
            )));
        };
        let closed = ask(
            kernel,
            Verb::Sink,
            "urn:iki:ledger:close",
            &[("item", number), ("content", why)],
        )?;
        done.push(format!("{} — {why}", closed.trim()));
    }
    Ok(done)
}

That function is the one piece of real code in this part, and it is code for a reason: there is no resource that turns a result set into ledger writes. Everything it decides is in the two queries and the key; everything it does is the ledger’s append and close. And it is idempotent by construction — a task with an item answers existing, an item that is closed is not FINISHED — so a host can run it on every commit, or on a file watcher, and a second run with nothing changed writes nothing.

“Writes nothing” is meant literally, and it matters to whoever reads the ledger in between. A re-sync whose appends all answer existing changes nothing in the store, so it cuts no golden thread, and a next that a reader has already asked for is still served from the cache after it. A sync on every commit costs the ledger’s readers nothing on the commits that touch no task. The walk below asserts it with Kernel::is_cached, which asks the cache without resolving anything; the sync that does close an item cuts next like any other write.

The task’s IRI is the key

Each append carries key=<the task's IRI>. A key is the caller’s own name for an item, unique in its ledger: if an item already carries it (or carried it and was deleted), nothing is filed, and the answer names that item with a third word a filing never has — #1 <iri> existing open instead of #1 <iri>. sync_ledger reads that word and reports only real filings.

The key is checked by the ledger itself, in the same store update that files the item, and that is what makes the sync safe to run twice at once. The other way to be idempotent is to ask first — “which open tasks does no item point at?”, a FILTER NOT EXISTS { ?item ledger:about ?task } in the query — and then append. That is a check followed by an act, and two syncs started together both see every task unfiled and both file it. Four syncs over this tree, with the query instead of the key and each one’s question answered before any of them appended, file twelve items for three tasks: every one of them files every task. That is the worst interleaving the race allows, made certain by a barrier between the question and the appends, and check_then_append_syncs_file_every_task_once_per_sync, in the crate’s tests, holds the figure to it. With the key the same four file three, and concurrent_syncs_file_each_task_once holds that. The keyed append arrived in ikigai-ledger 0.4.0; this part was first written against 0.3.0 with the query, and carried that cost as a warning.

⚠ What the sync recognizes is the key, not about. An item a person files by hand about a task, without the task’s IRI as its key, is not that task’s item to the sync: the next sync files a second one beside it (an_item_filed_about_a_task_without_its_key_is_filed_again). To file a task’s item by hand, file it as the sync would, with key=<the task's IRI>; the sync then answers existing for it.

The walk

A copy of the committed tree, in a scratch directory, so a box can be checked and the change archived for real:

#![allow(unused)]
fn main() {
extern crate openspec_ledger;
extern crate ikigai_core;
use ikigai_core::{Capability, Iri, Request, Verb};
use openspec_ledger::{ask, kernel, lift_tree, sync_ledger, Workspace};

let tree = Workspace::new();
let host = kernel();
lift_tree(&host, &tree.tree()).unwrap();

// Three open tasks; 1.1 is already checked, so it is never filed.
let filed = sync_ledger(&host).unwrap();
assert_eq!(filed.len(), 3, "{filed:?}");
assert!(filed[0].starts_with("#1 urn:iki:ledger:default:item:") && filed[0].ends_with("add-undo 1.2"));
assert!(filed[2].ends_with("add-undo 2.1"));
assert!(sync_ledger(&host).unwrap().is_empty(), "a second sync files nothing");

// What the second sync was told, for each task: the key is taken, and nothing was filed.
let again = ask(&host, Verb::Sink, "urn:iki:ledger:append", &[
    ("content", "add-undo 1.2: Empty the last move's square and return the turn"),
    ("key", "urn:openspec:change:add-undo:task:1.2"),
]).unwrap();
let first = filed[0].split(" — ").next().unwrap();
assert_eq!(again, format!("{first} existing open\n"));

// Each is about its task and the requirement it implements.
let item = ask(&host, Verb::Source, "urn:iki:ledger:item:1", &[]).unwrap();
assert!(item.contains("key:      urn:openspec:change:add-undo:task:1.2"), "{item}");
assert!(item.contains("about:    urn:openspec:change:add-undo:task:1.2"), "{item}");
assert!(item.contains("about:    urn:openspec:spec:moves:requirement:the-last-move-can-be-undone"));

// So the ledger can be asked by requirement: how many items implement it.
let undo = ask(&host, Verb::Source, "urn:iki:ledger:count", &[
    ("about", "urn:openspec:spec:moves:requirement:the-last-move-can-be-undone"),
]).unwrap();
assert_eq!(undo, "2\n");

// And `next` ranks them with everything else in the ledger.
let next = ask(&host, Verb::Source, "urn:iki:ledger:next", &[]).unwrap();
assert!(next.contains("ready: 3"), "{next}");

// A sync with nothing to do cuts nothing: that `next` is still in the cache after it.
let cached = || host.is_cached(
    &Request::new(Verb::Source, Iri::parse("urn:iki:ledger:next").unwrap()),
    &Capability::root(),
);
assert!(cached());
assert!(sync_ledger(&host).unwrap().is_empty());
assert!(cached(), "a re-sync that filed nothing cut next");

// Check off 1.2 in tasks.md, lift, sync: its item closes, with the reason.
tree.check("add-undo", "1.2");
lift_tree(&host, &tree.tree()).unwrap();
assert_eq!(sync_ledger(&host).unwrap(), ["closed #1 (done) — checked off in tasks.md"]);
assert!(!cached(), "a close is a write, and cuts next");

// Archive the change: its folder moves, the lift sees the new paths, the rest close.
tree.archive("add-undo", "2026-10-07");
lift_tree(&host, &tree.tree()).unwrap();
assert_eq!(sync_ledger(&host).unwrap(), [
    "closed #2 (done) — archived with add-undo on 2026-10-07",
    "closed #3 (done) — archived with add-undo on 2026-10-07",
]);
let next = ask(&host, Verb::Source, "urn:iki:ledger:next", &[]).unwrap();
assert!(next.starts_with("nothing is ready"), "{next}");
}

count takes the same filter items does (about, status, labels, …) and answers the number alone: one COUNT query, without the listing’s page or the check items makes over the whole ledger for an item it cannot read. It arrived in ikigai-ledger 0.9.0; items with the same about= ends with the same total (2 item(s)) and the two items besides.

The labels name the change ([add-undo openspec]), the title carries the task’s number, and the close reason is a comment on the item, so a person reading the ledger later sees why it closed without opening the tree. Nothing in the tree was written by any of this: the ledger points at the spec by IRI, and the spec does not know the ledger exists.

What about buys

Because an item names the requirement, the questions that cross the two are one query each. Which requirement has the most open work? Which open item is about a requirement that a change has since removed? Is there a requirement with scenarios and no item, no test, no change in flight? Each is a SELECT over the same union select already asks, and none needs a new resource — which is the point of keeping the spec, the work and the code’s annotations in one store as gonk does.

In gonk, and not in this page

gonk holds two of this chapter’s three spaces — the store and the ledger — and not the lift: ikigai-markdown is not in its manifest, and what is not compiled into gonk cannot be reached through it. So the split is the natural one. A host of your own links the lift and runs lift_tree and sync_ledger as above, with its store and ledger names pointed at gonk’s socket (as in Over the socket, where urn:gk:iki:store:load, urn:gk:iki:store:graph-select and urn:gk:iki:ledger:append are all served to the owner). The graphs then live in gonk’s durable store beside the ledger, and gonk’s own pages show the items. The key needs a gonk whose ledger knows it, ikigai-ledger 0.4.0 or later, and count one that links 0.9.0 or later; the gonk this book pins (gonk.rev) links 0.9.0, so both are served there as urn:gk:iki:ledger:append and urn:gk:iki:ledger:count. (This book’s checks run the in-process host above, not that arrangement.) gonk does not run the sync itself; doing that on a watcher over a browse root’s openspec/ directory would be a separate piece of work.

There are no Run cells in this part, on purpose. The page’s kernel is the ledger over an in-memory store, compiled to wasm, and the lift could join it — but urn:markdown:mapping:{name}, the name this chapter installs the mapping under, is the one resource ikigai-markdown does not build for wasm (there is no config home in a browser), and the tree itself would have to be baked into the page. A page that ran a different mapping lookup over a frozen copy of the tree would be showing something this chapter does not teach, so the Rust above is the runnable form.

What you built

The atoms are the tree’s files and the ledger’s graph. The spec graphs are derived from the files and the items are derived from the spec graphs, each by a composition the host did not write; OPEN_TASKS and FINISHED are views that span both; sync_ledger is the glue that acts on them. Close an item by hand and it stays closed; delete the tree and the items still say what they were about, by name.