@intentius/chant 0.90.0 → 0.91.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 (164) hide show
  1. package/dist/cli/handlers/components.d.ts +8 -0
  2. package/dist/cli/handlers/components.d.ts.map +1 -1
  3. package/dist/cli/handlers/operator.d.ts +14 -0
  4. package/dist/cli/handlers/operator.d.ts.map +1 -1
  5. package/dist/cli/handlers/run.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/mcp/workspace-tools.d.ts +6 -4
  8. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  9. package/dist/cli/registry.d.ts +31 -0
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/components/verbs/vuln-scan.d.ts +72 -0
  12. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  13. package/dist/lifecycle/git.d.ts +40 -5
  14. package/dist/lifecycle/git.d.ts.map +1 -1
  15. package/dist/lifecycle/lease.d.ts +72 -16
  16. package/dist/lifecycle/lease.d.ts.map +1 -1
  17. package/dist/lifecycle/member-ledger.d.ts +3 -2
  18. package/dist/lifecycle/member-ledger.d.ts.map +1 -1
  19. package/dist/lifecycle/plan-ledger.d.ts +114 -0
  20. package/dist/lifecycle/plan-ledger.d.ts.map +1 -0
  21. package/dist/lifecycle/work-lease.d.ts +140 -0
  22. package/dist/lifecycle/work-lease.d.ts.map +1 -0
  23. package/dist/op/activities/activity-contracts.d.ts +2 -2
  24. package/dist/op/builders.d.ts.map +1 -1
  25. package/dist/op/discover.d.ts +25 -0
  26. package/dist/op/discover.d.ts.map +1 -1
  27. package/dist/op/index.d.ts +9 -3
  28. package/dist/op/index.d.ts.map +1 -1
  29. package/dist/op/lifecycle-receipt-store.d.ts +34 -0
  30. package/dist/op/lifecycle-receipt-store.d.ts.map +1 -0
  31. package/dist/op/local-executor.d.ts +19 -0
  32. package/dist/op/local-executor.d.ts.map +1 -1
  33. package/dist/op/local-output.d.ts.map +1 -1
  34. package/dist/op/op-ir.d.ts +10 -1
  35. package/dist/op/op-ir.d.ts.map +1 -1
  36. package/dist/op/operator.d.ts +29 -0
  37. package/dist/op/operator.d.ts.map +1 -1
  38. package/dist/op/runtime.d.ts +9 -0
  39. package/dist/op/runtime.d.ts.map +1 -1
  40. package/dist/op/runtimes/local.d.ts.map +1 -1
  41. package/dist/op/step-output-ref.d.ts +2 -2
  42. package/dist/op/step-output-ref.d.ts.map +1 -1
  43. package/dist/op/steward.d.ts +140 -0
  44. package/dist/op/steward.d.ts.map +1 -0
  45. package/dist/op/types.d.ts +51 -0
  46. package/dist/op/types.d.ts.map +1 -1
  47. package/dist/op/work-lease-decl.d.ts +18 -0
  48. package/dist/op/work-lease-decl.d.ts.map +1 -0
  49. package/dist/op/work-lease-run.d.ts +173 -0
  50. package/dist/op/work-lease-run.d.ts.map +1 -0
  51. package/dist/workspace/box-isolation.d.ts +99 -0
  52. package/dist/workspace/box-isolation.d.ts.map +1 -0
  53. package/dist/workspace/checks/box-isolation.d.ts +18 -0
  54. package/dist/workspace/checks/box-isolation.d.ts.map +1 -0
  55. package/dist/workspace/checks/boxes.d.ts +71 -0
  56. package/dist/workspace/checks/boxes.d.ts.map +1 -0
  57. package/dist/workspace/checks/records.d.ts +1 -0
  58. package/dist/workspace/checks/records.d.ts.map +1 -1
  59. package/dist/workspace/checks.d.ts +10 -1
  60. package/dist/workspace/checks.d.ts.map +1 -1
  61. package/dist/workspace/decide.d.ts +184 -0
  62. package/dist/workspace/decide.d.ts.map +1 -0
  63. package/dist/workspace/decision-points.schema.json +137 -0
  64. package/dist/workspace/declaration.d.ts +51 -0
  65. package/dist/workspace/declaration.d.ts.map +1 -1
  66. package/dist/workspace/declaration.schema.json +172 -0
  67. package/dist/workspace/declared-kinds.d.ts +12 -0
  68. package/dist/workspace/declared-kinds.d.ts.map +1 -1
  69. package/dist/workspace/points-cli.d.ts +113 -0
  70. package/dist/workspace/points-cli.d.ts.map +1 -0
  71. package/dist/workspace/points.d.ts +320 -0
  72. package/dist/workspace/points.d.ts.map +1 -0
  73. package/dist/workspace/reason-codes.d.ts +26 -0
  74. package/dist/workspace/reason-codes.d.ts.map +1 -1
  75. package/dist/workspace/record-assets.d.ts.map +1 -1
  76. package/dist/workspace/records-cli.d.ts +12 -0
  77. package/dist/workspace/records-cli.d.ts.map +1 -1
  78. package/dist/workspace/records.d.ts +8 -2
  79. package/dist/workspace/records.d.ts.map +1 -1
  80. package/dist/workspace/status-stewards.d.ts +121 -0
  81. package/dist/workspace/status-stewards.d.ts.map +1 -0
  82. package/dist/workspace/status.d.ts +52 -1
  83. package/dist/workspace/status.d.ts.map +1 -1
  84. package/dist/workspace/work-cli.d.ts +78 -0
  85. package/dist/workspace/work-cli.d.ts.map +1 -0
  86. package/package.json +1 -1
  87. package/src/cli/handlers/components.test.ts +93 -0
  88. package/src/cli/handlers/components.ts +44 -3
  89. package/src/cli/handlers/operator.ts +107 -2
  90. package/src/cli/handlers/run.test.ts +19 -0
  91. package/src/cli/handlers/run.ts +53 -1
  92. package/src/cli/main.test.ts +40 -0
  93. package/src/cli/main.ts +78 -2
  94. package/src/cli/mcp/workspace-tools.test.ts +14 -1
  95. package/src/cli/mcp/workspace-tools.ts +49 -5
  96. package/src/cli/registry.ts +31 -0
  97. package/src/components/verbs/vuln-scan.test.ts +124 -1
  98. package/src/components/verbs/vuln-scan.ts +142 -1
  99. package/src/lifecycle/git.ts +65 -15
  100. package/src/lifecycle/lease.test.ts +22 -0
  101. package/src/lifecycle/lease.ts +133 -29
  102. package/src/lifecycle/member-ledger.ts +3 -2
  103. package/src/lifecycle/plan-ledger.test.ts +148 -0
  104. package/src/lifecycle/plan-ledger.ts +158 -0
  105. package/src/lifecycle/work-lease.test.ts +236 -0
  106. package/src/lifecycle/work-lease.ts +426 -0
  107. package/src/op/builders.ts +5 -0
  108. package/src/op/discover.ts +71 -0
  109. package/src/op/index.ts +16 -3
  110. package/src/op/lifecycle-receipt-store.test.ts +60 -0
  111. package/src/op/lifecycle-receipt-store.ts +61 -0
  112. package/src/op/local-executor.ts +216 -18
  113. package/src/op/local-output.ts +13 -0
  114. package/src/op/op-ir.ts +14 -0
  115. package/src/op/operator.ts +75 -4
  116. package/src/op/runtime.ts +6 -0
  117. package/src/op/runtimes/local.ts +3 -0
  118. package/src/op/step-output-ref.ts +6 -2
  119. package/src/op/steward.test.ts +212 -0
  120. package/src/op/steward.ts +253 -0
  121. package/src/op/types.ts +53 -0
  122. package/src/op/work-lease-decl.ts +80 -0
  123. package/src/op/work-lease-run.test.ts +326 -0
  124. package/src/op/work-lease-run.ts +395 -0
  125. package/src/workspace/box-isolation.test.ts +261 -0
  126. package/src/workspace/box-isolation.ts +205 -0
  127. package/src/workspace/check-contract.test.ts +3 -1
  128. package/src/workspace/check.schema.json +15 -7
  129. package/src/workspace/checks/box-isolation.ts +68 -0
  130. package/src/workspace/checks/boxes.test.ts +197 -0
  131. package/src/workspace/checks/boxes.ts +307 -0
  132. package/src/workspace/checks/records.ts +25 -0
  133. package/src/workspace/checks.test.ts +7 -0
  134. package/src/workspace/checks.ts +19 -2
  135. package/src/workspace/decide.test.ts +224 -0
  136. package/src/workspace/decide.ts +576 -0
  137. package/src/workspace/decision-points.schema.json +137 -0
  138. package/src/workspace/declaration.schema.json +172 -0
  139. package/src/workspace/declaration.ts +137 -0
  140. package/src/workspace/declared-kinds.ts +25 -2
  141. package/src/workspace/intent.schema.json +4 -1
  142. package/src/workspace/point-answer.schema.json +95 -0
  143. package/src/workspace/points-cli.ts +273 -0
  144. package/src/workspace/points-write.schema.json +489 -0
  145. package/src/workspace/points.schema.json +710 -0
  146. package/src/workspace/points.test.ts +264 -0
  147. package/src/workspace/points.ts +564 -0
  148. package/src/workspace/read-contract.test.ts +18 -1
  149. package/src/workspace/reason-codes.test.ts +14 -1
  150. package/src/workspace/reason-codes.ts +33 -0
  151. package/src/workspace/record-assets.test.ts +3 -2
  152. package/src/workspace/record-assets.ts +4 -1
  153. package/src/workspace/records-cli.ts +15 -2
  154. package/src/workspace/records-contract.test.ts +3 -2
  155. package/src/workspace/records.schema.json +17 -0
  156. package/src/workspace/records.ts +33 -3
  157. package/src/workspace/status-contract.test.ts +178 -0
  158. package/src/workspace/status-stewards.ts +225 -0
  159. package/src/workspace/status.schema.json +230 -4
  160. package/src/workspace/status.ts +106 -5
  161. package/src/workspace/work-cli.test.ts +180 -0
  162. package/src/workspace/work-cli.ts +246 -0
  163. package/src/workspace/work-lease.schema.json +233 -0
  164. package/src/workspace/work-readiness-chud.test.ts +145 -0
