@intentius/chant 0.100.0 → 0.101.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build.d.ts +6 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/build-options.d.ts +2 -0
- package/dist/cli/build-options.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/import.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.d.ts +8 -0
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +22 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/config.d.ts +11 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/lexicon.d.ts +24 -1
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/plan-digest.d.ts +26 -5
- package/dist/lifecycle/plan-digest.d.ts.map +1 -1
- package/dist/lint/config.d.ts +4 -4
- package/dist/op/activities/activity-contracts.d.ts +1 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/propose-upgrade.d.ts +2 -0
- package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
- package/dist/op/index.d.ts +1 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/serializer.d.ts +8 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/telemetry-attribution.d.ts +77 -0
- package/dist/telemetry-attribution.d.ts.map +1 -0
- package/dist/workspace/agent-cli.d.ts +83 -0
- package/dist/workspace/agent-cli.d.ts.map +1 -0
- package/dist/workspace/changes-cli.d.ts.map +1 -1
- package/dist/workspace/changes.d.ts +8 -1
- package/dist/workspace/changes.d.ts.map +1 -1
- package/dist/workspace/checks/links.d.ts +1 -0
- package/dist/workspace/checks/links.d.ts.map +1 -1
- package/dist/workspace/checks/live.d.ts +40 -0
- package/dist/workspace/checks/live.d.ts.map +1 -0
- package/dist/workspace/checks.d.ts +21 -2
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/compose-graph.d.ts +63 -0
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/decide.d.ts +1 -1
- package/dist/workspace/decide.d.ts.map +1 -1
- package/dist/workspace/declaration.d.ts +32 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +138 -3
- package/dist/workspace/export-cli.d.ts +12 -0
- package/dist/workspace/export-cli.d.ts.map +1 -0
- package/dist/workspace/export.d.ts +145 -0
- package/dist/workspace/export.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/import.d.ts +73 -0
- package/dist/workspace/import.d.ts.map +1 -0
- package/dist/workspace/kinds.d.ts +6 -2
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/lineage-adopt-cli.d.ts +15 -0
- package/dist/workspace/lineage-adopt-cli.d.ts.map +1 -0
- package/dist/workspace/lineage-adopt.d.ts +106 -0
- package/dist/workspace/lineage-adopt.d.ts.map +1 -0
- package/dist/workspace/lineage-check.d.ts +9 -2
- package/dist/workspace/lineage-check.d.ts.map +1 -1
- package/dist/workspace/lineage-cli.d.ts +6 -1
- package/dist/workspace/lineage-cli.d.ts.map +1 -1
- package/dist/workspace/lineage-hash-index.d.ts +110 -0
- package/dist/workspace/lineage-hash-index.d.ts.map +1 -0
- package/dist/workspace/lineage-init.d.ts +10 -0
- package/dist/workspace/lineage-init.d.ts.map +1 -1
- package/dist/workspace/lineage-lock.d.ts +147 -0
- package/dist/workspace/lineage-lock.d.ts.map +1 -1
- package/dist/workspace/lineage-migrations.d.ts +15 -3
- package/dist/workspace/lineage-migrations.d.ts.map +1 -1
- package/dist/workspace/lineage-provenance.d.ts +18 -0
- package/dist/workspace/lineage-provenance.d.ts.map +1 -0
- package/dist/workspace/lineage-upgrade-cli.d.ts +2 -0
- package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.d.ts +30 -1
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-versions.d.ts +116 -0
- package/dist/workspace/lineage-versions.d.ts.map +1 -0
- package/dist/workspace/links.d.ts +31 -5
- package/dist/workspace/links.d.ts.map +1 -1
- package/dist/workspace/ls-generated.d.ts +37 -0
- package/dist/workspace/ls-generated.d.ts.map +1 -0
- package/dist/workspace/ls.d.ts +4 -0
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/nested-graph.d.ts +56 -0
- package/dist/workspace/nested-graph.d.ts.map +1 -0
- package/dist/workspace/nesting.d.ts +21 -0
- package/dist/workspace/nesting.d.ts.map +1 -0
- package/dist/workspace/pin-cli.d.ts +10 -0
- package/dist/workspace/pin-cli.d.ts.map +1 -0
- package/dist/workspace/pin-integrity.d.ts +51 -0
- package/dist/workspace/pin-integrity.d.ts.map +1 -0
- package/dist/workspace/reason-codes.d.ts +26 -2
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-sessions.d.ts +7 -11
- package/dist/workspace/record-sessions.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +30 -1
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-close.d.ts +5 -2
- package/dist/workspace/records-close.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +22 -4
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +43 -5
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/returns.d.ts +129 -0
- package/dist/workspace/returns.d.ts.map +1 -0
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/template-manifest.d.ts +11 -3
- package/dist/workspace/template-manifest.d.ts.map +1 -1
- package/dist/workspace/trust/attestor.d.ts +8 -0
- package/dist/workspace/trust/attestor.d.ts.map +1 -1
- package/dist/workspace/trust/dsse.d.ts +58 -0
- package/dist/workspace/trust/dsse.d.ts.map +1 -0
- package/dist/workspace/trust/evidence-cli.d.ts +66 -0
- package/dist/workspace/trust/evidence-cli.d.ts.map +1 -0
- package/dist/workspace/trust/evidence.d.ts +93 -0
- package/dist/workspace/trust/evidence.d.ts.map +1 -0
- package/dist/workspace/trust/policy.d.ts +54 -1
- package/dist/workspace/trust/policy.d.ts.map +1 -1
- package/dist/workspace/trust/provenance.d.ts +21 -1
- package/dist/workspace/trust/provenance.d.ts.map +1 -1
- package/dist/workspace/trust/rotation.d.ts +132 -0
- package/dist/workspace/trust/rotation.d.ts.map +1 -0
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/signers-cli.d.ts +49 -0
- package/dist/workspace/trust/signers-cli.d.ts.map +1 -0
- package/dist/workspace/trust/ssh-commit.d.ts +12 -0
- package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
- package/dist/workspace/trust/test-repo.d.ts +13 -0
- package/dist/workspace/trust/test-repo.d.ts.map +1 -1
- package/dist/workspace/trust/verify.d.ts +15 -0
- package/dist/workspace/trust/verify.d.ts.map +1 -1
- package/dist/workspace/work-evidence.d.ts +1 -1
- package/dist/workspace/work-evidence.d.ts.map +1 -1
- package/dist/workspace/write-scope.d.ts +199 -0
- package/dist/workspace/write-scope.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/build.ts +8 -0
- package/src/cli/build-options.ts +4 -1
- package/src/cli/commands/build.ts +2 -0
- package/src/cli/commands/import-live.test.ts +69 -1
- package/src/cli/commands/import.ts +48 -22
- package/src/cli/handlers/fan-out.test.ts +6 -6
- package/src/cli/handlers/fan-out.ts +2 -1
- package/src/cli/handlers/graph.test.ts +42 -0
- package/src/cli/handlers/graph.ts +22 -0
- package/src/cli/handlers/operator.ts +1 -1
- package/src/cli/main.test.ts +32 -0
- package/src/cli/main.ts +103 -6
- package/src/cli/mcp/workspace-tools.test.ts +1 -1
- package/src/cli/mcp/workspace-tools.ts +33 -2
- package/src/cli/registry.ts +22 -0
- package/src/cli/serve-mcp-workspace.test.ts +1 -1
- package/src/codegen/release-wiring.test.ts +5 -1
- package/src/components/fan-out-output.test.ts +1 -1
- package/src/components/fan-out.test.ts +1 -1
- package/src/components/promote.test.ts +1 -1
- package/src/config.ts +12 -0
- package/src/content-digest.test.ts +2 -2
- package/src/lexicon.ts +25 -1
- package/src/lifecycle/gate-ledger.test.ts +14 -0
- package/src/lifecycle/gate-ledger.ts +2 -1
- package/src/lifecycle/plan-digest.test.ts +54 -3
- package/src/lifecycle/plan-digest.ts +38 -8
- package/src/op/activities/activity-contracts.ts +1 -0
- package/src/op/activities/propose-upgrade.ts +8 -5
- package/src/op/gate-approval.test.ts +17 -0
- package/src/op/gate.ts +3 -3
- package/src/op/index.ts +1 -1
- package/src/serializer.ts +9 -0
- package/src/telemetry-attribution.test.ts +91 -0
- package/src/telemetry-attribution.ts +145 -0
- package/src/workspace/agent-cli.ts +134 -0
- package/src/workspace/agent.schema.json +356 -0
- package/src/workspace/behold-kinds.test.ts +1 -1
- package/src/workspace/changes-cli.ts +5 -0
- package/src/workspace/changes.schema.json +179 -1
- package/src/workspace/changes.ts +58 -4
- package/src/workspace/check-live.test.ts +192 -0
- package/src/workspace/check.schema.json +64 -0
- package/src/workspace/checks/links.ts +23 -2
- package/src/workspace/checks/live.ts +113 -0
- package/src/workspace/checks.test.ts +2 -2
- package/src/workspace/checks.ts +20 -3
- package/src/workspace/compose-graph.test.ts +47 -0
- package/src/workspace/compose-graph.ts +120 -3
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +7 -1
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +5 -0
- package/src/workspace/declaration.schema.json +138 -3
- package/src/workspace/declaration.ts +106 -1
- package/src/workspace/declared-kinds.test.ts +33 -0
- package/src/workspace/evidence.schema.json +279 -0
- package/src/workspace/export-cli.ts +180 -0
- package/src/workspace/export-import.test.ts +282 -0
- package/src/workspace/export.ts +486 -0
- package/src/workspace/graph-cli.ts +47 -1
- package/src/workspace/graph-contract.test.ts +89 -4
- package/src/workspace/graph.schema.json +206 -1
- package/src/workspace/import.ts +325 -0
- package/src/workspace/kinds.test.ts +5 -5
- package/src/workspace/kinds.ts +25 -3
- package/src/workspace/lineage-adopt-cli.ts +103 -0
- package/src/workspace/lineage-adopt.test.ts +552 -0
- package/src/workspace/lineage-adopt.ts +452 -0
- package/src/workspace/lineage-check.ts +26 -5
- package/src/workspace/lineage-cli.ts +12 -2
- package/src/workspace/lineage-hash-index.ts +305 -0
- package/src/workspace/lineage-init.test.ts +9 -0
- package/src/workspace/lineage-init.ts +29 -9
- package/src/workspace/lineage-lock.ts +54 -0
- package/src/workspace/lineage-migrations.test.ts +27 -0
- package/src/workspace/lineage-migrations.ts +44 -14
- package/src/workspace/lineage-provenance.ts +40 -0
- package/src/workspace/lineage-upgrade-cli.ts +9 -5
- package/src/workspace/lineage-upgrade.test.ts +92 -1
- package/src/workspace/lineage-upgrade.ts +111 -16
- package/src/workspace/lineage-versions.ts +348 -0
- package/src/workspace/links.test.ts +121 -2
- package/src/workspace/links.ts +97 -6
- package/src/workspace/ls-contract.test.ts +84 -1
- package/src/workspace/ls-generated.ts +111 -0
- package/src/workspace/ls.schema.json +19 -0
- package/src/workspace/ls.ts +8 -1
- package/src/workspace/member-commands.ts +3 -1
- package/src/workspace/nested-graph.test.ts +176 -0
- package/src/workspace/nested-graph.ts +169 -0
- package/src/workspace/nesting.ts +37 -0
- package/src/workspace/pin-cli.test.ts +71 -0
- package/src/workspace/pin-cli.ts +57 -0
- package/src/workspace/pin-integrity.test.ts +121 -0
- package/src/workspace/pin-integrity.ts +104 -0
- package/src/workspace/points-write.schema.json +1 -0
- package/src/workspace/read-contract.test.ts +24 -0
- package/src/workspace/reason-codes.test.ts +10 -0
- package/src/workspace/reason-codes.ts +29 -2
- package/src/workspace/record-sessions.ts +12 -14
- package/src/workspace/records-amend.schema.json +4 -0
- package/src/workspace/records-cli.ts +93 -12
- package/src/workspace/records-close.schema.json +6 -2
- package/src/workspace/records-close.ts +10 -2
- package/src/workspace/records-formats.test.ts +13 -5
- package/src/workspace/records-new.schema.json +4 -0
- package/src/workspace/records-review.schema.json +4 -0
- package/src/workspace/records-sessions.test.ts +23 -6
- package/src/workspace/records-write.test.ts +65 -8
- package/src/workspace/records-write.ts +70 -6
- package/src/workspace/records.schema.json +55 -3
- package/src/workspace/records.ts +96 -5
- package/src/workspace/returns.ts +328 -0
- package/src/workspace/signers.schema.json +206 -0
- package/src/workspace/status-gates.ts +2 -1
- package/src/workspace/template-manifest.ts +22 -4
- package/src/workspace/trust/attestor.ts +15 -0
- package/src/workspace/trust/dsse.ts +134 -0
- package/src/workspace/trust/evidence-cli.ts +195 -0
- package/src/workspace/trust/evidence.test.ts +241 -0
- package/src/workspace/trust/evidence.ts +207 -0
- package/src/workspace/trust/policy.ts +110 -3
- package/src/workspace/trust/provenance.ts +41 -4
- package/src/workspace/trust/record-seal.test.ts +1 -1
- package/src/workspace/trust/rotation.test.ts +258 -0
- package/src/workspace/trust/rotation.ts +336 -0
- package/src/workspace/trust/seal.ts +11 -0
- package/src/workspace/trust/signers-cli.ts +178 -0
- package/src/workspace/trust/ssh-commit.ts +53 -4
- package/src/workspace/trust/test-repo.ts +18 -0
- package/src/workspace/trust/trust.test.ts +8 -2
- package/src/workspace/trust/verify-cli.ts +1 -0
- package/src/workspace/trust/verify.ts +22 -0
- package/src/workspace/work-evidence.schema.json +4 -0
- package/src/workspace/write-scope.test.ts +340 -0
- package/src/workspace/write-scope.ts +448 -0
|
@@ -50,9 +50,9 @@ import {
|
|
|
50
50
|
} from "./records";
|
|
51
51
|
import { gitTree, workingTree, type WorkspaceTree } from "./tree";
|
|
52
52
|
import type { DecisionWork } from "./work";
|
|
53
|
-
import { activeAttestors, type ProvenanceLevel } from "./trust/attestor";
|
|
53
|
+
import { activeAttestors, type CommitAttestor, type ProvenanceLevel } from "./trust/attestor";
|
|
54
54
|
import { policyAtBase, recordProvenance, resolveBase, type BaseSource, type RecordProvenance } from "./trust/provenance";
|
|
55
|
-
import type
|
|
55
|
+
import { returnedPolicy, type TrustPolicy } from "./trust/policy";
|
|
56
56
|
import type { SealedRecord } from "./trust/seal";
|
|
57
57
|
|
|
58
58
|
/** The version of the `records` output this chant writes. */
|
|
@@ -129,7 +129,7 @@ export type RecordsDocument =
|
|
|
129
129
|
| {
|
|
130
130
|
$schema: string;
|
|
131
131
|
contract: number;
|
|
132
|
-
kind: { name: string; schema: string; file: string; format: RecordFormat };
|
|
132
|
+
kind: { name: string; schema: string; file: string; format: RecordFormat; spec: boolean };
|
|
133
133
|
at: string | null;
|
|
134
134
|
/** The directory pinned paths resolve in, from the repository root: the workspace holding the kind file, or the repository root (#2549). */
|
|
135
135
|
workspaceRoot: string;
|
|
@@ -161,6 +161,47 @@ export interface RecordsSetDocument {
|
|
|
161
161
|
$schema: string;
|
|
162
162
|
contract: number;
|
|
163
163
|
kinds: (RecordsDocument & { declared: DeclaredKindView })[];
|
|
164
|
+
/** With `--current`: the workspace's spec (#2524 D20, #2546), from the kinds marked `spec: true`. */
|
|
165
|
+
spec?: SpecView;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The spec (#2524 D20): the current records of every declared kind marked
|
|
170
|
+
* `spec: true`, each with the workspace files it pins. `records --current
|
|
171
|
+
* --json` prints it beside the kinds, so an agent resumes from the
|
|
172
|
+
* declaration and this one read.
|
|
173
|
+
*/
|
|
174
|
+
export interface SpecView {
|
|
175
|
+
/** The spec kinds, as the declaration names them, in its order. */
|
|
176
|
+
kinds: DeclaredKindView[];
|
|
177
|
+
records: SpecRecord[];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** One current record of a spec kind. */
|
|
181
|
+
export interface SpecRecord {
|
|
182
|
+
/** The kind's name, such as decision. */
|
|
183
|
+
kind: string;
|
|
184
|
+
id: string | null;
|
|
185
|
+
path: string;
|
|
186
|
+
state: string | null;
|
|
187
|
+
valid: boolean;
|
|
188
|
+
/** The digest a verdict names (#2672). */
|
|
189
|
+
digest: string;
|
|
190
|
+
/** Each workspace file the record pins: its path from the workspace root, the sha256 pinned, and how it compares with the tree read (#2549). */
|
|
191
|
+
assets: RecordEntry["assets"];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The spec of a set of declared kinds already read with `--current`. */
|
|
195
|
+
export function specOf(kinds: RecordsSetDocument["kinds"]): SpecView {
|
|
196
|
+
const spec: SpecView = { kinds: [], records: [] };
|
|
197
|
+
for (const doc of kinds) {
|
|
198
|
+
if ("error" in doc || !doc.kind.spec) continue;
|
|
199
|
+
spec.kinds.push(doc.declared);
|
|
200
|
+
for (const r of doc.records) {
|
|
201
|
+
spec.records.push({ kind: doc.kind.name, id: r.id, path: r.path, state: r.state, valid: r.valid, digest: r.digest, assets: r.assets });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return spec;
|
|
164
205
|
}
|
|
165
206
|
|
|
166
207
|
/**
|
|
@@ -186,7 +227,7 @@ export async function queryDeclaredRecords(kinds: { declared: RecordKindDeclarat
|
|
|
186
227
|
const doc = await queryRecords({ ...query, kind: k.file });
|
|
187
228
|
out.push({ ...doc, declared: { member: k.declared.member, path: k.declared.path, name: k.declared.name } });
|
|
188
229
|
}
|
|
189
|
-
return { $schema: RECORDS_OUTPUT_SCHEMA_ID, contract: RECORDS_CONTRACT_VERSION, kinds: out };
|
|
230
|
+
return { $schema: RECORDS_OUTPUT_SCHEMA_ID, contract: RECORDS_CONTRACT_VERSION, kinds: out, ...(query.current ? { spec: specOf(out) } : {}) };
|
|
190
231
|
}
|
|
191
232
|
|
|
192
233
|
/** A records read, before provenance. */
|
|
@@ -320,21 +361,30 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
|
|
|
320
361
|
// Provenance, judged by the policy at base and never by the tree read (#2547).
|
|
321
362
|
const base = top ? resolveBase(top, query.base) : { commit: null, from: null };
|
|
322
363
|
const policy = top ? policyAtBase(top, base) : policyAtBase(root, base);
|
|
364
|
+
const attestors = policy.active ? await activeAttestors() : [];
|
|
323
365
|
const provenance = recordProvenance({
|
|
324
366
|
repo: top,
|
|
325
367
|
policy,
|
|
326
368
|
at,
|
|
327
369
|
paths: result.records.map((r) => r.path),
|
|
328
|
-
attestors
|
|
370
|
+
attestors,
|
|
371
|
+
...(policy.active ? { revisit: await returnedRevisit(tree, workspaceRoot, policy, attestors) } : {}),
|
|
329
372
|
});
|
|
330
373
|
const seals = loaded.kind.reviews ? await import("./trust/seal") : undefined;
|
|
331
374
|
const quorumOptions = await quorumOptionsFor(loaded, tree, policy);
|
|
332
|
-
const records: RecordView[] =
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
375
|
+
const records: RecordView[] = [];
|
|
376
|
+
for (const r of result.records) {
|
|
377
|
+
const p = provenance.get(r.path)!;
|
|
378
|
+
// Returned work (#2552): its seals verify against the signers admitted for its return too.
|
|
379
|
+
const recordPolicy = p.returned ? returnedPolicy(policy, p.returned.id) : policy;
|
|
380
|
+
const options = p.returned ? await quorumOptionsFor(loaded, tree, recordPolicy) : quorumOptions;
|
|
381
|
+
records.push({
|
|
382
|
+
...r,
|
|
383
|
+
provenance: p,
|
|
384
|
+
...(options ? { quorum: computeQuorum(loaded.kind, r, options) } : {}),
|
|
385
|
+
...(seals ? authorSeal(loaded.kind, r, recordPolicy, seals.checkRecordSeal) : {}),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
338
388
|
if (loaded.kind.approval && !loaded.kind.work && top) {
|
|
339
389
|
const decided = decidedCommits(top, at ?? "HEAD", records.map((r) => r.path), loaded.kind);
|
|
340
390
|
for (const r of records) r.decidedIn = decided.get(r.path) ?? null;
|
|
@@ -353,6 +403,7 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
|
|
|
353
403
|
schema: loaded.kind.schema.id,
|
|
354
404
|
file: relative(root, loaded.file).split("\\").join("/"),
|
|
355
405
|
format: loaded.kind.format,
|
|
406
|
+
spec: loaded.kind.spec === true,
|
|
356
407
|
},
|
|
357
408
|
at,
|
|
358
409
|
workspaceRoot,
|
|
@@ -368,6 +419,36 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
|
|
|
368
419
|
}
|
|
369
420
|
}
|
|
370
421
|
|
|
422
|
+
/**
|
|
423
|
+
* The second look recordProvenance takes at each record (#2552): a record
|
|
424
|
+
* whose bytes came back in a return is judged by the commit it was made in,
|
|
425
|
+
* which the return carries, rather than by the import here. `tree` is the
|
|
426
|
+
* workspace root's tree, as read; record paths are from the repository root.
|
|
427
|
+
*/
|
|
428
|
+
async function returnedRevisit(
|
|
429
|
+
tree: WorkspaceTree,
|
|
430
|
+
workspaceRoot: string,
|
|
431
|
+
policy: TrustPolicy,
|
|
432
|
+
attestors: readonly CommitAttestor[],
|
|
433
|
+
): Promise<((path: string, host: RecordProvenance) => RecordProvenance | undefined) | undefined> {
|
|
434
|
+
const { readReturns, returnedProvenance } = await import("./returns");
|
|
435
|
+
const { returns } = readReturns(tree);
|
|
436
|
+
if (returns.length === 0) return undefined;
|
|
437
|
+
const prefix = workspaceRoot === "." ? "" : `${workspaceRoot}/`;
|
|
438
|
+
return (path, host) => {
|
|
439
|
+
if (!path.startsWith(prefix)) return undefined;
|
|
440
|
+
const rel = path.slice(prefix.length);
|
|
441
|
+
const holding = returns.filter((r) => r.paths[rel] !== undefined);
|
|
442
|
+
if (holding.length === 0 || tree.stat(rel) !== "file") return undefined;
|
|
443
|
+
const content = Buffer.from(tree.bytes ? tree.bytes(rel) : tree.read(rel));
|
|
444
|
+
for (const r of holding) {
|
|
445
|
+
const p = returnedProvenance(policy, attestors, r, rel, content, host);
|
|
446
|
+
if (p) return p;
|
|
447
|
+
}
|
|
448
|
+
return undefined;
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
371
452
|
/**
|
|
372
453
|
* A record's author seal checked against the policy at base (#2688), for a
|
|
373
454
|
* kind with a reviews list: its author is the kind's `reviews.decider` field.
|
|
@@ -676,7 +757,7 @@ export async function runWorkspaceUnknown(ctx: CommandContext): Promise<number>
|
|
|
676
757
|
console.error(
|
|
677
758
|
formatError({
|
|
678
759
|
message: sub ? `Unknown workspace subcommand: ${sub}` : "chant workspace needs a subcommand",
|
|
679
|
-
hint: `Workspace subcommands: audit, build, check, graph, init, lineage, lint, ls, records, status, upgrade, verify, work. Run "chant --help" for their options.`,
|
|
760
|
+
hint: `Workspace subcommands: adopt-lineage, audit, build, check, evidence, graph, hash-index, init, lineage, lint, ls, pin, records, signers, status, upgrade, verify, versions, work. Run "chant --help" for their options.`,
|
|
680
761
|
}),
|
|
681
762
|
);
|
|
682
763
|
return 1;
|
|
@@ -84,8 +84,8 @@
|
|
|
84
84
|
},
|
|
85
85
|
"digest": {
|
|
86
86
|
"type": "string",
|
|
87
|
-
"pattern": "^[0-9a-f]{64}$",
|
|
88
|
-
"description": "The seal written: the lowercase hex SHA-256 of the
|
|
87
|
+
"pattern": "^sha256:[0-9a-f]{64}$",
|
|
88
|
+
"description": "The seal written: sha256: and the lowercase hex SHA-256 of the JCS form of the whole session without its seal field (its front matter as core and the text below it as body, line endings LF), which records checks on every read (session-seal-mismatch). Before #2546 it was the bare hex SHA-256 of the file's text without the seal line."
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
},
|
|
@@ -171,6 +171,9 @@
|
|
|
171
171
|
"write-usage-invalid",
|
|
172
172
|
"record-not-found",
|
|
173
173
|
"record-closed",
|
|
174
|
+
"write-scope-member",
|
|
175
|
+
"write-scope-kind",
|
|
176
|
+
"agent-unknown",
|
|
174
177
|
"record-unparseable",
|
|
175
178
|
"record-schema-invalid",
|
|
176
179
|
"record-id-duplicate",
|
|
@@ -178,6 +181,7 @@
|
|
|
178
181
|
"record-supersedes-conflict",
|
|
179
182
|
"record-remediates-unknown",
|
|
180
183
|
"record-remediates-not-closed",
|
|
184
|
+
"record-seal-mismatch",
|
|
181
185
|
"session-seal-mismatch",
|
|
182
186
|
"session-verdict-unknown-record"
|
|
183
187
|
]
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* session in one write. It sets the session's state to its kind's closed
|
|
4
4
|
* state, the time it closed and the commit it closed at (the fields the
|
|
5
5
|
* kind's `session` block names in `closedOn` and `closedRev`), and then the
|
|
6
|
-
* seal by chant's rule ({@link sessionSeal}), so a UI never
|
|
6
|
+
* seal by chant's whole-file rule ({@link sessionSeal}, #2546), so a UI never
|
|
7
|
+
* computes a seal.
|
|
7
8
|
*
|
|
8
9
|
* Like `new`, `amend` and `review`, it reads the records again with the file
|
|
9
10
|
* in place and writes only when the session comes back valid: a verdict
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
readAll,
|
|
27
28
|
RECORDS_WRITE_CONTRACT_VERSION,
|
|
28
29
|
RecordWriteError,
|
|
30
|
+
refuseOutOfScope,
|
|
29
31
|
replaceFields,
|
|
30
32
|
stableJson,
|
|
31
33
|
validateWrite,
|
|
@@ -33,6 +35,7 @@ import {
|
|
|
33
35
|
type WriteResult,
|
|
34
36
|
} from "./records-write";
|
|
35
37
|
import { headCommit } from "./session-kinds";
|
|
38
|
+
import { WRITE_SCOPE_CODES } from "./write-scope";
|
|
36
39
|
|
|
37
40
|
export const RECORDS_CLOSE_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/records-close/v1/records-close.schema.json";
|
|
38
41
|
|
|
@@ -42,6 +45,7 @@ export const CLOSE_ERROR_CODES = [
|
|
|
42
45
|
"write-usage-invalid",
|
|
43
46
|
"record-not-found",
|
|
44
47
|
"record-closed",
|
|
48
|
+
...WRITE_SCOPE_CODES,
|
|
45
49
|
...RECORD_REASON_CODES,
|
|
46
50
|
] as const satisfies readonly ReasonCode[];
|
|
47
51
|
export type CloseErrorCode = (typeof CLOSE_ERROR_CODES)[number];
|
|
@@ -65,6 +69,8 @@ export interface CloseRecordOptions {
|
|
|
65
69
|
cwd: string;
|
|
66
70
|
/** The close time. Defaults to now. */
|
|
67
71
|
now?: Date;
|
|
72
|
+
/** The agent session the write is made in (#2548). */
|
|
73
|
+
agent?: string;
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
/** An ISO 8601 time in UTC to the second, as the reference session schema's dateTime takes it. */
|
|
@@ -76,6 +82,7 @@ function isoSeconds(d: Date): string {
|
|
|
76
82
|
export async function closeRecord(opts: CloseRecordOptions): Promise<CloseDocument> {
|
|
77
83
|
try {
|
|
78
84
|
const o = await open(opts.kind, opts.cwd);
|
|
85
|
+
refuseOutOfScope(o, "close", opts.cwd, opts);
|
|
79
86
|
const { kind } = o.loaded;
|
|
80
87
|
const decl = kind.session;
|
|
81
88
|
if (!decl || kind.stateField === undefined) {
|
|
@@ -97,12 +104,13 @@ export async function closeRecord(opts: CloseRecordOptions): Promise<CloseDocume
|
|
|
97
104
|
...(decl.closedOn ? { [decl.closedOn]: isoSeconds(opts.now ?? new Date()) } : {}),
|
|
98
105
|
...(decl.closedRev ? { [decl.closedRev]: closedRev } : {}),
|
|
99
106
|
};
|
|
100
|
-
// The seal
|
|
107
|
+
// The seal is written last, into text that already holds everything else: the whole-file seal of the session without its seal field (#2546).
|
|
101
108
|
const merged = { ...old, ...set };
|
|
102
109
|
delete merged[decl.seal];
|
|
103
110
|
const unsealed = replaceFields(o.source.read(target.path), set, merged);
|
|
104
111
|
if (unsealed === undefined) throw new RecordWriteError("record-unparseable", `${target.path}: its fields can't be rewritten in place without changing the rest of the file`);
|
|
105
112
|
const digest = sessionSeal(unsealed, decl.seal, kind.format);
|
|
113
|
+
if (digest === null) throw new RecordWriteError("record-unparseable", `${target.path} can't be read, so it can't be sealed`);
|
|
106
114
|
const text = replaceFields(unsealed, { [decl.seal]: digest }, { ...merged, [decl.seal]: digest });
|
|
107
115
|
if (text === undefined || sessionSeal(text, decl.seal, kind.format) !== digest) {
|
|
108
116
|
throw new RecordWriteError("record-unparseable", `${target.path}: the ${decl.seal} line can't be added without changing the text it seals`);
|
|
@@ -154,7 +154,8 @@ const SESSION_SCHEMA = {
|
|
|
154
154
|
comments: { type: "array" },
|
|
155
155
|
follow_ups: { type: "array", items: ref("contractId") },
|
|
156
156
|
closed_at: ref("datetime"),
|
|
157
|
-
seal:
|
|
157
|
+
// The whole-file seal (#2546): sha256: and the hex SHA-256 of the session's JCS form without it.
|
|
158
|
+
seal: { type: "string", pattern: "^sha256:[0-9a-f]{64}$" },
|
|
158
159
|
},
|
|
159
160
|
};
|
|
160
161
|
|
|
@@ -294,7 +295,7 @@ const SESSION = {
|
|
|
294
295
|
comments: [],
|
|
295
296
|
follow_ups: ["C-002"],
|
|
296
297
|
closed_at: "2026-09-24T11:00:00.000Z",
|
|
297
|
-
seal: "e".repeat(64)
|
|
298
|
+
seal: `sha256:${"e".repeat(64)}`,
|
|
298
299
|
};
|
|
299
300
|
|
|
300
301
|
// ── 1. The json format ───────────────────────────────────────────────────────
|
|
@@ -372,11 +373,16 @@ describe("format json", () => {
|
|
|
372
373
|
});
|
|
373
374
|
|
|
374
375
|
describe("a JSON session kind (#2673)", () => {
|
|
375
|
-
test("its seal is the
|
|
376
|
+
test("its seal is the whole-file JCS seal without the seal member (#2546)", async () => {
|
|
376
377
|
write("design/session.kind.mjs", kindFile({ ...KINDS.session, session: { verdicts: "approvals", seal: "seal", subjects: { kind: "contract.kind.mjs" } } }));
|
|
377
378
|
write("design/contracts/C-001-first.md", '---\nid: "C-001"\nstatus: "approved"\n---\n');
|
|
379
|
+
// JCS of the object without its seal: keys sorted, no whitespace. The member order and indentation of the file play no part.
|
|
380
|
+
const sorted = (v: unknown): unknown =>
|
|
381
|
+
v === null || typeof v !== "object" ? v : Array.isArray(v) ? v.map(sorted) : Object.fromEntries(Object.keys(v).sort().map((k) => [k, sorted((v as Record<string, unknown>)[k])]));
|
|
382
|
+
const jcsSeal = (v: object): string => `sha256:${sha256(JSON.stringify(sorted(v)))}`;
|
|
378
383
|
const { seal: _drop, ...unsealed } = SESSION;
|
|
379
|
-
const seal =
|
|
384
|
+
const seal = jcsSeal(unsealed);
|
|
385
|
+
// Sealed over SESSION, then changed: a verdict added, or the id.
|
|
380
386
|
write("design/sessions/S-0001.json", json({ ...SESSION, approvals: [{ record: "C-001" }], seal }));
|
|
381
387
|
write("design/sessions/S-0002.json", json({ ...SESSION, id: "S-0002", seal }));
|
|
382
388
|
const doc = await query("session");
|
|
@@ -385,7 +391,7 @@ describe("a JSON session kind (#2673)", () => {
|
|
|
385
391
|
["S-0002", ["session-seal-mismatch"]],
|
|
386
392
|
]);
|
|
387
393
|
const good = { ...unsealed, id: "S-0003" };
|
|
388
|
-
write("design/sessions/S-0003.json",
|
|
394
|
+
write("design/sessions/S-0003.json", `${JSON.stringify({ seal: jcsSeal(good), ...Object.fromEntries(Object.entries(good).reverse()) })}\n`);
|
|
389
395
|
const [, , third] = (await query("session")).records;
|
|
390
396
|
expect([third.id, codes(third)]).toEqual(["S-0003", []]);
|
|
391
397
|
});
|
|
@@ -547,6 +553,8 @@ describe("kinds without a lifecycle", () => {
|
|
|
547
553
|
["neither idField nor idFrom", { ...KINDS.closure, idField: undefined }, /idField: a kind names its id with exactly one of idField and idFrom/],
|
|
548
554
|
["an unknown format", { ...KINDS.closure, format: "yaml" }, /format/],
|
|
549
555
|
["a session block without states", { ...KINDS.closure, session: { verdicts: "approvals", seal: "seal", subjects: { kind: "contract.kind.mjs" } } }, /session: a session kind must have states/],
|
|
556
|
+
["a seal without states (#2546)", { ...KINDS.closure, seal: { field: "seal" } }, /seal: a kind without states cannot have seal/],
|
|
557
|
+
["a seal naming another field than session.seal (#2546)", { ...KINDS.session, session: { verdicts: "approvals", seal: "seal", subjects: { kind: "contract.kind.mjs" } }, seal: { field: "closed_digest" } }, /seal: seal.field and session.seal name the field/],
|
|
550
558
|
])("a kind with %s is kind-invalid, naming the field", async (_label, kind, why) => {
|
|
551
559
|
write("design/closure.kind.mjs", kindFile(kind));
|
|
552
560
|
const error = await failure("closure");
|
|
@@ -165,6 +165,9 @@
|
|
|
165
165
|
"source-harvest-not-proposed",
|
|
166
166
|
"record-state-not-initial",
|
|
167
167
|
"ratify-quorum-not-met",
|
|
168
|
+
"write-scope-member",
|
|
169
|
+
"write-scope-kind",
|
|
170
|
+
"agent-unknown",
|
|
168
171
|
"record-unparseable",
|
|
169
172
|
"record-schema-invalid",
|
|
170
173
|
"record-id-duplicate",
|
|
@@ -172,6 +175,7 @@
|
|
|
172
175
|
"record-supersedes-conflict",
|
|
173
176
|
"record-remediates-unknown",
|
|
174
177
|
"record-remediates-not-closed",
|
|
178
|
+
"record-seal-mismatch",
|
|
175
179
|
"session-seal-mismatch",
|
|
176
180
|
"session-verdict-unknown-record"
|
|
177
181
|
]
|
|
@@ -259,6 +259,9 @@
|
|
|
259
259
|
"review-sign-failed",
|
|
260
260
|
"session-unknown",
|
|
261
261
|
"session-not-open",
|
|
262
|
+
"write-scope-member",
|
|
263
|
+
"write-scope-kind",
|
|
264
|
+
"agent-unknown",
|
|
262
265
|
"record-unparseable",
|
|
263
266
|
"record-schema-invalid",
|
|
264
267
|
"record-id-duplicate",
|
|
@@ -266,6 +269,7 @@
|
|
|
266
269
|
"record-supersedes-conflict",
|
|
267
270
|
"record-remediates-unknown",
|
|
268
271
|
"record-remediates-not-closed",
|
|
272
|
+
"record-seal-mismatch",
|
|
269
273
|
"session-seal-mismatch",
|
|
270
274
|
"session-verdict-unknown-record"
|
|
271
275
|
]
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* decision reviews that cite it.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
9
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
+
import yaml from "js-yaml";
|
|
11
12
|
import { afterAll, describe, expect, test } from "vitest";
|
|
12
13
|
import { cleanScratch, commitAll, contract, REPO } from "./__fixtures__/contract-repo";
|
|
13
14
|
import { reviewed, sessionText, SESSIONS_KIND as SESSIONS, sessionsRepo as fixture } from "./__fixtures__/sessions";
|
|
@@ -34,14 +35,30 @@ describe("the session kind (#2673)", () => {
|
|
|
34
35
|
expect(doc.records[0].citedBy).toEqual([]);
|
|
35
36
|
});
|
|
36
37
|
|
|
37
|
-
test("the seal is the
|
|
38
|
+
test("the seal is the whole-file JCS rule the docs give (#2546)", () => {
|
|
38
39
|
const file = join(REPO, "reference-workspace", "design", "sessions", "S-0001-first-walk-of-the-reference-decisions.md");
|
|
39
|
-
const awk = execFileSync("sh", ["-c", `awk 'NR==1&&/^---$/{f=1;print;next} f&&/^---$/{f=0} f&&/^closed_digest:/{next} {print}' "$1" | shasum -a 256`, "sh", file], { encoding: "utf-8" });
|
|
40
40
|
const text = readFileSync(file, "utf-8");
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
// The rule worked by hand: the front matter as JSON without closed_digest, the text below it, keys sorted, no whitespace.
|
|
42
|
+
const [, front, body] = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(text)!;
|
|
43
|
+
const { closed_digest: _seal, ...core } = yaml.load(front, { schema: yaml.JSON_SCHEMA }) as Record<string, unknown>;
|
|
44
|
+
const sorted = (v: unknown): unknown =>
|
|
45
|
+
v === null || typeof v !== "object" ? v : Array.isArray(v) ? v.map(sorted) : Object.fromEntries(Object.keys(v).sort().map((k) => [k, sorted((v as Record<string, unknown>)[k])]));
|
|
46
|
+
const byHand = `sha256:${createHash("sha256").update(JSON.stringify(sorted({ body, core })), "utf8").digest("hex")}`;
|
|
47
|
+
expect(sessionSeal(text, "closed_digest")).toBe(byHand);
|
|
48
|
+
expect(text).toContain(`closed_digest: "${byHand}"`);
|
|
43
49
|
// CRLF line endings hash as LF.
|
|
44
|
-
expect(sessionSeal(text.replace(/\n/g, "\r\n"), "closed_digest")).toBe(
|
|
50
|
+
expect(sessionSeal(text.replace(/\n/g, "\r\n"), "closed_digest")).toBe(byHand);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a session sealed by the text rule before #2546 reads as session-seal-mismatch, naming the old rule", async () => {
|
|
54
|
+
const root = fixture();
|
|
55
|
+
const file = join(root, "design", "sessions", "S-0001-first-walk-of-the-reference-decisions.md");
|
|
56
|
+
const text = readFileSync(file, "utf-8");
|
|
57
|
+
const old = createHash("sha256").update(text.replace(/^closed_digest: .*\n/m, ""), "utf8").digest("hex");
|
|
58
|
+
writeFileSync(file, text.replace(/^closed_digest: .*$/m, `closed_digest: "${old}"`));
|
|
59
|
+
const s1 = (await read(root)).records[0];
|
|
60
|
+
expect(s1.reasons.map((r) => r.code)).toContain("session-seal-mismatch");
|
|
61
|
+
expect(s1.reasons.find((r) => r.code === "session-seal-mismatch")!.message).toMatch(/the text rule chant used before #2546/);
|
|
45
62
|
});
|
|
46
63
|
|
|
47
64
|
test("a closed session edited after it closed is session-seal-mismatch", async () => {
|
|
@@ -10,7 +10,7 @@ import { cpSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync
|
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
13
|
-
import { parseFrontMatter, recordTextDigest as textDigest } from "./records";
|
|
13
|
+
import { parseFrontMatter, recordSeal, recordTextDigest as textDigest } from "./records";
|
|
14
14
|
import { queryRecords } from "./records-cli";
|
|
15
15
|
import { allocateId, amendRecord, newRecord, renderRecord, reviewRecord, toYaml } from "./records-write";
|
|
16
16
|
|
|
@@ -21,7 +21,7 @@ const KIND = "decisions/decision.kind.mjs";
|
|
|
21
21
|
type Data = Record<string, unknown>;
|
|
22
22
|
|
|
23
23
|
/** The digest of a decision: the kind names a ratified state, so the state leaves the digest with the reviews and seal (#2873). */
|
|
24
|
-
const recordTextDigest = (text: string) => textDigest(text, ["reviews", "seal", "state"]);
|
|
24
|
+
const recordTextDigest = (text: string) => textDigest(text, ["reviews", "seal", "state", "closed_digest"]);
|
|
25
25
|
|
|
26
26
|
const SAMPLE = (() => {
|
|
27
27
|
const fm = parseFrontMatter(readFileSync(join(DECISIONS, "ws-003-seal-scope.md"), "utf-8"));
|
|
@@ -287,7 +287,7 @@ describe("records amend", () => {
|
|
|
287
287
|
const evidence = [{ title: "A spec", url: "https://example.com/spec" }];
|
|
288
288
|
expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ evidence }), cwd: dir })).toMatchObject({ changed: ["evidence"] });
|
|
289
289
|
for (const by of ["alice", "bob"]) await reviewRecord({ kind: KIND, id: "ws-001", verdict: "agree", by, cwd: dir });
|
|
290
|
-
expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ state: "ratified" }), cwd: dir })).toMatchObject({ changed: ["state"] });
|
|
290
|
+
expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ state: "ratified" }), cwd: dir })).toMatchObject({ changed: ["state", "closed_digest"] });
|
|
291
291
|
put("ws-006-six.md", decision({ id: "ws-006", title: "Six" }));
|
|
292
292
|
expect(code(await amendRecord({ kind: KIND, id: "ws-006", fields: fields({ state: "withdrawn" }), cwd: dir }))).toBe("amend-supersede-instead");
|
|
293
293
|
});
|
|
@@ -353,12 +353,43 @@ describe("ratifying against the quorum (#2873)", () => {
|
|
|
353
353
|
await agree("alice");
|
|
354
354
|
await agree("bob");
|
|
355
355
|
const judged = recordTextDigest(text("ws-001-one.md"));
|
|
356
|
-
expect(await ratify()).toMatchObject({ changed: ["state"] });
|
|
356
|
+
expect(await ratify()).toMatchObject({ changed: ["state", "closed_digest"] });
|
|
357
357
|
expect(data("ws-001-one.md").state).toBe("ratified");
|
|
358
358
|
expect(recordTextDigest(text("ws-001-one.md"))).toBe(judged);
|
|
359
359
|
expect(await quorumOf("ws-001")).toMatchObject({ need: 2, agreed: 2, met: true, notCounted: [] });
|
|
360
360
|
});
|
|
361
361
|
|
|
362
|
+
test("ratifying writes the whole-file seal, which covers the reviews and the state the digest leaves out (#2546)", async () => {
|
|
363
|
+
await agree("alice");
|
|
364
|
+
await agree("bob");
|
|
365
|
+
const judged = textDigest(text("ws-001-one.md"), ["reviews", "seal", "state", "closed_digest"]);
|
|
366
|
+
await ratify();
|
|
367
|
+
const sealed = text("ws-001-one.md");
|
|
368
|
+
expect(data("ws-001-one.md").closed_digest).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
369
|
+
expect(data("ws-001-one.md").closed_digest).toBe(recordSeal(sealed, "closed_digest"));
|
|
370
|
+
// The closed_digest line goes last in the front matter, and the digest leaves it out.
|
|
371
|
+
expect(sealed).toMatch(/\nclosed_digest: "sha256:[0-9a-f]{64}"\n---\n/);
|
|
372
|
+
expect(textDigest(sealed, ["reviews", "seal", "state", "closed_digest"])).toBe(judged);
|
|
373
|
+
const read = async () => (await queryRecords({ kind: KIND, cwd: dir })) as { records: { id: string; valid: boolean; reasons: { code: string; message: string }[] }[] };
|
|
374
|
+
expect((await read()).records.find((r) => r.id === "ws-001")).toMatchObject({ valid: true, reasons: [] });
|
|
375
|
+
// A verdict, a state or a byte of the body edited by hand after the close breaks the seal.
|
|
376
|
+
for (const edit of [
|
|
377
|
+
(t: string) => t.replace('verdict: "agree"', 'verdict: "dissent"\n note: "no"'),
|
|
378
|
+
(t: string) => t.replace('state: "ratified"', 'state: "superseded"'),
|
|
379
|
+
(t: string) => `${t}\nAn afterthought.\n`,
|
|
380
|
+
]) {
|
|
381
|
+
writeFileSync(join(dir, "decisions", "ws-001-one.md"), edit(sealed));
|
|
382
|
+
const r = (await read()).records.find((x) => x.id === "ws-001")!;
|
|
383
|
+
expect(r.reasons.map((x) => x.code)).toContain("record-seal-mismatch");
|
|
384
|
+
}
|
|
385
|
+
// CRLF line endings, or a value quoted another way, leave the seal as it was.
|
|
386
|
+
writeFileSync(join(dir, "decisions", "ws-001-one.md"), sealed.replace('area: "D4"', "area: 'D4'").replace(/\n/g, "\r\n"));
|
|
387
|
+
expect((await read()).records.find((r) => r.id === "ws-001")).toMatchObject({ valid: true });
|
|
388
|
+
// And a closed record is never written again, so the seal can't be set by hand either.
|
|
389
|
+
writeFileSync(join(dir, "decisions", "ws-001-one.md"), sealed);
|
|
390
|
+
expect(code(await amendRecord({ kind: KIND, id: "ws-002", fields: fields({ closed_digest: `sha256:${"0".repeat(64)}` }), cwd: dir }))).toBe("write-input-invalid");
|
|
391
|
+
});
|
|
392
|
+
|
|
362
393
|
test("a met quorum with an open concern ratifies, and the concern stays listed", async () => {
|
|
363
394
|
await agree("alice");
|
|
364
395
|
await agree("bob");
|
|
@@ -379,7 +410,7 @@ describe("ratifying against the quorum (#2873)", () => {
|
|
|
379
410
|
await agree("alice");
|
|
380
411
|
const digest = recordTextDigest(text("ws-001-one.md"));
|
|
381
412
|
const reviews = [...(data("ws-001-one.md").reviews as Data[]), { reviewer: "bob", verdict: "agree", on: "2026-09-26", digest }];
|
|
382
|
-
expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ state: "ratified", reviews }), cwd: dir })).toMatchObject({ changed: ["state", "reviews"] });
|
|
413
|
+
expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ state: "ratified", reviews }), cwd: dir })).toMatchObject({ changed: ["state", "reviews", "closed_digest"] });
|
|
383
414
|
put("ws-006-six.md", decision({ id: "ws-006", title: "Six" }));
|
|
384
415
|
await agree("alice", "ws-006");
|
|
385
416
|
await agree("bob", "ws-006");
|
|
@@ -403,11 +434,11 @@ describe("ratifying against the quorum (#2873)", () => {
|
|
|
403
434
|
expect(withRatified).toContain('reviews: { field: "reviews", decider: "decided_by", ratified: "ratified" },');
|
|
404
435
|
writeFileSync(kindFile, withRatified.replace(', ratified: "ratified" }', " }"));
|
|
405
436
|
const judged = textDigest(text("ws-001-one.md"), ["reviews", "seal"]);
|
|
406
|
-
expect(await ratify()).toMatchObject({ changed: ["state"] });
|
|
407
|
-
expect(textDigest(text("ws-001-one.md"), ["reviews", "seal"])).not.toBe(judged);
|
|
437
|
+
expect(await ratify()).toMatchObject({ changed: ["state", "closed_digest"] });
|
|
438
|
+
expect(textDigest(text("ws-001-one.md"), ["reviews", "seal", "closed_digest"])).not.toBe(judged);
|
|
408
439
|
writeFileSync(kindFile, withRatified.replace(' reviews: { field: "reviews", decider: "decided_by", ratified: "ratified" },\n', ""));
|
|
409
440
|
put("ws-006-six.md", decision({ id: "ws-006", title: "Six" }));
|
|
410
|
-
expect(await ratify("ws-006")).toMatchObject({ changed: ["state"] });
|
|
441
|
+
expect(await ratify("ws-006")).toMatchObject({ changed: ["state", "closed_digest"] });
|
|
411
442
|
});
|
|
412
443
|
|
|
413
444
|
test("a kind whose reviews.ratified is not one of its states is refused", async () => {
|
|
@@ -417,6 +448,32 @@ describe("ratifying against the quorum (#2873)", () => {
|
|
|
417
448
|
expect(code(doc)).toBe("kind-invalid");
|
|
418
449
|
expect("error" in doc && doc.error.message).toMatch(/reviews\.ratified must be one of states/);
|
|
419
450
|
});
|
|
451
|
+
|
|
452
|
+
test("on a copy of the chant repo's own decisions, verdicts count through ratifying and sealing (#2555, ws-063)", async () => {
|
|
453
|
+
for (const f of readdirSync(join(dir, "decisions"))) if (f.endsWith(".md")) rmSync(join(dir, "decisions", f));
|
|
454
|
+
for (const f of readdirSync(DECISIONS)) if (/^ws-\d{3}-.+\.md$/.test(f)) cpSync(join(DECISIONS, f), join(dir, "decisions", f));
|
|
455
|
+
const read = async () => {
|
|
456
|
+
const doc = await queryRecords({ kind: KIND, cwd: dir });
|
|
457
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
458
|
+
return doc.records;
|
|
459
|
+
};
|
|
460
|
+
const decided = (await read()).filter((r) => r.state === "decided");
|
|
461
|
+
expect(decided.length).toBeGreaterThan(50);
|
|
462
|
+
// Every sixth one, and the last, keeps the test inside the unit-test budget (#2817).
|
|
463
|
+
const sample = decided.filter((_, i) => i % 6 === 0 || i === decided.length - 1);
|
|
464
|
+
const judged = new Map(sample.map((r) => [r.id!, r.digest]));
|
|
465
|
+
for (const id of judged.keys()) {
|
|
466
|
+
await agree("alice", id);
|
|
467
|
+
await agree("Bob ", id);
|
|
468
|
+
expect(await ratify(id)).toMatchObject({ changed: ["state", "closed_digest"] });
|
|
469
|
+
}
|
|
470
|
+
for (const r of (await read()).filter((x) => judged.has(x.id!))) {
|
|
471
|
+
expect(r, r.id!).toMatchObject({ state: "ratified", valid: true, reasons: [], digest: judged.get(r.id!) });
|
|
472
|
+
expect(r.quorum, r.id!).toMatchObject({ agreed: 2, met: true, notCounted: [] });
|
|
473
|
+
expect(r.quorum!.counted.map((c) => c.principal), r.id!).toEqual(["alice", "bob"]);
|
|
474
|
+
expect(r.data?.closed_digest, r.id!).toBe(recordSeal(readFileSync(join(dir, r.path), "utf-8"), "closed_digest"));
|
|
475
|
+
}
|
|
476
|
+
});
|
|
420
477
|
});
|
|
421
478
|
|
|
422
479
|
describe("records review", () => {
|