skill-family-harness-node 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ <!-- release-skill:changelog:start version=0.3.0 locale=en baseline=sha256:7afef97792b4714abbd0412dcb7ea76ca66260ef9f8dd2191131b7c3f8539813 -->
4
+ ## [0.3.0] - 2026-08-12
5
+
6
+ This source candidate updates the Quickstart Profile harness to verify v2 Task and Result exchanges without taking ownership of consumer semantics.
7
+
8
+ ### Added
9
+
10
+ - Recomputes real bytes for path-backed outputs and evidence, and rejects duplicate Resource ids across observations, outputs, and evidence.
11
+ - Verifies operation identity, Task digest, every run/stage/attempt field, and the exact evidence binding set.
12
+
13
+ ### Changed
14
+
15
+ - Replaces the incompatible 0.2.1 candidate surface; consumers that still require v1 must remain pinned to exactly 0.2.1.
16
+ - Leaves method selection, retry policy, and domain result interpretation to the consumer.
17
+
18
+ ### Upgrade Notes
19
+
20
+ Version 0.3.0 is a local, unpublished source candidate. Pin the candidate subpath to an exact package version and update v1 exchange producers before adopting it.
21
+ <!-- release-skill:changelog:end version=0.3.0 locale=en -->
22
+
23
+
3
24
  <!-- release-skill:changelog:start version=0.2.1 locale=en baseline=sha256:acd7d4e02eb309b149a31f4b88a8163c69ae094a53591f173c20c407e8ff4ed0 -->
4
25
  ## [0.2.1] - 2026-08-10
5
26
 
@@ -1,5 +1,26 @@
1
1
  # 变更日志
2
2
 
3
+ <!-- release-skill:changelog:start version=0.3.0 locale=zh-CN baseline=sha256:b635e4170d3f7ac634e59f612939683ab04727f87101790d9a9f5613ea38fcfe -->
4
+ ## [0.3.0] - 2026-08-12
5
+
6
+ 本源码候选版把 Quickstart Profile Harness 更新为 v2 Task/Result 交换校验,同时不接管消费者语义。
7
+
8
+ ### 新增
9
+
10
+ - 对 path-backed output 与 evidence 重算真实字节摘要,并拒绝 observation、output、evidence 之间重复的 Resource id。
11
+ - 逐项复验 operation 身份、Task digest、run/stage/attempt 字段和 evidence 精确绑定集合。
12
+
13
+ ### 变更
14
+
15
+ - 替换与 0.2.1 不兼容的 candidate 面;仍依赖 v1 的消费者必须继续精确锁定 0.2.1。
16
+ - 方法选择、重试策略与领域结果解释继续归消费者所有。
17
+
18
+ ### 升级说明
19
+
20
+ 0.3.0 当前只是本地、未发布的源码候选。candidate 子路径必须精确锁定包版本,采用前需更新 v1 交换生产方。
21
+ <!-- release-skill:changelog:end version=0.3.0 locale=zh-CN -->
22
+
23
+
3
24
  <!-- release-skill:changelog:start version=0.2.1 locale=zh-CN baseline=sha256:2f2c74ab9dcf0f1a84872743bf203eb43d15c5e722e823e670e8d81ca5f7de65 -->
4
25
  ## [0.2.1] - 2026-08-10
5
26
 
package/README.md CHANGED
@@ -4,28 +4,28 @@
4
4
 
5
5
  # skill-family-harness-node
6
6
 
7
- <!-- release-skill:release-version: 0.2.1 -->
7
+ <!-- release-skill:release-version: 0.3.0 -->
8
8
 
9
9
  The **single default Node implementation** of the Contracts mechanism protocol. This is a thin runtime: it only implements the mechanism protocol, introduces no business semantics, and does not provide a second-language implementation.
10
10
 
11
11
  <!-- release-skill:managed:start id=latest-release -->
12
- **0.2.1** (2026-08-10)
12
+ **0.3.0** (2026-08-12)
13
13
 
14
- This release adds candidate Quickstart Profile exchange helpers and makes the package release documentation available in English and Simplified Chinese.
14
+ This source candidate updates the Quickstart Profile harness to verify v2 Task and Result exchanges without taking ownership of consumer semantics.
15
15
 
16
16
  **Added**
17
17
 
18
- - Adds candidate helpers that create and revalidate observation Resources, build Tasks, wrap Results, and fail closed when a Result does not bind the exact Task and correlation fields.
19
- - Adds complete English and Simplified Chinese package documentation, including an agent quick-reference section.
18
+ - Recomputes real bytes for path-backed outputs and evidence, and rejects duplicate Resource ids across observations, outputs, and evidence.
19
+ - Verifies operation identity, Task digest, every run/stage/attempt field, and the exact evidence binding set.
20
20
 
