@intentius/chant 0.84.0 → 0.86.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.
Files changed (152) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/mcp/resource-handlers.d.ts +2 -1
  3. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  4. package/dist/cli/mcp/server.d.ts +1 -0
  5. package/dist/cli/mcp/server.d.ts.map +1 -1
  6. package/dist/cli/mcp/tools/composites.d.ts +44 -0
  7. package/dist/cli/mcp/tools/composites.d.ts.map +1 -0
  8. package/dist/cli/mcp/tools/search.d.ts.map +1 -1
  9. package/dist/cli/registry.d.ts +13 -1
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/components/cli-support.d.ts +4 -0
  12. package/dist/components/cli-support.d.ts.map +1 -1
  13. package/dist/composite.d.ts +6 -0
  14. package/dist/composite.d.ts.map +1 -1
  15. package/dist/lexicon.d.ts +44 -0
  16. package/dist/lexicon.d.ts.map +1 -1
  17. package/dist/lifecycle/gate-ledger.d.ts +13 -0
  18. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  19. package/dist/workspace/__fixtures__/contract-repo.d.ts +7 -0
  20. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  21. package/dist/workspace/__fixtures__/sessions.d.ts +23 -0
  22. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
  23. package/dist/workspace/checks/records.d.ts +1 -0
  24. package/dist/workspace/checks/records.d.ts.map +1 -1
  25. package/dist/workspace/checks.d.ts +4 -0
  26. package/dist/workspace/checks.d.ts.map +1 -1
  27. package/dist/workspace/composites.d.ts +152 -0
  28. package/dist/workspace/composites.d.ts.map +1 -0
  29. package/dist/workspace/conformance/index.d.ts +211 -0
  30. package/dist/workspace/conformance/index.d.ts.map +1 -0
  31. package/dist/workspace/conformance/vitest.d.ts +11 -0
  32. package/dist/workspace/conformance/vitest.d.ts.map +1 -0
  33. package/dist/workspace/declaration.d.ts +28 -0
  34. package/dist/workspace/declaration.d.ts.map +1 -1
  35. package/dist/workspace/declaration.schema.json +40 -0
  36. package/dist/workspace/declared-kinds.d.ts +43 -0
  37. package/dist/workspace/declared-kinds.d.ts.map +1 -0
  38. package/dist/workspace/graph-cli.d.ts +24 -2
  39. package/dist/workspace/graph-cli.d.ts.map +1 -1
  40. package/dist/workspace/intent-cli.d.ts +6 -1
  41. package/dist/workspace/intent-cli.d.ts.map +1 -1
  42. package/dist/workspace/intent-joins.d.ts +74 -9
  43. package/dist/workspace/intent-joins.d.ts.map +1 -1
  44. package/dist/workspace/intent.d.ts +90 -6
  45. package/dist/workspace/intent.d.ts.map +1 -1
  46. package/dist/workspace/ls.d.ts +31 -1
  47. package/dist/workspace/ls.d.ts.map +1 -1
  48. package/dist/workspace/member-commands.d.ts +7 -2
  49. package/dist/workspace/member-commands.d.ts.map +1 -1
  50. package/dist/workspace/reason-codes.d.ts +52 -2
  51. package/dist/workspace/reason-codes.d.ts.map +1 -1
  52. package/dist/workspace/record-sessions.d.ts +51 -0
  53. package/dist/workspace/record-sessions.d.ts.map +1 -0
  54. package/dist/workspace/record-source.d.ts +2 -0
  55. package/dist/workspace/record-source.d.ts.map +1 -1
  56. package/dist/workspace/records-cli.d.ts +65 -4
  57. package/dist/workspace/records-cli.d.ts.map +1 -1
  58. package/dist/workspace/records-since.d.ts +90 -0
  59. package/dist/workspace/records-since.d.ts.map +1 -0
  60. package/dist/workspace/records-write.d.ts +164 -0
  61. package/dist/workspace/records-write.d.ts.map +1 -0
  62. package/dist/workspace/records.d.ts +202 -15
  63. package/dist/workspace/records.d.ts.map +1 -1
  64. package/dist/workspace/runtimes.d.ts +60 -0
  65. package/dist/workspace/runtimes.d.ts.map +1 -0
  66. package/dist/workspace/status-gates.d.ts +90 -0
  67. package/dist/workspace/status-gates.d.ts.map +1 -0
  68. package/dist/workspace/status.d.ts +17 -0
  69. package/dist/workspace/status.d.ts.map +1 -1
  70. package/dist/workspace/work.d.ts +56 -0
  71. package/dist/workspace/work.d.ts.map +1 -0
  72. package/package.json +19 -1
  73. package/src/cli/handlers/graph.ts +4 -0
  74. package/src/cli/main.test.ts +9 -0
  75. package/src/cli/main.ts +56 -3
  76. package/src/cli/mcp/resource-handlers.ts +17 -0
  77. package/src/cli/mcp/server.test.ts +140 -4
  78. package/src/cli/mcp/server.ts +5 -1
  79. package/src/cli/mcp/tools/composites.ts +98 -0
  80. package/src/cli/mcp/tools/search.ts +47 -5
  81. package/src/cli/registry.ts +13 -1
  82. package/src/components/cli-support.test.ts +16 -0
  83. package/src/components/cli-support.ts +8 -2
  84. package/src/composite.ts +9 -0
  85. package/src/lexicon.ts +47 -0
  86. package/src/lifecycle/gate-ledger.ts +14 -0
  87. package/src/workspace/__fixtures__/contract-repo.ts +17 -0
  88. package/src/workspace/__fixtures__/sessions.ts +66 -0
  89. package/src/workspace/checks/records.ts +19 -0
  90. package/src/workspace/checks.test.ts +2 -0
  91. package/src/workspace/checks.ts +7 -1
  92. package/src/workspace/composites.schema.json +533 -0
  93. package/src/workspace/composites.test.ts +334 -0
  94. package/src/workspace/composites.ts +316 -0
  95. package/src/workspace/conformance/__fixture__/app/package.json +7 -0
  96. package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
  97. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
  98. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +364 -0
  99. package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
  100. package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
  101. package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
  102. package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
  103. package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
  104. package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
  105. package/src/workspace/conformance/conformance.test.ts +149 -0
  106. package/src/workspace/conformance/index.mjs +31 -0
  107. package/src/workspace/conformance/index.ts +453 -0
  108. package/src/workspace/conformance/vitest.ts +62 -0
  109. package/src/workspace/declaration.schema.json +40 -0
  110. package/src/workspace/declaration.ts +62 -0
  111. package/src/workspace/declared-kinds.test.ts +321 -0
  112. package/src/workspace/declared-kinds.ts +76 -0
  113. package/src/workspace/graph-cli.ts +40 -4
  114. package/src/workspace/intent-cli.ts +54 -7
  115. package/src/workspace/intent-joins.test.ts +60 -0
  116. package/src/workspace/intent-joins.ts +117 -20
  117. package/src/workspace/intent.schema.json +357 -19
  118. package/src/workspace/intent.test.ts +235 -20
  119. package/src/workspace/intent.ts +396 -51
  120. package/src/workspace/ls.schema.json +34 -0
  121. package/src/workspace/ls.ts +69 -4
  122. package/src/workspace/member-commands.ts +11 -5
  123. package/src/workspace/read-contract.test.ts +52 -3
  124. package/src/workspace/reason-codes.test.ts +48 -4
  125. package/src/workspace/reason-codes.ts +67 -2
  126. package/src/workspace/record-assets.test.ts +3 -1
  127. package/src/workspace/record-sessions.ts +105 -0
  128. package/src/workspace/record-source.ts +14 -5
  129. package/src/workspace/records-amend.schema.json +167 -0
  130. package/src/workspace/records-cli.ts +246 -19
  131. package/src/workspace/records-contract.test.ts +77 -2
  132. package/src/workspace/records-formats.test.ts +640 -0
  133. package/src/workspace/records-new.schema.json +158 -0
  134. package/src/workspace/records-quorum.test.ts +196 -0
  135. package/src/workspace/records-review.schema.json +202 -0
  136. package/src/workspace/records-sessions.test.ts +108 -0
  137. package/src/workspace/records-since.schema.json +193 -0
  138. package/src/workspace/records-since.test.ts +174 -0
  139. package/src/workspace/records-since.ts +259 -0
  140. package/src/workspace/records-write-contract.test.ts +125 -0
  141. package/src/workspace/records-write.test.ts +373 -0
  142. package/src/workspace/records-write.ts +736 -0
  143. package/src/workspace/records.schema.json +187 -9
  144. package/src/workspace/records.test.ts +93 -0
  145. package/src/workspace/records.ts +683 -49
  146. package/src/workspace/runtimes.ts +107 -0
  147. package/src/workspace/status-contract.test.ts +163 -0
  148. package/src/workspace/status-gates.ts +215 -0
  149. package/src/workspace/status.schema.json +69 -3
  150. package/src/workspace/status.ts +35 -2
  151. package/src/workspace/work.test.ts +388 -0
  152. package/src/workspace/work.ts +163 -0
