GEML Illustrated · profile · geml-history/v1 (Stable) · 中文

GEML Illustrated · geml-history

The .geml file holds only the current version; a same-named .gemlhistory sidecar holds the history: reverse patches walking back from the current version, plus full snapshots. The sidecar is itself a GEML document, admitting three block types through profile = "geml-history/v1"; it carries a mirror of the committed current version, so reconstructing any old version depends on neither the live file, nor git, nor the network. This page puts each rule of the profile next to the four geml history verbs, run for real.

Board

14 rules, each with its source and status

Settled fixed by the profile document (it calls itself Stable). Observed not prescribed, this is what the tool does today. No implementation gap found on this page.

Settled 12Observed 2
RuleSourceStatus
1An application-layer profile; the core is untouched, and a processor that does not know it is fully conforming. Presence is signalled by the sidecar file, vocabulary by the sidecar's own meta; never inferred from the .gemlhistory extension or the history-of key (§8.6 rule 2).§1 · §1.1Settled
2The declaration admits three types: history-revision (keys id parent author summary hash newline), history-keyframe (id hash), history-blob (lang). Admission is names only: bodies stay raw, the same model as without the declaration.§1.1Settled
3doc.geml is the single source of truth for the current version, the hot path; doc.gemlhistory is the self-contained cold path, always carrying a keyframe mirror of the committed current version, maintained by tools and not hand-edited; the two reconcile by hash.§2Settled
4Lose the sidecar and the current document is intact; lose the live file and any version is still rebuildable from the sidecar. Neither direction depends on the other.§2Settled
5Header meta keys: history-of, geml-version, current, keyframe-interval. Keyframe and blob bodies embed whole GEML verbatim, so their fences must be longer than the longest fence in the payload.§3 · §3.1Settled
6Block identity: an #id when present; otherwise a key derived from content hash plus structural position, algorithm implementation-defined, bookkeeping only in the sidecar and never written back into the live file. No block is forced to carry an id.§4Settled
7Reverse patches have four operations: delete, replace … <- blob:, insert <- blob: <anchor>, move; applied in written order; a blob: reference must resolve to a history-blob in the same file, else error.§5Settled
8Reconstruction: take the nearest keyframe at or newer than the target, apply reverse patches along the parent chain down to the target, and the result's hash must equal the recorded hash. Independent of the live file.§6Settled
9Rollback is linear and destructive: every version after the target is truncated and current becomes the target; with uncommitted changes in the live file it must refuse unless the caller consents explicitly (interactive confirmation or --force).§7Settled
10hash is SHA-256 over the whole .geml's UTF-8 bytes, prefixed sha256:, newline style included (newline=lf|crlf). A revision id is <UTC timestamp>-<first 8 of hash>, sorting by time; any unambiguous prefix works as a selector.§8Settled
11Two severities: a broken chain, a dangling blob, or a reconstruction hash mismatch is corruption → error; the live file's hash differing from current is merely uncommitted → warning, and must not block read-only operations.§8 · §9Settled
12Every tool-generated sidecar must write profile = "geml-history/v1" in its meta; agents may read the sidecar but should not hand-write patches, blobs, ids or hashes; use the tool's save / get / restore / verify.§9 · §10Settled
13CLI: save appends a revision (no-op when identical to the tip); get without arguments lists all, with a rev prints that version in full; restore <rev> [--force]; verify reconstructs and rehashes the whole chain. rev spellings: 0 tip, -N back N, or an unambiguous id prefix.geml history --helpObserved
14geml revert <file> #id [--rev sel] is a core verb that uses the sidecar to return one block to a past version: splice back, resurrect, or delete. The project's PostToolUse hook auto-saves .geml files under spec/.geml --helpObserved
Two files

Hot path and cold path

Editors, renderers and agents touch only the .geml; history loads only when asked for.

What one save does Settled
doc.gemlcurrent version · single truth
→ save →
keyframemirror of committed current, refreshed
history-revisionid · parent · hash · reverse patch
history-blobold content of the replaced block
→
doc.gemlhistoryself-contained · tool-maintained
Why reverse
The newest version is the one most often read, so the mirror holds the newest version and patches walk backwards: reconstructing the previous version from the tip takes one patch, not a replay from the start. A keyframe recurs every keyframe-interval revisions, bounding both the number of reverse steps to any target and the blast radius of one bad patch to a single segment.
Why self-contained
§2: with uncommitted changes in the live file, or no live file at all, any version in the sidecar still reconstructs; conversely a lost sidecar leaves the current document untouched. Neither file owes the other anything.
Sidecar

A real .gemlhistory

The file after two saves, unedited. It passes geml check itself.

Header, mirror keyframe, one reverse patch, one blob, the root revision Settled
doc.gemlhistory · tool-generated
# History of doc.geml

=== meta
profile           = "geml-history/v1"
history-of        = "doc.geml"
geml-version      = "1.0"
current           = "20260904T080743Z-40a503a6"
keyframe-interval = 10
===

