Every post so far in this series read data. This one writes it — SPARQL 1.1 Update, and then, because a write that vanishes on restart isn't durable, the delta-log write path that gives updates a crash-safe home on disk.
SPARQL11.Algebra.fst:5733's apply_update : rdf_dataset -> sparql_update -> rdf_dataset is a verified, total F* function
implementing INSERT DATA, DELETE DATA, DELETE/INSERT WHERE, CLEAR, and
full graph management (ADD/COPY/MOVE/CREATE/DROP) — LOAD is accepted by
the algebra but rejected defensively at the HTTP sandbox layer
(SPARQL.Update.Analysis.fst's update_has_load, an operational
policy decision, not a gap in the algebra). The W3C SPARQL 1.1 Update
conformance suite — 14 manifests, 176 test cases — scores 176 pass, 0
fail (out of 176), per
docs/test-results/latest.json
(2026-07-06 05:29 UTC, commit
616247d):
add 8/8, basic-update 13/13, clear 4/4, copy 6/6, delete
19/19, delete-data 6/6, delete-insert 17/17, delete-where 6/6,
drop 4/4, move 6/6, syntax-update-1 54/54, syntax-update-2
1/1, update-silent 13/13, http-rdf-update 19/19.
docs/_includes/hub.njk's fn adapter now wires up update —
fn.update(dataset, updateText) — the same typed shape
npm/factoidal/index.js's update(data, updateText) -> Dataset
exposes, built on the raw npm-entry ABI's updateDataset export
underneath (the js_of_ocaml build this whole series' cells run
against). This cell wraps it in a try/catch per the
cell contract's capability-check pattern (an older bundle
might predate the Update export), and actually runs an INSERT DATA:
const ttl = `
@prefix foaf: <http://xmlns.com/foaf/0.1/> .
<http://example.org/alice> foaf:name "Alice" .
`;
try {
const before = await fn.parse(ttl, { format: "turtle" });
const insertData = `# Add Bob's name to the dataset as a new ground triple.
PREFIX foaf: <http://xmlns.com/foaf/0.1/>
INSERT DATA { <http://example.org/bob> foaf:name "Bob" . }
`;
const after = await fn.update(before, insertData);
const rows = await fn.query(
after,
`# List every name now in the dataset, alphabetically.
PREFIX foaf: <http://xmlns.com/foaf/0.1/> SELECT ?name WHERE { ?s foaf:name ?name } ORDER BY ?name`
);
return {
available: true,
namesBeforeInsertData: ["Alice"],
namesAfterInsertData: rows.map((row) => row.get("name").value),
};
} catch (err) {
return { available: false, note: err.message };
}
namesAfterInsertData should read ["Alice", "Bob"] — Bob's triple,
inserted by a real apply_update call running in your browser right
now, is already there when the very next query runs.
INSERT DATA/DELETE DATA only ever touch ground triples. The pattern
most real edits need is DELETE/INSERT WHERE — find whatever matches a
pattern, then replace it. Same fn.update, same engine, correcting
Bob's name in place rather than deleting and re-inserting by hand:
const ttl2 = `
@prefix foaf: <http://xmlns.com/foaf/0.1/> .
<http://example.org/alice> foaf:name "Alice" .
<http://example.org/bob> foaf:name "Bob" .
`;
try {
const before = await fn.parse(ttl2, { format: "turtle" });
const deleteInsertWhere = `# Rename Bob to Bobby: find the triple matching foaf:name "Bob"
# and replace it with foaf:name "Bobby".
PREFIX foaf: <http://xmlns.com/foaf/0.1/>
DELETE { ?s foaf:name "Bob" }
INSERT { ?s foaf:name "Bobby" }
WHERE { ?s foaf:name "Bob" }
`;
const after = await fn.update(before, deleteInsertWhere);
const rows = await fn.query(
after,
`# List every name now in the dataset, alphabetically.
PREFIX foaf: <http://xmlns.com/foaf/0.1/> SELECT ?name WHERE { ?s foaf:name ?name } ORDER BY ?name`
);
return {
available: true,
namesAfterDeleteInsertWhere: rows.map((row) => row.get("name").value),
};
} catch (err) {
return { available: false, note: err.message };
}
namesAfterDeleteInsertWhere should read ["Alice", "Bobby"] — the
WHERE clause found the one triple matching foaf:name "Bob", and
the DELETE/INSERT pair swapped it for "Bobby" in a single
request, entirely in your browser, against the same engine
bin/linux-x86_64/factoidal runs natively.
Everything above runs apply_update over a plain in-memory
rdf_dataset — correct, but gone the moment the process exits. To make
an update survive a restart, factoidal writes it to an append-only
delta log on disk, specified first in
docs/designissues/2026-07-06-durable-update-design.md.
Three layers make that work, each with its own proof and measurement:
The delta-log entry format, proved.
RDF.Store.Columnar.DeltaLog.fst
defines the five delta-entry shapes an update can produce
(DE_Add/DE_Remove/DE_Clear/DE_Drop/DE_Create), each
serialized to a length-prefixed, checksummed byte frame. The module's
payoff is a proved lemma, not an assertion: parse_delta_entry (serialize_delta_entry e ++ rest) == Some (e, rest) for every
well-formed entry — parsing what serializing just wrote is a
theorem, checked by Z3 4.13.3, not a round-trip test that merely
happened to pass. 74 unit assertions (every constructor, non-ASCII and
astral-plane UTF-8, a 100KB literal, 8 corruption cases) additionally
pin the extracted OCaml against the same claim
(868a20b).
A crash-safe log file. The same module's delta_batch/DLOG file
layer adds streaming serialize_log/parse_log with an extended
round-trip lemma, realized on disk by five assume val I/O primitives
(append/fsync/read-all/atomic-rename/fsync-dir — issue #282, per Iron
Rule #3). The check that matters for a crash-safety claim:
tests/local/delta_log_crash_harness.sh SIGKILLs a writer process
mid-append at random points and confirms the log recovers a clean
prefix every time — 270 kills, 270 clean recoveries, 0 corrupt
accepts over two seeded runs
(1f27320).
Reads see the delta.
RDF.Store.Columnar.DeltaMerge.fst's
merge_on_read composes a base graph's rows with a resolved delta
(adds, tombstoned removes, CLEAR/DROP/CREATE applied in sequence
order), backed by a proved correspondence lemma
(lemma_merge_on_read_matches_apply_entries): for every triple,
membership in the reference application of delta entries to the base
graph equals membership in the merged read. The CLI's --delta-log PATH (alongside --data-cottas) routes a query through this path
(5e8399a,
tests/local/durable_update_stage3.sh, 15 pass, 0 fail, plus a
25-kill-iteration harness with 25 clean recoveries).
One boundary, stated plainly rather than oversold: the proved lemma
covers merge_on_read against delta entries, the primitive op
shapes. The fuller lemma over real SPARQL update_op values (what
apply_update consumes) rests on the update_ops_to_delta_entries
translator, which is pinned by acceptance test rather than by proof.
That gap is written into the module itself as a residual.
Compaction — folding an accumulated delta back into a fresh
.cottas base so the log doesn't grow without bound — has since
landed as factoidal compact (durable-UPDATE stage 4): each
compaction writes a full new artifact set under a versioned directory
and repoints a current symlink, so older epochs stay queryable and
a crash mid-compaction never corrupts the live store.
What the delta log still does not have:
Every claim above carries its own commit rather than a blanket "durable UPDATE shipped" — the write path and compaction are real and measured; the Parliament-scale run is not.
bin/factoidal-http is the standalone SPARQL 1.1 Protocol server:
GET/POST /query (and /sparql), POST /update, a --read-only
flag that turns the latter into a 403, and CORS controls — the same
34/34 protocol suite score cited above covers this dispatch layer.
It's what serves the
live UK Parliament demo —
3,143,406 real quads, queryable today over the actual SPARQL 1.1
Protocol, not a mock.
Every query against it produces layered timing data
(docs/observability.md):
a fixed-field stderr line per query (operator-only), a machine-readable
Server-Timing response header (parse;dur=0, eval;dur=137000, format;dur=0, total;dur=137001, renders natively in browser DevTools),
and /admin/recent.json (last 50 queries + counters). The
Server-Timing header is the one surface gated by policy — per issue
#266, per-stage
timing can leak query-cost information (named-graph cardinality, FILTER
selectivity) to a requester who shouldn't observe it, so
--server-timing defaults to auto: off whenever the deployment
looks multi-tenant or tunnel-exposed (a per-user write sandbox flag is
set, CORS is anything but off, or the bind host isn't loopback), on
otherwise. on/off are also available as explicit overrides. The
stderr line and /admin/recent.json are operator-only and are never
gated — only the header that goes back to the requester is.
Graph Store Protocol: specified, proven, and routed
(e8085da).
SPARQL.GraphStore.fst
is a verified, assume-val-free F* module implementing the five GSP
operations (gsp_get/gsp_head/gsp_put/gsp_post/gsp_delete) over
a graph_store value — PUT-creates-vs-replaces, POST-merges,
DELETE-existence, all decided in F*, not in a runner shim. It's
exercised by the W3C http-rdf-update manifest's stateful test
sequence in the test runner (19/19, cited above) — a suite-level shared
store that runs PUT/POST/DELETE against it across a whole manifest.
bin/factoidal-http/factoidal_http.ml wires
GET/HEAD /data?graph=... unconditionally and PUT/POST/DELETE
under a --rw flag, through total GSP-to-delta-entry translators
(update_ops_to_delta_entries's sibling for GSP verbs), with
SPARQL.GraphStore.fst's own spec-correct status codes, 405 without
--rw. One limitation is disclosed rather than papered over: a
DELETE durably clears a named graph's content, but a later GET may
still answer 200 with an empty body rather than 404 — the
merge-on-read architecture doesn't yet distinguish "emptied" from
"never existed." Acceptance:
tests/local/durable_update_stage8_http.sh, 29 pass, 0 fail (a curl
matrix over all five GSP verbs, plus concurrent-reader/SIGKILL-mid-
write recovery checks). Post 18 picks
up the in-browser side of this same durable-UPDATE story.
The verified-in-F* post covers why F* and what "verified" means here. Post 18 runs the durable-UPDATE lifecycle — update, persist, reload, corrupt, recover — live in your browser.
The live cells above are pinned in
tests/hub/post17_test.mjs —
the exact same source, executed against the real npm/factoidal typed
API instead of the in-browser fn adapter.