@forumlayer/contracts
v0.1.0
Published
Canonical ForumLayer schemas, operation registry, and runtime validators
Maintainers
Readme
@forumlayer/contracts
Research use only under LicenseRef-ForumLayer-Research-Only-1.0; see LICENSE in this package.
Commercial use, production deployment, and customer-facing services are not permitted.
schema 是唯一事实源。 TypeScript 类型由 pnpm codegen 从 schemas/*.json 生成,
src/generated/ 下的文件不要手工编辑 —— 改类型请改 schema 再重跑。
为什么校验器比类型重要
TypeScript 类型只在编译期存在,且 json-schema-to-typescript 处理顶层 allOf 时
会退化成开放对象([k: string]: unknown)。跨越信任边界时以运行时校验为准:
import { assertValid, type EvidencePackV1 } from '@forumlayer/contracts';
const pack = assertValid<EvidencePackV1>('evidencePack', await res.json());入站请求、出站响应、从存储读回的 pack —— 这三处必须校验。内部函数之间传递 已校验过的对象不必重复调用。
这些 schema 守住的是什么
不是"字段类型对不对",而是合法 JSON 能不能自相矛盾。例如:
insufficient_evidence必须伴随search_exhaustiveness.status = completed—— 这是全额扣点的机械判据,不能靠执行器自报effect = DENY时全部allowed必须为假- 内容已删除或社区转私密时
usable_for_new_claims必须为 false - pack 体内不接受
settlement_status/task_points_charged—— 结算发生在首字节之后,发第一个字节时那个值还不存在 - pack 体内不接受
lifecycle.status—— 当前服务状态的唯一事实源是pack_registry matched的地域判定必须有支撑信号 —— 无 basis 的 matched 就是把 unknown 写成 matched
每一条都有对应的测试(test/invariants.test.ts),失败时能直接指回文档里那句"必须"。
operation registry —— 三通路的唯一权威
registry/operations.v1.json 是 REST / Remote MCP / stdio adapter 三条通路的唯一事实源
(ARCHITECTURE §10.3.5、ADR-001)。pnpm codegen 由它完全生成四份产物:
| 产物 | 用途 |
|---|---|
| src/generated/operations.ts | TS 常量、OperationId / ToolName / OperationCategory / PolicyDomain / PolicyPurpose / PolicyAction / DecisionPoint 类型、REGISTRY_DIGEST、GENERATOR_PROTOCOL_VERSION |
| generated/openapi.v1.json | REST OpenAPI 3.1,schema 全部 bundle 进 components.schemas,无外链 |
| generated/mcp-tools-list.v1.json | Remote MCP 的 tools/list 响应,inputSchema / outputSchema 自包含 |
| generated/adapter-descriptors.v1.json | stdio adapter 的 REST 映射与幂等/成本元数据 |
几条不能绕的规矩:
operation_id是无语义 opaque ID(op_0001),永不复用、永不物理删除。 废弃只置deprecated_at/sunset_at。破坏性变更走新 tool 名 + 新 ID,不是改旧条目。新增 ID 必须先进 ledger:
pnpm allocate:operation-id显式追加,codegen 不会替你补 —— 让生成器改自己的输入等于给不可变性检查开后门。policy_domain决定这个 operation 走不走 PDP(ENTITLEMENT_MODEL §5.0、ADR-002 §10.3,2026-08-04 裁定):entitlement= PDP 判定 + scope;control_plane= 仅 scope,不过 PDP。 判据不是"感觉像控制面",而是:该 operation 的输入或输出中是否出现任何 artifact、artifact 衍生物、 或从外部内容导出的统计量 —— 出现即entitlement,一个字段都算。 当前research.cancel与usage.get是control_plane(owner 点名裁定),其余全部entitlement。research.get保守留在entitlement—— §5.0 的判据是单向的(出现 artifact ⇒ 必须 entitlement, 不出现并不强制 control_plane),而硬约束 4 说"归错域按entitlement处理"。实现方不自行扩大豁免面。 ⚠️ 两者曾经共用run-state.v1,那本身就是一条现行违规:run_state.v1带pack_id/revision, 而 contracts 自己认定 pack / community 的句柄也是 artifact 衍生物 ——「拿到句柄还要走pack.get才有正文」不改变它是句柄这件事,§5.0 的判据也没有「只有正文才算」的例外。owner 已裁定 cancel 是 控制面,所以要改的是 schema 不是域:cancel 现在返回专用的run_cancel_result.v1(schema_version+run_id回显 +cancellation_accepted_at,没有任何 artifact 句柄),research.get继续用run_state.v1并保留 entitlement 判定。 结算字段刻意不进取消回执:取消何时 settle 属于 ARCHITECTURE §10 尚未裁定的 cancel / disconnect 传播语义,写成必填等于替 owner 把「取消与结算原子完成」裁定掉, 写成可选则把「没算完」与「算完了没告诉你」变成同一种响应。结算经research.get读。control_plane的policy_purpose/policy_action表达为「字段缺席」,不是哨兵值。null/"NOT_APPLICABLE"/ 空对象都是错的:任何一次漏判空,那个值就会以真实判定输入的身份进 PDP, 而 registry 没有任何办法阻止它。字段缺席则查表必然拿不到东西,消费方按既有规则 fail-closed。 schema 层用if/then双向强制:entitlement⇒ 两者必填;control_plane⇒ 两者必须缺席。purpose 与 action 是三个正交字段,不是一个(ADR-002 §1):
operation_category(文档分组与用量报表,不参与判定)·policy_purpose(ENTITLEMENT_MODEL §3.2 词表)·policy_action(§3.1 词表)。 三者各自独立标注,不允许存在operation_category → policy_purpose的映射表。policy_purpose的唯一来源是 registry,服务端查表得到后注入TrustedContext,绝不从入站 request 读取。policy_action是decision_point → policy_action[]的映射,不是平铺的上界集合(ADR-002 §1):"policy_action": { "PLAN": ["PROCESS", "QUOTE", "READ"], "PERSIST": ["PERSIST"], "COHORT": ["SERVE"] }判定点取值来自 ARCHITECTURE §3 的三个 ★ 判定点,按生命周期先后:
PLAN(步骤 4)·PERSIST(步骤 9,2026-08-04 由 ADR-002 §10.2 裁定新增) ·COHORT(步骤 10)。 字面量与packages/pdp的PLAN_PDP/PERSIST_PDP/COHORT_PDP一致;registry 不自造第四个判定点 —— 新增判定点是架构决策,要走 owner 裁定(PERSIST 就是这样加进来的)。 每个判定点允许哪些 action 照架构原文写死在 schema 里 (PLAN ⊆{QUOTE, READ, PROCESS}· PERSIST ={PERSIST}· COHORT ={SERVE}), 所以"把一个 action 塞进架构没给它安排位置的判定点"在 schema 层就失败。registry-core.mjs里还有第二份ARCHITECTURE_DECISION_POINT_ACTIONS,与 schema 字面量逐值比对。 不是重复而是双录:词表镜像检查只保证 action 属于全局policyAction词表, 把PERSIST判定点的 items 改成["READ"]它照样通过 —— 而判定点存在的意义恰恰是 "这个 action 只能在这个位置被判"。 平铺集合回答不了 PDP 真正要问的那个问题——"这个 operation 在这个判定点允许哪个 action"—— 映射责任会被推给调用方,不同服务各自过滤出不同结果,registry 的唯一事实源地位就被架空。 映射里没有的判定点 = registry 没有为该 operation 在该判定点授权任何 action,消费方必须 fail-closed。查表用
lookupOperation(operationId, decisionPoint)(或lookupOperationByToolName), 一次原子产出三样共享同一个operation_id的东西:注入TrustedContext的contextBinding、 PDP 在该判定点的decisionBinding、handler 的handlerBinding。只要判定期望,用registryExpectationFor(operationId, decisionPoint)。控制面走另一个入口:
lookupControlPlaneOperation(operationId)(或…ByToolName), 它不吃decisionPoint(控制面路径上没有判定点可选),也不给decisionBinding—— 给它一个判定绑定就是在鼓励别人拿去判一次不存在的判定。两个入口互不相交:控制面 operation 在
lookupOperation()下永远返回undefined,反之亦然。 于是"我该不该过 PDP"不是调用方读一个布尔字段自行决定的,而是由"哪个入口查得到"回答。contextBinding是判别联合(policyDomain: 'entitlement'才有policyPurpose), 不是"purpose 可选" —— 后者少写一次判空就会把undefined当 purpose 传进 PDP。 安全默认见policyDomainFor():缺失、非法取值、未知 ID 一律entitlement(§5.0 硬约束 4,不得 fail-open)。 本包故意不提供任何"把所有判定点的 action union 起来"的便利函数 —— 那种函数存在的唯一用途就是制造 ADR-002 §1 要消灭的那个缺陷。 只比对 purpose 不构成"两条链路查的是同一个 operation"的证明:当前全部已发布 operation 的policy_purpose都是PRODUCT_SERVING,purpose 相等是常态,所以三个 binding 都带operation_id。§5.0 的四条硬约束,每条都要有机器检查("缺一条这个维度就会变成绕过权利判定的后门"):
| 硬约束 | 机器检查 | |---|---| | 1 编译期固定、绝不从入站 request 读、进 digest 与身份字段 | schema 必填 +
policy_domain进publicationProjection与IDENTITY_FIELDS+checkPolicyFieldsAreNeverInbound():任何 operation 的 input schema 闭包里不得出现判定字段,也不得$refcommon 的四个判定词表。禁名按规范化后的完整名字比较(去掉所有非字母数字字符再小写),因此policy_domain/policyDomain/Policy-Domain/POLICY_DOMAIN一并禁掉 —— 真正做分流的routePolicyDomain()认的是 camelCase,只禁 snake_case 等于没禁。禁名分两类:判定值(policyDomain/policyPurpose/policyAction/policyDecisionPoint)与判定记录的选择器(operationId/toolName,它们是lookupControlPlaneOperation*()与routePolicyDomain()的连接键 —— 入参能声明它,客户端就能在走 A 路由时自称正在执行 B)。合法用途请用带前缀的名字(target_operation_id),规范化后不命中。入参闭包一律不支持patternProperties:任意正则与"规范化后等于禁名"的无限名字集合是否相交,静态判不了(^Policy-一个已知拼写都不匹配,却接受Policy-Domain) | | 2control_plane不得返回任何 artifact 派生数据 |checkControlPlaneCarriesNoArtifactData(),三层,见下 | | 3 不豁免租户隔离、配额、审计、删除 | contracts 侧只能守住"scope 仍必填、幂等键仍强制、仍进发布物";租户隔离与配额的执行不在本包,由 PEP / PDP 那条线负责 | | 4 缺失或非法一律按entitlement| schema 必填 +policyDomainOf()/policyDomainFor()的安全默认(未知 ID 也返回entitlement)+ 控制面查表只认严格相等的control_plane|硬约束 2 的三层是独立的,缺一层就有洞:
- 第 1 层(ADR 原文口径):控制面 operation 的 input 与 output 的
$ref闭包里不得出现ARTIFACT_SCHEMA_IDS(evidence / pack / community 三族及其*-ref句柄、delivery-manifest、 quote、run-submit-result、workspace-*)。原文只说 output,这里按 §5.0 的分类判据取严。 - 第 2 层(更重要):闭包里每一份可达定义都必须在
CONTROL_PLANE_SAFE_SCHEMAS里, 且规范化正文的 SHA-256 必须匹配。§5.0 真正担心的是"往一份已经判定为安全的 schema 里 加一个字段" —— 加字段既不新增 schema、也不新增 artifact$ref,第 1 层完全看不见它。 正文钉死之后,加一个字段就必须显式改这张表,于是这次分类判断一定会出现在 diff 与 review 里。 - 第 3 层(判据本身):闭包里不得声明 artifact 句柄字段(
pack_id/packId/community_id/artifact_id/evidence_id/revision…),覆盖嵌套、数组、组合与条件分支、const/enum字面量,以及required/dependentRequired/dependentSchemas的触发键 —— 它们不描述 形状,但点名说了这个字段存在。第 2 层只能说"正文变了,请重新判一次",判断仍留在人脑里; 这一层把判断写成代码。比较用规范化后的完整名字而不是词根包含 —— 词根扫会误伤config_revision这类合法字段,而误报会逼人把检查关掉。控制面闭包不支持patternProperties与propertyNames(字段名不是静态可枚举的)。 ⚠️ 光扫名字挡不住开放对象:{metadata:{type:"object"}}里一个句柄名都没写,实例照样能带{metadata:{pack_id:…}}。所以这一层还要求控制面闭包的每个实例位置都能自证拒绝未声明内容 —— 与硬约束 1 的入参侧共用同一份判据(collectInstancePaths()+provesClosed()), 不另写简化版:简化版会在allOf/ 条件分支 /unevaluatedProperties上给出错误答案, 而错的方向是放行。 ⚠️ 即便如此,它仍是危险名字 + 结构封闭的检查,不是"无 artifact 数据"的完整语义证明: 语义仍由第 2 层的正文哈希与 review 承担。
CONTROL_PLANE_SAFE_SCHEMAS不得有死条目(测试钉死:它必须与控制面 operation 的实际可达面 一一相等)。一条没人用得到的"已判定安全"条目不是无害的库存,它是一扇开着的门 —— 将来谁$ref到它,三层检查都不会说话。run-state/run-completion因此从安全清单移到ARTIFACT_SCHEMA_IDS:它们带 pack 句柄,第 1 层应当用判据拒绝它们,而不是靠第 2 层的"你没登记"。 闭包按片段求(reachableRefs()),不是按文档:整份common.v1里有artifactType这类外部数据词表,放行整份等于给控制面输出开一扇$ref common#/$defs/artifactType的门。 同理,入站禁令也从入参节点出发 —— 否则入参只用了common#/$defs/uuid也会因为 common 里存在policyDomain而误报。分类口径写死在
CONTROL_PLANE_SAFE_SCHEMAS的注释里:聚合的 Run 执行量、作业终态与 点数结算依据属于租户自己的作业 / 账务状态(owner 裁定 cancel 为控制面时明确纳入的范围)—— 但"属于账务状态"只说明它可以进控制面,不代表它必须进:run_completion.v1同时带着pack_id/revision,那一份因此整体不进控制面,取消回执改用不含句柄的专用 schema; 按社区、来源、artifact 类型等维度拆分的命中量、覆盖量或内容成员关系属于外部导出数据, 一律不得进入控制面 —— §5.0 的反例警告说得很清楚:usage.get一旦返回"本月检索了哪些社区" 就立刻变回entitlement。- 第 1 层(ADR 原文口径):控制面 operation 的 input 与 output 的
一个 operation 可以有多个成功分支(ADR-002 §2)。
research.run声明200 → ok(同步返回 pack) 与202 → accepted(async=true,envelope.run_ref.run_id非空、result=null)。客户端凭run_id调research.get,wire 中没有poll_url。生成器与测试都不得假设"只有一个成功状态码"。planned 不进任何生成物;deprecated 必须继续进 —— 一置废就消失等于物理删除。
registry_digest(JCS + SHA-256)是全链路唯一的同一性标识,握手、发布物、CI 断言用的都是它。它的定义是「整份发布物的身份」,不是「wire 校验语义的身份」(ADR-002 §8)。原先两层不一致 —— operation 层把
description当可变字段不进摘要,schema 层却哈希整份正文(改title就变 digest)。 现在统一按前者:发布物的任何字节变化都改变 digest,客户端据此判断"要不要重新拉取", 而不是判断"语义有没有变"。| 变化 | digest | |---|---| | 缩进 / 键序 / 条目顺序 / scopes 与 action 的顺序 | 不变 | |
planned条目的任何变化 | 不变(它们不进任何生成物) | |description/display/ 成功分支的description| 变(它们确实出现在 OpenAPI 与tools/list里) | |registry_version、deprecated_at/sunset_at/replaced_by| 变(同上) | | 任一语义字段、成功分支集合、被引用 schema 的正文 | 变 | | 生成器产出逻辑(经GENERATOR_PROTOCOL_VERSION) | 变 |GENERATOR_PROTOCOL_VERSION参与摘要(ADR-002 §8):同一份 registry + schemas 下, 改变 OpenAPI 信封拼装、MCP union 生成或 descriptor 字段集合,产出的 wire 已经变了而输入一个字节没动。 改动任何影响生成物字节的生成器逻辑,必须 bumpscripts/lib/registry-core.mjs里的这个常量。Protocol 1 = 首次引入、尚未对外发布的那一版实现。首次冻结/发布之前还没有外部消费者拿它 做过握手或缓存身份,反复 bump 只会造出从未存在过的历史版本号;首次合入或发布之后一律递增。
digest 与不可变性 gate 回答的是不同问题,不矛盾:gate 说"这个改动允不允许", digest 说"发布物变没变、要不要重新拉"。可变白名单(
description·display· 成功分支的description·sunset_at·replaced_by的一次性补充)里的字段改动 gate 通过且 digest 必变,两条由测试同时钉死。不可变性 gate 按字段比较
rest,不整体比较(ADR-002 §8):身份是method+path(含占位符名字)- 成功分支的
{status, envelope}集合;分支description在可变白名单里。 整体比较会把明确可变的分支 description 判成"REST 绑定被改动",与 digest 的划分自相矛盾。
- 成功分支的
schema
$id必须唯一(ADR-002 §8):loadSchemas递归读取整个schemas树(npmfiles会把这棵树整体打包),缺$id或任意层级两个文件声明同一个$id一律抛错并报出两个文件名。schemas根与.json条目必须是真实目录/普通文件,软链与特殊文件 fail closed,避免文件系统 loader 与 merge-base git tree reader 得出两种结果。重复的$id会让"哪份正文进 digest / 进发布物"取决于文件排序。scripts/codegen.mjs复用同一个 loader,不维护第二份检查;嵌套 schema 生成同层级类型目录,两个来源若 映射到同一个输出路径,或 schema 撞到index.ts/operations.ts固定生成物,都在写文件前失败,不能静默覆盖。pnpm check:registry与目标分支的 merge-base 比较(不是HEAD^),并且已接进pnpm test—— 根pnpm test会跑它,该任务键关掉了 Turbo 缓存(结论依赖 git 状态,按 inputs 缓存会假绿)。 显式基线解析不到、浅克隆找不到 merge-base,一律 fail closed。端到端负例(同一次变更里既改 registry 又改 ledger)见test/registry-gate.e2e.test.ts。⚠️ CI 接线仍是外部前置条件(ADR-002 §7.6):仓库里还没有
.github/。引入 CI 时必须同时配置 PR 上REGISTRY_BASE_REF="origin/$GITHUB_BASE_REF"+ 足够的 checkout 历史, 并给主分支 push 一个明确基线(如HEAD~1)—— 否则主分支会永远停在merge-base === HEAD的严格模式失败上。
scope 词表已由 owner 于 2026-08-04 签字(ADR-002 §9.1):原 research:execute 拆成
research:run 与 research:cancel —— 合成一条会让"只读 + 可取消"这种最常见的 Agent 授权配置
无法表达,取消是止损动作,通常应该比发起更容易获得。其余值照签字形态冻结。
quote:execute 与 usage:read 按 §9.1 本轮不随计价契约一起冻结:没有任何已发布条目引用它们,
改动只影响 planned 条目。
将来的 export / deletion / workspace write 等 scope 走新增而不是拆分现有值,因此不构成破坏性变更。
- scope 词表是 registry-only 的,已从
common.v1拆出到schemas/operation-scope.v1.json(ADR-002 §8 §9.1)。 原先它寄居在common.v1,而common.v1是 wire 闭包成员 —— 会被bundleSchemas整份嵌进 OpenAPI 的components/schemas与 MCP 的自包含 schema,也整份进registry_digest的 schema 闭包。 于是标着"本轮不冻结"的提案值照样出现在发布物里:文档说未发布、产物实际发布。 没有任何 wire schema 引用过 scope 词表,只有operation-registry.v1.json引用它,因此它独立成文件后 不进 generated wire/catalog JSON 的 schema bundle(OpenAPIcomponents.schemas/tools/list的自包含 schema), 也不进 digest 闭包 ——operation-scope.v1.json不是 wire 根,也没被任何 wire 根$ref。准确口径:它仍然参与本包自己的 schema→TS codegen(
src/generated/operation-scope-v1.ts), 那是开发态类型,不是对外 wire 契约;文档根本身就是冻结词表,所以生成的类型只含那 6 个值。operation-scope.v1.json的文档根= 已签字冻结的 6 个值,只有它们可以被active/deprecated条目使用: 这条由operation-registry.v1.json的 lifecycle 条件(schema 层)+checkPublishedScopesAreFrozen(不变式层)双重强制,不是注释。planned 条目要激活,必须先把它引用的 scope 加进冻结词表 —— 那是 owner 的显式冻结动作。⚠️ schema 能强制"没冻结就不能发布",强制不了"冻结的人真的拿到了签字", 后者靠 review 流程。$defs/registryScopeName= 只校验命名形态、不冻结取值,planned 条目的提案值走它。提案可以未定,但笔误不行。- 已发布条目实际持有的 scope 值照常出现在发布物里 —— OpenAPI 的
x-scopes、tools/list的_meta.scopes、adapter descriptors。那是数据,该发布就发布;不发布的是尚未冻结的词表。 - 对照:
policyAction/policyPurpose这类 ENTITLEMENT_MODEL 已定义的完整词表留在common.v1, 进 digest 是可接受的,不要顺手一起拆走。判据是"这份词表是不是 wire 语义的一部分",不是"名字里有没有 policy"。 - "未冻结"只约束发布,不代表 planned 条目是自由涂改区。 它引用的 scope 名仍然是身份字段,
受不可变性 gate 保护(
scopes在SET_IDENTITY_FIELDS里)。owner 批准同名 ⇒ 把值加进冻结词表、 lifecycle 改 active,scopes一个字不动,gate 通过;owner 否决这个名字 ⇒ 只能按既有规则走 新 tool 名 + 新 operation_id,不能原地改名再激活。 - 验收口径写清楚:未冻结的 scope 不进 generated wire/catalog 产物(
generated/*.json、src/generated/) 与 digest 闭包。它们仍然存在于registry/operations.v1.json的 planned 条目里,而registry/在package.json的files与./registry/*导出里 —— 也就是说 npm 包仍然带着这份开发态 canonical registry 原文。那是 registry 的输入,不是 generated callable catalog;"没发布"指的是没被生成成 可调用的 operation、也没被冻结成对外契约,不是"这段文本不随包分发"。dist/同样在files里, 发布前必须重跑pnpm codegen && pnpm build,否则陈旧构建产物会把已经拆掉的词表带出去。
计价相关的两个 operation 按 PRODUCT_PLAN D28 维持 planned:P1 前期免费,
usage.quote 与 usage.get 作为独立入口不上线,usage.v1 的 wire shape(ADR-002 §7.2)不冻结。
⚠️ 但
quote.v1已按 ADR-002 §10.4 解冻并正常冻结。 D28 的措辞据此修正: 暂缓的是货币价格契约,点数不在暂缓之列 —— 点数是 D23 定义的内部工作量单位, 对外露出它不等于开始收费;没有它dry_run基本没有实用价值,而 ARCHITECTURE §10.3.5 又要求高成本 tool 必须实现dry_run。所以quote.v1照常出现在发布物里 (它是research.run的dry_run载荷,ADR-002 §7.4),解冻的是 schema,不是入口。
quote.v1中不得出现任何货币、价格、折扣、税相关字段 —— 这条由checkQuoteWireHasNoPricing()机器强制,挂在不变式上(codegen /check:registry/ vitest 三处都跑): 属性名走正向白名单(新增任何字段先失败一次,再显式判断它是不是货币口径), 货币词根扫描作为第二层兜住嵌套字段。只用关键词黑名单是不够的 ——rate/subtotal/unit_value这类别名永远漏得掉。 ⚠️ 免费期不通过改cost_class实现。cost_class描述的是"这个 operation 本质上是否消耗点数", 是固有属性;免费期用额度授予实现。把task_points改成free会让恢复收费时的cost_class变更成为破坏性契约变更,并且免费期的全部用量记录会缺少可核对的点数口径。research.run因此仍然是task_points。
发布阻断清单:合入与发布是两道门
pnpm test 全绿不等于 contracts 可发布。测试守的是"合法 JSON 不能自相矛盾",
守不了"这些语义有没有被 owner 拍板"。未决项记在 registry/release-blockers.v1.json,
由 pnpm check:release 机器强制:只要那份清单非空就失败。
它管的是发布,不是合入(ADR-002 §10.1 裁定):清单非空时可以 commit 并继续开发,
只是不得对外发布 —— 一道门会让整条开发线停在裁定上,而裁定往往要等真实数据。
CI 的形态与之对应:verify 每 PR 必过管合入,release-gate(仅 tag 与手动触发)管发布,
publish job needs: release-gate。它因此故意不接进 pnpm test。
当前清单为空 —— ADR-002 §10 的七条裁定(2026-08-04)把原有四条全部消解:
| 原 id | 怎么消解的 |
|---|---|
| PERSIST_DECISION_POINT_UNRESOLVED | §10.2 裁定新增第三个判定点 Persist PDP(ARCHITECTURE §3 步骤 9),research.run / research.resume 据此声明 PERSIST:["PERSIST"] |
| CANCEL_POLICY_DOMAIN_UNRESOLVED | §10.3 裁定新增 policy_domain 维度,research.cancel / usage.get 归 control_plane,不再硬标一个不存在的 cohort 判定 |
| PRICING_WIRE_STILL_PUBLISHED | §10.4 裁定暂缓的是货币价格契约,点数不在暂缓之列;quote.v1 正常冻结,不是"仍然违规"而是"它本来就该在" |
| SECTION_7_NOT_APPROVED | §10.5 三条领域语义(Run 结算 / community 双阈值 / 结构化版规三态)全部认可 |
⚠️ 空清单不等于"没有任何遗留风险",它只等于"没有等待 owner 拍板的事项"。 ADR-002 §7.8/§8 要求的已发布
$id历史正文不可变门禁现已接入check:registry: 它从目标分支 merge-base 读取 base registry 与 schemas,以 base 的 active/deprecated wire$ref闭包为冻结范围,按$id(不按路径)比较 canonical 正文。删除或原地改写 已发布$id会失败;base planned / 不可达 schema 与 current 新增主版本不受影响。⚠️ 另一条遗留:
registry/已进版本库,policy_domain/policy_purpose/policy_action已受 merge-base gate 保护;但现有 gate 没有 owner-approved breaking change 的机器化出口。 下次再发生这类 owner 批准的判定输入变更之前,需要设计精确绑定from/to的审批或迁移机制, 不得临时加一个宽泛 waiver(而且审批记录必须先独立合入受保护基线,同一个 PR 里 自己写一条 approval 只能证明"有人写了 approved_by")。
policyPurpose / policyAction 是 ENTITLEMENT_MODEL 词表的镜像,不是第二个权威 ——
packages/policy-schema 落地后必须改为由它生成或 $ref 并加一致性门禁(ADR-002 §7.0)。
active 条目的 input_schema_ref / output_schema_ref 必须解析到仓内真实 schema。
悬空 ref、占位 ref 在测试里直接失败 —— 这条是防止 registry 退化成一张漂亮的空表。
错误重试语义
ERROR_POLICY 是唯一权威。客户端不得靠 HTTP status 猜是否可重试。
retryable(重试可能成功)与 safeToRetry(重试不产生副作用)是两件事,
两者都为真才能自动重试;retryable 但不 safe 的必须带幂等键。
