@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
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
* `records close` is in `records-close.ts`.
|
|
21
21
|
*
|
|
22
22
|
* `new --sign` and `amend --sign` seal the record's author (#2688), and
|
|
23
|
-
* `review --sign` seals a verdict (#2687): see `trust/seal.ts`.
|
|
23
|
+
* `review --sign` seals a verdict (#2687): see `trust/seal.ts`. A record of
|
|
24
|
+
* a kind that declares `seal` gets its whole-file seal when `new` or `amend`
|
|
25
|
+
* writes it into a closed state (#2546, ws-063).
|
|
24
26
|
*/
|
|
25
27
|
|
|
26
28
|
import { readFileSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -37,6 +39,7 @@ import {
|
|
|
37
39
|
RecordReadError,
|
|
38
40
|
RECORD_SEAL_FIELD,
|
|
39
41
|
digestFields,
|
|
42
|
+
recordSeal,
|
|
40
43
|
recordTextDigest,
|
|
41
44
|
type LoadedRecordKind,
|
|
42
45
|
type RecordEntry,
|
|
@@ -47,6 +50,8 @@ import { policyAtBase, resolveBase } from "./trust/provenance";
|
|
|
47
50
|
import { WorkspaceReadError } from "./declaration";
|
|
48
51
|
import { findSessionKinds, headCommit, sessionKindsFor } from "./session-kinds";
|
|
49
52
|
import { workingTree } from "./tree";
|
|
53
|
+
import { AGENT_ENV, refuseRecordWrite, WRITE_SCOPE_CODES, WriteScopeError } from "./write-scope";
|
|
54
|
+
import type { WriteVerb } from "./declaration";
|
|
50
55
|
|
|
51
56
|
// ── Contract ─────────────────────────────────────────────────────────────────
|
|
52
57
|
|
|
@@ -72,6 +77,7 @@ export const NEW_ERROR_CODES = [
|
|
|
72
77
|
"source-harvest-not-proposed",
|
|
73
78
|
"record-state-not-initial",
|
|
74
79
|
"ratify-quorum-not-met",
|
|
80
|
+
...WRITE_SCOPE_CODES,
|
|
75
81
|
...RECORD_REASON_CODES,
|
|
76
82
|
] as const satisfies readonly ReasonCode[];
|
|
77
83
|
|
|
@@ -86,6 +92,7 @@ export const AMEND_ERROR_CODES = [
|
|
|
86
92
|
"amend-supersede-instead",
|
|
87
93
|
"record-sign-failed",
|
|
88
94
|
"ratify-quorum-not-met",
|
|
95
|
+
...WRITE_SCOPE_CODES,
|
|
89
96
|
...RECORD_REASON_CODES,
|
|
90
97
|
] as const satisfies readonly ReasonCode[];
|
|
91
98
|
|
|
@@ -100,6 +107,7 @@ export const REVIEW_ERROR_CODES = [
|
|
|
100
107
|
"review-sign-failed",
|
|
101
108
|
"session-unknown",
|
|
102
109
|
"session-not-open",
|
|
110
|
+
...WRITE_SCOPE_CODES,
|
|
103
111
|
...RECORD_REASON_CODES,
|
|
104
112
|
] as const satisfies readonly ReasonCode[];
|
|
105
113
|
|
|
@@ -348,6 +356,15 @@ export async function open(kind: string, cwd: string): Promise<Opened> {
|
|
|
348
356
|
};
|
|
349
357
|
}
|
|
350
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Refuse a write outside the writer's scope (#2548): the kind `o` opened,
|
|
361
|
+
* written with `verb`, by the session `agent` names (`CHANT_AGENT`) or the
|
|
362
|
+
* principal `by` names. Throws a {@link WriteScopeError}.
|
|
363
|
+
*/
|
|
364
|
+
export function refuseOutOfScope(o: Opened, verb: WriteVerb, cwd: string, writer: { agent?: string; by?: string }): void {
|
|
365
|
+
refuseRecordWrite(cwd, { kindName: o.loaded.kind.name, kindFile: o.loaded.file, recordsDir: o.loaded.dir, verb, agent: writer.agent, principal: writer.by });
|
|
366
|
+
}
|
|
367
|
+
|
|
351
368
|
/**
|
|
352
369
|
* The kind's records as `records` reads them from `source`. A session kind's
|
|
353
370
|
* subject records are read from the same source (#2693), so a write is
|
|
@@ -460,6 +477,33 @@ function refuseSealField(o: Opened, fields: Record<string, unknown>, flag: strin
|
|
|
460
477
|
if (o.loaded.kind.reviews && RECORD_SEAL_FIELD in fields) {
|
|
461
478
|
throw new RecordWriteError("write-input-invalid", `the fields given with ${flag} set ${RECORD_SEAL_FIELD}, and only --sign writes a record's seal`);
|
|
462
479
|
}
|
|
480
|
+
const closed = o.loaded.kind.seal?.field;
|
|
481
|
+
if (closed !== undefined && !o.loaded.kind.session && closed in fields) {
|
|
482
|
+
throw new RecordWriteError("write-input-invalid", `the fields given with ${flag} set ${closed}, and chant writes it when the record closes`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* `text`, the record `data` holds, with its whole-file seal written into the
|
|
488
|
+
* kind's `seal.field` when the record is in a closed state (#2546, ws-063).
|
|
489
|
+
* The seal goes last, after the author seal, so it covers every other value
|
|
490
|
+
* and the body. `text` is returned as it is for a kind without `seal`, a
|
|
491
|
+
* session kind (whose seal `records close` writes), or an open record.
|
|
492
|
+
*/
|
|
493
|
+
function sealClosed(o: Opened, text: string, data: Record<string, unknown>, id: string): { text: string; data: Record<string, unknown> } {
|
|
494
|
+
const { kind } = o.loaded;
|
|
495
|
+
if (!kind.seal || kind.session || kind.stateField === undefined) return { text, data };
|
|
496
|
+
const state = data[kind.stateField];
|
|
497
|
+
if (typeof state !== "string" || !(kind.closedStates ?? []).includes(state)) return { text, data };
|
|
498
|
+
const field = kind.seal.field;
|
|
499
|
+
const seal = recordSeal(text, field, kind.format);
|
|
500
|
+
if (seal === null) throw new RecordWriteError("record-unparseable", `${id} can't be read, so it can't be sealed`);
|
|
501
|
+
const sealedData = { ...data, [field]: seal };
|
|
502
|
+
const sealed = replaceFields(text, { [field]: seal }, sealedData);
|
|
503
|
+
if (sealed === undefined || recordSeal(sealed, field, kind.format) !== seal) {
|
|
504
|
+
throw new RecordWriteError("record-unparseable", `the ${field} field can't be written into ${id} without changing the text it seals`);
|
|
505
|
+
}
|
|
506
|
+
return { text: sealed, data: sealedData };
|
|
463
507
|
}
|
|
464
508
|
|
|
465
509
|
/**
|
|
@@ -584,7 +628,7 @@ function today(): string {
|
|
|
584
628
|
}
|
|
585
629
|
|
|
586
630
|
export function failure<C>(schema: string, err: unknown): WriteFailure<C> {
|
|
587
|
-
if (err instanceof RecordWriteError || err instanceof RecordReadError) {
|
|
631
|
+
if (err instanceof RecordWriteError || err instanceof RecordReadError || err instanceof WriteScopeError) {
|
|
588
632
|
return { $schema: schema, contract: RECORDS_WRITE_CONTRACT_VERSION, error: { code: err.code as C, message: err.message } };
|
|
589
633
|
}
|
|
590
634
|
throw err;
|
|
@@ -644,6 +688,8 @@ export interface NewRecordOptions {
|
|
|
644
688
|
* `reviews.decider` field, which the fields must set.
|
|
645
689
|
*/
|
|
646
690
|
sign?: string | true;
|
|
691
|
+
/** The agent session the write is made in (`CHANT_AGENT`, #2548), which its write scope binds. */
|
|
692
|
+
agent?: string;
|
|
647
693
|
}
|
|
648
694
|
|
|
649
695
|
/**
|
|
@@ -727,6 +773,7 @@ export async function newRecord(opts: NewRecordOptions & ChannelOptions): Promis
|
|
|
727
773
|
throw new RecordWriteError("write-usage-invalid", `--prefix takes letters and digits, starting with a letter, not ${JSON.stringify(opts.prefix)}`);
|
|
728
774
|
}
|
|
729
775
|
const o = await open(opts.kind, opts.cwd);
|
|
776
|
+
refuseOutOfScope(o, "new", opts.cwd, opts);
|
|
730
777
|
const fields = applyChannel(o, parseFields(opts.fields, "--from"), opts, true, "--from");
|
|
731
778
|
const { kind, schema } = o.loaded;
|
|
732
779
|
refuseSealField(o, fields, "--from");
|
|
@@ -758,6 +805,7 @@ export async function newRecord(opts: NewRecordOptions & ChannelOptions): Promis
|
|
|
758
805
|
let text = renderRecord(data, title ? `\n# ${title}\n` : "");
|
|
759
806
|
let seal: AuthorSeal | undefined;
|
|
760
807
|
if (opts.sign !== undefined) ({ text, seal } = await sealAuthor(o, text, data, id, opts.sign, opts.cwd));
|
|
808
|
+
({ text } = sealClosed(o, text, seal ? { ...data, [RECORD_SEAL_FIELD]: seal } : data, id));
|
|
761
809
|
const written = await validatedEntry(o, before, path, text);
|
|
762
810
|
await refuseRatifyBelowQuorum(o, written, id);
|
|
763
811
|
const warnings = written.warnings;
|
|
@@ -789,6 +837,8 @@ export interface AmendRecordOptions {
|
|
|
789
837
|
cwd: string;
|
|
790
838
|
/** Seal the amended record's author, as `records new` does (#2688). */
|
|
791
839
|
sign?: string | true;
|
|
840
|
+
/** The agent session the write is made in (#2548). */
|
|
841
|
+
agent?: string;
|
|
792
842
|
}
|
|
793
843
|
|
|
794
844
|
/**
|
|
@@ -808,6 +858,7 @@ export interface AmendRecordOptions {
|
|
|
808
858
|
export async function amendRecord(opts: AmendRecordOptions & ChannelOptions): Promise<AmendDocument> {
|
|
809
859
|
try {
|
|
810
860
|
const o = await open(opts.kind, opts.cwd);
|
|
861
|
+
refuseOutOfScope(o, "amend", opts.cwd, opts);
|
|
811
862
|
const given = applyChannel(o, parseFields(opts.fields, "--set"), opts, false, "--set");
|
|
812
863
|
const { kind } = o.loaded;
|
|
813
864
|
refuseSealField(o, given, "--set");
|
|
@@ -871,6 +922,12 @@ export async function amendRecord(opts: AmendRecordOptions & ChannelOptions): Pr
|
|
|
871
922
|
sealDropped = `${opts.id} was sealed${typeof signer === "string" ? ` by ${signer}` : ""}, and the amendment moves its digest or its state, so the seal was removed: seal it again with records amend ${opts.id} --sign`;
|
|
872
923
|
}
|
|
873
924
|
if (stableJson(old[RECORD_SEAL_FIELD]) !== stableJson(merged[RECORD_SEAL_FIELD])) changed.push(RECORD_SEAL_FIELD);
|
|
925
|
+
// Entering a closed state seals the whole record, last (#2546, ws-063).
|
|
926
|
+
if (changed.length > 0 && kind.seal && !kind.session) {
|
|
927
|
+
const closedSeal = sealClosed(o, text, merged, opts.id);
|
|
928
|
+
text = closedSeal.text;
|
|
929
|
+
if (stableJson(old[kind.seal.field]) !== stableJson(closedSeal.data[kind.seal.field])) changed.push(kind.seal.field);
|
|
930
|
+
}
|
|
874
931
|
let warnings = target.warnings;
|
|
875
932
|
if (changed.length > 0) {
|
|
876
933
|
const written = await validatedEntry(o, before, target.path, text);
|
|
@@ -916,6 +973,8 @@ export interface ReviewRecordOptions {
|
|
|
916
973
|
* git's `user.signingkey`. Without it the verdict is written unsealed.
|
|
917
974
|
*/
|
|
918
975
|
sign?: string | true;
|
|
976
|
+
/** The agent session the write is made in (#2548). */
|
|
977
|
+
agent?: string;
|
|
919
978
|
}
|
|
920
979
|
|
|
921
980
|
/**
|
|
@@ -932,6 +991,7 @@ export async function reviewRecord(opts: ReviewRecordOptions): Promise<ReviewDoc
|
|
|
932
991
|
if (opts.by.trim() === "") throw new RecordWriteError("write-usage-invalid", "--by needs the reviewer's name");
|
|
933
992
|
if (opts.session !== undefined && opts.session === "") throw new RecordWriteError("write-usage-invalid", "--session needs a session id");
|
|
934
993
|
const o = await open(opts.kind, opts.cwd);
|
|
994
|
+
refuseOutOfScope(o, "review", opts.cwd, opts);
|
|
935
995
|
const { kind } = o.loaded;
|
|
936
996
|
if (!kind.reviews) {
|
|
937
997
|
throw new RecordWriteError("review-unsupported", `the ${kind.name} kind declares no reviews field, so its records take no review`);
|
|
@@ -946,6 +1006,8 @@ export async function reviewRecord(opts: ReviewRecordOptions): Promise<ReviewDoc
|
|
|
946
1006
|
throw new RecordWriteError("review-note-required", `a dissent needs a reason: pass --note <text> with the concern`);
|
|
947
1007
|
}
|
|
948
1008
|
const inSession = opts.session !== undefined ? await openSession(o, opts.session, opts.cwd) : undefined;
|
|
1009
|
+
// The verdict is appended to the session too, so the session's kind must be in scope as well.
|
|
1010
|
+
if (inSession) refuseOutOfScope(inSession.so, "review", opts.cwd, opts);
|
|
949
1011
|
const reviews = target.data[field] ?? [];
|
|
950
1012
|
if (!Array.isArray(reviews)) throw new RecordWriteError("record-schema-invalid", `${target.path}: ${field} is not a list`);
|
|
951
1013
|
const current = o.source.read(target.path);
|
|
@@ -1129,6 +1191,8 @@ export async function runRecordsWrite(ctx: CommandContext): Promise<number> {
|
|
|
1129
1191
|
const { args } = ctx;
|
|
1130
1192
|
const cwd = process.cwd();
|
|
1131
1193
|
const verb = args.extraPositional;
|
|
1194
|
+
// The agent session the write is made in (#2548): set by the harness that runs the agent.
|
|
1195
|
+
const agent = process.env[AGENT_ENV] || undefined;
|
|
1132
1196
|
const print = (doc: object): number => {
|
|
1133
1197
|
console.log(JSON.stringify(doc, null, 2));
|
|
1134
1198
|
return "error" in doc ? 1 : 0;
|
|
@@ -1140,7 +1204,7 @@ export async function runRecordsWrite(ctx: CommandContext): Promise<number> {
|
|
|
1140
1204
|
if (!id) return print(usage(RECORDS_CLOSE_SCHEMA_ID, "close needs the session's id"));
|
|
1141
1205
|
const kind = args.kind !== undefined ? resolveWriteKind(args.kind, cwd) : await declaredSessionKind(RECORDS_CLOSE_SCHEMA_ID, cwd);
|
|
1142
1206
|
if (typeof kind !== "string") return print(kind);
|
|
1143
|
-
return print(await closeRecord({ kind, id, dryRun: args.dryRun, cwd }));
|
|
1207
|
+
return print(await closeRecord({ kind, id, dryRun: args.dryRun, cwd, agent }));
|
|
1144
1208
|
}
|
|
1145
1209
|
if (verb === "new") {
|
|
1146
1210
|
const named = args.extraPositional2 ?? args.kind;
|
|
@@ -1148,7 +1212,7 @@ export async function runRecordsWrite(ctx: CommandContext): Promise<number> {
|
|
|
1148
1212
|
if (typeof kind !== "string") return print(kind);
|
|
1149
1213
|
const input = readInput(RECORDS_NEW_SCHEMA_ID, "--from", args.migrateFrom, cwd);
|
|
1150
1214
|
if (typeof input !== "string") return print(input);
|
|
1151
|
-
return print(await newRecord({ kind, fields: input, prefix: args.prefix, by: args.by, sign: args.sign, dryRun: args.dryRun, cwd }));
|
|
1215
|
+
return print(await newRecord({ kind, fields: input, prefix: args.prefix, by: args.by, sign: args.sign, dryRun: args.dryRun, cwd, agent }));
|
|
1152
1216
|
}
|
|
1153
1217
|
const schema = verb === "amend" ? RECORDS_AMEND_SCHEMA_ID : RECORDS_REVIEW_SCHEMA_ID;
|
|
1154
1218
|
const id = args.extraPositional2;
|
|
@@ -1159,11 +1223,11 @@ export async function runRecordsWrite(ctx: CommandContext): Promise<number> {
|
|
|
1159
1223
|
if (args.by !== undefined) return print(usage(schema, "--by is not taken by amend: set decided_by, or a kind's proposedBy field, with --set"));
|
|
1160
1224
|
const input = readInput(schema, "--set", args.set, cwd);
|
|
1161
1225
|
if (typeof input !== "string") return print(input);
|
|
1162
|
-
return print(await amendRecord({ kind, id, fields: input, sign: args.sign, dryRun: args.dryRun, cwd }));
|
|
1226
|
+
return print(await amendRecord({ kind, id, fields: input, sign: args.sign, dryRun: args.dryRun, cwd, agent }));
|
|
1163
1227
|
}
|
|
1164
1228
|
if (args.verdict === undefined) return print(usage(schema, "--verdict agree|dissent|abstain is required"));
|
|
1165
1229
|
if (args.by === undefined) return print(usage(schema, "--by <principal> is required"));
|
|
1166
1230
|
return print(
|
|
1167
|
-
await reviewRecord({ kind, id, verdict: args.verdict, by: args.by, note: args.note, session: args.session, sign: args.sign, dryRun: args.dryRun, cwd }),
|
|
1231
|
+
await reviewRecord({ kind, id, verdict: args.verdict, by: args.by, note: args.note, session: args.session, sign: args.sign, dryRun: args.dryRun, cwd, agent }),
|
|
1168
1232
|
);
|
|
1169
1233
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://intentius.io/chant/schemas/workspace/records/v1/records.schema.json",
|
|
4
4
|
"title": "chant workspace records output",
|
|
5
|
-
"description": "What `chant workspace records --json` prints, version 1 of the read contract for records (#2536).
|
|
5
|
+
"description": "What `chant workspace records --json` prints, version 1 of the read contract for records (#2536). A closed record of a kind that declares seal carries a whole-file seal, checked on every read (#2546). Readers ignore fields they do not know; a field is only ever added within a version. The reason and error codes are closed lists: a new code is a new contract version. Contract version 1 is written by chant 0.81.0 and newer; a reader that needs it refuses output whose `contract` it doesn't know. Every code is in the one closed list of `reason-codes.ts`.",
|
|
6
6
|
"oneOf": [{ "$ref": "#/$defs/result" }, { "$ref": "#/$defs/failure" }, { "$ref": "#/$defs/set" }],
|
|
7
7
|
"$defs": {
|
|
8
8
|
"decidedIn": {
|
|
@@ -47,6 +47,43 @@
|
|
|
47
47
|
]
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
|
+
"spec": {
|
|
51
|
+
"description": "Added in contract 1 by #2546 (#2524 D20). With --current only: the workspace's spec, the current records of each declared kind marked spec: true, each with the workspace files it pins. A kind whose read failed is left out, and is listed under kinds with its error.",
|
|
52
|
+
"type": "object",
|
|
53
|
+
"required": ["kinds", "records"],
|
|
54
|
+
"properties": {
|
|
55
|
+
"kinds": {
|
|
56
|
+
"type": "array",
|
|
57
|
+
"description": "The spec kinds, as the declaration names them, in its order.",
|
|
58
|
+
"items": {
|
|
59
|
+
"type": "object",
|
|
60
|
+
"required": ["member", "path", "name"],
|
|
61
|
+
"properties": {
|
|
62
|
+
"member": { "type": ["string", "null"] },
|
|
63
|
+
"path": { "type": "string" },
|
|
64
|
+
"name": { "type": ["string", "null"] }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"records": {
|
|
69
|
+
"type": "array",
|
|
70
|
+
"description": "Every current record of the spec kinds, in the order of kinds and then path. Invalid records are listed with valid false, as under kinds.",
|
|
71
|
+
"items": {
|
|
72
|
+
"type": "object",
|
|
73
|
+
"required": ["kind", "id", "path", "state", "valid", "digest", "assets"],
|
|
74
|
+
"properties": {
|
|
75
|
+
"kind": { "type": "string", "description": "The kind's name, such as decision." },
|
|
76
|
+
"id": { "type": ["string", "null"] },
|
|
77
|
+
"path": { "type": "string" },
|
|
78
|
+
"state": { "type": ["string", "null"] },
|
|
79
|
+
"valid": { "type": "boolean" },
|
|
80
|
+
"digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
|
|
81
|
+
"assets": { "type": "array", "items": { "$ref": "#/$defs/asset" } }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
50
87
|
"kind": false,
|
|
51
88
|
"records": false,
|
|
52
89
|
"error": false
|
|
@@ -69,6 +106,10 @@
|
|
|
69
106
|
"format": {
|
|
70
107
|
"enum": ["markdown-front-matter", "json"],
|
|
71
108
|
"description": "Added in contract 1 by #2664 (ws-053). How each record file holds its structured core: the YAML front matter of a Markdown file, or the whole file as one JSON object, read as I-JSON (no repeated member name)."
|
|
109
|
+
},
|
|
110
|
+
"spec": {
|
|
111
|
+
"type": "boolean",
|
|
112
|
+
"description": "Added in contract 1 by #2546. true when the kind file marks the kind spec: true, so its current records and the files they pin are part of the workspace's spec (#2524 D20)."
|
|
72
113
|
}
|
|
73
114
|
}
|
|
74
115
|
},
|
|
@@ -163,7 +204,7 @@
|
|
|
163
204
|
"items": { "$ref": "#/$defs/warning" }
|
|
164
205
|
},
|
|
165
206
|
"digest": {
|
|
166
|
-
"description": "Added in contract 1 by #2672. The lowercase hex SHA-256 of the record file's text, with LF line endings and without the kind's reviews block and its top-level seal block (#2688) in the front matter, or for a kind of format json without the top-level reviews and seal members, each with one comma beside it (#2664). A record with no seal hashes as it did before #2688. A review verdict names it in its digest field, and stops counting once the record's digest moves on; a record's author seal signs it. For a kind with no reviews list, the digest of the whole text.",
|
|
207
|
+
"description": "Added in contract 1 by #2672. The lowercase hex SHA-256 of the record file's text, with LF line endings and without the kind's reviews block and its top-level seal block (#2688) in the front matter, or for a kind of format json without the top-level reviews and seal members, each with one comma beside it (#2664). A record with no seal hashes as it did before #2688. A review verdict names it in its digest field, and stops counting once the record's digest moves on; a record's author seal signs it. A kind that declares seal (#2546) also leaves out the field holding the whole-file seal, and a kind with reviews.ratified its state line (#2873). For a kind with no reviews list, the digest of the whole text. It is not the record's seal: the seal covers the whole file (#2546, ws-063).",
|
|
167
208
|
"type": "string",
|
|
168
209
|
"pattern": "^[0-9a-f]{64}$"
|
|
169
210
|
},
|
|
@@ -405,7 +446,17 @@
|
|
|
405
446
|
"attestor": { "type": "string", "description": "The attestor that answered, such as ssh-commit." },
|
|
406
447
|
"principal": { "type": "string", "description": "The signer, when attested." },
|
|
407
448
|
"key": { "type": "string", "description": "The signing key's fingerprint, when known." },
|
|
408
|
-
"reason": { "type": "string" }
|
|
449
|
+
"reason": { "type": "string" },
|
|
450
|
+
"returned": {
|
|
451
|
+
"description": "Added in contract 1 by #2552. Set when the record's bytes came back in a return that carries the commit they were made in: level, principal and key then judge that commit, with the signers at base plus those admitted for the return, and commit is the commit here that holds the bytes. A signer neither lists reads as attested-unverifiable-here until an admin admits it.",
|
|
452
|
+
"type": "object",
|
|
453
|
+
"required": ["id", "importedIn"],
|
|
454
|
+
"properties": {
|
|
455
|
+
"id": { "type": "string", "pattern": "^ret-[0-9a-f]{12}$", "description": "The return, .chant/returns/<id>.json at the workspace root." },
|
|
456
|
+
"commit": { "type": "string", "pattern": "^[0-9a-f]{40,64}$", "description": "The commit in the returned copy that last changed the file." },
|
|
457
|
+
"importedIn": { "type": ["string", "null"], "pattern": "^[0-9a-f]{40,64}$", "description": "The commit here that holds the returned bytes." }
|
|
458
|
+
}
|
|
459
|
+
}
|
|
409
460
|
}
|
|
410
461
|
},
|
|
411
462
|
"reason": {
|
|
@@ -421,6 +472,7 @@
|
|
|
421
472
|
"record-supersedes-conflict",
|
|
422
473
|
"record-remediates-unknown",
|
|
423
474
|
"record-remediates-not-closed",
|
|
475
|
+
"record-seal-mismatch",
|
|
424
476
|
"session-seal-mismatch",
|
|
425
477
|
"session-verdict-unknown-record"
|
|
426
478
|
]
|
package/src/workspace/records.ts
CHANGED
|
@@ -13,15 +13,18 @@
|
|
|
13
13
|
*
|
|
14
14
|
* A record that fails any of that is still returned, with reason codes, and the
|
|
15
15
|
* read succeeds. Only a failure to read the kind, its schema or the revision is
|
|
16
|
-
* an error.
|
|
17
|
-
* #
|
|
16
|
+
* an error. A closed record of a kind that declares `seal` is checked against
|
|
17
|
+
* its whole-file seal ({@link recordSeal}, #2546); author and verdict seals
|
|
18
|
+
* are checked by `records-cli.ts` against the policy at base. Nothing here
|
|
19
|
+
* needs a `chant.workspace.json`.
|
|
18
20
|
*
|
|
19
21
|
* Everything under `workspace/` loads only when a `chant workspace` command
|
|
20
22
|
* runs. The level-0 goldens (#2526) fail if a level-0 command loads it.
|
|
21
23
|
*/
|
|
22
24
|
|
|
23
25
|
import { createHash } from "node:crypto";
|
|
24
|
-
import { sha256Hex } from "../content-digest";
|
|
26
|
+
import { contentDigest, sha256Hex } from "../content-digest";
|
|
27
|
+
import { canonicalJson } from "../effect-receipt";
|
|
25
28
|
import { readFileSync, statSync } from "node:fs";
|
|
26
29
|
import { dirname, posix, relative, resolve, sep } from "node:path";
|
|
27
30
|
import yaml from "js-yaml";
|
|
@@ -57,6 +60,8 @@ export const RECORD_REASON_CODES = [
|
|
|
57
60
|
"record-remediates-unknown",
|
|
58
61
|
/** A `remediates` link names a record that isn't closed: a record still open is amended instead (#2774). */
|
|
59
62
|
"record-remediates-not-closed",
|
|
63
|
+
/** A closed record's seal is not the whole-file seal of its text now: it changed after it closed (#2546, ws-063). */
|
|
64
|
+
"record-seal-mismatch",
|
|
60
65
|
/** A closed session's seal is not the digest of its text: it changed after it closed (#2673). */
|
|
61
66
|
"session-seal-mismatch",
|
|
62
67
|
/** A session's verdict names a record the kind's subject records do not have (#2673). */
|
|
@@ -171,13 +176,21 @@ export const RECORD_SEAL_FIELD = "seal";
|
|
|
171
176
|
* moving a record to that state keeps the verdicts that ratified it
|
|
172
177
|
* counting.
|
|
173
178
|
*/
|
|
174
|
-
export function digestFields(kind: Pick<RecordKind, "reviews" | "stateField">): string[] | null {
|
|
179
|
+
export function digestFields(kind: Pick<RecordKind, "reviews" | "stateField" | "seal">): string[] | null {
|
|
175
180
|
if (!kind.reviews) return null;
|
|
176
181
|
const fields = [kind.reviews.field, RECORD_SEAL_FIELD];
|
|
177
182
|
if (kind.reviews.ratified !== undefined && kind.stateField !== undefined) fields.push(kind.stateField);
|
|
183
|
+
// The whole-file seal is written when the record closes (#2546, ws-063), so
|
|
184
|
+
// the verdicts that ratified it keep counting on the sealed record.
|
|
185
|
+
if (kind.seal !== undefined) fields.push(kind.seal.field);
|
|
178
186
|
return fields;
|
|
179
187
|
}
|
|
180
188
|
|
|
189
|
+
/** The field holding a closed record's whole-file seal: the kind's `seal.field`, or a session kind's `session.seal` (#2546, #2673). */
|
|
190
|
+
export function sealFieldOf(kind: Pick<RecordKind, "seal" | "session">): string | undefined {
|
|
191
|
+
return kind.seal?.field ?? kind.session?.seal;
|
|
192
|
+
}
|
|
193
|
+
|
|
181
194
|
/** What a verdict's seal establishes (#2687). See `trust/seal.ts`. */
|
|
182
195
|
export interface VerdictAttestation {
|
|
183
196
|
/** Why the verdict is not attested. Absent when its seal verified. */
|
|
@@ -430,8 +443,32 @@ export const recordKindSchema = z
|
|
|
430
443
|
* Optional.
|
|
431
444
|
*/
|
|
432
445
|
answers: z.object({ points: z.string().min(1) }).strict().optional(),
|
|
446
|
+
/**
|
|
447
|
+
* The top-level field that holds a closed record's seal (#2546, ws-003,
|
|
448
|
+
* ws-063): {@link recordSeal}, the whole-file JCS/SHA-256 of the record
|
|
449
|
+
* without that field. `records amend` and `records new` write it when a
|
|
450
|
+
* record enters one of `closedStates`, and `records` reports a closed
|
|
451
|
+
* record whose seal does not match as `record-seal-mismatch`. Requires
|
|
452
|
+
* states. A session kind's `session.seal` names the same field for a
|
|
453
|
+
* session, and when both are given they must agree. Optional.
|
|
454
|
+
*/
|
|
455
|
+
seal: z.object({ field: z.string().min(1) }).strict().optional(),
|
|
456
|
+
/**
|
|
457
|
+
* The kind's current records, with the files they pin, are part of the
|
|
458
|
+
* workspace's spec (#2524 D20): `chant workspace records --current
|
|
459
|
+
* --json` lists them under `spec`. Optional, and false when left out.
|
|
460
|
+
*/
|
|
461
|
+
spec: z.boolean().optional(),
|
|
433
462
|
})
|
|
434
463
|
.strict()
|
|
464
|
+
.refine((k) => k.states !== undefined || k.seal === undefined, {
|
|
465
|
+
message: "a kind without states cannot have seal: a record is sealed when it enters a closed state",
|
|
466
|
+
path: ["seal"],
|
|
467
|
+
})
|
|
468
|
+
.refine((k) => k.seal === undefined || k.session === undefined || k.seal.field === k.session.seal, {
|
|
469
|
+
message: "seal.field and session.seal name the field that holds a closed session's seal, and must be the same",
|
|
470
|
+
path: ["seal"],
|
|
471
|
+
})
|
|
435
472
|
.refine((k) => (k.idField === undefined) !== (k.idFrom === undefined), {
|
|
436
473
|
message: "a kind names its id with exactly one of idField and idFrom",
|
|
437
474
|
path: ["idField"],
|
|
@@ -748,7 +785,9 @@ function nonJson(v: unknown, at: string, seen: Set<object>): string | undefined
|
|
|
748
785
|
* record's author seal (#2688; {@link digestFields}), so sealing a record
|
|
749
786
|
* leaves its digest where it was too. A record with no `seal` hashes exactly
|
|
750
787
|
* as it did before author seals existed. A kind with `reviews.ratified` adds
|
|
751
|
-
* its state field (#2873)
|
|
788
|
+
* its state field (#2873), and a kind with `seal` its seal field (#2546), so
|
|
789
|
+
* the whole-file seal written at the close moves no digest. The digest is not
|
|
790
|
+
* that seal: {@link recordSeal} covers the whole record.
|
|
752
791
|
*
|
|
753
792
|
* The rule, which a hand-editor can follow with a text editor and
|
|
754
793
|
* `sha256sum`:
|
|
@@ -776,6 +815,50 @@ export function recordTextDigest(text: string, field: string | readonly string[]
|
|
|
776
815
|
return createHash("sha256").update(kept, "utf8").digest("hex");
|
|
777
816
|
}
|
|
778
817
|
|
|
818
|
+
// ── Record seal ──────────────────────────────────────────────────────────────
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* The seal of a closed record (#2546, ws-003, ws-063): `sha256:` and the
|
|
822
|
+
* lowercase hex SHA-256 of the RFC 8785 (JCS) form of the whole record, with
|
|
823
|
+
* only the field that holds the seal left out. It is a separate hash from
|
|
824
|
+
* {@link recordTextDigest}, which a review verdict names: the digest leaves
|
|
825
|
+
* out the reviews, the author seal and the state so verdicts keep counting,
|
|
826
|
+
* and the seal covers all of them.
|
|
827
|
+
*
|
|
828
|
+
* 1. Line endings become LF (CRLF and a lone CR each become one LF).
|
|
829
|
+
* 2. The structured core is parsed as the kind's format says: the front
|
|
830
|
+
* matter, as the JSON subset of YAML, or the whole JSON object. The
|
|
831
|
+
* top-level member `field` is removed from it.
|
|
832
|
+
* 3. For a Markdown record the value hashed is the object
|
|
833
|
+
* `{"body": <the text after the closing --- line>, "core": <the core>}`;
|
|
834
|
+
* for a JSON record it is the core itself.
|
|
835
|
+
* 4. That value is written in JCS (keys sorted by UTF-16 code units, no
|
|
836
|
+
* whitespace, ECMAScript number and string forms) and hashed as UTF-8.
|
|
837
|
+
*
|
|
838
|
+
* So every value in the record and every byte of its body is sealed, the
|
|
839
|
+
* reviews, the author seal and the state included, while the key order,
|
|
840
|
+
* quoting and indentation of the front matter are not: the same record
|
|
841
|
+
* written another way has the same seal. A YAML comment in the front matter
|
|
842
|
+
* is not part of the record, so it is not sealed either (#3066). Returns null when
|
|
843
|
+
* the core can't be parsed.
|
|
844
|
+
*/
|
|
845
|
+
export function recordSeal(text: string, field: string, format: RecordFormat = "markdown-front-matter"): string | null {
|
|
846
|
+
const lf = text.replace(/\r\n?/g, "\n");
|
|
847
|
+
const parsed = parseRecord(format, lf);
|
|
848
|
+
if (!parsed.ok) return null;
|
|
849
|
+
const core = { ...parsed.value };
|
|
850
|
+
delete core[field];
|
|
851
|
+
const sealed = format === "json" ? core : { body: bodyText(lf), core };
|
|
852
|
+
return contentDigest(canonicalJson(sealed));
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/** The text below a Markdown file's front matter, with LF line endings: everything after the closing `---` line. */
|
|
856
|
+
export function bodyText(text: string): string {
|
|
857
|
+
const normalised = text.replace(/\r\n?/g, "\n");
|
|
858
|
+
const m = normalised.match(/^---\n[\s\S]*?\n---(?:\n|$)/);
|
|
859
|
+
return m ? normalised.slice(m[0].length) : "";
|
|
860
|
+
}
|
|
861
|
+
|
|
779
862
|
/**
|
|
780
863
|
* `text` with the top-level member `field` removed from a JSON record, by the
|
|
781
864
|
* rule a hand-editor follows for a JSON record (ws-053). With several fields
|
|
@@ -1282,6 +1365,14 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
1282
1365
|
if (drift) entry.warnings.push({ code: "source-transcript-drift", message: drift });
|
|
1283
1366
|
}
|
|
1284
1367
|
}
|
|
1368
|
+
if (kind.seal && !kind.session && entry.state !== null && (kind.closedStates ?? []).includes(entry.state)) {
|
|
1369
|
+
// A closed record's whole-file seal (#2546, ws-063). A session's is checked with its verdicts (#2673).
|
|
1370
|
+
const seal = fm.value[kind.seal.field];
|
|
1371
|
+
const actual = typeof seal === "string" ? recordSeal(text, kind.seal.field, kind.format) : null;
|
|
1372
|
+
if (typeof seal === "string" && actual !== seal) {
|
|
1373
|
+
entry.reasons.push({ code: "record-seal-mismatch", message: `${kind.seal.field} is ${seal}, but the record seals to ${actual}: it changed after it closed` });
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1285
1376
|
if (kind.pins) {
|
|
1286
1377
|
const cited = fm.value[kind.pins.field];
|
|
1287
1378
|
if (Array.isArray(cited) && cited.length === 0) {
|