@quolu/lattice 0.14.0 → 0.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Lattice — phase-aware TODO graph compiler and conflict-aware orchestration runtime",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -60,7 +60,7 @@
60
60
  "test": "node scripts/run-product-tests.mjs",
61
61
  "test:sensor": "npm --prefix sensor test",
62
62
  "precheck": "node --check src/bridge-launch-agent.mjs",
63
- "check": "node --check bin/lattice.mjs && node --check bin/lattice-mcp.mjs && node --check bin/lattice-dashboard.mjs && node --check bin/lattice-bridge.mjs && node --check bin/lattice-scripted-adapter.mjs && node --check src/cli-stdio.mjs && node --check src/bridge-address.mjs && node --check src/bridge-registrar.mjs && node --check src/todo-independence-guidance.mjs && node --check src/todo-independence.mjs && node --check src/todo-independence-contracts.mjs && node --check src/todo-gantt-layout.mjs && node --check src/todo-gantt-scope.mjs && node --check src/bridge-config.mjs && node --check src/bridge-server.mjs && node --check src/bridge-daemon.mjs && node --check src/bridge-cli.mjs && node --check src/project-cli.mjs && node --check src/sensor-cli.mjs && node --check src/sensor-runtime.mjs && node --check src/sensor-adapter.mjs && node --check src/factory-diagnostics.mjs && node --check src/runtime-errors.mjs && node --check src/runtime-contracts.mjs && node --check src/runtime-event-store.mjs && node --check src/runtime-adapter-registry.mjs && node --check src/runtime-scripted-adapter-controller.mjs && node --check src/hash-chain.mjs && node --check src/dag-chain.mjs && node --check src/todo-contracts.mjs && node --check src/todo-chain.mjs && node --check src/todo-store.mjs && node --check src/todo-migration.mjs && node --check src/todo-revision.mjs && node --check src/todo-status.mjs && node --check src/todo-cli.mjs && node --check src/todo-dashboard-registry.mjs && node --check src/todo-gantt-presentation.mjs && node --check src/todo-gantt-live.mjs && node --check src/bounded-seam.mjs && node --check src/todo-narrative-anchor.mjs && node --check src/todo-markdown-renderer.mjs && node --check src/todo-gantt-svg.mjs && node --check src/todo-gantt-html.mjs && node --check src/runtime-projection.mjs && node --check src/runtime-decision-verifier.mjs && node --check src/runtime-front-end.mjs && node --check src/runtime-cli.mjs && node --check src/rc3-dogfood-scaffold.mjs && node --check src/runtime-engine.mjs && node --check src/runtime-scripted-executor.mjs && node --check src/runtime-diff-observer.mjs && node --check src/runtime-worktree-executor.mjs && node --check src/runtime-hold-recompile.mjs && node --check src/rc3-scripted-campaign.mjs && node --check src/rc3-actual-dogfood.mjs && node --check src/rc4-stage1-dogfood.mjs && node --check research/fixtures/dispatch-record/src/dispatch-record.mjs && node --check test/research-dispatch-record.test.mjs",
63
+ "check": "node --check bin/lattice.mjs && node --check bin/lattice-mcp.mjs && node --check bin/lattice-dashboard.mjs && node --check bin/lattice-bridge.mjs && node --check bin/lattice-scripted-adapter.mjs && node --check src/cli-stdio.mjs && node --check src/bridge-address.mjs && node --check src/bridge-registrar.mjs && node --check src/todo-independence-guidance.mjs && node --check src/todo-independence.mjs && node --check src/todo-independence-contracts.mjs && node --check src/seam-proposal-contracts.mjs && node --check src/seam-proposal-queries.mjs && node --check src/seam-proposal.mjs && node --check src/todo-gantt-layout.mjs && node --check src/todo-gantt-scope.mjs && node --check src/bridge-config.mjs && node --check src/bridge-server.mjs && node --check src/bridge-daemon.mjs && node --check src/bridge-cli.mjs && node --check src/project-cli.mjs && node --check src/sensor-cli.mjs && node --check src/sensor-runtime.mjs && node --check src/sensor-adapter.mjs && node --check src/factory-diagnostics.mjs && node --check src/runtime-errors.mjs && node --check src/runtime-contracts.mjs && node --check src/runtime-event-store.mjs && node --check src/runtime-adapter-registry.mjs && node --check src/runtime-scripted-adapter-controller.mjs && node --check src/hash-chain.mjs && node --check src/dag-chain.mjs && node --check src/todo-contracts.mjs && node --check src/todo-chain.mjs && node --check src/todo-store.mjs && node --check src/todo-migration.mjs && node --check src/todo-revision.mjs && node --check src/todo-status.mjs && node --check src/todo-cli.mjs && node --check src/todo-dashboard-registry.mjs && node --check src/todo-gantt-presentation.mjs && node --check src/todo-gantt-live.mjs && node --check src/bounded-seam.mjs && node --check src/todo-narrative-anchor.mjs && node --check src/todo-markdown-renderer.mjs && node --check src/todo-gantt-svg.mjs && node --check src/todo-gantt-html.mjs && node --check src/runtime-projection.mjs && node --check src/runtime-decision-verifier.mjs && node --check src/runtime-front-end.mjs && node --check src/runtime-cli.mjs && node --check src/rc3-dogfood-scaffold.mjs && node --check src/runtime-engine.mjs && node --check src/runtime-scripted-executor.mjs && node --check src/runtime-diff-observer.mjs && node --check src/runtime-worktree-executor.mjs && node --check src/runtime-hold-recompile.mjs && node --check src/rc3-scripted-campaign.mjs && node --check src/rc3-actual-dogfood.mjs && node --check src/rc4-stage1-dogfood.mjs && node --check research/fixtures/dispatch-record/src/dispatch-record.mjs && node --check test/research-dispatch-record.test.mjs",
64
64
  "check:project-identity": "node --check src/project-identity.mjs",
