Skip to content

feat(canonical): add magi-cj/1 canonical encoding and artifact identity - #33

Draft
jamespud wants to merge 3 commits into
mainfrom
codex/c0-canonical-encoding
Draft

jamespud wants to merge 3 commits into
mainfrom
codex/c0-canonical-encoding

Conversation

@jamespud

@jamespud jamespud commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

阶段:CAS-9 / C0 Canonical Encoding & Artifact Identity
契约:docs/canonical-encoding-contract.md(本 PR 冻结)· 规范权威:Rev4 §2
状态:Draft(未独立审阅,不合并)

A. 基线

  • Base:main = a43e71e2a0e6ec3341913e7c80b200d611fc447b(开始前已复核,远端未变、无未合并 PR)
  • 任务分支:codex/c0-canonical-encoding
  • 范围:新增 canonical 编码/身份契约 + 最小编码器与严格解析 + Golden Vector + 遗留摘要字节冻结测试
  • 非目标:不修改 Rev4;不迁移历史持久化摘要;不改现有 canonicalDigest / ApprovalIntentDigest / A2A RequestHash / ArtifactRemap 的语义;不启用任何受保护外发;不启动确认性统计实验。

B. 实施

现有仓库有三套互不兼容且都已持久化的摘要方案(legacy-manifest/v1、legacy-approval-intent/v1、legacy-a2a-request/v1),且完全没有重复键检测。本 PR 新增独立、版本化的新协议 profile,而不是改变它们:

backend/domain/canonical/(新增)实现 magi-cj/1:

  • 键按 UTF-16 code unit 排序;数组保序;无空白。
  • RFC 8785 最小转义;不做 HTML 转义。
  • MAGI 数值约束:只接受十进制整数字面量且 ∈ ±(2^53−1);拒绝 1.0、1e0、-0、NaN/Infinity。
  • NFC 只验证不转换(编码器不改写它签名的内容);外部字节忠实材料明确不在本编码器管辖内。
  • null / 字段缺失 / 空集合三态区分,显式 null 保留。
  • 严格解析(全部返回 typed *canonical.Error,不 panic、不静默覆盖):重复键(含 "a" 与 "\u0061" 冲突)、孤立代理项、非法 UTF-8、字符串内裸控制字节、非法转义、尾随数据、未知工件类型。
  • 身份:digest = SHA-256("magi:canonical:v1:<type>\n" || canonical_bytes);artifact_id = <prefix>_<digest> 由摘要派生;类型走封闭白名单,禁止分隔符。

C. RED → GREEN

RED(真实违规,已在改动前复现) —— encoding/json 基线行为:

duplicate-key unmarshal: err=<nil> value=map[a:2]        # Rev4 要求拒绝重复字段
html escaping: {"s":"\u003ca\u003e\u0026\u003cb\u003e"}  # JCS 禁止 HTML 转义
float acceptance: map[e:1 n:1]                            # 1.0/1e0 被静默折叠
invalid utf8 accepted: "\ufffd"                            # 非法 UTF-8 被静默替换

新能力缺失时 go test ./domain/canonical/ 直接构建失败(no non-test Go files),即 RED。

GREEN:

  • TestCanonicalGoldenVectors(15 个正例:字节 + 摘要逐字节一致)
  • TestCanonicalRejectsInvalidInput(14 个反例:重复键、1.0/1e0/-0/NaN/Infinity、越界整数、非 NFC、孤立代理项、非法 JSON、尾随数据,均要求精确 error kind)
  • TestDigestDomainSeparation、TestArtifactIDDerivedFromDigest、TestCanonicalizeIsIdempotent、TestCanonicalizeStructMatchesJSONPath、TestExplicitNullDiffersFromAbsent

跨语言独立实现:testdata/verify_golden.py 是与 Go 无关的第二实现(不同语言、不同解析路径),负责生成并复核同一份 Golden Vector;Go 侧 TestGoldenVectorsReproduceInPython 会执行它,两语言必须对每条向量一致。

遗留字节冻结(兼容性证据,非推测):

  • domain/execution: TestLegacyManifestDigestsAreByteFrozen 固定 ApprovalIntentDigest、ToolIdempotencyKey、ModelReferenceDigest、Model/Toolset/Config/ManifestDigest 的既有值。
  • application/a2a: TestLegacyA2ARequestHashIsByteFrozen 固定 submission dedup key。
  • 两者都是回归保护:若有人改动旧算法,测试立即失败;本 PR 不改动它们。

