immune-brain 2.8.2
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/README.md +239 -0
- package/README.zh-CN.md +239 -0
- package/package.json +84 -0
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +666 -0
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +1622 -0
- package/plugins/immune-brain/.pi-extension/package.json +11 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +711 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance.ts +154 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +349 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-invocations.ts +90 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-native-review.ts +214 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-qa-findings.ts +50 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-review-bundle.ts +262 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +28 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +237 -0
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +414 -0
- package/plugins/immune-brain/.pi-extension/tsconfig.json +20 -0
- package/plugins/immune-brain/bin/imm-kernel +4 -0
- package/plugins/immune-brain/bin/imm-plan +4 -0
- package/plugins/immune-brain/bin/imm-pr-diag +230 -0
- package/plugins/immune-brain/bin/imm-retire-stale-wrapper +4 -0
- package/plugins/immune-brain/bin/imm-retired +4 -0
- package/plugins/immune-brain/bin/imm-tracker +4 -0
- package/plugins/immune-brain/dist/BASELINE.md +138 -0
- package/plugins/immune-brain/dist/docs/reference/HANDOFF-template.md +122 -0
- package/plugins/immune-brain/dist/docs/reference/design-contract-audit-rubric.md +149 -0
- package/plugins/immune-brain/dist/docs/reference/design-contract-review-checklist.md +55 -0
- package/plugins/immune-brain/dist/docs/reference/i18n-review-checklist.md +110 -0
- package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +52 -0
- package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +94 -0
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +44 -0
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +105 -0
- package/plugins/immune-brain/dist/docs/reference/ux-heuristic-checklist.md +131 -0
- package/plugins/immune-brain/dist/imm-brainstorm.md +140 -0
- package/plugins/immune-brain/dist/imm-doc-prune.md +137 -0
- package/plugins/immune-brain/dist/imm-loop.md +158 -0
- package/plugins/immune-brain/dist/imm-planner.md +387 -0
- package/plugins/immune-brain/dist/imm-pr-fix.md +71 -0
- package/plugins/immune-brain/dist/registry.yaml +49 -0
- package/plugins/immune-brain/dist/role-prompts/advisory-reviewer.md +16 -0
- package/plugins/immune-brain/dist/role-prompts/arch-explorer.md +14 -0
- package/plugins/immune-brain/dist/role-prompts/code-review.md +15 -0
- package/plugins/immune-brain/dist/role-prompts/compounder.md +20 -0
- package/plugins/immune-brain/dist/role-prompts/executor.md +13 -0
- package/plugins/immune-brain/dist/role-prompts/pr-fix.md +81 -0
- package/plugins/immune-brain/dist/role-prompts/qa.md +23 -0
- package/plugins/immune-brain/dist/role-prompts/test-fixer.md +3 -0
- package/plugins/immune-brain/dist/role-prompts/ui-review.md +14 -0
- package/plugins/immune-brain/runtime/authority_commit_receipts.ts +716 -0
- package/plugins/immune-brain/runtime/canonical_json.ts +19 -0
- package/plugins/immune-brain/runtime/commands/kernel.ts +1160 -0
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +1009 -0
- package/plugins/immune-brain/runtime/kernel/application.ts +300 -0
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +284 -0
- package/plugins/immune-brain/runtime/kernel/authority_port.ts +208 -0
- package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +451 -0
- package/plugins/immune-brain/runtime/kernel/backend_claim.ts +197 -0
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +507 -0
- package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +73 -0
- package/plugins/immune-brain/runtime/kernel/completion.ts +160 -0
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +194 -0
- package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +123 -0
- package/plugins/immune-brain/runtime/kernel/index.ts +29 -0
- package/plugins/immune-brain/runtime/kernel/intent.ts +563 -0
- package/plugins/immune-brain/runtime/kernel/intent_token_registry.ts +80 -0
- package/plugins/immune-brain/runtime/kernel/legacy.ts +299 -0
- package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +153 -0
- package/plugins/immune-brain/runtime/kernel/observation.ts +395 -0
- package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +169 -0
- package/plugins/immune-brain/runtime/kernel/readiness.ts +282 -0
- package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +132 -0
- package/plugins/immune-brain/runtime/kernel/reducer.ts +624 -0
- package/plugins/immune-brain/runtime/kernel/storage.ts +1780 -0
- package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +791 -0
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +492 -0
- package/plugins/immune-brain/runtime/kernel/types.ts +295 -0
- package/plugins/immune-brain/runtime/kernel/validation.ts +963 -0
- package/plugins/immune-brain/runtime/loop_contract.ts +362 -0
- package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +462 -0
- package/plugins/immune-brain/runtime/plan_core.ts +1053 -0
- package/plugins/immune-brain/runtime/prompts/advisory-reviewer.md +16 -0
- package/plugins/immune-brain/runtime/prompts/arch-explorer.md +14 -0
- package/plugins/immune-brain/runtime/prompts/code-review.md +15 -0
- package/plugins/immune-brain/runtime/prompts/compounder.md +20 -0
- package/plugins/immune-brain/runtime/prompts/executor.md +13 -0
- package/plugins/immune-brain/runtime/prompts/pr-fix.md +81 -0
- package/plugins/immune-brain/runtime/prompts/qa.md +23 -0
- package/plugins/immune-brain/runtime/prompts/test-fixer.md +3 -0
- package/plugins/immune-brain/runtime/prompts/ui-review.md +14 -0
- package/plugins/immune-brain/runtime/role_prompt_bridge.ts +160 -0
- package/plugins/immune-brain/runtime/v4_runtime.ts +295 -0
- package/plugins/immune-brain/runtime/verification_descriptor.ts +162 -0
- package/plugins/immune-brain/runtime/workspace_scope.ts +623 -0
- package/plugins/immune-brain/skills/.ignore +1 -0
- package/plugins/immune-brain/skills/BASELINE.md +138 -0
- package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +66 -0
- package/plugins/immune-brain/skills/imm-doc-prune/SKILL.md +11 -0
- package/plugins/immune-brain/skills/imm-loop/SKILL.md +52 -0
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +221 -0
- package/plugins/immune-brain/skills/imm-pr-fix/SKILL.md +10 -0
- package/plugins/immune-brain/skills/registry.yaml +49 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AUTHORITY_COMMIT_RECEIPT_V2_CONTRACT,
|
|
3
|
+
AUTHORITY_OBSERVATION_GENERATION_V2,
|
|
4
|
+
AUTHORITY_OBSERVER_VERSION_V2,
|
|
5
|
+
type AuthorityCommitReceipt,
|
|
6
|
+
} from "../authority_commit_receipts";
|
|
7
|
+
import type { V3AuthorityObservationV2 } from "./automatic_observations";
|
|
8
|
+
import {
|
|
9
|
+
loadReadinessEvidence,
|
|
10
|
+
type ReadinessEvidenceBundle,
|
|
11
|
+
type ReadinessEvidenceInput,
|
|
12
|
+
} from "./readiness_evidence";
|
|
13
|
+
|
|
14
|
+
export { loadReadinessEvidence, type ReadinessEvidenceBundle, type ReadinessEvidenceInput } from "./readiness_evidence";
|
|
15
|
+
|
|
16
|
+
export interface ReadinessGap {
|
|
17
|
+
code:
|
|
18
|
+
| "epoch_empty"
|
|
19
|
+
| "window_too_short"
|
|
20
|
+
| "lifecycle_coverage"
|
|
21
|
+
| "family_coverage"
|
|
22
|
+
| "evidence_bundle_missing"
|
|
23
|
+
| "missing_observation"
|
|
24
|
+
| "orphan_observation"
|
|
25
|
+
| "binding_mismatch"
|
|
26
|
+
| "version_discontinuity"
|
|
27
|
+
| "shadow_divergence"
|
|
28
|
+
| "evidence_integrity"
|
|
29
|
+
| "evidence_bundle_invalid"
|
|
30
|
+
| "rollback_rehearsal_invalid";
|
|
31
|
+
reference: string | null;
|
|
32
|
+
detail: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ReadinessReport {
|
|
36
|
+
contract: "assurance_kernel/readiness_report/v1";
|
|
37
|
+
status: "collecting" | "blocked" | "candidate";
|
|
38
|
+
observer_version: typeof AUTHORITY_OBSERVER_VERSION_V2;
|
|
39
|
+
epoch_started_at: string | null;
|
|
40
|
+
window_started_at: string | null;
|
|
41
|
+
window_days: number;
|
|
42
|
+
receipts_v2_count: number;
|
|
43
|
+
observations_v2_count: number;
|
|
44
|
+
reconciled_terminal_count: number;
|
|
45
|
+
lifecycle_count: number;
|
|
46
|
+
families_covered: string[];
|
|
47
|
+
families_missing: string[];
|
|
48
|
+
gaps: ReadinessGap[];
|
|
49
|
+
legacy_counts: { receipts_v1: number; observations_v1: number };
|
|
50
|
+
migration_digest: { presented: string | null; current: string | null; match: boolean };
|
|
51
|
+
rollback_rehearsal: { present: boolean; result: string | null; at: string | null };
|
|
52
|
+
generated_at: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ProjectReadinessInput {
|
|
56
|
+
receipts: AuthorityCommitReceipt[];
|
|
57
|
+
observations: V3AuthorityObservationV2[];
|
|
58
|
+
evidence: ReadinessEvidenceInput;
|
|
59
|
+
current_migration_digest: string | null;
|
|
60
|
+
now: string;
|
|
61
|
+
legacy_counts: { receipts_v1: number; observations_v1: number };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const EXECUTION = new Set(["record_execution_evidence", "record_work_probe_evidence"]);
|
|
65
|
+
const REVIEW = new Set(["review_step", "review_gate_pass"]);
|
|
66
|
+
const TERMINATION = new Set(["finish_reset", "terminate_plan"]);
|
|
67
|
+
const ACTIVATION = new Set(["sync_plan_from_imm_plan", "activate_step"]);
|
|
68
|
+
const REQUIRED_FAMILIES = ["execution", "review", "termination"];
|
|
69
|
+
// Short verification window: a qualifying epoch needs at least one full UTC
|
|
70
|
+
// day span (day 1 is the epoch's own day; day 2 means the epoch crossed a
|
|
71
|
+
// UTC midnight). This deliberately replaces the original 14-day observation
|
|
72
|
+
// gate — the literal user requested a short validation window after the first
|
|
73
|
+
// real canary walkthrough; the gate remains waivable at enrollment.
|
|
74
|
+
const MIN_QUALIFYING_WINDOW_DAYS = 2;
|
|
75
|
+
const BLOCKING_CODES = new Set<ReadinessGap["code"]>([
|
|
76
|
+
"missing_observation",
|
|
77
|
+
"orphan_observation",
|
|
78
|
+
"binding_mismatch",
|
|
79
|
+
"version_discontinuity",
|
|
80
|
+
"shadow_divergence",
|
|
81
|
+
"evidence_integrity",
|
|
82
|
+
"evidence_bundle_invalid",
|
|
83
|
+
"rollback_rehearsal_invalid",
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
function validTime(value: string): number | null {
|
|
87
|
+
const parsed = Date.parse(value);
|
|
88
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function utcDay(value: string): number | null {
|
|
92
|
+
const parsed = validTime(value);
|
|
93
|
+
if (parsed === null) return null;
|
|
94
|
+
const date = new Date(parsed);
|
|
95
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function addGap(gaps: ReadinessGap[], code: ReadinessGap["code"], detail: string, reference: string | null = null): void {
|
|
99
|
+
if (gaps.some((gap) => gap.code === code && gap.reference === reference && gap.detail === detail)) return;
|
|
100
|
+
gaps.push({ code, detail, reference });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function sameObservation(left: V3AuthorityObservationV2, right: V3AuthorityObservationV2): boolean {
|
|
104
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function bindingMatches(receipt: AuthorityCommitReceipt, observation: V3AuthorityObservationV2): boolean {
|
|
108
|
+
const seed = receipt.observation_seed;
|
|
109
|
+
return Boolean(
|
|
110
|
+
seed &&
|
|
111
|
+
observation.receipt_record_id === receipt.record_id &&
|
|
112
|
+
observation.receipt_attempt_id === receipt.attempt_id &&
|
|
113
|
+
observation.source_kind === receipt.source_kind &&
|
|
114
|
+
observation.state_path_identity === receipt.state_path_identity &&
|
|
115
|
+
observation.committed_bytes_sha256 === seed.committed_bytes_sha256 &&
|
|
116
|
+
observation.ledger_revision === seed.committed_revision &&
|
|
117
|
+
observation.source_ref === seed.source_ref &&
|
|
118
|
+
observation.plan_path === seed.plan_path &&
|
|
119
|
+
observation.plan_signature === seed.plan_signature,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function eventFamily(action: string): string | null {
|
|
124
|
+
if (EXECUTION.has(action)) return "execution";
|
|
125
|
+
if (REVIEW.has(action)) return "review";
|
|
126
|
+
if (TERMINATION.has(action)) return "termination";
|
|
127
|
+
if (ACTIVATION.has(action)) return "activation";
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function lifecycleCount(observations: V3AuthorityObservationV2[]): number {
|
|
132
|
+
const byPlan = new Map<string, Array<{ at: number; family: string }>>();
|
|
133
|
+
for (const observation of observations) {
|
|
134
|
+
if (!observation.plan_path) continue;
|
|
135
|
+
for (const event of observation.source_events) {
|
|
136
|
+
const family = eventFamily(event.action);
|
|
137
|
+
const at = validTime(event.at ?? observation.committed_at);
|
|
138
|
+
if (!family || at === null) continue;
|
|
139
|
+
const entries = byPlan.get(observation.plan_path) ?? [];
|
|
140
|
+
entries.push({ at, family });
|
|
141
|
+
byPlan.set(observation.plan_path, entries);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
let completed = 0;
|
|
145
|
+
for (const events of byPlan.values()) {
|
|
146
|
+
events.sort((left, right) => left.at - right.at);
|
|
147
|
+
const terminations = events.filter((event) => event.family === "termination");
|
|
148
|
+
if (terminations.length !== 1) continue;
|
|
149
|
+
const termination = terminations[0];
|
|
150
|
+
if (events.some((event) => event.at > termination.at)) continue;
|
|
151
|
+
const executions = events.filter((event) => event.family === "execution" && event.at <= termination.at);
|
|
152
|
+
const reviews = events.filter((event) => event.family === "review" && event.at <= termination.at);
|
|
153
|
+
if (executions.length === 0 || reviews.length === 0) continue;
|
|
154
|
+
const executionAt = executions[0].at;
|
|
155
|
+
const reviewAt = reviews.find((event) => event.at >= executionAt)?.at;
|
|
156
|
+
if (reviewAt === undefined || reviewAt > termination.at) continue;
|
|
157
|
+
const activation = events.find((event) => event.family === "activation");
|
|
158
|
+
if (activation && activation.at > executionAt) continue;
|
|
159
|
+
completed += 1;
|
|
160
|
+
}
|
|
161
|
+
return completed;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function projectReadiness(input: ProjectReadinessInput): ReadinessReport {
|
|
165
|
+
const gaps: ReadinessGap[] = [];
|
|
166
|
+
const receipts = input.receipts.filter((receipt) => receipt.contract === AUTHORITY_COMMIT_RECEIPT_V2_CONTRACT);
|
|
167
|
+
const prepared = receipts.filter((receipt) => receipt.status === "prepared");
|
|
168
|
+
const terminals = receipts.filter((receipt) => receipt.status === "committed" || receipt.status === "recovered_committed");
|
|
169
|
+
const terminalByAttempt = new Map<string, AuthorityCommitReceipt>();
|
|
170
|
+
const preparedByAttempt = new Map<string, AuthorityCommitReceipt>();
|
|
171
|
+
for (const record of prepared) {
|
|
172
|
+
if (preparedByAttempt.has(record.attempt_id)) addGap(gaps, "evidence_integrity", "duplicate prepared receipt", record.attempt_id);
|
|
173
|
+
preparedByAttempt.set(record.attempt_id, record);
|
|
174
|
+
}
|
|
175
|
+
for (const record of terminals) {
|
|
176
|
+
if (!preparedByAttempt.has(record.attempt_id) || terminalByAttempt.has(record.attempt_id))
|
|
177
|
+
addGap(gaps, "evidence_integrity", "terminal receipt has no unique prepared receipt", record.record_id);
|
|
178
|
+
terminalByAttempt.set(record.attempt_id, record);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const expectedGeneration = AUTHORITY_OBSERVATION_GENERATION_V2;
|
|
182
|
+
for (const record of receipts) {
|
|
183
|
+
if (record.observation_generation !== expectedGeneration || record.observation_seed?.observer_version !== AUTHORITY_OBSERVER_VERSION_V2)
|
|
184
|
+
addGap(gaps, "version_discontinuity", "receipt generation or observer version changed", record.record_id);
|
|
185
|
+
}
|
|
186
|
+
for (const observation of input.observations) {
|
|
187
|
+
if (observation.observer_generation !== expectedGeneration || observation.observer_version !== AUTHORITY_OBSERVER_VERSION_V2)
|
|
188
|
+
addGap(gaps, "version_discontinuity", "observation generation or observer version changed", observation.receipt_record_id);
|
|
189
|
+
if (observation.divergence.detected)
|
|
190
|
+
addGap(gaps, "shadow_divergence", `observation divergence: ${observation.divergence.fields.join(",")}`, observation.receipt_record_id);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const observationsByReceipt = new Map<string, V3AuthorityObservationV2[]>();
|
|
194
|
+
for (const observation of input.observations) {
|
|
195
|
+
const entries = observationsByReceipt.get(observation.receipt_record_id) ?? [];
|
|
196
|
+
entries.push(observation);
|
|
197
|
+
observationsByReceipt.set(observation.receipt_record_id, entries);
|
|
198
|
+
}
|
|
199
|
+
let reconciled = 0;
|
|
200
|
+
for (const terminal of terminals) {
|
|
201
|
+
const entries = observationsByReceipt.get(terminal.record_id) ?? [];
|
|
202
|
+
if (entries.length === 0) {
|
|
203
|
+
addGap(gaps, "missing_observation", "terminal receipt has no automatic observation", terminal.record_id);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (entries.some((entry) => !sameObservation(entry, entries[0]))) {
|
|
207
|
+
addGap(gaps, "evidence_integrity", "terminal receipt has conflicting observations", terminal.record_id);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (!bindingMatches(terminal, entries[0])) {
|
|
211
|
+
addGap(gaps, "binding_mismatch", "receipt and observation binding fields differ", terminal.record_id);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
reconciled += 1;
|
|
215
|
+
}
|
|
216
|
+
const terminalIds = new Set(terminals.map((terminal) => terminal.record_id));
|
|
217
|
+
for (const receiptId of observationsByReceipt.keys()) {
|
|
218
|
+
if (!terminalIds.has(receiptId)) addGap(gaps, "orphan_observation", "observation has no committed terminal receipt", receiptId);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const epochTimes = prepared.map((receipt) => validTime(receipt.recorded_at)).filter((value): value is number => value !== null);
|
|
222
|
+
const epochStartedAt = epochTimes.length > 0 ? new Date(Math.min(...epochTimes)).toISOString() : null;
|
|
223
|
+
let windowDays = 0;
|
|
224
|
+
if (epochStartedAt) {
|
|
225
|
+
const start = utcDay(epochStartedAt);
|
|
226
|
+
const end = utcDay(input.now);
|
|
227
|
+
if (start === null || end === null || end < start) addGap(gaps, "evidence_integrity", "readiness clock or epoch timestamp is invalid");
|
|
228
|
+
else windowDays = Math.floor((end - start) / 86_400_000) + 1;
|
|
229
|
+
} else addGap(gaps, "epoch_empty", "no receipt v2 prepared record exists");
|
|
230
|
+
if (epochStartedAt && windowDays < MIN_QUALIFYING_WINDOW_DAYS)
|
|
231
|
+
addGap(gaps, "window_too_short", `qualifying window has ${windowDays} UTC days`);
|
|
232
|
+
|
|
233
|
+
const familiesCovered = [...new Set(input.observations.flatMap((observation) => observation.source_events.map((event) => eventFamily(event.action)).filter((family): family is string => Boolean(family))))].sort();
|
|
234
|
+
const familiesMissing = REQUIRED_FAMILIES.filter((family) => !familiesCovered.includes(family));
|
|
235
|
+
if (familiesMissing.length > 0) addGap(gaps, "family_coverage", `missing families: ${familiesMissing.join(",")}`);
|
|
236
|
+
const lifecycles = lifecycleCount(input.observations);
|
|
237
|
+
if (lifecycles < 3) addGap(gaps, "lifecycle_coverage", `only ${lifecycles} complete distinct lifecycles`);
|
|
238
|
+
|
|
239
|
+
let migrationPresented: string | null = null;
|
|
240
|
+
let migrationMatch = false;
|
|
241
|
+
let rehearsal = { present: false, result: null as string | null, at: null as string | null };
|
|
242
|
+
if (input.evidence.status === "missing") addGap(gaps, "evidence_bundle_missing", "readiness evidence bundle is missing");
|
|
243
|
+
else if (input.evidence.status === "invalid") addGap(gaps, "evidence_bundle_invalid", input.evidence.reason);
|
|
244
|
+
else {
|
|
245
|
+
const bundle = input.evidence.bundle;
|
|
246
|
+
migrationPresented = bundle.migration_dry_run.digest;
|
|
247
|
+
migrationMatch = Boolean(input.current_migration_digest && migrationPresented === input.current_migration_digest && bundle.migration_dry_run.writes_performed === false);
|
|
248
|
+
if (!migrationMatch) addGap(gaps, "evidence_bundle_invalid", "migration dry-run digest does not match the current report");
|
|
249
|
+
rehearsal = { present: true, result: bundle.rollback_rehearsal.result, at: bundle.rollback_rehearsal.at };
|
|
250
|
+
const rehearsalAt = validTime(bundle.rollback_rehearsal.at);
|
|
251
|
+
const epochAt = epochStartedAt ? validTime(epochStartedAt) : null;
|
|
252
|
+
if (
|
|
253
|
+
bundle.rollback_rehearsal.result !== "passed" ||
|
|
254
|
+
rehearsalAt === null ||
|
|
255
|
+
(epochAt !== null &&
|
|
256
|
+
(rehearsalAt < epochAt || rehearsalAt > (validTime(input.now) ?? -1)))
|
|
257
|
+
)
|
|
258
|
+
addGap(gaps, "rollback_rehearsal_invalid", "rollback rehearsal is outside the current window or did not pass");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const blocked = gaps.some((gap) => BLOCKING_CODES.has(gap.code));
|
|
262
|
+
const collecting = gaps.some((gap) => !BLOCKING_CODES.has(gap.code));
|
|
263
|
+
return {
|
|
264
|
+
contract: "assurance_kernel/readiness_report/v1",
|
|
265
|
+
status: blocked ? "blocked" : collecting ? "collecting" : "candidate",
|
|
266
|
+
observer_version: AUTHORITY_OBSERVER_VERSION_V2,
|
|
267
|
+
epoch_started_at: epochStartedAt,
|
|
268
|
+
window_started_at: epochStartedAt,
|
|
269
|
+
window_days: windowDays,
|
|
270
|
+
receipts_v2_count: receipts.length,
|
|
271
|
+
observations_v2_count: input.observations.length,
|
|
272
|
+
reconciled_terminal_count: reconciled,
|
|
273
|
+
lifecycle_count: lifecycles,
|
|
274
|
+
families_covered: familiesCovered,
|
|
275
|
+
families_missing: familiesMissing,
|
|
276
|
+
gaps,
|
|
277
|
+
legacy_counts: { ...input.legacy_counts },
|
|
278
|
+
migration_digest: { presented: migrationPresented, current: input.current_migration_digest, match: migrationMatch },
|
|
279
|
+
rollback_rehearsal: rehearsal,
|
|
280
|
+
generated_at: input.now,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { closeSync, lstatSync, openSync, readFileSync } from "node:fs";
|
|
4
|
+
import { relative, resolve, sep } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
AUTHORITY_OBSERVATION_GENERATION_V2,
|
|
7
|
+
AUTHORITY_OBSERVER_VERSION_V2,
|
|
8
|
+
} from "../authority_commit_receipts";
|
|
9
|
+
|
|
10
|
+
export interface ReadinessEvidenceBundle {
|
|
11
|
+
contract: "assurance_kernel/readiness_evidence/v1";
|
|
12
|
+
generated_at: string;
|
|
13
|
+
migration_dry_run: { digest: string; writes_performed: false };
|
|
14
|
+
rollback_rehearsal: {
|
|
15
|
+
result: "passed";
|
|
16
|
+
at: string;
|
|
17
|
+
summary: string;
|
|
18
|
+
receipt_record_ids: string[];
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ReadinessEvidenceInput =
|
|
23
|
+
| { status: "missing" }
|
|
24
|
+
| { status: "invalid"; reason: string }
|
|
25
|
+
| { status: "valid"; bundle: ReadinessEvidenceBundle };
|
|
26
|
+
|
|
27
|
+
const EVIDENCE_RELATIVE_PATH = "docs/evidence/assurance-kernel/readiness.json";
|
|
28
|
+
const EVIDENCE_MAX_BYTES = 64 * 1024;
|
|
29
|
+
const SHA256 = /^sha256:[a-f0-9]{64}$/;
|
|
30
|
+
|
|
31
|
+
function validTime(value: string): number | null {
|
|
32
|
+
const parsed = Date.parse(value);
|
|
33
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function object(value: unknown): Record<string, unknown> | null {
|
|
37
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
38
|
+
? value as Record<string, unknown>
|
|
39
|
+
: null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function exactKeys(value: Record<string, unknown>, keys: string[]): boolean {
|
|
43
|
+
const actual = Object.keys(value).sort();
|
|
44
|
+
const expected = [...keys].sort();
|
|
45
|
+
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseEvidenceBundle(value: unknown): ReadinessEvidenceBundle {
|
|
49
|
+
const root = object(value);
|
|
50
|
+
if (!root || !exactKeys(root, ["contract", "generated_at", "observer_generation", "observer_version", "migration_dry_run", "rollback_rehearsal"]))
|
|
51
|
+
throw new Error("readiness evidence must use the exact v1 schema");
|
|
52
|
+
const migration = object(root.migration_dry_run);
|
|
53
|
+
const rehearsal = object(root.rollback_rehearsal);
|
|
54
|
+
if (!migration || !exactKeys(migration, ["digest", "writes_performed"]) || typeof migration.digest !== "string" || !SHA256.test(migration.digest) || migration.writes_performed !== false)
|
|
55
|
+
throw new Error("readiness migration evidence is invalid");
|
|
56
|
+
if (!rehearsal || !exactKeys(rehearsal, ["result", "at", "summary", "receipt_record_ids"]) || rehearsal.result !== "passed" || typeof rehearsal.at !== "string" || validTime(rehearsal.at) === null || typeof rehearsal.summary !== "string" || rehearsal.summary.trim().length === 0 || !Array.isArray(rehearsal.receipt_record_ids) || rehearsal.receipt_record_ids.length === 0 || rehearsal.receipt_record_ids.some((entry) => typeof entry !== "string" || !SHA256.test(entry)))
|
|
57
|
+
throw new Error("readiness rollback rehearsal evidence is invalid");
|
|
58
|
+
if (root.contract !== "assurance_kernel/readiness_evidence/v1" || root.observer_generation !== AUTHORITY_OBSERVATION_GENERATION_V2 || root.observer_version !== AUTHORITY_OBSERVER_VERSION_V2 || typeof root.generated_at !== "string" || validTime(root.generated_at) === null)
|
|
59
|
+
throw new Error("readiness evidence identity is invalid");
|
|
60
|
+
return {
|
|
61
|
+
contract: "assurance_kernel/readiness_evidence/v1",
|
|
62
|
+
generated_at: root.generated_at,
|
|
63
|
+
migration_dry_run: { digest: migration.digest, writes_performed: false },
|
|
64
|
+
rollback_rehearsal: {
|
|
65
|
+
result: "passed",
|
|
66
|
+
at: rehearsal.at,
|
|
67
|
+
summary: rehearsal.summary,
|
|
68
|
+
receipt_record_ids: [...rehearsal.receipt_record_ids] as string[],
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function gitClean(root: string, args: string[]): boolean {
|
|
74
|
+
// node:child_process (not Bun.spawnSync) so this module also runs in the
|
|
75
|
+
// Pi extension runtime, which has no Bun global.
|
|
76
|
+
const result = spawnSync("git", args, { cwd: root, stdio: "pipe" });
|
|
77
|
+
return result.status === 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function ensureNoSymlink(root: string, target: string): void {
|
|
81
|
+
const rel = relative(root, target);
|
|
82
|
+
if (rel.startsWith("..") || rel === "" || rel.split(sep).includes(".."))
|
|
83
|
+
throw new Error("readiness evidence path escapes project root");
|
|
84
|
+
let current = root;
|
|
85
|
+
for (const part of rel.split(sep)) {
|
|
86
|
+
current = resolve(current, part);
|
|
87
|
+
const stat = lstatSync(current);
|
|
88
|
+
if (stat.isSymbolicLink()) throw new Error("readiness evidence path contains a symlink");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function loadReadinessEvidence(root: string, now: string): ReadinessEvidenceInput {
|
|
93
|
+
const target = resolve(root, EVIDENCE_RELATIVE_PATH);
|
|
94
|
+
try {
|
|
95
|
+
ensureNoSymlink(root, target);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT")
|
|
98
|
+
return { status: "missing" };
|
|
99
|
+
return { status: "invalid", reason: error instanceof Error ? error.message : String(error) };
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
if (!gitClean(root, ["ls-files", "--error-unmatch", "--", EVIDENCE_RELATIVE_PATH]))
|
|
103
|
+
throw new Error("readiness evidence is not Git-tracked");
|
|
104
|
+
if (!gitClean(root, ["diff", "--quiet", "--", EVIDENCE_RELATIVE_PATH]))
|
|
105
|
+
throw new Error("readiness evidence has worktree changes");
|
|
106
|
+
if (!gitClean(root, ["diff", "--cached", "--quiet", "--", EVIDENCE_RELATIVE_PATH]))
|
|
107
|
+
throw new Error("readiness evidence has staged changes");
|
|
108
|
+
const before = lstatSync(target);
|
|
109
|
+
if (!before.isFile() || before.size > EVIDENCE_MAX_BYTES)
|
|
110
|
+
throw new Error("readiness evidence must be a regular file no larger than 64 KiB");
|
|
111
|
+
const fd = openSync(target, "r");
|
|
112
|
+
let bytes: Buffer;
|
|
113
|
+
try {
|
|
114
|
+
bytes = readFileSync(fd);
|
|
115
|
+
} finally {
|
|
116
|
+
closeSync(fd);
|
|
117
|
+
}
|
|
118
|
+
const after = lstatSync(target);
|
|
119
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs)
|
|
120
|
+
throw new Error("readiness evidence changed while being read");
|
|
121
|
+
const reread = readFileSync(target);
|
|
122
|
+
if (createHash("sha256").update(bytes).digest("hex") !== createHash("sha256").update(reread).digest("hex"))
|
|
123
|
+
throw new Error("readiness evidence bytes changed while being read");
|
|
124
|
+
const bundle = parseEvidenceBundle(JSON.parse(bytes.toString("utf8")));
|
|
125
|
+
const nowMs = validTime(now);
|
|
126
|
+
if (nowMs === null || (validTime(bundle.generated_at) ?? Infinity) > nowMs || (validTime(bundle.rollback_rehearsal.at) ?? Infinity) > nowMs)
|
|
127
|
+
throw new Error("readiness evidence contains a future timestamp");
|
|
128
|
+
return { status: "valid", bundle };
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return { status: "invalid", reason: error instanceof Error ? error.message : String(error) };
|
|
131
|
+
}
|
|
132
|
+
}
|