@kontourai/flow-agents 3.2.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 (50) hide show
  1. package/.github/workflows/ci.yml +12 -0
  2. package/CHANGELOG.md +13 -0
  3. package/build/src/cli/assignment-provider.js +10 -1
  4. package/build/src/cli/workflow-artifact-cleanup-audit.js +418 -11
  5. package/build/src/cli/workflow-sidecar.d.ts +224 -0
  6. package/build/src/cli/workflow-sidecar.js +775 -4
  7. package/build/src/tools/validate-source-tree.js +3 -2
  8. package/context/contracts/artifact-contract.md +16 -2
  9. package/context/scripts/hooks/workflow-steering.js +73 -1
  10. package/docs/coordination-guide.md +370 -0
  11. package/docs/decisions/agent-coordination.md +26 -9
  12. package/docs/decisions/index.md +2 -2
  13. package/docs/decisions/trust-reconcile.md +42 -9
  14. package/docs/fixture-ownership.md +3 -2
  15. package/docs/index.md +4 -0
  16. package/docs/integrations/flow-agents-console.md +108 -0
  17. package/docs/integrations/index.md +4 -0
  18. package/docs/workflow-artifact-lifecycle.md +38 -1
  19. package/evals/ci/antigaming-suite.sh +1 -0
  20. package/evals/ci/run-baseline.sh +6 -0
  21. package/evals/fixtures/reconcile-preflight/disputed-critique-unsuperseded.json +48 -0
  22. package/evals/fixtures/reconcile-preflight/standalone-disputed-session-local.json +59 -0
  23. package/evals/integration/test_checkpoint_signing.sh +10 -2
  24. package/evals/integration/test_ci_actor_identity.sh +221 -0
  25. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  26. package/evals/integration/test_publish_delivery.sh +59 -2
  27. package/evals/integration/test_reconcile_preflight.sh +304 -0
  28. package/evals/integration/test_takeover_protocol.sh +340 -0
  29. package/evals/integration/test_trust_reconcile_negatives.sh +91 -0
  30. package/evals/integration/test_verify_hold.sh +910 -0
  31. package/evals/integration/test_veritas_governance_kit.sh +257 -0
  32. package/evals/integration/test_workflow_artifact_cleanup_audit.sh +575 -3
  33. package/evals/run.sh +8 -0
  34. package/kits/builder/skills/continue-work/SKILL.md +2 -0
  35. package/kits/builder/skills/deliver/SKILL.md +73 -0
  36. package/kits/builder/skills/pull-work/SKILL.md +12 -2
  37. package/kits/veritas-governance/docs/README.md +81 -3
  38. package/kits/veritas-governance/fixtures/exemption/approved.trust-bundle.json +74 -0
  39. package/kits/veritas-governance/fixtures/exemption/not-approved.trust-bundle.json +74 -0
  40. package/kits/veritas-governance/flows/exemption-issuance.flow.json +35 -0
  41. package/kits/veritas-governance/kit.json +5 -0
  42. package/package.json +1 -1
  43. package/scripts/ci/trust-reconcile.js +78 -253
  44. package/scripts/hooks/lib/actor-identity.js +82 -0
  45. package/scripts/hooks/workflow-steering.js +73 -1
  46. package/scripts/lib/reconcile-shape.js +381 -0
  47. package/src/cli/assignment-provider.ts +12 -1
  48. package/src/cli/workflow-artifact-cleanup-audit.ts +483 -10
  49. package/src/cli/workflow-sidecar.ts +866 -4
  50. package/src/tools/validate-source-tree.ts +3 -2
@@ -0,0 +1,381 @@
1
+ 'use strict';
2
+ //
3
+ // Shared bundle-shape classification/divergence-construction, extracted from
4
+ // scripts/ci/trust-reconcile.js so a local, pre-push preflight (issue #356) can reuse
5
+ // EXACTLY the same shape checks CI enforces, rather than risk a forked copy that
6
+ // silently drifts from what trust-reconcile.js actually does (the historical failure
7
+ // mode this module exists to close off — see command-log-chain.js for the identical
8
+ // rationale applied to the hash-chain/laundering primitives).
9
+ //
10
+ // This module is SHAPE-only: it classifies a bundle's own claims/evidence and builds
11
+ // the `issues[]` entries that do not require a fresh CI command re-run (no `runCommand`,
12
+ // no manifest command execution). The ACTUAL fresh-run comparison for reconcilable
13
+ // command claims (`ciResult.passed`) stays in trust-reconcile.js, since that requires a
14
+ // live CI/local command execution a local preflight must not perform.
15
+ //
16
+ // trust-reconcile.js requires this module instead of defining these functions inline —
17
+ // see its own comments at the require() site and the (former) location of
18
+ // classifyBundleClaims for the extraction history.
19
+
20
+ // hasLaunderingOperator is imported (not re-implemented) so this module and
21
+ // scripts/ci/trust-reconcile.js apply the identical exit-code-mask heuristic.
22
+ const { hasLaunderingOperator } = require('./command-log-chain.js');
23
+
24
+ /**
25
+ * Classify a trust.bundle's claims into: reconcilable command claims (test_output +
26
+ * execution.label), session-local claims (attestation/observation/citation), never-captured
27
+ * or unbacked command claims (not-run divergence), and command-backed claims carrying a
28
+ * waiver (waiver-on-command divergence). Returns
29
+ * { reconcilable, sessionLocal, noEvidenceCommand, waiverOnCommand }.
30
+ *
31
+ * Source of truth: evidence[].execution.label is the command string recorded at capture time.
32
+ * evidence[].passing (normalized) means the agent claimed this passed. `claim.status` is NOT
33
+ * trusted here — the caller re-derives it CI-side (see derive-claim-status.mjs / finding-3).
34
+ *
35
+ * WS8 iteration-2 hardening:
36
+ * - finding 1: ANY pass-asserting claim whose evidence is `evidenceType: test_output`
37
+ * (Surface's default when unset) but which did NOT reconcile — i.e. it has no
38
+ * manifest-matchable execution.label — is a divergence, NOT session-local. A test_output
39
+ * claim either reconciles against the manifest or is a divergence; it is never accepted on
40
+ * self-reported status. (Previously only the literal claimType `workflow.check.command`
41
+ * was guarded, so a fabricated kind:"test" claim with no command slipped through.)
42
+ * - finding 4: a command-backed (test_output) claim carrying a waiver is a divergence — a
43
+ * command-backed check reconciles against CI or fails; it cannot be waived.
44
+ */
45
+ function classifyBundleClaims(bundle) {
46
+ const evidence = Array.isArray(bundle.evidence) ? bundle.evidence : [];
47
+ const claims = Array.isArray(bundle.claims) ? bundle.claims : [];
48
+
49
+ const claimById = new Map();
50
+ for (const c of claims) if (c && c.id) claimById.set(c.id, c);
51
+
52
+ // Evidence indexing. A missing evidenceType defaults to test_output for backward
53
+ // compatibility with pre-classification bundles (same default classifyEvidence uses).
54
+ const claimHasLabeledTestOutput = new Set(); // test_output evidence WITH an execution.label
55
+ const claimHasTestOutputEvidence = new Set(); // ANY test_output evidence (label or not)
56
+ // WS8 iteration-4 (converged finding): the session-local (non-test_output) evidenceType per
57
+ // claim, so the reconciler can name it on the loud ATTESTED marker below — a fabricated
58
+ // human_attestation/attestation/external claim with no --command is otherwise
59
+ // indistinguishable, in the reconciler's own output, from a genuinely re-runnable check.
60
+ const claimEvidenceType = new Map();
61
+ for (const ev of evidence) {
62
+ if (!ev || !ev.claimId) continue;
63
+ const evType = ev.evidenceType || 'test_output';
64
+ if (evType !== 'test_output') {
65
+ if (!claimEvidenceType.has(ev.claimId)) claimEvidenceType.set(ev.claimId, evType);
66
+ continue;
67
+ }
68
+ claimHasTestOutputEvidence.add(ev.claimId);
69
+ if (ev.execution && ev.execution.label) claimHasLabeledTestOutput.add(ev.claimId);
70
+ }
71
+
72
+ // finding 4: a command-backed (test_output-evidence) claim that also carries a waiver.
73
+ const waiverOnCommand = [];
74
+ for (const c of claims) {
75
+ if (!c || !c.id) continue;
76
+ const waiver = (c.metadata && typeof c.metadata === 'object') ? c.metadata.waiver : undefined;
77
+ if (waiver && typeof waiver === 'object' && claimHasTestOutputEvidence.has(c.id)) {
78
+ waiverOnCommand.push({ claimId: c.id, claimType: String(c.claimType || ''), subject: c.subjectId || c.fieldOrBehavior || c.id });
79
+ }
80
+ }
81
+
82
+ // (A) Reconcilable claimed-passes: evidence items that are test_output (CI-reconcilable),
83
+ // carry an execution.label, and assert pass. Session-local evidenceTypes
84
+ // (crawl_observation, human_attestation, attestation, policy_rule, source_excerpt,
85
+ // document_citation, calculation_trace) are NOT reconciled per-command — they are handled
86
+ // by the session-local/waiver path below.
87
+ const reconcilable = [];
88
+ const reconcilableClaimIds = new Set();
89
+ const seen = new Set();
90
+ for (const ev of evidence) {
91
+ if (!ev || !ev.execution || !ev.execution.label) continue;
92
+ if (!isPassingValue(ev.passing)) continue;
93
+ const evType = ev.evidenceType || 'test_output';
94
+ if (evType !== 'test_output') continue; // session-local — not CI-reconcilable
95
+ const cmd = normalizeCmd(ev.execution.label);
96
+ if (!cmd) continue;
97
+ reconcilableClaimIds.add(ev.claimId);
98
+ if (seen.has(cmd)) continue;
99
+ seen.add(cmd);
100
+ const claim = claimById.get(ev.claimId);
101
+ reconcilable.push({ cmd, claimId: ev.claimId, evId: ev.id, claimType: claim ? String(claim.claimType || '') : '' });
102
+ }
103
+
104
+ // (B) Session-local claims, never-captured command claims, and unreconciled test_output.
105
+ const sessionLocal = [];
106
+ const noEvidenceCommand = [];
107
+ const seenClaims = new Set();
108
+ for (const c of claims) {
109
+ if (!c || !c.id || typeof c.claimType !== 'string') continue;
110
+ // #267/#282: a superseded critique write is HISTORY — excluded from reconcile evaluation so a
111
+ // resolved session converges (a fail critique that a later same-reviewer pass superseded no
112
+ // longer blocks). Scoped to NON-test_output claims so a command-backed claim can never launder
113
+ // a real failure by carrying superseded_by — a test_output claim always reconciles or diverges.
114
+ if (c.metadata && typeof c.metadata === 'object' && c.metadata.superseded_by && !claimHasTestOutputEvidence.has(c.id)) continue;
115
+ if (reconcilableClaimIds.has(c.id)) continue; // handled by (A)
116
+ if (seenClaims.has(c.id)) continue;
117
+ const status = String(c.status || '');
118
+ const assertsPass = isPassingValue(c.value) || status === 'verified' || status === 'assumed';
119
+ const isFailing = status === 'disputed' || status === 'rejected';
120
+ if (!assertsPass && !isFailing) continue; // pending/unknown non-asserting — ignore (as before)
121
+ seenClaims.add(c.id);
122
+
123
+ // finding 1: a pass-asserting claim backed by test_output evidence that did NOT reconcile
124
+ // (it has test_output evidence but no manifest-matchable execution.label — otherwise it
125
+ // would be in bucket A) is a not-run divergence. A test_output claim reconciles against the
126
+ // manifest or it is a divergence — it is NEVER accepted as session-local on self-report.
127
+ if (assertsPass && claimHasTestOutputEvidence.has(c.id)) {
128
+ const rawCmd = normalizeCmd(c.fieldOrBehavior || c.value || '');
129
+ noEvidenceCommand.push({ cmd: rawCmd || `[claim:${c.id}]`, claimId: c.id, claimType: c.claimType, reason: 'test_output-unreconciled' });
130
+ continue;
131
+ }
132
+
133
+ // A workflow.check.command claim with no captured (labeled) evidence is a never-captured
134
+ // claimed pass — not-run divergence (anti-gaming teeth preserved).
135
+ if (assertsPass && c.claimType === 'workflow.check.command' && !claimHasLabeledTestOutput.has(c.id)) {
136
+ const rawCmd = normalizeCmd(c.fieldOrBehavior || c.value || '');
137
+ noEvidenceCommand.push({ cmd: rawCmd || `[claim:${c.id}:${c.claimType}]`, claimId: c.id, claimType: c.claimType, reason: 'no-evidence-command' });
138
+ continue;
139
+ }
140
+
141
+ const waiver = (c.metadata && typeof c.metadata === 'object') ? c.metadata.waiver : undefined;
142
+ sessionLocal.push({
143
+ claimId: c.id,
144
+ claimType: c.claimType,
145
+ assertedStatus: status,
146
+ value: c.value,
147
+ waiver: (waiver && typeof waiver === 'object') ? waiver : null,
148
+ subject: c.subjectId || c.fieldOrBehavior || c.id,
149
+ evidenceType: claimEvidenceType.get(c.id) || 'unknown',
150
+ });
151
+ }
152
+
153
+ return { reconcilable, sessionLocal, noEvidenceCommand, waiverOnCommand };
154
+ }
155
+
156
+ /** Normalize a command string: collapse whitespace, trim. (Mirrors trust-reconcile.js's own.) */
157
+ function normalizeCmd(cmd) {
158
+ return String(cmd || '').replace(/\s+/g, ' ').trim();
159
+ }
160
+
161
+ /**
162
+ * Normalize ev.passing to a boolean.
163
+ * Treats true / 1 / "true" / "pass" as passing.
164
+ * Prevents a claim from dodging reconciliation via a non-boolean value.
165
+ */
166
+ function isPassingValue(v) {
167
+ return v === true || v === 1 || v === 'true' || v === 'pass';
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Pure shape-level divergence ("issue") construction.
172
+ //
173
+ // Each function below takes already-computed classification inputs (the buckets
174
+ // classifyBundleClaims returns, plus a resolved manifest / derived-status map) and
175
+ // returns an issues[] array structurally IDENTICAL in shape (`{ type, cmd?, message }`)
176
+ // to what trust-reconcile.js's Step 2 block pushes inline. None of these functions
177
+ // execute a command or otherwise perform a fresh CI run — they are pure, local, and fast.
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /**
181
+ * finding 4 (server-side): a command-backed (test_output-evidence) claim carrying a
182
+ * waiver is a divergence — a command-backed check reconciles against CI or fails; it
183
+ * cannot be waived away.
184
+ */
185
+ function waiverOnCommandIssues(waiverOnCommand) {
186
+ const issues = [];
187
+ for (const { claimId, claimType, subject } of waiverOnCommand || []) {
188
+ issues.push({
189
+ type: 'waiver-on-command-check',
190
+ 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`,
191
+ });
192
+ }
193
+ return issues;
194
+ }
195
+
196
+ /**
197
+ * not-run divergences: never-captured command claims (no evidence) AND test_output
198
+ * claims that did not reconcile (no manifest-matchable execution.label).
199
+ */
200
+ function noEvidenceCommandIssues(noEvidenceCommand) {
201
+ const issues = [];
202
+ for (const { cmd, claimId, claimType, reason } of noEvidenceCommand || []) {
203
+ const message = reason === 'test_output-unreconciled'
204
+ ? `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)`
205
+ : `trust divergence: claim '${claimId}' (claimType: ${claimType}) asserts pass but has no supporting evidence item — command never captured`;
206
+ issues.push({ type: 'not-run', cmd, message });
207
+ }
208
+ return issues;
209
+ }
210
+
211
+ /**
212
+ * Manifest-membership subset of the `reconcilable` loop: ONLY the "not in the reconcile
213
+ * manifest" `not-run` case, plus the laundering check (via the shared hasLaunderingOperator).
214
+ * The ACTUAL fresh-run comparison (`ciResult.passed`) is NOT here — it requires a live CI/
215
+ * local command execution the preflight must not perform, and stays in trust-reconcile.js.
216
+ *
217
+ * Returns { issues, unresolved } where `unresolved` is the subset of `reconcilable` entries
218
+ * that passed the laundering + manifest-membership checks and therefore DO require a fresh
219
+ * CI run to fully reconcile — callers that need full parity (trust-reconcile.js) continue
220
+ * from there; callers that are shape-only (the local preflight) simply do not resolve them
221
+ * further and treat "manifest-matched, not laundered" as shape-clean.
222
+ */
223
+ function reconcilableManifestIssues(reconcilable, manifestByCmd) {
224
+ const issues = [];
225
+ const unresolved = [];
226
+ for (const entry of reconcilable || []) {
227
+ const { cmd } = entry;
228
+ const normalCmd = normalizeCmd(cmd);
229
+
230
+ // (a) Laundering operator check — must come first (most specific signal).
231
+ if (hasLaunderingOperator(cmd)) {
232
+ issues.push({
233
+ type: 'laundering',
234
+ cmd,
235
+ message: `trust divergence: agent claimed '${cmd}' passed; command contains exit-code-laundering operator (|| ... / ; true / ; exit 0 / etc.)`,
236
+ });
237
+ continue;
238
+ }
239
+
240
+ // A test_output claim MUST name a manifest (required-lane) command. An agent
241
+ // cannot self-label an arbitrary command test_output to dodge the manifest.
242
+ const manifestEntry = manifestByCmd.get(normalCmd);
243
+ if (!manifestEntry) {
244
+ issues.push({
245
+ type: 'not-run',
246
+ cmd,
247
+ 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)`,
248
+ });
249
+ continue;
250
+ }
251
+
252
+ unresolved.push({ ...entry, manifestEntry });
253
+ }
254
+ return { issues, unresolved };
255
+ }
256
+
257
+ /**
258
+ * Session-local claims: not CI-reconcilable, but NOT a pass bypass. Each must either
259
+ * (a) carry a loud, justified waiver, or (b) resolve a real CI-RE-DERIVED `verified`
260
+ * status. WS8 iteration-2 hardening:
261
+ * - finding 3: the status used here is RE-DERIVED CI-side, never the self-reported
262
+ * claim.status. A mismatch is a `status-misassertion` divergence.
263
+ * - finding 2: `assumed` alone is NO LONGER a silent pass. `assumed` is acceptable
264
+ * ONLY with a waiver (printed as a loud WAIVED line by the caller). An unwaived
265
+ * `assumed` claim is an `unwaived-assumed` divergence (restores pre-WS8 semantics
266
+ * where `assumed` alone never satisfied assertsPass).
267
+ *
268
+ * Q1/iteration-1-F1 (extraction-granularity + caller-controlled mode): `derivedStatus` is a
269
+ * `Map<string,string|null>|null` — the SAME value trust-reconcile.js's `deriveClaimStatuses()`
270
+ * produces (shells out to derive-claim-status.mjs, local-only, no CI command execution).
271
+ * `opts.onUnderivable` makes the `derivedStatus === null` behavior an EXPLICIT caller choice —
272
+ * there is no silent default that fails open:
273
+ * - `'fail'` (DEFAULT — the safe/original CI behavior; a caller that forgets `opts` never
274
+ * fails open): when `derivedStatus` is null, EVERY session-local pass-asserting claim
275
+ * becomes a `status-underivable` divergence (verbatim pre-#356 message + `continue`) —
276
+ * we never fall back to trusting the bundle's own status. `scripts/ci/trust-reconcile.js`
277
+ * MUST use this mode; it is CI's trust anchor.
278
+ * - `'reduce'` (LOCAL-PREFLIGHT-ONLY opt-in): when `derivedStatus` is null, DEGRADE to a
279
+ * documented reduced-coverage mode — status-misassertion/status-underivable checks are
280
+ * skipped entirely (nothing to re-derive against), but the waiver/unwaived-assumed/
281
+ * session-local-failed/unwaived-session-local checks still run against the claim's own
282
+ * self-reported `assertedStatus`. Only `src/cli/workflow-sidecar.ts`'s local
283
+ * `runReconcilePreflight` opts into this (and surfaces the reduced coverage to the user via
284
+ * a warning) — CI must never reach this branch.
285
+ * When `derivedStatus` is non-null, both modes behave identically (full parity with CI).
286
+ *
287
+ * Returns { issues, attestedCount, logEvents } — attestedCount mirrors trust-reconcile.js's
288
+ * own "N attested claim(s) accepted without independent verification" summary line;
289
+ * logEvents is the ordered list of WAIVED/ATTESTED terminal classifications (F3, iteration-1)
290
+ * so a caller's stdout narrative (e.g. trust-reconcile.js's WAIVED/ATTESTED log lines) is
291
+ * driven by this single classification instead of a parallel re-derivation.
292
+ */
293
+ function sessionLocalShapeIssues(sessionLocal, derivedStatus, opts) {
294
+ const onUnderivable = (opts && opts.onUnderivable) || 'fail';
295
+ const issues = [];
296
+ let attestedCount = 0;
297
+ // F3 (iteration-1): single source of truth for the WAIVED/ATTESTED classification, so
298
+ // trust-reconcile.js's stdout narrative loop consumes this instead of re-deriving its own
299
+ // (previously parallel, driftable) copy. Only populated when a claim reaches the WAIVED or
300
+ // ATTESTED terminal below (never for issues) — callers that don't log can ignore it.
301
+ const logEvents = [];
302
+
303
+ for (const { claimId, claimType, assertedStatus, waiver, subject, evidenceType } of sessionLocal || []) {
304
+ let status;
305
+ if (derivedStatus) {
306
+ // finding 3: re-derive; never trust the asserted status.
307
+ const derived = derivedStatus.get(claimId);
308
+ if (derived === undefined || derived === null) {
309
+ issues.push({
310
+ type: 'status-underivable',
311
+ 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)`,
312
+ });
313
+ continue;
314
+ }
315
+ if (derived !== assertedStatus) {
316
+ issues.push({
317
+ type: 'status-misassertion',
318
+ 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`,
319
+ });
320
+ continue;
321
+ }
322
+ status = derived;
323
+ } else if (onUnderivable === 'reduce') {
324
+ // Reduced-coverage mode (derivedStatus === null, explicit local-preflight opt-in): trust
325
+ // the self-reported status for the remaining shape checks only. status-misassertion/
326
+ // status-underivable are, by definition, not checkable without a derivation source —
327
+ // documented gap, not a bug.
328
+ status = assertedStatus;
329
+ } else {
330
+ // Fail-closed mode (default; CI): restores the pre-#356 inline behavior verbatim — we
331
+ // never fall back to trusting a self-reported status.
332
+ issues.push({
333
+ type: 'status-underivable',
334
+ 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)`,
335
+ });
336
+ continue;
337
+ }
338
+
339
+ if (status === 'disputed' || status === 'rejected') {
340
+ issues.push({
341
+ type: 'session-local-failed',
342
+ 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)`,
343
+ });
344
+ continue;
345
+ }
346
+ // finding 2: a waiver is the ONLY way an `assumed` (or otherwise non-`verified`)
347
+ // session-local claim passes. `verified` still passes on its own re-derived status.
348
+ if (waiver && waiver.reason && waiver.approved_by) {
349
+ logEvents.push({ kind: 'waived', claimId, claimType, subject, evidenceType, status, waiver });
350
+ continue; // WAIVED — caller may log this loudly; not an issue.
351
+ }
352
+ if (status === 'verified') {
353
+ attestedCount++;
354
+ logEvents.push({ kind: 'attested', claimId, claimType, subject, evidenceType, status });
355
+ continue; // ATTESTED (not independently verifiable at L0) — caller may log; not an issue.
356
+ }
357
+ if (status === 'assumed') {
358
+ issues.push({
359
+ type: 'unwaived-assumed',
360
+ 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`,
361
+ });
362
+ continue;
363
+ }
364
+ issues.push({
365
+ type: 'unwaived-session-local',
366
+ 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`,
367
+ });
368
+ }
369
+
370
+ return { issues, attestedCount, logEvents };
371
+ }
372
+
373
+ module.exports = {
374
+ classifyBundleClaims,
375
+ normalizeCmd,
376
+ isPassingValue,
377
+ waiverOnCommandIssues,
378
+ noEvidenceCommandIssues,
379
+ reconcilableManifestIssues,
380
+ sessionLocalShapeIssues,
381
+ };
@@ -120,6 +120,7 @@ function loadActorIdentityHelper(): {
120
120
  isUnresolvedActor: (actor: string) => boolean;
121
121
  sanitizeSegment: (value: unknown) => string;
122
122
  detectRuntime: (env: NodeJS.ProcessEnv) => string;
123
+ detectCiActor: (env: NodeJS.ProcessEnv) => { runtime: string; session_id: string } | null;
123
124
  } {
124
125
  const _req = createRequire(import.meta.url);
125
126
  const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/actor-identity.js");
@@ -129,6 +130,7 @@ function loadActorIdentityHelper(): {
129
130
  isUnresolvedActor: (actor: string) => boolean;
130
131
  sanitizeSegment: (value: unknown) => string;
131
132
  detectRuntime: (env: NodeJS.ProcessEnv) => string;
133
+ detectCiActor: (env: NodeJS.ProcessEnv) => { runtime: string; session_id: string } | null;
132
134
  };
133
135
  }
134
136
 
@@ -199,7 +201,16 @@ function loadActorStruct(args: ParsedArgs): { actor: ActorStruct; actorKey?: str
199
201
  const helper = loadActorIdentityHelper();
200
202
  const resolved = helper.resolveActor(process.env);
201
203
  if (helper.isUnresolvedActor(resolved.actor)) throw new Error("could not resolve an actor identity (no --actor-json and no resolvable environment actor); pass --actor-json explicitly");
202
- return { actor: { runtime: helper.detectRuntime(process.env), session_id: resolved.actor, host: os.hostname(), human: null }, actorKey: resolved.actor };
204
+ // #398: reconstruct the SAME struct resolveActor serialized for a CI actor, mirroring
205
+ // resolveEnsureSessionActor (workflow-sidecar.ts) via the shared detectCiActor. Without this the
206
+ // else-branch would write `record.actor = {runtime:"unknown", session_id:<the whole triple>}` for a
207
+ // CI session — actor_key stays correct (so no false-block), but record.actor is malformed and the
208
+ // audit-trail / `assignment-provider status` output for CI sessions would be corrupt.
209
+ const ci = resolved.source.startsWith("ci-runtime") ? helper.detectCiActor(process.env) : null;
210
+ const actor: ActorStruct = ci && ci.session_id
211
+ ? { runtime: ci.runtime, session_id: ci.session_id, host: os.hostname(), human: null }
212
+ : { runtime: helper.detectRuntime(process.env), session_id: resolved.actor, host: os.hostname(), human: null };
213
+ return { actor, actorKey: resolved.actor };
203
214
  }
204
215
 
205
216
  export function assignmentFilePath(artifactRoot: string, subjectId: string): string {