D. 验证(实际执行)

命令 结果
gofmt -l . 无输出
go vet ./... 通过
go test ./... 全部通过(无 FAIL)
go test -race ./... 通过,无 DATA RACE
MAGI_TEST_MYSQL_DSN=… go test -p 1 ./bootstrap/ ./adapter/(MySQL 8.4) 534 PASS / 0 FAIL / 4 SKIP
go test -race -p 1 ./bootstrap/ -run '^TestMySQLDecisionWriter_' 通过(16.0s)
python3 scripts/test-decision-cutover.py OK(3 tests)
python3 testdata/verify_golden.py testdata/golden_vectors.json OK(0 failures)

4 个 SKIP 均为 TestCodeRunnerAdapter_RealSandbox_*(需真实代码沙箱),未通过、仅未执行。MySQL 为本地临时容器执行,非 CI 结果;CI 结果以本 PR checks 为准。

E. 放行决定

  • 本 PR:Draft,等待独立实现审阅(编码器、严格解析、Golden Vector、遗留冻结)。
  • 不代表 C0 完成:仅覆盖九类工件的编码与身份层;DomainProfile/PersonaSpec/ContextManifest/AuthorityProof/ActionIntent 的字段契约属于 C1/C2/C3。
  • 不影响 CAS-9 状态(仍 In Progress);[BUG] Validate report citations and persist truthful dissent from authoritative inputs #13 未改动。
  • 未合并、未推送额外分支、未改 Issue/Linear。

F. 未决边界(明确记录)

  • 域分离前缀 magi:canonical:v1: 与类型白名单已冻结;AuditReceipt/SendRecord/AcceptanceManifest 需在各自 Epic 声明使用同一 profile。
  • 跨语言验证目前是 Python ↔ Go;未做 Rust/JS 实现(Golden Vector 文件本身是语言中立的,可复用)。
  • 本 PR 不提供签名/验签能力(仓库当前只有 session cookie 的 HMAC);AuthorityProof 的验签属 C2/[EPIC] Authenticated authority, delegation, owner consent and human decisions #5。

G. 第一轮独立审阅修订(b1354ff)

审阅对象为 a9dbd66;以下为逐项处理(全部先确认反例成立再改)。

  • F33-01(P1)CanonicalizeStruct 可绕过严格约束:确认成立——json.Marshal(float64(1.0)) 产出 1,非法 UTF-8 产出 U+FFFD。现改为先递归校验内存值再序列化:拒绝 float、非法 UTF-8、非 NFC、越界整数、[]byte(隐式 base64)、自定义 json.Marshaler/MarshalText 与不支持类型。RED→GREEN:TestCanonicalizeStructRejectsLossyInput(含嵌套 map/slice)。
  • F33-02(P1)Digest/ArtifactID 接受任意字节:确认成立。现引入不透明 Canonical 类型(字段不导出,外部无法伪造),Digest/ArtifactID 改为其方法;新增 Adopt 作为存储回读路径(重新解析 + 逐字节相等),乱序键/重复键/浮点/空载荷一律拒绝。RED→GREEN:TestAdoptRejectsNonCanonicalBytes、TestZeroCanonicalHasNoIdentity、TestEquivalentJSONHasSameIdentity。
  • F33-03(P1)缺资源边界:确认成立。新增 Limits{MaxBytes,MaxDepth,MaxMembers,MaxStringBytes},在解析前/中强制,错误为 payload_too_large/too_deep/too_many_members/string_too_long;默认值为 C0 实现默认(非领域门槛),工件契约可用 CanonicalizeWithLimits 收紧。RED→GREEN:TestLimitsAreEnforced(含边界值恰好通过)。
  • F33-04(P2)职责划界:契约 §5 现分为 §5.1 C0 公共层(无损解析/数值/Unicode/规范形/域分离/身份安全/资源上限)与 §5.2 工件层 C1/C2/C3(字段白名单、schema_version、时间字段、授权语义,复用 domain/validation),并冻结调用顺序 解析 → Schema → 身份 → 发布(§5.3);明确 C0 不宣称字段校验。