@@ -3,8 +3,44 @@
3
3
  "$id": "https://intentius.io/chant/schemas/workspace/records/v1/records.schema.json",
4
4
  "title": "chant workspace records output",
5
5
  "description": "What `chant workspace records --json` prints, version 1 of the read contract for records (#2536). This is the first-test slice (#2546): records are unsealed and there is no workspace declaration yet. Readers ignore fields they do not know; a field is only ever added within a version. The reason and error codes are closed lists: a new code is a new contract version. Contract version 1 is written by chant 0.81.0 and newer; a reader that needs it refuses output whose `contract` it doesn't know. Every code is in the one closed list of `reason-codes.ts`.",
6
- "oneOf": [{ "$ref": "#/$defs/result" }, { "$ref": "#/$defs/failure" }],
6
+ "oneOf": [{ "$ref": "#/$defs/result" }, { "$ref": "#/$defs/failure" }, { "$ref": "#/$defs/set" }],
7
7
  "$defs": {
8
+ "set": {
9
+ "description": "Added in contract 1 by #2680. What records prints without --kind when the workspace declaration names record kinds: one result or failure per declared kind, in the declaration's order (the workspace's own kinds, then each member's in member order). Exit code 1 when any kind's read failed; the others are still read and listed.",
10
+ "type": "object",
11
+ "required": ["$schema", "contract", "kinds"],
12
+ "properties": {
13
+ "$schema": { "const": "https://intentius.io/chant/schemas/workspace/records/v1/records.schema.json" },
14
+ "contract": { "const": 1 },
15
+ "kinds": {
16
+ "type": "array",
17
+ "items": {
18
+ "allOf": [
19
+ { "oneOf": [{ "$ref": "#/$defs/result" }, { "$ref": "#/$defs/failure" }] },
20
+ {
21
+ "type": "object",
22
+ "required": ["declared"],
23
+ "properties": {
24
+ "declared": {
25
+ "description": "The declaration's entry for the kind.",
26
+ "type": "object",
27
+ "required": ["member", "path", "name"],
28
+ "properties": {
29
+ "member": { "type": ["string", "null"], "description": "The member that declares the kind, or null for the workspace's own." },
30
+ "path": { "type": "string", "description": "The kind file from the workspace root, with / separators." },
31
+ "name": { "type": ["string", "null"], "description": "The name the declaration gives the kind, or null; kind.name is the kind file's own." }
32
+ }
33
+ }
34
+ }
35
+ }
36
+ ]
37
+ }
38
+ },
39
+ "kind": false,
40
+ "records": false,
41
+ "error": false
42
+ }
43
+ },
8
44
  "result": {
9
45
  "description": "The read worked. Exit code 0, even when some records are invalid.",
10
46
  "type": "object",
@@ -18,7 +54,11 @@
18
54
  "properties": {
19
55
  "name": { "type": "string", "description": "The kind's name, such as \"decision\"." },
20
56
  "schema": { "type": "string", "description": "The `$id` of the schema each record was validated against." },
21
- "file": { "type": "string", "description": "The kind file, relative to the repository root, with / separators." }
57
+ "file": { "type": "string", "description": "The kind file, relative to the repository root, with / separators." },
58
+ "format": {
59
+ "enum": ["markdown-front-matter", "json"],
60
+ "description": "Added in contract 1 by #2664 (ws-053). How each record file holds its structured core: the YAML front matter of a Markdown file, or the whole file as one JSON object, read as I-JSON (no repeated member name)."
61
+ }
22
62
  }
23
63
  },
24
64
  "at": {
@@ -47,16 +87,40 @@
47
87
  "invalid": { "type": "integer", "minimum": 0 },
48
88
  "superseded": { "type": "integer", "minimum": 0, "description": "Records a closed record supersedes, counted before --current leaves them out." }
49
89
  }
90
+ },
91
+ "decisions": {
92
+ "description": "Added in contract 1 by #2683, for a work kind only. Every decision the kind file named in the work kind's work.decisions reads at the same revision, whether or not a work record names it, each with the work records implementing it. A decision with an empty implementedBy has no work item yet.",
93
+ "type": "array",
94
+ "items": { "$ref": "#/$defs/decisionWork" }
50
95
  }
