@intentius/chant 0.124.24 → 0.124.25

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 (96) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/main.js +21 -7
  3. package/dist/cli/mcp/workspace-tools.d.ts +3 -3
  4. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  5. package/dist/cli/mcp/workspace-tools.js +27 -4
  6. package/dist/effect-receipt.d.ts +13 -0
  7. package/dist/effect-receipt.d.ts.map +1 -1
  8. package/dist/effect-receipt.js +19 -0
  9. package/dist/lexicon-module.d.ts +7 -1
  10. package/dist/lexicon-module.d.ts.map +1 -1
  11. package/dist/lexicon-module.js +7 -2
  12. package/dist/lifecycle/receipt-plan.d.ts.map +1 -1
  13. package/dist/lifecycle/receipt-plan.js +4 -2
  14. package/dist/op/local-executor.d.ts.map +1 -1
  15. package/dist/op/local-executor.js +64 -5
  16. package/dist/op/receipt-store.d.ts +5 -0
  17. package/dist/op/receipt-store.d.ts.map +1 -1
  18. package/dist/op/receipt-store.js +4 -3
  19. package/dist/op/step-output-ref.d.ts +26 -18
  20. package/dist/op/step-output-ref.d.ts.map +1 -1
  21. package/dist/op/step-output-ref.js +86 -63
  22. package/dist/wave-runtime.mjs +213 -213
  23. package/dist/workspace/agent-cli.d.ts.map +1 -1
  24. package/dist/workspace/agent-cli.js +3 -0
  25. package/dist/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  26. package/dist/workspace/conformance/writer.js +1 -1
  27. package/dist/workspace/decide.d.ts +51 -9
  28. package/dist/workspace/decide.d.ts.map +1 -1
  29. package/dist/workspace/decide.js +125 -14
  30. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  31. package/dist/workspace/lineage-upgrade.js +20 -11
  32. package/dist/workspace/point-answer.schema.json +16 -1
  33. package/dist/workspace/points-cli.d.ts +5 -3
  34. package/dist/workspace/points-cli.d.ts.map +1 -1
  35. package/dist/workspace/points-cli.js +16 -7
  36. package/dist/workspace/points-write.schema.json +39 -4
  37. package/dist/workspace/points.schema.json +42 -3
  38. package/dist/workspace/reason-codes.d.ts +2 -1
  39. package/dist/workspace/reason-codes.d.ts.map +1 -1
  40. package/dist/workspace/reason-codes.js +2 -1
  41. package/dist/workspace/records-cli.d.ts +12 -2
  42. package/dist/workspace/records-cli.d.ts.map +1 -1
  43. package/dist/workspace/records-cli.js +27 -7
  44. package/dist/workspace/records-write.d.ts.map +1 -1
  45. package/dist/workspace/records-write.js +2 -3
  46. package/dist/workspace/records.d.ts +10 -4
  47. package/dist/workspace/records.d.ts.map +1 -1
  48. package/dist/workspace/records.js +16 -3
  49. package/dist/workspace/records.schema.json +2 -1
  50. package/dist/workspace/trust/verify-cli.d.ts.map +1 -1
  51. package/dist/workspace/trust/verify-cli.js +2 -1
  52. package/dist/workspace/trust/verify.d.ts +22 -0
  53. package/dist/workspace/trust/verify.d.ts.map +1 -1
  54. package/dist/workspace/trust/verify.js +94 -14
  55. package/dist/workspace/write-scope.d.ts +3 -1
  56. package/dist/workspace/write-scope.d.ts.map +1 -1
  57. package/dist/workspace/write-scope.js +11 -4
  58. package/package.json +1 -1
  59. package/src/cli/compiled-cli.e2e.test.ts +45 -1
  60. package/src/cli/main.test.ts +10 -0
  61. package/src/cli/main.ts +21 -7
  62. package/src/cli/mcp/workspace-tools.e2e.test.ts +1 -1
  63. package/src/cli/mcp/workspace-tools.ts +27 -4
  64. package/src/effect-receipt.ts +22 -0
  65. package/src/lexicon-module.ts +9 -2
  66. package/src/lifecycle/receipt-plan.test.ts +9 -0
  67. package/src/lifecycle/receipt-plan.ts +4 -1
  68. package/src/op/effect-step.test.ts +95 -1
  69. package/src/op/local-executor.test.ts +65 -0
  70. package/src/op/local-executor.ts +68 -5
  71. package/src/op/receipt-store.ts +13 -3
  72. package/src/op/step-output-ref.test.ts +68 -24
  73. package/src/op/step-output-ref.ts +85 -65
  74. package/src/workspace/agent-cli.ts +2 -0
  75. package/src/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  76. package/src/workspace/conformance/writer.ts +1 -1
  77. package/src/workspace/decide.test.ts +87 -3
  78. package/src/workspace/decide.ts +142 -17
  79. package/src/workspace/lineage-upgrade.test.ts +21 -0
  80. package/src/workspace/lineage-upgrade.ts +19 -11
  81. package/src/workspace/point-answer.schema.json +16 -1
  82. package/src/workspace/points-cli.ts +16 -8
  83. package/src/workspace/points-write.schema.json +39 -4
  84. package/src/workspace/points.schema.json +42 -3
  85. package/src/workspace/reason-codes.ts +2 -1
  86. package/src/workspace/record-assets.test.ts +14 -0
  87. package/src/workspace/records-cli.ts +28 -7
  88. package/src/workspace/records-write-commands.e2e.test.ts +32 -0
  89. package/src/workspace/records-write.ts +2 -3
  90. package/src/workspace/records.schema.json +2 -1
  91. package/src/workspace/records.ts +17 -4
  92. package/src/workspace/trust/trust.test.ts +105 -0
  93. package/src/workspace/trust/verify-cli.ts +2 -1
  94. package/src/workspace/trust/verify.ts +106 -13
  95. package/src/workspace/write-scope.test.ts +18 -0
  96. package/src/workspace/write-scope.ts +16 -5
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://intentius.io/chant/schemas/workspace/points/v1/points.schema.json",
4
4
  "title": "chant workspace points output",