21
21
  **Changed**
22
22
 
23
- - Manages the current README and CHANGELOG release sections from one bilingual, versioned notes source.
24
- - Distributes the project NOTICE separately from the Apache-2.0 LICENSE.
23
+ - Replaces the incompatible 0.2.1 candidate surface; consumers that still require v1 must remain pinned to exactly 0.2.1.
24
+ - Leaves method selection, retry policy, and domain result interpretation to the consumer.
25
25
 
26
26
  **Upgrade Notes**
27
27
 
28
- The candidate helpers do not alter the stable Harness API or add lifecycle, retry, orchestration, model-call, network, or Git-write semantics.
28
+ Version 0.3.0 is a local, unpublished source candidate. Pin the candidate subpath to an exact package version and update v1 exchange producers before adopting it.
29
29
  <!-- release-skill:managed:end id=latest-release -->
30
30
 
31
31
  ## Problem It Solves
@@ -39,14 +39,14 @@ The Harness consumes `skill-family-contracts` (a workspace dependency), reusing
39
39
  ## Installation and Minimal Example
40
40
 
41
41
  ```sh
42
- npm install skill-family-harness-node@0.2.1
42
+ npm install skill-family-harness-node@0.3.0
43
43
  npm info skill-family-harness-node --help
44
44
  ```
45
45
 
46
46
  The minimal example shows validating a contract document inside Node:
47
47
 
48
48
  ```js
49
- // Run from an empty directory: npm install skill-family-harness-node@0.2.1
49
+ // Run from an empty directory: npm install skill-family-harness-node@0.3.0
50
50
  import { validateContractDocument } from "skill-family-harness-node";
51
51
 
52
52
  const document = {
@@ -78,7 +78,9 @@ import {
78
78
  } from "skill-family-harness-node/candidate/quickstart-profile";
79
79
  ```
80
80
 
81
- This mechanism is intended for early integration trials of the candidate Contracts profile. It does not perform a domain audit, choose a method, retry work, or own lifecycle state. The subpath is public but **not stable** and may change or be removed in a later minor release; pin the exact package version and do not expose it through a stable consumer API.
81
+ The v2 mechanism recomputes the bytes of every path-backed output and evidence Resource. It also rejects duplicate Resource ids, correlation drift, a changed Task digest, and incomplete or mismatched evidence bindings. It does not perform a domain audit, choose a method, retry work, or own lifecycle state.
82
+
83
+ The subpath is public but **not stable** and may change or be removed in a later minor release. Pin exactly `0.3.0` for v2; integrations that still produce candidate v1 exchanges must stay pinned to exactly `0.2.1`.
82
84
 
83
85
  ## Typical Use Cases
84
86
 
package/README.zh-CN.md CHANGED
@@ -5,28 +5,28 @@
5
5
 
6
6
  # skill-family-harness-node
7
7
 
8
- <!-- release-skill:release-version: 0.2.1 -->
8
+ <!-- release-skill:release-version: 0.3.0 -->
9
9
 
10
10
  Contracts 机制协议的**唯一默认 Node 实现**。这是一个薄运行时(thin runtime):只实现机制协议,不引入业务语义,不做第二语言实现。
11
11
 
12
12
  <!-- release-skill:managed:start id=latest-release -->
13
- **0.2.1** (2026-08-10)
13
+ **0.3.0** (2026-08-12)
14
14
 
15
- 本版新增 Quickstart Profile 候选交换辅助函数,并为包发布文档提供完整英文版与简体中文版。
15
+ 本源码候选版把 Quickstart Profile Harness 更新为 v2 Task/Result 交换校验,同时不接管消费者语义。
16
16
 
17
17
  **新增**
18
18
 
19
- - 新增候选辅助函数,用于创建并复验 observation Resource、构造 Task、封装 Result,并在 Result 未精确绑定 Task 与关联字段时失败关闭。
20
- - 新增完整的英文与简体中文包文档,并补充智能体快速参考章节。
19
+ - path-backed output evidence 重算真实字节摘要,并拒绝 observation、output、evidence 之间重复的 Resource id。
20
+ - 逐项复验 operation 身份、Task digest、run/stage/attempt 字段和 evidence 精确绑定集合。
21
21
 
22
22
  **变更**
23
23
 
24
- - 使用同一份双语版本化说明源管理当前 README CHANGELOG 的发布区域。
25
- - 项目 NOTICE 与 Apache-2.0 LICENSE 分开分发。
24
+ - 替换与 0.2.1 不兼容的 candidate 面;仍依赖 v1 的消费者必须继续精确锁定 0.2.1。
25
+ - 方法选择、重试策略与领域结果解释继续归消费者所有。
26
26
 