51
96
  }
52
97
  },
98
+ "workLink": {
99
+ "type": "object",
100
+ "required": ["id", "state"],
101
+ "properties": {
102
+ "id": { "type": "string" },
103
+ "state": { "type": ["string", "null"], "description": "The linked record's state as it writes it, or null when no record has the id." }
104
+ }
105
+ },
106
+ "decisionWork": {
107
+ "type": "object",
108
+ "required": ["id", "path", "state", "supersededBy", "implementedBy"],
109
+ "properties": {
110
+ "id": { "type": "string" },
111
+ "path": { "type": "string", "description": "From the repository root, with / separators." },
112
+ "state": { "type": ["string", "null"] },
113
+ "supersededBy": { "type": ["string", "null"] },
114
+ "implementedBy": { "type": "array", "items": { "$ref": "#/$defs/workLink" }, "description": "The work records whose implements list names the decision, in path order." }
115
+ }
116
+ },
53
117
  "record": {
54
118
  "type": "object",
55
119
  "required": ["id", "path", "state", "valid", "reasons", "supersededBy", "data"],
56
120
  "properties": {
57
- "id": { "type": ["string", "null"], "description": "Null when the front matter could not be parsed or has no string id." },
121
+ "id": { "type": ["string", "null"], "description": "Null when the record could not be parsed or has no string id. For a content-addressed kind (idFrom sha256, added by #2664), the lowercase hex SHA-256 of the file's bytes, the same string a pin of the file carries." },
58
122
  "path": { "type": "string", "description": "From the repository root, with / separators." },
59
- "state": { "type": ["string", "null"], "description": "The state as the record writes it. Supersession is reported in supersededBy, not here." },
123
+ "state": { "type": ["string", "null"], "description": "The state as the record writes it. Supersession is reported in supersededBy, not here. Null for every record of a kind without states (#2664)." },
60
124
  "valid": { "type": "boolean", "description": "True exactly when reasons is empty." },
61
125
  "reasons": { "type": "array", "items": { "$ref": "#/$defs/reason" } },
62
126
  "supersededBy": {
@@ -65,18 +129,46 @@
65
129
  },
66
130
  "data": {
67
131
  "type": ["object", "null"],
68
- "description": "The front matter as JSON, following the kind's schema when valid is true. Null when it could not be parsed."
132
+ "description": "The record's structured core as JSON: the front matter, or for a kind of format json the whole file (#2664). It follows the kind's schema when valid is true. Null when it could not be parsed."
69
133
  },
70
134
  "provenance": { "$ref": "#/$defs/provenance" },
71
135
  "assets": {
72
- "description": "Added in contract 1 by #2549. Each workspace file the record pins by hash, checked against the tree read: the working tree, or the revision under --at. Empty for a kind that pins nothing.",
136
+ "description": "Added in contract 1 by #2549. Each workspace file the record pins by hash, checked against the tree read: the working tree, or the revision under --at. Empty for a kind that pins nothing. A record of a content-addressed kind (#2664) whose file name claims a hash other than its bytes' lists itself here as drifted, with sha256 the hash the name claims and actual its id.",
73
137
  "type": "array",
74
138
  "items": { "$ref": "#/$defs/asset" }
75
139
  },
76
140
  "warnings": {
77
- "description": "Added in contract 1 by #2549. Findings that leave the record valid: a pinned file that changed, went missing or did not follow a superseding decision, or a supersedes link that has no effect yet. valid and --current do not look at them.",
141
+ "description": "Added in contract 1 by #2549. Findings that leave the record valid: a pinned file that changed, went missing or did not follow a superseding decision, a supersedes link that has no effect yet, (added by #2654) an evidence list that is empty, (added by #2672) a verdict that names no digest, or (added by #2683) a work record's links and dates. A work record never carries record-no-evidence; a done one with no evidence carries work-done-unpinned. valid and --current do not look at them.",
78
142
  "type": "array",
79
143
  "items": { "$ref": "#/$defs/warning" }
144
+ },
145
+ "digest": {
146
+ "description": "Added in contract 1 by #2672. The lowercase hex SHA-256 of the record file's text, with LF line endings and without the kind's reviews block in the front matter, or for a kind of format json without the top-level reviews member and one comma beside it (#2664). A review verdict names it in its digest field, and stops counting once the record's digest moves on. For a kind with no reviews list, the digest of the whole text.",
147
+ "type": "string",
148
+ "pattern": "^[0-9a-f]{64}$"
149
+ },
150
+ "quorum": {
151
+ "description": "Added in contract 1 by #2671, on every record of a kind with a reviews list. Null when the front matter could not be parsed.",
152
+ "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/quorum" }]
153
+ },
154
+ "citedBy": {
155
+ "description": "Added in contract 1 by #2673, on every record of a session kind and on no other. The review entries of the records the session kind names as its subjects (decisions, for the reference session kind) whose session field names this record's id, in path order and then list order.",
156
+ "type": "array",
157
+ "items": { "$ref": "#/$defs/citation" }
158
+ },
159
+ "ready": {
160
+ "description": "Added in contract 1 by #2683, on every parsed record of a work kind. True when the record is valid, not superseded, in the kind's open state, not in a needs cycle, and every work id its needs list names is done.",
161
+ "type": "boolean"
162
+ },
163
+ "blockedBy": {
164
+ "description": "Added in contract 1 by #2683, on every parsed record of a work kind. Each need that is not done, with its state now; null for an id no record has.",
165
+ "type": "array",
166
+ "items": { "$ref": "#/$defs/workLink" }
167
+ },
168
+ "implements": {
169
+ "description": "Added in contract 1 by #2683, on every parsed record of a work kind. Each decision the record implements, with its state now; null for an id no decision has.",
170
+ "type": "array",
171
+ "items": { "$ref": "#/$defs/workLink" }
80
172
  }
