AGENT NETWORK · DOCS
概念与架构

五条契约

C1–C5:模块之间只经契约说话

契约在这里是什么意思

三代实现留下三条教训。v1 的组织功能长进 35 个 daemon 文件,拔不出来;1.3.0-v3 没有契约,开源时只能用斧头,删掉 683,852 行;oss-v0.1 证明了减法产品成立,但减法靠 fork —— 内核逐字拷贝,两个月就漂移。

anet4 由此只有一句命题:让"裁剪"从 git 操作变成构建配置,一套代码,N 种发行版。

要做到这件事,模块之间不能直接互相引用。它们只经契约说话:兄弟模块互不可见,三个应用(ANet / ANetHub / ANetLink)永不互相 import,只都 import ANetCore。五条契约就是这条纪律的具体形状。

#契约连接形态今天在哪
C1CapabilityProviderdaemon ↔ 一切能力来源Go 接口(进程内为主)+ HTTP-over-UDS 远程实现ANet/provider/provider.go
C2Hub Wire APIdaemon ↔ hubHTTP + AObj 签名,带线协版本头ANetHub/internal/aghub/server.go、ANet/internal/hubapi
C3Federationhub ↔ hubHTTP + AObj 签名;delivery 与 discovery 两个独立子面ANetHub/internal/federation
C4AdapterANetLink runtime ↔ 协议适配器Go 接口(AdapterSDK),编译期组合;ADAP 是进程外逃生舱ANetLink/sdk、ANetLink/adap
C5证据面所有人 → EffectRecord / AELANetCore 类型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 原文:

// Call is one capability invocation crossing C1.
type Call struct {
	// Capability is the exact capability id being invoked (taxonomy form,
	// e.g. "light.onoff"). Resolution to a provider happens before Invoke.
	Capability string
	// Args are CoreDet-encodable invocation arguments.
	Args map[string]any
	// CallID is a caller-chosen idempotency key; providers MAY use it to
	// de-duplicate retries.
	CallID string
	// CallerAID is the verified AID of the calling identity, or "" when the
	// call originates from a local surface (CLI/MCP) before any delegation.
	CallerAID string
}
 
// CapabilityProvider is C1. Implementations must be safe for concurrent use.
type CapabilityProvider interface {
	// ID is the stable provider identifier, unique within one daemon.
	ID() string
	// Capabilities lists the capability ids this provider currently offers.
	Capabilities(ctx context.Context) ([]string, error)
	// Describe returns the CID of the provider's descriptor object in the
	// local CAS ("" when the provider publishes none). The daemon mounts it
	// on the agent's ADP card without interpreting it.
	Describe(ctx context.Context) (string, error)
	// Invoke executes one capability call and reports its effect. Transport
	// or execution errors are returned as error; a reachable target whose
	// effect cannot be verified is NOT an error (effect.Unverified).
	Invoke(ctx context.Context, call Call) (effect.Effect, error)
	// Health reports provider liveness; a non-nil error marks every
	// capability of this provider temporarily unavailable.
	Health(ctx context.Context) error
}

五个方法,没有第六个。注意 Invoke 的注释:传输或执行错误作为 error 返回;够得着但效果验不了不是错误,是 UNVERIFIED。这条把 C1 和 C5 缝在一起 —— 详见下面的证据面。

还有一个可选接口:

// Priced is implemented by a provider whose capabilities cost something.
type Priced interface {
	// Price reports what a capability costs, in the hub's credit units,
	// and whether it costs anything at all. Free is (0, false), not
	// (0, true): a caller should not have to read a payment requirement
	// to learn there is none.
	Price(capability string) (uint64, bool)
}

定价属于 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>,例如:

ptz.absolute@onvif/cam-front

这串 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)。
anetlinkd --config anetlinkd.json --c1-socket /run/anetlink.sock
anet delegate <provider-aid> --capability ptz.absolute@onvif/cam-front --args '{"pan":0.2,"tilt":-0.1}'

例子里的 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 定义,两侧各自重算、从不在线上交换:

anet-relay/<action>/<aid>/<ts>

<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 的载荷无论是否经过联邦都端到端可验,联邦改变不了这一点。

收方的校验顺序(任何一步失败给带标签的错误):

  1. 联邦已开,否则 POLICY_REFUSED
  2. 体积上限
  3. v == 1,否则 VERSION_UNSUPPORTED
  4. 源 hub 在 peer 表里,否则 POLICY_REFUSED
  5. hop 未超,seen_hubs 里没有自己(环路),否则 HOP_EXCEEDED
  6. payload 是合法 base64,重算 CID 与 payload_cid 相符,否则 MALFORMED
  7. 取源 hub 的 KEL,验签,否则 INVALID_SIGNATURE
  8. 去重:同一 payload CID 七天内的第二次投递返回 DUPLICATE —— 是成功的空操作,不是错误
  9. 目的 AID 在本地,否则 UNKNOWN_DESTINATION
  10. 入信箱

一跳纪律。 出向铸出的信封 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 知道我存在"。

anet visibility federated

见证

联邦打开时,默认还会去钉每个 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:

// Adapter is C4. One adapter covers one protocol family through one of
// possibly several backends.
type Adapter interface {
	Info() Info
	// Backends returns this adapter's backends in priority order. The
	// runtime probes them and starts the adapter with the first available
	// one (or the configured override).
	Backends() []Backend
	Start(ctx context.Context, backend Backend, sink Sink) error
	Stop(ctx context.Context) error
	Invoke(ctx context.Context, call Call) (effect.Effect, error)
}
 
