@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.
Files changed (89) hide show
  1. package/.github/CODEOWNERS +8 -0
  2. package/.github/workflows/ci.yml +12 -0
  3. package/.github/workflows/trust-reconcile.yml +62 -4
  4. package/CHANGELOG.md +23 -0
  5. package/CONTEXT.md +21 -0
  6. package/build/src/cli/assignment-provider.d.ts +1 -0
  7. package/build/src/cli/assignment-provider.js +748 -0
  8. package/build/src/cli/effective-assignment-provider-settings.d.ts +1 -0
  9. package/build/src/cli/effective-assignment-provider-settings.js +125 -0
  10. package/build/src/cli/validate-workflow-artifacts.js +5 -1
  11. package/build/src/cli/workflow-sidecar.js +157 -110
  12. package/build/src/cli.js +6 -0
  13. package/build/src/tools/validate-source-tree.js +1 -0
  14. package/context/contracts/artifact-contract.md +2 -0
  15. package/context/contracts/assignment-provider-contract.md +239 -0
  16. package/context/contracts/builder-kit-workflow-state-contract.md +2 -0
  17. package/context/contracts/decision-registry-contract.md +2 -0
  18. package/context/contracts/delivery-contract.md +2 -0
  19. package/context/contracts/execution-contract.md +25 -0
  20. package/context/contracts/governance-adapter-contract.md +2 -0
  21. package/context/contracts/knowledge-store-contract.md +197 -0
  22. package/context/contracts/planning-contract.md +2 -0
  23. package/context/contracts/review-contract.md +2 -0
  24. package/context/contracts/sandbox-policy.md +2 -0
  25. package/context/contracts/standing-directives.md +13 -0
  26. package/context/contracts/verification-contract.md +2 -0
  27. package/context/contracts/work-item-contract.md +2 -0
  28. package/context/settings/assignment-provider-settings.json +33 -0
  29. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +180 -0
  30. package/docs/context-map.md +1 -0
  31. package/docs/decisions/index.md +3 -0
  32. package/docs/decisions/knowledge-store-provider.md +51 -0
  33. package/docs/decisions/model-routing.md +63 -0
  34. package/docs/decisions/standing-directives.md +66 -0
  35. package/docs/fixture-ownership.md +1 -0
  36. package/docs/workflow-shared-contracts.md +2 -1
  37. package/docs/workflow-usage-guide.md +6 -0
  38. package/evals/ci/run-baseline.sh +6 -0
  39. package/evals/fixtures/assignment-provider/actor-a.json +6 -0
  40. package/evals/fixtures/assignment-provider/actor-b.json +6 -0
  41. package/evals/fixtures/assignment-provider/github-issue-claimed.json +27 -0
  42. package/evals/fixtures/assignment-provider/github-issue-unassigned.json +7 -0
  43. package/evals/fixtures/assignment-provider/liveness-fresh.json +9 -0
  44. package/evals/fixtures/assignment-provider/liveness-stale.json +9 -0
  45. package/evals/integration/test_assignment_provider_github.sh +318 -0
  46. package/evals/integration/test_assignment_provider_local_file.sh +222 -0
  47. package/evals/integration/test_critique_supersession_roundtrip.sh +182 -0
  48. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  49. package/evals/integration/test_publish_delivery.sh +21 -4
  50. package/evals/integration/test_pull_work_assignment_join.sh +132 -0
  51. package/evals/integration/test_pull_work_liveness_preflight.sh +14 -6
  52. package/evals/integration/test_reconcile_soundness.sh +33 -9
  53. package/evals/integration/test_trust_reconcile.sh +9 -8
  54. package/evals/integration/test_trust_reconcile_negatives.sh +608 -0
  55. package/evals/integration/test_workflow_sidecar_writer.sh +79 -0
  56. package/evals/run.sh +10 -0
  57. package/evals/static/test_flowdef_codeowners_coverage.sh +6 -0
  58. package/evals/static/test_knowledge_providers.sh +23 -0
  59. package/kits/builder/skills/deliver/SKILL.md +19 -0
  60. package/kits/builder/skills/pull-work/SKILL.md +78 -10
  61. package/kits/knowledge/kit.json +35 -0
  62. package/kits/knowledge/providers/conformance/fixtures/git-repo/CONTEXT.md +12 -0
  63. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/old-sprocket-shape.md +13 -0
  64. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/sprocket-shape.md +14 -0
  65. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/widget-format.md +14 -0
  66. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/learnings/fixture-learning.md +7 -0
  67. package/kits/knowledge/providers/conformance/fixtures/work-item/issues.json +30 -0
  68. package/kits/knowledge/providers/conformance/suite.test.js +125 -0
  69. package/kits/knowledge/providers/git-repo/index.js +236 -0
  70. package/kits/knowledge/providers/health/health-pass.test.js +99 -0
  71. package/kits/knowledge/providers/health/index.js +153 -0
  72. package/kits/knowledge/providers/index.js +24 -0
  73. package/kits/knowledge/providers/lib/model.js +91 -0
  74. package/kits/knowledge/providers/lib/schema-validate.js +119 -0
  75. package/kits/knowledge/providers/markdown-vault/index.js +169 -0
  76. package/kits/knowledge/providers/work-item/index.js +204 -0
  77. package/package.json +4 -2
  78. package/schemas/assignment-provider-settings.schema.json +125 -0
  79. package/schemas/knowledge/edge.schema.json +54 -0
  80. package/schemas/knowledge/health-report.schema.json +45 -0
  81. package/schemas/knowledge/node.schema.json +49 -0
  82. package/schemas/knowledge/proposal.schema.json +53 -0
  83. package/scripts/ci/trust-reconcile.js +521 -24
  84. package/src/cli/assignment-provider.ts +845 -0
  85. package/src/cli/effective-assignment-provider-settings.ts +112 -0
  86. package/src/cli/validate-workflow-artifacts.ts +5 -1
  87. package/src/cli/workflow-sidecar.ts +147 -106
  88. package/src/cli.ts +6 -0
  89. 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 (or no bundle present)
20
- * 1 fresh verify failed OR any divergence detected OR compile-only fallback
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
- * Fail-open on bundle absence: if no bundle is provided (and none auto-discovered at
23
- * delivery/trust.bundle or delivery/trust.checkpoint.json), only the fresh verify is
24
- * enforced. Fail-closed on divergence: any claimed-pass command CI cannot confirm blocks.
25
- * Fail-closed on compile-only: if no comprehensive verify is configured, exits 1.
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, continues fail-open (fresh verify only).
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 is intended to be a REQUIRED status check in GitHub branch
53
- * protection (making it the un-disablable CI anchor). Enabling it as required is
54
- * a server-side branch-protection step see .github/workflows/trust-reconcile.yml.
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 (fail-open on bundle absence).
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 candidates = [
463
- path.join(repoRoot, 'delivery', 'trust.bundle'),
464
- path.join(repoRoot, 'delivery', 'trust.checkpoint.json'),
465
- ];
466
- for (const candidate of candidates) {
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 (fail-open)
495
- const bundlePath = bundle
496
- || process.env.TRUST_RECONCILE_BUNDLE
497
- || discoverBundle(resolvedRepoRoot)
498
- || null;
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 — fresh verify only (fail-open on bundle absence)\n');
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
  // -------------------------------------------------------------------------