81
173
  },
82
174
  "allOf": [
@@ -101,10 +193,94 @@
101
193
  "type": "object",
102
194
  "required": ["code", "message"],
103
195
  "properties": {
104
- "code": { "enum": ["asset-drift", "asset-missing", "asset-stale", "record-supersedes-pending"] },
196
+ "code": {
197
+ "enum": [
198
+ "asset-drift",
199
+ "asset-missing",
200
+ "asset-stale",
201
+ "record-supersedes-pending",
202
+ "record-no-evidence",
203
+ "review-undigested",
204
+ "work-needs-unknown",
205
+ "work-implements-unknown",
206
+ "work-needs-cycle",
207
+ "work-implements-undecided",
208
+ "work-done-unpinned",
209
+ "work-closed-without-date"
210
+ ]
211
+ },
105
212
  "message": { "type": "string" }
106
213
  }
107
214
  },
215
+ "quorum": {
216
+ "description": "Added in contract 1 by #2671. The record's review verdicts counted against the quorum the workspace declares. A met quorum with an open concern is met with objections, never consensus: a reader shows consensus only when met is true and metWithObjections is false.",
217
+ "type": "object",
218
+ "required": ["need", "needFrom", "agreed", "counted", "notCounted", "openConcerns", "met", "metWithObjections"],
219
+ "properties": {
220
+ "need": { "type": "integer", "minimum": 0, "description": "How many counted agree verdicts, besides the decider's, the record needs: the declaration's quorum, or 2." },
221
+ "needFrom": { "enum": ["declaration", "default"], "description": "declaration when the workspace declaration in the tree read sets quorum; default otherwise, including when there is no declaration or it can't be read." },
222
+ "agreed": { "type": "integer", "minimum": 0, "description": "Counted verdicts whose verdict is agree." },
223
+ "counted": {
224
+ "description": "The verdicts that count: at most one per principal, the latest, in the order of the reviews list.",
225
+ "type": "array",
226
+ "items": { "allOf": [{ "$ref": "#/$defs/verdict" }, { "type": "object", "properties": { "reason": false } }] }
227
+ },
228
+ "notCounted": {
229
+ "description": "The verdicts that don't count, each with a reason.",
230
+ "type": "array",
231
+ "items": { "allOf": [{ "$ref": "#/$defs/verdict" }, { "type": "object", "required": ["reason"] }] }
232
+ },
233
+ "openConcerns": {
234
+ "description": "Every dissent with neither addressed_by nor withdrawn_on, counted or not.",
235
+ "type": "array",
236
+ "items": { "$ref": "#/$defs/concern" }
237
+ },
238
+ "met": { "type": "boolean", "description": "agreed is at least need." },
239
+ "metWithObjections": { "type": "boolean", "description": "met, and openConcerns is not empty." }
240
+ }
241
+ },
242
+ "verdict": {
243
+ "type": "object",
244
+ "required": ["index", "principal", "reviewer", "verdict", "digest"],
245
+ "properties": {
246
+ "index": { "type": "integer", "minimum": 0, "description": "The entry's position in the record's reviews list, from 0." },
247
+ "principal": { "type": "string", "description": "The reviewer after NFKC normalisation, trimming and lower-casing, so alice and \"Alice \" are one principal." },
248
+ "reviewer": { "type": "string", "description": "The reviewer as the record writes it." },
249
+ "verdict": { "enum": ["agree", "dissent", "abstain"] },
250
+ "digest": { "type": ["string", "null"], "description": "The digest the verdict names, or null when it names none (a warning review-undigested on the record)." },
251
+ "reason": {
252
+ "description": "Why the verdict does not count. Present exactly on the entries in notCounted.",
253
+ "type": "object",
254
+ "required": ["code", "message"],
255
+ "properties": {
256
+ "code": { "enum": ["review-decider", "review-agent", "review-duplicate", "review-older-digest", "review-unattested"] },
257
+ "message": { "type": "string" }
258
+ }
259
+ }
260
+ }
261
+ },
262
+ "concern": {
263
+ "type": "object",
264
+ "required": ["index", "principal", "reviewer", "note"],
265
+ "properties": {
266
+ "index": { "type": "integer", "minimum": 0 },
267
+ "principal": { "type": "string" },
268
+ "reviewer": { "type": "string" },
269
+ "note": { "type": ["string", "null"], "description": "The dissent's reason." },
270
+ "proposes": { "type": "string", "description": "The proposed decision the dissent opened, when it names one." }
271
+ }
272
+ },
273
+ "citation": {
274
+ "type": "object",
275
+ "required": ["id", "path", "index", "reviewer", "verdict"],
276
+ "properties": {
277
+ "id": { "type": "string", "description": "The subject record's id." },
278
+ "path": { "type": "string", "description": "The subject record's path from the repository root, with / separators." },
279
+ "index": { "type": "integer", "minimum": 0, "description": "The entry's position in the subject's reviews list, from 0." },
280
+ "reviewer": { "type": ["string", "null"], "description": "The reviewer as the entry writes it, or null when it names none." },
281
+ "verdict": { "type": ["string", "null"], "description": "The verdict as the entry writes it, or null when it names none." }
282
+ }
283
+ },
108
284
  "trust": {
109
285
  "description": "Added in contract 1 by #2547. Where provenance was judged from: the policy read at the base revision, never the tree read.",
110
286
  "type": "object",
@@ -143,7 +319,9 @@
143
319
  "record-schema-invalid",
144
320
  "record-id-duplicate",
145
321
  "record-supersedes-unknown",
146
- "record-supersedes-conflict"
322
+ "record-supersedes-conflict",
323
+ "session-seal-mismatch",
324
+ "session-verdict-unknown-record"
147
325
  ]
