五条契约
C1–C5:模块之间只经契约说话
契约在这里是什么意思
三代实现留下三条教训。v1 的组织功能长进 35 个 daemon 文件,拔不出来;1.3.0-v3 没有契约,开源时只能用斧头,删掉 683,852 行;oss-v0.1 证明了减法产品成立,但减法靠 fork —— 内核逐字拷贝,两个月就漂移。
anet4 由此只有一句命题:让"裁剪"从 git 操作变成构建配置,一套代码,N 种发行版。
要做到这件事,模块之间不能直接互相引用。它们只经契约说话:兄弟模块互不可见,三个应用(ANet / ANetHub / ANetLink)永不互相 import,只都 import ANetCore。五条契约就是这条纪律的具体形状。
| # | 契约 | 连接 | 形态 | 今天在哪 |
|---|---|---|---|---|
| C1 | CapabilityProvider | daemon ↔ 一切能力来源 | Go 接口(进程内为主)+ HTTP-over-UDS 远程实现 | ANet/provider/provider.go |
| C2 | Hub Wire API | daemon ↔ hub | HTTP + AObj 签名,带线协版本头 | ANetHub/internal/aghub/server.go、ANet/internal/hubapi |
| C3 | Federation | hub ↔ hub | HTTP + AObj 签名;delivery 与 discovery 两个独立子面 | ANetHub/internal/federation |
| C4 | Adapter | ANetLink runtime ↔ 协议适配器 | Go 接口(AdapterSDK),编译期组合;ADAP 是进程外逃生舱 | ANetLink/sdk、ANetLink/adap |
| C5 | 证据面 | 所有人 → EffectRecord / AEL | ANetCore 类型 | ANetCore/effect、ANetCore/ael、ANetCore/evidence |
契约不是文档里的约定,是能被编译器或测试拒绝的东西:Go 接口(C1、C4)、线协版本头与跨仓字段名的契约测试(C2)、反射守卫(C1 的红线)、逐字钉死的签名 preimage(C2)、黄金向量(C5)。凡是只写在文档里、没有任何东西在验的部分,本页会写明。
C1 CapabilityProvider
连接谁
daemon ↔ 一切能力来源。本机 HTTP 服务、外部 agent、物理世界运行时(ANetLink)、模块自己实现的能力,全部从这一个门进来。daemon 侧的能力注册表 provider.Registry 属于内核,没有构建标签 —— 一个不认识 provider 的 daemon 不是 anet daemon。
接口
ANet/provider/provider.go 原文:
五个方法,没有第六个。注意 Invoke 的注释:传输或执行错误作为 error 返回;够得着但效果验不了不是错误,是 UNVERIFIED。这条把 C1 和 C5 缝在一起 —— 详见下面的证据面。
还有一个可选接口:
定价属于 provider —— 它知道自己的活值多少;是否已付款由 daemon 判定,不由 provider 判定。理由写在源码里:把付款判定放进每个 provider,等于每个能力作者都要写付费代码,于是会长出七种略有不同的"已付款"。
注册与解析
Registry.Register 在注册时就把能力索引好,冲突就整体失败(重复 provider id、或某条能力已被别的 provider 占住),不留半注册状态。Resolve 的匹配规则是先精确、再沿 . 边界向父级回退:注册了 light 的 provider 服务 light.onoff,而 lightning 永远不匹配 light。
红线:daemon 不得知道"设备"这个概念
这是 C1 最重要的一条,是 org 教训的成文形式。daemon 只知道三件事:某个 provider 声明了哪些能力、这些能力可被调用、调用返回证据。"设备"这个概念不越过 C1。
红线不靠自觉,靠两样东西守住:
一、能力 id 的形状。 ANetLink 的 C1 线协把能力表达成 <capability>@<adapter>/<device>,例如:
这串 id 里明明白白带着设备,而 daemon 仍然不知道设备是什么 —— 因为这个形式只在 ANetLink 的 northbound/c1serv 里被铸造和解析。daemon 侧的 provider/anetlink 拿到的是不透明字符串,internal/daemon/capability.go 只把它交给 Registry.Resolve,再原样放进 Call.Capability。没有一处代码在 @ 或 / 上切它。设备的存在是 ANetLink 内部的事实,不是 daemon 的知识。
二、一个反射守卫。 module/anetlink/anetlink_test.go 对 *anetlink.Module 逐个方法检查:方法名里不得含 device,返回类型名里也不得含 device;同时断言这个模块不实现 module.Payer(设备模块不该能花钱)和 module.Confidential。这个测试很钝,而钝是对的 —— 它要挡的失败是"有人加了一个看起来很有用的方法"。
把设备概念放进 daemon,代价不是抽象上的不洁,是可插拔性没了。v1 里 org 长进 35 个文件之后,减法就只能靠 fork。C1 的红线是为了让 -tags no_anetlink 这件事在两年后仍然做得到。
CallerAID:验签后的调用方身份
Call.CallerAID 是已经验过签的调用方 AID。在委派路径上,它取自这次交互的对端 —— 也就是那份 TaskDoc 的签名被验过之后确定下来的身份(internal/daemon/capability.go 里 CallerAID: ix.PeerAID)。本地面(CLI、MCP)在任何委派发生之前调用时,它是空串。
空串不是"本机、可信"。shell 模块把这一点写成了代码:CallerAID == "" 且 allow_local 未打开 ⇒ 拒绝,并把拒绝记进证据链;非空则查调用方名单,不在名单里同样拒绝。两种拒绝都答 UNAVAILABLE 并说明原因(被拒绝的人应当知道自己不被允许,而不是去猜这条能力是否存在 —— hub 已经告诉过他存在)。
理由:空值的含义是"这次调用没有携带验过的身份"。把它当成"本机、可信",会让将来 daemon 里任何一处漏填变成一次静默绕过。所以它有自己的开关,而那个开关默认关。
今天的实现状态
- 主形态是进程内 Go 接口。
- daemon ↔ 独立部署的
anetlinkd走 HTTP-over-UDS,由ANet/provider/anetlink实现。线协四个端点:GET /v0/health(204)、GET /v0/capabilities、GET /v0/describe、POST /v0/invoke。规范编码是 CoreDet-CBOR(请求带Content-Type: application/cbor、应答按Accept给),JSON 保留给调试。 - 模块配置块
modules.anetlink:socket必填,timeout_s是注册握手超时(默认 5 秒)。编译进来但没有配置,是安静的空操作;配置了却起不来,是致命错误 —— 不是记一行日志继续跑,因为"起来了但没有你要的能力"属于要靠一次莫名其妙找不到 provider 的委派才会被发现的那类失败。 - 一次 provider 调用上限 60 秒(provider 都是本地的:进程内,或一跳 UDS)。
例子里的 cam-front 取自 ANetLink 手册 §6.14 的 onvif 配置样例:设备 id 由运营者在 anetlinkd 配置里给出,不是协议规定的。onvif 是加法方向的适配器(见下面 C4),默认不编入,anetlinkd 要带 -tags onvif 构建才有 ptz.*。
与 2026-08-16 设计的差异
| 设计 | 今天 | 理由 |
|---|---|---|
Describe 返回 cid.CID | 返回 string,ANetLink 侧目前回 {"cid": ""} | CAS 目录尚未成为描述的载体。接口位置留着,但没有可挂的对象,于是 provider 描述现在挂不上 ADP 卡 |
Call 只有能力、参数、幂等键 | 增加 CallerAID | 设计时没有需要授权的 provider。shell 模块必须回答"谁在调用",没有这个字段就无法回答 |
| Go 接口(进程内)+ UDS 远程实现 | 一致 | — |
C2 Hub Wire API
连接谁
daemon ↔ hub。目录、中继信箱、评价上传、结算、会合点、准入,全部经这一条线。
形态:HTTP + 签名 + 版本头
线协版本。 请求与应答都带 X-ANet-Wire,当前值 1。两侧各自声明这个数字 —— daemon 在 ANet/internal/hubapi,hub 在 ANetHub/internal/aghub —— 而这正是重点:两个从不互相 import 的程序,仍然必须就同一件事达成一致,头是它们说这句话的方式。头缺席表示"在这之前",被接受;调用方版本高于 hub 时,hub 回 400,消息里把两个数字都写出来。
这条是有来历的:三个仓曾同时漂到三个不同的内核版本上,线上没有任何东西能说出这件事。
签名鉴权。 客户端签的字节由 ANetCore 的 relayauth 定义,两侧各自重算、从不在线上交换:
<action> 取 poll、ack、register、profile —— 动作进 preimage,所以一个动作的签名不能挪去用于另一个动作。时钟偏差与重放窗口 5 分钟。ANetCore 里有一条测试把这串字节逐字钉死:分隔符、字段顺序、动作名任何一处改动,症状都是"一把完全有效的密钥签出无效签名,全网节点进不了自己的信箱"。
POST /relay/send 是开放的(载荷端到端可验,谁投都一样);POST /relay/poll 与 POST /relay/ack 要签名,只有信箱主人能读、能清。
面有多大
internal/aghub/server.go 上 41 条公开路由(含 SPA 兜底的 GET /),外加根 mux 上的 GET /hub/identity(hub 自己的 AID 与 KEL,联邦的信任锚),以及联邦的三条 /fed/v1/*。分组:
| 组 | 路由 |
|---|---|
| 注册与目录 | POST /register、POST /profile、GET /agents、GET /agents/{aid}、GET /agents/{aid}/kel、GET /agents/{aid}/card、POST /agents/{aid}/visibility、POST /agents/{aid}/deregister |
| 中继信箱 | POST /relay/send、POST /relay/poll、POST /relay/ack |
| 评价与声誉 | POST /reviews、GET /federation/reviews、GET /agents/{aid}/reputation |
| 结算 | GET /x402/supported、POST /x402/verify、POST /x402/settle、POST /x402/redeem、GET /x402/supply、GET /x402/resource/{aid}/{capability}、GET /agents/{aid}/balance、GET /agents/{aid}/ledger、GET /agents/{aid}/redemptions |
| 发放链与见证 | GET /x402/issuance、GET /x402/issuance/head、POST /x402/witness、GET /x402/witnesses |
| 跨 hub 清算 | POST /federation/clear |
| p2p 会合 | POST /agents/{aid}/p2p、GET /agents/{aid}/p2p、GET /p2p/peers |
| 访客模式 | POST /guest/start、POST /guest/send、POST /guest/poll、POST /guest/end |
| 其他 | GET /healthz、GET /graph、GET /stats、GET /llms.txt、GET /research、GET / |
契约测试:两侧各自自洽,同时漂开
跨仓字段名由两处测试钉住:ANet/internal/hubapi/hubapi_test.go(daemon 侧的结构体)、ANetHub/internal/aghub/wirecontract_test.go(把 hub 的 Go 结构体和 webui 的 TypeScript 接口声明逐字段对读,双向:hub 发了 UI 没声明的字段报错,UI 期待 hub 从不发送的字段也报错)。
一个实例,按位置、行为、影响、发现方式:
- 位置:
ANet/internal/hubapi的AgentView。 - 行为:hub 一直在发
home_hub,而这个结构体里没有该字段,encoding/json静默丢弃。 - 影响:每个从对端 hub 学到的 agent,唯一说明"该去哪投递"的那一项被丢掉,
anet find --cap把别的 hub 上的 agent 当作本地列出。没有任何东西报错。 - 发现方式:把线协钉死。这类缺陷只有这一种发现方式 —— 两侧各自内部自洽,静静地漂开。
与 2026-08-16 设计的差异
契约形态没变:仍然是 HTTP + AObj 签名 + 版本化。面比设计宽得多:x402 结算与发放链、p2p 会合点、注销、可见性档位、准入,设计时全都不存在。
理由:这些不是设计期漏掉的条目,是实网跑出来的需求 —— 付费(D-5 / D-17)、见证(H-17 / H-18)、静默两档(H-15)、注销(D-19)、准入(2026-09-03)。设计给的是"这条线长什么样",不是"这条线上会有几个端点";前者至今成立。
C3 Federation
连接谁,以及为什么是两个子面
hub ↔ hub。delivery(投递)与 discovery(目录与评价证据)是两个独立子面,各自开关:数据目录下的 federation.json,delivery 与 discovery 各取 "off" 或 "allowlist",文件缺席等于全关。没有 hub 自动发现,peer 表是静态配置的。
拆分的理由写在源码里:一个 hub 可以替对端搬运流量,而不发布对端的目录 —— 这是两个真正不同的决定。封闭组织要的"可通信、不可见",落的就是这个拆分。
delivery:一跳,拉不动的推送
POST /fed/v1/forward。信封由源 hub 用自己的 AID 签名,签名的含义是"这条流过了我的配额与策略",不是内容背书;agent 的载荷无论是否经过联邦都端到端可验,联邦改变不了这一点。
收方的校验顺序(任何一步失败给带标签的错误):
- 联邦已开,否则
POLICY_REFUSED - 体积上限
v == 1,否则VERSION_UNSUPPORTED- 源 hub 在 peer 表里,否则
POLICY_REFUSED - hop 未超,
seen_hubs里没有自己(环路),否则HOP_EXCEEDED - payload 是合法 base64,重算 CID 与
payload_cid相符,否则MALFORMED - 取源 hub 的 KEL,验签,否则
INVALID_SIGNATURE - 去重:同一 payload CID 七天内的第二次投递返回
DUPLICATE—— 是成功的空操作,不是错误 - 目的 AID 在本地,否则
UNKNOWN_DESTINATION - 入信箱
一跳纪律。 出向铸出的信封 hop = 1;入向只做本地投递,不再向第三个 hub 转发。源码里的 MaxHop = 3 与 seen_hubs 环路检测是为多跳预留的边界,今天走不到。出向的策略是"依次问每个 peer,第一个回 202 的赢";404 表示"不是我的 agent",继续问下一个。
discovery:拉取而非推送
GET /fed/v1/cards?cursor= 与 GET /fed/v1/reviews?cursor=,两条流各自一个游标 —— 共用一个,会让繁忙的评价流拖着目录一起走,或让安静的目录把评价压住。
两条流都不鉴权,理由写在源码里:卡片是 agent 自己签的、并且明确要求被联邦的;评价是评价方已经签发、被服务方回执过的。要求签名才能读公开陈述,只拦得住诚实的人。
节奏:未收敛时 5 秒一轮,空转一轮就把间隔乘 3,上限 2 分钟;每 15 轮做一次全量重读(稳态下约半小时),所以丢过一条目录项的 hub 不用任何人重启,一小时内会自愈。
拒绝分两种,这是游标能否推进的判据:签名坏、主体与密钥史对不上 —— 永远错,推进游标;"这个 agent 现在注册在这里" —— 是关于今天的事实,不推进(ErrRefusedForNow),否则事实一变,那张卡片就被永久跳过了。
可见性三档
hub-local(默认)、federated、public。卡片流只发后两档。档位由 agent 自己定,不由 hub 运营者定 —— 这是"要不要让别的 hub 知道我存在"。
见证
联邦打开时,默认还会去钉每个 peer 的发放链头(federation.json 的 witness 字段,缺席为开,"off" 关)。理由:一次 HTTP 请求换来"对方的信用供应可以被它自己以外的人审计"。不愿意存别人账本陈述、或不愿意自己的签名出现在别人审计线上的运营者,有正当理由关掉。
与 2026-08-16 设计的差异
设计要两个 build tag(hub_fed_delivery / hub_fed_discovery),实现只有一个 no_federation。三个子面(投递、目录、评价证据)都在。
运行时两个开关仍然各自独立,所以"可通信、不可见"表达得出来 —— 靠 federation.json 的两个字段,不靠构建。代价说清楚:想要"只投递、不发目录"的 hub,二进制里仍然链接着 discovery 的代码,只是关掉了。源码没有写明为何把两个 tag 合并成一个;本页不替它编一个理由。
C4 Adapter
连接谁
ANetLink runtime ↔ 协议适配器。北向的两个面(MCP 与 C1)之下,协议之上。
接口
ANetLink/sdk/adapter.go:
两个可选接口,给"买了设备就该能用"这件事留位置:Discoverer 的 DiscoverDevices 枚举网络上有什么,Pairer 的 PairDevices 在一个时间窗内接纳新设备。两者返回的 Found 带着可直接粘贴的配置片段和厂商判定 —— 认不出厂商时字段留空,不写成"未知厂商":随机化 MAC 和跨网段主机认不出来是常态,把"没查到"呈现成"查到了是未知",是两种状态被合并。
注意 C4 里有 Device,C1 里没有。设备概念的边界正好在这两条契约之间:ANetLink 内部谈设备,北向只谈能力。
双后端制
Backends() 按优先级返回,runtime 探测后用第一个可用的,或者用运营者配置强制指定的。原则两条:宿主设施(BlueZ、已装的 OTBR、已跑的 zigbee2mqtt 或 Home Assistant)存在且健康就用它;兜底强制 —— 每个协议都必须有一条零外部依赖的自带路径,兜底缺席就不算完成。无论后端是哪一种,北向只有一个能力面,用户见能力,不见适配器,更不见后端。
编译期组合
14 个适配器,tag 两个方向都有:
| 方向 | tag | 适配器 |
|---|---|---|
| 减法(默认在) | no_<name> | modbus、mqtt、ha、zigbee、ble、btmesh、thread、opcua、bacnet、can、sim |
| 加法(默认不在) | <name> | onvif、hikvision、dahua |
Matter 是独立树,不作为 anetlinkd 插件挂载 —— 纯 Go Matter 栈是长线轨道,不阻塞发行。
ADAP:进程外逃生舱
C4 的主形态是 Go 接口,这对仓库里的 14 个适配器是对的,不会变。ADAP 是给不能成为我们二进制里 Go 包的那些适配器留的口子,源码列了三类:
- 另一种语言写的(Matter 控制器是 matter.js,为点亮一只灯泡用 Go 重写它不是任何人该做的交易);
- 以共享库形式分发、自带线程、分配器和信号处理主张的厂商 SDK;
- 干脆不愿在进程内托管的面 —— 它解析敌意输入,或链接着我们审计不了的代码。
让它不至于变成第二套架构的那个决定:逃生舱终止在同一个契约上。 一个 adap.Host 包住一个树外适配器进程,把它当作 sdk.Adapter 交给 runtime;runtime 分辨不出差别,runtime 里也没有任何东西知道"进程外适配器"这回事存在。
线协是 UDS 上的 CoreDet-CBOR —— 与 EffectRecord 和其他 ANet 对象同一种编码,所以一个 effect 穿过 socket 不需要翻译。第一版故意只有 14 个 op:hello claim describe bye get set state invoke result subscribe unsubscribe event ping error。错误标签是 SCREAMING_CASE(MALFORMED、NOT_FOUND、DEVICE_OFFLINE、ACCESS_DENIED 等)。
消息不签名:适配器不持有密钥,runtime 用桥自己的 AID 去签它产出的 EffectRecord。所以第三方适配器伪造不了证据 —— 这是这条切分的目的,不是它的疏漏。
ADAP 随 -tags adap 编入(加法方向),cmd/adapdemo 是照着线协手写、不 import adap 包的第三方示例 —— 那才是第三方作者的处境。
与 2026-08-16 设计的差异
与设计一致:Go 接口编译期组合为主,ADAP/UDS 降格为逃生舱。
有一条值得写下来的经验。ADAP 的实现与插件接线早就齐了,但除了包自身的测试之外零使用 —— "第三方进程能服务设备、运行时分辨不出差别"是一条没有实况证据的设计属性。补上 cmd/adapdemo 第一次真跑,查出三个缺陷:
runtime.Invoke对 Profile 为 nil 的设备解引用,整个进程 panic;而 claim 与 describe 之间的那个窗口每个适配器都要经过,且从 C1 socket 可达。c1serv和mcpserv用Profile.Protocol拼设备 key,而运行时用Adapter.Info().Name存。编译进来的适配器两者恰好相同,所以一直没暴露;ADAP 让它们天然不同,于是这类适配器的能力全部广告得出去、调不到。onDescribe对一个解码干净但零能力的 profile 回accepted: true(DeviceProfile没有 json tag,写capabilities而不是Caps会让整个能力列表消失且不报错)。
结论:逃生舱在有人真的走过之前并不存在。
C5 证据面
连接谁
所有人 → EffectRecord / AEL。C5 不是某两个组件之间的一条线,是每个组件产出的东西必须落进的同一种形状。
诚实效果状态
五种,不是四种也不是三种。"不知道"和"知道没问题"是两种状态,不得合并;PAYMENT_REQUIRED 也必须自己占一格,因为其余任何一个都不诚实 —— FAILED 说有东西坏了,UNAVAILABLE 说对端什么都没试,两者都会把调用方送去找一个并不存在的故障。
Effect 的可验证判据很紧:Verifiable() 只在状态为 OK 且 带有 metrics 记录时为真。
证据(Evidence)与 Quirk
effect.Evidence 八个字段:Requested(请求了什么)、Protocol、NativeAck(协议本身是否确认)、ObservedState(回读实际报了什么)、LatencyMS、VerifyTrust(效果验证等级)、AuthTrust(身份认证等级)、Quirk。
Quirk 记的是"这次读数应用了哪条厂商偏差修正"。修正过的值不是设备放到线上的值,分不清两者的消费方,没法审计这次修正 —— 而那正是 Evidence 存在的目的。
这个字段丢过一次,四件事:
- 位置:
ANetLink/northbound/c1serv的evidenceWire。 - 行为:该结构体缺
Quirk,修正标签止步于此。 - 影响:C1 以北的消费方看到修正后的数字,而无从得知它被修正过 —— 修正要么到达每一个面,要么一个都不到。
- 发现方式:
TestEveryEvidenceFieldCrossesTheWire,把线协结构体逐字段对着effect.Evidence读。
AEL:每个身份自己的防分叉链
ANetCore/ael 实现 P6 Agent Event Ledger:每 DID 一条、签名、仅追加、分叉可证的哈希链。
- 记录 id 是 keys 1..10 preimage 的 CID ——
chain_did、seq、signer_aid、key_state_seq都折进 id,所以跨链重放不成立。 - 创世的
prev_id是CID(CoreDet-CBOR("anet:ael:genesis"))。 - 每次 append 与 import 都 verify-before-store。
UNIQUE(chain_did, seq)是分叉陷阱:同一个(chain_did, seq)上出现两个不同的 id,产出EquivocationProof并把该链粘滞地置为CHAIN_QUARANTINED—— 不是 INSERT-OR-IGNORE。
daemon 侧落地为一个仅追加文件,每行一条 base64 的 CoreDet-CBOR 记录,每次打开都经 ael.Import 全量重建并验证:被改过或分叉的文件拒绝加载,而不是安静地提供一份被篡改的历史。
内核记的事件类型:anet.capability.effect、anet.interaction.receipt、anet.delegation.sent、anet.result.accepted;模块各自还有 anet.shell.command / anet.shell.refused、anet.payment.authorized / anet.payment.settled、anet.credit.redeemed、anet.issuance.head_seen、anet.voucher.redeemed / anet.voucher.refused。
顺序是契约的一部分:一次能力调用的效果在应答发出之前入链(deliverCapabilityResult 先 ledger.Append 再 relaySend),而且入链的是发给对方的那一份证据、取自同一个值 —— 一次效果的两份记述不可能互相矛盾。
回执与评价:没有会签的互锁
ANetCore/evidence 只有两个对象,各方各签一次,没有交互式会签:
- Receipt 由服务方签,交付时随结果回到请求方。它指名请求方与自己,并把请求与结果都内容寻址(
RequestCID/ResultCID)。 - Review 由请求方签,评价服务方,引用那份回执的 CID。
绑定不靠握手:hub 只在两个签名都验过、且回执里的 RequesterAID 等于评价的签名者时才接受。服务方伪造不了请求方的评价签名,请求方也伪造不了服务方的回执签名,所以一个评分可证地来自一次真实交互的真实对手方。
VerifyInterlock 十项检查,每一项都对应一种伪造评分的做法:
| 检查 | 挡住什么 |
|---|---|
| rating 在范围内 | 一条九星评价 |
| 同一个 interaction id | 评的是另一份活 |
| 评价方就是请求方 | 给自己没委托过的活打分 |
| 被评方就是服务方 | 夸一个不在场的人 |
| 评价锚定到这份回执 | 真回执配一份指着别的回执的评价 |
请求字节哈希得到 RequestCID | 评的是没被请求过的活 |
交付物哈希得到 ResultCID | 评的是没被交付过的东西 |
| 回执由服务方签 | 服务方从未签发过的回执 |
| 评价由评价方签 | 请求方从未写过的评价 |
| 两个签名都在活跃密钥下 | 签名时已经退役的密钥 |
它刻意不检查任何需要状态的东西:双方是否注册在某处、这次交互是否已被评过。那些是某个 hub 对着自己的库执行的策略,不是关于这些对象的事实。把它们排除在外,才使得一个手上只有文件的审计者能得出同一个结论。
这也是为什么 anet verify 不需要 daemon:
第二种形式不需要 daemon、不需要 hub、不需要网络,也不需要信任其中任何一个:一份回执、签名者的密钥历史,再加上回执声称覆盖的那些字节,答案由算术给出。这条命令存在之前,README 里"任何人可验证、无人能伪造,连网络运营方也不能"这句话是对设计成立而对被它说服的人不可用的 —— Receipt 住在 internal/ 里,Go 禁止任何其他模块 import 它。
与 2026-08-16 设计的差异
设计是"ANetCore 类型,design3 原样",今天超出设计:hub 自己的账本也上了链,并接受第三方见证。
理由写在 internal/aghub/issuance.go 里,是一条不对称:转账有对手方 —— 一次结算产出 hub 签名的收据,由付款方持有,并同时进入双方的证据链,hub 改动其中任何一份都会和它拿不到的记录相矛盾。发放没有对手方 —— 一笔 grant 凭空造出信用,另一头没有人,hub 之外没有任何东西为它作证。
于是每一次供应变化都成为一条仅追加、哈希链接、hub 签名的链上记录,公开可读。它买到的是:取过第 N 条记录的读者,日后可以验证第 N 条没被改过、并且仍然链向 N+1 —— 重写历史会产生一个分叉,任何持有旧副本的人都能演示出来;并且链上的和会与余额表对账,两者不符就说明 hub 动过信用而没有记账。
它买不到的,同样说清楚:它不阻止发放,并且对一个从未见过这条链的读者什么都不提供 —— 一个从未被观察过的 hub 可以出示任何一条链。见证关的是第二个缺口:见证方定期取链头并签"某时刻,该链的链头是 N,id 是 X"。这份陈述存在见证方那里,这才是它值钱的原因 —— 一份被观察对象删得掉的陈述,不是关于观察对象的证据。
契约之间怎么衔接
一次跨 hub 的付费能力调用,把五条都走一遍:
- 请求方
anet delegate <aid> --capability <id> --args '<json>' --pay。TaskDoc 由请求方签名,能力 id 放在Requires、参数放在Contexts—— 两者都在签名的规范 preimage 里(C2 的委派线协)。 - 消息进请求方 hub 的中继;目的 AID 不在本地,于是走
POST /fed/v1/forward到对端 hub(C3),源 hub 签的是"这条流过了我的策略",不背书内容。 - 服务方 daemon 收信、验签,拿到验过的对端身份,解析出能力 id,交给
Registry.Resolve。要钱就先结算,再Invoke,CallerAID填的是刚才验过的那个 AID(C1)。 - 若这条能力来自设备,调用经 UDS 到
anetlinkd,由 runtime 选定的后端交给某个适配器执行(C4);适配器返回effect.Effect,带状态与证据。 - 服务方签回执,把效果与证据写进自己的链,然后才把结果投递回去(C5)。请求方接受结果时在自己的链上留下对应记录,之后可以评价;第三方拿着回执与密钥历史就能离线复核。
每一步的输出都是下一步的输入,而每一步都只认识自己那条契约。这就是为什么 -tags no_anetlink 拿掉第 4 步之后,其余四步一行不用改。