@tiphys/kernel 0.0.0 → 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 (181) hide show
  1. package/AGENTS.md +611 -0
  2. package/LICENSE +202 -0
  3. package/assurance-modes.yaml +278 -0
  4. package/checklists/clean-room.yaml +325 -0
  5. package/checklists/env-failure-diagnosis.yaml +68 -0
  6. package/checklists/flake-playbook.yaml +68 -0
  7. package/checklists/hazard-review.yaml +144 -0
  8. package/checklists/plan-review.yaml +103 -0
  9. package/dist/bin/tiphys.d.ts +2 -0
  10. package/dist/bin/tiphys.js +14 -0
  11. package/dist/src/brief.d.ts +32 -0
  12. package/dist/src/brief.js +59 -0
  13. package/dist/src/checklists.d.ts +169 -0
  14. package/dist/src/checklists.js +310 -0
  15. package/dist/src/checks.d.ts +828 -0
  16. package/dist/src/checks.js +3314 -0
  17. package/dist/src/cli.d.ts +9 -0
  18. package/dist/src/cli.js +57 -0
  19. package/dist/src/commands/brief.d.ts +92 -0
  20. package/dist/src/commands/brief.js +320 -0
  21. package/dist/src/commands/checklist.d.ts +42 -0
  22. package/dist/src/commands/checklist.js +168 -0
  23. package/dist/src/commands/doctor.d.ts +35 -0
  24. package/dist/src/commands/doctor.js +605 -0
  25. package/dist/src/commands/gates.d.ts +9 -0
  26. package/dist/src/commands/gates.js +360 -0
  27. package/dist/src/commands/init.d.ts +30 -0
  28. package/dist/src/commands/init.js +128 -0
  29. package/dist/src/commands/lock.d.ts +1 -0
  30. package/dist/src/commands/lock.js +229 -0
  31. package/dist/src/commands/mode.d.ts +40 -0
  32. package/dist/src/commands/mode.js +134 -0
  33. package/dist/src/commands/plan.d.ts +20 -0
  34. package/dist/src/commands/plan.js +105 -0
  35. package/dist/src/commands/pool.d.ts +1 -0
  36. package/dist/src/commands/pool.js +128 -0
  37. package/dist/src/commands/spawn.d.ts +1 -0
  38. package/dist/src/commands/spawn.js +146 -0
  39. package/dist/src/commands/status.d.ts +19 -0
  40. package/dist/src/commands/status.js +120 -0
  41. package/dist/src/commands/teardown.d.ts +1 -0
  42. package/dist/src/commands/teardown.js +79 -0
  43. package/dist/src/commands/tuition.d.ts +21 -0
  44. package/dist/src/commands/tuition.js +218 -0
  45. package/dist/src/commands/validate.d.ts +78 -0
  46. package/dist/src/commands/validate.js +360 -0
  47. package/dist/src/commands/watch.d.ts +1 -0
  48. package/dist/src/commands/watch.js +172 -0
  49. package/dist/src/exec/env.d.ts +128 -0
  50. package/dist/src/exec/env.js +190 -0
  51. package/dist/src/fleet.d.ts +51 -0
  52. package/dist/src/fleet.js +80 -0
  53. package/dist/src/gates/adapters/http-json.d.ts +5 -0
  54. package/dist/src/gates/adapters/http-json.js +283 -0
  55. package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
  56. package/dist/src/gates/adapters/migrations-command.js +373 -0
  57. package/dist/src/gates/citations.d.ts +408 -0
  58. package/dist/src/gates/citations.js +1163 -0
  59. package/dist/src/gates/coverage.d.ts +284 -0
  60. package/dist/src/gates/coverage.js +701 -0
  61. package/dist/src/gates/credentials.d.ts +74 -0
  62. package/dist/src/gates/credentials.js +533 -0
  63. package/dist/src/gates/deploy.d.ts +1 -0
  64. package/dist/src/gates/deploy.js +33 -0
  65. package/dist/src/gates/manifest.d.ts +99 -0
  66. package/dist/src/gates/manifest.js +208 -0
  67. package/dist/src/gates/migrations.d.ts +1 -0
  68. package/dist/src/gates/migrations.js +36 -0
  69. package/dist/src/gates/pin.d.ts +114 -0
  70. package/dist/src/gates/pin.js +154 -0
  71. package/dist/src/gates/red-witness.d.ts +22 -0
  72. package/dist/src/gates/red-witness.js +390 -0
  73. package/dist/src/gates/release.d.ts +283 -0
  74. package/dist/src/gates/release.js +820 -0
  75. package/dist/src/gates/result.d.ts +116 -0
  76. package/dist/src/gates/result.js +91 -0
  77. package/dist/src/gates/run.d.ts +566 -0
  78. package/dist/src/gates/run.js +1536 -0
  79. package/dist/src/gates/schemas/citation-config.schema.json +59 -0
  80. package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
  81. package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
  82. package/dist/src/gates/schemas/gate-result.schema.json +160 -0
  83. package/dist/src/gates/schemas/phase-declaration.schema.json +42 -0
  84. package/dist/src/gates/schemas/release-record.schema.json +119 -0
  85. package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
  86. package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
  87. package/dist/src/gates/scope.d.ts +131 -0
  88. package/dist/src/gates/scope.js +1018 -0
  89. package/dist/src/gates/suite.d.ts +217 -0
  90. package/dist/src/gates/suite.js +927 -0
  91. package/dist/src/gates/validate.d.ts +121 -0
  92. package/dist/src/gates/validate.js +414 -0
  93. package/dist/src/hooks.d.ts +32 -0
  94. package/dist/src/hooks.js +62 -0
  95. package/dist/src/liveness.d.ts +321 -0
  96. package/dist/src/liveness.js +396 -0
  97. package/dist/src/lock.d.ts +178 -0
  98. package/dist/src/lock.js +500 -0
  99. package/dist/src/modes.d.ts +149 -0
  100. package/dist/src/modes.js +258 -0
  101. package/dist/src/path-identity.d.ts +2 -0
  102. package/dist/src/path-identity.js +10 -0
  103. package/dist/src/plan.d.ts +73 -0
  104. package/dist/src/plan.js +153 -0
  105. package/dist/src/pool.d.ts +130 -0
  106. package/dist/src/pool.js +721 -0
  107. package/dist/src/roles.d.ts +430 -0
  108. package/dist/src/roles.js +734 -0
  109. package/dist/src/spawn.d.ts +177 -0
  110. package/dist/src/spawn.js +332 -0
  111. package/dist/src/status.d.ts +91 -0
  112. package/dist/src/status.js +119 -0
  113. package/dist/src/task.d.ts +264 -0
  114. package/dist/src/task.js +305 -0
  115. package/dist/src/teardown.d.ts +32 -0
  116. package/dist/src/teardown.js +314 -0
  117. package/dist/src/tuition.d.ts +159 -0
  118. package/dist/src/tuition.js +311 -0
  119. package/dist/src/validate.d.ts +230 -0
  120. package/dist/src/validate.js +732 -0
  121. package/dist/src/version.d.ts +3 -0
  122. package/dist/src/version.js +38 -0
  123. package/dist/src/watcher.d.ts +275 -0
  124. package/dist/src/watcher.js +859 -0
  125. package/dist/src/witness/run.d.ts +274 -0
  126. package/dist/src/witness/run.js +1327 -0
  127. package/dist/src/witness/spec.d.ts +102 -0
  128. package/dist/src/witness/spec.js +253 -0
  129. package/dist/tsconfig.src.tsbuildinfo +1 -0
  130. package/gate-registry.yaml +390 -0
  131. package/gates.manifest.json +195 -0
  132. package/package.json +57 -3
  133. package/role-model-config.yaml +88 -0
  134. package/roles/README.md +128 -0
  135. package/roles/_shared-dispatch-contract.md +87 -0
  136. package/roles/adversarial-plan-reviewer.md +80 -0
  137. package/roles/clean-room-reviewer.md +140 -0
  138. package/roles/implementer.md +460 -0
  139. package/roles/investigator.md +138 -0
  140. package/roles/plan-writer.md +95 -0
  141. package/schemas/README.md +81 -0
  142. package/schemas/assurance-modes.schema.json +264 -0
  143. package/schemas/charter.schema.json +166 -0
  144. package/schemas/checklist.schema.json +114 -0
  145. package/schemas/decision-record.schema.json +88 -0
  146. package/schemas/final-report.schema.json +90 -0
  147. package/schemas/finding.schema.json +106 -0
  148. package/schemas/gate-registry.schema.json +260 -0
  149. package/schemas/mechanism-index.schema.json +94 -0
  150. package/schemas/plan.schema.json +300 -0
  151. package/schemas/report.schema.json +579 -0
  152. package/schemas/role-brief.schema.json +105 -0
  153. package/schemas/role-model-config.schema.json +90 -0
  154. package/schemas/status-line.schema.json +40 -0
  155. package/schemas/tuition.schema.json +191 -0
  156. package/schemas/verdict.schema.json +289 -0
  157. package/schemas/work-history.schema.json +183 -0
  158. package/templates/charter.example.yaml +54 -0
  159. package/templates/decision-record.example.yaml +27 -0
  160. package/templates/final-report.example.yaml +80 -0
  161. package/templates/plan.example.yaml +87 -0
  162. package/templates/report.example.yaml +236 -0
  163. package/templates/warnings.md +74 -0
  164. package/templates/work-history.example.yaml +185 -0
  165. package/tuition/README.md +76 -0
  166. package/tuition/T-001.yaml +48 -0
  167. package/tuition/T-002.yaml +51 -0
  168. package/tuition/T-003.yaml +100 -0
  169. package/tuition/T-004.yaml +52 -0
  170. package/tuition/T-005.yaml +72 -0
  171. package/tuition/T-006.yaml +81 -0
  172. package/tuition/T-007.yaml +56 -0
  173. package/tuition/T-008.yaml +111 -0
  174. package/tuition/T-009.yaml +50 -0
  175. package/tuition/T-015.yaml +36 -0
  176. package/tuition/T-016.yaml +36 -0
  177. package/tuition/T-017.yaml +46 -0
  178. package/tuition/T-018.yaml +84 -0
  179. package/tuition/T-021.yaml +40 -0
  180. package/tuition/T-022.yaml +36 -0
  181. package/tuition/mechanism-index.yaml +256 -0