测试补充:原始非法 UTF-8 与裸控制字节独立负例(TestCanonicalRejectsRawByteViolations)、内存有损转换反例、非规范字节 Adopt 拒绝、零值身份拒绝、资源上限边界、Unicode 边界向量(Hangul/预组合/astral/U+2126 必须被 NFC 分解)。Golden Vector 现为 18 正例 + 15 反例,Go 与 Python 双实现一致。

跨语言验证升级为 CI 硬门禁:.github/workflows/ci.yml 的 backend job 新增步骤 Canonical encoding cross-language vectors,直接运行 Python 参考实现,失败即失败;Go 单测在无 python3 时仍可跳过,但 CI 不允许跳过。

Unicode 数据版本政策:契约 §6 明确 NFC 接受集合由 x/text 与 CPython 的 Unicode 数据共同决定,冻结向量集是两者的兼容契约;库升级引起的分歧必须通过显式 profile 决议解决,而不是接受不同字符串集合。

(审阅过程中我自己的一个边界向量假设错误:前导组合符 \u0301a 其实 是 NFC 稳定的,已被跨语言校验器抓出并删除——这正是该门禁的价值。)

验证:gofmt -l(无输出)、go vet ./...、go test ./...、go test -race ./...、MySQL 8.4 套件 534 PASS / 0 FAIL / 4 SKIP、scripts/test-decision-cutover.py OK、Python 参考实现 OK。


H. 第二轮独立审阅修订(6252d60)

审阅对象为 b1354ff;两项新阻塞项均已先实测确认再修。

  • F33-05(P1)指针接收者 MarshalJSON 绕过校验:确认成立——实测
    CanonicalizeStruct(&ptrMarshalParent{Child: ptrMarshalChild{N: 1}}) 返回 err=<nil>,产出 {"child":{"n":999}},
    即被校验的内容与被计算身份的内容不一致。修复:hasCustomMarshaler 现同时检查指针类型(递归穿透指针种类)与值类型;
    覆盖嵌套结构体字段、指针字段、接口值、切片元素,以及指针接收者的 MarshalText。
    RED→GREEN:TestCanonicalizeStructRejectsPointerReceiverMarshaler(7 个子用例)+ 合法对照。
  • F33-06(P1)Struct 路径未在序列化前实施全部资源上限:确认成立(成员数、Map key 长度、字节预算都只在序列化之后才被 JSON 路径发现)。
    修复:validateInMemory 现在携带 inMemoryBudget,递归累计成员数(导出结构体字段计入,因为它们会成为对象成员)、
    检查 Map key 长度、并在 json.Marshal 之前累计最坏情况编码大小上界。上界假设每个字符串字节都可能被转义为 \u00XX,
    因此是保守的——该取舍已写入契约 §5.1(需要精确上限时应先自行编码再用 CanonicalizeWithLimits)。
    顺序证据(不使用内存峰值断言):新增 budget_internal_test.go,直接调用 validateInMemory(CanonicalizeStruct 在序列化前调用的正是它),
    断言其在预算处即返回错误——含越界成员、嵌套成员计数、以及指针接收者类型对校验器可见。

验证(6252d60):gofmt -l(无输出)、go vet ./...、go test ./...、go test -race ./... 全部通过;
MySQL 8.4 套件 534 PASS / 0 FAIL / 4 SKIP(4 项仍为真实 Sandbox,未执行);跨语言 CI 门禁继续运行。
另:本轮我第一次 MySQL 复跑因忘记创建 magi_ci 库而出现 71 个环境性 SKIP,已修正环境后复跑得到上述 534/0/4,特此记录以免误读。

C0 of the decision-protocol work (CAS-9). Rev4 section 2 requires a fixed
encoding contract that rejects duplicate fields and float ambiguity; the
repository had three mutually incompatible digest schemes and no duplicate-key
detection at all.

New package domain/canonical implements profile `magi-cj/1`, a strict subset of
RFC 8785 (JCS) plus MAGI input rules:

  - object keys sorted by UTF-16 code unit order, arrays keep order
  - RFC 8785 minimal escaping; HTML escaping (\u003c) is not applied
  - MAGI: decimal integer literals only within +/-(2^53-1); "1.0", "1e0",
    "-0", NaN/Infinity are rejected
  - MAGI: NFC is verified, never applied (the encoder does not rewrite what it
    signs); external byte-faithful material is explicitly out of scope
  - `null`, absent and empty are distinct; explicit null is preserved
  - strict parsing: duplicate keys (including `"a"` vs `"\u0061"`), lone
    surrogates, invalid UTF-8, raw control bytes, malformed escapes, trailing
    data and unknown artifact types all fail with a typed *Error, never panic
  - identity: digest = SHA-256("magi:canonical:v1:<type>\n" || canonical), and
    artifact_id is always derived from the digest for a closed type whitelist

