@kontourai/flow-agents 2.4.0 → 3.0.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/.github/CODEOWNERS +8 -0
- package/.github/workflows/ci.yml +12 -0
- package/.github/workflows/trust-reconcile.yml +62 -4
- package/CHANGELOG.md +23 -0
- package/CONTEXT.md +21 -0
- package/build/src/cli/assignment-provider.d.ts +1 -0
- package/build/src/cli/assignment-provider.js +748 -0
- package/build/src/cli/effective-assignment-provider-settings.d.ts +1 -0
- package/build/src/cli/effective-assignment-provider-settings.js +125 -0
- package/build/src/cli/validate-workflow-artifacts.js +5 -1
- package/build/src/cli/workflow-sidecar.js +157 -110
- package/build/src/cli.js +6 -0
- package/build/src/tools/validate-source-tree.js +1 -0
- package/context/contracts/artifact-contract.md +2 -0
- package/context/contracts/assignment-provider-contract.md +239 -0
- package/context/contracts/builder-kit-workflow-state-contract.md +2 -0
- package/context/contracts/decision-registry-contract.md +2 -0
- package/context/contracts/delivery-contract.md +2 -0
- package/context/contracts/execution-contract.md +25 -0
- package/context/contracts/governance-adapter-contract.md +2 -0
- package/context/contracts/knowledge-store-contract.md +197 -0
- package/context/contracts/planning-contract.md +2 -0
- package/context/contracts/review-contract.md +2 -0
- package/context/contracts/sandbox-policy.md +2 -0
- package/context/contracts/standing-directives.md +13 -0
- package/context/contracts/verification-contract.md +2 -0
- package/context/contracts/work-item-contract.md +2 -0
- package/context/settings/assignment-provider-settings.json +33 -0
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +180 -0
- package/docs/context-map.md +1 -0
- package/docs/decisions/index.md +3 -0
- package/docs/decisions/knowledge-store-provider.md +51 -0
- package/docs/decisions/model-routing.md +63 -0
- package/docs/decisions/standing-directives.md +66 -0
- package/docs/fixture-ownership.md +1 -0
- package/docs/workflow-shared-contracts.md +2 -1
- package/docs/workflow-usage-guide.md +6 -0
- package/evals/ci/run-baseline.sh +6 -0
- package/evals/fixtures/assignment-provider/actor-a.json +6 -0
- package/evals/fixtures/assignment-provider/actor-b.json +6 -0
- package/evals/fixtures/assignment-provider/github-issue-claimed.json +27 -0
- package/evals/fixtures/assignment-provider/github-issue-unassigned.json +7 -0
- package/evals/fixtures/assignment-provider/liveness-fresh.json +9 -0
- package/evals/fixtures/assignment-provider/liveness-stale.json +9 -0
- package/evals/integration/test_assignment_provider_github.sh +318 -0
- package/evals/integration/test_assignment_provider_local_file.sh +222 -0
- package/evals/integration/test_critique_supersession_roundtrip.sh +182 -0
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_publish_delivery.sh +21 -4
- package/evals/integration/test_pull_work_assignment_join.sh +132 -0
- package/evals/integration/test_pull_work_liveness_preflight.sh +14 -6
- package/evals/integration/test_reconcile_soundness.sh +33 -9
- package/evals/integration/test_trust_reconcile.sh +9 -8
- package/evals/integration/test_trust_reconcile_negatives.sh +608 -0
- package/evals/integration/test_workflow_sidecar_writer.sh +79 -0
- package/evals/run.sh +10 -0
- package/evals/static/test_flowdef_codeowners_coverage.sh +6 -0
- package/evals/static/test_knowledge_providers.sh +23 -0
- package/kits/builder/skills/deliver/SKILL.md +19 -0
- package/kits/builder/skills/pull-work/SKILL.md +78 -10
- package/kits/knowledge/kit.json +35 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/CONTEXT.md +12 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/old-sprocket-shape.md +13 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/sprocket-shape.md +14 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/widget-format.md +14 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/learnings/fixture-learning.md +7 -0
- package/kits/knowledge/providers/conformance/fixtures/work-item/issues.json +30 -0
- package/kits/knowledge/providers/conformance/suite.test.js +125 -0
- package/kits/knowledge/providers/git-repo/index.js +236 -0
- package/kits/knowledge/providers/health/health-pass.test.js +99 -0
- package/kits/knowledge/providers/health/index.js +153 -0
- package/kits/knowledge/providers/index.js +24 -0
- package/kits/knowledge/providers/lib/model.js +91 -0
- package/kits/knowledge/providers/lib/schema-validate.js +119 -0
- package/kits/knowledge/providers/markdown-vault/index.js +169 -0
- package/kits/knowledge/providers/work-item/index.js +204 -0
- package/package.json +4 -2
- package/schemas/assignment-provider-settings.schema.json +125 -0
- package/schemas/knowledge/edge.schema.json +54 -0
- package/schemas/knowledge/health-report.schema.json +45 -0
- package/schemas/knowledge/node.schema.json +49 -0
- package/schemas/knowledge/proposal.schema.json +53 -0
- package/scripts/ci/trust-reconcile.js +521 -24
- package/src/cli/assignment-provider.ts +845 -0
- package/src/cli/effective-assignment-provider-settings.ts +112 -0
- package/src/cli/validate-workflow-artifacts.ts +5 -1
- package/src/cli/workflow-sidecar.ts +147 -106
- package/src/cli.ts +6 -0
- package/src/tools/validate-source-tree.ts +1 -0
|
@@ -16,13 +16,56 @@
|
|
|
16
16
|
* e. checkpoint-only bundle (no evidence/claims) → DIVERGENCE (full bundle required)
|
|
17
17
|
*
|
|
18
18
|
* Exit codes:
|
|
19
|
-
* 0 — fresh verify passed AND no divergence (
|
|
20
|
-
*
|
|
19
|
+
* 0 — fresh verify passed AND no divergence (bundle reconciled clean, OR bundle
|
|
20
|
+
* absence is covered by a well-formed, in-scope delivery/DECLARED marker)
|
|
21
|
+
* 1 — fresh verify failed OR any divergence detected OR compile-only fallback OR
|
|
22
|
+
* bundle absent with no valid delivery/DECLARED marker (bundle-required by default)
|
|
21
23
|
*
|
|
22
|
-
*
|
|
23
|
-
* delivery/trust.bundle or delivery/trust.checkpoint.json),
|
|
24
|
-
*
|
|
25
|
-
*
|
|
24
|
+
* Bundle-required by default (ADR 0022 §1): if no bundle is provided (and none
|
|
25
|
+
* auto-discovered at delivery/trust.bundle or delivery/trust.checkpoint.json), the
|
|
26
|
+
* reconciler no longer fails open. It looks for a delivery/DECLARED no-agent-delivery
|
|
27
|
+
* marker (ADR 0022 §2) — a single {scope, reason, approved_by, declared_at} object, or a
|
|
28
|
+
* JSON array of such objects, resolved via the same path seam bundle discovery uses (see
|
|
29
|
+
* resolveDeliveryCandidates()). A well-formed marker whose `scope` matches this change
|
|
30
|
+
* (`ref:`, `commit:`/`commit:a..b`, `author:`, or `branch-prefix:` — see matchesScope())
|
|
31
|
+
* exempts Step 2 ONLY; Step 1 (fresh verify) still runs unconditionally and is never
|
|
32
|
+
* skipped. An absent, malformed (missing any required field), or out-of-scope marker is
|
|
33
|
+
* treated the same as bundle-absence-with-no-exemption: it produces a
|
|
34
|
+
* `bundle-required-no-declared-marker` issue and exits 1. Fail-closed on divergence: any
|
|
35
|
+
* claimed-pass command CI cannot confirm blocks. Fail-closed on compile-only: if no
|
|
36
|
+
* comprehensive verify is configured, exits 1.
|
|
37
|
+
*
|
|
38
|
+
* "Bundle-required" means a bundle FOR THIS CHANGE (ADR 0022 addendum §2), not merely a
|
|
39
|
+
* bundle reachable at the checkout: an AUTO-DISCOVERED bundle whose trust.checkpoint.json
|
|
40
|
+
* `commit_sha` neither equals nor is a git-ancestor of this change's own commit sha
|
|
41
|
+
* (TRUST_RECONCILE_SHA/GITHUB_SHA) is treated as ABSENT, not present — see
|
|
42
|
+
* bundleAttestsThisChange(). Without this, a bundle inherited unchanged from main via
|
|
43
|
+
* update-branch/rebase (live incident: PR #278) would satisfy Step 2 trivially for every
|
|
44
|
+
* dependabot/release-please PR, since neither bot's branch ever runs the deliver skill
|
|
45
|
+
* itself. A stale bundle prints a loud `stale bundle ignored — attests <theirs>, this
|
|
46
|
+
* change is <ours>` line and then falls through to delivery/DECLARED resolution exactly
|
|
47
|
+
* as if no bundle had been discovered at all. This check applies ONLY to auto-discovery —
|
|
48
|
+
* an explicit --bundle/TRUST_RECONCILE_BUNDLE is a deliberate caller choice (tests,
|
|
49
|
+
* programmatic callers), not something silently inherited from committed git tree state.
|
|
50
|
+
*
|
|
51
|
+
* Event-scoped enforcement (ADR 0022 addendum, part 4): bundle-required enforcement (the
|
|
52
|
+
* `bundle-required-no-declared-marker` fail-closed branch, and the staleness-gate
|
|
53
|
+
* consequence of it above) applies ONLY when this run is gating a proposed change —
|
|
54
|
+
* TRUST_RECONCILE_EVENT (set from `github.event_name` by
|
|
55
|
+
* .github/workflows/trust-reconcile.yml) resolving to anything other than 'push'
|
|
56
|
+
* (conservative default: unknown/absent → enforce; see resolveEnforcementEvent()). On a
|
|
57
|
+
* 'push' event, an absent or stale auto-discovered bundle is a LOUD NO-OP (`push event:
|
|
58
|
+
* ... — skipping Step 2 (gating happened on the PR run)`, exit 0), never a failure and
|
|
59
|
+
* never a delivery/DECLARED requirement — a push run on a protected branch happens AFTER
|
|
60
|
+
* the gating PR run already passed (branch protection already excludes direct pushes),
|
|
61
|
+
* and after a squash-merge the merge commit almost never has git ancestry back to the
|
|
62
|
+
* feature-branch commit a checkpoint was sealed against, so bundle absence/staleness on
|
|
63
|
+
* push is the EXPECTED case, not a divergence (reproduced empirically as a HIGH
|
|
64
|
+
* launch-blocker: without this scoping, every delivery would fail Trust Reconcile on
|
|
65
|
+
* `main` immediately post-merge and Phase 2 attestation minting would stop). Step 1
|
|
66
|
+
* (fresh verify) is unaffected by event scoping — it always runs. A fresh auto-discovered
|
|
67
|
+
* bundle on push still reconciles normally (Step 2 runs, forming the Phase 2 attestation
|
|
68
|
+
* basis) — only absence/staleness is treated differently by event.
|
|
26
69
|
*
|
|
27
70
|
* Inputs (CLI args take precedence over env):
|
|
28
71
|
* --bundle <path> Delivered trust.bundle (JSON) path. May also be a
|
|
@@ -38,10 +81,21 @@
|
|
|
38
81
|
* TRUST_RECONCILE_COMMAND_TIMEOUT_MS Timeout (ms) for each canonical/manifest command
|
|
39
82
|
* run (Step 1 fresh-verify + on-demand manifest
|
|
40
83
|
* reconcile). Default: 600000 (10 min).
|
|
84
|
+
* TRUST_RECONCILE_REF/_ACTOR/_SHA Override the delivery/DECLARED scope-matching
|
|
85
|
+
* context (ref / actor / commit sha) that otherwise
|
|
86
|
+
* falls back to GITHUB_HEAD_REF|GITHUB_REF,
|
|
87
|
+
* GITHUB_ACTOR, GITHUB_SHA respectively — for
|
|
88
|
+
* deterministic local/eval testing.
|
|
89
|
+
* TRUST_RECONCILE_EVENT CI event name (`github.event_name`) driving
|
|
90
|
+
* event-scoped bundle-required enforcement (ADR
|
|
91
|
+
* 0022 addendum, part 4) — see
|
|
92
|
+
* resolveEnforcementEvent(). Default (unset/
|
|
93
|
+
* unrecognized): treated as a gating event.
|
|
41
94
|
*
|
|
42
95
|
* Auto-discovery (when no --bundle or TRUST_RECONCILE_BUNDLE is set):
|
|
43
|
-
* Checks delivery/trust.bundle, then delivery/trust.checkpoint.json under repo root
|
|
44
|
-
* If neither exists,
|
|
96
|
+
* Checks delivery/trust.bundle, then delivery/trust.checkpoint.json under repo root
|
|
97
|
+
* (via the resolveDeliveryCandidates() seam). If neither exists, falls through to
|
|
98
|
+
* delivery/DECLARED marker resolution — see "Bundle-required by default" above.
|
|
45
99
|
*
|
|
46
100
|
* Canonical commands resolution (fail-closed on compile-only):
|
|
47
101
|
* Priority: CLI --commands > TRUST_RECONCILE_COMMANDS env > package.json
|
|
@@ -49,9 +103,11 @@
|
|
|
49
103
|
* "no comprehensive trust-reconcile-verify configured" — refuses to attest a
|
|
50
104
|
* compile-only check.
|
|
51
105
|
*
|
|
52
|
-
* NOTE: This job
|
|
53
|
-
*
|
|
54
|
-
*
|
|
106
|
+
* NOTE: This job IS ALREADY a required status check in GitHub branch protection on
|
|
107
|
+
* `main` (verified via the GitHub branch-protection API — required_status_checks.contexts
|
|
108
|
+
* includes "Trust Reconcile", enforce_admins: true). Disabling or downgrading that
|
|
109
|
+
* requirement is a server-side branch-protection change — it cannot be done by editing
|
|
110
|
+
* this file or .github/workflows/trust-reconcile.yml.
|
|
55
111
|
*
|
|
56
112
|
* Programmatic use:
|
|
57
113
|
* const { runTrustReconcile } = require('./trust-reconcile.js');
|
|
@@ -229,6 +285,11 @@ function classifyBundleClaims(bundle) {
|
|
|
229
285
|
const seenClaims = new Set();
|
|
230
286
|
for (const c of claims) {
|
|
231
287
|
if (!c || !c.id || typeof c.claimType !== 'string') continue;
|
|
288
|
+
// #267/#282: a superseded critique write is HISTORY — excluded from reconcile evaluation so a
|
|
289
|
+
// resolved session converges (a fail critique that a later same-reviewer pass superseded no
|
|
290
|
+
// longer blocks). Scoped to NON-test_output claims so a command-backed claim can never launder
|
|
291
|
+
// a real failure by carrying superseded_by — a test_output claim always reconciles or diverges.
|
|
292
|
+
if (c.metadata && typeof c.metadata === 'object' && c.metadata.superseded_by && !claimHasTestOutputEvidence.has(c.id)) continue;
|
|
232
293
|
if (reconcilableClaimIds.has(c.id)) continue; // handled by (A)
|
|
233
294
|
if (seenClaims.has(c.id)) continue;
|
|
234
295
|
const status = String(c.status || '');
|
|
@@ -453,22 +514,370 @@ function resolveCanonicalCommands(args, repoRoot) {
|
|
|
453
514
|
return null;
|
|
454
515
|
}
|
|
455
516
|
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
// delivery/ path-resolution seam (#335-aware) + ADR 0022 DECLARED-marker support.
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
|
|
521
|
+
const DELIVERY_DIR = 'delivery';
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Single seam: every delivery/ path resolution in this file routes through here.
|
|
525
|
+
* #335 (per-session delivery paths, e.g. delivery/<session-slug>/<filename> instead of
|
|
526
|
+
* one shared path) is expected to change ONLY this function's body — e.g. glob
|
|
527
|
+
* delivery/<slug-glob>/<filename> and return every match in a defined precedence order,
|
|
528
|
+
* or
|
|
529
|
+
* resolve the current PR's session slug and return its single subpath. discoverBundle()
|
|
530
|
+
* and discoverDeclaredMarker() both iterate whatever candidates this returns and pick the
|
|
531
|
+
* first existing one, so a future per-session layout composes with zero call-site changes.
|
|
532
|
+
*/
|
|
533
|
+
function resolveDeliveryCandidates(repoRoot, filename) {
|
|
534
|
+
return [path.join(repoRoot, DELIVERY_DIR, filename)];
|
|
535
|
+
}
|
|
536
|
+
|
|
456
537
|
/**
|
|
457
538
|
* Auto-discover the delivery bundle when no explicit path is provided.
|
|
458
|
-
* Checks delivery/trust.bundle, then delivery/trust.checkpoint.json under repo root
|
|
459
|
-
* Returns null if neither is present
|
|
539
|
+
* Checks delivery/trust.bundle, then delivery/trust.checkpoint.json under repo root
|
|
540
|
+
* (via resolveDeliveryCandidates()). Returns null if neither is present — the caller
|
|
541
|
+
* then falls through to delivery/DECLARED marker resolution (ADR 0022 §1); bundle
|
|
542
|
+
* absence is no longer fail-open on its own.
|
|
460
543
|
*/
|
|
461
544
|
function discoverBundle(repoRoot) {
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
545
|
+
for (const filename of ['trust.bundle', 'trust.checkpoint.json']) {
|
|
546
|
+
for (const candidate of resolveDeliveryCandidates(repoRoot, filename)) {
|
|
547
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Auto-discover the delivery/DECLARED no-agent-delivery marker (ADR 0022 §2) via the
|
|
555
|
+
* same resolveDeliveryCandidates() seam bundle discovery uses. Returns null if absent.
|
|
556
|
+
*/
|
|
557
|
+
function discoverDeclaredMarker(repoRoot) {
|
|
558
|
+
for (const candidate of resolveDeliveryCandidates(repoRoot, 'DECLARED')) {
|
|
467
559
|
if (fs.existsSync(candidate)) return candidate;
|
|
468
560
|
}
|
|
469
561
|
return null;
|
|
470
562
|
}
|
|
471
563
|
|
|
564
|
+
/** Required, non-empty-string fields on every delivery/DECLARED marker entry (ADR 0022 §2). */
|
|
565
|
+
const DECLARED_REQUIRED_FIELDS = ['scope', 'reason', 'approved_by', 'declared_at'];
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Parse and validate a delivery/DECLARED marker file. Accepts either a single
|
|
569
|
+
* {scope, reason, approved_by, declared_at} object, or a JSON array of such objects
|
|
570
|
+
* (small, backward-compatible extension so one marker file can cover multiple
|
|
571
|
+
* non-agent-delivery scopes, e.g. dependabot AND release-please, without a blanket scope).
|
|
572
|
+
*
|
|
573
|
+
* @returns {{ok:false, reason:string}} on unreadable/malformed-JSON input, else
|
|
574
|
+
* {{ok:true, wellFormed:object[], malformed:{entry:*, missing:string[]}[], totalEntries:number}}
|
|
575
|
+
* — every entry is validated; malformed entries are reported individually rather than
|
|
576
|
+
* invalidating the whole file (so one bad entry cannot silently mask a good one).
|
|
577
|
+
*/
|
|
578
|
+
function parseDeclaredMarker(markerPath) {
|
|
579
|
+
let raw;
|
|
580
|
+
try {
|
|
581
|
+
raw = fs.readFileSync(markerPath, 'utf8');
|
|
582
|
+
} catch (err) {
|
|
583
|
+
return { ok: false, reason: `delivery/DECLARED marker is malformed: not valid JSON (unreadable: ${err.message})` };
|
|
584
|
+
}
|
|
585
|
+
let parsed;
|
|
586
|
+
try {
|
|
587
|
+
parsed = JSON.parse(raw);
|
|
588
|
+
} catch {
|
|
589
|
+
return { ok: false, reason: 'delivery/DECLARED marker is malformed: not valid JSON' };
|
|
590
|
+
}
|
|
591
|
+
if (Array.isArray(parsed) && parsed.length === 0) {
|
|
592
|
+
return { ok: false, reason: 'delivery/DECLARED marker is malformed: array contains zero entries' };
|
|
593
|
+
}
|
|
594
|
+
const entries = Array.isArray(parsed) ? parsed : [parsed];
|
|
595
|
+
const wellFormed = [];
|
|
596
|
+
const malformed = [];
|
|
597
|
+
for (const entry of entries) {
|
|
598
|
+
const missing = DECLARED_REQUIRED_FIELDS.filter((field) => {
|
|
599
|
+
const value = entry && typeof entry === 'object' ? entry[field] : undefined;
|
|
600
|
+
return typeof value !== 'string' || value.trim() === '';
|
|
601
|
+
});
|
|
602
|
+
if (missing.length > 0) {
|
|
603
|
+
malformed.push({ entry, missing });
|
|
604
|
+
} else {
|
|
605
|
+
wellFormed.push(entry);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return { ok: true, wellFormed, malformed, totalEntries: entries.length };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Resolve the scope-matching context for delivery/DECLARED evaluation:
|
|
613
|
+
* ref = TRUST_RECONCILE_REF || GITHUB_HEAD_REF || GITHUB_REF stripped of 'refs/heads/' || ''
|
|
614
|
+
* actor = TRUST_RECONCILE_ACTOR || GITHUB_ACTOR || ''
|
|
615
|
+
* sha = TRUST_RECONCILE_SHA || GITHUB_SHA || ''
|
|
616
|
+
* An empty context value never matches any scope segment (see matchesScope) — this
|
|
617
|
+
* prevents an empty-string scope segment from becoming an accidental wildcard.
|
|
618
|
+
*/
|
|
619
|
+
function resolveScopeContext(repoRoot) {
|
|
620
|
+
const githubRef = process.env.GITHUB_REF || '';
|
|
621
|
+
const strippedGithubRef = githubRef.startsWith('refs/heads/')
|
|
622
|
+
? githubRef.slice('refs/heads/'.length)
|
|
623
|
+
: githubRef;
|
|
624
|
+
const ref = process.env.TRUST_RECONCILE_REF
|
|
625
|
+
|| process.env.GITHUB_HEAD_REF
|
|
626
|
+
|| strippedGithubRef
|
|
627
|
+
|| '';
|
|
628
|
+
const actor = process.env.TRUST_RECONCILE_ACTOR || process.env.GITHUB_ACTOR || '';
|
|
629
|
+
const sha = process.env.TRUST_RECONCILE_SHA || process.env.GITHUB_SHA || '';
|
|
630
|
+
return { ref, actor, sha, repoRoot };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Resolve the CI event this run is gating (ADR 0022 addendum, part 4 — event-scoped
|
|
635
|
+
* bundle-required enforcement). `TRUST_RECONCILE_EVENT` (set by
|
|
636
|
+
* .github/workflows/trust-reconcile.yml from `github.event_name`) is the source.
|
|
637
|
+
*
|
|
638
|
+
* Conservative default: an unknown/absent event value is treated as a GATING event
|
|
639
|
+
* (enforce) — every existing local/test caller that sets no TRUST_RECONCILE_EVENT keeps
|
|
640
|
+
* today's fail-closed behavior unchanged, and a misconfigured/unrecognized CI event fails
|
|
641
|
+
* toward the STRICTER gate, never the looser post-merge one. Only the literal value
|
|
642
|
+
* 'push' is treated as a post-merge run — bundle-required enforcement (and its
|
|
643
|
+
* staleness-gate consequence) applies to every other event, including
|
|
644
|
+
* `workflow_dispatch`, which is not guaranteed to be post-merge.
|
|
645
|
+
*
|
|
646
|
+
* Residual (HIGH, documented not solved): the VALUE `github.event_name` resolves to is
|
|
647
|
+
* authoritative GitHub Actions context, but the WORKFLOW FILE that sets
|
|
648
|
+
* `TRUST_RECONCILE_EVENT: ${{ github.event_name }}` is itself PR-editable content on a
|
|
649
|
+
* `pull_request` run — a PR could hardcode `TRUST_RECONCILE_EVENT: push` in its own copy
|
|
650
|
+
* of the workflow to fake the post-merge no-op path. This is closed by required
|
|
651
|
+
* code-owner review on `.github/workflows/trust-reconcile.yml` (#225, not yet server-side
|
|
652
|
+
* enforced), the same residual class as every other self-asserted CI input in this file.
|
|
653
|
+
* Step 1 (fresh verify) is unaffected by event scoping either way — it always runs and
|
|
654
|
+
* must pass regardless of which branch this function's caller takes.
|
|
655
|
+
*/
|
|
656
|
+
function resolveEnforcementEvent() {
|
|
657
|
+
return (process.env.TRUST_RECONCILE_EVENT || '').trim() || 'pull_request';
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Best-effort single-direction ancestor check: is `ancestorSha` an ancestor of (or equal
|
|
662
|
+
* to) `descendantSha`? Uses `git merge-base --is-ancestor` (a commit is its own ancestor).
|
|
663
|
+
* Never throws — underivable (git missing, shallow clone, unknown sha, etc.) → false.
|
|
664
|
+
* Shared by isShaInRange() (commit: scope ranges) and bundleAttestsThisChange() (the
|
|
665
|
+
* bundle-ownership staleness check, ADR 0022 addendum §2).
|
|
666
|
+
*/
|
|
667
|
+
function isAncestorCommit(repoRoot, ancestorSha, descendantSha) {
|
|
668
|
+
if (!ancestorSha || !descendantSha) return false;
|
|
669
|
+
try {
|
|
670
|
+
const res = spawnSync('git', ['merge-base', '--is-ancestor', ancestorSha, descendantSha], {
|
|
671
|
+
cwd: repoRoot, stdio: 'ignore',
|
|
672
|
+
});
|
|
673
|
+
return !!res && !res.error && res.status === 0;
|
|
674
|
+
} catch {
|
|
675
|
+
return false;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Best-effort commit-range membership check: is `sha` reachable from `to` AND is `from`
|
|
681
|
+
* an ancestor of `sha`? (a commit is its own ancestor, so sha === from or sha === to both
|
|
682
|
+
* count as in-range). Never throws — underivable → false, never a match.
|
|
683
|
+
*/
|
|
684
|
+
function isShaInRange(repoRoot, sha, from, to) {
|
|
685
|
+
if (!sha || !from || !to) return false;
|
|
686
|
+
return isAncestorCommit(repoRoot, from, sha) && isAncestorCommit(repoRoot, sha, to);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Match a SINGLE scope condition (one of the four forms) against the resolved context.
|
|
691
|
+
* Extracted from matchesScope() so a compound (space-separated, AND) scope can validate
|
|
692
|
+
* each condition independently and still fail closed if any one is unrecognized.
|
|
693
|
+
* String equality/prefix ops ONLY — zero RegExp construction from marker content (AC4).
|
|
694
|
+
* Supported forms: `ref:<exact>`, `commit:<sha>` or `commit:<from>..<to>` (range, via
|
|
695
|
+
* isShaInRange), `author:<exact>`, `branch-prefix:<prefix>` (via String#startsWith).
|
|
696
|
+
* An unrecognized prefix does not match (not an error — fails closed silently). Every
|
|
697
|
+
* arm requires the COMPARED CONTEXT value to be non-empty, so an empty ref/actor/sha
|
|
698
|
+
* (e.g. running outside CI with no override) can never be treated as a wildcard match.
|
|
699
|
+
*/
|
|
700
|
+
function matchesScopeCondition(condition, ctx) {
|
|
701
|
+
const s = typeof condition === 'string' ? condition : '';
|
|
702
|
+
|
|
703
|
+
if (s.startsWith('ref:')) {
|
|
704
|
+
const want = s.slice('ref:'.length);
|
|
705
|
+
return !!want && !!ctx.ref && ctx.ref === want;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (s.startsWith('commit:')) {
|
|
709
|
+
const want = s.slice('commit:'.length);
|
|
710
|
+
if (!want || !ctx.sha) return false;
|
|
711
|
+
const rangeSep = want.indexOf('..');
|
|
712
|
+
if (rangeSep === -1) {
|
|
713
|
+
return ctx.sha === want;
|
|
714
|
+
}
|
|
715
|
+
const from = want.slice(0, rangeSep);
|
|
716
|
+
const to = want.slice(rangeSep + 2);
|
|
717
|
+
if (!from || !to) return false;
|
|
718
|
+
return isShaInRange(ctx.repoRoot, ctx.sha, from, to);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (s.startsWith('author:')) {
|
|
722
|
+
const want = s.slice('author:'.length);
|
|
723
|
+
return !!want && !!ctx.actor && ctx.actor === want;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (s.startsWith('branch-prefix:')) {
|
|
727
|
+
const want = s.slice('branch-prefix:'.length);
|
|
728
|
+
return !!want && !!ctx.ref && ctx.ref.startsWith(want);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Unrecognized prefix (or empty condition) — does not match, not an error.
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Match a delivery/DECLARED marker's `scope` string against the resolved context.
|
|
737
|
+
*
|
|
738
|
+
* `scope` is either a single condition (backward compatible, unchanged behavior) OR
|
|
739
|
+
* multiple space-separated conditions that must ALL match (AND) — e.g.
|
|
740
|
+
* `"author:github-actions[bot] branch-prefix:release-please--"` binds an identity claim
|
|
741
|
+
* (`author:`, bound to the platform-set `GITHUB_ACTOR`, not attacker-controlled) to a
|
|
742
|
+
* narrower blast radius (`branch-prefix:`) rather than trusting either alone. This
|
|
743
|
+
* closes a security-review finding: `ref:`/`branch-prefix:` ALONE match against
|
|
744
|
+
* `GITHUB_HEAD_REF`, which is PUSHER-CONTROLLED on a fork PR — a `ref:`-only or
|
|
745
|
+
* `branch-prefix:`-only scope can therefore be satisfied by anyone who can name a
|
|
746
|
+
* branch, not just the bot the marker is meant to identify. Compound (AND) scopes let a
|
|
747
|
+
* marker require both signals. Any unrecognized-prefix condition anywhere in a compound
|
|
748
|
+
* scope makes the WHOLE scope never match (fail closed) — see matchesScopeCondition().
|
|
749
|
+
* Zero RegExp construction from marker content (AC4) — space-splitting uses a plain
|
|
750
|
+
* string split, not a marker-derived pattern.
|
|
751
|
+
*/
|
|
752
|
+
function matchesScope(scope, ctx) {
|
|
753
|
+
const s = typeof scope === 'string' ? scope : '';
|
|
754
|
+
const conditions = s.split(' ').map((c) => c.trim()).filter(Boolean);
|
|
755
|
+
if (conditions.length === 0) return false;
|
|
756
|
+
return conditions.every((condition) => matchesScopeCondition(condition, ctx));
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Orchestrate delivery/DECLARED resolution for the bundle-absent path: discover → parse
|
|
761
|
+
* → scope-match → return {exempt, marker?, diagnostic?}. First in-scope, well-formed
|
|
762
|
+
* entry wins (array form); every entry is validated regardless, and malformed entries
|
|
763
|
+
* are logged individually so a bad entry never silently masks a good one.
|
|
764
|
+
*
|
|
765
|
+
* Diagnostic strings below are pinned (grepped by callers/evals) — do not reword.
|
|
766
|
+
*/
|
|
767
|
+
function resolveDeclaredExemption(repoRoot, ctx) {
|
|
768
|
+
const markerPath = discoverDeclaredMarker(repoRoot);
|
|
769
|
+
if (!markerPath) {
|
|
770
|
+
return {
|
|
771
|
+
exempt: false,
|
|
772
|
+
diagnostic: 'no bundle present and no delivery/DECLARED marker found — bundle-required by default (ADR 0022 §1); publish delivery/trust.bundle, or add a well-formed, in-scope delivery/DECLARED marker',
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const parsed = parseDeclaredMarker(markerPath);
|
|
777
|
+
if (!parsed.ok) {
|
|
778
|
+
return { exempt: false, diagnostic: parsed.reason };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
for (const { missing } of parsed.malformed) {
|
|
782
|
+
process.stderr.write(`[trust-reconcile] delivery/DECLARED marker is malformed: missing required field(s) [${missing.join(', ')}]\n`);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (parsed.wellFormed.length === 0) {
|
|
786
|
+
const first = parsed.malformed[0];
|
|
787
|
+
const diagnostic = first
|
|
788
|
+
? `delivery/DECLARED marker is malformed: missing required field(s) [${first.missing.join(', ')}]`
|
|
789
|
+
: 'delivery/DECLARED marker is malformed: not valid JSON';
|
|
790
|
+
return { exempt: false, diagnostic };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
for (const marker of parsed.wellFormed) {
|
|
794
|
+
if (matchesScope(marker.scope, ctx)) {
|
|
795
|
+
return { exempt: true, marker };
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return {
|
|
800
|
+
exempt: false,
|
|
801
|
+
diagnostic: 'delivery/DECLARED marker present but out of scope for this change',
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Extract the strongest available commit-identity binding from a discovered bundle/
|
|
807
|
+
* checkpoint, for the bundle-ownership staleness check (ADR 0022 addendum §2). Returns
|
|
808
|
+
* the commit sha string the bundle/checkpoint attests to, or null if no usable binding
|
|
809
|
+
* exists (fail-closed: the caller treats null the same as a mismatch — stale/absent).
|
|
810
|
+
*
|
|
811
|
+
* trust.checkpoint.json's envelope carries `commit_sha` directly (stamped by
|
|
812
|
+
* sealTrustCheckpoint() from `git rev-parse HEAD` at seal time —
|
|
813
|
+
* src/cli/workflow-sidecar.ts). trust.bundle itself carries NO commit/branch metadata
|
|
814
|
+
* (schemaVersion 5: {schemaVersion, source, claims, evidence, policies, events} — confirmed
|
|
815
|
+
* by inspection), so when the discovered bundle IS trust.bundle, this falls through to its
|
|
816
|
+
* sibling delivery/trust.checkpoint.json (same resolveDeliveryCandidates() seam) for the
|
|
817
|
+
* binding — publishDelivery() always copies both together.
|
|
818
|
+
*/
|
|
819
|
+
function extractBundleCommitSha(repoRoot, bundlePath, bundleJson) {
|
|
820
|
+
if (bundleJson && typeof bundleJson.commit_sha === 'string' && bundleJson.commit_sha.trim()) {
|
|
821
|
+
return bundleJson.commit_sha.trim();
|
|
822
|
+
}
|
|
823
|
+
for (const candidate of resolveDeliveryCandidates(repoRoot, 'trust.checkpoint.json')) {
|
|
824
|
+
if (candidate === bundlePath || !fs.existsSync(candidate)) continue;
|
|
825
|
+
try {
|
|
826
|
+
const checkpoint = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
|
827
|
+
if (checkpoint && typeof checkpoint.commit_sha === 'string' && checkpoint.commit_sha.trim()) {
|
|
828
|
+
return checkpoint.commit_sha.trim();
|
|
829
|
+
}
|
|
830
|
+
} catch {
|
|
831
|
+
// Malformed sibling checkpoint — no usable binding from it; keep looking / fall through.
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Does the discovered bundle/checkpoint attest THIS change? "Bundle-required" (ADR 0022
|
|
839
|
+
* §1) means a bundle FOR THIS CHANGE, not any bundle merely reachable at the checkout — a
|
|
840
|
+
* bundle inherited unchanged from main via update-branch/rebase (live incident: PR #278, a
|
|
841
|
+
* dependabot PR whose stale main-authored trust.checkpoint.json otherwise satisfied Step 2
|
|
842
|
+
* trivially) must NOT be treated as owned by this change.
|
|
843
|
+
*
|
|
844
|
+
* "Attests this change" = the bundle's commit_sha equals this change's own sha, OR is an
|
|
845
|
+
* ancestor of it (the bundle was sealed earlier in the SAME open PR's own linear commit
|
|
846
|
+
* history, before a later delivery commit — the normal, legitimate shape; sealTrustCheckpoint
|
|
847
|
+
* necessarily stamps a commit that precedes its own delivery commit, so exact equality alone
|
|
848
|
+
* would reject every legitimate delivery). Reuses the same `git merge-base --is-ancestor`
|
|
849
|
+
* primitive `commit:` scope ranges already use (isAncestorCommit()) rather than a new check.
|
|
850
|
+
*
|
|
851
|
+
* FAIL CLOSED on ambiguity: no extractable commit_sha (bundle/checkpoint carries none, or
|
|
852
|
+
* this change's own sha is unresolvable) → never treated as fresh/owned.
|
|
853
|
+
*
|
|
854
|
+
* CORRECTNESS REQUIRES the caller's CI context to provide two things (see
|
|
855
|
+
* .github/workflows/trust-reconcile.yml's "CI-context contract" comment for the concrete
|
|
856
|
+
* settings this repo's own required check uses):
|
|
857
|
+
* 1. A full-history (non-shallow) checkout. `git merge-base --is-ancestor` needs the
|
|
858
|
+
* parent commit objects; a shallow clone (the common `fetch-depth: 1` default) does
|
|
859
|
+
* not have them, so the underlying `git merge-base` call exits 128 (unresolvable).
|
|
860
|
+
* 2. `changeSha` bound to the actual commit a checkpoint could plausibly have been
|
|
861
|
+
* sealed against — on a GitHub `pull_request` trigger this is the PR's own HEAD
|
|
862
|
+
* commit, NOT the synthetic merge commit `GITHUB_SHA` resolves to by default (a
|
|
863
|
+
* seal-checkpoint run never stamps a merge commit that did not exist yet).
|
|
864
|
+
* Shallow-clone / missing-object conditions are DEGRADED-BUT-SAFE, never silently
|
|
865
|
+
* accepted: isAncestorCommit() (and therefore this function) fails toward STALE, the same
|
|
866
|
+
* as any other unresolvable ancestry, so a real fresh bundle under a misconfigured shallow
|
|
867
|
+
* checkout is loudly, diagnosably rejected (the `stale bundle ignored — attests <theirs>,
|
|
868
|
+
* this change is <ours>` line still names both shas — an operator can immediately see the
|
|
869
|
+
* mismatch is a checkout-depth artifact, not a real staleness), rather than a bundle CI
|
|
870
|
+
* cannot actually verify slipping through as accepted.
|
|
871
|
+
*
|
|
872
|
+
* @returns {{fresh: boolean, bundleSha: string|null}}
|
|
873
|
+
*/
|
|
874
|
+
function bundleAttestsThisChange(repoRoot, bundlePath, bundleJson, changeSha) {
|
|
875
|
+
const bundleSha = extractBundleCommitSha(repoRoot, bundlePath, bundleJson);
|
|
876
|
+
if (!bundleSha || !changeSha) return { fresh: false, bundleSha };
|
|
877
|
+
const fresh = bundleSha === changeSha || isAncestorCommit(repoRoot, bundleSha, changeSha);
|
|
878
|
+
return { fresh, bundleSha };
|
|
879
|
+
}
|
|
880
|
+
|
|
472
881
|
// ---------------------------------------------------------------------------
|
|
473
882
|
// Core reconcile function (exported for programmatic use)
|
|
474
883
|
// ---------------------------------------------------------------------------
|
|
@@ -491,11 +900,57 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null, mani
|
|
|
491
900
|
repoRoot || process.env.TRUST_RECONCILE_REPO_ROOT || process.cwd()
|
|
492
901
|
);
|
|
493
902
|
|
|
494
|
-
// Resolve bundle path: explicit arg > env > auto-discovery > null
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
903
|
+
// Resolve bundle path: explicit arg > env > auto-discovery > null. The staleness check
|
|
904
|
+
// below (ADR 0022 addendum §2) applies ONLY to the auto-discovered case — an explicit
|
|
905
|
+
// --bundle/TRUST_RECONCILE_BUNDLE is a deliberate caller choice (tests, programmatic
|
|
906
|
+
// callers), not something silently picked up from committed git tree state, and real CI
|
|
907
|
+
// (.github/workflows/trust-reconcile.yml) never passes --bundle; it always relies on
|
|
908
|
+
// auto-discovery, which is exactly the path PR #278's stale-bundle incident went through.
|
|
909
|
+
const explicitBundlePath = bundle || process.env.TRUST_RECONCILE_BUNDLE || null;
|
|
910
|
+
let bundlePath = explicitBundlePath || discoverBundle(resolvedRepoRoot) || null;
|
|
911
|
+
const bundleWasAutoDiscovered = !explicitBundlePath && !!bundlePath;
|
|
912
|
+
|
|
913
|
+
// Scope-matching context (ref/actor/sha) — resolved once, reused by both the
|
|
914
|
+
// bundle-ownership staleness check below and the delivery/DECLARED exemption path
|
|
915
|
+
// further down (bundle-absent branch).
|
|
916
|
+
const scopeCtx = resolveScopeContext(resolvedRepoRoot);
|
|
917
|
+
|
|
918
|
+
// Event-scoped enforcement (ADR 0022 addendum, part 4): bundle-required enforcement
|
|
919
|
+
// (and the staleness-gate consequence of it, immediately below) applies ONLY when this
|
|
920
|
+
// run is gating a proposed change. A push run on main happens AFTER the gating PR run
|
|
921
|
+
// already passed — see the isPostMergeEvent branch further down for the full rationale.
|
|
922
|
+
const enforcementEvent = resolveEnforcementEvent();
|
|
923
|
+
const isPostMergeEvent = enforcementEvent === 'push';
|
|
924
|
+
|
|
925
|
+
// Bundle-ownership staleness check (ADR 0022 addendum §2): an AUTO-DISCOVERED bundle
|
|
926
|
+
// must attest THIS change, not a historically-committed bundle inherited via
|
|
927
|
+
// update-branch/rebase (live incident: PR #278). A stale bundle is treated as ABSENT —
|
|
928
|
+
// on a gating event it falls through to the exact same DECLARED/fail-closed branch as
|
|
929
|
+
// no-bundle-at-all; on a push event it falls through to a loud no-op instead (below).
|
|
930
|
+
// A bundle that fails to parse here is NOT treated as stale (left alone so the existing
|
|
931
|
+
// Step 2 "failed to read bundle" diagnostic still fires with its own clearer message).
|
|
932
|
+
// bundleWasDiscardedAsStale is tracked only to word the push-event no-op line accurately
|
|
933
|
+
// (an auto-discovered-but-stale bundle vs. no bundle ever being found are both a no-op
|
|
934
|
+
// on push, but "inherited bundle does not attest" is only true of the former).
|
|
935
|
+
let bundleWasDiscardedAsStale = false;
|
|
936
|
+
if (bundlePath && bundleWasAutoDiscovered) {
|
|
937
|
+
let bundleJsonForStaleness = null;
|
|
938
|
+
try {
|
|
939
|
+
bundleJsonForStaleness = JSON.parse(fs.readFileSync(bundlePath, 'utf8'));
|
|
940
|
+
} catch {
|
|
941
|
+
bundleJsonForStaleness = null;
|
|
942
|
+
}
|
|
943
|
+
if (bundleJsonForStaleness) {
|
|
944
|
+
const { fresh, bundleSha } = bundleAttestsThisChange(resolvedRepoRoot, bundlePath, bundleJsonForStaleness, scopeCtx.sha);
|
|
945
|
+
if (!fresh) {
|
|
946
|
+
const theirs = bundleSha || 'no usable commit binding';
|
|
947
|
+
const ours = scopeCtx.sha || 'unknown (no TRUST_RECONCILE_SHA/GITHUB_SHA)';
|
|
948
|
+
process.stdout.write(`[trust-reconcile] stale bundle ignored — attests ${theirs}, this change is ${ours}\n`);
|
|
949
|
+
bundlePath = null;
|
|
950
|
+
bundleWasDiscardedAsStale = true;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
499
954
|
|
|
500
955
|
const canonicalCommands = resolveCanonicalCommands({ commands: commands || [] }, resolvedRepoRoot);
|
|
501
956
|
|
|
@@ -528,8 +983,10 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null, mani
|
|
|
528
983
|
}
|
|
529
984
|
if (bundlePath) {
|
|
530
985
|
process.stdout.write(`[trust-reconcile] bundle: ${bundlePath}\n`);
|
|
986
|
+
} else if (isPostMergeEvent) {
|
|
987
|
+
process.stdout.write('[trust-reconcile] no bundle present — push event (post-merge run): Step 1 still runs; Step 2/delivery-DECLARED enforcement does not apply here (ADR 0022 addendum, part 4)\n');
|
|
531
988
|
} else {
|
|
532
|
-
process.stdout.write('[trust-reconcile] no bundle present —
|
|
989
|
+
process.stdout.write('[trust-reconcile] no bundle present — bundle-required by default (ADR 0022 §1); Step 1 still runs, then checking delivery/DECLARED for a Step-2 exemption\n');
|
|
533
990
|
}
|
|
534
991
|
|
|
535
992
|
// -------------------------------------------------------------------------
|
|
@@ -819,6 +1276,46 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null, mani
|
|
|
819
1276
|
// is simply absent (grep-stable for downstream tooling / reviewers).
|
|
820
1277
|
process.stdout.write(`[trust-reconcile] ${attestedCount} attested claim(s) accepted without independent verification\n`);
|
|
821
1278
|
}
|
|
1279
|
+
} else if (isPostMergeEvent) {
|
|
1280
|
+
// Event-scoped enforcement (ADR 0022 addendum, part 4): bundle-required enforcement
|
|
1281
|
+
// (and delivery/DECLARED as its only exemption) exists to gate a PROPOSED change —
|
|
1282
|
+
// it applies on pull_request (and any other/unknown event, conservatively). A push
|
|
1283
|
+
// run on `main` happens AFTER the gating PR run already passed: branch protection
|
|
1284
|
+
// already excludes direct pushes from anyone but the merge machinery, so this run's
|
|
1285
|
+
// job is Step 1 fresh-verify (unconditional, already ran above) plus, IF the
|
|
1286
|
+
// just-merged bundle happens to still attest this exact commit, Step 2 reconcile as
|
|
1287
|
+
// the basis for Phase 2 attestation minting (handled by the `if (bundlePath)` branch
|
|
1288
|
+
// above when fresh — unaffected by this branch). A squash-merge commit on main almost
|
|
1289
|
+
// never has git ancestry back to the feature-branch commit a checkpoint was sealed
|
|
1290
|
+
// against (squash discards the original commit graph — see the staleness-check
|
|
1291
|
+
// addendum), so bundlePath being null here on push is the EXPECTED, common case, not
|
|
1292
|
+
// a divergence — reproduced empirically (synthetic squash pair) as a HIGH
|
|
1293
|
+
// launch-blocker: without this branch, EVERY delivery would falsely fail Trust
|
|
1294
|
+
// Reconcile on main immediately post-merge and Phase 2 attestation minting would stop
|
|
1295
|
+
// entirely. Requiring a delivery/DECLARED marker on every push to main would also be
|
|
1296
|
+
// nonsensical — there is no "change" being gated here to declare an exemption from.
|
|
1297
|
+
// This is a loud no-op: exit 0, no issue pushed.
|
|
1298
|
+
const detail = bundleWasDiscardedAsStale
|
|
1299
|
+
? 'inherited bundle does not attest this commit'
|
|
1300
|
+
: 'no bundle to reconcile';
|
|
1301
|
+
process.stdout.write(`[trust-reconcile] push event: ${detail} — skipping Step 2 (gating happened on the PR run)\n`);
|
|
1302
|
+
} else {
|
|
1303
|
+
// Bundle absent (ADR 0022 §1) — either never discovered, or discovered-but-stale and
|
|
1304
|
+
// nulled out above. The ONLY exemption is a well-formed, in-scope delivery/DECLARED
|
|
1305
|
+
// marker, and it exempts Step 2 ONLY — Step 1 (fresh verify, above) already ran
|
|
1306
|
+
// unconditionally and was never skipped. Reuses the scopeCtx resolved above (same
|
|
1307
|
+
// context the staleness check already used).
|
|
1308
|
+
const exemption = resolveDeclaredExemption(resolvedRepoRoot, scopeCtx);
|
|
1309
|
+
if (exemption.exempt) {
|
|
1310
|
+
const { scope, reason, approved_by, declared_at } = exemption.marker;
|
|
1311
|
+
process.stdout.write(`[trust-reconcile] DECLARED (no-agent-delivery): ${scope} — ${reason} (approved by ${approved_by}, declared ${declared_at})\n`);
|
|
1312
|
+
} else {
|
|
1313
|
+
process.stderr.write(`[trust-reconcile] ${exemption.diagnostic}\n`);
|
|
1314
|
+
issues.push({
|
|
1315
|
+
type: 'bundle-required-no-declared-marker',
|
|
1316
|
+
message: exemption.diagnostic,
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
822
1319
|
}
|
|
823
1320
|
|
|
824
1321
|
// -------------------------------------------------------------------------
|