5
- "description": "What `chant workspace points --json` prints (ws-058, #2739): the decision points each declared answer kind's points file declares, and the questions asked of them, each an answer record. A question is open while it is escalated to people, or proposed by a model and not yet confirmed; it carries any model's answer with its confidence and threshold, so a reader can prompt a person. With --open only open questions are listed. chant never calls a model to print this. Readers ignore fields they do not know; a field is only ever added within a version. The reason and error codes are closed lists, each in the one closed list of `reason-codes.ts`. Contract version 1 is written by chant 0.81.0 and newer, and this document was added to it by #2739; a chant without it refuses the command.",
5
+ "description": "What `chant workspace points --json` prints (ws-058, #2739): the decision points each declared answer kind's points file declares, and the questions asked of them, each an answer record. A question is open while it is escalated to people, or proposed by a model and not yet confirmed, and not withdrawn (#2888); it carries any model's answer with its confidence and threshold, so a reader can prompt a person. With --open only open questions are listed. chant never calls a model to print this. Readers ignore fields they do not know; a field is only ever added within a version. The reason and error codes are closed lists, each in the one closed list of `reason-codes.ts`. Contract version 1 is written by chant 0.81.0 and newer, and this document was added to it by #2739; a chant without it refuses the command.",
6
6
  "oneOf": [
7
7
  {
8
8
  "$ref": "#/$defs/result"
@@ -95,7 +95,8 @@
95
95
  "open",
96
96
  "escalated",
97
97
  "proposed",
98
- "answered"
98
+ "answered",
99
+ "withdrawn"
99
100
  ],
100
101
  "properties": {
101
102
  "points": {
@@ -121,6 +122,11 @@
121
122
  "answered": {
122
123
  "type": "integer",
123
124
  "minimum": 0
125
+ },
126
+ "withdrawn": {
127
+ "type": "integer",
128
+ "minimum": 0,
129
+ "description": "Questions withdrawn because they no longer apply (#2888), whatever their state; none of them is open."
124
130
  }
125
131
  }
126
132
  }
@@ -765,6 +771,38 @@
765
771
  }
766
772
  }
767
773
  },
774
+ "withdrawn": {
775
+ "type": [
776
+ "object",
777
+ "null"
778
+ ],
779
+ "description": "Who withdrew the question, when and why (points withdraw, #2888): an open question that no longer applies, such as one about a work item its author changed. A withdrawn question keeps its state and is not open. null when it was not withdrawn, and absent from a chant before it.",
780
+ "required": [
781
+ "by",
782
+ "on",
783
+ "note"
784
+ ],
785
+ "properties": {
786
+ "by": {
787
+ "type": "array",
788
+ "items": {
789
+ "type": "string"
790
+ }
791
+ },
792
+ "on": {
793
+ "type": [
794
+ "string",
795
+ "null"
796
+ ]
797
+ },
798
+ "note": {
799
+ "type": [
800
+ "string",
801
+ "null"
802
+ ]
803
+ }
804
+ }
805
+ },
768
806
  "asked": {
769
807
  "description": "An ad-hoc point's question as its ask gave it (points ask --candidates, #3403): the question's text, and its criteria, what each candidate means, in the point's question type's shape. People's answer is one of the candidates. null for a declared point's question, and absent from a chant before it.",
770
808
  "oneOf": [
@@ -825,7 +863,8 @@
825
863
  "answer-points-unreadable",
826
864
  "answer-point-unknown",
827
865
  "answer-point-changed",
828
- "record-unattested"
866
+ "record-unattested",
867
+ "record-ratified-below-quorum"
829
868
  ]
830
869
  },