@@ -0,0 +1,307 @@
1
+ /**
2
+ * The box checks, WSP121 and WSP122 (#2726).
3
+ *
4
+ * A member whose entry has a `box` block is a box, or holds a box's
5
+ * declarations. A box holds no credential: each capability it needs outside
6
+ * itself (inference, Fountain, a third-party API) is reached through a
7
+ * broker, a runtime such as a lobby or a door, which holds the credential
8
+ * and enforces the scope the block declares. chant only states and checks
9
+ * this; it never runs a broker (ws-052).
10
+ *
11
+ * | Id | Code | Fails when |
12
+ * |---|---|---|
13
+ * | WSP121 | `box-credential-declared` | a file in the box member's directory carries a literal secret (fixed) |
14
+ * | WSP122 | `box-capability-unbrokered` | a capability in the block names no broker |
15
+ *
16
+ * WSP121 reads the member's files from the tree checked, so it works under
17
+ * `--at`, and runs no member code. A literal secret is either a value with a
18
+ * well-known credential shape (an AWS key id, a GitHub or Slack token, an
19
+ * `sk-` key, private key material), wherever it sits, or a literal string
20
+ * where a credential goes: under a key named like one (`API_KEY`, `token`,
21
+ * `credential`, `password`, ...), or the `value` of a `{ key, value }` vault
22
+ * secret. A reference is never one: `${VAR}` and `$VAR`, a secret-manager
23
+ * reference (`op://`, `bws://`, `infisical://`), a `{{...}}` template
24
+ * placeholder, and any expression, such as `process.env.X`, that is not a
25
+ * string literal. WSP121 is fixed: a credential in a box's files is in git.
26
+ *
27
+ * WSP123 and WSP124, box isolation (#2727), are in `./box-isolation.ts` and
28
+ * listed here after these two.
29
+ */
30
+
31
+ import * as ts from "typescript";
32
+ import type { WorkspaceCheck, WorkspaceCheckContext, WorkspaceDiagnostic } from "../checks";
33
+ import type { Member } from "../declaration";
34
+ import type { ReasonCode } from "../reason-codes";
35
+ import { joinPath, skippedDir, type WorkspaceTree } from "../tree";
36
+ import { BOX_ISOLATION_CHECKS } from "./box-isolation";
37
+
38
+ /** The read contract's codes for the box findings, carried as `code` on each. */
39
+ export const BOX_FINDING_CODES = ["box-credential-declared", "box-capability-unbrokered", "box-isolation-collision", "box-isolation-literal"] as const satisfies readonly ReasonCode[];
40
+
41
+ export const WSP_BOX_CREDENTIAL = "WSP121";
42
+ export const WSP_BOX_UNBROKERED = "WSP122";
43
+
44
+ /** Values whose shape alone says they are a credential. The list FTN001 uses, plus Anthropic keys. */
45
+ export const CREDENTIAL_SHAPES: readonly { pattern: RegExp; label: string }[] = [
46
+ { pattern: /\bAKIA[0-9A-Z]{16}\b/, label: "an AWS access key id" },
47
+ { pattern: /\b(?:ghp|gho|ghs|ghu)_[A-Za-z0-9]{20,}/, label: "a GitHub token" },
48
+ { pattern: /\bgithub_pat_[A-Za-z0-9_]{20,}/, label: "a GitHub fine-grained token" },
49
+ { pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/, label: "an Anthropic API key" },
50
+ { pattern: /\bsk-[A-Za-z0-9_-]{20,}/, label: "a secret API key (sk-)" },
51
+ { pattern: /\bftn_[A-Za-z0-9]{16,}/, label: "a Fountain API key" },
52
+ { pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}/, label: "a Slack token" },
53
+ { pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/, label: "private key material" },
54
+ ];
55
+
56
+ /** Secret-manager references a value may be instead of the secret. */
57
+ export const SECRET_REFERENCE_SCHEMES = ["op://", "bws://", "infisical://"] as const;
58
+
59
+ /**
60
+ * A key whose value is a credential: `API_KEY`, `GITHUB_TOKEN`, `apiKey`,
61
+ * `token`, `credential`, `password`, `AWS_SECRET_ACCESS_KEY`. A key that
62
+ * names where a secret is (`secretKey`, `TOKEN_FILE`, `tokenHash`) is not.
63
+ */
64
+ const CREDENTIAL_KEY = /(?:^|[_-])(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|PRIVATE_?KEY|ACCESS_?KEY)$|^(?:apiKey|token|secret|password|credentials?|privateKey|accessKey|authToken)$|[a-z](?:ApiKey|Token|Secret|Password|Credentials?|PrivateKey|AccessKey)$/i;
65
+
66
+ /** Whether `key` names a credential. Exported for tests. */
67
+ export function isCredentialKey(key: string): boolean {
68
+ return CREDENTIAL_KEY.test(key);
69
+ }
70
+
71
+ /** Whether `value` refers to a secret rather than being one. Exported for tests. */
72
+ export function isSecretReference(value: string): boolean {
73
+ const v = value.trim();
74
+ if (v === "") return true;
75
+ if (v.includes("${")) return true;
76
+ // $VAR, $1, $(command): the shell fills it in.
77
+ if (v.startsWith("$")) return true;
78
+ if (/^\{\{[^}]*\}\}$/.test(v)) return true;
79
+ return SECRET_REFERENCE_SCHEMES.some((s) => v.startsWith(s));
80
+ }
81
+
82
+ /**
83
+ * Whether a value under a credential key says where a secret is rather than
84
+ * being one: a path (`~/box/llm-token`, `/run/secrets/token`), a URL, or an
85
+ * environment variable's name (`GIT_TOKEN`).
86
+ */
87
+ function namesWhereSecretIs(value: string): boolean {
88
+ const v = value.trim();
89
+ return /^(?:\/|~|\.\.?\/)/.test(v) || /^[a-z][a-z0-9+.-]*:\/\//i.test(v) || /^[A-Z][A-Z0-9_]*$/.test(v);
90
+ }
91
+
92
+ /** The credential shape `value` has, or undefined. */
93
+ export function credentialShape(value: string): string | undefined {
94
+ return CREDENTIAL_SHAPES.find((s) => s.pattern.test(value))?.label;
95
+ }
96
+
97
+ /** One literal secret found in a file. */
98
+ export interface LiteralSecret {
99
+ /** 1-based. */
100
+ line: number;
101
+ column: number;
102
+ /** What was found, for the message: never the value itself. */
103
+ what: string;
104
+ }
105
+
106
+ const CODE_EXTENSIONS: Record<string, ts.ScriptKind> = {
107
+ ".ts": ts.ScriptKind.TS,
108
+ ".mts": ts.ScriptKind.TS,
109
+ ".cts": ts.ScriptKind.TS,
110
+ ".tsx": ts.ScriptKind.TSX,
111
+ ".js": ts.ScriptKind.JS,
112
+ ".mjs": ts.ScriptKind.JS,
113
+ ".cjs": ts.ScriptKind.JS,
114
+ ".jsx": ts.ScriptKind.JSX,
115
+ ".json": ts.ScriptKind.JSON,
116
+ ".jsonc": ts.ScriptKind.JSON,
117
+ };
118
+
119
+ const PROSE_EXTENSIONS = new Set([".md", ".mdx", ".txt", ".html", ".rst"]);
120
+
121
+ /** Files larger than this are not read: a box's declarations are small, and a large file is data or a build. */
122
+ const MAX_FILE_BYTES = 1024 * 1024;
123
+
124
+ function propertyName(name: ts.PropertyName | undefined): string | undefined {
125
+ if (!name) return undefined;
126
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text;
127
+ return undefined;
128
+ }
129
+
130
+ const isStringLiteral = (n: ts.Node): n is ts.StringLiteral | ts.NoSubstitutionTemplateLiteral => ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n);
131
+
132
+ /**
133
+ * The literal secrets in a TypeScript, JavaScript or JSON file, read as
134
+ * syntax and never run.
135
+ */
136
+ export function literalSecretsInCode(text: string, file: string, kind: ts.ScriptKind): LiteralSecret[] {
137
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, kind);
138
+ const out: LiteralSecret[] = [];
139
+ const at = (node: ts.Node, what: string) => {
140
+ const { line, character } = source.getLineAndCharacterOfPosition(node.getStart(source));
141
+ out.push({ line: line + 1, column: character + 1, what });
142
+ };
143
+ const reported = new Set<ts.Node>();
144
+ const visit = (node: ts.Node) => {
145
+ if (isStringLiteral(node) && !reported.has(node)) {
146
+ const shape = credentialShape(node.text);
147
+ if (shape && !isSecretReference(node.text)) {
148
+ reported.add(node);
149
+ at(node, shape);
150
+ }
151
+ }
152
+ if (ts.isPropertyAssignment(node) && isStringLiteral(node.initializer) && !reported.has(node.initializer)) {
153
+ const key = propertyName(node.name);
154
+ const value = node.initializer.text;
155
+ // A value with a credential's shape is reported by that shape, below.
156
+ if (key !== undefined && !isSecretReference(value) && !namesWhereSecretIs(value) && credentialShape(value) === undefined) {
157
+ if (isCredentialKey(key)) {
158
+ reported.add(node.initializer);
159
+ at(node.initializer, `a literal value for ${key}`);
160
+ } else if (key === "value" && ts.isObjectLiteralExpression(node.parent) && isVaultSecret(node.parent)) {
161
+ reported.add(node.initializer);
162
+ at(node.initializer, `a literal vault secret value for ${vaultSecretKey(node.parent) ?? "a key"}`);
163
+ }
164
+ }
165
+ }
166
+ ts.forEachChild(node, visit);
167
+ };
168
+ visit(source);
169
+ return out;
170
+ }
171
+
172
+ /** `{ key, value }` with nothing else but a description: the shape of a vault secret. */
173
+ function isVaultSecret(obj: ts.ObjectLiteralExpression): boolean {
174
+ const names = obj.properties.map((p) => (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p) ? propertyName(p.name) : undefined));
175
+ return names.includes("key") && names.includes("value") && names.every((n) => n === "key" || n === "value" || n === "description");
176
+ }
177
+
178
+ function vaultSecretKey(obj: ts.ObjectLiteralExpression): string | undefined {
179
+ for (const p of obj.properties) {
180
+ if (ts.isPropertyAssignment(p) && propertyName(p.name) === "key" && isStringLiteral(p.initializer)) return p.initializer.text;
181
+ }
182
+ return undefined;
183
+ }
184
+
185
+ /** `KEY=value`, `export KEY=value` or `KEY: value`, the value optionally quoted. */
186
+ const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*[=:]\s*(["']?)([^"'\s#]+)\2\s*(?:#.*)?$/;
187
+
188
+ /**
189
+ * The literal secrets in any other text file, such as a shell script, an
190
+ * env file or YAML: a credential shape anywhere, or a line assigning a
191
+ * literal to a credential-named key.
192
+ */
193
+ export function literalSecretsInText(text: string, assignments = true): LiteralSecret[] {
194
+ const out: LiteralSecret[] = [];
195
+ text.split(/\r?\n/).forEach((lineText, i) => {
196
+ for (const { pattern, label } of CREDENTIAL_SHAPES) {
197
+ const m = pattern.exec(lineText);
198
+ if (m && !isSecretReference(m[0])) {
199
+ out.push({ line: i + 1, column: m.index + 1, what: label });
200
+ return;
201
+ }
202
+ }
203
+ const a = assignments ? ASSIGNMENT.exec(lineText) : null;
204
+ if (a && isCredentialKey(a[1]) && !isSecretReference(a[3]) && !namesWhereSecretIs(a[3])) {
205
+ out.push({ line: i + 1, column: lineText.indexOf(a[3]) + 1, what: `a literal value for ${a[1]}` });
206
+ }
207
+ });
208
+ return out;
209
+ }
210
+
211
+ function extension(path: string): string {
212
+ const base = path.slice(path.lastIndexOf("/") + 1);
213
+ const dot = base.lastIndexOf(".");
214
+ return dot > 0 ? base.slice(dot).toLowerCase() : "";
215
+ }
216
+
217
+ /** The literal secrets in one file, or none when the file is binary or too large. */
218
+ export function literalSecretsInFile(text: string, path: string): LiteralSecret[] {
219
+ if (text.length > MAX_FILE_BYTES || text.includes("\0")) return [];
220
+ const kind = CODE_EXTENSIONS[extension(path)];
221
+ if (kind !== undefined) return literalSecretsInCode(text, path, kind);
222
+ // Prose says "Token: ..." without meaning one, so only a credential's shape counts there.
223
+ return literalSecretsInText(text, !PROSE_EXTENSIONS.has(extension(path)));
224
+ }
225
+
226
+ /** Every file under `dir` in `tree`, skipping node_modules and dot-directories, sorted. */
227
+ function filesUnder(tree: WorkspaceTree, dir: string): string[] {
228
+ const out: string[] = [];
229
+ const walk = (d: string) => {
230
+ for (const e of tree.list(d) ?? []) {
231
+ const p = joinPath(d, e.name);
232
+ if (e.type === "dir") {
233
+ if (!skippedDir(e.name)) walk(p);
234
+ } else out.push(p);
235
+ }
236
+ };
237
+ walk(dir);
238
+ return out.sort();
239
+ }
240
+
241
+ const boxMembers = (ctx: WorkspaceCheckContext): Member[] => ctx.declaration.members.filter((m) => m.box !== null);
242
+
243
+ export const BOX_CHECKS: readonly WorkspaceCheck[] = [
244
+ {
245
+ id: WSP_BOX_CREDENTIAL,
246
+ name: "box-credential-declared",
247
+ description:
248
+ "A box holds no credential: no file in a box member's directory carries a literal secret, in its declarations, an env or a vault's defaults. Variable references and secret-manager references (op://, bws://, infisical://) are not secrets.",
249
+ severity: "error",
250
+ configurable: false,
251
+ check(ctx) {
252
+ const out: WorkspaceDiagnostic[] = [];
253
+ for (const m of boxMembers(ctx)) {
254
+ const dir = m.dir === "." ? "" : m.dir;
255
+ if (ctx.tree.stat(dir) !== "dir") continue;
256
+ for (const path of filesUnder(ctx.tree, dir)) {
257
+ let text: string;
258
+ try {
259
+ text = ctx.tree.read(path);
260
+ } catch {
261
+ continue;
262
+ }
263
+ for (const s of literalSecretsInFile(text, path)) {
264
+ out.push({
265
+ checkId: this.id,
266
+ severity: this.severity,
267
+ code: "box-credential-declared",
268
+ message: `box-credential-declared: ${path} holds ${s.what}, and member ${m.name} is a box, which holds no credential; replace it with a \${VAR} or secret-manager reference, or declare the capability as brokered in the member's box block`,
269
+ entity: m.name,
270
+ pointer: m.box!.pointer,
271
+ treeFile: path,
272
+ line: s.line,
273
+ column: s.column,
274
+ });
275
+ }
276
+ }
277
+ }
278
+ return out;
279
+ },
280
+ },
281
+ {
282
+ id: WSP_BOX_UNBROKERED,
283
+ name: "box-capability-unbrokered",
284
+ description: "Every capability a box declares names the broker that holds its credential and enforces its scope.",
285
+ severity: "error",
286
+ configurable: true,
287
+ check(ctx) {
288
+ const out: WorkspaceDiagnostic[] = [];
289
+ for (const m of boxMembers(ctx)) {
290
+ for (const c of m.box!.capabilities) {
291
+ if (c.broker !== null) continue;
292
+ out.push({
293
+ checkId: this.id,
294
+ severity: this.severity,
295
+ code: "box-capability-unbrokered",
296
+ message: `box-capability-unbrokered: member ${m.name}'s box needs ${c.name} and names no broker for it, so the box would hold its credential; set broker to the runtime that holds it, such as the lobby`,
297
+ entity: m.name,
298
+ pointer: c.pointer,
299
+ });
300
+ }
301
+ }
302
+ return out;
303
+ },
304
+ },
305
+ // Box isolation (#2727), WSP123 and WSP124.
306
+ ...BOX_ISOLATION_CHECKS,
307
+ ];
@@ -14,6 +14,7 @@
14
14
  * | WSP113 | a pinned file that a superseding record pins at the old hash: the artifact did not follow the decision |
15
15
  * | WSP114 | the records of `--kind` can't be read (fixed) |
16
16
  * | WSP115 | a record kind the declaration names is missing or does not load as one (fixed, #2680) |
17
+ * | WSP116 | an answer kind's decision points file is missing or not valid (fixed, ws-058, #2738) |
17
18
  */
18
19
 
19
20
  import type { WorkspaceCheck, WorkspaceDiagnostic } from "../checks";
@@ -99,4 +100,28 @@ export const RECORD_CHECKS: readonly WorkspaceCheck[] = [
99
100
  }));
100
101
  },
