@dogfood-lab/ingest 1.8.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/integrity.js +15 -1
- package/lib/unsafe-segment.js +15 -7
- package/load-context.js +344 -55
- package/package.json +1 -1
- package/persist.js +250 -5
- package/rebuild-indexes.js +18 -2
- package/run.js +525 -44
- package/verify-chain.js +253 -17
package/run.js
CHANGED
|
@@ -24,8 +24,9 @@ import { randomBytes } from 'node:crypto';
|
|
|
24
24
|
import { verify } from '@dogfood-lab/verify';
|
|
25
25
|
import { stubProvenance, provenanceForProvider } from '@dogfood-lab/verify/validators/provenance.js';
|
|
26
26
|
import { logStage as sharedLogStage } from '@dogfood-lab/dogfood-swarm/lib/log-stage.js';
|
|
27
|
-
import { loadGlobalPolicy, loadRepoPolicy, loadScenarios } from './load-context.js';
|
|
28
|
-
import { isDuplicate, writeRecord, computeRecordPath } from './persist.js';
|
|
27
|
+
import { loadGlobalPolicy, loadRepoPolicy, loadScenarios, githubScenarioFetcher } from './load-context.js';
|
|
28
|
+
import { isDuplicate, writeRecord, computeRecordPath, UnsafeRecordPathError } from './persist.js';
|
|
29
|
+
import { RecordValidationError } from './validate-record.js';
|
|
29
30
|
import { rebuildIndexes } from './rebuild-indexes.js';
|
|
30
31
|
import { verifyChain, formatChainResult } from './verify-chain.js';
|
|
31
32
|
import { handleAnchorCompute, handleAnchorPost, handleAnchorVerify } from './anchor/cli.js';
|
|
@@ -44,6 +45,27 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
44
45
|
* @param {object} submission
|
|
45
46
|
* @returns {{ provenance: object } | { err: Error }}
|
|
46
47
|
*/
|
|
48
|
+
/**
|
|
49
|
+
* F-a2e40a09: the ONE definition of "running in CI" for the provenance
|
|
50
|
+
* branches. Truthy check on either conventional variable — CI systems export
|
|
51
|
+
* CI=1 / CI=yes / CI=true interchangeably. Previously the stub-forbidden
|
|
52
|
+
* guard used this truthy check while the real-by-default branch demanded the
|
|
53
|
+
* exact string 'true', so a CI=1 environment was "CI" for one branch and
|
|
54
|
+
* "not CI" for the other (fail-closed, but with a misleading flag-required
|
|
55
|
+
* error instead of the token hint).
|
|
56
|
+
*
|
|
57
|
+
* F-5ca6c91a: the literal strings 'false' and '0' are explicit OPT-OUTS
|
|
58
|
+
* (a convention many tools respect — `CI=false npm test` is a real idiom),
|
|
59
|
+
* not truthy CI signals. Without this carve-out, a CI=false environment was
|
|
60
|
+
* classified as CI: stub provenance forbidden and the no-flag default
|
|
61
|
+
* demanding a real token — fail-closed, but against the operator's stated
|
|
62
|
+
* intent.
|
|
63
|
+
*/
|
|
64
|
+
function isCI() {
|
|
65
|
+
const truthy = (v) => Boolean(v && v !== 'false' && v !== '0');
|
|
66
|
+
return truthy(process.env.CI) || truthy(process.env.GITHUB_ACTIONS);
|
|
67
|
+
}
|
|
68
|
+
|
|
47
69
|
function resolveProviderProvenance(submission) {
|
|
48
70
|
const provider = (submission && submission.source && submission.source.provider) || 'github';
|
|
49
71
|
const factory = provenanceForProvider(provider);
|
|
@@ -60,6 +82,71 @@ function resolveProviderProvenance(submission) {
|
|
|
60
82
|
return { provenance: factory(token) };
|
|
61
83
|
}
|
|
62
84
|
|
|
85
|
+
/**
|
|
86
|
+
* V2-INVARIAN-004: the ONE definition of "which submissions get a scenario
|
|
87
|
+
* fetcher" (and with it required-steps enforcement). Previously this
|
|
88
|
+
* condition lived inline in the CLI entrypoint where it was untestable
|
|
89
|
+
* without spawning a process.
|
|
90
|
+
*
|
|
91
|
+
* Returns a `githubScenarioFetcher` when ALL hold:
|
|
92
|
+
* - provenance is REAL (not stubProvenance — preview/test runs never hit
|
|
93
|
+
* the GitHub API);
|
|
94
|
+
* - the submission is a plain object with provider `github` (default when
|
|
95
|
+
* `source.provider` is absent) — GitLab has no contents-API adapter yet,
|
|
96
|
+
* so its required_steps gate remains unenforced (documented gap);
|
|
97
|
+
* - `submission.repo` and `submission.ref.commit_sha` are strings (the
|
|
98
|
+
* fetcher re-validates their shapes fail-closed);
|
|
99
|
+
* - a GitHub token is present in `env` (GITHUB_TOKEN, then GH_TOKEN — the
|
|
100
|
+
* same token resolveProviderProvenance validated).
|
|
101
|
+
*
|
|
102
|
+
* Otherwise returns null: verification proceeds without scenario
|
|
103
|
+
* definitions, exactly as before F-3bfc2885 wired the production path.
|
|
104
|
+
*
|
|
105
|
+
* @param {object} provenance - resolved provenance adapter
|
|
106
|
+
* @param {object} submission
|
|
107
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
108
|
+
* @returns {ReturnType<typeof githubScenarioFetcher> | null}
|
|
109
|
+
*/
|
|
110
|
+
export function resolveScenarioFetcher(provenance, submission, env = process.env) {
|
|
111
|
+
return resolveScenarioFetcherDecision(provenance, submission, env).fetcher;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* F-b04473d5: the reason-carrying form of {@link resolveScenarioFetcher}.
|
|
116
|
+
* Every ineligible branch previously returned a silent null — an operator
|
|
117
|
+
* auditing an accepted record could not tell "required_steps enforced and
|
|
118
|
+
* satisfied" from "enforcement skipped (GitLab gap / missing token)" in the
|
|
119
|
+
* run log. The CLI logs the decision as a `scenario_enforcement` NDJSON
|
|
120
|
+
* event; this function is the unit-testable seam behind it.
|
|
121
|
+
*
|
|
122
|
+
* @param {object} provenance
|
|
123
|
+
* @param {object} submission
|
|
124
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
125
|
+
* @returns {{ fetcher: ReturnType<typeof githubScenarioFetcher> | null,
|
|
126
|
+
* active: boolean, reason: string | null }} `reason` names the skip class
|
|
127
|
+
* when `active` is false; null when a fetcher was constructed.
|
|
128
|
+
*/
|
|
129
|
+
export function resolveScenarioFetcherDecision(provenance, submission, env = process.env) {
|
|
130
|
+
const skip = (reason) => ({ fetcher: null, active: false, reason });
|
|
131
|
+
if (provenance === stubProvenance) return skip('stub-provenance');
|
|
132
|
+
if (!submission || typeof submission !== 'object' || Array.isArray(submission)) {
|
|
133
|
+
return skip('submission-malformed');
|
|
134
|
+
}
|
|
135
|
+
if (((submission.source && submission.source.provider) || 'github') !== 'github') {
|
|
136
|
+
// GitLab has no contents-API adapter yet — the documented gap.
|
|
137
|
+
return skip('provider-unsupported');
|
|
138
|
+
}
|
|
139
|
+
if (typeof submission.repo !== 'string') return skip('repo-not-string');
|
|
140
|
+
if (typeof submission.ref?.commit_sha !== 'string') return skip('commit-sha-not-string');
|
|
141
|
+
const token = env.GITHUB_TOKEN || env.GH_TOKEN;
|
|
142
|
+
if (!token) return skip('no-token');
|
|
143
|
+
return {
|
|
144
|
+
fetcher: githubScenarioFetcher(token, submission.repo, submission.ref.commit_sha),
|
|
145
|
+
active: true,
|
|
146
|
+
reason: null
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
63
150
|
/**
|
|
64
151
|
* SEED-1 (d3-ingest-003) — posixify a path-shaped value at the operator/log
|
|
65
152
|
* SERIALIZATION boundary. `computeRecordPath`/`writeRecord` return OS-native
|
|
@@ -81,6 +168,50 @@ function posixifyPath(p) {
|
|
|
81
168
|
return typeof p === 'string' ? p.split(sep).join('/') : p;
|
|
82
169
|
}
|
|
83
170
|
|
|
171
|
+
/**
|
|
172
|
+
* F-2750c4e8: ceiling on individually-persisted `scenario-load:` rejection
|
|
173
|
+
* reasons. A hostile pre-schema-gate payload can produce up to
|
|
174
|
+
* MAX_DISTINCT_SCENARIO_FETCHES scenario-load errors; persisting one reason
|
|
175
|
+
* line per error bloats the evidence record without adding operator signal
|
|
176
|
+
* (a 1000-error submission has one root cause). Everything past the ceiling
|
|
177
|
+
* collapses into a single count summary.
|
|
178
|
+
*/
|
|
179
|
+
const SCENARIO_LOAD_REASON_CEILING = 20;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Format loadScenarios errors as `scenario-load:` rejection reasons,
|
|
183
|
+
* collapsing past {@link SCENARIO_LOAD_REASON_CEILING}.
|
|
184
|
+
*
|
|
185
|
+
* @param {string[]} scenarioErrors
|
|
186
|
+
* @returns {string[]}
|
|
187
|
+
*/
|
|
188
|
+
function scenarioLoadReasons(scenarioErrors) {
|
|
189
|
+
const reasons = scenarioErrors.map(e => `scenario-load: ${e}`);
|
|
190
|
+
if (reasons.length <= SCENARIO_LOAD_REASON_CEILING) return reasons;
|
|
191
|
+
return [
|
|
192
|
+
...reasons.slice(0, SCENARIO_LOAD_REASON_CEILING),
|
|
193
|
+
`scenario-load: (+${reasons.length - SCENARIO_LOAD_REASON_CEILING} more scenario-load errors collapsed; ${reasons.length} total)`
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* DESIGN RULING (wave 4): loadScenarios' `warnings` (true-404 scenario
|
|
199
|
+
* definitions — nothing declared, nothing to enforce) land on the v1.6.0
|
|
200
|
+
* accepted-with-warning channel (`verification.warnings`), NEVER on
|
|
201
|
+
* rejection_reasons. verify() only sets the field when policy warnings
|
|
202
|
+
* exist, so create-or-append here.
|
|
203
|
+
*
|
|
204
|
+
* @param {object} record - the record returned by verify()
|
|
205
|
+
* @param {string[]} scenarioWarnings
|
|
206
|
+
*/
|
|
207
|
+
function appendScenarioWarnings(record, scenarioWarnings) {
|
|
208
|
+
if (!scenarioWarnings || scenarioWarnings.length === 0) return;
|
|
209
|
+
record.verification.warnings = [
|
|
210
|
+
...(record.verification.warnings || []),
|
|
211
|
+
...scenarioWarnings
|
|
212
|
+
];
|
|
213
|
+
}
|
|
214
|
+
|
|
84
215
|
/**
|
|
85
216
|
* Emit a single structured stage-transition log line via the shared helper.
|
|
86
217
|
*
|
|
@@ -146,6 +277,27 @@ function resolveCorrelationId(submission) {
|
|
|
146
277
|
return synthCorrelationId();
|
|
147
278
|
}
|
|
148
279
|
|
|
280
|
+
/**
|
|
281
|
+
* F-41872706: count how many loaded scenario definitions actually carry an
|
|
282
|
+
* enforceable required_steps gate. `scenarios.size` alone answers "how many
|
|
283
|
+
* definitions were fetched"; this answers "how many of those could fire the
|
|
284
|
+
* step-results-present / step-verdict-consistent reject rules" — a definition
|
|
285
|
+
* with no (or empty) success_criteria.required_steps enforces nothing (see
|
|
286
|
+
* verify/index.js: requiredSteps defaults to [] and the loop no-ops).
|
|
287
|
+
*
|
|
288
|
+
* @param {Map|null} scenarios - the Map returned by loadScenarios, or null
|
|
289
|
+
* @returns {number}
|
|
290
|
+
*/
|
|
291
|
+
function countRequiredStepsGates(scenarios) {
|
|
292
|
+
if (!scenarios || typeof scenarios.values !== 'function') return 0;
|
|
293
|
+
let gates = 0;
|
|
294
|
+
for (const definition of scenarios.values()) {
|
|
295
|
+
const steps = definition?.success_criteria?.required_steps;
|
|
296
|
+
if (Array.isArray(steps) && steps.length > 0) gates++;
|
|
297
|
+
}
|
|
298
|
+
return gates;
|
|
299
|
+
}
|
|
300
|
+
|
|
149
301
|
/**
|
|
150
302
|
* Run the full ingestion pipeline.
|
|
151
303
|
*
|
|
@@ -199,7 +351,40 @@ export async function ingest(submission, options) {
|
|
|
199
351
|
timing: submission.timing,
|
|
200
352
|
verification: { status: 'accepted' }
|
|
201
353
|
};
|
|
202
|
-
|
|
354
|
+
// F-f8952a50 (wave 10): this probe's hardcoded `status: 'accepted'` is
|
|
355
|
+
// ALSO the site the finding proved a corrected repo:mismatch resubmission
|
|
356
|
+
// was swallowed at — isDuplicate() only asks isRetryableRejection() when
|
|
357
|
+
// the record it's handed claims 'accepted' (see isDuplicate's own
|
|
358
|
+
// comment), so this probe is the FIRST place a stale non-schema rejection
|
|
359
|
+
// could mask a genuine correction, before verify() ever runs. No separate
|
|
360
|
+
// fix belongs HERE, though: this probe and writeRecord()'s own internal
|
|
361
|
+
// isDuplicate() call (persist.js) share the exact same isDuplicate() /
|
|
362
|
+
// isRetryableRejection() code path, so persist.js's per-prefix
|
|
363
|
+
// `retryable` fix (parse-rejection.js) already reaches both call sites —
|
|
364
|
+
// widening retryability there is what unblocks this probe too, with
|
|
365
|
+
// nothing probe-specific to change.
|
|
366
|
+
// F-a37d36f5: isDuplicate -> computeRecordPath runs against RAW untrusted
|
|
367
|
+
// submission.repo/run_id, three steps BEFORE verify()'s schema gate. A
|
|
368
|
+
// malformed repo ('a/b/c', a path-traversal attempt, etc.) makes
|
|
369
|
+
// computeRecordPath THROW ('invalid repo format' / 'unsafe repo segment'
|
|
370
|
+
// / 'unsafe run_id') — good, the traversal guards hold — but letting that
|
|
371
|
+
// throw escape here inverts this repo's submission-bad vs operational
|
|
372
|
+
// doctrine: it propagates as an uncaught fault (exit 2, "operator error"
|
|
373
|
+
// per the CLI's own USAGE block) for input that is squarely the
|
|
374
|
+
// SUBMITTER's to fix, and no `_rejected` evidence record is ever
|
|
375
|
+
// written. A malformed repo/run_id can never collide with an existing
|
|
376
|
+
// record anyway, so treating an unpathable submission as "not a
|
|
377
|
+
// duplicate" is semantically free — verify()'s schema gate is the
|
|
378
|
+
// authoritative judge of bad input, and writeRecord's own
|
|
379
|
+
// computeRecordPath (which runs AFTER validateRecord, on the SCHEMA-
|
|
380
|
+
// VALIDATED persisted record) remains the real enforcement point.
|
|
381
|
+
let duplicate;
|
|
382
|
+
try {
|
|
383
|
+
duplicate = isDuplicate(submission.run_id, probeRecord, repoRoot);
|
|
384
|
+
} catch {
|
|
385
|
+
duplicate = false;
|
|
386
|
+
}
|
|
387
|
+
if (duplicate) {
|
|
203
388
|
logStage('rejected_pre_persist', {
|
|
204
389
|
submission_id: submissionId,
|
|
205
390
|
correlation_id,
|
|
@@ -226,11 +411,26 @@ export async function ingest(submission, options) {
|
|
|
226
411
|
repo_policy_present: !!repoPolicy
|
|
227
412
|
});
|
|
228
413
|
|
|
229
|
-
// 3. Load scenario definitions
|
|
414
|
+
// 3. Load scenario definitions.
|
|
415
|
+
// F-3bfc2885: keep the loaded `scenarios` Map (previously discarded) and hand
|
|
416
|
+
// it to verify() so success_criteria.required_steps is actually enforced.
|
|
417
|
+
// F-efe4f893: gate on Array.isArray — this runs BEFORE the schema gate on
|
|
418
|
+
// untrusted input, and a string/object scenario_results must never reach the
|
|
419
|
+
// fetch loop (a string iterates CHARACTERS). verify() lands the
|
|
420
|
+
// authoritative schema rejection downstream.
|
|
421
|
+
// DESIGN RULING (wave 4): a true-404 definition is a WARNING (nothing
|
|
422
|
+
// declared, nothing to enforce), a malformed committed file is a
|
|
423
|
+
// `scenario-load:` rejection, and V2-CROSS-BO-001 outages (incl. exhausted
|
|
424
|
+
// timeout, F-07ab7f86) throw `scenario-fetch-fault:` PAST this step — the
|
|
425
|
+
// CLI's outer catch emits a structured operational error and exits 2.
|
|
230
426
|
let scenarioErrors = [];
|
|
231
|
-
|
|
427
|
+
let scenarioWarnings = [];
|
|
428
|
+
let scenarios = null;
|
|
429
|
+
if (scenarioFetcher && submissionIsObject && Array.isArray(submission.scenario_results)) {
|
|
232
430
|
const result = await loadScenarios(submission, scenarioFetcher);
|
|
233
431
|
scenarioErrors = result.errors;
|
|
432
|
+
scenarioWarnings = result.warnings || [];
|
|
433
|
+
scenarios = result.scenarios;
|
|
234
434
|
}
|
|
235
435
|
|
|
236
436
|
// 4. Call verifier — the law engine makes all decisions
|
|
@@ -238,7 +438,8 @@ export async function ingest(submission, options) {
|
|
|
238
438
|
globalPolicy,
|
|
239
439
|
repoPolicy,
|
|
240
440
|
provenance,
|
|
241
|
-
policyVersion
|
|
441
|
+
policyVersion,
|
|
442
|
+
scenarios
|
|
242
443
|
});
|
|
243
444
|
|
|
244
445
|
logStage('verify_complete', {
|
|
@@ -246,14 +447,25 @@ export async function ingest(submission, options) {
|
|
|
246
447
|
correlation_id,
|
|
247
448
|
status: record.verification?.status ?? null,
|
|
248
449
|
rejection_reason_count: record.verification?.rejection_reasons?.length ?? 0,
|
|
249
|
-
verdict: record.overall_verdict?.verified ?? null
|
|
450
|
+
verdict: record.overall_verdict?.verified ?? null,
|
|
451
|
+
// F-41872706: the scenario_enforcement event (CLI wrapper, pre-load) logs
|
|
452
|
+
// scenario_count = how many scenarios the submission DECLARED. These three
|
|
453
|
+
// are the post-load breakdown so an operator can tell "N gates enforced"
|
|
454
|
+
// from "N scenarios, all true-404, enforcement skipped". scenarios_loaded
|
|
455
|
+
// is the Map size (definitions actually fetched); required_steps_gates_fired
|
|
456
|
+
// is the subset that carried a non-empty required_steps.
|
|
457
|
+
scenarios_loaded: scenarios ? scenarios.size : 0,
|
|
458
|
+
required_steps_gates_fired: countRequiredStepsGates(scenarios),
|
|
459
|
+
scenarios_warned: scenarioWarnings.length,
|
|
460
|
+
scenarios_errored: scenarioErrors.length
|
|
250
461
|
});
|
|
251
462
|
|
|
252
|
-
// 4b.
|
|
463
|
+
// 4b. Surface scenario loading outcomes: warnings (true-404 definitions) go
|
|
464
|
+
// to verification.warnings; errors (malformed committed files) become
|
|
465
|
+
// scenario-load rejections, collapsed past the reason ceiling (F-2750c4e8).
|
|
466
|
+
appendScenarioWarnings(record, scenarioWarnings);
|
|
253
467
|
if (scenarioErrors.length > 0) {
|
|
254
|
-
record.verification.rejection_reasons.push(
|
|
255
|
-
...scenarioErrors.map(e => `scenario-load: ${e}`)
|
|
256
|
-
);
|
|
468
|
+
record.verification.rejection_reasons.push(...scenarioLoadReasons(scenarioErrors));
|
|
257
469
|
// If scenario loading failed, this is a rejection
|
|
258
470
|
if (record.verification.status === 'accepted' && scenarioErrors.length > 0) {
|
|
259
471
|
record.verification.status = 'rejected';
|
|
@@ -285,7 +497,72 @@ export async function ingest(submission, options) {
|
|
|
285
497
|
return { record, path: null, written: false, duplicate: false };
|
|
286
498
|
}
|
|
287
499
|
const persistStart = Date.now();
|
|
288
|
-
|
|
500
|
+
let path, written;
|
|
501
|
+
try {
|
|
502
|
+
({ path, written } = writeRecord(record, repoRoot));
|
|
503
|
+
} catch (err) {
|
|
504
|
+
// F-4acd28d8: computeRecordPath()'s traversal guard (isUnsafeSegment) is
|
|
505
|
+
// STRICTER than the submission schema's repo pattern (F-bbbe2e1f — e.g.
|
|
506
|
+
// `../etc` is schema-valid but traversal-unsafe), so a record can reach
|
|
507
|
+
// here without `_skipPersist` ever having been set. The record's own
|
|
508
|
+
// identifier is what's unfilable — submission-bad, not an operator
|
|
509
|
+
// incident — so route it like `_skipPersist` above instead of letting the
|
|
510
|
+
// throw reach the outer CLI catch, which would misreport it as
|
|
511
|
+
// failed_stage:'cli_parse_payload' and exit 2 ("operator error") for
|
|
512
|
+
// content that is squarely the submitter's to fix.
|
|
513
|
+
//
|
|
514
|
+
// RecordValidationError is the SIBLING gap F-4036ae25's _skipPersist
|
|
515
|
+
// narrowing opens: dogfood-record.schema.json mirrors the submission
|
|
516
|
+
// schema's constraints on every source-authored field verify() copies
|
|
517
|
+
// verbatim (ref, source, timing, scenario_results, overall_verdict.proposed),
|
|
518
|
+
// so a submission that is schema-invalid on one of THOSE fields (not just
|
|
519
|
+
// repo/run_id/timing.finished_at) reaches writeRecord() filable-by-identity
|
|
520
|
+
// and still fails validateRecord() here. Only catch it when the submission
|
|
521
|
+
// was ALREADY schema-invalid (record.verification.schema_valid === false)
|
|
522
|
+
// — that is the authoritative signal this is submission-bad fallout, not a
|
|
523
|
+
// genuine internal defect in what verify() assembled for an
|
|
524
|
+
// otherwise-valid submission, which must keep crashing loudly.
|
|
525
|
+
const isUnfilableRecordPath = err instanceof UnsafeRecordPathError;
|
|
526
|
+
const isSubmissionBadRecordShape =
|
|
527
|
+
err instanceof RecordValidationError && record.verification.schema_valid === false;
|
|
528
|
+
if (!isUnfilableRecordPath && !isSubmissionBadRecordShape) {
|
|
529
|
+
throw err;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// verify() already rejected this submission for a real reason whenever
|
|
533
|
+
// one exists (e.g. repo:mismatch, or the schema violation itself) — those
|
|
534
|
+
// reasons survive untouched. When verify() had no reason to reject (an
|
|
535
|
+
// otherwise-accepted record whose ONLY problem is storage-unsafety),
|
|
536
|
+
// downgrade the verdict here — the same shape used below for a late
|
|
537
|
+
// scenario-load rejection.
|
|
538
|
+
if (isUnfilableRecordPath) {
|
|
539
|
+
record.verification.rejection_reasons.push(`unsafe-record-path: ${err.message}`);
|
|
540
|
+
if (record.verification.status === 'accepted') {
|
|
541
|
+
record.verification.status = 'rejected';
|
|
542
|
+
record.verification.policy_valid = false;
|
|
543
|
+
if (record.overall_verdict.verified === 'pass') {
|
|
544
|
+
record.overall_verdict.verified = 'fail';
|
|
545
|
+
record.overall_verdict.downgraded = true;
|
|
546
|
+
if (!record.overall_verdict.downgrade_reasons) {
|
|
547
|
+
record.overall_verdict.downgrade_reasons = [];
|
|
548
|
+
}
|
|
549
|
+
record.overall_verdict.downgrade_reasons.push('record path could not be safely computed');
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
logStage('rejected_pre_persist', {
|
|
555
|
+
submission_id: submissionId,
|
|
556
|
+
correlation_id,
|
|
557
|
+
reason: isUnfilableRecordPath ? 'unsafe_record_path' : 'record_schema_invalid_from_submission',
|
|
558
|
+
rejection_reasons: record.verification.rejection_reasons ?? [],
|
|
559
|
+
// Operator-debug only: validateRecord()'s OWN structured errors, distinct
|
|
560
|
+
// from (and not merged into) the submitter-facing rejection_reasons above,
|
|
561
|
+
// which stay exactly as verify() computed them against the submission schema.
|
|
562
|
+
...(isSubmissionBadRecordShape ? { record_validation_errors: err.errors } : {})
|
|
563
|
+
});
|
|
564
|
+
return { record, path: null, written: false, duplicate: false };
|
|
565
|
+
}
|
|
289
566
|
logStage('persist_complete', {
|
|
290
567
|
submission_id: submissionId,
|
|
291
568
|
correlation_id,
|
|
@@ -347,7 +624,20 @@ export async function ingest(submission, options) {
|
|
|
347
624
|
}
|
|
348
625
|
}
|
|
349
626
|
|
|
350
|
-
|
|
627
|
+
// F-7b97fbd4 (wave 10): mirror persist_complete's OWN `duplicate: !written`
|
|
628
|
+
// (logged a few lines above, off the same `written`) instead of a bare
|
|
629
|
+
// `false` that silently disagreed with it. By this point `written` can be
|
|
630
|
+
// `false` for exactly one reason — writeRecord()'s internal isDuplicate()
|
|
631
|
+
// blocked the write as a collision (persist.js) — every OTHER
|
|
632
|
+
// non-persisting outcome in this function (record._skipPersist above; the
|
|
633
|
+
// UnsafeRecordPathError / RecordValidationError catch above that) already
|
|
634
|
+
// returns its own honest `duplicate: false` earlier and never reaches this
|
|
635
|
+
// line. The CLI wrapper's `if (result.duplicate)` branch depends on this
|
|
636
|
+
// being accurate: a blocked resubmission must take the terse exit-0
|
|
637
|
+
// `{status:'duplicate'}` path, the same one an early-detected duplicate
|
|
638
|
+
// (the pre-verify probe above) already takes, not fall through to the
|
|
639
|
+
// full rejected-record exit-1 shape.
|
|
640
|
+
return { record, path, written, duplicate: !written };
|
|
351
641
|
}
|
|
352
642
|
|
|
353
643
|
/**
|
|
@@ -417,11 +707,18 @@ export async function verifyOnly(submission, options) {
|
|
|
417
707
|
repo_policy_present: !!repoPolicy
|
|
418
708
|
});
|
|
419
709
|
|
|
420
|
-
// 3. Load scenario definitions
|
|
710
|
+
// 3. Load scenario definitions — same gates + semantics as ingest() step 3
|
|
711
|
+
// (F-3bfc2885 Map pass-through, F-efe4f893 Array.isArray gate, wave-4
|
|
712
|
+
// ruling's warning/rejection/operational split), so verify-only and real
|
|
713
|
+
// ingest produce identical records for the same submission.
|
|
421
714
|
let scenarioErrors = [];
|
|
422
|
-
|
|
715
|
+
let scenarioWarnings = [];
|
|
716
|
+
let scenarios = null;
|
|
717
|
+
if (scenarioFetcher && submissionIsObject && Array.isArray(submission.scenario_results)) {
|
|
423
718
|
const result = await loadScenarios(submission, scenarioFetcher);
|
|
424
719
|
scenarioErrors = result.errors;
|
|
720
|
+
scenarioWarnings = result.warnings || [];
|
|
721
|
+
scenarios = result.scenarios;
|
|
425
722
|
}
|
|
426
723
|
|
|
427
724
|
// 4. Call verifier
|
|
@@ -429,7 +726,8 @@ export async function verifyOnly(submission, options) {
|
|
|
429
726
|
globalPolicy,
|
|
430
727
|
repoPolicy,
|
|
431
728
|
provenance,
|
|
432
|
-
policyVersion
|
|
729
|
+
policyVersion,
|
|
730
|
+
scenarios
|
|
433
731
|
});
|
|
434
732
|
|
|
435
733
|
logStage('verify_complete', {
|
|
@@ -437,15 +735,21 @@ export async function verifyOnly(submission, options) {
|
|
|
437
735
|
correlation_id,
|
|
438
736
|
status: record.verification?.status ?? null,
|
|
439
737
|
rejection_reason_count: record.verification?.rejection_reasons?.length ?? 0,
|
|
440
|
-
verdict: record.overall_verdict?.verified ?? null
|
|
738
|
+
verdict: record.overall_verdict?.verified ?? null,
|
|
739
|
+
// F-41872706: same post-load scenario breakdown as ingest()'s
|
|
740
|
+
// verify_complete, so verify-only and real ingest produce identical NDJSON
|
|
741
|
+
// observability for the same submission.
|
|
742
|
+
scenarios_loaded: scenarios ? scenarios.size : 0,
|
|
743
|
+
required_steps_gates_fired: countRequiredStepsGates(scenarios),
|
|
744
|
+
scenarios_warned: scenarioWarnings.length,
|
|
745
|
+
scenarios_errored: scenarioErrors.length
|
|
441
746
|
});
|
|
442
747
|
|
|
443
748
|
// 4b. Mirror ingest's scenario-error verdict downgrade so verify-only and
|
|
444
749
|
// real ingest produce identical records for the same submission.
|
|
750
|
+
appendScenarioWarnings(record, scenarioWarnings);
|
|
445
751
|
if (scenarioErrors.length > 0) {
|
|
446
|
-
record.verification.rejection_reasons.push(
|
|
447
|
-
...scenarioErrors.map(e => `scenario-load: ${e}`)
|
|
448
|
-
);
|
|
752
|
+
record.verification.rejection_reasons.push(...scenarioLoadReasons(scenarioErrors));
|
|
449
753
|
if (record.verification.status === 'accepted' && scenarioErrors.length > 0) {
|
|
450
754
|
record.verification.status = 'rejected';
|
|
451
755
|
record.verification.policy_valid = false;
|
|
@@ -472,10 +776,12 @@ export async function verifyOnly(submission, options) {
|
|
|
472
776
|
would_persist_to = computeRecordPath(record, repoRoot);
|
|
473
777
|
} catch {
|
|
474
778
|
// Defensive: if a record passes verify() but still trips path
|
|
475
|
-
// computation (e.g
|
|
476
|
-
// verify-only side-effect-free. Real ingest
|
|
477
|
-
//
|
|
478
|
-
//
|
|
779
|
+
// computation (e.g. F-bbbe2e1f's `../etc` — schema-valid but
|
|
780
|
+
// traversal-unsafe), keep verify-only side-effect-free. Real ingest
|
|
781
|
+
// hits the SAME underlying computeRecordPath failure inside
|
|
782
|
+
// writeRecord() but now catches it too (F-4acd28d8, UnsafeRecordPathError)
|
|
783
|
+
// rather than letting it escape; verify-only just returns null here and
|
|
784
|
+
// lets the operator see the rejection in record.verification.rejection_reasons.
|
|
479
785
|
would_persist_to = null;
|
|
480
786
|
}
|
|
481
787
|
}
|
|
@@ -521,6 +827,46 @@ function emitCliErrorEvent({ failedStage, correlationId, submissionId = null, er
|
|
|
521
827
|
// --- CLI entrypoint ---
|
|
522
828
|
// When run directly, reads submission from stdin or file argument
|
|
523
829
|
|
|
830
|
+
// F-9a65b10c (Stage C humanization): the write-path CLI ships a USAGE block like
|
|
831
|
+
// every other bin (verify/cli.js, report/cli.js, portfolio/generate.js). This is
|
|
832
|
+
// the only operator-facing entry point that writes records, so its reference has
|
|
833
|
+
// to live IN the tool, not scattered across docs.
|
|
834
|
+
const USAGE = `ingest — persist a dogfood submission (verify → policy → provenance → write)
|
|
835
|
+
|
|
836
|
+
Usage:
|
|
837
|
+
node packages/ingest/run.js --file <path> --provenance=github|stub
|
|
838
|
+
node packages/ingest/run.js --payload '<json>' --provenance=github|stub
|
|
839
|
+
echo '<json>' | node packages/ingest/run.js --provenance=github|stub
|
|
840
|
+
|
|
841
|
+
Input (exactly one; stdin used when neither flag is given):
|
|
842
|
+
--file <path> Read the submission JSON from a file.
|
|
843
|
+
--payload <json> Pass the submission JSON inline.
|
|
844
|
+
(stdin) Pipe the submission JSON on stdin.
|
|
845
|
+
|
|
846
|
+
Provenance (required for an ingest):
|
|
847
|
+
--provenance=github Confirm the source run via the GitHub API.
|
|
848
|
+
--provenance=stub No-network local confirm (dry-run / dev only).
|
|
849
|
+
|
|
850
|
+
Modes:
|
|
851
|
+
--verify-only Run the full pipeline WITHOUT writing or rebuilding
|
|
852
|
+
indexes; report where a real ingest WOULD have landed.
|
|
853
|
+
|
|
854
|
+
Standalone audit verbs (no submission, no stdin, no --provenance):
|
|
855
|
+
--verify-chain Verify the append-only integrity ledger (offline).
|
|
856
|
+
--reconcile Also fail on genuine torn-write orphans (on-disk records
|
|
857
|
+
missing from the ledger). Records that predate the
|
|
858
|
+
integrity chain itself are reported separately and do
|
|
859
|
+
not fail the audit.
|
|
860
|
+
--all Report every independent break instead of the first.
|
|
861
|
+
--anchor-compute Compute + write the next XRPL anchor manifest (offline).
|
|
862
|
+
--anchor-post Compute if needed + post the anchor to XRPL (needs XRPL_SEED).
|
|
863
|
+
--anchor-verify Verify local manifests + run the truncation check (offline).
|
|
864
|
+
|
|
865
|
+
-h, --help Show this help.
|
|
866
|
+
|
|
867
|
+
Exit codes:
|
|
868
|
+
0 success 1 integrity/audit break 2 operator error (flags / IO / JSON)`;
|
|
869
|
+
|
|
524
870
|
const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(__dirname, 'run.js');
|
|
525
871
|
|
|
526
872
|
if (isMain) {
|
|
@@ -551,6 +897,7 @@ if (isMain) {
|
|
|
551
897
|
// per-record-independent break and report every break in one pass.
|
|
552
898
|
let reconcileFlag = false;
|
|
553
899
|
let allBreaksFlag = false;
|
|
900
|
+
let helpFlag = false;
|
|
554
901
|
// Anchor verbs (optional, off-by-default, operator-run). --anchor-compute and
|
|
555
902
|
// --anchor-verify are fully offline (never import xrpl); --anchor-post lazily
|
|
556
903
|
// loads the optional xrpl package and needs XRPL_SEED.
|
|
@@ -590,9 +937,38 @@ if (isMain) {
|
|
|
590
937
|
const hasValue = inlineValue !== null || nextIsValue;
|
|
591
938
|
const takeValue = () => (inlineValue !== null ? inlineValue : args[++i]);
|
|
592
939
|
|
|
593
|
-
|
|
940
|
+
// F-e0bcbc47 (amended wave 29, after the first attempt regressed
|
|
941
|
+
// F-INGEST-003): `flagIs(name)` both TESTS the token and RECORDS that
|
|
942
|
+
// `name` is a flag this chain knows about, so the catch-all below can tell
|
|
943
|
+
// "unknown flag" (no branch knows this name) apart from "known flag, no
|
|
944
|
+
// value" (a branch knows the name but its `&& hasValue` guard failed —
|
|
945
|
+
// exactly what F-INGEST-003 causes for `--anchor-network --anchor-compute`).
|
|
946
|
+
// The first attempt at this fix had a bare `else if (arg.startsWith('--'))`
|
|
947
|
+
// catch-all that could not distinguish the two and so reported a KNOWN flag
|
|
948
|
+
// as "unknown argument: --anchor-network" — a claim wider than what it
|
|
949
|
+
// actually checked, which is the exact class this repo keeps paying for.
|
|
950
|
+
//
|
|
951
|
+
// `knownFlagName` is DERIVED from the chain itself rather than duplicated
|
|
952
|
+
// into a second list of flag names: the only way a name enters the known
|
|
953
|
+
// set is for the chain to literally test for it one line below. A parallel
|
|
954
|
+
// `KNOWN_FLAGS` enumeration would be a second population to keep in sync
|
|
955
|
+
// (the same enumeration-vs-property class as CONTROL_CLASS / ZALGO_RUN /
|
|
956
|
+
// DASH_CONFUSABLES), and it would drift the first time someone adds a flag
|
|
957
|
+
// and forgets the list. Adding a branch here cannot desync it — the branch
|
|
958
|
+
// IS the registration. The one residual shape is a future branch written as
|
|
959
|
+
// a bare `arg === '--new'` instead of `flagIs('--new')`; that would make the
|
|
960
|
+
// new flag report as unknown when dangling, and is guarded by
|
|
961
|
+
// f-e0bcbc47-unknown-flag-rejection.test.js's chain-shape assertion.
|
|
962
|
+
let knownFlagName = false;
|
|
963
|
+
const flagIs = (name) => {
|
|
964
|
+
if (arg !== name) return false;
|
|
965
|
+
knownFlagName = true;
|
|
966
|
+
return true;
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
if (flagIs('--provenance') && hasValue) {
|
|
594
970
|
provenanceMode = takeValue();
|
|
595
|
-
} else if (
|
|
971
|
+
} else if (flagIs('--file') && hasValue) {
|
|
596
972
|
const { readFileSync } = await import('node:fs');
|
|
597
973
|
// D1B-001 family (operator-legibility): a --file read failure
|
|
598
974
|
// (ENOENT/EACCES) routes through the structured error event and exits 2
|
|
@@ -612,56 +988,115 @@ if (isMain) {
|
|
|
612
988
|
});
|
|
613
989
|
process.exit(2);
|
|
614
990
|
}
|
|
615
|
-
} else if (
|
|
991
|
+
} else if (flagIs('--payload') && hasValue) {
|
|
616
992
|
submissionJson = takeValue();
|
|
617
|
-
} else if (
|
|
993
|
+
} else if (flagIs('--verify-only')) {
|
|
618
994
|
// F-252714-058: dry-run the pipeline without writing or rebuilding
|
|
619
995
|
// indexes. CI / operators preview what WOULD have been persisted.
|
|
620
996
|
verifyOnlyFlag = true;
|
|
621
|
-
} else if (
|
|
997
|
+
} else if (flagIs('--verify-chain')) {
|
|
622
998
|
// Integrity chain v1: verify the append-only tamper-evident ledger at
|
|
623
999
|
// indexes/integrity/chain.jsonl, fully offline. No submission, no stdin,
|
|
624
1000
|
// no provenance — a standalone audit command.
|
|
625
1001
|
verifyChainFlag = true;
|
|
626
|
-
} else if (
|
|
1002
|
+
} else if (flagIs('--reconcile')) {
|
|
627
1003
|
// Modifier for --verify-chain: also reconcile on-disk records against the
|
|
628
1004
|
// ledger and fail on any orphan (INGEST-PROACT-001).
|
|
629
1005
|
reconcileFlag = true;
|
|
630
|
-
} else if (
|
|
1006
|
+
} else if (flagIs('--all')) {
|
|
631
1007
|
// Modifier for --verify-chain: report every per-record-independent break
|
|
632
1008
|
// instead of stopping at the first (INGEST-PROACT-004).
|
|
633
1009
|
allBreaksFlag = true;
|
|
634
|
-
} else if (
|
|
1010
|
+
} else if (flagIs('--anchor-compute')) {
|
|
635
1011
|
// Optional XRPL anchor: compute + write the next anchor manifest. Offline.
|
|
636
1012
|
anchorComputeFlag = true;
|
|
637
|
-
} else if (
|
|
1013
|
+
} else if (flagIs('--anchor-post')) {
|
|
638
1014
|
// Optional XRPL anchor: compute if needed + post to XRPL. Needs the
|
|
639
1015
|
// optional xrpl package (lazily loaded) and XRPL_SEED.
|
|
640
1016
|
anchorPostFlag = true;
|
|
641
|
-
} else if (
|
|
1017
|
+
} else if (flagIs('--anchor-verify')) {
|
|
642
1018
|
// Optional XRPL anchor: verify local manifests + run the truncation check.
|
|
643
1019
|
// Offline reports honest NOT-verified for the on-chain leg.
|
|
644
1020
|
anchorVerifyFlag = true;
|
|
645
|
-
} else if (
|
|
1021
|
+
} else if (flagIs('--anchor-all')) {
|
|
646
1022
|
// Genesis snapshot mode for compute/post (covers the whole chain).
|
|
647
1023
|
anchorMode = 'all';
|
|
648
|
-
} else if (
|
|
1024
|
+
} else if (flagIs('--anchor-algo') && hasValue) {
|
|
649
1025
|
anchorAlgo = takeValue();
|
|
650
|
-
} else if (
|
|
1026
|
+
} else if (flagIs('--anchor-network') && hasValue) {
|
|
651
1027
|
anchorNetwork = takeValue();
|
|
652
|
-
} else if (
|
|
1028
|
+
} else if (flagIs('--anchor-tx') && hasValue) {
|
|
653
1029
|
// Path to a JSON file containing a fetched XRPL tx (with Memos) for the
|
|
654
1030
|
// on-chain leg of --anchor-verify. Offline-honest: omit it to run the
|
|
655
1031
|
// truncation check only.
|
|
656
1032
|
anchorTxFile = takeValue();
|
|
657
|
-
} else if (
|
|
1033
|
+
} else if (flagIs('--anchor-trusted') && hasValue) {
|
|
658
1034
|
// Comma-separated trusted anchor accounts (UNIONed with the bundled list).
|
|
659
1035
|
anchorTrustedAccounts = takeValue().split(',').map((s) => s.trim()).filter(Boolean);
|
|
1036
|
+
} else if (flagIs('-h') || flagIs('--help') || flagIs('--usage')) {
|
|
1037
|
+
helpFlag = true;
|
|
1038
|
+
} else if (arg.startsWith('--') && knownFlagName) {
|
|
1039
|
+
// F-e0bcbc47 / F-INGEST-003 boundary: a KNOWN flag whose `&& hasValue`
|
|
1040
|
+
// guard failed — i.e. a value flag given no value, because the next
|
|
1041
|
+
// token was itself a flag (`--anchor-network --anchor-compute`) or it
|
|
1042
|
+
// ended argv. F-INGEST-003 pins that the FOLLOWING flag is still parsed
|
|
1043
|
+
// on its own rather than swallowed as this one's value, so this token
|
|
1044
|
+
// keeps its historical path (fall through to positionalArgs, continue)
|
|
1045
|
+
// and `--anchor-compute` runs. Rejecting here instead would break that
|
|
1046
|
+
// pinned contract.
|
|
1047
|
+
//
|
|
1048
|
+
// RESIDUAL, STATED (not fixed here): the dangling flag is still
|
|
1049
|
+
// IGNORED — `--anchor-network` with no value does not set the network
|
|
1050
|
+
// and does not fail the run. That is pre-existing behavior and this
|
|
1051
|
+
// amend does not change it. What changes is that it is no longer
|
|
1052
|
+
// SILENT: an operator gets a structured warn naming the flag instead of
|
|
1053
|
+
// the value vanishing with no trace. Making it a hard error is a real
|
|
1054
|
+
// contract change (it would stop `--anchor-compute` from running and
|
|
1055
|
+
// rewrite F-INGEST-003's pin), which is a bigger claim than a Stage C
|
|
1056
|
+
// humanization amend should make unilaterally — filed as a follow-up
|
|
1057
|
+
// rather than landed here.
|
|
1058
|
+
logStage('warn', {
|
|
1059
|
+
kind: 'cli_flag_missing_value',
|
|
1060
|
+
flag: arg,
|
|
1061
|
+
correlation_id: synthCorrelationId(),
|
|
1062
|
+
message: `${arg} was given no value and is being ignored (the next token is a flag, not a value)`
|
|
1063
|
+
});
|
|
1064
|
+
positionalArgs.push(args[i]);
|
|
1065
|
+
} else if (arg.startsWith('--')) {
|
|
1066
|
+
// F-e0bcbc47 (Stage C humanization): a genuinely unrecognized `--flag`
|
|
1067
|
+
// — no branch above knows this name at all. It used to fall through to
|
|
1068
|
+
// the dead `positionalArgs` sink (declared, pushed to, and never read
|
|
1069
|
+
// anywhere else in this file — confirmed by grep) and the CLI proceeded
|
|
1070
|
+
// to read stdin, hit EOF, and crashed with a raw 'Unexpected end of
|
|
1071
|
+
// JSON input' stack that misattributed the failure to the submission
|
|
1072
|
+
// payload rather than the misspelled flag (live-proven trigger:
|
|
1073
|
+
// `--provenance=stub --fiel <path>`, a one-character typo of --file).
|
|
1074
|
+
// Reject at the point of the typo instead — matches the sibling pattern
|
|
1075
|
+
// already correct in this same domain (packages/verify/cli.js:
|
|
1076
|
+
// `unknown argument: --version` -> exit 2, no stack, no fallthrough).
|
|
1077
|
+
// Scoped to `--`-prefixed tokens only (a bare positional stays in the
|
|
1078
|
+
// historical positionalArgs sink) because that is exactly the shape a
|
|
1079
|
+
// mistyped flag takes, and it is the shape the reachable repro above
|
|
1080
|
+
// hits BEFORE any value token is even read.
|
|
1081
|
+
emitCliErrorEvent({
|
|
1082
|
+
failedStage: 'cli_parse_args',
|
|
1083
|
+
correlationId: synthCorrelationId(),
|
|
1084
|
+
err: new Error(`unknown argument: ${arg}`),
|
|
1085
|
+
humanPrefix: 'invalid CLI invocation'
|
|
1086
|
+
});
|
|
1087
|
+
process.exit(2);
|
|
660
1088
|
} else {
|
|
661
1089
|
positionalArgs.push(args[i]);
|
|
662
1090
|
}
|
|
663
1091
|
}
|
|
664
1092
|
|
|
1093
|
+
// F-9a65b10c: print USAGE and exit 0 before any input resolution — help must
|
|
1094
|
+
// never block on stdin or demand a --provenance flag.
|
|
1095
|
+
if (helpFlag) {
|
|
1096
|
+
console.log(USAGE);
|
|
1097
|
+
process.exit(0);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
665
1100
|
// --verify-chain is a standalone, side-effect-free audit: it reads only the
|
|
666
1101
|
// ledger + the record files it references, takes no submission, reads no
|
|
667
1102
|
// stdin, and needs no provenance adapter. Handle it BEFORE the stdin read and
|
|
@@ -684,7 +1119,12 @@ if (isMain) {
|
|
|
684
1119
|
// operator asked for it, so a grep of the NDJSON shows how many breaks and
|
|
685
1120
|
// orphans were found, not just the first break.
|
|
686
1121
|
...(Array.isArray(result.breaks) ? { break_count: result.breaks.length } : {}),
|
|
687
|
-
...(Array.isArray(result.orphans) ? { orphan_count: result.orphans.length } : {})
|
|
1122
|
+
...(Array.isArray(result.orphans) ? { orphan_count: result.orphans.length } : {}),
|
|
1123
|
+
// F-29134790: pre-adoption records are excluded from chain_ok but still
|
|
1124
|
+
// worth a grep-able count — an operator diffing orphan_count over time
|
|
1125
|
+
// should see the pre-chain figure hold steady while orphan_count reflects
|
|
1126
|
+
// only genuine torn writes.
|
|
1127
|
+
...(Array.isArray(result.pre_adoption) ? { pre_adoption_count: result.pre_adoption.length } : {})
|
|
688
1128
|
});
|
|
689
1129
|
const lines = formatChainResult(result);
|
|
690
1130
|
if (result.ok) {
|
|
@@ -744,6 +1184,19 @@ if (isMain) {
|
|
|
744
1184
|
}
|
|
745
1185
|
|
|
746
1186
|
if (!submissionJson) {
|
|
1187
|
+
// F-aa67ea9a (Stage C humanization): guard an interactive stdin. Without a
|
|
1188
|
+
// piped payload, `for await (…process.stdin)` blocks forever at a TTY —
|
|
1189
|
+
// exactly what a first-run operator who forgot to pipe input types — and
|
|
1190
|
+
// looks like a hang. Fail fast with the same empty-state error verify/cli.js
|
|
1191
|
+
// gives, naming the concrete input levers.
|
|
1192
|
+
if (process.stdin.isTTY) {
|
|
1193
|
+
console.error('ERROR: no submission provided');
|
|
1194
|
+
console.error(
|
|
1195
|
+
" hint: pass --file <path> or --payload '<json>', or pipe JSON on stdin; " +
|
|
1196
|
+
'run --help for usage.'
|
|
1197
|
+
);
|
|
1198
|
+
process.exit(2);
|
|
1199
|
+
}
|
|
747
1200
|
// Read from stdin
|
|
748
1201
|
const chunks = [];
|
|
749
1202
|
for await (const chunk of process.stdin) {
|
|
@@ -791,7 +1244,7 @@ if (isMain) {
|
|
|
791
1244
|
let provenance;
|
|
792
1245
|
if (provenanceMode === 'stub') {
|
|
793
1246
|
// Structural anti-misuse: stub only allowed outside CI
|
|
794
|
-
if (
|
|
1247
|
+
if (isCI()) {
|
|
795
1248
|
emitCliErrorEvent({
|
|
796
1249
|
failedStage: 'cli_provenance_resolve',
|
|
797
1250
|
correlationId: cliCorrelationId,
|
|
@@ -819,7 +1272,7 @@ if (isMain) {
|
|
|
819
1272
|
process.exit(2);
|
|
820
1273
|
}
|
|
821
1274
|
provenance = resolved.provenance;
|
|
822
|
-
} else if (
|
|
1275
|
+
} else if (isCI()) {
|
|
823
1276
|
// In CI without an explicit flag: default to real provenance, routed by the
|
|
824
1277
|
// submission's source.provider (github | gitlab).
|
|
825
1278
|
const resolved = resolveProviderProvenance(submission);
|
|
@@ -845,6 +1298,34 @@ if (isMain) {
|
|
|
845
1298
|
process.exit(2);
|
|
846
1299
|
}
|
|
847
1300
|
|
|
1301
|
+
// F-3bfc2885: construct a scenario fetcher for the production path. Before
|
|
1302
|
+
// this, the CLI never passed one, so scenario definitions — and with them the
|
|
1303
|
+
// `step-results-present` / `step-verdict-consistent` required_steps gates —
|
|
1304
|
+
// were test-only: an operator-declared severity:reject rule silently passed.
|
|
1305
|
+
// Real (non-stub) provenance + a github-provider submission loads scenario
|
|
1306
|
+
// definitions from the source repo at the persisted commit, reusing the token
|
|
1307
|
+
// resolveProviderProvenance already validated. GitLab submissions have no
|
|
1308
|
+
// scenario fetcher yet (no gitlab contents-API adapter exists); their
|
|
1309
|
+
// required_steps gate remains unenforced — a known, documented gap.
|
|
1310
|
+
// V2-INVARIAN-004: the eligibility condition is the exported, unit-tested
|
|
1311
|
+
// resolveScenarioFetcherDecision above.
|
|
1312
|
+
// F-b04473d5: log the decision — an operator auditing an accepted record
|
|
1313
|
+
// must be able to tell "required_steps enforced" from "enforcement skipped
|
|
1314
|
+
// (and why)" straight from the NDJSON stream.
|
|
1315
|
+
const scenarioDecision = resolveScenarioFetcherDecision(provenance, submission, process.env);
|
|
1316
|
+
const scenarioFetcher = scenarioDecision.fetcher;
|
|
1317
|
+
logStage('scenario_enforcement', {
|
|
1318
|
+
submission_id: submission && typeof submission === 'object' && !Array.isArray(submission)
|
|
1319
|
+
? (submission.run_id || null)
|
|
1320
|
+
: null,
|
|
1321
|
+
correlation_id: cliCorrelationId,
|
|
1322
|
+
active: scenarioDecision.active,
|
|
1323
|
+
...(scenarioDecision.reason ? { reason: scenarioDecision.reason } : {}),
|
|
1324
|
+
scenario_count: submission && typeof submission === 'object' && Array.isArray(submission.scenario_results)
|
|
1325
|
+
? submission.scenario_results.length
|
|
1326
|
+
: 0
|
|
1327
|
+
});
|
|
1328
|
+
|
|
848
1329
|
// D1B-001: track the last successful pipeline stage so the outer catch
|
|
849
1330
|
// can surface a useful `failed_stage` in its structured error event.
|
|
850
1331
|
// We update it once the verify/ingest call has RETURNED — anything
|
|
@@ -856,7 +1337,7 @@ if (isMain) {
|
|
|
856
1337
|
// when they could.
|
|
857
1338
|
try {
|
|
858
1339
|
if (verifyOnlyFlag) {
|
|
859
|
-
const result = await verifyOnly(submission, { repoRoot, provenance });
|
|
1340
|
+
const result = await verifyOnly(submission, { repoRoot, provenance, scenarioFetcher });
|
|
860
1341
|
lastSuccessfulStage = 'verify_only';
|
|
861
1342
|
|
|
862
1343
|
console.log(JSON.stringify({
|
|
@@ -877,7 +1358,7 @@ if (isMain) {
|
|
|
877
1358
|
process.exit(result.record.verification.status === 'accepted' ? 0 : 1);
|
|
878
1359
|
}
|
|
879
1360
|
|
|
880
|
-
const result = await ingest(submission, { repoRoot, provenance });
|
|
1361
|
+
const result = await ingest(submission, { repoRoot, provenance, scenarioFetcher });
|
|
881
1362
|
lastSuccessfulStage = 'ingest';
|
|
882
1363
|
|
|
883
1364
|
if (result.duplicate) {
|