@runbooks/schema 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -0
  3. package/dist/agent-skills.test.d.ts +1 -0
  4. package/dist/agent-skills.test.js +71 -0
  5. package/dist/capabilities.d.ts +40 -0
  6. package/dist/capabilities.js +211 -0
  7. package/dist/capabilities.test.d.ts +1 -0
  8. package/dist/capabilities.test.js +119 -0
  9. package/dist/content-classes.test.d.ts +1 -0
  10. package/dist/content-classes.test.js +77 -0
  11. package/dist/generate.cli.d.ts +2 -0
  12. package/dist/generate.cli.js +27 -0
  13. package/dist/generate.d.ts +54 -0
  14. package/dist/generate.js +133 -0
  15. package/dist/generate.test.d.ts +1 -0
  16. package/dist/generate.test.js +85 -0
  17. package/dist/generated.d.ts +101 -0
  18. package/dist/generated.js +9 -0
  19. package/dist/generated.ts +82 -0
  20. package/dist/hash.d.ts +36 -0
  21. package/dist/hash.js +102 -0
  22. package/dist/hash.test.d.ts +1 -0
  23. package/dist/hash.test.js +54 -0
  24. package/dist/index.d.ts +58 -0
  25. package/dist/index.js +61 -0
  26. package/dist/index.test.d.ts +1 -0
  27. package/dist/index.test.js +236 -0
  28. package/dist/interpolation.d.ts +11 -0
  29. package/dist/interpolation.js +34 -0
  30. package/dist/node.d.ts +41 -0
  31. package/dist/node.js +111 -0
  32. package/dist/p1.test.d.ts +1 -0
  33. package/dist/p1.test.js +87 -0
  34. package/dist/run-report.test.d.ts +1 -0
  35. package/dist/run-report.test.js +145 -0
  36. package/dist/served.d.ts +36 -0
  37. package/dist/served.js +128 -0
  38. package/dist/stats.d.ts +17 -0
  39. package/dist/stats.js +23 -0
  40. package/dist/stats.test.d.ts +1 -0
  41. package/dist/stats.test.js +32 -0
  42. package/dist/validate.d.ts +16 -0
  43. package/dist/validate.js +46 -0
  44. package/dist/validate.test.d.ts +1 -0
  45. package/dist/validate.test.js +61 -0
  46. package/dist/vocabularies.test.d.ts +1 -0
  47. package/dist/vocabularies.test.js +77 -0
  48. package/package.json +50 -0
  49. package/spec/v1/schema/attestation.json +62 -0
  50. package/spec/v1/schema/p0.json +383 -0
  51. package/spec/v1/schema/p1.json +408 -0
  52. package/spec/v1/schema/record.json +908 -0
  53. package/spec/v1/schema/run-report-envelope.json +25 -0
  54. package/spec/v1/schema/run-report.json +121 -0
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Type generation from the published schemas.
3
+ *
4
+ * The failure this prevents: a hand-written interface drifting from the schema we
5
+ * publish, so the site renders a field the specification does not define — or stops
6
+ * rendering one it does. Types are generated, and a schema change without regeneration
7
+ * fails CI.
8
+ *
9
+ * Deliberately narrow rather than a general JSON Schema compiler. It understands exactly
10
+ * the constructs our schemas use and **throws on anything else**, so adding an
11
+ * unsupported construct breaks generation loudly instead of quietly producing a type
12
+ * that is wrong in a way nobody notices. A general library gets this backwards: it
13
+ * guesses, and a guess about a contract is worse than a failure to compile one.
14
+ */
15
+ export class UnsupportedConstruct extends Error {
16
+ constructor(construct, path) {
17
+ super(`The generator does not understand "${construct}" at ${path}. Teach it that construct rather than hand-writing the type: a hand-written type is exactly the drift this exists to prevent.`);
18
+ this.name = "UnsupportedConstruct";
19
+ }
20
+ }
21
+ /** Keywords that constrain values without changing their TypeScript type. */
22
+ const IGNORED = new Set([
23
+ "$schema", "$id", "title", "description", "pattern", "format", "minLength",
24
+ "maxLength", "minimum", "maximum", "minItems", "maxItems", "minProperties",
25
+ "uniqueItems", "default", "required", "if", "then", "allOf", "examples",
26
+ ]);
27
+ function literal(value) {
28
+ return typeof value === "string" ? JSON.stringify(value) : String(value);
29
+ }
30
+ function isForbidden(schema) {
31
+ // `{ not: {} }` matches nothing, which is how a forbidden property is written.
32
+ return schema.not !== undefined && Object.keys(schema.not).length === 0;
33
+ }
34
+ function typeOf(schema, path, indent) {
35
+ for (const key of Object.keys(schema)) {
36
+ if (IGNORED.has(key))
37
+ continue;
38
+ if (!["type", "enum", "const", "properties", "additionalProperties", "items", "not", "anyOf", "oneOf", "$defs", "$ref"].includes(key)) {
39
+ throw new UnsupportedConstruct(key, path);
40
+ }
41
+ }
42
+ if (schema.$ref) {
43
+ // Our only refs are same-document `#/$defs/...`, which the caller inlines.
44
+ throw new UnsupportedConstruct("$ref", path);
45
+ }
46
+ if (schema.const !== undefined)
47
+ return literal(schema.const);
48
+ if (schema.enum)
49
+ return schema.enum.map(literal).join(" | ");
50
+ if (schema.anyOf || schema.oneOf) {
51
+ return (schema.anyOf ?? schema.oneOf)
52
+ .map((s, i) => typeOf(s, `${path}/${i}`, indent))
53
+ .join(" | ");
54
+ }
55
+ switch (schema.type) {
56
+ case "string": return "string";
57
+ case "number":
58
+ case "integer": return "number";
59
+ case "boolean": return "boolean";
60
+ case "array": return `${typeOf(schema.items ?? {}, `${path}/items`, indent)}[]`;
61
+ case "object": return objectType(schema, path, indent);
62
+ case undefined: return "unknown";
63
+ default: throw new UnsupportedConstruct(`type: ${schema.type}`, path);
64
+ }
65
+ }
66
+ function objectType(schema, path, indent) {
67
+ const inner = `${indent} `;
68
+ const required = new Set(schema.required ?? []);
69
+ const lines = [];
70
+ for (const [name, property] of Object.entries(schema.properties ?? {})) {
71
+ if (isForbidden(property)) {
72
+ lines.push(`${inner}/** Forbidden by the schema. ${property.description ?? ""} */`);
73
+ lines.push(`${inner}${key(name)}?: never;`);
74
+ continue;
75
+ }
76
+ if (property.description) {
77
+ lines.push(`${inner}/** ${property.description.replace(/\s+/g, " ")} */`);
78
+ }
79
+ const optional = required.has(name) ? "" : "?";
80
+ lines.push(`${inner}${key(name)}${optional}: ${typeOf(property, `${path}/${name}`, inner)};`);
81
+ }
82
+ if (schema.additionalProperties !== false) {
83
+ const value = typeof schema.additionalProperties === "object"
84
+ ? typeOf(schema.additionalProperties, `${path}/additionalProperties`, inner)
85
+ : "unknown";
86
+ lines.push(`${inner}[key: string]: ${value} | undefined;`);
87
+ }
88
+ return lines.length === 0 ? "Record<string, never>" : `{\n${lines.join("\n")}\n${indent}}`;
89
+ }
90
+ function key(name) {
91
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
92
+ }
93
+ /** Inline same-document `$defs` refs, which is the only form our schemas use. */
94
+ function inline(schema, defs) {
95
+ if (Array.isArray(schema))
96
+ return schema;
97
+ if (!schema || typeof schema !== "object")
98
+ return schema;
99
+ if (schema.$ref?.startsWith("#/$defs/")) {
100
+ const name = schema.$ref.slice("#/$defs/".length);
101
+ const target = defs[name];
102
+ if (!target)
103
+ throw new UnsupportedConstruct(`$ref to missing ${name}`, "#");
104
+ return inline(target, defs);
105
+ }
106
+ const out = {};
107
+ for (const [k, v] of Object.entries(schema)) {
108
+ out[k] = Array.isArray(v)
109
+ ? v.map((x) => inline(x, defs))
110
+ : v && typeof v === "object"
111
+ ? inline(v, defs)
112
+ : v;
113
+ }
114
+ return out;
115
+ }
116
+ export const GENERATED_BANNER = `/* eslint-disable */
117
+ /**
118
+ * GENERATED — do not edit.
119
+ *
120
+ * Run \`pnpm --filter @runbooks/schema generate\` after changing spec/v1/schema/*.json.
121
+ * A schema change without regeneration fails CI: a hand-written type that has drifted
122
+ * from the published schema is worse than no type, because it makes the drift invisible.
123
+ */
124
+ `;
125
+ export function generateTypes(schemas) {
126
+ const parts = schemas.map(({ name, schema }) => {
127
+ const resolved = inline(schema, schema.$defs ?? {});
128
+ const { $defs: _defs, ...withoutDefs } = resolved;
129
+ const body = typeOf(withoutDefs, `#${name}`, "");
130
+ return `export interface ${name} ${body}\n`;
131
+ });
132
+ return `${GENERATED_BANNER}\n${parts.join("\n")}`;
133
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { generateTypes, UnsupportedConstruct } from "./generate.js";
6
+ import { loadSchema, detectProfile } from "./node.js";
7
+ const HERE = dirname(fileURLToPath(import.meta.url));
8
+ /**
9
+ * P-02's acceptance, and the whole reason the generator exists: a schema change without
10
+ * regeneration fails CI. A hand-written type that has drifted from the published schema
11
+ * is worse than no type, because it makes the drift invisible.
12
+ */
13
+ describe("types cannot drift from the schema", () => {
14
+ it("the checked-in types are what the current schema generates", () => {
15
+ const generated = generateTypes([
16
+ { name: "RunbookP0", schema: loadSchema("P0") },
17
+ ]);
18
+ const committed = readFileSync(join(HERE, "generated.ts"), "utf8");
19
+ expect(generated, "spec/v1/schema changed without regenerating: run pnpm --filter @runbooks/schema generate").toBe(committed);
20
+ });
21
+ it("carries the schema's own descriptions into the types", () => {
22
+ const committed = readFileSync(join(HERE, "generated.ts"), "utf8");
23
+ expect(committed).toContain("A privilege request, not a description");
24
+ expect(committed).toContain("GENERATED");
25
+ });
26
+ it("marks a forbidden property as never rather than omitting it", () => {
27
+ const committed = readFileSync(join(HERE, "generated.ts"), "utf8");
28
+ // A record may not assert its own trust (§8). Typing it `never` says so at the call
29
+ // site; omitting it would just make the field unknown.
30
+ expect(committed).toMatch(/trust\?: never/);
31
+ });
32
+ });
33
+ /**
34
+ * A general compiler guesses at a construct it does not know. A guess about a contract
35
+ * is worse than a failure to compile one, so this generator throws instead.
36
+ */
37
+ describe("an unsupported construct fails loudly", () => {
38
+ it("throws rather than guessing", () => {
39
+ expect(() => generateTypes([{ name: "X", schema: { type: "object", propertyNames: { pattern: "^a" } } }])).toThrow(UnsupportedConstruct);
40
+ });
41
+ it("names the construct and says to teach the generator", () => {
42
+ try {
43
+ generateTypes([{ name: "X", schema: { type: "object", patternProperties: {} } }]);
44
+ expect.unreachable();
45
+ }
46
+ catch (error) {
47
+ expect(error.message).toContain("patternProperties");
48
+ expect(error.message).toMatch(/Teach it that construct/);
49
+ }
50
+ });
51
+ });
52
+ describe("profile detection reports the checked fact, not the claim", () => {
53
+ const base = {
54
+ name: "example-procedure",
55
+ description: "A procedure used to check profile detection.",
56
+ runbook: {
57
+ schema_version: "v1", profile: "P0", domain: "databases", targets: ["postgres"],
58
+ trigger: "alert", execution: "human-only", risk: "read-only", capabilities: [],
59
+ publisher: "std", lang: "en", license: "MIT",
60
+ source: { url: "https://example.com/r", fetched_at: "2026-09-03", upstream_state: "current" },
61
+ },
62
+ };
63
+ it("detects P0 for a prose record", () => {
64
+ expect(detectProfile(base)).toBe("P0");
65
+ });
66
+ it("detects P1 once there are steps", () => {
67
+ const p1 = {
68
+ ...base,
69
+ runbook: {
70
+ ...base.runbook,
71
+ profile: "P1",
72
+ steps: [{ id: "s1", kind: "action", title: "Do the thing", risk: "read-only", next: "end:success" }],
73
+ },
74
+ };
75
+ expect(detectProfile(p1)).toBe("P1");
76
+ });
77
+ // The facet has to be the checked fact: a record claiming P1 with no steps is a claim.
78
+ it("returns P0 for a record that claims P1 without steps", () => {
79
+ const claiming = { ...base, runbook: { ...base.runbook, profile: "P1" } };
80
+ expect(detectProfile(claiming)).toBe("P0");
81
+ });
82
+ it("returns undefined for something that is neither", () => {
83
+ expect(detectProfile({ name: "x" })).toBeUndefined();
84
+ });
85
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * GENERATED — do not edit.
3
+ *
4
+ * Run `pnpm --filter @runbooks/schema generate` after changing spec/v1/schema/*.json.
5
+ * A schema change without regeneration fails CI: a hand-written type that has drifted
6
+ * from the published schema is worse than no type, because it makes the drift invisible.
7
+ */
8
+ export interface RunbookP0 {
9
+ /** Agent Skills field. ASCII slug, never reused (§9). */
10
+ name: string;
11
+ /** Agent Skills field. What the procedure does and when to reach for it. */
12
+ description: string;
13
+ runbook: {
14
+ schema_version: "v1";
15
+ /** ULID assigned by the catalog. Immutable, survives renames (§9). Absent in an authored document. */
16
+ id?: string;
17
+ /** The version this snapshot is. Assigned by the catalog alongside `id` and `content_hash`; absent in an authored document, present in everything the catalog serves. */
18
+ semver?: string;
19
+ /** What this version is (RUNBOOK.md 9). Agent pins point at it and attestations bind to it. Computed over the document with this field and x-layout excluded, so it cannot be part of what it hashes. */
20
+ content_hash?: string;
21
+ profile: "P0" | "P1" | "P2";
22
+ /** Lowest client runtime profile permitted to follow this runbook (§13.3). A client refuses a document whose minimum exceeds what it enforces. */
23
+ min_runtime_profile?: "R0" | "R1" | "R2";
24
+ /** Closed list, changed only by RFC (§4). Navigation and URLs, not classification. */
25
+ domain: "incident-response" | "data-pipelines" | "llm-ops" | "agent-workflows" | "cloud-infra" | "databases" | "ci-cd" | "security";
26
+ targets: string[];
27
+ trigger: "alert" | "symptom" | "scheduled" | "manual" | "agent-invoked";
28
+ execution: "human-only" | "human-with-agent" | "agent-autonomous";
29
+ risk: "read-only" | "reversible-write" | "destructive" | "irreversible";
30
+ blast_radius?: "resource" | "service" | "cluster" | "tenant" | "global";
31
+ duration?: "<5m" | "5-30m" | ">30m";
32
+ /** A privilege request, not a description (§2). A client MUST check it against its own allowlist before execution (§12). */
33
+ capabilities: string[];
34
+ /** Version ranges per target, e.g. {"postgres": ">=14 <17"}. */
35
+ applies_to?: {
36
+ [key: string]: string | undefined;
37
+ };
38
+ /** Namespace (§7). Claimed, not granted, except `std` which is ours. */
39
+ publisher: string;
40
+ lang: string;
41
+ title_en?: string;
42
+ summary_en?: string;
43
+ translation_of?: string;
44
+ /** The catalog record this was adapted from, when the original is itself in the catalog. Attribution for an adaptation is its source (RUNBOOK.md 10.2), which every record carries; this is the edge that lets a reader see both and keeps deduplication from counting one procedure twice. Requiring it of an adaptation whose original is not in the catalog would be requiring a link to nothing. */
45
+ derived_from?: string;
46
+ /** How this record got here (RUNBOOK.md 15). `adapted` means the procedure derives from somebody else's document under a licence that allows it, whether a machine segmented it or a person rewrote it by hand. It is the one class with a rule of its own: it does not publish without a human confirming the step segmentation and the risk labelling, because an unreviewed machine adaptation is what that rule is guarding against. Its attribution is `source`, which is mandatory for every record. */
47
+ content_class?: "ingested" | "adapted" | "submitted" | "curated";
48
+ /** SPDX identifier. Mandatory: an undetectable licence means the record is not ingested at all (§10.2). */
49
+ license: string;
50
+ /** The pressure valve for anything that is not a facet: uncontrolled, unguaranteed, not filterable (§4). */
51
+ keywords?: string[];
52
+ /** Declared parameters. Interpolation may reference only these, and a missing required input aborts a run rather than interpolating empty (§5). */
53
+ inputs?: {
54
+ [key: string]: {
55
+ type: "string" | "number" | "boolean";
56
+ required?: boolean;
57
+ default?: unknown;
58
+ description?: string;
59
+ /** The value is a credential and is redacted wherever the run is shown to a human - an approval request above all (RUNBOOK.md 13, NG3). An approval channel is somewhere a secret should never arrive. */
60
+ secret?: boolean;
61
+ } | undefined;
62
+ };
63
+ /** Permitted but unconstrained at P0, which is prose. P1 requires it and constrains every item (spec/v1/schema/p1.json). It is declared here so that each profile is a strict superset of the one below: a P1 document must also be a valid P0 document, or every facet, hub and search filter would need a second code path. */
64
+ steps?: unknown[];
65
+ /** Provenance. A record without it does not validate (§10). */
66
+ source: {
67
+ url: string;
68
+ commit?: string;
69
+ fetched_at: string;
70
+ upstream_state: "current" | "changed" | "gone";
71
+ /** The hash of the upstream document as it was when this record was made. What a reindex compares against: without it, 'has upstream changed' can only be answered by re-deriving the record, and a derivation that differs for our own reasons would read as the source having moved. Never part of content_hash — noticing that a source moved is not a new version of this record. */
72
+ upstream_hash?: string;
73
+ /** When the upstream was last looked at, which is not when the record was made. A marker with no date is a claim about the present made at an unknown time. */
74
+ checked_at?: string;
75
+ };
76
+ /** Forbidden by the schema. Forbidden. Trust is computed from evidence plus a clock and is never asserted by a record (RUNBOOK.md 8). A document that declares its own trust is invalid, not merely ignored. */
77
+ trust?: never;
78
+ /** Forbidden by the schema. Forbidden. Attestations are issued and verified, never self-declared (RUNBOOK.md 8). They reach a record through the catalog, not through its frontmatter. */
79
+ attestations?: never;
80
+ /** Forbidden by the schema. Forbidden. There is no self-certification of any kind. See RUNBOOK.md 19 on why there is no score. */
81
+ profile_verified?: never;
82
+ /** Edges to other records (§22). A relation is authored: unlike an attestation, it is the author saying how this procedure stands to another, and only they can say it. Every reference must resolve to a record this catalog holds, or the record does not publish. */
83
+ relations?: {
84
+ /** requires: this cannot run until that one has. rollback_of: this undoes that one. escalates_to: when this hands off, it hands off to that one. part_of: this is a step of that larger procedure (§18.6's decomposition). */
85
+ kind: "requires" | "rollback_of" | "escalates_to" | "part_of";
86
+ /** publisher/slug, optionally pinned with @semver. `requires` and `rollback_of` must pin, because they assert what another record does and that is only true of a version. `part_of` and `escalates_to` must not, because they are structural and a pinned structure rots. */
87
+ ref: string;
88
+ /** One sentence for a reader. Optional, and the only free text in a relation. */
89
+ why?: string;
90
+ /** `part_of` only: the step of the parent this record expands (§18.6). Without it a decomposition is a claim of membership with no place in the parent's procedure, and a reader cannot tell which of twelve steps this is the detail of. */
91
+ step?: string;
92
+ }[];
93
+ /** Forbidden by the schema. Forbidden. The evidence for a trust level is what the catalog concluded while computing it (RUNBOOK.md 8, 18.5), so it arrives with the level and never from the document. Submit the record; the level and its evidence are added on the way out, and both are in schema/record.json. */
94
+ trust_evidence?: never;
95
+ /** Forbidden by the schema. Forbidden. A signature field in a served record is what checking the publisher's key concluded (RUNBOOK.md 8), not a claim the document makes about itself: a record that carried its own verdict would be asserting the one thing verification exists to decide. Sign the record with the publishing key instead. */
96
+ signature?: never;
97
+ };
98
+ /** The markdown body of the SKILL.md this record came from, carried so it participates in content_hash. Changing the prose of a procedure is a change to the procedure. */
99
+ body?: string;
100
+ [key: string]: unknown | undefined;
101
+ }
@@ -0,0 +1,9 @@
1
+ /* eslint-disable */
2
+ /**
3
+ * GENERATED — do not edit.
4
+ *
5
+ * Run `pnpm --filter @runbooks/schema generate` after changing spec/v1/schema/*.json.
6
+ * A schema change without regeneration fails CI: a hand-written type that has drifted
7
+ * from the published schema is worse than no type, because it makes the drift invisible.
8
+ */
9
+ export {};
@@ -0,0 +1,82 @@
1
+ /* eslint-disable */
2
+ /**
3
+ * GENERATED — do not edit.
4
+ *
5
+ * Run `pnpm --filter @runbooks/schema generate` after changing spec/v1/schema/*.json.
6
+ * A schema change without regeneration fails CI: a hand-written type that has drifted
7
+ * from the published schema is worse than no type, because it makes the drift invisible.
8
+ */
9
+
10
+ export interface RunbookP0 {
11
+ /** Agent Skills field. ASCII slug, never reused (§9). */
12
+ name: string;
13
+ /** Agent Skills field. What the procedure does and when to reach for it. */
14
+ description: string;
15
+ runbook: {
16
+ schema_version: "v1";
17
+ /** ULID assigned by the catalog. Immutable, survives renames (§9). Absent in an authored document. */
18
+ id?: string;
19
+ /** The version this snapshot is. Assigned by the catalog alongside `id` and `content_hash`; absent in an authored document, present in everything the catalog serves. */
20
+ semver?: string;
21
+ /** What this version is (RUNBOOK.md 9). Agent pins point at it and attestations bind to it. Computed over the document with this field and x-layout excluded, so it cannot be part of what it hashes. */
22
+ content_hash?: string;
23
+ profile: "P0" | "P1" | "P2";
24
+ /** Lowest client runtime profile permitted to follow this runbook (§13.3). A client refuses a document whose minimum exceeds what it enforces. */
25
+ min_runtime_profile?: "R0" | "R1" | "R2";
26
+ /** Closed list, changed only by RFC (§4). Navigation and URLs, not classification. */
27
+ domain: "incident-response" | "data-pipelines" | "llm-ops" | "agent-workflows" | "cloud-infra" | "databases" | "ci-cd" | "security";
28
+ targets: string[];
29
+ trigger: "alert" | "symptom" | "scheduled" | "manual" | "agent-invoked";
30
+ execution: "human-only" | "human-with-agent" | "agent-autonomous";
31
+ risk: "read-only" | "reversible-write" | "destructive" | "irreversible";
32
+ blast_radius?: "resource" | "service" | "cluster" | "tenant" | "global";
33
+ duration?: "<5m" | "5-30m" | ">30m";
34
+ /** A privilege request, not a description (§2). A client MUST check it against its own allowlist before execution (§12). */
35
+ capabilities: string[];
36
+ /** Version ranges per target, e.g. {"postgres": ">=14 <17"}. */
37
+ applies_to?: {
38
+ [key: string]: string | undefined;
39
+ };
40
+ /** Namespace (§7). Claimed, not granted, except `std` which is ours. */
41
+ publisher: string;
42
+ lang: string;
43
+ title_en?: string;
44
+ summary_en?: string;
45
+ translation_of?: string;
46
+ /** Set on an adaptation, pointing at the record it was lifted from (§10.2). Does not create a new Topic and does not dedup separately. */
47
+ derived_from?: string;
48
+ /** SPDX identifier. Mandatory: an undetectable licence means the record is not ingested at all (§10.2). */
49
+ license: string;
50
+ /** The pressure valve for anything that is not a facet: uncontrolled, unguaranteed, not filterable (§4). */
51
+ keywords?: string[];
52
+ /** Declared parameters. Interpolation may reference only these, and a missing required input aborts a run rather than interpolating empty (§5). */
53
+ inputs?: {
54
+ [key: string]: {
55
+ type: "string" | "number" | "boolean";
56
+ required?: boolean;
57
+ default?: unknown;
58
+ description?: string;
59
+ /** The value is a credential and is redacted wherever the run is shown to a human - an approval request above all (RUNBOOK.md 13, NG3). An approval channel is somewhere a secret should never arrive. */
60
+ secret?: boolean;
61
+ } | undefined;
62
+ };
63
+ /** Permitted but unconstrained at P0, which is prose. P1 requires it and constrains every item (spec/v1/schema/p1.json). It is declared here so that each profile is a strict superset of the one below: a P1 document must also be a valid P0 document, or every facet, hub and search filter would need a second code path. */
64
+ steps?: unknown[];
65
+ /** Provenance. A record without it does not validate (§10). */
66
+ source: {
67
+ url: string;
68
+ commit?: string;
69
+ fetched_at: string;
70
+ upstream_state: "current" | "changed" | "gone";
71
+ };
72
+ /** Forbidden by the schema. Forbidden. Trust is computed from evidence plus a clock and is never asserted by a record (RUNBOOK.md 8). A document that declares its own trust is invalid, not merely ignored. */
73
+ trust?: never;
74
+ /** Forbidden by the schema. Forbidden. Attestations are issued and verified, never self-declared (RUNBOOK.md 8). They reach a record through the catalog, not through its frontmatter. */
75
+ attestations?: never;
76
+ /** Forbidden by the schema. Forbidden. There is no self-certification of any kind. See RUNBOOK.md 19 on why there is no score. */
77
+ profile_verified?: never;
78
+ };
79
+ /** The markdown body of the SKILL.md this record came from, carried so it participates in content_hash. Changing the prose of a procedure is a change to the procedure. */
80
+ body?: string;
81
+ [key: string]: unknown | undefined;
82
+ }
package/dist/hash.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `content_hash`: what a version *is*.
3
+ *
4
+ * Agent pins point at it, attestations bind to it, and the resolver refuses a document
5
+ * whose bytes do not match it. So the computation has to be reproducible by anyone,
6
+ * which means canonicalization is specified rather than incidental.
7
+ *
8
+ * Web Crypto, not `node:crypto`: this package is a dependency of the supervisor core and
9
+ * of the graph package, which runs in a browser for the live editor. A Node-only import
10
+ * here would break both at a distance.
11
+ */
12
+ /**
13
+ * Key order, once, for every canonicalization this product has.
14
+ *
15
+ * By code point — which is what `jq -cS` emits, what Python's `sort_keys` and Go's
16
+ * `encoding/json` do, and what `spec/v1/signing.md` prints as the recipe a publisher signs
17
+ * with **without our code**. JavaScript's `<` compares UTF-16 code units instead, and the
18
+ * two disagree the moment a key above U+FFFF meets one in U+E000..U+FFFF: by code unit the
19
+ * astral key comes first, by code point it comes last. A publisher following the printed
20
+ * recipe would then produce bytes this product refuses to verify, which is the single
21
+ * failure a canonicalization exists to prevent.
22
+ *
23
+ * No key in this catalog is outside ASCII, so this reorders nothing that has been
24
+ * published. It is the rule that was wrong, and a rule about signatures is not worth
25
+ * leaving wrong until a document arrives that shows it.
26
+ */
27
+ export declare function byCodePoint(a: string, b: string): number;
28
+ /**
29
+ * Deterministic serialization: object keys sorted, excluded keys dropped at every depth,
30
+ * array order preserved because order is meaning in `steps[]`.
31
+ */
32
+ export declare function canonicalize(value: unknown): string;
33
+ /** The document as it is hashed: catalog-assigned fields removed at every depth. */
34
+ export declare function hashableDocument(document: unknown): unknown;
35
+ export declare function contentHash(document: unknown): Promise<string>;
36
+ export declare function matchesHash(document: unknown, expected: string): Promise<boolean>;
package/dist/hash.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * `content_hash`: what a version *is*.
3
+ *
4
+ * Agent pins point at it, attestations bind to it, and the resolver refuses a document
5
+ * whose bytes do not match it. So the computation has to be reproducible by anyone,
6
+ * which means canonicalization is specified rather than incidental.
7
+ *
8
+ * Web Crypto, not `node:crypto`: this package is a dependency of the supervisor core and
9
+ * of the graph package, which runs in a browser for the live editor. A Node-only import
10
+ * here would break both at a distance.
11
+ */
12
+ /**
13
+ * Excluded from the hash.
14
+ *
15
+ * `x-layout` is presentation rather than content (RUNBOOK.md 6): re-laying-out a graph is
16
+ * not a new version.
17
+ *
18
+ * The rest are assigned by the catalog rather than written by an author. `content_hash`
19
+ * cannot be part of what it hashes, and `trust` decays with the calendar — a hash that
20
+ * moved when a badge expired would make every pinned reference break on a quiet Tuesday.
21
+ *
22
+ * The upstream markers are the same kind of thing as trust: an observation the catalog
23
+ * made about the world, not content an author wrote. §3 is explicit that a moved
24
+ * upstream does not invalidate the indexed version — the record remains and the marker
25
+ * changes — and a marker inside the hash would make noticing that a source moved into a
26
+ * new version of our record, breaking every pin that referred to it.
27
+ *
28
+ * This set is shared by whatever computes a hash and whatever verifies one. They cannot
29
+ * be allowed to disagree: when they did, the catalog published records that failed their
30
+ * own verification.
31
+ */
32
+ const EXCLUDED = new Set([
33
+ "x-layout",
34
+ "content_hash",
35
+ "trust",
36
+ "trust_evidence",
37
+ "upstream_state",
38
+ "upstream_hash",
39
+ "checked_at",
40
+ /**
41
+ * The catalog's own identifier (§9). Assigned by us, not written by an author, and
42
+ * stable across renames — so it cannot be part of what identifies the content, and a
43
+ * record that gained one would otherwise stop hashing to its own published hash. This
44
+ * is the fourth field to arrive here for the same reason, which is why the reason is
45
+ * stated once at the top rather than four times.
46
+ */
47
+ "id",
48
+ ]);
49
+ /**
50
+ * Key order, once, for every canonicalization this product has.
51
+ *
52
+ * By code point — which is what `jq -cS` emits, what Python's `sort_keys` and Go's
53
+ * `encoding/json` do, and what `spec/v1/signing.md` prints as the recipe a publisher signs
54
+ * with **without our code**. JavaScript's `<` compares UTF-16 code units instead, and the
55
+ * two disagree the moment a key above U+FFFF meets one in U+E000..U+FFFF: by code unit the
56
+ * astral key comes first, by code point it comes last. A publisher following the printed
57
+ * recipe would then produce bytes this product refuses to verify, which is the single
58
+ * failure a canonicalization exists to prevent.
59
+ *
60
+ * No key in this catalog is outside ASCII, so this reorders nothing that has been
61
+ * published. It is the rule that was wrong, and a rule about signatures is not worth
62
+ * leaving wrong until a document arrives that shows it.
63
+ */
64
+ export function byCodePoint(a, b) {
65
+ const left = [...a];
66
+ const right = [...b];
67
+ for (let i = 0; i < left.length && i < right.length; i += 1) {
68
+ const difference = left[i].codePointAt(0) - right[i].codePointAt(0);
69
+ if (difference !== 0)
70
+ return difference;
71
+ }
72
+ return left.length - right.length;
73
+ }
74
+ /**
75
+ * Deterministic serialization: object keys sorted, excluded keys dropped at every depth,
76
+ * array order preserved because order is meaning in `steps[]`.
77
+ */
78
+ export function canonicalize(value) {
79
+ if (value === null || typeof value !== "object")
80
+ return JSON.stringify(value) ?? "null";
81
+ if (Array.isArray(value))
82
+ return `[${value.map(canonicalize).join(",")}]`;
83
+ const entries = Object.entries(value)
84
+ .filter(([k, v]) => !EXCLUDED.has(k) && v !== undefined)
85
+ .sort(([a], [b]) => byCodePoint(a, b));
86
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`).join(",")}}`;
87
+ }
88
+ function hex(buffer) {
89
+ return [...new Uint8Array(buffer)].map((b) => b.toString(16).padStart(2, "0")).join("");
90
+ }
91
+ /** The document as it is hashed: catalog-assigned fields removed at every depth. */
92
+ export function hashableDocument(document) {
93
+ return JSON.parse(canonicalize(document));
94
+ }
95
+ export async function contentHash(document) {
96
+ const bytes = new TextEncoder().encode(canonicalize(document));
97
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
98
+ return `sha256:${hex(digest)}`;
99
+ }
100
+ export async function matchesHash(document, expected) {
101
+ return (await contentHash(document)) === expected;
102
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { canonicalize, contentHash, matchesHash } from "./hash.js";
3
+ describe("canonicalization", () => {
4
+ it("does not depend on key order", () => {
5
+ expect(canonicalize({ b: 1, a: 2 })).toBe(canonicalize({ a: 2, b: 1 }));
6
+ });
7
+ // Order is meaning in steps[]: reordering a procedure is a different procedure.
8
+ it("preserves array order", () => {
9
+ expect(canonicalize([1, 2])).not.toBe(canonicalize([2, 1]));
10
+ });
11
+ it("sorts at every depth", () => {
12
+ expect(canonicalize({ x: { b: 1, a: 2 } })).toBe(canonicalize({ x: { a: 2, b: 1 } }));
13
+ });
14
+ // Presentation is not content: re-laying-out a graph must not create a new version.
15
+ it("ignores x-layout wherever it appears", () => {
16
+ const plain = { runbook: { steps: [{ id: "s1" }] } };
17
+ const laid = { runbook: { steps: [{ id: "s1", "x-layout": { x: 10, y: 20 } }] } };
18
+ expect(canonicalize(laid)).toBe(canonicalize(plain));
19
+ });
20
+ });
21
+ describe("content hash", () => {
22
+ it("is stable across runs and key order", async () => {
23
+ const a = await contentHash({ name: "x", runbook: { profile: "P0" } });
24
+ const b = await contentHash({ runbook: { profile: "P0" }, name: "x" });
25
+ expect(a).toBe(b);
26
+ expect(a).toMatch(/^sha256:[0-9a-f]{64}$/);
27
+ });
28
+ it("changes when content changes", async () => {
29
+ expect(await contentHash({ a: 1 })).not.toBe(await contentHash({ a: 2 }));
30
+ });
31
+ it("does not change when only layout moves", async () => {
32
+ const doc = { runbook: { steps: [{ id: "s1" }] } };
33
+ const moved = { runbook: { steps: [{ id: "s1", "x-layout": { x: 1, y: 2 } }] } };
34
+ expect(await matchesHash(moved, await contentHash(doc))).toBe(true);
35
+ });
36
+ });
37
+ /**
38
+ * The fourth field to be excluded for the same reason, and the second time a
39
+ * catalog-assigned field was added after hashing and broke a record's ability to verify
40
+ * itself. Assigned by us is not content.
41
+ */
42
+ describe("a catalog-assigned identifier is not content", () => {
43
+ it("ignores id wherever it appears", async () => {
44
+ const bare = { runbook: { semver: "1.0.0" } };
45
+ const identified = { runbook: { semver: "1.0.0", id: `rb_${"0".repeat(26)}` } };
46
+ expect(await contentHash(identified)).toBe(await contentHash(bare));
47
+ });
48
+ it("keeps a served record hashing to what it declares", async () => {
49
+ const served = { name: "x", runbook: { semver: "1.0.0", id: `rb_${"0".repeat(26)}`, trust: "T2" } };
50
+ const declared = await contentHash(served);
51
+ expect(await contentHash({ ...served, runbook: { ...served.runbook, content_hash: declared } }))
52
+ .toBe(declared);
53
+ });
54
+ });