// Sink is where an adapter reports inventory and state. Implemented by the
// runtime; adapters must not retain it past Stop.
type Sink interface {
	UpsertDevice(d Device)
	RemoveDevice(id string)
	State(deviceID string, metrics map[string]float64)
}

两个可选接口,给"买了设备就该能用"这件事留位置: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 第一次真跑,查出三个缺陷:

  1. runtime.Invoke 对 Profile 为 nil 的设备解引用,整个进程 panic;而 claim 与 describe 之间的那个窗口每个适配器都要经过,且从 C1 socket 可达。
  2. c1serv 和 mcpserv 用 Profile.Protocol 拼设备 key,而运行时用 Adapter.Info().Name 存。编译进来的适配器两者恰好相同,所以一直没暴露;ADAP 让它们天然不同,于是这类适配器的能力全部广告得出去、调不到。
  3. onDescribe 对一个解码干净但零能力的 profile 回 accepted: true(DeviceProfile 没有 json tag,写 capabilities 而不是 Caps 会让整个能力列表消失且不报错)。

结论:逃生舱在有人真的走过之前并不存在。


C5 证据面

连接谁

所有人 → EffectRecord / AEL。C5 不是某两个组件之间的一条线,是每个组件产出的东西必须落进的同一种形状。

诚实效果状态

const (
	OK              Status = "OK"                // 执行了,效果可验证
	Unverified      Status = "UNVERIFIED"        // 按要求执行了,但这个信任等级上没有可验证的效果信号。不是失败
	Failed          Status = "FAILED"            // 执行失败
	Unavailable     Status = "UNAVAILABLE"       // 调用时目标够不着
	PaymentRequired Status = "PAYMENT_REQUIRED"  // 付了就干
)

五种,不是四种也不是三种。"不知道"和"知道没问题"是两种状态,不得合并;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),而且入链的是发给对方的那一份证据、取自同一个值 —— 一次效果的两份记述不可能互相矛盾。

anet evidence --type anet.capability.effect --since 0 --limit 50

回执与评价:没有会签的互锁

ANetCore/evidence 只有两个对象,各方各签一次,没有交互式会签:

  • Receipt 由服务方签,交付时随结果回到请求方。它指名请求方与自己,并把请求与结果都内容寻址(RequestCID / ResultCID)。
  • Review 由请求方签,评价服务方,引用那份回执的 CID。

绑定不靠握手:hub 只在两个签名都验过、且回执里的 RequesterAID 等于评价的签名者时才接受。服务方伪造不了请求方的评价签名,请求方也伪造不了服务方的回执签名,所以一个评分可证地来自一次真实交互的真实对手方。

VerifyInterlock 十项检查,每一项都对应一种伪造评分的做法:

检查挡住什么
rating 在范围内一条九星评价
同一个 interaction id评的是另一份活
评价方就是请求方给自己没委托过的活打分
被评方就是服务方夸一个不在场的人
评价锚定到这份回执真回执配一份指着别的回执的评价
请求字节哈希得到 RequestCID评的是没被请求过的活
交付物哈希得到 ResultCID评的是没被交付过的东西
回执由服务方签服务方从未签发过的回执
评价由评价方签请求方从未写过的评价
两个签名都在活跃密钥下签名时已经退役的密钥

它刻意不检查任何需要状态的东西:双方是否注册在某处、这次交互是否已被评过。那些是某个 hub 对着自己的库执行的策略,不是关于这些对象的事实。把它们排除在外,才使得一个手上只有文件的审计者能得出同一个结论。

这也是为什么 anet verify 不需要 daemon:

anet verify <interaction-id>
anet verify --receipt <base64> --kel <base64> --result FILE
anet verify --receipt <base64> --hub https://hub.agentnetwork.org.cn
anet verify --attestation <base64> --hub https://hub.agentnetwork.org.cn

第二种形式不需要 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"。这份陈述存在见证方那里,这才是它值钱的原因 —— 一份被观察对象删得掉的陈述,不是关于观察对象的证据。

anet audit-hub

契约之间怎么衔接

一次跨 hub 的付费能力调用,把五条都走一遍:

  1. 请求方 anet delegate <aid> --capability <id> --args '<json>' --pay。TaskDoc 由请求方签名,能力 id 放在 Requires、参数放在 Contexts —— 两者都在签名的规范 preimage 里(C2 的委派线协)。
  2. 消息进请求方 hub 的中继;目的 AID 不在本地,于是走 POST /fed/v1/forward 到对端 hub(C3),源 hub 签的是"这条流过了我的策略",不背书内容。
  3. 服务方 daemon 收信、验签,拿到验过的对端身份,解析出能力 id,交给 Registry.Resolve。要钱就先结算,再 Invoke,CallerAID 填的是刚才验过的那个 AID(C1)。
  4. 若这条能力来自设备,调用经 UDS 到 anetlinkd,由 runtime 选定的后端交给某个适配器执行(C4);适配器返回 effect.Effect,带状态与证据。
  5. 服务方签回执,把效果与证据写进自己的链,然后才把结果投递回去(C5)。请求方接受结果时在自己的链上留下对应记录,之后可以评价;第三方拿着回执与密钥历史就能离线复核。

每一步的输出都是下一步的输入,而每一步都只认识自己那条契约。这就是为什么 -tags no_anetlink 拿掉第 4 步之后,其余四步一行不用改。


延伸阅读