geml

GEML History — 版本化与回溯扩展

*English 中文*

配套规范(稳定版)

字段 取值
扩展自 GEML 1.0(见 GEML-spec_CN.md
版本 1.0
状态 稳定(stable)
文件后缀 .gemlhistory

摘要

本配套规范为 GEML 定义一个自包含的版本化层。文档的 .geml 文件只保存当前版本, 且仅保存当前版本。一个与之同基名、后缀为 .gemlhistory 的伴生文件,以自当前版本 向回的逆向增量记录历史,并辅以全量关键帧快照。历史文件是自包含的:它始终携带 一份由工具维护、镜像已提交当前版的关键帧,因此任意历史修订都可还原、主文件也可回滚到 任意历史修订——全程不依赖活动的 .geml、不依赖任何外部版本控制系统、也不依赖任何在线 服务。两个文件刻意把热路径(频繁读写的当前版)与冷路径(仅在需要时加载的历史) 分开。历史文件由工具生成与校验;消费方(包括 AI 智能体)以纯文本读取它。

目录

  1. 范围与同 GEML 的关系
  2. 文件角色
  3. .gemlhistory 文档
  4. 块身份与 id
  5. 逆向补丁操作集
  6. 还原
  7. 回滚
  8. 修订 id、完整性与哈希
  9. 一致性
  10. 工具与 AI 使用(非规范)

约定

关键词 MUST(必须)MUST NOT(必须不)MAY(可)SHOULD(应) 的要求等级沿用核心规范的定义。修订(revision) 指文档的一个被记录的状态,以提交 id(§8)标识;当前版本(current) 指最新修订。活动文件指工作副本 doc.geml已提交当前版指历史文件中以当前修订记录的内容。


1. 范围与同 GEML 的关系

本扩展不向 GEML 增加任何新语法。版本化完全骑在既有的类型块原语(核心规范 §3)、 属性对象(§4)、稳定 id 与引用(§5)之上。不实现本扩展的处理器不受影响:.geml 文件本身仍是一份完整、合法的 GEML 文档,任何普通 GEML 工具都能渲染。

历史层是可选的,其存在仅由一个同基名的 .gemlhistory 文件来标示。


2. 文件角色

对于文档 doc.geml

所有权明确无歧义:活动的 doc.geml 是当前版本的真源;doc.gemlhistory 中的「已提交 当前版关键帧」是工具维护的镜像,绝不手改,二者由哈希检查(§8)对账。

由此带来两个推论:


3. .gemlhistory 文档

.gemlhistory 文件本身就是一份 GEML 文档。本历史扩展注册四种块类型:

类型 正文模式 角色
meta data 历史文件头(每行一个 key=val
revision raw 每个修订一条:元数据写在属性里,逆向补丁操作写在正文里
blob raw 一段原样载荷(某修订下某块的内容),由补丁操作按 id 引用
keyframe raw 某修订完整 .geml 内容的原样全量快照

当前修订的关键帧始终存在(已提交当前版镜像);其余关键帧周期性出现(见 keyframe-interval)。

由于 keyframeblob 的正文原样内嵌整段 GEML 片段,其开围栏必须比载荷内部最长的 围栏更长(核心规范 §3):内部用 === 的载荷以 ==== 包住,依此类推。

3.1 文件头(=== meta

含义
history-of 活动文件的基名,如 "doc.geml"
geml-version 历史所遵循的 GEML 语言版本
current 当前修订的 id(§8)
keyframe-interval 关键帧快照之间推荐的修订间隔数

3.2 示例

# History of budget.geml

=== meta
history-of        = "budget.geml"
geml-version      = "1.0"
current           = "20260617T103012Z-33ab12cd"
keyframe-interval = 10
===

# 已提交当前版镜像(始终存在):

==== keyframe {id="20260617T103012Z-33ab12cd" hash="sha256:33ab12cd…"}
# 预算方案

=== meta
title = "预算方案"
===

=== table {#budget caption="年成本"}
| 方案  | 人月 | 单价 |
|-------|-----:|-----:|
| 基础版 |    1 |   25 |
===

=== note {#risks}
主要风险是供应商锁定。
===
====

=== revision {id="20260617T103012Z-33ab12cd" parent="20260501T140000Z-22cd34de" author="alice" summary="新增风险 note;修订预算单价" hash="sha256:33ab12cd…"}
delete #risks
replace #budget <- blob:b-22cd34de-budget
===

==== blob {#b-22cd34de-budget lang=geml}
=== table {#budget caption="年成本"}
| 方案  | 人月 | 单价 |
|-------|-----:|-----:|
| 基础版 |    1 |   30 |
===
====

=== revision {id="20260501T140000Z-22cd34de" parent="20260410T091500Z-11ef56ab" author="alice" summary="移除遗留费率说明" hash="sha256:22cd34de…"}
insert <- blob:b-11ef56ab-legacy after #budget
===

==== blob {#b-11ef56ab-legacy lang=geml}
=== note {#legacy}
旧费率口径,保留备查。
===
====

=== revision {id="20260410T091500Z-11ef56ab" author="alice" summary="初稿" hash="sha256:11ef56ab…"}
===

根修订是一条逆向补丁正文、且无 parentrevision:它没有前驱。若某 revision 的载荷会迫使围栏深层嵌套,则该载荷必须以独立的顶层 blob 携带,按 blob:<id> 引用。(允许用更长围栏内联载荷,但不推荐——正确的围栏长度嵌套容易出错。)

块排列(建议)。 物理顺序不影响正确性——处理器按 type 与修订 id 建索引取块, 与位置无关。但出于可读性与流式效率,.gemlhistory 文件应(SHOULD)采用最新在前 的布局:meta,然后是已提交当前版 keyframe,再是从当前回溯到根的 revision 块, 且每个 blob 紧跟在引用它的 revision 之后。这使文件自上而下的顺序与逆向补丁的回放 方向(§6)一致,把最常读的条目(当前版镜像与最近的若干变更)放在最前,并让只需近期 修订的读者尽早停止。meta 可(MAY)携带一份区间关键帧 id 的索引,以便无需扫描即可 定位到更老的入口点。


4. 块身份与 id

逆向补丁通过块身份(区别于修订 id,§8)寻址块:

块 id 在语言层仍是可选的;本扩展不强制任何块带 id。两条性质使强制 id 没有必要:

  1. 关键帧是全量快照,因此块匹配只用于让相邻修订之间的增量更紧凑——绝不决定还原 的正确性。当 id-less 块无法被可靠匹配时,增量退化为更粗的整块替换,而最近的关键帧 始终是一份精确的兜底。
  2. 标题自动派生 id(核心规范 §4),所以即便文档里没有任何显式 id,章节级锚点也 始终可用。

工具应(SHOULD)在「易被引用或易被修改、且需要跨版本稳定身份」的块上记录显式 #id(表格、图形、note、重要小节)。注意: 标题的自动 id 是其文本的函数,因此重命名 标题会改变其 id,差分会把该变更视为「删除 + 新增」而非「重命名」。要跨重命名稳定追踪, 需显式 id。


5. 逆向补丁操作集

revision 正文是一串面向行的操作,把某修订的内容变换为其 parent 的内容。 块键(block-key) 对带 id 块为 #<id>,对 id-less 块为工具派生的键令牌。 锚点(anchor)at-startat-endafter <块键>before <块键> 之一。

操作 撤销(较新修订中的) 效果(朝向 parent)
delete <块键> 一个被新增的块 移除该块
replace <块键> <- blob:<id> 一个被修改的块 把该块内容置为 parent 的载荷
insert <- blob:<id> <锚点> 一个被删除的块 在锚点处以 parent 修订内容重新插入该块
move <块键> <锚点> 一个被移动的块 重新定位该块

一条 revision 内的操作按书写顺序套用。每个 blob:<id> 引用必须解析到同一 .gemlhistory 文件内的 blob 块;无法解析的引用是构建错误,与核心规范的引用 校验规则(§5)一致。


6. 还原

把文档还原(查看)到目标修订 R(只读;不修改任何文件):

  1. 选取链上 R 或更新的最近关键帧。历史文件始终包含当前修订的关键帧(已提交当前版 镜像);其余 keyframe 块提供更早的入口点。因此还原依赖活动的 doc.geml, 即便活动文件存在未提交改动或缺失也照常工作。
  2. 沿 parent 链逐个修订向回套用逆向补丁,直到抵达 R
  3. 校验结果:所还原内容的哈希必须等于 R 记录的 hash(§8)。

关键帧把任意目标修订的逆向步数封顶,并把单条补丁损坏的影响限制在一个关键帧区段内。 每对相邻修订的逆向补丁都被保留,以保证任一步都可用;关键帧是额外的,充当有界、可校验 的入口点。


7. 回滚

回滚把活动文件重写为某历史修订并从那里继续。历史是线性的:无分支,无合并。

由于历史文件自包含(§2),目标修订的还原不依赖活动文件的当前状态。

未提交改动策略。 回滚会覆盖活动文件。若 doc.geml 存在未提交改动(§8),处理器 必须不(MUST NOT)隐式丢弃它们:除非调用方显式同意丢弃,否则必须拒绝回滚—— 交互式下为确认提示,非交互(脚本或 Agent)下为显式 force 选项。处理器应(SHOULD) 提示改用 commit 来保留当前改动。

回滚到修订 R

  1. 还原修订 R(§6)。
  2. 把还原出的内容写入活动的 doc.geml
  3. 截断历史,使 R 成为 current:丢弃链上比 R 更新的所有 revisionkeyframe,以及仅被这些被丢弃修订引用的 blob 块;把已提交当前版关键帧刷新为 R;置 meta.currentR 的 id。

回滚是破坏式的:R 之后的修订(含原当前修订)被永久丢弃且不可恢复。之后的编辑 作为带全新 id(新时间戳)的新修订提交;id 绝不复用,因此回滚后的修订永远不会与被 丢弃的 tip 混淆。(工具可(MAY)在截断前把被丢弃的 tip 另存一份快照作为可选保险; 这不是必须的。)


8. 修订 id、完整性与哈希

区分两种严重度不同的情况:

注(非规范): 由于短码取自版本的内容哈希、而非父绑定的提交哈希,id 链不具备 密码学意义上的防篡改性。对文档历史而言这是可接受的;若实现需要防篡改,可保持同样的 时间戳-<短码> id 格式、改为从 sha256(parent ‖ content ‖ metadata) 派生 <短码> 即可,其余不变。


9. 一致性

合规历史处理器必须:

  1. doc.geml 视为当前版本的唯一真源,渲染它时从不要求 doc.gemlhistory
  2. doc.gemlhistory 作为合规 GEML 文档解析(核心规范 §3–§5)。
  3. 在历史文件中维护一份已提交当前版关键帧,使还原与活动文件的状态无关。
  4. 能从链上不早于目标修订的最近关键帧起、通过套用逆向补丁还原任意修订(按其 id 选取), 对照记录哈希校验结果,且在活动文件存在未提交改动或缺失时同样可行(§6、§8)。
  5. 对损坏报错误parent 链断裂、blob: 引用悬空、或还原结果哈希不符。
  6. hash(doc.geml)current 的不一致报为未提交改动警告,且绝不因此阻塞 只读操作。
  7. 以破坏式、线性的截断方式执行回滚(§7),且必须不在未经调用方显式同意(确认或 force)的情况下丢弃未提交改动。
  8. 不强制任何块带 id,不依赖 git 或任何在线服务。

10. 工具与 AI 使用(非规范)

历史文件由工具生成与校验,而非手工编写。记录一次修订(commit)会刷新已提交当前版 关键帧、写入逆向补丁与相关 blob、并记录新的哈希与 id。逆向补丁需要精确的块内容抽取、 围栏长度记账与哈希——这些手工产出极易出错(对 AI 智能体亦然)。推荐的分工:

由于历史以同基名的纯文本伴生文件随文档一同流转,这些信息可离线获取、在复制与转发后 依然完整、且自我描述——这些性质是带外的版本控制与在线文档历史所不具备的。