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 +21 -0
- package/CHANGELOG.zh-CN.md +21 -0
- package/README.md +13 -11
- package/README.zh-CN.md +13 -11
- package/candidate/quickstart-profile.mjs +324 -50
- package/package.json +4 -3
- package/release-notes/0.3.0.yaml +23 -0
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
|
|
package/CHANGELOG.zh-CN.md
CHANGED
|
@@ -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.
|
|
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.
|
|
12
|
+
**0.3.0** (2026-08-12)
|
|
13
13
|
|
|
14
|
-
This
|
|
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
|
-
-
|
|
19
|
-
-
|
|
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
|
-
-
|
|
24
|
-
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
13
|
+
**0.3.0** (2026-08-12)
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
本源码候选版把 Quickstart Profile Harness 更新为 v2 Task/Result 交换校验,同时不接管消费者语义。
|
|
16
16
|
|
|
17
17
|
**新增**
|
|
18
18
|
|
|
19
|
-
-
|
|
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
|
-
-
|
|
25
|
-
-
|
|
24
|
+
- 替换与 0.2.1 不兼容的 candidate 面;仍依赖 v1 的消费者必须继续精确锁定 0.2.1。
|
|
25
|
+
- 方法选择、重试策略与领域结果解释继续归消费者所有。
|
|
26
26
|
|
|
27
27
|
**升级说明**
|
|
28
28
|
|
|
29
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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:
|
|
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
|
-
|
|
70
|
-
{
|
|
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,
|
|
112
|
+
return { resource: normalized, byteDigest: actual };
|
|
74
113
|
}
|
|
75
114
|
|
|
76
|
-
/**
|
|
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:
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
142
|
-
};
|
|
143
|
-
|
|
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
|
-
*
|
|
149
|
-
*
|
|
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
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
/**
|
|
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:
|
|
203
|
-
message
|
|
204
|
-
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.
|
|
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.
|
|
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 交换生产方。
|