101
102
  },
103
+ {
104
+ id: "WSP116",
105
+ name: "decision-points-invalid",
106
+ description:
107
+ "Every answer kind the declaration names has a decision points file that matches decision-points.schema.json: each input names a read-contract output, each table row tests declared inputs and answers a candidate, each model id is pinned, and the chain ends in its one quorum (ws-058).",
108
+ severity: "error",
109
+ configurable: false,
110
+ check(ctx) {
111
+ const out: WorkspaceDiagnostic[] = [];
112
+ for (const k of ctx.facts?.declaredKinds ?? []) {
113
+ if (!k.points) continue;
114
+ const where = k.declared.member === null ? "the workspace" : `member ${k.declared.member}`;
115
+ const finding = (message: string) => ({
116
+ checkId: this.id,
117
+ severity: this.severity,
118
+ message,
119
+ ...(k.declared.member !== null ? { entity: k.declared.member } : {}),
120
+ pointer: `${k.declared.pointer}/kind`,
121
+ });
122
+ for (const p of k.points.problems) out.push(finding(`${where} declares the answer kind ${k.declared.kind}, whose points file ${k.points.file} ${p.field ? `has ${p.field}, which ${p.message}` : p.message}`));
123
+ }
124
+ return out;
125
+ },
126
+ },
102
127
  ];
