@tiangong-ai/cli 0.0.34 → 0.0.36
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/AGENTS.md +18 -6
- package/README.md +147 -2
- package/dist/research/orchestration.js +651 -24
- package/dist/research/orchestration.js.map +1 -1
- package/dist/research/workspace/audit-bundle.d.ts +44 -0
- package/dist/research/workspace/audit-bundle.js +357 -0
- package/dist/research/workspace/audit-bundle.js.map +1 -0
- package/dist/research/workspace/input-plan.js +27 -1
- package/dist/research/workspace/input-plan.js.map +1 -1
- package/dist/research/workspace/preflight.d.ts +34 -2
- package/dist/research/workspace/preflight.js +145 -1
- package/dist/research/workspace/preflight.js.map +1 -1
- package/dist/research/workspace/projects.d.ts +32 -5
- package/dist/research/workspace/projects.js +263 -7
- package/dist/research/workspace/projects.js.map +1 -1
- package/dist/research/workspace/publication-workflow.d.ts +146 -0
- package/dist/research/workspace/publication-workflow.js +994 -0
- package/dist/research/workspace/publication-workflow.js.map +1 -0
- package/dist/research/workspace/publication.d.ts +110 -0
- package/dist/research/workspace/publication.js +246 -0
- package/dist/research/workspace/publication.js.map +1 -0
- package/dist/research/workspace/research-policy-wizard.d.ts +40 -0
- package/dist/research/workspace/research-policy-wizard.js +167 -0
- package/dist/research/workspace/research-policy-wizard.js.map +1 -0
- package/dist/research/workspace/research-policy.d.ts +70 -0
- package/dist/research/workspace/research-policy.js +886 -0
- package/dist/research/workspace/research-policy.js.map +1 -0
- package/dist/research/workspace/runtime.d.ts +17 -0
- package/dist/research/workspace/runtime.js +84 -2
- package/dist/research/workspace/runtime.js.map +1 -1
- package/dist/research/workspace/sanitization.js +9 -3
- package/dist/research/workspace/sanitization.js.map +1 -1
- package/dist/research/workspace/scientific-design.d.ts +336 -0
- package/dist/research/workspace/scientific-design.js +1845 -0
- package/dist/research/workspace/scientific-design.js.map +1 -0
- package/dist/research/workspace/scientific-review.d.ts +101 -0
- package/dist/research/workspace/scientific-review.js +1167 -0
- package/dist/research/workspace/scientific-review.js.map +1 -0
- package/dist/research/workspace/setup-catalog.js +2 -2
- package/dist/research/workspace/setup-wizard.d.ts +18 -1
- package/dist/research/workspace/setup-wizard.js +1 -1
- package/dist/research/workspace/setup-wizard.js.map +1 -1
- package/dist/research/workspace/setup.d.ts +1 -0
- package/dist/research/workspace/setup.js +64 -0
- package/dist/research/workspace/setup.js.map +1 -1
- package/dist/research/workspace/types.d.ts +58 -0
- package/dist/research/workspace/workspace.js +25 -7
- package/dist/research/workspace/workspace.js.map +1 -1
- package/package.json +4 -2
|
@@ -0,0 +1,1167 @@
|
|
|
1
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
2
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import { CliError } from "../../errors.js";
|
|
5
|
+
import { appendJournalEvent, verifyJournal } from "./journal.js";
|
|
6
|
+
import { loadProject, nextScientificGate, saveProject } from "./projects.js";
|
|
7
|
+
import { evaluateScientificDesign, parseScientificDesign, scientificDesignPolicyGaps, } from "./scientific-design.js";
|
|
8
|
+
import { sanitizeResearchValue } from "./sanitization.js";
|
|
9
|
+
import { canonicalJson, fileRecord, isObject, pathExists, resolveContained, sha256File, sha256Text, workspacePaths, writeJsonAtomic, writeTextAtomic, } from "./storage.js";
|
|
10
|
+
import { loadWorkspaceConfig, withWorkspaceLock } from "./workspace.js";
|
|
11
|
+
const MAX_SCIENTIFIC_REVIEW_BYTES = 2 * 1024 * 1024;
|
|
12
|
+
const SHA256_PATTERN = "^[a-f0-9]{64}$";
|
|
13
|
+
const ajv = new Ajv2020({ allErrors: true, strict: true });
|
|
14
|
+
const assessmentValidators = new Map();
|
|
15
|
+
const reviewValidators = new Map();
|
|
16
|
+
export function scientificGateAssessmentSchema(role) {
|
|
17
|
+
const properties = {
|
|
18
|
+
schemaVersion: { const: 1 },
|
|
19
|
+
role: { const: role },
|
|
20
|
+
designSha256: { type: "string", pattern: SHA256_PATTERN },
|
|
21
|
+
recommendation: { enum: ["pass", "revise", "stop", "handoff"] },
|
|
22
|
+
findings: findingsSchema(),
|
|
23
|
+
};
|
|
24
|
+
let roleRequired;
|
|
25
|
+
if (role === "research-design") {
|
|
26
|
+
properties.checks = closedObject([
|
|
27
|
+
"identityCoherent",
|
|
28
|
+
"estimandObservable",
|
|
29
|
+
"claimGraphComplete",
|
|
30
|
+
"endpointTruthRolesCorrect",
|
|
31
|
+
"quantityOntologyComplete",
|
|
32
|
+
"validationSemanticsCorrect",
|
|
33
|
+
"knownGapDispositionComplete",
|
|
34
|
+
"lifecycleFeasible",
|
|
35
|
+
], {
|
|
36
|
+
identityCoherent: { type: "boolean" },
|
|
37
|
+
estimandObservable: { type: "boolean" },
|
|
38
|
+
claimGraphComplete: { type: "boolean" },
|
|
39
|
+
endpointTruthRolesCorrect: { type: "boolean" },
|
|
40
|
+
quantityOntologyComplete: { type: "boolean" },
|
|
41
|
+
validationSemanticsCorrect: { type: "boolean" },
|
|
42
|
+
knownGapDispositionComplete: { type: "boolean" },
|
|
43
|
+
lifecycleFeasible: { type: "boolean" },
|
|
44
|
+
});
|
|
45
|
+
roleRequired = ["checks"];
|
|
46
|
+
}
|
|
47
|
+
else if (role === "evidence-construct") {
|
|
48
|
+
properties.constructCanary = closedObject([
|
|
49
|
+
"usesRealRecords",
|
|
50
|
+
"outcomeBlind",
|
|
51
|
+
"resultValuesInspected",
|
|
52
|
+
"rowCount",
|
|
53
|
+
"constructedEdgeIds",
|
|
54
|
+
"failedEdgeIds",
|
|
55
|
+
"artifactSha256s",
|
|
56
|
+
], {
|
|
57
|
+
usesRealRecords: { type: "boolean" },
|
|
58
|
+
outcomeBlind: { type: "boolean" },
|
|
59
|
+
resultValuesInspected: { type: "boolean" },
|
|
60
|
+
rowCount: { type: "integer", minimum: 0 },
|
|
61
|
+
constructedEdgeIds: stringSetSchema(),
|
|
62
|
+
failedEdgeIds: stringSetSchema(),
|
|
63
|
+
artifactSha256s: {
|
|
64
|
+
type: "array",
|
|
65
|
+
uniqueItems: true,
|
|
66
|
+
items: { type: "string", pattern: SHA256_PATTERN },
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
properties.evidenceRoleCoverage = {
|
|
70
|
+
type: "array",
|
|
71
|
+
items: closedObject([
|
|
72
|
+
"roleId",
|
|
73
|
+
"fullTextSourceIds",
|
|
74
|
+
"independentSourceIds",
|
|
75
|
+
"datedSourceIds",
|
|
76
|
+
"peerReviewedSourceIds",
|
|
77
|
+
"dimensionIds",
|
|
78
|
+
"sourceTypes",
|
|
79
|
+
], {
|
|
80
|
+
roleId: boundedStringSchema(),
|
|
81
|
+
fullTextSourceIds: stringSetSchema(),
|
|
82
|
+
independentSourceIds: stringSetSchema(),
|
|
83
|
+
datedSourceIds: stringSetSchema(),
|
|
84
|
+
peerReviewedSourceIds: stringSetSchema(),
|
|
85
|
+
dimensionIds: stringSetSchema(),
|
|
86
|
+
sourceTypes: stringSetSchema(),
|
|
87
|
+
}),
|
|
88
|
+
};
|
|
89
|
+
properties.closestWorkDispositionComplete = { type: "boolean" };
|
|
90
|
+
properties.centralEvidenceFitsContext = { type: "boolean" };
|
|
91
|
+
roleRequired = [
|
|
92
|
+
"constructCanary",
|
|
93
|
+
"evidenceRoleCoverage",
|
|
94
|
+
"closestWorkDispositionComplete",
|
|
95
|
+
"centralEvidenceFitsContext",
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
properties.checks = closedObject([
|
|
100
|
+
"noDataLeakage",
|
|
101
|
+
"noCircularValidation",
|
|
102
|
+
"endpointComparisonsCompatible",
|
|
103
|
+
"baselineFair",
|
|
104
|
+
"unitsAndDenominatorsVerified",
|
|
105
|
+
"thresholdsTyped",
|
|
106
|
+
"decisionLossMetricsComputed",
|
|
107
|
+
], {
|
|
108
|
+
noDataLeakage: { type: "boolean" },
|
|
109
|
+
noCircularValidation: { type: "boolean" },
|
|
110
|
+
endpointComparisonsCompatible: { type: "boolean" },
|
|
111
|
+
baselineFair: { type: "boolean" },
|
|
112
|
+
unitsAndDenominatorsVerified: { type: "boolean" },
|
|
113
|
+
thresholdsTyped: { type: "boolean" },
|
|
114
|
+
decisionLossMetricsComputed: { type: "boolean" },
|
|
115
|
+
});
|
|
116
|
+
properties.validationAudits = {
|
|
117
|
+
type: "array",
|
|
118
|
+
minItems: 1,
|
|
119
|
+
items: closedObject([
|
|
120
|
+
"validationPlanId",
|
|
121
|
+
"outcomeBlind",
|
|
122
|
+
"originalUnitCount",
|
|
123
|
+
"independentClusterCount",
|
|
124
|
+
"effectiveIndependentUnits",
|
|
125
|
+
"clusterKeyIds",
|
|
126
|
+
"independenceJustification",
|
|
127
|
+
"resamplingUnit",
|
|
128
|
+
"resamplingIterations",
|
|
129
|
+
"resamplingMethod",
|
|
130
|
+
"resamplingStateSpaceSize",
|
|
131
|
+
"reportingPrecision",
|
|
132
|
+
"minimumDetectableDifference",
|
|
133
|
+
"independentValidationStatus",
|
|
134
|
+
"independentValidationGapId",
|
|
135
|
+
], {
|
|
136
|
+
validationPlanId: boundedStringSchema(),
|
|
137
|
+
outcomeBlind: { type: "boolean" },
|
|
138
|
+
originalUnitCount: { type: "integer", minimum: 0 },
|
|
139
|
+
independentClusterCount: { type: "integer", minimum: 0 },
|
|
140
|
+
effectiveIndependentUnits: { type: "number", minimum: 0 },
|
|
141
|
+
clusterKeyIds: stringSetSchema(),
|
|
142
|
+
independenceJustification: boundedStringSchema(),
|
|
143
|
+
resamplingUnit: boundedStringSchema(),
|
|
144
|
+
resamplingIterations: { type: "integer", minimum: 0 },
|
|
145
|
+
resamplingMethod: { enum: ["exact-enumeration", "cluster-bootstrap", "none"] },
|
|
146
|
+
resamplingStateSpaceSize: { type: "integer", minimum: 0 },
|
|
147
|
+
reportingPrecision: boundedStringSchema(),
|
|
148
|
+
minimumDetectableDifference: { type: ["string", "null"] },
|
|
149
|
+
independentValidationStatus: {
|
|
150
|
+
enum: ["available", "planned", "unavailable-scope-bounded", "not-required"],
|
|
151
|
+
},
|
|
152
|
+
independentValidationGapId: { type: ["string", "null"] },
|
|
153
|
+
}),
|
|
154
|
+
};
|
|
155
|
+
properties.decisionLossMetricIds = stringSetSchema();
|
|
156
|
+
roleRequired = ["checks", "validationAudits", "decisionLossMetricIds"];
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
160
|
+
type: "object",
|
|
161
|
+
additionalProperties: false,
|
|
162
|
+
required: [
|
|
163
|
+
"schemaVersion",
|
|
164
|
+
"role",
|
|
165
|
+
"designSha256",
|
|
166
|
+
"recommendation",
|
|
167
|
+
...roleRequired,
|
|
168
|
+
"findings",
|
|
169
|
+
],
|
|
170
|
+
properties,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
export function scientificReviewSchema(role) {
|
|
174
|
+
return {
|
|
175
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
176
|
+
type: "object",
|
|
177
|
+
additionalProperties: false,
|
|
178
|
+
required: [
|
|
179
|
+
"schemaVersion",
|
|
180
|
+
"role",
|
|
181
|
+
"packetSha256",
|
|
182
|
+
"reviewerSessionSha256",
|
|
183
|
+
"decision",
|
|
184
|
+
"findings",
|
|
185
|
+
"boundedRecommendation",
|
|
186
|
+
],
|
|
187
|
+
properties: {
|
|
188
|
+
schemaVersion: { const: 1 },
|
|
189
|
+
role: { const: role },
|
|
190
|
+
packetSha256: { type: "string", pattern: SHA256_PATTERN },
|
|
191
|
+
reviewerSessionSha256: { type: "string", pattern: SHA256_PATTERN },
|
|
192
|
+
decision: { enum: ["pass", "revise", "stop", "handoff"] },
|
|
193
|
+
findings: findingsSchema(),
|
|
194
|
+
boundedRecommendation: { type: "string", minLength: 1, maxLength: 8000 },
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
export async function prepareScientificReview(input) {
|
|
199
|
+
return withWorkspaceLock(input.root, "research.scientific-review.prepare", async () => {
|
|
200
|
+
const paths = workspacePaths(input.root);
|
|
201
|
+
await verifyJournal(paths.journal);
|
|
202
|
+
const [config, project] = await Promise.all([
|
|
203
|
+
loadWorkspaceConfig(input.root),
|
|
204
|
+
loadProject(input.root, input.projectId),
|
|
205
|
+
]);
|
|
206
|
+
if (!project.scientificDesign || !project.publicationPolicy) {
|
|
207
|
+
throw scientificGateError("This project does not have a scientific design review route.");
|
|
208
|
+
}
|
|
209
|
+
const next = nextScientificGate(project);
|
|
210
|
+
if (!next || next.role !== input.role) {
|
|
211
|
+
throw new CliError("Scientific review role is not the next blocking gate.", {
|
|
212
|
+
code: "RESEARCH_SCIENTIFIC_REVIEW_ORDER_INVALID",
|
|
213
|
+
exitCode: 3,
|
|
214
|
+
details: { requestedRole: input.role, nextRole: next?.role ?? null },
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
if (!["pending", "revision-required"].includes(next.status)) {
|
|
218
|
+
throw new CliError("The current scientific gate already has an active review packet.", {
|
|
219
|
+
code: "RESEARCH_SCIENTIFIC_REVIEW_STATE_INVALID",
|
|
220
|
+
exitCode: 3,
|
|
221
|
+
details: { role: input.role, status: next.status },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
if (config.reviewer.agent !== input.reviewerAgent) {
|
|
225
|
+
throw new CliError("Scientific review must use the configured independent reviewer route.", {
|
|
226
|
+
code: "RESEARCH_SCIENTIFIC_REVIEWER_MISMATCH",
|
|
227
|
+
exitCode: 3,
|
|
228
|
+
details: {
|
|
229
|
+
configuredReviewer: config.reviewer.agent,
|
|
230
|
+
requestedReviewer: input.reviewerAgent,
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
if (input.reviewerAgent === project.scientificDesign.producer.agent) {
|
|
235
|
+
throw new CliError("Scientific review must use a different agent family from the producer.", {
|
|
236
|
+
code: "RESEARCH_SCIENTIFIC_REVIEW_NOT_INDEPENDENT",
|
|
237
|
+
exitCode: 3,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
const sessionId = input.reviewerSessionId.trim();
|
|
241
|
+
if (!sessionId) {
|
|
242
|
+
throw new CliError("Scientific review requires an opaque reviewer session identifier.", {
|
|
243
|
+
code: "RESEARCH_SCIENTIFIC_REVIEW_SESSION_INVALID",
|
|
244
|
+
exitCode: 2,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
const reviewerSessionSha256 = sha256Text(sessionId);
|
|
248
|
+
if (reviewerSessionSha256 === project.scientificDesign.producer.sessionSha256) {
|
|
249
|
+
throw new CliError("Producer and reviewer session identities must be independent.", {
|
|
250
|
+
code: "RESEARCH_SCIENTIFIC_REVIEW_NOT_INDEPENDENT",
|
|
251
|
+
exitCode: 3,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
const projectRoot = join(paths.projects, project.id);
|
|
255
|
+
const registryPath = join(projectRoot, "scientific", "reviewer-sessions.json");
|
|
256
|
+
const registry = await loadReviewerSessionRegistry(registryPath);
|
|
257
|
+
if (registry.sessions.some((entry) => entry.sessionSha256 === reviewerSessionSha256)) {
|
|
258
|
+
throw new CliError("A reviewer session may be used for only one scientific review packet.", {
|
|
259
|
+
code: "RESEARCH_SCIENTIFIC_REVIEW_SESSION_REUSED",
|
|
260
|
+
exitCode: 3,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
const design = await loadBoundScientificDesign(input.root, project);
|
|
264
|
+
const assessment = await readAssessment(input.assessmentPath, input.role);
|
|
265
|
+
if (assessment.designSha256 !== project.scientificDesign.designSha256) {
|
|
266
|
+
throw new CliError("Scientific assessment does not match the frozen design.", {
|
|
267
|
+
code: "RESEARCH_SCIENTIFIC_ASSESSMENT_DESIGN_MISMATCH",
|
|
268
|
+
exitCode: 3,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
const stageInputs = await stageInputRecords(input.root, project, input.role, design);
|
|
272
|
+
const assessmentSha256 = exactJsonSha256(assessment);
|
|
273
|
+
const assessmentLocator = `projects/${project.id}/scientific/assessments/${input.role}/${assessmentSha256}.json`;
|
|
274
|
+
await writeImmutableJson(join(paths.control, assessmentLocator), assessment);
|
|
275
|
+
const policyBindingSha256 = exactJsonSha256(project.publicationPolicy);
|
|
276
|
+
const policyObjectLocator = `projects/${project.id}/scientific/policy/objects/${policyBindingSha256}.json`;
|
|
277
|
+
await writeImmutableJson(resolveContained(paths.control, policyObjectLocator), project.publicationPolicy);
|
|
278
|
+
const packetPolicy = {
|
|
279
|
+
resolvedPolicySha256: project.publicationPolicy.resolvedPolicySha256,
|
|
280
|
+
approvalSha256: project.publicationPolicy.approvalSha256,
|
|
281
|
+
targetJournal: project.publicationPolicy.targetJournal,
|
|
282
|
+
bindingSha256: policyBindingSha256,
|
|
283
|
+
objectLocator: policyObjectLocator,
|
|
284
|
+
};
|
|
285
|
+
await assertBoundPolicyObject(input.root, project, packetPolicy);
|
|
286
|
+
const designEvaluation = evaluateScientificDesign(design);
|
|
287
|
+
const issues = evaluateAssessment(input.role, assessment, design, project.evidenceRequirements, project.publicationPolicy);
|
|
288
|
+
const futureGateObligations = scientificFutureGateObligations(design, input.role);
|
|
289
|
+
const packetCore = {
|
|
290
|
+
schemaVersion: 1,
|
|
291
|
+
kind: "tiangong-scientific-review-packet",
|
|
292
|
+
projectId: project.id,
|
|
293
|
+
role: input.role,
|
|
294
|
+
design: {
|
|
295
|
+
sha256: project.scientificDesign.designSha256,
|
|
296
|
+
objectLocator: project.scientificDesign.objectLocator,
|
|
297
|
+
},
|
|
298
|
+
policy: packetPolicy,
|
|
299
|
+
reviewer: { agent: input.reviewerAgent, sessionSha256: reviewerSessionSha256 },
|
|
300
|
+
preparedAt: new Date().toISOString(),
|
|
301
|
+
stageInputs,
|
|
302
|
+
assessment: { sha256: assessmentSha256, objectLocator: assessmentLocator },
|
|
303
|
+
mechanicalAssessment: {
|
|
304
|
+
canPass: issues.length === 0 && assessment.recommendation === "pass",
|
|
305
|
+
issueCodes: issues.map((issue) => issue.code),
|
|
306
|
+
issues,
|
|
307
|
+
futureGateObligations,
|
|
308
|
+
designEvaluation: {
|
|
309
|
+
readyForDesignReview: designEvaluation.readyForDesignReview,
|
|
310
|
+
issueCodes: designEvaluation.issueCodes,
|
|
311
|
+
effectiveIndependentUnits: designEvaluation.effectiveIndependentUnits,
|
|
312
|
+
requiredEvidenceRoles: designEvaluation.requiredEvidenceRoles,
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
lifecycle: {
|
|
316
|
+
producerExecution: "native-host-app",
|
|
317
|
+
baseStages: ["discover", "acquire", "analyze", "synthesize", "review", "close"],
|
|
318
|
+
earlyScientificReviews: [
|
|
319
|
+
"research-design",
|
|
320
|
+
"evidence-construct",
|
|
321
|
+
"pilot-methods",
|
|
322
|
+
],
|
|
323
|
+
finalPublicationReviews: [...project.publicationPolicy.requiredReviewers],
|
|
324
|
+
finalManuscriptFreezeRequired: true,
|
|
325
|
+
newGenerationOnMaterialChange: true,
|
|
326
|
+
revisionReserveIncluded: true,
|
|
327
|
+
},
|
|
328
|
+
instructions: reviewInstructions(input.role),
|
|
329
|
+
};
|
|
330
|
+
const packetSha256 = sha256Text(canonicalJson(packetCore));
|
|
331
|
+
const packet = { ...packetCore, packetSha256 };
|
|
332
|
+
const packetLocator = `projects/${project.id}/scientific/review-packets/${input.role}/${packetSha256}.json`;
|
|
333
|
+
await writeImmutableJson(join(paths.control, packetLocator), packet);
|
|
334
|
+
registry.sessions.push({
|
|
335
|
+
sessionSha256: reviewerSessionSha256,
|
|
336
|
+
role: input.role,
|
|
337
|
+
agent: input.reviewerAgent,
|
|
338
|
+
packetSha256,
|
|
339
|
+
usedAt: packet.preparedAt,
|
|
340
|
+
});
|
|
341
|
+
await writeJsonAtomic(registryPath, registry);
|
|
342
|
+
const gate = project.scientificDesign.gates[input.role];
|
|
343
|
+
gate.status = "prepared";
|
|
344
|
+
gate.packetSha256 = packetSha256;
|
|
345
|
+
gate.assessmentSha256 = assessmentSha256;
|
|
346
|
+
gate.reviewSha256 = null;
|
|
347
|
+
gate.reviewerSessionSha256 = reviewerSessionSha256;
|
|
348
|
+
project.updatedAt = packet.preparedAt;
|
|
349
|
+
await saveProject(input.root, project);
|
|
350
|
+
await appendJournalEvent(paths.journal, "scientific-review.prepared", project.id, {
|
|
351
|
+
projectId: project.id,
|
|
352
|
+
role: input.role,
|
|
353
|
+
designSha256: project.scientificDesign.designSha256,
|
|
354
|
+
assessmentSha256,
|
|
355
|
+
packetSha256,
|
|
356
|
+
policyBindingSha256,
|
|
357
|
+
reviewerAgent: input.reviewerAgent,
|
|
358
|
+
reviewerSessionSha256,
|
|
359
|
+
mechanicalIssueCodes: packet.mechanicalAssessment.issueCodes,
|
|
360
|
+
futureGateObligations,
|
|
361
|
+
stageInputs,
|
|
362
|
+
});
|
|
363
|
+
return packet;
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
export async function submitScientificReview(input) {
|
|
367
|
+
return withWorkspaceLock(input.root, "research.scientific-review.submit", async () => {
|
|
368
|
+
const paths = workspacePaths(input.root);
|
|
369
|
+
await verifyJournal(paths.journal);
|
|
370
|
+
const project = await loadProject(input.root, input.projectId);
|
|
371
|
+
const binding = project.scientificDesign;
|
|
372
|
+
if (!binding)
|
|
373
|
+
throw scientificGateError("Project does not have a scientific design binding.");
|
|
374
|
+
const gate = binding.gates[input.role];
|
|
375
|
+
if (gate.status !== "prepared" ||
|
|
376
|
+
!gate.packetSha256 ||
|
|
377
|
+
!gate.assessmentSha256 ||
|
|
378
|
+
!gate.reviewerSessionSha256) {
|
|
379
|
+
throw new CliError("Scientific review has no prepared packet to submit.", {
|
|
380
|
+
code: "RESEARCH_SCIENTIFIC_REVIEW_STATE_INVALID",
|
|
381
|
+
exitCode: 3,
|
|
382
|
+
details: { role: input.role, status: gate.status },
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
const packet = await loadBoundPacket(input.root, project, input.role, gate.packetSha256);
|
|
386
|
+
const review = await readReview(input.reviewPath, input.role);
|
|
387
|
+
if (review.packetSha256 !== packet.packetSha256 ||
|
|
388
|
+
review.reviewerSessionSha256 !== packet.reviewer.sessionSha256 ||
|
|
389
|
+
review.reviewerSessionSha256 !== gate.reviewerSessionSha256) {
|
|
390
|
+
throw new CliError("Scientific review does not match its packet and reviewer binding.", {
|
|
391
|
+
code: "RESEARCH_SCIENTIFIC_REVIEW_BINDING_INVALID",
|
|
392
|
+
exitCode: 3,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
const reviewSha256 = exactJsonSha256(review);
|
|
396
|
+
const reviewLocator = `projects/${project.id}/scientific/reviews/${input.role}/${reviewSha256}.json`;
|
|
397
|
+
await writeImmutableJson(join(paths.control, reviewLocator), review);
|
|
398
|
+
const assessment = await loadBoundAssessment(input.root, project, input.role, packet);
|
|
399
|
+
const mechanicsPass = packet.mechanicalAssessment.issueCodes.length === 0 &&
|
|
400
|
+
packet.mechanicalAssessment.canPass &&
|
|
401
|
+
assessment.recommendation === "pass";
|
|
402
|
+
let status;
|
|
403
|
+
if (review.decision === "pass" && mechanicsPass)
|
|
404
|
+
status = "passed";
|
|
405
|
+
else if (review.decision === "stop" || review.decision === "handoff")
|
|
406
|
+
status = "stopped";
|
|
407
|
+
else
|
|
408
|
+
status = "revision-required";
|
|
409
|
+
gate.status = status;
|
|
410
|
+
gate.reviewSha256 = reviewSha256;
|
|
411
|
+
project.updatedAt = new Date().toISOString();
|
|
412
|
+
await saveProject(input.root, project);
|
|
413
|
+
await appendJournalEvent(paths.journal, "scientific-review.submitted", project.id, {
|
|
414
|
+
projectId: project.id,
|
|
415
|
+
role: input.role,
|
|
416
|
+
packetSha256: packet.packetSha256,
|
|
417
|
+
assessmentSha256: packet.assessment.sha256,
|
|
418
|
+
reviewSha256,
|
|
419
|
+
reviewerSessionSha256: review.reviewerSessionSha256,
|
|
420
|
+
reviewerDecision: review.decision,
|
|
421
|
+
mechanicalIssueCodes: packet.mechanicalAssessment.issueCodes,
|
|
422
|
+
status,
|
|
423
|
+
});
|
|
424
|
+
return { status, reviewSha256, issueCodes: packet.mechanicalAssessment.issueCodes };
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
export async function assertScientificGateForStage(root, project, stage) {
|
|
428
|
+
if (!project.scientificDesign)
|
|
429
|
+
return;
|
|
430
|
+
const design = await loadBoundScientificDesign(root, project);
|
|
431
|
+
for (const role of requiredGateRoles(stage)) {
|
|
432
|
+
const gate = project.scientificDesign.gates[role];
|
|
433
|
+
if (gate.status !== "passed" || !gate.packetSha256 || !gate.reviewSha256) {
|
|
434
|
+
throw new CliError(`Scientific ${role} review must pass before ${stage}.`, {
|
|
435
|
+
code: "RESEARCH_SCIENTIFIC_GATE_REQUIRED",
|
|
436
|
+
exitCode: 3,
|
|
437
|
+
details: { role, stage, status: gate.status },
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
const packet = await loadBoundPacket(root, project, role, gate.packetSha256);
|
|
441
|
+
const review = await loadBoundReview(root, project, role, gate.reviewSha256);
|
|
442
|
+
const assessment = await loadBoundAssessment(root, project, role, packet);
|
|
443
|
+
const currentIssues = evaluateAssessment(role, assessment, design, project.evidenceRequirements, project.publicationPolicy).map((issue) => issue.code);
|
|
444
|
+
if (packet.assessment.sha256 !== gate.assessmentSha256 ||
|
|
445
|
+
packet.reviewer.sessionSha256 !== gate.reviewerSessionSha256 ||
|
|
446
|
+
review.packetSha256 !== packet.packetSha256 ||
|
|
447
|
+
review.reviewerSessionSha256 !== packet.reviewer.sessionSha256 ||
|
|
448
|
+
review.decision !== "pass" ||
|
|
449
|
+
assessment.recommendation !== "pass" ||
|
|
450
|
+
packet.mechanicalAssessment.issueCodes.length !== 0 ||
|
|
451
|
+
currentIssues.length !== 0) {
|
|
452
|
+
throw scientificGateError("Scientific gate bindings or mechanical results are invalid.", role);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
export async function inspectScientificReviewStatus(root, projectId) {
|
|
457
|
+
const project = await loadProject(root, projectId);
|
|
458
|
+
if (!project.scientificDesign) {
|
|
459
|
+
return { projectId, reviewState: "not-required", nextGate: null, gates: null };
|
|
460
|
+
}
|
|
461
|
+
const nextGate = nextScientificGate(project);
|
|
462
|
+
const reviewState = !nextGate
|
|
463
|
+
? "complete"
|
|
464
|
+
: nextGate.status === "revision-required"
|
|
465
|
+
? "revision-required"
|
|
466
|
+
: nextGate.status === "stopped"
|
|
467
|
+
? "stopped"
|
|
468
|
+
: "awaiting-review";
|
|
469
|
+
return {
|
|
470
|
+
projectId,
|
|
471
|
+
reviewState,
|
|
472
|
+
nextGate,
|
|
473
|
+
gates: structuredClone(project.scientificDesign.gates),
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
function evaluateAssessment(role, assessment, design, requirements, policy) {
|
|
477
|
+
const issues = new Map();
|
|
478
|
+
const add = (code, message, objectIds = []) => {
|
|
479
|
+
if (!issues.has(code))
|
|
480
|
+
issues.set(code, { code, message, objectIds });
|
|
481
|
+
};
|
|
482
|
+
const gateRank = {
|
|
483
|
+
"research-design": 0,
|
|
484
|
+
"evidence-construct": 1,
|
|
485
|
+
"pilot-methods": 2,
|
|
486
|
+
"publication-freeze": 3,
|
|
487
|
+
};
|
|
488
|
+
for (const gap of scientificDesignPolicyGaps(design, policy)) {
|
|
489
|
+
add(`POLICY_${gap.replaceAll(/[^A-Za-z0-9]+/gu, "_").toUpperCase()}`, "The frozen scientific design no longer matches its approved Research Policy disposition contract.", [gap]);
|
|
490
|
+
}
|
|
491
|
+
const duePolicyRules = design.policyRuleDispositions.filter((disposition) => disposition.status === "planned" &&
|
|
492
|
+
disposition.dueGate !== "publication-freeze" &&
|
|
493
|
+
gateRank[disposition.dueGate] <= gateRank[role]);
|
|
494
|
+
if (duePolicyRules.length) {
|
|
495
|
+
add("POLICY_RULE_DUE_UNRESOLVED", "A planned Research Policy rule reached its declared early-review gate without a new design generation that records how it was satisfied.", duePolicyRules.map((disposition) => disposition.ruleId));
|
|
496
|
+
}
|
|
497
|
+
const dueUncertaintyFreezes = design.uncertaintyParameters.filter((parameter) => parameter.stateValueStatus === "pending-source-acquisition" &&
|
|
498
|
+
gateRank[parameter.freezeBeforeGate] <= gateRank[role]);
|
|
499
|
+
if (dueUncertaintyFreezes.length) {
|
|
500
|
+
add("UNCERTAINTY_STATE_VALUES_NOT_FROZEN", "Source-derived uncertainty values reached their declared freeze gate without a new authoritative design generation containing exact states and source bindings.", dueUncertaintyFreezes.map((parameter) => parameter.id));
|
|
501
|
+
}
|
|
502
|
+
const dueModelImplementations = design.identity.modelStructures.filter((model) => model.implementationStatus === "pending-source-acquisition" &&
|
|
503
|
+
gateRank[model.implementationFreezeBeforeGate] <= gateRank[role]);
|
|
504
|
+
if (dueModelImplementations.length) {
|
|
505
|
+
add("MODEL_IMPLEMENTATION_NOT_FROZEN", "A model implementation reached its declared freeze gate without a new authoritative design generation binding executable model bytes.", dueModelImplementations.map((model) => model.id));
|
|
506
|
+
}
|
|
507
|
+
const dueModelEnvironmentLocks = design.identity.modelStructures.filter((model) => model.environmentLockStatus === "pending-runtime-lock" &&
|
|
508
|
+
gateRank[model.environmentLockFreezeBeforeGate] <= gateRank[role]);
|
|
509
|
+
if (dueModelEnvironmentLocks.length) {
|
|
510
|
+
add("MODEL_ENVIRONMENT_LOCK_NOT_FROZEN", "A model environment reached its declared freeze gate without a new authoritative design generation binding an exact runtime and dependency lock.", dueModelEnvironmentLocks.map((model) => model.id));
|
|
511
|
+
}
|
|
512
|
+
if (role === "research-design") {
|
|
513
|
+
const value = assessment;
|
|
514
|
+
for (const issue of evaluateScientificDesign(design).issues) {
|
|
515
|
+
add(issue.code, issue.message, issue.objectIds);
|
|
516
|
+
}
|
|
517
|
+
for (const [key, passed] of Object.entries(value.checks)) {
|
|
518
|
+
if (!passed)
|
|
519
|
+
add(`DESIGN_CHECK_${camelToCode(key)}_FAILED`, `Design check ${key} failed.`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
else if (role === "evidence-construct") {
|
|
523
|
+
const value = assessment;
|
|
524
|
+
const canary = value.constructCanary;
|
|
525
|
+
if (!canary.usesRealRecords ||
|
|
526
|
+
!canary.outcomeBlind ||
|
|
527
|
+
canary.resultValuesInspected ||
|
|
528
|
+
canary.rowCount < 1 ||
|
|
529
|
+
canary.artifactSha256s.length < 1) {
|
|
530
|
+
add("CANARY_NOT_REAL", "Construct canary must use real records without inspecting outcomes.");
|
|
531
|
+
}
|
|
532
|
+
const centralEdges = design.edges
|
|
533
|
+
.filter((edge) => edge.role === "central")
|
|
534
|
+
.map((edge) => edge.id);
|
|
535
|
+
const unconstructed = centralEdges.filter((edgeId) => !canary.constructedEdgeIds.includes(edgeId) || canary.failedEdgeIds.includes(edgeId));
|
|
536
|
+
if (unconstructed.length) {
|
|
537
|
+
add("CENTRAL_EDGE_UNCONSTRUCTED", "Every central claim edge must survive a real-record construct canary.", unconstructed);
|
|
538
|
+
}
|
|
539
|
+
for (const required of design.evidenceRoles.filter((item) => item.required)) {
|
|
540
|
+
const coverage = value.evidenceRoleCoverage.find((item) => item.roleId === required.id);
|
|
541
|
+
if (!coverage ||
|
|
542
|
+
new Set(coverage.fullTextSourceIds).size < required.minimumFullText ||
|
|
543
|
+
new Set(coverage.independentSourceIds).size < required.minimumIndependentSources) {
|
|
544
|
+
add("EVIDENCE_ROLE_FULLTEXT_INSUFFICIENT", "A required evidence role lacks its frozen full-text or independence minimum.", [required.id]);
|
|
545
|
+
}
|
|
546
|
+
if (!coverage || new Set(coverage.datedSourceIds).size < required.minimumDatedSources) {
|
|
547
|
+
add("EVIDENCE_ROLE_DATED_INSUFFICIENT", "A required evidence role lacks its frozen dated-source minimum.", [required.id]);
|
|
548
|
+
}
|
|
549
|
+
if (required.peerReviewedRequired &&
|
|
550
|
+
(!coverage || new Set(coverage.peerReviewedSourceIds).size < required.minimumFullText)) {
|
|
551
|
+
add("EVIDENCE_ROLE_PEER_REVIEWED_INSUFFICIENT", "A peer-reviewed evidence role lacks its required frozen peer-reviewed full texts.", [required.id]);
|
|
552
|
+
}
|
|
553
|
+
const dimensions = new Set(coverage?.dimensionIds ?? []);
|
|
554
|
+
if (required.coverageDimensionIds.some((dimension) => !dimensions.has(dimension))) {
|
|
555
|
+
add("EVIDENCE_ROLE_DIMENSION_UNCOVERED", "A required evidence role did not demonstrate every declared research dimension.", [required.id]);
|
|
556
|
+
}
|
|
557
|
+
const sourceTypes = new Set(coverage?.sourceTypes ?? []);
|
|
558
|
+
if (required.sourceTypeRequirements.some((sourceType) => !sourceTypes.has(sourceType))) {
|
|
559
|
+
add("EVIDENCE_ROLE_SOURCE_TYPE_UNCOVERED", "A required evidence role did not demonstrate every declared source type.", [required.id]);
|
|
560
|
+
}
|
|
561
|
+
if (coverage) {
|
|
562
|
+
const independentIds = new Set(coverage.independentSourceIds);
|
|
563
|
+
const fullTextIds = new Set(coverage.fullTextSourceIds);
|
|
564
|
+
if (coverage.fullTextSourceIds.some((sourceId) => !independentIds.has(sourceId)) ||
|
|
565
|
+
coverage.datedSourceIds.some((sourceId) => !independentIds.has(sourceId)) ||
|
|
566
|
+
coverage.peerReviewedSourceIds.some((sourceId) => !independentIds.has(sourceId) || !fullTextIds.has(sourceId))) {
|
|
567
|
+
add("EVIDENCE_ROLE_SOURCE_ID_INCONSISTENT", "Full-text, dated, and peer-reviewed IDs must refer to sources in the same role's independent source set.", [required.id]);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
const allIndependentSourceIds = new Set(value.evidenceRoleCoverage.flatMap((coverage) => coverage.independentSourceIds));
|
|
572
|
+
const allFullTextSourceIds = new Set(value.evidenceRoleCoverage.flatMap((coverage) => coverage.fullTextSourceIds));
|
|
573
|
+
const allDatedSourceIds = new Set(value.evidenceRoleCoverage.flatMap((coverage) => coverage.datedSourceIds));
|
|
574
|
+
if (allIndependentSourceIds.size < requirements.minSources) {
|
|
575
|
+
add("EVIDENCE_UNIQUE_SOURCE_COVERAGE_INSUFFICIENT", "Unique evidence sources across roles do not meet the project minimum.");
|
|
576
|
+
}
|
|
577
|
+
if (allFullTextSourceIds.size < requirements.minFullTextSources) {
|
|
578
|
+
add("EVIDENCE_UNIQUE_FULLTEXT_COVERAGE_INSUFFICIENT", "Unique full-text sources across roles do not meet the project minimum.");
|
|
579
|
+
}
|
|
580
|
+
if (allDatedSourceIds.size < requirements.minDatedSources) {
|
|
581
|
+
add("EVIDENCE_UNIQUE_DATED_COVERAGE_INSUFFICIENT", "Unique dated sources across roles do not meet the project minimum.");
|
|
582
|
+
}
|
|
583
|
+
if (!value.closestWorkDispositionComplete) {
|
|
584
|
+
add("CLOSEST_WORK_DISPOSITION_INCOMPLETE", "Closest prior work must be obtained, compared, and dispositioned before acquisition.");
|
|
585
|
+
}
|
|
586
|
+
if (!value.centralEvidenceFitsContext) {
|
|
587
|
+
add("CENTRAL_CONTEXT_OVERFLOW", "Central evidence does not fit the planned context and extraction route.");
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
const value = assessment;
|
|
592
|
+
for (const [key, passed] of Object.entries(value.checks)) {
|
|
593
|
+
if (!passed)
|
|
594
|
+
add(`PILOT_CHECK_${camelToCode(key)}_FAILED`, `Pilot check ${key} failed.`);
|
|
595
|
+
}
|
|
596
|
+
const auditIds = value.validationAudits.map((audit) => audit.validationPlanId);
|
|
597
|
+
const knownPlanIds = new Set(design.validationPlans.map((plan) => plan.id));
|
|
598
|
+
const missingOrUnknownPlans = [
|
|
599
|
+
...design.validationPlans
|
|
600
|
+
.map((plan) => plan.id)
|
|
601
|
+
.filter((planId) => !auditIds.includes(planId)),
|
|
602
|
+
...auditIds.filter((planId) => !knownPlanIds.has(planId)),
|
|
603
|
+
];
|
|
604
|
+
if (new Set(auditIds).size !== auditIds.length || missingOrUnknownPlans.length) {
|
|
605
|
+
add("VALIDATION_PLAN_UNVERIFIED", "Every declared validation plan requires exactly one pilot audit and undeclared plans are forbidden.", [...new Set(missingOrUnknownPlans)]);
|
|
606
|
+
}
|
|
607
|
+
for (const audit of value.validationAudits) {
|
|
608
|
+
if (audit.independentClusterCount > audit.originalUnitCount ||
|
|
609
|
+
audit.effectiveIndependentUnits > audit.independentClusterCount) {
|
|
610
|
+
add("EFFECTIVE_SAMPLE_SIZE_INFLATED", "Resampling cannot create independent units beyond the original independent clusters.", [audit.validationPlanId]);
|
|
611
|
+
}
|
|
612
|
+
const repeatedWithinCluster = audit.originalUnitCount > audit.independentClusterCount;
|
|
613
|
+
if (repeatedWithinCluster &&
|
|
614
|
+
/^(cell|row|record|observation|measurement)s?$/iu.test(audit.resamplingUnit.trim())) {
|
|
615
|
+
add("RESAMPLING_UNIT_INVALID", "Resampling must preserve the independent cluster when observations repeat within clusters.", [audit.validationPlanId]);
|
|
616
|
+
}
|
|
617
|
+
const plan = design.validationPlans.find((candidate) => candidate.id === audit.validationPlanId);
|
|
618
|
+
if (!plan)
|
|
619
|
+
continue;
|
|
620
|
+
if (audit.originalUnitCount !== plan.originalUnitCount ||
|
|
621
|
+
audit.independentClusterCount !== plan.independentClusterCount ||
|
|
622
|
+
audit.effectiveIndependentUnits !== plan.effectiveIndependentUnits) {
|
|
623
|
+
add("PILOT_SAMPLE_DEFINITION_DRIFT", "Pilot sample counts differ from the frozen validation plan and require a new design generation.", [plan.id]);
|
|
624
|
+
}
|
|
625
|
+
if (!sameStringSet(audit.clusterKeyIds, plan.clusterKeyIds) ||
|
|
626
|
+
audit.independenceJustification !== plan.independenceJustification ||
|
|
627
|
+
audit.resamplingUnit !== plan.resamplingUnit) {
|
|
628
|
+
add("PILOT_CLUSTER_DEFINITION_DRIFT", "Pilot cluster keys, independence justification, or resampling unit differ from the frozen validation plan.", [plan.id]);
|
|
629
|
+
}
|
|
630
|
+
if (audit.resamplingIterations !== plan.resamplingIterations ||
|
|
631
|
+
audit.resamplingMethod !== plan.resamplingMethod ||
|
|
632
|
+
audit.resamplingStateSpaceSize !== plan.resamplingStateSpaceSize) {
|
|
633
|
+
add("PILOT_RESAMPLING_PLAN_DRIFT", "Pilot resampling method, iterations, or state space differ from the frozen validation plan.", [plan.id]);
|
|
634
|
+
}
|
|
635
|
+
if (audit.reportingPrecision !== plan.reportingPrecision ||
|
|
636
|
+
audit.minimumDetectableDifference !== plan.minimumDetectableDifference) {
|
|
637
|
+
add("PILOT_PRECISION_PLAN_DRIFT", "Pilot reporting precision or minimum detectable difference differs from the frozen validation plan.", [plan.id]);
|
|
638
|
+
}
|
|
639
|
+
if (audit.outcomeBlind !== plan.outcomeBlind) {
|
|
640
|
+
add("PILOT_OUTCOME_BLINDING_DRIFT", "Pilot outcome blinding differs from the frozen validation plan.", [plan.id]);
|
|
641
|
+
}
|
|
642
|
+
if (audit.independentValidationStatus !== plan.independentValidation.status ||
|
|
643
|
+
audit.independentValidationGapId !== plan.independentValidation.gapId) {
|
|
644
|
+
add("PILOT_VALIDATION_DISPOSITION_DRIFT", "Pilot independent-validation status or gap binding differs from the frozen validation plan.", [plan.id]);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
const missingLoss = design.baselinePlan.decisionLossMetrics.filter((metric) => !value.decisionLossMetricIds.includes(metric.id));
|
|
648
|
+
if (missingLoss.length) {
|
|
649
|
+
add("DECISION_LOSS_METRIC_MISSING", "Every frozen decision-loss metric must be computed in the pilot.", missingLoss.map((metric) => metric.id));
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return [...issues.values()];
|
|
653
|
+
}
|
|
654
|
+
async function stageInputRecords(root, project, role, design) {
|
|
655
|
+
const paths = workspacePaths(root);
|
|
656
|
+
const projectRoot = join(paths.projects, project.id);
|
|
657
|
+
if (role === "research-design") {
|
|
658
|
+
const records = [];
|
|
659
|
+
const promote = async (input) => {
|
|
660
|
+
const source = resolveContained(paths.control, input.sourceLocator);
|
|
661
|
+
const info = await lstat(source).catch(() => undefined);
|
|
662
|
+
if (!info?.isFile() || info.isSymbolicLink()) {
|
|
663
|
+
throw input.onFailure("unavailable-or-unsafe");
|
|
664
|
+
}
|
|
665
|
+
if (info.size > MAX_SCIENTIFIC_REVIEW_BYTES) {
|
|
666
|
+
throw input.onFailure("oversized");
|
|
667
|
+
}
|
|
668
|
+
if ((await sha256File(source)) !== input.sha256) {
|
|
669
|
+
throw input.onFailure("content-hash-mismatch");
|
|
670
|
+
}
|
|
671
|
+
const sourceBytes = await readFile(source, "utf8");
|
|
672
|
+
try {
|
|
673
|
+
JSON.parse(sourceBytes);
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
throw input.onFailure("not-reviewable-json");
|
|
677
|
+
}
|
|
678
|
+
const promotedLocator = `projects/${project.id}/scientific/lineage/objects/${input.sha256}.json`;
|
|
679
|
+
const promoted = resolveContained(paths.control, promotedLocator);
|
|
680
|
+
if (await pathExists(promoted)) {
|
|
681
|
+
const promotedInfo = await lstat(promoted).catch(() => undefined);
|
|
682
|
+
if (!promotedInfo?.isFile() ||
|
|
683
|
+
promotedInfo.isSymbolicLink() ||
|
|
684
|
+
promotedInfo.size !== info.size ||
|
|
685
|
+
(await sha256File(promoted)) !== input.sha256) {
|
|
686
|
+
throw scientificGateError("A promoted scientific design object failed its immutable binding.", role);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
else {
|
|
690
|
+
await writeTextAtomic(promoted, sourceBytes);
|
|
691
|
+
}
|
|
692
|
+
records.push({
|
|
693
|
+
path: promotedLocator,
|
|
694
|
+
sha256: input.sha256,
|
|
695
|
+
bytes: info.size,
|
|
696
|
+
purpose: input.purpose,
|
|
697
|
+
ownerId: input.ownerId,
|
|
698
|
+
sourceLocator: input.sourceLocator,
|
|
699
|
+
hashBasis: "raw-file-bytes",
|
|
700
|
+
});
|
|
701
|
+
};
|
|
702
|
+
for (const model of design.identity.modelStructures) {
|
|
703
|
+
await promote({
|
|
704
|
+
sourceLocator: model.implementationArtifactLocator,
|
|
705
|
+
sha256: model.implementationArtifactSha256,
|
|
706
|
+
purpose: "model-implementation",
|
|
707
|
+
ownerId: model.id,
|
|
708
|
+
onFailure: (reason) => modelArtifactObjectError(role, model.id, "implementation", reason),
|
|
709
|
+
});
|
|
710
|
+
await promote({
|
|
711
|
+
sourceLocator: model.environmentLockLocator,
|
|
712
|
+
sha256: model.environmentLockSha256,
|
|
713
|
+
purpose: "model-environment-lock",
|
|
714
|
+
ownerId: model.id,
|
|
715
|
+
onFailure: (reason) => modelArtifactObjectError(role, model.id, "environment-lock", reason),
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
for (const gap of design.knownGaps) {
|
|
719
|
+
for (const artifact of gap.sourceArtifacts) {
|
|
720
|
+
await promote({
|
|
721
|
+
sourceLocator: artifact.objectLocator,
|
|
722
|
+
sha256: artifact.sha256,
|
|
723
|
+
purpose: "inherited-gap",
|
|
724
|
+
ownerId: gap.id,
|
|
725
|
+
onFailure: (reason) => inheritedGapObjectError(role, gap.id, artifact.kind, reason),
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return records;
|
|
730
|
+
}
|
|
731
|
+
const requiredPackage = role === "evidence-construct" ? "discover" : "acquire";
|
|
732
|
+
const packageState = project.packages.find((item) => item.id === requiredPackage);
|
|
733
|
+
if (packageState?.status !== "complete") {
|
|
734
|
+
throw new CliError(`Scientific ${role} review requires completed ${requiredPackage}.`, {
|
|
735
|
+
code: "RESEARCH_SCIENTIFIC_REVIEW_PREREQUISITE_MISSING",
|
|
736
|
+
exitCode: 3,
|
|
737
|
+
details: { role, requiredPackage, status: packageState?.status ?? null },
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
const relativeOutputs = role === "evidence-construct"
|
|
741
|
+
? ["outputs/evidence.json"]
|
|
742
|
+
: ["outputs/acquisition.json", "outputs/evidence-snapshot.json"];
|
|
743
|
+
return Promise.all(relativeOutputs.map(async (relativePath) => {
|
|
744
|
+
const sourceLocator = `projects/${project.id}/${relativePath}`;
|
|
745
|
+
return {
|
|
746
|
+
...(await fileRecord(join(projectRoot, relativePath), sourceLocator)),
|
|
747
|
+
purpose: "stage-output",
|
|
748
|
+
ownerId: requiredPackage,
|
|
749
|
+
sourceLocator,
|
|
750
|
+
hashBasis: "raw-file-bytes",
|
|
751
|
+
};
|
|
752
|
+
}));
|
|
753
|
+
}
|
|
754
|
+
async function loadBoundScientificDesign(root, project) {
|
|
755
|
+
const binding = project.scientificDesign;
|
|
756
|
+
if (!binding)
|
|
757
|
+
throw scientificGateError("Project has no scientific design binding.");
|
|
758
|
+
const path = join(workspacePaths(root).control, binding.objectLocator);
|
|
759
|
+
const value = await readExactJson(path, "Scientific design", "RESEARCH_SCIENTIFIC_GATE_INVALID");
|
|
760
|
+
const design = parseScientificDesign(value);
|
|
761
|
+
const normalized = normalizedJson(design);
|
|
762
|
+
if (design.projectId !== project.id ||
|
|
763
|
+
sha256Text(normalized) !== binding.designSha256 ||
|
|
764
|
+
(await readFile(path, "utf8")) !== normalized) {
|
|
765
|
+
throw scientificGateError("Frozen scientific design failed its exact binding.");
|
|
766
|
+
}
|
|
767
|
+
return design;
|
|
768
|
+
}
|
|
769
|
+
async function loadBoundPacket(root, project, role, packetSha256) {
|
|
770
|
+
const path = join(workspacePaths(root).projects, project.id, "scientific", "review-packets", role, `${packetSha256}.json`);
|
|
771
|
+
const value = await readExactJson(path, "Scientific review packet", "RESEARCH_SCIENTIFIC_GATE_INVALID");
|
|
772
|
+
if (!isScientificReviewPacket(value, project.id, role, project.publicationPolicy?.requiredReviewers ?? [])) {
|
|
773
|
+
throw scientificGateError("Scientific review packet is malformed.", role);
|
|
774
|
+
}
|
|
775
|
+
const { packetSha256: recorded, ...core } = value;
|
|
776
|
+
if (recorded !== packetSha256 ||
|
|
777
|
+
sha256Text(canonicalJson(core)) !== packetSha256 ||
|
|
778
|
+
(await readFile(path, "utf8")) !== normalizedJson(value)) {
|
|
779
|
+
throw scientificGateError("Scientific review packet failed its immutable hash binding.", role);
|
|
780
|
+
}
|
|
781
|
+
await assertBoundPolicyObject(root, project, value.policy);
|
|
782
|
+
await assertBoundStageInputs(root, value.stageInputs, role);
|
|
783
|
+
return value;
|
|
784
|
+
}
|
|
785
|
+
async function assertBoundStageInputs(root, records, role) {
|
|
786
|
+
const paths = workspacePaths(root);
|
|
787
|
+
for (const record of records) {
|
|
788
|
+
const path = resolveContained(paths.control, record.path);
|
|
789
|
+
const info = await lstat(path).catch(() => undefined);
|
|
790
|
+
if (!info?.isFile() ||
|
|
791
|
+
info.isSymbolicLink() ||
|
|
792
|
+
info.size !== record.bytes ||
|
|
793
|
+
(await sha256File(path)) !== record.sha256) {
|
|
794
|
+
throw scientificGateError("A scientific review stage input failed its immutable binding.", role);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
async function assertBoundPolicyObject(root, project, packetPolicy) {
|
|
799
|
+
const binding = project.publicationPolicy;
|
|
800
|
+
if (!binding)
|
|
801
|
+
throw scientificGateError("Project has no publication policy binding.");
|
|
802
|
+
const expectedSha256 = exactJsonSha256(binding);
|
|
803
|
+
const expectedLocator = `projects/${project.id}/scientific/policy/objects/${expectedSha256}.json`;
|
|
804
|
+
if (packetPolicy.bindingSha256 !== expectedSha256 ||
|
|
805
|
+
packetPolicy.objectLocator !== expectedLocator ||
|
|
806
|
+
packetPolicy.resolvedPolicySha256 !== binding.resolvedPolicySha256 ||
|
|
807
|
+
packetPolicy.approvalSha256 !== binding.approvalSha256 ||
|
|
808
|
+
packetPolicy.targetJournal !== binding.targetJournal) {
|
|
809
|
+
throw scientificGateError("Scientific review policy binding does not match the project.");
|
|
810
|
+
}
|
|
811
|
+
const paths = workspacePaths(root);
|
|
812
|
+
const policyPath = resolveContained(paths.control, expectedLocator);
|
|
813
|
+
const value = await readExactJson(policyPath, "Scientific review policy binding", "RESEARCH_SCIENTIFIC_GATE_INVALID");
|
|
814
|
+
if (exactJsonSha256(value) !== expectedSha256 ||
|
|
815
|
+
(await readFile(policyPath, "utf8")) !== normalizedJson(binding)) {
|
|
816
|
+
throw scientificGateError("Scientific review policy object failed its immutable binding.");
|
|
817
|
+
}
|
|
818
|
+
for (const document of binding.documents) {
|
|
819
|
+
const documentPath = resolveContained(paths.control, document.objectLocator);
|
|
820
|
+
const info = await lstat(documentPath).catch(() => undefined);
|
|
821
|
+
if (!info?.isFile() ||
|
|
822
|
+
info.isSymbolicLink() ||
|
|
823
|
+
(await sha256File(documentPath)) !== document.sha256) {
|
|
824
|
+
throw scientificGateError("A policy document referenced by the review packet is unavailable or drifted.");
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
async function loadBoundAssessment(root, project, role, packet) {
|
|
829
|
+
const expectedLocator = `projects/${project.id}/scientific/assessments/${role}/${packet.assessment.sha256}.json`;
|
|
830
|
+
if (packet.assessment.objectLocator !== expectedLocator) {
|
|
831
|
+
throw scientificGateError("Scientific assessment locator is not canonical.", role);
|
|
832
|
+
}
|
|
833
|
+
const path = join(workspacePaths(root).control, expectedLocator);
|
|
834
|
+
const value = await readExactJson(path, "Scientific assessment", "RESEARCH_SCIENTIFIC_GATE_INVALID");
|
|
835
|
+
const assessment = parseAssessment(value, role, "RESEARCH_SCIENTIFIC_GATE_INVALID");
|
|
836
|
+
if (assessment.designSha256 !== project.scientificDesign?.designSha256 ||
|
|
837
|
+
exactJsonSha256(assessment) !== packet.assessment.sha256 ||
|
|
838
|
+
(await readFile(path, "utf8")) !== normalizedJson(assessment)) {
|
|
839
|
+
throw scientificGateError("Scientific assessment failed its immutable binding.", role);
|
|
840
|
+
}
|
|
841
|
+
return assessment;
|
|
842
|
+
}
|
|
843
|
+
async function loadBoundReview(root, project, role, reviewSha256) {
|
|
844
|
+
const path = join(workspacePaths(root).projects, project.id, "scientific", "reviews", role, `${reviewSha256}.json`);
|
|
845
|
+
const value = await readExactJson(path, "Scientific review", "RESEARCH_SCIENTIFIC_GATE_INVALID");
|
|
846
|
+
const review = parseReview(value, role, "RESEARCH_SCIENTIFIC_GATE_INVALID");
|
|
847
|
+
if (exactJsonSha256(review) !== reviewSha256 ||
|
|
848
|
+
(await readFile(path, "utf8")) !== normalizedJson(review)) {
|
|
849
|
+
throw scientificGateError("Scientific review failed its immutable hash binding.", role);
|
|
850
|
+
}
|
|
851
|
+
return review;
|
|
852
|
+
}
|
|
853
|
+
async function readAssessment(path, role) {
|
|
854
|
+
return parseAssessment(await readExternalJson(path, "Scientific assessment", "RESEARCH_SCIENTIFIC_ASSESSMENT_INVALID"), role);
|
|
855
|
+
}
|
|
856
|
+
async function readReview(path, role) {
|
|
857
|
+
return parseReview(await readExternalJson(path, "Scientific review", "RESEARCH_SCIENTIFIC_REVIEW_INVALID"), role);
|
|
858
|
+
}
|
|
859
|
+
function parseAssessment(value, role, code = "RESEARCH_SCIENTIFIC_ASSESSMENT_INVALID") {
|
|
860
|
+
let validate = assessmentValidators.get(role);
|
|
861
|
+
if (!validate) {
|
|
862
|
+
validate = ajv.compile(scientificGateAssessmentSchema(role));
|
|
863
|
+
assessmentValidators.set(role, validate);
|
|
864
|
+
}
|
|
865
|
+
if (!validate(value)) {
|
|
866
|
+
throw schemaError("Scientific assessment does not match the authoritative schema.", code, validate.errors);
|
|
867
|
+
}
|
|
868
|
+
return value;
|
|
869
|
+
}
|
|
870
|
+
function parseReview(value, role, code = "RESEARCH_SCIENTIFIC_REVIEW_INVALID") {
|
|
871
|
+
let validate = reviewValidators.get(role);
|
|
872
|
+
if (!validate) {
|
|
873
|
+
validate = ajv.compile(scientificReviewSchema(role));
|
|
874
|
+
reviewValidators.set(role, validate);
|
|
875
|
+
}
|
|
876
|
+
if (!validate(value)) {
|
|
877
|
+
throw schemaError("Scientific review does not match the authoritative schema.", code, validate.errors);
|
|
878
|
+
}
|
|
879
|
+
return value;
|
|
880
|
+
}
|
|
881
|
+
async function readExternalJson(path, label, code) {
|
|
882
|
+
if (!isAbsolute(path)) {
|
|
883
|
+
throw new CliError(`${label} path must be absolute.`, { code, exitCode: 2 });
|
|
884
|
+
}
|
|
885
|
+
return readExactJson(path, label, code);
|
|
886
|
+
}
|
|
887
|
+
async function readExactJson(path, label, code) {
|
|
888
|
+
const info = await lstat(path).catch(() => undefined);
|
|
889
|
+
if (!info?.isFile() || info.isSymbolicLink()) {
|
|
890
|
+
throw new CliError(`${label} must be an existing regular file and not a symbolic link.`, {
|
|
891
|
+
code,
|
|
892
|
+
exitCode: 2,
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
if (info.size > MAX_SCIENTIFIC_REVIEW_BYTES) {
|
|
896
|
+
throw new CliError(`${label} exceeds the bounded input size.`, { code, exitCode: 2 });
|
|
897
|
+
}
|
|
898
|
+
try {
|
|
899
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
900
|
+
}
|
|
901
|
+
catch {
|
|
902
|
+
throw new CliError(`${label} is not valid JSON.`, { code, exitCode: 2 });
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
async function writeImmutableJson(path, value) {
|
|
906
|
+
const expected = normalizedJson(value);
|
|
907
|
+
if (await pathExists(path)) {
|
|
908
|
+
const info = await lstat(path).catch(() => undefined);
|
|
909
|
+
if (!info?.isFile() || info.isSymbolicLink() || (await readFile(path, "utf8")) !== expected) {
|
|
910
|
+
throw scientificGateError("A content-addressed scientific object has been modified.");
|
|
911
|
+
}
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
await writeJsonAtomic(path, value);
|
|
915
|
+
}
|
|
916
|
+
async function loadReviewerSessionRegistry(path) {
|
|
917
|
+
if (!(await pathExists(path)))
|
|
918
|
+
return { schemaVersion: 1, sessions: [] };
|
|
919
|
+
const value = await readExactJson(path, "Scientific reviewer session registry", "RESEARCH_SCIENTIFIC_GATE_INVALID");
|
|
920
|
+
if (!isObject(value) ||
|
|
921
|
+
value.schemaVersion !== 1 ||
|
|
922
|
+
!Array.isArray(value.sessions) ||
|
|
923
|
+
value.sessions.some((entry) => !isObject(entry) ||
|
|
924
|
+
typeof entry.sessionSha256 !== "string" ||
|
|
925
|
+
!new RegExp(SHA256_PATTERN).test(entry.sessionSha256) ||
|
|
926
|
+
!["research-design", "evidence-construct", "pilot-methods"].includes(String(entry.role)) ||
|
|
927
|
+
!["codex", "claude"].includes(String(entry.agent)) ||
|
|
928
|
+
typeof entry.packetSha256 !== "string" ||
|
|
929
|
+
!new RegExp(SHA256_PATTERN).test(entry.packetSha256) ||
|
|
930
|
+
typeof entry.usedAt !== "string")) {
|
|
931
|
+
throw scientificGateError("Scientific reviewer session registry is malformed.");
|
|
932
|
+
}
|
|
933
|
+
return value;
|
|
934
|
+
}
|
|
935
|
+
function isScientificReviewPacket(value, projectId, role, finalPublicationReviews) {
|
|
936
|
+
return (isObject(value) &&
|
|
937
|
+
value.schemaVersion === 1 &&
|
|
938
|
+
value.kind === "tiangong-scientific-review-packet" &&
|
|
939
|
+
value.projectId === projectId &&
|
|
940
|
+
value.role === role &&
|
|
941
|
+
isObject(value.design) &&
|
|
942
|
+
typeof value.design.sha256 === "string" &&
|
|
943
|
+
typeof value.design.objectLocator === "string" &&
|
|
944
|
+
isObject(value.policy) &&
|
|
945
|
+
typeof value.policy.resolvedPolicySha256 === "string" &&
|
|
946
|
+
typeof value.policy.approvalSha256 === "string" &&
|
|
947
|
+
(value.policy.targetJournal === null || typeof value.policy.targetJournal === "string") &&
|
|
948
|
+
typeof value.policy.bindingSha256 === "string" &&
|
|
949
|
+
new RegExp(SHA256_PATTERN).test(value.policy.bindingSha256) &&
|
|
950
|
+
typeof value.policy.objectLocator === "string" &&
|
|
951
|
+
isObject(value.reviewer) &&
|
|
952
|
+
["codex", "claude"].includes(String(value.reviewer.agent)) &&
|
|
953
|
+
typeof value.reviewer.sessionSha256 === "string" &&
|
|
954
|
+
typeof value.preparedAt === "string" &&
|
|
955
|
+
Array.isArray(value.stageInputs) &&
|
|
956
|
+
value.stageInputs.every((record) => isObject(record) &&
|
|
957
|
+
typeof record.path === "string" &&
|
|
958
|
+
record.path.startsWith(`projects/${projectId}/`) &&
|
|
959
|
+
typeof record.sha256 === "string" &&
|
|
960
|
+
new RegExp(SHA256_PATTERN).test(record.sha256) &&
|
|
961
|
+
typeof record.bytes === "number" &&
|
|
962
|
+
Number.isSafeInteger(record.bytes) &&
|
|
963
|
+
record.bytes >= 0 &&
|
|
964
|
+
[
|
|
965
|
+
"inherited-gap",
|
|
966
|
+
"model-implementation",
|
|
967
|
+
"model-environment-lock",
|
|
968
|
+
"stage-output",
|
|
969
|
+
].includes(String(record.purpose)) &&
|
|
970
|
+
typeof record.ownerId === "string" &&
|
|
971
|
+
record.ownerId.length > 0 &&
|
|
972
|
+
typeof record.sourceLocator === "string" &&
|
|
973
|
+
record.sourceLocator.length > 0 &&
|
|
974
|
+
record.hashBasis === "raw-file-bytes") &&
|
|
975
|
+
isObject(value.assessment) &&
|
|
976
|
+
typeof value.assessment.sha256 === "string" &&
|
|
977
|
+
typeof value.assessment.objectLocator === "string" &&
|
|
978
|
+
isObject(value.mechanicalAssessment) &&
|
|
979
|
+
typeof value.mechanicalAssessment.canPass === "boolean" &&
|
|
980
|
+
Array.isArray(value.mechanicalAssessment.issueCodes) &&
|
|
981
|
+
Array.isArray(value.mechanicalAssessment.issues) &&
|
|
982
|
+
Array.isArray(value.mechanicalAssessment.futureGateObligations) &&
|
|
983
|
+
value.mechanicalAssessment.futureGateObligations.every((obligation) => isObject(obligation) &&
|
|
984
|
+
[
|
|
985
|
+
"UNCERTAINTY_STATE_VALUES_NOT_FROZEN",
|
|
986
|
+
"MODEL_IMPLEMENTATION_NOT_FROZEN",
|
|
987
|
+
"MODEL_ENVIRONMENT_LOCK_NOT_FROZEN",
|
|
988
|
+
].includes(String(obligation.code)) &&
|
|
989
|
+
["evidence-construct", "pilot-methods"].includes(String(obligation.dueGate)) &&
|
|
990
|
+
Array.isArray(obligation.objectIds) &&
|
|
991
|
+
obligation.objectIds.every((id) => typeof id === "string" && id.length > 0) &&
|
|
992
|
+
Array.isArray(obligation.policyRuleIds) &&
|
|
993
|
+
obligation.policyRuleIds.every((id) => typeof id === "string" && id.length > 0)) &&
|
|
994
|
+
isObject(value.mechanicalAssessment.designEvaluation) &&
|
|
995
|
+
typeof value.mechanicalAssessment.designEvaluation.readyForDesignReview === "boolean" &&
|
|
996
|
+
Array.isArray(value.mechanicalAssessment.designEvaluation.issueCodes) &&
|
|
997
|
+
typeof value.mechanicalAssessment.designEvaluation.effectiveIndependentUnits === "number" &&
|
|
998
|
+
typeof value.mechanicalAssessment.designEvaluation.requiredEvidenceRoles === "number" &&
|
|
999
|
+
isObject(value.lifecycle) &&
|
|
1000
|
+
value.lifecycle.producerExecution === "native-host-app" &&
|
|
1001
|
+
exactStringArray(value.lifecycle.baseStages, [
|
|
1002
|
+
"discover",
|
|
1003
|
+
"acquire",
|
|
1004
|
+
"analyze",
|
|
1005
|
+
"synthesize",
|
|
1006
|
+
"review",
|
|
1007
|
+
"close",
|
|
1008
|
+
]) &&
|
|
1009
|
+
exactStringArray(value.lifecycle.earlyScientificReviews, [
|
|
1010
|
+
"research-design",
|
|
1011
|
+
"evidence-construct",
|
|
1012
|
+
"pilot-methods",
|
|
1013
|
+
]) &&
|
|
1014
|
+
exactStringArray(value.lifecycle.finalPublicationReviews, finalPublicationReviews) &&
|
|
1015
|
+
value.lifecycle.finalManuscriptFreezeRequired === true &&
|
|
1016
|
+
value.lifecycle.newGenerationOnMaterialChange === true &&
|
|
1017
|
+
value.lifecycle.revisionReserveIncluded === true &&
|
|
1018
|
+
Array.isArray(value.instructions) &&
|
|
1019
|
+
typeof value.packetSha256 === "string");
|
|
1020
|
+
}
|
|
1021
|
+
function requiredGateRoles(stage) {
|
|
1022
|
+
const ordered = [
|
|
1023
|
+
"research-design",
|
|
1024
|
+
"evidence-construct",
|
|
1025
|
+
"pilot-methods",
|
|
1026
|
+
];
|
|
1027
|
+
if (stage === "discover")
|
|
1028
|
+
return ordered.slice(0, 1);
|
|
1029
|
+
if (stage === "acquire")
|
|
1030
|
+
return ordered.slice(0, 2);
|
|
1031
|
+
return ordered;
|
|
1032
|
+
}
|
|
1033
|
+
function reviewInstructions(role) {
|
|
1034
|
+
return [
|
|
1035
|
+
`Review only the exact immutable ${role} packet and its referenced objects.`,
|
|
1036
|
+
"Treat all mechanical failures as blocking; prose cannot upgrade them.",
|
|
1037
|
+
"Use a fresh independent reviewer session and return the authoritative closed JSON schema.",
|
|
1038
|
+
"Every stageInputs sha256 is the digest of raw file bytes at path; sourceLocator records provenance, while path is the promoted portable object that must be reviewed.",
|
|
1039
|
+
"Do not infer field validation, causality, independence, or quantity scope beyond the design.",
|
|
1040
|
+
"Interpret this gate within the declared lifecycle: three early scientific reviews precede four final publication reviews of the frozen manuscript.",
|
|
1041
|
+
"A material post-review change must create a new authoritative generation and consume the declared revision reserve.",
|
|
1042
|
+
"mechanicalAssessment.futureGateObligations names source-derived values, model implementations, and environment locks that are allowed to remain pending now but will become blocking mechanical errors at their exact due gate unless a new authoritative design generation freezes them.",
|
|
1043
|
+
];
|
|
1044
|
+
}
|
|
1045
|
+
function scientificFutureGateObligations(design, role) {
|
|
1046
|
+
const gateRank = {
|
|
1047
|
+
"research-design": 0,
|
|
1048
|
+
"evidence-construct": 1,
|
|
1049
|
+
"pilot-methods": 2,
|
|
1050
|
+
};
|
|
1051
|
+
const grouped = new Map();
|
|
1052
|
+
const addObligation = (code, dueGate, objectId, policyRuleIds) => {
|
|
1053
|
+
if (gateRank[dueGate] <= gateRank[role])
|
|
1054
|
+
return;
|
|
1055
|
+
const key = `${code}:${dueGate}`;
|
|
1056
|
+
const current = grouped.get(key) ?? {
|
|
1057
|
+
objectIds: [],
|
|
1058
|
+
policyRuleIds: new Set(),
|
|
1059
|
+
};
|
|
1060
|
+
current.objectIds.push(objectId);
|
|
1061
|
+
policyRuleIds.forEach((ruleId) => current.policyRuleIds.add(ruleId));
|
|
1062
|
+
grouped.set(key, current);
|
|
1063
|
+
};
|
|
1064
|
+
for (const parameter of design.uncertaintyParameters) {
|
|
1065
|
+
if (parameter.stateValueStatus !== "pending-source-acquisition" ||
|
|
1066
|
+
parameter.freezeBeforeGate === "research-design") {
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
addObligation("UNCERTAINTY_STATE_VALUES_NOT_FROZEN", parameter.freezeBeforeGate, parameter.id, design.policyRuleDispositions
|
|
1070
|
+
.filter((disposition) => disposition.status === "planned" &&
|
|
1071
|
+
disposition.dueGate === parameter.freezeBeforeGate &&
|
|
1072
|
+
disposition.uncertaintyParameterIds.includes(parameter.id))
|
|
1073
|
+
.map((disposition) => disposition.ruleId));
|
|
1074
|
+
}
|
|
1075
|
+
for (const model of design.identity.modelStructures) {
|
|
1076
|
+
if (model.implementationStatus === "pending-source-acquisition" &&
|
|
1077
|
+
model.implementationFreezeBeforeGate !== "research-design") {
|
|
1078
|
+
addObligation("MODEL_IMPLEMENTATION_NOT_FROZEN", model.implementationFreezeBeforeGate, model.id, design.policyRuleDispositions
|
|
1079
|
+
.filter((disposition) => disposition.status === "planned" &&
|
|
1080
|
+
disposition.dueGate === model.implementationFreezeBeforeGate &&
|
|
1081
|
+
disposition.modelStructureIds.includes(model.id))
|
|
1082
|
+
.map((disposition) => disposition.ruleId));
|
|
1083
|
+
}
|
|
1084
|
+
if (model.environmentLockStatus === "pending-runtime-lock" &&
|
|
1085
|
+
model.environmentLockFreezeBeforeGate !== "research-design") {
|
|
1086
|
+
addObligation("MODEL_ENVIRONMENT_LOCK_NOT_FROZEN", model.environmentLockFreezeBeforeGate, model.id, design.policyRuleDispositions
|
|
1087
|
+
.filter((disposition) => disposition.status === "planned" &&
|
|
1088
|
+
disposition.dueGate === model.environmentLockFreezeBeforeGate &&
|
|
1089
|
+
disposition.modelStructureIds.includes(model.id))
|
|
1090
|
+
.map((disposition) => disposition.ruleId));
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
return [...grouped.entries()].map(([key, obligation]) => ({
|
|
1094
|
+
code: key.slice(0, key.indexOf(":")),
|
|
1095
|
+
dueGate: key.slice(key.indexOf(":") + 1),
|
|
1096
|
+
objectIds: obligation.objectIds,
|
|
1097
|
+
policyRuleIds: [...obligation.policyRuleIds],
|
|
1098
|
+
}));
|
|
1099
|
+
}
|
|
1100
|
+
function exactStringArray(value, expected) {
|
|
1101
|
+
return (Array.isArray(value) &&
|
|
1102
|
+
value.length === expected.length &&
|
|
1103
|
+
value.every((item, index) => item === expected[index]));
|
|
1104
|
+
}
|
|
1105
|
+
function sameStringSet(left, right) {
|
|
1106
|
+
return left.length === right.length && left.every((item) => right.includes(item));
|
|
1107
|
+
}
|
|
1108
|
+
function exactJsonSha256(value) {
|
|
1109
|
+
return sha256Text(normalizedJson(value));
|
|
1110
|
+
}
|
|
1111
|
+
function normalizedJson(value) {
|
|
1112
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
1113
|
+
}
|
|
1114
|
+
function closedObject(required, properties) {
|
|
1115
|
+
return { type: "object", additionalProperties: false, required, properties };
|
|
1116
|
+
}
|
|
1117
|
+
function boundedStringSchema() {
|
|
1118
|
+
return { type: "string", minLength: 1, maxLength: 512 };
|
|
1119
|
+
}
|
|
1120
|
+
function stringSetSchema() {
|
|
1121
|
+
return { type: "array", uniqueItems: true, items: boundedStringSchema() };
|
|
1122
|
+
}
|
|
1123
|
+
function findingsSchema() {
|
|
1124
|
+
return {
|
|
1125
|
+
type: "array",
|
|
1126
|
+
items: closedObject(["code", "severity", "message", "evidenceIds"], {
|
|
1127
|
+
code: { type: "string", minLength: 1, maxLength: 128, pattern: "^[A-Z0-9_-]+$" },
|
|
1128
|
+
severity: { enum: ["blocking", "warning", "note"] },
|
|
1129
|
+
message: { type: "string", minLength: 1, maxLength: 4000 },
|
|
1130
|
+
evidenceIds: stringSetSchema(),
|
|
1131
|
+
}),
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
function schemaError(message, code, errors) {
|
|
1135
|
+
return new CliError(message, {
|
|
1136
|
+
code,
|
|
1137
|
+
exitCode: 2,
|
|
1138
|
+
details: sanitizeResearchValue({
|
|
1139
|
+
validation: (errors ?? []).map((error) => `${error.instancePath || "/"}: ${error.message}`),
|
|
1140
|
+
}),
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
function scientificGateError(message, role) {
|
|
1144
|
+
return new CliError(message, {
|
|
1145
|
+
code: "RESEARCH_SCIENTIFIC_GATE_INVALID",
|
|
1146
|
+
exitCode: 3,
|
|
1147
|
+
details: role ? { role } : undefined,
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
function inheritedGapObjectError(role, gapId, artifactKind, reason) {
|
|
1151
|
+
return new CliError("An inherited-gap source object failed its immutable binding.", {
|
|
1152
|
+
code: "RESEARCH_SCIENTIFIC_GATE_INVALID",
|
|
1153
|
+
exitCode: 3,
|
|
1154
|
+
details: { role, gapId, artifactKind, reason },
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
function modelArtifactObjectError(role, modelId, artifactKind, reason) {
|
|
1158
|
+
return new CliError("A frozen model object failed its immutable binding.", {
|
|
1159
|
+
code: "RESEARCH_SCIENTIFIC_GATE_INVALID",
|
|
1160
|
+
exitCode: 3,
|
|
1161
|
+
details: { role, modelId, artifactKind, reason },
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
function camelToCode(value) {
|
|
1165
|
+
return value.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
|
|
1166
|
+
}
|
|
1167
|
+
//# sourceMappingURL=scientific-review.js.map
|