27
27
  **升级说明**
28
28
 
29
- 候选辅助函数不改变稳定 Harness API,也不引入生命周期、重试、编排、模型调用、网络或 Git 写入语义。
29
+ 0.3.0 当前只是本地、未发布的源码候选。candidate 子路径必须精确锁定包版本,采用前需更新 v1 交换生产方。
30
30
  <!-- release-skill:managed:end id=latest-release -->
31
31
 
32
32
  ## 解决的问题
@@ -40,14 +40,14 @@ Harness 消费 `skill-family-contracts`(工作区依赖),复用其方言
40
40
  ## 安装和最小示例
41
41
 
42
42
  ```sh
43
- npm install skill-family-harness-node@0.2.1
43
+ npm install skill-family-harness-node@0.3.0
44
44
  npm info skill-family-harness-node --help
45
45
  ```
46
46
 
47
47
  最小示例演示在 Node 内校验一份契约文档:
48
48
 
49
49
  ```js
50
- // 从空目录运行:npm install skill-family-harness-node@0.2.1
50
+ // 从空目录运行:npm install skill-family-harness-node@0.3.0
51
51
  import { validateContractDocument } from "skill-family-harness-node";
52
52
 
53
53
  const document = {
@@ -79,7 +79,9 @@ import {
79
79
  } from "skill-family-harness-node/candidate/quickstart-profile";
80
80
  ```
81
81
 
82
- 以上机制用于 candidate Contracts Profile 的早期接入试验。它不执行领域审计,不选择 method,不编排重试,也不拥有生命周期状态。该子路径公开但**不稳定**,后续小版本可以修改或移除;调用方需要锁定精确包版本,并避免通过自身稳定 API 再导出。
82
+ v2 机制会重算每个 path-backed output evidence Resource 的真实字节摘要,并拒绝重复 Resource id、correlation 漂移、Task digest 变化,以及缺失或错配的 evidence binding。它不执行领域审计,不选择 method,不编排重试,也不拥有生命周期状态。
83
+
84
+ 该子路径公开但**不稳定**,后续小版本可以修改或移除。使用 v2 时应精确锁定 `0.3.0`;仍生产 candidate v1 交换的接入必须继续精确锁定 `0.2.1`。
83
85
 
84
86
  ## 典型使用场景
85
87
 
@@ -1,16 +1,31 @@
1
- import { canonicalJson, digestDocument } from "skill-family-contracts";
1
+ import { canonicalJson, digestDocument, isRegisteredErrorCode } from "skill-family-contracts";
2
2
  import {
3
3
  QUICKSTART_PROTOCOL,
4
+ findNonJsonValue,
4
5
  validateQuickstartProfileDocument,
5
6
  } from "skill-family-contracts/candidate/quickstart-profile";
6
7
  import { computeResourceClosure, digestBytes } from "../src/closure.mjs";
7
- import { HARNESS_ERROR_KINDS, mechanismError } from "../src/errors.mjs";
8
+ import { HARNESS_ERROR_KINDS, HarnessError, mechanismError } from "../src/errors.mjs";
9
+
10
+ /**
11
+ * Candidate quickstart profile v2 mechanisms (unstable).
12
+ *
13
+ * The harness only interprets mechanism constraints: contained reads, real
14
+ * byte digests, profile validation, and per-field Task/Result binding. The
15
+ * single operation is the business-neutral execute-method; params.method,
16
+ * params.parameters, and domainResult are caller-owned and never read here.
17
+ */
18
+
19
+ const QUICKSTART_OPERATION = "execute-method";
20
+ const RESULT_STATES = Object.freeze(["succeeded", "failed", "rejected"]);
21
+ const CORRELATION_FIELDS = Object.freeze(["run", "stage", "attempt"]);
22
+ const ERROR_ENTRY_FIELDS = new Set(["code", "message", "path", "details"]);
8
23
 
9
24
  function invalidProfile(kind, outcome) {
10
25
  return mechanismError(
11
26
  HARNESS_ERROR_KINDS.INVALID_RESULT,
12
27
  `quickstart ${kind} violates the Foundation candidate profile`,
13
- { profileKind: kind, findings: outcome.errors },
28
+ { category: "profile", profileKind: kind, findings: outcome.errors },
14
29
  );
15
30
  }
16
31
 
@@ -20,12 +35,28 @@ function assertProfile(kind, document) {
20
35
  return outcome.data;
21
36
  }
22
37
 