@@ -0,0 +1,99 @@
1
+ import type { SchemaDocument } from "./validate.ts";
2
+ /**
3
+ * THE GATE MANIFEST (kernel plan M2, M2-P1 step 6).
4
+ *
5
+ * One manifest per repository. It declares which gates exist, how each is
6
+ * invoked, what one unit of its work is, whether an unmet precondition
7
+ * fails the run, and which commands can destroy work.
8
+ *
9
+ * SCHEMA RESOLUTION FROM SOURCE AND FROM dist/ (step 5). The schema
10
+ * documents sit beside this module at `./schemas/*.json` and are resolved
11
+ * through `import.meta.url`, so the same code finds
12
+ * `src/gates/schemas/...` when run from source and
13
+ * `dist/src/gates/schemas/...` when run from the compiled entry. `tsc` does
14
+ * not copy JSON, which was MEASURED rather than assumed (the measurement is
15
+ * in the work history), so the build script copies the directory into
16
+ * `dist/` and criterion 10 witnesses both documents in `npm pack` output.
17
+ * They are deliberately NOT imported as modules: `resolveJsonModule` is off
18
+ * in both tsconfigs, and turning it on would inline the schema into the
19
+ * bundle and make the shipped document and the enforced document two
20
+ * different things.
21
+ *
22
+ * M2-C-6: the manifest path comes from the caller, so it is read through the
23
+ * DELIVERED `readRegularFileIfPresent`. A named pipe at the manifest path
24
+ * reports `error` naming the path and the type, and never blocks.
25
+ */
26
+ export type PreconditionKind = "file-exists" | "file-absent" | "branch-matches" | "diff-touches" | "command-exit-zero";
27
+ export declare const PRECONDITION_KINDS: readonly PreconditionKind[];
28
+ export type RunParameter = "base" | "head" | "phase";
29
+ export interface PreconditionSpec {
30
+ id: string;
31
+ kind: PreconditionKind;
32
+ path?: string;
33
+ pattern?: string;
34
+ paths?: string[];
35
+ command?: string[];
36
+ }
37
+ export interface GateEntry {
38
+ id: string;
39
+ command: string[];
40
+ unitLabel: string;
41
+ applicability: "required" | "conditional";
42
+ parameters?: RunParameter[];
43
+ precondition?: PreconditionSpec;
44
+ modes?: string[];
45
+ }
46
+ export interface GateManifest {
47
+ version: number;
48
+ gates: GateEntry[];
49
+ destructiveCommands: string[];
50
+ }
51
+ export type ManifestLoad = {
52
+ ok: true;
53
+ manifest: GateManifest;
54
+ sha256: string;
55
+ body: string;
56
+ } | {
57
+ ok: false;
58
+ reason: string;
59
+ diagnostics: string[];
60
+ };
61
+ export declare function manifestSchema(): SchemaDocument;
62
+ export declare function resultSchema(): SchemaDocument;
63
+ /**
64
+ * Absolute paths of every schema document `manifest-self-check` validates.
65
+ *
66
+ * ENUMERATED, NOT ENUMERABLE-BY-HAND (fix round, M2-P1). This milestone's
67
+ * plan (kernel-plan-v1.md) adds four more schema documents to this same
68
+ * directory across M2-P2, M2-P4, M2-P5, M2-P6 and M2-P7. A fixed two-entry
69
+ * list here was the mechanism, not a fluke: it named the two documents that
70
+ * existed at the time this function was written and would keep returning
71
+ * exactly those two forever, so manifest-self-check would silently stop
72
+ * validating every document a later phase ships, while test/gates.test.ts's
73
+ * independent readdir-based parity check reddens correctly the moment a
74
+ * phase adds one (CR-812's test, `test/gates.test.ts` near line 2378). The
75
+ * fix is to make the SAME enumeration the source of truth here: read the
76
+ * directory beside this module, keep entries matching `*.schema.json`, and
77
+ * sort them by filename so the order is deterministic across platforms and
78
+ * across runs (readdir order is not guaranteed by POSIX). Every document a
79
+ * phase drops into `src/gates/schemas/` is then validated automatically,
80
+ * and the parity property the test checks becomes structural rather than
81
+ * a thing two call sites have to be kept in sync by hand.
82
+ */
83
+ export declare function schemaDocumentPaths(): string[];
84
+ /**
85
+ * Validate an already-parsed manifest document. Returns the diagnostics in
86
+ * the contract's deterministic order (by pointer, then message).
87
+ */
88
+ export declare function validateManifestDocument(document: unknown): string[];
89
+ /** Validate an already-parsed gate result record. */
90
+ export declare function validateResultDocument(document: unknown): string[];
91
+ /**
92
+ * Load and validate a manifest from a path the caller supplied.
93
+ *
94
+ * Fail closed at every step: not a regular file, absent, unparseable, or
95
+ * schema-invalid all produce `ok: false` with a reason. None of them
96
+ * produces a manifest with fewer gates in it, which is the failure this
97
+ * whole milestone exists to make impossible.
98
+ */
99
+ export declare function loadManifest(path: string): ManifestLoad;
@@ -0,0 +1,208 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdirSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { readRegularFileIfPresent } from "../task.js";
5
+ import { DIAGNOSTIC_MESSAGES, formatDiagnostics, loadSchema, validate, } from "./validate.js";
6
+ export const PRECONDITION_KINDS = [
7
+ "file-exists",
8
+ "file-absent",
9
+ "branch-matches",
10
+ "diff-touches",
11
+ "command-exit-zero",
12
+ ];
13
+ const schemaDirectory = new URL("./schemas/", import.meta.url);
14
+ function readSchemaDocument(name) {
15
+ const path = fileURLToPath(new URL(name, schemaDirectory));
16
+ const read = readRegularFileIfPresent(path);
17
+ if (read.kind !== "read") {
18
+ throw new Error(read.kind === "absent"
19
+ ? `schema document ${path} is missing from this installation`
20
+ : read.reason);
21
+ }
22
+ const parsed = JSON.parse(read.body);
23
+ const loaded = loadSchema(parsed, name);
24
+ if (!loaded.ok) {
25
+ throw new Error(loaded.reason);
26
+ }
27
+ return loaded.schema;
28
+ }
29
+ /** The two schema documents this milestone ships, loaded once. */
30
+ let cachedManifestSchema;
31
+ let cachedResultSchema;
32
+ export function manifestSchema() {
33
+ if (cachedManifestSchema === undefined) {
34
+ cachedManifestSchema = readSchemaDocument("gate-manifest.schema.json");
35
+ }
36
+ return cachedManifestSchema;
37
+ }
38
+ export function resultSchema() {
39
+ if (cachedResultSchema === undefined) {
40
+ cachedResultSchema = readSchemaDocument("gate-result.schema.json");
41
+ }
42
+ return cachedResultSchema;
43
+ }
44
+ /**
45
+ * Absolute paths of every schema document `manifest-self-check` validates.
46
+ *
47
+ * ENUMERATED, NOT ENUMERABLE-BY-HAND (fix round, M2-P1). This milestone's
48
+ * plan (kernel-plan-v1.md) adds four more schema documents to this same
49
+ * directory across M2-P2, M2-P4, M2-P5, M2-P6 and M2-P7. A fixed two-entry
50
+ * list here was the mechanism, not a fluke: it named the two documents that
51
+ * existed at the time this function was written and would keep returning
52
+ * exactly those two forever, so manifest-self-check would silently stop
53
+ * validating every document a later phase ships, while test/gates.test.ts's
54
+ * independent readdir-based parity check reddens correctly the moment a
55
+ * phase adds one (CR-812's test, `test/gates.test.ts` near line 2378). The
56
+ * fix is to make the SAME enumeration the source of truth here: read the
57
+ * directory beside this module, keep entries matching `*.schema.json`, and
58
+ * sort them by filename so the order is deterministic across platforms and
59
+ * across runs (readdir order is not guaranteed by POSIX). Every document a
60
+ * phase drops into `src/gates/schemas/` is then validated automatically,
61
+ * and the parity property the test checks becomes structural rather than
62
+ * a thing two call sites have to be kept in sync by hand.
63
+ */
64
+ export function schemaDocumentPaths() {
65
+ const directory = fileURLToPath(schemaDirectory);
66
+ return readdirSync(directory)
67
+ .filter((name) => name.endsWith(".schema.json"))
68
+ .sort()
69
+ .map((name) => fileURLToPath(new URL(name, schemaDirectory)));
70
+ }
71
+ /**
72
+ * The kind-specific required fields, checked here rather than in the schema.
73
+ *
74
+ * WHY NOT IN THE SCHEMA. Expressing "kind file-exists requires path" needs
75
+ * conditional composition (`if`/`then`, or `oneOf`), and both are outside
76
+ * M2-D-04's closed keyword set, which DR-0013 decided against growing. So
77
+ * the check lives in code and emits the SAME `INVALID <pointer> <message>`
78
+ * diagnostics, which keeps one diagnostic contract rather than two. When
79
+ * M3-P1 swaps in Ajv the constraint can move into the schema without any
80
+ * consumer noticing, because the output line is unchanged.
81
+ */
82
+ const REQUIRED_FIELD_FOR_KIND = {
83
+ "file-exists": "path",
84
+ "file-absent": "path",
85
+ "branch-matches": "pattern",
86
+ "diff-touches": "paths",
87
+ "command-exit-zero": "command",
88
+ };
89
+ function preconditionFieldDiagnostics(manifest) {
90
+ const found = [];
91
+ const gates = manifest.gates;
92
+ if (!Array.isArray(gates)) {
93
+ return found;
94
+ }
95
+ for (let index = 0; index < gates.length; index += 1) {
96
+ const gate = gates[index];
97
+ const precondition = gate?.precondition;
98
+ if (precondition === undefined || precondition === null) {
99
+ continue;
100
+ }
101
+ const kind = precondition["kind"];
102
+ if (typeof kind !== "string" || !PRECONDITION_KINDS.includes(kind)) {
103
+ continue;
104
+ }
105
+ const field = REQUIRED_FIELD_FOR_KIND[kind];
106
+ if (precondition[field] === undefined) {
107
+ found.push({
108
+ pointer: `#/gates/${index}/precondition/${field}`,
109
+ message: DIAGNOSTIC_MESSAGES.required(field),
110
+ });
111
+ }
112
+ }
113
+ return found;
114
+ }
115
+ /** Every duplicate gate id, as diagnostics. A registry keyed by name cannot hold two. */
116
+ function duplicateIdDiagnostics(manifest) {
117
+ const found = [];
118
+ const gates = manifest.gates;
119
+ if (!Array.isArray(gates)) {
120
+ return found;
121
+ }
122
+ const seen = new Set();
123
+ for (let index = 0; index < gates.length; index += 1) {
124
+ const id = gates[index]?.id;
125
+ if (typeof id !== "string") {
126
+ continue;
127
+ }
128
+ if (seen.has(id)) {
129
+ found.push({
130
+ pointer: `#/gates/${index}/id`,
131
+ message: DIAGNOSTIC_MESSAGES.duplicateId(JSON.stringify(id)),
132
+ });
133
+ }
134
+ seen.add(id);
135
+ }
136
+ return found;
137
+ }
138
+ /**
139
+ * Validate an already-parsed manifest document. Returns the diagnostics in
140
+ * the contract's deterministic order (by pointer, then message).
141
+ */
142
+ export function validateManifestDocument(document) {
143
+ const diagnostics = [
144
+ ...validate(manifestSchema(), document),
145
+ ...preconditionFieldDiagnostics(document),
146
+ ...duplicateIdDiagnostics(document),
147
+ ];
148
+ diagnostics.sort((a, b) => {
149
+ if (a.pointer !== b.pointer) {
150
+ return a.pointer < b.pointer ? -1 : 1;
151
+ }
152
+ if (a.message === b.message) {
153
+ return 0;
154
+ }
155
+ return a.message < b.message ? -1 : 1;
156
+ });
157
+ return formatDiagnostics(diagnostics);
158
+ }
159
+ /** Validate an already-parsed gate result record. */
160
+ export function validateResultDocument(document) {
161
+ return formatDiagnostics(validate(resultSchema(), document));
162
+ }
163
+ /**
164
+ * Load and validate a manifest from a path the caller supplied.
165
+ *
166
+ * Fail closed at every step: not a regular file, absent, unparseable, or
167
+ * schema-invalid all produce `ok: false` with a reason. None of them
168
+ * produces a manifest with fewer gates in it, which is the failure this
169
+ * whole milestone exists to make impossible.
170
+ */
171
+ export function loadManifest(path) {
172
+ const read = readRegularFileIfPresent(path);
173
+ if (read.kind === "absent") {
174
+ return {
175
+ ok: false,
176
+ reason: `manifest ${path} does not exist`,
177
+ diagnostics: [],
178
+ };
179
+ }
180
+ if (read.kind === "refused") {
181
+ return { ok: false, reason: read.reason, diagnostics: [] };
182
+ }
183
+ let parsed;
184
+ try {
185
+ parsed = JSON.parse(read.body);
186
+ }
187
+ catch (error) {
188
+ return {
189
+ ok: false,
190
+ reason: `manifest ${path} does not parse as JSON: ${error.message}`,
191
+ diagnostics: [],
192
+ };
193
+ }
194
+ const diagnostics = validateManifestDocument(parsed);
195
+ if (diagnostics.length > 0) {
196
+ return {
197
+ ok: false,
198
+ reason: `manifest ${path} is not a valid gate manifest`,
199
+ diagnostics,
200
+ };
201
+ }
202
+ return {
203
+ ok: true,
204
+ manifest: parsed,
205
+ sha256: createHash("sha256").update(read.body).digest("hex"),
206
+ body: read.body,
207
+ };
208
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { runReleaseGate } from "./release.js";
4
+ /**
5
+ * THE MIGRATIONS GATE ENTRY (kernel plan M2, M2-P7 step 4). A thin entry
6
+ * point that names the declared `migrations` verification and calls the one
7
+ * contract module, `src/gates/release.ts`. Deploy verification and
8
+ * migration verification are two INSTANCES of one post-merge verification
9
+ * contract, not two contracts (DR-0014, investigation section 1): the two
10
+ * registry entries exist so that a project can declare one and not the
11
+ * other, and so the evidence bundle shows two lines rather than one
12
+ * aggregate that hides which half was skipped.
13
+ *
14
+ * The two inventories are not symmetric (plan criterion 8): a declared
15
+ * REPOSITORY migrations location containing zero migrations is
16
+ * not-applicable, but a non-empty repository inventory with an EMPTY
17
+ * applied inventory is red, never not-applicable, because that is the
18
+ * recorded incident exactly ("migrations skipped by a flake while the code
19
+ * deployed anyway"). The asymmetry lives in the migrations-command adapter;
20
+ * this entry is shape-identical to deploy's.
21
+ */
22
+ function isMain() {
23
+ const argv1 = process.argv[1];
24
+ if (argv1 === undefined) {
25
+ return false;
26
+ }
27
+ try {
28
+ return realpathSync(argv1) === realpathSync(fileURLToPath(import.meta.url));
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ if (isMain()) {
35
+ process.exitCode = await runReleaseGate("migrations", process.argv.slice(2));
36
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * RUN PINNING (kernel plan M2, M2-P1 step 3; M2-C-5; M2-D-06).
3
+ *
4
+ * T-004 lesson 3: "a test run is only evidence if you can prove what it
5
+ * ran". In that incident no artifact recorded the content of the source
6
+ * files during a failing run, so a forensic reconstruction was the only way
7
+ * to establish that the tree had been rewritten underneath the run, and the
8
+ * root cause was never awarded.
9
+ *
10
+ * A pin is that artifact: the file set of the tree that was actually
11
+ * executed, with sha256, size and mtimeMs per file, taken at the start and
12
+ * at the end of the run. ANY difference in ANY of the four fields (the
13
+ * path's presence included) is a difference and makes the run's record
14
+ * `error`.
15
+ *
16
+ * WHY ctimeMs IS PINNED TOO (CR-809, added in fix round 1). mtime is
17
+ * SETTABLE FROM USERSPACE. `cp -p`, `rsync -a` and `tar -x` all restore it
18
+ * through `utimensat` at nanosecond precision, so a rewrite performed by any
19
+ * of those three ordinary commands passes a pin of {sha256, size, mtimeMs}
20
+ * without a mark, and T-004's incident is a rewrite. ctime is the inode
21
+ * change time: no userspace call sets it, and `utimensat` BUMPS it, so the
22
+ * very act of restoring mtime is what makes the change visible. It also
23
+ * catches replace-by-rename, which changes the inode.
24
+ *
25
+ * This is a strict ADDITION to M2-C-5's four fields, never a substitution:
26
+ * the constraint says any difference in file set, sha256, size or mtime makes
27
+ * the record `error`, and pinning a fifth field can only make more runs
28
+ * `error`, never fewer. M2-P2 criterion 7 and M2-P3 criterion 8 are pin
29
+ * witnesses and should be read against five fields, not four.
30
+ *
31
+ * The residue, stated rather than left to be discovered: ctime is not
32
+ * forgeable from userspace, but it is not a cryptographic seal either. A
33
+ * privileged actor with raw device access can write any inode field it
34
+ * likes. That is outside anything this kernel can measure.
35
+ *
36
+ * WHY mtimeMs IS PINNED AND A CONTENT HASH IS NOT ENOUGH. T-004's forensics
37
+ * describe a BYTE-IDENTICAL rewrite: `src/lock.ts` was replaced with
38
+ * pristine content 42.8 seconds into the failing run. A content-only pin
39
+ * passes that unchanged, and the whole value of the pin would be lost at
40
+ * precisely the shape the incident actually had. M2-P2 criterion 7 turns
41
+ * that into a witness rather than an assertion.
42
+ *
43
+ * M2-C-6 IS OBEYED BY REUSE, NOT BY REIMPLEMENTATION. The walk reads paths
44
+ * it did not create, so every open is preceded by the DELIVERED
45
+ * `classifyEntry` from src/task.ts. There is no second copy of "may this
46
+ * path be opened" here: T-005 records that this project paid for that class
47
+ * twice because the second component reimplemented the mechanism. The one
48
+ * `readFileSync` in this module sits on the line after the probe, and it is
49
+ * the only read of an externally supplied path anywhere in `src/gates/` that
50
+ * is not `readRegularFileIfPresent`, which returns UTF-8 text and therefore
51
+ * cannot hash a binary file honestly.
52
+ *
53
+ * `lstatSync` and `statSync` here are PROBES, not opens: they are the same
54
+ * two syscalls `classifyEntry` itself performs, they answer questions about
55
+ * the directory entry, and neither can block on a named pipe. They are used
56
+ * only to decide directory-ness and to read size and mtime, never to decide
57
+ * whether a path may be opened, which stays the delivered helper's job.
58
+ *
59
+ * FAIL CLOSED ON ANYTHING THAT IS NOT A REGULAR FILE OR A REAL DIRECTORY.
60
+ * A named pipe, socket or device node inside a pinned root makes `takePin`
61
+ * THROW with a reason naming the path and the observed type. It never opens
62
+ * the path, so it never blocks, and the caller turns the throw into an
63
+ * `error` record. Directory symlinks are deliberately not followed: a pin
64
+ * that follows them can be made to walk outside its declared roots or to
65
+ * loop, and neither is a measurement.
66
+ */
67
+ export interface PinFile {
68
+ /** `join(root, ...)` for the root exactly as the caller supplied it. */
69
+ path: string;
70
+ sha256: string;
71
+ size: number;
72
+ mtimeMs: number;
73
+ /** Inode change time. Not settable from userspace; see the header. */
74
+ ctimeMs: number;
75
+ }
76
+ export interface Pin {
77
+ roots: string[];
78
+ takenAt: string;
79
+ /**
80
+ * `files.length`, carried explicitly so a vacuous pin is visible in the
81
+ * RECORD and not only to a caller who thinks to check (CR-804). Nothing in
82
+ * the shape used to distinguish "the tree did not change" from "no tree was
83
+ * measured", at the module two other phases consume as primary evidence.
84
+ */
85
+ fileCount: number;
86
+ files: PinFile[];
87
+ }
88
+ export type PinFieldName = "sha256" | "size" | "mtimeMs" | "ctimeMs";
89
+ export type PinDifference = {
90
+ path: string;
91
+ kind: "added";
92
+ } | {
93
+ path: string;
94
+ kind: "removed";
95
+ } | {
96
+ path: string;
97
+ kind: "changed";
98
+ fields: PinFieldName[];
99
+ };
100
+ /**
101
+ * Pin the given roots. Throws with a reason naming the path and the observed
102
+ * type when a root, or anything under it, is neither a regular file nor a
103
+ * real directory (M2-C-6, fail closed). Callers wrap this with `runStep`
104
+ * from src/task.ts and report `error`.
105
+ */
106
+ export declare function takePin(roots: string[]): Pin;
107
+ /**
108
+ * Every difference between two pins, in a deterministic order (by path).
109
+ * A byte-identical rewrite that changes only mtimeMs is a difference, which
110
+ * is the whole reason mtimeMs is in the record.
111
+ */
112
+ export declare function comparePins(a: Pin, b: Pin): PinDifference[];
113
+ /** One line per difference, for a record's detail text. */
114
+ export declare function describePinDifference(difference: PinDifference): string;
@@ -0,0 +1,154 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstatSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { classifyEntry } from "../task.js";
5
+ function hashFile(path) {
6
+ // THE PROBE, then the open. The order is the point (MECHANISMS.md, CR-520).
7
+ const entry = classifyEntry(path);
8
+ if (entry.kind !== "regular") {
9
+ throw new Error(entry.kind === "absent" || entry.kind === "dangling"
10
+ ? `${path} vanished while the pin was being taken`
11
+ : entry.reason);
12
+ }
13
+ const stats = statSync(path);
14
+ const body = readFileSync(path);
15
+ return {
16
+ path,
17
+ sha256: createHash("sha256").update(body).digest("hex"),
18
+ size: stats.size,
19
+ mtimeMs: stats.mtimeMs,
20
+ ctimeMs: stats.ctimeMs,
21
+ };
22
+ }
23
+ /**
24
+ * True only for a REAL directory, never for a symlink that resolves to one.
25
+ * lstat answers about the link itself, so a symlinked directory falls
26
+ * through to the regular-file probe and is refused there.
27
+ */
28
+ function isRealDirectory(path) {
29
+ try {
30
+ return lstatSync(path).isDirectory();
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ function walk(root, dir, into) {
37
+ const names = readdirSync(dir).sort();
38
+ for (const name of names) {
39
+ const path = join(dir, name);
40
+ if (isRealDirectory(path)) {
41
+ walk(root, path, into);
42
+ continue;
43
+ }
44
+ const entry = classifyEntry(path);
45
+ if (entry.kind === "absent" || entry.kind === "dangling") {
46
+ // Raced away, or a dangling symlink: nothing to hash, and nothing
47
+ // hidden either, because a later pin reports it as added.
48
+ continue;
49
+ }
50
+ if (entry.kind !== "regular") {
51
+ throw new Error(`${entry.reason}; refusing to pin ${root}`);
52
+ }
53
+ into.push(hashFile(path));
54
+ }
55
+ }
56
+ /**
57
+ * Pin the given roots. Throws with a reason naming the path and the observed
58
+ * type when a root, or anything under it, is neither a regular file nor a
59
+ * real directory (M2-C-6, fail closed). Callers wrap this with `runStep`
60
+ * from src/task.ts and report `error`.
61
+ */
62
+ export function takePin(roots) {
63
+ const files = [];
64
+ if (roots.length === 0) {
65
+ throw new Error("a pin over no roots measures nothing (M2-C-5)");
66
+ }
67
+ for (const root of roots) {
68
+ const before = files.length;
69
+ if (isRealDirectory(root)) {
70
+ walk(root, root, files);
71
+ // THE VACUITY FLOOR (CR-804). A root a caller DECLARED that holds no
72
+ // files is a configuration error, not a measurement of an unchanged
73
+ // tree, and the two are indistinguishable in the result. M2-P2 pins
74
+ // "the clone's source and test roots"; if a scratch clone puts sources
75
+ // one directory deeper than the computed root, a silent empty pin
76
+ // reports no difference and the gate goes green on evidence nobody
77
+ // took. The rule lives HERE, in the module that carries the mechanism,
78
+ // rather than in each consumer (T-005).
79
+ if (files.length === before) {
80
+ throw new Error(`pin root ${root} contributed no files; a declared root that holds nothing is a configuration error, not an unchanged tree (M2-C-5)`);
81
+ }
82
+ continue;
83
+ }
84
+ const entry = classifyEntry(root);
85
+ if (entry.kind === "absent" || entry.kind === "dangling") {
86
+ throw new Error(`pin root ${root} does not exist`);
87
+ }
88
+ if (entry.kind === "regular") {
89
+ files.push(hashFile(root));
90
+ continue;
91
+ }
92
+ throw new Error(`${entry.reason}; refusing to pin ${root}`);
93
+ }
94
+ files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
95
+ return {
96
+ roots: [...roots],
97
+ takenAt: new Date().toISOString(),
98
+ fileCount: files.length,
99
+ files,
100
+ };
101
+ }
102
+ /**
103
+ * Every difference between two pins, in a deterministic order (by path).
104
+ * A byte-identical rewrite that changes only mtimeMs is a difference, which
105
+ * is the whole reason mtimeMs is in the record.
106
+ */
107
+ export function comparePins(a, b) {
108
+ const before = new Map(a.files.map((f) => [f.path, f]));
109
+ const after = new Map(b.files.map((f) => [f.path, f]));
110
+ const paths = [...new Set([...before.keys(), ...after.keys()])].sort();
111
+ const differences = [];
112
+ for (const path of paths) {
113
+ const start = before.get(path);
114
+ const end = after.get(path);
115
+ if (start === undefined && end !== undefined) {
116
+ differences.push({ path, kind: "added" });
117
+ continue;
118
+ }
119
+ if (start !== undefined && end === undefined) {
120
+ differences.push({ path, kind: "removed" });
121
+ continue;
122
+ }
123
+ if (start === undefined || end === undefined) {
124
+ continue;
125
+ }
126
+ const fields = [];
127
+ if (start.sha256 !== end.sha256) {
128
+ fields.push("sha256");
129
+ }
130
+ if (start.size !== end.size) {
131
+ fields.push("size");
132
+ }
133
+ if (start.mtimeMs !== end.mtimeMs) {
134
+ fields.push("mtimeMs");
135
+ }
136
+ if (start.ctimeMs !== end.ctimeMs) {
137
+ fields.push("ctimeMs");
138
+ }
139
+ if (fields.length > 0) {
140
+ differences.push({ path, kind: "changed", fields });
141
+ }
142
+ }
143
+ return differences;
144
+ }
145
+ /** One line per difference, for a record's detail text. */
146
+ export function describePinDifference(difference) {
147
+ if (difference.kind === "added") {
148
+ return `${difference.path} was added during the run`;
149
+ }
150
+ if (difference.kind === "removed") {
151
+ return `${difference.path} was removed during the run`;
152
+ }
153
+ return `${difference.path} changed during the run (${difference.fields.join(", ")})`;
154
+ }
@@ -0,0 +1,22 @@
1
+ import type { GateResult } from "./result.ts";
2
+ import type { WitnessEvaluation, WitnessHooks } from "../witness/run.ts";
3
+ export interface RedWitnessOutcome {
4
+ result: GateResult;
5
+ exitCode: number;
6
+ evaluations: WitnessEvaluation[];
7
+ /** Wall-clock cost of the stored-witness re-evaluation, milliseconds. */
8
+ reEvaluationMs: number;
9
+ }
10
+ export interface RedWitnessRun {
11
+ repoRoot: string;
12
+ base: string;
13
+ head?: string;
14
+ baseline?: string;
15
+ evidenceDir?: string;
16
+ hooks?: WitnessHooks;
17
+ }
18
+ /**
19
+ * Run the red-witness gate against a repository. Exported so tests can
20
+ * drive it without a subprocess; the CLI below is a thin shell over it.
21
+ */
22
+ export declare function runRedWitnessGate(run: RedWitnessRun): RedWitnessOutcome;