@quolu/lattice 0.15.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.15.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",
@@ -129,13 +129,30 @@ function assertRuntimeQuerySet(querySet) {
129
129
  }
130
130
  }
131
131
 
132
+ function assertConcernSymbols(concernSymbols) {
133
+ if (!Array.isArray(concernSymbols) || concernSymbols.length > QUERY_LIMIT) {
134
+ fail('SEAM_QUERY_CONCERN_SYMBOLS_INVALID', 'concern_symbols_not_bounded_array');
135
+ }
136
+ for (const symbol of concernSymbols) {
137
+ if (!boundedText(symbol, 1_024)) {
138
+ fail('SEAM_QUERY_CONCERN_SYMBOL_INVALID', 'concern_symbol_invalid');
139
+ }
140
+ }
141
+ return sortedUnique(concernSymbols);
142
+ }
143
+
132
144
  /**
133
145
  * Build the schema-less `lattice.run_request.v1.sensor_query_set` vocabulary used by
134
146
  * todo-independence. The wrapper records non-code conflicts without putting their targets
135
147
  * on the sensor command line.
148
+ *
149
+ * `concernSymbols` are the symbol names declared through witness `concern_anchors`. They only
150
+ * need the `query` operation: the binder asks whether the declared name resolves to exactly one
151
+ * symbol, and where it lives. Graph expansion stays owned by the conflict resources.
136
152
  */