# Committed-current mirror (always present):
==== history-keyframe {id="20260904T080743Z-40a503a6" hash="sha256:40a5…b4f4"}
=== meta
title = "history probe"
===
# Plan {#plan}
=== text {#intro}
Second version of the intro.
===
=== table {#t format=csv}
a,b
1,2
===
====                                  %% four dashes around three: the payload contains ===

=== history-revision {id="20260904T080743Z-40a503a6" parent="20260904T080742Z-5a3b3ac6"
                     summary="edit intro" hash="sha256:40a5…" newline="lf"}
replace #intro <- blob:b1                %% current → previous: swap #intro back to its old content
===

==== history-blob {#b1 lang=geml}
=== text {#intro}
First version of the intro.
===
====

=== history-revision {id="20260904T080742Z-5a3b3ac6" summary="first" hash="sha256:5a3b…" newline="lf"}
===                                    %% the root: no parent, no patch
geml list · geml check
$ geml list doc.gemlhistory
#history-of-docgeml                       heading           h1    L1-10
=== meta                                  meta              anon  L3-9
#committed-current-mirror-always-present  heading           h1    L11-39
=== history-keyframe                      history-keyframe  anon  L12-25
=== history-revision@4214327e             history-revision  anon  L27-29
#b1                                       history-blob            L31-35
=== history-revision@e36c84ca             history-revision  anon  L37-38

$ geml check doc.gemlhistory
ok: no diagnostics     ← profile declared: the three types raise no unknown-block-type
BlockBodyHolds
metakey-valuehistory-of, geml-version, current, keyframe-interval
history-keyframerawthe complete .geml of one version; the current one is always present
history-revisionrawattributes are metadata, the body is the operations that turn this version into its parent
history-blobrawone block's text at some version, referenced by blob:<id>
ids
§8: 20260904T080743Z-40a503a6 is a UTC timestamp plus the first 8 characters of the content hash, sorting by time naturally; verification uses the full hash, not those 8. The second save came 5 seconds after the first, and the timestamps differ by one second.
Fences
§3: keyframes and blobs embed GEML verbatim and the payload contains ===, so they open with ====. That is exactly the core's §3 fence discipline; the sidecar invents nothing.
Admission
§1.1: the three type names carry the history- prefix because §8.5 wants extension names hyphenated; bare revision / keyframe / blob would sit on names the specification reserves.
Verbs

save · get · verify · restore, plus the core's revert

All run for real in a temp directory; with uncommitted changes verify only warns and never blocks, which is §8's two-severities rule.

One full round Settled CLI observed
shell
$ geml history save doc.geml -m "first"
saved 20260904T080742Z-5a3b3ac6

$ printf 'Second version of the intro.\n' | geml set doc.geml '#intro' --in - --body
wrote doc.geml

$ geml history verify doc.geml          live file changed, not saved
warning: uncommitted changes: hash(doc.geml) differs from current
verify: OK (1 revisions reconstructed & hashed)     exit 0 — a read-only operation is not blocked

$ geml history save doc.geml -m "edit intro"
saved 20260904T080743Z-40a503a6

$ geml history get doc.geml             the first column is the selector
0       20260904T080743Z-40a503a6  -  edit intro
-1      20260904T080742Z-5a3b3ac6  -  first

$ geml history verify doc.geml
verify: OK (2 revisions reconstructed & hashed)
Rules side by side
VerbDoesSource
save [-m]refreshes the mirror keyframe, writes the reverse patch and blobs, records the new hash and id; no-op when identical to the tip§10
get [<rev>]no argument: all versions, newest first; with a rev: that version's full text (reconstructed, hash checked)§6
verifyreconstructs every version along the chain and rehashes; broken chain, dangling blob, hash mismatch → error; live file merely uncommitted → warning§8 · §9
restore <rev> [--force]overwrites the live file and truncates history; refuses with uncommitted changes unless --force discards them§7
revert #id [--rev]core verb: returns one block to some version, everything else untouchedgeml --help
rev spellings: 0 = tip · -N = N back · an unambiguous id prefix
Two severities
§8: uncommitted changes are a normal editing state, not corruption, so they warn and may not block get / verify / reconstruction. A broken chain, a dangling blob reference or a reconstruction hash mismatch are the errors. The verify above passing while the change was unsaved is that rule.
Rollback
§7: restore is a destructive linear truncation; versions after the target are gone for good; ids are never reused, so a new commit after a rollback cannot be confused with the discarded tip. With uncommitted changes it must refuse unless --force.
Agents
§10: agents may read the sidecar freely (plain text, per block, a summary per version) but should not hand-write patches, blobs, ids or hashes. In this repo, saves for .geml under spec/ are done by a PostToolUse hook, config documents excepted.
Evidence

Probes

Run in the short-path temp directory %TEMP%\geml-hist-probe, because the session scratchpad's path exceeds the Win32 working-directory limit and node would not start there. Current in-repo build, 1.9.2.

StepResultMaps to
save → set → verify → save → get → verifyall exit 0, one warning while uncommittedverbs, board 11, 13
geml check doc.gemlhistory0 diagnosticssidecar, board 2, 12
geml list doc.gemlhistorymeta, keyframe, two revisions, one blobsidecar
revert #intro --rev -2 (only 2 revisions)error offset out of range, live file untouchedout of range leaves the file alone