@quolu/lattice 0.12.20 → 0.12.22

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/README.md CHANGED
@@ -95,6 +95,12 @@ LANや外部reverse proxyから閲覧するoptional bridgeは既定で無効で
95
95
  凡例の件数バッジを押すと全工程を描いた図へ切り替わり、`lattice todo gantt --scope all`は最初から全件を
96
96
  描きます。表示規約は[ADR 0066](docs/adr/0066-gantt-live-scope-drops-finished-work.md)が正です。
97
97
 
98
+ 右ペインは概要・選択工程・全工程の3面で、いずれもToDo storeを表示します(元plan Markdown本文は
99
+ 再表示しません。元文書へは各工程の詳細が持つ行対応から辿ります)。全工程一覧は動いているplanを
100
+ 最終活動の新しい順で上に、全工程が図から外れた完走planを古い順で下にまとめ、plan内は登録順です。
101
+ 決着済みPhaseと図から外した工程は既定で畳み、開けば読めます。規約は
102
+ [ADR 0067](docs/adr/0067-right-pane-shows-the-store-and-orders-by-activity.md)が正です。
103
+
98
104
  静的工程表は`lattice todo gantt status`で`current / stale / missing`を確認でき、HTMLまたは
99
105
  digest付きsidecarの欠落・改ざんはtyped failureになります。
100
106
  dashboard daemonは起動時に読み込んだ版数をhealthで名乗り、installされた版と食い違えば`lattice status`の