Golden vectors are generated by an independent Python reference
(testdata/verify_golden.py) and reproduced by the Go implementation; the same
script is executed from the Go test suite, so the two languages must agree on
every vector (15 positive, 14 negative).

Legacy compatibility is proven, not assumed: legacy-manifest/v1,
legacy-approval-intent/v1 and legacy-a2a-request/v1 are byte-frozen by tests
that pin the exact digests already persisted in checkpoints, approvals and A2A
submission dedup keys. C0 does not re-encode, backfill or relax them.

Verified: gofmt -l (clean), go vet ./..., go test ./..., go test -race ./...
and the MySQL suites (bootstrap + adapter: 534 pass / 0 fail / 4 real-sandbox
skips) plus scripts/test-decision-cutover.py.
Addresses the first C0 review round.

F33-01: CanonicalizeStruct marshalled first, but encoding/json is lossy --
float64(1.0) renders as `1` and invalid UTF-8 becomes U+FFFD, so inputs the
contract must reject were laundered into valid JSON. The in-memory value is now
validated recursively BEFORE marshalling: floats, invalid UTF-8, non-NFC
strings, out-of-range integers, []byte (implicit base64), custom
json.Marshaler/MarshalText implementations and unsupported kinds are rejected.

F33-02: Digest/ArtifactID accepted arbitrary []byte, so non-canonical input
(reordered keys, duplicate keys, floats, empty) could obtain a stable identity.
Canonical is now an opaque type only this package can construct, and
Digest/ArtifactID are methods on it; Adopt is the checked path for bytes read
from storage (re-parse plus byte-for-byte equality).

F33-03: parsing had no resource bound. Limits{MaxBytes, MaxDepth, MaxMembers,
MaxStringBytes} are enforced before and during parsing, with explicit errors
(payload_too_large / too_deep / too_many_members / string_too_long) and
documented C0 defaults that artifact contracts may tighten via
CanonicalizeWithLimits.

F33-04: the contract conflated C0 with artifact-layer validation. It now splits
the layers explicitly (C0: lossless parsing, numbers, Unicode, canonical form,
domain separation, identity safety, ceilings; C1/C2/C3: field whitelist,
schema_version, time fields, authorization semantics, reusing
domain/validation), freezes the call order parse -> schema -> identity ->
publish, and states that the C0 layer never claims field validation.

Test/evidence additions: raw invalid-UTF-8 and bare-control-byte negatives,
in-memory lossy-conversion counterexamples (including nested maps/slices),
Adopt rejection of non-canonical bytes, zero-value identity rejection,
resource-limit boundary tests, and Unicode NFC boundary vectors (Hangul,
precomposed, astral, U+2126 which NFC must decompose). The cross-language
Python verifier is now a hard CI step instead of a skippable test.
…uct path

Second C0 review round.

F33-05: the custom-marshaler check only looked at v.Type(), but encoding/json
calls a pointer-receiver MarshalJSON on addressable struct fields. Confirmed with
`&Parent{Child: Child{N: 1}}` where (*Child).MarshalJSON returns `{"n":999}`:
the validator approved N=1 while the marshaller produced N=999, so the identity
covered different content than the validated value. hasCustomMarshaler now checks
the pointer type (recursively through pointer kinds) as well as the value type,
which covers nested struct fields, pointer fields, interface values and slice
elements, plus pointer-receiver MarshalText.

F33-06: the struct path enforced depth but not members, map-key length or a
pre-marshal size bound, so an over-budget object was fully marshalled before the
JSON path rejected it. validateInMemory now carries an inMemoryBudget: members
are counted recursively (struct fields included, since they become object
members), map keys are length-checked, and a worst-case encoded-size budget is
accumulated before json.Marshal runs. Because the budget assumes every string
byte may escape to \u00XX it is conservative, and that trade-off is documented.

Ordering is proven without memory-peak assertions: budget_internal_test.go calls
validateInMemory directly (which CanonicalizeStruct runs before marshalling) and
asserts it rejects at the budget, including a nested-member case and a check that
the pointer-receiver type is visible to the validator.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant