@kontourai/flow-agents 3.1.0 → 3.3.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 (86) hide show
  1. package/.github/workflows/ci.yml +16 -0
  2. package/CHANGELOG.md +30 -0
  3. package/build/src/cli/assignment-provider.d.ts +45 -0
  4. package/build/src/cli/assignment-provider.js +107 -13
  5. package/build/src/cli/workflow-artifact-cleanup-audit.js +418 -11
  6. package/build/src/cli/workflow-sidecar.d.ts +238 -4
  7. package/build/src/cli/workflow-sidecar.js +875 -14
  8. package/build/src/tools/validate-source-tree.js +3 -2
  9. package/context/contracts/artifact-contract.md +16 -2
  10. package/context/contracts/assignment-provider-contract.md +1 -1
  11. package/context/contracts/execution-contract.md +78 -0
  12. package/context/scripts/hooks/config-protection.js +11 -4
  13. package/context/scripts/hooks/stop-goal-fit.js +259 -4
  14. package/context/scripts/hooks/workflow-steering.js +73 -1
  15. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +111 -0
  16. package/docs/coordination-guide.md +370 -0
  17. package/docs/decisions/agent-coordination.md +26 -9
  18. package/docs/decisions/index.md +2 -2
  19. package/docs/decisions/trust-reconcile.md +42 -9
  20. package/docs/fixture-ownership.md +3 -2
  21. package/docs/index.md +4 -0
  22. package/docs/integrations/flow-agents-console.md +108 -0
  23. package/docs/integrations/index.md +4 -0
  24. package/docs/workflow-artifact-lifecycle.md +38 -1
  25. package/evals/ci/antigaming-suite.sh +1 -0
  26. package/evals/ci/run-baseline.sh +8 -0
  27. package/evals/fixtures/reconcile-preflight/disputed-critique-unsuperseded.json +48 -0
  28. package/evals/fixtures/reconcile-preflight/standalone-disputed-session-local.json +59 -0
  29. package/evals/integration/test_checkpoint_signing.sh +14 -5
  30. package/evals/integration/test_ci_actor_identity.sh +221 -0
  31. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  32. package/evals/integration/test_gate_lockdown.sh +36 -0
  33. package/evals/integration/test_model_routing_escalation.sh +145 -0
  34. package/evals/integration/test_publish_delivery.sh +73 -8
  35. package/evals/integration/test_reconcile_preflight.sh +304 -0
  36. package/evals/integration/test_stop_hook_release.sh +552 -0
  37. package/evals/integration/test_takeover_protocol.sh +340 -0
  38. package/evals/integration/test_trust_reconcile_negatives.sh +261 -0
  39. package/evals/integration/test_verify_hold.sh +910 -0
  40. package/evals/integration/test_veritas_governance_kit.sh +257 -0
  41. package/evals/integration/test_workflow_artifact_cleanup_audit.sh +575 -3
  42. package/evals/run.sh +14 -0
  43. package/evals/static/test_model_routing_hints.sh +107 -0
  44. package/kits/builder/skills/builder-shape/SKILL.md +10 -0
  45. package/kits/builder/skills/continue-work/SKILL.md +2 -0
  46. package/kits/builder/skills/deliver/SKILL.md +125 -11
  47. package/kits/builder/skills/design-probe/SKILL.md +10 -0
  48. package/kits/builder/skills/execute-plan/SKILL.md +13 -0
  49. package/kits/builder/skills/fix-bug/SKILL.md +17 -0
  50. package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
  51. package/kits/builder/skills/plan-work/SKILL.md +9 -0
  52. package/kits/builder/skills/pull-work/SKILL.md +22 -2
  53. package/kits/builder/skills/review-work/SKILL.md +11 -0
  54. package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
  55. package/kits/builder/skills/verify-work/SKILL.md +11 -0
  56. package/kits/knowledge/adapters/default-store/index.js +56 -15
  57. package/kits/knowledge/adapters/flow-runner/index.js +912 -16
  58. package/kits/knowledge/adapters/obsidian-store/index.js +29 -11
  59. package/kits/knowledge/adapters/shared/codec.js +124 -0
  60. package/kits/knowledge/docs/store-contract.md +405 -3
  61. package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
  62. package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
  63. package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
  64. package/kits/knowledge/evals/contract-suite/suite.test.js +36 -0
  65. package/kits/knowledge/evals/freshness/suite.test.js +339 -0
  66. package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
  67. package/kits/knowledge/evals/retirement/suite.test.js +1 -1
  68. package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
  69. package/kits/veritas-governance/docs/README.md +81 -3
  70. package/kits/veritas-governance/fixtures/exemption/approved.trust-bundle.json +74 -0
  71. package/kits/veritas-governance/fixtures/exemption/not-approved.trust-bundle.json +74 -0
  72. package/kits/veritas-governance/flows/exemption-issuance.flow.json +35 -0
  73. package/kits/veritas-governance/kit.json +5 -0
  74. package/package.json +1 -1
  75. package/schemas/workflow-handoff.schema.json +6 -0
  76. package/scripts/ci/mint-attestation.js +33 -6
  77. package/scripts/ci/trust-reconcile.js +222 -279
  78. package/scripts/hooks/config-protection.js +11 -4
  79. package/scripts/hooks/lib/actor-identity.js +82 -0
  80. package/scripts/hooks/stop-goal-fit.js +259 -4
  81. package/scripts/hooks/workflow-steering.js +73 -1
  82. package/scripts/lib/reconcile-shape.js +381 -0
  83. package/src/cli/assignment-provider.ts +122 -13
  84. package/src/cli/workflow-artifact-cleanup-audit.ts +483 -10
  85. package/src/cli/workflow-sidecar.ts +965 -14
  86. package/src/tools/validate-source-tree.ts +3 -2
@@ -124,6 +124,18 @@ const path = require('path');
124
124
  // copy here had drifted (it was missing the trailing `/bin/true` check), which is
125
125
  // exactly why this is now imported rather than duplicated.
126
126
  const { hasLaunderingOperator } = require('../lib/command-log-chain.js');
127
+ // #356: the bundle-shape classification (classifyBundleClaims) and the pure,
128
+ // shape-level divergence-construction helpers used by Step 2 below are shared with a
129
+ // local pre-push preflight (reconcile-preflight) via this module, so the preflight can
130
+ // never drift from what this CI reconciler actually enforces. See that module's own
131
+ // header comment for the extraction rationale.
132
+ const {
133
+ classifyBundleClaims,
134
+ waiverOnCommandIssues,
135
+ noEvidenceCommandIssues,
136
+ reconcilableManifestIssues,
137
+ sessionLocalShapeIssues,
138
+ } = require('../lib/reconcile-shape.js');
127
139
 
128
140
  // ---------------------------------------------------------------------------
129
141
  // Helpers
@@ -199,138 +211,6 @@ function runCommand(cmd, repoRoot) {
199
211
  };
200
212
  }
201
213
 
202
- /**
203
- * Classify a trust.bundle's claims into: reconcilable command claims (test_output +
204
- * execution.label), session-local claims (attestation/observation/citation), never-captured
205
- * or unbacked command claims (not-run divergence), and command-backed claims carrying a
206
- * waiver (waiver-on-command divergence). Returns
207
- * { reconcilable, sessionLocal, noEvidenceCommand, waiverOnCommand }.
208
- *
209
- * Source of truth: evidence[].execution.label is the command string recorded at capture time.
210
- * evidence[].passing (normalized) means the agent claimed this passed. `claim.status` is NOT
211
- * trusted here — the caller re-derives it CI-side (see derive-claim-status.mjs / finding-3).
212
- *
213
- * WS8 iteration-2 hardening:
214
- * - finding 1: ANY pass-asserting claim whose evidence is `evidenceType: test_output`
215
- * (Surface's default when unset) but which did NOT reconcile — i.e. it has no
216
- * manifest-matchable execution.label — is a divergence, NOT session-local. A test_output
217
- * claim either reconciles against the manifest or is a divergence; it is never accepted on
218
- * self-reported status. (Previously only the literal claimType `workflow.check.command`
219
- * was guarded, so a fabricated kind:"test" claim with no command slipped through.)
220
- * - finding 4: a command-backed (test_output) claim carrying a waiver is a divergence — a
221
- * command-backed check reconciles against CI or fails; it cannot be waived.
222
- */
223
- function classifyBundleClaims(bundle) {
224
- const evidence = Array.isArray(bundle.evidence) ? bundle.evidence : [];
225
- const claims = Array.isArray(bundle.claims) ? bundle.claims : [];
226
-
227
- const claimById = new Map();
228
- for (const c of claims) if (c && c.id) claimById.set(c.id, c);
229
-
230
- // Evidence indexing. A missing evidenceType defaults to test_output for backward
231
- // compatibility with pre-classification bundles (same default classifyEvidence uses).
232
- const claimHasLabeledTestOutput = new Set(); // test_output evidence WITH an execution.label
233
- const claimHasTestOutputEvidence = new Set(); // ANY test_output evidence (label or not)
234
- // WS8 iteration-4 (converged finding): the session-local (non-test_output) evidenceType per
235
- // claim, so the reconciler can name it on the loud ATTESTED marker below — a fabricated
236
- // human_attestation/attestation/external claim with no --command is otherwise
237
- // indistinguishable, in the reconciler's own output, from a genuinely re-runnable check.
238
- const claimEvidenceType = new Map();
239
- for (const ev of evidence) {
240
- if (!ev || !ev.claimId) continue;
241
- const evType = ev.evidenceType || 'test_output';
242
- if (evType !== 'test_output') {
243
- if (!claimEvidenceType.has(ev.claimId)) claimEvidenceType.set(ev.claimId, evType);
244
- continue;
245
- }
246
- claimHasTestOutputEvidence.add(ev.claimId);
247
- if (ev.execution && ev.execution.label) claimHasLabeledTestOutput.add(ev.claimId);
248
- }
249
-
250
- // finding 4: a command-backed (test_output-evidence) claim that also carries a waiver.
251
- const waiverOnCommand = [];
252
- for (const c of claims) {
253
- if (!c || !c.id) continue;
254
- const waiver = (c.metadata && typeof c.metadata === 'object') ? c.metadata.waiver : undefined;
255
- if (waiver && typeof waiver === 'object' && claimHasTestOutputEvidence.has(c.id)) {
256
- waiverOnCommand.push({ claimId: c.id, claimType: String(c.claimType || ''), subject: c.subjectId || c.fieldOrBehavior || c.id });
257
- }
258
- }
259
-
260
- // (A) Reconcilable claimed-passes: evidence items that are test_output (CI-reconcilable),
261
- // carry an execution.label, and assert pass. Session-local evidenceTypes
262
- // (crawl_observation, human_attestation, attestation, policy_rule, source_excerpt,
263
- // document_citation, calculation_trace) are NOT reconciled per-command — they are handled
264
- // by the session-local/waiver path below.
265
- const reconcilable = [];
266
- const reconcilableClaimIds = new Set();
267
- const seen = new Set();
268
- for (const ev of evidence) {
269
- if (!ev || !ev.execution || !ev.execution.label) continue;
270
- if (!isPassingValue(ev.passing)) continue;
271
- const evType = ev.evidenceType || 'test_output';
272
- if (evType !== 'test_output') continue; // session-local — not CI-reconcilable
273
- const cmd = normalizeCmd(ev.execution.label);
274
- if (!cmd) continue;
275
- reconcilableClaimIds.add(ev.claimId);
276
- if (seen.has(cmd)) continue;
277
- seen.add(cmd);
278
- const claim = claimById.get(ev.claimId);
279
- reconcilable.push({ cmd, claimId: ev.claimId, evId: ev.id, claimType: claim ? String(claim.claimType || '') : '' });
280
- }
281
-
282
- // (B) Session-local claims, never-captured command claims, and unreconciled test_output.
283
- const sessionLocal = [];
284
- const noEvidenceCommand = [];
285
- const seenClaims = new Set();
286
- for (const c of claims) {
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;
293
- if (reconcilableClaimIds.has(c.id)) continue; // handled by (A)
294
- if (seenClaims.has(c.id)) continue;
295
- const status = String(c.status || '');
296
- const assertsPass = isPassingValue(c.value) || status === 'verified' || status === 'assumed';
297
- const isFailing = status === 'disputed' || status === 'rejected';
298
- if (!assertsPass && !isFailing) continue; // pending/unknown non-asserting — ignore (as before)
299
- seenClaims.add(c.id);
300
-
301
- // finding 1: a pass-asserting claim backed by test_output evidence that did NOT reconcile
302
- // (it has test_output evidence but no manifest-matchable execution.label — otherwise it
303
- // would be in bucket A) is a not-run divergence. A test_output claim reconciles against the
304
- // manifest or it is a divergence — it is NEVER accepted as session-local on self-report.
305
- if (assertsPass && claimHasTestOutputEvidence.has(c.id)) {
306
- const rawCmd = normalizeCmd(c.fieldOrBehavior || c.value || '');
307
- noEvidenceCommand.push({ cmd: rawCmd || `[claim:${c.id}]`, claimId: c.id, claimType: c.claimType, reason: 'test_output-unreconciled' });
308
- continue;
309
- }
310
-
311
- // A workflow.check.command claim with no captured (labeled) evidence is a never-captured
312
- // claimed pass — not-run divergence (anti-gaming teeth preserved).
313
- if (assertsPass && c.claimType === 'workflow.check.command' && !claimHasLabeledTestOutput.has(c.id)) {
314
- const rawCmd = normalizeCmd(c.fieldOrBehavior || c.value || '');
315
- noEvidenceCommand.push({ cmd: rawCmd || `[claim:${c.id}:${c.claimType}]`, claimId: c.id, claimType: c.claimType, reason: 'no-evidence-command' });
316
- continue;
317
- }
318
-
319
- const waiver = (c.metadata && typeof c.metadata === 'object') ? c.metadata.waiver : undefined;
320
- sessionLocal.push({
321
- claimId: c.id,
322
- claimType: c.claimType,
323
- assertedStatus: status,
324
- value: c.value,
325
- waiver: (waiver && typeof waiver === 'object') ? waiver : null,
326
- subject: c.subjectId || c.fieldOrBehavior || c.id,
327
- evidenceType: claimEvidenceType.get(c.id) || 'unknown',
328
- });
329
- }
330
-
331
- return { reconcilable, sessionLocal, noEvidenceCommand, waiverOnCommand };
332
- }
333
-
334
214
  /**
335
215
  * WS8 (finding 3): re-derive every claim's status CI-side from the bundle's OWN
336
216
  * evidence/events/policies, using @kontourai/surface's canonical deriveClaimStatus (the same
@@ -522,32 +402,139 @@ const DELIVERY_DIR = 'delivery';
522
402
 
523
403
  /**
524
404
  * 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.
405
+ *
406
+ * #335/#379 (per-session delivery paths) is IMPLEMENTED here now, not deferred: a shared
407
+ * `delivery/<filename>` guarantees a git merge conflict between ANY two concurrent
408
+ * deliveries — and a conflicting (DIRTY) PR gets NO `pull_request` workflows scheduled, so
409
+ * the required Trust Reconcile check silently never runs (field incidents #330/#358/#378).
410
+ * The fix is per-session paths so concurrent deliveries write to DISTINCT files and never
411
+ * contend.
412
+ *
413
+ * Returned candidate order (precedence, first is highest):
414
+ * 1. the flat `delivery/<filename>` — kept FIRST for full back-compat. A repo that never
415
+ * adopts per-session layout sees byte-identical behavior; an already-committed flat
416
+ * bundle from before this change still resolves.
417
+ * 2. `delivery/<slug>/<filename>` for every immediate subdirectory of `delivery/`, sorted
418
+ * by name for determinism.
419
+ *
420
+ * Ordering here is ONLY the tie-break / fallback. Which candidate actually reconciles is
421
+ * decided by COMMIT-OWNERSHIP (discoverBundle() + bundleAttestsThisChange()), not directory
422
+ * order: among the candidates, the one whose checkpoint attests an ancestor-or-equal commit
423
+ * of THIS change wins, and stale siblings from other concurrent sessions are ignored.
424
+ * discoverBundle() and discoverDeclaredMarker() both iterate whatever this returns, so the
425
+ * per-session layout composes with zero call-site changes.
532
426
  */
533
427
  function resolveDeliveryCandidates(repoRoot, filename) {
534
- return [path.join(repoRoot, DELIVERY_DIR, filename)];
428
+ const deliveryRoot = path.join(repoRoot, DELIVERY_DIR);
429
+ const candidates = [path.join(deliveryRoot, filename)];
430
+ // #379: append delivery/<slug>/<filename> for each immediate subdirectory of delivery/.
431
+ // readdirSync is best-effort — a missing delivery/ (or an unreadable one) yields just the
432
+ // flat candidate, never throws.
433
+ let entries = [];
434
+ try {
435
+ entries = fs.readdirSync(deliveryRoot, { withFileTypes: true });
436
+ } catch {
437
+ entries = [];
438
+ }
439
+ const subdirs = entries
440
+ .filter((e) => e.isDirectory())
441
+ .map((e) => e.name)
442
+ .sort();
443
+ for (const name of subdirs) {
444
+ candidates.push(path.join(deliveryRoot, name, filename));
445
+ }
446
+ return candidates;
535
447
  }
536
448
 
537
449
  /**
538
450
  * Auto-discover the delivery bundle when no explicit path is provided.
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.
451
+ *
452
+ * Gathers every existing candidate flat `delivery/trust.bundle`, flat
453
+ * `delivery/trust.checkpoint.json`, and their `delivery/<slug>/…` per-session siblings (via
454
+ * resolveDeliveryCandidates()) with trust.bundle preferred over trust.checkpoint.json.
455
+ *
456
+ * #379 (per-session, ownership-aware selection): when `changeSha` is known, the candidate
457
+ * whose checkpoint attests an ancestor-or-equal commit of THIS change wins (same
458
+ * bundleAttestsThisChange() semantics the flat staleness gate already uses). Stale/older
459
+ * siblings from OTHER concurrent sessions are ignored.
460
+ *
461
+ * PREFER-NEWEST among owning candidates. More than one candidate can legitimately "own" the
462
+ * change at once: an inherited FLAT bundle's commit_sha can be a real ancestor of HEAD in a
463
+ * merge-commit repo (it was committed on the trunk's linear history before this branch
464
+ * point), AND this session's fresh per-session bundle attests a NEWER ancestor (the delivery
465
+ * commit right before HEAD). "First-fresh-wins" would then select the STALE inherited flat
466
+ * bundle purely because it sorts first — reconciling last delivery's claims against this
467
+ * change's CI. So among all owning candidates we pick the one attesting the NEWEST commit
468
+ * (the descendant-most: the owning candidate whose commit_sha every other owning candidate's
469
+ * commit_sha is an ancestor of), via the same `git merge-base --is-ancestor` primitive. This
470
+ * makes an inherited/older owning bundle HARMLESS whether or not it was ever pruned — the
471
+ * fresh per-session bundle wins on recency, not on being deleted first.
472
+ *
473
+ * When no candidate owns this change (or `changeSha` is unknown, e.g. a local run with no
474
+ * TRUST_RECONCILE_SHA), the first existing candidate is returned so the caller's staleness
475
+ * gate below handles it exactly as before: a stale bundle is treated as absent, a malformed
476
+ * one still raises the Step 2 read error.
477
+ *
478
+ * Returns null if no candidate exists at all — the caller then falls through to
479
+ * delivery/DECLARED marker resolution (ADR 0022 §1); bundle absence is not fail-open.
543
480
  */
544
- function discoverBundle(repoRoot) {
481
+ function discoverBundle(repoRoot, changeSha) {
482
+ const candidates = [];
545
483
  for (const filename of ['trust.bundle', 'trust.checkpoint.json']) {
546
484
  for (const candidate of resolveDeliveryCandidates(repoRoot, filename)) {
547
- if (fs.existsSync(candidate)) return candidate;
485
+ if (fs.existsSync(candidate)) candidates.push(candidate);
548
486
  }
549
487
  }
550
- return null;
488
+ if (candidates.length === 0) return null;
489
+
490
+ const deliveryRoot = path.join(repoRoot, DELIVERY_DIR);
491
+ const hasPerSession = candidates.some((c) => path.dirname(c) !== deliveryRoot);
492
+
493
+ // Ownership-aware selection (#379). Only meaningful when we know this change's sha; a
494
+ // candidate that fails to parse is skipped here (never treated as owning) — if it turns
495
+ // out to be the only candidate, it is still returned below so Step 2's "failed to read
496
+ // bundle" diagnostic fires unchanged.
497
+ if (changeSha) {
498
+ const owning = []; // { candidate, bundleSha } — every candidate that attests this change
499
+ for (const candidate of candidates) {
500
+ let json = null;
501
+ try {
502
+ json = JSON.parse(fs.readFileSync(candidate, 'utf8'));
503
+ } catch {
504
+ json = null;
505
+ }
506
+ if (!json) continue;
507
+ const { fresh, bundleSha } = bundleAttestsThisChange(repoRoot, candidate, json, changeSha);
508
+ if (fresh) owning.push({ candidate, bundleSha });
509
+ }
510
+ if (owning.length > 0) {
511
+ // Prefer the owning candidate attesting the NEWEST (descendant-most) commit. `best` is
512
+ // upgraded to a candidate whose commit is a strict descendant of the current best's —
513
+ // i.e. best.sha is an ancestor of cand.sha. Every owning entry has a non-null bundleSha
514
+ // (bundleAttestsThisChange returns fresh=false otherwise). Ties / incomparable siblings
515
+ // keep the earlier (candidate-order) entry, so the flat path still wins a genuine tie.
516
+ let best = owning[0];
517
+ for (let i = 1; i < owning.length; i++) {
518
+ const cand = owning[i];
519
+ if (cand.bundleSha !== best.bundleSha && isAncestorCommit(repoRoot, best.bundleSha, cand.bundleSha)) {
520
+ best = cand;
521
+ }
522
+ }
523
+ if (hasPerSession) {
524
+ process.stdout.write(`[trust-reconcile] #379: selected delivery candidate ${path.relative(repoRoot, best.candidate)} — attests this change ${changeSha} (${candidates.length} candidate(s) present across flat + per-session delivery/<slug>/; ${owning.length} owning, newest wins; older/stale ignored)\n`);
525
+ }
526
+ return best.candidate;
527
+ }
528
+ // No candidate attests this change. When per-session candidates were in play, emit a
529
+ // loud, grep-stable concurrency hint (#379) so the next agent can tell a concurrent
530
+ // per-session collision apart from a plain stale/absent bundle — the per-candidate
531
+ // stale line still prints in runTrustReconcile's staleness gate for the returned one.
532
+ if (hasPerSession) {
533
+ const rels = candidates.map((c) => path.relative(repoRoot, c)).join(', ');
534
+ process.stdout.write(`[trust-reconcile] #379: examined ${candidates.length} delivery candidate(s) (flat + per-session delivery/<slug>/); none attests this change ${changeSha}. If this is a concurrent-delivery collision, the OWNING session's bundle is not on this ref — re-publish this session's delivery, or check whether main moved under the PR. Candidates: ${rels}\n`);
535
+ }
536
+ }
537
+ return candidates[0];
551
538
  }
552
539
 
553
540
  /**
@@ -813,22 +800,30 @@ function resolveDeclaredExemption(repoRoot, ctx) {
813
800
  * src/cli/workflow-sidecar.ts). trust.bundle itself carries NO commit/branch metadata
814
801
  * (schemaVersion 5: {schemaVersion, source, claims, evidence, policies, events} — confirmed
815
802
  * 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.
803
+ * SIBLING trust.checkpoint.json for the binding — publishDelivery() always writes both
804
+ * together into the SAME directory.
805
+ *
806
+ * #379: the sibling is resolved from the bundle's OWN directory (path.dirname(bundlePath)),
807
+ * not from a global resolveDeliveryCandidates() scan. In the per-session layout each session
808
+ * gets its own delivery/<slug>/ dir, and a global scan would cross-contaminate — pairing a
809
+ * per-session trust.bundle with the FLAT (or a different session's) trust.checkpoint.json and
810
+ * reading the wrong commit binding. Same-directory resolution is identical to the prior
811
+ * behavior for the flat layout (delivery/trust.bundle ↔ delivery/trust.checkpoint.json) and
812
+ * correct for per-session.
818
813
  */
819
814
  function extractBundleCommitSha(repoRoot, bundlePath, bundleJson) {
820
815
  if (bundleJson && typeof bundleJson.commit_sha === 'string' && bundleJson.commit_sha.trim()) {
821
816
  return bundleJson.commit_sha.trim();
822
817
  }
823
- for (const candidate of resolveDeliveryCandidates(repoRoot, 'trust.checkpoint.json')) {
824
- if (candidate === bundlePath || !fs.existsSync(candidate)) continue;
818
+ const siblingCheckpoint = path.join(path.dirname(bundlePath), 'trust.checkpoint.json');
819
+ if (siblingCheckpoint !== bundlePath && fs.existsSync(siblingCheckpoint)) {
825
820
  try {
826
- const checkpoint = JSON.parse(fs.readFileSync(candidate, 'utf8'));
821
+ const checkpoint = JSON.parse(fs.readFileSync(siblingCheckpoint, 'utf8'));
827
822
  if (checkpoint && typeof checkpoint.commit_sha === 'string' && checkpoint.commit_sha.trim()) {
828
823
  return checkpoint.commit_sha.trim();
829
824
  }
830
825
  } catch {
831
- // Malformed sibling checkpoint — no usable binding from it; keep looking / fall through.
826
+ // Malformed sibling checkpoint — no usable binding from it; fall through to null.
832
827
  }
833
828
  }
834
829
  return null;
@@ -907,14 +902,17 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null, mani
907
902
  // (.github/workflows/trust-reconcile.yml) never passes --bundle; it always relies on
908
903
  // auto-discovery, which is exactly the path PR #278's stale-bundle incident went through.
909
904
  const explicitBundlePath = bundle || process.env.TRUST_RECONCILE_BUNDLE || null;
910
- let bundlePath = explicitBundlePath || discoverBundle(resolvedRepoRoot) || null;
911
- const bundleWasAutoDiscovered = !explicitBundlePath && !!bundlePath;
912
905
 
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).
906
+ // Scope-matching context (ref/actor/sha) — resolved once, reused by ownership-aware
907
+ // auto-discovery (#379, immediately below), the bundle-ownership staleness check further
908
+ // down (ADR 0022 addendum §2), and the delivery/DECLARED exemption path (bundle-absent
909
+ // branch). Resolved BEFORE auto-discovery because discoverBundle() now uses scopeCtx.sha
910
+ // to pick, among per-session candidates, the one that attests THIS change.
916
911
  const scopeCtx = resolveScopeContext(resolvedRepoRoot);
917
912
 
913
+ let bundlePath = explicitBundlePath || discoverBundle(resolvedRepoRoot, scopeCtx.sha) || null;
914
+ const bundleWasAutoDiscovered = !explicitBundlePath && !!bundlePath;
915
+
918
916
  // Event-scoped enforcement (ADR 0022 addendum, part 4): bundle-required enforcement
919
917
  // (and the staleness-gate consequence of it, immediately below) applies ONLY when this
920
918
  // run is gating a proposed change. A push run on main happens AFTER the gating PR run
@@ -1106,50 +1104,24 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null, mani
1106
1104
  }
1107
1105
 
1108
1106
  // finding 4 (server-side): a command-backed (test_output-evidence) claim carrying a
1109
- // waiver is a divergence — a command-backed check reconciles against CI or fails; it
1110
- // cannot be waived away.
1111
- for (const { claimId, claimType, subject } of waiverOnCommand) {
1112
- issues.push({
1113
- type: 'waiver-on-command-check',
1114
- message: `trust divergence: claim '${claimId}' (${subject}, claimType: ${claimType}) carries a waiver but is backed by test_output evidence — a command-backed check reconciles against CI or fails and cannot be waived`,
1115
- });
1116
- }
1107
+ // waiver is a divergence — shape-only, delegated to scripts/lib/reconcile-shape.js
1108
+ // (used byte-identically here and by the local reconcile-preflight).
1109
+ issues.push(...waiverOnCommandIssues(waiverOnCommand));
1117
1110
 
1118
1111
  // not-run divergences: never-captured command claims (no evidence) AND test_output
1119
- // claims that did not reconcile (no manifest-matchable execution.label).
1120
- for (const { cmd, claimId, claimType, reason } of noEvidenceCommand) {
1121
- const message = reason === 'test_output-unreconciled'
1122
- ? `trust divergence: claim '${claimId}' (claimType: ${claimType}) asserts pass with test_output evidence but has no manifest-matched execution.label — a test_output claim must reconcile against the manifest or it is a divergence (never accepted as session-local)`
1123
- : `trust divergence: claim '${claimId}' (claimType: ${claimType}) asserts pass but has no supporting evidence item — command never captured`;
1124
- issues.push({ type: 'not-run', cmd, message });
1125
- }
1126
-
1127
- // Reconcilable test_output claims: laundering → manifest match → CI fresh result.
1128
- for (const { cmd } of reconcilable) {
1112
+ // claims that did not reconcile (no manifest-matchable execution.label). Shape-only,
1113
+ // delegated to scripts/lib/reconcile-shape.js.
1114
+ issues.push(...noEvidenceCommandIssues(noEvidenceCommand));
1115
+
1116
+ // Reconcilable test_output claims: laundering manifest match (shape-only, delegated
1117
+ // to scripts/lib/reconcile-shape.js) CI fresh result (CI-only, stays here — requires
1118
+ // a live command execution the local preflight must not perform).
1119
+ const { issues: manifestIssues, unresolved: reconcilableUnresolved } =
1120
+ reconcilableManifestIssues(reconcilable, manifestByCmd);
1121
+ issues.push(...manifestIssues);
1122
+ for (const { cmd, manifestEntry: entry } of reconcilableUnresolved) {
1129
1123
  const normalCmd = normalizeCmd(cmd);
1130
1124
 
1131
- // (a) Laundering operator check — must come first (most specific signal).
1132
- if (hasLaunderingOperator(cmd)) {
1133
- issues.push({
1134
- type: 'laundering',
1135
- cmd,
1136
- message: `trust divergence: agent claimed '${cmd}' passed; command contains exit-code-laundering operator (|| ... / ; true / ; exit 0 / etc.)`,
1137
- });
1138
- continue;
1139
- }
1140
-
1141
- // A test_output claim MUST name a manifest (required-lane) command. An agent
1142
- // cannot self-label an arbitrary command test_output to dodge the manifest.
1143
- const entry = manifestByCmd.get(normalCmd);
1144
- if (!entry) {
1145
- issues.push({
1146
- type: 'not-run',
1147
- cmd,
1148
- message: `trust divergence: agent claimed '${cmd}' passed; command is not in the reconcile manifest — a test_output claim must name a manifest/required-lane command (CI cannot self-declare an arbitrary command)`,
1149
- });
1150
- continue;
1151
- }
1152
-
1153
1125
  // Prefer the fresh-verify Step 1 result; otherwise re-run this (manifest, and
1154
1126
  // therefore required-lane) command fresh now. Only manifest commands are ever
1155
1127
  // run on demand, so the set is bounded by the registry.
@@ -1185,90 +1157,26 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null, mani
1185
1157
  process.stdout.write(`[trust-reconcile] RECONCILED: '${cmd}' (manifest id: ${entry.id}) — claimed pass, CI fresh run = PASS\n`);
1186
1158
  }
1187
1159
 
1188
- // Session-local claims: not CI-reconcilable, but NOT a pass bypass. Each must either
1189
- // (a) carry a loud, justified waiver, or (b) resolve a real CI-RE-DERIVED `verified`
1190
- // status. WS8 iteration-2 hardening:
1191
- // - finding 3: the status used here is RE-DERIVED CI-side, never the self-reported
1192
- // claim.status. A mismatch is a `status-misassertion` divergence. If re-derivation
1193
- // is unavailable we fail closed (`status-underivable`) we never fall back to
1194
- // trusting the bundle's own status.
1195
- // - finding 2: `assumed` alone is NO LONGER a silent pass. `assumed` is acceptable
1196
- // ONLY with a waiver (printed as a loud WAIVED line). An unwaived `assumed` claim is
1197
- // an `unwaived-assumed` divergence (restores pre-WS8 semantics where `assumed` alone
1198
- // never satisfied assertsPass).
1199
- //
1200
- // WS8 iteration-4 (converged iteration-3 finding, both gates): a session-local claim
1201
- // that re-derives `verified` with NO waiver is, by definition, backed only by
1202
- // human_attestation/attestation/external/etc. evidence with no --command i.e.
1203
- // `deriveClaimStatus` only proves the bundle is internally self-consistent, never that
1204
- // the underlying attestation is true. A fully fabricated bundle (fake claim + fake
1205
- // evidence + fake event, all hand-authored to agree) is INDISTINGUISHABLE from a real
1206
- // one at this layer. This was previously printed as a quiet `SESSION-LOCAL OK` line
1207
- // indistinguishable from a genuinely reconciled check. It is now always printed as a
1208
- // loud, distinct `ATTESTED (not independently verifiable at L0)` marker — see ADR 0020
1209
- // Residuals. This does NOT change the exit code (attestations are not blocked at L0;
1210
- // blocking every honest human-attestation use is not the fix) — it is a visibility-only
1211
- // change so a reviewer/downstream tool can grep for and count exactly how many claims in
1212
- // a passing bundle rest on bundle-internal consistency alone rather than independent
1213
- // (CI fresh-run or cryptographically-signed) verification.
1214
- let attestedCount = 0;
1215
- for (const { claimId, claimType, assertedStatus, waiver, subject, evidenceType } of sessionLocal) {
1216
- // finding 3: re-derive; never trust the asserted status.
1217
- let status;
1218
- if (!derivedStatus) {
1219
- issues.push({
1220
- type: 'status-underivable',
1221
- message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) asserts status '${assertedStatus || 'unknown'}' but CI-side re-derivation is unavailable — refusing to trust a self-reported status (fail-closed)`,
1222
- });
1223
- continue;
1224
- }
1225
- const derived = derivedStatus.get(claimId);
1226
- if (derived === undefined || derived === null) {
1227
- issues.push({
1228
- type: 'status-underivable',
1229
- message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) could not be re-derived CI-side from the bundle's own evidence/events/policies — refusing to trust its self-reported status '${assertedStatus || 'unknown'}' (fail-closed)`,
1230
- });
1231
- continue;
1232
- }
1233
- if (derived !== assertedStatus) {
1234
- issues.push({
1235
- type: 'status-misassertion',
1236
- message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) asserts status '${assertedStatus || 'unknown'}' but CI re-derivation from the bundle's own evidence/events/policies yields '${derived}' — the reconciler does not trust self-reported claim.status`,
1237
- });
1238
- continue;
1239
- }
1240
- status = derived;
1241
-
1242
- if (status === 'disputed' || status === 'rejected') {
1243
- issues.push({
1244
- type: 'session-local-failed',
1245
- message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) has re-derived status '${status}' — a failing/rejected claim blocks (session-local classification is not a pass bypass)`,
1246
- });
1247
- continue;
1248
- }
1249
- // finding 2: a waiver is the ONLY way an `assumed` (or otherwise non-`verified`)
1250
- // session-local claim passes, and it is printed loudly. `verified` still passes on its
1251
- // own re-derived status.
1252
- if (waiver && waiver.reason && waiver.approved_by) {
1160
+ // Session-local claims: not CI-reconcilable, but NOT a pass bypass. Shape-only,
1161
+ // delegated to scripts/lib/reconcile-shape.js (sessionLocalShapeIssues) see that
1162
+ // module for the full WS8 finding-2/finding-3/iteration-4 rationale (waiver-only pass
1163
+ // for non-`verified` status, CI-re-derived status never self-reported, loud ATTESTED
1164
+ // marker for bundle-internal-only consistency). iteration-1 F1: CI MUST pass
1165
+ // `onUnderivable: 'fail'` explicitlythis is CI's trust anchor and must never silently
1166
+ // degrade to trusting a self-reported status when re-derivation is unavailable (that
1167
+ // reduced-coverage mode is opt-in only, for the LOCAL preflight see reconcile-shape.js).
1168
+ // iteration-1 F3: the WAIVED/ATTESTED log lines below are driven by the shared function's
1169
+ // own `logEvents` classification (not a parallel re-derivation) so CI's stdout narrative
1170
+ // can never drift from what sessionLocalShapeIssues actually decided.
1171
+ const { issues: sessionLocalIssues, attestedCount, logEvents } =
1172
+ sessionLocalShapeIssues(sessionLocal, derivedStatus, { onUnderivable: 'fail' });
1173
+ issues.push(...sessionLocalIssues);
1174
+ for (const { kind, claimId, claimType, subject, evidenceType, status, waiver } of logEvents) {
1175
+ if (kind === 'waived') {
1253
1176
  process.stdout.write(`[trust-reconcile] WAIVED: ${subject} (${claimType}) status=${status} — ${waiver.reason} (approved by ${waiver.approved_by})\n`);
1254
- continue;
1255
- }
1256
- if (status === 'verified') {
1257
- attestedCount++;
1177
+ } else if (kind === 'attested') {
1258
1178
  process.stdout.write(`[trust-reconcile] ATTESTED (not independently verifiable at L0): '${claimId}' (${claimType}) evidenceType=${evidenceType} — accepted on bundle-internal consistency only; see ADR 0020 Residuals\n`);
1259
- continue;
1260
1179
  }
1261
- if (status === 'assumed') {
1262
- issues.push({
1263
- type: 'unwaived-assumed',
1264
- message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) has re-derived status 'assumed' but carries no waiver — 'assumed' alone is not a pass; it requires a documented waiver (--accepted-gap-reason/--waived-by) to be accepted`,
1265
- });
1266
- continue;
1267
- }
1268
- issues.push({
1269
- type: 'unwaived-session-local',
1270
- message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) asserts pass with re-derived status '${status || 'unknown'}' but has no waiver and no CI-re-derived verified status`,
1271
- });
1272
1180
  }
1273
1181
 
1274
1182
  // WS8 iteration-4: always emit the summary count, even when zero, so a passing bundle
@@ -1378,6 +1286,41 @@ function main() {
1378
1286
  // The direct-CLI behavior is preserved: when run as a script, main() is called below.
1379
1287
  module.exports.runTrustReconcile = runTrustReconcile;
1380
1288
 
1289
+ // #356: manifest-resolution helpers exported for reuse by the local reconcile-preflight
1290
+ // (Wave 2) so it resolves the manifest via the EXACT SAME priority chain this CI reconciler
1291
+ // uses (CLI --manifest > TRUST_RECONCILE_MANIFEST > package.json > run-baseline.sh
1292
+ // --manifest-json > legacy fresh-verify-commands fallback) — never a second implementation.
1293
+ // These stay defined here (not moved to scripts/lib/reconcile-shape.js) because they are
1294
+ // CI-adjacent resolution logic (they may spawn `evals/ci/run-baseline.sh --manifest-json`,
1295
+ // a cheap static-registry emit, not a fresh test run) rather than pure bundle-shape
1296
+ // classification.
1297
+ module.exports.resolveManifest = resolveManifest;
1298
+ module.exports.runBaselineManifest = runBaselineManifest;
1299
+ module.exports.normalizeManifestEntries = normalizeManifestEntries;
1300
+ module.exports.slugifyLabel = slugifyLabel;
1301
+ module.exports.normalizeCmd = normalizeCmd;
1302
+ module.exports.isAncestorCommit = isAncestorCommit;
1303
+ // #356: resolveManifest's legacy fallback tier (tier 5, "legacy:fresh-verify-commands")
1304
+ // folds the CANONICAL verify commands into the manifest when no dedicated manifest source
1305
+ // resolves. Exporting resolveCanonicalCommands too so the local preflight's manifest
1306
+ // resolution has genuine parity with CI on that fallback tier as well — otherwise a repo (or
1307
+ // a fixture/test repo) with no run-baseline.sh/package.json manifest source would silently
1308
+ // resolve an EMPTY legacy-fallback manifest locally while CI's own resolution (which always
1309
+ // threads its own resolved canonicalCommands into this same fallback) resolves a non-empty
1310
+ // one, a real parity gap, not merely a fixture quirk.
1311
+ module.exports.resolveCanonicalCommands = resolveCanonicalCommands;
1312
+
1313
+ // #356: re-export the shared bundle-shape classification/issue-construction functions
1314
+ // (primary home: scripts/lib/reconcile-shape.js) so a caller that already requires
1315
+ // trust-reconcile.js (e.g. an existing test harness) can reach them without also needing
1316
+ // to know the shared module's path. This is a re-export, not a second implementation —
1317
+ // the single definition lives in scripts/lib/reconcile-shape.js.
1318
+ module.exports.classifyBundleClaims = classifyBundleClaims;
1319
+ module.exports.waiverOnCommandIssues = waiverOnCommandIssues;
1320
+ module.exports.noEvidenceCommandIssues = noEvidenceCommandIssues;
1321
+ module.exports.reconcilableManifestIssues = reconcilableManifestIssues;
1322
+ module.exports.sessionLocalShapeIssues = sessionLocalShapeIssues;
1323
+
1381
1324
  if (require.main === module) {
1382
1325
  main();
1383
1326
  }
@@ -180,7 +180,9 @@ function checkProtectedPathPattern(filePath) {
180
180
  // copy a forged bundle here to corrupt the CI trust check.
181
181
  // SAFE: publishDelivery writes via fs.copyFileSync (not Write/Edit tool).
182
182
  // RESIDUAL: runtime-constructed paths and fs writes are unaffected.
183
- if (/(?:^|\/)delivery\/trust\.bundle$/.test(norm)) {
183
+ // #379: the optional (?:[^/]+\/)? segment also covers the per-session path
184
+ // delivery/<slug>/trust.bundle — the forgery surface moved with the write path.
185
+ if (/(?:^|\/)delivery\/(?:[^/]+\/)?trust\.bundle$/.test(norm)) {
184
186
  return {
185
187
  name: "delivery/trust.bundle",
186
188
  reason: "an agent could write a forged bundle to corrupt the CI trust-reconcile anchor",
@@ -189,7 +191,8 @@ function checkProtectedPathPattern(filePath) {
189
191
 
190
192
  // delivery/trust.checkpoint.json -- the signed checkpoint companion.
191
193
  // SAFE: publishDelivery writes via fs.copyFileSync, NOT via Write/Edit tool.
192
- if (/(?:^|\/)delivery\/trust\.checkpoint\.json$/.test(norm)) {
194
+ // #379: optional (?:[^/]+\/)? segment also covers delivery/<slug>/trust.checkpoint.json.
195
+ if (/(?:^|\/)delivery\/(?:[^/]+\/)?trust\.checkpoint\.json$/.test(norm)) {
193
196
  return {
194
197
  name: "delivery/trust.checkpoint.json",
195
198
  reason: "an agent could forge a signed delivery by writing a tampered checkpoint",
@@ -421,7 +424,9 @@ function checkCommandForBypass(command) {
421
424
  * .kontourai/flow-agents/<slug>/trust.bundle, and deprecated runtime-shaped
422
425
  * .flow-agents equivalents.
423
426
  */
424
- const REDIRECT_PROTECTED_RE = /(?:^|\/|~\/)(\.bash_profile|\.bashrc|\.profile|\.zprofile|\.zshrc)$|(?:^|\/)\.claude\/settings(?:\.local)?\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\/[^/]+\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/\.goal-fit-block-streak\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/state\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/trust\.bundle$|(?:^|\/)delivery\/trust\.bundle$|(?:^|\/)delivery\/trust\.checkpoint\.json$/;
427
+ // #379: the delivery/ arms carry an optional (?:[^/]+\/)? segment so redirects/tee to the
428
+ // per-session path delivery/<slug>/trust.bundle (+ checkpoint) are caught, not just the flat path.
429
+ const REDIRECT_PROTECTED_RE = /(?:^|\/|~\/)(\.bash_profile|\.bashrc|\.profile|\.zprofile|\.zshrc)$|(?:^|\/)\.claude\/settings(?:\.local)?\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\/[^/]+\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/\.goal-fit-block-streak\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/state\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/trust\.bundle$|(?:^|\/)delivery\/(?:[^/]+\/)?trust\.bundle$|(?:^|\/)delivery\/(?:[^/]+\/)?trust\.checkpoint\.json$/;
425
430
 
426
431
  /**
427
432
  * Return true when a token (an unquoted redirect target or tee argument) matches
@@ -569,8 +574,10 @@ function checkInterpreterWriteToProtected(command) {
569
574
  * Delivery-protected path regex: delivery/trust.bundle and delivery/trust.checkpoint.json.
570
575
  * These are the CI anchor files whose contents must not be agent-forged.
571
576
  * Used by checkCopyMoveToProtected to catch `cp x delivery/trust.bundle`.
577
+ * #379: the optional (?:[^/]+\/)? segment also catches the per-session path
578
+ * `cp forged.json delivery/<slug>/trust.bundle`.
572
579
  */
573
- const DELIVERY_COPY_PROTECTED_RE = /(?:^|\/)delivery\/trust\.bundle$|(?:^|\/)delivery\/trust\.checkpoint\.json$/;
580
+ const DELIVERY_COPY_PROTECTED_RE = /(?:^|\/)delivery\/(?:[^/]+\/)?trust\.bundle$|(?:^|\/)delivery\/(?:[^/]+\/)?trust\.checkpoint\.json$/;
574
581
 
575
582
  /**
576
583
  * Return true when a normalized token matches a delivery-protected path.