23
- function resourcePath(resource) {
38
+ /**
39
+ * Caller-owned values must be pure JSON before they enter any Task or
40
+ * Result: this refuses BigInt and friends before structuredClone,
41
+ * digestDocument, or JSON.stringify could throw or silently drift.
42
+ */
43
+ function assertJsonValue(caller, label, value) {
44
+ const issue = findNonJsonValue(value);
45
+ if (issue) {
46
+ throw new TypeError(
47
+ `${caller}: ${label} must be a JSON value; found ${issue.reason}` +
48
+ (issue.instancePath ? ` at ${issue.instancePath}` : ""),
49
+ );
50
+ }
51
+ }
52
+
53
+ function containedPath(resource, roleDescription) {
24
54
  const relPath = resource?.location?.path;
25
55
  if (typeof relPath !== "string") {
26
56
  throw mechanismError(
27
57
  HARNESS_ERROR_KINDS.INVALID_RESULT,
28
- "the quickstart observation Resource must use a contained relative path",
58
+ `the quickstart ${roleDescription} Resource must use a contained relative path`,
59
+ { category: "resource-location", resourceId: resource?.id },
29
60
  );
30
61
  }
31
62
  return relPath;
@@ -49,31 +80,54 @@ export async function createObservationResource({ root, path, id = "observation"
49
80
  return assertProfile("resource", resource);
50
81
  }
51
82
 
52
- /** Recompute and verify the single observation Resource digest. */
53
- export async function verifyObservationResource({ root, resource } = {}) {
83
+ /**
84
+ * Recomputes the real byte digest of one path-backed Resource and compares it
85
+ * with the declared digest. URI-backed Resources are structurally checked
86
+ * only: Foundation never fetches URIs.
87
+ */
88
+ export async function verifyResourceBytes({ root, resource } = {}) {
54
89
  const normalized = assertProfile("resource", resource);
55
- if (normalized.role !== "observation") {
56
- throw mechanismError(
57
- HARNESS_ERROR_KINDS.INVALID_RESULT,
58
- "quickstart task input must have the observation role",
59
- );
90
+ const relPath = normalized.location.path;
91
+ if (typeof relPath !== "string") {
92
+ return { resource: normalized, byteDigest: null };
60
93
  }
61
94
  const closure = await computeResourceClosure({
62
95
  root,
63
- resources: [{ path: resourcePath(normalized), role: "input" }],
96
+ resources: [{ path: relPath, role: "input" }],
64
97
  });
65
98
  const actual = closure.resources[0].sha256;
66
99
  if (actual !== normalized.digest.value) {
67
100
  throw mechanismError(
68
101
  HARNESS_ERROR_KINDS.INVALID_RESULT,
69
- "observation Resource digest does not match its current bytes",
70
- { resourceId: normalized.id, expected: normalized.digest.value, actual },
102
+ `quickstart ${normalized.role} Resource digest does not match its current bytes`,
103
+ {
104
+ category: "resource-bytes",
105
+ role: normalized.role,
106
+ resourceId: normalized.id,
107
+ expected: normalized.digest.value,
108
+ actual,
109
+ },
71
110
  );
72
111
  }
73
- return { resource: normalized, closureDigest: closure.digest };
112
+ return { resource: normalized, byteDigest: actual };
74
113
  }
75
114
 
76
- /** Build a candidate Task inside the existing operation-request envelope. */
115
+ /** Verify the single observation Resource: role, path-backed location, bytes. */
116
+ export async function verifyObservationResource({ root, resource } = {}) {
117
+ const normalized = assertProfile("resource", resource);
118
+ if (normalized.role !== "observation") {
119
+ throw mechanismError(
120
+ HARNESS_ERROR_KINDS.INVALID_RESULT,
121
+ "quickstart task input must have the observation role",
122
+ { category: "resource-role", resourceId: normalized.id, role: normalized.role },
123
+ );
124
+ }
125
+ containedPath(normalized, "observation");
126
+ const { byteDigest } = await verifyResourceBytes({ root, resource: normalized });
127
+ return { resource: normalized, byteDigest };
128
+ }
129
+
130
+ /** Build a candidate v2 Task inside the stable operation-request envelope. */
77
131
  export async function createQuickstartTask({
78
132
  root,
79
133
  observationPath,
@@ -85,6 +139,7 @@ export async function createQuickstartTask({
85
139
  stage,
86
140
  attempt,
87
141
  } = {}) {
142
+ assertJsonValue("createQuickstartTask", "parameters", parameters);
88
143
  const observation = await createObservationResource({
89
144
  root,
90
145
  path: observationPath,
@@ -95,7 +150,7 @@ export async function createQuickstartTask({
95
150
  kind: "skill-family.operation-request",
96
151
  protocol: { ...QUICKSTART_PROTOCOL },
97
152
  operationId,
98
- operation: "audit",
153
+ operation: QUICKSTART_OPERATION,
99
154
  params: {
100
155
  method,
101
156
  parameters: structuredClone(parameters),
@@ -106,7 +161,54 @@ export async function createQuickstartTask({
106
161
  return assertProfile("task", task);
107
162
  }
108
163
 
109
- /** Wrap a domain result inside the existing terminal operation-result envelope. */
164
+ function normalizeErrorEntries(errors) {
165
+ if (!Array.isArray(errors) || errors.length === 0) {
166
+ throw new TypeError(
167
+ "wrapQuickstartResult: failed and rejected results require at least one error entry",
168
+ );
169
+ }
170
+ assertJsonValue("wrapQuickstartResult", "errors", errors);
171
+ return errors.map((entry, index) => {
172
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
173
+ throw new TypeError(`wrapQuickstartResult: error entry ${index} must be an object`);
174
+ }
175
+ const extraField = Object.keys(entry).find((field) => !ERROR_ENTRY_FIELDS.has(field));
176
+ if (extraField !== undefined) {
177
+ throw new TypeError(
178
+ `wrapQuickstartResult: error entry ${index} has unsupported field ${extraField}`,
179
+ );
180
+ }
181
+ const { code, message, path, details } = entry;
182
+ if (typeof code !== "string" || !isRegisteredErrorCode(code)) {
183
+ throw new TypeError(
184
+ `wrapQuickstartResult: error entry ${index} code must be registered in the frozen contracts error registry`,
185
+ );
186
+ }
187
+ if (typeof message !== "string" || message.length === 0) {
188
+ throw new TypeError(
189
+ `wrapQuickstartResult: error entry ${index} must carry a non-empty message`,
190
+ );
191
+ }
192
+ if (path !== undefined) {
193
+ assertJsonValue("wrapQuickstartResult", `error entry ${index} path`, path);
194
+ }
195
+ if (details !== undefined) {
196
+ assertJsonValue("wrapQuickstartResult", `error entry ${index} details`, details);
197
+ }
198
+ const normalized = { code, message };
199
+ if (path !== undefined) normalized.path = path;
200
+ if (details !== undefined) normalized.details = structuredClone(details);
201
+ return normalized;
202
+ });
203
+ }
204
+
205
+ /**
206
+ * Wrap one terminal Result for the candidate protocol. succeeded builds the
207
+ * full outputs envelope (summary, outputs, evidence, domainResult,
208
+ * taskBinding with exactly one evidenceBindings entry per evidence
209
+ * Resource); failed and rejected always carry null outputs and at least one
210
+ * registry-registered error.
211
+ */
110
212
  export function wrapQuickstartResult({
111
213
  task,
112
214
  state = "succeeded",
@@ -114,94 +216,266 @@ export function wrapQuickstartResult({
114
216
  outputs = [],
115
217
  evidence = [],
116
218
  domainResult,
117
- errors = [],
219
+ errors,
118
220
  } = {}) {
119
221
  const normalizedTask = assertProfile("task", task);
222
+ if (!RESULT_STATES.includes(state)) {
223
+ throw new TypeError(
224
+ `wrapQuickstartResult: state must be one of ${RESULT_STATES.join(", ")}`,
225
+ );
226
+ }
120
227
  const observation = normalizedTask.params.inputs[0];
121
- const result = {
228
+ const base = {
122
229
  schemaVersion: 1,
123
230
  kind: "skill-family.operation-result",
124
231
  protocol: structuredClone(normalizedTask.protocol),
125
232
  operationId: normalizedTask.operationId,
126
233
  operation: normalizedTask.operation,
127
234
  state,
235
+ };
236
+ if (state !== "succeeded") {
237
+ for (const [name, value] of [
238
+ ["summary", summary],
239
+ ["outputs", outputs],
240
+ ["evidence", evidence],
241
+ ["domainResult", domainResult],
242
+ ]) {
243
+ const untouched =
244
+ (name === "outputs" || name === "evidence") && Array.isArray(value) && value.length === 0;
245
+ if (!untouched && value !== undefined) {
246
+ throw new TypeError(
247
+ `wrapQuickstartResult: ${state} results never carry ${name}; outputs must be null`,
248
+ );
249
+ }
250
+ }
251
+ return assertProfile("result", {
252
+ ...base,
253
+ outputs: null,
254
+ errors: normalizeErrorEntries(errors),
255
+ });
256
+ }
257
+ if (typeof summary !== "string" || summary.length === 0) {
258
+ throw new TypeError("wrapQuickstartResult: succeeded results require a non-empty summary");
259
+ }
260
+ assertJsonValue("wrapQuickstartResult", "outputs", outputs);
261
+ assertJsonValue("wrapQuickstartResult", "evidence", evidence);
262
+ assertJsonValue("wrapQuickstartResult", "domainResult", domainResult);
263
+ const normalizedEvidence = structuredClone(evidence);
264
+ const correlation = structuredClone(normalizedTask.params.correlation);
265
+ return assertProfile("result", {
266
+ ...base,
128
267
  outputs: {
129
268
  summary,
130
269
  outputs: structuredClone(outputs),
131
- evidence: structuredClone(evidence),
270
+ evidence: normalizedEvidence,
132
271
  domainResult: structuredClone(domainResult),
133
272
  taskBinding: {
134
273
  operationId: normalizedTask.operationId,
135
274
  taskDigest: digestDocument(normalizedTask),
136
275
  observationId: observation.id,
137
276
  observationDigest: observation.digest.value,
138
- correlation: structuredClone(normalizedTask.params.correlation),
277
+ correlation,
278
+ evidenceBindings: normalizedEvidence.map((resource) => ({
279
+ resourceId: resource?.id,
280
+ operationId: normalizedTask.operationId,
281
+ observationId: observation.id,
282
+ correlation: structuredClone(correlation),
283
+ })),
139
284
  },
140
285
  },
141
- errors: structuredClone(errors),
142
- };
143
- return assertProfile("result", result);
286
+ errors: [],
287
+ });
288
+ }
289
+
290
+ /**
291
+ * Per-field correlation comparison: run, stage, and attempt mismatches are
292
+ * reported as distinguishable paths instead of one opaque binding mismatch.
293
+ */
294
+ function correlationMismatches(actual, expected, prefix) {
295
+ const mismatches = [];
296
+ for (const field of CORRELATION_FIELDS) {
297
+ if (actual[field] !== expected[field]) mismatches.push(`${prefix}.${field}`);
298
+ }
299
+ return mismatches;
300
+ }
301
+
302
+ /**
303
+ * Cross-checks taskBinding.evidenceBindings against the declared evidence
304
+ * Resources: exactly one entry per evidence Resource id, each echoing the
305
+ * exact operationId, observationId, and run/stage/attempt of this Task.
306
+ */
307
+ function evidenceBindingMismatches(bindings, evidenceResources, normalizedTask, observation) {
308
+ const mismatches = [];
309
+ const evidenceIds = new Set(evidenceResources.map((resource) => resource.id));
310
+ const bound = new Set();
311
+ bindings.forEach((entry, index) => {
312
+ const { resourceId } = entry;
313
+ if (!evidenceIds.has(resourceId)) {
314
+ mismatches.push(`binding.evidenceBindings.extra:${resourceId}`);
315
+ return;
316
+ }
317
+ if (bound.has(resourceId)) {
318
+ mismatches.push(`binding.evidenceBindings.duplicate:${resourceId}`);
319
+ return;
320
+ }
321
+ bound.add(resourceId);
322
+ const prefix = `binding.evidenceBindings[${index}]`;
323
+ if (entry.operationId !== normalizedTask.operationId) {
324
+ mismatches.push(`${prefix}.operationId`);
325
+ }
326
+ if (entry.observationId !== observation.id) {
327
+ mismatches.push(`${prefix}.observationId`);
328
+ }
329
+ mismatches.push(
330
+ ...correlationMismatches(entry.correlation, normalizedTask.params.correlation, `${prefix}.correlation`),
331
+ );
332
+ });
333
+ for (const resourceId of [...evidenceIds].filter((id) => !bound.has(id)).sort()) {
334
+ mismatches.push(`binding.evidenceBindings.missing:${resourceId}`);
335
+ }
336
+ return mismatches;
144
337
  }
145
338
 
146
339
  /**
147
340
  * Fail-closed exchange assertion. It validates both candidate profiles,
148
- * recomputes the observation, and proves the result echoes and binds the
149
- * exact Task/correlation fields. No retry or lifecycle state is introduced.
341
+ * refuses duplicate Resource ids across the Task observation and all
342
+ * output/evidence Resources, recomputes the real bytes of every path-backed
343
+ * Resource, and proves the result echoes and binds the exact
344
+ * protocol/operation/operationId/Task-digest/observation/correlation fields
345
+ * plus one evidenceBindings entry per evidence Resource. No retry or
346
+ * lifecycle state is introduced and no Result file is written.
150
347
  */
151
348
  export async function assertQuickstartExchange({ root, task, result } = {}) {
152
349
  const normalizedTask = assertProfile("task", task);
153
350
  const normalizedResult = assertProfile("result", result);
154
- const observation = normalizedTask.params.inputs[0];
155
- await verifyObservationResource({ root, resource: observation });
156
351
 
157
352
  const mismatches = [];
158
353
  if (canonicalJson(normalizedResult.protocol) !== canonicalJson(normalizedTask.protocol)) {
159
354
  mismatches.push("protocol");
160
355
  }
161
- if (normalizedResult.operationId !== normalizedTask.operationId) mismatches.push("operationId");
162
356
  if (normalizedResult.operation !== normalizedTask.operation) mismatches.push("operation");
357
+ if (normalizedResult.operationId !== normalizedTask.operationId) mismatches.push("operationId");
163
358
 
164
- const binding = normalizedResult.outputs?.taskBinding;
165
- if (!binding) {
166
- mismatches.push("taskBinding");
167
- } else {
168
- if (binding.operationId !== normalizedTask.operationId) mismatches.push("binding.operationId");
169
- if (binding.taskDigest !== digestDocument(normalizedTask)) mismatches.push("binding.taskDigest");
170
- if (binding.observationId !== observation.id) mismatches.push("binding.observationId");
171
- if (binding.observationDigest !== observation.digest.value) {
172
- mismatches.push("binding.observationDigest");
359
+ const observation = normalizedTask.params.inputs[0];
360
+ if (normalizedResult.state === "succeeded") {
361
+ const declared = [
362
+ observation,
363
+ ...normalizedResult.outputs.outputs,
364
+ ...normalizedResult.outputs.evidence,
365
+ ];
366
+ const seen = new Set();
367
+ for (const resource of declared) {
368
+ if (seen.has(resource.id)) {
369
+ throw mechanismError(
370
+ HARNESS_ERROR_KINDS.INVALID_RESULT,
371
+ `quickstart result declares a duplicate Resource id: ${resource.id}`,
372
+ { category: "duplicate-resource-id", resourceId: resource.id },
373
+ );
374
+ }
375
+ seen.add(resource.id);
173
376
  }
174
- if (
175
- canonicalJson(binding.correlation) !==
176
- canonicalJson(normalizedTask.params.correlation)
177
- ) {
178
- mismatches.push("binding.correlation");
377
+
378
+ const binding = normalizedResult.outputs?.taskBinding;
379
+ if (!binding) {
380
+ mismatches.push("taskBinding");
381
+ } else {
382
+ if (binding.operationId !== normalizedTask.operationId) {
383
+ mismatches.push("binding.operationId");
384
+ }
385
+ if (binding.taskDigest !== digestDocument(normalizedTask)) {
386
+ mismatches.push("binding.taskDigest");
387
+ }
388
+ if (binding.observationId !== observation.id) mismatches.push("binding.observationId");
389
+ if (binding.observationDigest !== observation.digest.value) {
390
+ mismatches.push("binding.observationDigest");
391
+ }
392
+ mismatches.push(
393
+ ...correlationMismatches(
394
+ binding.correlation,
395
+ normalizedTask.params.correlation,
396
+ "binding.correlation",
397
+ ),
398
+ );
399
+ mismatches.push(
400
+ ...evidenceBindingMismatches(
401
+ binding.evidenceBindings,
402
+ normalizedResult.outputs.evidence,
403
+ normalizedTask,
404
+ observation,
405
+ ),
406
+ );
179
407
  }
180
408
  }
181
409
  if (mismatches.length > 0) {
182
410
  throw mechanismError(
183
411
  HARNESS_ERROR_KINDS.INVALID_RESULT,
184
412
  `quickstart result does not bind the exact task: ${mismatches.join(", ")}`,
185
- { mismatches },
413
+ { category: "binding", mismatches },
186
414
  );
187
415
  }
416
+
417
+ await verifyObservationResource({ root, resource: observation });
418
+
419
+ if (normalizedResult.state === "succeeded") {
420
+ const declared = [
421
+ ...normalizedResult.outputs.outputs,
422
+ ...normalizedResult.outputs.evidence,
423
+ ];
424
+ for (const resource of declared) {
425
+ await verifyResourceBytes({ root, resource });
426
+ }
427
+ }
428
+
188
429
  return {
189
430
  valid: true,
431
+ state: normalizedResult.state,
190
432
  taskDigest: digestDocument(normalizedTask),
191
433
  observationDigest: observation.digest.value,
192
434
  };
193
435
  }
194
436
 
195
- /** Non-throwing form for callers that need a structured finding. */
437
+ /**
438
+ * Non-throwing form for callers that need a structured finding. Every
439
+ * failure resolves to code SFC2004 with a deterministic string details.kind
440
+ * and details.category: HarnessError paths from the closure and containment
441
+ * machinery (missing-resource, path, symlink escapes) keep their stable kind
442
+ * and every useful detail field, ordinary TypeErrors and omitted inputs fall
443
+ * back to the invalid-result/unexpected-failure pair.
444
+ */
196
445
  export async function verifyQuickstartExchange(input) {
197
446
  try {
198
447
  return await assertQuickstartExchange(input);
199
448
  } catch (cause) {
449
+ const message =
450
+ cause instanceof Error && typeof cause.message === "string" && cause.message.length > 0
451
+ ? cause.message
452
+ : String(cause);
453
+ if (cause instanceof HarnessError) {
454
+ const rawDetails =
455
+ cause.details && typeof cause.details === "object" ? cause.details : {};
456
+ const kind =
457
+ typeof rawDetails.kind === "string" && rawDetails.kind.length > 0
458
+ ? rawDetails.kind
459
+ : HARNESS_ERROR_KINDS.EXECUTION_FAILED;
460
+ const category =
461
+ typeof rawDetails.category === "string" && rawDetails.category.length > 0
462
+ ? rawDetails.category
463
+ : "resource-closure";
464
+ return {
465
+ valid: false,
466
+ code: "SFC2004",
467
+ message,
468
+ details: { ...rawDetails, kind, category },
469
+ };
470
+ }
200
471
  return {
201
472
  valid: false,
202
- code: cause?.code ?? "SFC2004",
203
- message: cause?.message ?? String(cause),
204
- details: cause?.details,
473
+ code: "SFC2004",
474
+ message,
475
+ details: {
476
+ kind: HARNESS_ERROR_KINDS.INVALID_RESULT,
477
+ category: "unexpected-failure",
478
+ },
205
479
  };
206
480
  }
207
481
  }
package/package.json CHANGED
@@ -5,11 +5,12 @@
5
5
  "url": "https://github.com/ifoohoo/skill-family-harness-node/issues"
6
6
  },
7
7
  "dependencies": {
8
- "skill-family-contracts": "0.2.1"
8
+ "skill-family-contracts": "0.3.0"
9
9
  },
10
10
  "description": "Thin Node.js mechanism runtime for Skill Family engineering contracts.",
11
11
  "engines": {
12
- "node": ">=22.22.2 <23"
12
+ "node": ">=22.22.2 <23",
13
+ "pnpm": "10.30.0"
13
14
  },
14
15
  "exports": {
15
16
  ".": "./src/index.mjs",
@@ -37,7 +38,7 @@
37
38
  "url": "https://github.com/ifoohoo/skill-family-harness-node.git"
38
39
  },
39
40
  "type": "module",
40
- "version": "0.2.1",
41
+ "version": "0.3.0",
41
42
  "scripts": {
42
43
  "check": "node --test",
43
44
  "test": "node --test"
@@ -0,0 +1,23 @@
1
+ version: 0.3.0
2
+ date: 2026-08-12
3
+ locales:
4
+ en:
5
+ summary: This source candidate updates the Quickstart Profile harness to verify v2 Task and Result exchanges without taking ownership of consumer semantics.
6
+ changes:
7
+ added:
8
+ - Recomputes real bytes for path-backed outputs and evidence, and rejects duplicate Resource ids across observations, outputs, and evidence.
9
+ - Verifies operation identity, Task digest, every run/stage/attempt field, and the exact evidence binding set.
10
+ changed:
11
+ - Replaces the incompatible 0.2.1 candidate surface; consumers that still require v1 must remain pinned to exactly 0.2.1.
12
+ - Leaves method selection, retry policy, and domain result interpretation to the consumer.
13
+ upgradeNotes: Version 0.3.0 is a local, unpublished source candidate. Pin the candidate subpath to an exact package version and update v1 exchange producers before adopting it.
14
+ zh-CN:
15
+ summary: 本源码候选版把 Quickstart Profile Harness 更新为 v2 Task/Result 交换校验,同时不接管消费者语义。
16
+ changes:
17
+ added:
18
+ - 对 path-backed output 与 evidence 重算真实字节摘要,并拒绝 observation、output、evidence 之间重复的 Resource id。
19
+ - 逐项复验 operation 身份、Task digest、run/stage/attempt 字段和 evidence 精确绑定集合。
20
+ changed:
21
+ - 替换与 0.2.1 不兼容的 candidate 面;仍依赖 v1 的消费者必须继续精确锁定 0.2.1。
22
+ - 方法选择、重试策略与领域结果解释继续归消费者所有。
23
+ upgradeNotes: 0.3.0 当前只是本地、未发布的源码候选。candidate 子路径必须精确锁定包版本,采用前需更新 v1 交换生产方。