148
326
  },
149
327
  "message": { "type": "string" }
@@ -93,6 +93,49 @@ describe("readRecords", () => {
93
93
  expect(r.data).not.toBeNull();
94
94
  });
95
95
 
96
+ describe("a dissent needs a reason (#2652)", () => {
97
+ const withReviews = (reviews: string) => decision("ws-001").replace(/^reviews: \[\]$/m, `reviews:\n${reviews}`);
98
+ const review = (verdict: string, extra = "") => ` - reviewer: "ana"\n verdict: "${verdict}"\n on: "2026-09-24"\n${extra}`;
99
+
100
+ for (const [label, note] of [
101
+ ["null", " note: null\n"],
102
+ ["empty", ' note: ""\n'],
103
+ ["blank", ' note: " "\n'],
104
+ ["missing", ""],
105
+ ] as const) {
106
+ test(`a dissent with a ${label} note is record-schema-invalid, naming the reviewer`, async () => {
107
+ write("ws-001-a.md", withReviews(review("dissent", note)));
108
+ const [r] = (await read()).records;
109
+ expect(r.valid).toBe(false);
110
+ expect(codes(r)).toEqual(["record-schema-invalid"]);
111
+ expect(r.reasons[0].message).toBe("/reviews/0 A dissent needs a reason: the dissent by ana has no note.");
112
+ });
113
+ }
114
+
115
+ test("a dissent with a note is valid, and so are agree and abstain without one", async () => {
116
+ write(
117
+ "ws-001-a.md",
118
+ withReviews(
119
+ review("dissent", ' note: "the seal should cover the lockfile too"\n') +
120
+ review("agree") +
121
+ review("abstain", " note: null\n"),
122
+ ),
123
+ );
124
+ const [r] = (await read()).records;
125
+ expect(r.reasons).toEqual([]);
126
+ expect(r.valid).toBe(true);
127
+ });
128
+
129
+ test("a dissent carries its concern's lifecycle; another verdict cannot", async () => {
130
+ write("ws-001-a.md", withReviews(review("dissent", ' note: "why"\n addressed_by: "INTENTIUS/chant#2652"\n withdrawn_on: "2026-09-25"\n')));
131
+ expect((await read()).records[0].valid).toBe(true);
132
+ write("ws-001-a.md", withReviews(review("agree", ' addressed_by: "ws-002"\n')));
133
+ const [r] = (await read()).records;
134
+ expect(codes(r)).toEqual(["record-schema-invalid"]);
135
+ expect(r.reasons[0].message).toBe("/reviews/0 Only a dissent is a concern: the agree by ana cannot carry addressed_by or withdrawn_on.");
136
+ });
137
+ });
138
+
96
139
  test("a supersedes link to a missing id is record-supersedes-unknown", async () => {
97
140
  write("ws-002-b.md", decision("ws-002", "decided", ["ws-404"]));
98
141
  const [r] = (await read()).records;
@@ -199,6 +242,56 @@ describe("readRecords", () => {
199
242
  });
200
243
  });