@@ -0,0 +1,57 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/kitepon-rgb/Lattice/blob/main/docs/schemas/lattice.executor_packet.v1.schema.json",
4
+ "title": "lattice.executor_packet.v1",
5
+ "description": "The single context packet issued at dispatch. It is the machine record of the context an executor received.",
6
+ "$comment": "Runtime validation additionally enforces canonical JSON bytes, the self-digest rule for `packet_digest`, and that `context_content_digest` equals the SHA-256 of the canonical JSON projection of exactly {todo_id, task_ref, scope, base_sha, verifier_refs, forbidden_operations} — plan attribution fields are excluded so that an epoch rebind is provably content-preserving.",
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "required": [
10
+ "schema",
11
+ "packet_id",
12
+ "todo_id",
13
+ "task_ref",
14
+ "scope",
15
+ "base_sha",
16
+ "plan_ref",
17
+ "plan_epoch",
18
+ "verifier_refs",
19
+ "forbidden_operations",
20
+ "context_content_digest",
21
+ "packet_digest"
22
+ ],
23
+ "properties": {
24
+ "schema": { "const": "lattice.executor_packet.v1" },
25
+ "packet_id": { "$ref": "#/$defs/identifier" },
26
+ "todo_id": {
27
+ "$ref": "#/$defs/identifier",
28
+ "$comment": "Carried through from `run_request.v1`. It is not qualified by any TODO store project/plan/revision identity."
29
+ },
30
+ "task_ref": { "$ref": "#/$defs/identifier" },
31
+ "scope": { "type": "object" },
32
+ "base_sha": { "$ref": "#/$defs/gitSha" },
33
+ "plan_ref": { "$ref": "#/$defs/identifier" },
34
+ "plan_epoch": { "type": "integer", "minimum": 0 },
35
+ "verifier_refs": {
36
+ "type": "array",
37
+ "maxItems": 256,
38
+ "items": { "type": "string" }
39
+ },
40
+ "forbidden_operations": {
41
+ "type": "array",
42
+ "minItems": 1,
43
+ "maxItems": 256,
44
+ "items": { "type": "string" }
45
+ },
46
+ "context_content_digest": { "$ref": "#/$defs/digest" },
47
+ "packet_digest": { "$ref": "#/$defs/digest" }
48
+ },
49
+ "$defs": {
50
+ "identifier": {
51
+ "type": "string",
52
+ "pattern": "^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$"
53
+ },
54
+ "digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
55
+ "gitSha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }
56
+ }
57
+ }
@@ -0,0 +1,66 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/kitepon-rgb/Lattice/blob/main/docs/schemas/lattice.executor_receipt.v1.schema.json",
4
+ "title": "lattice.executor_receipt.v1",
5
+ "description": "The receipt an executor returns for one dispatched packet. `packet_digest` is what attributes it to the packet it received.",
6
+ "$comment": "Runtime validation additionally enforces canonical JSON bytes and the self-digest rule for `receipt_digest`. Acceptance is decided by event order, not by the receipt's own claims: the binding is checked against the `executor_dispatched` / `epoch_rebound` event that recorded the handle, worktree and packet digest for that TODO.",
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "required": [
10
+ "schema",
11
+ "receipt_id",
12
+ "executor_handle",
13
+ "worktree_id",
14
+ "base_sha",
15
+ "plan_epoch",
16
+ "packet_digest",
17
+ "todo_id",
18
+ "checkpoint_digest",
19
+ "observed_diff",
20
+ "receipt_digest"
21
+ ],
22
+ "properties": {
23
+ "schema": { "const": "lattice.executor_receipt.v1" },
24
+ "receipt_id": { "$ref": "#/$defs/identifier" },
25
+ "executor_handle": { "$ref": "#/$defs/identifier" },
26
+ "worktree_id": { "$ref": "#/$defs/identifier" },
27
+ "base_sha": { "$ref": "#/$defs/gitSha" },
28
+ "plan_epoch": { "type": "integer", "minimum": 0 },
29
+ "packet_digest": {
30
+ "$ref": "#/$defs/digest",
31
+ "$comment": "Attribution to the received `executor_packet.v1`. This is the only link back to the dispatch."
32
+ },
33
+ "todo_id": {
34
+ "$ref": "#/$defs/identifier",
35
+ "$comment": "Carried through from `run_request.v1`. It is not qualified by any TODO store project/plan/revision identity, so a receipt alone does not prove which TODO store task it belongs to."
36
+ },
37
+ "checkpoint_digest": { "$ref": "#/$defs/digest" },
38
+ "observed_diff": {
39
+ "type": "array",
40
+ "maxItems": 256,
41
+ "items": {
42
+ "type": "object",
43
+ "additionalProperties": false,
44
+ "required": ["path", "change"],
45
+ "properties": {
46
+ "path": { "$ref": "#/$defs/repoRelativePath" },
47
+ "change": { "enum": ["added", "modified", "deleted"] }
48
+ }
49
+ }
50
+ },
51
+ "receipt_digest": { "$ref": "#/$defs/digest" }
52
+ },
53
+ "$defs": {
54
+ "identifier": {
55
+ "type": "string",
56
+ "pattern": "^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$"
57
+ },
58
+ "digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
59
+ "gitSha": { "type": "string", "pattern": "^[0-9a-f]{40}$" },
60
+ "repoRelativePath": {
61
+ "type": "string",
62
+ "minLength": 1,
63
+ "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?(?:/|$))[^\\u0000-\\u001f\\u007f\\\\]+$"
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,238 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/kitepon-rgb/Lattice/blob/main/docs/schemas/lattice.run_request.v1.schema.json",
4
+ "title": "lattice.run_request.v1",
5
+ "description": "Canonical run request accepted by `lattice plan compile --request` and `lattice run start --request`.",
6
+ "$comment": "Runtime validation additionally enforces canonical JSON bytes and the self-digest rule: `request_digest` is the SHA-256 of the canonical JSON of the request with `request_digest` removed. Rejections are reported as `lattice.cli_error.v2` with `detail.reason` and `detail.path`.",
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "required": [
10
+ "schema",
11
+ "request_id",
12
+ "repo",
13
+ "capacity",
14
+ "todos",
15
+ "manual_witness",
16
+ "sensor_query_set",
17
+ "executor_capability",
18
+ "claim_mode",
19
+ "request_digest"
20
+ ],
21
+ "properties": {
22
+ "schema": { "const": "lattice.run_request.v1" },
23
+ "request_id": { "$ref": "#/$defs/identifier" },
24
+ "repo": {
25
+ "type": "object",
26
+ "additionalProperties": false,
27
+ "required": ["base_sha", "root_kind"],
28
+ "properties": {
29
+ "base_sha": { "$ref": "#/$defs/gitSha" },
30
+ "root_kind": { "$ref": "#/$defs/identifier" }
31
+ }
32
+ },
33
+ "capacity": {
34
+ "type": "object",
35
+ "additionalProperties": false,
36
+ "required": ["executors"],
37
+ "properties": { "executors": { "type": "integer", "minimum": 1 } }
38
+ },
39
+ "todos": {
40
+ "type": "array",
41
+ "minItems": 1,
42
+ "maxItems": 256,
43
+ "items": {
44
+ "type": "object",
45
+ "additionalProperties": false,
46
+ "required": ["todo_id"],
47
+ "properties": { "todo_id": { "$ref": "#/$defs/identifier" } }
48
+ },
49
+ "$comment": "`todo_id` is chosen by the host. It is not qualified by any TODO store project/plan/revision identity."
50
+ },
51
+ "manual_witness": {
52
+ "type": "object",
53
+ "description": "Keys must be exactly the `todo_id` values listed in `todos`.",
54
+ "additionalProperties": { "$ref": "#/$defs/manualWitness" }
55
+ },
56
+ "sensor_query_set": {
57
+ "type": "object",
58
+ "additionalProperties": false,
59
+ "required": ["queries"],
60
+ "properties": {
61
+ "queries": {
62
+ "type": "array",
63
+ "maxItems": 256,
64
+ "items": { "$ref": "#/$defs/sensorQuery" }
65
+ }
66
+ }
67
+ },
68
+ "executor_capability": {
69
+ "type": "object",
70
+ "additionalProperties": false,
71
+ "required": ["adapters"],
72
+ "properties": {
73
+ "adapters": {
74
+ "type": "array",
75
+ "minItems": 1,
76
+ "maxItems": 256,
77
+ "uniqueItems": true,
78
+ "items": { "$ref": "#/$defs/identifier" }
79
+ }
80
+ }
81
+ },
82
+ "claim_mode": { "const": "exact_minimum" },
83
+ "request_digest": { "$ref": "#/$defs/digest" }
84
+ },
85
+ "$defs": {
86
+ "identifier": {
87
+ "type": "string",
88
+ "pattern": "^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$"
89
+ },
90
+ "digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
91
+ "gitSha": { "type": "string", "pattern": "^[0-9a-f]{40}$" },
92
+ "repoRelativePath": {
93
+ "type": "string",
94
+ "minLength": 1,
95
+ "description": "Repo-relative path. Absolute paths, `..` traversal, empty segments, control characters and `\\` separators are rejected.",
96
+ "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?(?:/|$))[^\\u0000-\\u001f\\u007f\\\\]+$"
97
+ },
98
+ "repoRelativePathOrPrefix": {
99
+ "type": "string",
100
+ "minLength": 1,
101
+ "description": "Same as `repoRelativePath` but a trailing `/` is allowed to declare a directory write prefix.",
102
+ "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?(?:/|$))[^\\u0000-\\u001f\\u007f\\\\]+/?$"
103
+ },
104
+ "sensorQuery": {
105
+ "type": "object",
106
+ "additionalProperties": false,
107
+ "required": ["id", "operation"],
108
+ "properties": {
109
+ "id": { "$ref": "#/$defs/identifier" },
110
+ "operation": {
111
+ "enum": ["status", "query", "callers", "callees", "impact", "affected"]
112
+ },
113
+ "target": { "type": "string", "minLength": 1 }
114
+ },
115
+ "$comment": "`id` must be unique across the query set."
116
+ },
117
+ "sensorExpect": {
118
+ "oneOf": [
119
+ {
120
+ "type": "object",
121
+ "additionalProperties": false,
122
+ "required": ["kind", "name", "path"],
123
+ "properties": {
124
+ "kind": { "const": "symbol" },
125
+ "name": { "type": "string", "minLength": 1 },
126
+ "path": { "$ref": "#/$defs/repoRelativePath" }
127
+ }
128
+ },
129
+ {
130
+ "type": "object",
131
+ "additionalProperties": false,
132
+ "required": ["kind", "path"],
133
+ "properties": {
134
+ "kind": { "enum": ["path", "affected"] },
135
+ "path": { "$ref": "#/$defs/repoRelativePath" }
136
+ }
137
+ }
138
+ ]
139
+ },
140
+ "manualWitness": {
141
+ "type": "object",
142
+ "additionalProperties": false,
143
+ "required": [
144
+ "owns",
145
+ "reads",
146
+ "writes",
147
+ "resources",
148
+ "state_effects",
149
+ "sensor_provenance",
150
+ "affected_tests",
151
+ "unknowns"
152
+ ],
153
+ "properties": {
154
+ "owns": {
155
+ "type": "array",
156
+ "maxItems": 256,
157
+ "items": {
158
+ "type": "object",
159
+ "additionalProperties": false,
160
+ "required": ["kind", "target"],
161
+ "properties": {
162
+ "kind": { "enum": ["symbol", "path"] },
163
+ "target": { "type": "string", "minLength": 1, "maxLength": 1024 }
164
+ }
165
+ }
166
+ },
167
+ "reads": {
168
+ "type": "array",
169
+ "maxItems": 256,
170
+ "items": { "$ref": "#/$defs/repoRelativePath" }
171
+ },
172
+ "writes": {
173
+ "type": "array",
174
+ "maxItems": 256,
175
+ "items": { "$ref": "#/$defs/repoRelativePathOrPrefix" }
176
+ },
177
+ "resources": {
178
+ "type": "array",
179
+ "maxItems": 256,
180
+ "items": { "$ref": "#/$defs/identifier" }
181
+ },
182
+ "state_effects": {
183
+ "type": "array",
184
+ "maxItems": 256,
185
+ "items": {
186
+ "type": "object",
187
+ "additionalProperties": false,
188
+ "required": ["resource_id", "kind"],
189
+ "properties": {
190
+ "resource_id": { "$ref": "#/$defs/identifier" },
191
+ "kind": {
192
+ "enum": ["state", "schema", "invariant", "effect", "external_effect"]
193
+ }
194
+ }
195
+ }
196
+ },
197
+ "sensor_provenance": {
198
+ "type": "object",
199
+ "additionalProperties": false,
200
+ "required": ["queries"],
201
+ "properties": {
202
+ "queries": {
203
+ "type": "array",
204
+ "maxItems": 256,
205
+ "items": {
206
+ "type": "object",
207
+ "additionalProperties": false,
208
+ "required": ["query_id", "expect"],
209
+ "properties": {
210
+ "query_id": { "$ref": "#/$defs/identifier" },
211
+ "expect": { "$ref": "#/$defs/sensorExpect" }
212
+ }
213
+ }
214
+ }
215
+ }
216
+ },
217
+ "affected_tests": {
218
+ "type": "array",
219
+ "maxItems": 256,
220
+ "items": { "$ref": "#/$defs/repoRelativePath" }
221
+ },
222
+ "unknowns": {
223
+ "type": "array",
224
+ "maxItems": 256,
225
+ "items": {
226
+ "type": "object",
227
+ "additionalProperties": false,
228
+ "required": ["kind", "ref"],
229
+ "properties": {
230
+ "kind": { "$ref": "#/$defs/identifier" },
231
+ "ref": { "type": "string", "minLength": 1 }
232
+ }
233
+ }
234
+ }
235
+ }
236
+ }
237
+ }
238
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.12.20",
3
+ "version": "0.12.22",
4
4
  "description": "Lattice — phase-aware TODO graph compiler and conflict-aware orchestration runtime",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,6 +21,9 @@
21
21
  "docs/schemas/lattice.plan_create_input.v1.schema.json",
22
22
  "docs/schemas/lattice.plan_create_input.v2.schema.json",
23
23
  "docs/schemas/lattice.plan_create_input.v3.schema.json",
24
+ "docs/schemas/lattice.run_request.v1.schema.json",
25
+ "docs/schemas/lattice.executor_packet.v1.schema.json",
26
+ "docs/schemas/lattice.executor_receipt.v1.schema.json",
24
27
  "docs/bridge-setup.md",
25
28
  "sensor/dist",
26
29
  "!sensor/dist/bin/lattice-sensor.*",
package/src/cli-help.mjs CHANGED
@@ -24,12 +24,14 @@ Commands:
24
24
  create --schema --json
25
25
  create --schema-version <2|3> --json
26
26
  compile --request <request.json>
27
+ compile --schema --json # lattice.run_request.v1 の JSON Schema を出す
27
28
  verify --request <request.json> --plan <plan.json>
28
29
  `,
29
30
  run: `Usage: lattice run <command> [options]
30
31
 
31
32
  Commands:
32
33
  start --request <request.json> --executor <adapter>
34
+ start --schema --json # lattice.run_request.v1 の JSON Schema を出す
33
35
  observe --run .lattice/runs/<id>
34
36
  status --run .lattice/runs/<id>
35
37
  resume --run .lattice/runs/<id>
@@ -43,6 +45,7 @@ Commands:
43
45
 
44
46
  Read commands:
45
47
  status [--json]
48
+ bindings [--plan <key>] [--json] # compile_binding付きTaskをTODO identityつきで投影する
46
49
  verify [--plan <key>] [--json]
47
50
  snapshot --rebuild --plan <key>
48
51
  gantt [--out <file>] [--scope live|all] # 既定live: 完走した工程を図から除く(一覧には残る)
@@ -97,9 +100,9 @@ registerはLATTICE_BRIDGE_REGISTRAR_SSH_HOSTとLATTICE_BRIDGE_REGISTRAR_SCRIPT
97
100
 
98
101
  const SUBCOMMAND_USAGE = Object.freeze({
99
102
  'plan create': 'plan create --input <file> | --schema --json | --schema-version <2|3> --json',
100
- 'plan compile': 'plan compile --request <request.json>',
103
+ 'plan compile': 'plan compile --request <request.json> | --schema --json',
101
104
  'plan verify': 'plan verify --request <request.json> --plan <plan.json>',
102
- 'run start': 'run start --request <request.json> --executor <adapter>',
105
+ 'run start': 'run start --request <request.json> --executor <adapter> | --schema --json',
103
106
  'run observe': 'run observe --run .lattice/runs/<id>',
104
107
  'run status': 'run status --run .lattice/runs/<id>',
105
108
  'run resume': 'run resume --run .lattice/runs/<id>',
@@ -108,6 +111,7 @@ const SUBCOMMAND_USAGE = Object.freeze({
108
111
  'run list': 'run list --json',
109
112
  'event verify': 'event verify --run .lattice/runs/<id>',
110
113
  'todo status': 'todo status [--json]',
114
+ 'todo bindings': 'todo bindings [--plan <key>] [--json]',
111
115
  'todo verify': 'todo verify [--plan <key>] [--json]',
112
116
  'todo snapshot': 'todo snapshot --rebuild --plan <key>',
113
117
  'todo gantt': 'todo gantt [--out <file>] [--scope live|all] | status [--out <file>] | serve --port <port> [--scope live|all]',
@@ -24,6 +24,7 @@ import {
24
24
  evidenceFromCollectedOutcomes,
25
25
  } from './runtime-front-end.mjs';
26
26
  import {
27
+ explainRunRequest,
27
28
  validateRunRequest,
28
29
  validateRuntimeBoundaryManifest,
29
30
  validateRuntimePlan,
@@ -247,10 +248,31 @@ function canonicalNow() {
247
248
  return new Date().toISOString();
248
249
  }
249
250
 
251
+ /**
252
+ * 同梱の`lattice.run_request.v1` JSON Schemaをstdoutへ出す(ADR 0123)。
253
+ * hostがrequestを推測で組まずに済むよう、契約を配布物から直接取れるようにする。
254
+ */
255
+ async function runRequestSchema({ stdout }) {
256
+ const schemaUrl = new URL('../docs/schemas/lattice.run_request.v1.schema.json', import.meta.url);
257
+ const schema = JSON.parse(await readFile(schemaUrl, 'utf8'));
258
+ if (schema?.title !== 'lattice.run_request.v1') {
259
+ throw new CliContractError('CONTRACT_VIOLATION', '同梱run_request schemaが不正');
260
+ }
261
+ stdout.write(`${JSON.stringify(schema)}\n`);
262
+ return 0;
263
+ }
264
+
250
265
  async function loadRequest(requestPath) {
251
266
  const request = await readBoundedJson(requestPath, 'run request');
252
- if (!validateRunRequest(request)) {
253
- throw new CliContractError('INVALID_RUN_REQUEST', 'run_request.v1 contractを満たさない');
267
+ const verdict = explainRunRequest(request);
268
+ if (!verdict.valid) {
269
+ // 拒否理由と違反箇所を返す(ADR 0123)。TODO面と同じdiagnosabilityへ揃え、
270
+ // hostがschemaを推測せずにrequestを直せるようにする。
271
+ throw new CliContractError(
272
+ 'INVALID_RUN_REQUEST',
273
+ 'run_request.v1 contractを満たさない',
274
+ { reason: verdict.reason, path: verdict.path },
275
+ );
254
276
  }
255
277
  return request;
256
278
  }
@@ -2753,6 +2775,14 @@ export async function runRuntimeCli({ argv, cwd, stdout, stderr }) {
2753
2775
  }
2754
2776
  let action = null;
2755
2777
  if (argv.length === 4
2778
+ && argv[0] === 'plan' && argv[1] === 'compile'
2779
+ && argv[2] === '--schema' && argv[3] === '--json') {
2780
+ action = () => runRequestSchema({ stdout });
2781
+ } else if (argv.length === 4
2782
+ && argv[0] === 'run' && argv[1] === 'start'
2783
+ && argv[2] === '--schema' && argv[3] === '--json') {
2784
+ action = () => runRequestSchema({ stdout });
2785
+ } else if (argv.length === 4
2756
2786
  && argv[0] === 'plan' && argv[1] === 'compile' && argv[2] === '--request'
2757
2787
  && typeof argv[3] === 'string' && argv[3].length > 0) {
2758
2788
  action = () => planCompile({ requestPath: path.resolve(cwd, argv[3]), cwd, stdout });
@@ -164,7 +164,7 @@ function unknownEntry(value) {
164
164
  && value.ref.length > 0;
165
165
  }
166
166
 
167
- const MANUAL_WITNESS_FIELDS = Object.freeze([
167
+ export const MANUAL_WITNESS_FIELDS = Object.freeze([
168
168
  'owns',
169
169
  'reads',
170
170
  'writes',
@@ -175,18 +175,8 @@ const MANUAL_WITNESS_FIELDS = Object.freeze([
175
175
  'unknowns',
176
176
  ]);
177
177
 
178
- function manualWitnessEntry(value) {
179
- return plainObject(value)
180
- && exactRecord(value, MANUAL_WITNESS_FIELDS)
181
- && boundedArray(value.owns, ownEntry)
182
- && repoPathArray(value.reads)
183
- && repoPathArray(value.writes, { allowPrefix: true })
184
- && boundedArray(value.resources, identifier)
185
- && boundedArray(value.state_effects, stateEffectEntry)
186
- && plainObject(value.sensor_provenance)
187
- && repoPathArray(value.affected_tests)
188
- && boundedArray(value.unknowns, unknownEntry);
189
- }
178
+ // manual witness entryの判定は`explainRunRequest`だけが所有する。
179
+ // 同じ規則をbooleanと診断の二箇所へ持たないための単一正本化(ADR 0123)。
190
180
 
191
181
  function boundedArray(value, predicate, { min = 0, max = MAX_COLLECTION } = {}) {
192
182
  return Array.isArray(value)
@@ -231,43 +221,139 @@ const WITNESS_PROVENANCE = Object.freeze([
231
221
  'manual_state_effect',
232
222
  ]);
233
223
 
224
+ export const RUN_REQUEST_FIELDS = Object.freeze([
225
+ 'schema',
226
+ 'request_id',
227
+ 'repo',
228
+ 'capacity',
229
+ 'todos',
230
+ 'manual_witness',
231
+ 'sensor_query_set',
232
+ 'executor_capability',
233
+ 'claim_mode',
234
+ 'request_digest',
235
+ ]);
236
+
237
+ export const RUN_REQUEST_CLAIM_MODE = 'exact_minimum';
238
+
239
+ /**
240
+ * `sensor_query_set.queries[].operation`のclosed set。
241
+ * runtime front-endはこの定数を輸入して使う(同じ閉集合を二箇所へ持たない)。
242
+ */
243
+ export const SENSOR_QUERY_OPERATIONS = Object.freeze([
244
+ 'status',
245
+ 'query',
246
+ 'callers',
247
+ 'callees',
248
+ 'impact',
249
+ 'affected',
250
+ ]);
251
+
252
+ /** `manual_witness[].sensor_provenance.queries[].expect.kind`のclosed set。 */
253
+ export const SENSOR_EXPECT_KINDS = Object.freeze(['symbol', 'path', 'affected']);
254
+
255
+ /** `sensor_provenance` entryのexpectを検査する(front-end adapter契約と同一規則)。 */
256
+ function sensorExpect(value) {
257
+ if (!plainObject(value) || !SENSOR_EXPECT_KINDS.includes(value.kind)) return false;
258
+ if (value.kind === 'symbol') {
259
+ return exactRecord(value, ['kind', 'name', 'path'])
260
+ && typeof value.name === 'string' && value.name.length > 0
261
+ && repoRelativePath(value.path);
262
+ }
263
+ return exactRecord(value, ['kind', 'path']) && repoRelativePath(value.path);
264
+ }
265
+
266
+ /**
267
+ * `lattice.run_request.v1`の唯一の判定正本(ADR 0123)。
268
+ * 受理は`{ valid: true }`、拒否は最初の違反の`reason`と`path`を返す。
269
+ * `validateRunRequest`は本関数へ委譲するため、boolean判定と診断が乖離しない。
270
+ */
271
+ export function explainRunRequest(value) {
272
+ const reject = (reason, at) => ({ valid: false, reason, path: at });
273
+ try {
274
+ canonicalizeArtifact(value);
275
+ } catch {
276
+ return reject('non_canonical_request_bytes', '');
277
+ }
278
+ if (!exactRecord(value, RUN_REQUEST_FIELDS)) return reject('unexpected_or_missing_top_level_keys', '');
279
+ if (value.schema !== 'lattice.run_request.v1') return reject('schema_mismatch', '/schema');
280
+ if (!identifier(value.request_id)) return reject('invalid_identifier', '/request_id');
281
+ if (!exactRecord(value.repo, ['base_sha', 'root_kind'])) return reject('unexpected_or_missing_keys', '/repo');
282
+ if (!gitSha(value.repo.base_sha)) return reject('invalid_git_sha', '/repo/base_sha');
283
+ if (!identifier(value.repo.root_kind)) return reject('invalid_identifier', '/repo/root_kind');
284
+ if (!exactRecord(value.capacity, ['executors'])) return reject('unexpected_or_missing_keys', '/capacity');
285
+ if (!positiveInteger(value.capacity.executors)) return reject('not_a_positive_integer', '/capacity/executors');
286
+ if (!Array.isArray(value.todos) || value.todos.length < 1 || value.todos.length > MAX_COLLECTION) {
287
+ return reject('bounded_collection_violation', '/todos');
288
+ }
289
+ for (const [index, todo] of value.todos.entries()) {
290
+ if (!exactRecord(todo, ['todo_id'])) return reject('unexpected_or_missing_keys', `/todos/${index}`);
291
+ if (!identifier(todo.todo_id)) return reject('invalid_identifier', `/todos/${index}/todo_id`);
292
+ }
293
+ const todoIds = value.todos.map((todo) => todo.todo_id);
294
+ if (new Set(todoIds).size !== todoIds.length) return reject('duplicate_todo_id', '/todos');
295
+ if (!plainObject(value.manual_witness)) return reject('not_an_object', '/manual_witness');
296
+ if (!exactRecord(value.manual_witness, todoIds)) {
297
+ return reject('manual_witness_keys_must_equal_todo_ids', '/manual_witness');
298
+ }
299
+ for (const todoId of todoIds) {
300
+ const witness = value.manual_witness[todoId];
301
+ const at = `/manual_witness/${todoId}`;
302
+ if (!plainObject(witness)) return reject('not_an_object', at);
303
+ if (!exactRecord(witness, MANUAL_WITNESS_FIELDS)) return reject('unexpected_or_missing_keys', at);
304
+ if (!boundedArray(witness.owns, ownEntry)) return reject('invalid_own_entries', `${at}/owns`);
305
+ if (!repoPathArray(witness.reads)) return reject('invalid_repo_relative_paths', `${at}/reads`);
306
+ if (!repoPathArray(witness.writes, { allowPrefix: true })) return reject('invalid_repo_relative_paths', `${at}/writes`);
307
+ if (!boundedArray(witness.resources, identifier)) return reject('invalid_identifier', `${at}/resources`);
308
+ if (!boundedArray(witness.state_effects, stateEffectEntry)) return reject('invalid_state_effect_entries', `${at}/state_effects`);
309
+ // sensor_provenanceもfront-end adapter契約のshapeまで検査する(ADR 0123)。
310
+ const provenanceAt = `${at}/sensor_provenance`;
311
+ if (!exactRecord(witness.sensor_provenance, ['queries'])) return reject('unexpected_or_missing_keys', provenanceAt);
312
+ if (!Array.isArray(witness.sensor_provenance.queries)
313
+ || witness.sensor_provenance.queries.length > MAX_COLLECTION) {
314
+ return reject('bounded_collection_violation', `${provenanceAt}/queries`);
315
+ }
316
+ for (const [index, entry] of witness.sensor_provenance.queries.entries()) {
317
+ const entryAt = `${provenanceAt}/queries/${index}`;
318
+ if (!exactRecord(entry, ['query_id', 'expect'])) return reject('unexpected_or_missing_keys', entryAt);
319
+ if (!identifier(entry.query_id)) return reject('invalid_identifier', `${entryAt}/query_id`);
320
+ if (!sensorExpect(entry.expect)) return reject('invalid_sensor_expect', `${entryAt}/expect`);
321
+ }
322
+ if (!repoPathArray(witness.affected_tests)) return reject('invalid_repo_relative_paths', `${at}/affected_tests`);
323
+ if (!boundedArray(witness.unknowns, unknownEntry)) return reject('invalid_unknown_entries', `${at}/unknowns`);
324
+ }
325
+ // sensor_query_set/executor_capabilityは、runtime front-endとrun startが実際に要求する
326
+ // shapeまで検査する。schemaで通ってから後段で落ちる契約分裂を残さない(ADR 0123)。
327
+ if (!exactRecord(value.sensor_query_set, ['queries'])) return reject('unexpected_or_missing_keys', '/sensor_query_set');
328
+ if (!Array.isArray(value.sensor_query_set.queries) || value.sensor_query_set.queries.length > MAX_COLLECTION) {
329
+ return reject('bounded_collection_violation', '/sensor_query_set/queries');
330
+ }
331
+ const queryIds = new Set();
332
+ for (const [index, query] of value.sensor_query_set.queries.entries()) {
333
+ const at = `/sensor_query_set/queries/${index}`;
334
+ const keys = plainObject(query) && Object.hasOwn(query, 'target')
335
+ ? ['id', 'operation', 'target'] : ['id', 'operation'];
336
+ if (!exactRecord(query, keys)) return reject('unexpected_or_missing_keys', at);
337
+ if (!identifier(query.id)) return reject('invalid_identifier', `${at}/id`);
338
+ if (!SENSOR_QUERY_OPERATIONS.includes(query.operation)) return reject('unknown_sensor_query_operation', `${at}/operation`);
339
+ if (keys.includes('target') && (typeof query.target !== 'string' || query.target.length === 0)) {
340
+ return reject('empty_query_target', `${at}/target`);
341
+ }
342
+ if (queryIds.has(query.id)) return reject('duplicate_query_id', `${at}/id`);
343
+ queryIds.add(query.id);
344
+ }
345
+ if (!exactRecord(value.executor_capability, ['adapters'])) return reject('unexpected_or_missing_keys', '/executor_capability');
346
+ if (!uniqueIdentifierArray(value.executor_capability.adapters, { min: 1 })) {
347
+ return reject('invalid_adapter_identifiers', '/executor_capability/adapters');
348
+ }
349
+ if (value.claim_mode !== RUN_REQUEST_CLAIM_MODE) return reject('claim_mode_must_be_exact_minimum', '/claim_mode');
350
+ if (!selfDigestValid(value, 'request_digest')) return reject('request_digest_mismatch', '/request_digest');
351
+ return { valid: true };
352
+ }
353
+
234
354
  /** `lattice.run_request.v1`。manual witnessはTODOごとに完備でなければならない。 */
235
355
  export function validateRunRequest(value) {
236
- return validateSafely(value, (request) => {
237
- if (!exactRecord(request, [
238
- 'schema',
239
- 'request_id',
240
- 'repo',
241
- 'capacity',
242
- 'todos',
243
- 'manual_witness',
244
- 'sensor_query_set',
245
- 'executor_capability',
246
- 'claim_mode',
247
- 'request_digest',
248
- ])
249
- || request.schema !== 'lattice.run_request.v1'
250
- || !identifier(request.request_id)
251
- || !exactRecord(request.repo, ['base_sha', 'root_kind'])
252
- || !gitSha(request.repo.base_sha)
253
- || !identifier(request.repo.root_kind)
254
- || !exactRecord(request.capacity, ['executors'])
255
- || !positiveInteger(request.capacity.executors)
256
- || !boundedArray(request.todos, (todo) => (
257
- exactRecord(todo, ['todo_id']) && identifier(todo.todo_id)
258
- ), { min: 1 })) {
259
- return false;
260
- }
261
- const todoIds = request.todos.map((todo) => todo.todo_id);
262
- if (new Set(todoIds).size !== todoIds.length) return false;
263
- return plainObject(request.manual_witness)
264
- && exactRecord(request.manual_witness, todoIds)
265
- && Object.values(request.manual_witness).every(manualWitnessEntry)
266
- && plainObject(request.sensor_query_set)
267
- && plainObject(request.executor_capability)
268
- && request.claim_mode === 'exact_minimum'
269
- && selfDigestValid(request, 'request_digest');
270
- });
356
+ return explainRunRequest(value).valid;
271
357
  }
272
358
 
273
359
  /**
@@ -6,6 +6,8 @@ import { portableSensorOutcome } from './sensor-adapter.mjs';
6
6
  import { compileSchedulabilityGraphV2 } from './schedulability-compiler-v2.mjs';
7
7
  import { verifySchedulabilityPlanV2 } from './schedulability-verifier-v2.mjs';
8
8
  import {
9
+ SENSOR_EXPECT_KINDS,
10
+ SENSOR_QUERY_OPERATIONS,
9
11
  selfDigest,
10
12
  validateRunRequest,
11
13
  validateRuntimeBoundaryManifest,
@@ -36,16 +38,10 @@ import {
36
38
  * portable outcome projectionのcanonical digestだけを入れる(Decision 10.4)。
37
39
  */
38
40
 
39
- const QUERY_OPERATIONS = Object.freeze([
40
- 'status',
41
- 'query',
42
- 'callers',
43
- 'callees',
44
- 'impact',
45
- 'affected',
46
- ]);
41
+ // 閉集合の正本はruntime-contracts。ここでは輸入して使う。
42
+ const QUERY_OPERATIONS = SENSOR_QUERY_OPERATIONS;
47
43
  const STRUCTURE_OPERATIONS = new Set(['query', 'callers', 'callees', 'impact']);
48
- const EXPECT_KINDS = new Set(['symbol', 'path', 'affected']);
44
+ const EXPECT_KINDS = new Set(SENSOR_EXPECT_KINDS);
49
45
  const IDENTIFIER = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/;
50
46
  const STATE_KIND_MAP = Object.freeze({
51
47
  state: 'state',
package/src/todo-cli.mjs CHANGED
@@ -46,7 +46,7 @@ import {
46
46
  appendTodoExtraction,
47
47
  validateTodoExtraction,
48
48
  } from './todo-migration.mjs';
49
- import { projectTodoStatus } from './todo-status.mjs';
49
+ import { projectTodoBindings, projectTodoStatus } from './todo-status.mjs';
50
50
  import {
51
51
  parseTodoSourceRef, todoLegacyReconciliationDigest, validatePhaseTodoRevision,
52
52
  validateTodoRevision, validateTodoRevisionSet,
@@ -474,6 +474,10 @@ async function status({ repoRoot }) {
474
474
  return projectTodoStatus(await readTodoStore({ repoRoot }));
475
475
  }
476
476
 
477
+ async function bindings({ repoRoot, requestedPlanKey }) {
478
+ return projectTodoBindings(await readTodoStore({ repoRoot }), { requestedPlanKey });
479
+ }
480
+
477
481
  async function readNarrative(repoRoot, ref) {
478
482
  const canonicalRoot = await realpath(repoRoot);
479
483
  const source = parseTodoSourceRef(ref);
@@ -926,6 +930,13 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
926
930
  if ((argv.length === 1 && argv[0] === 'status')
927
931
  || (argv.length === 2 && argv[0] === 'status' && argv[1] === '--json')) {
928
932
  action = (repoRoot) => status({ repoRoot });
933
+ } else if ((argv.length === 1 && argv[0] === 'bindings')
934
+ || (argv.length === 2 && argv[0] === 'bindings' && argv[1] === '--json')) {
935
+ action = (repoRoot) => bindings({ repoRoot, requestedPlanKey: null });
936
+ } else if ((argv.length === 3 || argv.length === 4) && argv[0] === 'bindings'
937
+ && argv[1] === '--plan' && isTodoIdentifier(argv[2])
938
+ && (argv.length === 3 || argv[3] === '--json')) {
939
+ action = (repoRoot) => bindings({ repoRoot, requestedPlanKey: argv[2] });
929
940
  } else if ((argv.length === 1 && argv[0] === 'verify')
930
941
  || (argv.length === 2 && argv[0] === 'verify' && argv[1] === '--json')) {
931
942
  action = (repoRoot) => verify({ repoRoot, requestedPlanKey: null });
@@ -338,3 +338,63 @@ export function projectTodoStatus(readModel) {
338
338
  }
339
339
  return result;
340
340
  }
341
+
342
+ export const TODO_BINDING_PROJECTION_SCHEMA = 'lattice.todo_binding_projection.v1';
343
+
344
+ /**
345
+ * `compile_binding`が設定されたTaskだけを、TODO正本のidentityつきで投影する(ADR 0124)。
346
+ *
347
+ * これはTODO工程storeとruntime実行を結ぶ唯一の公開読み取り面である。host は
348
+ * `compiled_plan_digest`で`runtime_plan.v1`を、`base_sha`でrun requestのbaseを照合し、
349
+ * plan→`executor_packet.v1`→`executor_receipt.v1`(`packet_digest`帰属)まで辿れる。
350
+ *
351
+ * `todo_status_result.v4`は変更しない。binding投影は加算の別面とし、v4を受理する
352
+ * 既存hostを壊さない。
353
+ */
354
+ export function projectTodoBindings(readModel, { requestedPlanKey = null } = {}) {
355
+ if (!plain(readModel) || readModel.schema !== 'lattice.todo_store_read.v1'
356
+ || !isTodoIdentifier(readModel.project_id) || !Array.isArray(readModel.members)) {
357
+ fail('TODO_STATUS_INVALID_INPUT', 'todo_status_read_model_invalid');
358
+ }
359
+ if (requestedPlanKey !== null && !isTodoIdentifier(requestedPlanKey)) {
360
+ fail('TODO_STATUS_INVALID_INPUT', 'todo_binding_plan_key_invalid');
361
+ }
362
+ const members = [...readModel.members].sort((left, right) => {
363
+ const leftKey = left?.plan?.plan_key ?? '';
364
+ const rightKey = right?.plan?.plan_key ?? '';
365
+ return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
366
+ });
367
+ const bindings = [];
368
+ let matchedPlan = requestedPlanKey === null;
369
+ for (const member of members) {
370
+ const plan = member?.plan;
371
+ if (!plain(plan) || !Array.isArray(plan.tasks)) continue;
372
+ if (requestedPlanKey !== null && plan.plan_key !== requestedPlanKey) continue;
373
+ if (requestedPlanKey !== null) matchedPlan = true;
374
+ for (const task of plan.tasks) {
375
+ if (!plain(task) || task.compile_binding === null || task.compile_binding === undefined) continue;
376
+ bindings.push({
377
+ project_id: plan.project_id,
378
+ plan_key: plan.plan_key,
379
+ plan_version: plan.plan_version,
380
+ task_id: task.task_id,
381
+ compile_binding: task.compile_binding,
382
+ });
383
+ if (bindings.length > TODO_STATUS_LIST_LIMIT) {
384
+ fail('TODO_SCALE_EXCEEDED', 'todo_binding_projection_limit_exceeded', {
385
+ binding_limit: TODO_STATUS_LIST_LIMIT,
386
+ });
387
+ }
388
+ }
389
+ }
390
+ if (!matchedPlan) fail('TODO_STATUS_INVALID_INPUT', 'todo_binding_plan_not_found');
391
+ const result = {
392
+ schema: TODO_BINDING_PROJECTION_SCHEMA,
393
+ project_id: readModel.project_id,
394
+ plan_key: requestedPlanKey,
395
+ bindings,
396
+ result_digest: '',
397
+ };
398
+ result.result_digest = todoSelfDigest(result, 'result_digest');
399
+ return result;
400
+ }