@@ -60,6 +60,13 @@ describe("the WSP catalog", () => {
60
60
  "records-unreadable",
61
61
  // A declared record kind that doesn't load breaks every reader's default (#2680).
62
62
  "record-kind-unloadable",
63
+ // An answer kind's points file that isn't valid can't be asked (ws-058).
64
+ "decision-points-invalid",
65
+ // A credential in a box's files is in git (#2726).
66
+ "box-credential-declared",
67
+ // Two boxes sharing a value, or a box that only holds on one machine, is never the fix (#2727).
68
+ "box-isolation-collision",
69
+ "box-isolation-literal",
63
70
  ]);
64
71
  });
65
72
 
@@ -28,6 +28,9 @@
28
28
  * | WSP101 to WSP106 | generated files |
29
29
  * | WSP111 to WSP114 | records read with `--kind` (#2549) |
30
30
  * | WSP115 | the record kinds the declaration names (#2680) |
31
+ * | WSP116 | the decision points file of each declared answer kind (ws-058) |
32
+ * | WSP121, WSP122 | boxes: no literal credential, every capability brokered (#2726) |
33
+ * | WSP123, WSP124 | box isolation: no shared port, state path or cookie, no literal machine path (#2727) |
31
34
  */
32
35
 
33
36
  import { realpathSync } from "node:fs";
@@ -45,6 +48,7 @@ import { gatherGeneratedFacts, GENERATED_CHECKS, type GeneratedFileFacts } from
45
48
  import { gatherLedgerFacts, LEDGER_CHECKS, type MemberLedgerFacts } from "./checks/ledgers";
46
49
  import { gatherPipelineFacts, PIPELINE_CHECKS, type MemberPipelineFacts } from "./checks/pipelines";
47
50
  import { RECORD_CHECKS, type RecordFacts } from "./checks/records";
51
+ import { BOX_CHECKS } from "./checks/boxes";
48
52
  import { loadDeclaredKinds, type DeclaredKind } from "./declared-kinds";
49
53
 
50
54
  /**
@@ -87,6 +91,12 @@ export interface WorkspaceDiagnostic extends PostSynthDiagnostic {
87
91
  pointer: string;
88
92
  /** An absolute path, for a finding about a file other than the declaration, such as a record (#2549). It is reported at its first line. */
89
93
  file?: string;
94
+ /** A path in the tree checked, relative to the workspace root, for a finding about a file there (#2726). It is reported at `line` and `column`. */
95
+ treeFile?: string;
96
+ line?: number;
97
+ column?: number;
98
+ /** The read contract's code for the finding, when it has one (#2726). */
99
+ code?: ReasonCode;
90
100
  }
91
101
 
92
102
  export interface WorkspaceCheck {
@@ -104,7 +114,7 @@ export interface WorkspaceCheck {
104
114
  /** A diagnostic ready for lint's reporters, with the entry it is about. */
105
115
  export interface WorkspaceFinding extends LintDiagnostic {
106
116
  entity?: string;
107
- /** The read contract's reason code, for a WSP001 finding: why the declaration can't be read (#2536). */
117
+ /** The read contract's reason code: for a WSP001 finding, why the declaration can't be read (#2536); for a box check, its finding code (#2726). */
108
118
  code?: ReasonCode;
109
119
  }
110
120
 
@@ -307,6 +317,8 @@ export const WORKSPACE_CHECKS: readonly WorkspaceCheck[] = [
307
317
  ...GENERATED_CHECKS,
308
318
  // Records read with --kind (#2549).
309
319
  ...RECORD_CHECKS,
320
+ // Boxes and their brokered capabilities (#2726).
321
+ ...BOX_CHECKS,
310
322
  ];
311
323
 
312
324
  const BY_ID = new Map(WORKSPACE_CHECKS.map((c) => [c.id, c]));
@@ -459,11 +471,16 @@ export async function runDeclarationChecks(
459
471
  const locate = (pointer: string): TextLocation => (parsed.ok ? parsed.locate(pointer) : { line: 1, column: 1 });
460
472
  const file = display(declaration.file);
461
473
  const toFinding = (d: WorkspaceDiagnostic): WorkspaceFinding => ({
462
- ...(d.file !== undefined ? { file: display(relative(root, d.file).split(sep).join("/")), line: 1, column: 1 } : { file, ...locate(d.pointer) }),
474
+ ...(d.treeFile !== undefined
475
+ ? { file: display(d.treeFile), line: d.line ?? 1, column: d.column ?? 1 }
476
+ : d.file !== undefined
477
+ ? { file: display(relative(root, d.file).split(sep).join("/")), line: 1, column: 1 }
478
+ : { file, ...locate(d.pointer) }),
463
479
  ruleId: d.checkId,
464
480
  severity: d.severity,
465
481
  message: d.message,
466
482
  ...(d.entity !== undefined ? { entity: d.entity } : {}),
483
+ ...(d.code !== undefined ? { code: d.code } : {}),
467
484
  });
468
485
  const order = (a: LintDiagnostic, b: LintDiagnostic) => a.line - b.line || a.column - b.column || (a.ruleId < b.ruleId ? -1 : a.ruleId > b.ruleId ? 1 : 0);
469
486
  const diagnostics = active.map(toFinding).sort(order);
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Answers to decision points as records (ws-058, #2739), with a stub model
3
+ * decider, on a workspace in a throwaway git repository: `points ask` and
4
+ * `points answer` as `decide.ts` runs them, `points --json` as
5
+ * `points-cli.ts` prints it, and WSP116 in `check`.
6
+ */
7
+
8
+ import { cpSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
11
+ import { runDeclarationChecks } from "./checks";
12
+ import { cleanScratch, commitAll, contract, REPO, repo } from "./__fixtures__/contract-repo";
13
+ import { answerPoint, askPoint, type PointsWriteDocument } from "./decide";
14
+ import pointAnswerSchema from "./point-answer.schema.json";
15
+ import { workspacePoints } from "./points-cli";
16
+ import pointsSchema from "./points.schema.json";
17
+ import pointsWriteSchema from "./points-write.schema.json";
18
+ import type { ModelAsk, WireAnswer } from "./points";
19
+ import { queryRecords } from "./records-cli";
20
+
21
+ const REF = join(REPO, "reference-workspace");
22
+ const POINTS = JSON.parse(readFileSync(join(REF, "decisions", "points.json"), "utf-8")) as { points: Record<string, unknown> };
23
+
24
+ const points = {
25
+ points: {
26
+ ...POINTS.points,
27
+ "needs-decision": {
28
+ title: "Does this change need a decision",
29
+ question: { type: "noul", instructions: "Does the change make a choice nobody has recorded?", criteria: { true: "It needs a decision.", false: "It does not." } },
30
+ inputs: { commit: "the commit, as graph --intent lists it" },
31
+ deciders: [
32
+ { kind: "model", backend: "systemone", model: "jev-1.13.0", threshold: 0.8 },
33
+ { kind: "quorum", count: 2, roles: ["lead"] },
34
+ ],
35
+ },
36
+ },
37
+ };
38
+
39
+ const write = contract(pointsWriteSchema);
40
+ const read = contract(pointsSchema);
41
+
42
+ let root: string;
43
+ const on = "2026-09-25";
44
+
45
+ /** A stub model decider answering every point with `answer`, at the pinned model. */
46
+ const stub =
47
+ (answer: WireAnswer): ModelAsk =>
48
+ async (req) => ({ model: req.model, answer });
49
+
50
+ const big = { "work-item.criteria": 9, "work-item.fits_small": false, "work-item.fits_medium": false };
51
+ const confident: WireAnswer = { type: "choice", choice: "large", probabilities: { small: 0.01, medium: 0.07, large: 0.92 }, confidence: 0.88 };
52
+ const unsure: WireAnswer = { type: "choice", choice: "medium", probabilities: { small: 0.2, medium: 0.45, large: 0.35 }, confidence: 0.175 };
53
+
54
+ function ok(doc: PointsWriteDocument): Extract<PointsWriteDocument, { question: unknown }> {
55
+ write.expectValid(doc);
56
+ if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
57
+ return doc;
58
+ }
59
+
60
+ function refused(doc: PointsWriteDocument): string {
61
+ write.expectValid(doc);
62
+ if (!("error" in doc)) throw new Error("expected a refusal");
63
+ return doc.error.code;
64
+ }
65
+
66
+ const answers = () => readdirSync(join(root, "answers")).filter((f) => f.endsWith(".md")).sort();
67
+ const fm = (path: string) => readFileSync(join(root, path), "utf-8");
68
+
69
+ beforeAll(() => {
70
+ root = repo(
71
+ {
72
+ "chant.workspace.json": JSON.stringify(
73
+ { name: "studio", schema: 1, members: [{ name: "app", dir: "app", kind: "other", because: "a plain Node server" }], records: [{ kind: "answers/answer.kind.mjs" }] },
74
+ null,
75
+ 2,
76
+ ),
77
+ "app/server.mjs": "export const port = 8080;\n",
78
+ "decisions/points.json": JSON.stringify(points, null, 2),
79
+ ".chant/trust.json": JSON.stringify({ schema: 1, roles: { agent: ["bot"], lead: ["alice", "bob", "bot"] } }),
80
+ },
81
+ false,
82
+ );
83
+ cpSync(join(REF, "answers", "answer.kind.mjs"), join(root, "answers", "answer.kind.mjs"), { recursive: true });
84
+ cpSync(join(REF, "answers", "answer.schema.json"), join(root, "answers", "answer.schema.json"));
85
+ commitAll(root, "c0");
86
+ });
87
+
88
+ afterAll(cleanScratch);
89
+
90
+ describe("points ask (#2739)", () => {
91
+ test("a table's answer is answered at once", async () => {
92
+ const doc = ok(await askPoint({ cwd: root, point: "slice-tier", inputs: { "work-item.fits_small": true }, subject: "W-001", on }));
93
+ expect(doc.question).toMatchObject({ state: "answered", open: false, answer: "small", decider: { kind: "table", row: 0 }, subject: "W-001", answeredOn: on, model: null });
94
+ expect(doc.written).toBe(true);
95
+ });
96
+
97
+ test("a model answer at or above its threshold is written as proposed, with its values and source", async () => {
98
+ const doc = ok(await askPoint({ cwd: root, point: "slice-tier", inputs: big, subject: "W-002", ask: stub(confident), on }));
99
+ expect(doc.question).toMatchObject({
100
+ state: "proposed",
101
+ open: true,
102
+ answer: "large",
103
+ decider: { kind: "model", backend: "systemone", model: "bosun-v3.1-1.7b" },
104
+ probabilities: { small: 0.01, medium: 0.07, large: 0.92 },
105
+ confidence: 0.88,
106
+ threshold: 0.8,
107
+ model: { answer: "large", confidence: 0.88, threshold: 0.8, observed: true },
108
+ escalations: [{ kind: "table", reason: "no row matches these inputs" }],
109
+ });
110
+ expect(doc.id).toBe(`slice-tier-${doc.question.inputsHash.slice(0, 12)}`);
111
+ expect(fm(doc.path)).toContain('source:\n via: "cli"\n model: "bosun-v3.1-1.7b"');
112
+ const records = await queryRecords({ kind: "answers/answer.kind.mjs", cwd: root });
113
+ if ("error" in records) throw new Error(records.error.message);
114
+ expect(records.records.find((r) => r.id === doc.id)).toMatchObject({ valid: true, state: "proposed", warnings: [] });
115
+ });
116
+
117
+ test("asking again with the same inputs returns the existing record", async () => {
118
+ const before = answers();
119
+ const first = ok(await askPoint({ cwd: root, point: "slice-tier", inputs: big, subject: "W-002", ask: stub(unsure), on }));
120
+ // Key order does not matter: the inputs hash over canonical JSON.
121
+ const again = ok(await askPoint({ cwd: root, point: "slice-tier", inputs: Object.fromEntries(Object.entries(big).reverse()), ask: stub(unsure) }));
122
+ for (const d of [first, again]) expect(d).toMatchObject({ reused: true, written: false, question: { state: "proposed", answer: "large" } });
123
+ expect(answers()).toEqual(before);
124
+ });
125
+
126
+ test("below its threshold the question is escalated and listed as open for a quorum, with the model's answer", async () => {
127
+ const doc = ok(await askPoint({ cwd: root, point: "slice-tier", inputs: { ...big, "work-item.criteria": 12 }, subject: "W-003", ask: stub(unsure), on }));
128
+ expect(doc.question).toMatchObject({ state: "escalated", open: true, answer: null, decider: { kind: "quorum", count: 1 } });
129
+ expect(fm(doc.path)).not.toMatch(/^answer:/m);
130
+ const listed = await workspacePoints({ cwd: root, open: true });
131
+ read.expectValid(listed);
132
+ if ("error" in listed) throw new Error(listed.error.message);
133
+ const q = listed.questions.find((x) => x.id === doc.id)!;
134
+ expect(q).toMatchObject({ state: "escalated", open: true, current: true, model: { answer: "medium", confidence: 0.175, threshold: 0.8, model: "bosun-v3.1-1.7b", observed: false } });
135
+ expect(q.escalations.map((e) => e.reason)).toEqual(["no row matches these inputs", "not observed: confidence 0.175 is below the threshold 0.8"]);
136
+ expect(listed.questions.every((x) => x.open)).toBe(true);
137
+ expect(listed.points.map((p) => p.name)).toEqual(["slice-tier", "ship-skip", "needs-decision"]);
138
+ expect(listed.points[0].inputs[0]).toEqual({ name: "work-item.criteria", output: "work-item", description: "acceptance criteria in the work item" });
139
+
140
+ // A later ask still escalating keeps the standing record; one a model answers rewrites it, as proposed.
141
+ const still = ok(await askPoint({ cwd: root, point: "slice-tier", inputs: { ...big, "work-item.criteria": 12 } }));
142
+ expect(still).toMatchObject({ reused: true, question: { state: "escalated" } });
143
+ const now = ok(await askPoint({ cwd: root, point: "slice-tier", inputs: { ...big, "work-item.criteria": 12 }, ask: stub(confident), on }));
144
+ expect(now).toMatchObject({ id: doc.id, path: doc.path, reused: false, question: { state: "proposed", subject: "W-003", answer: "large" } });
145
+ });
146
+
147
+ test("with --dry-run nothing is written, and the text is returned", async () => {
148
+ const before = answers();
149
+ const doc = ok(await askPoint({ cwd: root, point: "ship-skip", inputs: { "release.units": 3 }, dryRun: true, on }));
150
+ expect(doc).toMatchObject({ written: false, dryRun: true, question: { state: "answered", answer: false } });
151
+ expect((doc as { text?: string }).text).toContain("answer: false");
152
+ expect(answers()).toEqual(before);
153
+ });
154
+
155
+ test("refusals: an unknown point, undeclared inputs, a closed-failing backend", async () => {
156
+ expect(refused(await askPoint({ cwd: root, point: "nope", inputs: {} }))).toBe("point-unknown");
157
+ expect(refused(await askPoint({ cwd: root, point: "slice-tier", inputs: { "contract.size": 3 } }))).toBe("point-inputs-invalid");
158
+ expect(refused(await askPoint({ cwd: root, point: "slice-tier", inputs: [] }))).toBe("point-inputs-invalid");
159
+ });
160
+ });
161
+
162
+ describe("points answer (#2739)", () => {
163
+ test("a person confirms a model's proposal, which keeps the model as its decider", async () => {
164
+ const asked = ok(await askPoint({ cwd: root, point: "slice-tier", inputs: big, ask: stub(confident) }));
165
+ const doc = ok(await answerPoint({ cwd: root, id: asked.id, answer: "large", by: ["alice"], on }));
166
+ expect(doc.question).toMatchObject({ state: "answered", open: false, answer: "large", decider: { kind: "model", model: "bosun-v3.1-1.7b" }, confidence: 0.88, answeredBy: ["alice"], answeredOn: on });
167
+ expect(refused(await answerPoint({ cwd: root, id: asked.id, answer: "small", by: ["alice"] }))).toBe("record-closed");
168
+ // Asking again returns the confirmed answer.
169
+ expect(ok(await askPoint({ cwd: root, point: "slice-tier", inputs: big, ask: stub(unsure) }))).toMatchObject({ reused: true, question: { state: "answered" } });
170
+ });
171
+
172
+ test("people answering otherwise is the quorum's answer, and the proposal moves into the escalations", async () => {
173
+ const asked = ok(await askPoint({ cwd: root, point: "slice-tier", inputs: { ...big, "work-item.criteria": 4 }, ask: stub(confident), on }));
174
+ expect(refused(await answerPoint({ cwd: root, id: asked.id, answer: "tiny", by: ["alice"] }))).toBe("answer-not-candidate");
175
+ const doc = ok(await answerPoint({ cwd: root, id: asked.id, answer: "medium", by: ["bob"], on }));
176
+ expect(doc.question).toMatchObject({ state: "answered", answer: "medium", decider: { kind: "quorum", count: 1, by: ["bob"] }, probabilities: null, confidence: null });
177
+ expect(doc.question.escalations.at(-1)).toMatchObject({ kind: "model", answer: "large", confidence: 0.88, reason: "proposed large, and people answered medium" });
178
+ });
179
+
180
+ test("the quorum counts distinct people holding its roles, never an agent", async () => {
181
+ const asked = ok(await askPoint({ cwd: root, point: "needs-decision", inputs: { commit: { sha: "abc" } }, ask: stub({ type: "noul", noul: 0.55 }), on }));
182
+ expect(asked.question).toMatchObject({ state: "escalated", model: { answer: true, confidence: 0.55, observed: false } });
183
+ expect(refused(await answerPoint({ cwd: root, id: asked.id, answer: "yes", by: ["alice", "Alice ", "bot", "carol"] }))).toBe("quorum-not-met");
184
+ const doc = ok(await answerPoint({ cwd: root, id: asked.id, answer: "yes", by: ["alice", "bob"], on }));
185
+ expect(doc.question).toMatchObject({ state: "answered", answer: true, decider: { kind: "quorum", count: 2, roles: ["lead"], by: ["alice", "bob"] } });
186
+ });
187
+
188
+ test("an unknown id is refused", async () => {
189
+ expect(refused(await answerPoint({ cwd: root, id: "slice-tier-000000000000", answer: "small", by: ["alice"] }))).toBe("record-not-found");
190
+ });
191
+
192
+ test("every record written follows point-answer.schema.json, and the reference workspace carries a copy of it", () => {
193
+ expect(JSON.parse(readFileSync(join(REF, "answers", "answer.schema.json"), "utf-8"))).toEqual(pointAnswerSchema);
194
+ expect(answers().length).toBeGreaterThan(3);
195
+ });
196
+ });
197
+
198
+ describe("the points file in check and on read (#2738)", () => {
199
+ test("a changed point warns on its answers, and an invalid points file is WSP116 and a points-invalid source", async () => {
200
+ const file = join(root, "decisions", "points.json");
201
+ const changed = JSON.parse(readFileSync(file, "utf-8"));
202
+ changed.points["slice-tier"].title = "Which builder builds this work item";
203
+ writeFileSync(file, JSON.stringify(changed, null, 2));
204
+ const doc = await workspacePoints({ cwd: root });
205
+ read.expectValid(doc);
206
+ if ("error" in doc) throw new Error(doc.error.message);
207
+ const slice = doc.questions.filter((q) => q.point === "slice-tier");
208
+ expect(slice.every((q) => q.current === false && q.warnings.some((w) => w.code === "answer-point-changed"))).toBe(true);
209
+ expect((await runDeclarationChecks(root)).diagnostics.filter((d) => d.ruleId === "WSP116")).toEqual([]);
210
+
211
+ changed.points["slice-tier"].deciders.pop();
212
+ writeFileSync(file, JSON.stringify(changed, null, 2));
213
+ const report = await runDeclarationChecks(root);
214
+ expect(report.diagnostics.filter((d) => d.ruleId === "WSP116").map((d) => d.message)).toEqual([
215
+ "the workspace declares the answer kind answers/answer.kind.mjs, whose points file decisions/points.json has points.slice-tier.deciders, which must end in a quorum: when no table row or model answers, people do",
216
+ ]);
217
+ expect(report.ok).toBe(false);
218
+ const broken = await workspacePoints({ cwd: root });
219
+ read.expectValid(broken);
220
+ if ("error" in broken) throw new Error(broken.error.message);
221
+ expect(broken.sources[0].reason?.code).toBe("points-invalid");
222
+ expect(refused(await askPoint({ cwd: root, point: "slice-tier", inputs: big }))).toBe("points-invalid");
223
+ });
224
+ });