201
244
 
245
+ /**
246
+ * A decision made in the workspace (#2654): the workspace source form with no
247
+ * issue, and no evidence. `constrains` replaces ws-003's.
248
+ */
249
+ function workspaceDecision(id: string, constrains: string[] = ["member:app"]): string {
250
+ const list = constrains.length === 0 ? "constrains: []\n" : `constrains:\n${constrains.map((c) => ` - "${c}"\n`).join("")}`;
251
+ return decision(id)
252
+ .replace(/^source:\n(?: .*\n)*/m, 'source:\n kind: "workspace"\n member: "app"\n')
253
+ .replace(/^evidence:\n(?: .*\n)*/m, "evidence: []\n")
254
+ .replace(/^constrains:\n(?: .*\n)*/m, list);
255
+ }
256
+
257
+ describe("a decision that originates in the workspace (#2654)", () => {
258
+ test("validates with no issue and no evidence, is current, and carries record-no-evidence", async () => {
259
+ write("ws-001-a.md", workspaceDecision("ws-001"));
260
+ const current = await read({ current: true });
261
+ expect(current.records.map((r) => [r.id, r.valid, r.reasons, r.warnings.map((w) => w.code)])).toEqual([
262
+ ["ws-001", true, [], ["record-no-evidence"]],
263
+ ]);
264
+ expect(current.records[0].data?.source).toEqual({ kind: "workspace", member: "app" });
265
+ });
266
+
267
+ test("takes a session and an issue, and nothing else", async () => {
268
+ const base = workspaceDecision("ws-001");
269
+ write("ws-001-a.md", base.replace(' member: "app"\n', ' member: "app"\n session: "S-0001"\n issue: "jhgaylor/chud#77"\n'));
270
+ write("ws-002-b.md", base.replace('id: "ws-001"', 'id: "ws-002"').replace(' member: "app"\n', ' member: "app"\n session: null\n'));
271
+ write("ws-003-c.md", base.replace('id: "ws-001"', 'id: "ws-003"').replace(' member: "app"\n', ' member: "app"\n row: "Sort order"\n'));
272
+ write("ws-004-d.md", base.replace('id: "ws-001"', 'id: "ws-004"').replace(' member: "app"\n', ""));
273
+ const records = (await read()).records;
274
+ expect(records.map((r) => [r.id, codes(r)])).toEqual([
275
+ ["ws-001", []],
276
+ ["ws-002", []],
277
+ ["ws-003", ["record-schema-invalid"]],
278
+ ["ws-004", ["record-schema-invalid"]],
279
+ ]);
280
+ });
281
+
282
+ test("a record with evidence carries no record-no-evidence warning", async () => {
283
+ write("ws-001-a.md", decision("ws-001"));
284
+ expect((await read()).records[0].warnings).toEqual([]);
285
+ });
286
+
287
+ test("a record that constrains nothing is refused", async () => {
288
+ write("ws-001-a.md", workspaceDecision("ws-001", []));
289
+ const [r] = (await read()).records;
290
+ expect(codes(r)).toEqual(["record-schema-invalid"]);
291
+ expect(r.reasons[0].message).toMatch(/constrains/);
292
+ });
293
+ });
294
+
202
295
  describe("loadRecordKind", () => {
203
296
  const load = () => loadRecordKind(join(dir, "decisions", "decision.kind.mjs"));
204
297