65
65
  "ci": "npm run test && npm run test:sensor && npm run check && npm run check:project-identity"
66
66
  }
package/src/cli-help.mjs CHANGED
@@ -53,6 +53,7 @@ Read commands:
53
53
  status [--json]
54
54
  bindings [--plan <key>] [--json] # compile_binding付きTaskをTODO identityつきで投影する
55
55
  independence [--plan <key>] [--json] # readyを検証済み並列・要直列・未検査へ分けて投影する
56
+ seam-proposal [--plan <key>] [--json] # 記録済みseam提案をsensor無しで投影する
56
57
  verify [--plan <key>] [--json]
57
58
  snapshot --rebuild --plan <key>
58
59
  gantt [--out <file>] [--scope live|all] # 既定live: 完走した工程を図から除く(一覧には残る)
@@ -69,6 +70,7 @@ Write commands:
69
70
  evidence promote --plan <key> --task <id> --evidence <file>
70
71
  independence compile --plan <key> --input <file> # witness setとsensorから並列可否を記録する
71
72
  independence witness migrate --plan <key> # revision後の宣言をtask migrationで写す
73
+ seam-proposal compile --plan <key> # 並列可否記録と実sensorからseam提案を記録する
72
74
  revise --plan <key> --input <file>
73
75
  revise-phase --plan <key> --input <file>
74
76
  revise-set --input <file>