831
870
  "message": {
@@ -112,6 +112,7 @@ export const REASONS = {
112
112
  "seal-unverifiable": "Nothing here can say whose seal it is: there is no signers file at base, ssh-keygen is not installed, or the record came back in a return sealed by a signer no admission lists yet.",
113
113
  // A record whose author seal is not attested under a signers file at base (records, #2688). A warning: the record is still read.
114
114
  "record-unattested": "A signers file is active at base, and the record names an author whose seal does not verify: it has none, the author has no key in the file, or the signature fails.",
115
+ "record-ratified-below-quorum": "The record is in its kind's ratified state (reviews.ratified), and its quorum is not met: it was written to that state by hand, past the check of records new and amend. It is still read, and stays valid.",
115
116
  // A records read that fails (records).
116
117
  "kind-unreadable": "The record kind file is missing or could not be imported.",
117
118
  "kind-invalid": "The record kind file exports no recordKind, or its shape is wrong.",
@@ -161,7 +162,7 @@ export const REASONS = {
161
162
  "answer-in-steward-turn": "The answer was given during a steward's turn, or by a process it started: a steward never answers a decision point, and a person answers it through hud or at a shell.",
162
163
  "answer-never-over-mcp": "The answer or retraction came over MCP or ACP, or from a process a session on one started, and the question constrains a gate that is never resolved there: a person answers it at a shell or through hud (#3817).",
163
164
  "answer-not-answered": "points retract names a question that has no answer to retract: it is escalated or proposed, and people answer it instead (#3351).",
164
- "answer-field-unsupported": "The answer kind's copy of point-answer.schema.json predates a field the write needs, a note, a relay, a retraction or an ad-hoc question's asked: copy the schema anew to turn it on (#3351, #3402, #3403).",
165
+ "answer-field-unsupported": "The answer kind's copy of point-answer.schema.json predates a field the write needs, a note, a relay, a retraction, an ad-hoc question's asked or a withdrawal: copy the schema anew to turn it on (#3351, #3402, #3403, #2888).",
165
166
  "point-candidates-invalid": "points ask names an ad-hoc point without --candidates, a declared point with them, or candidates that are not a question and criteria of the point's question type (#3403).",
166
167
  // records --since that fails (#2673).
167
168
  "since-rev-unknown": "--since names no commit, or a session with no opening revision and no commit that added it.",
@@ -181,6 +181,20 @@ describe("chant workspace records checks each pin", () => {
181
181
  expect(pinFile("ws/design/screens/home.png", root)).toEqual({ path: "design/screens/home.png", sha256: sha(PNG) });
182
182
  expect(pinFile("design", join(root, "ws"))).toEqual({ error: "design is not a file" });
183
183
  });
184
+
185
+ test("records pin takes the workspace above the current directory or the kind file, not the nested one above the file (#3038)", () => {
186
+ const root = repo({
187
+ "chant.workspace.json": declaration([{ name: "template", dir: "template", kind: "other", because: "a template" }]),
188
+ "template/chant.workspace.json": declaration([{ name: "box", dir: "box", kind: "other", because: "the box" }]),
189
+ "template/box/builder.mjs": "export {};\n",
190
+ "decisions/decision.kind.mjs": KIND,
191
+ });
192
+ const hash = sha("export {};\n");
193
+ expect(pinFile("template/box/builder.mjs", root)).toEqual({ path: "template/box/builder.mjs", sha256: hash });
194
+ expect(pinFile("box/builder.mjs", join(root, "template"))).toEqual({ path: "box/builder.mjs", sha256: hash });
195
+ expect(pinFile("box/builder.mjs", join(root, "template"), "../decisions/decision.kind.mjs")).toEqual({ path: "template/box/builder.mjs", sha256: hash });
196
+ expect(pinFile("chant.workspace.json", root, "template/box/builder.mjs")).toMatchObject({ error: expect.stringContaining("not a file path inside the workspace") });
197
+ });
184
198
  });
185
199
 
186
200
  describe("chant workspace graph --kind", () => {
@@ -78,7 +78,7 @@ export const RECORDS_CONTRACT_VERSION = 1;
78
78
  export const RECORDS_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/records/v1/records.schema.json";
79
79
 
80
80
  const USAGE =
81
- "chant workspace records [--kind <kind file>] [--current] [--uncommitted | --at <rev>] [--base <rev>] [--require attested] [--json] | chant workspace records [--kind <kind file>] --since <rev|session id> [--at <rev>] [--json] | chant workspace records pin <path> | chant workspace records new|amend|review|close (#2670, #2693)";
81
+ "chant workspace records [--kind <kind file>] [--current] [--uncommitted | --at <rev>] [--base <rev>] [--require attested] [--json] | chant workspace records [--kind <kind file>] --since <rev|session id> [--at <rev>] [--json] | chant workspace records pin <path> [--kind <kind file>] | chant workspace records new|amend|review|close (#2670, #2693)";
82
82
 
83
83
  /** Exit code when the read worked and a record falls below `--require`. */
84
84
  export const EXIT_BELOW_REQUIRED = 2;
@@ -336,6 +336,16 @@ export async function quorumOptionsFor(loaded: LoadedRecordKind, tree: Workspace
336
336
  };
337
337
  }
338
338
 
339
+ /**
340
+ * What a quorum that is not met lacks, for `ratify-quorum-not-met` and
341
+ * `record-ratified-below-quorum` (#2873, #2878): the need, the agreeing
342
+ * verdicts that count, and each verdict that does not count, with why.
343
+ */
344
+ export function quorumShortfall(q: Quorum): string {
345
+ const notCounted = q.notCounted.length > 0 ? ` (not counted: ${q.notCounted.map((v) => `${v.reviewer}, ${v.reason!.code}`).join("; ")})` : "";
346
+ return `its quorum needs ${q.need} agreeing verdicts that count, and it has ${q.agreed}${notCounted}`;
347
+ }
348
+
339
349
  /**
340
350
  * Where a kind's pinned paths resolve: the workspace whose declaration sits
341
351
  * nearest above the kind file, when it is inside the repository, or else the
@@ -446,10 +456,16 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
446
456
  // Returned work (#2552): its seals verify against the signers admitted for its return too.
447
457
  const recordPolicy = p.returned ? returnedPolicy(policy, p.returned.id) : policy;
448
458
  const options = p.returned ? await quorumOptionsFor(loaded, tree, recordPolicy) : quorumOptions;
459
+ const quorum = options ? computeQuorum(loaded.kind, r, options) : null;
460
+ const ratified = loaded.kind.reviews?.ratified;
461
+ if (quorum && !quorum.met && ratified !== undefined && r.state === ratified) {
462
+ // Written to the ratified state by hand, past the check of records new and amend (#2878).
463
+ r.warnings.push({ code: "record-ratified-below-quorum", message: `${r.id ?? r.path} is ${ratified}, and ${quorumShortfall(quorum)}` });
464
+ }
449
465
  records.push({
450
466
  ...r,
451
467
  provenance: p,
452
- ...(options ? { quorum: computeQuorum(loaded.kind, r, options) } : {}),
468
+ ...(options ? { quorum } : {}),
453
469
  ...(seals ? authorSeal(loaded.kind, r, recordPolicy, seals.checkRecordSeal) : {}),
454
470
  });
455
471
  }
@@ -635,12 +651,17 @@ async function raiseWorkGaps(loaded: LoadedRecordKind, records: RecordView[], op
635
651
  /**
636
652
  * `chant workspace records pin <path>`: the `{path, sha256}` a decision's
637
653
  * evidence entry holds for a file, with the path from the workspace root
638
- * (the nearest declaration above the file, or the repository root).
654
+ * records resolve pins in. With `kind`, that is the declaration nearest above
655
+ * the kind file, or the repository root, as {@link pinRoot} has it. Without,
656
+ * it is the declaration nearest above `cwd`, and only when there is none the
657
+ * one nearest above the file, so a workspace nested inside the current one is
658
+ * never taken for it (#3038).
639
659
  */
640
- export function pinFile(file: string, cwd: string): { path: string; sha256: string } | { error: string } {
660
+ export function pinFile(file: string, cwd: string, kind?: string): { path: string; sha256: string } | { error: string } {
641
661
  const abs = realpathOr(resolve(cwd, file));
642
- const top = gitRoot(dirname(abs));
643
- const ws = findWorkspaceRoot(dirname(abs));
662
+ const from = kind === undefined ? dirname(abs) : dirname(resolve(cwd, kind));
663
+ const ws = kind === undefined ? (findWorkspaceRoot(cwd) ?? findWorkspaceRoot(from)) : findWorkspaceRoot(from);
664
+ const top = gitRoot(from);
644
665
  const base = ws ? realpathOr(ws.dir) : top ? realpathOr(top) : realpathOr(cwd);
645
666
  const path = relative(base, abs).split(sep).join("/");
646
667
  if (path.startsWith("..") || !isWorkspacePath(path)) return { error: `${file} is not a file path inside the workspace at ${base}` };
@@ -659,7 +680,7 @@ export async function runWorkspaceRecords(ctx: CommandContext): Promise<number>
659
680
  console.error(formatError({ message: "pin needs the path of a file", hint: USAGE }));
660
681
  return 1;
661
682
  }
662
- const pin = pinFile(args.extraPositional2, process.cwd());
683
+ const pin = pinFile(args.extraPositional2, process.cwd(), args.kind);
663
684
  if ("error" in pin) {
664
685
  console.error(formatError({ message: pin.error, hint: USAGE }));
665
686
  return 1;
@@ -419,6 +419,38 @@ describe("ratifying against the quorum (#2873)", () => {
419
419
  expect(code(doc)).toBe("ratify-quorum-not-met");
420
420
  });
421
421
 
422
+ test("a record edited by hand to ratified below its quorum reads with record-ratified-below-quorum, and stays valid (#2878)", async () => {
423
+ const read = async (id: string, kind = KIND) => {
424
+ const doc = await queryRecords({ kind, cwd: dir });
425
+ if ("error" in doc) throw new Error(doc.error.message);
426
+ return doc.records.find((r) => r.id === id)!;
427
+ };
428
+ const warned = (r: { warnings: { code: string }[] }) => r.warnings.some((w) => w.code === "record-ratified-below-quorum");
429
+ // A decided record below its quorum is not warned about.
430
+ await agree("alice");
431
+ await agree("lex00");
432
+ expect(warned(await read("ws-001"))).toBe(false);
433
+ writeFileSync(join(dir, "decisions", "ws-001-one.md"), text("ws-001-one.md").replace('state: "decided"', 'state: "ratified"'));
434
+ const r = await read("ws-001");
435
+ expect(r).toMatchObject({ state: "ratified", valid: true, reasons: [] });
436
+ expect(r.quorum).toMatchObject({ need: 2, agreed: 1, met: false });
437
+ expect(r.warnings.find((w) => w.code === "record-ratified-below-quorum")!.message).toBe(
438
+ "ws-001 is ratified, and its quorum needs 2 agreeing verdicts that count, and it has 1 (not counted: lex00, review-decider)",
439
+ );
440
+ // A record ratified through amend at its quorum carries no such warning.
441
+ put("ws-006-six.md", decision({ id: "ws-006", title: "Six" }));
442
+ await agree("alice", "ws-006");
443
+ await agree("bob", "ws-006");
444
+ expect(await ratify("ws-006")).toMatchObject({ changed: ["state", "closed_digest"] });
445
+ expect(warned(await read("ws-006"))).toBe(false);
446
+ // A kind without reviews.ratified never warns: ws-003 opened ratified with no verdicts.
447
+ expect(warned(await read("ws-003"))).toBe(true);
448
+ // A kind module is cached by its path, so the kind without it is a second file.
449
+ const kindFile = join(dir, "decisions", "decision.kind.mjs");
450
+ writeFileSync(join(dir, "decisions", "plain.kind.mjs"), readFileSync(kindFile, "utf-8").replace(', ratified: "ratified" }', " }"));
451
+ expect(warned(await read("ws-003", "decisions/plain.kind.mjs"))).toBe(false);
452
+ });
453
+
422
454
  test("records new refuses a record that opens ratified below its quorum", async () => {
423
455
  const fresh = decision({ title: "Straight in", state: "ratified" });
424
456
  delete fresh.id;
@@ -61,7 +61,7 @@ import {
61
61
  type RecordEntry,
62
62
  type RecordWarning,
63
63
  } from "./records";
64
- import { declaredKindFiles, pinRoot, quorumOptionsFor, realpathOr } from "./records-cli";
64
+ import { declaredKindFiles, pinRoot, quorumOptionsFor, quorumShortfall, realpathOr } from "./records-cli";
65
65
  import { policyAtBase, resolveBase } from "./trust/provenance";
66
66
  import { WorkspaceReadError } from "./declaration";
67
67
  import { findSessionKinds, headCommit, sessionKindsFor } from "./session-kinds";
@@ -516,10 +516,9 @@ async function refuseRatifyBelowQuorum(o: Opened, written: RecordEntry, id: stri
516
516
  const options = await quorumOptionsFor(o.loaded, tree, policy);
517
517
  const q = options ? computeQuorum(o.loaded.kind, written, options) : null;
518
518
  if (q === null || q.met) return;
519
- const notCounted = q.notCounted.length > 0 ? ` (not counted: ${q.notCounted.map((v) => `${v.reviewer}, ${v.reason!.code}`).join("; ")})` : "";
520
519
  throw new RecordWriteError(
521
520
  "ratify-quorum-not-met",
522
- `${id} can't be ${ratified} yet: its quorum needs ${q.need} agreeing verdicts that count, and it has ${q.agreed}${notCounted}. Add verdicts with chant workspace records review ${id} --verdict agree --by <reviewer>, then set ${o.loaded.kind.stateField} to ${ratified} again`,
521
+ `${id} can't be ${ratified} yet: ${quorumShortfall(q)}. Add verdicts with chant workspace records review ${id} --verdict agree --by <reviewer>, then set ${o.loaded.kind.stateField} to ${ratified} again`,
523
522
  );
524
523
  }
525
524
 
@@ -413,7 +413,8 @@
413
413
  "answer-points-unreadable",
414
414
  "answer-point-unknown",
415
415
  "answer-point-changed",
416
- "record-unattested"
416
+ "record-unattested",
417
+ "record-ratified-below-quorum"
417
418
  ]
418
419
  },
419
420
  "message": { "type": "string" }
@@ -147,8 +147,9 @@ export const SEAL_REASON_CODES = [
147
147
  export type SealCode = (typeof SEAL_REASON_CODES)[number];
148
148
 
149
149
  /**
150
- * A warning about a record's own seal (#2688), raised by `records` with the
151
- * policy at base, never by a write. Closed, like the reason codes.
150
+ * A warning `records` raises with the policy at base, never by a write: about
151
+ * a record's own seal (#2688), or a ratified record's quorum (#2878). Closed,
152
+ * like the reason codes.
152
153
  */
153
154
  export const SEAL_WARNING_CODES = [
154
155
  /**
@@ -158,6 +159,13 @@ export const SEAL_WARNING_CODES = [
158
159
  * fails. The record is still read, and still valid.
159
160
  */
160
161
  "record-unattested",
162
+ /**
163
+ * The record is in its kind's ratified state (`reviews.ratified`, #2873),
164
+ * and its quorum, computed as `records` computes it, is not met: it was
165
+ * written to that state by hand, past the check of `records new` and
166
+ * `amend`. The record is still read, and still valid (#2878).
167
+ */
168
+ "record-ratified-below-quorum",
161
169
  ] as const satisfies readonly ReasonCode[];
162
170
  export type SealWarningCode = (typeof SEAL_WARNING_CODES)[number];
163
171
 
@@ -201,7 +209,7 @@ export interface VerdictAttestation {
201
209
  }
202
210
 
203
211
  export interface RecordWarning {
204
- /** A work kind's records also carry the codes of `WORK_WARNING_CODES` (#2683), an answer kind's those of `ANSWER_WARNING_CODES` (#2739), and `records` adds `SEAL_WARNING_CODES` (#2688). */
212
+ /** A work kind's records also carry the codes of `WORK_WARNING_CODES` (#2683), an answer kind's those of `ANSWER_WARNING_CODES` (#2739), and `records` adds `SEAL_WARNING_CODES` (#2688, #2878). */
205
213
  code: RecordWarningCode | WorkWarningCode | SealWarningCode | AnswerWarningCode;
206
214
  message: string;
207
215
  }
@@ -618,12 +626,17 @@ export interface LoadedRecordKind {
618
626
  * Import the kind file at `path` through the path loader lexicons use (#2520).
619
627
  * The kind is registered under a name no lexicon package can have (npm names
620
628
  * hold no `:`), imported and unregistered again, so no lexicon lookup sees it.
629
+ *
630
+ * A kind file is a data-only ES module, so a `.mjs`, `.cjs` or `.js` one loads
631
+ * with plain node and only a TypeScript one registers tsx (#3276). That keeps
632
+ * `workspace ls`, `status` and `records` from paying for tsx's loader thread
633
+ * on every read.
621
634
  */
622
635
  export async function importKindModule(path: string): Promise<Record<string, unknown>> {
623
636
  const name = `record-kind:${path}`;
624
637
  registerLexiconDeclarations([{ name, module: path }], dirname(path));
625
638
  try {
626
- const mod = await importLexiconModule(name);
639
+ const mod = await importLexiconModule(name, { typeScript: !/\.[cm]?js$/.test(path) });
627
640
  if (!mod) throw new Error(`${path} was not registered`);
628
641
  return mod;
629
642
  } finally {
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { execFileSync } from "node:child_process";
11
+ import { join } from "node:path";
11
12
  import Ajv2020 from "ajv/dist/2020";
12
13
  import schema from "../records.schema.json";
13
14
  import { afterEach, describe, expect, test } from "vitest";
@@ -357,6 +358,110 @@ describe.skipIf(!hasSshKeygen)("the ssh-commit attestor, against the policy at b
357
358
  });
358
359
  });
359
360
 
361
+ describe.skipIf(!hasSshKeygen)("writeScope and agents in the declaration are protected (#3079)", () => {
362
+ const scoped = (over: Record<string, unknown> = {}) =>
363
+ JSON.stringify(
364
+ {
365
+ name: "ws",
366
+ schema: 1,
367
+ members: [{ name: "app", dir: "app", kind: "other", because: "plain files" }],
368
+ writeScope: { agent: { records: {} } },
369
+ agents: [{ name: "app-agent", member: "app" }],
370
+ ...over,
371
+ },
372
+ null,
373
+ 2,
374
+ );
375
+
376
+ /** main: alice and bob sign, alice is admin when `admin`; the declaration at base restricts agents. */
377
+ function baseline(label: string, opts: { admin?: boolean; declaration?: string; dir?: string } = {}) {
378
+ const r = repo(label);
379
+ const alice = r.key("alice");
380
+ const bob = r.key("bob");
381
+ r.write(".chant/allowed_signers", signers(["alice@example.test", alice], ["bob@example.test", bob]));
382
+ if (opts.admin) r.write(".chant/trust.json", JSON.stringify({ schema: 1, roles: { admin: ["alice@example.test"] } }));
383
+ r.commit("policy", alice);
384
+ r.write(`${opts.dir ? `${opts.dir}/` : ""}chant.workspace.json`, opts.declaration ?? scoped());
385
+ r.commit("declaration", alice);
386
+ r.git(["checkout", "-q", "-b", "change"]);
387
+ return { r, alice, bob };
388
+ }
389
+
390
+ test("an unsigned commit that widens writeScope is a refused protected write", async () => {
391
+ const { r } = baseline("scope-unsigned");
392
+ r.write("chant.workspace.json", scoped({ writeScope: { agent: { records: { decision: ["new", "amend"] } } } }));
393
+ const c = r.commit("let agents amend");
394
+ const report = await verify(r);
395
+ expect(report.ok).toBe(false);
396
+ expect(report.protectedWrites).toEqual([expect.objectContaining({ commit: c, paths: ["chant.workspace.json"], allowed: false })]);
397
+ expect(report.failures.join("\n")).toMatch(/chant\.workspace\.json \(writeScope\) is protected/);
398
+ });
399
+
400
+ test("a change to agents is a protected write, allowed when a signer at base signs it", async () => {
401
+ const { r, alice } = baseline("agents-signed");
402
+ r.write("chant.workspace.json", scoped({ agents: [{ name: "app-agent", member: "app" }, { name: "other", member: "app" }] }));
403
+ r.commit("add a session", alice);
404
+ const report = await verify(r);
405
+ expect(report.protectedWrites).toEqual([expect.objectContaining({ allowed: true, principal: "alice@example.test", blocks: ["agents"] })]);
406
+ expect(report.ok).toBe(true);
407
+ });
408
+
409
+ test("with an admin role granted, a signer who is not an admin may not change writeScope", async () => {
410
+ const { r, bob } = baseline("scope-admin", { admin: true });
411
+ r.write("chant.workspace.json", scoped({ writeScope: {} }));
412
+ r.commit("drop the scope", bob);
413
+ const report = await verify(r);
414
+ expect(report.protectedWrites[0]).toMatchObject({ allowed: false, principal: "bob@example.test", blocks: ["writeScope"] });
415
+ expect(report.ok).toBe(false);
416
+ });
417
+
418
+ test("deleting the declaration drops the scope, and is a protected write", async () => {
419
+ const { r } = baseline("scope-deleted");
420
+ r.git(["rm", "-q", "chant.workspace.json"]);
421
+ r.commit("no more workspace");
422
+ const report = await verify(r);
423
+ expect(report.protectedWrites[0]).toMatchObject({ allowed: false, blocks: ["writeScope", "agents"] });
424
+ expect(report.ok).toBe(false);
425
+ });
426
+
427
+ test("other edits to the declaration are not protected", async () => {
428
+ const { r } = baseline("scope-other");
429
+ r.write("chant.workspace.json", scoped({ name: "renamed" }));
430
+ r.commit("rename");
431
+ const report = await verify(r);
432
+ expect(report.protectedWrites).toEqual([]);
433
+ expect(report.declaration).toEqual({ path: "chant.workspace.json", blocks: ["writeScope", "agents"], changed: [] });
434
+ expect(report.ok).toBe(true);
435
+ });
436
+
437
+ test("a scope the change adds, where the declaration at base has none, is not protected", async () => {
438
+ const { r } = baseline("scope-none", { declaration: JSON.stringify({ name: "ws", schema: 1, members: [] }) });
439
+ r.write("chant.workspace.json", scoped());
440
+ r.commit("restrict agents");
441
+ const report = await verify(r);
442
+ expect(report.protectedWrites).toEqual([]);
443
+ expect(report.declaration).toBeNull();
444
+ });
445
+
446
+ test("a change and its revert inside the range leave nothing to protect", async () => {
447
+ const { r } = baseline("scope-revert");
448
+ r.write("chant.workspace.json", scoped({ writeScope: {} }));
449
+ r.commit("drop");
450
+ r.write("chant.workspace.json", scoped());
451
+ r.commit("restore");
452
+ const report = await verify(r);
453
+ expect(report.protectedWrites).toEqual([]);
454
+ });
455
+
456
+ test("the declaration is found from cwd, as check --changes finds it", async () => {
457
+ const { r } = baseline("scope-nested", { dir: "ws" });
458
+ r.write("ws/chant.workspace.json", scoped({ writeScope: {} }));
459
+ r.commit("drop");
460
+ const report = await verifyChange({ repo: r.dir, cwd: join(r.dir, "ws"), base: "main", attestors: await activeAttestors() });
461
+ expect(report.protectedWrites).toEqual([expect.objectContaining({ paths: ["ws/chant.workspace.json"], allowed: false })]);
462
+ });
463
+ });
464
+
360
465
  describe("attestation is opt-in", () => {
361
466
  test("without a signers file at base nothing is verified, and every record is unattested", async () => {
362
467
  const r = repo("off");
@@ -26,6 +26,7 @@ export async function runWorkspaceVerify(ctx: CommandContext): Promise<number> {
26
26
  }
27
27
  const report = verifyChange({
28
28
  repo,
29
+ cwd: process.cwd(),
29
30
  base: args.base,
30
31
  head: args.head,
31
32
  require: args.require === "attested" || args.require === "attested-runs" ? args.require : undefined,
@@ -49,7 +50,7 @@ function printReport(r: ChangeReport): void {
49
50
  lines.push(` ${c.commit.slice(0, 8)} ${c.level}${who} ${c.subject}${note}`);
50
51
  }
51
52
  for (const c of r.runs.commits) lines.push(` ${c.commit.slice(0, 8)} agent run ${c.runs.join(", ") || c.run}: ${c.attested ? "attested" : "not attested"}, ${c.reason}`);
52
- for (const w of r.protectedWrites) lines.push(` protected write ${w.commit.slice(0, 8)} ${w.paths.join(", ")}: ${w.allowed ? "allowed" : "refused"}, ${w.reason}`);
53
+ for (const w of r.protectedWrites) lines.push(` protected write ${w.commit.slice(0, 8)} ${w.paths.join(", ")}${w.blocks ? ` (${w.blocks.join(", ")})` : ""}: ${w.allowed ? "allowed" : "refused"}, ${w.reason}`);
53
54
  if (r.rotation && "to" in r.rotation) lines.push(` signer set: version ${r.rotation.from} to ${r.rotation.to}, signed by ${r.rotation.signedBy.join(", ")}`);
54
55
  for (const n of r.notes) lines.push(n);
55
56
  console.log(lines.join("\n"));
@@ -8,6 +8,12 @@
8
8
  * that touches those files must be attested by a signer the base policy
9
9
  * trusts, and by an admin when the base policy grants the admin role.
10
10
  *
11
+ * The write scope is policy too (#3079): when the workspace declaration at
12
+ * base has a `writeScope` or `agents` block, a commit that changes either
13
+ * block, or removes the declaration, is a protected write on the same terms.
14
+ * Other edits to the declaration are not. The declaration is found from
15
+ * `cwd`, as `chant workspace check --changes` finds it.
16
+ *
11
17
  * Attestation is opt-in (#2525 rule 6). Without a signers file at base the
12
18
  * check passes and says so, unless `--require attested` asks for more.
13
19
  *
@@ -22,6 +28,11 @@
22
28
  */
23
29
 
24
30
  import { execFileSync } from "node:child_process";
31
+ import { posix } from "node:path";
32
+ import { isDeepStrictEqual } from "node:util";
33
+ import { DECLARATION_FILES, WorkspaceReadError } from "../declaration";
34
+ import { parseJsonText } from "../jsonc";
35
+ import { locateWorkspace } from "../which-chant";
25
36
  import { type CommitAttestor, type ProvenanceLevel } from "./attestor";
26
37
  import { policyAtBase, commitProvenance, resolveBase, type BaseSource, type RecordProvenance } from "./provenance";
27
38
  import { policyWriters, protectedPaths, type ExcludedSigner, type TrustPolicy } from "./policy";
@@ -41,6 +52,8 @@ export interface CommitVerdict extends RecordProvenance {
41
52
  export interface ProtectedWrite {
42
53
  commit: string;
43
54
  paths: string[];
55
+ /** For a declaration write (#3079): the scope blocks the commit changes. */
56
+ blocks?: ScopeBlock[];
44
57
  level: ProvenanceLevel;
45
58
  principal?: string;
46
59
  allowed: boolean;
@@ -67,6 +80,12 @@ export interface ChangeReport {
67
80
  /** Protected paths whose content differs between the merge base and head. */
68
81
  protectedChanged: string[];
69
82
  protectedWrites: ProtectedWrite[];
83
+ /**
84
+ * The workspace declaration at base, when it has a scope block (#3079): its
85
+ * path from the repository root, the blocks it has, and the blocks that
86
+ * differ between the merge base and head.
87
+ */
88
+ declaration: { path: string; blocks: ScopeBlock[]; changed: ScopeBlock[] } | null;
70
89
  /**
71
90
  * When the change edits the signer set (#2553): the version it proposes and
72
91
  * who signed it, or, when the new set is refused, the version it had to
@@ -80,6 +99,8 @@ export interface ChangeReport {
80
99
 
81
100
  export interface VerifyOptions {
82
101
  repo: string;
102
+ /** Where to look for the workspace declaration, as `check --changes` does; the repository root by default. */
103
+ cwd?: string;
83
104
  base?: string;
84
105
  head?: string;
85
106
  require?: VerifyRequirement;
@@ -106,6 +127,51 @@ export function commitsTouching(repo: string, range: string, paths: string[]): M
106
127
  return out;
107
128
  }
108
129
 
130
+ /** The declaration blocks whose change is a protected write (#3079). */
131
+ export type ScopeBlock = "writeScope" | "agents";
132
+ const SCOPE_BLOCKS: readonly ScopeBlock[] = ["writeScope", "agents"];
133
+
134
+ /** The scope blocks of the declaration in `dir` at `rev`: absent ones undefined, or the text when it can't be read. */
135
+ type ScopeState = { blocks: Partial<Record<ScopeBlock, unknown>> } | { unreadable: string };
136
+
137
+ function scopeState(repo: string, rev: string, dir: string): ScopeState {
138
+ const present = DECLARATION_FILES.map((name) => ({ name, text: fileAt(repo, rev, posix.join(dir, name)) })).filter((f) => f.text !== undefined);
139
+ if (present.length === 0) return { blocks: {} };
140
+ if (present.length > 1) return { unreadable: present.map((f) => f.text).join("\0") };
141
+ const parsed = parseJsonText(present[0].text!, { jsonc: present[0].name.endsWith(".jsonc") });
142
+ if (!parsed.ok || typeof parsed.value !== "object" || parsed.value === null || Array.isArray(parsed.value)) return { unreadable: present[0].text! };
143
+ const obj = parsed.value as Record<string, unknown>;
144
+ return { blocks: Object.fromEntries(SCOPE_BLOCKS.filter((b) => obj[b] !== undefined).map((b) => [b, obj[b]])) };
145
+ }
146
+
147
+ /** The scope blocks that differ between two states; all of them when either can't be read and the texts differ. */
148
+ function changedBlocks(a: ScopeState, b: ScopeState): ScopeBlock[] {
149
+ if ("unreadable" in a || "unreadable" in b) {
150
+ return "unreadable" in a && "unreadable" in b && a.unreadable === b.unreadable ? [] : [...SCOPE_BLOCKS];
151
+ }
152
+ return SCOPE_BLOCKS.filter((k) => !isDeepStrictEqual(a.blocks[k], b.blocks[k]));
153
+ }
154
+
155
+ /**
156
+ * The directory, from the repository root, of the declaration found from
157
+ * `cwd` at `base`, with the scope blocks it has there. Null when there is no
158
+ * declaration, or one with neither block: then nothing in it is protected. A
159
+ * declaration that can't be read at base counts as having both.
160
+ */
161
+ function scopedDeclarationAt(repo: string, cwd: string, base: string): { dir: string; blocks: ScopeBlock[] } | null {
162
+ let dir: string;
163
+ try {
164
+ const root = locateWorkspace(cwd, base).root;
165
+ dir = root === "." ? "" : root;
166
+ } catch (err) {
167
+ if (err instanceof WorkspaceReadError) return null;
168
+ throw err;
169
+ }
170
+ const state = scopeState(repo, base, dir);
171
+ const blocks = "unreadable" in state ? [...SCOPE_BLOCKS] : SCOPE_BLOCKS.filter((b) => b in state.blocks);
172
+ return blocks.length > 0 ? { dir, blocks } : null;
173
+ }
174
+
109
175
  /** Whether a merge commit changes nothing beyond what its parents bring. */
110
176
  function mergeWithoutOwnChanges(repo: string, commit: string, parents: number): boolean {
111
177
  if (parents < 2) return false;
@@ -134,6 +200,7 @@ export function verifyChange(opts: VerifyOptions): ChangeReport {
134
200
  protectedPaths: protectedPaths(policy),
135
201
  protectedChanged: [],
136
202
  protectedWrites: [],
203
+ declaration: null,
137
204
  rotation: null,
138
205
  notes: [],
139
206
  failures: [],
@@ -157,7 +224,8 @@ export function verifyChange(opts: VerifyOptions): ChangeReport {
157
224
 
158
225
  // Every commit in the change, oldest first, with its parents.
159
226
  const listing = git(repo, ["rev-list", "--reverse", "--parents", `${base.commit}..${head}`]).split("\n").filter(Boolean);
160
- const parentsOf = new Map(listing.map((l) => { const [c, ...p] = l.split(" "); return [c, p.length] as const; }));
227
+ const parentList = new Map(listing.map((l) => { const [c, ...p] = l.split(" "); return [c, p] as const; }));
228
+ const parentsOf = new Map([...parentList].map(([c, p]) => [c, p.length] as const));
161
229
  report.runs = changeRuns(repo, [...parentsOf.keys()], policy.runners);
162
230
  if (opts.require === "attested-runs") {
163
231
  if (policy.runners.length === 0 && report.runs.commits.length > 0) {
@@ -204,23 +272,48 @@ export function verifyChange(opts: VerifyOptions): ChangeReport {
204
272
  }
205
273
 
206
274
  // Protected writes: every commit that touches the policy needs a writer's signature.
275
+ const writers = policyWriters(policy);
276
+ const judge = (commit: string, paths: string[], blocks?: ScopeBlock[]) => {
277
+ const v = verdicts.get(commit) ?? { ...commitProvenance(repo, policy, commit, opts.attestors), commit, subject: "" };
278
+ const allowed = v.level === "attested" && v.principal !== undefined && writers.has(v.principal);
279
+ const who = policy.roles.admin?.length ? "an admin the policy at base names" : "a signer the policy at base lists";
280
+ const what = blocks ? `${paths.join(", ")} (${blocks.join(", ")})` : paths.join(", ");
281
+ const reason = allowed
282
+ ? `signed by ${v.principal}, ${who.replace(/^an? /, "")}`
283
+ : v.level === "attested"
284
+ ? `signed by ${v.principal}, who is not ${who}`
285
+ : `${what} is protected and needs a signature by ${who}: ${v.reason}`;
286
+ report.protectedWrites.push({ commit, paths, ...(blocks ? { blocks } : {}), level: v.level, ...(v.principal ? { principal: v.principal } : {}), allowed, reason });
287
+ if (!allowed) report.failures.push(`${commit.slice(0, 8)} changes ${what}: ${reason}`);
288
+ };
207
289
  if (changedPaths.length > 0) {
208
- const writers = policyWriters(policy);
209
290
  const touching = commitsTouching(repo, `${base.commit}..${head}`, report.protectedPaths);
210
291
  if (touching.size === 0) {
211
292
  report.failures.push(`the policy files ${changedPaths.join(", ")} differ from base, and no commit in the change accounts for it`);
212
293
  }
213
- for (const [commit, paths] of touching) {
214
- const v = verdicts.get(commit) ?? { ...commitProvenance(repo, policy, commit, opts.attestors), commit, subject: "" };
215
- const allowed = v.level === "attested" && v.principal !== undefined && writers.has(v.principal);
216
- const who = policy.roles.admin?.length ? "an admin the policy at base names" : "a signer the policy at base lists";
217
- const reason = allowed
218
- ? `signed by ${v.principal}, ${who.replace(/^an? /, "")}`
219
- : v.level === "attested"
220
- ? `signed by ${v.principal}, who is not ${who}`
221
- : `${paths.join(", ")} is protected and needs a signature by ${who}: ${v.reason}`;
222
- report.protectedWrites.push({ commit, paths, level: v.level, ...(v.principal ? { principal: v.principal } : {}), allowed, reason });
223
- if (!allowed) report.failures.push(`${commit.slice(0, 8)} changes ${paths.join(", ")}: ${reason}`);
294
+ for (const [commit, paths] of touching) judge(commit, paths);
295
+ }
296
+
297
+ // The write scope in the declaration at base (#3079): a commit that changes writeScope or agents.
298
+ const scoped = scopedDeclarationAt(repo, opts.cwd ?? repo, base.commit);
299
+ if (scoped) {
300
+ const files = DECLARATION_FILES.map((name) => posix.join(scoped.dir, name));
301
+ const changed = changedBlocks(scopeState(repo, mergeBase, scoped.dir), scopeState(repo, head, scoped.dir));
302
+ report.declaration = { path: files.find((f) => fileAt(repo, base.commit!, f) !== undefined) ?? files[0], blocks: scoped.blocks, changed };
303
+ if (changed.length > 0) {
304
+ let accounted = false;
305
+ for (const [commit, paths] of commitsTouching(repo, `${base.commit}..${head}`, files)) {
306
+ const after = scopeState(repo, commit, scoped.dir);
307
+ // A merge changes a block only when it differs from every parent.
308
+ const per = (parentList.get(commit) ?? []).map((p) => new Set(changedBlocks(scopeState(repo, p, scoped.dir), after)));
309
+ const blocks = SCOPE_BLOCKS.filter((b) => per.length > 0 && per.every((s) => s.has(b)));
310
+ if (blocks.length === 0) continue;
311
+ accounted = true;
312
+ judge(commit, paths, blocks);
313
+ }
314
+ if (!accounted) {
315
+ report.failures.push(`${changed.join(" and ")} in ${report.declaration.path} differ from base, and no commit in the change accounts for it`);
316
+ }
224
317
  }
225
318
  }
226
319