137
- export function buildSeamProposalQuerySet({ conflictResources } = {}) {
153
+ export function buildSeamProposalQuerySet({ conflictResources, concernSymbols = [] } = {}) {
138
154
  const resources = assertConflictResources(conflictResources);
155
+ const concerns = assertConcernSymbols(concernSymbols);
139
156
  const queryById = new Map([[
140
157
  'seam-00-status',
141
158
  { id: 'seam-00-status', operation: 'status' },
@@ -168,6 +185,13 @@ export function buildSeamProposalQuerySet({ conflictResources } = {}) {
168
185
  }
169
186
  }
170
187
 
188
+ // 宣言concern symbolの解決query。conflict symbolと同名なら既存queryがそのまま答えになる。
189
+ for (const symbol of concerns) {
190
+ const id = queryId('query', 'symbol', symbol);
191
+ if (queryById.has(id)) continue;
192
+ queryById.set(id, { id, operation: 'query', target: symbol });
193
+ }
194
+
171
195
  const querySet = {
172
196
  queries: [...queryById.values()]
173
197
  .sort((left, right) => compareText(left.id, right.id)),
@@ -755,6 +755,122 @@ export function verifyVirtualCompileReceipt({ verification, ...options } = {}) {
755
755
  };
756
756
  }
757
757
 
758
+ /** 宣言されたconcern symbolを、`query` operationの解決receiptから探す。 */
759
+ function resolvedSymbolPath(queries, name) {
760
+ const receipt = queries.find((query) => (
761
+ query.operation === 'query'
762
+ && query.target === name
763
+ && query.outcome === 'resolved'
764
+ && query.resolved_name === name
765
+ && typeof query.resolved_path === 'string'
766
+ && query.resolved_path.length > 0
767
+ ));
768
+ return receipt === undefined ? null : receipt.resolved_path;
769
+ }
770
+
771
+ function pathContains(resourcePath, symbolPath) {
772
+ return resourcePath.endsWith('/')
773
+ ? symbolPath.startsWith(resourcePath)
774
+ : symbolPath === resourcePath;
775
+ }
776
+
777
+ /**
778
+ * Resolve declared concern anchors against fresh sensor evidence.
779
+ *
780
+ * A declaration only becomes a binding anchor when the sensor resolves the exact name to exactly
781
+ * one path and that path lies inside the declared resource. Fuzzy resolution to a neighbouring
782
+ * symbol, an absent name, or a symbol living outside the contested resource yields a typed
783
+ * unknown instead — a wrong declaration must never widen what the binder believes it knows.
784
+ *
785
+ * Two ToDos claiming the same symbol is a contradiction in the declarations themselves, not a cut
786
+ * to be discovered: the anchor is dropped from both and reported, so neither side can be bound by
787
+ * a claim the other also makes.
788
+ */
789
+ export function resolveConcernAnchors({ manualWitness, taskIds, evidence } = {}) {
790
+ if (!plainRecord(manualWitness) || !Array.isArray(taskIds) || !plainRecord(evidence)) {
791
+ fail('concern anchor resolution input shapeが不正');
792
+ }
793
+ // receiptが1件も無い証拠は「解決しなかった」であって、宣言を素通しさせる理由にはしない。
794
+ const queries = Array.isArray(evidence.queries) ? evidence.queries : [];
795
+ const anchorsByTask = new Map();
796
+ const unknowns = [];
797
+ for (const taskId of taskIds) {
798
+ const anchors = [];
799
+ for (const entry of manualWitness[taskId]?.concern_anchors ?? []) {
800
+ const resourcePath = entry.within.kind === 'path'
801
+ ? entry.within.target
802
+ : resolvedSymbolPath(queries, entry.within.target);
803
+ if (resourcePath === null) {
804
+ unknowns.push({
805
+ kind: 'concern_anchor_resource_unresolved',
806
+ ref: `${taskId}:${entry.within.kind}:${entry.within.target}`,
807
+ });
808
+ continue;
809
+ }
810
+ for (const symbol of entry.symbols) {
811
+ const symbolPath = resolvedSymbolPath(queries, symbol);
812
+ if (symbolPath === null) {
813
+ unknowns.push({ kind: 'concern_anchor_unresolved', ref: `${taskId}:${symbol}` });
814
+ continue;
815
+ }
816
+ if (!pathContains(resourcePath, symbolPath)) {
817
+ unknowns.push({
818
+ kind: 'concern_anchor_outside_resource',
819
+ ref: `${taskId}:${symbol}:${symbolPath}`,
820
+ });
821
+ continue;
822
+ }
823
+ anchors.push(`concern:${symbolPath}\0${symbol}`);
824
+ }
825
+ }
826
+ anchorsByTask.set(taskId, sortedUnique(anchors));
827
+ }
828
+
829
+ // 同じsymbolを2 task以上が担当と主張したら、どちらの束縛根拠にもしない。
830
+ const claimantsByAnchor = new Map();
831
+ for (const [taskId, anchors] of anchorsByTask) {
832
+ for (const anchor of anchors) {
833
+ if (!claimantsByAnchor.has(anchor)) claimantsByAnchor.set(anchor, []);
834
+ claimantsByAnchor.get(anchor).push(taskId);
835
+ }
836
+ }
837
+ const overlapping = new Set();
838
+ for (const [anchor, claimants] of claimantsByAnchor) {
839
+ if (claimants.length < 2) continue;
840
+ overlapping.add(anchor);
841
+ const [path, symbol] = anchor.slice('concern:'.length).split('\0');
842
+ unknowns.push({
843
+ kind: 'concern_anchor_overlap',
844
+ ref: `${[...claimants].sort(compareText).join(',')}:${path}:${symbol}`,
845
+ });
846
+ }
847
+ if (overlapping.size > 0) {
848
+ for (const [taskId, anchors] of anchorsByTask) {
849
+ anchorsByTask.set(taskId, anchors.filter((anchor) => !overlapping.has(anchor)));
850
+ }
851
+ }
852
+
853
+ return {
854
+ anchorsByTask,
855
+ unknowns: unknowns.sort((left, right) => compareText(
856
+ `${left.kind}\0${left.ref}`, `${right.kind}\0${right.ref}`,
857
+ )),
858
+ };
859
+ }
860
+
861
+ /** witness set全体から、sensorへ問い合わせるconcern symbol名を集める。 */
862
+ export function declaredConcernSymbols(manualWitness) {
863
+ if (!plainRecord(manualWitness)) fail('manual witness shapeが不正');
864
+ const names = [];
865
+ for (const witness of Object.values(manualWitness)) {
866
+ for (const entry of witness?.concern_anchors ?? []) {
867
+ names.push(...entry.symbols);
868
+ if (entry.within.kind === 'symbol') names.push(entry.within.target);
869
+ }
870
+ }
871
+ return sortedUnique(names);
872
+ }
873
+
758
874
  function uniqueIntentAnchors(manualWitness, taskIds) {
759
875
  const anchorsByTask = new Map();
760
876
  const counts = new Map();
@@ -1008,6 +1124,12 @@ function canonicalPartitions(partitions) {
1008
1124
  }
1009
1125
 
1010
1126
  function anchorMatchesNode(anchor, node) {
1127
+ if (anchor.startsWith('concern:')) {
1128
+ const separator = anchor.indexOf('\0');
1129
+ return node.kind === 'symbol'
1130
+ && node.path === anchor.slice('concern:'.length, separator)
1131
+ && node.target === anchor.slice(separator + 1);
1132
+ }
1011
1133
  if (anchor.startsWith('owns:symbol\0')) {
1012
1134
  return node.kind === 'symbol' && node.target === anchor.slice('owns:symbol\0'.length);
1013
1135
  }
@@ -1021,18 +1143,32 @@ function anchorMatchesNode(anchor, node) {
1021
1143
  return false;
1022
1144
  }
1023
1145
 
1024
- function bindSkeleton({ skeleton, graph, intentAnchors, taskIds }) {
1146
+ /**
1147
+ * Bind tasks to partitions.
1148
+ *
1149
+ * Declared concern anchors take precedence over the coarse owns/writes/test anchors within one
1150
+ * skeleton: a ToDo that named the symbols it touches inside the contested resource has given
1151
+ * strictly more specific evidence than "it owns some file". The coarse anchors stay as the
1152
+ * fallback for skeletons the declaration says nothing about, so declaring a concern for one
1153
+ * conflict never blinds the binder to another.
1154
+ */
1155
+ function bindSkeleton({ skeleton, graph, intentAnchors, concernAnchors, taskIds }) {
1025
1156
  const nodeByKey = new Map(graph.nodes.map((node) => [graphNodeKey(node), node]));
1026
1157
  const taskBindings = [];
1027
1158
  const unknowns = [];
1028
- for (const taskId of taskIds) {
1029
- const matches = [];
1159
+ const matchesFor = (anchors) => {
1160
+ const matched = [];
1030
1161
  skeleton.partitions.forEach((partition, index) => {
1031
- const anchors = intentAnchors.get(taskId).filter((anchor) => (
1162
+ const hits = anchors.filter((anchor) => (
1032
1163
  partition.some((key) => anchorMatchesNode(anchor, nodeByKey.get(key)))
1033
1164
  ));
1034
- if (anchors.length > 0) matches.push({ index, anchors: sortedUnique(anchors) });
1165
+ if (hits.length > 0) matched.push({ index, anchors: sortedUnique(hits) });
1035
1166
  });
1167
+ return matched;
1168
+ };
1169
+ for (const taskId of taskIds) {
1170
+ const declared = matchesFor(concernAnchors.get(taskId) ?? []);
1171
+ const matches = declared.length > 0 ? declared : matchesFor(intentAnchors.get(taskId));
1036
1172
  if (matches.length === 0) {
1037
1173
  unknowns.push({
1038
1174
  kind: 'semantic_owner_binding_missing',
@@ -1068,15 +1204,54 @@ function bindSkeleton({ skeleton, graph, intentAnchors, taskIds }) {
1068
1204
  };
1069
1205
  }
1070
1206
 
1207
+ /**
1208
+ * Build a cut skeleton for a contested repo path out of the declared concern anchors alone.
1209
+ *
1210
+ * A path conflict has no call graph to partition — the sensor was only asked which tests the file
1211
+ * affects. What the declarations do give is a partition of the file's symbols by owner, which is
1212
+ * exactly the shape a cut needs. Every task in the component must have named at least one symbol
1213
+ * inside the path; otherwise there is nothing to say about who owns which half and the caller
1214
+ * keeps reporting the resource as unavailable rather than guessing.
1215
+ */
1216
+ function declaredPartitionSkeleton({ conflict, concernAnchors, taskIds }) {
1217
+ const rootPath = conflict.target;
1218
+ const root = { kind: 'path', target: rootPath, path: rootPath };
1219
+ const nodes = [root];
1220
+ const partitions = [];
1221
+ for (const taskId of taskIds) {
1222
+ const owned = (concernAnchors.get(taskId) ?? [])
1223
+ .map((anchor) => {
1224
+ const separator = anchor.indexOf('\0');
1225
+ return {
1226
+ path: anchor.slice('concern:'.length, separator),
1227
+ name: anchor.slice(separator + 1),
1228
+ };
1229
+ })
1230
+ .filter(({ path }) => pathContains(rootPath, path));
1231
+ if (owned.length === 0) return null;
1232
+ for (const { path, name } of owned) nodes.push({ kind: 'symbol', target: name, path });
1233
+ partitions.push(owned.map(({ path, name }) => graphNodeKey({
1234
+ kind: 'symbol', target: name, path,
1235
+ })));
1236
+ }
1237
+ return {
1238
+ root,
1239
+ nodes,
1240
+ partitions: canonicalPartitions(partitions),
1241
+ };
1242
+ }
1243
+
1071
1244
  /**
1072
1245
  * Enumerate structural cut skeletons from the in-memory sensor outcomes. Graph edges only shape
1073
- * SCC/closure/frontier partitions; task ownership is bound exclusively by unique witness anchors.
1246
+ * SCC/closure/frontier partitions; task ownership is bound exclusively by unique witness anchors
1247
+ * and by declared concern anchors, never by the edges themselves.
1074
1248
  */
1075
1249
  export function enumerateCutSkeletons({
1076
1250
  component,
1077
1251
  request,
1078
1252
  evidence,
1079
1253
  rawCollected,
1254
+ concernAnchors = new Map(),
1080
1255
  } = {}) {
1081
1256
  if (!plainRecord(component)
1082
1257
  || !Array.isArray(component.task_ids)
@@ -1087,7 +1262,9 @@ export function enumerateCutSkeletons({
1087
1262
  }
1088
1263
  const taskIds = [...component.task_ids].sort(compareText);
1089
1264
  const intentAnchors = uniqueIntentAnchors(request.manual_witness, taskIds);
1090
- const missingIntent = taskIds.filter((taskId) => intentAnchors.get(taskId).length === 0);
1265
+ const missingIntent = taskIds.filter((taskId) => (
1266
+ intentAnchors.get(taskId).length === 0 && (concernAnchors.get(taskId) ?? []).length === 0
1267
+ ));
1091
1268
  if (missingIntent.length > 0) {
1092
1269
  return {
1093
1270
  skeletons: [],
@@ -1102,6 +1279,26 @@ export function enumerateCutSkeletons({
1102
1279
  const skeletonByLayout = new Map();
1103
1280
  const unknowns = [];
1104
1281
  for (const conflict of component.conflicts) {
1282
+ if (conflict.kind === 'path') {
1283
+ const declared = declaredPartitionSkeleton({ conflict, concernAnchors, taskIds });
1284
+ if (declared === null || declared.partitions.length < 2) {
1285
+ unknowns.push({ kind: 'raw_graph_unavailable', ref: conflict.resource_id });
1286
+ continue;
1287
+ }
1288
+ const layoutKey = digestArtifact({
1289
+ conflict_resource_id: conflict.resource_id,
1290
+ partitions: declared.partitions,
1291
+ });
1292
+ skeletonByLayout.set(layoutKey, {
1293
+ skeleton_id: `cut-${sha16(layoutKey)}`,
1294
+ conflict_resource_id: conflict.resource_id,
1295
+ cut_kinds: ['declared_partition'],
1296
+ root_surface: structuredClone(declared.root),
1297
+ partitions: declared.partitions,
1298
+ raw_graph: { nodes: structuredClone(declared.nodes), edges: [] },
1299
+ });
1300
+ continue;
1301
+ }
1105
1302
  if (conflict.kind !== 'symbol') {
1106
1303
  unknowns.push({ kind: 'raw_graph_unavailable', ref: conflict.resource_id });
1107
1304
  continue;
@@ -1154,6 +1351,7 @@ export function enumerateCutSkeletons({
1154
1351
  skeleton,
1155
1352
  graph: skeleton.raw_graph,
1156
1353
  intentAnchors,
1354
+ concernAnchors,
1157
1355
  taskIds,
1158
1356
  }));
1159
1357
  for (const skeleton of skeletons) unknowns.push(...skeleton.binding_unknowns);
@@ -1251,6 +1449,7 @@ export function evaluateSeamProposalCandidates({
1251
1449
  evidence,
1252
1450
  candidateSpecs,
1253
1451
  explorationComplete = false,
1452
+ concernAnchors = new Map(),
1254
1453
  } = {}) {
1255
1454
  if (!plainRecord(component)
1256
1455
  || !Array.isArray(component.task_ids)
@@ -1265,7 +1464,9 @@ export function evaluateSeamProposalCandidates({
1265
1464
  }
1266
1465
  const taskSet = new Set(taskIds);
1267
1466
  const intentAnchors = uniqueIntentAnchors(request.manual_witness, taskIds);
1268
- const missingIntent = taskIds.filter((taskId) => intentAnchors.get(taskId).length === 0);
1467
+ const missingIntent = taskIds.filter((taskId) => (
1468
+ intentAnchors.get(taskId).length === 0 && (concernAnchors.get(taskId) ?? []).length === 0
1469
+ ));
1269
1470
  if (missingIntent.length > 0) {
1270
1471
  return decisionUnknown(component, missingIntent.map((taskId) => ({
1271
1472
  kind: 'semantic_owner_binding_missing',
@@ -1556,15 +1757,20 @@ export function compileSeamProposalDecision({
1556
1757
  sensorEvidence,
1557
1758
  evidence,
1558
1759
  rawCollected,
1760
+ concernAnchors = new Map(),
1761
+ concernUnknowns = [],
1559
1762
  } = {}) {
1560
1763
  const enumeration = enumerateCutSkeletons({
1561
1764
  component,
1562
1765
  request,
1563
1766
  evidence,
1564
1767
  rawCollected,
1768
+ concernAnchors,
1565
1769
  });
1566
- if (enumeration.unknowns.length > 0) {
1567
- return decisionUnknown(component, enumeration.unknowns);
1770
+ // 宣言が解決しなかった事実は、束縛が成功したかに関わらず記録から消さない。
1771
+ const unknowns = [...concernUnknowns, ...enumeration.unknowns];
1772
+ if (unknowns.length > 0) {
1773
+ return decisionUnknown(component, unknowns);
1568
1774
  }
1569
1775
  const candidateSpecs = enumeration.skeletons.map((skeleton) => (
1570
1776
  skeletonCandidateSpec({ skeleton, component, request, evidence })
@@ -1582,6 +1788,7 @@ export function compileSeamProposalDecision({
1582
1788
  evidence,
1583
1789
  candidateSpecs,
1584
1790
  explorationComplete: enumeration.exploration_complete,
1791
+ concernAnchors,
1585
1792
  });
1586
1793
  }
1587
1794
 
@@ -1730,13 +1937,25 @@ export function compileSeamProposalArtifact({
1730
1937
  baseSha: independenceArtifact.base_sha,
1731
1938
  requestId: `seam-proposal-${independenceArtifact.result_digest.slice(0, 24)}`,
1732
1939
  });
1733
- const decisions = conflictComponents(independenceArtifact).map((component) => (
1940
+ const components = conflictComponents(independenceArtifact);
1941
+ const concern = resolveConcernAnchors({
1942
+ manualWitness: witnessSet.manual_witness,
1943
+ taskIds: [...new Set(components.flatMap(({ task_ids: ids }) => ids))].sort(compareText),
1944
+ evidence,
1945
+ });
1946
+ const decisions = components.map((component) => (
1734
1947
  compileSeamProposalDecision({
1735
1948
  component,
1736
1949
  request,
1737
1950
  sensorEvidence,
1738
1951
  evidence,
1739
1952
  rawCollected,
1953
+ concernAnchors: concern.anchorsByTask,
1954
+ // このcomponentのtaskに関する解決失敗だけを持ち込む。
1955
+ concernUnknowns: concern.unknowns.filter((unknown) => (
1956
+ component.task_ids.some((taskId) => unknown.ref.startsWith(`${taskId}:`)
1957
+ || unknown.ref.split(':')[0].split(',').includes(taskId))
1958
+ )),
1740
1959
  })
1741
1960
  )).sort((left, right) => compareText(left.component_id, right.component_id));
1742
1961
  const artifact = {
package/src/todo-cli.mjs CHANGED
@@ -83,7 +83,7 @@ import {
83
83
  SEAM_PROPOSAL_PROJECTION_SCHEMA,
84
84
  validateSeamProposalProjection,
85
85
  } from './seam-proposal-contracts.mjs';
86
- import { compileSeamProposalArtifact } from './seam-proposal.mjs';
86
+ import { compileSeamProposalArtifact, declaredConcernSymbols } from './seam-proposal.mjs';
87
87
  import {
88
88
  parseTodoSourceRef, todoLegacyReconciliationDigest, validatePhaseTodoRevision,
89
89
  validateTodoRevision, validateTodoRevisionSet,
@@ -893,6 +893,7 @@ async function seamProposalCompile({ repoRoot, planKey }) {
893
893
 
894
894
  const { query_set: querySet } = buildSeamProposalQuerySet({
895
895
  conflictResources: independenceArtifact.conflict_resources,
896
+ concernSymbols: declaredConcernSymbols(witnessSet.manual_witness),
896
897
  });
897
898
  const [sensorEvidence, proposalEvidence] = await Promise.all([
898
899
  collectWitnessSensorEvidence({ cwd: repoRoot, witnessSet }),
@@ -13,7 +13,19 @@ import {
13
13
  } from './runtime-contracts.mjs';
14
14
  import { TODO_INDEPENDENCE_GUIDANCE_CODES } from './todo-independence-guidance.mjs';
15
15
 
16
- export const TODO_WITNESS_SET_SCHEMA = 'lattice.todo_witness_set.v1';
16
+ export const TODO_WITNESS_SET_SCHEMA = 'lattice.todo_witness_set.v2';
17
+ /**
18
+ * まだ受理する旧witness set契約。v1はconcern anchorを持てないだけで、境界宣言としては
19
+ * v2と同値である。既存宣言を書き換えさせないために読み口を残す。
20
+ */
21
+ export const TODO_WITNESS_SET_LEGACY_SCHEMAS = Object.freeze(['lattice.todo_witness_set.v1']);
22
+ export const TODO_WITNESS_SET_SCHEMAS = Object.freeze([
23
+ TODO_WITNESS_SET_SCHEMA,
24
+ ...TODO_WITNESS_SET_LEGACY_SCHEMAS,
25
+ ]);
26
+
27
+ /** 1 taskが宣言できるconcern anchorの資源数と、資源あたりのsymbol数の上限。 */
28
+ export const TODO_CONCERN_ANCHOR_LIMIT = 256;
17
29
  export const TODO_INDEPENDENCE_SCHEMA = 'lattice.todo_independence.v3';
18
30
  export const TODO_INDEPENDENCE_PROJECTION_SCHEMA = 'lattice.todo_independence_projection.v2';
19
31
  export const TODO_INDEPENDENCE_LEGACY_MARKER_SCHEMA = 'lattice.todo_independence_legacy_marker.v1';
@@ -97,6 +109,10 @@ function boundedText(value, maximumBytes = 4_096) {
97
109
  * manual witnessとsensor query setの判定正本は`explainRunRequest`だけが持つ(ADR 0123)。
98
110
  * ここで同じ規則を書き直すと契約が二箇所へ分裂するため、検証もcompileもこの合成を通す。
99
111
  * `requestId`はplan identityとwitness digestから導出され、run lifecycleへは登録されない。
112
+ *
113
+ * `concern_anchors`はここで落とす。並列可否の判定はこの合成requestだけを入力にするので、
114
+ * 落としておけばconcern宣言が判定へ影響しないことが構造で保証される(testの主張ではない)。
115
+ * 宣言はseam束縛の入力であり、witness setから直接読む。
100
116
  */
101
117
  export function synthesizeWitnessRunRequest(witnessSet, { baseSha, requestId }) {
102
118
  const taskIds = Object.keys(witnessSet.manual_witness).sort(compareText);
@@ -106,7 +122,10 @@ export function synthesizeWitnessRunRequest(witnessSet, { baseSha, requestId })
106
122
  repo: { base_sha: baseSha, root_kind: 'git' },
107
123
  capacity: witnessSet.capacity,
108
124
  todos: taskIds.map((taskId) => ({ todo_id: taskId })),
109
- manual_witness: witnessSet.manual_witness,
125
+ manual_witness: Object.fromEntries(taskIds.map((taskId) => {
126
+ const { concern_anchors: _concernAnchors, ...boundary } = witnessSet.manual_witness[taskId];
127
+ return [taskId, boundary];
128
+ })),
110
129
  sensor_query_set: witnessSet.sensor_query_set,
111
130
  executor_capability: { adapters: ['todo-independence'] },
112
131
  claim_mode: RUN_REQUEST_CLAIM_MODE,
@@ -117,10 +136,57 @@ export function synthesizeWitnessRunRequest(witnessSet, { baseSha, requestId })
117
136
  }
118
137
 
119
138
  /**
120
- * `lattice.todo_witness_set.v1`を検証し、拒否理由とpathを返す。
139
+ * 1 taskのconcern anchor宣言を検査する。
140
+ *
141
+ * `within`はそのtask自身が`owns`で主張している資源に限る。所有していない資源の内側に
142
+ * 担当を主張させない。symbol名の実在・資源内包含・task間排他はsensorとcompile側が見る。
143
+ */
144
+ function explainConcernAnchors(anchors, owns, at) {
145
+ const reject = (reason, path) => ({ valid: false, reason, path });
146
+ if (!Array.isArray(anchors) || anchors.length > TODO_CONCERN_ANCHOR_LIMIT) {
147
+ return reject('bounded_collection_violation', at);
148
+ }
149
+ const ownedKeys = new Set(owns.map((own) => `${own.kind}\0${own.target}`));
150
+ for (const [index, entry] of anchors.entries()) {
151
+ const entryAt = `${at}/${index}`;
152
+ if (!exactRecord(entry, ['within', 'symbols'])) {
153
+ return reject('unexpected_or_missing_keys', entryAt);
154
+ }
155
+ if (!exactRecord(entry.within, ['kind', 'target'])
156
+ || !['symbol', 'path'].includes(entry.within.kind)) {
157
+ return reject('invalid_concern_anchor_resource', `${entryAt}/within`);
158
+ }
159
+ const targetValid = entry.within.kind === 'path'
160
+ ? repoRelativeResourceTarget(entry.within.target)
161
+ : boundedText(entry.within.target, 1_024);
162
+ if (!targetValid) return reject('invalid_concern_anchor_resource', `${entryAt}/within`);
163
+ if (!ownedKeys.has(`${entry.within.kind}\0${entry.within.target}`)) {
164
+ return reject('concern_anchor_resource_not_owned', `${entryAt}/within`);
165
+ }
166
+ if (!Array.isArray(entry.symbols) || entry.symbols.length < 1
167
+ || entry.symbols.length > TODO_CONCERN_ANCHOR_LIMIT) {
168
+ return reject('bounded_collection_violation', `${entryAt}/symbols`);
169
+ }
170
+ if (!entry.symbols.every((symbol) => boundedText(symbol, 1_024))) {
171
+ return reject('invalid_concern_anchor_symbol', `${entryAt}/symbols`);
172
+ }
173
+ if (!strictlySorted(entry.symbols)) {
174
+ return reject('unsorted_or_duplicate_collection', `${entryAt}/symbols`);
175
+ }
176
+ }
177
+ if (!strictlySorted(anchors, (entry) => `${entry.within.kind}\0${entry.within.target}`)) {
178
+ return reject('unsorted_or_duplicate_collection', at);
179
+ }
180
+ return { valid: true };
181
+ }
182
+
183
+ /**
184
+ * `lattice.todo_witness_set.v2`(およびconcern anchorを持たない旧v1)を検証し、
185
+ * 拒否理由とpathを返す。
121
186
  *
122
187
  * 自分のshapeだけをここで見て、witness本体はprobe requestへ合成して
123
188
  * `explainRunRequest`へ委譲する。probeのbase_shaは検証専用の定数であり永続化しない。
189
+ * `concern_anchors`はprobeから落ちるので、ここだけが判定正本になる。
124
190
  */
125
191
  export function explainTodoWitnessSet(value) {
126
192
  const reject = (reason, at = '') => ({ valid: false, reason, path: at });
@@ -129,7 +195,7 @@ export function explainTodoWitnessSet(value) {
129
195
  'schema', 'project_id', 'plan_key', 'capacity', 'sensor_query_set',
130
196
  'manual_witness', 'witness_set_digest',
131
197
  ])) return reject('unexpected_or_missing_top_level_keys');
132
- if (value.schema !== TODO_WITNESS_SET_SCHEMA) return reject('schema_mismatch', '/schema');
198
+ if (!TODO_WITNESS_SET_SCHEMAS.includes(value.schema)) return reject('schema_mismatch', '/schema');
133
199
  if (!isTodoIdentifier(value.project_id)) return reject('invalid_identifier', '/project_id');
134
200
  if (!isTodoIdentifier(value.plan_key)) return reject('invalid_identifier', '/plan_key');
135
201
  if (!plain(value.manual_witness)) return reject('not_an_object', '/manual_witness');
@@ -146,6 +212,16 @@ export function explainTodoWitnessSet(value) {
146
212
  });
147
213
  const explained = explainRunRequest(probe);
148
214
  if (!explained.valid) return reject(explained.reason, explained.path);
215
+ // concern anchorはprobeへ写していないので、ここが唯一の判定正本になる。
216
+ const legacy = TODO_WITNESS_SET_LEGACY_SCHEMAS.includes(value.schema);
217
+ for (const taskId of taskIds) {
218
+ const witness = value.manual_witness[taskId];
219
+ const at = `/manual_witness/${taskId}/concern_anchors`;
220
+ if (!Object.hasOwn(witness, 'concern_anchors')) continue;
221
+ if (legacy) return reject('concern_anchors_require_witness_set_v2', at);
222
+ const anchors = explainConcernAnchors(witness.concern_anchors, witness.owns, at);
223
+ if (!anchors.valid) return reject(anchors.reason, anchors.path);
224
+ }
149
225
  return { valid: true };
150
226
  } catch {
151
227
  return reject('non_canonical_witness_set_bytes');