@@ -130,6 +132,7 @@ const SUBCOMMAND_USAGE = Object.freeze({
130
132
  'todo status': 'todo status [--json]',
131
133
  'todo bindings': 'todo bindings [--plan <key>] [--json]',
132
134
  'todo independence': 'todo independence [--plan <key>] [--json] | compile --plan <key> --input <file> | witness migrate --plan <key>',
135
+ 'todo seam-proposal': 'todo seam-proposal [--plan <key>] [--json] | compile --plan <key>',
133
136
  'todo verify': 'todo verify [--plan <key>] [--json]',
134
137
  'todo snapshot': 'todo snapshot --rebuild --plan <key>',
135
138
  'todo gantt': 'todo gantt [--out <file>] [--scope live|all] | status [--out <file>] | serve --port <port> [--scope live|all]',
@@ -14,6 +14,7 @@ import {
14
14
  } from './todo-contracts.mjs';
15
15
  import { projectTodoStatus } from './todo-status.mjs';
16
16
  import { projectIndependenceFrontier } from './todo-independence.mjs';
17
+ import { isTodoIndependenceLegacyMarker } from './todo-independence-contracts.mjs';
17
18
  import { selectIndependenceGuidance } from './todo-independence-guidance.mjs';
18
19
  import { ensureTodoDashboardActivity } from './todo-dashboard-registry.mjs';
19
20
  import { resolveProjectIdentity } from './project-identity.mjs';
@@ -161,6 +162,7 @@ async function summarizeIndependence({ repoRoot, store, todo }) {
161
162
  coverage: projected.coverage,
162
163
  guidance: selectIndependenceGuidance({
163
164
  coverage: projected.coverage,
165
+ contractSuperseded: isTodoIndependenceLegacyMarker(artifact),
164
166
  readyCount: todo.next_ready.filter((task) => task.plan_key === planKey).length,
165
167
  taskDeclared: projected.frontier.unknown
166
168
  .every(({ unknowns }) => !unknowns.some(({ kind }) => kind === 'witness_missing')),
@@ -0,0 +1,427 @@
1
+ import {
2
+ digestTodoArtifact,
3
+ exactRecord,
4
+ isNonNegativeSafeInteger,
5
+ isStrictTodoTimestamp,
6
+ isTodoDigest,
7
+ isTodoIdentifier,
8
+ todoSelfDigest,
9
+ } from './todo-contracts.mjs';
10
+ import {
11
+ TODO_INDEPENDENCE_CONFLICT_KINDS,
12
+ TODO_INDEPENDENCE_COVERAGE,
13
+ TODO_INDEPENDENCE_SCHEMA,
14
+ TODO_INDEPENDENCE_TASK_LIMIT,
15
+ isGitSha,
16
+ severabilityOfConflictKind,
17
+ } from './todo-independence-contracts.mjs';
18
+ import { SEAM_PROPOSAL_GUIDANCE_CODES } from './todo-independence-guidance.mjs';
19
+
20
+ export const SEAM_PROPOSAL_SCHEMA = 'lattice.seam_proposal.v1';
21
+ export const SEAM_PROPOSAL_PROJECTION_SCHEMA = 'lattice.seam_proposal_projection.v1';
22
+ export const SEAM_PROPOSAL_VERDICTS = Object.freeze([
23
+ 'seam_candidate',
24
+ 'intentional_serial',
25
+ 'unknown_requires_evidence',
26
+ ]);
27
+ export const SEAM_PROPOSAL_STABLE_SURFACE_ROLES = Object.freeze([
28
+ 'composition_test',
29
+ 'facade',
30
+ 'fixed_oracle',
31
+ ]);
32
+
33
+ const LIST_LIMIT = 4_096;
34
+ const CONTROL = /[\u0000-\u001f\u007f]/u;
35
+ const PROPOSAL_ID = /^seam-[0-9a-f]{64}$/u;
36
+ const SURFACE_KINDS = Object.freeze(['path', 'symbol']);
37
+ const STABLE_SURFACE_ROLES = new Set(SEAM_PROPOSAL_STABLE_SURFACE_ROLES);
38
+
39
+ const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
40
+
41
+ function boundedText(value, maxBytes = 4_096) {
42
+ return typeof value === 'string' && value.length > 0 && value === value.trim()
43
+ && !CONTROL.test(value) && Buffer.byteLength(value) <= maxBytes;
44
+ }
45
+
46
+ function boundedList(value, validator, { min = 0, max = LIST_LIMIT } = {}) {
47
+ return Array.isArray(value) && value.length >= min && value.length <= max
48
+ && value.every(validator);
49
+ }
50
+
51
+ function strictlySorted(values, key = (value) => value) {
52
+ return values.every((value, index) => index === 0
53
+ || compareText(key(values[index - 1]), key(value)) < 0);
54
+ }
55
+
56
+ function repoRelativePathOrPrefix(value) {
57
+ if (!boundedText(value) || value.startsWith('/') || value.includes('\\')
58
+ || /^[A-Za-z]:/u.test(value)) return false;
59
+ const body = value.endsWith('/') ? value.slice(0, -1) : value;
60
+ return body.length > 0 && body.split('/')
61
+ .every((segment) => segment !== '' && segment !== '.' && segment !== '..');
62
+ }
63
+
64
+ function repoRelativePath(value) {
65
+ return repoRelativePathOrPrefix(value) && !value.endsWith('/');
66
+ }
67
+
68
+ function sourceBinding(value) {
69
+ return exactRecord(value, [
70
+ 'independence_schema', 'independence_result_digest', 'witness_set_digest',
71
+ 'plan_version', 'topology_digest', 'base_sha',
72
+ ])
73
+ && value.independence_schema === TODO_INDEPENDENCE_SCHEMA
74
+ && isTodoDigest(value.independence_result_digest)
75
+ && isTodoDigest(value.witness_set_digest)
76
+ && isTodoIdentifier(value.plan_version)
77
+ && isTodoDigest(value.topology_digest)
78
+ && isGitSha(value.base_sha);
79
+ }
80
+
81
+ function taskPair(value, taskIds) {
82
+ return Array.isArray(value) && value.length === 2
83
+ && value.every(isTodoIdentifier)
84
+ && compareText(value[0], value[1]) < 0
85
+ && value.every((taskId) => taskIds.has(taskId));
86
+ }
87
+
88
+ function conflictEntry(value, taskIds) {
89
+ return exactRecord(value, ['resource_id', 'kind', 'target', 'task_pairs'])
90
+ && boundedText(value.resource_id)
91
+ && TODO_INDEPENDENCE_CONFLICT_KINDS.includes(value.kind)
92
+ && (value.kind === 'path'
93
+ ? repoRelativePathOrPrefix(value.target)
94
+ : boundedText(value.target))
95
+ && boundedList(value.task_pairs, (pair) => taskPair(pair, taskIds), { min: 1 })
96
+ && strictlySorted(value.task_pairs, (pair) => `${pair[0]}\0${pair[1]}`);
97
+ }
98
+
99
+ function conflictsFormConnectedComponent(conflicts, taskIds) {
100
+ const adjacency = new Map([...taskIds].map((taskId) => [taskId, new Set()]));
101
+ for (const { task_pairs: pairs } of conflicts) {
102
+ for (const [left, right] of pairs) {
103
+ adjacency.get(left).add(right);
104
+ adjacency.get(right).add(left);
105
+ }
106
+ }
107
+ const first = taskIds.values().next().value;
108
+ const visited = new Set([first]);
109
+ const pending = [first];
110
+ while (pending.length > 0) {
111
+ for (const adjacent of adjacency.get(pending.pop())) {
112
+ if (!visited.has(adjacent)) {
113
+ visited.add(adjacent);
114
+ pending.push(adjacent);
115
+ }
116
+ }
117
+ }
118
+ return visited.size === taskIds.size;
119
+ }
120
+
121
+ const surfaceKey = (value) => `${value.kind}\0${value.target}\0${value.path}\0${value.role}`;
122
+
123
+ function surfaceEntry(value, taskIds, { proposed }) {
124
+ if (!exactRecord(value, ['kind', 'target', 'path', 'role', 'owner_task_ids'])
125
+ || !SURFACE_KINDS.includes(value.kind)
126
+ || !repoRelativePathOrPrefix(value.path)
127
+ || !boundedText(value.target)
128
+ || !isTodoIdentifier(value.role)
129
+ || !boundedList(value.owner_task_ids, isTodoIdentifier, {
130
+ max: proposed ? 1 : TODO_INDEPENDENCE_TASK_LIMIT,
131
+ })
132
+ || !strictlySorted(value.owner_task_ids)
133
+ || !value.owner_task_ids.every((taskId) => taskIds.has(taskId))) return false;
134
+ if (value.kind === 'path' && value.target !== value.path) return false;
135
+ if (value.kind === 'symbol' && !repoRelativePath(value.path)) return false;
136
+ if (!proposed) return true;
137
+ return STABLE_SURFACE_ROLES.has(value.role)
138
+ ? value.owner_task_ids.length === 0
139
+ : value.owner_task_ids.length === 1;
140
+ }
141
+
142
+ function queryEntry(value) {
143
+ return exactRecord(value, [
144
+ 'query_id', 'operation', 'target', 'outcome', 'resolved_name', 'resolved_path',
145
+ 'result_digest',
146
+ ])
147
+ && isTodoIdentifier(value.query_id)
148
+ && isTodoIdentifier(value.operation)
149
+ && boundedText(value.target)
150
+ && isTodoIdentifier(value.outcome)
151
+ && (value.resolved_name === null || boundedText(value.resolved_name))
152
+ && (value.resolved_path === null || repoRelativePath(value.resolved_path))
153
+ && isTodoDigest(value.result_digest);
154
+ }
155
+
156
+ /**
157
+ * `query_set_digest` is an external binding to the exact sensor query set input used for
158
+ * collection (the same meaning as `digestArtifact(request.sensor_query_set)` in the runtime
159
+ * front end). That input is not embedded here, so this validator can check only digest shape.
160
+ * `evidence_digest` separately self-seals this record, including that binding and every receipt.
161
+ */
162
+ function evidenceEntry(value) {
163
+ return exactRecord(value, ['query_set_digest', 'evidence_digest', 'queries'])
164
+ && isTodoDigest(value.query_set_digest)
165
+ && isTodoDigest(value.evidence_digest)
166
+ && boundedList(value.queries, queryEntry)
167
+ && strictlySorted(value.queries, (query) => query.query_id)
168
+ && value.evidence_digest === todoSelfDigest(value, 'evidence_digest');
169
+ }
170
+
171
+ function verificationEntry(value) {
172
+ return exactRecord(value, [
173
+ 'virtual_compile_input_digest', 'virtual_compile_result_digest', 'residual_conflicts',
174
+ ])
175
+ && isTodoDigest(value.virtual_compile_input_digest)
176
+ && isTodoDigest(value.virtual_compile_result_digest)
177
+ && Array.isArray(value.residual_conflicts)
178
+ && value.residual_conflicts.length === 0;
179
+ }
180
+
181
+ function normalizeConflictIdentity(conflicts) {
182
+ return conflicts.map(({ resource_id: resourceId, kind, target, task_pairs: taskPairs }) => ({
183
+ resource_id: resourceId,
184
+ kind,
185
+ target,
186
+ task_pairs: taskPairs.map(([left, right]) => [left, right])
187
+ .sort((left, right) => compareText(`${left[0]}\0${left[1]}`, `${right[0]}\0${right[1]}`)),
188
+ })).sort((left, right) => compareText(left.resource_id, right.resource_id));
189
+ }
190
+
191
+ function normalizeProposedOwnership(surfaces) {
192
+ return surfaces.map(({ kind, target, path, role, owner_task_ids: ownerTaskIds }) => ({
193
+ kind,
194
+ target,
195
+ path,
196
+ role,
197
+ owner_task_ids: [...ownerTaskIds].sort(compareText),
198
+ })).sort((left, right) => compareText(surfaceKey(left), surfaceKey(right)));
199
+ }
200
+
201
+ /**
202
+ * Content receipts and source freshness deliberately do not participate in semantic identity.
203
+ * The caller still has to pass the same conflict component and proposed ownership shape.
204
+ */
205
+ export function deriveSeamProposalId({ conflicts, proposed_surfaces: proposedSurfaces }) {
206
+ const semanticKey = {
207
+ conflicts: normalizeConflictIdentity(conflicts),
208
+ proposed_surfaces: normalizeProposedOwnership(proposedSurfaces),
209
+ };
210
+ return `seam-${digestTodoArtifact(semanticKey)}`;
211
+ }
212
+
213
+ function seamCandidate(value, conflicts, taskIds) {
214
+ if (!exactRecord(value, [
215
+ 'proposal_id', 'current_surfaces', 'proposed_surfaces', 'affected_tests',
216
+ 'verification', 'evidence', 'limits', 'proposal_digest',
217
+ ])
218
+ || !PROPOSAL_ID.test(value.proposal_id)
219
+ || !boundedList(value.current_surfaces,
220
+ (surface) => surfaceEntry(surface, taskIds, { proposed: false }))
221
+ || !strictlySorted(value.current_surfaces, surfaceKey)
222
+ || !boundedList(value.proposed_surfaces,
223
+ (surface) => surfaceEntry(surface, taskIds, { proposed: true }), { min: 1 })
224
+ || !strictlySorted(value.proposed_surfaces, surfaceKey)
225
+ || !boundedList(value.affected_tests, repoRelativePath)
226
+ || !strictlySorted(value.affected_tests)
227
+ || !verificationEntry(value.verification)
228
+ || !evidenceEntry(value.evidence)
229
+ || !boundedList(value.limits, isTodoIdentifier, { min: 1 })
230
+ || !strictlySorted(value.limits)
231
+ || !value.limits.includes('structural_only')
232
+ || !isTodoDigest(value.proposal_digest)) return false;
233
+ const proposedOwnerTaskIds = new Set(value.proposed_surfaces
234
+ .flatMap(({ owner_task_ids: ownerTaskIds }) => ownerTaskIds));
235
+ if (proposedOwnerTaskIds.size !== taskIds.size
236
+ || ![...taskIds].every((taskId) => proposedOwnerTaskIds.has(taskId))) return false;
237
+ if (value.proposal_id !== deriveSeamProposalId({
238
+ conflicts,
239
+ proposed_surfaces: value.proposed_surfaces,
240
+ })) return false;
241
+ return value.proposal_digest === todoSelfDigest(value, 'proposal_digest');
242
+ }
243
+
244
+ function reasonEntry(value) {
245
+ return exactRecord(value, ['code', 'detail'])
246
+ && isTodoIdentifier(value.code) && boundedText(value.detail);
247
+ }
248
+
249
+ function unknownEntry(value) {
250
+ return exactRecord(value, ['kind', 'ref'])
251
+ && isTodoIdentifier(value.kind) && boundedText(value.ref);
252
+ }
253
+
254
+ function decisionEntry(value) {
255
+ if (!exactRecord(value, [
256
+ 'component_id', 'task_ids', 'conflicts', 'verdict', 'seam_candidate',
257
+ 'reasons', 'unknowns',
258
+ ])
259
+ || !isTodoIdentifier(value.component_id)
260
+ || !boundedList(value.task_ids, isTodoIdentifier, {
261
+ min: 2, max: TODO_INDEPENDENCE_TASK_LIMIT,
262
+ })
263
+ || !strictlySorted(value.task_ids)
264
+ || !SEAM_PROPOSAL_VERDICTS.includes(value.verdict)
265
+ || !boundedList(value.reasons, reasonEntry)
266
+ || !strictlySorted(value.reasons, (reason) => `${reason.code}\0${reason.detail}`)
267
+ || !boundedList(value.unknowns, unknownEntry)
268
+ || !strictlySorted(value.unknowns, (unknown) => `${unknown.kind}\0${unknown.ref}`)) return false;
269
+ const taskIds = new Set(value.task_ids);
270
+ if (!boundedList(value.conflicts, (conflict) => conflictEntry(conflict, taskIds), { min: 1 })
271
+ || !strictlySorted(value.conflicts, (conflict) => conflict.resource_id)
272
+ || !conflictsFormConnectedComponent(value.conflicts, taskIds)) return false;
273
+ if (value.verdict === 'seam_candidate') {
274
+ return value.unknowns.length === 0
275
+ && value.conflicts.every(({ kind }) => severabilityOfConflictKind(kind) === 'code_seam')
276
+ && seamCandidate(value.seam_candidate, value.conflicts, taskIds);
277
+ }
278
+ if (value.seam_candidate !== null) return false;
279
+ if (value.verdict === 'intentional_serial') return value.reasons.length > 0;
280
+ return value.unknowns.length > 0;
281
+ }
282
+
283
+ /**
284
+ * Validate the immutable public `lattice.seam_proposal.v1` artifact.
285
+ *
286
+ * This validates the artifact's closed runtime shape and canonical relations. Binding the
287
+ * recorded conflicts back to the referenced independence bytes is a consumer responsibility.
288
+ */
289
+ export function validateSeamProposal(value) {
290
+ try {
291
+ if (!exactRecord(value, [
292
+ 'schema', 'project_id', 'plan_key', 'source_binding', 'compiled_at',
293
+ 'decisions', 'result_digest',
294
+ ])
295
+ || value.schema !== SEAM_PROPOSAL_SCHEMA
296
+ || !isTodoIdentifier(value.project_id)
297
+ || !isTodoIdentifier(value.plan_key)
298
+ || !sourceBinding(value.source_binding)
299
+ || !isStrictTodoTimestamp(value.compiled_at)
300
+ || !boundedList(value.decisions, decisionEntry)
301
+ || !strictlySorted(value.decisions, (decision) => decision.component_id)
302
+ || !isTodoDigest(value.result_digest)) return false;
303
+ const resourceIds = value.decisions
304
+ .flatMap(({ conflicts }) => conflicts.map(({ resource_id: resourceId }) => resourceId));
305
+ if (new Set(resourceIds).size !== resourceIds.length) return false;
306
+ return value.result_digest === todoSelfDigest(value, 'result_digest');
307
+ } catch {
308
+ return false;
309
+ }
310
+ }
311
+
312
+ const PROJECTION_GUIDANCE_BY_COVERAGE = Object.freeze({
313
+ missing: 'seam_proposal_unrecorded',
314
+ stale: 'seam_proposal_stale',
315
+ superseded: 'seam_proposal_superseded',
316
+ verified: 'seam_proposal_verified',
317
+ });
318
+
319
+ function projectionGuidance(value, coverage) {
320
+ return exactRecord(value, ['code', 'message', 'next_action'])
321
+ && SEAM_PROPOSAL_GUIDANCE_CODES.includes(value.code)
322
+ && value.code === PROJECTION_GUIDANCE_BY_COVERAGE[coverage]
323
+ && boundedText(value.message)
324
+ && isTodoIdentifier(value.next_action);
325
+ }
326
+
327
+ function projectionComponent(value) {
328
+ if (!exactRecord(value, [
329
+ 'component_id', 'verdict', 'task_ids', 'conflicts', 'proposed_surfaces',
330
+ 'affected_tests', 'limits', 'reasons', 'unknowns',
331
+ ])
332
+ || !isTodoIdentifier(value.component_id)
333
+ || !SEAM_PROPOSAL_VERDICTS.includes(value.verdict)
334
+ || !boundedList(value.task_ids, isTodoIdentifier, {
335
+ min: 2, max: TODO_INDEPENDENCE_TASK_LIMIT,
336
+ })
337
+ || !strictlySorted(value.task_ids)
338
+ || !boundedList(value.reasons, reasonEntry)
339
+ || !strictlySorted(value.reasons, (reason) => `${reason.code}\0${reason.detail}`)
340
+ || !boundedList(value.unknowns, unknownEntry)
341
+ || !strictlySorted(value.unknowns, (unknown) => `${unknown.kind}\0${unknown.ref}`)
342
+ || !boundedList(value.affected_tests, repoRelativePath)
343
+ || !strictlySorted(value.affected_tests)
344
+ || !boundedList(value.limits, isTodoIdentifier)
345
+ || !strictlySorted(value.limits)) return false;
346
+ const taskIds = new Set(value.task_ids);
347
+ if (!boundedList(value.conflicts, (conflict) => conflictEntry(conflict, taskIds), { min: 1 })
348
+ || !strictlySorted(value.conflicts, (conflict) => conflict.resource_id)
349
+ || !conflictsFormConnectedComponent(value.conflicts, taskIds)
350
+ || !boundedList(value.proposed_surfaces,
351
+ (surface) => surfaceEntry(surface, taskIds, { proposed: true }))
352
+ || !strictlySorted(value.proposed_surfaces, surfaceKey)) return false;
353
+ if (value.verdict === 'seam_candidate') {
354
+ const ownerTaskIds = new Set(value.proposed_surfaces
355
+ .flatMap(({ owner_task_ids: owners }) => owners));
356
+ return value.unknowns.length === 0
357
+ && value.proposed_surfaces.length > 0
358
+ && value.limits.includes('structural_only')
359
+ && ownerTaskIds.size === taskIds.size
360
+ && [...taskIds].every((taskId) => ownerTaskIds.has(taskId));
361
+ }
362
+ if (value.proposed_surfaces.length > 0
363
+ || value.affected_tests.length > 0
364
+ || value.limits.length > 0) return false;
365
+ if (value.verdict === 'intentional_serial') return value.reasons.length > 0;
366
+ return value.unknowns.length > 0;
367
+ }
368
+
369
+ /**
370
+ * Validate the read-only lattice.seam_proposal_projection.v1 public CLI projection.
371
+ * The immutable seam proposal artifact contract above remains independent from this read model.
372
+ */
373
+ export function validateSeamProposalProjection(value) {
374
+ try {
375
+ if (!exactRecord(value, [
376
+ 'schema', 'project_id', 'plan_key', 'coverage', 'compiled_base_sha',
377
+ 'current_base_sha', 'plan_version', 'topology_digest', 'independence_result_digest',
378
+ 'compiled_at', 'guidance', 'component_count', 'conflict_resource_count',
379
+ 'components', 'result_digest',
380
+ ])
381
+ || value.schema !== SEAM_PROPOSAL_PROJECTION_SCHEMA
382
+ || !isTodoIdentifier(value.project_id)
383
+ || !isTodoIdentifier(value.plan_key)
384
+ || !TODO_INDEPENDENCE_COVERAGE.includes(value.coverage)
385
+ || !isGitSha(value.current_base_sha)
386
+ || !projectionGuidance(value.guidance, value.coverage)
387
+ || !isTodoDigest(value.result_digest)) return false;
388
+
389
+ if (value.coverage === 'missing') {
390
+ return value.compiled_base_sha === null
391
+ && value.plan_version === null
392
+ && value.topology_digest === null
393
+ && value.independence_result_digest === null
394
+ && value.compiled_at === null
395
+ && value.component_count === null
396
+ && value.conflict_resource_count === null
397
+ && Array.isArray(value.components)
398
+ && value.components.length === 0
399
+ && value.result_digest === todoSelfDigest(value, 'result_digest');
400
+ }
401
+
402
+ if (!isGitSha(value.compiled_base_sha)
403
+ || !isTodoIdentifier(value.plan_version)
404
+ || !isTodoDigest(value.topology_digest)
405
+ || !isTodoDigest(value.independence_result_digest)
406
+ || !isStrictTodoTimestamp(value.compiled_at)
407
+ || !isNonNegativeSafeInteger(value.component_count)
408
+ || !isNonNegativeSafeInteger(value.conflict_resource_count)
409
+ || !boundedList(value.components, projectionComponent)
410
+ || !strictlySorted(value.components, (component) => component.component_id)
411
+ || value.component_count !== value.components.length
412
+ || value.conflict_resource_count !== value.components
413
+ .reduce((count, component) => count + component.conflicts.length, 0)) return false;
414
+ if (value.coverage === 'verified' && value.compiled_base_sha !== value.current_base_sha) {
415
+ return false;
416
+ }
417
+ if (value.coverage === 'stale' && value.compiled_base_sha === value.current_base_sha) {
418
+ return false;
419
+ }
420
+ const resourceIds = value.components
421
+ .flatMap(({ conflicts }) => conflicts.map(({ resource_id: resourceId }) => resourceId));
422
+ return new Set(resourceIds).size === resourceIds.length
423
+ && value.result_digest === todoSelfDigest(value, 'result_digest');
424
+ } catch {
425
+ return false;
426
+ }
427
+ }